view schwab_trader/dashboard.py @ 271:13d61401c57d

redirect Copilot cache to service state Set XDG_CACHE_HOME from the configured inference state so the systemd service can extract Copilot outside its protected home directory. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 13:24:05 -0700
parents eb8b4230fdb9
children
line wrap: on
line source

from __future__ import annotations

import json
import os
import re
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from schwab_trader.schwab_client import DEFAULT_TOKEN_FILE, SchwabConfig, load_tokens


DEFAULT_DASHBOARD_DB = "~/.local/share/zenbu/schwab_trader/dashboard.db"

DEFAULT_SETTINGS: dict[str, Any] = {
    "profit_target_pct": 3.0,
    "stop_loss_pct": 2.0,
    "max_trade_dollars": 500.0,
    "max_account_pct": 2.0,
    "max_open_positions": 3,
    "min_confidence": 0.60,
    "min_evidence_count": 3,
    "min_source_count": 2,
    "cooldown_minutes": 60,
    "market_hours_only": True,
    "max_daily_loss_dollars": 250.0,
    "day_trade_limit": 3,
    "live_trading_enabled": False,
    "require_manual_confirmation": True,
    "llm_enabled": False,
    "allowlist": [],
    "blocklist": [],
}

POSITIVE_WORDS = {
    "beat",
    "beats",
    "bull",
    "bullish",
    "buy",
    "calls",
    "growth",
    "hype",
    "moon",
    "mooning",
    "profit",
    "rally",
    "strong",
    "surge",
    "up",
    "winner",
}

NEGATIVE_WORDS = {
    "bear",
    "bearish",
    "crash",
    "dump",
    "fall",
    "falling",
    "fraud",
    "lawsuit",
    "loss",
    "miss",
    "puts",
    "risk",
    "sell",
    "short",
    "weak",
}

SYMBOL_RE = re.compile(r"(?<![A-Z0-9])\$?([A-Z]{1,5})(?![A-Z0-9])")
COMMON_WORDS = {
    "A",
    "AI",
    "AM",
    "API",
    "CEO",
    "CFO",
    "DD",
    "ETF",
    "GDP",
    "IPO",
    "IRS",
    "LLM",
    "PDT",
    "SEC",
    "USA",
    "USD",
}


@dataclass(frozen=True)
class EvidenceInput:
    source: str
    text: str
    symbol: str | None = None
    url: str | None = None
    engagement: float = 0.0
    raw: dict[str, Any] | None = None


class DashboardStore:
    def __init__(self, db_path: Path | str | None = None) -> None:
        if db_path is None:
            db_path = os.environ.get("SCHWAB_DASHBOARD_DB", DEFAULT_DASHBOARD_DB)
        self.db_path = Path(db_path).expanduser()
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._init_db()

    def get_status(self) -> dict[str, Any]:
        token_file = Path(os.environ.get("SCHWAB_TOKEN_FILE", DEFAULT_TOKEN_FILE)).expanduser()
        token_status: dict[str, Any] = {
            "path": str(token_file),
            "exists": token_file.exists(),
            "access_token_present": False,
            "refresh_token_present": False,
            "saved_at": None,
            "age_seconds": None,
        }
        if token_file.exists():
            try:
                tokens = load_tokens(token_file)
                saved_at = tokens.get("saved_at")
                token_status.update(
                    {
                        "access_token_present": bool(tokens.get("access_token")),
                        "refresh_token_present": bool(tokens.get("refresh_token")),
                        "saved_at": saved_at,
                        "age_seconds": int(time.time()) - int(saved_at) if saved_at else None,
                    }
                )
            except (OSError, ValueError, TypeError) as error:
                token_status["error"] = str(error)

        env_status = {
            "SCHWAB_APP_KEY": bool(os.environ.get("SCHWAB_APP_KEY")),
            "SCHWAB_APP_SECRET": bool(os.environ.get("SCHWAB_APP_SECRET")),
            "SCHWAB_REDIRECT_URI": bool(os.environ.get("SCHWAB_REDIRECT_URI")),
        }

        return {
            "service": "schwab-dashboard",
            "database": str(self.db_path),
            "env": env_status,
            "tokens": token_status,
            "live_trading_enabled": False,
            "live_trading_note": "Dashboard has no live-trade endpoint; use CLI dry-run/manual confirmation flow.",
            "counts": self.get_counts(),
        }

    def get_counts(self) -> dict[str, int]:
        with self._connect() as conn:
            return {
                "evidence": self._count(conn, "evidence"),
                "signals": self._count(conn, "signals"),
                "paper_trades": self._count(conn, "paper_trades"),
                "audit_events": self._count(conn, "audit"),
            }

    def get_settings(self) -> dict[str, Any]:
        settings = dict(DEFAULT_SETTINGS)
        with self._connect() as conn:
            for row in conn.execute("SELECT key, value_json FROM settings"):
                settings[row["key"]] = json.loads(row["value_json"])
        return settings

    def update_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
        allowed = set(DEFAULT_SETTINGS)
        unknown = sorted(set(updates) - allowed)
        if unknown:
            raise ValueError("Unknown settings: " + ", ".join(unknown))

        current = self.get_settings()
        current.update(updates)
        self._validate_settings(current)

        with self._connect() as conn:
            for key, value in current.items():
                conn.execute(
                    """
                    INSERT INTO settings(key, value_json)
                    VALUES (?, ?)
                    ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json
                    """,
                    (key, json.dumps(value, sort_keys=True)),
                )
            conn.commit()
        self.add_audit("settings.updated", "Dashboard settings updated", updates)
        return current

    def add_evidence(self, item: EvidenceInput) -> dict[str, Any]:
        source = item.source.strip().lower()
        text = item.text.strip()
        if not source:
            raise ValueError("source is required")
        if not text:
            raise ValueError("text is required")

        symbol = normalize_symbol(item.symbol) if item.symbol else extract_symbol(text)
        if not symbol:
            raise ValueError("symbol is required or must be detectable as a ticker in text")

        sentiment_score = score_sentiment(text)
        created_at = int(time.time())

        with self._connect() as conn:
            cursor = conn.execute(
                """
                INSERT INTO evidence(
                  source, symbol, url, text, engagement, sentiment_score, raw_json, created_at
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    source,
                    symbol,
                    item.url,
                    text,
                    float(item.engagement),
                    sentiment_score,
                    json.dumps(item.raw or {}, sort_keys=True),
                    created_at,
                ),
            )
            evidence_id = int(cursor.lastrowid)
            conn.commit()

        signal = self.recompute_signal(symbol)
        self.add_audit(
            "evidence.added",
            f"Added {source} evidence for {symbol}",
            {"evidence_id": evidence_id, "symbol": symbol, "signal": signal},
        )
        return {"id": evidence_id, "symbol": symbol, "sentiment_score": sentiment_score, "signal": signal}

    def list_evidence(self, limit: int = 100) -> list[dict[str, Any]]:
        with self._connect() as conn:
            rows = conn.execute(
                """
                SELECT id, source, symbol, url, text, engagement, sentiment_score, created_at
                FROM evidence
                ORDER BY id DESC
                LIMIT ?
                """,
                (limit,),
            ).fetchall()
        return [dict(row) for row in rows]

    def recompute_signal(self, symbol: str) -> dict[str, Any]:
        symbol = normalize_symbol(symbol)
        settings = self.get_settings()
        with self._connect() as conn:
            rows = conn.execute(
                """
                SELECT source, sentiment_score, engagement, created_at
                FROM evidence
                WHERE symbol = ?
                ORDER BY id DESC
                LIMIT 100
                """,
                (symbol,),
            ).fetchall()

            evidence_count = len(rows)
            source_count = len({row["source"] for row in rows})
            weighted_total = 0.0
            weight_sum = 0.0
            for row in rows:
                engagement_weight = min(5.0, 1.0 + max(0.0, float(row["engagement"])) / 100.0)
                weighted_total += float(row["sentiment_score"]) * engagement_weight
                weight_sum += engagement_weight
            sentiment_score = weighted_total / weight_sum if weight_sum else 0.0
            confidence = compute_confidence(sentiment_score, evidence_count, source_count)
            action = decide_signal_action(symbol, sentiment_score, confidence, evidence_count, source_count, settings)
            summary = summarize_signal(symbol, sentiment_score, confidence, evidence_count, source_count, action)
            updated_at = int(time.time())

            conn.execute(
                """
                INSERT INTO signals(
                  symbol, sentiment_score, confidence, evidence_count, source_count,
                  action, summary, updated_at
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(symbol) DO UPDATE SET
                  sentiment_score = excluded.sentiment_score,
                  confidence = excluded.confidence,
                  evidence_count = excluded.evidence_count,
                  source_count = excluded.source_count,
                  action = excluded.action,
                  summary = excluded.summary,
                  updated_at = excluded.updated_at
                """,
                (
                    symbol,
                    sentiment_score,
                    confidence,
                    evidence_count,
                    source_count,
                    action,
                    summary,
                    updated_at,
                ),
            )
            conn.commit()

        return {
            "symbol": symbol,
            "sentiment_score": round(sentiment_score, 4),
            "confidence": round(confidence, 4),
            "evidence_count": evidence_count,
            "source_count": source_count,
            "action": action,
            "summary": summary,
            "updated_at": updated_at,
        }

    def list_signals(self) -> list[dict[str, Any]]:
        with self._connect() as conn:
            rows = conn.execute(
                """
                SELECT symbol, sentiment_score, confidence, evidence_count, source_count,
                       action, summary, updated_at
                FROM signals
                ORDER BY confidence DESC, updated_at DESC
                """
            ).fetchall()
        return [dict(row) for row in rows]

    def add_paper_trade(self, payload: dict[str, Any]) -> dict[str, Any]:
        symbol = normalize_symbol(str(payload.get("symbol", "")))
        action = str(payload.get("action", "")).upper()
        quantity = float(payload.get("quantity", 0))
        price = float(payload.get("price", 0))
        reason = str(payload.get("reason", "manual paper trade")).strip()

        if action not in {"BUY", "SELL"}:
            raise ValueError("action must be BUY or SELL")
        if not symbol:
            raise ValueError("symbol is required")
        if quantity <= 0:
            raise ValueError("quantity must be greater than zero")
        if price <= 0:
            raise ValueError("price must be greater than zero")

        settings = self.get_settings()
        notional = quantity * price
        status = "accepted"
        if notional > float(settings["max_trade_dollars"]):
            status = "rejected_max_trade_dollars"
        elif symbol in {normalize_symbol(s) for s in settings["blocklist"]}:
            status = "rejected_blocklist"

        created_at = int(time.time())
        with self._connect() as conn:
            cursor = conn.execute(
                """
                INSERT INTO paper_trades(symbol, action, quantity, price, notional, status, reason, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (symbol, action, quantity, price, notional, status, reason, created_at),
            )
            trade_id = int(cursor.lastrowid)
            conn.commit()

        result = {
            "id": trade_id,
            "symbol": symbol,
            "action": action,
            "quantity": quantity,
            "price": price,
            "notional": notional,
            "status": status,
            "reason": reason,
            "created_at": created_at,
        }
        self.add_audit("paper_trade.created", f"Paper trade {status}: {action} {quantity} {symbol}", result)
        return result

    def list_paper_trades(self, limit: int = 100) -> list[dict[str, Any]]:
        with self._connect() as conn:
            rows = conn.execute(
                """
                SELECT id, symbol, action, quantity, price, notional, status, reason, created_at
                FROM paper_trades
                ORDER BY id DESC
                LIMIT ?
                """,
                (limit,),
            ).fetchall()
        return [dict(row) for row in rows]

    def add_audit(self, event_type: str, message: str, payload: dict[str, Any] | None = None) -> None:
        with self._connect() as conn:
            conn.execute(
                """
                INSERT INTO audit(event_type, message, payload_json, created_at)
                VALUES (?, ?, ?, ?)
                """,
                (event_type, message, json.dumps(payload or {}, sort_keys=True), int(time.time())),
            )
            conn.commit()

    def list_audit(self, limit: int = 100) -> list[dict[str, Any]]:
        with self._connect() as conn:
            rows = conn.execute(
                """
                SELECT id, event_type, message, payload_json, created_at
                FROM audit
                ORDER BY id DESC
                LIMIT ?
                """,
                (limit,),
            ).fetchall()
        events = []
        for row in rows:
            event = dict(row)
            event["payload"] = json.loads(event.pop("payload_json"))
            events.append(event)
        return events

    def _init_db(self) -> None:
        with self._connect() as conn:
            conn.executescript(
                """
                CREATE TABLE IF NOT EXISTS settings (
                  key TEXT PRIMARY KEY,
                  value_json TEXT NOT NULL
                );

                CREATE TABLE IF NOT EXISTS evidence (
                  id INTEGER PRIMARY KEY AUTOINCREMENT,
                  source TEXT NOT NULL,
                  symbol TEXT NOT NULL,
                  url TEXT,
                  text TEXT NOT NULL,
                  engagement REAL NOT NULL DEFAULT 0,
                  sentiment_score REAL NOT NULL,
                  raw_json TEXT NOT NULL DEFAULT '{}',
                  created_at INTEGER NOT NULL
                );

                CREATE INDEX IF NOT EXISTS idx_evidence_symbol_created
                ON evidence(symbol, created_at);

                CREATE TABLE IF NOT EXISTS signals (
                  symbol TEXT PRIMARY KEY,
                  sentiment_score REAL NOT NULL,
                  confidence REAL NOT NULL,
                  evidence_count INTEGER NOT NULL,
                  source_count INTEGER NOT NULL,
                  action TEXT NOT NULL,
                  summary TEXT NOT NULL,
                  updated_at INTEGER NOT NULL
                );

                CREATE TABLE IF NOT EXISTS paper_trades (
                  id INTEGER PRIMARY KEY AUTOINCREMENT,
                  symbol TEXT NOT NULL,
                  action TEXT NOT NULL,
                  quantity REAL NOT NULL,
                  price REAL NOT NULL,
                  notional REAL NOT NULL,
                  status TEXT NOT NULL,
                  reason TEXT NOT NULL,
                  created_at INTEGER NOT NULL
                );

                CREATE TABLE IF NOT EXISTS audit (
                  id INTEGER PRIMARY KEY AUTOINCREMENT,
                  event_type TEXT NOT NULL,
                  message TEXT NOT NULL,
                  payload_json TEXT NOT NULL,
                  created_at INTEGER NOT NULL
                );
                """
            )
            for key, value in DEFAULT_SETTINGS.items():
                conn.execute(
                    "INSERT OR IGNORE INTO settings(key, value_json) VALUES (?, ?)",
                    (key, json.dumps(value, sort_keys=True)),
                )
            conn.commit()

    def _connect(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        return conn

    @staticmethod
    def _count(conn: sqlite3.Connection, table: str) -> int:
        return int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])

    @staticmethod
    def _validate_settings(settings: dict[str, Any]) -> None:
        positive_numbers = [
            "profit_target_pct",
            "stop_loss_pct",
            "max_trade_dollars",
            "max_account_pct",
            "max_daily_loss_dollars",
        ]
        for key in positive_numbers:
            if float(settings[key]) <= 0:
                raise ValueError(f"{key} must be greater than zero")
        if not 0 <= float(settings["min_confidence"]) <= 1:
            raise ValueError("min_confidence must be between 0 and 1")
        if int(settings["min_evidence_count"]) < 1:
            raise ValueError("min_evidence_count must be at least 1")
        if int(settings["min_source_count"]) < 1:
            raise ValueError("min_source_count must be at least 1")
        if bool(settings["live_trading_enabled"]):
            raise ValueError("live_trading_enabled cannot be enabled from the dashboard")


def normalize_symbol(value: str) -> str:
    symbol = value.strip().upper().lstrip("$")
    if not re.fullmatch(r"[A-Z]{1,5}", symbol):
        raise ValueError("symbol must be 1-5 letters")
    return symbol


def extract_symbol(text: str) -> str | None:
    for match in SYMBOL_RE.finditer(text.upper()):
        symbol = match.group(1)
        if symbol not in COMMON_WORDS:
            return symbol
    return None


def score_sentiment(text: str) -> float:
    words = re.findall(r"[a-zA-Z']+", text.lower())
    positive = sum(1 for word in words if word in POSITIVE_WORDS)
    negative = sum(1 for word in words if word in NEGATIVE_WORDS)
    total = positive + negative
    if total == 0:
        return 0.0
    return max(-1.0, min(1.0, (positive - negative) / total))


def compute_confidence(sentiment_score: float, evidence_count: int, source_count: int) -> float:
    evidence_component = min(0.35, evidence_count * 0.07)
    source_component = min(0.25, source_count * 0.10)
    sentiment_component = min(0.20, abs(sentiment_score) * 0.20)
    return min(0.95, 0.20 + evidence_component + source_component + sentiment_component)


def decide_signal_action(
    symbol: str,
    sentiment_score: float,
    confidence: float,
    evidence_count: int,
    source_count: int,
    settings: dict[str, Any],
) -> str:
    blocklist = {normalize_symbol(item) for item in settings.get("blocklist", [])}
    allowlist = {normalize_symbol(item) for item in settings.get("allowlist", [])}
    if symbol in blocklist:
        return "NO_TRADE_BLOCKED"
    if allowlist and symbol not in allowlist:
        return "NO_TRADE_NOT_ALLOWLISTED"
    if evidence_count < int(settings["min_evidence_count"]):
        return "NO_TRADE_NEEDS_EVIDENCE"
    if source_count < int(settings["min_source_count"]):
        return "NO_TRADE_NEEDS_SOURCE_DIVERSITY"
    if confidence < float(settings["min_confidence"]):
        return "NO_TRADE_LOW_CONFIDENCE"
    if sentiment_score >= 0.25:
        return "CONSIDER_BUY"
    if sentiment_score <= -0.25:
        return "CONSIDER_SELL"
    return "WATCH"


def summarize_signal(
    symbol: str,
    sentiment_score: float,
    confidence: float,
    evidence_count: int,
    source_count: int,
    action: str,
) -> str:
    direction = "positive" if sentiment_score > 0 else "negative" if sentiment_score < 0 else "neutral"
    return (
        f"{symbol} has {direction} social sentiment from {evidence_count} evidence item(s) "
        f"across {source_count} source(s). Confidence is {confidence:.0%}. Action: {action}."
    )


def get_config_if_available() -> SchwabConfig | None:
    try:
        return SchwabConfig.from_env()
    except Exception:
        return None