CiscsoPonce commited on
Commit
ffc1e30
·
1 Parent(s): 1358dec

feat: VPS dashboard + LangSmith online evaluators and prompt versioning

Browse files

- Add GET /portfolio/summary (lightweight stats, no yFinance calls)
- Add GET /dashboard serving a full Chart.js portfolio dashboard
- Create src/core/online_eval.py with inline format_score and
verdict_validity evaluators that post LangSmith feedback per run
- Add tag_for_review() to annotate WATCH/fallback runs for human review
- Support PROMPT_VERSION env var in senior_broker.py for Hub A/B testing

Made-with: Cursor

src/agent.py CHANGED
@@ -32,6 +32,7 @@ from src.core.search import brave_search
32
  from src.core.ticker_utils import extract_tickers, resolve_ticker_suffix, normalize_price
33
  from src.core.memory import load_seen_tickers, mark_ticker_seen
34
  from src.core.state import AgentState
 
35
  from src.prompts.senior_broker import get_analyst_prompt
36
 
37
  from src.discovery.screener import screen_microcaps, get_trending_tickers_from_brave
@@ -348,6 +349,9 @@ def analyst_node(state):
348
  structured_verdict=result.verdict,
349
  position_size=result.position_size)
350
 
 
 
 
351
  return {
352
  "final_verdict": verdict, "final_report": verdict,
353
  "chart_data": chart_bytes, "debate_used": True,
@@ -388,6 +392,9 @@ def analyst_node(state):
388
  record_paper_trade(ticker, price, verdict, source="Chainlit UI",
389
  structured_verdict=result.verdict,
390
  position_size=result.position_size)
 
 
 
391
  except Exception as exc:
392
  logger.warning("Structured output failed for %s, falling back to plain LLM: %s", ticker, exc)
393
  try:
@@ -409,6 +416,9 @@ def analyst_node(state):
409
  )
410
  record_paper_trade(ticker, price, verdict, source="Chainlit UI",
411
  position_size=pos)
 
 
 
412
  except Exception as exc2:
413
  logger.error("LLM analysis failed for %s: %s", ticker, exc2)
414
  verdict = f"Strategy: {strategy}\nLLM analysis unavailable: {exc2}"
 
32
  from src.core.ticker_utils import extract_tickers, resolve_ticker_suffix, normalize_price
33
  from src.core.memory import load_seen_tickers, mark_ticker_seen
34
  from src.core.state import AgentState
35
+ from src.core.online_eval import log_online_feedback, tag_for_review, get_current_run_id
36
  from src.prompts.senior_broker import get_analyst_prompt
37
 
38
  from src.discovery.screener import screen_microcaps, get_trending_tickers_from_brave
 
349
  structured_verdict=result.verdict,
350
  position_size=result.position_size)
351
 
352
+ _run_id = get_current_run_id()
353
+ log_online_feedback(verdict, ticker, run_id=_run_id)
354
+ tag_for_review(verdict, ticker, run_id=_run_id)
355
  return {
356
  "final_verdict": verdict, "final_report": verdict,
357
  "chart_data": chart_bytes, "debate_used": True,
 
392
  record_paper_trade(ticker, price, verdict, source="Chainlit UI",
393
  structured_verdict=result.verdict,
394
  position_size=result.position_size)
395
+ _run_id = get_current_run_id()
396
+ log_online_feedback(verdict, ticker, run_id=_run_id)
397
+ tag_for_review(verdict, ticker, run_id=_run_id)
398
  except Exception as exc:
399
  logger.warning("Structured output failed for %s, falling back to plain LLM: %s", ticker, exc)
400
  try:
 
416
  )
417
  record_paper_trade(ticker, price, verdict, source="Chainlit UI",
418
  position_size=pos)
419
+ _run_id = get_current_run_id()
420
+ log_online_feedback(verdict, ticker, run_id=_run_id, is_fallback=True)
421
+ tag_for_review(verdict, ticker, run_id=_run_id, is_fallback=True)
422
  except Exception as exc2:
423
  logger.error("LLM analysis failed for %s: %s", ticker, exc2)
424
  verdict = f"Strategy: {strategy}\nLLM analysis unavailable: {exc2}"
src/core/online_eval.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inline (online) evaluators — run after each analyst verdict during cron.
2
+
3
+ Only runs the *cheap* evaluators (no LLM calls):
4
+ - format_score: structural checks (headers, duplicates, Kelly section)
5
+ - verdict_validity_score: valid verdict keyword present
6
+
7
+ Results are logged as LangSmith feedback on the current run.
8
+ Falls back silently if LangSmith is not configured.
9
+ """
10
+
11
+ import os
12
+ import re
13
+
14
+ from src.core.logger import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ VALID_VERDICTS = {"STRONG BUY", "BUY", "WATCH", "AVOID"}
19
+
20
+ REQUIRED_HEADERS = [
21
+ "### THE QUANTITATIVE BASE",
22
+ "### THE LYNCH PITCH",
23
+ "### THE MUNGER INVERT",
24
+ "### FINAL VERDICT",
25
+ ]
26
+
27
+
28
+ def _format_score(verdict_text: str) -> dict:
29
+ """Check structural correctness of the verdict report."""
30
+ if not verdict_text or "REJECTED" in verdict_text.upper():
31
+ return {"key": "format_score", "score": 1.0, "comment": "Rejected/empty, N/A"}
32
+
33
+ issues = []
34
+ total_checks = 0
35
+
36
+ for header in REQUIRED_HEADERS:
37
+ total_checks += 1
38
+ count = verdict_text.count(header)
39
+ if count == 0:
40
+ issues.append(f"Missing: {header}")
41
+ elif count > 1:
42
+ issues.append(f"Duplicated ({count}x): {header}")
43
+
44
+ upper = verdict_text.upper()
45
+ is_buy = "STRONG BUY" in upper or ("BUY" in upper and "AVOID" not in upper)
46
+
47
+ if is_buy:
48
+ total_checks += 1
49
+ if "POSITION SIZING" not in verdict_text and "Kelly" not in verdict_text:
50
+ issues.append("Missing Kelly section for BUY verdict")
51
+
52
+ passed = total_checks - len(issues)
53
+ score = passed / total_checks if total_checks > 0 else 1.0
54
+
55
+ return {
56
+ "key": "format_score",
57
+ "score": score,
58
+ "comment": "; ".join(issues) if issues else "All format checks passed",
59
+ }
60
+
61
+
62
+ def _verdict_validity_score(verdict_text: str) -> dict:
63
+ """Check that the final verdict is one of the 4 valid values."""
64
+ if not verdict_text or "REJECTED" in verdict_text.upper():
65
+ return {"key": "verdict_validity", "score": 1.0, "comment": "Rejected, N/A"}
66
+
67
+ upper = verdict_text.upper()
68
+ found = None
69
+ if "STRONG BUY" in upper:
70
+ found = "STRONG BUY"
71
+ elif "BUY" in upper:
72
+ found = "BUY"
73
+ elif "WATCH" in upper:
74
+ found = "WATCH"
75
+ elif "AVOID" in upper:
76
+ found = "AVOID"
77
+
78
+ if found and found in VALID_VERDICTS:
79
+ return {"key": "verdict_validity", "score": 1.0, "comment": f"Valid: {found}"}
80
+ return {"key": "verdict_validity", "score": 0.0, "comment": "Invalid/missing verdict"}
81
+
82
+
83
+ def log_online_feedback(
84
+ verdict_text: str,
85
+ ticker: str,
86
+ *,
87
+ run_id: str | None = None,
88
+ is_fallback: bool = False,
89
+ ) -> None:
90
+ """Run cheap evaluators and post results as LangSmith feedback.
91
+
92
+ Requires LANGCHAIN_API_KEY and LANGCHAIN_TRACING_V2=true in env.
93
+ Fails silently if LangSmith is unavailable.
94
+ """
95
+ api_key = os.getenv("LANGCHAIN_API_KEY", "")
96
+ tracing = os.getenv("LANGCHAIN_TRACING_V2", "").lower() == "true"
97
+
98
+ if not api_key or not tracing:
99
+ return
100
+
101
+ evals = [
102
+ _format_score(verdict_text),
103
+ _verdict_validity_score(verdict_text),
104
+ ]
105
+
106
+ try:
107
+ from langsmith import Client
108
+
109
+ client = Client()
110
+
111
+ for ev in evals:
112
+ client.create_feedback(
113
+ run_id=run_id,
114
+ key=ev["key"],
115
+ score=ev["score"],
116
+ comment=f"[{ticker}] {ev['comment']}",
117
+ source_info={"type": "online_eval", "ticker": ticker},
118
+ ) if run_id else None
119
+
120
+ logger.info(
121
+ "Online eval [%s] %s: %.2f — %s",
122
+ ticker, ev["key"], ev["score"], ev["comment"],
123
+ )
124
+
125
+ except Exception as exc:
126
+ logger.debug("LangSmith feedback skipped: %s", exc)
127
+
128
+
129
+ def tag_for_review(
130
+ verdict_text: str,
131
+ ticker: str,
132
+ *,
133
+ run_id: str | None = None,
134
+ is_fallback: bool = False,
135
+ ) -> None:
136
+ """Tag LangSmith runs that need human review.
137
+
138
+ Criteria:
139
+ - WATCH or AVOID verdicts (edge cases worth reviewing)
140
+ - Fallback-path verdicts (structured output failed)
141
+ """
142
+ api_key = os.getenv("LANGCHAIN_API_KEY", "")
143
+ tracing = os.getenv("LANGCHAIN_TRACING_V2", "").lower() == "true"
144
+
145
+ if not api_key or not tracing or not run_id:
146
+ return
147
+
148
+ upper = (verdict_text or "").upper()
149
+ needs_review = is_fallback or "WATCH" in upper or "AVOID" in upper
150
+
151
+ if not needs_review:
152
+ return
153
+
154
+ reasons = []
155
+ if is_fallback:
156
+ reasons.append("fallback_path")
157
+ if "WATCH" in upper:
158
+ reasons.append("WATCH_verdict")
159
+ if "AVOID" in upper:
160
+ reasons.append("AVOID_verdict")
161
+
162
+ try:
163
+ from langsmith import Client
164
+
165
+ client = Client()
166
+ client.update_run(
167
+ run_id,
168
+ extra={
169
+ "metadata": {
170
+ "needs_review": True,
171
+ "review_reasons": reasons,
172
+ "ticker": ticker,
173
+ }
174
+ },
175
+ tags=["needs_review"] + reasons,
176
+ )
177
+ logger.info(
178
+ "Tagged run %s for review: %s (%s)",
179
+ run_id[:8] if run_id else "?", ticker, ", ".join(reasons),
180
+ )
181
+ except Exception as exc:
182
+ logger.debug("LangSmith annotation skipped: %s", exc)
183
+
184
+
185
+ def get_current_run_id() -> str | None:
186
+ """Attempt to retrieve the current LangSmith run ID from callback context."""
187
+ try:
188
+ from langsmith import get_current_run_tree
189
+ rt = get_current_run_tree()
190
+ return str(rt.id) if rt else None
191
+ except Exception:
192
+ return None
src/prompts/senior_broker.py CHANGED
@@ -53,19 +53,28 @@ def get_analyst_prompt() -> str:
53
 
54
  Tries LangSmith Hub first (if LANGCHAIN_API_KEY is set), otherwise
55
  returns the local fallback.
 
 
 
 
56
  """
57
  if os.getenv("LANGCHAIN_API_KEY"):
58
  try:
59
  from langsmith import Client
60
 
61
  client = Client()
62
- hub_prompt = client.pull_prompt("primogreedy/senior-broker")
 
 
 
 
 
 
 
63
 
64
- # Extract the template string from the ChatPromptTemplate
65
  messages = hub_prompt.messages
66
  if messages:
67
  template_str = messages[0].prompt.template
68
- logger.info("Loaded analyst prompt from LangSmith Hub")
69
  return template_str
70
  except Exception as exc:
71
  logger.warning("Hub pull failed, using local fallback: %s", exc)
 
53
 
54
  Tries LangSmith Hub first (if LANGCHAIN_API_KEY is set), otherwise
55
  returns the local fallback.
56
+
57
+ Supports ``PROMPT_VERSION`` env var for pinning to a specific Hub
58
+ commit. Set to "latest" (default) or a commit hash like
59
+ "abc123def456" to lock a specific version during A/B testing.
60
  """
61
  if os.getenv("LANGCHAIN_API_KEY"):
62
  try:
63
  from langsmith import Client
64
 
65
  client = Client()
66
+ version = os.getenv("PROMPT_VERSION", "latest").strip()
67
+ prompt_id = "primogreedy/senior-broker"
68
+ if version and version != "latest":
69
+ prompt_id = f"{prompt_id}:{version}"
70
+ logger.info("Pulling Hub prompt pinned to %s", version[:12])
71
+
72
+ hub_prompt = client.pull_prompt(prompt_id)
73
+ logger.info("Loaded analyst prompt from Hub (%s)", version)
74
 
 
75
  messages = hub_prompt.messages
76
  if messages:
77
  template_str = messages[0].prompt.template
 
78
  return template_str
79
  except Exception as exc:
80
  logger.warning("Hub pull failed, using local fallback: %s", exc)
src/whale_hunter.py CHANGED
@@ -41,6 +41,7 @@ from src.core.search import brave_search
41
  from src.core.ticker_utils import normalize_price, REGION_SUFFIXES
42
  from src.core.memory import load_seen_tickers, mark_ticker_seen
43
  from src.core.state import AgentState
 
44
 
45
  from src.discovery.screener import screen_microcaps, get_trending_tickers_from_brave
46
  from src.discovery.scoring import rank_candidates
@@ -268,6 +269,9 @@ def analyst_node(state):
268
  structured_verdict=result.verdict,
269
  position_size=result.position_size)
270
 
 
 
 
271
  return {
272
  "final_verdict": verdict, "debate_used": True,
273
  "bull_case": debate_result.get("bull_case", ""),
@@ -326,6 +330,9 @@ def analyst_node(state):
326
  record_paper_trade(ticker, price, verdict, source="Morning Cron",
327
  structured_verdict=result.verdict,
328
  position_size=result.position_size)
 
 
 
329
  except Exception as exc:
330
  logger.warning("Structured output failed for %s, falling back to plain LLM: %s", ticker, exc)
331
  try:
@@ -347,6 +354,9 @@ def analyst_node(state):
347
  )
348
  record_paper_trade(ticker, price, verdict, source="Morning Cron",
349
  position_size=pos)
 
 
 
350
  except Exception as exc2:
351
  logger.error("LLM analysis failed for %s: %s", ticker, exc2)
352
  verdict = f"LLM analysis unavailable: {exc2}"
 
41
  from src.core.ticker_utils import normalize_price, REGION_SUFFIXES
42
  from src.core.memory import load_seen_tickers, mark_ticker_seen
43
  from src.core.state import AgentState
44
+ from src.core.online_eval import log_online_feedback, tag_for_review, get_current_run_id
45
 
46
  from src.discovery.screener import screen_microcaps, get_trending_tickers_from_brave
47
  from src.discovery.scoring import rank_candidates
 
269
  structured_verdict=result.verdict,
270
  position_size=result.position_size)
271
 
272
+ _run_id = get_current_run_id()
273
+ log_online_feedback(verdict, ticker, run_id=_run_id)
274
+ tag_for_review(verdict, ticker, run_id=_run_id)
275
  return {
276
  "final_verdict": verdict, "debate_used": True,
277
  "bull_case": debate_result.get("bull_case", ""),
 
330
  record_paper_trade(ticker, price, verdict, source="Morning Cron",
331
  structured_verdict=result.verdict,
332
  position_size=result.position_size)
333
+ _run_id = get_current_run_id()
334
+ log_online_feedback(verdict, ticker, run_id=_run_id)
335
+ tag_for_review(verdict, ticker, run_id=_run_id)
336
  except Exception as exc:
337
  logger.warning("Structured output failed for %s, falling back to plain LLM: %s", ticker, exc)
338
  try:
 
354
  )
355
  record_paper_trade(ticker, price, verdict, source="Morning Cron",
356
  position_size=pos)
357
+ _run_id = get_current_run_id()
358
+ log_online_feedback(verdict, ticker, run_id=_run_id, is_fallback=True)
359
+ tag_for_review(verdict, ticker, run_id=_run_id, is_fallback=True)
360
  except Exception as exc2:
361
  logger.error("LLM analysis failed for %s: %s", ticker, exc2)
362
  verdict = f"LLM analysis unavailable: {exc2}"
vps/api.py CHANGED
@@ -8,6 +8,7 @@ Usage:
8
  """
9
 
10
  import os
 
11
  import time
12
  from contextlib import asynccontextmanager
13
  from datetime import datetime, timezone
@@ -17,6 +18,7 @@ import duckdb
17
  import yfinance as yf
18
  from dotenv import load_dotenv
19
  from fastapi import FastAPI, Header, HTTPException, Query
 
20
  from pydantic import BaseModel
21
 
22
  load_dotenv()
@@ -315,3 +317,319 @@ def evaluate_portfolio(x_api_key: str = Header(...)):
315
  "avg_return": round(avg_roi, 2),
316
  "trades": trades,
317
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  import os
11
+ import re
12
  import time
13
  from contextlib import asynccontextmanager
14
  from datetime import datetime, timezone
 
18
  import yfinance as yf
19
  from dotenv import load_dotenv
20
  from fastapi import FastAPI, Header, HTTPException, Query
21
+ from fastapi.responses import HTMLResponse
22
  from pydantic import BaseModel
23
 
24
  load_dotenv()
 
317
  "avg_return": round(avg_roi, 2),
318
  "trades": trades,
319
  }
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Portfolio Summary (lightweight, no yFinance calls)
324
+ # ---------------------------------------------------------------------------
325
+
326
+ @app.get("/portfolio/summary")
327
+ def portfolio_summary(x_api_key: str = Header(...)):
328
+ """Aggregated portfolio stats without live price lookups."""
329
+ verify_key(x_api_key)
330
+ con = get_db()
331
+
332
+ trades = con.execute(
333
+ """SELECT ticker, entry_price, date, verdict, source,
334
+ position_size, order_id, fill_price, broker_status
335
+ FROM paper_portfolio ORDER BY date DESC"""
336
+ ).fetchall()
337
+
338
+ seen_count = con.execute("SELECT COUNT(*) FROM seen_tickers").fetchone()[0]
339
+
340
+ runs = con.execute(
341
+ """SELECT id, ticker, timestamp, status, region
342
+ FROM agent_runs ORDER BY timestamp DESC LIMIT 20"""
343
+ ).fetchall()
344
+
345
+ con.close()
346
+
347
+ by_verdict: dict[str, int] = {}
348
+ by_source: dict[str, int] = {}
349
+ recent_trades = []
350
+
351
+ for r in trades:
352
+ v = _extract_verdict_label(r[3])
353
+ by_verdict[v] = by_verdict.get(v, 0) + 1
354
+ src = r[4] or "unknown"
355
+ by_source[src] = by_source.get(src, 0) + 1
356
+
357
+ trade_obj = {
358
+ "ticker": r[0], "entry_price": r[1], "date": str(r[2]),
359
+ "verdict": v, "source": src,
360
+ "position_size": r[5], "order_id": r[6],
361
+ "fill_price": r[7], "broker_status": r[8] or "none",
362
+ }
363
+ if len(recent_trades) < 15:
364
+ recent_trades.append(trade_obj)
365
+
366
+ recent_runs = [
367
+ {"id": r[0], "ticker": r[1], "timestamp": str(r[2]),
368
+ "status": r[3], "region": r[4]}
369
+ for r in runs
370
+ ]
371
+
372
+ return {
373
+ "total_trades": len(trades),
374
+ "by_verdict": by_verdict,
375
+ "by_source": by_source,
376
+ "seen_tickers_count": seen_count,
377
+ "recent_trades": recent_trades,
378
+ "recent_runs": recent_runs,
379
+ }
380
+
381
+
382
+ def _extract_verdict_label(verdict_text: str) -> str:
383
+ """Pull the verdict keyword from the full verdict text."""
384
+ upper = (verdict_text or "").upper()
385
+ if "STRONG BUY" in upper:
386
+ return "STRONG BUY"
387
+ if "BUY" in upper:
388
+ return "BUY"
389
+ if "WATCH" in upper:
390
+ return "WATCH"
391
+ if "AVOID" in upper:
392
+ return "AVOID"
393
+ return "OTHER"
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Dashboard (public — no API key, behind Tailscale)
398
+ # ---------------------------------------------------------------------------
399
+
400
+ @app.get("/dashboard", response_class=HTMLResponse)
401
+ def dashboard():
402
+ """Serve the live portfolio dashboard."""
403
+ return _DASHBOARD_HTML
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # Dashboard HTML (inline — no static file dependencies)
408
+ # ---------------------------------------------------------------------------
409
+
410
+ _DASHBOARD_HTML = """\
411
+ <!DOCTYPE html>
412
+ <html lang="en">
413
+ <head>
414
+ <meta charset="UTF-8">
415
+ <meta name="viewport" content="width=device-width,initial-scale=1">
416
+ <title>PrimoGreedy Dashboard</title>
417
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
418
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
419
+ <style>
420
+ :root{--bg:#0a0e17;--s1:#111827;--s2:#1a2236;--bd:#1e293b;--cy:#22d3ee;--pu:#a78bfa;
421
+ --gn:#34d399;--rd:#f87171;--yl:#fbbf24;--tx:#e2e8f0;--td:#94a3b8;--tw:#f8fafc}
422
+ *{margin:0;padding:0;box-sizing:border-box}
423
+ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--tx);line-height:1.5}
424
+ .wrap{max-width:1200px;margin:0 auto;padding:24px}
425
+ header{display:flex;align-items:center;justify-content:space-between;margin-bottom:32px;flex-wrap:wrap;gap:12px}
426
+ header h1{font-size:28px;font-weight:800;letter-spacing:-1px}
427
+ header h1 span{background:linear-gradient(135deg,var(--cy),var(--pu));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
428
+ .badge{font-size:12px;padding:4px 12px;border:1px solid var(--bd);border-radius:99px;color:var(--gn);background:rgba(52,211,153,.08)}
429
+ .badge .dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--gn);margin-right:6px;animation:pulse 2s infinite}
430
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
431
+ .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:32px}
432
+ .card{background:var(--s1);border:1px solid var(--bd);border-radius:12px;padding:20px}
433
+ .card .label{font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--td);margin-bottom:4px}
434
+ .card .val{font-size:32px;font-weight:700;color:var(--tw)}
435
+ .card .sub{font-size:13px;color:var(--td);margin-top:4px}
436
+ .card .val.green{color:var(--gn)}.card .val.red{color:var(--rd)}
437
+ .grid2{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:32px}
438
+ @media(max-width:768px){.grid2{grid-template-columns:1fr}}
439
+ .panel{background:var(--s1);border:1px solid var(--bd);border-radius:12px;padding:24px}
440
+ .panel h2{font-size:16px;margin-bottom:16px;color:var(--tw)}
441
+ canvas{max-height:260px}
442
+ table{width:100%;border-collapse:collapse;font-size:13px}
443
+ th{text-align:left;padding:10px 12px;border-bottom:1px solid var(--bd);color:var(--td);font-weight:600;
444
+ text-transform:uppercase;letter-spacing:.5px;font-size:11px;cursor:pointer;user-select:none}
445
+ th:hover{color:var(--cy)}
446
+ td{padding:10px 12px;border-bottom:1px solid rgba(30,41,59,.5)}
447
+ tr:hover td{background:rgba(34,211,238,.03)}
448
+ .ticker{font-family:'JetBrains Mono',monospace;font-weight:600;color:var(--cy)}
449
+ .verdict-buy{color:var(--gn);font-weight:600}.verdict-watch{color:var(--yl);font-weight:600}
450
+ .verdict-avoid{color:var(--rd);font-weight:600}.verdict-strong{color:#2dd4bf;font-weight:700}
451
+ .gain-pos{color:var(--gn)}.gain-neg{color:var(--rd)}
452
+ .broker-filled{color:var(--gn);font-size:11px}.broker-pending{color:var(--yl);font-size:11px}
453
+ .broker-none{color:var(--td);font-size:11px}
454
+ .activity{list-style:none;max-height:320px;overflow-y:auto}
455
+ .activity li{padding:8px 0;border-bottom:1px solid rgba(30,41,59,.4);font-size:13px;display:flex;justify-content:space-between}
456
+ .activity .ts{color:var(--td);font-size:11px;font-family:'JetBrains Mono',monospace}
457
+ .refresh{font-size:11px;color:var(--td);text-align:center;margin-top:24px}
458
+ .loading{text-align:center;padding:40px;color:var(--td)}
459
+ </style>
460
+ </head>
461
+ <body>
462
+ <div class="wrap">
463
+ <header>
464
+ <h1>Primo<span>Greedy</span> Dashboard</h1>
465
+ <div class="badge"><span class="dot"></span>Live — Auto-refresh 5m</div>
466
+ </header>
467
+
468
+ <div class="cards" id="cards"><div class="loading">Loading...</div></div>
469
+
470
+ <div class="grid2">
471
+ <div class="panel"><h2>Verdict Distribution</h2><canvas id="verdictChart"></canvas></div>
472
+ <div class="panel"><h2>Position Sizing</h2><canvas id="sizingChart"></canvas></div>
473
+ </div>
474
+
475
+ <div class="panel" style="margin-bottom:32px">
476
+ <h2>Portfolio</h2>
477
+ <div style="overflow-x:auto">
478
+ <table id="portfolioTable">
479
+ <thead><tr>
480
+ <th data-col="ticker">Ticker</th><th data-col="date">Date</th>
481
+ <th data-col="entry_price">Entry</th><th data-col="verdict">Verdict</th>
482
+ <th data-col="position_size">Kelly %</th><th data-col="broker_status">Broker</th>
483
+ <th data-col="source">Source</th>
484
+ </tr></thead>
485
+ <tbody id="tbody"></tbody>
486
+ </table>
487
+ </div>
488
+ </div>
489
+
490
+ <div class="grid2">
491
+ <div class="panel"><h2>Recent Agent Runs</h2><ul class="activity" id="runs"></ul></div>
492
+ <div class="panel"><h2>Seen Tickers (Active)</h2><div id="seenInfo" style="margin-bottom:12px"></div><ul class="activity" id="seen"></ul></div>
493
+ </div>
494
+
495
+ <div class="refresh" id="refreshNote"></div>
496
+ </div>
497
+
498
+ <script>
499
+ const API_KEY = '""" + API_KEY + """';
500
+ const H = {'X-API-Key': API_KEY};
501
+ let sortCol = 'date', sortAsc = false;
502
+
503
+ async function load() {
504
+ try {
505
+ const [sumR, seenR] = await Promise.all([
506
+ fetch('/portfolio/summary', {headers: H}),
507
+ fetch('/seen-tickers', {headers: H})
508
+ ]);
509
+ const sum = await sumR.json();
510
+ const seen = await seenR.json();
511
+ renderCards(sum, seen);
512
+ renderVerdictChart(sum.by_verdict);
513
+ renderSizingChart(sum.recent_trades);
514
+ renderTable(sum.recent_trades);
515
+ renderRuns(sum.recent_runs);
516
+ renderSeen(seen);
517
+ document.getElementById('refreshNote').textContent =
518
+ 'Last refresh: ' + new Date().toLocaleTimeString() + ' — next in 5 min';
519
+ } catch(e) {
520
+ document.getElementById('cards').innerHTML =
521
+ '<div class="loading">Error loading data: ' + e.message + '</div>';
522
+ }
523
+ }
524
+
525
+ function renderCards(sum, seen) {
526
+ const buys = (sum.by_verdict['BUY']||0) + (sum.by_verdict['STRONG BUY']||0);
527
+ const avoids = sum.by_verdict['AVOID']||0;
528
+ const watches = sum.by_verdict['WATCH']||0;
529
+ const seenCount = Object.keys(seen).length;
530
+ document.getElementById('cards').innerHTML = `
531
+ <div class="card"><div class="label">Total Trades</div><div class="val">${sum.total_trades}</div>
532
+ <div class="sub">${buys} buys, ${watches} watch, ${avoids} avoid</div></div>
533
+ <div class="card"><div class="label">Buy Rate</div>
534
+ <div class="val green">${sum.total_trades ? Math.round(buys/sum.total_trades*100) : 0}%</div>
535
+ <div class="sub">${buys} actionable of ${sum.total_trades}</div></div>
536
+ <div class="card"><div class="label">Seen Tickers</div><div class="val">${seenCount}</div>
537
+ <div class="sub">Active in ledger</div></div>
538
+ <div class="card"><div class="label">Sources</div><div class="val">${Object.keys(sum.by_source).length}</div>
539
+ <div class="sub">${Object.entries(sum.by_source).map(([k,v])=>k+': '+v).join(', ')}</div></div>`;
540
+ }
541
+
542
+ let vChart, sChart;
543
+ function renderVerdictChart(bv) {
544
+ const labels = Object.keys(bv), data = Object.values(bv);
545
+ const colors = labels.map(l => l==='BUY'?'#34d399':l==='STRONG BUY'?'#2dd4bf':l==='WATCH'?'#fbbf24':'#f87171');
546
+ if (vChart) vChart.destroy();
547
+ vChart = new Chart(document.getElementById('verdictChart'), {
548
+ type:'doughnut', data:{labels, datasets:[{data, backgroundColor:colors, borderWidth:0}]},
549
+ options:{plugins:{legend:{labels:{color:'#94a3b8',font:{size:12}}}},cutout:'60%'}
550
+ });
551
+ }
552
+
553
+ function renderSizingChart(trades) {
554
+ const filtered = trades.filter(t => t.position_size > 0);
555
+ const labels = filtered.map(t => t.ticker);
556
+ const data = filtered.map(t => t.position_size);
557
+ const colors = filtered.map(t => {
558
+ const v = (t.verdict||'').toUpperCase();
559
+ return v.includes('STRONG')?'#2dd4bf':v.includes('BUY')?'#34d399':v.includes('WATCH')?'#fbbf24':'#f87171';
560
+ });
561
+ if (sChart) sChart.destroy();
562
+ sChart = new Chart(document.getElementById('sizingChart'), {
563
+ type:'bar', data:{labels, datasets:[{label:'Kelly %', data, backgroundColor:colors, borderRadius:4}]},
564
+ options:{plugins:{legend:{display:false}},scales:{
565
+ x:{ticks:{color:'#94a3b8',font:{size:10}},grid:{display:false}},
566
+ y:{ticks:{color:'#94a3b8',callback:v=>v+'%'},grid:{color:'rgba(30,41,59,.5)'}}
567
+ }}
568
+ });
569
+ }
570
+
571
+ function renderTable(trades) {
572
+ const sorted = [...trades].sort((a,b) => {
573
+ let av = a[sortCol], bv = b[sortCol];
574
+ if (typeof av === 'string') { av = av.toLowerCase(); bv = (bv||'').toLowerCase(); }
575
+ if (av < bv) return sortAsc ? -1 : 1;
576
+ if (av > bv) return sortAsc ? 1 : -1;
577
+ return 0;
578
+ });
579
+ const tbody = document.getElementById('tbody');
580
+ tbody.innerHTML = sorted.map(t => {
581
+ const vc = verdictClass(t.verdict);
582
+ const bc = t.broker_status === 'filled' ? 'broker-filled' :
583
+ t.broker_status === 'none' ? 'broker-none' : 'broker-pending';
584
+ return `<tr>
585
+ <td class="ticker">${t.ticker}</td><td>${t.date}</td>
586
+ <td>$${t.entry_price.toFixed(2)}</td><td class="${vc}">${t.verdict}</td>
587
+ <td>${t.position_size > 0 ? t.position_size.toFixed(1)+'%' : '—'}</td>
588
+ <td class="${bc}">${t.broker_status||'none'}</td>
589
+ <td>${t.source}</td></tr>`;
590
+ }).join('');
591
+ }
592
+
593
+ function verdictClass(v) {
594
+ const u = (v||'').toUpperCase();
595
+ if (u.includes('STRONG')) return 'verdict-strong';
596
+ if (u.includes('BUY')) return 'verdict-buy';
597
+ if (u.includes('WATCH')) return 'verdict-watch';
598
+ return 'verdict-avoid';
599
+ }
600
+
601
+ function renderRuns(runs) {
602
+ const el = document.getElementById('runs');
603
+ if (!runs || !runs.length) { el.innerHTML = '<li>No runs recorded</li>'; return; }
604
+ el.innerHTML = runs.map(r =>
605
+ `<li><span><span class="ticker">${r.ticker}</span> — ${r.status} (${r.region||'?'})</span>
606
+ <span class="ts">${r.timestamp?.slice(0,16)||''}</span></li>`
607
+ ).join('');
608
+ }
609
+
610
+ function renderSeen(seen) {
611
+ const tickers = Object.entries(seen).sort((a,b) => b[1]-a[1]);
612
+ const el = document.getElementById('seen');
613
+ document.getElementById('seenInfo').innerHTML =
614
+ `<span style="color:var(--td);font-size:13px">${tickers.length} tickers in active ledger</span>`;
615
+ el.innerHTML = tickers.slice(0, 30).map(([t, ts]) => {
616
+ const d = new Date(ts * 1000);
617
+ return `<li><span class="ticker">${t}</span><span class="ts">${d.toLocaleDateString()}</span></li>`;
618
+ }).join('');
619
+ }
620
+
621
+ document.querySelectorAll('th[data-col]').forEach(th => {
622
+ th.addEventListener('click', () => {
623
+ const col = th.dataset.col;
624
+ if (sortCol === col) sortAsc = !sortAsc;
625
+ else { sortCol = col; sortAsc = true; }
626
+ load();
627
+ });
628
+ });
629
+
630
+ load();
631
+ setInterval(load, 5 * 60 * 1000);
632
+ </script>
633
+ </body>
634
+ </html>
635
+ """