From 08607eaa567b0f9f6785ba1ada5e62a4c5bee290 Mon Sep 17 00:00:00 2001 From: agentson Date: Sat, 28 Feb 2026 14:40:19 +0900 Subject: [PATCH 1/2] feat: block US BUY entries below minimum price threshold (#320) --- src/config.py | 1 + src/main.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/config.py b/src/config.py index 0e60e32..7f27aeb 100644 --- a/src/config.py +++ b/src/config.py @@ -60,6 +60,7 @@ class Settings(BaseSettings): # 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) + US_MIN_PRICE: float = Field(default=5.0, ge=0.0) 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 cc158a2..a0c716e 100644 --- a/src/main.py +++ b/src/main.py @@ -1291,6 +1291,24 @@ async def trading_cycle( stock_code, market.name, ) + elif market.code.startswith("US"): + min_price = float(getattr(settings, "US_MIN_PRICE", 5.0) if settings else 5.0) + if current_price <= min_price: + decision = TradeDecision( + action="HOLD", + confidence=decision.confidence, + rationale=( + f"US minimum price filter blocked BUY " + f"(price={current_price:.4f} <= {min_price:.4f})" + ), + ) + logger.info( + "BUY suppressed for %s (%s): US min price filter %.4f <= %.4f", + stock_code, + market.name, + current_price, + min_price, + ) if decision.action == "HOLD": open_position = get_open_position(db_conn, stock_code, market.code) @@ -2442,6 +2460,24 @@ async def run_daily_session( stock_code, market.name, ) + elif market.code.startswith("US"): + min_price = float(getattr(settings, "US_MIN_PRICE", 5.0)) + if stock_data["current_price"] <= min_price: + decision = TradeDecision( + action="HOLD", + confidence=decision.confidence, + rationale=( + f"US minimum price filter blocked BUY " + f"(price={stock_data['current_price']:.4f} <= {min_price:.4f})" + ), + ) + logger.info( + "BUY suppressed for %s (%s): US min price filter %.4f <= %.4f", + stock_code, + market.name, + stock_data["current_price"], + min_price, + ) if decision.action == "HOLD": daily_open = get_open_position(db_conn, stock_code, market.code) if not daily_open: From 9267f1fb778553c512cf0e1c2d9f7178ac95ef1f Mon Sep 17 00:00:00 2001 From: agentson Date: Sat, 28 Feb 2026 17:15:10 +0900 Subject: [PATCH 2/2] test: add US minimum price boundary and KR-scope coverage (#320) --- tests/test_main.py | 143 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 63ee0da..a016634 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5654,6 +5654,149 @@ async def test_order_policy_rejection_skips_order_execution() -> None: broker.send_order.assert_not_called() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("price", "should_block"), + [ + (4.99, True), + (5.00, True), + (5.01, False), + ], +) +async def test_us_min_price_filter_boundary(price: float, should_block: bool) -> None: + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + + broker = MagicMock() + broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]}) + + overseas_broker = MagicMock() + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": str(price), "rate": "0.0"}} + ) + overseas_broker.get_overseas_balance = AsyncMock( + return_value={"output1": [], "output2": [{"frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}]} + ) + overseas_broker.get_overseas_buying_power = AsyncMock( + return_value={"output": {"ovrs_ord_psbl_amt": "10000"}} + ) + overseas_broker.send_overseas_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"}) + + 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() + + settings = MagicMock() + settings.POSITION_SIZING_ENABLED = False + settings.CONFIDENCE_THRESHOLD = 80 + settings.MODE = "paper" + settings.PAPER_OVERSEAS_CASH = 50000 + settings.US_MIN_PRICE = 5.0 + settings.USD_BUFFER_MIN = 1000.0 + + await trading_cycle( + broker=broker, + overseas_broker=overseas_broker, + scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("AAPL"))), + playbook=_make_playbook("US_NASDAQ"), + risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=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, + ) + + if should_block: + overseas_broker.send_overseas_order.assert_not_called() + else: + overseas_broker.send_overseas_order.assert_called_once() + + +@pytest.mark.asyncio +async def test_us_min_price_filter_not_applied_to_kr_market() -> None: + db_conn = init_db(":memory:") + decision_logger = DecisionLogger(db_conn) + + broker = MagicMock() + broker.get_current_price = AsyncMock(return_value=(4.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 + + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + settings = MagicMock() + settings.POSITION_SIZING_ENABLED = False + settings.CONFIDENCE_THRESHOLD = 80 + settings.MODE = "paper" + settings.US_MIN_PRICE = 5.0 + settings.USD_BUFFER_MIN = 1000.0 + + 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=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="005930", + scan_candidates={}, + settings=settings, + ) + + broker.send_order.assert_called_once() + + def test_overnight_policy_prioritizes_killswitch_over_exception() -> None: market = MagicMock() with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_AFTER")):