"""Tests for DailyReviewer.""" from __future__ import annotations import json import sqlite3 from datetime import UTC, datetime 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 TODAY = datetime.now(UTC).strftime("%Y-%m-%d") @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(TODAY, "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(TODAY, "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(TODAY, "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