Packages

Elixir client library for the Alpaca Trading API. Supports trading, market data, and account management.

Current section

Files

Jump to
alpa_ex lib alpa stream market_data.ex
Raw

lib/alpa/stream/market_data.ex

defmodule Alpa.Stream.MarketData do
@moduledoc """
WebSocket stream for real-time market data from the Alpaca Market Data API.
This module provides a GenServer-based WebSocket client that streams
real-time trades, quotes, and bars for subscribed symbols.
## Usage
# Start the stream
{:ok, pid} = Alpa.Stream.MarketData.start_link(
callback: fn event -> IO.inspect(event, label: "Market Data") end
)
# Subscribe to symbols
Alpa.Stream.MarketData.subscribe(pid, trades: ["AAPL", "MSFT"])
Alpa.Stream.MarketData.subscribe(pid, quotes: ["AAPL"], bars: ["SPY"])
# Unsubscribe
Alpa.Stream.MarketData.unsubscribe(pid, trades: ["MSFT"])
# Stop the stream
Alpa.Stream.MarketData.stop(pid)
## Event Types
Events have a `:type` field indicating the data type:
- `:trade` - Real-time trade data
- `:quote` - Real-time quote (NBBO) data
- `:bar` - Real-time minute bar data
## Feeds
Available feeds:
- `"iex"` - IEX exchange data (free)
- `"sip"` - All US exchanges (requires subscription)
"""
use WebSockex
require Logger
alias Alpa.Config
alias Alpa.Models.{Bar, Quote, Trade}
@iex_stream_url "wss://stream.data.alpaca.markets/v2/iex"
@sip_stream_url "wss://stream.data.alpaca.markets/v2/sip"
@max_reconnect_ms 60_000
@initial_backoff_ms 1_000
@jitter_min 0.5
@jitter_max 1.5
@default_max_reconnect_attempts 50
defstruct [
:callback,
:config,
:authenticated,
:subscriptions,
:connection_status,
reconnect_attempts: 0,
max_reconnect_attempts: @default_max_reconnect_attempts,
consecutive_callback_errors: 0
]
@type callback :: (map() -> any()) | {module(), atom(), list()}
@type subscription_type :: :trades | :quotes | :bars
@doc """
Start the market data WebSocket stream.
## Options
* `:callback` - Required. Function or MFA tuple to handle market data events
* `:feed` - Data feed: "iex" (default) or "sip"
* `:api_key` - API key (uses config if not provided)
* `:api_secret` - API secret (uses config if not provided)
* `:name` - Optional GenServer name for the process
## Examples
{:ok, pid} = Alpa.Stream.MarketData.start_link(
callback: fn event -> process_event(event) end,
feed: "iex"
)
"""
@spec start_link(keyword()) :: {:ok, pid()} | {:error, term()}
def start_link(opts) do
callback = Keyword.fetch!(opts, :callback)
config = Config.new(opts)
feed = Keyword.get(opts, :feed, "iex")
max_reconnect = Keyword.get(opts, :max_reconnect_attempts, @default_max_reconnect_attempts)
if Config.has_credentials?(config) do
url = if feed == "sip", do: @sip_stream_url, else: @iex_stream_url
state = %__MODULE__{
callback: callback,
config: config,
authenticated: false,
subscriptions: %{trades: [], quotes: [], bars: []},
connection_status: :connecting,
reconnect_attempts: 0,
max_reconnect_attempts: max_reconnect,
consecutive_callback_errors: 0
}
ws_opts =
case Keyword.get(opts, :name) do
nil -> []
name -> [name: name]
end
WebSockex.start_link(url, __MODULE__, state, ws_opts)
else
{:error, :missing_credentials}
end
end
@doc """
Subscribe to market data for symbols.
## Options
* `:trades` - List of symbols to subscribe for trades
* `:quotes` - List of symbols to subscribe for quotes
* `:bars` - List of symbols to subscribe for minute bars
## Examples
Alpa.Stream.MarketData.subscribe(pid, trades: ["AAPL", "MSFT"], quotes: ["AAPL"])
"""
@spec subscribe(pid(), keyword()) :: :ok
def subscribe(pid, subscriptions) do
WebSockex.cast(pid, {:subscribe, subscriptions})
end
@doc """
Unsubscribe from market data for symbols.
## Examples
Alpa.Stream.MarketData.unsubscribe(pid, trades: ["MSFT"])
"""
@spec unsubscribe(pid(), keyword()) :: :ok
def unsubscribe(pid, subscriptions) do
WebSockex.cast(pid, {:unsubscribe, subscriptions})
end
@doc """
Stop the market data stream.
"""
@spec stop(pid()) :: :ok
def stop(pid) do
WebSockex.cast(pid, :close)
end
@doc """
Returns the current connection status.
Possible values: `:connected`, `:disconnected`, `:connecting`
"""
@spec connection_status(pid()) :: :connected | :disconnected | :connecting
def connection_status(pid) do
state = :sys.get_state(pid)
state.connection_status
rescue
_ -> :disconnected
catch
:exit, _ -> :disconnected
end
# WebSockex Callbacks
@impl WebSockex
def handle_connect(_conn, state) do
Logger.debug("[MarketData] Connected to WebSocket")
send(self(), :authenticate)
{:ok, %{state | connection_status: :connecting}}
end
@impl WebSockex
def handle_info(:authenticate, state) do
auth_msg =
Jason.encode!(%{
action: "auth",
key: state.config.api_key,
secret: state.config.api_secret
})
{:reply, {:text, auth_msg}, state}
end
@impl WebSockex
def handle_info(:reconnect, state) do
{:reconnect, %{state | connection_status: :connecting}}
end
@impl WebSockex
def handle_info(:resubscribe, state) do
if state.authenticated and has_subscriptions?(state.subscriptions) do
msg =
Jason.encode!(%{
action: "subscribe",
trades: state.subscriptions.trades,
quotes: state.subscriptions.quotes,
bars: state.subscriptions.bars
})
{:reply, {:text, msg}, state}
else
# Not authenticated yet, try again in 1 second
Process.send_after(self(), :resubscribe, 1000)
{:ok, state}
end
end
@impl WebSockex
def handle_frame({:text, msg}, state) do
case Jason.decode(msg) do
{:ok, messages} when is_list(messages) ->
state = Enum.reduce(messages, state, &handle_message/2)
{:ok, state}
{:ok, message} when is_map(message) ->
state = handle_message(message, state)
{:ok, state}
{:error, reason} ->
Logger.warning("[MarketData] Failed to parse message: #{reason}")
{:ok, state}
end
end
@impl WebSockex
def handle_frame({:ping, _}, state) do
{:reply, {:pong, ""}, state}
end
@impl WebSockex
def handle_frame(frame, state) do
Logger.debug("[MarketData] Received frame: #{inspect(frame)}")
{:ok, state}
end
@impl WebSockex
def handle_cast({:subscribe, subscriptions}, state) do
msg =
Jason.encode!(%{
action: "subscribe",
trades: Keyword.get(subscriptions, :trades, []),
quotes: Keyword.get(subscriptions, :quotes, []),
bars: Keyword.get(subscriptions, :bars, [])
})
new_subs = merge_subscriptions(state.subscriptions, subscriptions)
{:reply, {:text, msg}, %{state | subscriptions: new_subs}}
end
@impl WebSockex
def handle_cast({:unsubscribe, subscriptions}, state) do
msg =
Jason.encode!(%{
action: "unsubscribe",
trades: Keyword.get(subscriptions, :trades, []),
quotes: Keyword.get(subscriptions, :quotes, []),
bars: Keyword.get(subscriptions, :bars, [])
})
new_subs = remove_subscriptions(state.subscriptions, subscriptions)
{:reply, {:text, msg}, %{state | subscriptions: new_subs}}
end
@impl WebSockex
def handle_cast(:close, state) do
{:close, state}
end
@impl WebSockex
def handle_disconnect(%{reason: reason}, state) do
Logger.warning("[MarketData] Disconnected: #{inspect(reason)}")
new_attempts = state.reconnect_attempts + 1
if new_attempts >= state.max_reconnect_attempts do
Logger.warning(
"[MarketData] Max reconnect attempts (#{state.max_reconnect_attempts}) reached, giving up"
)
{:ok,
%{
state
| authenticated: false,
connection_status: :disconnected,
reconnect_attempts: new_attempts
}}
else
delay = reconnect_delay(new_attempts)
Logger.info("[MarketData] Reconnecting in #{delay}ms (attempt #{new_attempts})")
Process.send_after(self(), :reconnect, delay)
{:ok,
%{
state
| authenticated: false,
connection_status: :disconnected,
reconnect_attempts: new_attempts
}}
end
end
@impl WebSockex
def terminate(reason, _state) do
Logger.info("[MarketData] Terminated: #{inspect(reason)}")
:ok
end
# Message handlers
defp handle_message(%{"T" => "success", "msg" => "connected"}, state) do
Logger.debug("[MarketData] Connected message received")
state
end
defp handle_message(%{"T" => "success", "msg" => "authenticated"}, state) do
Logger.info("[MarketData] Authenticated successfully")
# Resubscribe if we have existing subscriptions (reconnection case)
if has_subscriptions?(state.subscriptions) do
send(self(), :resubscribe)
end
%{
state
| authenticated: true,
connection_status: :connected,
reconnect_attempts: 0
}
end
defp handle_message(%{"T" => "error", "code" => code, "msg" => msg}, state) do
Logger.error("[MarketData] Error #{code}: #{msg}")
state
end
defp handle_message(%{"T" => "subscription"} = msg, state) do
Logger.info(
"[MarketData] Subscription updated: trades=#{length(msg["trades"] || [])}, quotes=#{length(msg["quotes"] || [])}, bars=#{length(msg["bars"] || [])}"
)
state
end
defp handle_message(%{"T" => "t"} = data, state) do
# Trade
event = %{type: :trade, data: Trade.from_map(data)}
invoke_callback(event, state)
end
defp handle_message(%{"T" => "q"} = data, state) do
# Quote
event = %{type: :quote, data: Quote.from_map(data)}
invoke_callback(event, state)
end
defp handle_message(%{"T" => "b"} = data, state) do
# Bar
event = %{type: :bar, data: Bar.from_map(data)}
invoke_callback(event, state)
end
defp handle_message(msg, state) do
Logger.debug("[MarketData] Unhandled message: #{inspect(msg)}")
state
end
# Private helpers
defp reconnect_delay(attempts) do
base = min(@initial_backoff_ms * Integer.pow(2, attempts - 1), @max_reconnect_ms)
jitter_factor = @jitter_min + :rand.uniform() * (@jitter_max - @jitter_min)
trunc(base * jitter_factor)
end
defp invoke_callback(event, state) do
case state.callback do
fun when is_function(fun, 1) ->
fun.(event)
{mod, fun, args} ->
apply(mod, fun, [event | args])
end
%{state | consecutive_callback_errors: 0}
rescue
error ->
new_count = state.consecutive_callback_errors + 1
if new_count >= 10 do
Logger.error(
"[MarketData] #{new_count} consecutive callback errors, latest: #{inspect(error)}"
)
else
Logger.error("[MarketData] Callback error: #{inspect(error)}")
end
%{state | consecutive_callback_errors: new_count}
end
defp merge_subscriptions(current, new) do
%{
trades: Enum.uniq(current.trades ++ Keyword.get(new, :trades, [])),
quotes: Enum.uniq(current.quotes ++ Keyword.get(new, :quotes, [])),
bars: Enum.uniq(current.bars ++ Keyword.get(new, :bars, []))
}
end
defp remove_subscriptions(current, to_remove) do
%{
trades: current.trades -- Keyword.get(to_remove, :trades, []),
quotes: current.quotes -- Keyword.get(to_remove, :quotes, []),
bars: current.bars -- Keyword.get(to_remove, :bars, [])
}
end
defp has_subscriptions?(subs) do
subs.trades != [] or subs.quotes != [] or subs.bars != []
end
end