diff --git a/src/main.py b/src/main.py index 05648cb..ae5fe05 100644 --- a/src/main.py +++ b/src/main.py @@ -11,13 +11,12 @@ import asyncio import logging import signal import sys -from datetime import UTC, datetime +from datetime import UTC, date, datetime from typing import Any from src.analysis.scanner import MarketScanner from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner from src.analysis.volatility import VolatilityAnalyzer -from src.brain.gemini_client import GeminiClient from src.broker.kis_api import KISBroker from src.broker.overseas import OverseasBroker from src.config import Settings @@ -30,7 +29,13 @@ from src.db import init_db, log_trade from src.logging.decision_logger import DecisionLogger from src.logging_config import setup_logging from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets +from src.brain.context_selector import ContextSelector +from src.brain.gemini_client import GeminiClient, TradeDecision from src.notifications.telegram_client import TelegramClient, TelegramCommandHandler +from src.strategy.models import DayPlaybook +from src.strategy.playbook_store import PlaybookStore +from src.strategy.pre_market_planner import PreMarketPlanner +from src.strategy.scenario_engine import ScenarioEngine, ScenarioMatch logger = logging.getLogger(__name__) @@ -75,7 +80,8 @@ TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions async def trading_cycle( broker: KISBroker, overseas_broker: OverseasBroker, - brain: GeminiClient, + scenario_engine: ScenarioEngine, + playbook: DayPlaybook, risk: RiskManager, db_conn: Any, decision_logger: DecisionLogger, @@ -135,13 +141,26 @@ async def trading_cycle( else 0.0 ) - market_data = { + market_data: dict[str, Any] = { "stock_code": stock_code, "market_name": market.name, "current_price": current_price, "foreigner_net": foreigner_net, } + # Enrich market_data with scanner metrics for scenario engine + candidate = scan_candidates.get(stock_code) + if candidate: + market_data["rsi"] = candidate.rsi + market_data["volume_ratio"] = candidate.volume_ratio + + # Build portfolio data for global rule evaluation + portfolio_data = { + "portfolio_pnl_pct": pnl_pct, + "total_cash": total_cash, + "total_eval": total_eval, + } + # 1.5. Get volatility metrics from context store (L7_REALTIME) latest_timeframe = context_store.get_latest_timeframe(ContextLayer.L7_REALTIME) volatility_score = 50.0 # Default normal volatility @@ -178,8 +197,13 @@ async def trading_cycle( volume_surge, ) - # 2. Ask the brain for a decision - decision = await brain.decide(market_data) + # 2. Evaluate scenario (local, no API call) + match = scenario_engine.evaluate(playbook, stock_code, market_data, portfolio_data) + decision = TradeDecision( + action=match.action.value, + confidence=match.confidence, + rationale=match.rationale, + ) logger.info( "Decision for %s (%s): %s (confidence=%d)", stock_code, @@ -188,6 +212,19 @@ async def trading_cycle( decision.confidence, ) + # 2.1. Notify scenario match + if match.matched_scenario is not None: + try: + condition_parts = [f"{k}={v}" for k, v in match.match_details.items()] + await telegram.notify_scenario_matched( + stock_code=stock_code, + action=decision.action, + condition_summary=", ".join(condition_parts) if condition_parts else "matched", + confidence=float(decision.confidence), + ) + except Exception as exc: + logger.warning("Scenario matched notification failed: %s", exc) + # 2.5. Log decision with context snapshot context_snapshot = { "L1": { @@ -200,7 +237,7 @@ async def trading_cycle( "purchase_total": purchase_total, "pnl_pct": pnl_pct, }, - # L3-L7 will be populated when context tree is implemented + "scenario_match": match.match_details, } input_data = { "current_price": current_price, @@ -324,7 +361,9 @@ async def trading_cycle( async def run_daily_session( broker: KISBroker, overseas_broker: OverseasBroker, - brain: GeminiClient, + scenario_engine: ScenarioEngine, + playbook_store: PlaybookStore, + pre_market_planner: PreMarketPlanner, risk: RiskManager, db_conn: Any, decision_logger: DecisionLogger, @@ -336,10 +375,8 @@ async def run_daily_session( ) -> None: """Execute one daily trading session. - Designed for API efficiency with Gemini Free tier: - - Batch decision making (1 API call per market) - - Runs N times per day at fixed intervals - - Minimizes API usage while maintaining trading capability + V2 proactive strategy: 1 Gemini call for playbook generation, + then local scenario evaluation per stock (0 API calls). """ # Get currently open markets open_markets = get_open_markets(settings.enabled_market_list) @@ -350,29 +387,67 @@ async def run_daily_session( logger.info("Starting daily trading session for %d markets", len(open_markets)) + today = date.today() + # Process each open market for market in open_markets: # Dynamic stock discovery via scanner (no static watchlists) + candidates_list: list[ScanCandidate] = [] try: - candidates = await smart_scanner.scan() - watchlist = [c.stock_code for c in candidates] if candidates else [] + candidates_list = await smart_scanner.scan() if smart_scanner else [] except Exception as exc: logger.error("Smart Scanner failed for %s: %s", market.name, exc) - watchlist = [] - if not watchlist: + if not candidates_list: logger.info("No scanner candidates for market %s — skipping", market.code) continue + watchlist = [c.stock_code for c in candidates_list] + candidate_map = {c.stock_code: c for c in candidates_list} logger.info("Processing market: %s (%d stocks)", market.name, len(watchlist)) + # Generate or load playbook (1 Gemini API call per market per day) + playbook = playbook_store.load(today, market.code) + if playbook is None: + try: + playbook = await pre_market_planner.generate_playbook( + market=market.code, + candidates=candidates_list, + today=today, + ) + playbook_store.save(playbook) + try: + await telegram.notify_playbook_generated( + market=market.code, + stock_count=playbook.stock_count, + scenario_count=playbook.scenario_count, + token_count=playbook.token_count, + ) + except Exception as exc: + logger.warning("Playbook notification failed: %s", exc) + logger.info( + "Generated playbook for %s: %d stocks, %d scenarios", + market.code, playbook.stock_count, playbook.scenario_count, + ) + except Exception as exc: + logger.error("Playbook generation failed for %s: %s", market.code, exc) + try: + await telegram.notify_playbook_failed( + market=market.code, reason=str(exc)[:200], + ) + except Exception as notify_exc: + logger.warning("Playbook failed notification error: %s", notify_exc) + playbook = PreMarketPlanner._empty_playbook(today, market.code) + # Collect market data for all stocks from scanner stocks_data = [] for stock_code in watchlist: try: if market.is_domestic: orderbook = await broker.get_orderbook(stock_code) - current_price = safe_float(orderbook.get("output1", {}).get("stck_prpr", "0")) + current_price = safe_float( + orderbook.get("output1", {}).get("stck_prpr", "0") + ) foreigner_net = safe_float( orderbook.get("output1", {}).get("frgn_ntby_qty", "0") ) @@ -380,17 +455,23 @@ async def run_daily_session( price_data = await overseas_broker.get_overseas_price( market.exchange_code, stock_code ) - current_price = safe_float(price_data.get("output", {}).get("last", "0")) + current_price = safe_float( + price_data.get("output", {}).get("last", "0") + ) foreigner_net = 0.0 - stocks_data.append( - { - "stock_code": stock_code, - "market_name": market.name, - "current_price": current_price, - "foreigner_net": foreigner_net, - } - ) + stock_data: dict[str, Any] = { + "stock_code": stock_code, + "market_name": market.name, + "current_price": current_price, + "foreigner_net": foreigner_net, + } + # Enrich with scanner metrics + cand = candidate_map.get(stock_code) + if cand: + stock_data["rsi"] = cand.rsi + stock_data["volume_ratio"] = cand.volume_ratio + stocks_data.append(stock_data) except Exception as exc: logger.error("Failed to fetch data for %s: %s", stock_code, exc) continue @@ -399,17 +480,19 @@ async def run_daily_session( logger.warning("No valid stock data for market %s", market.code) continue - # Get batch decisions (1 API call for all stocks in this market) - logger.info("Requesting batch decision for %d stocks in %s", len(stocks_data), market.name) - decisions = await brain.decide_batch(stocks_data) - # Get balance data once for the market if market.is_domestic: balance_data = await broker.get_balance() output2 = balance_data.get("output2", [{}]) - total_eval = safe_float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0 - total_cash = safe_float(output2[0].get("dnca_tot_amt", "0")) if output2 else 0 - purchase_total = safe_float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0 + total_eval = safe_float( + output2[0].get("tot_evlu_amt", "0") + ) if output2 else 0 + total_cash = safe_float( + output2[0].get("dnca_tot_amt", "0") + ) if output2 else 0 + purchase_total = safe_float( + output2[0].get("pchs_amt_smtl_amt", "0") + ) if output2 else 0 else: balance_data = await overseas_broker.get_overseas_balance(market.exchange_code) output2 = balance_data.get("output2", [{}]) @@ -422,21 +505,37 @@ async def run_daily_session( total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0") total_cash = safe_float(balance_info.get("frcr_dncl_amt_2", "0") or "0") - purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0") + purchase_total = safe_float( + balance_info.get("frcr_buy_amt_smtl", "0") or "0" + ) # Calculate daily P&L % pnl_pct = ( - ((total_eval - purchase_total) / purchase_total * 100) if purchase_total > 0 else 0.0 + ((total_eval - purchase_total) / purchase_total * 100) + if purchase_total > 0 + else 0.0 ) + portfolio_data = { + "portfolio_pnl_pct": pnl_pct, + "total_cash": total_cash, + "total_eval": total_eval, + } - # Execute decisions for each stock + # Evaluate scenarios for each stock (local, no API calls) + logger.info( + "Evaluating %d stocks against playbook for %s", + len(stocks_data), market.name, + ) for stock_data in stocks_data: stock_code = stock_data["stock_code"] - decision = decisions.get(stock_code) - - if not decision: - logger.warning("No decision for %s — skipping", stock_code) - continue + match = scenario_engine.evaluate( + playbook, stock_code, stock_data, portfolio_data, + ) + decision = TradeDecision( + action=match.action.value, + confidence=match.confidence, + rationale=match.rationale, + ) logger.info( "Decision for %s (%s): %s (confidence=%d)", @@ -458,6 +557,7 @@ async def run_daily_session( "purchase_total": purchase_total, "pnl_pct": pnl_pct, }, + "scenario_match": match.match_details, } input_data = { "current_price": stock_data["current_price"], @@ -509,7 +609,9 @@ async def run_daily_session( threshold=exc.threshold, ) except Exception as notify_exc: - logger.warning("Circuit breaker notification failed: %s", notify_exc) + logger.warning( + "Circuit breaker notification failed: %s", notify_exc + ) raise # Send order @@ -544,7 +646,9 @@ async def run_daily_session( except Exception as exc: logger.warning("Telegram notification failed: %s", exc) except Exception as exc: - logger.error("Order execution failed for %s: %s", stock_code, exc) + logger.error( + "Order execution failed for %s: %s", stock_code, exc + ) continue # Log trade @@ -571,6 +675,20 @@ async def run(settings: Settings) -> None: decision_logger = DecisionLogger(db_conn) context_store = ContextStore(db_conn) + # V2 proactive strategy components + context_selector = ContextSelector(context_store) + scenario_engine = ScenarioEngine() + playbook_store = PlaybookStore(db_conn) + pre_market_planner = PreMarketPlanner( + gemini_client=brain, + context_store=context_store, + context_selector=context_selector, + settings=settings, + ) + + # Track playbooks per market (in-memory cache) + playbooks: dict[str, DayPlaybook] = {} + # Initialize Telegram notifications telegram = TelegramClient( bot_token=settings.TELEGRAM_BOT_TOKEN, @@ -802,7 +920,9 @@ async def run(settings: Settings) -> None: await run_daily_session( broker, overseas_broker, - brain, + scenario_engine, + playbook_store, + pre_market_planner, risk, db_conn, decision_logger, @@ -850,6 +970,8 @@ async def run(settings: Settings) -> None: except Exception as exc: logger.warning("Market close notification failed: %s", exc) _market_states[market_code] = False + # Clear playbook for closed market (new one generated next open) + playbooks.pop(market_code, None) # No markets open — wait until next market opens try: @@ -887,7 +1009,8 @@ async def run(settings: Settings) -> None: # Smart Scanner: dynamic stock discovery (no static watchlists) now_timestamp = asyncio.get_event_loop().time() last_scan = last_scan_time.get(market.code, 0.0) - if now_timestamp - last_scan >= SCAN_INTERVAL_SECONDS: + rescan_interval = settings.RESCAN_INTERVAL_SECONDS + if now_timestamp - last_scan >= rescan_interval: try: logger.info("Smart Scanner: Scanning %s market", market.name) @@ -909,6 +1032,44 @@ async def run(settings: Settings) -> None: market.name, [f"{c.stock_code}(RSI={c.rsi:.0f})" for c in candidates], ) + + # Generate playbook on first scan (1 Gemini call per market) + if market.code not in playbooks: + try: + pb = await pre_market_planner.generate_playbook( + market=market.code, + candidates=candidates, + ) + playbook_store.save(pb) + playbooks[market.code] = pb + try: + await telegram.notify_playbook_generated( + market=market.code, + stock_count=pb.stock_count, + scenario_count=pb.scenario_count, + token_count=pb.token_count, + ) + except Exception as exc: + logger.warning( + "Playbook notification failed: %s", exc + ) + except Exception as exc: + logger.error( + "Playbook generation failed for %s: %s", + market.code, exc, + ) + try: + await telegram.notify_playbook_failed( + market=market.code, + reason=str(exc)[:200], + ) + except Exception: + pass + playbooks[market.code] = ( + PreMarketPlanner._empty_playbook( + date.today(), market.code + ) + ) else: logger.info( "Smart Scanner: No candidates for %s — no trades", market.name @@ -933,13 +1094,20 @@ async def run(settings: Settings) -> None: if shutdown.is_set(): break + # Get playbook for this market + market_playbook = playbooks.get( + market.code, + PreMarketPlanner._empty_playbook(date.today(), market.code), + ) + # Retry logic for connection errors for attempt in range(1, MAX_CONNECTION_RETRIES + 1): try: await trading_cycle( broker, overseas_broker, - brain, + scenario_engine, + market_playbook, risk, db_conn, decision_logger, diff --git a/tests/test_main.py b/tests/test_main.py index 9ed185e..bb4baf4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,12 +1,42 @@ -"""Tests for main trading loop telegram integration.""" +"""Tests for main trading loop integration.""" import asyncio +from datetime import date from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected from src.main import safe_float, trading_cycle +from src.strategy.models import DayPlaybook, ScenarioAction, StockCondition, StockPlaybook, StockScenario +from src.strategy.scenario_engine import ScenarioEngine, ScenarioMatch + + +def _make_playbook(market: str = "KR") -> DayPlaybook: + """Create a minimal empty playbook for testing.""" + return DayPlaybook(date=date(2026, 2, 8), market=market) + + +def _make_buy_match(stock_code: str = "005930") -> ScenarioMatch: + """Create a ScenarioMatch that returns BUY.""" + return ScenarioMatch( + stock_code=stock_code, + matched_scenario=None, + action=ScenarioAction.BUY, + confidence=85, + rationale="Test buy", + ) + + +def _make_hold_match(stock_code: str = "005930") -> ScenarioMatch: + """Create a ScenarioMatch that returns HOLD.""" + return ScenarioMatch( + stock_code=stock_code, + matched_scenario=None, + action=ScenarioAction.HOLD, + confidence=0, + rationale="No scenario conditions met", + ) class TestSafeFloat: @@ -81,15 +111,16 @@ class TestTradingCycleTelegramIntegration: return broker @pytest.fixture - def mock_brain(self) -> MagicMock: - """Create mock brain that decides to buy.""" - brain = MagicMock() - decision = MagicMock() - decision.action = "BUY" - decision.confidence = 85 - decision.rationale = "Test buy" - brain.decide = AsyncMock(return_value=decision) - return brain + def mock_scenario_engine(self) -> MagicMock: + """Create mock scenario engine that returns BUY.""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_buy_match()) + return engine + + @pytest.fixture + def mock_playbook(self) -> DayPlaybook: + """Create a minimal day playbook.""" + return _make_playbook() @pytest.fixture def mock_risk(self) -> MagicMock: @@ -134,6 +165,7 @@ class TestTradingCycleTelegramIntegration: telegram.notify_trade_execution = AsyncMock() telegram.notify_fat_finger = AsyncMock() telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() return telegram @pytest.fixture @@ -151,7 +183,8 @@ class TestTradingCycleTelegramIntegration: self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, - mock_brain: MagicMock, + mock_scenario_engine: MagicMock, + mock_playbook: DayPlaybook, mock_risk: MagicMock, mock_db: MagicMock, mock_decision_logger: MagicMock, @@ -165,7 +198,8 @@ class TestTradingCycleTelegramIntegration: await trading_cycle( broker=mock_broker, overseas_broker=mock_overseas_broker, - brain=mock_brain, + scenario_engine=mock_scenario_engine, + playbook=mock_playbook, risk=mock_risk, db_conn=mock_db, decision_logger=mock_decision_logger, @@ -190,7 +224,8 @@ class TestTradingCycleTelegramIntegration: self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, - mock_brain: MagicMock, + mock_scenario_engine: MagicMock, + mock_playbook: DayPlaybook, mock_risk: MagicMock, mock_db: MagicMock, mock_decision_logger: MagicMock, @@ -208,7 +243,8 @@ class TestTradingCycleTelegramIntegration: await trading_cycle( broker=mock_broker, overseas_broker=mock_overseas_broker, - brain=mock_brain, + scenario_engine=mock_scenario_engine, + playbook=mock_playbook, risk=mock_risk, db_conn=mock_db, decision_logger=mock_decision_logger, @@ -228,7 +264,8 @@ class TestTradingCycleTelegramIntegration: self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, - mock_brain: MagicMock, + mock_scenario_engine: MagicMock, + mock_playbook: DayPlaybook, mock_risk: MagicMock, mock_db: MagicMock, mock_decision_logger: MagicMock, @@ -250,7 +287,8 @@ class TestTradingCycleTelegramIntegration: await trading_cycle( broker=mock_broker, overseas_broker=mock_overseas_broker, - brain=mock_brain, + scenario_engine=mock_scenario_engine, + playbook=mock_playbook, risk=mock_risk, db_conn=mock_db, decision_logger=mock_decision_logger, @@ -275,7 +313,8 @@ class TestTradingCycleTelegramIntegration: self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, - mock_brain: MagicMock, + mock_scenario_engine: MagicMock, + mock_playbook: DayPlaybook, mock_risk: MagicMock, mock_db: MagicMock, mock_decision_logger: MagicMock, @@ -299,7 +338,8 @@ class TestTradingCycleTelegramIntegration: await trading_cycle( broker=mock_broker, overseas_broker=mock_overseas_broker, - brain=mock_brain, + scenario_engine=mock_scenario_engine, + playbook=mock_playbook, risk=mock_risk, db_conn=mock_db, decision_logger=mock_decision_logger, @@ -319,7 +359,8 @@ class TestTradingCycleTelegramIntegration: self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, - mock_brain: MagicMock, + mock_scenario_engine: MagicMock, + mock_playbook: DayPlaybook, mock_risk: MagicMock, mock_db: MagicMock, mock_decision_logger: MagicMock, @@ -329,18 +370,15 @@ class TestTradingCycleTelegramIntegration: mock_market: MagicMock, ) -> None: """Test no trade notification sent when decision is HOLD.""" - # Change brain decision to HOLD - decision = MagicMock() - decision.action = "HOLD" - decision.confidence = 50 - decision.rationale = "Insufficient signal" - mock_brain.decide = AsyncMock(return_value=decision) + # Scenario engine returns HOLD + mock_scenario_engine.evaluate = MagicMock(return_value=_make_hold_match()) with patch("src.main.log_trade"): await trading_cycle( broker=mock_broker, overseas_broker=mock_overseas_broker, - brain=mock_brain, + scenario_engine=mock_scenario_engine, + playbook=mock_playbook, risk=mock_risk, db_conn=mock_db, decision_logger=mock_decision_logger, @@ -472,15 +510,16 @@ class TestOverseasBalanceParsing: return market @pytest.fixture - def mock_brain_hold(self) -> MagicMock: - """Create mock brain that always holds.""" - brain = MagicMock() - decision = MagicMock() - decision.action = "HOLD" - decision.confidence = 50 - decision.rationale = "Testing balance parsing" - brain.decide = AsyncMock(return_value=decision) - return brain + def mock_scenario_engine_hold(self) -> MagicMock: + """Create mock scenario engine that always returns HOLD.""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_hold_match("AAPL")) + return engine + + @pytest.fixture + def mock_playbook(self) -> DayPlaybook: + """Create a minimal playbook.""" + return _make_playbook("US") @pytest.fixture def mock_risk(self) -> MagicMock: @@ -517,14 +556,17 @@ class TestOverseasBalanceParsing: @pytest.fixture def mock_telegram(self) -> MagicMock: """Create mock telegram client.""" - return MagicMock() + telegram = MagicMock() + telegram.notify_scenario_matched = AsyncMock() + return telegram @pytest.mark.asyncio async def test_overseas_balance_list_format( self, mock_domestic_broker: MagicMock, mock_overseas_broker_with_list: MagicMock, - mock_brain_hold: MagicMock, + mock_scenario_engine_hold: MagicMock, + mock_playbook: DayPlaybook, mock_risk: MagicMock, mock_db: MagicMock, mock_decision_logger: MagicMock, @@ -539,7 +581,8 @@ class TestOverseasBalanceParsing: await trading_cycle( broker=mock_domestic_broker, overseas_broker=mock_overseas_broker_with_list, - brain=mock_brain_hold, + scenario_engine=mock_scenario_engine_hold, + playbook=mock_playbook, risk=mock_risk, db_conn=mock_db, decision_logger=mock_decision_logger, @@ -559,7 +602,8 @@ class TestOverseasBalanceParsing: self, mock_domestic_broker: MagicMock, mock_overseas_broker_with_dict: MagicMock, - mock_brain_hold: MagicMock, + mock_scenario_engine_hold: MagicMock, + mock_playbook: DayPlaybook, mock_risk: MagicMock, mock_db: MagicMock, mock_decision_logger: MagicMock, @@ -574,7 +618,8 @@ class TestOverseasBalanceParsing: await trading_cycle( broker=mock_domestic_broker, overseas_broker=mock_overseas_broker_with_dict, - brain=mock_brain_hold, + scenario_engine=mock_scenario_engine_hold, + playbook=mock_playbook, risk=mock_risk, db_conn=mock_db, decision_logger=mock_decision_logger, @@ -594,7 +639,8 @@ class TestOverseasBalanceParsing: self, mock_domestic_broker: MagicMock, mock_overseas_broker_with_empty: MagicMock, - mock_brain_hold: MagicMock, + mock_scenario_engine_hold: MagicMock, + mock_playbook: DayPlaybook, mock_risk: MagicMock, mock_db: MagicMock, mock_decision_logger: MagicMock, @@ -609,7 +655,8 @@ class TestOverseasBalanceParsing: await trading_cycle( broker=mock_domestic_broker, overseas_broker=mock_overseas_broker_with_empty, - brain=mock_brain_hold, + scenario_engine=mock_scenario_engine_hold, + playbook=mock_playbook, risk=mock_risk, db_conn=mock_db, decision_logger=mock_decision_logger, @@ -629,7 +676,8 @@ class TestOverseasBalanceParsing: self, mock_domestic_broker: MagicMock, mock_overseas_broker_with_empty_price: MagicMock, - mock_brain_hold: MagicMock, + mock_scenario_engine_hold: MagicMock, + mock_playbook: DayPlaybook, mock_risk: MagicMock, mock_db: MagicMock, mock_decision_logger: MagicMock, @@ -644,7 +692,8 @@ class TestOverseasBalanceParsing: await trading_cycle( broker=mock_domestic_broker, overseas_broker=mock_overseas_broker_with_empty_price, - brain=mock_brain_hold, + scenario_engine=mock_scenario_engine_hold, + playbook=mock_playbook, risk=mock_risk, db_conn=mock_db, decision_logger=mock_decision_logger, @@ -658,3 +707,299 @@ class TestOverseasBalanceParsing: # Verify price API was called mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once() + + +class TestScenarioEngineIntegration: + """Test scenario engine integration in trading_cycle.""" + + @pytest.fixture + def mock_broker(self) -> MagicMock: + """Create mock broker with standard domestic data.""" + broker = MagicMock() + broker.get_orderbook = AsyncMock( + return_value={ + "output1": {"stck_prpr": "50000", "frgn_ntby_qty": "100"} + } + ) + broker.get_balance = AsyncMock( + return_value={ + "output2": [ + { + "tot_evlu_amt": "10000000", + "dnca_tot_amt": "5000000", + "pchs_amt_smtl_amt": "9500000", + } + ] + } + ) + broker.send_order = AsyncMock(return_value={"msg1": "OK"}) + return broker + + @pytest.fixture + def mock_market(self) -> MagicMock: + """Create mock KR market.""" + market = MagicMock() + market.name = "Korea" + market.code = "KR" + market.exchange_code = "KRX" + market.is_domestic = True + return market + + @pytest.fixture + def mock_telegram(self) -> MagicMock: + """Create mock telegram with all notification methods.""" + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + return telegram + + @pytest.mark.asyncio + async def test_scenario_engine_called_with_enriched_market_data( + self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock, + ) -> None: + """Test scenario engine receives market_data enriched with scanner metrics.""" + from src.analysis.smart_scanner import ScanCandidate + + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_hold_match()) + playbook = _make_playbook() + + candidate = ScanCandidate( + stock_code="005930", name="Samsung", price=50000, + volume=1000000, volume_ratio=3.5, rsi=25.0, + signal="oversold", score=85.0, + ) + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=playbook, + risk=MagicMock(), + db_conn=MagicMock(), + decision_logger=MagicMock(), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=mock_telegram, + market=mock_market, + stock_code="005930", + scan_candidates={"005930": candidate}, + ) + + # Verify evaluate was called + engine.evaluate.assert_called_once() + call_args = engine.evaluate.call_args + market_data = call_args[0][2] # 3rd positional arg + portfolio_data = call_args[0][3] # 4th positional arg + + # Scanner data should be enriched into market_data + assert market_data["rsi"] == 25.0 + assert market_data["volume_ratio"] == 3.5 + assert market_data["current_price"] == 50000.0 + + # Portfolio data should include pnl + assert "portfolio_pnl_pct" in portfolio_data + assert "total_cash" in portfolio_data + + @pytest.mark.asyncio + async def test_scenario_engine_called_without_scanner_data( + self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock, + ) -> None: + """Test scenario engine works when stock has no scan candidate.""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_hold_match()) + playbook = _make_playbook() + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=playbook, + risk=MagicMock(), + db_conn=MagicMock(), + decision_logger=MagicMock(), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=mock_telegram, + market=mock_market, + stock_code="005930", + scan_candidates={}, # No scanner data + ) + + # Should still work, just without rsi/volume_ratio + engine.evaluate.assert_called_once() + market_data = engine.evaluate.call_args[0][2] + assert "rsi" not in market_data + assert "volume_ratio" not in market_data + assert market_data["current_price"] == 50000.0 + + @pytest.mark.asyncio + async def test_scenario_matched_notification_sent( + self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock, + ) -> None: + """Test telegram notification sent when a scenario matches.""" + # Create a match with matched_scenario (not None) + scenario = StockScenario( + condition=StockCondition(rsi_below=30), + action=ScenarioAction.BUY, + confidence=88, + rationale="RSI oversold bounce", + ) + match = ScenarioMatch( + stock_code="005930", + matched_scenario=scenario, + action=ScenarioAction.BUY, + confidence=88, + rationale="RSI oversold bounce", + match_details={"rsi": 25.0}, + ) + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=match) + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=_make_playbook(), + risk=MagicMock(), + db_conn=MagicMock(), + decision_logger=MagicMock(), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=mock_telegram, + market=mock_market, + stock_code="005930", + scan_candidates={}, + ) + + # Scenario matched notification should be sent + mock_telegram.notify_scenario_matched.assert_called_once() + call_kwargs = mock_telegram.notify_scenario_matched.call_args.kwargs + assert call_kwargs["stock_code"] == "005930" + assert call_kwargs["action"] == "BUY" + assert "rsi=25.0" in call_kwargs["condition_summary"] + + @pytest.mark.asyncio + async def test_no_scenario_matched_notification_on_default_hold( + self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock, + ) -> None: + """Test no scenario notification when default HOLD is returned.""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_hold_match()) + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=_make_playbook(), + risk=MagicMock(), + db_conn=MagicMock(), + decision_logger=MagicMock(), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=mock_telegram, + market=mock_market, + stock_code="005930", + scan_candidates={}, + ) + + # No scenario matched notification for default HOLD + mock_telegram.notify_scenario_matched.assert_not_called() + + @pytest.mark.asyncio + async def test_decision_logger_receives_scenario_match_details( + self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock, + ) -> None: + """Test decision logger context includes scenario match details.""" + match = ScenarioMatch( + stock_code="005930", + matched_scenario=None, + action=ScenarioAction.HOLD, + confidence=0, + rationale="No match", + match_details={"rsi": 45.0, "volume_ratio": 1.2}, + ) + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=match) + decision_logger = MagicMock() + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=_make_playbook(), + risk=MagicMock(), + db_conn=MagicMock(), + decision_logger=decision_logger, + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=mock_telegram, + market=mock_market, + stock_code="005930", + scan_candidates={}, + ) + + decision_logger.log_decision.assert_called_once() + call_kwargs = decision_logger.log_decision.call_args.kwargs + assert "scenario_match" in call_kwargs["context_snapshot"] + assert call_kwargs["context_snapshot"]["scenario_match"]["rsi"] == 45.0 + + @pytest.mark.asyncio + async def test_reduce_all_does_not_execute_order( + self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock, + ) -> None: + """Test REDUCE_ALL action does not trigger order execution.""" + match = ScenarioMatch( + stock_code="005930", + matched_scenario=None, + action=ScenarioAction.REDUCE_ALL, + confidence=100, + rationale="Global rule: portfolio loss > 2%", + ) + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=match) + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=_make_playbook(), + risk=MagicMock(), + db_conn=MagicMock(), + decision_logger=MagicMock(), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=mock_telegram, + market=mock_market, + stock_code="005930", + scan_candidates={}, + ) + + # REDUCE_ALL is not BUY or SELL — no order sent + mock_broker.send_order.assert_not_called() + mock_telegram.notify_trade_execution.assert_not_called()