Upload analyst.py with huggingface_hub
Browse files- analyst.py +148 -10
analyst.py
CHANGED
|
@@ -52,16 +52,49 @@ DEBATE_CLOSE = re.compile(r"Judge Round|\[4/5\]|── LLM Debate")
|
|
| 52 |
|
| 53 |
# Per-stage result one-liners (label and result share a line via end=" ").
|
| 54 |
# Each becomes a timeline milestone: {step, text, done}.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
MILESTONES = [
|
| 56 |
-
("data", re.compile(r"\[(\d+)/17\]\s*(.+?)\.\.\."),
|
| 57 |
-
("features", re.compile(r"PIT fundamental features\.{3}\s*(.+)"), lambda m:
|
| 58 |
-
("tech", re.compile(r"Technical analysis \(yearly\)\.{3}\s*(.+)"), lambda m: m.group(1)
|
| 59 |
-
("fund", re.compile(r"Fundamental scorecard\.{3}\s*(.+)"), lambda m: m.group(1)
|
| 60 |
-
("sent", re.compile(r"Sentiment analysis\.{3}\s*(.+)"), lambda m: m.group(1)
|
| 61 |
-
("fundml", re.compile(r"Blended fair value:\s*(.+?)(?:\s{2,}|$)"), lambda m: f"
|
| 62 |
-
("macro", re.compile(r"Macro analysis \(yearly \+ yield curve\)\.{3}\s*(.+)"), lambda m: m.group(1)
|
| 63 |
-
("ml", re.compile(r"ML ensemble \(fund\+macro features\)\.{3}\s*(.+)"), lambda m: m.group(1)
|
| 64 |
-
("screen", re.compile(r"
|
| 65 |
("blend", re.compile(r"Quant blend:\s*(.+?)(?:\s{2,}|$)"), lambda m: m.group(1).strip(), True),
|
| 66 |
]
|
| 67 |
|
|
@@ -125,10 +158,18 @@ def _extract_debate(ld: dict) -> dict | None:
|
|
| 125 |
"key_points": entry.get("key_points", []),
|
| 126 |
}
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
return {
|
| 129 |
"recommendation": ld.get("recommendation"),
|
| 130 |
"vote_summary": ld.get("vote_summary"),
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
| 132 |
"bear_model": ld.get("bear_model"),
|
| 133 |
"p_bear": ld.get("p_bear"),
|
| 134 |
"p_bull": ld.get("p_bull"),
|
|
@@ -199,6 +240,24 @@ def _run(job: dict) -> None:
|
|
| 199 |
job["confidence"] = report.get("confidence")
|
| 200 |
job["debate"] = _extract_debate(report.get("llm_debate") or {})
|
| 201 |
job["damodaran"] = report.get("damodaran")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
| 203 |
pass
|
| 204 |
finally:
|
|
@@ -330,12 +389,15 @@ def _save_report(job: dict) -> None:
|
|
| 330 |
"confidence": job["confidence"], "rating": job["rating"],
|
| 331 |
"debate": job["debate"], "damodaran": job["damodaran"],
|
| 332 |
"n_lines": len(job.get("lines") or []),
|
|
|
|
|
|
|
| 333 |
"cached_at": time.time(),
|
| 334 |
"price_at_analysis": (job.get("damodaran") or {}).get("current_price"),
|
| 335 |
"source": job.get("source", "user"),
|
| 336 |
}
|
| 337 |
f = _REPORTS_DIR / f"{job['ticker']}.json"
|
| 338 |
f.write_text(json.dumps(payload))
|
|
|
|
| 339 |
if _HF_TOKEN:
|
| 340 |
try:
|
| 341 |
from huggingface_hub import HfApi
|
|
@@ -347,6 +409,78 @@ def _save_report(job: dict) -> None:
|
|
| 347 |
pass
|
| 348 |
|
| 349 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
def _hydrate_reports() -> None:
|
| 351 |
"""On startup, pull recent reports from the dataset repo so the cache survives redeploys."""
|
| 352 |
if not _HF_TOKEN:
|
|
@@ -522,5 +656,9 @@ def result(job_id: str) -> dict:
|
|
| 522 |
"rating": job["rating"],
|
| 523 |
"debate": job["debate"],
|
| 524 |
"damodaran": job["damodaran"],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
"n_lines": len(job["lines"]),
|
| 526 |
}
|
|
|
|
| 52 |
|
| 53 |
# Per-stage result one-liners (label and result share a line via end=" ").
|
| 54 |
# Each becomes a timeline milestone: {step, text, done}.
|
| 55 |
+
def _pct_word(txt):
|
| 56 |
+
"""'+2.99% (score:16/32 conf:100%)' → '+3.0% expected · scorecard 16/32'."""
|
| 57 |
+
m = re.search(r"([+-]?\d+(?:\.\d+)?)%", txt)
|
| 58 |
+
return f"{float(m.group(1)):+.1f}% expected" if m else txt.strip()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _ml_words(txt):
|
| 62 |
+
"""'+10.73% (dir_acc:65% RankIC:0.183 OOS:60 MAE:13.9% feat:89)' →
|
| 63 |
+
'+10.7% expected · 65% direction accuracy out of sample · typical error ±14%'."""
|
| 64 |
+
r = re.search(r"([+-]?\d+(?:\.\d+)?)%", txt); da = re.search(r"dir_acc:(\d+)%", txt); mae = re.search(r"MAE:(\d+(?:\.\d+)?)%", txt)
|
| 65 |
+
parts = []
|
| 66 |
+
if r: parts.append(f"{float(r.group(1)):+.1f}% expected")
|
| 67 |
+
if da: parts.append(f"{da.group(1)}% direction accuracy out of sample")
|
| 68 |
+
if mae: parts.append(f"typical error ±{float(mae.group(1)):.0f}%")
|
| 69 |
+
if "low reliability" in txt: parts.append("low reliability — near-zero weight")
|
| 70 |
+
return " · ".join(parts) or txt.strip()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _fund_words(txt):
|
| 74 |
+
r = re.search(r"([+-]?\d+(?:\.\d+)?)%", txt); sc = re.search(r"score:(\d+/\d+)", txt)
|
| 75 |
+
out = f"{float(r.group(1)):+.1f}% expected" if r else ""
|
| 76 |
+
if sc: out += f" · scorecard {sc.group(1)}"
|
| 77 |
+
return out or txt.strip()
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _data_words(m):
|
| 81 |
+
step = m.group(2).strip().lower()
|
| 82 |
+
nice = {"daily price history (10yr)": "10 years of daily prices", "company overview": "company profile",
|
| 83 |
+
"income statement": "income statements", "balance sheet": "balance sheets", "cash flow": "cash-flow statements",
|
| 84 |
+
"earnings": "earnings history", "news sentiment": "news & sentiment"}
|
| 85 |
+
return f"{m.group(1)} of 17 sources · {nice.get(step, step)}"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
MILESTONES = [
|
| 89 |
+
("data", re.compile(r"\[(\d+)/17\]\s*(.+?)\.\.\."), _data_words, False),
|
| 90 |
+
("features", re.compile(r"PIT fundamental features\.{3}\s*(.+)"), lambda m: "point-in-time features built — no look-ahead", True),
|
| 91 |
+
("tech", re.compile(r"Technical analysis \(yearly\)\.{3}\s*(.+)"), lambda m: _pct_word(m.group(1)), True),
|
| 92 |
+
("fund", re.compile(r"Fundamental scorecard\.{3}\s*(.+)"), lambda m: _fund_words(m.group(1)), True),
|
| 93 |
+
("sent", re.compile(r"Sentiment analysis\.{3}\s*(.+)"), lambda m: _pct_word(m.group(1)), True),
|
| 94 |
+
("fundml", re.compile(r"Blended fair value:\s*(.+?)(?:\s{2,}|$)"), lambda m: f"market composite {m.group(1).strip()}", True),
|
| 95 |
+
("macro", re.compile(r"Macro analysis \(yearly \+ yield curve\)\.{3}\s*(.+)"), lambda m: ("yield curve normal" if "NORMAL" in m.group(1) else "yield curve inverted — caution") , True),
|
| 96 |
+
("ml", re.compile(r"ML ensemble \(fund\+macro features\)\.{3}\s*(.+)"), lambda m: _ml_words(m.group(1)), True),
|
| 97 |
+
("screen", re.compile(r"Panel-ML rank \(12m cross-sectional[^)]*\):\s*(.+)"), lambda m: re.sub(r"decile \d+/10 · ", "", m.group(1).split("(as-of")[0]).replace("exp ", "expected ").strip(), True),
|
| 98 |
("blend", re.compile(r"Quant blend:\s*(.+?)(?:\s{2,}|$)"), lambda m: m.group(1).strip(), True),
|
| 99 |
]
|
| 100 |
|
|
|
|
| 158 |
"key_points": entry.get("key_points", []),
|
| 159 |
}
|
| 160 |
|
| 161 |
+
_roles_tmp = {
|
| 162 |
+
"neutral": role("round1", "gemini"), "bull_initial": role("round1", "gpt"),
|
| 163 |
+
"bear_initial": role("round1", "kimi"), "pm": role("round2", "claude"),
|
| 164 |
+
}
|
| 165 |
+
_missing = [k for k in ("neutral", "bear_initial", "bull_initial", "pm") if not _roles_tmp.get(k)]
|
| 166 |
return {
|
| 167 |
"recommendation": ld.get("recommendation"),
|
| 168 |
"vote_summary": ld.get("vote_summary"),
|
| 169 |
+
# complete only when EVERY seat produced a result (audit: AVGO's Neutral seat failed
|
| 170 |
+
# silently while the old flag — which only tracked Bull/Bear — still said complete)
|
| 171 |
+
"panel_complete": bool(ld.get("panel_complete", True)) and not _missing,
|
| 172 |
+
"missing_roles": _missing,
|
| 173 |
"bear_model": ld.get("bear_model"),
|
| 174 |
"p_bear": ld.get("p_bear"),
|
| 175 |
"p_bull": ld.get("p_bull"),
|
|
|
|
| 240 |
job["confidence"] = report.get("confidence")
|
| 241 |
job["debate"] = _extract_debate(report.get("llm_debate") or {})
|
| 242 |
job["damodaran"] = report.get("damodaran")
|
| 243 |
+
job["blend"] = report.get("blend_sources") or []
|
| 244 |
+
job["eps_gate"] = ((report.get("fundamental_detail") or {}).get("EPS Consensus Gate")
|
| 245 |
+
or (report.get("pred_fund") or {}).get("eps_consensus_gate"))
|
| 246 |
+
# Uncertainty band from the price model's OUT-OF-SAMPLE error (an 80% band =
|
| 247 |
+
# ±1.28 × MAE-as-sigma proxy), only when enough independent observations exist.
|
| 248 |
+
_ml = report.get("ml_detail") or {}
|
| 249 |
+
_mae = _ml.get("mae"); _neff = _ml.get("n_oos_eff") or 0
|
| 250 |
+
if _mae and _neff and _neff >= 3 and job["expected_return"] is not None:
|
| 251 |
+
_hw = round(1.28 * float(_mae) * 100, 1)
|
| 252 |
+
job["band"] = {"low": round(job["expected_return"] - _hw, 1),
|
| 253 |
+
"high": round(job["expected_return"] + _hw, 1),
|
| 254 |
+
"basis": f"80% band from the price model's out-of-sample error (MAE {float(_mae)*100:.0f}%, ~{int(_neff)} independent obs)"}
|
| 255 |
+
else:
|
| 256 |
+
job["band"] = {"low": None, "high": None,
|
| 257 |
+
"basis": "not estimable — too few independent out-of-sample observations"}
|
| 258 |
+
_c = job.get("confidence") or 0
|
| 259 |
+
job["confidence_label"] = ("high" if _c >= 0.65 else "moderate" if _c >= 0.30 else
|
| 260 |
+
"low" if _c >= 0.12 else "very low — treat the point estimate as indicative only")
|
| 261 |
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
| 262 |
pass
|
| 263 |
finally:
|
|
|
|
| 389 |
"confidence": job["confidence"], "rating": job["rating"],
|
| 390 |
"debate": job["debate"], "damodaran": job["damodaran"],
|
| 391 |
"n_lines": len(job.get("lines") or []),
|
| 392 |
+
"blend": job.get("blend") or [], "band": job.get("band"),
|
| 393 |
+
"confidence_label": job.get("confidence_label"), "eps_gate": job.get("eps_gate"),
|
| 394 |
"cached_at": time.time(),
|
| 395 |
"price_at_analysis": (job.get("damodaran") or {}).get("current_price"),
|
| 396 |
"source": job.get("source", "user"),
|
| 397 |
}
|
| 398 |
f = _REPORTS_DIR / f"{job['ticker']}.json"
|
| 399 |
f.write_text(json.dumps(payload))
|
| 400 |
+
_ledger_append(payload)
|
| 401 |
if _HF_TOKEN:
|
| 402 |
try:
|
| 403 |
from huggingface_hub import HfApi
|
|
|
|
| 409 |
pass
|
| 410 |
|
| 411 |
|
| 412 |
+
_LEDGER = _REPORTS_DIR / "_ledger.jsonl"
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def _ledger_append(payload: dict) -> None:
|
| 416 |
+
"""Append-only record of every call, so realized accuracy can be measured later
|
| 417 |
+
(the 7-day report cache prunes; the ledger never does). Mirrored to the dataset repo."""
|
| 418 |
+
try:
|
| 419 |
+
row = {"ticker": payload["ticker"], "ts": payload["cached_at"],
|
| 420 |
+
"date": dt_date_str(payload["cached_at"]),
|
| 421 |
+
"price": payload.get("price_at_analysis"),
|
| 422 |
+
"expected_return": payload.get("expected_return"), "rating": payload.get("rating"),
|
| 423 |
+
"value_implied": (payload.get("damodaran") or {}).get("value_implied"),
|
| 424 |
+
"source": payload.get("source", "user")}
|
| 425 |
+
if not row["price"] or row["expected_return"] is None:
|
| 426 |
+
return
|
| 427 |
+
with open(_LEDGER, "a") as fh:
|
| 428 |
+
fh.write(json.dumps(row) + "\n")
|
| 429 |
+
if _HF_TOKEN:
|
| 430 |
+
try:
|
| 431 |
+
from huggingface_hub import HfApi
|
| 432 |
+
HfApi().upload_file(path_or_fileobj=str(_LEDGER), path_in_repo="ledger/_ledger.jsonl",
|
| 433 |
+
repo_id=_REPORTS_REPO, repo_type="dataset", token=_HF_TOKEN)
|
| 434 |
+
except Exception:
|
| 435 |
+
pass
|
| 436 |
+
except Exception:
|
| 437 |
+
pass
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def dt_date_str(ts):
|
| 441 |
+
import datetime as _dt
|
| 442 |
+
return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%d")
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def track_record(min_age_days: int = 30) -> dict:
|
| 446 |
+
"""Realized accuracy of past calls: for ledger rows older than min_age_days, compare the
|
| 447 |
+
predicted direction/return with the price move since. Empty until the ledger ages."""
|
| 448 |
+
rows = []
|
| 449 |
+
try:
|
| 450 |
+
if _LEDGER.exists():
|
| 451 |
+
rows = [json.loads(l) for l in _LEDGER.read_text().splitlines() if l.strip()]
|
| 452 |
+
except Exception:
|
| 453 |
+
rows = []
|
| 454 |
+
now = time.time()
|
| 455 |
+
aged = [r for r in rows if now - r["ts"] >= min_age_days * 86400]
|
| 456 |
+
total = len(rows)
|
| 457 |
+
if not aged:
|
| 458 |
+
return {"calls_logged": total, "evaluated": 0, "min_age_days": min_age_days,
|
| 459 |
+
"note": f"track record builds automatically — first reading when calls are {min_age_days} days old"}
|
| 460 |
+
# dedupe to latest aged call per ticker to avoid overweighting repeats
|
| 461 |
+
latest = {}
|
| 462 |
+
for r in aged:
|
| 463 |
+
if r["ticker"] not in latest or r["ts"] > latest[r["ticker"]]["ts"]:
|
| 464 |
+
latest[r["ticker"]] = r
|
| 465 |
+
hits = 0; errs = []; det = []
|
| 466 |
+
for r in latest.values():
|
| 467 |
+
p1 = _price_now(r["ticker"])
|
| 468 |
+
if not p1:
|
| 469 |
+
continue
|
| 470 |
+
real = p1 / r["price"] - 1.0
|
| 471 |
+
pred = (r["expected_return"] or 0) / 100.0
|
| 472 |
+
ok = (real >= 0) == (pred >= 0) if abs(pred) >= 0.02 else abs(real) < 0.05
|
| 473 |
+
hits += 1 if ok else 0
|
| 474 |
+
errs.append(abs(real - pred))
|
| 475 |
+
det.append({"ticker": r["ticker"], "date": r["date"], "rating": r["rating"],
|
| 476 |
+
"predicted": round(pred * 100, 1), "realized_to_date": round(real * 100, 1), "direction_ok": ok})
|
| 477 |
+
n = len(det)
|
| 478 |
+
return {"calls_logged": total, "evaluated": n, "min_age_days": min_age_days,
|
| 479 |
+
"direction_hit_rate": round(hits / n, 3) if n else None,
|
| 480 |
+
"mean_abs_error_pct": round(sum(errs) / n * 100, 1) if n else None,
|
| 481 |
+
"detail": sorted(det, key=lambda x: x["date"])[-25:]}
|
| 482 |
+
|
| 483 |
+
|
| 484 |
def _hydrate_reports() -> None:
|
| 485 |
"""On startup, pull recent reports from the dataset repo so the cache survives redeploys."""
|
| 486 |
if not _HF_TOKEN:
|
|
|
|
| 656 |
"rating": job["rating"],
|
| 657 |
"debate": job["debate"],
|
| 658 |
"damodaran": job["damodaran"],
|
| 659 |
+
"blend": job.get("blend") or [],
|
| 660 |
+
"band": job.get("band"),
|
| 661 |
+
"confidence_label": job.get("confidence_label"),
|
| 662 |
+
"eps_gate": job.get("eps_gate"),
|
| 663 |
"n_lines": len(job["lines"]),
|
| 664 |
}
|