diff --git a/traderex/lib/core/bots/bot_persistence.ex b/traderex/lib/core/bots/bot_persistence.ex new file mode 100644 index 0000000..3daf0e5 --- /dev/null +++ b/traderex/lib/core/bots/bot_persistence.ex @@ -0,0 +1,137 @@ +defmodule Core.Bots.BotPersistence do + @moduledoc """ + Handles persistence of bot state to disk, allowing bots to resume + after application restarts. + """ + require Logger + + @default_storage_path "/tmp" + @filename "traderex_bots_state.json" + + @doc """ + Returns the storage directory path from environment or default. + """ + def storage_path do + System.get_env("STORAGE") || @default_storage_path + end + + @doc """ + Returns the full path to the state file. + """ + def state_file_path do + Path.join(storage_path(), @filename) + end + + @doc """ + Saves the state of all running bots to disk. + """ + def save_state do + bots = Core.Bots.BotSupervisor.list_bots() + + state_data = + Enum.map(bots, fn bot -> + %{ + symbol: bot.symbol, + shares: bot.shares, + cost_basis: bot.cost_basis, + avg_price: bot.avg_price, + return_pct: bot.return_pct, + last_purchase_price: bot.last_purchase_price, + saved_at: DateTime.utc_now() |> DateTime.to_iso8601() + } + end) + + json = Jason.encode!(state_data, pretty: true) + + case File.write(state_file_path(), json) do + :ok -> + Logger.debug("Bot state saved: #{length(state_data)} bots") + :ok + + {:error, reason} -> + Logger.error("Failed to save bot state: #{inspect(reason)}") + {:error, reason} + end + end + + @doc """ + Loads bot state from disk and returns a list of bot configurations. + Returns an empty list if the file doesn't exist or can't be read. + """ + def load_state do + path = state_file_path() + + case File.read(path) do + {:ok, contents} -> + case Jason.decode(contents) do + {:ok, state_data} -> + Logger.info("Loaded bot state: #{length(state_data)} bots") + state_data + + {:error, reason} -> + Logger.error("Failed to parse bot state file: #{inspect(reason)}") + [] + end + + {:error, :enoent} -> + Logger.info("No existing bot state file found") + [] + + {:error, reason} -> + Logger.error("Failed to read bot state file: #{inspect(reason)}") + [] + end + end + + @doc """ + Restores bots from saved state by starting them via the supervisor. + Returns a list of {:ok, pid} or {:error, reason} tuples. + """ + def restore_bots do + state_data = load_state() + + results = + Enum.map(state_data, fn bot_state -> + symbol = bot_state["symbol"] + + case Core.Bots.BotSupervisor.start_bot(symbol, restore_state: bot_state) do + {:ok, pid} -> + Logger.info("Restored bot for #{symbol} with #{bot_state["shares"]} shares at avg $#{bot_state["avg_price"]}") + {:ok, pid} + + {:error, {:already_started, pid}} -> + Logger.info("Bot for #{symbol} already running") + {:ok, pid} + + {:error, reason} = error -> + Logger.error("Failed to restore bot for #{symbol}: #{inspect(reason)}") + error + end + end) + + successful = Enum.count(results, fn {status, _} -> status == :ok end) + Logger.info("Restored #{successful}/#{length(results)} bots") + + results + end + + @doc """ + Deletes the state file. + """ + def clear_state do + path = state_file_path() + + case File.rm(path) do + :ok -> + Logger.info("Bot state file deleted") + :ok + + {:error, :enoent} -> + :ok + + {:error, reason} -> + Logger.error("Failed to delete bot state file: #{inspect(reason)}") + {:error, reason} + end + end +end diff --git a/traderex/lib/core/bots/bot_supervisor.ex b/traderex/lib/core/bots/bot_supervisor.ex index 532be0f..5ae068f 100644 --- a/traderex/lib/core/bots/bot_supervisor.ex +++ b/traderex/lib/core/bots/bot_supervisor.ex @@ -16,9 +16,16 @@ defmodule Core.Bots.BotSupervisor do @doc """ Starts a new DCA bot for the given symbol. Returns {:ok, pid} on success or {:error, reason} on failure. + + ## Options + + * `symbol` - The stock symbol (required) + * `opts` - Keyword list of options: + * `:restore_state` - Optional saved state to restore position from """ - def start_bot(symbol) when is_binary(symbol) do - child_spec = {Core.Bots.DCABot, symbol: symbol} + def start_bot(symbol, opts \\ []) when is_binary(symbol) do + restore_state = Keyword.get(opts, :restore_state) + child_spec = {Core.Bots.DCABot, symbol: symbol, restore_state: restore_state} DynamicSupervisor.start_child(__MODULE__, child_spec) end @@ -28,7 +35,12 @@ defmodule Core.Bots.BotSupervisor do def stop_bot(symbol) when is_binary(symbol) do case Core.Bots.BotRegistry.lookup(symbol) do {:ok, pid} -> - DynamicSupervisor.terminate_child(__MODULE__, pid) + 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 :error -> {:error, :not_found} @@ -52,4 +64,18 @@ defmodule Core.Bots.BotSupervisor do end) |> Enum.reject(&is_nil/1) end + + @doc """ + Stops all running bots and saves their state. + """ + def stop_all_bots do + # Save state first + Core.Bots.BotPersistence.save_state() + + # Then stop all bots + DynamicSupervisor.which_children(__MODULE__) + |> Enum.each(fn {_, pid, _, _} -> + DynamicSupervisor.terminate_child(__MODULE__, pid) + end) + end end diff --git a/traderex/lib/core/bots/dca_bot.ex b/traderex/lib/core/bots/dca_bot.ex index 8457fdb..29f2f56 100644 --- a/traderex/lib/core/bots/dca_bot.ex +++ b/traderex/lib/core/bots/dca_bot.ex @@ -3,21 +3,35 @@ defmodule Core.Bots.DCABot do 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 + - **Initial buy**: On startup, purchases using 5% of account balance (when market opens) - **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 + **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. + + **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 + differ slightly from the quote price at order placement time. + 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. + Bot state is automatically persisted after every position change, allowing bots to resume + after application restarts. + ## Usage # Start a bot via the supervisor (recommended) - # This will immediately make an initial 5% purchase + # This will make an initial 5% purchase when market opens {:ok, _pid} = Core.Bots.BotSupervisor.start_bot("SPY") + # If started during market hours, bot buys immediately + # If started outside market hours, bot waits until market opens + # Or start directly (for testing) {:ok, _pid} = Core.Bots.DCABot.start_link(symbol: "SPY") @@ -53,10 +67,16 @@ defmodule Core.Bots.DCABot do @doc """ Starts the DCA bot for the given symbol. The bot will check and log the price every minute. + + ## Options + + * `:symbol` - The stock symbol to trade (required) + * `:restore_state` - Optional saved state to restore position from """ 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)) + init_opts = %{symbol: symbol, restore_state: Keyword.get(opts, :restore_state)} + GenServer.start_link(__MODULE__, init_opts, name: Core.Bots.BotRegistry.via(symbol)) end @doc """ @@ -102,45 +122,85 @@ defmodule Core.Bots.DCABot do # GenServer Callbacks @impl true - def init(symbol) do + def init(%{symbol: symbol, restore_state: restore_state}) do account_id = Core.Client.account_id() - Logger.info("DCABot started for symbol: #{symbol}") - # Schedule initial purchase - send(self(), :initial_buy) + state = + if restore_state do + # Restoring from saved state + shares = restore_state["shares"] || 0 + cost_basis = restore_state["cost_basis"] || 0.0 + avg_price = restore_state["avg_price"] || 0.0 + return_pct = restore_state["return_pct"] || 0.0 + 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)}%") + + %{ + symbol: symbol, + account_id: account_id, + last_price: nil, + last_purchase_price: last_purchase_price, + bot_shares: shares, + bot_cost_basis: cost_basis, + current_return_pct: return_pct + } + else + # Starting fresh - schedule initial purchase + Logger.info("DCABot CREATED for #{symbol} - scheduling initial 5% purchase") + send(self(), :initial_buy) + + %{ + 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 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 - }} + {:ok, state} end @impl true def handle_info(:initial_buy, state) do - Logger.info("Performing initial purchase for #{state.symbol}") + case market_open?() do + true -> + 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) + 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 + {:error, reason} -> + Logger.error("Initial buy failed for #{state.symbol}: #{inspect(reason)}") + state + end - {:noreply, state} + {:noreply, state} + + false -> + Logger.info("Market closed - deferring initial purchase for #{state.symbol}") + # Retry initial buy on next tick + Process.send_after(self(), :initial_buy, @tick_interval) + {:noreply, state} + end end @impl true def handle_info(:tick, state) do - state = check_price(state) + state = + if market_open?() do + check_price(state) + else + Logger.debug("Market closed - skipping price check for #{state.symbol}") + state + end + schedule_tick() {:noreply, state} end @@ -158,13 +218,14 @@ 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} = 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} = 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 + return_pct: current_return_pct, + last_purchase_price: last_purchase_price } {:reply, position_info, state} @@ -279,11 +340,13 @@ defmodule Core.Bots.DCABot do 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} - + with {:ok, order} <- place_sell_order(account_id, symbol, quantity), + {:ok, filled_order} <- verify_order_filled(account_id, order.id, symbol) do + actual_price = filled_order.avg_fill_price + actual_shares = filled_order.exec_quantity + Logger.info("Sell order FILLED: #{actual_shares} shares of #{symbol} at $#{actual_price}") + {:ok, filled_order} + else {:error, reason} = error -> Logger.error("Sell failed for #{symbol}: #{inspect(reason)}") error @@ -296,20 +359,30 @@ defmodule Core.Bots.DCABot do Logger.info("Bot position updated: #{new_shares} total shares, cost basis: $#{Float.round(new_cost_basis, 2)}") - %{state | + new_state = %{state | bot_shares: new_shares, bot_cost_basis: new_cost_basis, last_purchase_price: price } + + # Save state after position update + save_bot_state() + + new_state end defp reset_bot_position(state) do - %{state | + new_state = %{state | bot_shares: 0, bot_cost_basis: 0.0, last_purchase_price: nil, current_return_pct: 0.0 } + + # Save state after reset + save_bot_state() + + new_state end defp execute_buy(%{symbol: symbol, account_id: account_id}, trigger) do @@ -317,8 +390,11 @@ defmodule Core.Bots.DCABot do {: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 + {:ok, order} <- place_buy_order(account_id, symbol, shares), + {:ok, filled_order} <- verify_order_filled(account_id, order.id, symbol) do + actual_price = filled_order.avg_fill_price + actual_shares = filled_order.exec_quantity + total_cost = actual_shares * actual_price cash_amount = buying_power * 0.05 trigger_type = @@ -330,8 +406,8 @@ defmodule Core.Bots.DCABot do 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} + 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} else {:error, reason} = error -> Logger.error("Buy failed for #{symbol}: #{inspect(reason)}") @@ -406,4 +482,50 @@ defmodule Core.Bots.DCABot do preview: false ) 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} -> + cond do + Core.Order.filled?(order) -> + Logger.info("Order #{order_id} for #{symbol} filled at $#{order.avg_fill_price}") + {:ok, order} + + Core.Order.rejected?(order) -> + Logger.error("Order #{order_id} for #{symbol} was rejected/canceled: #{order.status}") + {:error, {:order_rejected, order.status}} + + Core.Order.pending?(order) && retries > 0 -> + Logger.debug("Order #{order_id} for #{symbol} still pending (#{order.status}), retrying...") + # Wait 500ms before checking again + Process.sleep(500) + verify_order_filled(account_id, order_id, symbol, retries - 1) + + true -> + Logger.error("Order #{order_id} for #{symbol} timed out or unknown status: #{order.status}") + {:error, {:order_timeout, order.status}} + end + + {:error, reason} = error -> + Logger.error("Failed to check order #{order_id}: #{inspect(reason)}") + error + end + end + + defp save_bot_state do + # Async state save to avoid blocking the bot + Task.start(fn -> Core.Bots.BotPersistence.save_state() end) + end + + defp market_open? do + case Core.Client.get_market_clock() do + {:ok, clock} -> + Core.Clock.open?(clock) + + {:error, reason} -> + Logger.error("Failed to fetch market clock: #{inspect(reason)}") + # If we can't determine market status, assume closed for safety + false + end + end end diff --git a/traderex/lib/core/client.ex b/traderex/lib/core/client.ex index 8c8cd73..f1198a8 100644 --- a/traderex/lib/core/client.ex +++ b/traderex/lib/core/client.ex @@ -117,6 +117,28 @@ defmodule Core.Client do end end + @doc """ + Fetches the market clock status. + + Returns information about whether the market is open, closed, premarket, or postmarket, + along with timing information for the next state change. + + ## Example + + {:ok, clock} = Core.Client.get_market_clock() + clock.state + #=> "open" + clock.description + #=> "Market is open from 09:30 to 16:00" + """ + @spec get_market_clock() :: {:ok, Core.Clock.t()} | {:error, term()} + def get_market_clock do + case get("/markets/clock") do + {:ok, body} -> {:ok, Core.Clock.from_api(body)} + {:error, reason} -> {:error, reason} + end + end + @doc """ Fetches stock quotes for one or more symbols. @@ -143,6 +165,25 @@ defmodule Core.Client do end end + @doc """ + Fetches the status of a specific order. + + ## Example + + {:ok, order} = Core.Client.get_order("6YA15850", 123456) + order.status + #=> "filled" + order.avg_fill_price + #=> 128.25 + """ + @spec get_order(String.t(), integer()) :: {:ok, Core.Order.t()} | {:error, term()} + def get_order(account_id, order_id) when is_integer(order_id) do + case get("/accounts/#{account_id}/orders/#{order_id}") do + {:ok, body} -> {:ok, Core.Order.from_api(body)} + {:error, reason} -> {:error, reason} + end + end + @doc """ Places an order for an account. diff --git a/traderex/lib/core/clock.ex b/traderex/lib/core/clock.ex new file mode 100644 index 0000000..2d5ab0a --- /dev/null +++ b/traderex/lib/core/clock.ex @@ -0,0 +1,89 @@ +defmodule Core.Clock do + @moduledoc """ + Represents the market clock information from Tradier API. + """ + + @type t :: %__MODULE__{ + date: Date.t(), + description: String.t(), + state: String.t(), + timestamp: integer(), + next_change: String.t(), + next_state: String.t() + } + + defstruct [ + :date, + :description, + :state, + :timestamp, + :next_change, + :next_state + ] + + @doc """ + Creates a Clock struct from a Tradier API clock response. + + ## Example + + iex> data = %{"clock" => %{"date" => "2026-01-21", "state" => "open", ...}} + iex> Core.Clock.from_api(data) + %Core.Clock{date: ~D[2026-01-21], state: "open", ...} + """ + @spec from_api(map()) :: t() + def from_api(%{"clock" => clock}) do + from_api(clock) + end + + def from_api(data) when is_map(data) do + %__MODULE__{ + date: parse_date(data["date"]), + description: data["description"], + state: data["state"], + timestamp: data["timestamp"], + next_change: data["next_change"], + next_state: data["next_state"] + } + end + + defp parse_date(nil), do: nil + + defp parse_date(date_string) when is_binary(date_string) do + case Date.from_iso8601(date_string) do + {:ok, date} -> date + {:error, _reason} -> nil + end + end + + @doc """ + Returns true if the market is currently open. + """ + @spec open?(t()) :: boolean() + def open?(%__MODULE__{state: state}) do + state == "open" + end + + @doc """ + Returns true if the market is currently closed. + """ + @spec closed?(t()) :: boolean() + def closed?(%__MODULE__{state: state}) do + state == "closed" + end + + @doc """ + Returns true if the market is in premarket. + """ + @spec premarket?(t()) :: boolean() + def premarket?(%__MODULE__{state: state}) do + state == "premarket" + end + + @doc """ + Returns true if the market is in postmarket. + """ + @spec postmarket?(t()) :: boolean() + def postmarket?(%__MODULE__{state: state}) do + state == "postmarket" + end +end diff --git a/traderex/lib/core/order.ex b/traderex/lib/core/order.ex index a7239a2..81e04ac 100644 --- a/traderex/lib/core/order.ex +++ b/traderex/lib/core/order.ex @@ -5,13 +5,39 @@ defmodule Core.Order do @type t :: %__MODULE__{ id: integer() | nil, + type: String.t() | nil, + symbol: String.t() | nil, + side: String.t() | nil, + quantity: float() | nil, status: String.t() | nil, + duration: String.t() | nil, + avg_fill_price: float() | nil, + exec_quantity: float() | nil, + last_fill_price: float() | nil, + last_fill_quantity: float() | nil, + remaining_quantity: float() | nil, + create_date: DateTime.t() | nil, + transaction_date: DateTime.t() | nil, + class: String.t() | nil, partner_id: String.t() | nil } defstruct [ :id, + :type, + :symbol, + :side, + :quantity, :status, + :duration, + :avg_fill_price, + :exec_quantity, + :last_fill_price, + :last_fill_quantity, + :remaining_quantity, + :create_date, + :transaction_date, + :class, :partner_id ] @@ -20,9 +46,9 @@ defmodule Core.Order do ## Example - iex> data = %{"order" => %{"id" => 123456, "status" => "ok", "partner_id" => "partner_12345"}} + iex> data = %{"order" => %{"id" => 123456, "status" => "filled", ...}} iex> Core.Order.from_api(data) - %Core.Order{id: 123456, status: "ok", partner_id: "partner_12345"} + %Core.Order{id: 123456, status: "filled", ...} """ @spec from_api(map()) :: t() def from_api(%{"order" => order}) when is_map(order) do @@ -32,8 +58,54 @@ defmodule Core.Order do def from_api(data) when is_map(data) do %__MODULE__{ id: data["id"], + type: data["type"], + symbol: data["symbol"], + side: data["side"], + quantity: data["quantity"], status: data["status"], + duration: data["duration"], + avg_fill_price: data["avg_fill_price"], + exec_quantity: data["exec_quantity"], + last_fill_price: data["last_fill_price"], + last_fill_quantity: data["last_fill_quantity"], + remaining_quantity: data["remaining_quantity"], + create_date: parse_datetime(data["create_date"]), + transaction_date: parse_datetime(data["transaction_date"]), + class: data["class"], partner_id: data["partner_id"] } end + + defp parse_datetime(nil), do: nil + + defp parse_datetime(datetime_string) when is_binary(datetime_string) do + case DateTime.from_iso8601(datetime_string) do + {:ok, datetime, _offset} -> datetime + {:error, _reason} -> nil + end + end + + @doc """ + Returns true if the order has been filled. + """ + @spec filled?(t()) :: boolean() + def filled?(%__MODULE__{status: status}) do + status == "filled" + end + + @doc """ + Returns true if the order is pending. + """ + @spec pending?(t()) :: boolean() + def pending?(%__MODULE__{status: status}) do + status in ["pending", "open", "partially_filled"] + end + + @doc """ + Returns true if the order was rejected or canceled. + """ + @spec rejected?(t()) :: boolean() + def rejected?(%__MODULE__{status: status}) do + status in ["rejected", "canceled", "cancelled"] + end end diff --git a/traderex/lib/traderex/application.ex b/traderex/lib/traderex/application.ex index 137eb39..222e49e 100644 --- a/traderex/lib/traderex/application.ex +++ b/traderex/lib/traderex/application.ex @@ -22,7 +22,21 @@ defmodule Traderex.Application do # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Traderex.Supervisor] - Supervisor.start_link(children, opts) + + case Supervisor.start_link(children, opts) do + {:ok, _pid} = result -> + # Restore bots from saved state after supervisor is up + Task.start(fn -> + # Give the supervisor a moment to fully initialize + Process.sleep(100) + Core.Bots.BotPersistence.restore_bots() + end) + + result + + error -> + error + end end # Tell Phoenix to update the endpoint configuration diff --git a/traderex/lib/traderex_web/live/dashboard_live.ex b/traderex/lib/traderex_web/live/dashboard_live.ex index e985774..dd1098f 100644 --- a/traderex/lib/traderex_web/live/dashboard_live.ex +++ b/traderex/lib/traderex_web/live/dashboard_live.ex @@ -19,6 +19,7 @@ defmodule TraderexWeb.DashboardLive do |> assign(:bots, []) |> assign(:bot_symbol, "") |> assign(:bot_error, nil) + |> assign(:market_clock, nil) if connected?(socket) do send(self(), :load_data) @@ -40,6 +41,10 @@ defmodule TraderexWeb.DashboardLive do } bots = Core.Bots.BotSupervisor.list_bots() + market_clock = case Core.Client.get_market_clock() do + {:ok, clock} -> clock + {:error, _} -> nil + end socket = case results do @@ -50,6 +55,7 @@ defmodule TraderexWeb.DashboardLive do |> assign(:events, Enum.take(events, 5)) |> assign(:balance_history, balance_history) |> assign(:bots, bots) + |> assign(:market_clock, market_clock) |> assign(:loading, false) |> assign(:error, nil) @@ -158,6 +164,7 @@ defmodule TraderexWeb.DashboardLive do <:subtitle>Account Overview <:actions>