Added first bot.
This commit is contained in:
31
traderex/lib/core/bots/bot_registry.ex
Normal file
31
traderex/lib/core/bots/bot_registry.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Core.Bots.BotRegistry do
|
||||
@moduledoc """
|
||||
Registry for tracking DCA bots by symbol.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns the via tuple for registering a bot with a symbol.
|
||||
"""
|
||||
def via(symbol) when is_binary(symbol) do
|
||||
{:via, Registry, {__MODULE__, symbol}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up a bot by symbol.
|
||||
Returns {:ok, pid} if found, :error otherwise.
|
||||
"""
|
||||
def lookup(symbol) when is_binary(symbol) do
|
||||
case Registry.lookup(__MODULE__, symbol) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all registered bots.
|
||||
Returns a list of {symbol, pid} tuples.
|
||||
"""
|
||||
def list do
|
||||
Registry.select(__MODULE__, [{{:"$1", :"$2", :_}, [], [{{:"$1", :"$2"}}]}])
|
||||
end
|
||||
end
|
||||
55
traderex/lib/core/bots/bot_supervisor.ex
Normal file
55
traderex/lib/core/bots/bot_supervisor.ex
Normal file
@@ -0,0 +1,55 @@
|
||||
defmodule Core.Bots.BotSupervisor do
|
||||
@moduledoc """
|
||||
DynamicSupervisor for managing multiple DCA bot instances.
|
||||
"""
|
||||
use DynamicSupervisor
|
||||
|
||||
def start_link(init_arg) do
|
||||
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
DynamicSupervisor.init(strategy: :one_for_one)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a new DCA bot for the given symbol.
|
||||
Returns {:ok, pid} on success or {:error, reason} on failure.
|
||||
"""
|
||||
def start_bot(symbol) when is_binary(symbol) do
|
||||
child_spec = {Core.Bots.DCABot, symbol: symbol}
|
||||
DynamicSupervisor.start_child(__MODULE__, child_spec)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the DCA bot for the given symbol.
|
||||
"""
|
||||
def stop_bot(symbol) when is_binary(symbol) do
|
||||
case Core.Bots.BotRegistry.lookup(symbol) do
|
||||
{:ok, pid} ->
|
||||
DynamicSupervisor.terminate_child(__MODULE__, pid)
|
||||
|
||||
:error ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all running bots with their information.
|
||||
Returns a list of maps with symbol, pid, and position data.
|
||||
"""
|
||||
def list_bots do
|
||||
DynamicSupervisor.which_children(__MODULE__)
|
||||
|> Enum.map(fn {_, pid, _, _} ->
|
||||
case Core.Bots.DCABot.position(pid) do
|
||||
position when is_map(position) ->
|
||||
Map.put(position, :pid, pid)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
end
|
||||
409
traderex/lib/core/bots/dca_bot.ex
Normal file
409
traderex/lib/core/bots/dca_bot.ex
Normal file
@@ -0,0 +1,409 @@
|
||||
defmodule Core.Bots.DCABot do
|
||||
@moduledoc """
|
||||
Dollar Cost Averaging Bot that periodically monitors stock prices and
|
||||
automatically trades based on the following rules:
|
||||
|
||||
- **Initial buy**: On startup, immediately purchases using 5% of account balance
|
||||
- **Auto-buy**: When price drops 4% from last purchase, buy using 5% of account balance
|
||||
- **Auto-sell**: When total position return exceeds 20%, sell all shares
|
||||
- **Auto-reinvest**: After selling, immediately re-establish position with 5% of account balance
|
||||
|
||||
Bots maintain their own position tracking independent of other bots or manual trades.
|
||||
The bot operates in a continuous cycle: buy on startup → accumulate on dips → take profit →
|
||||
re-establish position → repeat.
|
||||
|
||||
## Usage
|
||||
|
||||
# Start a bot via the supervisor (recommended)
|
||||
# This will immediately make an initial 5% purchase
|
||||
{:ok, _pid} = Core.Bots.BotSupervisor.start_bot("SPY")
|
||||
|
||||
# Or start directly (for testing)
|
||||
{:ok, _pid} = Core.Bots.DCABot.start_link(symbol: "SPY")
|
||||
|
||||
# Bot will check every 60 seconds and:
|
||||
# 1. Log current price and position status
|
||||
# 2. Auto-sell if position is up 20%+ (takes profit and resets)
|
||||
# 3. Auto-buy if price drops 4% from last purchase (accumulates position)
|
||||
|
||||
# Check a bot's position
|
||||
Core.Bots.DCABot.position("SPY")
|
||||
# => %{symbol: "SPY", shares: 15, cost_basis: 1440.0, avg_price: 96.0, return_pct: 5.2}
|
||||
|
||||
# Manually trigger a buy for a specific bot
|
||||
Core.Bots.DCABot.buy("SPY")
|
||||
# => {:ok, %Core.Order{...}} or {:error, reason}
|
||||
|
||||
# List all running bots
|
||||
Core.Bots.BotSupervisor.list_bots()
|
||||
|
||||
# Stop a bot
|
||||
Core.Bots.BotSupervisor.stop_bot("SPY")
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
@tick_interval 60_000 # 1 minute
|
||||
@drop_threshold 0.04 # 4% price drop triggers auto-buy
|
||||
@profit_threshold 0.20 # 20% total return triggers auto-sell
|
||||
|
||||
# Public API
|
||||
|
||||
@doc """
|
||||
Starts the DCA bot for the given symbol.
|
||||
The bot will check and log the price every minute.
|
||||
"""
|
||||
def start_link(opts) when is_list(opts) do
|
||||
symbol = Keyword.fetch!(opts, :symbol)
|
||||
GenServer.start_link(__MODULE__, symbol, name: Core.Bots.BotRegistry.via(symbol))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Executes a buy order using 5% of the account balance.
|
||||
Accepts either a pid or a symbol string.
|
||||
Returns {:ok, order} on success or {:error, reason} on failure.
|
||||
"""
|
||||
def buy(pid_or_symbol)
|
||||
|
||||
def buy(pid) when is_pid(pid) do
|
||||
GenServer.call(pid, :buy)
|
||||
end
|
||||
|
||||
def buy(symbol) when is_binary(symbol) do
|
||||
case Core.Bots.BotRegistry.lookup(symbol) do
|
||||
{:ok, pid} -> GenServer.call(pid, :buy)
|
||||
:error -> {:error, :bot_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the bot's current position information.
|
||||
Accepts either a pid or a symbol string.
|
||||
|
||||
## Example
|
||||
|
||||
Core.Bots.DCABot.position("SPY")
|
||||
#=> %{symbol: "SPY", shares: 15, cost_basis: 1440.0, avg_price: 96.0}
|
||||
"""
|
||||
def position(pid_or_symbol)
|
||||
|
||||
def position(pid) when is_pid(pid) do
|
||||
GenServer.call(pid, :position)
|
||||
end
|
||||
|
||||
def position(symbol) when is_binary(symbol) do
|
||||
case Core.Bots.BotRegistry.lookup(symbol) do
|
||||
{:ok, pid} -> GenServer.call(pid, :position)
|
||||
:error -> {:error, :bot_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(symbol) do
|
||||
account_id = Core.Client.account_id()
|
||||
Logger.info("DCABot started for symbol: #{symbol}")
|
||||
|
||||
# Schedule initial purchase
|
||||
send(self(), :initial_buy)
|
||||
|
||||
schedule_tick()
|
||||
{:ok, %{
|
||||
symbol: symbol,
|
||||
account_id: account_id,
|
||||
last_price: nil,
|
||||
last_purchase_price: nil,
|
||||
bot_shares: 0,
|
||||
bot_cost_basis: 0.0,
|
||||
current_return_pct: 0.0
|
||||
}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:initial_buy, state) do
|
||||
Logger.info("Performing initial purchase for #{state.symbol}")
|
||||
|
||||
state =
|
||||
case execute_buy(state, :initial) do
|
||||
{:ok, _order, purchase_price, shares} ->
|
||||
update_bot_position(state, shares, purchase_price)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Initial buy failed for #{state.symbol}: #{inspect(reason)}")
|
||||
state
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:tick, state) do
|
||||
state = check_price(state)
|
||||
schedule_tick()
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:buy, _from, state) do
|
||||
case execute_buy(state, :manual) do
|
||||
{:ok, order, purchase_price, shares} ->
|
||||
new_state = update_bot_position(state, shares, purchase_price)
|
||||
{:reply, {:ok, order}, new_state}
|
||||
|
||||
{:error, _reason} = error ->
|
||||
{:reply, error, state}
|
||||
end
|
||||
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} = 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
|
||||
}
|
||||
|
||||
{:reply, position_info, state}
|
||||
end
|
||||
|
||||
# Private Functions
|
||||
|
||||
defp schedule_tick do
|
||||
Process.send_after(self(), :tick, @tick_interval)
|
||||
end
|
||||
|
||||
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
|
||||
{:ok, [quote | _]} ->
|
||||
price = quote.last
|
||||
|
||||
# Calculate current return
|
||||
return_pct =
|
||||
if bot_shares > 0 && bot_cost_basis > 0 do
|
||||
current_value = bot_shares * price
|
||||
Float.round((current_value - bot_cost_basis) / bot_cost_basis * 100, 2)
|
||||
else
|
||||
0.0
|
||||
end
|
||||
|
||||
# Log price and bot position status
|
||||
if bot_shares > 0 do
|
||||
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}%")
|
||||
else
|
||||
Logger.info("#{symbol} current price: $#{price} | Bot: no position")
|
||||
end
|
||||
|
||||
state = %{state | last_price: price, current_return_pct: return_pct}
|
||||
|
||||
# Check if we should auto-sell (position up 20%)
|
||||
state = maybe_auto_sell(state, price)
|
||||
|
||||
# Check if we should auto-buy (price dropped 4% from last purchase)
|
||||
state = maybe_auto_buy(state, price, last_purchase_price)
|
||||
|
||||
state
|
||||
|
||||
{:ok, []} ->
|
||||
Logger.warning("No quote data available for #{symbol}")
|
||||
state
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to fetch quote for #{symbol}: #{inspect(reason)}")
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
if price_drop >= @drop_threshold do
|
||||
drop_percent = Float.round(price_drop * 100, 2)
|
||||
Logger.info("Price dropped #{drop_percent}% from last purchase ($#{last_purchase_price}). Auto-buying...")
|
||||
|
||||
case execute_buy(state, :auto) do
|
||||
{:ok, _order, purchase_price, shares} ->
|
||||
update_bot_position(state, shares, purchase_price)
|
||||
|
||||
{:error, _reason} ->
|
||||
state
|
||||
end
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_auto_buy(state, _current_price, nil) do
|
||||
# No previous purchase, nothing to compare against
|
||||
state
|
||||
end
|
||||
|
||||
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
|
||||
current_value = bot_shares * current_price
|
||||
total_return = (current_value - bot_cost_basis) / bot_cost_basis
|
||||
|
||||
if total_return >= @profit_threshold do
|
||||
profit_percent = Float.round(total_return * 100, 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...")
|
||||
|
||||
case execute_sell(state, bot_shares) do
|
||||
{:ok, _order} ->
|
||||
Logger.info("Successfully sold all bot shares. Re-establishing position with 5% of account...")
|
||||
state = reset_bot_position(state)
|
||||
|
||||
# Re-establish position immediately
|
||||
case execute_buy(state, :reinvest) do
|
||||
{:ok, _order, purchase_price, shares} ->
|
||||
update_bot_position(state, shares, purchase_price)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to re-establish position: #{inspect(reason)}")
|
||||
state
|
||||
end
|
||||
|
||||
{:error, _reason} ->
|
||||
state
|
||||
end
|
||||
else
|
||||
state
|
||||
end
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp execute_sell(%{symbol: symbol, account_id: account_id}, quantity) do
|
||||
case place_sell_order(account_id, symbol, quantity) do
|
||||
{:ok, order} ->
|
||||
Logger.info("Sell order placed: #{quantity} shares of #{symbol}")
|
||||
{:ok, order}
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Sell failed for #{symbol}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp update_bot_position(%{bot_shares: bot_shares, bot_cost_basis: bot_cost_basis} = state, shares, price) do
|
||||
new_shares = bot_shares + shares
|
||||
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)}")
|
||||
|
||||
%{state |
|
||||
bot_shares: new_shares,
|
||||
bot_cost_basis: new_cost_basis,
|
||||
last_purchase_price: price
|
||||
}
|
||||
end
|
||||
|
||||
defp reset_bot_position(state) do
|
||||
%{state |
|
||||
bot_shares: 0,
|
||||
bot_cost_basis: 0.0,
|
||||
last_purchase_price: nil,
|
||||
current_return_pct: 0.0
|
||||
}
|
||||
end
|
||||
|
||||
defp execute_buy(%{symbol: symbol, account_id: account_id}, trigger) do
|
||||
with {:ok, account} <- fetch_account(account_id),
|
||||
{:ok, quote} <- fetch_quote(symbol),
|
||||
buying_power = get_buying_power(account),
|
||||
{:ok, shares} <- calculate_shares(buying_power, quote.last),
|
||||
{:ok, order} <- place_buy_order(account_id, symbol, shares) do
|
||||
total_cost = shares * quote.last
|
||||
cash_amount = buying_power * 0.05
|
||||
|
||||
trigger_type =
|
||||
cond do
|
||||
trigger == :initial -> "Initial buy"
|
||||
trigger == :reinvest -> "Reinvest buy"
|
||||
trigger == :auto -> "Auto-buy"
|
||||
true -> "Manual buy"
|
||||
end
|
||||
|
||||
Logger.info("#{trigger_type}: Purchasing power: $#{Float.round(buying_power, 2)} | Using 5%: $#{Float.round(cash_amount, 2)}")
|
||||
Logger.info("#{trigger_type} executed: #{shares} shares of #{symbol} at $#{quote.last} (total: $#{Float.round(total_cost, 2)})")
|
||||
{:ok, order, quote.last, shares}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Buy failed for #{symbol}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_account(account_id) do
|
||||
case Core.Client.get_account(account_id) do
|
||||
{:ok, account} ->
|
||||
{:ok, account}
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_quote(symbol) do
|
||||
case Core.Client.get_quotes(symbol) do
|
||||
{:ok, [quote | _]} ->
|
||||
{:ok, quote}
|
||||
|
||||
{:ok, []} ->
|
||||
{:error, :no_quote_data}
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp get_buying_power(%{margin: %{stock_buying_power: buying_power}}) when is_number(buying_power) do
|
||||
buying_power
|
||||
end
|
||||
|
||||
defp get_buying_power(%{total_cash: total_cash}) when is_number(total_cash) do
|
||||
total_cash
|
||||
end
|
||||
|
||||
defp get_buying_power(_account), do: 0.0
|
||||
|
||||
defp calculate_shares(buying_power, price) when is_number(buying_power) and is_number(price) do
|
||||
cash_amount = buying_power * 0.05
|
||||
shares = floor(cash_amount / price)
|
||||
|
||||
if shares > 0 do
|
||||
{:ok, shares}
|
||||
else
|
||||
{:error, :insufficient_funds}
|
||||
end
|
||||
end
|
||||
|
||||
defp place_buy_order(account_id, symbol, shares) do
|
||||
Core.Client.place_order(account_id,
|
||||
class: "equity",
|
||||
symbol: symbol,
|
||||
side: "buy",
|
||||
quantity: shares,
|
||||
type: "market",
|
||||
duration: "day",
|
||||
preview: false
|
||||
)
|
||||
end
|
||||
|
||||
defp place_sell_order(account_id, symbol, shares) do
|
||||
Core.Client.place_order(account_id,
|
||||
class: "equity",
|
||||
symbol: symbol,
|
||||
side: "sell",
|
||||
quantity: shares,
|
||||
type: "market",
|
||||
duration: "day",
|
||||
preview: false
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -11,8 +11,10 @@ defmodule Traderex.Application do
|
||||
TraderexWeb.Telemetry,
|
||||
{DNSCluster, query: Application.get_env(:traderex, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Traderex.PubSub},
|
||||
# Start a worker by calling: Traderex.Worker.start_link(arg)
|
||||
# {Traderex.Worker, arg},
|
||||
# Registry for tracking DCA bots
|
||||
{Registry, keys: :unique, name: Core.Bots.BotRegistry},
|
||||
# DynamicSupervisor for managing DCA bots
|
||||
Core.Bots.BotSupervisor,
|
||||
# Start to serve requests, typically the last entry
|
||||
TraderexWeb.Endpoint
|
||||
]
|
||||
|
||||
@@ -16,6 +16,9 @@ defmodule TraderexWeb.DashboardLive do
|
||||
|> assign(:events_expanded, false)
|
||||
|> assign(:loading, true)
|
||||
|> assign(:error, nil)
|
||||
|> assign(:bots, [])
|
||||
|> assign(:bot_symbol, "")
|
||||
|> assign(:bot_error, nil)
|
||||
|
||||
if connected?(socket) do
|
||||
send(self(), :load_data)
|
||||
@@ -36,6 +39,8 @@ defmodule TraderexWeb.DashboardLive do
|
||||
Core.Client.get_balance_history(account_id, period: period)
|
||||
}
|
||||
|
||||
bots = Core.Bots.BotSupervisor.list_bots()
|
||||
|
||||
socket =
|
||||
case results do
|
||||
{{:ok, account}, {:ok, positions}, {:ok, events}, {:ok, balance_history}} ->
|
||||
@@ -44,6 +49,7 @@ defmodule TraderexWeb.DashboardLive do
|
||||
|> assign(:positions, positions)
|
||||
|> assign(:events, Enum.take(events, 5))
|
||||
|> assign(:balance_history, balance_history)
|
||||
|> assign(:bots, bots)
|
||||
|> assign(:loading, false)
|
||||
|> assign(:error, nil)
|
||||
|
||||
@@ -98,6 +104,51 @@ defmodule TraderexWeb.DashboardLive do
|
||||
{:noreply, assign(socket, :events_expanded, !socket.assigns.events_expanded)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_bot_symbol", %{"symbol" => symbol}, socket) do
|
||||
{:noreply, assign(socket, bot_symbol: String.upcase(symbol))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("create_bot", %{"symbol" => symbol}, socket) do
|
||||
symbol = String.upcase(String.trim(symbol))
|
||||
|
||||
socket =
|
||||
if symbol == "" do
|
||||
assign(socket, :bot_error, "Symbol cannot be empty")
|
||||
else
|
||||
case Core.Bots.BotSupervisor.start_bot(symbol) do
|
||||
{:ok, _pid} ->
|
||||
socket
|
||||
|> assign(:bot_symbol, "")
|
||||
|> assign(:bot_error, nil)
|
||||
|> put_flash(:info, "Bot started for #{symbol}")
|
||||
|
||||
{:error, {:already_started, _pid}} ->
|
||||
assign(socket, :bot_error, "Bot already running for #{symbol}")
|
||||
|
||||
{:error, reason} ->
|
||||
assign(socket, :bot_error, "Failed to start bot: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
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}")}
|
||||
|
||||
{:error, :not_found} ->
|
||||
{:noreply, put_flash(socket, :error, "Bot not found for #{symbol}")}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to stop bot: #{inspect(reason)}")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
@@ -167,6 +218,70 @@ defmodule TraderexWeb.DashboardLive do
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">DCA Bots</h2>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Create New Bot</span>
|
||||
</label>
|
||||
<form phx-submit="create_bot" class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name="symbol"
|
||||
value={@bot_symbol}
|
||||
phx-change="update_bot_symbol"
|
||||
placeholder="Symbol (e.g., SPY)"
|
||||
class="input input-bordered flex-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<.icon name="hero-plus" class="size-5" /> Create Bot
|
||||
</button>
|
||||
</form>
|
||||
<div :if={@bot_error} class="alert alert-error mt-2">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
<span>{@bot_error}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@bots == []} class="text-base-content/70 py-4">
|
||||
No bots running
|
||||
</div>
|
||||
|
||||
<.table :if={@bots != []} id="bots" rows={@bots} row_id={&"bot-#{&1.symbol}"}>
|
||||
<:col :let={bot} label="Symbol">
|
||||
<span class="font-bold">{bot.symbol}</span>
|
||||
</:col>
|
||||
<:col :let={bot} label="Position Size">
|
||||
{format_number(bot.shares)} shares
|
||||
</:col>
|
||||
<:col :let={bot} label="Cost Basis">
|
||||
{format_currency(bot.cost_basis)}
|
||||
</:col>
|
||||
<:col :let={bot} label="Avg Price">
|
||||
{format_currency(bot.avg_price)}
|
||||
</:col>
|
||||
<:col :let={bot} label="Return">
|
||||
<span class={return_color(bot.return_pct)}>
|
||||
{format_percent_simple(bot.return_pct)}
|
||||
</span>
|
||||
</:col>
|
||||
<:col :let={bot} label="Actions">
|
||||
<button
|
||||
phx-click="stop_bot"
|
||||
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>
|
||||
</:col>
|
||||
</.table>
|
||||
</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">
|
||||
@@ -394,4 +509,15 @@ defmodule TraderexWeb.DashboardLive do
|
||||
defp format_period_label("YEAR_5"), do: "5Y"
|
||||
defp format_period_label("ALL"), do: "All"
|
||||
defp format_period_label(period), do: period
|
||||
|
||||
defp format_percent_simple(nil), do: "-"
|
||||
|
||||
defp format_percent_simple(percent) when is_number(percent) do
|
||||
sign = if percent >= 0, do: "+", else: ""
|
||||
"#{sign}#{:erlang.float_to_binary(percent / 1, decimals: 2)}%"
|
||||
end
|
||||
|
||||
defp return_color(nil), do: nil
|
||||
defp return_color(pct) when pct >= 0, do: "text-success font-bold"
|
||||
defp return_color(_pct), do: "text-error font-bold"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user