diff --git a/src/config.py b/src/config.py index 7f27aeb..656044c 100644 --- a/src/config.py +++ b/src/config.py @@ -61,6 +61,7 @@ class Settings(BaseSettings): PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0) USD_BUFFER_MIN: float = Field(default=1000.0, ge=0.0) US_MIN_PRICE: float = Field(default=5.0, ge=0.0) + STOPLOSS_REENTRY_COOLDOWN_MINUTES: int = Field(default=120, ge=1, le=1440) OVERNIGHT_EXCEPTION_ENABLED: bool = True # Trading frequency mode (daily = batch API calls, realtime = per-stock calls) diff --git a/src/main.py b/src/main.py index 1172b86..2499550 100644 --- a/src/main.py +++ b/src/main.py @@ -70,6 +70,7 @@ BLACKOUT_ORDER_MANAGER = BlackoutOrderManager( _SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"} _RUNTIME_EXIT_STATES: dict[str, PositionState] = {} _RUNTIME_EXIT_PEAKS: dict[str, float] = {} +_STOPLOSS_REENTRY_COOLDOWN_UNTIL: dict[str, float] = {} def safe_float(value: str | float | None, default: float = 0.0) -> float: @@ -118,6 +119,16 @@ def _resolve_sell_qty_for_pnl(*, sell_qty: int | None, buy_qty: int | None) -> i return max(0, int(buy_qty or 0)) +def _stoploss_cooldown_key(*, market: MarketInfo, stock_code: str) -> str: + return f"{market.code}:{stock_code}" + + +def _stoploss_cooldown_minutes(settings: Settings | None) -> int: + if settings is None: + return 120 + return max(1, int(getattr(settings, "STOPLOSS_REENTRY_COOLDOWN_MINUTES", 120))) + + async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kwargs: Any) -> Any: """Call an async function retrying on ConnectionError with exponential backoff. @@ -1332,6 +1343,23 @@ async def trading_cycle( current_price, min_price, ) + if decision.action == "BUY": + cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code) + now_epoch = datetime.now(UTC).timestamp() + cooldown_until = _STOPLOSS_REENTRY_COOLDOWN_UNTIL.get(cooldown_key, 0.0) + if now_epoch < cooldown_until: + remaining = int(cooldown_until - now_epoch) + decision = TradeDecision( + action="HOLD", + confidence=decision.confidence, + rationale=f"Stop-loss reentry cooldown active ({remaining}s remaining)", + ) + logger.info( + "BUY suppressed for %s (%s): stop-loss cooldown active (%ds remaining)", + stock_code, + market.name, + remaining, + ) if decision.action == "HOLD": open_position = get_open_position(db_conn, stock_code, market.code) @@ -1715,6 +1743,18 @@ async def trading_cycle( pnl=trade_pnl, accuracy=1 if trade_pnl > 0 else 0, ) + if trade_pnl < 0: + cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code) + cooldown_minutes = _stoploss_cooldown_minutes(settings) + _STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = ( + datetime.now(UTC).timestamp() + cooldown_minutes * 60 + ) + logger.info( + "Stop-loss cooldown set for %s (%s): %d minutes", + stock_code, + market.name, + cooldown_minutes, + ) # 6. Log trade with selection context (skip if order was rejected) if decision.action in ("BUY", "SELL") and not order_succeeded: @@ -2511,6 +2551,23 @@ async def run_daily_session( stock_data["current_price"], min_price, ) + if decision.action == "BUY": + cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code) + now_epoch = datetime.now(UTC).timestamp() + cooldown_until = _STOPLOSS_REENTRY_COOLDOWN_UNTIL.get(cooldown_key, 0.0) + if now_epoch < cooldown_until: + remaining = int(cooldown_until - now_epoch) + decision = TradeDecision( + action="HOLD", + confidence=decision.confidence, + rationale=f"Stop-loss reentry cooldown active ({remaining}s remaining)", + ) + logger.info( + "BUY suppressed for %s (%s): stop-loss cooldown active (%ds remaining)", + stock_code, + market.name, + remaining, + ) if decision.action == "HOLD": daily_open = get_open_position(db_conn, stock_code, market.code) if not daily_open: @@ -2842,6 +2899,18 @@ async def run_daily_session( pnl=trade_pnl, accuracy=1 if trade_pnl > 0 else 0, ) + if trade_pnl < 0: + cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code) + cooldown_minutes = _stoploss_cooldown_minutes(settings) + _STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = ( + datetime.now(UTC).timestamp() + cooldown_minutes * 60 + ) + logger.info( + "Stop-loss cooldown set for %s (%s): %d minutes", + stock_code, + market.name, + cooldown_minutes, + ) # Log trade (skip if order was rejected by API) if decision.action in ("BUY", "SELL") and not order_succeeded: diff --git a/tests/test_main.py b/tests/test_main.py index ff7f4ed..4de28aa 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -15,6 +15,7 @@ from src.evolution.scorecard import DailyScorecard from src.logging.decision_logger import DecisionLogger from src.main import ( KILL_SWITCH, + _STOPLOSS_REENTRY_COOLDOWN_UNTIL, _RUNTIME_EXIT_PEAKS, _RUNTIME_EXIT_STATES, _should_force_exit_for_overnight, @@ -93,10 +94,12 @@ def _reset_kill_switch_state() -> None: KILL_SWITCH.clear_block() _RUNTIME_EXIT_STATES.clear() _RUNTIME_EXIT_PEAKS.clear() + _STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear() yield KILL_SWITCH.clear_block() _RUNTIME_EXIT_STATES.clear() _RUNTIME_EXIT_PEAKS.clear() + _STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear() class TestExtractAvgPriceFromBalance: @@ -2053,6 +2056,105 @@ async def test_sell_updates_original_buy_decision_outcome() -> None: assert updated_buy is not None assert updated_buy.outcome_pnl == 20.0 assert updated_buy.outcome_accuracy == 1 + assert "KR:005930" not in _STOPLOSS_REENTRY_COOLDOWN_UNTIL + + +@pytest.mark.asyncio +async def test_stoploss_reentry_cooldown_blocks_buy_when_active() -> None: + _STOPLOSS_REENTRY_COOLDOWN_UNTIL["KR:005930"] = datetime.now(UTC).timestamp() + 300 + db_conn = init_db(":memory:") + + broker = MagicMock() + broker.get_current_price = AsyncMock(return_value=(100.0, 0.0, 0.0)) + broker.get_balance = AsyncMock( + return_value={ + "output1": [], + "output2": [{"tot_evlu_amt": "100000", "dnca_tot_amt": "50000", "pchs_amt_smtl_amt": "50000"}], + } + ) + broker.send_order = AsyncMock(return_value={"msg1": "OK"}) + + market = MagicMock() + market.name = "Korea" + market.code = "KR" + market.exchange_code = "KRX" + market.is_domestic = True + + await trading_cycle( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("005930"))), + playbook=_make_playbook(), + risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()), + db_conn=db_conn, + decision_logger=DecisionLogger(db_conn), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None), set_context=MagicMock()), + 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=market, + stock_code="005930", + scan_candidates={}, + settings=MagicMock(POSITION_SIZING_ENABLED=False, CONFIDENCE_THRESHOLD=80, MODE="paper"), + ) + + broker.send_order.assert_not_called() + + +@pytest.mark.asyncio +async def test_stoploss_reentry_cooldown_allows_buy_after_expiry() -> None: + _STOPLOSS_REENTRY_COOLDOWN_UNTIL["KR:005930"] = datetime.now(UTC).timestamp() - 10 + db_conn = init_db(":memory:") + + broker = MagicMock() + broker.get_current_price = AsyncMock(return_value=(100.0, 0.0, 0.0)) + broker.get_balance = AsyncMock( + return_value={ + "output1": [], + "output2": [{"tot_evlu_amt": "100000", "dnca_tot_amt": "50000", "pchs_amt_smtl_amt": "50000"}], + } + ) + broker.send_order = AsyncMock(return_value={"msg1": "OK"}) + + market = MagicMock() + market.name = "Korea" + market.code = "KR" + market.exchange_code = "KRX" + market.is_domestic = True + + await trading_cycle( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("005930"))), + playbook=_make_playbook(), + risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()), + db_conn=db_conn, + decision_logger=DecisionLogger(db_conn), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None), set_context=MagicMock()), + 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=market, + stock_code="005930", + scan_candidates={}, + settings=MagicMock(POSITION_SIZING_ENABLED=False, CONFIDENCE_THRESHOLD=80, MODE="paper"), + ) + + broker.send_order.assert_called_once() @pytest.mark.asyncio