view schwab_trader/dashboard_server.py @ 234:1de6f553cbc0

[resume] Add Microsoft Member of Technical Staff role
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 18:27:15 -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())