feat: model validation, calibration, and signal quality layer
ci/woodpecker/push/test Pipeline failed
ci/woodpecker/push/build-1 unknown status
ci/woodpecker/push/build-3 unknown status
ci/woodpecker/push/build-2 unknown status
ci/woodpecker/push/finalize unknown status
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
ci/woodpecker/push/test Pipeline failed
ci/woodpecker/push/build-1 unknown status
ci/woodpecker/push/build-3 unknown status
ci/woodpecker/push/build-2 unknown status
ci/woodpecker/push/finalize unknown status
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
- Migration 035: prediction_snapshots, prediction_outcomes, signal_evidence_links, model_metric_snapshots tables + SQL views - Prediction snapshot writer with canonical evidence keys, duplicate detection, contribution scores - Outcome evaluator across 5 horizons (1h, 6h, 1d, 7d, 30d) - Metrics engine: ECE, Brier score, IC, Rank IC, benchmark comparison - Attribution engine: per-source, per-catalyst, per-layer performance - Calibration engine: Bayesian shrinkage source reliability - Quality gate for live trading eligibility with configurable thresholds - 7 new /api/validation/* endpoints - Upgraded OpsModel dashboard with validation tab - Enhanced recommendation display with calibration context - Backtest replay validation mode - 86 Python tests (unit + property-based), 179 frontend tests passing
This commit is contained in:
@@ -0,0 +1,591 @@
|
||||
"""Attribution Engine — per-source, per-catalyst, and per-layer performance.
|
||||
|
||||
Joins signal evidence links with prediction outcomes to compute attribution
|
||||
metrics that identify which sources, catalyst types, and signal layers
|
||||
contribute most to accurate predictions.
|
||||
|
||||
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceAttribution:
|
||||
"""Performance metrics for a single source."""
|
||||
|
||||
source: str
|
||||
source_type: str
|
||||
prediction_count: int
|
||||
avg_weight: float
|
||||
avg_contribution_score: float
|
||||
win_rate: float
|
||||
avg_future_return: float
|
||||
avg_excess_return_vs_spy: float
|
||||
information_coefficient: float | None
|
||||
duplicate_rate: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalystAttribution:
|
||||
"""Performance metrics for a single catalyst type."""
|
||||
|
||||
catalyst_type: str
|
||||
prediction_count: int
|
||||
win_rate: float
|
||||
avg_future_return: float
|
||||
avg_excess_return_vs_spy: float
|
||||
information_coefficient: float | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayerAttribution:
|
||||
"""Performance metrics for a signal layer."""
|
||||
|
||||
layer: str # company, macro, competitive
|
||||
avg_contribution_pct: float
|
||||
dominant_win_rate: float # win rate when this layer > 30% contribution
|
||||
dominant_ic: float | None # IC when this layer > 30% contribution
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure computation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pearson_correlation(xs: list[float], ys: list[float]) -> float | None:
|
||||
"""Compute Pearson correlation coefficient between two lists.
|
||||
|
||||
Returns None if the lists have fewer than 2 elements or if either
|
||||
has zero variance. Guards against NaN/infinity.
|
||||
"""
|
||||
n = len(xs)
|
||||
if n < 2:
|
||||
return None
|
||||
|
||||
mean_x = sum(xs) / n
|
||||
mean_y = sum(ys) / n
|
||||
|
||||
cov = 0.0
|
||||
var_x = 0.0
|
||||
var_y = 0.0
|
||||
|
||||
for x, y in zip(xs, ys):
|
||||
dx = x - mean_x
|
||||
dy = y - mean_y
|
||||
cov += dx * dy
|
||||
var_x += dx * dx
|
||||
var_y += dy * dy
|
||||
|
||||
if var_x == 0.0 or var_y == 0.0:
|
||||
return None
|
||||
|
||||
r = cov / math.sqrt(var_x * var_y)
|
||||
|
||||
if math.isnan(r) or math.isinf(r):
|
||||
return None
|
||||
|
||||
return max(-1.0, min(1.0, r))
|
||||
|
||||
|
||||
def _compute_ic(
|
||||
contribution_scores: list[float],
|
||||
future_returns: list[float],
|
||||
) -> float | None:
|
||||
"""Compute IC (Pearson correlation) between contribution scores and returns.
|
||||
|
||||
Returns None when fewer than 30 data points.
|
||||
"""
|
||||
if len(contribution_scores) < 30 or len(future_returns) < 30:
|
||||
return None
|
||||
|
||||
n = min(len(contribution_scores), len(future_returns))
|
||||
return _pearson_correlation(contribution_scores[:n], future_returns[:n])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL queries — source attribution via v_source_performance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SOURCE_ATTRIBUTION_SQL = """
|
||||
SELECT
|
||||
source,
|
||||
source_type,
|
||||
weight,
|
||||
contribution_score,
|
||||
is_duplicate,
|
||||
direction_correct,
|
||||
future_return,
|
||||
excess_return_vs_spy
|
||||
FROM v_source_performance
|
||||
WHERE horizon = $1
|
||||
AND generated_at >= $2
|
||||
"""
|
||||
|
||||
_SOURCE_ATTRIBUTION_ALL_SQL = """
|
||||
SELECT
|
||||
source,
|
||||
source_type,
|
||||
weight,
|
||||
contribution_score,
|
||||
is_duplicate,
|
||||
direction_correct,
|
||||
future_return,
|
||||
excess_return_vs_spy
|
||||
FROM v_source_performance
|
||||
WHERE horizon = $1
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL queries — catalyst attribution via v_source_performance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CATALYST_ATTRIBUTION_SQL = """
|
||||
SELECT
|
||||
catalyst_type,
|
||||
weight,
|
||||
contribution_score,
|
||||
direction_correct,
|
||||
future_return,
|
||||
excess_return_vs_spy
|
||||
FROM v_source_performance
|
||||
WHERE horizon = $1
|
||||
AND generated_at >= $2
|
||||
"""
|
||||
|
||||
_CATALYST_ATTRIBUTION_ALL_SQL = """
|
||||
SELECT
|
||||
catalyst_type,
|
||||
weight,
|
||||
contribution_score,
|
||||
direction_correct,
|
||||
future_return,
|
||||
excess_return_vs_spy
|
||||
FROM v_source_performance
|
||||
WHERE horizon = $1
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL queries — layer attribution via prediction_snapshots + outcomes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LAYER_ATTRIBUTION_SQL = """
|
||||
SELECT
|
||||
ps.score_company,
|
||||
ps.score_macro,
|
||||
ps.score_competitive,
|
||||
po.direction_correct,
|
||||
po.future_return
|
||||
FROM prediction_snapshots ps
|
||||
JOIN prediction_outcomes po ON po.prediction_id = ps.id
|
||||
WHERE po.horizon = $1
|
||||
AND ps.generated_at >= $2
|
||||
"""
|
||||
|
||||
_LAYER_ATTRIBUTION_ALL_SQL = """
|
||||
SELECT
|
||||
ps.score_company,
|
||||
ps.score_macro,
|
||||
ps.score_competitive,
|
||||
po.direction_correct,
|
||||
po.future_return
|
||||
FROM prediction_snapshots ps
|
||||
JOIN prediction_outcomes po ON po.prediction_id = ps.id
|
||||
WHERE po.horizon = $1
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source attribution (Requirements 7.1, 7.2, 7.7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def compute_source_attribution(
|
||||
pool: asyncpg.Pool,
|
||||
lookback_days: int = 30,
|
||||
horizon: str = "7d",
|
||||
) -> list[SourceAttribution]:
|
||||
"""Compute per-source performance metrics.
|
||||
|
||||
Queries v_source_performance, groups by source, and computes:
|
||||
prediction count, avg weight, avg contribution score, win rate,
|
||||
avg future return, avg excess return vs SPY, IC, and duplicate rate.
|
||||
|
||||
Returns a list of SourceAttribution sorted by prediction count descending.
|
||||
"""
|
||||
now = datetime.now().astimezone()
|
||||
cutoff = now - timedelta(days=lookback_days)
|
||||
|
||||
try:
|
||||
rows = await pool.fetch(_SOURCE_ATTRIBUTION_SQL, horizon, cutoff)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to query source attribution for horizon=%s lookback=%dd",
|
||||
horizon,
|
||||
lookback_days,
|
||||
)
|
||||
return []
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Group rows by source
|
||||
source_groups: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
key = r.get("source") or "unknown"
|
||||
source_groups.setdefault(key, []).append(r)
|
||||
|
||||
results: list[SourceAttribution] = []
|
||||
|
||||
for source, group in source_groups.items():
|
||||
count = len(group)
|
||||
|
||||
# Source type — take the most common one
|
||||
source_type = group[0].get("source_type") or "unknown"
|
||||
|
||||
# Avg weight
|
||||
weights = [r["weight"] for r in group if r.get("weight") is not None]
|
||||
avg_weight = sum(weights) / len(weights) if weights else 0.0
|
||||
|
||||
# Avg contribution score
|
||||
contrib_scores = [
|
||||
r["contribution_score"]
|
||||
for r in group
|
||||
if r.get("contribution_score") is not None
|
||||
]
|
||||
avg_contribution_score = (
|
||||
sum(contrib_scores) / len(contrib_scores) if contrib_scores else 0.0
|
||||
)
|
||||
|
||||
# Win rate
|
||||
direction_rows = [r for r in group if r.get("direction_correct") is not None]
|
||||
win_count = sum(1 for r in direction_rows if r["direction_correct"] is True)
|
||||
win_rate = win_count / len(direction_rows) if direction_rows else 0.0
|
||||
|
||||
# Avg future return
|
||||
returns = [
|
||||
r["future_return"] for r in group if r.get("future_return") is not None
|
||||
]
|
||||
avg_future_return = sum(returns) / len(returns) if returns else 0.0
|
||||
|
||||
# Avg excess return vs SPY
|
||||
excess_returns = [
|
||||
r["excess_return_vs_spy"]
|
||||
for r in group
|
||||
if r.get("excess_return_vs_spy") is not None
|
||||
]
|
||||
avg_excess_return_vs_spy = (
|
||||
sum(excess_returns) / len(excess_returns) if excess_returns else 0.0
|
||||
)
|
||||
|
||||
# IC: correlation between contribution scores and future returns
|
||||
ic_scores = [
|
||||
r["contribution_score"]
|
||||
for r in group
|
||||
if r.get("contribution_score") is not None
|
||||
and r.get("future_return") is not None
|
||||
]
|
||||
ic_returns = [
|
||||
r["future_return"]
|
||||
for r in group
|
||||
if r.get("contribution_score") is not None
|
||||
and r.get("future_return") is not None
|
||||
]
|
||||
ic = _compute_ic(ic_scores, ic_returns)
|
||||
|
||||
# Duplicate rate: is_duplicate=true / total
|
||||
dup_count = sum(1 for r in group if r.get("is_duplicate") is True)
|
||||
duplicate_rate = dup_count / count
|
||||
|
||||
results.append(
|
||||
SourceAttribution(
|
||||
source=source,
|
||||
source_type=source_type,
|
||||
prediction_count=count,
|
||||
avg_weight=avg_weight,
|
||||
avg_contribution_score=avg_contribution_score,
|
||||
win_rate=win_rate,
|
||||
avg_future_return=avg_future_return,
|
||||
avg_excess_return_vs_spy=avg_excess_return_vs_spy,
|
||||
information_coefficient=ic,
|
||||
duplicate_rate=duplicate_rate,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by prediction count descending
|
||||
results.sort(key=lambda a: a.prediction_count, reverse=True)
|
||||
|
||||
logger.info(
|
||||
"Computed source attribution for %d sources (horizon=%s, lookback=%dd)",
|
||||
len(results),
|
||||
horizon,
|
||||
lookback_days,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalyst attribution (Requirements 7.3, 7.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def compute_catalyst_attribution(
|
||||
pool: asyncpg.Pool,
|
||||
lookback_days: int = 30,
|
||||
horizon: str = "7d",
|
||||
) -> list[CatalystAttribution]:
|
||||
"""Compute per-catalyst-type performance metrics.
|
||||
|
||||
Queries v_source_performance, groups by catalyst_type, and computes:
|
||||
prediction count, win rate, avg future return, avg excess return vs SPY,
|
||||
and IC.
|
||||
|
||||
Returns a list of CatalystAttribution sorted by prediction count descending.
|
||||
"""
|
||||
now = datetime.now().astimezone()
|
||||
cutoff = now - timedelta(days=lookback_days)
|
||||
|
||||
try:
|
||||
rows = await pool.fetch(_CATALYST_ATTRIBUTION_SQL, horizon, cutoff)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to query catalyst attribution for horizon=%s lookback=%dd",
|
||||
horizon,
|
||||
lookback_days,
|
||||
)
|
||||
return []
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Group rows by catalyst_type
|
||||
catalyst_groups: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
key = r.get("catalyst_type") or "unknown"
|
||||
catalyst_groups.setdefault(key, []).append(r)
|
||||
|
||||
results: list[CatalystAttribution] = []
|
||||
|
||||
for catalyst_type, group in catalyst_groups.items():
|
||||
count = len(group)
|
||||
|
||||
# Win rate
|
||||
direction_rows = [r for r in group if r.get("direction_correct") is not None]
|
||||
win_count = sum(1 for r in direction_rows if r["direction_correct"] is True)
|
||||
win_rate = win_count / len(direction_rows) if direction_rows else 0.0
|
||||
|
||||
# Avg future return
|
||||
returns = [
|
||||
r["future_return"] for r in group if r.get("future_return") is not None
|
||||
]
|
||||
avg_future_return = sum(returns) / len(returns) if returns else 0.0
|
||||
|
||||
# Avg excess return vs SPY
|
||||
excess_returns = [
|
||||
r["excess_return_vs_spy"]
|
||||
for r in group
|
||||
if r.get("excess_return_vs_spy") is not None
|
||||
]
|
||||
avg_excess_return_vs_spy = (
|
||||
sum(excess_returns) / len(excess_returns) if excess_returns else 0.0
|
||||
)
|
||||
|
||||
# IC: correlation between contribution scores and future returns
|
||||
ic_scores = [
|
||||
r["contribution_score"]
|
||||
for r in group
|
||||
if r.get("contribution_score") is not None
|
||||
and r.get("future_return") is not None
|
||||
]
|
||||
ic_returns = [
|
||||
r["future_return"]
|
||||
for r in group
|
||||
if r.get("contribution_score") is not None
|
||||
and r.get("future_return") is not None
|
||||
]
|
||||
ic = _compute_ic(ic_scores, ic_returns)
|
||||
|
||||
results.append(
|
||||
CatalystAttribution(
|
||||
catalyst_type=catalyst_type,
|
||||
prediction_count=count,
|
||||
win_rate=win_rate,
|
||||
avg_future_return=avg_future_return,
|
||||
avg_excess_return_vs_spy=avg_excess_return_vs_spy,
|
||||
information_coefficient=ic,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by prediction count descending
|
||||
results.sort(key=lambda a: a.prediction_count, reverse=True)
|
||||
|
||||
logger.info(
|
||||
"Computed catalyst attribution for %d catalyst types "
|
||||
"(horizon=%s, lookback=%dd)",
|
||||
len(results),
|
||||
horizon,
|
||||
lookback_days,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer attribution (Requirements 7.5, 7.6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def compute_layer_attribution(
|
||||
pool: asyncpg.Pool,
|
||||
lookback_days: int = 30,
|
||||
horizon: str = "7d",
|
||||
) -> list[LayerAttribution]:
|
||||
"""Compute per-layer (company, macro, competitive) performance metrics.
|
||||
|
||||
Queries prediction_snapshots joined with prediction_outcomes to get
|
||||
score_company, score_macro, score_competitive alongside outcomes.
|
||||
|
||||
For each layer computes:
|
||||
- avg_contribution_pct: average of layer_score / total_score across all
|
||||
predictions (where total_score > 0)
|
||||
- dominant_win_rate: win rate for predictions where the layer contributes
|
||||
more than 30% of the total score
|
||||
- dominant_ic: IC (Pearson correlation between layer score and future
|
||||
return) for predictions where the layer contributes > 30%
|
||||
|
||||
Returns a list of 3 LayerAttribution objects (company, macro, competitive).
|
||||
"""
|
||||
now = datetime.now().astimezone()
|
||||
cutoff = now - timedelta(days=lookback_days)
|
||||
|
||||
try:
|
||||
rows = await pool.fetch(_LAYER_ATTRIBUTION_SQL, horizon, cutoff)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to query layer attribution for horizon=%s lookback=%dd",
|
||||
horizon,
|
||||
lookback_days,
|
||||
)
|
||||
return []
|
||||
|
||||
if not rows:
|
||||
return [
|
||||
LayerAttribution(
|
||||
layer="company",
|
||||
avg_contribution_pct=0.0,
|
||||
dominant_win_rate=0.0,
|
||||
dominant_ic=None,
|
||||
),
|
||||
LayerAttribution(
|
||||
layer="macro",
|
||||
avg_contribution_pct=0.0,
|
||||
dominant_win_rate=0.0,
|
||||
dominant_ic=None,
|
||||
),
|
||||
LayerAttribution(
|
||||
layer="competitive",
|
||||
avg_contribution_pct=0.0,
|
||||
dominant_win_rate=0.0,
|
||||
dominant_ic=None,
|
||||
),
|
||||
]
|
||||
|
||||
row_dicts = [dict(r) for r in rows]
|
||||
|
||||
layers = [
|
||||
("company", "score_company"),
|
||||
("macro", "score_macro"),
|
||||
("competitive", "score_competitive"),
|
||||
]
|
||||
|
||||
results: list[LayerAttribution] = []
|
||||
|
||||
for layer_name, score_field in layers:
|
||||
# --- Average contribution percentage ---
|
||||
contribution_pcts: list[float] = []
|
||||
for r in row_dicts:
|
||||
total = (
|
||||
(r.get("score_company") or 0.0)
|
||||
+ (r.get("score_macro") or 0.0)
|
||||
+ (r.get("score_competitive") or 0.0)
|
||||
)
|
||||
if total > 0.0:
|
||||
layer_score = r.get(score_field) or 0.0
|
||||
contribution_pcts.append(layer_score / total)
|
||||
|
||||
avg_contribution_pct = (
|
||||
sum(contribution_pcts) / len(contribution_pcts)
|
||||
if contribution_pcts
|
||||
else 0.0
|
||||
)
|
||||
|
||||
# --- Dominant predictions: layer > 30% of total score ---
|
||||
dominant_rows: list[dict] = []
|
||||
for r in row_dicts:
|
||||
total = (
|
||||
(r.get("score_company") or 0.0)
|
||||
+ (r.get("score_macro") or 0.0)
|
||||
+ (r.get("score_competitive") or 0.0)
|
||||
)
|
||||
if total > 0.0:
|
||||
layer_score = r.get(score_field) or 0.0
|
||||
if layer_score / total > 0.30:
|
||||
dominant_rows.append(r)
|
||||
|
||||
# Dominant win rate
|
||||
dominant_direction_rows = [
|
||||
r for r in dominant_rows if r.get("direction_correct") is not None
|
||||
]
|
||||
dominant_win_count = sum(
|
||||
1 for r in dominant_direction_rows if r["direction_correct"] is True
|
||||
)
|
||||
dominant_win_rate = (
|
||||
dominant_win_count / len(dominant_direction_rows)
|
||||
if dominant_direction_rows
|
||||
else 0.0
|
||||
)
|
||||
|
||||
# Dominant IC: correlation between layer score and future return
|
||||
dom_scores = [
|
||||
r.get(score_field) or 0.0
|
||||
for r in dominant_rows
|
||||
if r.get("future_return") is not None
|
||||
]
|
||||
dom_returns = [
|
||||
r["future_return"]
|
||||
for r in dominant_rows
|
||||
if r.get("future_return") is not None
|
||||
]
|
||||
dominant_ic = _compute_ic(dom_scores, dom_returns)
|
||||
|
||||
results.append(
|
||||
LayerAttribution(
|
||||
layer=layer_name,
|
||||
avg_contribution_pct=avg_contribution_pct,
|
||||
dominant_win_rate=dominant_win_rate,
|
||||
dominant_ic=dominant_ic,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Computed layer attribution for 3 layers (horizon=%s, lookback=%dd)",
|
||||
horizon,
|
||||
lookback_days,
|
||||
)
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user