Some checks failed
CI / test (pull_request) Has been cancelled
Generate per-market daily scorecards from decision_logs and trades, optional Gemini-powered lessons, and store results in L6 context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
197 lines
6.8 KiB
Python
197 lines
6.8 KiB
Python
"""Daily review generator for market-scoped end-of-day scorecards."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import sqlite3
|
|
from dataclasses import asdict
|
|
|
|
from src.brain.gemini_client import GeminiClient
|
|
from src.context.layer import ContextLayer
|
|
from src.context.store import ContextStore
|
|
from src.evolution.scorecard import DailyScorecard
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DailyReviewer:
|
|
"""Builds daily scorecards and optional AI-generated lessons."""
|
|
|
|
def __init__(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
context_store: ContextStore,
|
|
gemini_client: GeminiClient | None = None,
|
|
) -> None:
|
|
self._conn = conn
|
|
self._context_store = context_store
|
|
self._gemini = gemini_client
|
|
|
|
def generate_scorecard(self, date: str, market: str) -> DailyScorecard:
|
|
"""Generate a market-scoped scorecard from decision logs and trades."""
|
|
decision_rows = self._conn.execute(
|
|
"""
|
|
SELECT action, confidence, context_snapshot
|
|
FROM decision_logs
|
|
WHERE DATE(timestamp) = ? AND market = ?
|
|
""",
|
|
(date, market),
|
|
).fetchall()
|
|
|
|
total_decisions = len(decision_rows)
|
|
buys = sum(1 for row in decision_rows if row[0] == "BUY")
|
|
sells = sum(1 for row in decision_rows if row[0] == "SELL")
|
|
holds = sum(1 for row in decision_rows if row[0] == "HOLD")
|
|
avg_confidence = (
|
|
round(sum(int(row[1]) for row in decision_rows) / total_decisions, 2)
|
|
if total_decisions > 0
|
|
else 0.0
|
|
)
|
|
|
|
matched = 0
|
|
for row in decision_rows:
|
|
try:
|
|
snapshot = json.loads(row[2]) if row[2] else {}
|
|
except json.JSONDecodeError:
|
|
snapshot = {}
|
|
scenario_match = snapshot.get("scenario_match", {})
|
|
if isinstance(scenario_match, dict) and scenario_match:
|
|
matched += 1
|
|
scenario_match_rate = (
|
|
round((matched / total_decisions) * 100, 2)
|
|
if total_decisions
|
|
else 0.0
|
|
)
|
|
|
|
trade_stats = self._conn.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(SUM(pnl), 0.0),
|
|
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END),
|
|
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END)
|
|
FROM trades
|
|
WHERE DATE(timestamp) = ? AND market = ?
|
|
""",
|
|
(date, market),
|
|
).fetchone()
|
|
total_pnl = round(float(trade_stats[0] or 0.0), 2) if trade_stats else 0.0
|
|
wins = int(trade_stats[1] or 0) if trade_stats else 0
|
|
losses = int(trade_stats[2] or 0) if trade_stats else 0
|
|
win_rate = round((wins / (wins + losses)) * 100, 2) if (wins + losses) > 0 else 0.0
|
|
|
|
top_winners = [
|
|
row[0]
|
|
for row in self._conn.execute(
|
|
"""
|
|
SELECT stock_code, SUM(pnl) AS stock_pnl
|
|
FROM trades
|
|
WHERE DATE(timestamp) = ? AND market = ?
|
|
GROUP BY stock_code
|
|
HAVING stock_pnl > 0
|
|
ORDER BY stock_pnl DESC
|
|
LIMIT 3
|
|
""",
|
|
(date, market),
|
|
).fetchall()
|
|
]
|
|
|
|
top_losers = [
|
|
row[0]
|
|
for row in self._conn.execute(
|
|
"""
|
|
SELECT stock_code, SUM(pnl) AS stock_pnl
|
|
FROM trades
|
|
WHERE DATE(timestamp) = ? AND market = ?
|
|
GROUP BY stock_code
|
|
HAVING stock_pnl < 0
|
|
ORDER BY stock_pnl ASC
|
|
LIMIT 3
|
|
""",
|
|
(date, market),
|
|
).fetchall()
|
|
]
|
|
|
|
return DailyScorecard(
|
|
date=date,
|
|
market=market,
|
|
total_decisions=total_decisions,
|
|
buys=buys,
|
|
sells=sells,
|
|
holds=holds,
|
|
total_pnl=total_pnl,
|
|
win_rate=win_rate,
|
|
avg_confidence=avg_confidence,
|
|
scenario_match_rate=scenario_match_rate,
|
|
top_winners=top_winners,
|
|
top_losers=top_losers,
|
|
lessons=[],
|
|
cross_market_note="",
|
|
)
|
|
|
|
async def generate_lessons(self, scorecard: DailyScorecard) -> list[str]:
|
|
"""Generate concise lessons from scorecard metrics using Gemini."""
|
|
if self._gemini is None:
|
|
return []
|
|
|
|
prompt = (
|
|
"You are a trading performance reviewer.\n"
|
|
"Return ONLY a JSON array of 1-3 short lessons in English.\n"
|
|
f"Market: {scorecard.market}\n"
|
|
f"Date: {scorecard.date}\n"
|
|
f"Total decisions: {scorecard.total_decisions}\n"
|
|
f"Buys/Sells/Holds: {scorecard.buys}/{scorecard.sells}/{scorecard.holds}\n"
|
|
f"Total PnL: {scorecard.total_pnl}\n"
|
|
f"Win rate: {scorecard.win_rate}%\n"
|
|
f"Average confidence: {scorecard.avg_confidence}\n"
|
|
f"Scenario match rate: {scorecard.scenario_match_rate}%\n"
|
|
f"Top winners: {', '.join(scorecard.top_winners) or 'N/A'}\n"
|
|
f"Top losers: {', '.join(scorecard.top_losers) or 'N/A'}\n"
|
|
)
|
|
|
|
try:
|
|
decision = await self._gemini.decide(
|
|
{
|
|
"stock_code": "REVIEW",
|
|
"market_name": scorecard.market,
|
|
"current_price": 0,
|
|
"prompt_override": prompt,
|
|
}
|
|
)
|
|
return self._parse_lessons(decision.rationale)
|
|
except Exception as exc:
|
|
logger.warning("Failed to generate daily lessons: %s", exc)
|
|
return []
|
|
|
|
def store_scorecard_in_context(self, scorecard: DailyScorecard) -> None:
|
|
"""Store scorecard in L6 using market-scoped key."""
|
|
self._context_store.set_context(
|
|
ContextLayer.L6_DAILY,
|
|
scorecard.date,
|
|
f"scorecard_{scorecard.market}",
|
|
asdict(scorecard),
|
|
)
|
|
|
|
def _parse_lessons(self, raw_text: str) -> list[str]:
|
|
"""Parse lessons from JSON array response or fallback text."""
|
|
raw_text = raw_text.strip()
|
|
try:
|
|
parsed = json.loads(raw_text)
|
|
if isinstance(parsed, list):
|
|
return [str(item).strip() for item in parsed if str(item).strip()][:3]
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
match = re.search(r"\[.*\]", raw_text, re.DOTALL)
|
|
if match:
|
|
try:
|
|
parsed = json.loads(match.group(0))
|
|
if isinstance(parsed, list):
|
|
return [str(item).strip() for item in parsed if str(item).strip()][:3]
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
lines = [line.strip("-* \t") for line in raw_text.splitlines() if line.strip()]
|
|
return lines[:3]
|