Files
ten31-signal-engine/signal_engine/signals/under_acted.py
T

76 lines
4.1 KiB
Python

"""Under-acted-conviction scorer — Job B, the §7.1 backtest target.
score = conviction_weight x exposure_gap x rising_independent_corroboration
Fires when Ten31 believes something (high conviction), has little/no position (exposure gap), and the
world is beginning to corroborate it or a derivative of it — independently and with acceleration. This
is the signal that should have flagged "size up power-infra picks-and-shovels" in 2023.
Exposure is joined LOCALLY (never crosses the frontier boundary, §4.6). Corroboration is RETRIEVED
(stats nominate), then an LLM helper only FILTERS retrieval near-misses (§5.1) — it cannot add claims.
"""
from __future__ import annotations
from .llm_helpers import derivative_relevance
from .windows import windowed_independence
CONVICTION_WEIGHT = {"low": 0.15, "med": 0.4, "med-high": 0.7, "high": 1.0}
EXPOSURE_GAP = {"none": 1.0, "lt2": 0.8, "2to10": 0.4, "gt10": 0.1, "unset": 0.6}
def score_node(conn, sc, backend, *, as_of: str, derivative: str, conviction_id: str,
node_id: str | None, conviction_level: str, exposure: str,
is_breaker: bool = False, top_k: int = 40, window_days: int = 28) -> dict:
cw = CONVICTION_WEIGHT.get(conviction_level, 0.4)
eg = EXPOSURE_GAP.get(exposure, 0.6)
# 1. RETRIEVE (stats nominate): hybrid search over embedded propositions; as-of post-filter.
try:
res = sc.search(derivative, collection="propositions", top_k=top_k, rerank=True)
except Exception as e: # noqa: BLE001
return _result(conviction_id, node_id, 0.0, {"reason": f"search_failed:{str(e)[:60]}"},
cw, eg, exposure, is_breaker)
hits = res.get("data", []) if isinstance(res, dict) else []
cand = []
for h in hits:
pl = (h.get("payload") or {}) if isinstance(h, dict) else {}
d = pl.get("date")
if not pl.get("claim_id") or not d or d[:10] > as_of: # Qdrant can't date-filter; do it here
continue
cand.append({"claim_id": pl["claim_id"], "proposition": pl.get("proposition", ""),
"date": d, "source_id": pl.get("source_id")})
if not cand:
return _result(conviction_id, node_id, 0.0, {"reason": "no_retrieval", "n_retrieved": 0},
cw, eg, exposure, is_breaker)
# 2. FILTER near-misses with the LLM (affirms-only). Not a nominator — can't add claims.
rel = derivative_relevance(backend, derivative,
[{"claim_id": c["claim_id"], "proposition": c["proposition"]} for c in cand])
confirmed = [c for c in cand
if rel.get(c["claim_id"], {}).get("corroborates")
and rel[c["claim_id"]].get("direction") == "affirms"]
n_src = len({c["source_id"] for c in confirmed})
# 3. CORROBORATION = independence-weighted acceleration over the confirmed set (treat as a topic).
# window_days matches corpus cadence: ~90d for quarterly filings/earnings, ~28d for weekly podcasts.
wi = windowed_independence(conn, [(c["date"], c["source_id"]) for c in confirmed], as_of, days=window_days)
a_corrob = wi["acceleration"]
eisc_corrob = wi["eisc0"]
corroboration = max(0.0, a_corrob) * eisc_corrob
score = corroboration if is_breaker else cw * eg * corroboration
inputs = {
"as_of": as_of, "derivative": derivative, "n_retrieved": len(cand), "n_confirmed": len(confirmed),
"n_src": n_src, "a_corrob": a_corrob, "eisc_corrob": eisc_corrob, "k_eff0": wi["k_eff0"],
"window_counts": wi["counts"], "window_eisc": wi["eisc"], "corroboration": round(corroboration, 3),
"confirmed_claim_ids": [c["claim_id"] for c in confirmed][:50],
}
return _result(conviction_id, node_id, score, inputs, cw, eg, exposure, is_breaker)
def _result(conviction_id, node_id, score, inputs, cw, eg, exposure, is_breaker) -> dict:
inputs = {**inputs, "conviction_weight": cw, "exposure_gap": eg, "exposure": exposure,
"is_breaker": is_breaker}
return {"scorer": "under_acted", "conviction_id": conviction_id, "node_id": node_id,
"score": round(float(score), 4), "inputs": inputs}