Added stopping logic.
This commit is contained in:
@@ -31,16 +31,52 @@ 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} ->
|
||||||
|
# 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)
|
result = DynamicSupervisor.terminate_child(__MODULE__, pid)
|
||||||
|
|
||||||
# Save state after stopping to remove bot from persistence
|
# Save state after stopping to remove bot from persistence
|
||||||
Task.start(fn -> Core.Bots.BotPersistence.save_state() end)
|
Task.start(fn -> Core.Bots.BotPersistence.save_state() end)
|
||||||
|
|
||||||
result
|
result
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
|
||||||
:error ->
|
:error ->
|
||||||
{:error, :not_found}
|
{: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.
|
**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 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)
|
check_price(state)
|
||||||
|
end
|
||||||
|
|
||||||
|
state
|
||||||
|
else
|
||||||
|
if state.sell_and_stop_pending do
|
||||||
|
Logger.debug("Market closed - waiting to sell #{state.symbol}")
|
||||||
else
|
else
|
||||||
Logger.debug("Market closed - skipping price check for #{state.symbol}")
|
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} ->
|
||||||
|
|||||||
@@ -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
|
||||||
|
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 ->
|
: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} ->
|
{: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} ->
|
{: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
|
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">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-bold">{bot.symbol}</span>
|
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user