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())
+