Files
stonks-oracle/services/reporting/collector.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

307 lines
11 KiB
Python

"""Data collector for trading performance reports.
Queries all relevant trading data for a reporting period and returns
a CollectedData bundle for downstream section builders.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from datetime import date
from typing import Any
import asyncpg
logger = logging.getLogger(__name__)
@dataclass
class CollectedData:
"""Raw data collected for a reporting period."""
trading_decisions: list[dict] = field(default_factory=list)
orders: list[dict] = field(default_factory=list)
open_positions: list[dict] = field(default_factory=list)
closed_positions: list[dict] = field(default_factory=list)
portfolio_snapshot: dict | None = None
previous_portfolio_snapshot: dict | None = None
recommendations: list[dict] = field(default_factory=list)
prediction_outcomes: list[dict] = field(default_factory=list)
model_metric_snapshots: list[dict] = field(default_factory=list)
circuit_breaker_events: list[dict] = field(default_factory=list)
reserve_pool_balance: float = 0.0
def _row_dict(row: asyncpg.Record) -> dict[str, Any]:
"""Convert asyncpg Record to dict with UUID→str coercion."""
d = dict(row)
for k, v in d.items():
if isinstance(v, uuid.UUID):
d[k] = str(v)
return d
async def collect_report_data(
pool: asyncpg.Pool,
period_start: date,
period_end: date,
) -> CollectedData:
"""Query all trading data for the reporting period.
Queries: trading_decisions, orders, positions, portfolio_snapshots,
recommendations, prediction_outcomes, model_metric_snapshots,
circuit_breaker_events, reserve_pool_ledger.
Returns CollectedData with all raw query results.
If no trading_decisions exist, returns empty lists (zero-activity).
"""
async with pool.acquire() as conn:
trading_decisions = await _fetch_trading_decisions(conn, period_start, period_end)
orders = await _fetch_orders(conn, period_start, period_end)
open_positions = await _fetch_open_positions(conn)
closed_positions = await _fetch_closed_positions(conn, period_start, period_end)
portfolio_snapshot = await _fetch_portfolio_snapshot(conn, period_start, period_end)
previous_portfolio_snapshot = await _fetch_previous_portfolio_snapshot(conn, period_start)
recommendations = await _fetch_recommendations(conn, period_start, period_end)
prediction_outcomes = await _fetch_prediction_outcomes(conn, period_start, period_end)
model_metric_snapshots = await _fetch_model_metric_snapshots(conn, period_start, period_end)
circuit_breaker_events = await _fetch_circuit_breaker_events(conn, period_start, period_end)
reserve_pool_balance = await _fetch_reserve_pool_balance(conn)
return CollectedData(
trading_decisions=trading_decisions,
orders=orders,
open_positions=open_positions,
closed_positions=closed_positions,
portfolio_snapshot=portfolio_snapshot,
previous_portfolio_snapshot=previous_portfolio_snapshot,
recommendations=recommendations,
prediction_outcomes=prediction_outcomes,
model_metric_snapshots=model_metric_snapshots,
circuit_breaker_events=circuit_breaker_events,
reserve_pool_balance=reserve_pool_balance,
)
async def _fetch_trading_decisions(
conn: asyncpg.Connection,
period_start: date,
period_end: date,
) -> list[dict]:
"""Fetch trading decisions created within the period."""
rows = await conn.fetch(
"""SELECT id, recommendation_id, decision, skip_reason, ticker,
computed_position_size, computed_share_quantity,
risk_tier_at_decision, portfolio_heat_at_decision,
active_pool_at_decision, reserve_pool_at_decision,
circuit_breaker_status, correlation_check_result,
sector_exposure_check_result, earnings_proximity_flag,
is_micro_trade, decision_trace, created_at
FROM trading_decisions
WHERE created_at >= $1::date AND created_at < ($2::date + INTERVAL '1 day')
ORDER BY created_at""",
period_start,
period_end,
)
return [_row_dict(r) for r in rows]
async def _fetch_orders(
conn: asyncpg.Connection,
period_start: date,
period_end: date,
) -> list[dict]:
"""Fetch orders created within the period."""
rows = await conn.fetch(
"""SELECT id, recommendation_id, broker_account_id, ticker, side,
order_type, quantity, limit_price, stop_price, status,
broker_order_id, fill_price, fill_quantity,
submitted_at, filled_at, cancelled_at, rejected_at,
rejection_reason, created_at
FROM orders
WHERE created_at >= $1::date AND created_at < ($2::date + INTERVAL '1 day')
ORDER BY created_at""",
period_start,
period_end,
)
return [_row_dict(r) for r in rows]
async def _fetch_open_positions(conn: asyncpg.Connection) -> list[dict]:
"""Fetch currently open positions (quantity > 0)."""
rows = await conn.fetch(
"""SELECT id, broker_account_id, ticker, quantity,
avg_entry_price, current_price,
unrealized_pnl, realized_pnl, updated_at
FROM positions
WHERE quantity > 0
ORDER BY ticker""",
)
return [_row_dict(r) for r in rows]
async def _fetch_closed_positions(
conn: asyncpg.Connection,
period_start: date,
period_end: date,
) -> list[dict]:
"""Fetch positions closed during the period (quantity = 0, updated within period)."""
rows = await conn.fetch(
"""SELECT id, broker_account_id, ticker, quantity,
avg_entry_price, current_price,
unrealized_pnl, realized_pnl, updated_at
FROM positions
WHERE quantity = 0
AND updated_at >= $1::date
AND updated_at < ($2::date + INTERVAL '1 day')
ORDER BY updated_at""",
period_start,
period_end,
)
return [_row_dict(r) for r in rows]
async def _fetch_portfolio_snapshot(
conn: asyncpg.Connection,
period_start: date,
period_end: date,
) -> dict | None:
"""Fetch the most recent portfolio snapshot within the period."""
row = await conn.fetchrow(
"""SELECT id, snapshot_date, portfolio_value, active_pool, reserve_pool,
daily_return, cumulative_return, unrealized_pnl, realized_pnl,
win_count, loss_count, win_rate, sharpe_ratio,
max_drawdown, current_drawdown_pct, portfolio_heat,
risk_tier, positions, metrics, created_at
FROM portfolio_snapshots
WHERE snapshot_date >= $1 AND snapshot_date <= $2
ORDER BY snapshot_date DESC
LIMIT 1""",
period_start,
period_end,
)
return _row_dict(row) if row else None
async def _fetch_previous_portfolio_snapshot(
conn: asyncpg.Connection,
period_start: date,
) -> dict | None:
"""Fetch the most recent portfolio snapshot before the period start."""
row = await conn.fetchrow(
"""SELECT id, snapshot_date, portfolio_value, active_pool, reserve_pool,
daily_return, cumulative_return, unrealized_pnl, realized_pnl,
win_count, loss_count, win_rate, sharpe_ratio,
max_drawdown, current_drawdown_pct, portfolio_heat,
risk_tier, positions, metrics, created_at
FROM portfolio_snapshots
WHERE snapshot_date < $1
ORDER BY snapshot_date DESC
LIMIT 1""",
period_start,
)
return _row_dict(row) if row else None
async def _fetch_recommendations(
conn: asyncpg.Connection,
period_start: date,
period_end: date,
) -> list[dict]:
"""Fetch recommendations created within the period."""
rows = await conn.fetch(
"""SELECT id, ticker, company_id, action, mode, confidence,
time_horizon, thesis, portfolio_pct, max_loss_pct,
model_version, generated_at, created_at
FROM recommendations
WHERE created_at >= $1::date AND created_at < ($2::date + INTERVAL '1 day')
ORDER BY created_at""",
period_start,
period_end,
)
return [_row_dict(r) for r in rows]
async def _fetch_prediction_outcomes(
conn: asyncpg.Connection,
period_start: date,
period_end: date,
) -> list[dict]:
"""Fetch prediction outcomes evaluated within the period."""
rows = await conn.fetch(
"""SELECT po.id, po.prediction_id, po.evaluated_at, po.horizon,
po.future_price, po.future_return,
po.spy_future_price, po.spy_return,
po.sector_etf_future_price, po.sector_etf_return,
po.excess_return_vs_spy, po.excess_return_vs_sector,
po.direction_correct, po.profitable,
ps.ticker, ps.direction, ps.action, ps.confidence
FROM prediction_outcomes po
JOIN prediction_snapshots ps ON ps.id = po.prediction_id
WHERE po.evaluated_at >= $1::date
AND po.evaluated_at < ($2::date + INTERVAL '1 day')
ORDER BY po.evaluated_at""",
period_start,
period_end,
)
return [_row_dict(r) for r in rows]
async def _fetch_model_metric_snapshots(
conn: asyncpg.Connection,
period_start: date,
period_end: date,
) -> list[dict]:
"""Fetch model metric snapshots generated within the period."""
rows = await conn.fetch(
"""SELECT id, generated_at, lookback_window, horizon,
prediction_count, win_rate, directional_accuracy,
information_coefficient, rank_information_coefficient,
avg_return, avg_excess_return_vs_spy,
avg_excess_return_vs_sector,
calibration_error, brier_score,
buy_win_rate, sell_win_rate, hold_win_rate,
created_at
FROM model_metric_snapshots
WHERE generated_at >= $1::date
AND generated_at < ($2::date + INTERVAL '1 day')
ORDER BY generated_at DESC""",
period_start,
period_end,
)
return [_row_dict(r) for r in rows]
async def _fetch_circuit_breaker_events(
conn: asyncpg.Connection,
period_start: date,
period_end: date,
) -> list[dict]:
"""Fetch circuit breaker events from trading decisions within the period.
Circuit breaker events are trading decisions where
circuit_breaker_status is not 'clear' (i.e. a breaker was active).
"""
rows = await conn.fetch(
"""SELECT id, recommendation_id, decision, ticker,
circuit_breaker_status, decision_trace, created_at
FROM trading_decisions
WHERE circuit_breaker_status != 'clear'
AND created_at >= $1::date
AND created_at < ($2::date + INTERVAL '1 day')
ORDER BY created_at""",
period_start,
period_end,
)
return [_row_dict(r) for r in rows]
async def _fetch_reserve_pool_balance(conn: asyncpg.Connection) -> float:
"""Fetch the latest reserve pool balance."""
row = await conn.fetchrow(
"SELECT balance_after FROM reserve_pool_ledger ORDER BY created_at DESC LIMIT 1",
)
return float(row["balance_after"]) if row else 0.0