Files
Celes Renata a72f336ad1 feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the
monolithic 9B model extraction pipeline. CPU-first specialist services
handle routine extraction while the 9B vLLM model is preserved for
semantic adjudication of ambiguous cases.

Key components:
- Capability-aware inference gateway (OpenAI-compatible + Ollama)
- Endpoint registry with DB migrations and REST API
- Sentence-aware document segmenter (property tests)
- Deterministic financial parsing with offset integrity
- Symbol resolution with ambiguity detection
- Specialist service (GLiNER2, dynamic batching, K8s deployment)
- Company-specific sentiment (FinBERT, calibration)
- Retrieval-based novelty and duplicate detection
- Confidence calibration pipeline
- Deterministic routing engine (property tests)
- 9B adjudication layer with VRAM gating
- Stock-specific impact model (features, labels, baseline, trained)
- Pipeline orchestrator (state machine, queues, leases, feature flags)
- Bounded parallelism (async workers, semaphore, load shedding)
- Observability (tracing, metrics, alerts)
- Compatibility adapter (v3→v2 golden mapping tests)
- Shadow/canary promotion framework
- Active learning and fine-tuning pipeline

Test results: 1,161 tests pass, ruff lint clean.
All 282 spec tasks completed.
2026-07-13 02:14:59 +00:00

729 lines
27 KiB
Python

"""Tests for the stock-specific impact model layer.
Covers:
- Task 36: Event-time feature snapshots
- Task 37: Outcome labels
- Task 38: Deterministic impact baseline
- Task 39: Trained tabular impact model
- Task 40: Impact output integration
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta, timezone
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from services.intelligence_pipeline_v3.impact.baseline import (
EVENT_CLASS_BASE_MAGNITUDE,
EVENT_CLASS_DIRECTION,
DeterministicImpactBaseline,
)
from services.intelligence_pipeline_v3.impact.features import (
ImpactFeatureSet,
clear_feature_snapshots,
get_feature_snapshot,
persist_feature_snapshot,
validate_no_future_leakage,
)
from services.intelligence_pipeline_v3.impact.integration import (
ComparisonMetric,
DirectionProbabilities,
HorizonProbabilities,
ImpactPipelineConfig,
ImpactPredictionOutput,
clear_comparison_metrics,
filter_generative_scores,
get_comparison_metrics,
map_to_legacy_impact,
record_comparison_metric,
)
from services.intelligence_pipeline_v3.impact.labels import (
LABEL_GENERATOR_VERSION,
compute_abnormal_return,
compute_abnormal_volume,
compute_time_to_peak,
generate_outcome_labels,
)
from services.intelligence_pipeline_v3.impact.trained_model import (
ImpactModelTrainer,
TrainingExample,
clear_artifact_registry,
create_walk_forward_splits,
get_approved_model,
get_model_artifact,
register_model_artifact,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
EVENT_TIME = datetime(2024, 6, 15, 14, 30, 0, tzinfo=timezone.utc)
def _make_feature_set(**overrides) -> ImpactFeatureSet:
"""Create a valid ImpactFeatureSet with sensible defaults."""
defaults = {
"event_class_probabilities": {"earnings_beat": 0.7, "guidance_raise": 0.2, "other": 0.1},
"sentiment_positive": 0.6,
"sentiment_negative": 0.1,
"sentiment_neutral": 0.3,
"magnitude": 0.05,
"surprise": 0.7,
"source_credibility": 0.8,
"novelty_score": 0.6,
"evidence_coverage": 0.9,
"company_sector": "Technology",
"company_industry": "Software",
"market_cap_bucket": "large",
"beta": 1.2,
"pre_event_volatility": 0.25,
"volume_regime": "normal",
"broad_market_regime": "bull",
"event_directness": "direct",
"document_type": "news",
"event_time": EVENT_TIME,
"feature_version": "1.0.0",
}
defaults.update(overrides)
return ImpactFeatureSet(**defaults)
def _make_price_series(
start: datetime, n_points: int = 100, base_price: float = 100.0, daily_return: float = 0.001
) -> list[tuple[datetime, float]]:
"""Generate a simple price series."""
series = []
price = base_price
for i in range(n_points):
ts = start + timedelta(hours=i)
series.append((ts, price))
price *= 1.0 + daily_return
return series
# ===========================================================================
# Task 36: Event-time feature snapshots
# ===========================================================================
class TestImpactFeatureSet:
"""Tests for ImpactFeatureSet model and snapshot persistence."""
def test_create_valid_feature_set(self):
fs = _make_feature_set()
assert fs.sentiment_positive == 0.6
assert fs.company_sector == "Technology"
assert fs.event_time == EVENT_TIME
def test_missing_numeric_as_nan(self):
fs = _make_feature_set(magnitude=float("nan"), surprise=float("nan"))
assert math.isnan(fs.magnitude)
assert math.isnan(fs.surprise)
def test_unknown_categorical_defaults(self):
fs = _make_feature_set(
company_sector="unknown",
volume_regime="unknown",
broad_market_regime="unknown",
)
assert fs.company_sector == "unknown"
assert fs.volume_regime == "unknown"
assert fs.broad_market_regime == "unknown"
def test_invalid_categorical_becomes_unknown(self):
fs = _make_feature_set(market_cap_bucket="supermassive")
assert fs.market_cap_bucket == "unknown"
def test_to_numeric_vector_returns_list(self):
fs = _make_feature_set()
vec = fs.to_numeric_vector()
assert isinstance(vec, list)
assert all(isinstance(v, float) for v in vec)
def test_persist_feature_snapshot_immutable(self):
clear_feature_snapshots()
fs = _make_feature_set()
pred_time = EVENT_TIME + timedelta(minutes=5)
snapshot_id = persist_feature_snapshot(fs, pred_time)
assert snapshot_id
assert get_feature_snapshot(snapshot_id) is not None
# Same content produces same ID (content-addressed)
snapshot_id_2 = persist_feature_snapshot(fs, pred_time)
assert snapshot_id == snapshot_id_2
def test_persist_rejects_future_prediction_time(self):
clear_feature_snapshots()
fs = _make_feature_set()
# prediction_time before event_time is invalid
pred_time = EVENT_TIME - timedelta(hours=1)
with pytest.raises(ValueError, match="cannot be before"):
persist_feature_snapshot(fs, pred_time)
def test_validate_no_future_leakage_clean(self):
fs = _make_feature_set()
timestamps = [EVENT_TIME - timedelta(hours=i) for i in range(1, 5)]
violations = validate_no_future_leakage(fs, timestamps)
assert violations == []
def test_validate_no_future_leakage_detects_post_event(self):
fs = _make_feature_set()
timestamps = [
EVENT_TIME - timedelta(hours=1),
EVENT_TIME + timedelta(hours=1), # LEAKAGE
]
violations = validate_no_future_leakage(fs, timestamps)
assert len(violations) == 1
assert "after" in violations[0]
# ===========================================================================
# Task 36.4: Property test — no post-event timestamps in features
# ===========================================================================
# Hypothesis strategy for valid feature sets
@st.composite
def feature_set_strategy(draw):
event_time = draw(
st.datetimes(
min_value=datetime(2020, 1, 1),
max_value=datetime(2025, 1, 1),
timezones=st.just(timezone.utc),
)
)
return _make_feature_set(event_time=event_time)
@st.composite
def pre_event_timestamps_strategy(draw, event_time: datetime):
"""Generate timestamps all before event_time."""
n = draw(st.integers(min_value=1, max_value=10))
timestamps = []
for _ in range(n):
offset_seconds = draw(st.integers(min_value=1, max_value=86400 * 30))
timestamps.append(event_time - timedelta(seconds=offset_seconds))
return timestamps
@given(
event_time=st.datetimes(
min_value=datetime(2020, 1, 1),
max_value=datetime(2025, 1, 1),
timezones=st.just(timezone.utc),
),
n_timestamps=st.integers(min_value=1, max_value=10),
)
@settings(max_examples=100)
def test_pbt_no_post_event_leakage(event_time, n_timestamps):
"""**Validates: Requirements 12.2, 12.10**
Property: When market_data_timestamps are all before event_time,
validate_no_future_leakage returns no violations.
"""
fs = _make_feature_set(event_time=event_time)
# All timestamps strictly before event_time
timestamps = [
event_time - timedelta(seconds=i + 1) for i in range(n_timestamps)
]
violations = validate_no_future_leakage(fs, timestamps)
assert violations == [], f"Expected no leakage but found: {violations}"
@given(
event_time=st.datetimes(
min_value=datetime(2020, 1, 1),
max_value=datetime(2024, 12, 31),
timezones=st.just(timezone.utc),
),
post_offset_seconds=st.integers(min_value=0, max_value=86400),
)
@settings(max_examples=100)
def test_pbt_detects_post_event_leakage(event_time, post_offset_seconds):
"""**Validates: Requirements 12.2, 12.10**
Property: When any market_data_timestamp is at or after event_time,
validate_no_future_leakage detects the violation.
"""
fs = _make_feature_set(event_time=event_time)
# Include one timestamp at or after event_time
timestamps = [event_time + timedelta(seconds=post_offset_seconds)]
violations = validate_no_future_leakage(fs, timestamps)
assert len(violations) >= 1
# ===========================================================================
# Task 37: Outcome labels
# ===========================================================================
class TestOutcomeLabels:
"""Tests for abnormal return computation and label generation."""
def test_compute_abnormal_return_basic(self):
"""Asset goes up 5%, benchmark goes up 2% → abnormal = 3%."""
event = EVENT_TIME
price_series = [
(event, 100.0),
(event + timedelta(days=1), 105.0),
]
bench_series = [
(event, 100.0),
(event + timedelta(days=1), 102.0),
]
result = compute_abnormal_return(
price_series, bench_series, event, timedelta(days=1)
)
assert abs(result - 0.03) < 1e-10
def test_compute_abnormal_return_negative(self):
"""Asset goes down 3%, benchmark goes up 1% → abnormal = -4%."""
event = EVENT_TIME
price_series = [
(event, 100.0),
(event + timedelta(days=1), 97.0),
]
bench_series = [
(event, 100.0),
(event + timedelta(days=1), 101.0),
]
result = compute_abnormal_return(
price_series, bench_series, event, timedelta(days=1)
)
assert abs(result - (-0.04)) < 1e-10
def test_compute_abnormal_return_empty_series_raises(self):
with pytest.raises(ValueError, match="empty"):
compute_abnormal_return([], [(EVENT_TIME, 100.0)], EVENT_TIME, timedelta(days=1))
def test_compute_abnormal_return_zero_price_raises(self):
event = EVENT_TIME
price_series = [(event, 0.0), (event + timedelta(days=1), 5.0)]
bench_series = [(event, 100.0), (event + timedelta(days=1), 101.0)]
with pytest.raises(ValueError, match="zero"):
compute_abnormal_return(price_series, bench_series, event, timedelta(days=1))
def test_compute_abnormal_volume(self):
event = EVENT_TIME
# Trailing: 20 days of volume=1000
volume_series = [
(event - timedelta(days=i), 1000.0) for i in range(1, 21)
]
# Event day: volume=3000 (3x normal)
volume_series.append((event, 3000.0))
volume_series.sort(key=lambda x: x[0])
result = compute_abnormal_volume(volume_series, event, timedelta(days=1))
assert result is not None
assert abs(result - 3.0) < 0.1
def test_compute_time_to_peak(self):
event = EVENT_TIME
# Price spikes 2 hours after event
price_series = [
(event, 100.0),
(event + timedelta(hours=1), 101.0),
(event + timedelta(hours=2), 105.0), # Peak
(event + timedelta(hours=3), 103.0),
(event + timedelta(hours=4), 102.0),
]
result = compute_time_to_peak(price_series, event, timedelta(hours=6))
assert result is not None
assert abs(result - 2.0) < 0.1
def test_generate_outcome_labels_all_horizons(self):
event = EVENT_TIME
# Generate enough price data for 90d horizon
price_series = _make_price_series(event - timedelta(hours=1), n_points=2200)
bench_series = _make_price_series(event - timedelta(hours=1), n_points=2200, daily_return=0.0005)
labels = generate_outcome_labels(
ticker="AAPL",
event_time=event,
price_series=price_series,
benchmark_series=bench_series,
)
assert labels.ticker == "AAPL"
assert labels.label_generator_version == LABEL_GENERATOR_VERSION
assert len(labels.labels) == 5 # All 5 horizons
def test_label_generator_version_tracked(self):
assert LABEL_GENERATOR_VERSION == "1.0.0"
# ===========================================================================
# Task 38: Deterministic impact baseline
# ===========================================================================
class TestDeterministicImpactBaseline:
"""Tests for the rule-based baseline model."""
def setup_method(self):
self.baseline = DeterministicImpactBaseline()
def test_predict_returns_impact_prediction(self):
fs = _make_feature_set()
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities is not None
assert prediction.expected_magnitude > 0
assert prediction.model_source.startswith("deterministic_baseline")
def test_earnings_beat_positive_direction(self):
"""Earnings beat should have predominantly positive direction."""
fs = _make_feature_set(
event_class_probabilities={"earnings_beat": 0.9, "other": 0.1},
sentiment_positive=0.7,
sentiment_negative=0.1,
sentiment_neutral=0.2,
)
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities["positive"] > prediction.direction_probabilities["negative"]
def test_earnings_miss_negative_direction(self):
"""Earnings miss should have predominantly negative direction."""
fs = _make_feature_set(
event_class_probabilities={"earnings_miss": 0.9, "other": 0.1},
sentiment_positive=0.1,
sentiment_negative=0.7,
sentiment_neutral=0.2,
)
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities["negative"] > prediction.direction_probabilities["positive"]
def test_guidance_raise_positive(self):
fs = _make_feature_set(
event_class_probabilities={"guidance_raise": 0.8, "other": 0.2},
sentiment_positive=0.6,
sentiment_negative=0.1,
sentiment_neutral=0.3,
)
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities["positive"] > 0.4
def test_guidance_cut_negative(self):
fs = _make_feature_set(
event_class_probabilities={"guidance_cut": 0.8, "other": 0.2},
sentiment_positive=0.1,
sentiment_negative=0.6,
sentiment_neutral=0.3,
)
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities["negative"] > 0.4
def test_ma_announcement_high_magnitude(self):
"""M&A has higher base magnitude than dividend changes."""
fs_ma = _make_feature_set(
event_class_probabilities={"ma_announcement": 0.9, "other": 0.1},
)
fs_div = _make_feature_set(
event_class_probabilities={"dividend_change": 0.9, "other": 0.1},
)
pred_ma = self.baseline.predict(fs_ma)
pred_div = self.baseline.predict(fs_div)
assert pred_ma.expected_magnitude > pred_div.expected_magnitude
def test_novelty_amplifies_magnitude(self):
"""Higher novelty should increase expected magnitude."""
fs_novel = _make_feature_set(novelty_score=0.9)
fs_stale = _make_feature_set(novelty_score=0.1)
pred_novel = self.baseline.predict(fs_novel)
pred_stale = self.baseline.predict(fs_stale)
assert pred_novel.expected_magnitude > pred_stale.expected_magnitude
def test_low_evidence_discounts_magnitude(self):
"""Low evidence coverage should reduce magnitude."""
fs_strong = _make_feature_set(evidence_coverage=0.95)
fs_weak = _make_feature_set(evidence_coverage=0.1)
pred_strong = self.baseline.predict(fs_strong)
pred_weak = self.baseline.predict(fs_weak)
assert pred_strong.expected_magnitude > pred_weak.expected_magnitude
def test_direct_event_shorter_horizon(self):
"""Direct events should have more weight on shorter horizons."""
fs_direct = _make_feature_set(event_directness="direct")
fs_spec = _make_feature_set(event_directness="speculative")
pred_direct = self.baseline.predict(fs_direct)
pred_spec = self.baseline.predict(fs_spec)
assert pred_direct.horizon_probabilities["intraday"] > pred_spec.horizon_probabilities["intraday"]
def test_unknown_event_high_uncertainty(self):
"""Unknown events should produce higher uncertainty."""
fs_known = _make_feature_set(
event_class_probabilities={"earnings_beat": 0.95, "other": 0.05},
)
fs_unknown = _make_feature_set(
event_class_probabilities={},
)
pred_known = self.baseline.predict(fs_known)
pred_unknown = self.baseline.predict(fs_unknown)
assert pred_unknown.uncertainty > pred_known.uncertainty
def test_all_event_classes_have_mappings(self):
"""Every event class in the lookup tables should produce valid output."""
for event_class in EVENT_CLASS_BASE_MAGNITUDE:
fs = _make_feature_set(
event_class_probabilities={event_class: 0.9, "other": 0.1},
)
prediction = self.baseline.predict(fs)
assert prediction.expected_magnitude > 0
total_dir = sum(prediction.direction_probabilities.values())
assert abs(total_dir - 1.0) < 0.01
total_hor = sum(prediction.horizon_probabilities.values())
assert abs(total_hor - 1.0) < 0.01
def test_direction_probabilities_sum_to_one(self):
"""Direction probs should always sum to approximately 1.0."""
for event_class in EVENT_CLASS_DIRECTION:
fs = _make_feature_set(
event_class_probabilities={event_class: 0.8, "other": 0.2},
)
prediction = self.baseline.predict(fs)
total = sum(prediction.direction_probabilities.values())
assert abs(total - 1.0) < 0.01, f"Failed for {event_class}: sum={total}"
def test_horizon_probabilities_sum_to_one(self):
"""Horizon probs should always sum to approximately 1.0."""
for event_class in EVENT_CLASS_DIRECTION:
fs = _make_feature_set(
event_class_probabilities={event_class: 0.8, "other": 0.2},
)
prediction = self.baseline.predict(fs)
total = sum(prediction.horizon_probabilities.values())
assert abs(total - 1.0) < 0.01, f"Failed for {event_class}: sum={total}"
def test_magnitude_bounded(self):
"""Magnitude should never exceed 2x base (conservative cap)."""
for event_class, base_mag in EVENT_CLASS_BASE_MAGNITUDE.items():
fs = _make_feature_set(
event_class_probabilities={event_class: 0.95, "other": 0.05},
novelty_score=1.0,
surprise=1.0,
evidence_coverage=1.0,
)
prediction = self.baseline.predict(fs)
assert prediction.expected_magnitude <= base_mag * 2.0 + 0.001
# ===========================================================================
# Task 39: Trained tabular impact model
# ===========================================================================
class TestTrainedImpactModel:
"""Tests for the trained tabular impact model."""
def setup_method(self):
clear_artifact_registry()
def _make_training_examples(self, n: int = 50) -> list[TrainingExample]:
"""Generate synthetic training examples."""
from services.intelligence_pipeline_v3.impact.labels import (
OutcomeLabel,
OutcomeLabelSet,
)
examples = []
base_time = datetime(2023, 1, 1, tzinfo=timezone.utc)
for i in range(n):
event_time = base_time + timedelta(days=i)
features = _make_feature_set(
event_time=event_time,
event_class_probabilities={"earnings_beat": 0.7, "other": 0.3},
sentiment_positive=0.5 + 0.3 * (i % 2),
sentiment_negative=0.2 - 0.1 * (i % 2),
sentiment_neutral=0.3 - 0.2 * (i % 2),
)
labels = OutcomeLabelSet(
event_time=event_time,
ticker="AAPL",
labels=[
OutcomeLabel(
horizon="1d",
signed_return=0.02 * (1 if i % 2 == 0 else -1),
absolute_return=0.02,
),
],
)
examples.append(TrainingExample(
features=features, labels=labels, ticker="AAPL", event_time=event_time
))
return examples
def test_train_produces_model_card(self):
examples = self._make_training_examples(50)
trainer = ImpactModelTrainer()
card = trainer.train(examples)
assert card.model_id
assert card.method == "gradient_boosted"
assert card.feature_version == "1.0.0"
assert card.total_training_samples > 0
def test_train_empty_raises(self):
trainer = ImpactModelTrainer()
with pytest.raises(ValueError, match="empty"):
trainer.train([])
def test_walk_forward_splits_temporal_ordering(self):
start = datetime(2023, 1, 1, tzinfo=timezone.utc)
end = datetime(2024, 1, 1, tzinfo=timezone.utc)
splits = create_walk_forward_splits(start, end, n_splits=3)
assert len(splits) == 3
for split in splits:
assert split.train_start <= split.train_end
assert split.train_end <= split.calibration_end
assert split.calibration_end <= split.validation_end
def test_trainer_predict_untrained_falls_to_baseline(self):
trainer = ImpactModelTrainer()
fs = _make_feature_set()
prediction = trainer.predict(fs)
assert "baseline" in prediction.model_source
def test_trainer_predict_after_training(self):
examples = self._make_training_examples(50)
trainer = ImpactModelTrainer()
trainer.train(examples)
assert trainer.is_trained
fs = _make_feature_set()
prediction = trainer.predict(fs)
assert prediction.direction_probabilities is not None
assert prediction.expected_magnitude >= 0
def test_register_and_retrieve_artifact(self):
examples = self._make_training_examples(30)
trainer = ImpactModelTrainer()
card = trainer.train(examples)
model_id = register_model_artifact(card)
assert get_model_artifact(model_id) is not None
def test_no_approved_model_initially(self):
assert get_approved_model() is None
def test_model_card_has_segment_metrics(self):
examples = self._make_training_examples(50)
trainer = ImpactModelTrainer()
card = trainer.train(examples)
# Should have metrics by event and sector
assert isinstance(card.metrics_by_event, list)
assert isinstance(card.metrics_by_sector, list)
# ===========================================================================
# Task 40: Impact output integration
# ===========================================================================
class TestImpactIntegration:
"""Tests for impact output integration and legacy compatibility."""
def setup_method(self):
clear_comparison_metrics()
def test_impact_prediction_output_model(self):
pred = ImpactPredictionOutput(
direction_probs=DirectionProbabilities(positive=0.6, negative=0.2, neutral=0.2),
expected_magnitude=0.03,
signed_magnitude=0.02,
horizon_probs=HorizonProbabilities(
intraday=0.3, one_day=0.3, seven_day=0.2, thirty_day=0.1, ninety_day=0.1
),
uncertainty=0.3,
model_source="deterministic_baseline_v1.0.0",
)
assert pred.direction_probs.positive == 0.6
assert pred.expected_magnitude == 0.03
def test_map_to_legacy_impact_score(self):
pred = ImpactPredictionOutput(
direction_probs=DirectionProbabilities(positive=0.7, negative=0.1, neutral=0.2),
expected_magnitude=0.04,
signed_magnitude=0.03,
horizon_probs=HorizonProbabilities(
intraday=0.1, one_day=0.4, seven_day=0.3, thirty_day=0.15, ninety_day=0.05
),
uncertainty=0.3,
model_source="test",
)
legacy = map_to_legacy_impact(pred)
assert legacy.impact_score == 0.03
assert legacy.impact_horizon == "1d"
def test_map_to_legacy_clamps_score(self):
pred = ImpactPredictionOutput(
direction_probs=DirectionProbabilities(positive=0.9, negative=0.0, neutral=0.1),
expected_magnitude=2.0,
signed_magnitude=1.5, # Exceeds 1.0
horizon_probs=HorizonProbabilities(intraday=0.5, one_day=0.3),
uncertainty=0.2,
model_source="test",
)
legacy = map_to_legacy_impact(pred)
assert legacy.impact_score == 1.0 # Clamped
def test_filter_generative_scores_v3_mode(self):
config = ImpactPipelineConfig(v3_mode_enabled=True)
signal = {
"impact_score": 0.5,
"impact_horizon": "7d",
"novelty_score": 0.8,
"confidence": 0.7,
"sentiment": "positive",
"ticker": "AAPL",
}
filtered = filter_generative_scores(signal, config)
assert "impact_score" not in filtered
assert "impact_horizon" not in filtered
assert "novelty_score" not in filtered
assert "confidence" not in filtered
# Non-generative fields preserved
assert filtered["sentiment"] == "positive"
assert filtered["ticker"] == "AAPL"
def test_filter_generative_scores_disabled(self):
config = ImpactPipelineConfig(v3_mode_enabled=False)
signal = {"impact_score": 0.5, "ticker": "AAPL"}
filtered = filter_generative_scores(signal, config)
assert filtered == signal # No filtering when disabled
def test_comparison_metrics_disabled_by_default(self):
metric = ComparisonMetric(
ticker="AAPL",
event_time=EVENT_TIME,
prediction_source="baseline",
predicted_direction="positive",
predicted_magnitude=0.03,
predicted_horizon="1d",
)
# Default config has comparison disabled
record_comparison_metric(metric)
assert get_comparison_metrics() == []
def test_comparison_metrics_when_enabled(self):
import os
os.environ["IMPACT_COMPARISON_METRICS"] = "true"
try:
metric = ComparisonMetric(
ticker="AAPL",
event_time=EVENT_TIME,
prediction_source="baseline",
predicted_direction="positive",
predicted_magnitude=0.03,
predicted_horizon="1d",
)
record_comparison_metric(metric)
metrics = get_comparison_metrics()
assert len(metrics) == 1
assert metrics[0].ticker == "AAPL"
finally:
os.environ.pop("IMPACT_COMPARISON_METRICS", None)
clear_comparison_metrics()