Added persistence and better order tracking.
This commit is contained in:
137
traderex/lib/core/bots/bot_persistence.ex
Normal file
137
traderex/lib/core/bots/bot_persistence.ex
Normal file
@@ -0,0 +1,137 @@
|
||||
defmodule Core.Bots.BotPersistence do
|
||||
@moduledoc """
|
||||
Handles persistence of bot state to disk, allowing bots to resume
|
||||
after application restarts.
|
||||
"""
|
||||
require Logger
|
||||
|
||||
@default_storage_path "/tmp"
|
||||
@filename "traderex_bots_state.json"
|
||||
|
||||
@doc """
|
||||
Returns the storage directory path from environment or default.
|
||||
"""
|
||||
def storage_path do
|
||||
System.get_env("STORAGE") || @default_storage_path
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the full path to the state file.
|
||||
"""
|
||||
def state_file_path do
|
||||
Path.join(storage_path(), @filename)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Saves the state of all running bots to disk.
|
||||
"""
|
||||
def save_state do
|
||||
bots = Core.Bots.BotSupervisor.list_bots()
|
||||
|
||||
state_data =
|
||||
Enum.map(bots, fn bot ->
|
||||
%{
|
||||
symbol: bot.symbol,
|
||||
shares: bot.shares,
|
||||
cost_basis: bot.cost_basis,
|
||||
avg_price: bot.avg_price,
|
||||
return_pct: bot.return_pct,
|
||||
last_purchase_price: bot.last_purchase_price,
|
||||
saved_at: DateTime.utc_now() |> DateTime.to_iso8601()
|
||||
}
|
||||
end)
|
||||
|
||||
json = Jason.encode!(state_data, pretty: true)
|
||||
|
||||
case File.write(state_file_path(), json) do
|
||||
:ok ->
|
||||
Logger.debug("Bot state saved: #{length(state_data)} bots")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to save bot state: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads bot state from disk and returns a list of bot configurations.
|
||||
Returns an empty list if the file doesn't exist or can't be read.
|
||||
"""
|
||||
def load_state do
|
||||
path = state_file_path()
|
||||
|
||||
case File.read(path) do
|
||||
{:ok, contents} ->
|
||||
case Jason.decode(contents) do
|
||||
{:ok, state_data} ->
|
||||
Logger.info("Loaded bot state: #{length(state_data)} bots")
|
||||
state_data
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to parse bot state file: #{inspect(reason)}")
|
||||
[]
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.info("No existing bot state file found")
|
||||
[]
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read bot state file: #{inspect(reason)}")
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Restores bots from saved state by starting them via the supervisor.
|
||||
Returns a list of {:ok, pid} or {:error, reason} tuples.
|
||||
"""
|
||||
def restore_bots do
|
||||
state_data = load_state()
|
||||
|
||||
results =
|
||||
Enum.map(state_data, fn bot_state ->
|
||||
symbol = bot_state["symbol"]
|
||||
|
||||
case Core.Bots.BotSupervisor.start_bot(symbol, restore_state: bot_state) do
|
||||
{:ok, pid} ->
|
||||
Logger.info("Restored bot for #{symbol} with #{bot_state["shares"]} shares at avg $#{bot_state["avg_price"]}")
|
||||
{:ok, pid}
|
||||
|
||||
{:error, {:already_started, pid}} ->
|
||||
Logger.info("Bot for #{symbol} already running")
|
||||
{:ok, pid}
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Failed to restore bot for #{symbol}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end)
|
||||
|
||||
successful = Enum.count(results, fn {status, _} -> status == :ok end)
|
||||
Logger.info("Restored #{successful}/#{length(results)} bots")
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the state file.
|
||||
"""
|
||||
def clear_state do
|
||||
path = state_file_path()
|
||||
|
||||
case File.rm(path) do
|
||||
:ok ->
|
||||
Logger.info("Bot state file deleted")
|
||||
:ok
|
||||
|
||||
{:error, :enoent} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to delete bot state file: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,9 +16,16 @@ defmodule Core.Bots.BotSupervisor do
|
||||
@doc """
|
||||
Starts a new DCA bot for the given symbol.
|
||||
Returns {:ok, pid} on success or {:error, reason} on failure.
|
||||
|
||||
## Options
|
||||
|
||||
* `symbol` - The stock symbol (required)
|
||||
* `opts` - Keyword list of options:
|
||||
* `:restore_state` - Optional saved state to restore position from
|
||||
"""
|
||||
def start_bot(symbol) when is_binary(symbol) do
|
||||
child_spec = {Core.Bots.DCABot, symbol: symbol}
|
||||
def start_bot(symbol, opts \\ []) when is_binary(symbol) do
|
||||
restore_state = Keyword.get(opts, :restore_state)
|
||||
child_spec = {Core.Bots.DCABot, symbol: symbol, restore_state: restore_state}
|
||||
DynamicSupervisor.start_child(__MODULE__, child_spec)
|
||||
end
|
||||
|
||||
@@ -28,7 +35,12 @@ defmodule Core.Bots.BotSupervisor do
|
||||
def stop_bot(symbol) when is_binary(symbol) do
|
||||
case Core.Bots.BotRegistry.lookup(symbol) do
|
||||
{:ok, pid} ->
|
||||
DynamicSupervisor.terminate_child(__MODULE__, pid)
|
||||
result = DynamicSupervisor.terminate_child(__MODULE__, pid)
|
||||
|
||||
# Save state after stopping to remove bot from persistence
|
||||
Task.start(fn -> Core.Bots.BotPersistence.save_state() end)
|
||||
|
||||
result
|
||||
|
||||
:error ->
|
||||
{:error, :not_found}
|
||||
@@ -52,4 +64,18 @@ defmodule Core.Bots.BotSupervisor do
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops all running bots and saves their state.
|
||||
"""
|
||||
def stop_all_bots do
|
||||
# Save state first
|
||||
Core.Bots.BotPersistence.save_state()
|
||||
|
||||
# Then stop all bots
|
||||
DynamicSupervisor.which_children(__MODULE__)
|
||||
|> Enum.each(fn {_, pid, _, _} ->
|
||||
DynamicSupervisor.terminate_child(__MODULE__, pid)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,21 +3,35 @@ defmodule Core.Bots.DCABot do
|
||||
Dollar Cost Averaging Bot that periodically monitors stock prices and
|
||||
automatically trades based on the following rules:
|
||||
|
||||
- **Initial buy**: On startup, immediately purchases using 5% of account balance
|
||||
- **Initial buy**: On startup, purchases using 5% of account balance (when market opens)
|
||||
- **Auto-buy**: When price drops 4% from last purchase, buy using 5% of account balance
|
||||
- **Auto-sell**: When total position return exceeds 20%, sell all shares
|
||||
- **Auto-reinvest**: After selling, immediately re-establish position with 5% of account balance
|
||||
|
||||
**Market Hours**: The bot only executes trades and checks prices when the market is open.
|
||||
During premarket, postmarket, or closed hours, the bot waits and defers actions until
|
||||
market open.
|
||||
|
||||
**Order Verification**: All orders are verified to be filled before updating bot position.
|
||||
The bot uses the actual fill price (`avg_fill_price`) from the executed order, which may
|
||||
differ slightly from the quote price at order placement time.
|
||||
|
||||
Bots maintain their own position tracking independent of other bots or manual trades.
|
||||
The bot operates in a continuous cycle: buy on startup → accumulate on dips → take profit →
|
||||
re-establish position → repeat.
|
||||
|
||||
Bot state is automatically persisted after every position change, allowing bots to resume
|
||||
after application restarts.
|
||||
|
||||
## Usage
|
||||
|
||||
# Start a bot via the supervisor (recommended)
|
||||
# This will immediately make an initial 5% purchase
|
||||
# This will make an initial 5% purchase when market opens
|
||||
{:ok, _pid} = Core.Bots.BotSupervisor.start_bot("SPY")
|
||||
|
||||
# If started during market hours, bot buys immediately
|
||||
# If started outside market hours, bot waits until market opens
|
||||
|
||||
# Or start directly (for testing)
|
||||
{:ok, _pid} = Core.Bots.DCABot.start_link(symbol: "SPY")
|
||||
|
||||
@@ -53,10 +67,16 @@ defmodule Core.Bots.DCABot do
|
||||
@doc """
|
||||
Starts the DCA bot for the given symbol.
|
||||
The bot will check and log the price every minute.
|
||||
|
||||
## Options
|
||||
|
||||
* `:symbol` - The stock symbol to trade (required)
|
||||
* `:restore_state` - Optional saved state to restore position from
|
||||
"""
|
||||
def start_link(opts) when is_list(opts) do
|
||||
symbol = Keyword.fetch!(opts, :symbol)
|
||||
GenServer.start_link(__MODULE__, symbol, name: Core.Bots.BotRegistry.via(symbol))
|
||||
init_opts = %{symbol: symbol, restore_state: Keyword.get(opts, :restore_state)}
|
||||
GenServer.start_link(__MODULE__, init_opts, name: Core.Bots.BotRegistry.via(symbol))
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -102,45 +122,85 @@ defmodule Core.Bots.DCABot do
|
||||
# GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(symbol) do
|
||||
def init(%{symbol: symbol, restore_state: restore_state}) do
|
||||
account_id = Core.Client.account_id()
|
||||
Logger.info("DCABot started for symbol: #{symbol}")
|
||||
|
||||
# Schedule initial purchase
|
||||
send(self(), :initial_buy)
|
||||
state =
|
||||
if restore_state do
|
||||
# Restoring from saved state
|
||||
shares = restore_state["shares"] || 0
|
||||
cost_basis = restore_state["cost_basis"] || 0.0
|
||||
avg_price = restore_state["avg_price"] || 0.0
|
||||
return_pct = restore_state["return_pct"] || 0.0
|
||||
last_purchase_price = restore_state["last_purchase_price"]
|
||||
|
||||
Logger.info("DCABot RESTORED for #{symbol}: #{shares} shares @ avg $#{Float.round(avg_price, 2)} | Return: #{Float.round(return_pct, 2)}%")
|
||||
|
||||
%{
|
||||
symbol: symbol,
|
||||
account_id: account_id,
|
||||
last_price: nil,
|
||||
last_purchase_price: last_purchase_price,
|
||||
bot_shares: shares,
|
||||
bot_cost_basis: cost_basis,
|
||||
current_return_pct: return_pct
|
||||
}
|
||||
else
|
||||
# Starting fresh - schedule initial purchase
|
||||
Logger.info("DCABot CREATED for #{symbol} - scheduling initial 5% purchase")
|
||||
send(self(), :initial_buy)
|
||||
|
||||
%{
|
||||
symbol: symbol,
|
||||
account_id: account_id,
|
||||
last_price: nil,
|
||||
last_purchase_price: nil,
|
||||
bot_shares: 0,
|
||||
bot_cost_basis: 0.0,
|
||||
current_return_pct: 0.0
|
||||
}
|
||||
end
|
||||
|
||||
schedule_tick()
|
||||
{:ok, %{
|
||||
symbol: symbol,
|
||||
account_id: account_id,
|
||||
last_price: nil,
|
||||
last_purchase_price: nil,
|
||||
bot_shares: 0,
|
||||
bot_cost_basis: 0.0,
|
||||
current_return_pct: 0.0
|
||||
}}
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:initial_buy, state) do
|
||||
Logger.info("Performing initial purchase for #{state.symbol}")
|
||||
case market_open?() do
|
||||
true ->
|
||||
Logger.info("Performing initial purchase for #{state.symbol}")
|
||||
|
||||
state =
|
||||
case execute_buy(state, :initial) do
|
||||
{:ok, _order, purchase_price, shares} ->
|
||||
update_bot_position(state, shares, purchase_price)
|
||||
state =
|
||||
case execute_buy(state, :initial) do
|
||||
{:ok, _order, purchase_price, shares} ->
|
||||
update_bot_position(state, shares, purchase_price)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Initial buy failed for #{state.symbol}: #{inspect(reason)}")
|
||||
state
|
||||
end
|
||||
{:error, reason} ->
|
||||
Logger.error("Initial buy failed for #{state.symbol}: #{inspect(reason)}")
|
||||
state
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
{:noreply, state}
|
||||
|
||||
false ->
|
||||
Logger.info("Market closed - deferring initial purchase for #{state.symbol}")
|
||||
# Retry initial buy on next tick
|
||||
Process.send_after(self(), :initial_buy, @tick_interval)
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:tick, state) do
|
||||
state = check_price(state)
|
||||
state =
|
||||
if market_open?() do
|
||||
check_price(state)
|
||||
else
|
||||
Logger.debug("Market closed - skipping price check for #{state.symbol}")
|
||||
state
|
||||
end
|
||||
|
||||
schedule_tick()
|
||||
{:noreply, state}
|
||||
end
|
||||
@@ -158,13 +218,14 @@ defmodule Core.Bots.DCABot do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:position, _from, %{symbol: symbol, bot_shares: bot_shares, bot_cost_basis: bot_cost_basis, current_return_pct: current_return_pct} = state) do
|
||||
def handle_call(:position, _from, %{symbol: symbol, bot_shares: bot_shares, bot_cost_basis: bot_cost_basis, current_return_pct: current_return_pct, last_purchase_price: last_purchase_price} = state) do
|
||||
position_info = %{
|
||||
symbol: symbol,
|
||||
shares: bot_shares,
|
||||
cost_basis: bot_cost_basis,
|
||||
avg_price: if(bot_shares > 0, do: bot_cost_basis / bot_shares, else: 0.0),
|
||||
return_pct: current_return_pct
|
||||
return_pct: current_return_pct,
|
||||
last_purchase_price: last_purchase_price
|
||||
}
|
||||
|
||||
{:reply, position_info, state}
|
||||
@@ -279,11 +340,13 @@ defmodule Core.Bots.DCABot do
|
||||
end
|
||||
|
||||
defp execute_sell(%{symbol: symbol, account_id: account_id}, quantity) do
|
||||
case place_sell_order(account_id, symbol, quantity) do
|
||||
{:ok, order} ->
|
||||
Logger.info("Sell order placed: #{quantity} shares of #{symbol}")
|
||||
{:ok, order}
|
||||
|
||||
with {:ok, order} <- place_sell_order(account_id, symbol, quantity),
|
||||
{:ok, filled_order} <- verify_order_filled(account_id, order.id, symbol) do
|
||||
actual_price = filled_order.avg_fill_price
|
||||
actual_shares = filled_order.exec_quantity
|
||||
Logger.info("Sell order FILLED: #{actual_shares} shares of #{symbol} at $#{actual_price}")
|
||||
{:ok, filled_order}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Sell failed for #{symbol}: #{inspect(reason)}")
|
||||
error
|
||||
@@ -296,20 +359,30 @@ defmodule Core.Bots.DCABot do
|
||||
|
||||
Logger.info("Bot position updated: #{new_shares} total shares, cost basis: $#{Float.round(new_cost_basis, 2)}")
|
||||
|
||||
%{state |
|
||||
new_state = %{state |
|
||||
bot_shares: new_shares,
|
||||
bot_cost_basis: new_cost_basis,
|
||||
last_purchase_price: price
|
||||
}
|
||||
|
||||
# Save state after position update
|
||||
save_bot_state()
|
||||
|
||||
new_state
|
||||
end
|
||||
|
||||
defp reset_bot_position(state) do
|
||||
%{state |
|
||||
new_state = %{state |
|
||||
bot_shares: 0,
|
||||
bot_cost_basis: 0.0,
|
||||
last_purchase_price: nil,
|
||||
current_return_pct: 0.0
|
||||
}
|
||||
|
||||
# Save state after reset
|
||||
save_bot_state()
|
||||
|
||||
new_state
|
||||
end
|
||||
|
||||
defp execute_buy(%{symbol: symbol, account_id: account_id}, trigger) do
|
||||
@@ -317,8 +390,11 @@ defmodule Core.Bots.DCABot do
|
||||
{:ok, quote} <- fetch_quote(symbol),
|
||||
buying_power = get_buying_power(account),
|
||||
{:ok, shares} <- calculate_shares(buying_power, quote.last),
|
||||
{:ok, order} <- place_buy_order(account_id, symbol, shares) do
|
||||
total_cost = shares * quote.last
|
||||
{:ok, order} <- place_buy_order(account_id, symbol, shares),
|
||||
{:ok, filled_order} <- verify_order_filled(account_id, order.id, symbol) do
|
||||
actual_price = filled_order.avg_fill_price
|
||||
actual_shares = filled_order.exec_quantity
|
||||
total_cost = actual_shares * actual_price
|
||||
cash_amount = buying_power * 0.05
|
||||
|
||||
trigger_type =
|
||||
@@ -330,8 +406,8 @@ defmodule Core.Bots.DCABot do
|
||||
end
|
||||
|
||||
Logger.info("#{trigger_type}: Purchasing power: $#{Float.round(buying_power, 2)} | Using 5%: $#{Float.round(cash_amount, 2)}")
|
||||
Logger.info("#{trigger_type} executed: #{shares} shares of #{symbol} at $#{quote.last} (total: $#{Float.round(total_cost, 2)})")
|
||||
{:ok, order, quote.last, shares}
|
||||
Logger.info("#{trigger_type} FILLED: #{actual_shares} shares of #{symbol} at $#{actual_price} (total: $#{Float.round(total_cost, 2)})")
|
||||
{:ok, filled_order, actual_price, actual_shares}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Buy failed for #{symbol}: #{inspect(reason)}")
|
||||
@@ -406,4 +482,50 @@ defmodule Core.Bots.DCABot do
|
||||
preview: false
|
||||
)
|
||||
end
|
||||
|
||||
defp verify_order_filled(account_id, order_id, symbol, retries \\ 10) do
|
||||
case Core.Client.get_order(account_id, order_id) do
|
||||
{:ok, order} ->
|
||||
cond do
|
||||
Core.Order.filled?(order) ->
|
||||
Logger.info("Order #{order_id} for #{symbol} filled at $#{order.avg_fill_price}")
|
||||
{:ok, order}
|
||||
|
||||
Core.Order.rejected?(order) ->
|
||||
Logger.error("Order #{order_id} for #{symbol} was rejected/canceled: #{order.status}")
|
||||
{:error, {:order_rejected, order.status}}
|
||||
|
||||
Core.Order.pending?(order) && retries > 0 ->
|
||||
Logger.debug("Order #{order_id} for #{symbol} still pending (#{order.status}), retrying...")
|
||||
# Wait 500ms before checking again
|
||||
Process.sleep(500)
|
||||
verify_order_filled(account_id, order_id, symbol, retries - 1)
|
||||
|
||||
true ->
|
||||
Logger.error("Order #{order_id} for #{symbol} timed out or unknown status: #{order.status}")
|
||||
{:error, {:order_timeout, order.status}}
|
||||
end
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Failed to check order #{order_id}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp save_bot_state do
|
||||
# Async state save to avoid blocking the bot
|
||||
Task.start(fn -> Core.Bots.BotPersistence.save_state() end)
|
||||
end
|
||||
|
||||
defp market_open? do
|
||||
case Core.Client.get_market_clock() do
|
||||
{:ok, clock} ->
|
||||
Core.Clock.open?(clock)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to fetch market clock: #{inspect(reason)}")
|
||||
# If we can't determine market status, assume closed for safety
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,6 +117,28 @@ defmodule Core.Client do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches the market clock status.
|
||||
|
||||
Returns information about whether the market is open, closed, premarket, or postmarket,
|
||||
along with timing information for the next state change.
|
||||
|
||||
## Example
|
||||
|
||||
{:ok, clock} = Core.Client.get_market_clock()
|
||||
clock.state
|
||||
#=> "open"
|
||||
clock.description
|
||||
#=> "Market is open from 09:30 to 16:00"
|
||||
"""
|
||||
@spec get_market_clock() :: {:ok, Core.Clock.t()} | {:error, term()}
|
||||
def get_market_clock do
|
||||
case get("/markets/clock") do
|
||||
{:ok, body} -> {:ok, Core.Clock.from_api(body)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches stock quotes for one or more symbols.
|
||||
|
||||
@@ -143,6 +165,25 @@ defmodule Core.Client do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches the status of a specific order.
|
||||
|
||||
## Example
|
||||
|
||||
{:ok, order} = Core.Client.get_order("6YA15850", 123456)
|
||||
order.status
|
||||
#=> "filled"
|
||||
order.avg_fill_price
|
||||
#=> 128.25
|
||||
"""
|
||||
@spec get_order(String.t(), integer()) :: {:ok, Core.Order.t()} | {:error, term()}
|
||||
def get_order(account_id, order_id) when is_integer(order_id) do
|
||||
case get("/accounts/#{account_id}/orders/#{order_id}") do
|
||||
{:ok, body} -> {:ok, Core.Order.from_api(body)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Places an order for an account.
|
||||
|
||||
|
||||
89
traderex/lib/core/clock.ex
Normal file
89
traderex/lib/core/clock.ex
Normal file
@@ -0,0 +1,89 @@
|
||||
defmodule Core.Clock do
|
||||
@moduledoc """
|
||||
Represents the market clock information from Tradier API.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
date: Date.t(),
|
||||
description: String.t(),
|
||||
state: String.t(),
|
||||
timestamp: integer(),
|
||||
next_change: String.t(),
|
||||
next_state: String.t()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:date,
|
||||
:description,
|
||||
:state,
|
||||
:timestamp,
|
||||
:next_change,
|
||||
:next_state
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a Clock struct from a Tradier API clock response.
|
||||
|
||||
## Example
|
||||
|
||||
iex> data = %{"clock" => %{"date" => "2026-01-21", "state" => "open", ...}}
|
||||
iex> Core.Clock.from_api(data)
|
||||
%Core.Clock{date: ~D[2026-01-21], state: "open", ...}
|
||||
"""
|
||||
@spec from_api(map()) :: t()
|
||||
def from_api(%{"clock" => clock}) do
|
||||
from_api(clock)
|
||||
end
|
||||
|
||||
def from_api(data) when is_map(data) do
|
||||
%__MODULE__{
|
||||
date: parse_date(data["date"]),
|
||||
description: data["description"],
|
||||
state: data["state"],
|
||||
timestamp: data["timestamp"],
|
||||
next_change: data["next_change"],
|
||||
next_state: data["next_state"]
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_date(nil), do: nil
|
||||
|
||||
defp parse_date(date_string) when is_binary(date_string) do
|
||||
case Date.from_iso8601(date_string) do
|
||||
{:ok, date} -> date
|
||||
{:error, _reason} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the market is currently open.
|
||||
"""
|
||||
@spec open?(t()) :: boolean()
|
||||
def open?(%__MODULE__{state: state}) do
|
||||
state == "open"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the market is currently closed.
|
||||
"""
|
||||
@spec closed?(t()) :: boolean()
|
||||
def closed?(%__MODULE__{state: state}) do
|
||||
state == "closed"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the market is in premarket.
|
||||
"""
|
||||
@spec premarket?(t()) :: boolean()
|
||||
def premarket?(%__MODULE__{state: state}) do
|
||||
state == "premarket"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the market is in postmarket.
|
||||
"""
|
||||
@spec postmarket?(t()) :: boolean()
|
||||
def postmarket?(%__MODULE__{state: state}) do
|
||||
state == "postmarket"
|
||||
end
|
||||
end
|
||||
@@ -5,13 +5,39 @@ defmodule Core.Order do
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer() | nil,
|
||||
type: String.t() | nil,
|
||||
symbol: String.t() | nil,
|
||||
side: String.t() | nil,
|
||||
quantity: float() | nil,
|
||||
status: String.t() | nil,
|
||||
duration: String.t() | nil,
|
||||
avg_fill_price: float() | nil,
|
||||
exec_quantity: float() | nil,
|
||||
last_fill_price: float() | nil,
|
||||
last_fill_quantity: float() | nil,
|
||||
remaining_quantity: float() | nil,
|
||||
create_date: DateTime.t() | nil,
|
||||
transaction_date: DateTime.t() | nil,
|
||||
class: String.t() | nil,
|
||||
partner_id: String.t() | nil
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:id,
|
||||
:type,
|
||||
:symbol,
|
||||
:side,
|
||||
:quantity,
|
||||
:status,
|
||||
:duration,
|
||||
:avg_fill_price,
|
||||
:exec_quantity,
|
||||
:last_fill_price,
|
||||
:last_fill_quantity,
|
||||
:remaining_quantity,
|
||||
:create_date,
|
||||
:transaction_date,
|
||||
:class,
|
||||
:partner_id
|
||||
]
|
||||
|
||||
@@ -20,9 +46,9 @@ defmodule Core.Order do
|
||||
|
||||
## Example
|
||||
|
||||
iex> data = %{"order" => %{"id" => 123456, "status" => "ok", "partner_id" => "partner_12345"}}
|
||||
iex> data = %{"order" => %{"id" => 123456, "status" => "filled", ...}}
|
||||
iex> Core.Order.from_api(data)
|
||||
%Core.Order{id: 123456, status: "ok", partner_id: "partner_12345"}
|
||||
%Core.Order{id: 123456, status: "filled", ...}
|
||||
"""
|
||||
@spec from_api(map()) :: t()
|
||||
def from_api(%{"order" => order}) when is_map(order) do
|
||||
@@ -32,8 +58,54 @@ defmodule Core.Order do
|
||||
def from_api(data) when is_map(data) do
|
||||
%__MODULE__{
|
||||
id: data["id"],
|
||||
type: data["type"],
|
||||
symbol: data["symbol"],
|
||||
side: data["side"],
|
||||
quantity: data["quantity"],
|
||||
status: data["status"],
|
||||
duration: data["duration"],
|
||||
avg_fill_price: data["avg_fill_price"],
|
||||
exec_quantity: data["exec_quantity"],
|
||||
last_fill_price: data["last_fill_price"],
|
||||
last_fill_quantity: data["last_fill_quantity"],
|
||||
remaining_quantity: data["remaining_quantity"],
|
||||
create_date: parse_datetime(data["create_date"]),
|
||||
transaction_date: parse_datetime(data["transaction_date"]),
|
||||
class: data["class"],
|
||||
partner_id: data["partner_id"]
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_datetime(nil), do: nil
|
||||
|
||||
defp parse_datetime(datetime_string) when is_binary(datetime_string) do
|
||||
case DateTime.from_iso8601(datetime_string) do
|
||||
{:ok, datetime, _offset} -> datetime
|
||||
{:error, _reason} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the order has been filled.
|
||||
"""
|
||||
@spec filled?(t()) :: boolean()
|
||||
def filled?(%__MODULE__{status: status}) do
|
||||
status == "filled"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the order is pending.
|
||||
"""
|
||||
@spec pending?(t()) :: boolean()
|
||||
def pending?(%__MODULE__{status: status}) do
|
||||
status in ["pending", "open", "partially_filled"]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the order was rejected or canceled.
|
||||
"""
|
||||
@spec rejected?(t()) :: boolean()
|
||||
def rejected?(%__MODULE__{status: status}) do
|
||||
status in ["rejected", "canceled", "cancelled"]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,7 +22,21 @@ defmodule Traderex.Application do
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: Traderex.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
|
||||
case Supervisor.start_link(children, opts) do
|
||||
{:ok, _pid} = result ->
|
||||
# Restore bots from saved state after supervisor is up
|
||||
Task.start(fn ->
|
||||
# Give the supervisor a moment to fully initialize
|
||||
Process.sleep(100)
|
||||
Core.Bots.BotPersistence.restore_bots()
|
||||
end)
|
||||
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
# Tell Phoenix to update the endpoint configuration
|
||||
|
||||
@@ -19,6 +19,7 @@ defmodule TraderexWeb.DashboardLive do
|
||||
|> assign(:bots, [])
|
||||
|> assign(:bot_symbol, "")
|
||||
|> assign(:bot_error, nil)
|
||||
|> assign(:market_clock, nil)
|
||||
|
||||
if connected?(socket) do
|
||||
send(self(), :load_data)
|
||||
@@ -40,6 +41,10 @@ defmodule TraderexWeb.DashboardLive do
|
||||
}
|
||||
|
||||
bots = Core.Bots.BotSupervisor.list_bots()
|
||||
market_clock = case Core.Client.get_market_clock() do
|
||||
{:ok, clock} -> clock
|
||||
{:error, _} -> nil
|
||||
end
|
||||
|
||||
socket =
|
||||
case results do
|
||||
@@ -50,6 +55,7 @@ defmodule TraderexWeb.DashboardLive do
|
||||
|> assign(:events, Enum.take(events, 5))
|
||||
|> assign(:balance_history, balance_history)
|
||||
|> assign(:bots, bots)
|
||||
|> assign(:market_clock, market_clock)
|
||||
|> assign(:loading, false)
|
||||
|> assign(:error, nil)
|
||||
|
||||
@@ -158,6 +164,7 @@ defmodule TraderexWeb.DashboardLive do
|
||||
<:subtitle>Account Overview</:subtitle>
|
||||
<:actions>
|
||||
<div class="flex items-center gap-4">
|
||||
<.market_status_badge :if={@market_clock} clock={@market_clock} />
|
||||
<TraderexWeb.Layouts.theme_toggle />
|
||||
<.link href={~p"/auth/logout"} method="delete" class="btn btn-ghost btn-sm">
|
||||
<.icon name="hero-arrow-right-on-rectangle" class="size-4" /> Logout
|
||||
@@ -427,6 +434,28 @@ defmodule TraderexWeb.DashboardLive do
|
||||
"""
|
||||
end
|
||||
|
||||
attr :clock, :map, required: true
|
||||
|
||||
defp market_status_badge(assigns) do
|
||||
~H"""
|
||||
<div class={[
|
||||
"badge badge-lg gap-2",
|
||||
market_status_class(@clock.state)
|
||||
]}>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span :if={@clock.state == "open"} class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75">
|
||||
</span>
|
||||
<span class={[
|
||||
"relative inline-flex rounded-full h-2 w-2",
|
||||
market_dot_class(@clock.state)
|
||||
]}>
|
||||
</span>
|
||||
</span>
|
||||
<span class="font-semibold">{market_status_text(@clock.state)}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :title, :string, required: true
|
||||
attr :value, :any, required: true
|
||||
attr :icon, :string, required: true
|
||||
@@ -520,4 +549,20 @@ defmodule TraderexWeb.DashboardLive do
|
||||
defp return_color(nil), do: nil
|
||||
defp return_color(pct) when pct >= 0, do: "text-success font-bold"
|
||||
defp return_color(_pct), do: "text-error font-bold"
|
||||
|
||||
defp market_status_text("open"), do: "Market Open"
|
||||
defp market_status_text("closed"), do: "Market Closed"
|
||||
defp market_status_text("premarket"), do: "Pre-Market"
|
||||
defp market_status_text("postmarket"), do: "Post-Market"
|
||||
defp market_status_text(_), do: "Unknown"
|
||||
|
||||
defp market_status_class("open"), do: "badge-success"
|
||||
defp market_status_class("premarket"), do: "badge-warning"
|
||||
defp market_status_class("postmarket"), do: "badge-warning"
|
||||
defp market_status_class(_), do: "badge-ghost"
|
||||
|
||||
defp market_dot_class("open"), do: "bg-success"
|
||||
defp market_dot_class("premarket"), do: "bg-warning"
|
||||
defp market_dot_class("postmarket"), do: "bg-warning"
|
||||
defp market_dot_class(_), do: "bg-base-content"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user