feat: trading feedback engine — periodic performance reports with AI summarization
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
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
- 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
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user