Added first bot.

This commit is contained in:
2026-01-25 10:26:53 -05:00
parent 0a8db9c21d
commit 147b636d5d
5 changed files with 625 additions and 2 deletions

View 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

View 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

View 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

View File

@@ -11,8 +11,10 @@ defmodule Traderex.Application do
TraderexWeb.Telemetry,
{DNSCluster, query: Application.get_env(:traderex, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Traderex.PubSub},
# Start a worker by calling: Traderex.Worker.start_link(arg)
# {Traderex.Worker, arg},
# Registry for tracking DCA bots
{Registry, keys: :unique, name: Core.Bots.BotRegistry},
# DynamicSupervisor for managing DCA bots
Core.Bots.BotSupervisor,
# Start to serve requests, typically the last entry
TraderexWeb.Endpoint
]

View File

@@ -16,6 +16,9 @@ defmodule TraderexWeb.DashboardLive do
|> assign(:events_expanded, false)
|> assign(:loading, true)
|> assign(:error, nil)
|> assign(:bots, [])
|> assign(:bot_symbol, "")
|> assign(:bot_error, nil)
if connected?(socket) do
send(self(), :load_data)
@@ -36,6 +39,8 @@ defmodule TraderexWeb.DashboardLive do
Core.Client.get_balance_history(account_id, period: period)
}
bots = Core.Bots.BotSupervisor.list_bots()
socket =
case results do
{{:ok, account}, {:ok, positions}, {:ok, events}, {:ok, balance_history}} ->
@@ -44,6 +49,7 @@ defmodule TraderexWeb.DashboardLive do
|> assign(:positions, positions)
|> assign(:events, Enum.take(events, 5))
|> assign(:balance_history, balance_history)
|> assign(:bots, bots)
|> assign(:loading, false)
|> assign(:error, nil)
@@ -98,6 +104,51 @@ defmodule TraderexWeb.DashboardLive do
{:noreply, assign(socket, :events_expanded, !socket.assigns.events_expanded)}
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
def render(assigns) do
~H"""
@@ -167,6 +218,70 @@ defmodule TraderexWeb.DashboardLive do
/>
</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-body">
<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("ALL"), do: "All"
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