Compare commits

...

3 Commits

Author SHA1 Message Date
20eb62df43 Fixed formatting for return column. 2026-02-11 20:34:31 -05:00
0e154f4ced Load from saved state after crash. 2026-02-11 20:29:40 -05:00
b917a0297c Added dashboard values 2026-02-11 20:27:59 -05:00
4 changed files with 159 additions and 49 deletions

View File

@@ -96,7 +96,10 @@ defmodule Core.Bots.BotPersistence do
case Core.Bots.BotSupervisor.start_bot(symbol, restore_state: bot_state) do case Core.Bots.BotSupervisor.start_bot(symbol, restore_state: bot_state) do
{:ok, pid} -> {:ok, pid} ->
Logger.info("Restored bot for #{symbol} with #{bot_state["shares"]} shares at avg $#{bot_state["avg_price"]}") Logger.info(
"Restored bot for #{symbol} with #{bot_state["shares"]} shares at avg $#{bot_state["avg_price"]}"
)
{:ok, pid} {:ok, pid}
{:error, {:already_started, pid}} -> {:error, {:already_started, pid}} ->

View File

@@ -52,7 +52,11 @@ defmodule Core.Bots.BotSupervisor do
{:ok, :deferred} -> {:ok, :deferred} ->
# Sell deferred until market opens - keep bot alive # Sell deferred until market opens - keep bot alive
require Logger require Logger
Logger.info("Sell deferred for #{symbol} - bot will stop after selling when market opens")
Logger.info(
"Sell deferred for #{symbol} - bot will stop after selling when market opens"
)
false false
{:error, reason} -> {:error, reason} ->

View File

@@ -58,9 +58,12 @@ defmodule Core.Bots.DCABot do
use GenServer use GenServer
require Logger require Logger
@tick_interval 60_000 # 1 minute # 1 minute
@drop_threshold 0.04 # 4% price drop triggers auto-buy @tick_interval 60_000
@profit_threshold 0.20 # 20% total return triggers auto-sell # 3% price drop triggers auto-buy
@drop_threshold 0.03
# 20% total return triggers auto-sell
@profit_threshold 0.20
# Public API # Public API
@@ -156,6 +159,10 @@ defmodule Core.Bots.DCABot do
def init(%{symbol: symbol, restore_state: restore_state}) do def init(%{symbol: symbol, restore_state: restore_state}) do
account_id = Core.Client.account_id() account_id = Core.Client.account_id()
# If no restore_state was explicitly provided, check if there's saved state for this symbol
restore_state =
restore_state || find_saved_state_for_symbol(symbol)
state = state =
if restore_state do if restore_state do
# Restoring from saved state # Restoring from saved state
@@ -165,7 +172,9 @@ defmodule Core.Bots.DCABot do
return_pct = restore_state["return_pct"] || 0.0 return_pct = restore_state["return_pct"] || 0.0
last_purchase_price = restore_state["last_purchase_price"] last_purchase_price = restore_state["last_purchase_price"]
Logger.info("DCABot RESTORED for #{symbol}: #{shares} shares @ avg $#{Float.round(avg_price, 2)} | Return: #{Float.round(return_pct, 2)}%") Logger.info(
"DCABot RESTORED for #{symbol}: #{shares} shares @ avg $#{Float.round(avg_price, 2)} | Return: #{Float.round(return_pct, 2)}%"
)
%{ %{
symbol: symbol, symbol: symbol,
@@ -175,6 +184,7 @@ defmodule Core.Bots.DCABot do
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,
current_pl_dollars: 0.0,
sell_and_stop_pending: false sell_and_stop_pending: false
} }
else else
@@ -190,6 +200,7 @@ defmodule Core.Bots.DCABot do
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,
current_pl_dollars: 0.0,
sell_and_stop_pending: false sell_and_stop_pending: false
} }
end end
@@ -265,13 +276,32 @@ 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, sell_and_stop_pending: sell_and_stop_pending} = 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,
current_pl_dollars: current_pl_dollars,
last_price: last_price,
last_purchase_price: last_purchase_price,
sell_and_stop_pending: sell_and_stop_pending
} = state
) do
next_buy_price =
if last_purchase_price, do: last_purchase_price * (1 - @drop_threshold), else: nil
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),
current_price: last_price,
next_buy_price: next_buy_price,
return_pct: current_return_pct, return_pct: current_return_pct,
pl_dollars: current_pl_dollars,
last_purchase_price: last_purchase_price, last_purchase_price: last_purchase_price,
sell_and_stop_pending: sell_and_stop_pending sell_and_stop_pending: sell_and_stop_pending
} }
@@ -313,29 +343,46 @@ defmodule Core.Bots.DCABot do
Process.send_after(self(), :tick, @tick_interval) Process.send_after(self(), :tick, @tick_interval)
end end
defp check_price(%{symbol: symbol, last_purchase_price: last_purchase_price, bot_shares: bot_shares, bot_cost_basis: bot_cost_basis} = state) do 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 case Core.Client.get_quotes(symbol) do
{:ok, [quote | _]} -> {:ok, [quote | _]} ->
price = quote.last price = quote.last
# Calculate current return # Calculate current return
return_pct = {return_pct, pl_dollars} =
if bot_shares > 0 && bot_cost_basis > 0 do if bot_shares > 0 && bot_cost_basis > 0 do
current_value = bot_shares * price current_value = bot_shares * price
Float.round((current_value - bot_cost_basis) / bot_cost_basis * 100, 2) pct = Float.round((current_value - bot_cost_basis) / bot_cost_basis * 100, 2)
dollars = Float.round(current_value - bot_cost_basis, 2)
{pct, dollars}
else else
0.0 {0.0, 0.0}
end end
# Log price and bot position status # Log price and bot position status
if bot_shares > 0 do if bot_shares > 0 do
avg_cost = Float.round(bot_cost_basis / bot_shares, 2) 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}%")
Logger.info(
"#{symbol} price: $#{price} | Bot: #{bot_shares} shares @ avg $#{avg_cost} | Return: #{return_pct}%"
)
else else
Logger.info("#{symbol} current price: $#{price} | Bot: no position") Logger.info("#{symbol} current price: $#{price} | Bot: no position")
end end
state = %{state | last_price: price, current_return_pct: return_pct} state = %{
state
| last_price: price,
current_return_pct: return_pct,
current_pl_dollars: pl_dollars
}
# Check if we should auto-sell (position up 20%) # Check if we should auto-sell (position up 20%)
state = maybe_auto_sell(state, price) state = maybe_auto_sell(state, price)
@@ -355,12 +402,16 @@ defmodule Core.Bots.DCABot do
end end
end end
defp maybe_auto_buy(state, current_price, last_purchase_price) when is_number(last_purchase_price) do 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 price_drop = (last_purchase_price - current_price) / last_purchase_price
if price_drop >= @drop_threshold do if price_drop >= @drop_threshold do
drop_percent = Float.round(price_drop * 100, 2) drop_percent = Float.round(price_drop * 100, 2)
Logger.info("Price dropped #{drop_percent}% from last purchase ($#{last_purchase_price}). Auto-buying...")
Logger.info(
"Price dropped #{drop_percent}% from last purchase ($#{last_purchase_price}). Auto-buying..."
)
case execute_buy(state, :auto) do case execute_buy(state, :auto) do
{:ok, _order, purchase_price, shares} -> {:ok, _order, purchase_price, shares} ->
@@ -379,7 +430,10 @@ defmodule Core.Bots.DCABot do
state state
end end
defp maybe_auto_sell(%{bot_shares: bot_shares, bot_cost_basis: bot_cost_basis} = state, current_price) do 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 if bot_shares > 0 && bot_cost_basis > 0 do
current_value = bot_shares * current_price current_value = bot_shares * current_price
total_return = (current_value - bot_cost_basis) / bot_cost_basis total_return = (current_value - bot_cost_basis) / bot_cost_basis
@@ -387,11 +441,17 @@ defmodule Core.Bots.DCABot do
if total_return >= @profit_threshold do if total_return >= @profit_threshold do
profit_percent = Float.round(total_return * 100, 2) profit_percent = Float.round(total_return * 100, 2)
profit_amount = Float.round(current_value - bot_cost_basis, 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...")
Logger.info(
"Bot position up #{profit_percent}% (profit: $#{profit_amount}). Auto-selling #{bot_shares} shares..."
)
case execute_sell(state, bot_shares) do case execute_sell(state, bot_shares) do
{:ok, _order} -> {:ok, _order} ->
Logger.info("Successfully sold all bot shares. Re-establishing position with 5% of account...") Logger.info(
"Successfully sold all bot shares. Re-establishing position with 5% of account..."
)
state = reset_bot_position(state) state = reset_bot_position(state)
# Re-establish position immediately # Re-establish position immediately
@@ -429,16 +489,23 @@ defmodule Core.Bots.DCABot do
end end
end end
defp update_bot_position(%{bot_shares: bot_shares, bot_cost_basis: bot_cost_basis} = state, shares, price) do defp update_bot_position(
%{bot_shares: bot_shares, bot_cost_basis: bot_cost_basis} = state,
shares,
price
) do
new_shares = bot_shares + shares new_shares = bot_shares + shares
new_cost_basis = bot_cost_basis + (shares * price) 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)}") Logger.info(
"Bot position updated: #{new_shares} total shares, cost basis: $#{Float.round(new_cost_basis, 2)}"
)
new_state = %{state | new_state = %{
bot_shares: new_shares, state
bot_cost_basis: new_cost_basis, | bot_shares: new_shares,
last_purchase_price: price bot_cost_basis: new_cost_basis,
last_purchase_price: price
} }
# Save state after position update # Save state after position update
@@ -448,11 +515,13 @@ defmodule Core.Bots.DCABot do
end end
defp reset_bot_position(state) do defp reset_bot_position(state) do
new_state = %{state | new_state = %{
bot_shares: 0, state
bot_cost_basis: 0.0, | bot_shares: 0,
last_purchase_price: nil, bot_cost_basis: 0.0,
current_return_pct: 0.0 last_purchase_price: nil,
current_return_pct: 0.0,
current_pl_dollars: 0.0
} }
# Save state after reset # Save state after reset
@@ -481,8 +550,14 @@ defmodule Core.Bots.DCABot do
true -> "Manual buy" true -> "Manual buy"
end end
Logger.info("#{trigger_type}: Purchasing power: $#{Float.round(buying_power, 2)} | Using 5%: $#{Float.round(cash_amount, 2)}") Logger.info(
Logger.info("#{trigger_type} FILLED: #{actual_shares} shares of #{symbol} at $#{actual_price} (total: $#{Float.round(total_cost, 2)})") "#{trigger_type}: Purchasing power: $#{Float.round(buying_power, 2)} | Using 5%: $#{Float.round(cash_amount, 2)}"
)
Logger.info(
"#{trigger_type} FILLED: #{actual_shares} shares of #{symbol} at $#{actual_price} (total: $#{Float.round(total_cost, 2)})"
)
{:ok, filled_order, actual_price, actual_shares} {:ok, filled_order, actual_price, actual_shares}
else else
{:error, reason} = error -> {:error, reason} = error ->
@@ -514,7 +589,8 @@ defmodule Core.Bots.DCABot do
end end
end end
defp get_buying_power(%{margin: %{stock_buying_power: buying_power}}) when is_number(buying_power) do defp get_buying_power(%{margin: %{stock_buying_power: buying_power}})
when is_number(buying_power) do
buying_power buying_power
end end
@@ -590,13 +666,19 @@ defmodule Core.Bots.DCABot do
{:error, {:order_rejected, order.status}} {:error, {:order_rejected, order.status}}
Core.Order.pending?(order) && retries > 0 -> Core.Order.pending?(order) && retries > 0 ->
Logger.debug("Order #{order_id} for #{symbol} still pending (#{order.status}), retrying...") Logger.debug(
"Order #{order_id} for #{symbol} still pending (#{order.status}), retrying..."
)
# Wait 500ms before checking again # Wait 500ms before checking again
Process.sleep(500) Process.sleep(500)
verify_order_filled(account_id, order_id, symbol, retries - 1) verify_order_filled(account_id, order_id, symbol, retries - 1)
true -> true ->
Logger.error("Order #{order_id} for #{symbol} timed out or unknown status: #{order.status}") Logger.error(
"Order #{order_id} for #{symbol} timed out or unknown status: #{order.status}"
)
{:error, {:order_timeout, order.status}} {:error, {:order_timeout, order.status}}
end end
@@ -622,4 +704,12 @@ defmodule Core.Bots.DCABot do
false false
end end
end end
defp find_saved_state_for_symbol(symbol) do
state_data = Core.Bots.BotPersistence.load_state()
Enum.find(state_data, fn bot_state ->
bot_state["symbol"] == symbol
end)
end
end end

View File

@@ -42,10 +42,12 @@ defmodule TraderexWeb.DashboardLive do
} }
bots = Core.Bots.BotSupervisor.list_bots() bots = Core.Bots.BotSupervisor.list_bots()
market_clock = case Core.Client.get_market_clock() do
{:ok, clock} -> clock market_clock =
{:error, _} -> nil case Core.Client.get_market_clock() do
end {:ok, clock} -> clock
{:error, _} -> nil
end
socket = socket =
case results do case results do
@@ -306,13 +308,24 @@ defmodule TraderexWeb.DashboardLive do
<:col :let={bot} label="Cost Basis"> <:col :let={bot} label="Cost Basis">
{format_currency(bot.cost_basis)} {format_currency(bot.cost_basis)}
</:col> </:col>
<:col :let={bot} label="Current Price">
{format_currency(Map.get(bot, :current_price))}
</:col>
<:col :let={bot} label="Avg Price"> <:col :let={bot} label="Avg Price">
{format_currency(bot.avg_price)} {format_currency(bot.avg_price)}
</:col> </:col>
<:col :let={bot} label="Next Buy Price">
{format_currency(Map.get(bot, :next_buy_price))}
</:col>
<:col :let={bot} label="Return"> <:col :let={bot} label="Return">
<span class={return_color(bot.return_pct)}> <div class="flex flex-col">
{format_percent_simple(bot.return_pct)} <span class={["text-sm", pl_color(Map.get(bot, :pl_dollars, 0))]}>
</span> {format_currency(Map.get(bot, :pl_dollars, 0))}
</span>
<span class={return_color(bot.return_pct)}>
({format_percent_simple(bot.return_pct)})
</span>
</div>
</:col> </:col>
<:col :let={bot} label="Actions"> <:col :let={bot} label="Actions">
<button <button
@@ -375,8 +388,7 @@ defmodule TraderexWeb.DashboardLive do
phx-value-sell="false" phx-value-sell="false"
class="btn btn-warning flex-1" class="btn btn-warning flex-1"
> >
<.icon name="hero-pause" class="size-5" /> <.icon name="hero-pause" class="size-5" /> Keep Shares & Stop Bot
Keep Shares & Stop Bot
</button> </button>
<button <button
@@ -385,8 +397,7 @@ defmodule TraderexWeb.DashboardLive do
phx-value-sell="true" phx-value-sell="true"
class="btn btn-error flex-1" class="btn btn-error flex-1"
> >
<.icon name="hero-arrow-trending-down" class="size-5" /> <.icon name="hero-arrow-trending-down" class="size-5" /> Sell All & Stop Bot
Sell All & Stop Bot
</button> </button>
<button phx-click="close_stop_bot_modal" class="btn btn-ghost"> <button phx-click="close_stop_bot_modal" class="btn btn-ghost">
@@ -408,8 +419,7 @@ defmodule TraderexWeb.DashboardLive do
phx-value-sell="false" phx-value-sell="false"
class="btn btn-error" class="btn btn-error"
> >
<.icon name="hero-stop" class="size-5" /> <.icon name="hero-stop" class="size-5" /> Stop Bot
Stop Bot
</button> </button>
<button phx-click="close_stop_bot_modal" class="btn btn-ghost"> <button phx-click="close_stop_bot_modal" class="btn btn-ghost">
@@ -574,7 +584,10 @@ defmodule TraderexWeb.DashboardLive do
market_status_class(@clock.state) market_status_class(@clock.state)
]}> ]}>
<span class="relative flex h-2 w-2"> <span class="relative flex h-2 w-2">
<span :if={@clock.state == "open"} class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"> <span
:if={@clock.state == "open"}
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"
>
</span> </span>
<span class={[ <span class={[
"relative inline-flex rounded-full h-2 w-2", "relative inline-flex rounded-full h-2 w-2",