Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alpaca http test #9

Merged
merged 4 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions lib/basket/http/alpaca.ex
Original file line number Diff line number Diff line change
@@ -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
31 changes: 17 additions & 14 deletions lib/basket/alpaca/http/client.ex → lib/basket/http/alpaca/impl.ex
Original file line number Diff line number Diff line change
@@ -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}} ->
Expand All @@ -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}} ->
Expand All @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions lib/basket/websocket/alpaca/impl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@ defmodule Basket.Websocket.Alpaca.Impl do

require Logger

@behaviour Basket.Websocket.Alpaca

@subscribe_message %{
action: :subscribe
}
@unsubscribe_message %{
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))

Expand All @@ -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))

Expand Down
4 changes: 2 additions & 2 deletions lib/basket_web/components/search_input.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ defmodule BasketWeb.Components.SearchInput do
{/for}
</datalist>
<:actions>
<.button class="whitespace-nowrap">
Add
<.button class="bg-green-600 whitespace-nowrap w-12">
+
</.button>
</:actions>
</.inline_form>
Expand Down
48 changes: 48 additions & 0 deletions lib/basket_web/components/ticker_bar_table.ex
Original file line number Diff line number Diff line change
@@ -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"""
<div>
<.table id="ticker-list" rows={@rows}>
<:col :let={ticker} key="S" label="ticker">{value_from_ticker_bar(ticker["S"])}</:col>
<:col :let={ticker} key="o" label="open">{value_from_ticker_bar(ticker["o"])}</:col>
<:col :let={ticker} key="h" label="high">{value_from_ticker_bar(ticker["h"])}</:col>
<:col :let={ticker} key="l" label="low">{value_from_ticker_bar(ticker["l"])}</:col>
<:col :let={ticker} key="c" label="close">{value_from_ticker_bar(ticker["c"])}</:col>
<:col :let={ticker} key="v" label="volume">{value_from_ticker_bar(ticker["v"])}</:col>
<:col :let={ticker} key="t" label="timestamp">{value_from_ticker_bar(ticker["t"])}</:col>
<:col :let={ticker} label="remove">
<.button
phx-click="ticker-remove"
phx-value-ticker={value_from_ticker_bar(ticker["S"])}
class="bg-red-600"
>
X
</.button>
</:col>"
</.table>
</div>
"""
end

defp value_from_ticker_bar(ticker_bar), do: elem(ticker_bar, 0)
end
89 changes: 45 additions & 44 deletions lib/basket_web/live/overview.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: [])
Expand All @@ -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(
Expand Down Expand Up @@ -106,20 +115,7 @@ defmodule BasketWeb.Overview do
<div class="w-1/4">
<.live_component module={SearchInput} id="stock-search-input" tickers={@tickers} />
</div>
<.table id="ticker-list" rows={@basket}>
<:col :let={ticker} key="S" label="ticker">{elem(ticker["S"], 0)}</:col>
<:col :let={ticker} key="o" label="open">{elem(ticker["o"], 0)}</:col>
<:col :let={ticker} key="h" label="high">{elem(ticker["h"], 0)}</:col>
<:col :let={ticker} key="l" label="low">{elem(ticker["l"], 0)}</:col>
<:col :let={ticker} key="c" label="close">{elem(ticker["c"], 0)}</:col>
<:col :let={ticker} key="v" label="volume">{elem(ticker["v"], 0)}</:col>
<:col :let={ticker} key="t" label="timestamp">{elem(ticker["t"], 0)}</:col>
<:col :let={ticker} label="remove">
<.button phx-click="ticker-remove" phx-value-ticker={elem(ticker["S"], 0)}>
Remove
</.button>
</:col>"
</.table>
<.live_component module={TickerBarTable} id="ticker-bar-table" rows={@basket} />
</div>
"""
end
Expand All @@ -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 ->
Expand All @@ -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
38 changes: 38 additions & 0 deletions lib/basket_web/live/overview/bars.ex
Original file line number Diff line number Diff line change
@@ -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
Loading