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 """
|
@doc """
|
||||||
Starts a new DCA bot for the given symbol.
|
Starts a new DCA bot for the given symbol.
|
||||||
Returns {:ok, pid} on success or {:error, reason} on failure.
|
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
|
def start_bot(symbol, opts \\ []) when is_binary(symbol) do
|
||||||
child_spec = {Core.Bots.DCABot, symbol: symbol}
|
restore_state = Keyword.get(opts, :restore_state)
|
||||||
|
child_spec = {Core.Bots.DCABot, symbol: symbol, restore_state: restore_state}
|
||||||
DynamicSupervisor.start_child(__MODULE__, child_spec)
|
DynamicSupervisor.start_child(__MODULE__, child_spec)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -28,7 +35,12 @@ defmodule Core.Bots.BotSupervisor do
|
|||||||
def stop_bot(symbol) when is_binary(symbol) do
|
def stop_bot(symbol) when is_binary(symbol) do
|
||||||
case Core.Bots.BotRegistry.lookup(symbol) do
|
case Core.Bots.BotRegistry.lookup(symbol) do
|
||||||
{:ok, pid} ->
|
{: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 ->
|
||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
@@ -52,4 +64,18 @@ defmodule Core.Bots.BotSupervisor do
|
|||||||
end)
|
end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -3,21 +3,35 @@ defmodule Core.Bots.DCABot do
|
|||||||
Dollar Cost Averaging Bot that periodically monitors stock prices and
|
Dollar Cost Averaging Bot that periodically monitors stock prices and
|
||||||
automatically trades based on the following rules:
|
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-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-sell**: When total position return exceeds 20%, sell all shares
|
||||||
- **Auto-reinvest**: After selling, immediately re-establish position with 5% of account balance
|
- **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.
|
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 →
|
The bot operates in a continuous cycle: buy on startup → accumulate on dips → take profit →
|
||||||
re-establish position → repeat.
|
re-establish position → repeat.
|
||||||
|
|
||||||
|
Bot state is automatically persisted after every position change, allowing bots to resume
|
||||||
|
after application restarts.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
# Start a bot via the supervisor (recommended)
|
# 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")
|
{: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)
|
# Or start directly (for testing)
|
||||||
{:ok, _pid} = Core.Bots.DCABot.start_link(symbol: "SPY")
|
{:ok, _pid} = Core.Bots.DCABot.start_link(symbol: "SPY")
|
||||||
|
|
||||||
@@ -53,10 +67,16 @@ defmodule Core.Bots.DCABot do
|
|||||||
@doc """
|
@doc """
|
||||||
Starts the DCA bot for the given symbol.
|
Starts the DCA bot for the given symbol.
|
||||||
The bot will check and log the price every minute.
|
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
|
def start_link(opts) when is_list(opts) do
|
||||||
symbol = Keyword.fetch!(opts, :symbol)
|
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
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -102,45 +122,85 @@ defmodule Core.Bots.DCABot do
|
|||||||
# GenServer Callbacks
|
# GenServer Callbacks
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(symbol) do
|
def init(%{symbol: symbol, restore_state: restore_state}) do
|
||||||
account_id = Core.Client.account_id()
|
account_id = Core.Client.account_id()
|
||||||
Logger.info("DCABot started for symbol: #{symbol}")
|
|
||||||
|
|
||||||
# Schedule initial purchase
|
state =
|
||||||
send(self(), :initial_buy)
|
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()
|
schedule_tick()
|
||||||
{:ok, %{
|
{:ok, state}
|
||||||
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:initial_buy, state) do
|
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 =
|
state =
|
||||||
case execute_buy(state, :initial) do
|
case execute_buy(state, :initial) do
|
||||||
{:ok, _order, purchase_price, shares} ->
|
{:ok, _order, purchase_price, shares} ->
|
||||||
update_bot_position(state, shares, purchase_price)
|
update_bot_position(state, shares, purchase_price)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.error("Initial buy failed for #{state.symbol}: #{inspect(reason)}")
|
Logger.error("Initial buy failed for #{state.symbol}: #{inspect(reason)}")
|
||||||
state
|
state
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:tick, state) do
|
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()
|
schedule_tick()
|
||||||
{:noreply, state}
|
{:noreply, state}
|
||||||
end
|
end
|
||||||
@@ -158,13 +218,14 @@ defmodule Core.Bots.DCABot do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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 = %{
|
position_info = %{
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
shares: bot_shares,
|
shares: bot_shares,
|
||||||
cost_basis: bot_cost_basis,
|
cost_basis: bot_cost_basis,
|
||||||
avg_price: if(bot_shares > 0, do: bot_cost_basis / bot_shares, else: 0.0),
|
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}
|
{:reply, position_info, state}
|
||||||
@@ -279,11 +340,13 @@ defmodule Core.Bots.DCABot do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp execute_sell(%{symbol: symbol, account_id: account_id}, quantity) do
|
defp execute_sell(%{symbol: symbol, account_id: account_id}, quantity) do
|
||||||
case place_sell_order(account_id, symbol, quantity) do
|
with {:ok, order} <- place_sell_order(account_id, symbol, quantity),
|
||||||
{:ok, order} ->
|
{:ok, filled_order} <- verify_order_filled(account_id, order.id, symbol) do
|
||||||
Logger.info("Sell order placed: #{quantity} shares of #{symbol}")
|
actual_price = filled_order.avg_fill_price
|
||||||
{:ok, order}
|
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 ->
|
{:error, reason} = error ->
|
||||||
Logger.error("Sell failed for #{symbol}: #{inspect(reason)}")
|
Logger.error("Sell failed for #{symbol}: #{inspect(reason)}")
|
||||||
error
|
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)}")
|
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_shares: new_shares,
|
||||||
bot_cost_basis: new_cost_basis,
|
bot_cost_basis: new_cost_basis,
|
||||||
last_purchase_price: price
|
last_purchase_price: price
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Save state after position update
|
||||||
|
save_bot_state()
|
||||||
|
|
||||||
|
new_state
|
||||||
end
|
end
|
||||||
|
|
||||||
defp reset_bot_position(state) do
|
defp reset_bot_position(state) do
|
||||||
%{state |
|
new_state = %{state |
|
||||||
bot_shares: 0,
|
bot_shares: 0,
|
||||||
bot_cost_basis: 0.0,
|
bot_cost_basis: 0.0,
|
||||||
last_purchase_price: nil,
|
last_purchase_price: nil,
|
||||||
current_return_pct: 0.0
|
current_return_pct: 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Save state after reset
|
||||||
|
save_bot_state()
|
||||||
|
|
||||||
|
new_state
|
||||||
end
|
end
|
||||||
|
|
||||||
defp execute_buy(%{symbol: symbol, account_id: account_id}, trigger) do
|
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),
|
{:ok, quote} <- fetch_quote(symbol),
|
||||||
buying_power = get_buying_power(account),
|
buying_power = get_buying_power(account),
|
||||||
{:ok, shares} <- calculate_shares(buying_power, quote.last),
|
{:ok, shares} <- calculate_shares(buying_power, quote.last),
|
||||||
{:ok, order} <- place_buy_order(account_id, symbol, shares) do
|
{:ok, order} <- place_buy_order(account_id, symbol, shares),
|
||||||
total_cost = shares * quote.last
|
{: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
|
cash_amount = buying_power * 0.05
|
||||||
|
|
||||||
trigger_type =
|
trigger_type =
|
||||||
@@ -330,8 +406,8 @@ defmodule Core.Bots.DCABot do
|
|||||||
end
|
end
|
||||||
|
|
||||||
Logger.info("#{trigger_type}: Purchasing power: $#{Float.round(buying_power, 2)} | Using 5%: $#{Float.round(cash_amount, 2)}")
|
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)})")
|
Logger.info("#{trigger_type} FILLED: #{actual_shares} shares of #{symbol} at $#{actual_price} (total: $#{Float.round(total_cost, 2)})")
|
||||||
{:ok, order, quote.last, shares}
|
{:ok, filled_order, actual_price, actual_shares}
|
||||||
else
|
else
|
||||||
{:error, reason} = error ->
|
{:error, reason} = error ->
|
||||||
Logger.error("Buy failed for #{symbol}: #{inspect(reason)}")
|
Logger.error("Buy failed for #{symbol}: #{inspect(reason)}")
|
||||||
@@ -406,4 +482,50 @@ defmodule Core.Bots.DCABot do
|
|||||||
preview: false
|
preview: false
|
||||||
)
|
)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -117,6 +117,28 @@ defmodule Core.Client do
|
|||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Fetches stock quotes for one or more symbols.
|
Fetches stock quotes for one or more symbols.
|
||||||
|
|
||||||
@@ -143,6 +165,25 @@ defmodule Core.Client do
|
|||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Places an order for an account.
|
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__{
|
@type t :: %__MODULE__{
|
||||||
id: integer() | nil,
|
id: integer() | nil,
|
||||||
|
type: String.t() | nil,
|
||||||
|
symbol: String.t() | nil,
|
||||||
|
side: String.t() | nil,
|
||||||
|
quantity: float() | nil,
|
||||||
status: String.t() | 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
|
partner_id: String.t() | nil
|
||||||
}
|
}
|
||||||
|
|
||||||
defstruct [
|
defstruct [
|
||||||
:id,
|
:id,
|
||||||
|
:type,
|
||||||
|
:symbol,
|
||||||
|
:side,
|
||||||
|
:quantity,
|
||||||
:status,
|
:status,
|
||||||
|
:duration,
|
||||||
|
:avg_fill_price,
|
||||||
|
:exec_quantity,
|
||||||
|
:last_fill_price,
|
||||||
|
:last_fill_quantity,
|
||||||
|
:remaining_quantity,
|
||||||
|
:create_date,
|
||||||
|
:transaction_date,
|
||||||
|
:class,
|
||||||
:partner_id
|
:partner_id
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -20,9 +46,9 @@ defmodule Core.Order do
|
|||||||
|
|
||||||
## Example
|
## 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)
|
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()
|
@spec from_api(map()) :: t()
|
||||||
def from_api(%{"order" => order}) when is_map(order) do
|
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
|
def from_api(data) when is_map(data) do
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
id: data["id"],
|
id: data["id"],
|
||||||
|
type: data["type"],
|
||||||
|
symbol: data["symbol"],
|
||||||
|
side: data["side"],
|
||||||
|
quantity: data["quantity"],
|
||||||
status: data["status"],
|
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"]
|
partner_id: data["partner_id"]
|
||||||
}
|
}
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -22,7 +22,21 @@ defmodule Traderex.Application do
|
|||||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
# for other strategies and supported options
|
# for other strategies and supported options
|
||||||
opts = [strategy: :one_for_one, name: Traderex.Supervisor]
|
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
|
end
|
||||||
|
|
||||||
# Tell Phoenix to update the endpoint configuration
|
# Tell Phoenix to update the endpoint configuration
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
|> assign(:bots, [])
|
|> assign(:bots, [])
|
||||||
|> assign(:bot_symbol, "")
|
|> assign(:bot_symbol, "")
|
||||||
|> assign(:bot_error, nil)
|
|> assign(:bot_error, nil)
|
||||||
|
|> assign(:market_clock, nil)
|
||||||
|
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
send(self(), :load_data)
|
send(self(), :load_data)
|
||||||
@@ -40,6 +41,10 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
}
|
}
|
||||||
|
|
||||||
bots = Core.Bots.BotSupervisor.list_bots()
|
bots = Core.Bots.BotSupervisor.list_bots()
|
||||||
|
market_clock = case Core.Client.get_market_clock() do
|
||||||
|
{:ok, clock} -> clock
|
||||||
|
{:error, _} -> nil
|
||||||
|
end
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
case results do
|
case results do
|
||||||
@@ -50,6 +55,7 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
|> assign(:events, Enum.take(events, 5))
|
|> assign(:events, Enum.take(events, 5))
|
||||||
|> assign(:balance_history, balance_history)
|
|> assign(:balance_history, balance_history)
|
||||||
|> assign(:bots, bots)
|
|> assign(:bots, bots)
|
||||||
|
|> assign(:market_clock, market_clock)
|
||||||
|> assign(:loading, false)
|
|> assign(:loading, false)
|
||||||
|> assign(:error, nil)
|
|> assign(:error, nil)
|
||||||
|
|
||||||
@@ -158,6 +164,7 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
<:subtitle>Account Overview</:subtitle>
|
<:subtitle>Account Overview</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
<.market_status_badge :if={@market_clock} clock={@market_clock} />
|
||||||
<TraderexWeb.Layouts.theme_toggle />
|
<TraderexWeb.Layouts.theme_toggle />
|
||||||
<.link href={~p"/auth/logout"} method="delete" class="btn btn-ghost btn-sm">
|
<.link href={~p"/auth/logout"} method="delete" class="btn btn-ghost btn-sm">
|
||||||
<.icon name="hero-arrow-right-on-rectangle" class="size-4" /> Logout
|
<.icon name="hero-arrow-right-on-rectangle" class="size-4" /> Logout
|
||||||
@@ -427,6 +434,28 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
"""
|
"""
|
||||||
end
|
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 :title, :string, required: true
|
||||||
attr :value, :any, required: true
|
attr :value, :any, required: true
|
||||||
attr :icon, :string, required: true
|
attr :icon, :string, required: true
|
||||||
@@ -520,4 +549,20 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
defp return_color(nil), do: nil
|
defp return_color(nil), do: nil
|
||||||
defp return_color(pct) when pct >= 0, do: "text-success font-bold"
|
defp return_color(pct) when pct >= 0, do: "text-success font-bold"
|
||||||
defp return_color(_pct), do: "text-error 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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user