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