From d6edbc0fa2cc835f71a975d4c6afccc125f6d81b Mon Sep 17 00:00:00 2001 From: agentson Date: Fri, 20 Feb 2026 08:31:24 +0900 Subject: [PATCH] feat: use market_outlook to adjust BUY confidence threshold (#173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import MarketOutlook at module level in main.py - After scenario evaluation, check market_outlook and apply BUY confidence threshold: BEARISH→90, BULLISH→75, others→settings.CONFIDENCE_THRESHOLD - BUY actions below the adjusted threshold are downgraded to HOLD with a descriptive rationale including the outlook and threshold values - Add 5 integration tests covering bearish suppression, bearish allow, bullish allow, bullish suppression, and neutral default threshold Closes #173 Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 30 ++++- tests/test_main.py | 281 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 58bd045..6058278 100644 --- a/src/main.py +++ b/src/main.py @@ -42,7 +42,7 @@ 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.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler -from src.strategy.models import DayPlaybook +from src.strategy.models import DayPlaybook, MarketOutlook from src.strategy.playbook_store import PlaybookStore from src.strategy.pre_market_planner import PreMarketPlanner from src.strategy.scenario_engine import ScenarioEngine @@ -457,6 +457,34 @@ async def trading_cycle( ) stock_playbook = playbook.get_stock_playbook(stock_code) + # 2.1. Apply market_outlook-based BUY confidence threshold + if decision.action == "BUY": + base_threshold = (settings.CONFIDENCE_THRESHOLD if settings else 80) + outlook = playbook.market_outlook + if outlook == MarketOutlook.BEARISH: + min_confidence = 90 + elif outlook == MarketOutlook.BULLISH: + min_confidence = 75 + else: + min_confidence = base_threshold + if match.confidence < min_confidence: + logger.info( + "BUY suppressed for %s (%s): confidence %d < %d (market_outlook=%s)", + stock_code, + market.name, + match.confidence, + min_confidence, + outlook.value, + ) + decision = TradeDecision( + action="HOLD", + confidence=match.confidence, + rationale=( + f"BUY confidence {match.confidence} < {min_confidence} " + f"(market_outlook={outlook.value})" + ), + ) + if decision.action == "HOLD": open_position = get_open_position(db_conn, stock_code, market.code) if open_position: diff --git a/tests/test_main.py b/tests/test_main.py index ef95c14..d70b91b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2114,3 +2114,284 @@ def test_start_dashboard_server_enabled_starts_thread() -> None: assert thread == mock_thread mock_thread_cls.assert_called_once() mock_thread.start.assert_called_once() + + +# --------------------------------------------------------------------------- +# market_outlook BUY confidence threshold tests (#173) +# --------------------------------------------------------------------------- + + +class TestMarketOutlookConfidenceThreshold: + """Tests for market_outlook-based BUY confidence suppression in trading_cycle.""" + + @pytest.fixture + def mock_broker(self) -> MagicMock: + broker = MagicMock() + broker.get_current_price = AsyncMock(return_value=(50000.0, 1.0, 0.0)) + 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: + 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: + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + return telegram + + def _make_buy_match_with_confidence( + self, confidence: int, stock_code: str = "005930" + ) -> ScenarioMatch: + from src.strategy.models import StockScenario + scenario = StockScenario( + condition=StockCondition(rsi_below=30), + action=ScenarioAction.BUY, + confidence=confidence, + allocation_pct=10.0, + ) + return ScenarioMatch( + stock_code=stock_code, + matched_scenario=scenario, + action=ScenarioAction.BUY, + confidence=confidence, + rationale="Test buy", + ) + + def _make_playbook_with_outlook( + self, outlook_str: str, market: str = "KR" + ) -> DayPlaybook: + from src.strategy.models import MarketOutlook + outlook_map = { + "bearish": MarketOutlook.BEARISH, + "bullish": MarketOutlook.BULLISH, + "neutral": MarketOutlook.NEUTRAL, + "neutral_to_bullish": MarketOutlook.NEUTRAL_TO_BULLISH, + "neutral_to_bearish": MarketOutlook.NEUTRAL_TO_BEARISH, + } + return DayPlaybook( + date=date(2026, 2, 20), + market=market, + market_outlook=outlook_map[outlook_str], + ) + + @pytest.mark.asyncio + async def test_bearish_outlook_raises_buy_confidence_threshold( + self, + mock_broker: MagicMock, + mock_market: MagicMock, + mock_telegram: MagicMock, + ) -> None: + """BUY with confidence 85 should be suppressed to HOLD in bearish market.""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(85)) + playbook = self._make_playbook_with_outlook("bearish") + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="decision-id") + + 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=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={}, + ) + + # HOLD should be logged (not BUY) — check decision_logger was called with HOLD + call_args = decision_logger.log_decision.call_args + assert call_args is not None + assert call_args.kwargs["action"] == "HOLD" + + @pytest.mark.asyncio + async def test_bearish_outlook_allows_high_confidence_buy( + self, + mock_broker: MagicMock, + mock_market: MagicMock, + mock_telegram: MagicMock, + ) -> None: + """BUY with confidence 92 should proceed in bearish market (threshold=90).""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(92)) + playbook = self._make_playbook_with_outlook("bearish") + risk = MagicMock() + risk.validate_order = MagicMock() + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="decision-id") + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=playbook, + risk=risk, + 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={}, + ) + + call_args = decision_logger.log_decision.call_args + assert call_args is not None + assert call_args.kwargs["action"] == "BUY" + + @pytest.mark.asyncio + async def test_bullish_outlook_lowers_buy_confidence_threshold( + self, + mock_broker: MagicMock, + mock_market: MagicMock, + mock_telegram: MagicMock, + ) -> None: + """BUY with confidence 77 should proceed in bullish market (threshold=75).""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(77)) + playbook = self._make_playbook_with_outlook("bullish") + risk = MagicMock() + risk.validate_order = MagicMock() + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="decision-id") + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=playbook, + risk=risk, + 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={}, + ) + + call_args = decision_logger.log_decision.call_args + assert call_args is not None + assert call_args.kwargs["action"] == "BUY" + + @pytest.mark.asyncio + async def test_bullish_outlook_suppresses_very_low_confidence_buy( + self, + mock_broker: MagicMock, + mock_market: MagicMock, + mock_telegram: MagicMock, + ) -> None: + """BUY with confidence 70 should be suppressed even in bullish market (threshold=75).""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(70)) + playbook = self._make_playbook_with_outlook("bullish") + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="decision-id") + + 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=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={}, + ) + + call_args = decision_logger.log_decision.call_args + assert call_args is not None + assert call_args.kwargs["action"] == "HOLD" + + @pytest.mark.asyncio + async def test_neutral_outlook_uses_default_threshold( + self, + mock_broker: MagicMock, + mock_market: MagicMock, + mock_telegram: MagicMock, + ) -> None: + """BUY with confidence 82 should proceed in neutral market (default=80).""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(82)) + playbook = self._make_playbook_with_outlook("neutral") + risk = MagicMock() + risk.validate_order = MagicMock() + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="decision-id") + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=playbook, + risk=risk, + 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={}, + ) + + call_args = decision_logger.log_decision.call_args + assert call_args is not None + assert call_args.kwargs["action"] == "BUY"