diff --git a/src/evolution/__init__.py b/src/evolution/__init__.py index 3346a61..6cb1696 100644 --- a/src/evolution/__init__.py +++ b/src/evolution/__init__.py @@ -1,6 +1,7 @@ """Evolution engine for self-improving trading strategies.""" from src.evolution.ab_test import ABTester, ABTestResult, StrategyPerformance +from src.evolution.daily_review import DailyReviewer from src.evolution.optimizer import EvolutionOptimizer from src.evolution.performance_tracker import ( PerformanceDashboard, @@ -18,4 +19,5 @@ __all__ = [ "PerformanceDashboard", "StrategyMetrics", "DailyScorecard", + "DailyReviewer", ] diff --git a/src/evolution/daily_review.py b/src/evolution/daily_review.py new file mode 100644 index 0000000..fd4eb0c --- /dev/null +++ b/src/evolution/daily_review.py @@ -0,0 +1,196 @@ +"""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] diff --git a/tests/test_daily_review.py b/tests/test_daily_review.py new file mode 100644 index 0000000..2b93001 --- /dev/null +++ b/tests/test_daily_review.py @@ -0,0 +1,383 @@ +"""Tests for DailyReviewer.""" + +from __future__ import annotations + +import json +import sqlite3 +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.context.layer import ContextLayer +from src.context.store import ContextStore +from src.db import init_db, log_trade +from src.evolution.daily_review import DailyReviewer +from src.evolution.scorecard import DailyScorecard +from src.logging.decision_logger import DecisionLogger + + +@pytest.fixture +def db_conn() -> sqlite3.Connection: + return init_db(":memory:") + + +@pytest.fixture +def context_store(db_conn: sqlite3.Connection) -> ContextStore: + return ContextStore(db_conn) + + +def _log_decision( + logger: DecisionLogger, + *, + stock_code: str, + market: str, + action: str, + confidence: int, + scenario_match: dict[str, float] | None = None, +) -> str: + return logger.log_decision( + stock_code=stock_code, + market=market, + exchange_code="KRX" if market == "KR" else "NASDAQ", + action=action, + confidence=confidence, + rationale="test", + context_snapshot={"scenario_match": scenario_match or {}}, + input_data={"stock_code": stock_code}, + ) + + +def test_generate_scorecard_market_scoped( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + reviewer = DailyReviewer(db_conn, context_store) + logger = DecisionLogger(db_conn) + + buy_id = _log_decision( + logger, + stock_code="005930", + market="KR", + action="BUY", + confidence=90, + scenario_match={"rsi": 29.0}, + ) + _log_decision( + logger, + stock_code="000660", + market="KR", + action="HOLD", + confidence=60, + ) + _log_decision( + logger, + stock_code="AAPL", + market="US", + action="SELL", + confidence=80, + scenario_match={"volume_ratio": 2.1}, + ) + + log_trade( + db_conn, + "005930", + "BUY", + 90, + "buy", + quantity=1, + price=100.0, + pnl=10.0, + market="KR", + exchange_code="KRX", + decision_id=buy_id, + ) + log_trade( + db_conn, + "000660", + "HOLD", + 60, + "hold", + quantity=0, + price=0.0, + pnl=0.0, + market="KR", + exchange_code="KRX", + ) + log_trade( + db_conn, + "AAPL", + "SELL", + 80, + "sell", + quantity=1, + price=200.0, + pnl=-5.0, + market="US", + exchange_code="NASDAQ", + ) + + scorecard = reviewer.generate_scorecard("2026-02-14", "KR") + + assert scorecard.market == "KR" + assert scorecard.total_decisions == 2 + assert scorecard.buys == 1 + assert scorecard.sells == 0 + assert scorecard.holds == 1 + assert scorecard.total_pnl == 10.0 + assert scorecard.win_rate == 100.0 + assert scorecard.avg_confidence == 75.0 + assert scorecard.scenario_match_rate == 50.0 + + +def test_generate_scorecard_top_winners_and_losers( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + reviewer = DailyReviewer(db_conn, context_store) + logger = DecisionLogger(db_conn) + + for code, pnl in [("005930", 30.0), ("000660", 10.0), ("035420", -15.0), ("051910", -5.0)]: + decision_id = _log_decision( + logger, + stock_code=code, + market="KR", + action="BUY" if pnl >= 0 else "SELL", + confidence=80, + scenario_match={"rsi": 30.0}, + ) + log_trade( + db_conn, + code, + "BUY" if pnl >= 0 else "SELL", + 80, + "test", + quantity=1, + price=100.0, + pnl=pnl, + market="KR", + exchange_code="KRX", + decision_id=decision_id, + ) + + scorecard = reviewer.generate_scorecard("2026-02-14", "KR") + assert scorecard.top_winners == ["005930", "000660"] + assert scorecard.top_losers == ["035420", "051910"] + + +def test_generate_scorecard_empty_day( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + reviewer = DailyReviewer(db_conn, context_store) + scorecard = reviewer.generate_scorecard("2026-02-14", "KR") + + assert scorecard.total_decisions == 0 + assert scorecard.total_pnl == 0.0 + assert scorecard.win_rate == 0.0 + assert scorecard.avg_confidence == 0.0 + assert scorecard.scenario_match_rate == 0.0 + assert scorecard.top_winners == [] + assert scorecard.top_losers == [] + + +@pytest.mark.asyncio +async def test_generate_lessons_without_gemini_returns_empty( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + reviewer = DailyReviewer(db_conn, context_store, gemini_client=None) + lessons = await reviewer.generate_lessons( + DailyScorecard( + date="2026-02-14", + market="KR", + total_decisions=1, + buys=1, + sells=0, + holds=0, + total_pnl=5.0, + win_rate=100.0, + avg_confidence=90.0, + scenario_match_rate=100.0, + ) + ) + assert lessons == [] + + +@pytest.mark.asyncio +async def test_generate_lessons_parses_json_array( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + mock_gemini = MagicMock() + mock_gemini.decide = AsyncMock( + return_value=SimpleNamespace(rationale='["Cut losers earlier", "Reduce midday churn"]') + ) + reviewer = DailyReviewer(db_conn, context_store, gemini_client=mock_gemini) + + lessons = await reviewer.generate_lessons( + DailyScorecard( + date="2026-02-14", + market="KR", + total_decisions=3, + buys=1, + sells=1, + holds=1, + total_pnl=-2.5, + win_rate=50.0, + avg_confidence=70.0, + scenario_match_rate=66.7, + ) + ) + assert lessons == ["Cut losers earlier", "Reduce midday churn"] + + +@pytest.mark.asyncio +async def test_generate_lessons_fallback_to_lines( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + mock_gemini = MagicMock() + mock_gemini.decide = AsyncMock( + return_value=SimpleNamespace(rationale="- Keep risk tighter\n- Increase selectivity") + ) + reviewer = DailyReviewer(db_conn, context_store, gemini_client=mock_gemini) + + lessons = await reviewer.generate_lessons( + DailyScorecard( + date="2026-02-14", + market="US", + total_decisions=2, + buys=1, + sells=1, + holds=0, + total_pnl=1.0, + win_rate=50.0, + avg_confidence=75.0, + scenario_match_rate=100.0, + ) + ) + assert lessons == ["Keep risk tighter", "Increase selectivity"] + + +@pytest.mark.asyncio +async def test_generate_lessons_handles_gemini_error( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + mock_gemini = MagicMock() + mock_gemini.decide = AsyncMock(side_effect=RuntimeError("boom")) + reviewer = DailyReviewer(db_conn, context_store, gemini_client=mock_gemini) + + lessons = await reviewer.generate_lessons( + DailyScorecard( + date="2026-02-14", + market="US", + total_decisions=0, + buys=0, + sells=0, + holds=0, + total_pnl=0.0, + win_rate=0.0, + avg_confidence=0.0, + scenario_match_rate=0.0, + ) + ) + assert lessons == [] + + +def test_store_scorecard_in_context( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + reviewer = DailyReviewer(db_conn, context_store) + scorecard = DailyScorecard( + date="2026-02-14", + market="KR", + total_decisions=5, + buys=2, + sells=1, + holds=2, + total_pnl=15.0, + win_rate=66.67, + avg_confidence=82.0, + scenario_match_rate=80.0, + lessons=["Keep position sizing stable"], + cross_market_note="US risk-off", + ) + + reviewer.store_scorecard_in_context(scorecard) + + stored = context_store.get_context( + ContextLayer.L6_DAILY, + "2026-02-14", + "scorecard_KR", + ) + assert stored is not None + assert stored["market"] == "KR" + assert stored["total_pnl"] == 15.0 + assert stored["lessons"] == ["Keep position sizing stable"] + + +def test_store_scorecard_key_is_market_scoped( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + reviewer = DailyReviewer(db_conn, context_store) + kr = DailyScorecard( + date="2026-02-14", + market="KR", + total_decisions=1, + buys=1, + sells=0, + holds=0, + total_pnl=1.0, + win_rate=100.0, + avg_confidence=90.0, + scenario_match_rate=100.0, + ) + us = DailyScorecard( + date="2026-02-14", + market="US", + total_decisions=1, + buys=0, + sells=1, + holds=0, + total_pnl=-1.0, + win_rate=0.0, + avg_confidence=70.0, + scenario_match_rate=100.0, + ) + + reviewer.store_scorecard_in_context(kr) + reviewer.store_scorecard_in_context(us) + + kr_ctx = context_store.get_context(ContextLayer.L6_DAILY, "2026-02-14", "scorecard_KR") + us_ctx = context_store.get_context(ContextLayer.L6_DAILY, "2026-02-14", "scorecard_US") + + assert kr_ctx["market"] == "KR" + assert us_ctx["market"] == "US" + assert kr_ctx["total_pnl"] == 1.0 + assert us_ctx["total_pnl"] == -1.0 + + +def test_generate_scorecard_handles_invalid_context_snapshot( + db_conn: sqlite3.Connection, context_store: ContextStore, +) -> None: + reviewer = DailyReviewer(db_conn, context_store) + db_conn.execute( + """ + INSERT INTO decision_logs ( + decision_id, timestamp, stock_code, market, exchange_code, + action, confidence, rationale, context_snapshot, input_data + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "d1", + "2026-02-14T09:00:00+00:00", + "005930", + "KR", + "KRX", + "HOLD", + 50, + "test", + "{invalid_json", + json.dumps({}), + ), + ) + db_conn.commit() + + scorecard = reviewer.generate_scorecard("2026-02-14", "KR") + assert scorecard.total_decisions == 1 + assert scorecard.scenario_match_rate == 0.0