comparison schwab_trader/dashboard.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 json
4 import os
5 import re
6 import sqlite3
7 import time
8 from dataclasses import dataclass
9 from pathlib import Path
10 from typing import Any
11
12 from schwab_trader.schwab_client import DEFAULT_TOKEN_FILE, SchwabConfig, load_tokens
13
14
15 DEFAULT_DASHBOARD_DB = "~/.local/share/zenbu/schwab_trader/dashboard.db"
16
17 DEFAULT_SETTINGS: dict[str, Any] = {
18 "profit_target_pct": 3.0,
19 "stop_loss_pct": 2.0,
20 "max_trade_dollars": 500.0,
21 "max_account_pct": 2.0,
22 "max_open_positions": 3,
23 "min_confidence": 0.60,
24 "min_evidence_count": 3,
25 "min_source_count": 2,
26 "cooldown_minutes": 60,
27 "market_hours_only": True,
28 "max_daily_loss_dollars": 250.0,
29 "day_trade_limit": 3,
30 "live_trading_enabled": False,
31 "require_manual_confirmation": True,
32 "llm_enabled": False,
33 "allowlist": [],
34 "blocklist": [],
35 }
36
37 POSITIVE_WORDS = {
38 "beat",
39 "beats",
40 "bull",
41 "bullish",
42 "buy",
43 "calls",
44 "growth",
45 "hype",
46 "moon",
47 "mooning",
48 "profit",
49 "rally",
50 "strong",
51 "surge",
52 "up",
53 "winner",
54 }
55
56 NEGATIVE_WORDS = {
57 "bear",
58 "bearish",
59 "crash",
60 "dump",
61 "fall",
62 "falling",
63 "fraud",
64 "lawsuit",
65 "loss",
66 "miss",
67 "puts",
68 "risk",
69 "sell",
70 "short",
71 "weak",
72 }
73
74 SYMBOL_RE = re.compile(r"(?<![A-Z0-9])\$?([A-Z]{1,5})(?![A-Z0-9])")
75 COMMON_WORDS = {
76 "A",
77 "AI",
78 "AM",
79 "API",
80 "CEO",
81 "CFO",
82 "DD",
83 "ETF",
84 "GDP",
85 "IPO",
86 "IRS",
87 "LLM",
88 "PDT",
89 "SEC",
90 "USA",
91 "USD",
92 }
93
94
95 @dataclass(frozen=True)
96 class EvidenceInput:
97 source: str
98 text: str
99 symbol: str | None = None
100 url: str | None = None
101 engagement: float = 0.0
102 raw: dict[str, Any] | None = None
103
104
105 class DashboardStore:
106 def __init__(self, db_path: Path | str | None = None) -> None:
107 if db_path is None:
108 db_path = os.environ.get("SCHWAB_DASHBOARD_DB", DEFAULT_DASHBOARD_DB)
109 self.db_path = Path(db_path).expanduser()
110 self.db_path.parent.mkdir(parents=True, exist_ok=True)
111 self._init_db()
112
113 def get_status(self) -> dict[str, Any]:
114 token_file = Path(os.environ.get("SCHWAB_TOKEN_FILE", DEFAULT_TOKEN_FILE)).expanduser()
115 token_status: dict[str, Any] = {
116 "path": str(token_file),
117 "exists": token_file.exists(),
118 "access_token_present": False,
119 "refresh_token_present": False,
120 "saved_at": None,
121 "age_seconds": None,
122 }
123 if token_file.exists():
124 try:
125 tokens = load_tokens(token_file)
126 saved_at = tokens.get("saved_at")
127 token_status.update(
128 {
129 "access_token_present": bool(tokens.get("access_token")),
130 "refresh_token_present": bool(tokens.get("refresh_token")),
131 "saved_at": saved_at,
132 "age_seconds": int(time.time()) - int(saved_at) if saved_at else None,
133 }
134 )
135 except (OSError, ValueError, TypeError) as error:
136 token_status["error"] = str(error)
137
138 env_status = {
139 "SCHWAB_APP_KEY": bool(os.environ.get("SCHWAB_APP_KEY")),
140 "SCHWAB_APP_SECRET": bool(os.environ.get("SCHWAB_APP_SECRET")),
141 "SCHWAB_REDIRECT_URI": bool(os.environ.get("SCHWAB_REDIRECT_URI")),
142 }
143
144 return {
145 "service": "schwab-dashboard",
146 "database": str(self.db_path),
147 "env": env_status,
148 "tokens": token_status,
149 "live_trading_enabled": False,
150 "live_trading_note": "Dashboard has no live-trade endpoint; use CLI dry-run/manual confirmation flow.",
151 "counts": self.get_counts(),
152 }
153
154 def get_counts(self) -> dict[str, int]:
155 with self._connect() as conn:
156 return {
157 "evidence": self._count(conn, "evidence"),
158 "signals": self._count(conn, "signals"),
159 "paper_trades": self._count(conn, "paper_trades"),
160 "audit_events": self._count(conn, "audit"),
161 }
162
163 def get_settings(self) -> dict[str, Any]:
164 settings = dict(DEFAULT_SETTINGS)
165 with self._connect() as conn:
166 for row in conn.execute("SELECT key, value_json FROM settings"):
167 settings[row["key"]] = json.loads(row["value_json"])
168 return settings
169
170 def update_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
171 allowed = set(DEFAULT_SETTINGS)
172 unknown = sorted(set(updates) - allowed)
173 if unknown:
174 raise ValueError("Unknown settings: " + ", ".join(unknown))
175
176 current = self.get_settings()
177 current.update(updates)
178 self._validate_settings(current)
179
180 with self._connect() as conn:
181 for key, value in current.items():
182 conn.execute(
183 """
184 INSERT INTO settings(key, value_json)
185 VALUES (?, ?)
186 ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json
187 """,
188 (key, json.dumps(value, sort_keys=True)),
189 )
190 conn.commit()
191 self.add_audit("settings.updated", "Dashboard settings updated", updates)
192 return current
193
194 def add_evidence(self, item: EvidenceInput) -> dict[str, Any]:
195 source = item.source.strip().lower()
196 text = item.text.strip()
197 if not source:
198 raise ValueError("source is required")
199 if not text:
200 raise ValueError("text is required")
201
202 symbol = normalize_symbol(item.symbol) if item.symbol else extract_symbol(text)
203 if not symbol:
204 raise ValueError("symbol is required or must be detectable as a ticker in text")
205
206 sentiment_score = score_sentiment(text)
207 created_at = int(time.time())
208
209 with self._connect() as conn:
210 cursor = conn.execute(
211 """
212 INSERT INTO evidence(
213 source, symbol, url, text, engagement, sentiment_score, raw_json, created_at
214 )
215 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
216 """,
217 (
218 source,
219 symbol,
220 item.url,
221 text,
222 float(item.engagement),
223 sentiment_score,
224 json.dumps(item.raw or {}, sort_keys=True),
225 created_at,
226 ),
227 )
228 evidence_id = int(cursor.lastrowid)
229 conn.commit()
230
231 signal = self.recompute_signal(symbol)
232 self.add_audit(
233 "evidence.added",
234 f"Added {source} evidence for {symbol}",
235 {"evidence_id": evidence_id, "symbol": symbol, "signal": signal},
236 )
237 return {"id": evidence_id, "symbol": symbol, "sentiment_score": sentiment_score, "signal": signal}
238
239 def list_evidence(self, limit: int = 100) -> list[dict[str, Any]]:
240 with self._connect() as conn:
241 rows = conn.execute(
242 """
243 SELECT id, source, symbol, url, text, engagement, sentiment_score, created_at
244 FROM evidence
245 ORDER BY id DESC
246 LIMIT ?
247 """,
248 (limit,),
249 ).fetchall()
250 return [dict(row) for row in rows]
251
252 def recompute_signal(self, symbol: str) -> dict[str, Any]:
253 symbol = normalize_symbol(symbol)
254 settings = self.get_settings()
255 with self._connect() as conn:
256 rows = conn.execute(
257 """
258 SELECT source, sentiment_score, engagement, created_at
259 FROM evidence
260 WHERE symbol = ?
261 ORDER BY id DESC
262 LIMIT 100
263 """,
264 (symbol,),
265 ).fetchall()
266
267 evidence_count = len(rows)
268 source_count = len({row["source"] for row in rows})
269 weighted_total = 0.0
270 weight_sum = 0.0
271 for row in rows:
272 engagement_weight = min(5.0, 1.0 + max(0.0, float(row["engagement"])) / 100.0)
273 weighted_total += float(row["sentiment_score"]) * engagement_weight
274 weight_sum += engagement_weight
275 sentiment_score = weighted_total / weight_sum if weight_sum else 0.0
276 confidence = compute_confidence(sentiment_score, evidence_count, source_count)
277 action = decide_signal_action(symbol, sentiment_score, confidence, evidence_count, source_count, settings)
278 summary = summarize_signal(symbol, sentiment_score, confidence, evidence_count, source_count, action)
279 updated_at = int(time.time())
280
281 conn.execute(
282 """
283 INSERT INTO signals(
284 symbol, sentiment_score, confidence, evidence_count, source_count,
285 action, summary, updated_at
286 )
287 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
288 ON CONFLICT(symbol) DO UPDATE SET
289 sentiment_score = excluded.sentiment_score,
290 confidence = excluded.confidence,
291 evidence_count = excluded.evidence_count,
292 source_count = excluded.source_count,
293 action = excluded.action,
294 summary = excluded.summary,
295 updated_at = excluded.updated_at
296 """,
297 (
298 symbol,
299 sentiment_score,
300 confidence,
301 evidence_count,
302 source_count,
303 action,
304 summary,
305 updated_at,
306 ),
307 )
308 conn.commit()
309
310 return {
311 "symbol": symbol,
312 "sentiment_score": round(sentiment_score, 4),
313 "confidence": round(confidence, 4),
314 "evidence_count": evidence_count,
315 "source_count": source_count,
316 "action": action,
317 "summary": summary,
318 "updated_at": updated_at,
319 }
320
321 def list_signals(self) -> list[dict[str, Any]]:
322 with self._connect() as conn:
323 rows = conn.execute(
324 """
325 SELECT symbol, sentiment_score, confidence, evidence_count, source_count,
326 action, summary, updated_at
327 FROM signals
328 ORDER BY confidence DESC, updated_at DESC
329 """
330 ).fetchall()
331 return [dict(row) for row in rows]
332
333 def add_paper_trade(self, payload: dict[str, Any]) -> dict[str, Any]:
334 symbol = normalize_symbol(str(payload.get("symbol", "")))
335 action = str(payload.get("action", "")).upper()
336 quantity = float(payload.get("quantity", 0))
337 price = float(payload.get("price", 0))
338 reason = str(payload.get("reason", "manual paper trade")).strip()
339
340 if action not in {"BUY", "SELL"}:
341 raise ValueError("action must be BUY or SELL")
342 if not symbol:
343 raise ValueError("symbol is required")
344 if quantity <= 0:
345 raise ValueError("quantity must be greater than zero")
346 if price <= 0:
347 raise ValueError("price must be greater than zero")
348
349 settings = self.get_settings()
350 notional = quantity * price
351 status = "accepted"
352 if notional > float(settings["max_trade_dollars"]):
353 status = "rejected_max_trade_dollars"
354 elif symbol in {normalize_symbol(s) for s in settings["blocklist"]}:
355 status = "rejected_blocklist"
356
357 created_at = int(time.time())
358 with self._connect() as conn:
359 cursor = conn.execute(
360 """
361 INSERT INTO paper_trades(symbol, action, quantity, price, notional, status, reason, created_at)
362 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
363 """,
364 (symbol, action, quantity, price, notional, status, reason, created_at),
365 )
366 trade_id = int(cursor.lastrowid)
367 conn.commit()
368
369 result = {
370 "id": trade_id,
371 "symbol": symbol,
372 "action": action,
373 "quantity": quantity,
374 "price": price,
375 "notional": notional,
376 "status": status,
377 "reason": reason,
378 "created_at": created_at,
379 }
380 self.add_audit("paper_trade.created", f"Paper trade {status}: {action} {quantity} {symbol}", result)
381 return result
382
383 def list_paper_trades(self, limit: int = 100) -> list[dict[str, Any]]:
384 with self._connect() as conn:
385 rows = conn.execute(
386 """
387 SELECT id, symbol, action, quantity, price, notional, status, reason, created_at
388 FROM paper_trades
389 ORDER BY id DESC
390 LIMIT ?
391 """,
392 (limit,),
393 ).fetchall()
394 return [dict(row) for row in rows]
395
396 def add_audit(self, event_type: str, message: str, payload: dict[str, Any] | None = None) -> None:
397 with self._connect() as conn:
398 conn.execute(
399 """
400 INSERT INTO audit(event_type, message, payload_json, created_at)
401 VALUES (?, ?, ?, ?)
402 """,
403 (event_type, message, json.dumps(payload or {}, sort_keys=True), int(time.time())),
404 )
405 conn.commit()
406
407 def list_audit(self, limit: int = 100) -> list[dict[str, Any]]:
408 with self._connect() as conn:
409 rows = conn.execute(
410 """
411 SELECT id, event_type, message, payload_json, created_at
412 FROM audit
413 ORDER BY id DESC
414 LIMIT ?
415 """,
416 (limit,),
417 ).fetchall()
418 events = []
419 for row in rows:
420 event = dict(row)
421 event["payload"] = json.loads(event.pop("payload_json"))
422 events.append(event)
423 return events
424
425 def _init_db(self) -> None:
426 with self._connect() as conn:
427 conn.executescript(
428 """
429 CREATE TABLE IF NOT EXISTS settings (
430 key TEXT PRIMARY KEY,
431 value_json TEXT NOT NULL
432 );
433
434 CREATE TABLE IF NOT EXISTS evidence (
435 id INTEGER PRIMARY KEY AUTOINCREMENT,
436 source TEXT NOT NULL,
437 symbol TEXT NOT NULL,
438 url TEXT,
439 text TEXT NOT NULL,
440 engagement REAL NOT NULL DEFAULT 0,
441 sentiment_score REAL NOT NULL,
442 raw_json TEXT NOT NULL DEFAULT '{}',
443 created_at INTEGER NOT NULL
444 );
445
446 CREATE INDEX IF NOT EXISTS idx_evidence_symbol_created
447 ON evidence(symbol, created_at);
448
449 CREATE TABLE IF NOT EXISTS signals (
450 symbol TEXT PRIMARY KEY,
451 sentiment_score REAL NOT NULL,
452 confidence REAL NOT NULL,
453 evidence_count INTEGER NOT NULL,
454 source_count INTEGER NOT NULL,
455 action TEXT NOT NULL,
456 summary TEXT NOT NULL,
457 updated_at INTEGER NOT NULL
458 );
459
460 CREATE TABLE IF NOT EXISTS paper_trades (
461 id INTEGER PRIMARY KEY AUTOINCREMENT,
462 symbol TEXT NOT NULL,
463 action TEXT NOT NULL,
464 quantity REAL NOT NULL,
465 price REAL NOT NULL,
466 notional REAL NOT NULL,
467 status TEXT NOT NULL,
468 reason TEXT NOT NULL,
469 created_at INTEGER NOT NULL
470 );
471
472 CREATE TABLE IF NOT EXISTS audit (
473 id INTEGER PRIMARY KEY AUTOINCREMENT,
474 event_type TEXT NOT NULL,
475 message TEXT NOT NULL,
476 payload_json TEXT NOT NULL,
477 created_at INTEGER NOT NULL
478 );
479 """
480 )
481 for key, value in DEFAULT_SETTINGS.items():
482 conn.execute(
483 "INSERT OR IGNORE INTO settings(key, value_json) VALUES (?, ?)",
484 (key, json.dumps(value, sort_keys=True)),
485 )
486 conn.commit()
487
488 def _connect(self) -> sqlite3.Connection:
489 conn = sqlite3.connect(self.db_path)
490 conn.row_factory = sqlite3.Row
491 return conn
492
493 @staticmethod
494 def _count(conn: sqlite3.Connection, table: str) -> int:
495 return int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
496
497 @staticmethod
498 def _validate_settings(settings: dict[str, Any]) -> None:
499 positive_numbers = [
500 "profit_target_pct",
501 "stop_loss_pct",
502 "max_trade_dollars",
503 "max_account_pct",
504 "max_daily_loss_dollars",
505 ]
506 for key in positive_numbers:
507 if float(settings[key]) <= 0:
508 raise ValueError(f"{key} must be greater than zero")
509 if not 0 <= float(settings["min_confidence"]) <= 1:
510 raise ValueError("min_confidence must be between 0 and 1")
511 if int(settings["min_evidence_count"]) < 1:
512 raise ValueError("min_evidence_count must be at least 1")
513 if int(settings["min_source_count"]) < 1:
514 raise ValueError("min_source_count must be at least 1")
515 if bool(settings["live_trading_enabled"]):
516 raise ValueError("live_trading_enabled cannot be enabled from the dashboard")
517
518
519 def normalize_symbol(value: str) -> str:
520 symbol = value.strip().upper().lstrip("$")
521 if not re.fullmatch(r"[A-Z]{1,5}", symbol):
522 raise ValueError("symbol must be 1-5 letters")
523 return symbol
524
525
526 def extract_symbol(text: str) -> str | None:
527 for match in SYMBOL_RE.finditer(text.upper()):
528 symbol = match.group(1)
529 if symbol not in COMMON_WORDS:
530 return symbol
531 return None
532
533
534 def score_sentiment(text: str) -> float:
535 words = re.findall(r"[a-zA-Z']+", text.lower())
536 positive = sum(1 for word in words if word in POSITIVE_WORDS)
537 negative = sum(1 for word in words if word in NEGATIVE_WORDS)
538 total = positive + negative
539 if total == 0:
540 return 0.0
541 return max(-1.0, min(1.0, (positive - negative) / total))
542
543
544 def compute_confidence(sentiment_score: float, evidence_count: int, source_count: int) -> float:
545 evidence_component = min(0.35, evidence_count * 0.07)
546 source_component = min(0.25, source_count * 0.10)
547 sentiment_component = min(0.20, abs(sentiment_score) * 0.20)
548 return min(0.95, 0.20 + evidence_component + source_component + sentiment_component)
549
550
551 def decide_signal_action(
552 symbol: str,
553 sentiment_score: float,
554 confidence: float,
555 evidence_count: int,
556 source_count: int,
557 settings: dict[str, Any],
558 ) -> str:
559 blocklist = {normalize_symbol(item) for item in settings.get("blocklist", [])}
560 allowlist = {normalize_symbol(item) for item in settings.get("allowlist", [])}
561 if symbol in blocklist:
562 return "NO_TRADE_BLOCKED"
563 if allowlist and symbol not in allowlist:
564 return "NO_TRADE_NOT_ALLOWLISTED"
565 if evidence_count < int(settings["min_evidence_count"]):
566 return "NO_TRADE_NEEDS_EVIDENCE"
567 if source_count < int(settings["min_source_count"]):
568 return "NO_TRADE_NEEDS_SOURCE_DIVERSITY"
569 if confidence < float(settings["min_confidence"]):
570 return "NO_TRADE_LOW_CONFIDENCE"
571 if sentiment_score >= 0.25:
572 return "CONSIDER_BUY"
573 if sentiment_score <= -0.25:
574 return "CONSIDER_SELL"
575 return "WATCH"
576
577
578 def summarize_signal(
579 symbol: str,
580 sentiment_score: float,
581 confidence: float,
582 evidence_count: int,
583 source_count: int,
584 action: str,
585 ) -> str:
586 direction = "positive" if sentiment_score > 0 else "negative" if sentiment_score < 0 else "neutral"
587 return (
588 f"{symbol} has {direction} social sentiment from {evidence_count} evidence item(s) "
589 f"across {source_count} source(s). Confidence is {confidence:.0%}. Action: {action}."
590 )
591
592
593 def get_config_if_available() -> SchwabConfig | None:
594 try:
595 return SchwabConfig.from_env()
596 except Exception:
597 return None
598