diff --git a/traderex/lib/core/bots/bot_registry.ex b/traderex/lib/core/bots/bot_registry.ex new file mode 100644 index 0000000..42b6e68 --- /dev/null +++ b/traderex/lib/core/bots/bot_registry.ex @@ -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 diff --git a/traderex/lib/core/bots/bot_supervisor.ex b/traderex/lib/core/bots/bot_supervisor.ex new file mode 100644 index 0000000..532be0f --- /dev/null +++ b/traderex/lib/core/bots/bot_supervisor.ex @@ -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 diff --git a/traderex/lib/core/bots/dca_bot.ex b/traderex/lib/core/bots/dca_bot.ex new file mode 100644 index 0000000..8457fdb --- /dev/null +++ b/traderex/lib/core/bots/dca_bot.ex @@ -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 diff --git a/traderex/lib/traderex/application.ex b/traderex/lib/traderex/application.ex index 363b8bd..137eb39 100644 --- a/traderex/lib/traderex/application.ex +++ b/traderex/lib/traderex/application.ex @@ -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 ] diff --git a/traderex/lib/traderex_web/live/dashboard_live.ex b/traderex/lib/traderex_web/live/dashboard_live.ex index 785a4b2..e985774 100644 --- a/traderex/lib/traderex_web/live/dashboard_live.ex +++ b/traderex/lib/traderex_web/live/dashboard_live.ex @@ -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 /> +
+
+

DCA Bots

+ +
+ +
+ + +
+
+ <.icon name="hero-exclamation-circle" class="size-5" /> + {@bot_error} +
+
+ +
+ No bots running +
+ + <.table :if={@bots != []} id="bots" rows={@bots} row_id={&"bot-#{&1.symbol}"}> + <:col :let={bot} label="Symbol"> + {bot.symbol} + + <:col :let={bot} label="Position Size"> + {format_number(bot.shares)} shares + + <:col :let={bot} label="Cost Basis"> + {format_currency(bot.cost_basis)} + + <:col :let={bot} label="Avg Price"> + {format_currency(bot.avg_price)} + + <:col :let={bot} label="Return"> + + {format_percent_simple(bot.return_pct)} + + + <:col :let={bot} label="Actions"> + + + +
+
+
@@ -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