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..505ecfc --- /dev/null +++ b/lib/basket/http/alpaca.ex @@ -0,0 +1,13 @@ +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() + + defp impl, + do: Application.get_env(:basket, :alpaca_http_client, Basket.Http.Alpaca.Impl) +end diff --git a/lib/basket/alpaca/http/client.ex b/lib/basket/http/alpaca/impl.ex similarity index 72% rename from lib/basket/alpaca/http/client.ex rename to lib/basket/http/alpaca/impl.ex index 40a31c7..41a3ef7 100644 --- a/lib/basket/alpaca/http/client.ex +++ b/lib/basket/http/alpaca/impl.ex @@ -1,22 +1,19 @@ -defmodule Basket.Alpaca.HttpClient do +defmodule Basket.Http.Alpaca.Impl do @moduledoc """ - HTTP client for Alpaca API. + Implmentation of the Alpaca API HTTP client. """ - use HTTPoison.Base - require Logger + @behaviour Basket.Http.Alpaca @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()} + @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}} -> @@ -27,9 +24,11 @@ defmodule Basket.Alpaca.HttpClient do end end - @spec list_assets() :: {:error, any()} | {:ok, list(map())} + @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}} -> @@ -40,10 +39,14 @@ defmodule Basket.Alpaca.HttpClient do end end - def process_response_body(body) do - Jason.decode!(body) + @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] 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/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 79796d7..c84b098 100644 --- a/lib/basket_web/live/overview.ex +++ b/lib/basket_web/live/overview.ex @@ -4,18 +4,15 @@ defmodule BasketWeb.Overview do """ use Surface.LiveView - import BasketWeb.CoreComponents - require Logger - alias Basket.Alpaca.HttpClient - alias Basket.Websocket.Alpaca - alias BasketWeb.Components.{NavRow, SearchInput} + alias Basket.{Http, Websocket} + alias BasketWeb.Components.{NavRow, SearchInput, TickerBarTable} 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,38 +31,50 @@ defmodule BasketWeb.Overview do end def handle_event("ticker-add", %{"selected-ticker" => ticker}, socket) do - :ok = Alpaca.subscribe(%{bars: [ticker], quotes: [], trades: []}) + basket_tickers = tickers(socket) - 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: []}) + 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( @@ -106,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 - - " - + <.live_component module={TickerBarTable} id="ticker-bar-table" rows={@basket} /> """ end @@ -134,7 +130,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 -> @@ -149,4 +145,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/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 4907d20..f86753b 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,16 +8,45 @@ defmodule BasketWeb.OverviewTest do alias BasketWeb.Overview @assigns_map %{__changed__: %{__context__: true}} - @socket %{ - assigns: @assigns_map, - __context__: %{}, - flash: %{}, - live_action: nil - } + + setup do + # Shares the mock with the Cachex fallback function. + Mox.set_mox_global() + + {:ok, + %{ + bars: %{ + "XYZ" => %{ + "S" => "XYZ", + "c" => 187.15, + "h" => 187.15, + "l" => 187.05, + "n" => 357, + "o" => 187.11, + "t" => "2023-11-15T20:59:00Z", + "v" => 43_025, + "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" => {43_031, ""}, + "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)) @@ -32,9 +61,9 @@ 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} = + assert {:noreply, _socket} = Overview.handle_event( "ticker-search", %{"selected-ticker" => "ABC"}, @@ -42,19 +71,221 @@ 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"]}}) - # ) + 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 socket == %{ - # __changed__: %{__context__: true, tickers: true}, - # __context__: %{}, - # tickers: ["XYZ"] - # } - # 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 + 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", %{basket_with_row: basket_with_row} do + 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 + + 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", + %{"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", + %{"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", + %{"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" => {43_032, "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" => 43_032, + "vw" => 189.117416 + } + }, + Map.merge(@assigns_map, %{ + assigns: %{tickers: [], basket: basket_with_row}, + 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)