Added persistence and better order tracking.

This commit is contained in:
2026-01-25 11:01:58 -05:00
parent 147b636d5d
commit 2d48dc3e94
8 changed files with 592 additions and 46 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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</:subtitle>
<:actions>
<div class="flex items-center gap-4">
<.market_status_badge :if={@market_clock} clock={@market_clock} />
<TraderexWeb.Layouts.theme_toggle />
<.link href={~p"/auth/logout"} method="delete" class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-right-on-rectangle" class="size-4" /> Logout
@@ -427,6 +434,28 @@ defmodule TraderexWeb.DashboardLive do
"""
end
attr :clock, :map, required: true
defp market_status_badge(assigns) do
~H"""
<div class={[
"badge badge-lg gap-2",
market_status_class(@clock.state)
]}>
<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>
<span class={[
"relative inline-flex rounded-full h-2 w-2",
market_dot_class(@clock.state)
]}>
</span>
</span>
<span class="font-semibold">{market_status_text(@clock.state)}</span>
</div>
"""
end
attr :title, :string, required: true
attr :value, :any, required: true
attr :icon, :string, required: true
@@ -520,4 +549,20 @@ defmodule TraderexWeb.DashboardLive do
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"
defp market_status_text("open"), do: "Market Open"
defp market_status_text("closed"), do: "Market Closed"
defp market_status_text("premarket"), do: "Pre-Market"
defp market_status_text("postmarket"), do: "Post-Market"
defp market_status_text(_), do: "Unknown"
defp market_status_class("open"), do: "badge-success"
defp market_status_class("premarket"), do: "badge-warning"
defp market_status_class("postmarket"), do: "badge-warning"
defp market_status_class(_), do: "badge-ghost"
defp market_dot_class("open"), do: "bg-success"
defp market_dot_class("premarket"), do: "bg-warning"
defp market_dot_class("postmarket"), do: "bg-warning"
defp market_dot_class(_), do: "bg-base-content"
end