diff --git a/src/config.py b/src/config.py index 7ff1c4d..34a886a 100644 --- a/src/config.py +++ b/src/config.py @@ -59,6 +59,7 @@ class Settings(BaseSettings): # KIS VTS overseas balance API returns errors for most accounts. # This value is used as a fallback when the balance API returns 0 in paper mode. PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0) + USD_BUFFER_MIN: float = Field(default=1000.0, ge=0.0) # Trading frequency mode (daily = batch API calls, realtime = per-stock calls) TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$") diff --git a/src/main.py b/src/main.py index 2fe9ef4..6d8e8bb 100644 --- a/src/main.py +++ b/src/main.py @@ -429,6 +429,26 @@ def _determine_order_quantity( return quantity +def _should_block_overseas_buy_for_fx_buffer( + *, + market: MarketInfo, + action: str, + total_cash: float, + order_amount: float, + settings: Settings | None, +) -> tuple[bool, float, float]: + if ( + market.is_domestic + or not market.code.startswith("US") + or action != "BUY" + or settings is None + ): + return False, total_cash - order_amount, 0.0 + remaining = total_cash - order_amount + required = settings.USD_BUFFER_MIN + return remaining < required, remaining, required + + async def build_overseas_symbol_universe( db_conn: Any, overseas_broker: OverseasBroker, @@ -1292,6 +1312,24 @@ async def trading_cycle( ) return order_amount = current_price * quantity + fx_blocked, remaining_cash, required_buffer = _should_block_overseas_buy_for_fx_buffer( + market=market, + action=decision.action, + total_cash=total_cash, + order_amount=order_amount, + settings=settings, + ) + if fx_blocked: + logger.warning( + "Skip BUY %s (%s): FX buffer guard (remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)", + stock_code, + market.name, + remaining_cash, + required_buffer, + total_cash, + order_amount, + ) + return # 4. Check BUY cooldown (set when a prior BUY failed due to insufficient balance) if decision.action == "BUY" and buy_cooldown is not None: @@ -2360,6 +2398,24 @@ async def run_daily_session( ) continue order_amount = stock_data["current_price"] * quantity + fx_blocked, remaining_cash, required_buffer = _should_block_overseas_buy_for_fx_buffer( + market=market, + action=decision.action, + total_cash=total_cash, + order_amount=order_amount, + settings=settings, + ) + if fx_blocked: + logger.warning( + "Skip BUY %s (%s): FX buffer guard (remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)", + stock_code, + market.name, + remaining_cash, + required_buffer, + total_cash, + order_amount, + ) + continue # Check BUY cooldown (insufficient balance) if decision.action == "BUY": diff --git a/tests/test_main.py b/tests/test_main.py index 3dee447..61887a0 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, + _should_block_overseas_buy_for_fx_buffer, _trigger_emergency_kill_switch, _apply_dashboard_flag, _determine_order_quantity, @@ -3690,6 +3691,81 @@ class TestOverseasBrokerIntegration: # DB도 브로커도 보유 없음 → BUY 주문이 실행되어야 함 (회귀 테스트) overseas_broker.send_overseas_order.assert_called_once() + @pytest.mark.asyncio + async def test_overseas_buy_blocked_by_usd_buffer_guard(self) -> None: + """Overseas BUY must be blocked when USD buffer would be breached.""" + db_conn = init_db(":memory:") + + overseas_broker = MagicMock() + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "182.50"}} + ) + overseas_broker.get_overseas_balance = AsyncMock( + return_value={ + "output1": [], + "output2": [ + { + "frcr_evlu_tota": "50000.00", + "frcr_buy_amt_smtl": "0.00", + } + ], + } + ) + overseas_broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ovrs_ord_psbl_amt": "50000.00"}} + ) + overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"}) + + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL")) + + market = MagicMock() + market.name = "NASDAQ" + market.code = "US_NASDAQ" + market.exchange_code = "NASD" + market.is_domestic = False + + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + decision_logger = MagicMock() + decision_logger.log_decision = MagicMock(return_value="decision-id") + + settings = MagicMock() + settings.POSITION_SIZING_ENABLED = False + settings.CONFIDENCE_THRESHOLD = 80 + settings.USD_BUFFER_MIN = 49900.0 + settings.MODE = "paper" + settings.PAPER_OVERSEAS_CASH = 50000.0 + + await trading_cycle( + broker=MagicMock(), + overseas_broker=overseas_broker, + scenario_engine=engine, + playbook=_make_playbook(market="US"), + risk=MagicMock(), + db_conn=db_conn, + decision_logger=decision_logger, + 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=telegram, + market=market, + stock_code="AAPL", + scan_candidates={}, + settings=settings, + ) + + overseas_broker.send_overseas_order.assert_not_called() + # --------------------------------------------------------------------------- # _retry_connection — unit tests (issue #209) @@ -3723,7 +3799,6 @@ class TestRetryConnection: with patch("src.main.asyncio.sleep") as mock_sleep: mock_sleep.return_value = None result = await _retry_connection(flaky, label="flaky") - assert result == "ok" assert call_count == 2 mock_sleep.assert_called_once() @@ -3778,6 +3853,48 @@ class TestRetryConnection: assert call_count == 1 # No retry for non-ConnectionError +def test_fx_buffer_guard_applies_only_to_us_and_respects_boundary() -> None: + settings = MagicMock() + settings.USD_BUFFER_MIN = 1000.0 + + us_market = MagicMock() + us_market.is_domestic = False + us_market.code = "US_NASDAQ" + + blocked, remaining, required = _should_block_overseas_buy_for_fx_buffer( + market=us_market, + action="BUY", + total_cash=5000.0, + order_amount=4001.0, + settings=settings, + ) + assert blocked + assert remaining == 999.0 + assert required == 1000.0 + + blocked_eq, _, _ = _should_block_overseas_buy_for_fx_buffer( + market=us_market, + action="BUY", + total_cash=5000.0, + order_amount=4000.0, + settings=settings, + ) + assert not blocked_eq + + jp_market = MagicMock() + jp_market.is_domestic = False + jp_market.code = "JP" + blocked_jp, _, required_jp = _should_block_overseas_buy_for_fx_buffer( + market=jp_market, + action="BUY", + total_cash=5000.0, + order_amount=4500.0, + settings=settings, + ) + assert not blocked_jp + assert required_jp == 0.0 + + # run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207) # ---------------------------------------------------------------------------