From 9339824e222ceb64b682be2833ed2519117d51ff Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 23 Feb 2026 16:47:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Daily=20CB=20P&L=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9D=84=20=EB=8B=B9=EC=9D=BC=20=EC=8B=9C=EC=9E=91=20=ED=8F=89?= =?UTF-8?q?=EA=B0=80=EA=B8=88=EC=95=A1=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run_daily_session에 daily_start_eval 파라미터 추가 (반환 타입: float) - 세션 첫 잔고 조회 시 total_eval을 baseline으로 캡처 - 이후 세션에서 pnl_pct = (total_eval - daily_start_eval) / daily_start_eval - 기존 purchase_total(누적) 기반 계산 제거 - run 함수 daily 루프에서 날짜 변경 시 baseline 리셋 (_cb_last_date 추적) - early return 시 daily_start_eval 반환하도록 버그 수정 (None 반환 방지) - TestDailyCBBaseline 클래스 4개 테스트 추가 - no_markets: 0.0/기존값 그대로 반환 - first session: total_eval을 baseline으로 캡처 - subsequent session: 기존 baseline 유지 (덮어쓰기 방지) Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 60 +++++++++-- tests/test_main.py | 241 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 9 deletions(-) diff --git a/src/main.py b/src/main.py index e76bca1..0268db0 100644 --- a/src/main.py +++ b/src/main.py @@ -908,18 +908,30 @@ async def run_daily_session( telegram: TelegramClient, settings: Settings, smart_scanner: SmartVolatilityScanner | None = None, -) -> None: + daily_start_eval: float = 0.0, +) -> float: """Execute one daily trading session. V2 proactive strategy: 1 Gemini call for playbook generation, then local scenario evaluation per stock (0 API calls). + + Args: + daily_start_eval: Portfolio evaluation at the start of the trading day. + Used to compute intra-day P&L for the Circuit Breaker. + Pass 0.0 on the first session of each day; the function will set + it from the first balance query and return it for subsequent + sessions. + + Returns: + The daily_start_eval value that should be forwarded to the next + session of the same trading day. """ # Get currently open markets open_markets = get_open_markets(settings.enabled_market_list) if not open_markets: logger.info("No markets open for this session") - return + return daily_start_eval logger.info("Starting daily trading session for %d markets", len(open_markets)) @@ -1121,12 +1133,27 @@ async def run_daily_session( ): total_cash = settings.PAPER_OVERSEAS_CASH - # Calculate daily P&L % - pnl_pct = ( - ((total_eval - purchase_total) / purchase_total * 100) - if purchase_total > 0 - else 0.0 - ) + # Capture the day's opening portfolio value on the first market processed + # in this session. Used to compute intra-day P&L for the CB instead of + # the cumulative purchase_total which spans the entire account history. + if daily_start_eval <= 0 and total_eval > 0: + daily_start_eval = total_eval + logger.info( + "Daily CB baseline set: total_eval=%.2f (first balance of the day)", + daily_start_eval, + ) + + # Daily P&L: compare current eval vs start-of-day eval. + # Falls back to purchase_total if daily_start_eval is unavailable (e.g. paper + # mode where balance API returns 0 for all values). + if daily_start_eval > 0: + pnl_pct = (total_eval - daily_start_eval) / daily_start_eval * 100 + else: + pnl_pct = ( + ((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, @@ -1395,6 +1422,7 @@ async def run_daily_session( ) logger.info("Daily trading session completed") + return daily_start_eval async def _handle_market_close( @@ -2030,13 +2058,26 @@ async def run(settings: Settings) -> None: session_interval = settings.SESSION_INTERVAL_HOURS * 3600 # Convert to seconds + # daily_start_eval: portfolio eval captured at the first session of each + # trading day. Reset on calendar-date change so the CB measures only + # today's drawdown, not cumulative account history. + _cb_daily_start_eval: float = 0.0 + _cb_last_date: str = "" + while not shutdown.is_set(): # Wait for trading to be unpaused await pause_trading.wait() _run_context_scheduler(context_scheduler, now=datetime.now(UTC)) + # Reset intra-day CB baseline on a new calendar date + today_str = datetime.now(UTC).date().isoformat() + if today_str != _cb_last_date: + _cb_last_date = today_str + _cb_daily_start_eval = 0.0 + logger.info("New trading day %s — daily CB baseline reset", today_str) + try: - await run_daily_session( + _cb_daily_start_eval = await run_daily_session( broker, overseas_broker, scenario_engine, @@ -2050,6 +2091,7 @@ async def run(settings: Settings) -> None: telegram, settings, smart_scanner=smart_scanner, + daily_start_eval=_cb_daily_start_eval, ) except CircuitBreakerTripped: logger.critical("Circuit breaker tripped — shutting down") diff --git a/tests/test_main.py b/tests/test_main.py index 76d7e13..3a58e20 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,6 +22,7 @@ from src.main import ( _run_context_scheduler, _run_evolution_loop, _start_dashboard_server, + run_daily_session, safe_float, trading_cycle, ) @@ -3271,3 +3272,243 @@ class TestRetryConnection: await _retry_connection(bad_input, label="bad") assert call_count == 1 # No retry for non-ConnectionError + + +# --------------------------------------------------------------------------- +# run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207) +# --------------------------------------------------------------------------- + + +class TestDailyCBBaseline: + """Tests for run_daily_session's daily_start_eval (CB baseline) behaviour. + + Issue #207: CB P&L should be computed relative to the portfolio value at + the start of each trading day, not the cumulative purchase_total. + """ + + def _make_settings(self) -> Settings: + return Settings( + KIS_APP_KEY="test-key", + KIS_APP_SECRET="test-secret", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="test-gemini", + MODE="paper", + PAPER_OVERSEAS_CASH=0, + ) + + def _make_domestic_balance( + self, tot_evlu_amt: float = 0.0, dnca_tot_amt: float = 50000.0 + ) -> dict: + return { + "output1": [], + "output2": [ + { + "tot_evlu_amt": str(tot_evlu_amt), + "dnca_tot_amt": str(dnca_tot_amt), + "pchs_amt_smtl_amt": "40000.0", + } + ], + } + + @pytest.mark.asyncio + async def test_returns_daily_start_eval_when_no_markets_open(self) -> None: + """run_daily_session returns the unchanged daily_start_eval when no markets are open.""" + with patch("src.main.get_open_markets", return_value=[]): + result = await run_daily_session( + broker=MagicMock(), + overseas_broker=MagicMock(), + scenario_engine=MagicMock(), + playbook_store=MagicMock(), + pre_market_planner=MagicMock(), + risk=MagicMock(), + db_conn=init_db(":memory:"), + decision_logger=MagicMock(), + context_store=MagicMock(), + criticality_assessor=MagicMock(), + telegram=MagicMock(), + settings=self._make_settings(), + smart_scanner=None, + daily_start_eval=12345.0, + ) + assert result == 12345.0 + + @pytest.mark.asyncio + async def test_returns_zero_when_no_markets_and_no_baseline(self) -> None: + """run_daily_session returns 0.0 when no markets are open and daily_start_eval=0.""" + with patch("src.main.get_open_markets", return_value=[]): + result = await run_daily_session( + broker=MagicMock(), + overseas_broker=MagicMock(), + scenario_engine=MagicMock(), + playbook_store=MagicMock(), + pre_market_planner=MagicMock(), + risk=MagicMock(), + db_conn=init_db(":memory:"), + decision_logger=MagicMock(), + context_store=MagicMock(), + criticality_assessor=MagicMock(), + telegram=MagicMock(), + settings=self._make_settings(), + smart_scanner=None, + daily_start_eval=0.0, + ) + assert result == 0.0 + + @pytest.mark.asyncio + async def test_captures_total_eval_as_baseline_on_first_session(self) -> None: + """When daily_start_eval=0 and balance returns a positive total_eval, the returned + value equals total_eval (the captured baseline for the day).""" + from src.analysis.smart_scanner import ScanCandidate + + settings = self._make_settings() + broker = MagicMock() + # Domestic balance: tot_evlu_amt=55000 + broker.get_balance = AsyncMock( + return_value=self._make_domestic_balance(tot_evlu_amt=55000.0) + ) + # Price data for the stock + broker.get_current_price = AsyncMock( + return_value=(100.0, 1.5, 100.0) + ) + + market = MagicMock() + market.name = "KR" + market.code = "KR" + market.exchange_code = "KRX" + market.is_domestic = True + market.timezone = __import__("zoneinfo").ZoneInfo("Asia/Seoul") + + smart_scanner = MagicMock() + smart_scanner.scan = AsyncMock( + return_value=[ + ScanCandidate( + stock_code="005930", + name="Samsung", + price=100.0, + volume=1_000_000.0, + volume_ratio=2.5, + rsi=45.0, + signal="momentum", + score=80.0, + ) + ] + ) + + playbook_store = MagicMock() + playbook_store.load = MagicMock(return_value=_make_playbook("KR")) + + scenario_engine = MagicMock(spec=ScenarioEngine) + scenario_engine.evaluate = MagicMock(return_value=_make_hold_match("005930")) + + risk = MagicMock() + risk.check_circuit_breaker = MagicMock() + risk.check_fat_finger = MagicMock() + + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="d1") + + async def _passthrough(fn, *a, label: str = "", **kw): # type: ignore[override] + return await fn(*a, **kw) + + with patch("src.main.get_open_markets", return_value=[market]), \ + patch("src.main._retry_connection", new=_passthrough): + result = await run_daily_session( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=scenario_engine, + playbook_store=playbook_store, + pre_market_planner=MagicMock(), + risk=risk, + db_conn=init_db(":memory:"), + decision_logger=decision_logger, + context_store=MagicMock(), + criticality_assessor=MagicMock(), + telegram=telegram, + settings=settings, + smart_scanner=smart_scanner, + daily_start_eval=0.0, + ) + + assert result == 55000.0 # captured from tot_evlu_amt + + @pytest.mark.asyncio + async def test_does_not_overwrite_existing_baseline(self) -> None: + """When daily_start_eval > 0, it must not be overwritten even if balance returns + a different value (baseline is fixed at the start of each trading day).""" + from src.analysis.smart_scanner import ScanCandidate + + settings = self._make_settings() + broker = MagicMock() + # Balance reports a different eval value (market moved during the day) + broker.get_balance = AsyncMock( + return_value=self._make_domestic_balance(tot_evlu_amt=58000.0) + ) + broker.get_current_price = AsyncMock(return_value=(100.0, 1.5, 100.0)) + + market = MagicMock() + market.name = "KR" + market.code = "KR" + market.exchange_code = "KRX" + market.is_domestic = True + market.timezone = __import__("zoneinfo").ZoneInfo("Asia/Seoul") + + smart_scanner = MagicMock() + smart_scanner.scan = AsyncMock( + return_value=[ + ScanCandidate( + stock_code="005930", + name="Samsung", + price=100.0, + volume=1_000_000.0, + volume_ratio=2.5, + rsi=45.0, + signal="momentum", + score=80.0, + ) + ] + ) + + playbook_store = MagicMock() + playbook_store.load = MagicMock(return_value=_make_playbook("KR")) + + scenario_engine = MagicMock(spec=ScenarioEngine) + scenario_engine.evaluate = MagicMock(return_value=_make_hold_match("005930")) + + risk = MagicMock() + risk.check_circuit_breaker = MagicMock() + + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="d1") + + async def _passthrough(fn, *a, label: str = "", **kw): # type: ignore[override] + return await fn(*a, **kw) + + with patch("src.main.get_open_markets", return_value=[market]), \ + patch("src.main._retry_connection", new=_passthrough): + result = await run_daily_session( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=scenario_engine, + playbook_store=playbook_store, + pre_market_planner=MagicMock(), + risk=risk, + db_conn=init_db(":memory:"), + decision_logger=decision_logger, + context_store=MagicMock(), + criticality_assessor=MagicMock(), + telegram=telegram, + settings=settings, + smart_scanner=smart_scanner, + daily_start_eval=55000.0, # existing baseline + ) + + # Must return the original baseline, NOT the new total_eval (58000) + assert result == 55000.0 -- 2.49.1