Skip to content

Commit 50e6d75

Browse files
authored
Merge pull request #8 from daveminer/websocket-behavior
Websocket behavior
2 parents 931ca79 + ca9a198 commit 50e6d75

File tree

29 files changed

+728
-35
lines changed

29 files changed

+728
-35
lines changed

assets/js/app.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
1919
import "phoenix_html"
2020
// Establish Phoenix Socket and LiveView configuration.
21-
import {Socket} from "phoenix"
22-
import {LiveSocket} from "phoenix_live_view"
21+
import { Socket } from "phoenix"
22+
import { LiveSocket } from "phoenix_live_view"
2323
import topbar from "../vendor/topbar"
2424
import Hooks from "./_hooks"
25+
import { toggleDarkMode } from "./darkMode"
2526

2627
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
2728
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})
@@ -31,6 +32,8 @@ topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
3132
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
3233
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
3334

35+
window.addEventListener("toggle-darkmode", _e => toggleDarkMode())
36+
3437
// connect if there are any LiveViews on the page
3538
liveSocket.connect()
3639

@@ -39,4 +42,3 @@ liveSocket.connect()
3942
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
4043
// >> liveSocket.disableLatencySim()
4144
window.liveSocket = liveSocket
42-

assets/js/darkMode.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
2+
function darkExpected() {
3+
return localStorage.theme === 'dark' || (!('theme' in localStorage) &&
4+
window.matchMedia('(prefers-color-scheme: dark)').matches);
5+
}
6+
7+
function setMode() {
8+
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
9+
if (darkExpected()) document.documentElement.classList.add('dark');
10+
else document.documentElement.classList.remove('dark');
11+
}
12+
13+
export function toggleDarkMode() {
14+
console.log("TOGGLEDARKMODE")
15+
if (darkExpected()) localStorage.theme = 'light';
16+
else localStorage.theme = 'dark';
17+
setMode();
18+
}

assets/tailwind.config.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// See the Tailwind configuration guide for advanced usage
22
// https://tailwindcss.com/docs/configuration
33

4+
const colors = require('tailwindcss/colors')
45
const plugin = require("tailwindcss/plugin")
56
const fs = require("fs")
67
const path = require("path")
@@ -11,11 +12,24 @@ module.exports = {
1112
"../lib/basket_web.ex",
1213
"../lib/basket_web/**/*.*ex"
1314
],
15+
darkMode: "class",
1416
theme: {
1517
extend: {
1618
colors: {
17-
brand: "#FD4F00",
18-
}
19+
primary: {
20+
light: colors.emerald-800,
21+
dark: colors.emerald-800
22+
},
23+
secondary: '#7a869a',
24+
accent: '#ff5630',
25+
background: {
26+
light: colors.white,
27+
dark: colors.emerald-800
28+
},
29+
surface: '#2c313a',
30+
onSurface: '#ffffff',
31+
error: '#de350b',
32+
},
1933
},
2034
},
2135
plugins: [

config/config.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ config :esbuild,
4949

5050
# Configure tailwind (the version is required)
5151
config :tailwind,
52-
version: "3.3.2",
52+
version: "3.3.5",
5353
default: [
5454
args: ~w(
5555
--config=tailwind.config.js

config/dev.exs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ config :basket, BasketWeb.Endpoint,
2626
secret_key_base: "gRiKl5rvtc1Mgj3hhDzBzjHgmPE+PiG47uS8Pxk8KwjZhaaR3WxJ/I6czbmXr0j9",
2727
watchers: [
2828
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
29-
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
30-
esbuild: {Esbuild, :install_and_run, [:catalogue, ~w(--sourcemap=inline --watch)]}
29+
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
3130
]
3231

3332
# ## SSL Support
@@ -68,6 +67,13 @@ config :basket, BasketWeb.Endpoint,
6867
# Enable dev routes for dashboard and mailbox
6968
config :basket, dev_routes: true
7069

70+
config :basket, :alpaca,
71+
api_key: System.get_env("ALPACA_API_KEY"),
72+
api_secret: System.get_env("ALPACA_API_SECRET"),
73+
data_http_url: "https://data.alpaca.markets",
74+
market_http_url: "https://api.alpaca.markets",
75+
market_ws_url: "wss://stream.data.alpaca.markets/v2"
76+
7177
# Do not include metadata nor timestamps in development logs
7278
config :logger, :console, format: "[$level] $message\n"
7379

config/test.exs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,10 @@ config :logger, level: :warning
3131

3232
# Initialize plugs at runtime for faster test compilation
3333
config :phoenix, :plug_init_mode, :runtime
34+
35+
config :basket, :alpaca,
36+
api_key: "api-key",
37+
api_secret: "api-secret",
38+
data_http_url: "https://test-suite-data.alpaca.markets",
39+
market_http_url: "https://test-suite-api.alpaca.markets",
40+
market_ws_url: "wss://test-suite-stream.data.alpaca.markets/v2"

lib/basket/alpaca/http/client.ex

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
defmodule Basket.Alpaca.HttpClient do
2+
@moduledoc """
3+
HTTP client for Alpaca API.
4+
"""
5+
6+
use HTTPoison.Base
7+
8+
require Logger
9+
10+
@assets_resource "/v2/assets"
11+
@latest_quotes_resource "/v2/stocks/bars/latest"
12+
13+
def process_request_headers(headers) do
14+
headers ++ [{"APCA-API-KEY-ID", api_key()}, {"APCA-API-SECRET-KEY", api_secret()}]
15+
end
16+
17+
@spec latest_quote(String.t()) :: {:error, any()} | {:ok, map()}
18+
def latest_quote(ticker) do
19+
case get("#{data_url()}#{@latest_quotes_resource}", [],
20+
params: %{feed: "iex", symbols: ticker}
21+
) do
22+
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
23+
{:ok, body}
24+
25+
{:error, error} ->
26+
{:error, error}
27+
end
28+
end
29+
30+
@spec list_assets() :: {:error, any()} | {:ok, list(map())}
31+
def list_assets do
32+
case get("#{market_url()}#{@assets_resource}", [],
33+
params: %{status: "active", asset_class: "us_equity"}
34+
) do
35+
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
36+
{:ok, body}
37+
38+
{:error, error} ->
39+
{:error, error}
40+
end
41+
end
42+
43+
def process_response_body(body) do
44+
Jason.decode!(body)
45+
end
46+
47+
defp data_url, do: Application.fetch_env!(:basket, :alpaca)[:data_http_url]
48+
49+
defp market_url, do: Application.fetch_env!(:basket, :alpaca)[:market_http_url]
50+
51+
defp api_key, do: Application.fetch_env!(:basket, :alpaca)[:api_key]
52+
53+
defp api_secret, do: Application.fetch_env!(:basket, :alpaca)[:api_secret]
54+
end

lib/basket/application.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,17 @@ defmodule Basket.Application do
1717
# Start a worker by calling: Basket.Worker.start_link(arg)
1818
# {Basket.Worker, arg},
1919
# Start to serve requests, typically the last entry
20-
BasketWeb.Endpoint
20+
BasketWeb.Endpoint,
21+
{Cachex, name: :assets}
2122
]
2223

24+
children =
25+
if Mix.env() != :test do
26+
children ++ [Basket.Websocket.Alpaca]
27+
else
28+
children
29+
end
30+
2331
# See https://hexdocs.pm/elixir/Supervisor.html
2432
# for other strategies and supported options
2533
opts = [strategy: :one_for_one, name: Basket.Supervisor]

lib/basket/websocket/alpaca.ex

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
defmodule Basket.Websocket.Alpaca do
2+
@moduledoc """
3+
Implementation of the websocket client for Alpaca Finance.
4+
Currently only supports the "bars" feed on the minute.
5+
"""
6+
7+
use WebSockex
8+
9+
require Logger
10+
11+
@type subscription_fields :: %{
12+
:bars => list(String.t()),
13+
:quotes => list(String.t()),
14+
:trades => list(String.t())
15+
}
16+
17+
@auth_success ~s([{\"T\":\"success\",\"msg\":\"authenticated\"}])
18+
@connection_success ~s([{\"T\":\"success\",\"msg\":\"connected\"}])
19+
@bars_topic "bars"
20+
21+
@callback start_link(term()) :: {:ok, pid()} | {:error, term()}
22+
@callback subscribe(subscription_fields()) :: :ok
23+
@callback unsubscribe(subscription_fields()) :: :ok
24+
25+
def start_link(state), do: impl().start_link(state)
26+
def subscribe(tickers), do: impl().subscribe(tickers)
27+
def unsubscribe(tickers), do: impl().unsubscribe(tickers)
28+
29+
def bars_topic, do: @bars_topic
30+
31+
@impl true
32+
def handle_connect(_conn, state) do
33+
Logger.info("Alpaca websocket connected.")
34+
{:ok, state}
35+
end
36+
37+
@impl true
38+
def handle_disconnect(disconnect_map, state) do
39+
Logger.info("Alpaca websocket disconnected.")
40+
super(disconnect_map, state)
41+
end
42+
43+
@doc """
44+
Handles the messages sent by the Alpaca websocket server, responding if necessary.
45+
Besides processing messages as they arrive, this function will also set up the initial
46+
subscription once the authorization acknowledgement method is received.
47+
"""
48+
@impl true
49+
def handle_frame({_type, @connection_success}, state) do
50+
Logger.info("Connection message received.")
51+
52+
{:ok, state}
53+
end
54+
55+
@impl true
56+
def handle_frame({_type, @auth_success}, state) do
57+
Logger.info("Alpaca websocket authenticated.")
58+
59+
{:ok, state}
60+
end
61+
62+
@impl true
63+
def handle_frame({_tpe, msg}, state) do
64+
case Jason.decode(msg) do
65+
{:ok, decoded_message} ->
66+
Enum.each(decoded_message, fn message ->
67+
process_message(message)
68+
end)
69+
70+
{:error, error} ->
71+
Logger.error("Error decoding websocket message: #{inspect(error)}")
72+
end
73+
74+
{:ok, state}
75+
end
76+
77+
defp process_message(message) do
78+
case Map.get(message, "T") do
79+
"b" ->
80+
handle_bars(message)
81+
82+
"d" ->
83+
handle_daily_bars(message)
84+
85+
"u" ->
86+
handle_bar_updates(message)
87+
88+
"error" ->
89+
Logger.error("Error message from Alpaca websocket connection: #{inspect(message)}")
90+
91+
"subscription" ->
92+
Logger.info("Subscription message from Alpaca websocket connection: #{inspect(message)}")
93+
94+
_ ->
95+
Logger.info("Unhandled websocket message: #{inspect(message)}")
96+
end
97+
end
98+
99+
defp handle_bars(
100+
%{
101+
"S" => _symbol,
102+
"o" => _open,
103+
"h" => _high,
104+
"l" => _low,
105+
"c" => _close,
106+
"v" => _volume,
107+
"t" => _timestamp
108+
} = message
109+
) do
110+
Logger.debug("Bars message received")
111+
BasketWeb.Endpoint.broadcast_from(self(), @bars_topic, "ticker-update", message)
112+
end
113+
114+
defp handle_daily_bars(_message) do
115+
Logger.debug("Daily bars message received.")
116+
end
117+
118+
defp handle_bar_updates(_message) do
119+
Logger.debug("Bar updates message received")
120+
end
121+
122+
defp impl, do: Application.get_env(:basket, :alpaca_ws_client, Basket.Websocket.Alpaca.Impl)
123+
end

lib/basket/websocket/alpaca/impl.ex

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
defmodule Basket.Websocket.Alpaca.Impl do
2+
@moduledoc """
3+
Implementation of the Alpaca websocket client.
4+
"""
5+
6+
require Logger
7+
8+
@subscribe_message %{
9+
action: :subscribe
10+
}
11+
@unsubscribe_message %{
12+
action: :unsubscribe
13+
}
14+
15+
def start_link(state) do
16+
Logger.info("Starting Alpaca websocket client.")
17+
18+
WebSockex.start_link(iex_feed(), Basket.Websocket.Alpaca, state, extra_headers: auth_headers())
19+
end
20+
21+
def subscribe(tickers) do
22+
decoded_message = Jason.encode!(build_message(@subscribe_message, tickers))
23+
24+
case WebSockex.send_frame(client_pid(), {:text, decoded_message}) do
25+
:ok -> Logger.debug("Subscription message sent: #{inspect(decoded_message)}")
26+
{:error, error} -> Logger.error("Error sending subscription message: #{inspect(error)}")
27+
end
28+
end
29+
30+
def unsubscribe(tickers) do
31+
decoded_message = Jason.encode!(build_message(@unsubscribe_message, tickers))
32+
33+
case WebSockex.send_frame(client_pid(), {:text, decoded_message}) do
34+
:ok ->
35+
Logger.debug("Subscription removal message sent: #{inspect(decoded_message)}")
36+
37+
{:error, error} ->
38+
Logger.error("Error sending subscription removal message: #{inspect(error)}")
39+
end
40+
end
41+
42+
defp auth_headers, do: [{"APCA-API-KEY-ID", api_key()}, {"APCA-API-SECRET-KEY", api_secret()}]
43+
44+
defp api_key, do: Application.fetch_env!(:basket, :alpaca)[:api_key]
45+
46+
defp api_secret, do: Application.fetch_env!(:basket, :alpaca)[:api_secret]
47+
48+
defp iex_feed, do: "#{url()}/iex"
49+
50+
defp url, do: Application.fetch_env!(:basket, :alpaca)[:market_ws_url]
51+
52+
defp client_pid do
53+
Supervisor.which_children(Basket.Supervisor)
54+
|> Enum.find(fn c ->
55+
case c do
56+
{Basket.Websocket.Alpaca, _pid, :worker, [Basket.Websocket.Alpaca]} ->
57+
true
58+
59+
_ ->
60+
false
61+
end
62+
end)
63+
|> elem(1)
64+
end
65+
66+
defp build_message(message, %{bars: bars, quotes: quotes, trades: trades}) do
67+
message = if bars, do: Map.put(message, :bars, bars), else: message
68+
message = if quotes, do: Map.put(message, :quotes, quotes), else: message
69+
if trades, do: Map.put(message, :trades, trades), else: message
70+
end
71+
end

0 commit comments

Comments
 (0)