diff --git a/src/main.py b/src/main.py index 222d4e8..c731317 100644 --- a/src/main.py +++ b/src/main.py @@ -81,6 +81,7 @@ def safe_float(value: str | float | None, default: float = 0.0) -> float: TRADE_INTERVAL_SECONDS = 60 SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds MAX_CONNECTION_RETRIES = 3 +_BUY_COOLDOWN_SECONDS = 600 # 10-minute cooldown after insufficient-balance rejection # Daily trading mode constants (for Free tier API efficiency) DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day @@ -298,6 +299,7 @@ async def trading_cycle( stock_code: str, scan_candidates: dict[str, dict[str, ScanCandidate]], settings: Settings | None = None, + buy_cooldown: dict[str, float] | None = None, ) -> None: """Execute one trading cycle for a single stock.""" cycle_start_time = asyncio.get_event_loop().time() @@ -642,7 +644,22 @@ async def trading_cycle( return order_amount = current_price * quantity - # 4. Risk check BEFORE order + # 4. Check BUY cooldown (set when a prior BUY failed due to insufficient balance) + if decision.action == "BUY" and buy_cooldown is not None: + cooldown_key = f"{market.code}:{stock_code}" + cooldown_until = buy_cooldown.get(cooldown_key, 0.0) + now = asyncio.get_event_loop().time() + if now < cooldown_until: + remaining = int(cooldown_until - now) + logger.info( + "Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)", + stock_code, + market.name, + remaining, + ) + return + + # 5a. Risk check BEFORE order try: risk.validate_order( current_pnl_pct=pnl_pct, @@ -690,12 +707,24 @@ async def trading_cycle( # Check if KIS rejected the order (rt_cd != "0") if result.get("rt_cd", "") != "0": order_succeeded = False + msg1 = result.get("msg1") or "" logger.warning( "Overseas order not accepted for %s: rt_cd=%s msg=%s", stock_code, result.get("rt_cd"), - result.get("msg1"), + msg1, ) + # Set BUY cooldown when the rejection is due to insufficient balance + if decision.action == "BUY" and buy_cooldown is not None and "주문가능금액" in msg1: + cooldown_key = f"{market.code}:{stock_code}" + buy_cooldown[cooldown_key] = ( + asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS + ) + logger.info( + "BUY cooldown set for %s: %.0fs (insufficient balance)", + stock_code, + _BUY_COOLDOWN_SECONDS, + ) logger.info("Order result: %s", result.get("msg1", "OK")) # 5.5. Notify trade execution (only on success) @@ -803,6 +832,9 @@ async def run_daily_session( logger.info("Starting daily trading session for %d markets", len(open_markets)) + # BUY cooldown: prevents retrying stocks rejected for insufficient balance + daily_buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp + # Process each open market for market in open_markets: # Use market-local date for playbook keying @@ -1075,6 +1107,21 @@ async def run_daily_session( continue order_amount = stock_data["current_price"] * quantity + # Check BUY cooldown (insufficient balance) + if decision.action == "BUY": + daily_cooldown_key = f"{market.code}:{stock_code}" + daily_cooldown_until = daily_buy_cooldown.get(daily_cooldown_key, 0.0) + now = asyncio.get_event_loop().time() + if now < daily_cooldown_until: + remaining = int(daily_cooldown_until - now) + logger.info( + "Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)", + stock_code, + market.name, + remaining, + ) + continue + # Risk check try: risk.validate_order( @@ -1131,12 +1178,23 @@ async def run_daily_session( ) if result.get("rt_cd", "") != "0": order_succeeded = False + daily_msg1 = result.get("msg1") or "" logger.warning( "Overseas order not accepted for %s: rt_cd=%s msg=%s", stock_code, result.get("rt_cd"), - result.get("msg1"), + daily_msg1, ) + if decision.action == "BUY" and "주문가능금액" in daily_msg1: + daily_cooldown_key = f"{market.code}:{stock_code}" + daily_buy_cooldown[daily_cooldown_key] = ( + asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS + ) + logger.info( + "BUY cooldown set for %s: %.0fs (insufficient balance)", + stock_code, + _BUY_COOLDOWN_SECONDS, + ) logger.info("Order result: %s", result.get("msg1", "OK")) # Notify trade execution (only on success) @@ -1755,6 +1813,9 @@ async def run(settings: Settings) -> None: # Active stocks per market (dynamically discovered by scanner) active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes] + # BUY cooldown: prevents retrying a stock rejected for insufficient balance + buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp + # Initialize latency control system criticality_assessor = CriticalityAssessor( critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0% @@ -2097,6 +2158,7 @@ async def run(settings: Settings) -> None: stock_code, scan_candidates, settings, + buy_cooldown, ) break # Success — exit retry loop except CircuitBreakerTripped as exc: diff --git a/tests/test_main.py b/tests/test_main.py index 0001412..e84cc14 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2194,6 +2194,245 @@ def test_start_dashboard_server_enabled_starts_thread() -> None: mock_thread.start.assert_called_once() +# --------------------------------------------------------------------------- +# BUY cooldown tests (#179) +# --------------------------------------------------------------------------- + + +class TestBuyCooldown: + """Tests for BUY cooldown after insufficient-balance rejection.""" + + @pytest.fixture + def mock_broker(self) -> MagicMock: + broker = MagicMock() + broker.get_current_price = AsyncMock(return_value=(100.0, 1.0, 0.0)) + broker.get_balance = AsyncMock( + return_value={ + "output2": [{"tot_evlu_amt": "1000000", "dnca_tot_amt": "500000", + "pchs_amt_smtl_amt": "500000"}] + } + ) + 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_overseas_market(self) -> MagicMock: + market = MagicMock() + market.name = "NASDAQ" + market.code = "US_NASDAQ" + market.exchange_code = "NAS" + market.is_domestic = False + return market + + @pytest.fixture + def mock_overseas_broker(self) -> MagicMock: + broker = MagicMock() + broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "1.0", "rate": "0.0", + "high": "1.05", "low": "0.95", "tvol": "1000000"}} + ) + broker.get_overseas_balance = AsyncMock(return_value={ + "output1": [], + "output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", + "frcr_buy_amt_smtl": "0"}], + }) + broker.send_overseas_order = AsyncMock( + return_value={"rt_cd": "1", "msg1": "모의투자 주문가능금액이 부족합니다."} + ) + return broker + + def _make_buy_match_overseas(self, stock_code: str = "MLECW") -> ScenarioMatch: + return ScenarioMatch( + stock_code=stock_code, + matched_scenario=None, + action=ScenarioAction.BUY, + confidence=85, + rationale="Test buy", + ) + + @pytest.mark.asyncio + async def test_cooldown_set_on_insufficient_balance( + self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, + mock_overseas_market: MagicMock, + ) -> None: + """BUY cooldown entry is created after 주문가능금액 rejection.""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW")) + buy_cooldown: dict[str, float] = {} + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=mock_overseas_broker, + scenario_engine=engine, + playbook=_make_playbook("US_NASDAQ"), + 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=MagicMock( + notify_trade_execution=AsyncMock(), + notify_fat_finger=AsyncMock(), + notify_circuit_breaker=AsyncMock(), + notify_scenario_matched=AsyncMock(), + ), + market=mock_overseas_market, + stock_code="MLECW", + scan_candidates={}, + buy_cooldown=buy_cooldown, + ) + + assert "US_NASDAQ:MLECW" in buy_cooldown + assert buy_cooldown["US_NASDAQ:MLECW"] > 0 + + @pytest.mark.asyncio + async def test_cooldown_skips_buy( + self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, + mock_overseas_market: MagicMock, + ) -> None: + """BUY is skipped when cooldown is active for the stock.""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW")) + + import asyncio + # Set an active cooldown (expires far in the future) + buy_cooldown: dict[str, float] = { + "US_NASDAQ:MLECW": asyncio.get_event_loop().time() + 600 + } + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=mock_overseas_broker, + scenario_engine=engine, + playbook=_make_playbook("US_NASDAQ"), + 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=MagicMock( + notify_trade_execution=AsyncMock(), + notify_fat_finger=AsyncMock(), + notify_circuit_breaker=AsyncMock(), + notify_scenario_matched=AsyncMock(), + ), + market=mock_overseas_market, + stock_code="MLECW", + scan_candidates={}, + buy_cooldown=buy_cooldown, + ) + + # Order should NOT have been sent + mock_overseas_broker.send_overseas_order.assert_not_called() + + @pytest.mark.asyncio + async def test_cooldown_not_set_on_other_errors( + self, mock_broker: MagicMock, mock_overseas_market: MagicMock, + ) -> None: + """Cooldown is NOT set for non-balance-related rejections.""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW")) + # Different rejection reason + overseas_broker = MagicMock() + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "1.0", "rate": "0.0", + "high": "1.05", "low": "0.95", "tvol": "1000000"}} + ) + overseas_broker.get_overseas_balance = AsyncMock(return_value={ + "output1": [], + "output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", + "frcr_buy_amt_smtl": "0"}], + }) + overseas_broker.send_overseas_order = AsyncMock( + return_value={"rt_cd": "1", "msg1": "기타 오류 메시지"} + ) + buy_cooldown: dict[str, float] = {} + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=overseas_broker, + scenario_engine=engine, + playbook=_make_playbook("US_NASDAQ"), + 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=MagicMock( + notify_trade_execution=AsyncMock(), + notify_fat_finger=AsyncMock(), + notify_circuit_breaker=AsyncMock(), + notify_scenario_matched=AsyncMock(), + ), + market=mock_overseas_market, + stock_code="MLECW", + scan_candidates={}, + buy_cooldown=buy_cooldown, + ) + + # Cooldown should NOT be set for non-balance errors + assert "US_NASDAQ:MLECW" not in buy_cooldown + + @pytest.mark.asyncio + async def test_no_cooldown_param_still_works( + self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, + mock_overseas_market: MagicMock, + ) -> None: + """trading_cycle works normally when buy_cooldown is None (default).""" + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW")) + + with patch("src.main.log_trade"): + await trading_cycle( + broker=mock_broker, + overseas_broker=mock_overseas_broker, + scenario_engine=engine, + playbook=_make_playbook("US_NASDAQ"), + 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=MagicMock( + notify_trade_execution=AsyncMock(), + notify_fat_finger=AsyncMock(), + notify_circuit_breaker=AsyncMock(), + notify_scenario_matched=AsyncMock(), + ), + market=mock_overseas_market, + stock_code="MLECW", + scan_candidates={}, + # buy_cooldown not passed → defaults to None + ) + + # Should attempt the order (and fail), but not crash + mock_overseas_broker.send_overseas_order.assert_called_once() + + # --------------------------------------------------------------------------- # market_outlook BUY confidence threshold tests (#173) # ---------------------------------------------------------------------------