Mercurial
view schwab_trader/dashboard_server.py @ 241:9c2eec61a152
[assets] Generate site images as WebP with Bazel
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 03 Aug 2026 11:26:30 -0700 |
| parents | eb8b4230fdb9 |
| children |
line wrap: on
line source
from __future__ import annotations import argparse import json from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, urlparse from schwab_trader.dashboard import DashboardStore, EvidenceInput, get_config_if_available from schwab_trader.schwab_client import SchwabError, get_account, get_account_numbers, get_accounts, load_tokens INDEX_HTML = """<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Schwab Sentiment Dashboard</title> <style> :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background: #0b1020; color: #eef2ff; } body { margin: 0; } header { padding: 24px; background: linear-gradient(135deg, #172554, #0f172a); border-bottom: 1px solid #334155; } h1 { margin: 0 0 8px; font-size: 28px; } main { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); padding: 16px; } section { background: #111827; border: 1px solid #334155; border-radius: 14px; padding: 16px; box-shadow: 0 10px 25px #0004; } h2 { margin-top: 0; font-size: 18px; } label { display: block; margin: 10px 0 4px; color: #cbd5e1; } input, textarea, select, button { width: 100%; box-sizing: border-box; border-radius: 8px; border: 1px solid #475569; background: #020617; color: #f8fafc; padding: 10px; } button { margin-top: 12px; background: #2563eb; border: 0; font-weight: 700; cursor: pointer; } button.secondary { background: #334155; } pre { overflow: auto; white-space: pre-wrap; word-break: break-word; background: #020617; padding: 12px; border-radius: 8px; } table { width: 100%; border-collapse: collapse; font-size: 13px; } th, td { border-bottom: 1px solid #334155; padding: 8px; text-align: left; vertical-align: top; } .ok { color: #86efac; } .warn { color: #fbbf24; } .bad { color: #fca5a5; } .span2 { grid-column: 1 / -1; } </style> </head> <body> <header> <h1>Schwab Sentiment Dashboard</h1> <div>This is local-first and read/paper-trade focused. There is no live-trade endpoint in this dashboard.</div> </header> <main> <section> <h2>Status</h2> <button onclick="loadAll()">Refresh</button> <pre id="status">Loading...</pre> </section> <section> <h2>Settings</h2> <label>Profit target %</label><input id="profit_target_pct" type="number" step="0.1"> <label>Stop loss %</label><input id="stop_loss_pct" type="number" step="0.1"> <label>Max dollars per paper trade</label><input id="max_trade_dollars" type="number" step="1"> <label>Minimum confidence</label><input id="min_confidence" type="number" step="0.01" min="0" max="1"> <button onclick="saveSettings()">Save Settings</button> <pre id="settingsResult"></pre> </section> <section> <h2>Add Social Evidence</h2> <label>Source</label><input id="source" placeholder="reddit"> <label>Symbol</label><input id="symbol" placeholder="AAPL"> <label>URL</label><input id="url" placeholder="https://..."> <label>Engagement</label><input id="engagement" type="number" value="0"> <label>Text</label><textarea id="text" rows="5" placeholder="$AAPL looks bullish..."></textarea> <button onclick="addEvidence()">Add Evidence</button> <pre id="evidenceResult"></pre> </section> <section> <h2>Paper Trade</h2> <label>Action</label><select id="paperAction"><option>BUY</option><option>SELL</option></select> <label>Symbol</label><input id="paperSymbol" placeholder="AAPL"> <label>Quantity</label><input id="paperQuantity" type="number" step="0.01" value="1"> <label>Price</label><input id="paperPrice" type="number" step="0.01"> <label>Reason</label><input id="paperReason" placeholder="manual paper trade"> <button onclick="addPaperTrade()">Create Paper Trade</button> <pre id="paperResult"></pre> </section> <section class="span2"> <h2>Signals</h2> <div id="signals"></div> </section> <section> <h2>Recent Evidence</h2> <div id="evidence"></div> </section> <section> <h2>Paper Trades</h2> <div id="paperTrades"></div> </section> <section class="span2"> <h2>Audit Log</h2> <div id="audit"></div> </section> </main> <script> async function api(path, options = {}) { const response = await fetch(path, { headers: {'Content-Type': 'application/json'}, ...options }); const body = await response.json(); if (!response.ok) throw new Error(body.error || response.statusText); return body; } function json(id, value) { document.getElementById(id).textContent = JSON.stringify(value, null, 2); } function table(rows, cols) { if (!rows.length) return '<p class="warn">No data yet.</p>'; return '<table><thead><tr>' + cols.map(c => `<th>${c}</th>`).join('') + '</tr></thead><tbody>' + rows.map(row => '<tr>' + cols.map(c => `<td>${row[c] ?? ''}</td>`).join('') + '</tr>').join('') + '</tbody></table>'; } async function loadAll() { const [status, settings, signals, evidence, paperTrades, audit] = await Promise.all([ api('/api/status'), api('/api/settings'), api('/api/signals'), api('/api/evidence'), api('/api/paper-trades'), api('/api/audit') ]); json('status', status); for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) { document.getElementById(key).value = settings[key]; } document.getElementById('signals').innerHTML = table(signals, ['symbol', 'action', 'sentiment_score', 'confidence', 'evidence_count', 'source_count', 'summary']); document.getElementById('evidence').innerHTML = table(evidence, ['id', 'source', 'symbol', 'sentiment_score', 'engagement', 'text']); document.getElementById('paperTrades').innerHTML = table(paperTrades, ['id', 'symbol', 'action', 'quantity', 'price', 'notional', 'status', 'reason']); document.getElementById('audit').innerHTML = table(audit, ['id', 'event_type', 'message', 'created_at']); } async function saveSettings() { try { const body = {}; for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) { body[key] = Number(document.getElementById(key).value); } json('settingsResult', await api('/api/settings', {method: 'POST', body: JSON.stringify(body)})); await loadAll(); } catch (error) { json('settingsResult', {error: error.message}); } } async function addEvidence() { try { const body = { source: document.getElementById('source').value, symbol: document.getElementById('symbol').value, url: document.getElementById('url').value, engagement: Number(document.getElementById('engagement').value), text: document.getElementById('text').value }; json('evidenceResult', await api('/api/evidence', {method: 'POST', body: JSON.stringify(body)})); await loadAll(); } catch (error) { json('evidenceResult', {error: error.message}); } } async function addPaperTrade() { try { const body = { action: document.getElementById('paperAction').value, symbol: document.getElementById('paperSymbol').value, quantity: Number(document.getElementById('paperQuantity').value), price: Number(document.getElementById('paperPrice').value), reason: document.getElementById('paperReason').value }; json('paperResult', await api('/api/paper-trades', {method: 'POST', body: JSON.stringify(body)})); await loadAll(); } catch (error) { json('paperResult', {error: error.message}); } } loadAll(); </script> </body> </html> """ def create_handler(store: DashboardStore) -> type[BaseHTTPRequestHandler]: class DashboardHandler(BaseHTTPRequestHandler): server_version = "SchwabDashboard/0.1" def do_GET(self) -> None: try: parsed = urlparse(self.path) query = parse_qs(parsed.query) if parsed.path == "/": self._send_html(INDEX_HTML) elif parsed.path == "/api/status": self._send_json(store.get_status()) elif parsed.path == "/api/settings": self._send_json(store.get_settings()) elif parsed.path == "/api/evidence": self._send_json(store.list_evidence()) elif parsed.path == "/api/signals": self._send_json(store.list_signals()) elif parsed.path == "/api/paper-trades": self._send_json(store.list_paper_trades()) elif parsed.path == "/api/audit": self._send_json(store.list_audit()) elif parsed.path == "/api/schwab/account-numbers": self._send_json(_load_schwab_account_numbers()) elif parsed.path == "/api/schwab/accounts": fields = "positions" if query.get("positions") == ["1"] else None self._send_json(_load_schwab_accounts(fields)) elif parsed.path == "/api/schwab/account": account_hash = query.get("account_hash", [""])[0] fields = "positions" if query.get("positions") == ["1"] else None self._send_json(_load_schwab_account(account_hash, fields)) else: self._send_error(HTTPStatus.NOT_FOUND, "Not found") except (ValueError, SchwabError) as error: self._send_error(HTTPStatus.BAD_REQUEST, str(error)) def do_POST(self) -> None: try: parsed = urlparse(self.path) payload = self._read_json() if parsed.path == "/api/settings": self._send_json(store.update_settings(payload)) elif parsed.path == "/api/evidence": result = store.add_evidence( EvidenceInput( source=str(payload.get("source", "")), symbol=str(payload["symbol"]) if payload.get("symbol") else None, url=str(payload["url"]) if payload.get("url") else None, text=str(payload.get("text", "")), engagement=float(payload.get("engagement", 0)), raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else None, ) ) self._send_json(result, HTTPStatus.CREATED) elif parsed.path == "/api/paper-trades": self._send_json(store.add_paper_trade(payload), HTTPStatus.CREATED) else: self._send_error(HTTPStatus.NOT_FOUND, "Not found") except (ValueError, KeyError, SchwabError) as error: self._send_error(HTTPStatus.BAD_REQUEST, str(error)) def log_message(self, format: str, *args: Any) -> None: print(f"[dashboard] {self.address_string()} - {format % args}") def _read_json(self) -> dict[str, Any]: length = int(self.headers.get("Content-Length", "0")) if length <= 0: return {} raw = self.rfile.read(length).decode("utf-8") value = json.loads(raw) if not isinstance(value, dict): raise ValueError("JSON body must be an object") return value def _send_html(self, body: str) -> None: data = body.encode("utf-8") self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _send_json(self, body: Any, status: HTTPStatus = HTTPStatus.OK) -> None: data = json.dumps(body, indent=2, sort_keys=True).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _send_error(self, status: HTTPStatus, message: str) -> None: self._send_json({"error": message}, status) return DashboardHandler def _load_schwab_account_numbers() -> Any: config, access_token = _load_schwab_access_token() return get_account_numbers(access_token).body def _load_schwab_accounts(fields: str | None) -> Any: config, access_token = _load_schwab_access_token() return get_accounts(access_token, fields).body def _load_schwab_account(account_hash: str, fields: str | None) -> Any: if not account_hash: raise ValueError("account_hash is required") config, access_token = _load_schwab_access_token() return get_account(access_token, account_hash, fields).body def _load_schwab_access_token() -> tuple[Any, str]: config = get_config_if_available() if config is None: raise SchwabError("Schwab environment is not configured") tokens = load_tokens(config.token_file) access_token = tokens.get("access_token") if not access_token: raise SchwabError(f"No access_token in {config.token_file}") return config, access_token def run(host: str, port: int, db_path: str | None = None) -> None: store = DashboardStore(db_path) server = ThreadingHTTPServer((host, port), create_handler(store)) print(f"Schwab dashboard listening on http://{host}:{port}") print("Live trading is disabled in this dashboard.") server.serve_forever() def main() -> int: parser = argparse.ArgumentParser(description="Run the local Schwab sentiment dashboard") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", default=8765, type=int) parser.add_argument("--db", help="SQLite dashboard DB path") args = parser.parse_args() run(args.host, args.port, args.db) return 0 if __name__ == "__main__": raise SystemExit(main())