Files
stonks-oracle/services/reporting/sections.py
T
Celes Renata bc077bfcc8
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-1 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
feat: trading feedback engine — periodic performance reports with AI summarization
- Migration 038: trading_reports table + report-summarizer agent seed
- 6 reporting modules: models, collector, sections, validator, summarizer, generator
- API endpoints: GET /api/reports (paginated, filterable), GET /api/reports/{id}
- Frontend hooks: useReports, useReport with TanStack Query
- Scheduler: daily (after 16:30 ET) and weekly (Saturday) report triggers
- Redis queue consumer for async report generation with retry/dedup
- 5 property-based tests (chunking, serialization, validation, accuracy, deltas)
- 109 unit/integration tests across all modules
- 6 frontend hook tests with MSW mocks
2026-05-01 22:13:09 +00:00

371 lines
13 KiB
Python

"""Section builders for trading performance reports.
Each builder takes a CollectedData bundle and returns a typed Pydantic
section model. All builders handle zero-activity gracefully by returning
zero values and empty lists when no data is available.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from services.reporting.collector import CollectedData
from services.reporting.models import (
ModelQualitySection,
ModelQualityWindow,
PLSection,
PositionDetail,
PositionPerformanceSection,
RecommendationAccuracySection,
RiskMetricsSection,
)
logger = logging.getLogger(__name__)
def build_pnl_section(data: CollectedData) -> PLSection:
"""Build P&L section from collected data.
Computes realized/unrealized P&L, daily return, cumulative return,
win/loss counts, win rate, profit factor, and Sharpe ratio from
portfolio_snapshot and closed positions.
"""
snap = data.portfolio_snapshot
if snap is None:
return PLSection(
realized_pnl=0.0,
unrealized_pnl=0.0,
daily_return=0.0,
cumulative_return=0.0,
win_count=0,
loss_count=0,
win_rate=0.0,
profit_factor=0.0,
sharpe_ratio=0.0,
)
# Compute profit factor from closed positions:
# sum of gains / abs(sum of losses)
gains = 0.0
losses = 0.0
for pos in data.closed_positions:
rpnl = float(pos.get("realized_pnl", 0) or 0)
if rpnl > 0:
gains += rpnl
elif rpnl < 0:
losses += abs(rpnl)
profit_factor = (gains / losses) if losses > 0 else 0.0
return PLSection(
realized_pnl=float(snap.get("realized_pnl", 0) or 0),
unrealized_pnl=float(snap.get("unrealized_pnl", 0) or 0),
daily_return=float(snap.get("daily_return", 0) or 0),
cumulative_return=float(snap.get("cumulative_return", 0) or 0),
win_count=int(snap.get("win_count", 0) or 0),
loss_count=int(snap.get("loss_count", 0) or 0),
win_rate=float(snap.get("win_rate", 0) or 0),
profit_factor=profit_factor,
sharpe_ratio=float(snap.get("sharpe_ratio", 0) or 0),
)
def build_recommendation_accuracy_section(
data: CollectedData,
) -> RecommendationAccuracySection:
"""Build recommendation accuracy section.
Joins trading_decisions with prediction_outcomes to compute
act/skip breakdown, win rate of acted recommendations, and
average confidence of acted vs skipped.
"""
if not data.trading_decisions:
return RecommendationAccuracySection(
total_evaluated=0,
act_count=0,
skip_count=0,
acted_win_rate=0.0,
avg_confidence_acted=0.0,
avg_confidence_skipped=0.0,
)
# Build lookup: recommendation_id -> prediction_outcome
# prediction_outcomes are joined with prediction_snapshots in the collector,
# so they carry ticker, direction, action, confidence from the snapshot.
# trading_decisions reference recommendations via recommendation_id.
# We need to match trading_decisions -> recommendations -> prediction_outcomes.
#
# The collector fetches prediction_outcomes joined with prediction_snapshots
# (po.prediction_id = ps.id). Trading decisions reference recommendation_id.
# Recommendations and prediction_snapshots share the same ticker, so we
# match by recommendation_id on the trading_decision side.
# Build recommendation_id -> recommendation dict for confidence lookup
rec_by_id: dict[str, dict] = {}
for rec in data.recommendations:
rec_id = str(rec.get("id", ""))
if rec_id:
rec_by_id[rec_id] = rec
# Build prediction_id -> prediction_outcome for profitability lookup
# We also need to map recommendation_id -> prediction_outcome.
# The link is: trading_decision.recommendation_id -> recommendation.id
# and prediction_outcome has ticker from prediction_snapshots.
# We match by ticker between recommendation and prediction_outcome.
outcome_by_ticker: dict[str, list[dict]] = {}
for po in data.prediction_outcomes:
ticker = po.get("ticker", "")
if ticker:
outcome_by_ticker.setdefault(ticker, []).append(po)
act_count = 0
skip_count = 0
acted_wins = 0
acted_total_with_outcome = 0
confidence_acted: list[float] = []
confidence_skipped: list[float] = []
for td in data.trading_decisions:
decision = str(td.get("decision", "")).lower()
rec_id = str(td.get("recommendation_id", ""))
rec = rec_by_id.get(rec_id, {})
conf = rec.get("confidence")
ticker = td.get("ticker", "")
if decision == "act":
act_count += 1
if conf is not None:
confidence_acted.append(float(conf))
# Check profitability from prediction_outcomes for this ticker
ticker_outcomes = outcome_by_ticker.get(ticker, [])
if ticker_outcomes:
# Use the most recent outcome for this ticker
latest = ticker_outcomes[-1]
acted_total_with_outcome += 1
if latest.get("profitable"):
acted_wins += 1
else:
skip_count += 1
if conf is not None:
confidence_skipped.append(float(conf))
total_evaluated = act_count + skip_count
acted_win_rate = (
(acted_wins / acted_total_with_outcome)
if acted_total_with_outcome > 0
else 0.0
)
avg_confidence_acted = (
(sum(confidence_acted) / len(confidence_acted))
if confidence_acted
else 0.0
)
avg_confidence_skipped = (
(sum(confidence_skipped) / len(confidence_skipped))
if confidence_skipped
else 0.0
)
return RecommendationAccuracySection(
total_evaluated=total_evaluated,
act_count=act_count,
skip_count=skip_count,
acted_win_rate=acted_win_rate,
avg_confidence_acted=avg_confidence_acted,
avg_confidence_skipped=avg_confidence_skipped,
)
def build_position_performance_section(
data: CollectedData,
) -> PositionPerformanceSection:
"""Build position performance section.
Lists each position (open and closed) with entry price,
current/exit price, P&L, P&L%, and hold duration.
"""
positions: list[PositionDetail] = []
now = datetime.now(timezone.utc)
# Open positions
for pos in data.open_positions:
entry_price = float(pos.get("avg_entry_price", 0) or 0)
current_price = float(pos.get("current_price", 0) or 0)
quantity = float(pos.get("quantity", 0) or 0)
pnl = (current_price - entry_price) * quantity
cost_basis = entry_price * quantity
pnl_pct = (pnl / cost_basis * 100) if cost_basis > 0 else 0.0
# Hold duration from updated_at to now
updated_at = pos.get("updated_at")
hold_hours = _compute_hold_hours(updated_at, now)
positions.append(
PositionDetail(
ticker=pos.get("ticker", ""),
entry_price=entry_price,
current_or_exit_price=current_price,
pnl=pnl,
pnl_pct=pnl_pct,
hold_duration_hours=hold_hours,
status="open",
)
)
# Closed positions
for pos in data.closed_positions:
entry_price = float(pos.get("avg_entry_price", 0) or 0)
current_price = float(pos.get("current_price", 0) or 0)
realized_pnl = float(pos.get("realized_pnl", 0) or 0)
cost_basis = entry_price * float(pos.get("quantity", 0) or 0)
# For closed positions, quantity is 0 in the DB, so use realized_pnl
# directly. P&L% is based on the original cost basis which we can
# approximate from entry_price and the realized_pnl.
# If entry_price is available, compute pnl_pct from realized_pnl / cost.
# Since quantity=0 for closed, we estimate original quantity from
# realized_pnl and price difference, or just use realized_pnl directly.
if entry_price > 0 and current_price != entry_price:
# Estimate original quantity from realized_pnl / (exit - entry)
price_diff = current_price - entry_price
if price_diff != 0:
est_quantity = abs(realized_pnl / price_diff)
est_cost = entry_price * est_quantity
pnl_pct = (realized_pnl / est_cost * 100) if est_cost > 0 else 0.0
else:
pnl_pct = 0.0
else:
pnl_pct = 0.0
updated_at = pos.get("updated_at")
hold_hours = _compute_hold_hours(updated_at, now)
positions.append(
PositionDetail(
ticker=pos.get("ticker", ""),
entry_price=entry_price,
current_or_exit_price=current_price,
pnl=realized_pnl,
pnl_pct=pnl_pct,
hold_duration_hours=hold_hours,
status="closed",
)
)
return PositionPerformanceSection(positions=positions)
def _compute_hold_hours(updated_at: datetime | str | None, now: datetime) -> float:
"""Compute hold duration in hours from updated_at to now."""
if updated_at is None:
return 0.0
if isinstance(updated_at, str):
try:
updated_at = datetime.fromisoformat(updated_at)
except (ValueError, TypeError):
return 0.0
if not isinstance(updated_at, datetime):
return 0.0
# Ensure timezone-aware comparison
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
delta = now - updated_at
return max(delta.total_seconds() / 3600.0, 0.0)
def build_risk_metrics_section(data: CollectedData) -> RiskMetricsSection:
"""Build risk metrics section.
Extracts current risk tier, portfolio heat, max drawdown,
current drawdown %, reserve pool balance, and circuit breaker
event count from collected data.
"""
snap = data.portfolio_snapshot
if snap is None:
return RiskMetricsSection(
current_risk_tier="unknown",
portfolio_heat=0.0,
max_drawdown=0.0,
current_drawdown_pct=0.0,
reserve_pool_balance=data.reserve_pool_balance,
circuit_breaker_event_count=len(data.circuit_breaker_events),
)
return RiskMetricsSection(
current_risk_tier=str(snap.get("risk_tier", "unknown") or "unknown"),
portfolio_heat=float(snap.get("portfolio_heat", 0) or 0),
max_drawdown=float(snap.get("max_drawdown", 0) or 0),
current_drawdown_pct=float(snap.get("current_drawdown_pct", 0) or 0),
reserve_pool_balance=data.reserve_pool_balance,
circuit_breaker_event_count=len(data.circuit_breaker_events),
)
def build_model_quality_section(data: CollectedData) -> ModelQualitySection:
"""Build model quality section.
Extracts latest model_metric_snapshot values for 7d, 30d, 90d
lookback windows.
"""
if not data.model_metric_snapshots:
return ModelQualitySection(windows=[])
# Group by lookback_window, take the latest (first in list since
# collector orders by generated_at DESC)
target_windows = {"7d", "30d", "90d"}
latest_by_window: dict[str, dict] = {}
for snap in data.model_metric_snapshots:
window = snap.get("lookback_window", "")
if window in target_windows and window not in latest_by_window:
latest_by_window[window] = snap
windows: list[ModelQualityWindow] = []
for w in ("7d", "30d", "90d"):
snap = latest_by_window.get(w)
if snap is None:
windows.append(
ModelQualityWindow(
lookback=w,
win_rate=None,
directional_accuracy=None,
information_coefficient=None,
calibration_error=None,
brier_score=None,
)
)
else:
windows.append(
ModelQualityWindow(
lookback=w,
win_rate=_safe_float(snap.get("win_rate")),
directional_accuracy=_safe_float(snap.get("directional_accuracy")),
information_coefficient=_safe_float(
snap.get("information_coefficient")
),
calibration_error=_safe_float(snap.get("calibration_error")),
brier_score=_safe_float(snap.get("brier_score")),
)
)
return ModelQualitySection(windows=windows)
def _safe_float(value: object) -> float | None:
"""Convert a value to float, returning None for None/invalid values."""
if value is None:
return None
try:
f = float(value) # type: ignore[arg-type]
# Replace NaN/inf with None
if f != f or f == float("inf") or f == float("-inf"):
return None
return f
except (ValueError, TypeError):
return None