From 0ae93487a1664b8bbb6b25b2f5facdca6131feee Mon Sep 17 00:00:00 2001 From: daveminer Date: Wed, 15 Nov 2023 21:11:14 -0500 Subject: [PATCH 1/4] overview unit tests --- config/test.exs | 3 + lib/basket/http/alpaca.ex | 10 ++ lib/basket/http/alpaca/impl.ex | 50 +++++++ lib/basket/websocket/alpaca/impl.ex | 5 + lib/basket_web/live/overview.ex | 49 ++++--- test/basket_web/live/overview_test.exs | 174 +++++++++++++++++++++---- test/support/conn_case.ex | 1 + test/support/data_case.ex | 14 ++ test/test_helper.exs | 7 +- 9 files changed, 268 insertions(+), 45 deletions(-) create mode 100644 lib/basket/http/alpaca.ex create mode 100644 lib/basket/http/alpaca/impl.ex diff --git a/config/test.exs b/config/test.exs index f6b7ffb..e489422 100644 --- a/config/test.exs +++ b/config/test.exs @@ -23,6 +23,9 @@ config :basket, BasketWeb.Endpoint, # In test we don't send emails. config :basket, Basket.Mailer, adapter: Swoosh.Adapters.Test +# config :basket, :alpaca_http_client, Basket.Http.MockAlpaca +# config :basket, :alpaca_websocket_client, Basket.Websocket.MockAlpaca + # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false diff --git a/lib/basket/http/alpaca.ex b/lib/basket/http/alpaca.ex new file mode 100644 index 0000000..cfa5d9b --- /dev/null +++ b/lib/basket/http/alpaca.ex @@ -0,0 +1,10 @@ +defmodule Basket.Http.Alpaca do + @callback latest_quote(ticker :: String.t()) :: {:ok, map} | {:error, String.t()} + @callback list_assets() :: {:ok, map} | {:error, String.t()} + + def latest_quote(ticker), do: impl().latest_quote(ticker) + def list_assets(), do: impl().list_assets() + + defp impl, + do: Application.get_env(:basket, :alpaca_http_client, Basket.Http.Alpaca.Impl) +end diff --git a/lib/basket/http/alpaca/impl.ex b/lib/basket/http/alpaca/impl.ex new file mode 100644 index 0000000..db7923e --- /dev/null +++ b/lib/basket/http/alpaca/impl.ex @@ -0,0 +1,50 @@ +defmodule Basket.Http.Alpaca.Impl do + use HTTPoison.Base + + @behaviour Basket.Http.Alpaca + + @assets_resource "/v2/assets" + @latest_quotes_resource "/v2/stocks/bars/latest" + + @impl Basket.Http.Alpaca + def latest_quote(ticker) do + case get("#{data_url()}#{@latest_quotes_resource}", [], + params: %{feed: "iex", symbols: ticker} + ) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, body} + + {:error, error} -> + {:error, error} + end + end + + @impl Basket.Http.Alpaca + def list_assets do + case get("#{market_url()}#{@assets_resource}", [], + params: %{status: "active", asset_class: "us_equity"} + ) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, body} + + {:error, error} -> + {:error, error} + end + end + + @impl true + def process_request_headers(headers) do + headers ++ [{"APCA-API-KEY-ID", api_key()}, {"APCA-API-SECRET-KEY", api_secret()}] + end + + @impl true + def process_response_body(body), do: Jason.decode!(body) + + defp data_url, do: Application.fetch_env!(:basket, :alpaca)[:data_http_url] + + defp market_url, do: Application.fetch_env!(:basket, :alpaca)[:market_http_url] + + defp api_key, do: Application.fetch_env!(:basket, :alpaca)[:api_key] + + defp api_secret, do: Application.fetch_env!(:basket, :alpaca)[:api_secret] +end diff --git a/lib/basket/websocket/alpaca/impl.ex b/lib/basket/websocket/alpaca/impl.ex index 36d5c70..c1c98c9 100644 --- a/lib/basket/websocket/alpaca/impl.ex +++ b/lib/basket/websocket/alpaca/impl.ex @@ -5,6 +5,8 @@ defmodule Basket.Websocket.Alpaca.Impl do require Logger + @behaviour Basket.Websocket.Alpaca + @subscribe_message %{ action: :subscribe } @@ -12,12 +14,14 @@ defmodule Basket.Websocket.Alpaca.Impl do action: :unsubscribe } + @impl true def start_link(state) do Logger.info("Starting Alpaca websocket client.") WebSockex.start_link(iex_feed(), Basket.Websocket.Alpaca, state, extra_headers: auth_headers()) end + @impl true def subscribe(tickers) do decoded_message = Jason.encode!(build_message(@subscribe_message, tickers)) @@ -27,6 +31,7 @@ defmodule Basket.Websocket.Alpaca.Impl do end end + @impl true def unsubscribe(tickers) do decoded_message = Jason.encode!(build_message(@unsubscribe_message, tickers)) diff --git a/lib/basket_web/live/overview.ex b/lib/basket_web/live/overview.ex index 79796d7..0c19e28 100644 --- a/lib/basket_web/live/overview.ex +++ b/lib/basket_web/live/overview.ex @@ -8,14 +8,13 @@ defmodule BasketWeb.Overview do require Logger - alias Basket.Alpaca.HttpClient - alias Basket.Websocket.Alpaca + alias Basket.{Http, Websocket} alias BasketWeb.Components.{NavRow, SearchInput} prop tickers, :list, default: [] def mount(_, _, socket) do - BasketWeb.Endpoint.subscribe(Alpaca.bars_topic()) + BasketWeb.Endpoint.subscribe(Websocket.Alpaca.bars_topic()) socket = assign(socket, tickers: []) socket = assign(socket, basket: []) @@ -34,31 +33,39 @@ defmodule BasketWeb.Overview do end def handle_event("ticker-add", %{"selected-ticker" => ticker}, socket) do - :ok = Alpaca.subscribe(%{bars: [ticker], quotes: [], trades: []}) + basket_tickers = + Enum.map(socket.assigns.basket, &Map.get(&1, "S")) + |> Enum.map(fn x -> if is_tuple(x), do: elem(x, 0), else: x end) - socket = - case HttpClient.latest_quote(ticker) do - {:ok, response} -> - %{"bars" => %{^ticker => bars}} = response + if ticker in basket_tickers or String.trim(ticker) == "" do + {:noreply, socket} + else + :ok = Websocket.Alpaca.subscribe(%{bars: [ticker], quotes: [], trades: []}) - initial_bars = for {k, v} <- bars, into: %{}, do: {k, {v, ""}} + socket = + case Http.Alpaca.latest_quote(ticker) do + {:ok, response} -> + %{"bars" => %{^ticker => bars}} = response - assign( - socket, - :basket, - socket.assigns.basket ++ [Map.merge(initial_bars, %{"S" => {ticker, ""}})] - ) + initial_bars = for {k, v} <- bars, into: %{}, do: {k, {v, ""}} - {:error, error} -> - Logger.error("Could not subscribe to ticker: #{error}") - socket - end + assign( + socket, + :basket, + socket.assigns.basket ++ [Map.merge(initial_bars, %{"S" => {ticker, ""}})] + ) - {:reply, %{}, socket} + {:error, error} -> + Logger.error("Could not subscribe to ticker: #{error}") + socket + end + + {:reply, %{}, socket} + end end def handle_event("ticker-remove", %{"ticker" => ticker}, socket) do - :ok = Alpaca.unsubscribe(%{bars: [ticker], quotes: [], trades: []}) + :ok = Websocket.Alpaca.unsubscribe(%{bars: [ticker], quotes: [], trades: []}) {:reply, %{}, assign( @@ -134,7 +141,7 @@ defmodule BasketWeb.Overview do end defp load_tickers do - case HttpClient.list_assets() do + case Http.Alpaca.list_assets() do {:ok, result} -> tickers = Enum.map(result, fn asset -> diff --git a/test/basket_web/live/overview_test.exs b/test/basket_web/live/overview_test.exs index 4907d20..1ca4acc 100644 --- a/test/basket_web/live/overview_test.exs +++ b/test/basket_web/live/overview_test.exs @@ -1,5 +1,5 @@ defmodule BasketWeb.OverviewTest do - use BasketWeb.ConnCase + use BasketWeb.ConnCase, async: false require Phoenix.LiveViewTest @@ -8,12 +8,27 @@ defmodule BasketWeb.OverviewTest do alias BasketWeb.Overview @assigns_map %{__changed__: %{__context__: true}} - @socket %{ - assigns: @assigns_map, - __context__: %{}, - flash: %{}, - live_action: nil - } + + setup do + # Will share the mock with the Cachex fallback. + Mox.set_mox_global() + + {:ok, + %{ + bars: %{ + "XYZ" => %{ + "c" => 188.15, + "h" => 188.15, + "l" => 188.05, + "n" => 358, + "o" => 188.11, + "t" => "2023-11-15T20:59:00Z", + "v" => 43031, + "vw" => 188.117416 + } + } + }} + end describe "mount/3" do test "assigns empty lists to keys" do @@ -32,7 +47,7 @@ defmodule BasketWeb.OverviewTest do end end - describe "handle_event/3" do + describe "handle_event/3 search" do test "ticker search does nothing without search criteria" do assert {:noreply, socket} = Overview.handle_event( @@ -42,19 +57,134 @@ defmodule BasketWeb.OverviewTest do ) end - # test "ticker search updates the ticker list" do - # assert {:reply, %{}, socket} = - # Overview.handle_event( - # "ticker-search", - # %{"selected-ticker" => "XYZ"}, - # Map.merge(@assigns_map, %{assigns: %{tickers: ["ABC", "XYZ"]}}) - # ) - - # assert socket == %{ - # __changed__: %{__context__: true, tickers: true}, - # __context__: %{}, - # tickers: ["XYZ"] - # } - # end + test "ticker search does nothing if the ticker list is already populated" do + assert {:noreply, + %{ + __changed__: %{__context__: true}, + assigns: %{tickers: ["ABC", "XYZ"]} + }} = + Overview.handle_event( + "ticker-search", + %{"selected-ticker" => "XYZ"}, + Map.merge(@assigns_map, %{assigns: %{tickers: ["ABC", "XYZ"]}}) + ) + end + + test "populates the ticker list with a web call if the cache is empty" do + Basket.Http.MockAlpaca + |> expect(:list_assets, fn -> + {:ok, + [ + %{ + "attributes" => [], + "class" => "us_equity", + "easy_to_borrow" => false, + "exchange" => "OTC", + "fractionable" => false, + "id" => "0634e31f-2a61-4990-b713-a4be6d9eee49", + "maintenance_margin_requirement" => 100, + "marginable" => false, + "name" => "METACRINE INC Common Stock", + "shortable" => false, + "status" => "active", + "symbol" => "MTCR", + "tradable" => false + }, + %{ + "attributes" => [], + "class" => "us_equity", + "easy_to_borrow" => false, + "exchange" => "OTC", + "fractionable" => false, + "id" => "ae2ab9f2-d2aa-4e7b-9ef8-2ffdf78ec0ff", + "maintenance_margin_requirement" => 100, + "marginable" => false, + "name" => "MTN Group, Ltd. Sponsored American Depositary Receipt", + "shortable" => false, + "status" => "active", + "symbol" => "MTNOY", + "tradable" => false + } + ]} + end) + + assert {:reply, %{}, + %{ + __changed__: %{__context__: true, tickers: true}, + assigns: %{tickers: []} + }} = + Overview.handle_event( + "ticker-search", + %{"selected-ticker" => "XYZ"}, + Map.merge(@assigns_map, %{assigns: %{tickers: []}}) + ) + end + end + + describe "handle_event/3 add_ticker" do + test "adds bars data to the liveview for the selected ticker", %{bars: bars} do + expect(Basket.Http.MockAlpaca, :latest_quote, fn _ -> {:ok, %{"bars" => bars}} end) + expect(Basket.Websocket.MockAlpaca, :subscribe, fn _ticker_subs -> :ok end) + + assert {:reply, %{}, + %{ + __changed__: %{__context__: true, basket: true}, + assigns: %{tickers: [], basket: []}, + basket: [bars] + }} = + Overview.handle_event( + "ticker-add", + %{"selected-ticker" => "XYZ"}, + Map.merge(@assigns_map, %{assigns: %{tickers: [], basket: []}}) + ) + end + + test "does nothing if no ticker is provided" do + # expect(Basket.Http.MockAlpaca, :latest_quote, fn _ -> {:ok, %{"bars" => bars}} end) + # expect(Basket.Websocket.MockAlpaca, :subscribe, fn _ticker_subs -> :ok end) + + assert {:noreply, + %{ + __changed__: %{__context__: true}, + assigns: %{tickers: [], basket: []} + }} = + Overview.handle_event( + "ticker-add", + %{"selected-ticker" => ""}, + Map.merge(@assigns_map, %{assigns: %{tickers: [], basket: []}}) + ) + end + + test "does nothing if the ticker is already present" 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" => {43031, ""}, + "vw" => {188.117416, ""} + } + ] + + assert {:noreply, + %{ + __changed__: %{__context__: true}, + assigns: %{tickers: [], basket: [bars]} + }} = + Overview.handle_event( + "ticker-add", + %{"selected-ticker" => "XYZ"}, + Map.merge(@assigns_map, %{ + assigns: %{ + tickers: [], + basket: basket_with_row + } + }) + ) + end end end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 42d4753..9a99983 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -33,6 +33,7 @@ defmodule BasketWeb.ConnCase do setup tags do Basket.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 096952c..14e9932 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -39,6 +39,14 @@ defmodule Basket.DataCase do """ def setup_sandbox(tags) do pid = Sandbox.start_owner!(Basket.Repo, shared: not tags[:async]) + + # Set up a default set of mocks for use in asynchronous tests + # Mox.defmock(Basket.Websocket.MockAlpaca, for: Basket.Websocket.Alpaca) + # Application.put_env(:basket, :alpaca_ws_client, Basket.Websocket.MockAlpaca) + + # Mox.defmock(Basket.Http.MockAlpaca, for: Basket.Http.Alpaca) + # Application.put_env(:basket, :alpaca_http_client, Basket.Http.MockAlpaca) + on_exit(fn -> Sandbox.stop_owner(pid) end) end @@ -57,4 +65,10 @@ defmodule Basket.DataCase do end) end) end + + # Mox.defmock(Basket.Websocket.MockAlpaca, for: Basket.Websocket.Alpaca) + # Application.put_env(:basket, :alpaca_ws_client, Basket.Websocket.MockAlpaca) + + # Mox.defmock(Basket.Http.MockAlpaca, for: Basket.Http.Alpaca) + # Application.put_env(:basket, :alpaca_http_client, Basket.Http.MockAlpaca) end diff --git a/test/test_helper.exs b/test/test_helper.exs index 6b4588f..7e71fcd 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,5 +1,8 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Basket.Repo, :manual) + Mox.defmock(Basket.Websocket.MockAlpaca, for: Basket.Websocket.Alpaca) Application.put_env(:basket, :alpaca_ws_client, Basket.Websocket.MockAlpaca) -ExUnit.start() -Ecto.Adapters.SQL.Sandbox.mode(Basket.Repo, :manual) +Mox.defmock(Basket.Http.MockAlpaca, for: Basket.Http.Alpaca) +Application.put_env(:basket, :alpaca_http_client, Basket.Http.MockAlpaca) From fbadba31823d67467c060c03f6a900d8c973383d Mon Sep 17 00:00:00 2001 From: daveminer Date: Thu, 16 Nov 2023 01:53:22 -0500 Subject: [PATCH 2/4] more overview unit testing --- lib/basket_web/live/overview.ex | 31 +++-- test/basket_web/live/overview_test.exs | 162 ++++++++++++++++++++----- 2 files changed, 152 insertions(+), 41 deletions(-) diff --git a/lib/basket_web/live/overview.ex b/lib/basket_web/live/overview.ex index 0c19e28..16b1d93 100644 --- a/lib/basket_web/live/overview.ex +++ b/lib/basket_web/live/overview.ex @@ -33,9 +33,7 @@ defmodule BasketWeb.Overview do end def handle_event("ticker-add", %{"selected-ticker" => ticker}, socket) do - basket_tickers = - Enum.map(socket.assigns.basket, &Map.get(&1, "S")) - |> Enum.map(fn x -> if is_tuple(x), do: elem(x, 0), else: x end) + basket_tickers = tickers(socket) if ticker in basket_tickers or String.trim(ticker) == "" do {:noreply, socket} @@ -64,15 +62,21 @@ defmodule BasketWeb.Overview do end end - def handle_event("ticker-remove", %{"ticker" => ticker}, socket) do - :ok = Websocket.Alpaca.unsubscribe(%{bars: [ticker], quotes: [], trades: []}) + def handle_event("ticker-remove", %{"selected-ticker" => ticker}, socket) do + basket_tickers = tickers(socket) - {:reply, %{}, - assign( - socket, - :basket, - Enum.filter(socket.assigns.basket, fn t -> elem(t["S"], 0) != ticker end) - )} + if ticker not in basket_tickers or String.trim(ticker) == "" do + {:noreply, socket} + else + :ok = Websocket.Alpaca.unsubscribe(%{bars: [ticker], quotes: [], trades: []}) + + {:reply, %{}, + assign( + socket, + :basket, + Enum.filter(socket.assigns.basket, fn t -> elem(t["S"], 0) != ticker end) + )} + end end def handle_info( @@ -156,4 +160,9 @@ defmodule BasketWeb.Overview do {:ignore, []} end end + + defp tickers(socket) do + Enum.map(socket.assigns.basket, &Map.get(&1, "S")) + |> Enum.map(fn x -> if is_tuple(x), do: elem(x, 0), else: x end) + end end diff --git a/test/basket_web/live/overview_test.exs b/test/basket_web/live/overview_test.exs index 1ca4acc..f710c97 100644 --- a/test/basket_web/live/overview_test.exs +++ b/test/basket_web/live/overview_test.exs @@ -4,6 +4,7 @@ defmodule BasketWeb.OverviewTest do require Phoenix.LiveViewTest import Mox + import Phoenix.Component alias BasketWeb.Overview @@ -17,22 +18,36 @@ defmodule BasketWeb.OverviewTest do %{ bars: %{ "XYZ" => %{ - "c" => 188.15, - "h" => 188.15, - "l" => 188.05, - "n" => 358, - "o" => 188.11, + "S" => "XYZ", + "c" => 187.15, + "h" => 187.15, + "l" => 187.05, + "n" => 357, + "o" => 187.11, "t" => "2023-11-15T20:59:00Z", - "v" => 43031, - "vw" => 188.117416 + "v" => 43025, + "vw" => 187.117416 } - } + }, + 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" => {43031, ""}, + "vw" => {188.117416, ""} + } + ] }} end describe "mount/3" do test "assigns empty lists to keys" do - Basket.Websocket.MockAlpaca |> expect(:start_link, fn state -> {:ok, 1} end) + Basket.Websocket.MockAlpaca |> expect(:start_link, fn _state -> {:ok, 1} end) assert({:ok, socket} = Overview.mount([], %{}, @assigns_map)) @@ -49,7 +64,7 @@ defmodule BasketWeb.OverviewTest do describe "handle_event/3 search" do test "ticker search does nothing without search criteria" do - assert {:noreply, socket} = + assert {:noreply, _socket} = Overview.handle_event( "ticker-search", %{"selected-ticker" => "ABC"}, @@ -130,7 +145,7 @@ defmodule BasketWeb.OverviewTest do %{ __changed__: %{__context__: true, basket: true}, assigns: %{tickers: [], basket: []}, - basket: [bars] + basket: [_bars] }} = Overview.handle_event( "ticker-add", @@ -140,9 +155,6 @@ defmodule BasketWeb.OverviewTest do end test "does nothing if no ticker is provided" do - # expect(Basket.Http.MockAlpaca, :latest_quote, fn _ -> {:ok, %{"bars" => bars}} end) - # expect(Basket.Websocket.MockAlpaca, :subscribe, fn _ticker_subs -> :ok end) - assert {:noreply, %{ __changed__: %{__context__: true}, @@ -155,25 +167,11 @@ defmodule BasketWeb.OverviewTest do ) end - test "does nothing if the ticker is already present" 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" => {43031, ""}, - "vw" => {188.117416, ""} - } - ] - + test "does nothing if the ticker is already present", %{basket_with_row: basket_with_row} do assert {:noreply, %{ __changed__: %{__context__: true}, - assigns: %{tickers: [], basket: [bars]} + assigns: %{tickers: [], basket: [_bars]} }} = Overview.handle_event( "ticker-add", @@ -187,4 +185,108 @@ defmodule BasketWeb.OverviewTest do ) end end + + describe "handle_event/3 remove_ticker" do + test "removes a ticker if it is present in the liveview", %{ + bars: bars, + basket_with_row: basket_with_row + } do + expect(Basket.Http.MockAlpaca, :latest_quote, fn _ -> {:ok, %{"bars" => bars}} end) + expect(Basket.Websocket.MockAlpaca, :unsubscribe, fn _ticker_subs -> :ok end) + + assert {:reply, %{}, + %{ + __changed__: %{__context__: true, basket: true}, + assigns: %{tickers: [], basket: ^basket_with_row}, + basket: [] + }} = + Overview.handle_event( + "ticker-remove", + %{"selected-ticker" => "XYZ"}, + Map.merge(@assigns_map, %{assigns: %{tickers: [], basket: basket_with_row}}) + ) + end + + test "does nothing if no ticker is provided" do + assert {:noreply, + %{ + __changed__: %{__context__: true}, + assigns: %{tickers: [], basket: []} + }} = + Overview.handle_event( + "ticker-remove", + %{"selected-ticker" => ""}, + Map.merge(@assigns_map, %{assigns: %{tickers: [], basket: []}}) + ) + end + + test "does nothing if the ticker is not in the liveview" do + assert {:noreply, + %{ + __changed__: %{__context__: true}, + assigns: %{tickers: [], basket: []} + }} = + Overview.handle_event( + "ticker-remove", + %{"selected-ticker" => "XYZ"}, + Map.merge(@assigns_map, %{ + assigns: %{ + tickers: [], + basket: [] + } + }) + ) + end + end + + describe "handle_info/3 ticker update" do + test "processes a message with new bars", %{ + basket_with_row: basket_with_row + } do + assert { + :noreply, + %{ + __changed__: %{__context__: true, basket: 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" => {43032, "down"}, + "vw" => {189.117416, "down"} + } + ] + } + } = + Overview.handle_info( + %Phoenix.Socket.Broadcast{ + 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" => 43032, + "vw" => 189.117416 + } + }, + Map.merge(@assigns_map, %{ + assigns: %{tickers: [], basket: basket_with_row}, + basket: basket_with_row + }) + ) + end + end end From 853dbde0fa45560b27cc0238b63bd011959e2a00 Mon Sep 17 00:00:00 2001 From: daveminer Date: Thu, 16 Nov 2023 14:04:25 -0500 Subject: [PATCH 3/4] break ticker table out to LiveComponent --- lib/basket/alpaca/http/client.ex | 54 ------------------- lib/basket/http/alpaca.ex | 5 +- lib/basket/http/alpaca/impl.ex | 11 +++- lib/basket_web/components/search_input.ex | 4 +- lib/basket_web/components/ticker_bar_table.ex | 48 +++++++++++++++++ lib/basket_web/live/overview.ex | 19 +------ test/basket_web/live/overview_test.exs | 8 +-- 7 files changed, 69 insertions(+), 80 deletions(-) delete mode 100644 lib/basket/alpaca/http/client.ex create mode 100644 lib/basket_web/components/ticker_bar_table.ex diff --git a/lib/basket/alpaca/http/client.ex b/lib/basket/alpaca/http/client.ex deleted file mode 100644 index 40a31c7..0000000 --- a/lib/basket/alpaca/http/client.ex +++ /dev/null @@ -1,54 +0,0 @@ -defmodule Basket.Alpaca.HttpClient do - @moduledoc """ - HTTP client for Alpaca API. - """ - - use HTTPoison.Base - - require Logger - - @assets_resource "/v2/assets" - @latest_quotes_resource "/v2/stocks/bars/latest" - - def process_request_headers(headers) do - headers ++ [{"APCA-API-KEY-ID", api_key()}, {"APCA-API-SECRET-KEY", api_secret()}] - end - - @spec latest_quote(String.t()) :: {:error, any()} | {:ok, map()} - def latest_quote(ticker) do - case get("#{data_url()}#{@latest_quotes_resource}", [], - params: %{feed: "iex", symbols: ticker} - ) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, body} - - {:error, error} -> - {:error, error} - end - end - - @spec list_assets() :: {:error, any()} | {:ok, list(map())} - def list_assets do - case get("#{market_url()}#{@assets_resource}", [], - params: %{status: "active", asset_class: "us_equity"} - ) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, body} - - {:error, error} -> - {:error, error} - end - end - - def process_response_body(body) do - Jason.decode!(body) - end - - defp data_url, do: Application.fetch_env!(:basket, :alpaca)[:data_http_url] - - defp market_url, do: Application.fetch_env!(:basket, :alpaca)[:market_http_url] - - defp api_key, do: Application.fetch_env!(:basket, :alpaca)[:api_key] - - defp api_secret, do: Application.fetch_env!(:basket, :alpaca)[:api_secret] -end diff --git a/lib/basket/http/alpaca.ex b/lib/basket/http/alpaca.ex index cfa5d9b..505ecfc 100644 --- a/lib/basket/http/alpaca.ex +++ b/lib/basket/http/alpaca.ex @@ -1,9 +1,12 @@ defmodule Basket.Http.Alpaca do + @moduledoc """ + Interface for the Alpaca REST API. + """ @callback latest_quote(ticker :: String.t()) :: {:ok, map} | {:error, String.t()} @callback list_assets() :: {:ok, map} | {:error, String.t()} def latest_quote(ticker), do: impl().latest_quote(ticker) - def list_assets(), do: impl().list_assets() + def list_assets, do: impl().list_assets() defp impl, do: Application.get_env(:basket, :alpaca_http_client, Basket.Http.Alpaca.Impl) diff --git a/lib/basket/http/alpaca/impl.ex b/lib/basket/http/alpaca/impl.ex index db7923e..41a3ef7 100644 --- a/lib/basket/http/alpaca/impl.ex +++ b/lib/basket/http/alpaca/impl.ex @@ -1,4 +1,7 @@ defmodule Basket.Http.Alpaca.Impl do + @moduledoc """ + Implmentation of the Alpaca API HTTP client. + """ use HTTPoison.Base @behaviour Basket.Http.Alpaca @@ -8,7 +11,9 @@ defmodule Basket.Http.Alpaca.Impl do @impl Basket.Http.Alpaca def latest_quote(ticker) do - case get("#{data_url()}#{@latest_quotes_resource}", [], + case get( + "#{data_url()}#{@latest_quotes_resource}", + [], params: %{feed: "iex", symbols: ticker} ) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> @@ -21,7 +26,9 @@ defmodule Basket.Http.Alpaca.Impl do @impl Basket.Http.Alpaca def list_assets do - case get("#{market_url()}#{@assets_resource}", [], + case get( + "#{market_url()}#{@assets_resource}", + [], params: %{status: "active", asset_class: "us_equity"} ) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> diff --git a/lib/basket_web/components/search_input.ex b/lib/basket_web/components/search_input.ex index ac37a83..bb38dd2 100644 --- a/lib/basket_web/components/search_input.ex +++ b/lib/basket_web/components/search_input.ex @@ -42,8 +42,8 @@ defmodule BasketWeb.Components.SearchInput do {/for} <:actions> - <.button class="whitespace-nowrap"> - Add + <.button class="bg-green-600 whitespace-nowrap w-12"> + + diff --git a/lib/basket_web/components/ticker_bar_table.ex b/lib/basket_web/components/ticker_bar_table.ex new file mode 100644 index 0000000..6607180 --- /dev/null +++ b/lib/basket_web/components/ticker_bar_table.ex @@ -0,0 +1,48 @@ +defmodule BasketWeb.Components.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 + + import BasketWeb.CoreComponents + + 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"])} + <:col :let={ticker} key="l" label="low">{value_from_ticker_bar(ticker["l"])} + <:col :let={ticker} key="c" label="close">{value_from_ticker_bar(ticker["c"])} + <: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 + phx-click="ticker-remove" + phx-value-ticker={value_from_ticker_bar(ticker["S"])} + class="bg-red-600" + > + X + + " + +
+ """ + end + + defp value_from_ticker_bar(ticker_bar), do: elem(ticker_bar, 0) +end diff --git a/lib/basket_web/live/overview.ex b/lib/basket_web/live/overview.ex index 16b1d93..6879b86 100644 --- a/lib/basket_web/live/overview.ex +++ b/lib/basket_web/live/overview.ex @@ -4,12 +4,10 @@ defmodule BasketWeb.Overview do """ use Surface.LiveView - import BasketWeb.CoreComponents - require Logger alias Basket.{Http, Websocket} - alias BasketWeb.Components.{NavRow, SearchInput} + alias BasketWeb.Components.{NavRow, SearchInput, TickerBarTable} prop tickers, :list, default: [] @@ -117,20 +115,7 @@ defmodule BasketWeb.Overview do
<.live_component module={SearchInput} id="stock-search-input" tickers={@tickers} />
- <.table id="ticker-list" rows={@basket}> - <:col :let={ticker} key="S" label="ticker">{elem(ticker["S"], 0)} - <:col :let={ticker} key="o" label="open">{elem(ticker["o"], 0)} - <:col :let={ticker} key="h" label="high">{elem(ticker["h"], 0)} - <:col :let={ticker} key="l" label="low">{elem(ticker["l"], 0)} - <:col :let={ticker} key="c" label="close">{elem(ticker["c"], 0)} - <:col :let={ticker} key="v" label="volume">{elem(ticker["v"], 0)} - <:col :let={ticker} key="t" label="timestamp">{elem(ticker["t"], 0)} - <:col :let={ticker} label="remove"> - <.button phx-click="ticker-remove" phx-value-ticker={elem(ticker["S"], 0)}> - Remove - - " - + """ end diff --git a/test/basket_web/live/overview_test.exs b/test/basket_web/live/overview_test.exs index f710c97..c910f2b 100644 --- a/test/basket_web/live/overview_test.exs +++ b/test/basket_web/live/overview_test.exs @@ -25,7 +25,7 @@ defmodule BasketWeb.OverviewTest do "n" => 357, "o" => 187.11, "t" => "2023-11-15T20:59:00Z", - "v" => 43025, + "v" => 43_025, "vw" => 187.117416 } }, @@ -38,7 +38,7 @@ defmodule BasketWeb.OverviewTest do "n" => {358, ""}, "o" => {188.11, ""}, "t" => {"2023-11-15T20:59:00Z", ""}, - "v" => {43031, ""}, + "v" => {43_031, ""}, "vw" => {188.117416, ""} } ] @@ -260,7 +260,7 @@ defmodule BasketWeb.OverviewTest do "n" => {359, ""}, "o" => {189.11, "down"}, "t" => {"2023-11-15T21:59:00Z", ""}, - "v" => {43032, "down"}, + "v" => {43_032, "down"}, "vw" => {189.117416, "down"} } ] @@ -278,7 +278,7 @@ defmodule BasketWeb.OverviewTest do "n" => 359, "o" => 189.11, "t" => "2023-11-15T21:59:00Z", - "v" => 43032, + "v" => 43_032, "vw" => 189.117416 } }, From 915499d4bb1521016b4a512af1a5a13d60a81671 Mon Sep 17 00:00:00 2001 From: daveminer Date: Thu, 16 Nov 2023 17:16:20 -0500 Subject: [PATCH 4/4] fix LiveComponent usage --- lib/basket_web/live/overview.ex | 4 +-- lib/basket_web/live/overview/bars.ex | 38 ++++++++++++++++++++++++++ test/basket_web/live/overview_test.exs | 9 +++--- 3 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 lib/basket_web/live/overview/bars.ex diff --git a/lib/basket_web/live/overview.ex b/lib/basket_web/live/overview.ex index 6879b86..c84b098 100644 --- a/lib/basket_web/live/overview.ex +++ b/lib/basket_web/live/overview.ex @@ -60,7 +60,7 @@ defmodule BasketWeb.Overview do end end - def handle_event("ticker-remove", %{"selected-ticker" => ticker}, socket) do + def handle_event("ticker-remove", %{"ticker" => ticker}, socket) do basket_tickers = tickers(socket) if ticker not in basket_tickers or String.trim(ticker) == "" do @@ -115,7 +115,7 @@ defmodule BasketWeb.Overview do
<.live_component module={SearchInput} id="stock-search-input" tickers={@tickers} />
- + <.live_component module={TickerBarTable} id="ticker-bar-table" rows={@basket} /> """ end diff --git a/lib/basket_web/live/overview/bars.ex b/lib/basket_web/live/overview/bars.ex new file mode 100644 index 0000000..090afab --- /dev/null +++ b/lib/basket_web/live/overview/bars.ex @@ -0,0 +1,38 @@ +defmodule BasketWeb.Overview.TickerBar do + @moduledoc """ + Stateful representation of a cell on the ticker bar table. + """ + alias __MODULE__ + + defstruct value: nil, prev_value: nil + + @typedoc """ + This module is responsible for taking the data from an external call and updating the state of the cell. + """ + @type t(value, prev_value) :: %TickerBar{ + value: value, + prev_value: prev_value + } + + @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 + + 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 + + @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/test/basket_web/live/overview_test.exs b/test/basket_web/live/overview_test.exs index c910f2b..f86753b 100644 --- a/test/basket_web/live/overview_test.exs +++ b/test/basket_web/live/overview_test.exs @@ -4,14 +4,13 @@ defmodule BasketWeb.OverviewTest do require Phoenix.LiveViewTest import Mox - import Phoenix.Component alias BasketWeb.Overview @assigns_map %{__changed__: %{__context__: true}} setup do - # Will share the mock with the Cachex fallback. + # Shares the mock with the Cachex fallback function. Mox.set_mox_global() {:ok, @@ -202,7 +201,7 @@ defmodule BasketWeb.OverviewTest do }} = Overview.handle_event( "ticker-remove", - %{"selected-ticker" => "XYZ"}, + %{"ticker" => "XYZ"}, Map.merge(@assigns_map, %{assigns: %{tickers: [], basket: basket_with_row}}) ) end @@ -215,7 +214,7 @@ defmodule BasketWeb.OverviewTest do }} = Overview.handle_event( "ticker-remove", - %{"selected-ticker" => ""}, + %{"ticker" => ""}, Map.merge(@assigns_map, %{assigns: %{tickers: [], basket: []}}) ) end @@ -228,7 +227,7 @@ defmodule BasketWeb.OverviewTest do }} = Overview.handle_event( "ticker-remove", - %{"selected-ticker" => "XYZ"}, + %{"ticker" => "XYZ"}, Map.merge(@assigns_map, %{ assigns: %{ tickers: [],