fix: price API exchange code mapping and VTS overseas balance fallback (#147)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
- Apply _PRICE_EXCHANGE_MAP in get_overseas_price() to send short codes (NASD→NAS, NYSE→NYS, AMEX→AMS) required by HHDFS00000300 price API - Add PAPER_OVERSEAS_CASH config setting (default $50,000) for simulated USD balance when VTS overseas balance API returns 0 in paper mode - Fall back to scan candidate price when live price API returns 0 - Both fixes together resolve "no affordable quantity (cash=0, price=0)" which was preventing all overseas trade execution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,10 @@ _RANKING_EXCHANGE_MAP: dict[str, str] = {
|
|||||||
"TSE": "TSE",
|
"TSE": "TSE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Price inquiry API (HHDFS00000300) uses the same short exchange codes as rankings.
|
||||||
|
# NASD → NAS, NYSE → NYS, AMEX → AMS (confirmed: AMEX returns empty, AMS returns price).
|
||||||
|
_PRICE_EXCHANGE_MAP: dict[str, str] = _RANKING_EXCHANGE_MAP
|
||||||
|
|
||||||
|
|
||||||
class OverseasBroker:
|
class OverseasBroker:
|
||||||
"""KIS Overseas Stock API wrapper that reuses KISBroker infrastructure."""
|
"""KIS Overseas Stock API wrapper that reuses KISBroker infrastructure."""
|
||||||
@@ -58,9 +62,11 @@ class OverseasBroker:
|
|||||||
session = self._broker._get_session()
|
session = self._broker._get_session()
|
||||||
|
|
||||||
headers = await self._broker._auth_headers("HHDFS00000300")
|
headers = await self._broker._auth_headers("HHDFS00000300")
|
||||||
|
# Map internal exchange codes to the short form expected by the price API.
|
||||||
|
price_excd = _PRICE_EXCHANGE_MAP.get(exchange_code, exchange_code)
|
||||||
params = {
|
params = {
|
||||||
"AUTH": "",
|
"AUTH": "",
|
||||||
"EXCD": exchange_code,
|
"EXCD": price_excd,
|
||||||
"SYMB": stock_code,
|
"SYMB": stock_code,
|
||||||
}
|
}
|
||||||
url = f"{self._broker._base_url}/uapi/overseas-price/v1/quotations/price"
|
url = f"{self._broker._base_url}/uapi/overseas-price/v1/quotations/price"
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ class Settings(BaseSettings):
|
|||||||
# Trading mode
|
# Trading mode
|
||||||
MODE: str = Field(default="paper", pattern="^(paper|live)$")
|
MODE: str = Field(default="paper", pattern="^(paper|live)$")
|
||||||
|
|
||||||
|
# Simulated USD cash for VTS (paper) overseas trading.
|
||||||
|
# KIS VTS overseas balance API returns errors for most accounts.
|
||||||
|
# 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)
|
||||||
|
|
||||||
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
|
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
|
||||||
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
|
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
|
||||||
DAILY_SESSIONS: int = Field(default=4, ge=1, le=10)
|
DAILY_SESSIONS: int = Field(default=4, ge=1, le=10)
|
||||||
|
|||||||
37
src/main.py
37
src/main.py
@@ -239,10 +239,33 @@ async def trading_cycle(
|
|||||||
total_cash = safe_float(balance_info.get("frcr_dncl_amt_2", "0") or "0")
|
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")
|
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.
|
||||||
|
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,
|
||||||
|
settings.PAPER_OVERSEAS_CASH,
|
||||||
|
)
|
||||||
|
total_cash = settings.PAPER_OVERSEAS_CASH
|
||||||
|
|
||||||
current_price = safe_float(price_data.get("output", {}).get("last", "0"))
|
current_price = safe_float(price_data.get("output", {}).get("last", "0"))
|
||||||
foreigner_net = 0.0 # Not available for overseas
|
foreigner_net = 0.0 # Not available for overseas
|
||||||
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
|
price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0"))
|
||||||
|
|
||||||
|
# Price API may return 0/empty for certain VTS exchange codes.
|
||||||
|
# Fall back to the scanner candidate's price so order sizing still works.
|
||||||
|
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:
|
||||||
|
current_price = cand_lookup.price
|
||||||
|
logger.debug(
|
||||||
|
"Price API returned 0 for %s; using scanner price %.4f",
|
||||||
|
stock_code,
|
||||||
|
current_price,
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate daily P&L %
|
# Calculate daily P&L %
|
||||||
pnl_pct = (
|
pnl_pct = (
|
||||||
((total_eval - purchase_total) / purchase_total * 100)
|
((total_eval - purchase_total) / purchase_total * 100)
|
||||||
@@ -692,6 +715,16 @@ async def run_daily_session(
|
|||||||
price_change_pct = safe_float(
|
price_change_pct = safe_float(
|
||||||
price_data.get("output", {}).get("rate", "0")
|
price_data.get("output", {}).get("rate", "0")
|
||||||
)
|
)
|
||||||
|
# Fall back to scanner candidate price if API returns 0.
|
||||||
|
if current_price <= 0:
|
||||||
|
cand_lookup = candidate_map.get(stock_code)
|
||||||
|
if cand_lookup and cand_lookup.price > 0:
|
||||||
|
current_price = cand_lookup.price
|
||||||
|
logger.debug(
|
||||||
|
"Price API returned 0 for %s; using scanner price %.4f",
|
||||||
|
stock_code,
|
||||||
|
current_price,
|
||||||
|
)
|
||||||
|
|
||||||
stock_data: dict[str, Any] = {
|
stock_data: dict[str, Any] = {
|
||||||
"stock_code": stock_code,
|
"stock_code": stock_code,
|
||||||
@@ -743,6 +776,10 @@ async def run_daily_session(
|
|||||||
balance_info.get("frcr_buy_amt_smtl", "0") or "0"
|
balance_info.get("frcr_buy_amt_smtl", "0") or "0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# VTS overseas balance API often returns 0; use paper fallback.
|
||||||
|
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
|
||||||
|
total_cash = settings.PAPER_OVERSEAS_CASH
|
||||||
|
|
||||||
# Calculate daily P&L %
|
# Calculate daily P&L %
|
||||||
pnl_pct = (
|
pnl_pct = (
|
||||||
((total_eval - purchase_total) / purchase_total * 100)
|
((total_eval - purchase_total) / purchase_total * 100)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import aiohttp
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.broker.kis_api import KISBroker
|
from src.broker.kis_api import KISBroker
|
||||||
from src.broker.overseas import OverseasBroker, _RANKING_EXCHANGE_MAP
|
from src.broker.overseas import OverseasBroker, _PRICE_EXCHANGE_MAP, _RANKING_EXCHANGE_MAP
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -302,7 +302,8 @@ class TestGetOverseasPrice:
|
|||||||
|
|
||||||
call_args = mock_session.get.call_args
|
call_args = mock_session.get.call_args
|
||||||
params = call_args[1]["params"]
|
params = call_args[1]["params"]
|
||||||
assert params["EXCD"] == "NASD"
|
# NASD is mapped to NAS for the price inquiry API (same as ranking API).
|
||||||
|
assert params["EXCD"] == "NAS"
|
||||||
assert params["SYMB"] == "AAPL"
|
assert params["SYMB"] == "AAPL"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -519,3 +520,98 @@ class TestExtractRankingRows:
|
|||||||
def test_filters_non_dict_rows(self, overseas_broker: OverseasBroker) -> None:
|
def test_filters_non_dict_rows(self, overseas_broker: OverseasBroker) -> None:
|
||||||
data = {"output": [{"a": 1}, "invalid", {"b": 2}]}
|
data = {"output": [{"a": 1}, "invalid", {"b": 2}]}
|
||||||
assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]
|
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."""
|
||||||
|
|
||||||
|
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.asyncio
|
||||||
|
async def test_get_overseas_price_uses_mapped_excd(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""AMEX should be sent as AMS to the price API."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output": {"last": "44.30"}})
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
params = mock_session.get.call_args[1]["params"]
|
||||||
|
assert params["EXCD"] == "NAS"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PAPER_OVERSEAS_CASH config default
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaperOverseasCash:
|
||||||
|
def test_default_value(self) -> None:
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="x",
|
||||||
|
KIS_APP_SECRET="x",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="x",
|
||||||
|
)
|
||||||
|
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")
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="x",
|
||||||
|
KIS_APP_SECRET="x",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="x",
|
||||||
|
)
|
||||||
|
assert settings.PAPER_OVERSEAS_CASH == 100000.0
|
||||||
|
|
||||||
|
def test_zero_disables_fallback(self) -> None:
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="x",
|
||||||
|
KIS_APP_SECRET="x",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="x",
|
||||||
|
PAPER_OVERSEAS_CASH=0.0,
|
||||||
|
)
|
||||||
|
assert settings.PAPER_OVERSEAS_CASH == 0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user