Compare commits

...
2 Commits
Author SHA1 Message Date
jarno 03a724f7ea Fixed a UI bug calculating equity percentage incorrectly. 2026-08-11 21:22:21 +03:00
jarno 1faca2cdda Added a simple ui 2026-08-11 21:20:07 +03:00
6 changed files with 1110 additions and 8 deletions
+9
View File
@@ -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).
+10 -8
View File
@@ -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
+39
View File
@@ -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`.
+1
View File
@@ -0,0 +1 @@
"""Read-only web UI for inspecting trading bot status."""
+891
View File
@@ -0,0 +1,891 @@
"""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"<span class=\"{_pill_class(label)}\">{escape(label)}</span>"
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"<p class=\"warning\">{escape(snapshot.warning)}</p>"
if snapshot.warning
else ""
)
current_portfolio_value = sum(row.market_value for row in snapshot.portfolio)
portfolio_rows = "\n".join(
"<tr>"
f"<td>{escape(row.symbol)}</td>"
f"<td>{_format_number(row.quantity)}</td>"
f"<td>{_format_currency(row.market_value)}</td>"
f"<td>{_format_percent(row.market_value / current_portfolio_value if current_portfolio_value else None)}</td>"
f"<td>{_format_currency(row.average_entry_price)}</td>"
f"<td>{_format_currency(row.current_price)}</td>"
f"<td>{_format_currency(row.unrealized_pl)}</td>"
"</tr>"
for row in snapshot.portfolio
)
trade_rows = "\n".join(
"<tr>"
f"<td>{escape(row.filled_at.isoformat(sep=' ', timespec='minutes') if row.filled_at else '')}</td>"
f"<td>{escape(row.symbol)}</td>"
f"<td>{_pill_html(row.side)}</td>"
f"<td>{_format_number(row.quantity)}</td>"
f"<td>{_format_currency(row.filled_average_price)}</td>"
f"<td>{_format_currency(row.notional)}</td>"
f"<td>{_pill_html(row.status)}</td>"
"</tr>"
for row in snapshot.trades
)
portfolio_rows = portfolio_rows or "<tr><td colspan=\"7\">No portfolio rows cached.</td></tr>"
trade_rows = trade_rows or "<tr><td colspan=\"7\">No completed trades cached.</td></tr>"
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trading Bot Performance</title>
<style>
:root {{
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--text: #17202a;
--muted: #5f6b7a;
--line: #d8dee8;
--account: #116466;
--benchmark: #c75000;
--warning: #7a4100;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.45;
}}
main {{
width: min(1120px, calc(100% - 32px));
margin: 0 auto;
padding: 32px 0 48px;
}}
header {{
display: grid;
gap: 8px;
margin-bottom: 24px;
}}
h1, h2 {{ margin: 0; letter-spacing: 0; }}
h1 {{ font-size: clamp(2rem, 4vw, 3.5rem); }}
h2 {{ font-size: 1.15rem; margin-bottom: 12px; }}
.summary {{
font-size: 1.25rem;
font-weight: 650;
}}
.meta, .legend, .warning {{
color: var(--muted);
margin: 0;
}}
.warning {{ color: var(--warning); }}
section {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
margin-top: 18px;
}}
.chart-wrap {{
min-height: 420px;
}}
canvas {{
width: 100%;
height: 360px;
display: block;
}}
.legend {{
display: flex;
gap: 18px;
flex-wrap: wrap;
margin-top: 10px;
font-size: 0.95rem;
}}
.legend span::before {{
content: "";
display: inline-block;
width: 20px;
height: 3px;
margin-right: 8px;
vertical-align: middle;
background: var(--account);
}}
.legend span:last-child::before {{ background: var(--benchmark); }}
.table-wrap {{ overflow-x: auto; }}
table {{
width: 100%;
border-collapse: collapse;
min-width: 760px;
}}
th, td {{
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: right;
white-space: nowrap;
}}
th:first-child, td:first-child,
.trades th:nth-child(3), .trades td:nth-child(3),
.trades th:nth-child(7), .trades td:nth-child(7) {{
text-align: left;
}}
th {{
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
}}
.pill {{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 58px;
padding: 3px 9px;
border-radius: 999px;
background: #edf1f5;
color: #3d4854;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0;
}}
.pill-positive {{
background: #dff3e8;
color: #17643a;
}}
.pill-negative {{
background: #ffe1df;
color: #9a2219;
}}
</style>
</head>
<body>
<main>
<header>
<h1>Trading Bot Performance</h1>
<div class="summary">{escape(_performance_text(snapshot.performance))}</div>
<p class="meta">Last refreshed {escape(snapshot.refreshed_at.astimezone().isoformat(sep=' ', timespec='minutes'))}</p>
{warning}
</header>
<section class="chart-wrap">
<h2>Account Value vs S&amp;P 500 Proxy</h2>
<canvas id="performanceChart" width="1040" height="360"></canvas>
<p class="legend"><span>Account value</span><span>SPY normalized to account start value</span></p>
</section>
<section>
<h2>Portfolio</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Symbol</th><th>Quantity</th><th>Market Value</th><th>Account %</th>
<th>Avg Entry</th><th>Current Price</th><th>Unrealized P/L</th>
</tr>
</thead>
<tbody>{portfolio_rows}</tbody>
</table>
</div>
</section>
<section>
<h2>Completed Trades</h2>
<div class="table-wrap">
<table class="trades">
<thead>
<tr>
<th>Filled At</th><th>Symbol</th><th>Side</th><th>Quantity</th>
<th>Avg Fill</th><th>Notional</th><th>Status</th>
</tr>
</thead>
<tbody>{trade_rows}</tbody>
</table>
</div>
</section>
</main>
<script>
const points = {chart_json};
const canvas = document.getElementById("performanceChart");
const ctx = canvas.getContext("2d");
const css = getComputedStyle(document.documentElement);
const accountColor = css.getPropertyValue("--account").trim();
const benchmarkColor = css.getPropertyValue("--benchmark").trim();
const gridColor = css.getPropertyValue("--line").trim();
const textColor = css.getPropertyValue("--muted").trim();
function currency(value) {{
return "$" + value.toLocaleString(undefined, {{ maximumFractionDigits: 0 }});
}}
function drawLine(values, color, minValue, maxValue, left, top, width, height) {{
const valid = values.filter((point) => point.value !== null && point.value !== undefined);
if (valid.length < 2) return;
ctx.beginPath();
valid.forEach((point, index) => {{
const x = left + (point.index / Math.max(points.length - 1, 1)) * width;
const y = top + (1 - ((point.value - minValue) / Math.max(maxValue - minValue, 1))) * height;
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}});
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.stroke();
}}
function drawChart() {{
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(Math.floor(rect.width * ratio), 320);
canvas.height = Math.floor(360 * ratio);
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.clearRect(0, 0, rect.width, 360);
const left = 72, right = 18, top = 16, bottom = 46;
const width = rect.width - left - right;
const height = 360 - top - bottom;
const series = points.flatMap((point) => [point.account, point.benchmark]).filter((value) => value !== null);
if (series.length === 0) {{
ctx.fillStyle = textColor;
ctx.fillText("No performance history cached.", left, top + 24);
return;
}}
const minValue = Math.min(...series) * 0.995;
const maxValue = Math.max(...series) * 1.005;
ctx.font = "12px system-ui, sans-serif";
ctx.strokeStyle = gridColor;
ctx.fillStyle = textColor;
for (let i = 0; i <= 4; i++) {{
const y = top + (i / 4) * height;
const value = maxValue - (i / 4) * (maxValue - minValue);
ctx.beginPath();
ctx.moveTo(left, y);
ctx.lineTo(left + width, y);
ctx.stroke();
ctx.fillText(currency(value), 6, y + 4);
}}
drawLine(points.map((point, index) => ({{ index, value: point.account }})), accountColor, minValue, maxValue, left, top, width, height);
drawLine(points.map((point, index) => ({{ index, value: point.benchmark }})), benchmarkColor, minValue, maxValue, left, top, width, height);
if (points.length > 0) {{
ctx.fillStyle = textColor;
ctx.fillText(points[0].date, left, 342);
ctx.textAlign = "right";
ctx.fillText(points[points.length - 1].date, left + width, 342);
ctx.textAlign = "left";
}}
}}
drawChart();
window.addEventListener("resize", drawChart);
</script>
</body>
</html>
"""
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()
+160
View File
@@ -0,0 +1,160 @@
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&amp;P 500 Proxy" in html
assert "Portfolio" in html
assert "Completed Trades" in html
assert "+$250.00 (+2.50%)" in html
assert "<th>Type</th>" not in html
assert "<th>Account %</th>" in html
assert "SELL</span>" in html
assert "FILLED</span>" in html
assert "orderside.sell" not in html
assert "orderstatus.filled" not in html
def test_portfolio_percentage_uses_current_portfolio_total() -> None:
html = render_dashboard(
DashboardSnapshot(
refreshed_at=datetime(2026, 8, 11, 10, 0, tzinfo=UTC),
performance=[
PerformancePoint(date(2026, 8, 11), 10_000.0, 10_000.0),
],
portfolio=[
PortfolioRow("CASH", "cash", None, 100.0, None, None, None),
PortfolioRow("SPY", "us_equity", 10.0, 10_000.0, 900.0, 1_000.0, 1_000.0),
],
trades=[],
)
)
assert "99.01%" in html
assert "100.00%" not in html