diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..539d213 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,216 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFileExtension, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/lib/basket/websocket/alpaca.ex b/lib/basket/websocket/alpaca.ex index 9c67e1c..b1bb8de 100644 --- a/lib/basket/websocket/alpaca.ex +++ b/lib/basket/websocket/alpaca.ex @@ -63,9 +63,7 @@ defmodule Basket.Websocket.Alpaca do def handle_frame({_tpe, msg}, state) do case Jason.decode(msg) do {:ok, decoded_message} -> - Enum.each(decoded_message, fn message -> - process_message(message) - end) + Enum.each(decoded_message, &process_message/1) {:error, error} -> Logger.error("Error decoding websocket message: #{inspect(error)}") diff --git a/lib/basket_web/components/core_components.ex b/lib/basket_web/components/core_components.ex index a349cff..92d0ac0 100644 --- a/lib/basket_web/components/core_components.ex +++ b/lib/basket_web/components/core_components.ex @@ -268,7 +268,7 @@ defmodule BasketWeb.CoreComponents do + + + """ + end + + defp load_tickers do + case Http.Alpaca.list_assets() do + {:ok, result} -> + tickers = + Enum.map(result, fn asset -> + asset["symbol"] + end) + + {:commit, tickers} + + {:error, error} -> + Logger.error("Could not fetch tickers: #{error}") + + {:ignore, []} + end + end +end diff --git a/lib/basket_web/live/overview/ticker_add.ex b/lib/basket_web/live/overview/ticker_add.ex new file mode 100644 index 0000000..787f6da --- /dev/null +++ b/lib/basket_web/live/overview/ticker_add.ex @@ -0,0 +1,46 @@ +defmodule BasketWeb.Live.Overview.TickerAdd do + @moduledoc """ + Creates a new ticker in the table. + """ + + alias Basket.Http + alias BasketWeb.Live.Overview.TickerBar + + require Logger + + @doc """ + Creates a row to be added to the ticker bar table. + """ + @spec call(ticker :: String.t()) :: :no_data | :market_closed | map() | {:error, String.t()} + def call(ticker) do + case Http.Alpaca.latest_quote(ticker) do + {:ok, response} -> + build_ticker_bars(response) + + {:error, error} -> + {:error, error} + end + end + + defp build_ticker_bars(%{"bars" => nil}), do: :no_data + + defp build_ticker_bars(%{"bars" => ticker_bars}) do + new_ticker_bars = Map.to_list(ticker_bars) |> List.first() + + case new_ticker_bars do + nil -> + :no_data + + map when map_size(map) == 0 -> + :market_closed + + {ticker, bars} -> + new_bars = + Enum.reduce(bars, %{}, fn {k, v}, acc -> + Map.put(acc, k, %TickerBar{value: v}) + end) + + Map.merge(new_bars, %{"S" => %TickerBar{value: ticker}}) + end + end +end diff --git a/lib/basket_web/live/overview/ticker_bar.ex b/lib/basket_web/live/overview/ticker_bar.ex new file mode 100644 index 0000000..d440b90 --- /dev/null +++ b/lib/basket_web/live/overview/ticker_bar.ex @@ -0,0 +1,73 @@ +defmodule BasketWeb.Live.Overview.TickerBar do + @moduledoc """ + Cell on the TickerBarTable. + """ + alias __MODULE__ + + defstruct value: nil, prev_value: nil + + @typedoc """ + This module takes the data from an external call and updates the state of the cell. + """ + @type t(value, prev_value) :: %TickerBar{ + value: value, + prev_value: prev_value + } + + @doc ~S""" + Sets a new value on a TickerBar. The prev_value is set to the current value for + computing change direction. + + ## Example + iex> set_value(%TickerBar{value: 1, prev_value: 0}, 2) + %TickerBar{value: 2, prev_value: 1} + """ + @spec set_value(TickerBar.t(any(), any()), any()) :: TickerBar.t(any(), any()) + def set_value(ticker_bar, value) do + %TickerBar{ticker_bar | value: value, prev_value: ticker_bar.value} + end + + @doc ~S""" + Determines the change direction of the TickerBar based on the difference + between the current value and the previous value. + + ## Example + iex> change_direction(%TickerBar{value: 1, prev_value: 0}) + 1 + iex> change_direction(%TickerBar{value: 0, prev_value: 0}) + 0 + iex> change_direction(%TickerBar{value: 0, prev_value: 1}) + -1 + """ + @spec change_direction(%{prev_value: any(), value: any()}) :: -1 | 0 | 1 + def change_direction(ticker_bar) do + case change_value(ticker_bar) do + x when x > 0 -> 1 + x when x < 0 -> -1 + _ -> 0 + end + end + + @doc ~S""" + Return the value of the change between the current value and the previous value. + Defaults to 0 if the value cannot be computed. + + ## Example + iex> change_value(%TickerBar{value: 10, prev_value: 4}) + 6 + iex> change_value(%TickerBar{value: 10, prev_value: 10}) + 0 + iex> change_value(%TickerBar{value: 10, prev_value: nil}) + 0 + iex> change_value(%TickerBar{value: "123", prev_value: 100}) + 0 + """ + @spec change_value(%{:prev_value => any() | nil, :value => any()}) :: integer() + def change_value(%{value: value, prev_value: prev_value}) do + if prev_value == nil or not (is_number(value) and is_number(prev_value)) do + 0 + else + value - prev_value + end + end +end diff --git a/lib/basket_web/components/ticker_bar_table.ex b/lib/basket_web/live/overview/ticker_bar_table.ex similarity index 72% rename from lib/basket_web/components/ticker_bar_table.ex rename to lib/basket_web/live/overview/ticker_bar_table.ex index 6607180..aa1e41a 100644 --- a/lib/basket_web/components/ticker_bar_table.ex +++ b/lib/basket_web/live/overview/ticker_bar_table.ex @@ -1,28 +1,20 @@ -defmodule BasketWeb.Components.TickerBarTable do +defmodule BasketWeb.Live.Overview.TickerBarTable do @moduledoc """ Allows the user to search for and add a ticker to the table. Will make an HTTP call if the ticker list is not populated, otherwise it will pull the list from the cache. """ - use Surface.LiveComponent + use Surface.Component - import BasketWeb.CoreComponents + alias BasketWeb.CoreComponents + prop id, :string prop rows, :list, default: [] - attr :id, :string, required: true - attr :class, :string, default: nil - - def mount(socket) do - socket = assign(socket, basket: []) - - {:ok, socket} - end - def render(assigns) do ~F"""
- <.table id="ticker-list" rows={@rows}> + <:col :let={ticker} key="S" label="ticker">{value_from_ticker_bar(ticker["S"])} <:col :let={ticker} key="o" label="open">{value_from_ticker_bar(ticker["o"])} <:col :let={ticker} key="h" label="high">{value_from_ticker_bar(ticker["h"])} @@ -31,18 +23,19 @@ defmodule BasketWeb.Components.TickerBarTable do <:col :let={ticker} key="v" label="volume">{value_from_ticker_bar(ticker["v"])} <:col :let={ticker} key="t" label="timestamp">{value_from_ticker_bar(ticker["t"])} <:col :let={ticker} label="remove"> - <.button + X - + " - +
""" end - defp value_from_ticker_bar(ticker_bar), do: elem(ticker_bar, 0) + defp value_from_ticker_bar(nil), do: nil + defp value_from_ticker_bar(ticker_bar), do: ticker_bar.value end diff --git a/lib/basket_web/router.ex b/lib/basket_web/router.ex index 3e5f20c..6e6ea36 100644 --- a/lib/basket_web/router.ex +++ b/lib/basket_web/router.ex @@ -34,9 +34,7 @@ defmodule BasketWeb.Router do scope "/", BasketWeb do pipe_through [:browser, :authenticated] - # get "/", PageController, :home - live "/", Overview - live "/demo", Demo + live "/", Live.Overview end # Other scopes may use custom stacks. diff --git a/mix.exs b/mix.exs index 5e4ef3e..a900563 100644 --- a/mix.exs +++ b/mix.exs @@ -65,7 +65,7 @@ defmodule Basket.MixProject do {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, {:plug_cowboy, "~> 2.5"}, - {:surface, "~> 0.11.0"}, + {:surface, "~> 0.11.1"}, # for surface.init; possible to remove. {:sourceror, "~> 0.12.0"}, {:surface_catalogue, "~> 0.6.1"}, diff --git a/test/basket_web/live/overview/ticker_bar_test.exs b/test/basket_web/live/overview/ticker_bar_test.exs new file mode 100644 index 0000000..771b58a --- /dev/null +++ b/test/basket_web/live/overview/ticker_bar_test.exs @@ -0,0 +1,8 @@ +defmodule TickerBarTest do + use ExUnit.Case, async: true + + import BasketWeb.Live.Overview.TickerBar + alias BasketWeb.Live.Overview.TickerBar + + doctest BasketWeb.Live.Overview.TickerBar, import: true +end diff --git a/test/basket_web/live/overview_test.exs b/test/basket_web/live/overview_test.exs index f86753b..ffb291c 100644 --- a/test/basket_web/live/overview_test.exs +++ b/test/basket_web/live/overview_test.exs @@ -1,11 +1,12 @@ -defmodule BasketWeb.OverviewTest do +defmodule BasketWeb.Live.OverviewTest do use BasketWeb.ConnCase, async: false require Phoenix.LiveViewTest import Mox - alias BasketWeb.Overview + alias BasketWeb.Live.Overview + alias BasketWeb.Live.Overview.{Search, TickerBar} @assigns_map %{__changed__: %{__context__: true}} @@ -30,15 +31,15 @@ defmodule BasketWeb.OverviewTest do }, basket_with_row: [ %{ - "S" => {"XYZ", ""}, - "c" => {188.15, ""}, - "h" => {188.15, ""}, - "l" => {188.05, ""}, - "n" => {358, ""}, - "o" => {188.11, ""}, - "t" => {"2023-11-15T20:59:00Z", ""}, - "v" => {43_031, ""}, - "vw" => {188.117416, ""} + "S" => %TickerBar{value: "XYZ", prev_value: "XYZ"}, + "c" => %TickerBar{value: 188.15, prev_value: 187.15}, + "h" => %TickerBar{value: 188.15, prev_value: 187.15}, + "l" => %TickerBar{value: 188.05, prev_value: 187.15}, + "n" => %TickerBar{value: 358, prev_value: 357}, + "o" => %TickerBar{value: 188.11, prev_value: 187.11}, + "t" => %TickerBar{value: "2023-11-15T20:59:00Z", prev_value: "2023-11-15T20:58:00Z"}, + "v" => %TickerBar{value: 43_031, prev_value: 43_025}, + "vw" => %TickerBar{value: 188.117416, prev_value: 187.137416} } ] }} @@ -52,10 +53,9 @@ defmodule BasketWeb.OverviewTest do assert( socket == %{ - __changed__: %{__context__: true, basket: true, tickers: true}, + __changed__: %{__context__: true, basket: true}, __context__: %{}, - basket: [], - tickers: [] + basket: [] } ) end @@ -64,9 +64,9 @@ defmodule BasketWeb.OverviewTest do describe "handle_event/3 search" do test "ticker search does nothing without search criteria" do assert {:noreply, _socket} = - Overview.handle_event( + Search.handle_event( "ticker-search", - %{"selected-ticker" => "ABC"}, + %{"ticker" => "ABC"}, Map.merge(@assigns_map, %{assigns: %{tickers: ["ABC", "XYZ"]}}) ) end @@ -77,9 +77,9 @@ defmodule BasketWeb.OverviewTest do __changed__: %{__context__: true}, assigns: %{tickers: ["ABC", "XYZ"]} }} = - Overview.handle_event( + Search.handle_event( "ticker-search", - %{"selected-ticker" => "XYZ"}, + %{"ticker" => "XYZ"}, Map.merge(@assigns_map, %{assigns: %{tickers: ["ABC", "XYZ"]}}) ) end @@ -127,9 +127,9 @@ defmodule BasketWeb.OverviewTest do __changed__: %{__context__: true, tickers: true}, assigns: %{tickers: []} }} = - Overview.handle_event( + Search.handle_event( "ticker-search", - %{"selected-ticker" => "XYZ"}, + %{"ticker" => "XYZ"}, Map.merge(@assigns_map, %{assigns: %{tickers: []}}) ) end @@ -142,39 +142,40 @@ defmodule BasketWeb.OverviewTest do assert {:reply, %{}, %{ - __changed__: %{__context__: true, basket: true}, - assigns: %{tickers: [], basket: []}, - basket: [_bars] + __changed__: %{__context__: true, form: true}, + assigns: %{tickers: [], form: []}, + form: %{"ticker" => ""} }} = - Overview.handle_event( + Search.handle_event( "ticker-add", - %{"selected-ticker" => "XYZ"}, - Map.merge(@assigns_map, %{assigns: %{tickers: [], basket: []}}) + %{"ticker" => "XYZ"}, + Map.merge(@assigns_map, %{assigns: %{tickers: [], form: []}}) ) end test "does nothing if no ticker is provided" do - assert {:noreply, + assert {:reply, %{}, %{ - __changed__: %{__context__: true}, - assigns: %{tickers: [], basket: []} + __changed__: %{__context__: true, form: true}, + assigns: %{basket: [], tickers: []}, + form: %{"ticker" => ""} }} = - Overview.handle_event( + Search.handle_event( "ticker-add", - %{"selected-ticker" => ""}, + %{"ticker" => ""}, Map.merge(@assigns_map, %{assigns: %{tickers: [], basket: []}}) ) end test "does nothing if the ticker is already present", %{basket_with_row: basket_with_row} do - assert {:noreply, + assert {:reply, %{}, %{ __changed__: %{__context__: true}, assigns: %{tickers: [], basket: [_bars]} }} = - Overview.handle_event( + Search.handle_event( "ticker-add", - %{"selected-ticker" => "XYZ"}, + %{"ticker" => "XYZ"}, Map.merge(@assigns_map, %{ assigns: %{ tickers: [], @@ -245,22 +246,25 @@ defmodule BasketWeb.OverviewTest do assert { :noreply, %{ - __changed__: %{__context__: true, basket: true}, + __changed__: %{__context__: true}, assigns: %{ basket: ^basket_with_row, tickers: [] }, basket: [ %{ - "S" => {"XYZ", ""}, - "c" => {189.15, "down"}, - "h" => {189.15, "down"}, - "l" => {189.05, "down"}, - "n" => {359, ""}, - "o" => {189.11, "down"}, - "t" => {"2023-11-15T21:59:00Z", ""}, - "v" => {43_032, "down"}, - "vw" => {189.117416, "down"} + "S" => %TickerBar{value: "XYZ", prev_value: "XYZ"}, + "c" => %TickerBar{value: 188.15, prev_value: 187.15}, + "h" => %TickerBar{value: 188.15, prev_value: 187.15}, + "l" => %TickerBar{value: 188.05, prev_value: 187.15}, + "n" => %TickerBar{value: 358, prev_value: 357}, + "o" => %TickerBar{value: 188.11, prev_value: 187.11}, + "t" => %TickerBar{ + value: "2023-11-15T20:59:00Z", + prev_value: "2023-11-15T20:58:00Z" + }, + "v" => %TickerBar{value: 43_031, prev_value: 43_025}, + "vw" => %TickerBar{value: 188.117416, prev_value: 187.137416} } ] } @@ -270,15 +274,19 @@ defmodule BasketWeb.OverviewTest do topic: "bars", event: "ticker-update", payload: %{ - "S" => "XYZ", - "c" => 189.15, - "h" => 189.15, - "l" => 189.05, - "n" => 359, - "o" => 189.11, - "t" => "2023-11-15T21:59:00Z", - "v" => 43_032, - "vw" => 189.117416 + "S" => %TickerBar{value: "AAPL", prev_value: nil}, + "T" => %TickerBar{value: "b", prev_value: nil}, + "c" => %TickerBar{value: 191.285, prev_value: nil}, + "h" => %TickerBar{value: 191.37, prev_value: nil}, + "l" => %TickerBar{value: 191.23, prev_value: nil}, + "n" => %TickerBar{value: 50, prev_value: nil}, + "o" => %TickerBar{value: 191.23, prev_value: nil}, + "t" => %TickerBar{ + value: "2023-11-20T16:24:00Z", + prev_value: nil + }, + "v" => %TickerBar{value: 5433, prev_value: nil}, + "vw" => %TickerBar{value: 191.328043, prev_value: nil} } }, Map.merge(@assigns_map, %{