Added stopping logic.
This commit is contained in:
@@ -31,16 +31,52 @@ 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} ->
|
||||
# Sell position if requested
|
||||
should_terminate =
|
||||
if sell_position do
|
||||
case Core.Bots.DCABot.sell_all(pid) do
|
||||
:ok ->
|
||||
# Sold immediately
|
||||
true
|
||||
|
||||
{: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
|
||||
|
||||
:error ->
|
||||
{:error, :not_found}
|
||||
|
||||
@@ -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 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
|
||||
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} ->
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@impl true
|
||||
def handle_event("close_stop_bot_modal", _, socket) do
|
||||
{:noreply, assign(socket, :stop_bot_modal, nil)}
|
||||
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 ->
|
||||
{:noreply, put_flash(socket, :info, "Bot stopped for #{symbol}")}
|
||||
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} ->
|
||||
{:noreply, put_flash(socket, :error, "Bot not found for #{symbol}")}
|
||||
socket
|
||||
|> assign(:stop_bot_modal, nil)
|
||||
|> put_flash(:error, "Bot not found for #{symbol}")
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to stop bot: #{inspect(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">
|
||||
<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 :let={bot} label="Position Size">
|
||||
{format_number(bot.shares)} shares
|
||||
@@ -277,18 +316,110 @@ defmodule TraderexWeb.DashboardLive do
|
||||
</:col>
|
||||
<:col :let={bot} label="Actions">
|
||||
<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}
|
||||
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>
|
||||
<div :if={Map.get(bot, :sell_and_stop_pending, false)} class="text-sm text-warning">
|
||||
Waiting for market...
|
||||
</div>
|
||||
</:col>
|
||||
</.table>
|
||||
</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-body">
|
||||
<div class="flex justify-between items-center flex-wrap gap-2">
|
||||
|
||||
Reference in New Issue
Block a user