Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 214:4c725fde6999 | 220:eb8b4230fdb9 |
|---|---|
| 1 from __future__ import annotations | |
| 2 | |
| 3 import argparse | |
| 4 import json | |
| 5 from http import HTTPStatus | |
| 6 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| 7 from typing import Any | |
| 8 from urllib.parse import parse_qs, urlparse | |
| 9 | |
| 10 from schwab_trader.dashboard import DashboardStore, EvidenceInput, get_config_if_available | |
| 11 from schwab_trader.schwab_client import SchwabError, get_account, get_account_numbers, get_accounts, load_tokens | |
| 12 | |
| 13 | |
| 14 INDEX_HTML = """<!doctype html> | |
| 15 <html lang="en"> | |
| 16 <head> | |
| 17 <meta charset="utf-8"> | |
| 18 <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 19 <title>Schwab Sentiment Dashboard</title> | |
| 20 <style> | |
| 21 :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background: #0b1020; color: #eef2ff; } | |
| 22 body { margin: 0; } | |
| 23 header { padding: 24px; background: linear-gradient(135deg, #172554, #0f172a); border-bottom: 1px solid #334155; } | |
| 24 h1 { margin: 0 0 8px; font-size: 28px; } | |
| 25 main { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); padding: 16px; } | |
| 26 section { background: #111827; border: 1px solid #334155; border-radius: 14px; padding: 16px; box-shadow: 0 10px 25px #0004; } | |
| 27 h2 { margin-top: 0; font-size: 18px; } | |
| 28 label { display: block; margin: 10px 0 4px; color: #cbd5e1; } | |
| 29 input, textarea, select, button { width: 100%; box-sizing: border-box; border-radius: 8px; border: 1px solid #475569; background: #020617; color: #f8fafc; padding: 10px; } | |
| 30 button { margin-top: 12px; background: #2563eb; border: 0; font-weight: 700; cursor: pointer; } | |
| 31 button.secondary { background: #334155; } | |
| 32 pre { overflow: auto; white-space: pre-wrap; word-break: break-word; background: #020617; padding: 12px; border-radius: 8px; } | |
| 33 table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| 34 th, td { border-bottom: 1px solid #334155; padding: 8px; text-align: left; vertical-align: top; } | |
| 35 .ok { color: #86efac; } | |
| 36 .warn { color: #fbbf24; } | |
| 37 .bad { color: #fca5a5; } | |
| 38 .span2 { grid-column: 1 / -1; } | |
| 39 </style> | |
| 40 </head> | |
| 41 <body> | |
| 42 <header> | |
| 43 <h1>Schwab Sentiment Dashboard</h1> | |
| 44 <div>This is local-first and read/paper-trade focused. There is no live-trade endpoint in this dashboard.</div> | |
| 45 </header> | |
| 46 <main> | |
| 47 <section> | |
| 48 <h2>Status</h2> | |
| 49 <button onclick="loadAll()">Refresh</button> | |
| 50 <pre id="status">Loading...</pre> | |
| 51 </section> | |
| 52 | |
| 53 <section> | |
| 54 <h2>Settings</h2> | |
| 55 <label>Profit target %</label><input id="profit_target_pct" type="number" step="0.1"> | |
| 56 <label>Stop loss %</label><input id="stop_loss_pct" type="number" step="0.1"> | |
| 57 <label>Max dollars per paper trade</label><input id="max_trade_dollars" type="number" step="1"> | |
| 58 <label>Minimum confidence</label><input id="min_confidence" type="number" step="0.01" min="0" max="1"> | |
| 59 <button onclick="saveSettings()">Save Settings</button> | |
| 60 <pre id="settingsResult"></pre> | |
| 61 </section> | |
| 62 | |
| 63 <section> | |
| 64 <h2>Add Social Evidence</h2> | |
| 65 <label>Source</label><input id="source" placeholder="reddit"> | |
| 66 <label>Symbol</label><input id="symbol" placeholder="AAPL"> | |
| 67 <label>URL</label><input id="url" placeholder="https://..."> | |
| 68 <label>Engagement</label><input id="engagement" type="number" value="0"> | |
| 69 <label>Text</label><textarea id="text" rows="5" placeholder="$AAPL looks bullish..."></textarea> | |
| 70 <button onclick="addEvidence()">Add Evidence</button> | |
| 71 <pre id="evidenceResult"></pre> | |
| 72 </section> | |
| 73 | |
| 74 <section> | |
| 75 <h2>Paper Trade</h2> | |
| 76 <label>Action</label><select id="paperAction"><option>BUY</option><option>SELL</option></select> | |
| 77 <label>Symbol</label><input id="paperSymbol" placeholder="AAPL"> | |
| 78 <label>Quantity</label><input id="paperQuantity" type="number" step="0.01" value="1"> | |
| 79 <label>Price</label><input id="paperPrice" type="number" step="0.01"> | |
| 80 <label>Reason</label><input id="paperReason" placeholder="manual paper trade"> | |
| 81 <button onclick="addPaperTrade()">Create Paper Trade</button> | |
| 82 <pre id="paperResult"></pre> | |
| 83 </section> | |
| 84 | |
| 85 <section class="span2"> | |
| 86 <h2>Signals</h2> | |
| 87 <div id="signals"></div> | |
| 88 </section> | |
| 89 | |
| 90 <section> | |
| 91 <h2>Recent Evidence</h2> | |
| 92 <div id="evidence"></div> | |
| 93 </section> | |
| 94 | |
| 95 <section> | |
| 96 <h2>Paper Trades</h2> | |
| 97 <div id="paperTrades"></div> | |
| 98 </section> | |
| 99 | |
| 100 <section class="span2"> | |
| 101 <h2>Audit Log</h2> | |
| 102 <div id="audit"></div> | |
| 103 </section> | |
| 104 </main> | |
| 105 <script> | |
| 106 async function api(path, options = {}) { | |
| 107 const response = await fetch(path, { | |
| 108 headers: {'Content-Type': 'application/json'}, | |
| 109 ...options | |
| 110 }); | |
| 111 const body = await response.json(); | |
| 112 if (!response.ok) throw new Error(body.error || response.statusText); | |
| 113 return body; | |
| 114 } | |
| 115 | |
| 116 function json(id, value) { | |
| 117 document.getElementById(id).textContent = JSON.stringify(value, null, 2); | |
| 118 } | |
| 119 | |
| 120 function table(rows, cols) { | |
| 121 if (!rows.length) return '<p class="warn">No data yet.</p>'; | |
| 122 return '<table><thead><tr>' + cols.map(c => `<th>${c}</th>`).join('') + | |
| 123 '</tr></thead><tbody>' + rows.map(row => '<tr>' + cols.map(c => `<td>${row[c] ?? ''}</td>`).join('') + '</tr>').join('') + '</tbody></table>'; | |
| 124 } | |
| 125 | |
| 126 async function loadAll() { | |
| 127 const [status, settings, signals, evidence, paperTrades, audit] = await Promise.all([ | |
| 128 api('/api/status'), api('/api/settings'), api('/api/signals'), | |
| 129 api('/api/evidence'), api('/api/paper-trades'), api('/api/audit') | |
| 130 ]); | |
| 131 json('status', status); | |
| 132 for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) { | |
| 133 document.getElementById(key).value = settings[key]; | |
| 134 } | |
| 135 document.getElementById('signals').innerHTML = table(signals, ['symbol', 'action', 'sentiment_score', 'confidence', 'evidence_count', 'source_count', 'summary']); | |
| 136 document.getElementById('evidence').innerHTML = table(evidence, ['id', 'source', 'symbol', 'sentiment_score', 'engagement', 'text']); | |
| 137 document.getElementById('paperTrades').innerHTML = table(paperTrades, ['id', 'symbol', 'action', 'quantity', 'price', 'notional', 'status', 'reason']); | |
| 138 document.getElementById('audit').innerHTML = table(audit, ['id', 'event_type', 'message', 'created_at']); | |
| 139 } | |
| 140 | |
| 141 async function saveSettings() { | |
| 142 try { | |
| 143 const body = {}; | |
| 144 for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) { | |
| 145 body[key] = Number(document.getElementById(key).value); | |
| 146 } | |
| 147 json('settingsResult', await api('/api/settings', {method: 'POST', body: JSON.stringify(body)})); | |
| 148 await loadAll(); | |
| 149 } catch (error) { json('settingsResult', {error: error.message}); } | |
| 150 } | |
| 151 | |
| 152 async function addEvidence() { | |
| 153 try { | |
| 154 const body = { | |
| 155 source: document.getElementById('source').value, | |
| 156 symbol: document.getElementById('symbol').value, | |
| 157 url: document.getElementById('url').value, | |
| 158 engagement: Number(document.getElementById('engagement').value), | |
| 159 text: document.getElementById('text').value | |
| 160 }; | |
| 161 json('evidenceResult', await api('/api/evidence', {method: 'POST', body: JSON.stringify(body)})); | |
| 162 await loadAll(); | |
| 163 } catch (error) { json('evidenceResult', {error: error.message}); } | |
| 164 } | |
| 165 | |
| 166 async function addPaperTrade() { | |
| 167 try { | |
| 168 const body = { | |
| 169 action: document.getElementById('paperAction').value, | |
| 170 symbol: document.getElementById('paperSymbol').value, | |
| 171 quantity: Number(document.getElementById('paperQuantity').value), | |
| 172 price: Number(document.getElementById('paperPrice').value), | |
| 173 reason: document.getElementById('paperReason').value | |
| 174 }; | |
| 175 json('paperResult', await api('/api/paper-trades', {method: 'POST', body: JSON.stringify(body)})); | |
| 176 await loadAll(); | |
| 177 } catch (error) { json('paperResult', {error: error.message}); } | |
| 178 } | |
| 179 | |
| 180 loadAll(); | |
| 181 </script> | |
| 182 </body> | |
| 183 </html> | |
| 184 """ | |
| 185 | |
| 186 | |
| 187 def create_handler(store: DashboardStore) -> type[BaseHTTPRequestHandler]: | |
| 188 class DashboardHandler(BaseHTTPRequestHandler): | |
| 189 server_version = "SchwabDashboard/0.1" | |
| 190 | |
| 191 def do_GET(self) -> None: | |
| 192 try: | |
| 193 parsed = urlparse(self.path) | |
| 194 query = parse_qs(parsed.query) | |
| 195 if parsed.path == "/": | |
| 196 self._send_html(INDEX_HTML) | |
| 197 elif parsed.path == "/api/status": | |
| 198 self._send_json(store.get_status()) | |
| 199 elif parsed.path == "/api/settings": | |
| 200 self._send_json(store.get_settings()) | |
| 201 elif parsed.path == "/api/evidence": | |
| 202 self._send_json(store.list_evidence()) | |
| 203 elif parsed.path == "/api/signals": | |
| 204 self._send_json(store.list_signals()) | |
| 205 elif parsed.path == "/api/paper-trades": | |
| 206 self._send_json(store.list_paper_trades()) | |
| 207 elif parsed.path == "/api/audit": | |
| 208 self._send_json(store.list_audit()) | |
| 209 elif parsed.path == "/api/schwab/account-numbers": | |
| 210 self._send_json(_load_schwab_account_numbers()) | |
| 211 elif parsed.path == "/api/schwab/accounts": | |
| 212 fields = "positions" if query.get("positions") == ["1"] else None | |
| 213 self._send_json(_load_schwab_accounts(fields)) | |
| 214 elif parsed.path == "/api/schwab/account": | |
| 215 account_hash = query.get("account_hash", [""])[0] | |
| 216 fields = "positions" if query.get("positions") == ["1"] else None | |
| 217 self._send_json(_load_schwab_account(account_hash, fields)) | |
| 218 else: | |
| 219 self._send_error(HTTPStatus.NOT_FOUND, "Not found") | |
| 220 except (ValueError, SchwabError) as error: | |
| 221 self._send_error(HTTPStatus.BAD_REQUEST, str(error)) | |
| 222 | |
| 223 def do_POST(self) -> None: | |
| 224 try: | |
| 225 parsed = urlparse(self.path) | |
| 226 payload = self._read_json() | |
| 227 if parsed.path == "/api/settings": | |
| 228 self._send_json(store.update_settings(payload)) | |
| 229 elif parsed.path == "/api/evidence": | |
| 230 result = store.add_evidence( | |
| 231 EvidenceInput( | |
| 232 source=str(payload.get("source", "")), | |
| 233 symbol=str(payload["symbol"]) if payload.get("symbol") else None, | |
| 234 url=str(payload["url"]) if payload.get("url") else None, | |
| 235 text=str(payload.get("text", "")), | |
| 236 engagement=float(payload.get("engagement", 0)), | |
| 237 raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else None, | |
| 238 ) | |
| 239 ) | |
| 240 self._send_json(result, HTTPStatus.CREATED) | |
| 241 elif parsed.path == "/api/paper-trades": | |
| 242 self._send_json(store.add_paper_trade(payload), HTTPStatus.CREATED) | |
| 243 else: | |
| 244 self._send_error(HTTPStatus.NOT_FOUND, "Not found") | |
| 245 except (ValueError, KeyError, SchwabError) as error: | |
| 246 self._send_error(HTTPStatus.BAD_REQUEST, str(error)) | |
| 247 | |
| 248 def log_message(self, format: str, *args: Any) -> None: | |
| 249 print(f"[dashboard] {self.address_string()} - {format % args}") | |
| 250 | |
| 251 def _read_json(self) -> dict[str, Any]: | |
| 252 length = int(self.headers.get("Content-Length", "0")) | |
| 253 if length <= 0: | |
| 254 return {} | |
| 255 raw = self.rfile.read(length).decode("utf-8") | |
| 256 value = json.loads(raw) | |
| 257 if not isinstance(value, dict): | |
| 258 raise ValueError("JSON body must be an object") | |
| 259 return value | |
| 260 | |
| 261 def _send_html(self, body: str) -> None: | |
| 262 data = body.encode("utf-8") | |
| 263 self.send_response(HTTPStatus.OK) | |
| 264 self.send_header("Content-Type", "text/html; charset=utf-8") | |
| 265 self.send_header("Content-Length", str(len(data))) | |
| 266 self.end_headers() | |
| 267 self.wfile.write(data) | |
| 268 | |
| 269 def _send_json(self, body: Any, status: HTTPStatus = HTTPStatus.OK) -> None: | |
| 270 data = json.dumps(body, indent=2, sort_keys=True).encode("utf-8") | |
| 271 self.send_response(status) | |
| 272 self.send_header("Content-Type", "application/json; charset=utf-8") | |
| 273 self.send_header("Content-Length", str(len(data))) | |
| 274 self.end_headers() | |
| 275 self.wfile.write(data) | |
| 276 | |
| 277 def _send_error(self, status: HTTPStatus, message: str) -> None: | |
| 278 self._send_json({"error": message}, status) | |
| 279 | |
| 280 return DashboardHandler | |
| 281 | |
| 282 | |
| 283 def _load_schwab_account_numbers() -> Any: | |
| 284 config, access_token = _load_schwab_access_token() | |
| 285 return get_account_numbers(access_token).body | |
| 286 | |
| 287 | |
| 288 def _load_schwab_accounts(fields: str | None) -> Any: | |
| 289 config, access_token = _load_schwab_access_token() | |
| 290 return get_accounts(access_token, fields).body | |
| 291 | |
| 292 | |
| 293 def _load_schwab_account(account_hash: str, fields: str | None) -> Any: | |
| 294 if not account_hash: | |
| 295 raise ValueError("account_hash is required") | |
| 296 config, access_token = _load_schwab_access_token() | |
| 297 return get_account(access_token, account_hash, fields).body | |
| 298 | |
| 299 | |
| 300 def _load_schwab_access_token() -> tuple[Any, str]: | |
| 301 config = get_config_if_available() | |
| 302 if config is None: | |
| 303 raise SchwabError("Schwab environment is not configured") | |
| 304 tokens = load_tokens(config.token_file) | |
| 305 access_token = tokens.get("access_token") | |
| 306 if not access_token: | |
| 307 raise SchwabError(f"No access_token in {config.token_file}") | |
| 308 return config, access_token | |
| 309 | |
| 310 | |
| 311 def run(host: str, port: int, db_path: str | None = None) -> None: | |
| 312 store = DashboardStore(db_path) | |
| 313 server = ThreadingHTTPServer((host, port), create_handler(store)) | |
| 314 print(f"Schwab dashboard listening on http://{host}:{port}") | |
| 315 print("Live trading is disabled in this dashboard.") | |
| 316 server.serve_forever() | |
| 317 | |
| 318 | |
| 319 def main() -> int: | |
| 320 parser = argparse.ArgumentParser(description="Run the local Schwab sentiment dashboard") | |
| 321 parser.add_argument("--host", default="127.0.0.1") | |
| 322 parser.add_argument("--port", default=8765, type=int) | |
| 323 parser.add_argument("--db", help="SQLite dashboard DB path") | |
| 324 args = parser.parse_args() | |
| 325 run(args.host, args.port, args.db) | |
| 326 return 0 | |
| 327 | |
| 328 | |
| 329 if __name__ == "__main__": | |
| 330 raise SystemExit(main()) | |
| 331 |