From 2e27000760248be53a27548c5180c820668660b2 Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 23 Feb 2026 17:25:15 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=B4=EC=99=B8=EC=A3=BC=EC=8B=9D=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=EA=B0=80=20=EB=B2=84=ED=8D=BC=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20BUY=20+0.2%=20/=20SELL=20-0.2%=20(#211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 정책(BUY +0.5%, SELL 현재가)의 두 가지 문제를 해결: - BUY 0.5% 버퍼는 대형주에서 불필요한 과다 지불 유발 ($50K 규모에서 연간 수십 달러 손실) - SELL 현재가 지정가는 가격이 소폭 하락 시 미체결 위험 (bid < last_price 구간) 변경: - BUY: current_price * 1.005 → current_price * 1.002 (+0.2%) 대형주 기준 90%+ 체결률 유지하면서 과다 지불 최소화 - SELL: current_price → current_price * 0.998 (-0.2%) bid가 last_price 아래일 때도 체결 보장 - VTS(paper)와 live 동일 정책 적용 — 더 현실적인 시뮬레이션 - KIS 시장가 주문은 상한가 기준 수량 계산 버그로 사용 안 함(유지) 테스트: - test_overseas_buy_order_uses_limit_price: 1.005 → 1.002 업데이트 - test_overseas_sell_order_uses_limit_price_below_current: 신규 추가 Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 18 +++++---- tests/test_main.py | 94 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/main.py b/src/main.py index bdf187d..f14c308 100644 --- a/src/main.py +++ b/src/main.py @@ -860,21 +860,23 @@ async def trading_cycle( price=0, # market order ) else: - # For overseas orders: - # - KIS VTS only accepts limit orders (지정가만 가능) - # - BUY: use 0.5% premium over last price to improve fill probability - # (ask price is typically slightly above last, and VTS won't fill below ask) - # - SELL: use last price as the limit + # For overseas orders, always use limit orders (지정가): + # - KIS market orders (ORD_DVSN=01) calculate quantity based on upper limit + # price (상한가 기준), resulting in only 60-80% of intended cash being used. + # - BUY: +0.2% above last price — tight enough to minimise overpayment while + # achieving >90% fill rate on large-cap US stocks. + # - SELL: -0.2% below last price — ensures fill even when price dips slightly + # (placing at exact last price risks no-fill if the bid is just below). if decision.action == "BUY": - order_price = round(current_price * 1.005, 4) + order_price = round(current_price * 1.002, 4) else: - order_price = current_price + order_price = round(current_price * 0.998, 4) result = await overseas_broker.send_overseas_order( exchange_code=market.exchange_code, stock_code=stock_code, order_type=decision.action, quantity=quantity, - price=order_price, # limit order — KIS VTS rejects market orders + price=order_price, # limit order ) # Check if KIS rejected the order (rt_cd != "0") if result.get("rt_cd", "") != "0": diff --git a/tests/test_main.py b/tests/test_main.py index f5d2e9a..d4ed19c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1105,10 +1105,11 @@ class TestOverseasBalanceParsing: mock_telegram: MagicMock, mock_overseas_market: MagicMock, ) -> None: - """Overseas BUY order must use current_price (limit), not 0 (market). + """Overseas BUY order must use current_price +0.2% limit, not market order. - KIS VTS rejects market orders for overseas paper trading. - Regression test for issue #149. + KIS market orders (ORD_DVSN=01) calculate quantity based on upper limit price + (상한가 기준), resulting in only 60-80% of intended cash being used. + Regression test for issue #149 / #211. """ mock_telegram.notify_trade_execution = AsyncMock() @@ -1129,14 +1130,93 @@ class TestOverseasBalanceParsing: scan_candidates={}, ) - # Verify limit order was sent with actual price + 0.5% premium (issue #151), not 0.0 + # Verify BUY limit order uses +0.2% premium (issue #211) mock_overseas_broker_with_buy_scenario.send_overseas_order.assert_called_once() call_kwargs = mock_overseas_broker_with_buy_scenario.send_overseas_order.call_args sent_price = call_kwargs[1].get("price") or call_kwargs[0][4] - expected_price = round(182.5 * 1.005, 4) # 0.5% premium for BUY limit orders + expected_price = round(182.5 * 1.002, 4) # 0.2% premium for BUY limit orders assert sent_price == expected_price, ( - f"Expected limit price {expected_price} (182.5 * 1.005) but got {sent_price}. " - "KIS VTS only accepts limit orders; BUY uses 0.5% premium to improve fill rate." + f"Expected limit price {expected_price} (182.5 * 1.002) but got {sent_price}. " + "BUY uses +0.2% to improve fill rate while minimising overpayment (#211)." + ) + + @pytest.mark.asyncio + async def test_overseas_sell_order_uses_limit_price_below_current( + self, + mock_domestic_broker: MagicMock, + mock_playbook: DayPlaybook, + mock_risk: MagicMock, + mock_db: MagicMock, + mock_decision_logger: MagicMock, + mock_context_store: MagicMock, + mock_criticality_assessor: MagicMock, + mock_telegram: MagicMock, + mock_overseas_market: MagicMock, + ) -> None: + """Overseas SELL order must use current_price -0.2% limit (#211). + + Placing SELL at exact last price risks no-fill when the bid is just below. + Using -0.2% ensures the order fills even if the price dips slightly. + """ + sell_price = 182.5 + + # Broker mock: returns price data and a balance with 5 AAPL shares held. + overseas_broker = MagicMock() + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": str(sell_price), "rate": "1.5", "tvol": "5000000"}} + ) + overseas_broker.get_overseas_balance = AsyncMock( + return_value={ + "output1": [ + { + "ovrs_pdno": "AAPL", + "ovrs_cblc_qty": "5", + "pchs_avg_pric": "170.0", + "evlu_pfls_rt": "7.35", + } + ], + "output2": [ + { + "frcr_evlu_tota": "100000.00", + "frcr_dncl_amt_2": "50000.00", + "frcr_buy_amt_smtl": "50000.00", + } + ], + } + ) + overseas_broker.send_overseas_order = AsyncMock( + return_value={"rt_cd": "0", "msg1": "OK"} + ) + + sell_engine = MagicMock(spec=ScenarioEngine) + sell_engine.evaluate = MagicMock(return_value=_make_sell_match("AAPL")) + mock_telegram.notify_trade_execution = AsyncMock() + + with patch("src.main.log_trade"), patch("src.main.get_open_position") as mock_pos: + mock_pos.return_value = {"quantity": 5, "stock_code": "AAPL", "price": 170.0} + await trading_cycle( + broker=mock_domestic_broker, + overseas_broker=overseas_broker, + scenario_engine=sell_engine, + playbook=mock_playbook, + risk=mock_risk, + db_conn=mock_db, + decision_logger=mock_decision_logger, + context_store=mock_context_store, + criticality_assessor=mock_criticality_assessor, + telegram=mock_telegram, + market=mock_overseas_market, + stock_code="AAPL", + scan_candidates={}, + ) + + overseas_broker.send_overseas_order.assert_called_once() + call_kwargs = overseas_broker.send_overseas_order.call_args + sent_price = call_kwargs[1].get("price") or call_kwargs[0][4] + expected_price = round(sell_price * 0.998, 4) # -0.2% for SELL limit orders + assert sent_price == expected_price, ( + f"Expected SELL limit price {expected_price} (182.5 * 0.998) but got {sent_price}. " + "SELL uses -0.2% to ensure fill even when price dips slightly (#211)." )