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

Websocket behavior #8

Merged
merged 38 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
12bb822
websocket auth pattern
daveminer Nov 2, 2023
2a69ac3
ws logger infos
daveminer Nov 2, 2023
4044fe0
receiving msgs
daveminer Nov 2, 2023
1282f17
ticker search
daveminer Nov 4, 2023
996dc6b
working ticker list
daveminer Nov 4, 2023
88de6f6
cache handling on the search input
daveminer Nov 4, 2023
79af568
add ticker to liveview list
daveminer Nov 4, 2023
f2e52eb
remove ticker
daveminer Nov 4, 2023
7b94292
send async ws subscription
daveminer Nov 5, 2023
c5ef122
handle ws bar messages
daveminer Nov 6, 2023
74719d2
add pubsub for bars updates
daveminer Nov 7, 2023
150794e
cell value change indication
daveminer Nov 9, 2023
b245565
websocket cell updates
daveminer Nov 9, 2023
65eba6d
update background color
daveminer Nov 9, 2023
77ab061
layout elements
daveminer Nov 10, 2023
593ab2a
remove unused dep from lockfile
daveminer Nov 10, 2023
c23115d
navbar
daveminer Nov 11, 2023
57e16ca
formatting
daveminer Nov 11, 2023
072ba56
remove unused dark mode component param
daveminer Nov 11, 2023
7e98030
remove unused flex class
daveminer Nov 11, 2023
4f3fe80
search box placement
daveminer Nov 11, 2023
7179e8e
lint
daveminer Nov 11, 2023
3ff0780
fix component render and attach darkmode hook to button
daveminer Nov 11, 2023
f0ffa82
lint
daveminer Nov 11, 2023
0fe80e9
credo suggestion changes
daveminer Nov 11, 2023
68e6b46
change inspect to logger error
daveminer Nov 11, 2023
edebe59
remove unneeded map check
daveminer Nov 11, 2023
48b7353
fix import order
daveminer Nov 11, 2023
9dccb25
fix remaining metadata logger issues
daveminer Nov 11, 2023
e7ea9b3
fix capitalization bug
daveminer Nov 11, 2023
f519029
fix cap error again
daveminer Nov 11, 2023
ce58936
client callback pattern work
daveminer Nov 13, 2023
fba8af2
ws adapter pattern
daveminer Nov 14, 2023
37381ec
fix ticker removal
daveminer Nov 14, 2023
34c08b6
remove unused helper module
daveminer Nov 14, 2023
c569eb3
overview unit test setup
daveminer Nov 14, 2023
3e8e9d6
initial overview unit tests
daveminer Nov 15, 2023
ca9a198
credo fixes
daveminer Nov 15, 2023
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
8 changes: 5 additions & 3 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import topbar from "../vendor/topbar"
import Hooks from "./_hooks"
import { toggleDarkMode } from "./darkMode"

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

window.addEventListener("toggle-darkmode", _e => toggleDarkMode())

// connect if there are any LiveViews on the page
liveSocket.connect()

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

18 changes: 18 additions & 0 deletions assets/js/darkMode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@

function darkExpected() {
return localStorage.theme === 'dark' || (!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
}

function setMode() {
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (darkExpected()) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
}

export function toggleDarkMode() {
console.log("TOGGLEDARKMODE")
if (darkExpected()) localStorage.theme = 'light';
else localStorage.theme = 'dark';
setMode();
}
18 changes: 16 additions & 2 deletions assets/tailwind.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// See the Tailwind configuration guide for advanced usage
// https://tailwindcss.com/docs/configuration

const colors = require('tailwindcss/colors')
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
Expand All @@ -11,11 +12,24 @@ module.exports = {
"../lib/basket_web.ex",
"../lib/basket_web/**/*.*ex"
],
darkMode: "class",
theme: {
extend: {
colors: {
brand: "#FD4F00",
}
primary: {
light: colors.emerald-800,
dark: colors.emerald-800
},
secondary: '#7a869a',
accent: '#ff5630',
background: {
light: colors.white,
dark: colors.emerald-800
},
surface: '#2c313a',
onSurface: '#ffffff',
error: '#de350b',
},
},
},
plugins: [
Expand Down
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ config :esbuild,

# Configure tailwind (the version is required)
config :tailwind,
version: "3.3.2",
version: "3.3.5",
default: [
args: ~w(
--config=tailwind.config.js
Expand Down
10 changes: 8 additions & 2 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ config :basket, BasketWeb.Endpoint,
secret_key_base: "gRiKl5rvtc1Mgj3hhDzBzjHgmPE+PiG47uS8Pxk8KwjZhaaR3WxJ/I6czbmXr0j9",
watchers: [
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
esbuild: {Esbuild, :install_and_run, [:catalogue, ~w(--sourcemap=inline --watch)]}
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
]

# ## SSL Support
Expand Down Expand Up @@ -68,6 +67,13 @@ config :basket, BasketWeb.Endpoint,
# Enable dev routes for dashboard and mailbox
config :basket, dev_routes: true

config :basket, :alpaca,
api_key: System.get_env("ALPACA_API_KEY"),
api_secret: System.get_env("ALPACA_API_SECRET"),
data_http_url: "https://data.alpaca.markets",
market_http_url: "https://api.alpaca.markets",
market_ws_url: "wss://stream.data.alpaca.markets/v2"

# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n"

Expand Down
7 changes: 7 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,10 @@ config :logger, level: :warning

# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime

config :basket, :alpaca,
api_key: "api-key",
api_secret: "api-secret",
data_http_url: "https://test-suite-data.alpaca.markets",
market_http_url: "https://test-suite-api.alpaca.markets",
market_ws_url: "wss://test-suite-stream.data.alpaca.markets/v2"
54 changes: 54 additions & 0 deletions lib/basket/alpaca/http/client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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
10 changes: 9 additions & 1 deletion lib/basket/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@ defmodule Basket.Application do
# Start a worker by calling: Basket.Worker.start_link(arg)
# {Basket.Worker, arg},
# Start to serve requests, typically the last entry
BasketWeb.Endpoint
BasketWeb.Endpoint,
{Cachex, name: :assets}
]

children =
if Mix.env() != :test do
children ++ [Basket.Websocket.Alpaca]
else
children
end

# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Basket.Supervisor]
Expand Down
123 changes: 123 additions & 0 deletions lib/basket/websocket/alpaca.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
defmodule Basket.Websocket.Alpaca do
@moduledoc """
Implementation of the websocket client for Alpaca Finance.
Currently only supports the "bars" feed on the minute.
"""

use WebSockex

require Logger

@type subscription_fields :: %{
:bars => list(String.t()),
:quotes => list(String.t()),
:trades => list(String.t())
}

@auth_success ~s([{\"T\":\"success\",\"msg\":\"authenticated\"}])
@connection_success ~s([{\"T\":\"success\",\"msg\":\"connected\"}])
@bars_topic "bars"

@callback start_link(term()) :: {:ok, pid()} | {:error, term()}
@callback subscribe(subscription_fields()) :: :ok
@callback unsubscribe(subscription_fields()) :: :ok

def start_link(state), do: impl().start_link(state)
def subscribe(tickers), do: impl().subscribe(tickers)
def unsubscribe(tickers), do: impl().unsubscribe(tickers)

def bars_topic, do: @bars_topic

@impl true
def handle_connect(_conn, state) do
Logger.info("Alpaca websocket connected.")
{:ok, state}
end

@impl true
def handle_disconnect(disconnect_map, state) do
Logger.info("Alpaca websocket disconnected.")
super(disconnect_map, state)
end

@doc """
Handles the messages sent by the Alpaca websocket server, responding if necessary.
Besides processing messages as they arrive, this function will also set up the initial
subscription once the authorization acknowledgement method is received.
"""
@impl true
def handle_frame({_type, @connection_success}, state) do
Logger.info("Connection message received.")

{:ok, state}
end

@impl true
def handle_frame({_type, @auth_success}, state) do
Logger.info("Alpaca websocket authenticated.")

{:ok, state}
end

@impl true
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)

{:error, error} ->
Logger.error("Error decoding websocket message: #{inspect(error)}")
end

{:ok, state}
end

defp process_message(message) do
case Map.get(message, "T") do
"b" ->
handle_bars(message)

"d" ->
handle_daily_bars(message)

"u" ->
handle_bar_updates(message)

"error" ->
Logger.error("Error message from Alpaca websocket connection: #{inspect(message)}")

"subscription" ->
Logger.info("Subscription message from Alpaca websocket connection: #{inspect(message)}")

_ ->
Logger.info("Unhandled websocket message: #{inspect(message)}")
end
end

defp handle_bars(
%{
"S" => _symbol,
"o" => _open,
"h" => _high,
"l" => _low,
"c" => _close,
"v" => _volume,
"t" => _timestamp
} = message
) do
Logger.debug("Bars message received")
BasketWeb.Endpoint.broadcast_from(self(), @bars_topic, "ticker-update", message)
end

defp handle_daily_bars(_message) do
Logger.debug("Daily bars message received.")
end

defp handle_bar_updates(_message) do
Logger.debug("Bar updates message received")
end

defp impl, do: Application.get_env(:basket, :alpaca_ws_client, Basket.Websocket.Alpaca.Impl)
end
71 changes: 71 additions & 0 deletions lib/basket/websocket/alpaca/impl.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
defmodule Basket.Websocket.Alpaca.Impl do
@moduledoc """
Implementation of the Alpaca websocket client.
"""

require Logger

@subscribe_message %{
action: :subscribe
}
@unsubscribe_message %{
action: :unsubscribe
}

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

def subscribe(tickers) do
decoded_message = Jason.encode!(build_message(@subscribe_message, tickers))

case WebSockex.send_frame(client_pid(), {:text, decoded_message}) do
:ok -> Logger.debug("Subscription message sent: #{inspect(decoded_message)}")
{:error, error} -> Logger.error("Error sending subscription message: #{inspect(error)}")
end
end

def unsubscribe(tickers) do
decoded_message = Jason.encode!(build_message(@unsubscribe_message, tickers))

case WebSockex.send_frame(client_pid(), {:text, decoded_message}) do
:ok ->
Logger.debug("Subscription removal message sent: #{inspect(decoded_message)}")

{:error, error} ->
Logger.error("Error sending subscription removal message: #{inspect(error)}")
end
end

defp auth_headers, do: [{"APCA-API-KEY-ID", api_key()}, {"APCA-API-SECRET-KEY", api_secret()}]

defp api_key, do: Application.fetch_env!(:basket, :alpaca)[:api_key]

defp api_secret, do: Application.fetch_env!(:basket, :alpaca)[:api_secret]

defp iex_feed, do: "#{url()}/iex"

defp url, do: Application.fetch_env!(:basket, :alpaca)[:market_ws_url]

defp client_pid do
Supervisor.which_children(Basket.Supervisor)
|> Enum.find(fn c ->
case c do
{Basket.Websocket.Alpaca, _pid, :worker, [Basket.Websocket.Alpaca]} ->
true

_ ->
false
end
end)
|> elem(1)
end

defp build_message(message, %{bars: bars, quotes: quotes, trades: trades}) do
message = if bars, do: Map.put(message, :bars, bars), else: message
message = if quotes, do: Map.put(message, :quotes, quotes), else: message
if trades, do: Map.put(message, :trades, trades), else: message
end
end
Loading
Loading