diff --git a/assets/js/app.js b/assets/js/app.js index 317fc21..a1f0c1e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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}) @@ -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() @@ -39,4 +42,3 @@ liveSocket.connect() // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket - diff --git a/assets/js/darkMode.js b/assets/js/darkMode.js new file mode 100644 index 0000000..3e071bb --- /dev/null +++ b/assets/js/darkMode.js @@ -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(); +} diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index ca5a0b3..def007e 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -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") @@ -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: [ diff --git a/config/config.exs b/config/config.exs index f5f7926..2794dbb 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 diff --git a/config/dev.exs b/config/dev.exs index 79f7150..8bde69d 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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 @@ -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" diff --git a/lib/basket/alpaca/http/client.ex b/lib/basket/alpaca/http/client.ex new file mode 100644 index 0000000..40a31c7 --- /dev/null +++ b/lib/basket/alpaca/http/client.ex @@ -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 diff --git a/lib/basket/alpaca/ws/client.ex b/lib/basket/alpaca/ws/client.ex new file mode 100644 index 0000000..3a840b3 --- /dev/null +++ b/lib/basket/alpaca/ws/client.ex @@ -0,0 +1,113 @@ +defmodule Basket.Alpaca.Websocket.Client do + @moduledoc """ + Instantiates an Alpaca websocket connection and processes the incoming messages. + Currently only handles "bars" messages. + """ + + use WebSockex + require Logger + + alias Basket.Alpaca.Websocket.Message + + @auth_success ~s([{\"T\":\"success\",\"msg\":\"authenticated\"}]) + @connection_success ~s([{\"T\":\"success\",\"msg\":\"connected\"}]) + + @spec start_link(bitstring()) :: {:error, any()} | {:ok, pid()} + def start_link(state) do + Logger.info("Starting Alpaca websocket client.") + + WebSockex.start_link(iex_feed(), __MODULE__, state, extra_headers: auth_headers()) + end + + @spec handle_connect(WebSockex.Conn.t(), bitstring()) :: {:ok, bitstring()} + def handle_connect(_conn, state) do + Logger.info("Alpaca websocket connected.") + {:ok, state} + end + + @spec handle_disconnect(map(), bitstring()) :: {:ok, bitstring()} + 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. + """ + @spec handle_frame(WebSockex.Frame.frame(), bitstring()) :: {:ok, bitstring()} + def handle_frame({_type, @connection_success}, state) do + Logger.info("Connection message received.") + + {:ok, state} + end + + def handle_frame({_type, @auth_success}, state) do + Logger.info("Alpaca websocket authenticated.") + + {:ok, state} + end + + def handle_frame({_tpe, msg}, state) do + case Jason.decode(msg) do + {:ok, message} -> + Message.process(message) + + {:error, error} -> + Logger.error("Error decoding message: #{inspect(error)}") + end + + {:ok, state} + end + + @spec subscribe_to_market_data(Message.subscription_fields()) :: :ok + def subscribe_to_market_data(tickers) do + case Message.market_data_subscription(tickers) do + {:ok, message} -> + WebSockex.send_frame(client_pid(), {:text, message}) + Logger.debug("Subscription message sent: #{inspect(message)}") + + {:error, error} -> + Logger.error("Error sending subscription message: #{inspect(error)}") + end + end + + @spec unsubscribe_to_market_data(Message.subscription_fields()) :: :ok + def unsubscribe_to_market_data(tickers) do + case Message.market_data_remove_subscription(tickers) do + {:ok, message} -> + Logger.info("Sending subscription removal message: #{inspect(message)}") + + WebSockex.send_frame(client_pid(), {:text, message}) + Logger.info("Subscription removal message sent: #{inspect(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 iex_feed, do: "#{url()}/iex" + + defp url, do: Application.fetch_env!(:basket, :alpaca)[:market_ws_url] + + defp api_key, do: Application.fetch_env!(:basket, :alpaca)[:api_key] + + defp api_secret, do: Application.fetch_env!(:basket, :alpaca)[:api_secret] + + defp client_pid, + do: + Supervisor.which_children(Basket.Supervisor) + |> Enum.find(fn c -> + case c do + {Basket.Alpaca.Websocket.Client, _pid, :worker, [Basket.Alpaca.Websocket.Client]} -> + true + + _ -> + false + end + end) + |> elem(1) +end diff --git a/lib/basket/alpaca/ws/message.ex b/lib/basket/alpaca/ws/message.ex new file mode 100644 index 0000000..76bb2a0 --- /dev/null +++ b/lib/basket/alpaca/ws/message.ex @@ -0,0 +1,118 @@ +defmodule Basket.Alpaca.Websocket.Message do + @moduledoc """ + Handles messages from anS Alpaca websocket connection. + """ + require Logger + + @bars_topic "bars" + + @type subscription_fields :: %{ + :bars => list(String.t()), + :quotes => list(String.t()), + :trades => list(String.t()) + } + + @spec process(bitstring()) :: :ok + def process(messages) do + Enum.each(messages, fn message -> + 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: #{message}") + + "subscription" -> + Logger.info("Subscription message from Alpaca websocket connection: #{message}") + + _ -> + Logger.info("Unhandled websocket message: #{message}") + end + end) + + :ok + end + + @spec market_data_subscription(subscription_fields) :: + {:error, Jason.EncodeError.t() | Exception.t()} + | {:ok, String.t()} + def market_data_subscription(fields) do + message = + build_message( + %{ + action: :subscribe + }, + fields + ) + + case Jason.encode(message) do + {:ok, encoded_message} -> + {:ok, encoded_message} + + {:error, error} -> + Logger.error("Error encoding market subscription message: #{error}") + + {:error, error} + end + end + + @spec market_data_remove_subscription(subscription_fields) :: + {:error, Jason.EncodeError.t() | Exception.t()} + | {:ok, String.t()} + def market_data_remove_subscription(fields) do + message = + build_message( + %{ + action: :unsubscribe + }, + fields + ) + + case Jason.encode(message) do + {:ok, encoded_message} -> + {:ok, encoded_message} + + {:error, error} -> + Logger.error("Error encoding market subscription message: #{error}") + + {:error, error} + end + end + + def bars_topic, do: @bars_topic + + 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 + + defp handle_bars( + %{ + "S" => _symbol, + "o" => _open, + "h" => _high, + "l" => _low, + "c" => _close, + "v" => _volume, + "t" => _timestamp + } = message + ) do + Logger.info("Bars message received") + BasketWeb.Endpoint.broadcast_from(self(), @bars_topic, "ticker-update", message) + end + + defp handle_daily_bars(_message) do + Logger.info("Daily bars message received.") + end + + defp handle_bar_updates(_message) do + Logger.info("Bar updates message received") + end +end diff --git a/lib/basket/application.ex b/lib/basket/application.ex index daa9e68..5f86400 100644 --- a/lib/basket/application.ex +++ b/lib/basket/application.ex @@ -17,7 +17,9 @@ 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, + Basket.Alpaca.Websocket.Client, + {Cachex, name: :assets} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/basket_web/components/card.ex b/lib/basket_web/components/card.ex index 17b226e..28d8493 100644 --- a/lib/basket_web/components/card.ex +++ b/lib/basket_web/components/card.ex @@ -33,7 +33,7 @@ defmodule BasketWeb.Components.Card do @apply px-6 py-4 text-gray-700 text-base; } .header { - @apply p-6 font-semibold text-2xl text-brand w-full bg-gray-200; + @apply p-6 font-semibold text-2xl text-primary-light dark:text-primary-dark w-full bg-gray-200; } .footer { @apply px-6 py-4; diff --git a/lib/basket_web/components/core_components.ex b/lib/basket_web/components/core_components.ex index 47e5be3..033b8af 100644 --- a/lib/basket_web/components/core_components.ex +++ b/lib/basket_web/components/core_components.ex @@ -114,7 +114,7 @@ defmodule BasketWeb.CoreComponents do
hide("##{@id}")} + phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("#{@id}")} role="alert" class={[ "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1", @@ -177,6 +177,42 @@ defmodule BasketWeb.CoreComponents do """ end + @doc """ + Renders an inline form. + + ## Examples + + <.inline_form for={@form} phx-change="validate" phx-submit="save"> + <.input field={@form[:email]} label="Email"/> + <.input field={@form[:username]} label="Username" /> + <:actions> + <.button>Save + + + """ + attr :for, :any, required: true, doc: "the datastructure for the form" + attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" + + attr :rest, :global, + include: ~w(autocomplete name rel action enctype method novalidate target multipart), + doc: "the arbitrary HTML attributes to apply to the form tag" + + slot :inner_block, required: true + slot :actions, doc: "the slot for form actions, such as a submit button" + + def inline_form(assigns) do + ~H""" + <.form :let={f} for={@for} as={@as} {@rest}> +
+
+ <%= render_slot(action, f) %> +
+ <%= render_slot(@inner_block, f) %> +
+ + """ + end + @doc """ Renders a simple form. @@ -479,7 +515,9 @@ defmodule BasketWeb.CoreComponents do - + @@ -487,6 +525,7 @@ defmodule BasketWeb.CoreComponents do @@ -494,7 +533,12 @@ defmodule BasketWeb.CoreComponents do
<%= col[:label] %> + <%= col[:label] %> + <%= gettext("Actions") %>
@@ -673,4 +717,16 @@ defmodule BasketWeb.CoreComponents do def translate_errors(errors, field) when is_list(errors) do for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) end + + def diff_color(col, row) do + key = Map.get(col, :key) + + field = row[key] + + case elem(field, 1) do + "up" -> "bg-emerald-300 text-emerald-900" + "down" -> "bg-rose-300 text-rose-900" + _ -> "" + end + end end diff --git a/lib/basket_web/components/dark_mode_toggle.ex b/lib/basket_web/components/dark_mode_toggle.ex new file mode 100644 index 0000000..17c6c71 --- /dev/null +++ b/lib/basket_web/components/dark_mode_toggle.ex @@ -0,0 +1,23 @@ +defmodule BasketWeb.Components.DarkModeToggle do + @moduledoc """ + A sample component generated by `mix surface.init`. + """ + use Surface.Component + + import BasketWeb.CoreComponents + + alias Phoenix.LiveView.JS + + def render(assigns) do + ~F""" +
+ + +
+ """ + end +end diff --git a/lib/basket_web/components/layouts/app.html.heex b/lib/basket_web/components/layouts/app.html.heex index e23bfc8..552eec1 100644 --- a/lib/basket_web/components/layouts/app.html.heex +++ b/lib/basket_web/components/layouts/app.html.heex @@ -4,7 +4,7 @@ -

+

v<%= Application.spec(:phoenix, :vsn) %>

@@ -24,7 +24,7 @@ -
+
<.flash_group flash={@flash} /> <%= @inner_content %> diff --git a/lib/basket_web/components/layouts/root.html.heex b/lib/basket_web/components/layouts/root.html.heex index 46e00c5..18852b1 100644 --- a/lib/basket_web/components/layouts/root.html.heex +++ b/lib/basket_web/components/layouts/root.html.heex @@ -4,8 +4,8 @@ - <.live_title suffix=" ยท Phoenix Framework"> - <%= assigns[:page_title] || "Basket" %> + <.live_title> + <%= assigns[:page_title] || "Stock Basket" %>