Instant-SWOT-Agent / src /utils /data_gate.py
vn6295337's picture
fix: Margin-consistency gate check and final-evaluation label
c90d619
Raw
History Blame Contribute Delete
4.77 kB
"""
Deterministic data gate for the Researcher -> Analyzer edge.
Two checks, run on the same extracted-metrics view the Analyzer consumes:
- DG (data gap): required metrics per category. Gaps are surfaced to the
Analyzer as explicit DATA NOT PROVIDED entries instead of silent omission,
giving the Critic's constraint-compliance rule something to enforce.
- SC (signal corruption): impossible magnitudes (unit/decimal slips upstream)
are quarantined before they can enter the reference table and be cited.
Bounds are deliberately loose - they reject the impossible, not the unusual.
"""
# Per-category requirement rules: (metric keys, minimum count present)
REQUIRED_METRICS = {
"fundamentals": (["revenue", "net_margin", "eps"], 2),
"valuation": (["pe_trailing", "pe_forward", "pb_ratio", "ps_ratio"], 1),
"volatility": (["beta", "vix", "historical_volatility"], 1),
"macro": (["gdp_growth", "interest_rate", "inflation", "unemployment"], 2),
}
# (min, max) inclusive. Values are in the units the extractor produces
# (margins/rates/vol in percent, ratios as multiples, currency in dollars).
SANITY_BOUNDS = {
"revenue": (0, 1e13),
"net_income": (-1e12, 1e12),
"free_cash_flow": (-1e12, 1e12),
"net_margin": (-200, 100),
"gross_margin": (-200, 100),
"operating_margin": (-200, 100),
"eps": (-10000, 10000),
"debt_to_equity": (-100, 100),
"revenue_cagr_3yr": (-100, 300),
"pe_trailing": (-1000, 1000),
"pe_forward": (-1000, 1000),
"pb_ratio": (-100, 500),
"ps_ratio": (0, 500),
"ev_ebitda": (-1000, 1000),
"beta": (-5, 10),
"vix": (5, 150),
"historical_volatility": (0, 500),
"gdp_growth": (-30, 30),
"interest_rate": (-5, 50),
"inflation": (-20, 100),
"unemployment": (0, 50),
}
def _numeric_value(metric_val):
"""Extracted metrics are either numbers or {'value': number, ...} dicts."""
if isinstance(metric_val, dict):
metric_val = metric_val.get("value")
if isinstance(metric_val, (int, float)) and not isinstance(metric_val, bool):
return float(metric_val)
return None
def audit_extracted_metrics(extracted: dict) -> dict:
"""
Audit the extracted metrics view.
Args:
extracted: dict from analyzer._extract_key_metrics -
{"fundamentals": {...}, "valuation": {...}, ...}
Returns:
{
"gaps": ["fundamentals: revenue", ...] # DG findings
"suspect": [("volatility", "vix", 1673.0), ...] # SC findings
}
"""
gaps = []
suspect = []
for category, (keys, min_present) in REQUIRED_METRICS.items():
data = extracted.get(category) or {}
present = [k for k in keys if _numeric_value(data.get(k)) is not None]
if len(present) < min_present:
for k in keys:
if k not in present:
gaps.append(f"{category}: {k}")
for category in REQUIRED_METRICS:
data = extracted.get(category) or {}
if not isinstance(data, dict):
continue
for key, raw in data.items():
value = _numeric_value(raw)
bounds = SANITY_BOUNDS.get(key)
if value is None or bounds is None:
continue
lo, hi = bounds
if not (lo <= value <= hi):
suspect.append((category, key, value))
# Cross-consistency: net_margin must agree with its components. A
# violation means mixed reporting periods upstream (annual revenue paired
# with a quarterly net income); quarantine all three so a wrong margin
# can never be cited as fact.
fundamentals = extracted.get("fundamentals") or {}
revenue = _numeric_value(fundamentals.get("revenue"))
net_income = _numeric_value(fundamentals.get("net_income"))
net_margin = _numeric_value(fundamentals.get("net_margin"))
if revenue and net_income is not None and net_margin is not None and revenue > 0:
derived = net_income / revenue * 100
if abs(derived - net_margin) > max(1.0, abs(net_margin) * 0.2):
for key, value in (("net_income", net_income), ("net_margin", net_margin)):
if ("fundamentals", key, value) not in suspect:
suspect.append(("fundamentals", key, value))
gaps.append("fundamentals: net_margin (inconsistent with components - period mixing suspected)")
return {"gaps": gaps, "suspect": suspect}
def scrub_suspect_metrics(extracted: dict, suspect: list) -> dict:
"""Remove quarantined values so they cannot enter the reference table."""
for category, key, _value in suspect:
if isinstance(extracted.get(category), dict):
extracted[category].pop(key, None)
return extracted