Mercurial
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 |