fix: overseas order rt_cd check, limit price premium, paper cash fallback (#151)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
Three fixes for overseas stock trading failures:
1. Price API exchange code mapping:
- get_overseas_price() now applies _PRICE_EXCHANGE_MAP (NASD→NAS, NYSE→NYS, AMEX→AMS)
- Price API HHDFS00000300 requires short exchange codes same as ranking API
2. rt_cd check in send_overseas_order():
- Log WARNING (not INFO) when rt_cd != "0" (e.g., "주문가능금액이 부족합니다")
- Caller (main.py) checks rt_cd == "0" before calling log_trade()
- Prevents DB from recording failed orders as successful trades
3. Limit order price premium for BUY:
- BUY limit price = current_price * 1.005 (0.5% premium)
- SELL limit price = current_price (no premium)
- Improves fill probability: KIS VTS only accepts limit orders,
and last price is typically at or below ask
4. PAPER_OVERSEAS_CASH fallback (config + main.py):
- New setting: PAPER_OVERSEAS_CASH = 50000.0 (USD)
- When VTS overseas balance API fails/returns 0, use this as simulated cash
- Applied in both trading_cycle() and run_daily_session()
5. Candidate price fallback:
- If price API returns 0, use scanner candidate price as fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -805,13 +805,14 @@ class TestOverseasBalanceParsing:
|
||||
scan_candidates={},
|
||||
)
|
||||
|
||||
# Verify limit order was sent with actual price, not 0.0
|
||||
# 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]
|
||||
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."
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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