Compare commits
7 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a47bec7f | ||
| c76e2dfed5 | |||
|
|
24fa22e77b | ||
| cd1579058c | |||
| 45b48fa7cd | |||
|
|
3952a5337b | ||
|
|
ccc97ebaa9 |
@@ -201,3 +201,68 @@
|
||||
- `tests/test_brain.py`: 3개 테스트 추가 (override 전달, optimization 우회, 미지정 시 기존 동작 유지)
|
||||
|
||||
**이슈/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
|
||||
|
||||
@@ -257,14 +257,27 @@ class OverseasBroker:
|
||||
f"send_overseas_order failed ({resp.status}): {text}"
|
||||
)
|
||||
data = await resp.json()
|
||||
logger.info(
|
||||
"Overseas order submitted",
|
||||
extra={
|
||||
"exchange": exchange_code,
|
||||
"stock_code": stock_code,
|
||||
"action": order_type,
|
||||
},
|
||||
)
|
||||
rt_cd = data.get("rt_cd", "")
|
||||
msg1 = data.get("msg1", "")
|
||||
if rt_cd == "0":
|
||||
logger.info(
|
||||
"Overseas order submitted",
|
||||
extra={
|
||||
"exchange": exchange_code,
|
||||
"stock_code": stock_code,
|
||||
"action": order_type,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Overseas order rejected (rt_cd=%s): %s [%s %s %s qty=%d]",
|
||||
rt_cd,
|
||||
msg1,
|
||||
order_type,
|
||||
stock_code,
|
||||
exchange_code,
|
||||
quantity,
|
||||
)
|
||||
return data
|
||||
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||
raise ConnectionError(
|
||||
|
||||
@@ -75,6 +75,14 @@ class Settings(BaseSettings):
|
||||
# Market selection (comma-separated market codes)
|
||||
ENABLED_MARKETS: str = "KR,US"
|
||||
|
||||
# Fallback stock list for KR domestic market when ranking API returns empty
|
||||
# (KIS VTS does not return data from volume-rank API).
|
||||
# Comma-separated 6-digit stock codes. Override in .env if needed.
|
||||
KR_FALLBACK_STOCKS: str = (
|
||||
"005930,000660,035420,005380,068270," # 삼성전자,SK하이닉스,NAVER,현대차,셀트리온
|
||||
"051910,035720,006400,207940,000270" # LG화학,카카오,삼성SDI,삼성바이오로직스,기아
|
||||
)
|
||||
|
||||
# Backup and Disaster Recovery (optional)
|
||||
BACKUP_ENABLED: bool = True
|
||||
BACKUP_DIR: str = "data/backups"
|
||||
|
||||
140
src/main.py
140
src/main.py
@@ -239,17 +239,27 @@ async def trading_cycle(
|
||||
total_cash = safe_float(balance_info.get("frcr_dncl_amt_2", "0") or "0")
|
||||
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
|
||||
|
||||
# VTS (paper trading) overseas balance API often returns 0 or errors.
|
||||
# Fall back to configured paper cash so BUY orders can be sized.
|
||||
# Paper mode fallback: VTS overseas balance API often fails for many accounts.
|
||||
if total_cash <= 0 and settings and settings.PAPER_OVERSEAS_CASH > 0:
|
||||
logger.debug(
|
||||
"Overseas cash balance is 0 for %s; using paper fallback %.2f",
|
||||
stock_code,
|
||||
"Overseas cash balance is 0 for %s; using paper fallback %.2f USD",
|
||||
market.exchange_code,
|
||||
settings.PAPER_OVERSEAS_CASH,
|
||||
)
|
||||
total_cash = settings.PAPER_OVERSEAS_CASH
|
||||
|
||||
current_price = safe_float(price_data.get("output", {}).get("last", "0"))
|
||||
# Fallback: if price API returns 0, use scanner candidate price
|
||||
if current_price <= 0:
|
||||
market_candidates_lookup = scan_candidates.get(market.code, {})
|
||||
cand_lookup = market_candidates_lookup.get(stock_code)
|
||||
if cand_lookup and cand_lookup.price > 0:
|
||||
logger.debug(
|
||||
"Price API returned 0 for %s; using scanner candidate price %.4f",
|
||||
stock_code,
|
||||
cand_lookup.price,
|
||||
)
|
||||
current_price = cand_lookup.price
|
||||
foreigner_net = 0.0 # Not available for overseas
|
||||
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
|
||||
|
||||
@@ -497,6 +507,7 @@ async def trading_cycle(
|
||||
raise # Re-raise to prevent trade
|
||||
|
||||
# 5. Send order
|
||||
order_succeeded = True
|
||||
if market.is_domestic:
|
||||
result = await broker.send_order(
|
||||
stock_code=stock_code,
|
||||
@@ -505,29 +516,48 @@ 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
|
||||
if decision.action == "BUY":
|
||||
order_price = round(current_price * 1.005, 4)
|
||||
else:
|
||||
order_price = current_price
|
||||
result = await overseas_broker.send_overseas_order(
|
||||
exchange_code=market.exchange_code,
|
||||
stock_code=stock_code,
|
||||
order_type=decision.action,
|
||||
quantity=quantity,
|
||||
price=0.0, # market order
|
||||
price=order_price, # limit order — KIS VTS rejects market orders
|
||||
)
|
||||
# Check if KIS rejected the order (rt_cd != "0")
|
||||
if result.get("rt_cd", "") != "0":
|
||||
order_succeeded = False
|
||||
logger.warning(
|
||||
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
||||
stock_code,
|
||||
result.get("rt_cd"),
|
||||
result.get("msg1"),
|
||||
)
|
||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||
|
||||
# 5.5. Notify trade execution
|
||||
try:
|
||||
await telegram.notify_trade_execution(
|
||||
stock_code=stock_code,
|
||||
market=market.name,
|
||||
action=decision.action,
|
||||
quantity=quantity,
|
||||
price=current_price,
|
||||
confidence=decision.confidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Telegram notification failed: %s", exc)
|
||||
# 5.5. Notify trade execution (only on success)
|
||||
if order_succeeded:
|
||||
try:
|
||||
await telegram.notify_trade_execution(
|
||||
stock_code=stock_code,
|
||||
market=market.name,
|
||||
action=decision.action,
|
||||
quantity=quantity,
|
||||
price=current_price,
|
||||
confidence=decision.confidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Telegram notification failed: %s", exc)
|
||||
|
||||
if decision.action == "SELL":
|
||||
if decision.action == "SELL" and order_succeeded:
|
||||
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
|
||||
if buy_trade and buy_trade.get("price") is not None:
|
||||
buy_price = float(buy_trade["price"])
|
||||
@@ -539,7 +569,9 @@ async def trading_cycle(
|
||||
accuracy=1 if trade_pnl > 0 else 0,
|
||||
)
|
||||
|
||||
# 6. Log trade with selection context
|
||||
# 6. Log trade with selection context (skip if order was rejected)
|
||||
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
||||
return
|
||||
selection_context = None
|
||||
if stock_code in market_candidates:
|
||||
candidate = market_candidates[stock_code]
|
||||
@@ -711,6 +743,16 @@ async def run_daily_session(
|
||||
current_price = safe_float(
|
||||
price_data.get("output", {}).get("last", "0")
|
||||
)
|
||||
# Fallback: if price API returns 0, use scanner candidate price
|
||||
if current_price <= 0:
|
||||
cand_lookup = candidate_map.get(stock_code)
|
||||
if cand_lookup and cand_lookup.price > 0:
|
||||
logger.debug(
|
||||
"Price API returned 0 for %s; using scanner candidate price %.4f",
|
||||
stock_code,
|
||||
cand_lookup.price,
|
||||
)
|
||||
current_price = cand_lookup.price
|
||||
foreigner_net = 0.0
|
||||
price_change_pct = safe_float(
|
||||
price_data.get("output", {}).get("rate", "0")
|
||||
@@ -775,6 +817,9 @@ async def run_daily_session(
|
||||
purchase_total = safe_float(
|
||||
balance_info.get("frcr_buy_amt_smtl", "0") or "0"
|
||||
)
|
||||
# Paper mode fallback: VTS overseas balance API often fails for many accounts.
|
||||
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
|
||||
total_cash = settings.PAPER_OVERSEAS_CASH
|
||||
|
||||
# VTS overseas balance API often returns 0; use paper fallback.
|
||||
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
|
||||
@@ -853,6 +898,7 @@ async def run_daily_session(
|
||||
quantity = 0
|
||||
trade_price = stock_data["current_price"]
|
||||
trade_pnl = 0.0
|
||||
order_succeeded = True
|
||||
if decision.action in ("BUY", "SELL"):
|
||||
quantity = _determine_order_quantity(
|
||||
action=decision.action,
|
||||
@@ -905,6 +951,7 @@ async def run_daily_session(
|
||||
raise
|
||||
|
||||
# Send order
|
||||
order_succeeded = True
|
||||
try:
|
||||
if market.is_domestic:
|
||||
result = await broker.send_order(
|
||||
@@ -914,34 +961,48 @@ async def run_daily_session(
|
||||
price=0, # market order
|
||||
)
|
||||
else:
|
||||
# KIS VTS only accepts limit orders; use 0.5% premium for BUY
|
||||
if decision.action == "BUY":
|
||||
order_price = round(stock_data["current_price"] * 1.005, 4)
|
||||
else:
|
||||
order_price = stock_data["current_price"]
|
||||
result = await overseas_broker.send_overseas_order(
|
||||
exchange_code=market.exchange_code,
|
||||
stock_code=stock_code,
|
||||
order_type=decision.action,
|
||||
quantity=quantity,
|
||||
price=0.0, # market order
|
||||
price=order_price, # limit order
|
||||
)
|
||||
if result.get("rt_cd", "") != "0":
|
||||
order_succeeded = False
|
||||
logger.warning(
|
||||
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
||||
stock_code,
|
||||
result.get("rt_cd"),
|
||||
result.get("msg1"),
|
||||
)
|
||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||
|
||||
# Notify trade execution
|
||||
try:
|
||||
await telegram.notify_trade_execution(
|
||||
stock_code=stock_code,
|
||||
market=market.name,
|
||||
action=decision.action,
|
||||
quantity=quantity,
|
||||
price=stock_data["current_price"],
|
||||
confidence=decision.confidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Telegram notification failed: %s", exc)
|
||||
# Notify trade execution (only on success)
|
||||
if order_succeeded:
|
||||
try:
|
||||
await telegram.notify_trade_execution(
|
||||
stock_code=stock_code,
|
||||
market=market.name,
|
||||
action=decision.action,
|
||||
quantity=quantity,
|
||||
price=stock_data["current_price"],
|
||||
confidence=decision.confidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Telegram notification failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Order execution failed for %s: %s", stock_code, exc
|
||||
)
|
||||
continue
|
||||
|
||||
if decision.action == "SELL":
|
||||
if decision.action == "SELL" and order_succeeded:
|
||||
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
|
||||
if buy_trade and buy_trade.get("price") is not None:
|
||||
buy_price = float(buy_trade["price"])
|
||||
@@ -953,7 +1014,9 @@ async def run_daily_session(
|
||||
accuracy=1 if trade_pnl > 0 else 0,
|
||||
)
|
||||
|
||||
# Log trade
|
||||
# Log trade (skip if order was rejected by API)
|
||||
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
||||
continue
|
||||
log_trade(
|
||||
conn=db_conn,
|
||||
stock_code=stock_code,
|
||||
@@ -1635,7 +1698,14 @@ async def run(settings: Settings) -> None:
|
||||
logger.info("Smart Scanner: Scanning %s market", market.name)
|
||||
|
||||
fallback_stocks: list[str] | None = None
|
||||
if not market.is_domestic:
|
||||
if market.is_domestic:
|
||||
# KIS VTS ranking API often returns empty for domestic.
|
||||
# Use configured fallback so scanner can still run.
|
||||
raw = settings.KR_FALLBACK_STOCKS if settings else ""
|
||||
fallback_stocks = [
|
||||
c.strip() for c in raw.split(",") if c.strip()
|
||||
] or None
|
||||
else:
|
||||
fallback_stocks = await build_overseas_symbol_universe(
|
||||
db_conn=db_conn,
|
||||
overseas_broker=overseas_broker,
|
||||
|
||||
@@ -738,6 +738,83 @@ class TestOverseasBalanceParsing:
|
||||
# Verify price API was called
|
||||
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 + 0.5% premium (issue #151), 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]
|
||||
expected_price = round(182.5 * 1.005, 4) # 0.5% 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."
|
||||
)
|
||||
|
||||
|
||||
class TestScenarioEngineIntegration:
|
||||
"""Test scenario engine integration in trading_cycle."""
|
||||
@@ -1601,3 +1678,43 @@ def test_start_dashboard_server_enabled_starts_thread() -> None:
|
||||
assert thread == mock_thread
|
||||
mock_thread_cls.assert_called_once()
|
||||
mock_thread.start.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KR fallback stocks config (issue #153)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKrFallbackStocksConfig:
|
||||
"""Test KR_FALLBACK_STOCKS default value and parsing."""
|
||||
|
||||
def test_default_contains_samsung(self) -> None:
|
||||
settings = Settings(
|
||||
KIS_APP_KEY="k",
|
||||
KIS_APP_SECRET="s",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="g",
|
||||
)
|
||||
codes = [c.strip() for c in settings.KR_FALLBACK_STOCKS.split(",") if c.strip()]
|
||||
assert "005930" in codes # 삼성전자
|
||||
|
||||
def test_default_has_ten_codes(self) -> None:
|
||||
settings = Settings(
|
||||
KIS_APP_KEY="k",
|
||||
KIS_APP_SECRET="s",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="g",
|
||||
)
|
||||
codes = [c.strip() for c in settings.KR_FALLBACK_STOCKS.split(",") if c.strip()]
|
||||
assert len(codes) == 10
|
||||
|
||||
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("KR_FALLBACK_STOCKS", "005930,000660")
|
||||
settings = Settings(
|
||||
KIS_APP_KEY="k",
|
||||
KIS_APP_SECRET="s",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="g",
|
||||
)
|
||||
codes = [c.strip() for c in settings.KR_FALLBACK_STOCKS.split(",") if c.strip()]
|
||||
assert codes == ["005930", "000660"]
|
||||
|
||||
@@ -302,8 +302,7 @@ class TestGetOverseasPrice:
|
||||
|
||||
call_args = mock_session.get.call_args
|
||||
params = call_args[1]["params"]
|
||||
# NASD is mapped to NAS for the price inquiry API (same as ranking API).
|
||||
assert params["EXCD"] == "NAS"
|
||||
assert params["EXCD"] == "NAS" # NASD → NAS via _PRICE_EXCHANGE_MAP
|
||||
assert params["SYMB"] == "AAPL"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -522,58 +521,32 @@ class TestExtractRankingRows:
|
||||
assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price exchange code mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPriceExchangeMap:
|
||||
"""Test that get_overseas_price uses the short exchange codes."""
|
||||
"""Test _PRICE_EXCHANGE_MAP is applied in get_overseas_price (issue #151)."""
|
||||
|
||||
def test_price_map_equals_ranking_map(self) -> None:
|
||||
assert _PRICE_EXCHANGE_MAP is _RANKING_EXCHANGE_MAP
|
||||
|
||||
def test_nasd_maps_to_nas(self) -> None:
|
||||
assert _PRICE_EXCHANGE_MAP["NASD"] == "NAS"
|
||||
|
||||
def test_amex_maps_to_ams(self) -> None:
|
||||
assert _PRICE_EXCHANGE_MAP["AMEX"] == "AMS"
|
||||
|
||||
def test_nyse_maps_to_nys(self) -> None:
|
||||
assert _PRICE_EXCHANGE_MAP["NYSE"] == "NYS"
|
||||
@pytest.mark.parametrize("original,expected", [
|
||||
("NASD", "NAS"),
|
||||
("NYSE", "NYS"),
|
||||
("AMEX", "AMS"),
|
||||
])
|
||||
def test_us_exchange_code_mapping(self, original: str, expected: str) -> None:
|
||||
assert _PRICE_EXCHANGE_MAP[original] == expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_overseas_price_uses_mapped_excd(
|
||||
async def test_get_overseas_price_sends_mapped_code(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""AMEX should be sent as AMS to the price API."""
|
||||
"""NASD → NAS must be sent to HHDFS00000300."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"output": {"last": "44.30"}})
|
||||
mock_resp.json = AsyncMock(return_value={"output": {"last": "200.00"}})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
overseas_broker._broker._auth_headers = AsyncMock(return_value={})
|
||||
|
||||
await overseas_broker.get_overseas_price("AMEX", "EWUS")
|
||||
|
||||
params = mock_session.get.call_args[1]["params"]
|
||||
assert params["EXCD"] == "AMS" # mapped, not raw "AMEX"
|
||||
assert params["SYMB"] == "EWUS"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_overseas_price_nasd_uses_nas(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"output": {"last": "220.00"}})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
_setup_broker_mocks(overseas_broker, mock_session)
|
||||
overseas_broker._broker._auth_headers = AsyncMock(return_value={})
|
||||
|
||||
await overseas_broker.get_overseas_price("NASD", "AAPL")
|
||||
|
||||
@@ -581,37 +554,90 @@ class TestPriceExchangeMap:
|
||||
assert params["EXCD"] == "NAS"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PAPER_OVERSEAS_CASH config default
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestOrderRtCdCheck:
|
||||
"""Test that send_overseas_order checks rt_cd and logs accordingly (issue #151)."""
|
||||
|
||||
@pytest.fixture
|
||||
def overseas_broker(self, mock_settings: Settings) -> OverseasBroker:
|
||||
broker = MagicMock(spec=KISBroker)
|
||||
broker._settings = mock_settings
|
||||
broker._account_no = "12345678"
|
||||
broker._product_cd = "01"
|
||||
broker._base_url = "https://openapivts.koreainvestment.com:9443"
|
||||
broker._rate_limiter = AsyncMock()
|
||||
broker._rate_limiter.acquire = AsyncMock()
|
||||
broker._auth_headers = AsyncMock(return_value={"authorization": "Bearer t"})
|
||||
broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||
return OverseasBroker(broker)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_rt_cd_returns_data(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""rt_cd='0' → order accepted, data returned."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "완료"})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||
|
||||
result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10, price=150.0)
|
||||
assert result["rt_cd"] == "0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_rt_cd_returns_data_with_msg(
|
||||
self, overseas_broker: OverseasBroker
|
||||
) -> None:
|
||||
"""rt_cd != '0' → order rejected, data still returned (caller checks rt_cd)."""
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(
|
||||
return_value={"rt_cd": "1", "msg1": "주문가능금액이 부족합니다."}
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||
|
||||
result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10, price=150.0)
|
||||
assert result["rt_cd"] == "1"
|
||||
assert "부족" in result["msg1"]
|
||||
|
||||
|
||||
class TestPaperOverseasCash:
|
||||
"""Test PAPER_OVERSEAS_CASH config setting (issue #151)."""
|
||||
|
||||
def test_default_value(self) -> None:
|
||||
settings = Settings(
|
||||
KIS_APP_KEY="x",
|
||||
KIS_APP_SECRET="x",
|
||||
KIS_APP_KEY="k",
|
||||
KIS_APP_SECRET="s",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="x",
|
||||
GEMINI_API_KEY="g",
|
||||
)
|
||||
assert settings.PAPER_OVERSEAS_CASH == 50000.0
|
||||
|
||||
def test_can_be_set_via_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PAPER_OVERSEAS_CASH", "100000.0")
|
||||
def test_env_override(self) -> None:
|
||||
import os
|
||||
os.environ["PAPER_OVERSEAS_CASH"] = "25000"
|
||||
settings = Settings(
|
||||
KIS_APP_KEY="x",
|
||||
KIS_APP_SECRET="x",
|
||||
KIS_APP_KEY="k",
|
||||
KIS_APP_SECRET="s",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="x",
|
||||
GEMINI_API_KEY="g",
|
||||
)
|
||||
assert settings.PAPER_OVERSEAS_CASH == 100000.0
|
||||
assert settings.PAPER_OVERSEAS_CASH == 25000.0
|
||||
del os.environ["PAPER_OVERSEAS_CASH"]
|
||||
|
||||
def test_zero_disables_fallback(self) -> None:
|
||||
import os
|
||||
os.environ["PAPER_OVERSEAS_CASH"] = "0"
|
||||
settings = Settings(
|
||||
KIS_APP_KEY="x",
|
||||
KIS_APP_SECRET="x",
|
||||
KIS_APP_KEY="k",
|
||||
KIS_APP_SECRET="s",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="x",
|
||||
PAPER_OVERSEAS_CASH=0.0,
|
||||
GEMINI_API_KEY="g",
|
||||
)
|
||||
assert settings.PAPER_OVERSEAS_CASH == 0.0
|
||||
del os.environ["PAPER_OVERSEAS_CASH"]
|
||||
|
||||
Reference in New Issue
Block a user