Added first bot.
This commit is contained in:
31
traderex/lib/core/bots/bot_registry.ex
Normal file
31
traderex/lib/core/bots/bot_registry.ex
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
defmodule Core.Bots.BotRegistry do
|
||||||
|
@moduledoc """
|
||||||
|
Registry for tracking DCA bots by symbol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the via tuple for registering a bot with a symbol.
|
||||||
|
"""
|
||||||
|
def via(symbol) when is_binary(symbol) do
|
||||||
|
{:via, Registry, {__MODULE__, symbol}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Looks up a bot by symbol.
|
||||||
|
Returns {:ok, pid} if found, :error otherwise.
|
||||||
|
"""
|
||||||
|
def lookup(symbol) when is_binary(symbol) do
|
||||||
|
case Registry.lookup(__MODULE__, symbol) do
|
||||||
|
[{pid, _}] -> {:ok, pid}
|
||||||
|
[] -> :error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all registered bots.
|
||||||
|
Returns a list of {symbol, pid} tuples.
|
||||||
|
"""
|
||||||
|
def list do
|
||||||
|
Registry.select(__MODULE__, [{{:"$1", :"$2", :_}, [], [{{:"$1", :"$2"}}]}])
|
||||||
|
end
|
||||||
|
end
|
||||||
55
traderex/lib/core/bots/bot_supervisor.ex
Normal file
55
traderex/lib/core/bots/bot_supervisor.ex
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
defmodule Core.Bots.BotSupervisor do
|
||||||
|
@moduledoc """
|
||||||
|
DynamicSupervisor for managing multiple DCA bot instances.
|
||||||
|
"""
|
||||||
|
use DynamicSupervisor
|
||||||
|
|
||||||
|
def start_link(init_arg) do
|
||||||
|
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_init_arg) do
|
||||||
|
DynamicSupervisor.init(strategy: :one_for_one)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts a new DCA bot for the given symbol.
|
||||||
|
Returns {:ok, pid} on success or {:error, reason} on failure.
|
||||||
|
"""
|
||||||
|
def start_bot(symbol) when is_binary(symbol) do
|
||||||
|
child_spec = {Core.Bots.DCABot, symbol: symbol}
|
||||||
|
DynamicSupervisor.start_child(__MODULE__, child_spec)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Stops the DCA bot for the given symbol.
|
||||||
|
"""
|
||||||
|
def stop_bot(symbol) when is_binary(symbol) do
|
||||||
|
case Core.Bots.BotRegistry.lookup(symbol) do
|
||||||
|
{:ok, pid} ->
|
||||||
|
DynamicSupervisor.terminate_child(__MODULE__, pid)
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all running bots with their information.
|
||||||
|
Returns a list of maps with symbol, pid, and position data.
|
||||||
|
"""
|
||||||
|
def list_bots do
|
||||||
|
DynamicSupervisor.which_children(__MODULE__)
|
||||||
|
|> Enum.map(fn {_, pid, _, _} ->
|
||||||
|
case Core.Bots.DCABot.position(pid) do
|
||||||
|
position when is_map(position) ->
|
||||||
|
Map.put(position, :pid, pid)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
end
|
||||||
|
end
|
||||||
409
traderex/lib/core/bots/dca_bot.ex
Normal file
409
traderex/lib/core/bots/dca_bot.ex
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
defmodule Core.Bots.DCABot do
|
||||||
|
@moduledoc """
|
||||||
|
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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
# Start a bot via the supervisor (recommended)
|
||||||
|
# This will immediately make an initial 5% purchase
|
||||||
|
{:ok, _pid} = Core.Bots.BotSupervisor.start_bot("SPY")
|
||||||
|
|
||||||
|
# Or start directly (for testing)
|
||||||
|
{:ok, _pid} = Core.Bots.DCABot.start_link(symbol: "SPY")
|
||||||
|
|
||||||
|
# Bot will check every 60 seconds and:
|
||||||
|
# 1. Log current price and position status
|
||||||
|
# 2. Auto-sell if position is up 20%+ (takes profit and resets)
|
||||||
|
# 3. Auto-buy if price drops 4% from last purchase (accumulates position)
|
||||||
|
|
||||||
|
# Check a bot's position
|
||||||
|
Core.Bots.DCABot.position("SPY")
|
||||||
|
# => %{symbol: "SPY", shares: 15, cost_basis: 1440.0, avg_price: 96.0, return_pct: 5.2}
|
||||||
|
|
||||||
|
# Manually trigger a buy for a specific bot
|
||||||
|
Core.Bots.DCABot.buy("SPY")
|
||||||
|
# => {:ok, %Core.Order{...}} or {:error, reason}
|
||||||
|
|
||||||
|
# List all running bots
|
||||||
|
Core.Bots.BotSupervisor.list_bots()
|
||||||
|
|
||||||
|
# Stop a bot
|
||||||
|
Core.Bots.BotSupervisor.stop_bot("SPY")
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@tick_interval 60_000 # 1 minute
|
||||||
|
@drop_threshold 0.04 # 4% price drop triggers auto-buy
|
||||||
|
@profit_threshold 0.20 # 20% total return triggers auto-sell
|
||||||
|
|
||||||
|
# Public API
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts the DCA bot for the given symbol.
|
||||||
|
The bot will check and log the price every minute.
|
||||||
|
"""
|
||||||
|
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))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Executes a buy order using 5% of the account balance.
|
||||||
|
Accepts either a pid or a symbol string.
|
||||||
|
Returns {:ok, order} on success or {:error, reason} on failure.
|
||||||
|
"""
|
||||||
|
def buy(pid_or_symbol)
|
||||||
|
|
||||||
|
def buy(pid) when is_pid(pid) do
|
||||||
|
GenServer.call(pid, :buy)
|
||||||
|
end
|
||||||
|
|
||||||
|
def buy(symbol) when is_binary(symbol) do
|
||||||
|
case Core.Bots.BotRegistry.lookup(symbol) do
|
||||||
|
{:ok, pid} -> GenServer.call(pid, :buy)
|
||||||
|
:error -> {:error, :bot_not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the bot's current position information.
|
||||||
|
Accepts either a pid or a symbol string.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Core.Bots.DCABot.position("SPY")
|
||||||
|
#=> %{symbol: "SPY", shares: 15, cost_basis: 1440.0, avg_price: 96.0}
|
||||||
|
"""
|
||||||
|
def position(pid_or_symbol)
|
||||||
|
|
||||||
|
def position(pid) when is_pid(pid) do
|
||||||
|
GenServer.call(pid, :position)
|
||||||
|
end
|
||||||
|
|
||||||
|
def position(symbol) when is_binary(symbol) do
|
||||||
|
case Core.Bots.BotRegistry.lookup(symbol) do
|
||||||
|
{:ok, pid} -> GenServer.call(pid, :position)
|
||||||
|
:error -> {:error, :bot_not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GenServer Callbacks
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(symbol) do
|
||||||
|
account_id = Core.Client.account_id()
|
||||||
|
Logger.info("DCABot started for symbol: #{symbol}")
|
||||||
|
|
||||||
|
# Schedule initial purchase
|
||||||
|
send(self(), :initial_buy)
|
||||||
|
|
||||||
|
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
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:initial_buy, state) do
|
||||||
|
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)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Initial buy failed for #{state.symbol}: #{inspect(reason)}")
|
||||||
|
state
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:tick, state) do
|
||||||
|
state = check_price(state)
|
||||||
|
schedule_tick()
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:buy, _from, state) do
|
||||||
|
case execute_buy(state, :manual) do
|
||||||
|
{:ok, order, purchase_price, shares} ->
|
||||||
|
new_state = update_bot_position(state, shares, purchase_price)
|
||||||
|
{:reply, {:ok, order}, new_state}
|
||||||
|
|
||||||
|
{:error, _reason} = error ->
|
||||||
|
{:reply, error, state}
|
||||||
|
end
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
{:reply, position_info, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Private Functions
|
||||||
|
|
||||||
|
defp schedule_tick do
|
||||||
|
Process.send_after(self(), :tick, @tick_interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_price(%{symbol: symbol, last_purchase_price: last_purchase_price, bot_shares: bot_shares, bot_cost_basis: bot_cost_basis} = state) do
|
||||||
|
case Core.Client.get_quotes(symbol) do
|
||||||
|
{:ok, [quote | _]} ->
|
||||||
|
price = quote.last
|
||||||
|
|
||||||
|
# Calculate current return
|
||||||
|
return_pct =
|
||||||
|
if bot_shares > 0 && bot_cost_basis > 0 do
|
||||||
|
current_value = bot_shares * price
|
||||||
|
Float.round((current_value - bot_cost_basis) / bot_cost_basis * 100, 2)
|
||||||
|
else
|
||||||
|
0.0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Log price and bot position status
|
||||||
|
if bot_shares > 0 do
|
||||||
|
avg_cost = Float.round(bot_cost_basis / bot_shares, 2)
|
||||||
|
Logger.info("#{symbol} price: $#{price} | Bot: #{bot_shares} shares @ avg $#{avg_cost} | Return: #{return_pct}%")
|
||||||
|
else
|
||||||
|
Logger.info("#{symbol} current price: $#{price} | Bot: no position")
|
||||||
|
end
|
||||||
|
|
||||||
|
state = %{state | last_price: price, current_return_pct: return_pct}
|
||||||
|
|
||||||
|
# Check if we should auto-sell (position up 20%)
|
||||||
|
state = maybe_auto_sell(state, price)
|
||||||
|
|
||||||
|
# Check if we should auto-buy (price dropped 4% from last purchase)
|
||||||
|
state = maybe_auto_buy(state, price, last_purchase_price)
|
||||||
|
|
||||||
|
state
|
||||||
|
|
||||||
|
{:ok, []} ->
|
||||||
|
Logger.warning("No quote data available for #{symbol}")
|
||||||
|
state
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to fetch quote for #{symbol}: #{inspect(reason)}")
|
||||||
|
state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_auto_buy(state, current_price, last_purchase_price) when is_number(last_purchase_price) do
|
||||||
|
price_drop = (last_purchase_price - current_price) / last_purchase_price
|
||||||
|
|
||||||
|
if price_drop >= @drop_threshold do
|
||||||
|
drop_percent = Float.round(price_drop * 100, 2)
|
||||||
|
Logger.info("Price dropped #{drop_percent}% from last purchase ($#{last_purchase_price}). Auto-buying...")
|
||||||
|
|
||||||
|
case execute_buy(state, :auto) do
|
||||||
|
{:ok, _order, purchase_price, shares} ->
|
||||||
|
update_bot_position(state, shares, purchase_price)
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
state
|
||||||
|
end
|
||||||
|
else
|
||||||
|
state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_auto_buy(state, _current_price, nil) do
|
||||||
|
# No previous purchase, nothing to compare against
|
||||||
|
state
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_auto_sell(%{bot_shares: bot_shares, bot_cost_basis: bot_cost_basis} = state, current_price) do
|
||||||
|
if bot_shares > 0 && bot_cost_basis > 0 do
|
||||||
|
current_value = bot_shares * current_price
|
||||||
|
total_return = (current_value - bot_cost_basis) / bot_cost_basis
|
||||||
|
|
||||||
|
if total_return >= @profit_threshold do
|
||||||
|
profit_percent = Float.round(total_return * 100, 2)
|
||||||
|
profit_amount = Float.round(current_value - bot_cost_basis, 2)
|
||||||
|
Logger.info("Bot position up #{profit_percent}% (profit: $#{profit_amount}). Auto-selling #{bot_shares} shares...")
|
||||||
|
|
||||||
|
case execute_sell(state, bot_shares) do
|
||||||
|
{:ok, _order} ->
|
||||||
|
Logger.info("Successfully sold all bot shares. Re-establishing position with 5% of account...")
|
||||||
|
state = reset_bot_position(state)
|
||||||
|
|
||||||
|
# Re-establish position immediately
|
||||||
|
case execute_buy(state, :reinvest) do
|
||||||
|
{:ok, _order, purchase_price, shares} ->
|
||||||
|
update_bot_position(state, shares, purchase_price)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to re-establish position: #{inspect(reason)}")
|
||||||
|
state
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
state
|
||||||
|
end
|
||||||
|
else
|
||||||
|
state
|
||||||
|
end
|
||||||
|
else
|
||||||
|
state
|
||||||
|
end
|
||||||
|
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}
|
||||||
|
|
||||||
|
{:error, reason} = error ->
|
||||||
|
Logger.error("Sell failed for #{symbol}: #{inspect(reason)}")
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_bot_position(%{bot_shares: bot_shares, bot_cost_basis: bot_cost_basis} = state, shares, price) do
|
||||||
|
new_shares = bot_shares + shares
|
||||||
|
new_cost_basis = bot_cost_basis + (shares * price)
|
||||||
|
|
||||||
|
Logger.info("Bot position updated: #{new_shares} total shares, cost basis: $#{Float.round(new_cost_basis, 2)}")
|
||||||
|
|
||||||
|
%{state |
|
||||||
|
bot_shares: new_shares,
|
||||||
|
bot_cost_basis: new_cost_basis,
|
||||||
|
last_purchase_price: price
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reset_bot_position(state) do
|
||||||
|
%{state |
|
||||||
|
bot_shares: 0,
|
||||||
|
bot_cost_basis: 0.0,
|
||||||
|
last_purchase_price: nil,
|
||||||
|
current_return_pct: 0.0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp execute_buy(%{symbol: symbol, account_id: account_id}, trigger) do
|
||||||
|
with {:ok, account} <- fetch_account(account_id),
|
||||||
|
{: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
|
||||||
|
cash_amount = buying_power * 0.05
|
||||||
|
|
||||||
|
trigger_type =
|
||||||
|
cond do
|
||||||
|
trigger == :initial -> "Initial buy"
|
||||||
|
trigger == :reinvest -> "Reinvest buy"
|
||||||
|
trigger == :auto -> "Auto-buy"
|
||||||
|
true -> "Manual buy"
|
||||||
|
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}
|
||||||
|
else
|
||||||
|
{:error, reason} = error ->
|
||||||
|
Logger.error("Buy failed for #{symbol}: #{inspect(reason)}")
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_account(account_id) do
|
||||||
|
case Core.Client.get_account(account_id) do
|
||||||
|
{:ok, account} ->
|
||||||
|
{:ok, account}
|
||||||
|
|
||||||
|
{:error, _} = error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_quote(symbol) do
|
||||||
|
case Core.Client.get_quotes(symbol) do
|
||||||
|
{:ok, [quote | _]} ->
|
||||||
|
{:ok, quote}
|
||||||
|
|
||||||
|
{:ok, []} ->
|
||||||
|
{:error, :no_quote_data}
|
||||||
|
|
||||||
|
{:error, _} = error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_buying_power(%{margin: %{stock_buying_power: buying_power}}) when is_number(buying_power) do
|
||||||
|
buying_power
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_buying_power(%{total_cash: total_cash}) when is_number(total_cash) do
|
||||||
|
total_cash
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_buying_power(_account), do: 0.0
|
||||||
|
|
||||||
|
defp calculate_shares(buying_power, price) when is_number(buying_power) and is_number(price) do
|
||||||
|
cash_amount = buying_power * 0.05
|
||||||
|
shares = floor(cash_amount / price)
|
||||||
|
|
||||||
|
if shares > 0 do
|
||||||
|
{:ok, shares}
|
||||||
|
else
|
||||||
|
{:error, :insufficient_funds}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp place_buy_order(account_id, symbol, shares) do
|
||||||
|
Core.Client.place_order(account_id,
|
||||||
|
class: "equity",
|
||||||
|
symbol: symbol,
|
||||||
|
side: "buy",
|
||||||
|
quantity: shares,
|
||||||
|
type: "market",
|
||||||
|
duration: "day",
|
||||||
|
preview: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp place_sell_order(account_id, symbol, shares) do
|
||||||
|
Core.Client.place_order(account_id,
|
||||||
|
class: "equity",
|
||||||
|
symbol: symbol,
|
||||||
|
side: "sell",
|
||||||
|
quantity: shares,
|
||||||
|
type: "market",
|
||||||
|
duration: "day",
|
||||||
|
preview: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,8 +11,10 @@ defmodule Traderex.Application do
|
|||||||
TraderexWeb.Telemetry,
|
TraderexWeb.Telemetry,
|
||||||
{DNSCluster, query: Application.get_env(:traderex, :dns_cluster_query) || :ignore},
|
{DNSCluster, query: Application.get_env(:traderex, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: Traderex.PubSub},
|
{Phoenix.PubSub, name: Traderex.PubSub},
|
||||||
# Start a worker by calling: Traderex.Worker.start_link(arg)
|
# Registry for tracking DCA bots
|
||||||
# {Traderex.Worker, arg},
|
{Registry, keys: :unique, name: Core.Bots.BotRegistry},
|
||||||
|
# DynamicSupervisor for managing DCA bots
|
||||||
|
Core.Bots.BotSupervisor,
|
||||||
# Start to serve requests, typically the last entry
|
# Start to serve requests, typically the last entry
|
||||||
TraderexWeb.Endpoint
|
TraderexWeb.Endpoint
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
|> assign(:events_expanded, false)
|
|> assign(:events_expanded, false)
|
||||||
|> assign(:loading, true)
|
|> assign(:loading, true)
|
||||||
|> assign(:error, nil)
|
|> assign(:error, nil)
|
||||||
|
|> assign(:bots, [])
|
||||||
|
|> assign(:bot_symbol, "")
|
||||||
|
|> assign(:bot_error, nil)
|
||||||
|
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
send(self(), :load_data)
|
send(self(), :load_data)
|
||||||
@@ -36,6 +39,8 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
Core.Client.get_balance_history(account_id, period: period)
|
Core.Client.get_balance_history(account_id, period: period)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bots = Core.Bots.BotSupervisor.list_bots()
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
case results do
|
case results do
|
||||||
{{:ok, account}, {:ok, positions}, {:ok, events}, {:ok, balance_history}} ->
|
{{:ok, account}, {:ok, positions}, {:ok, events}, {:ok, balance_history}} ->
|
||||||
@@ -44,6 +49,7 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
|> assign(:positions, positions)
|
|> assign(:positions, positions)
|
||||||
|> 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(:loading, false)
|
|> assign(:loading, false)
|
||||||
|> assign(:error, nil)
|
|> assign(:error, nil)
|
||||||
|
|
||||||
@@ -98,6 +104,51 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
{:noreply, assign(socket, :events_expanded, !socket.assigns.events_expanded)}
|
{:noreply, assign(socket, :events_expanded, !socket.assigns.events_expanded)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("update_bot_symbol", %{"symbol" => symbol}, socket) do
|
||||||
|
{:noreply, assign(socket, bot_symbol: String.upcase(symbol))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("create_bot", %{"symbol" => symbol}, socket) do
|
||||||
|
symbol = String.upcase(String.trim(symbol))
|
||||||
|
|
||||||
|
socket =
|
||||||
|
if symbol == "" do
|
||||||
|
assign(socket, :bot_error, "Symbol cannot be empty")
|
||||||
|
else
|
||||||
|
case Core.Bots.BotSupervisor.start_bot(symbol) do
|
||||||
|
{:ok, _pid} ->
|
||||||
|
socket
|
||||||
|
|> assign(:bot_symbol, "")
|
||||||
|
|> assign(:bot_error, nil)
|
||||||
|
|> put_flash(:info, "Bot started for #{symbol}")
|
||||||
|
|
||||||
|
{:error, {:already_started, _pid}} ->
|
||||||
|
assign(socket, :bot_error, "Bot already running for #{symbol}")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
assign(socket, :bot_error, "Failed to start bot: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("stop_bot", %{"symbol" => symbol}, socket) do
|
||||||
|
case Core.Bots.BotSupervisor.stop_bot(symbol) do
|
||||||
|
:ok ->
|
||||||
|
{:noreply, put_flash(socket, :info, "Bot stopped for #{symbol}")}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Bot not found for #{symbol}")}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Failed to stop bot: #{inspect(reason)}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@@ -167,6 +218,70 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">DCA Bots</h2>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Create New Bot</span>
|
||||||
|
</label>
|
||||||
|
<form phx-submit="create_bot" class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="symbol"
|
||||||
|
value={@bot_symbol}
|
||||||
|
phx-change="update_bot_symbol"
|
||||||
|
placeholder="Symbol (e.g., SPY)"
|
||||||
|
class="input input-bordered flex-1"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<.icon name="hero-plus" class="size-5" /> Create Bot
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div :if={@bot_error} class="alert alert-error mt-2">
|
||||||
|
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||||
|
<span>{@bot_error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@bots == []} class="text-base-content/70 py-4">
|
||||||
|
No bots running
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.table :if={@bots != []} id="bots" rows={@bots} row_id={&"bot-#{&1.symbol}"}>
|
||||||
|
<:col :let={bot} label="Symbol">
|
||||||
|
<span class="font-bold">{bot.symbol}</span>
|
||||||
|
</:col>
|
||||||
|
<:col :let={bot} label="Position Size">
|
||||||
|
{format_number(bot.shares)} shares
|
||||||
|
</:col>
|
||||||
|
<:col :let={bot} label="Cost Basis">
|
||||||
|
{format_currency(bot.cost_basis)}
|
||||||
|
</:col>
|
||||||
|
<:col :let={bot} label="Avg Price">
|
||||||
|
{format_currency(bot.avg_price)}
|
||||||
|
</:col>
|
||||||
|
<:col :let={bot} label="Return">
|
||||||
|
<span class={return_color(bot.return_pct)}>
|
||||||
|
{format_percent_simple(bot.return_pct)}
|
||||||
|
</span>
|
||||||
|
</:col>
|
||||||
|
<:col :let={bot} label="Actions">
|
||||||
|
<button
|
||||||
|
phx-click="stop_bot"
|
||||||
|
phx-value-symbol={bot.symbol}
|
||||||
|
class="btn btn-error btn-sm"
|
||||||
|
data-confirm="Are you sure you want to stop this bot?"
|
||||||
|
>
|
||||||
|
<.icon name="hero-stop" class="size-4" /> Stop
|
||||||
|
</button>
|
||||||
|
</:col>
|
||||||
|
</.table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-200 shadow-xl">
|
<div class="card bg-base-200 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex justify-between items-center flex-wrap gap-2">
|
<div class="flex justify-between items-center flex-wrap gap-2">
|
||||||
@@ -394,4 +509,15 @@ defmodule TraderexWeb.DashboardLive do
|
|||||||
defp format_period_label("YEAR_5"), do: "5Y"
|
defp format_period_label("YEAR_5"), do: "5Y"
|
||||||
defp format_period_label("ALL"), do: "All"
|
defp format_period_label("ALL"), do: "All"
|
||||||
defp format_period_label(period), do: period
|
defp format_period_label(period), do: period
|
||||||
|
|
||||||
|
defp format_percent_simple(nil), do: "-"
|
||||||
|
|
||||||
|
defp format_percent_simple(percent) when is_number(percent) do
|
||||||
|
sign = if percent >= 0, do: "+", else: ""
|
||||||
|
"#{sign}#{:erlang.float_to_binary(percent / 1, decimals: 2)}%"
|
||||||
|
end
|
||||||
|
|
||||||
|
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"
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user