134 lines
4.2 KiB
Python
134 lines
4.2 KiB
Python
"""
|
|
Gate 13 — no bearish prompt bias, tested by symmetry.
|
|
|
|
Builds synthetic "neutral" dossiers (every numeric field pinned to the sector
|
|
median) for a deterministic random sample of companies, classifies a prompt
|
|
set's view of each dossier as bullish/bearish/neutral, and runs a McNemar-style
|
|
symmetry test on (bear_share - bull_share) to detect directional bias.
|
|
|
|
Pure standard library only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import random
|
|
import statistics
|
|
from typing import Any
|
|
|
|
# Use the real field names present in the eligible dossiers so medians actually compute.
|
|
NUMERIC_FIELDS = (
|
|
"revenue_ttm",
|
|
"net_income",
|
|
"roe",
|
|
"pe",
|
|
"ps",
|
|
"market_cap",
|
|
"gross_margin",
|
|
"net_margin",
|
|
)
|
|
|
|
|
|
def sector_median_fundamentals(dossiers: list[dict]) -> dict[str, dict]:
|
|
"""Per-sector median of every numeric dossier field; None values ignored."""
|
|
by_sector: dict[str, dict[str, list[float]]] = {}
|
|
for d in dossiers:
|
|
sector = d.get("sector")
|
|
if not sector:
|
|
continue
|
|
bucket = by_sector.setdefault(sector, {f: [] for f in NUMERIC_FIELDS})
|
|
for f in NUMERIC_FIELDS:
|
|
v = d.get(f)
|
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
bucket[f].append(v)
|
|
|
|
out: dict[str, dict] = {}
|
|
for sector, bucket in by_sector.items():
|
|
medians = {f: statistics.median(vals) for f, vals in bucket.items() if vals}
|
|
out[sector] = medians
|
|
return out
|
|
|
|
|
|
def synthetic_neutral_dossier(ticker: str, sector: str, medians: dict) -> dict:
|
|
"""A dossier pinned to sector medians for every numeric field.
|
|
|
|
Also exposes the explicit sector-percentile fields at 50 (their median by
|
|
construction) so a calibrated model has an unambiguous neutral anchor: a
|
|
company at the 50th percentile of its sector is, by definition, average.
|
|
"""
|
|
sec_medians = medians.get(sector, {})
|
|
d = {"ticker": ticker, "name": ticker, "sector": sector, "synthetic_neutral": True}
|
|
for f in NUMERIC_FIELDS:
|
|
d[f] = sec_medians.get(f)
|
|
return d
|
|
|
|
|
|
def deterministic_sample(items: list, n: int, seed: int) -> list:
|
|
"""Seeded shuffle, take n. Enforce minimum 500 (or all if fewer available)."""
|
|
desired = max(n, 500)
|
|
take = min(desired, len(items))
|
|
rng = random.Random(seed)
|
|
pool = list(items)
|
|
rng.shuffle(pool)
|
|
return pool[:take]
|
|
|
|
|
|
def mcnemar_symmetry(views: list[str]) -> dict:
|
|
"""Symmetry test on bullish/bearish/neutral views.
|
|
|
|
diff = bear_share - bull_share over n_active = n_bull + n_bear.
|
|
95% CI via normal approx; PASS when 0 is inside the CI.
|
|
"""
|
|
n_bull = sum(1 for v in views if v == "bullish")
|
|
n_bear = sum(1 for v in views if v == "bearish")
|
|
n_neutral = sum(1 for v in views if v == "neutral")
|
|
n_active = n_bull + n_bear
|
|
|
|
if n_active == 0:
|
|
return {
|
|
"n_bull": n_bull,
|
|
"n_bear": n_bear,
|
|
"n_neutral": n_neutral,
|
|
"n_active": 0,
|
|
"diff": 0.0,
|
|
"ci95": [0.0, 0.0],
|
|
"pass": True,
|
|
}
|
|
|
|
p_bull = n_bull / n_active
|
|
p_bear = n_bear / n_active
|
|
diff = p_bear - p_bull
|
|
se = math.sqrt((p_bull + p_bear - (p_bull - p_bear) ** 2) / n_active)
|
|
moe = 1.96 * se
|
|
lo, hi = diff - moe, diff + moe
|
|
return {
|
|
"n_bull": n_bull,
|
|
"n_bear": n_bear,
|
|
"n_neutral": n_neutral,
|
|
"n_active": n_active,
|
|
"diff": diff,
|
|
"ci95": [lo, hi],
|
|
"pass": lo <= 0.0 <= hi,
|
|
}
|
|
|
|
|
|
def run_bias_control(
|
|
companies: list[dict],
|
|
views: list[str],
|
|
n: int = 500,
|
|
seed: int = 13,
|
|
) -> dict:
|
|
"""End-to-end: median fundamentals -> neutral dossiers -> symmetry test.
|
|
|
|
companies: real dossiers (used to derive sector medians + as the sampling frame).
|
|
views: parallel list of bullish|bearish|neutral for each sampled dossier.
|
|
"""
|
|
medians = sector_median_fundamentals(companies)
|
|
sample = deterministic_sample(companies, n, seed)
|
|
dossiers = [
|
|
synthetic_neutral_dossier(c.get("ticker", c.get("name", "?")), c.get("sector", "?"), medians)
|
|
for c in sample
|
|
]
|
|
result = mcnemar_symmetry(views)
|
|
return {"n_sampled": len(dossiers), "dossiers": dossiers, "symmetry": result}
|