From 1faca2cdda6c35fcf0148830364c14d0a18fa4ba Mon Sep 17 00:00:00 2001 From: Jarno Date: Tue, 11 Aug 2026 21:20:07 +0300 Subject: [PATCH] Added a simple ui --- README.md | 9 + docs/README.md | 18 +- docs/read-only-ui.md | 39 ++ src/trading_bot/ui/__init__.py | 1 + src/trading_bot/ui/dashboard.py | 895 ++++++++++++++++++++++++++++++++ tests/test_dashboard.py | 141 +++++ 6 files changed, 1095 insertions(+), 8 deletions(-) create mode 100644 docs/read-only-ui.md create mode 100644 src/trading_bot/ui/__init__.py create mode 100644 src/trading_bot/ui/dashboard.py create mode 100644 tests/test_dashboard.py diff --git a/README.md b/README.md index 4d95b16..f466e0f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,13 @@ The main Python entry points are: - `src/trading_bot/data/train_pipeline.py` for dataset generation and model training. - `src/trading_bot/models/trade.py` for Alpaca account inspection and SPY rebalancing. +- `src/trading_bot/ui/dashboard.py` for the read-only performance dashboard. + +Serve the read-only dashboard: + +```sh +mise exec -- uv run python -m trading_bot.ui.dashboard --paper +``` ## Documentation @@ -76,6 +83,8 @@ See [docs/README.md](docs/README.md) for the initial architecture notes and deci The current data collection note is [docs/data-fetcher.md](docs/data-fetcher.md), and the training dataset contract is [docs/training-dataset.md](docs/training-dataset.md). +The read-only dashboard design is documented in [docs/read-only-ui.md](docs/read-only-ui.md). + The first dataset generation notebook is [notebooks/spy_direction_dataset.ipynb](notebooks/spy_direction_dataset.ipynb). Manual test instructions for the Alpaca fetcher are in [docs/manual-test/README.md](docs/manual-test/README.md). diff --git a/docs/README.md b/docs/README.md index 05250ea..2d98065 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,16 +67,18 @@ Open decisions: ### Read-Only Web UI -Optional future module for checking status without controlling trading behavior. +Optional module for checking status without controlling trading behavior. -Possible scope: +The initial implementation is documented in [read-only-ui.md](read-only-ui.md). +It serves one backend-rendered page, reads Alpaca account state with `alpaca-py`, +and caches snapshots in SQLite so normal page loads do not repeatedly call Alpaca. -- current holdings; -- latest signals; -- recent orders; -- account summary; -- model version; -- bot health and logs. +Current scope: + +- three-month account performance summary; +- account value chart with a `SPY` S&P 500 proxy comparison; +- cash and equity portfolio table; +- completed trades table. ## Near-Term Priorities diff --git a/docs/read-only-ui.md b/docs/read-only-ui.md new file mode 100644 index 0000000..d57a327 --- /dev/null +++ b/docs/read-only-ui.md @@ -0,0 +1,39 @@ +# Read-Only Performance UI + +## Goal + +Provide a single read-only page for checking Alpaca paper account performance and recent trading activity without exposing any trading controls. + +## Requested Feature List + +- Show the last three months of account performance in the header as text, or a shorter range when less data is available. +- Show account value over time as the main body chart. +- Include an S&P 500 comparison line in the chart. The initial implementation uses `SPY` daily candles as the S&P 500 proxy because Alpaca stock market data exposes it through the same API path. +- Show a portfolio table below the chart with cash plus equity positions. +- Show a completed trades table at the end of the page. +- Use Python and `alpaca-py` in the backend. +- Serve the page directly from the backend as a single page with no user options. +- Cache fetched data in SQLite and refresh periodically, initially every 12 hours. + +## Design Notes + +The UI is intentionally separate from the trading and training modules. It reads account state from Alpaca, stores normalized snapshots in SQLite, and renders HTML from the backend. It does not place, replace, or cancel orders. + +The first cache path is `data/ui/performance.sqlite`. On each page request, the server refreshes data only when the cache is older than the configured refresh interval. If Alpaca refresh fails but cached data exists, the page can still render stale data with a warning. + +## Backend Data + +The dashboard stores: + +- account equity history for up to three months; +- normalized S&P 500 proxy values from `SPY`; +- the latest cash and equity holdings snapshot; +- recently closed orders that have a filled quantity. + +## Run Command + +```sh +mise exec -- uv run python -m trading_bot.ui.dashboard --paper +``` + +The server defaults to `127.0.0.1:8000` and reads `ALPACA_API_KEY` and `ALPACA_SECRET_KEY` from the environment or local `.env`. diff --git a/src/trading_bot/ui/__init__.py b/src/trading_bot/ui/__init__.py new file mode 100644 index 0000000..418e52f --- /dev/null +++ b/src/trading_bot/ui/__init__.py @@ -0,0 +1 @@ +"""Read-only web UI for inspecting trading bot status.""" diff --git a/src/trading_bot/ui/dashboard.py b/src/trading_bot/ui/dashboard.py new file mode 100644 index 0000000..bfeddba --- /dev/null +++ b/src/trading_bot/ui/dashboard.py @@ -0,0 +1,895 @@ +"""Read-only Alpaca performance dashboard served by a small Python backend.""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +from html import escape +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from alpaca.trading.client import TradingClient +from alpaca.trading.enums import QueryOrderStatus +from alpaca.trading.models import Order, Position, TradeAccount +from alpaca.trading.requests import GetOrdersRequest, GetPortfolioHistoryRequest +from dotenv import load_dotenv + +from trading_bot.data.alpaca_daily_lib import ( + DailyCandle, + EASTERN_TZ, + default_end_date, + fetch_daily_candles, +) + +load_dotenv() + +DEFAULT_DB_PATH = Path("data/ui/performance.sqlite") +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 +DEFAULT_REFRESH_HOURS = 12 +DEFAULT_PERIOD_DAYS = 92 +BENCHMARK_SYMBOL = "SPY" + + +@dataclass(frozen=True) +class PerformancePoint: + trading_day: date + account_value: float + benchmark_value: float | None + + +@dataclass(frozen=True) +class PortfolioRow: + symbol: str + asset_type: str + quantity: float | None + market_value: float + average_entry_price: float | None + current_price: float | None + unrealized_pl: float | None + + +@dataclass(frozen=True) +class TradeRow: + filled_at: datetime | None + symbol: str + side: str + quantity: float + filled_average_price: float | None + notional: float | None + status: str + + +@dataclass(frozen=True) +class DashboardSnapshot: + refreshed_at: datetime + performance: list[PerformancePoint] + portfolio: list[PortfolioRow] + trades: list[TradeRow] + warning: str | None = None + + +def _to_float(value: Any, default: float = 0.0) -> float: + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _to_optional_float(value: Any) -> float | None: + if value is None or value == "": + return None + return _to_float(value) + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _from_unix_timestamp(value: int | float) -> date: + return datetime.fromtimestamp(value, tz=UTC).astimezone(EASTERN_TZ).date() + + +def _benchmark_end_date(account_history_last_day: date) -> date: + return min(account_history_last_day, default_end_date()) + + +def init_database(db_path: Path) -> None: + db_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(db_path) as connection: + connection.executescript( + """ + create table if not exists metadata ( + key text primary key, + value text not null + ); + create table if not exists performance_points ( + trading_day text primary key, + account_value real not null, + benchmark_value real + ); + create table if not exists portfolio_rows ( + symbol text primary key, + asset_type text not null, + quantity real, + market_value real not null, + average_entry_price real, + current_price real, + unrealized_pl real + ); + create table if not exists trade_rows ( + id integer primary key autoincrement, + filled_at text, + symbol text not null, + side text not null, + quantity real not null, + filled_average_price real, + notional real, + status text not null + ); + """ + ) + + +def get_last_refresh(db_path: Path) -> datetime | None: + if not db_path.exists(): + return None + with sqlite3.connect(db_path) as connection: + row = connection.execute( + "select value from metadata where key = 'refreshed_at'" + ).fetchone() + if row is None: + return None + return datetime.fromisoformat(row[0]) + + +def is_cache_stale(db_path: Path, refresh_interval: timedelta, now: datetime | None = None) -> bool: + refreshed_at = get_last_refresh(db_path) + if refreshed_at is None: + return True + return (now or _utc_now()) - refreshed_at >= refresh_interval + + +def create_trading_client( + api_key: str | None, + secret_key: str | None, + paper: bool, +) -> TradingClient: + if api_key is None or secret_key is None: + raise ValueError("ALPACA_API_KEY and ALPACA_SECRET_KEY are required.") + return TradingClient(api_key, secret_key, paper=paper) + + +def fetch_performance_points( + client: TradingClient, + api_key: str | None, + secret_key: str | None, +) -> list[PerformancePoint]: + history = client.get_portfolio_history( + GetPortfolioHistoryRequest(period="3M", timeframe="1D") + ) + account_values_by_day = { + _from_unix_timestamp(timestamp): float(equity) + for timestamp, equity in zip(history.timestamp, history.equity, strict=False) + if equity is not None + } + if not account_values_by_day: + return [] + + first_day = min(account_values_by_day) + last_day = max(account_values_by_day) + benchmark = fetch_daily_candles( + BENCHMARK_SYMBOL, + end_date=_benchmark_end_date(last_day), + duration="3 M", + api_key=api_key, + secret_key=secret_key, + ) + benchmark_values_by_day = _normalize_benchmark( + benchmark, + start_day=first_day, + initial_account_value=account_values_by_day[first_day], + ) + + return [ + PerformancePoint( + trading_day=trading_day, + account_value=account_value, + benchmark_value=benchmark_values_by_day.get(trading_day), + ) + for trading_day, account_value in sorted(account_values_by_day.items()) + ] + + +def _normalize_benchmark( + candles: list[DailyCandle], + start_day: date, + initial_account_value: float, +) -> dict[date, float]: + closes = { + candle.trading_day: candle.close + for candle in candles + if candle.trading_day >= start_day and candle.close > 0 + } + if not closes: + return {} + base_day = min(closes) + base_close = closes[base_day] + return { + trading_day: initial_account_value * (close / base_close) + for trading_day, close in closes.items() + } + + +def fetch_portfolio_rows(client: TradingClient) -> list[PortfolioRow]: + account = client.get_account() + if not isinstance(account, TradeAccount): + raise TypeError(f"Expected TradeAccount, got {type(account)}") + + rows = [ + PortfolioRow( + symbol="CASH", + asset_type="cash", + quantity=None, + market_value=_to_float(account.cash), + average_entry_price=None, + current_price=None, + unrealized_pl=None, + ) + ] + + positions = client.get_all_positions() + if not isinstance(positions, list): + raise TypeError(f"Expected list of positions, got {type(positions)}") + for position in positions: + if not isinstance(position, Position): + raise TypeError(f"Expected Position, got {type(position)}") + rows.append( + PortfolioRow( + symbol=str(position.symbol).upper(), + asset_type=str(position.asset_class or "equity"), + quantity=_to_optional_float(position.qty), + market_value=_to_float(position.market_value), + average_entry_price=_to_optional_float(position.avg_entry_price), + current_price=_to_optional_float(position.current_price), + unrealized_pl=_to_optional_float(position.unrealized_pl), + ) + ) + return rows + + +def fetch_trade_rows(client: TradingClient) -> list[TradeRow]: + orders = client.get_orders( + GetOrdersRequest(status=QueryOrderStatus.CLOSED, limit=100) + ) + if not isinstance(orders, list): + raise TypeError(f"Expected list of orders, got {type(orders)}") + + trades: list[TradeRow] = [] + for order in orders: + if not isinstance(order, Order): + raise TypeError(f"Expected Order, got {type(order)}") + quantity = _to_float(order.filled_qty) + if quantity <= 0: + continue + trades.append( + TradeRow( + filled_at=order.filled_at, + symbol=str(order.symbol).upper(), + side=str(order.side).lower(), + quantity=quantity, + filled_average_price=_to_optional_float(order.filled_avg_price), + notional=_to_optional_float(order.notional), + status=str(order.status).lower(), + ) + ) + return trades + + +def fetch_alpaca_snapshot( + client: TradingClient, + api_key: str | None, + secret_key: str | None, +) -> DashboardSnapshot: + return DashboardSnapshot( + refreshed_at=_utc_now(), + performance=fetch_performance_points(client, api_key, secret_key), + portfolio=fetch_portfolio_rows(client), + trades=fetch_trade_rows(client), + ) + + +def write_snapshot(db_path: Path, snapshot: DashboardSnapshot) -> None: + init_database(db_path) + with sqlite3.connect(db_path) as connection: + connection.execute("delete from performance_points") + connection.execute("delete from portfolio_rows") + connection.execute("delete from trade_rows") + connection.executemany( + """ + insert into performance_points ( + trading_day, account_value, benchmark_value + ) values (?, ?, ?) + """, + [ + ( + point.trading_day.isoformat(), + point.account_value, + point.benchmark_value, + ) + for point in snapshot.performance + ], + ) + connection.executemany( + """ + insert into portfolio_rows ( + symbol, asset_type, quantity, market_value, + average_entry_price, current_price, unrealized_pl + ) values (?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + row.symbol, + row.asset_type, + row.quantity, + row.market_value, + row.average_entry_price, + row.current_price, + row.unrealized_pl, + ) + for row in snapshot.portfolio + ], + ) + connection.executemany( + """ + insert into trade_rows ( + filled_at, symbol, side, quantity, + filled_average_price, notional, status + ) values (?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + row.filled_at.isoformat() if row.filled_at else None, + row.symbol, + row.side, + row.quantity, + row.filled_average_price, + row.notional, + row.status, + ) + for row in snapshot.trades + ], + ) + connection.execute( + """ + insert into metadata (key, value) + values ('refreshed_at', ?) + on conflict(key) do update set value = excluded.value + """, + (snapshot.refreshed_at.isoformat(),), + ) + + +def read_snapshot(db_path: Path, warning: str | None = None) -> DashboardSnapshot: + init_database(db_path) + refreshed_at = get_last_refresh(db_path) or _utc_now() + with sqlite3.connect(db_path) as connection: + performance = [ + PerformancePoint( + trading_day=date.fromisoformat(row[0]), + account_value=row[1], + benchmark_value=row[2], + ) + for row in connection.execute( + """ + select trading_day, account_value, benchmark_value + from performance_points + order by trading_day + """ + ) + ] + portfolio = [ + PortfolioRow( + symbol=row[0], + asset_type=row[1], + quantity=row[2], + market_value=row[3], + average_entry_price=row[4], + current_price=row[5], + unrealized_pl=row[6], + ) + for row in connection.execute( + """ + select symbol, asset_type, quantity, market_value, + average_entry_price, current_price, unrealized_pl + from portfolio_rows + order by case when symbol = 'CASH' then 0 else 1 end, symbol + """ + ) + ] + trades = [ + TradeRow( + filled_at=datetime.fromisoformat(row[0]) if row[0] else None, + symbol=row[1], + side=row[2], + quantity=row[3], + filled_average_price=row[4], + notional=row[5], + status=row[6], + ) + for row in connection.execute( + """ + select filled_at, symbol, side, quantity, + filled_average_price, notional, status + from trade_rows + order by filled_at desc + """ + ) + ] + return DashboardSnapshot( + refreshed_at=refreshed_at, + performance=performance, + portfolio=portfolio, + trades=trades, + warning=warning, + ) + + +def load_or_refresh_snapshot( + db_path: Path, + api_key: str | None, + secret_key: str | None, + paper: bool, + refresh_interval: timedelta, +) -> DashboardSnapshot: + if not is_cache_stale(db_path, refresh_interval): + return read_snapshot(db_path) + + try: + client = create_trading_client(api_key=api_key, secret_key=secret_key, paper=paper) + snapshot = fetch_alpaca_snapshot(client, api_key, secret_key) + write_snapshot(db_path, snapshot) + return snapshot + except Exception as exc: + if db_path.exists() and get_last_refresh(db_path) is not None: + return read_snapshot(db_path, warning=f"Refresh failed; showing cached data. {exc}") + raise + + +def _format_currency(value: float | None) -> str: + if value is None: + return "" + sign = "-" if value < 0 else "" + return f"{sign}${abs(value):,.2f}" + + +def _format_number(value: float | None) -> str: + if value is None: + return "" + return f"{value:,.4f}" + + +def _format_percent(value: float | None) -> str: + if value is None: + return "" + return f"{value:.2%}" + + +def _clean_enum_text(value: Any) -> str: + text = str(value or "").strip() + if "." in text: + text = text.rsplit(".", maxsplit=1)[-1] + return text.lower() + + +def _pill_class(value: str) -> str: + normalized = _clean_enum_text(value) + if normalized in {"buy", "filled"}: + return "pill pill-positive" + if normalized in {"sell", "canceled", "cancelled", "failed", "expired", "rejected"}: + return "pill pill-negative" + return "pill" + + +def _pill_html(value: str) -> str: + label = _clean_enum_text(value).upper() + return f"{escape(label)}" + + +def _performance_text(points: list[PerformancePoint]) -> str: + if len(points) < 2: + return "Performance unavailable" + first = points[0] + last = points[-1] + change = last.account_value - first.account_value + percent = change / first.account_value if first.account_value else 0.0 + days = (last.trading_day - first.trading_day).days + sign = "+" if change >= 0 else "" + return ( + f"{sign}{_format_currency(change)} ({sign}{percent:.2%}) " + f"over {days} days" + ) + + +def render_dashboard(snapshot: DashboardSnapshot) -> str: + chart_data = [ + { + "date": point.trading_day.isoformat(), + "account": point.account_value, + "benchmark": point.benchmark_value, + } + for point in snapshot.performance + ] + chart_json = json.dumps(chart_data) + warning = ( + f"

{escape(snapshot.warning)}

" + if snapshot.warning + else "" + ) + latest_account_value = ( + snapshot.performance[-1].account_value + if snapshot.performance + else sum(row.market_value for row in snapshot.portfolio) + ) + portfolio_rows = "\n".join( + "" + f"{escape(row.symbol)}" + f"{_format_number(row.quantity)}" + f"{_format_currency(row.market_value)}" + f"{_format_percent(row.market_value / latest_account_value if latest_account_value else None)}" + f"{_format_currency(row.average_entry_price)}" + f"{_format_currency(row.current_price)}" + f"{_format_currency(row.unrealized_pl)}" + "" + for row in snapshot.portfolio + ) + trade_rows = "\n".join( + "" + f"{escape(row.filled_at.isoformat(sep=' ', timespec='minutes') if row.filled_at else '')}" + f"{escape(row.symbol)}" + f"{_pill_html(row.side)}" + f"{_format_number(row.quantity)}" + f"{_format_currency(row.filled_average_price)}" + f"{_format_currency(row.notional)}" + f"{_pill_html(row.status)}" + "" + for row in snapshot.trades + ) + portfolio_rows = portfolio_rows or "No portfolio rows cached." + trade_rows = trade_rows or "No completed trades cached." + + return f""" + + + + + Trading Bot Performance + + + +
+
+

Trading Bot Performance

+
{escape(_performance_text(snapshot.performance))}
+

Last refreshed {escape(snapshot.refreshed_at.astimezone().isoformat(sep=' ', timespec='minutes'))}

+ {warning} +
+ +
+

Account Value vs S&P 500 Proxy

+ +

Account valueSPY normalized to account start value

+
+ +
+

Portfolio

+
+ + + + + + + + {portfolio_rows} +
SymbolQuantityMarket ValueAccount %Avg EntryCurrent PriceUnrealized P/L
+
+
+ +
+

Completed Trades

+
+ + + + + + + + {trade_rows} +
Filled AtSymbolSideQuantityAvg FillNotionalStatus
+
+
+
+ + + + +""" + + +class DashboardRequestHandler(BaseHTTPRequestHandler): + db_path: Path + api_key: str | None + secret_key: str | None + paper: bool + refresh_interval: timedelta + + def do_GET(self) -> None: + if self.path not in {"/", "/index.html"}: + self.send_error(404) + return + try: + snapshot = load_or_refresh_snapshot( + db_path=self.db_path, + api_key=self.api_key, + secret_key=self.secret_key, + paper=self.paper, + refresh_interval=self.refresh_interval, + ) + body = render_dashboard(snapshot).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except Exception as exc: + body = f"Dashboard unavailable: {escape(str(exc))}".encode("utf-8") + self.send_response(500) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: + print(f"{self.address_string()} - {format % args}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Serve the read-only trading bot dashboard.") + parser.add_argument("--host", default=DEFAULT_HOST, help=f"Host to bind. Defaults to {DEFAULT_HOST}.") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Port to bind. Defaults to {DEFAULT_PORT}.") + parser.add_argument("--db-path", type=Path, default=DEFAULT_DB_PATH, help=f"SQLite cache path. Defaults to {DEFAULT_DB_PATH}.") + parser.add_argument("--refresh-hours", type=float, default=DEFAULT_REFRESH_HOURS, help="Refresh Alpaca data after this many hours. Defaults to 12.") + parser.add_argument("--api-key", default=os.getenv("ALPACA_API_KEY"), help="Alpaca API Key ID.") + parser.add_argument("--secret-key", default=os.getenv("ALPACA_SECRET_KEY"), help="Alpaca Secret Key.") + parser.add_argument("--paper", action="store_true", help="Use Alpaca paper trading mode. This is the default.") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + handler = type( + "ConfiguredDashboardRequestHandler", + (DashboardRequestHandler,), + { + "db_path": args.db_path, + "api_key": args.api_key, + "secret_key": args.secret_key, + "paper": True if args.paper else True, + "refresh_interval": timedelta(hours=args.refresh_hours), + }, + ) + server = ThreadingHTTPServer((args.host, args.port), handler) + print(f"Serving read-only dashboard at http://{args.host}:{args.port}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopping dashboard server.") + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..571e96a --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,141 @@ +from datetime import UTC, date, datetime, timedelta + +from trading_bot.data.alpaca_daily_lib import DailyCandle +from trading_bot.ui.dashboard import ( + DashboardSnapshot, + PerformancePoint, + PortfolioRow, + TradeRow, + _benchmark_end_date, + _from_unix_timestamp, + _normalize_benchmark, + is_cache_stale, + read_snapshot, + render_dashboard, + write_snapshot, +) + + +def test_normalize_benchmark_scales_spy_to_account_start_value() -> None: + candles = [ + DailyCandle(date(2026, 5, 1), 100.0, 100.0, 100.0, 100.0, 1_000), + DailyCandle(date(2026, 5, 2), 110.0, 110.0, 110.0, 110.0, 1_000), + ] + + values = _normalize_benchmark( + candles, + start_day=date(2026, 5, 1), + initial_account_value=1_000.0, + ) + + assert values[date(2026, 5, 1)] == 1_000.0 + assert values[date(2026, 5, 2)] == 1_100.0 + + +def test_portfolio_history_timestamps_are_interpreted_as_eastern_dates() -> None: + timestamp = datetime(2026, 8, 11, 2, 30, tzinfo=UTC).timestamp() + + assert _from_unix_timestamp(timestamp) == date(2026, 8, 10) + + +def test_benchmark_end_date_does_not_go_past_completed_market_day(monkeypatch) -> None: + monkeypatch.setattr( + "trading_bot.ui.dashboard.default_end_date", + lambda: date(2026, 8, 10), + ) + + assert _benchmark_end_date(date(2026, 8, 11)) == date(2026, 8, 10) + assert _benchmark_end_date(date(2026, 8, 8)) == date(2026, 8, 8) + + +def test_sqlite_snapshot_round_trip(tmp_path) -> None: + db_path = tmp_path / "dashboard.sqlite" + refreshed_at = datetime(2026, 8, 11, 10, 0, tzinfo=UTC) + snapshot = DashboardSnapshot( + refreshed_at=refreshed_at, + performance=[ + PerformancePoint(date(2026, 8, 10), 10_000.0, 9_900.0), + PerformancePoint(date(2026, 8, 11), 10_100.0, 10_000.0), + ], + portfolio=[ + PortfolioRow("CASH", "cash", None, 500.0, None, None, None), + PortfolioRow("SPY", "us_equity", 10.0, 5_000.0, 490.0, 500.0, 100.0), + ], + trades=[ + TradeRow( + datetime(2026, 8, 11, 9, 30, tzinfo=UTC), + "SPY", + "buy", + 1.0, + 500.0, + 500.0, + "filled", + ) + ], + ) + + write_snapshot(db_path, snapshot) + loaded = read_snapshot(db_path) + + assert loaded.refreshed_at == refreshed_at + assert loaded.performance == snapshot.performance + assert loaded.portfolio == snapshot.portfolio + assert loaded.trades == snapshot.trades + + +def test_cache_stale_after_refresh_interval(tmp_path) -> None: + db_path = tmp_path / "dashboard.sqlite" + refreshed_at = datetime(2026, 8, 11, 0, 0, tzinfo=UTC) + snapshot = DashboardSnapshot( + refreshed_at=refreshed_at, + performance=[], + portfolio=[], + trades=[], + ) + write_snapshot(db_path, snapshot) + + assert not is_cache_stale( + db_path, + timedelta(hours=12), + now=refreshed_at + timedelta(hours=11, minutes=59), + ) + assert is_cache_stale( + db_path, + timedelta(hours=12), + now=refreshed_at + timedelta(hours=12), + ) + + +def test_render_dashboard_contains_requested_sections() -> None: + html = render_dashboard( + DashboardSnapshot( + refreshed_at=datetime(2026, 8, 11, 10, 0, tzinfo=UTC), + performance=[ + PerformancePoint(date(2026, 8, 10), 10_000.0, 10_000.0), + PerformancePoint(date(2026, 8, 11), 10_250.0, 10_100.0), + ], + portfolio=[PortfolioRow("CASH", "cash", None, 250.0, None, None, None)], + trades=[ + TradeRow( + datetime(2026, 8, 11, 9, 30, tzinfo=UTC), + "SPY", + "orderside.sell", + 1.0, + 500.0, + 500.0, + "orderstatus.filled", + ) + ], + ) + ) + + assert "Account Value vs S&P 500 Proxy" in html + assert "Portfolio" in html + assert "Completed Trades" in html + assert "+$250.00 (+2.50%)" in html + assert "Type" not in html + assert "Account %" in html + assert "SELL" in html + assert "FILLED" in html + assert "orderside.sell" not in html + assert "orderstatus.filled" not in html