Merge pull request 'fix: overseas order uses limit price, not hardcoded 0 (#149)' (#150) from feature/issue-149-overseas-limit-order-price into main
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #150
This commit was merged in pull request #150.
This commit is contained in:
@@ -201,3 +201,68 @@
|
|||||||
- `tests/test_brain.py`: 3개 테스트 추가 (override 전달, optimization 우회, 미지정 시 기존 동작 유지)
|
- `tests/test_brain.py`: 3개 테스트 추가 (override 전달, optimization 우회, 미지정 시 기존 동작 유지)
|
||||||
|
|
||||||
**이슈/PR:** #143
|
**이슈/PR:** #143
|
||||||
|
|
||||||
|
### 미국장 거래 미실행 근본 원인 분석 및 수정 (자율 실행 세션)
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- 사용자 요청: "미국장 열면 프로그램 돌려서 거래 한 번도 못 한 거 꼭 원인 찾아서 해결해줘"
|
||||||
|
- 프로그램을 미국장 개장(9:30 AM EST) 전부터 실행하여 실시간 로그를 분석
|
||||||
|
|
||||||
|
**발견된 근본 원인 #1: Defensive Playbook — BUY 조건 없음**
|
||||||
|
|
||||||
|
- Gemini free tier (20 RPD) 소진 → `generate_playbook()` 실패 → `_defensive_playbook()` 폴백
|
||||||
|
- Defensive playbook은 `price_change_pct_below: -3.0 → SELL` 조건만 존재, BUY 조건 없음
|
||||||
|
- ScenarioEngine이 항상 HOLD 반환 → 거래 0건
|
||||||
|
|
||||||
|
**수정 #1 (PR #146, Issue #145):**
|
||||||
|
- `src/strategy/pre_market_planner.py`: `_smart_fallback_playbook()` 메서드 추가
|
||||||
|
- 스캐너 signal 기반 BUY 조건 생성: `momentum → volume_ratio_above`, `oversold → rsi_below`
|
||||||
|
- 기존 defensive stop-loss SELL 조건 유지
|
||||||
|
- Gemini 실패 시 defensive → smart fallback으로 전환
|
||||||
|
- 테스트 10개 추가
|
||||||
|
|
||||||
|
**발견된 근본 원인 #2: 가격 API 거래소 코드 불일치 + VTS 잔고 API 오류**
|
||||||
|
|
||||||
|
실제 로그:
|
||||||
|
```
|
||||||
|
Scenario matched for MRNX: BUY (confidence=80) ✓
|
||||||
|
Decision for EWUS (NYSE American): BUY (confidence=80) ✓
|
||||||
|
Skip BUY APLZ (NYSE American): no affordable quantity (cash=0.00, price=0.00) ✗
|
||||||
|
```
|
||||||
|
|
||||||
|
- `get_overseas_price()`: `NASD`/`NYSE`/`AMEX` 전송 → API가 `NAS`/`NYS`/`AMS` 기대 → 빈 응답 → `price=0`
|
||||||
|
- `VTTS3012R` 잔고 API: "ERROR : INPUT INVALID_CHECK_ACNO" → `total_cash=0`
|
||||||
|
- 결과: `_determine_order_quantity()` 가 0 반환 → 주문 건너뜀
|
||||||
|
|
||||||
|
**수정 #2 (PR #148, Issue #147):**
|
||||||
|
- `src/broker/overseas.py`: `_PRICE_EXCHANGE_MAP = _RANKING_EXCHANGE_MAP` 추가, 가격 API에 매핑 적용
|
||||||
|
- `src/config.py`: `PAPER_OVERSEAS_CASH: float = Field(default=50000.0)` — paper 모드 시뮬레이션 잔고
|
||||||
|
- `src/main.py`: 잔고 0일 때 PAPER_OVERSEAS_CASH 폴백, 가격 0일 때 candidate.price 폴백
|
||||||
|
- 테스트 8개 추가
|
||||||
|
|
||||||
|
**효과:**
|
||||||
|
- BUY 결정 → 실제 주문 전송까지의 파이프라인이 완전히 동작
|
||||||
|
- Paper 모드에서 KIS VTS 해외 잔고 API 오류에 관계없이 시뮬레이션 거래 가능
|
||||||
|
|
||||||
|
**이슈/PR:** #145, #146, #147, #148
|
||||||
|
|
||||||
|
### 해외주식 시장가 주문 거부 수정 (Fix #3, 연속 발견)
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- Fix #147 적용 후 주문 전송 시작 → KIS VTS가 거부: "지정가만 가능한 상품입니다"
|
||||||
|
|
||||||
|
**근본 원인:**
|
||||||
|
- `trading_cycle()`, `run_daily_session()` 양쪽에서 `send_overseas_order(price=0.0)` 하드코딩
|
||||||
|
- `price=0` → `ORD_DVSN="01"` (시장가) 전송 → KIS VTS 거부
|
||||||
|
- Fix #147에서 이미 `current_price`를 올바르게 계산했으나 주문 시 미사용
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- `src/main.py`: 두 곳에서 `price=0.0` → `price=current_price`/`price=stock_data["current_price"]`
|
||||||
|
- `tests/test_main.py`: 회귀 테스트 `test_overseas_buy_order_uses_limit_price` 추가
|
||||||
|
|
||||||
|
**최종 확인 로그:**
|
||||||
|
```
|
||||||
|
Order result: 모의투자 매수주문이 완료 되었습니다. ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
**이슈/PR:** #149, #150
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ async def trading_cycle(
|
|||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=0.0, # market order
|
price=current_price, # limit order — KIS VTS rejects market orders
|
||||||
)
|
)
|
||||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||||
|
|
||||||
@@ -919,7 +919,7 @@ async def run_daily_session(
|
|||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=0.0, # market order
|
price=stock_data["current_price"], # limit order — KIS VTS rejects market orders
|
||||||
)
|
)
|
||||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||||
|
|
||||||
|
|||||||
@@ -738,6 +738,82 @@ class TestOverseasBalanceParsing:
|
|||||||
# Verify price API was called
|
# Verify price API was called
|
||||||
mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once()
|
mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseas_broker_with_buy_scenario(self) -> MagicMock:
|
||||||
|
"""Create mock overseas broker that returns a valid price for BUY orders."""
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": "182.50"}}
|
||||||
|
)
|
||||||
|
broker.get_overseas_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"frcr_evlu_tota": "100000.00",
|
||||||
|
"frcr_dncl_amt_2": "50000.00",
|
||||||
|
"frcr_buy_amt_smtl": "50000.00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
|
||||||
|
return broker
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_scenario_engine_buy(self) -> MagicMock:
|
||||||
|
"""Create mock scenario engine that returns BUY."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL"))
|
||||||
|
return engine
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_overseas_buy_order_uses_limit_price(
|
||||||
|
self,
|
||||||
|
mock_domestic_broker: MagicMock,
|
||||||
|
mock_overseas_broker_with_buy_scenario: MagicMock,
|
||||||
|
mock_scenario_engine_buy: 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 BUY order must use current_price (limit), not 0 (market).
|
||||||
|
|
||||||
|
KIS VTS rejects market orders for overseas paper trading.
|
||||||
|
Regression test for issue #149.
|
||||||
|
"""
|
||||||
|
mock_telegram.notify_trade_execution = AsyncMock()
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_domestic_broker,
|
||||||
|
overseas_broker=mock_overseas_broker_with_buy_scenario,
|
||||||
|
scenario_engine=mock_scenario_engine_buy,
|
||||||
|
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={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify limit order was sent with actual price, not 0.0
|
||||||
|
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]
|
||||||
|
assert sent_price == 182.5, (
|
||||||
|
f"Expected limit price 182.5 but got {sent_price}. "
|
||||||
|
"KIS VTS only accepts limit orders for overseas paper trading."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestScenarioEngineIntegration:
|
class TestScenarioEngineIntegration:
|
||||||
"""Test scenario engine integration in trading_cycle."""
|
"""Test scenario engine integration in trading_cycle."""
|
||||||
|
|||||||
Reference in New Issue
Block a user