Added stopping logic.

This commit is contained in:
2026-01-25 13:27:14 -05:00
parent 2d48dc3e94
commit 9a78bed0f6
3 changed files with 284 additions and 23 deletions

View File

@@ -31,14 +31,50 @@ defmodule Core.Bots.BotSupervisor do
@doc """ @doc """
Stops the DCA bot for the given symbol. 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 case Core.Bots.BotRegistry.lookup(symbol) do
{:ok, pid} -> {: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 {:ok, :deferred} ->
Task.start(fn -> Core.Bots.BotPersistence.save_state() end) # 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 result

View File

@@ -10,7 +10,7 @@ defmodule Core.Bots.DCABot do
**Market Hours**: The bot only executes trades and checks prices when the market is open. **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 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. **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 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
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 # GenServer Callbacks
@impl true @impl true
@@ -143,7 +174,8 @@ defmodule Core.Bots.DCABot do
last_purchase_price: last_purchase_price, last_purchase_price: last_purchase_price,
bot_shares: shares, bot_shares: shares,
bot_cost_basis: cost_basis, bot_cost_basis: cost_basis,
current_return_pct: return_pct current_return_pct: return_pct,
sell_and_stop_pending: false
} }
else else
# Starting fresh - schedule initial purchase # Starting fresh - schedule initial purchase
@@ -157,7 +189,8 @@ defmodule Core.Bots.DCABot do
last_purchase_price: nil, last_purchase_price: nil,
bot_shares: 0, bot_shares: 0,
bot_cost_basis: 0.0, bot_cost_basis: 0.0,
current_return_pct: 0.0 current_return_pct: 0.0,
sell_and_stop_pending: false
} }
end end
@@ -195,9 +228,23 @@ defmodule Core.Bots.DCABot do
def handle_info(:tick, state) do def handle_info(:tick, state) do
state = state =
if market_open?() do 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 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 state
end end
@@ -218,19 +265,48 @@ 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, 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 = %{ 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 last_purchase_price: last_purchase_price,
sell_and_stop_pending: sell_and_stop_pending
} }
{:reply, position_info, state} {:reply, position_info, state}
end 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 # Private Functions
defp schedule_tick do defp schedule_tick do
@@ -483,6 +559,24 @@ defmodule Core.Bots.DCABot do
) )
end 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 defp verify_order_filled(account_id, order_id, symbol, retries \\ 10) do
case Core.Client.get_order(account_id, order_id) do case Core.Client.get_order(account_id, order_id) do
{:ok, order} -> {:ok, order} ->

View File

@@ -20,6 +20,7 @@ defmodule TraderexWeb.DashboardLive do
|> assign(:bot_symbol, "") |> assign(:bot_symbol, "")
|> assign(:bot_error, nil) |> assign(:bot_error, nil)
|> assign(:market_clock, nil) |> assign(:market_clock, nil)
|> assign(:stop_bot_modal, nil)
if connected?(socket) do if connected?(socket) do
send(self(), :load_data) send(self(), :load_data)
@@ -142,17 +143,47 @@ defmodule TraderexWeb.DashboardLive do
end end
@impl true @impl true
def handle_event("stop_bot", %{"symbol" => symbol}, socket) do def handle_event("show_stop_bot_modal", %{"symbol" => symbol}, socket) do
case Core.Bots.BotSupervisor.stop_bot(symbol) do # Find the bot to show its position info
:ok -> bot = Enum.find(socket.assigns.bots, fn b -> b.symbol == symbol end)
{:noreply, put_flash(socket, :info, "Bot stopped for #{symbol}")} {:noreply, assign(socket, :stop_bot_modal, bot)}
end
{:error, :not_found} -> @impl true
{:noreply, put_flash(socket, :error, "Bot not found for #{symbol}")} def handle_event("close_stop_bot_modal", _, socket) do
{:noreply, assign(socket, :stop_bot_modal, nil)}
end
{:error, reason} -> @impl true
{:noreply, put_flash(socket, :error, "Failed to stop bot: #{inspect(reason)}")} def handle_event("stop_bot", %{"symbol" => symbol, "sell" => sell_str}, socket) do
end 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 end
@impl true @impl true
@@ -259,7 +290,15 @@ defmodule TraderexWeb.DashboardLive do
<.table :if={@bots != []} id="bots" rows={@bots} row_id={&"bot-#{&1.symbol}"}> <.table :if={@bots != []} id="bots" rows={@bots} row_id={&"bot-#{&1.symbol}"}>
<:col :let={bot} label="Symbol"> <:col :let={bot} label="Symbol">
<span class="font-bold">{bot.symbol}</span> <div class="flex items-center gap-2">
<span class="font-bold">{bot.symbol}</span>
<span
:if={Map.get(bot, :sell_and_stop_pending, false)}
class="badge badge-warning badge-sm"
>
Stopping...
</span>
</div>
</:col> </:col>
<:col :let={bot} label="Position Size"> <:col :let={bot} label="Position Size">
{format_number(bot.shares)} shares {format_number(bot.shares)} shares
@@ -277,18 +316,110 @@ defmodule TraderexWeb.DashboardLive do
</:col> </:col>
<:col :let={bot} label="Actions"> <:col :let={bot} label="Actions">
<button <button
phx-click="stop_bot" :if={!Map.get(bot, :sell_and_stop_pending, false)}
phx-click="show_stop_bot_modal"
phx-value-symbol={bot.symbol} phx-value-symbol={bot.symbol}
class="btn btn-error btn-sm" 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 <.icon name="hero-stop" class="size-4" /> Stop
</button> </button>
<div :if={Map.get(bot, :sell_and_stop_pending, false)} class="text-sm text-warning">
Waiting for market...
</div>
</:col> </:col>
</.table> </.table>
</div> </div>
</div> </div>
<%!-- Stop Bot Modal --%>
<div
:if={@stop_bot_modal}
class="modal modal-open"
phx-click="close_stop_bot_modal"
>
<div class="modal-box" phx-click-away="close_stop_bot_modal">
<h3 class="font-bold text-lg">Stop Bot: {@stop_bot_modal.symbol}</h3>
<%= if @stop_bot_modal.shares > 0 do %>
<div class="py-4">
<p class="mb-4">What would you like to do with the current position?</p>
<div class="stats stats-vertical lg:stats-horizontal shadow mb-4 w-full">
<div class="stat">
<div class="stat-title">Position Size</div>
<div class="stat-value text-2xl">{format_number(@stop_bot_modal.shares)}</div>
<div class="stat-desc">shares</div>
</div>
<div class="stat">
<div class="stat-title">Cost Basis</div>
<div class="stat-value text-2xl">
{format_currency(@stop_bot_modal.cost_basis)}
</div>
<div class="stat-desc">total invested</div>
</div>
<div class="stat">
<div class="stat-title">Return</div>
<div class={["stat-value text-2xl", return_color(@stop_bot_modal.return_pct)]}>
{format_percent_simple(@stop_bot_modal.return_pct)}
</div>
</div>
</div>
</div>
<div class="modal-action flex gap-2">
<button
phx-click="stop_bot"
phx-value-symbol={@stop_bot_modal.symbol}
phx-value-sell="false"
class="btn btn-warning flex-1"
>
<.icon name="hero-pause" class="size-5" />
Keep Shares & Stop Bot
</button>
<button
phx-click="stop_bot"
phx-value-symbol={@stop_bot_modal.symbol}
phx-value-sell="true"
class="btn btn-error flex-1"
>
<.icon name="hero-arrow-trending-down" class="size-5" />
Sell All & Stop Bot
</button>
<button phx-click="close_stop_bot_modal" class="btn btn-ghost">
Cancel
</button>
</div>
<% else %>
<div class="py-4">
<p class="mb-4">
This bot has no position yet (waiting for market to open or initial purchase).
</p>
<p>Are you sure you want to stop this bot?</p>
</div>
<div class="modal-action">
<button
phx-click="stop_bot"
phx-value-symbol={@stop_bot_modal.symbol}
phx-value-sell="false"
class="btn btn-error"
>
<.icon name="hero-stop" class="size-5" />
Stop Bot
</button>
<button phx-click="close_stop_bot_modal" class="btn btn-ghost">
Cancel
</button>
</div>
<% end %>
</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">