4e010bc048
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
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
Implement full probabilistic signal processing pipeline gated behind probabilistic_scoring_enabled feature flag in risk_configs: - Bayesian log-likelihood accumulator with Beta posterior and entropy - Regime detector (trend-following, panic, mean-reversion, uncertainty) - Source accuracy tracker with per-source historical prediction accuracy - Sigmoid confidence gate replacing binary gate - Information gain surprise weighting for rare events - Adaptive recency decay with event-specific half-lives - Regime multiplier replacing market context multiplier - Weighted disagreement entropy for contradiction detection - Multiplicative macro exposure with conditional integration - Graph-distance attenuated competitive signal propagation - Exponentially weighted momentum with volatility scaling - Expected value recommendation gate All changes backward-compatible: flag=false preserves exact current behavior. New outputs stored in existing JSONB columns (no schema changes except source_accuracy table via migration 034). Tests: 26 property-based tests (14 correctness properties), 99 unit tests, 1789 total tests passing with zero regressions.
242 lines
7.2 KiB
Python
242 lines
7.2 KiB
Python
"""Tests for source accuracy tracker — SourceAccuracy dataclass and
|
|
database functions."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from services.aggregation.source_accuracy import (
|
|
SourceAccuracy,
|
|
fetch_source_accuracy,
|
|
update_source_accuracy,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SourceAccuracy.accuracy_factor property
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_accuracy_factor_low_sample_count():
|
|
"""When sample_count < 10, accuracy_factor returns neutral 1.0."""
|
|
sa = SourceAccuracy(
|
|
source_id="src-1",
|
|
accuracy_ratio=0.9,
|
|
sample_count=5,
|
|
last_updated=datetime.now(timezone.utc),
|
|
)
|
|
assert sa.accuracy_factor == 1.0
|
|
|
|
|
|
def test_accuracy_factor_exactly_ten_samples():
|
|
"""When sample_count == 10, accuracy_factor uses the formula."""
|
|
sa = SourceAccuracy(
|
|
source_id="src-1",
|
|
accuracy_ratio=0.8,
|
|
sample_count=10,
|
|
last_updated=datetime.now(timezone.utc),
|
|
)
|
|
assert abs(sa.accuracy_factor - 1.3) < 1e-9
|
|
|
|
|
|
def test_accuracy_factor_zero_accuracy():
|
|
"""0% accuracy with enough samples gives factor 0.5."""
|
|
sa = SourceAccuracy(
|
|
source_id="src-1",
|
|
accuracy_ratio=0.0,
|
|
sample_count=100,
|
|
last_updated=datetime.now(timezone.utc),
|
|
)
|
|
assert abs(sa.accuracy_factor - 0.5) < 1e-9
|
|
|
|
|
|
def test_accuracy_factor_full_accuracy():
|
|
"""100% accuracy with enough samples gives factor 1.5."""
|
|
sa = SourceAccuracy(
|
|
source_id="src-1",
|
|
accuracy_ratio=1.0,
|
|
sample_count=100,
|
|
last_updated=datetime.now(timezone.utc),
|
|
)
|
|
assert abs(sa.accuracy_factor - 1.5) < 1e-9
|
|
|
|
|
|
def test_accuracy_factor_clamps_corrupted_high():
|
|
"""Corrupted accuracy_ratio > 1.0 is clamped to 1.0 in the factor."""
|
|
sa = SourceAccuracy(
|
|
source_id="src-1",
|
|
accuracy_ratio=2.5,
|
|
sample_count=50,
|
|
last_updated=datetime.now(timezone.utc),
|
|
)
|
|
# clamped to 1.0 → factor = 0.5 + 1.0 = 1.5
|
|
assert abs(sa.accuracy_factor - 1.5) < 1e-9
|
|
|
|
|
|
def test_accuracy_factor_clamps_corrupted_negative():
|
|
"""Corrupted accuracy_ratio < 0.0 is clamped to 0.0 in the factor."""
|
|
sa = SourceAccuracy(
|
|
source_id="src-1",
|
|
accuracy_ratio=-0.3,
|
|
sample_count=50,
|
|
last_updated=datetime.now(timezone.utc),
|
|
)
|
|
# clamped to 0.0 → factor = 0.5 + 0.0 = 0.5
|
|
assert abs(sa.accuracy_factor - 0.5) < 1e-9
|
|
|
|
|
|
def test_accuracy_factor_nine_samples_neutral():
|
|
"""sample_count=9 is still below threshold, returns 1.0."""
|
|
sa = SourceAccuracy(
|
|
source_id="src-1",
|
|
accuracy_ratio=0.0,
|
|
sample_count=9,
|
|
last_updated=datetime.now(timezone.utc),
|
|
)
|
|
assert sa.accuracy_factor == 1.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_source_accuracy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_source_accuracy_empty_ids():
|
|
"""Empty source_ids list returns empty dict without querying."""
|
|
pool = AsyncMock()
|
|
result = await fetch_source_accuracy(pool, [])
|
|
assert result == {}
|
|
pool.fetch.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_source_accuracy_returns_records():
|
|
"""Successful fetch returns SourceAccuracy records keyed by source_id."""
|
|
now = datetime.now(timezone.utc)
|
|
pool = AsyncMock()
|
|
pool.fetch = AsyncMock(return_value=[
|
|
{
|
|
"source_id": "src-a",
|
|
"accuracy_ratio": 0.75,
|
|
"sample_count": 20,
|
|
"last_updated": now,
|
|
},
|
|
{
|
|
"source_id": "src-b",
|
|
"accuracy_ratio": 0.4,
|
|
"sample_count": 15,
|
|
"last_updated": now,
|
|
},
|
|
])
|
|
|
|
result = await fetch_source_accuracy(pool, ["src-a", "src-b"])
|
|
|
|
assert len(result) == 2
|
|
assert result["src-a"].accuracy_ratio == 0.75
|
|
assert result["src-a"].sample_count == 20
|
|
assert result["src-b"].accuracy_ratio == 0.4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_source_accuracy_clamps_corrupted():
|
|
"""Corrupted accuracy_ratio values are clamped to [0.0, 1.0]."""
|
|
now = datetime.now(timezone.utc)
|
|
pool = AsyncMock()
|
|
pool.fetch = AsyncMock(return_value=[
|
|
{
|
|
"source_id": "src-bad",
|
|
"accuracy_ratio": 1.5,
|
|
"sample_count": 30,
|
|
"last_updated": now,
|
|
},
|
|
])
|
|
|
|
result = await fetch_source_accuracy(pool, ["src-bad"])
|
|
assert result["src-bad"].accuracy_ratio == 1.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_source_accuracy_db_error_returns_empty():
|
|
"""When the database is unreachable, returns empty dict."""
|
|
pool = AsyncMock()
|
|
pool.fetch = AsyncMock(side_effect=Exception("connection refused"))
|
|
|
|
result = await fetch_source_accuracy(pool, ["src-a"])
|
|
assert result == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_source_accuracy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_source_accuracy_empty_outcomes():
|
|
"""Empty outcomes list does nothing."""
|
|
pool = AsyncMock()
|
|
await update_source_accuracy(pool, "src-1", [])
|
|
pool.execute.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_source_accuracy_counts_correctly():
|
|
"""Correct and incorrect predictions are counted properly."""
|
|
pool = AsyncMock()
|
|
pool.execute = AsyncMock()
|
|
|
|
outcomes = [
|
|
("bullish", 0.05), # correct
|
|
("bullish", -0.02), # incorrect
|
|
("bearish", -0.03), # correct
|
|
("bearish", 0.01), # incorrect
|
|
]
|
|
|
|
await update_source_accuracy(pool, "src-1", outcomes)
|
|
|
|
pool.execute.assert_called_once()
|
|
call_args = pool.execute.call_args
|
|
# accuracy_ratio = 2/4 = 0.5, total = 4
|
|
assert abs(call_args[0][2] - 0.5) < 1e-9 # accuracy_ratio
|
|
assert call_args[0][3] == 4 # total
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_source_accuracy_skips_neutral():
|
|
"""Neutral predictions and zero returns are excluded."""
|
|
pool = AsyncMock()
|
|
pool.execute = AsyncMock()
|
|
|
|
outcomes = [
|
|
("neutral", 0.05), # skipped — neutral direction
|
|
("bullish", 0.0), # skipped — zero return
|
|
("bullish", 0.03), # counted — correct
|
|
]
|
|
|
|
await update_source_accuracy(pool, "src-1", outcomes)
|
|
|
|
pool.execute.assert_called_once()
|
|
call_args = pool.execute.call_args
|
|
# accuracy_ratio = 1/1 = 1.0, total = 1
|
|
assert abs(call_args[0][2] - 1.0) < 1e-9
|
|
assert call_args[0][3] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_source_accuracy_all_neutral_skips():
|
|
"""When all outcomes are neutral/zero, no DB call is made."""
|
|
pool = AsyncMock()
|
|
await update_source_accuracy(pool, "src-1", [("neutral", 0.05)])
|
|
pool.execute.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_source_accuracy_db_error_logs_and_continues():
|
|
"""DB errors are logged but do not raise."""
|
|
pool = AsyncMock()
|
|
pool.execute = AsyncMock(side_effect=Exception("connection refused"))
|
|
|
|
# Should not raise
|
|
await update_source_accuracy(pool, "src-1", [("bullish", 0.05)])
|