From 9a78bed0f6f5b6274d6fae7bc30412a4e7ae29dc Mon Sep 17 00:00:00 2001 From: Jeffrey Ward Date: Sun, 25 Jan 2026 13:27:14 -0500 Subject: [PATCH] Added stopping logic. --- traderex/lib/core/bots/bot_supervisor.ex | 44 ++++- traderex/lib/core/bots/dca_bot.ex | 108 +++++++++++- .../lib/traderex_web/live/dashboard_live.ex | 155 ++++++++++++++++-- 3 files changed, 284 insertions(+), 23 deletions(-) diff --git a/traderex/lib/core/bots/bot_supervisor.ex b/traderex/lib/core/bots/bot_supervisor.ex index 5ae068f..0868621 100644 --- a/traderex/lib/core/bots/bot_supervisor.ex +++ b/traderex/lib/core/bots/bot_supervisor.ex @@ -31,14 +31,50 @@ defmodule Core.Bots.BotSupervisor do @doc """ Stops the DCA bot for the given symbol. + + ## Options + + * `:sell_position` - If true, sells all shares before stopping (default: false) """ - def stop_bot(symbol) when is_binary(symbol) do + def stop_bot(symbol, opts \\ []) when is_binary(symbol) do + sell_position = Keyword.get(opts, :sell_position, false) + case Core.Bots.BotRegistry.lookup(symbol) do {:ok, pid} -> - result = DynamicSupervisor.terminate_child(__MODULE__, pid) + # Sell position if requested + should_terminate = + if sell_position do + case Core.Bots.DCABot.sell_all(pid) do + :ok -> + # Sold immediately + true - # Save state after stopping to remove bot from persistence - Task.start(fn -> Core.Bots.BotPersistence.save_state() end) + {:ok, :deferred} -> + # Sell deferred until market opens - keep bot alive + require Logger + Logger.info("Sell deferred for #{symbol} - bot will stop after selling when market opens") + false + + {:error, reason} -> + require Logger + Logger.warning("Failed to sell position for #{symbol}: #{inspect(reason)}") + true + end + else + true + end + + result = + if should_terminate do + 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 + else + :ok + end result diff --git a/traderex/lib/core/bots/dca_bot.ex b/traderex/lib/core/bots/dca_bot.ex index 29f2f56..31d5869 100644 --- a/traderex/lib/core/bots/dca_bot.ex +++ b/traderex/lib/core/bots/dca_bot.ex @@ -10,7 +10,7 @@ defmodule Core.Bots.DCABot do **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. + market open. This includes initial purchases and sell-all-before-stop requests. **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 @@ -119,6 +119,37 @@ defmodule Core.Bots.DCABot do end end + @doc """ + Sells all shares held by the bot. + Accepts either a pid or a symbol string. + + If market is open: Sells immediately and returns :ok + If market is closed: Defers sell until market opens and returns {:ok, :deferred} + If no shares: Returns :ok without selling + + ## Example + + # During market hours + Core.Bots.DCABot.sell_all("SPY") + #=> :ok + + # After market hours + Core.Bots.DCABot.sell_all("SPY") + #=> {:ok, :deferred} + """ + def sell_all(pid_or_symbol) + + def sell_all(pid) when is_pid(pid) do + GenServer.call(pid, :sell_all, 30_000) + end + + def sell_all(symbol) when is_binary(symbol) do + case Core.Bots.BotRegistry.lookup(symbol) do + {:ok, pid} -> GenServer.call(pid, :sell_all, 30_000) + :error -> {:error, :bot_not_found} + end + end + # GenServer Callbacks @impl true @@ -143,7 +174,8 @@ defmodule Core.Bots.DCABot do last_purchase_price: last_purchase_price, bot_shares: shares, bot_cost_basis: cost_basis, - current_return_pct: return_pct + current_return_pct: return_pct, + sell_and_stop_pending: false } else # Starting fresh - schedule initial purchase @@ -157,7 +189,8 @@ defmodule Core.Bots.DCABot do last_purchase_price: nil, bot_shares: 0, bot_cost_basis: 0.0, - current_return_pct: 0.0 + current_return_pct: 0.0, + sell_and_stop_pending: false } end @@ -195,9 +228,23 @@ defmodule Core.Bots.DCABot do def handle_info(:tick, state) do state = if market_open?() do - check_price(state) + # Check if we need to sell and stop + state = + if state.sell_and_stop_pending do + Logger.info("Market opened - executing pending sell and stop for #{state.symbol}") + execute_sell_and_stop(state) + else + check_price(state) + end + + state else - Logger.debug("Market closed - skipping price check for #{state.symbol}") + if state.sell_and_stop_pending do + Logger.debug("Market closed - waiting to sell #{state.symbol}") + else + Logger.debug("Market closed - skipping price check for #{state.symbol}") + end + state end @@ -218,19 +265,48 @@ defmodule Core.Bots.DCABot do end @impl true - def handle_call(:position, _from, %{symbol: symbol, bot_shares: bot_shares, bot_cost_basis: bot_cost_basis, current_return_pct: current_return_pct, last_purchase_price: last_purchase_price} = 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, sell_and_stop_pending: sell_and_stop_pending} = 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, - last_purchase_price: last_purchase_price + last_purchase_price: last_purchase_price, + sell_and_stop_pending: sell_and_stop_pending } {:reply, position_info, state} end + @impl true + def handle_call(:sell_all, _from, %{bot_shares: bot_shares, symbol: symbol} = state) do + cond do + bot_shares == 0 -> + Logger.info("No shares to sell for #{symbol}") + {:reply, :ok, state} + + market_open?() -> + Logger.info("Selling all #{bot_shares} shares of #{symbol} before stopping bot") + + case execute_sell(state, bot_shares) do + {:ok, _order} -> + new_state = reset_bot_position(state) + {:reply, :ok, new_state} + + {:error, reason} = error -> + Logger.error("Failed to sell position for #{symbol}: #{inspect(reason)}") + {:reply, error, state} + end + + true -> + # Market is closed - defer sell until market opens + Logger.info("Market closed - deferring sell for #{symbol} until market opens") + new_state = %{state | sell_and_stop_pending: true} + {:reply, {:ok, :deferred}, new_state} + end + end + # Private Functions defp schedule_tick do @@ -483,6 +559,24 @@ defmodule Core.Bots.DCABot do ) end + defp execute_sell_and_stop(%{bot_shares: bot_shares, symbol: symbol} = state) do + Logger.info("Executing deferred sell: #{bot_shares} shares of #{symbol}") + + case execute_sell(state, bot_shares) do + {:ok, _order} -> + Logger.info("Sell complete - stopping bot for #{symbol}") + new_state = reset_bot_position(state) + # Stop the bot process + Process.exit(self(), :normal) + new_state + + {:error, reason} -> + Logger.error("Deferred sell failed for #{symbol}: #{inspect(reason)}, will retry") + # Keep trying on next tick + state + 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} -> diff --git a/traderex/lib/traderex_web/live/dashboard_live.ex b/traderex/lib/traderex_web/live/dashboard_live.ex index dd1098f..83790fd 100644 --- a/traderex/lib/traderex_web/live/dashboard_live.ex +++ b/traderex/lib/traderex_web/live/dashboard_live.ex @@ -20,6 +20,7 @@ defmodule TraderexWeb.DashboardLive do |> assign(:bot_symbol, "") |> assign(:bot_error, nil) |> assign(:market_clock, nil) + |> assign(:stop_bot_modal, nil) if connected?(socket) do send(self(), :load_data) @@ -142,17 +143,47 @@ defmodule TraderexWeb.DashboardLive do 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}")} + def handle_event("show_stop_bot_modal", %{"symbol" => symbol}, socket) do + # Find the bot to show its position info + bot = Enum.find(socket.assigns.bots, fn b -> b.symbol == symbol end) + {:noreply, assign(socket, :stop_bot_modal, bot)} + end - {:error, :not_found} -> - {:noreply, put_flash(socket, :error, "Bot not found for #{symbol}")} + @impl true + def handle_event("close_stop_bot_modal", _, socket) do + {:noreply, assign(socket, :stop_bot_modal, nil)} + end - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to stop bot: #{inspect(reason)}")} - end + @impl true + def handle_event("stop_bot", %{"symbol" => symbol, "sell" => sell_str}, socket) do + sell_position = sell_str == "true" + + socket = + case Core.Bots.BotSupervisor.stop_bot(symbol, sell_position: sell_position) do + :ok -> + message = + if sell_position do + "Bot for #{symbol} will sell position and stop when market opens" + else + "Bot stopped for #{symbol} (position kept)" + end + + socket + |> assign(:stop_bot_modal, nil) + |> put_flash(:info, message) + + {:error, :not_found} -> + socket + |> assign(:stop_bot_modal, nil) + |> put_flash(:error, "Bot not found for #{symbol}") + + {:error, reason} -> + socket + |> assign(:stop_bot_modal, nil) + |> put_flash(:error, "Failed to stop bot: #{inspect(reason)}") + end + + {:noreply, socket} end @impl true @@ -259,7 +290,15 @@ defmodule TraderexWeb.DashboardLive do <.table :if={@bots != []} id="bots" rows={@bots} row_id={&"bot-#{&1.symbol}"}> <:col :let={bot} label="Symbol"> - {bot.symbol} +
+ {bot.symbol} + + Stopping... + +
<:col :let={bot} label="Position Size"> {format_number(bot.shares)} shares @@ -277,18 +316,110 @@ defmodule TraderexWeb.DashboardLive do <:col :let={bot} label="Actions"> +
+ Waiting for market... +
+ <%!-- Stop Bot Modal --%> + +