view schwab_trader/dashboard_server.py @ 279:b3b547563ec7

Add Google connector service and agent wiki Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code. Co-authored-by: Copilot <[email protected]> Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:22:36 -0700
parents eb8b4230fdb9
children
line wrap: on
line source

from __future__ import annotations

import argparse
import json
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse

from schwab_trader.dashboard import DashboardStore, EvidenceInput, get_config_if_available
from schwab_trader.schwab_client import SchwabError, get_account, get_account_numbers, get_accounts, load_tokens


INDEX_HTML = """<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Schwab Sentiment Dashboard</title>
  <style>
    :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background: #0b1020; color: #eef2ff; }
    body { margin: 0; }
    header { padding: 24px; background: linear-gradient(135deg, #172554, #0f172a); border-bottom: 1px solid #334155; }
    h1 { margin: 0 0 8px; font-size: 28px; }
    main { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); padding: 16px; }
    section { background: #111827; border: 1px solid #334155; border-radius: 14px; padding: 16px; box-shadow: 0 10px 25px #0004; }
    h2 { margin-top: 0; font-size: 18px; }
    label { display: block; margin: 10px 0 4px; color: #cbd5e1; }
    input, textarea, select, button { width: 100%; box-sizing: border-box; border-radius: 8px; border: 1px solid #475569; background: #020617; color: #f8fafc; padding: 10px; }
    button { margin-top: 12px; background: #2563eb; border: 0; font-weight: 700; cursor: pointer; }
    button.secondary { background: #334155; }
    pre { overflow: auto; white-space: pre-wrap; word-break: break-word; background: #020617; padding: 12px; border-radius: 8px; }
    table { width: 100%; border-collapse: collapse; font-size: 13px; }
    th, td { border-bottom: 1px solid #334155; padding: 8px; text-align: left; vertical-align: top; }
    .ok { color: #86efac; }
    .warn { color: #fbbf24; }
    .bad { color: #fca5a5; }
    .span2 { grid-column: 1 / -1; }
  </style>
</head>
<body>
  <header>
    <h1>Schwab Sentiment Dashboard</h1>
    <div>This is local-first and read/paper-trade focused. There is no live-trade endpoint in this dashboard.</div>
  </header>
  <main>
    <section>
      <h2>Status</h2>
      <button onclick="loadAll()">Refresh</button>
      <pre id="status">Loading...</pre>
    </section>

    <section>
      <h2>Settings</h2>
      <label>Profit target %</label><input id="profit_target_pct" type="number" step="0.1">
      <label>Stop loss %</label><input id="stop_loss_pct" type="number" step="0.1">
      <label>Max dollars per paper trade</label><input id="max_trade_dollars" type="number" step="1">
      <label>Minimum confidence</label><input id="min_confidence" type="number" step="0.01" min="0" max="1">
      <button onclick="saveSettings()">Save Settings</button>
      <pre id="settingsResult"></pre>
    </section>

    <section>
      <h2>Add Social Evidence</h2>
      <label>Source</label><input id="source" placeholder="reddit">
      <label>Symbol</label><input id="symbol" placeholder="AAPL">
      <label>URL</label><input id="url" placeholder="https://...">
      <label>Engagement</label><input id="engagement" type="number" value="0">
      <label>Text</label><textarea id="text" rows="5" placeholder="$AAPL looks bullish..."></textarea>
      <button onclick="addEvidence()">Add Evidence</button>
      <pre id="evidenceResult"></pre>
    </section>

    <section>
      <h2>Paper Trade</h2>
      <label>Action</label><select id="paperAction"><option>BUY</option><option>SELL</option></select>
      <label>Symbol</label><input id="paperSymbol" placeholder="AAPL">
      <label>Quantity</label><input id="paperQuantity" type="number" step="0.01" value="1">
      <label>Price</label><input id="paperPrice" type="number" step="0.01">
      <label>Reason</label><input id="paperReason" placeholder="manual paper trade">
      <button onclick="addPaperTrade()">Create Paper Trade</button>
      <pre id="paperResult"></pre>
    </section>

    <section class="span2">
      <h2>Signals</h2>
      <div id="signals"></div>
    </section>

    <section>
      <h2>Recent Evidence</h2>
      <div id="evidence"></div>
    </section>

    <section>
      <h2>Paper Trades</h2>
      <div id="paperTrades"></div>
    </section>

    <section class="span2">
      <h2>Audit Log</h2>
      <div id="audit"></div>
    </section>
  </main>
  <script>
    async function api(path, options = {}) {
      const response = await fetch(path, {
        headers: {'Content-Type': 'application/json'},
        ...options
      });
      const body = await response.json();
      if (!response.ok) throw new Error(body.error || response.statusText);
      return body;
    }

    function json(id, value) {
      document.getElementById(id).textContent = JSON.stringify(value, null, 2);
    }

    function table(rows, cols) {
      if (!rows.length) return '<p class="warn">No data yet.</p>';
      return '<table><thead><tr>' + cols.map(c => `<th>${c}</th>`).join('') +
        '</tr></thead><tbody>' + rows.map(row => '<tr>' + cols.map(c => `<td>${row[c] ?? ''}</td>`).join('') + '</tr>').join('') + '</tbody></table>';
    }

    async function loadAll() {
      const [status, settings, signals, evidence, paperTrades, audit] = await Promise.all([
        api('/api/status'), api('/api/settings'), api('/api/signals'),
        api('/api/evidence'), api('/api/paper-trades'), api('/api/audit')
      ]);
      json('status', status);
      for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) {
        document.getElementById(key).value = settings[key];
      }
      document.getElementById('signals').innerHTML = table(signals, ['symbol', 'action', 'sentiment_score', 'confidence', 'evidence_count', 'source_count', 'summary']);
      document.getElementById('evidence').innerHTML = table(evidence, ['id', 'source', 'symbol', 'sentiment_score', 'engagement', 'text']);
      document.getElementById('paperTrades').innerHTML = table(paperTrades, ['id', 'symbol', 'action', 'quantity', 'price', 'notional', 'status', 'reason']);
      document.getElementById('audit').innerHTML = table(audit, ['id', 'event_type', 'message', 'created_at']);
    }

    async function saveSettings() {
      try {
        const body = {};
        for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) {
          body[key] = Number(document.getElementById(key).value);
        }
        json('settingsResult', await api('/api/settings', {method: 'POST', body: JSON.stringify(body)}));
        await loadAll();
      } catch (error) { json('settingsResult', {error: error.message}); }
    }

    async function addEvidence() {
      try {
        const body = {
          source: document.getElementById('source').value,
          symbol: document.getElementById('symbol').value,
          url: document.getElementById('url').value,
          engagement: Number(document.getElementById('engagement').value),
          text: document.getElementById('text').value
        };
        json('evidenceResult', await api('/api/evidence', {method: 'POST', body: JSON.stringify(body)}));
        await loadAll();
      } catch (error) { json('evidenceResult', {error: error.message}); }
    }

    async function addPaperTrade() {
      try {
        const body = {
          action: document.getElementById('paperAction').value,
          symbol: document.getElementById('paperSymbol').value,
          quantity: Number(document.getElementById('paperQuantity').value),
          price: Number(document.getElementById('paperPrice').value),
          reason: document.getElementById('paperReason').value
        };
        json('paperResult', await api('/api/paper-trades', {method: 'POST', body: JSON.stringify(body)}));
        await loadAll();
      } catch (error) { json('paperResult', {error: error.message}); }
    }

    loadAll();
  </script>
</body>
</html>
"""


def create_handler(store: DashboardStore) -> type[BaseHTTPRequestHandler]:
    class DashboardHandler(BaseHTTPRequestHandler):
        server_version = "SchwabDashboard/0.1"

        def do_GET(self) -> None:
            try:
                parsed = urlparse(self.path)
                query = parse_qs(parsed.query)
                if parsed.path == "/":
                    self._send_html(INDEX_HTML)
                elif parsed.path == "/api/status":
                    self._send_json(store.get_status())
                elif parsed.path == "/api/settings":
                    self._send_json(store.get_settings())
                elif parsed.path == "/api/evidence":
                    self._send_json(store.list_evidence())
                elif parsed.path == "/api/signals":
                    self._send_json(store.list_signals())
                elif parsed.path == "/api/paper-trades":
                    self._send_json(store.list_paper_trades())
                elif parsed.path == "/api/audit":
                    self._send_json(store.list_audit())
                elif parsed.path == "/api/schwab/account-numbers":
                    self._send_json(_load_schwab_account_numbers())
                elif parsed.path == "/api/schwab/accounts":
                    fields = "positions" if query.get("positions") == ["1"] else None
                    self._send_json(_load_schwab_accounts(fields))
                elif parsed.path == "/api/schwab/account":
                    account_hash = query.get("account_hash", [""])[0]
                    fields = "positions" if query.get("positions") == ["1"] else None
                    self._send_json(_load_schwab_account(account_hash, fields))
                else:
                    self._send_error(HTTPStatus.NOT_FOUND, "Not found")
            except (ValueError, SchwabError) as error:
                self._send_error(HTTPStatus.BAD_REQUEST, str(error))

        def do_POST(self) -> None:
            try:
                parsed = urlparse(self.path)
                payload = self._read_json()
                if parsed.path == "/api/settings":
                    self._send_json(store.update_settings(payload))
                elif parsed.path == "/api/evidence":
                    result = store.add_evidence(
                        EvidenceInput(
                            source=str(payload.get("source", "")),
                            symbol=str(payload["symbol"]) if payload.get("symbol") else None,
                            url=str(payload["url"]) if payload.get("url") else None,
                            text=str(payload.get("text", "")),
                            engagement=float(payload.get("engagement", 0)),
                            raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else None,
                        )
                    )
                    self._send_json(result, HTTPStatus.CREATED)
                elif parsed.path == "/api/paper-trades":
                    self._send_json(store.add_paper_trade(payload), HTTPStatus.CREATED)
                else:
                    self._send_error(HTTPStatus.NOT_FOUND, "Not found")
            except (ValueError, KeyError, SchwabError) as error:
                self._send_error(HTTPStatus.BAD_REQUEST, str(error))

        def log_message(self, format: str, *args: Any) -> None:
            print(f"[dashboard] {self.address_string()} - {format % args}")

        def _read_json(self) -> dict[str, Any]:
            length = int(self.headers.get("Content-Length", "0"))
            if length <= 0:
                return {}
            raw = self.rfile.read(length).decode("utf-8")
            value = json.loads(raw)
            if not isinstance(value, dict):
                raise ValueError("JSON body must be an object")
            return value

        def _send_html(self, body: str) -> None:
            data = body.encode("utf-8")
            self.send_response(HTTPStatus.OK)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def _send_json(self, body: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
            data = json.dumps(body, indent=2, sort_keys=True).encode("utf-8")
            self.send_response(status)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def _send_error(self, status: HTTPStatus, message: str) -> None:
            self._send_json({"error": message}, status)

    return DashboardHandler


def _load_schwab_account_numbers() -> Any:
    config, access_token = _load_schwab_access_token()
    return get_account_numbers(access_token).body


def _load_schwab_accounts(fields: str | None) -> Any:
    config, access_token = _load_schwab_access_token()
    return get_accounts(access_token, fields).body


def _load_schwab_account(account_hash: str, fields: str | None) -> Any:
    if not account_hash:
        raise ValueError("account_hash is required")
    config, access_token = _load_schwab_access_token()
    return get_account(access_token, account_hash, fields).body


def _load_schwab_access_token() -> tuple[Any, str]:
    config = get_config_if_available()
    if config is None:
        raise SchwabError("Schwab environment is not configured")
    tokens = load_tokens(config.token_file)
    access_token = tokens.get("access_token")
    if not access_token:
        raise SchwabError(f"No access_token in {config.token_file}")
    return config, access_token


def run(host: str, port: int, db_path: str | None = None) -> None:
    store = DashboardStore(db_path)
    server = ThreadingHTTPServer((host, port), create_handler(store))
    print(f"Schwab dashboard listening on http://{host}:{port}")
    print("Live trading is disabled in this dashboard.")
    server.serve_forever()


def main() -> int:
    parser = argparse.ArgumentParser(description="Run the local Schwab sentiment dashboard")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", default=8765, type=int)
    parser.add_argument("--db", help="SQLite dashboard DB path")
    args = parser.parse_args()
    run(args.host, args.port, args.db)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())