Mercurial
diff schwab_trader/dashboard_server.py @ 220:eb8b4230fdb9
[schwab-trader] Add guarded trading experiment
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 08:52:13 -0700 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schwab_trader/dashboard_server.py Sun Aug 02 08:52:13 2026 -0700 @@ -0,0 +1,331 @@ +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()) +