fix: overseas order rt_cd check, limit price premium, paper cash fallback (#151)
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:
agentson
2026-02-19 05:44:19 +09:00
parent cd1579058c
commit 24fa22e77b
4 changed files with 204 additions and 101 deletions

View File

@@ -257,6 +257,9 @@ class OverseasBroker:
f"send_overseas_order failed ({resp.status}): {text}" f"send_overseas_order failed ({resp.status}): {text}"
) )
data = await resp.json() data = await resp.json()
rt_cd = data.get("rt_cd", "")
msg1 = data.get("msg1", "")
if rt_cd == "0":
logger.info( logger.info(
"Overseas order submitted", "Overseas order submitted",
extra={ extra={
@@ -265,6 +268,16 @@ class OverseasBroker:
"action": order_type, "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 return data
except (TimeoutError, aiohttp.ClientError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError( raise ConnectionError(

View File

@@ -239,17 +239,27 @@ 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. # Paper mode fallback: VTS overseas balance API often fails for many accounts.
# Fall back to configured paper cash so BUY orders can be sized.
if total_cash <= 0 and settings and settings.PAPER_OVERSEAS_CASH > 0: if total_cash <= 0 and settings and settings.PAPER_OVERSEAS_CASH > 0:
logger.debug( logger.debug(
"Overseas cash balance is 0 for %s; using paper fallback %.2f", "Overseas cash balance is 0 for %s; using paper fallback %.2f USD",
stock_code, market.exchange_code,
settings.PAPER_OVERSEAS_CASH, settings.PAPER_OVERSEAS_CASH,
) )
total_cash = 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"))
# 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 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"))
@@ -497,6 +507,7 @@ async def trading_cycle(
raise # Re-raise to prevent trade raise # Re-raise to prevent trade
# 5. Send order # 5. Send order
order_succeeded = True
if market.is_domestic: if market.is_domestic:
result = await broker.send_order( result = await broker.send_order(
stock_code=stock_code, stock_code=stock_code,
@@ -505,16 +516,35 @@ async def trading_cycle(
price=0, # market order price=0, # market order
) )
else: 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( result = await overseas_broker.send_overseas_order(
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
stock_code=stock_code, stock_code=stock_code,
order_type=decision.action, order_type=decision.action,
quantity=quantity, quantity=quantity,
price=current_price, # limit order — KIS VTS rejects market orders 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")) logger.info("Order result: %s", result.get("msg1", "OK"))
# 5.5. Notify trade execution # 5.5. Notify trade execution (only on success)
if order_succeeded:
try: try:
await telegram.notify_trade_execution( await telegram.notify_trade_execution(
stock_code=stock_code, stock_code=stock_code,
@@ -527,7 +557,7 @@ async def trading_cycle(
except Exception as exc: except Exception as exc:
logger.warning("Telegram notification failed: %s", 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) buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
if buy_trade and buy_trade.get("price") is not None: if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"]) buy_price = float(buy_trade["price"])
@@ -539,7 +569,9 @@ async def trading_cycle(
accuracy=1 if trade_pnl > 0 else 0, 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 selection_context = None
if stock_code in market_candidates: if stock_code in market_candidates:
candidate = market_candidates[stock_code] candidate = market_candidates[stock_code]
@@ -711,6 +743,16 @@ async def run_daily_session(
current_price = safe_float( current_price = safe_float(
price_data.get("output", {}).get("last", "0") 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 foreigner_net = 0.0
price_change_pct = safe_float( price_change_pct = safe_float(
price_data.get("output", {}).get("rate", "0") price_data.get("output", {}).get("rate", "0")
@@ -775,6 +817,9 @@ async def run_daily_session(
purchase_total = safe_float( purchase_total = safe_float(
balance_info.get("frcr_buy_amt_smtl", "0") or "0" 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. # VTS overseas balance API often returns 0; use paper fallback.
if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0: if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0:
@@ -853,6 +898,7 @@ async def run_daily_session(
quantity = 0 quantity = 0
trade_price = stock_data["current_price"] trade_price = stock_data["current_price"]
trade_pnl = 0.0 trade_pnl = 0.0
order_succeeded = True
if decision.action in ("BUY", "SELL"): if decision.action in ("BUY", "SELL"):
quantity = _determine_order_quantity( quantity = _determine_order_quantity(
action=decision.action, action=decision.action,
@@ -905,6 +951,7 @@ async def run_daily_session(
raise raise
# Send order # Send order
order_succeeded = True
try: try:
if market.is_domestic: if market.is_domestic:
result = await broker.send_order( result = await broker.send_order(
@@ -914,16 +961,30 @@ async def run_daily_session(
price=0, # market order price=0, # market order
) )
else: 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( result = await overseas_broker.send_overseas_order(
exchange_code=market.exchange_code, exchange_code=market.exchange_code,
stock_code=stock_code, stock_code=stock_code,
order_type=decision.action, order_type=decision.action,
quantity=quantity, quantity=quantity,
price=stock_data["current_price"], # limit order — KIS VTS rejects market orders 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")) logger.info("Order result: %s", result.get("msg1", "OK"))
# Notify trade execution # Notify trade execution (only on success)
if order_succeeded:
try: try:
await telegram.notify_trade_execution( await telegram.notify_trade_execution(
stock_code=stock_code, stock_code=stock_code,
@@ -941,7 +1002,7 @@ async def run_daily_session(
) )
continue continue
if decision.action == "SELL": if decision.action == "SELL" and order_succeeded:
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code) buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
if buy_trade and buy_trade.get("price") is not None: if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"]) buy_price = float(buy_trade["price"])
@@ -953,7 +1014,9 @@ async def run_daily_session(
accuracy=1 if trade_pnl > 0 else 0, 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( log_trade(
conn=db_conn, conn=db_conn,
stock_code=stock_code, stock_code=stock_code,

View File

@@ -805,13 +805,14 @@ class TestOverseasBalanceParsing:
scan_candidates={}, 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() 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 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] sent_price = call_kwargs[1].get("price") or call_kwargs[0][4]
assert sent_price == 182.5, ( expected_price = round(182.5 * 1.005, 4) # 0.5% premium for BUY limit orders
f"Expected limit price 182.5 but got {sent_price}. " assert sent_price == expected_price, (
"KIS VTS only accepts limit orders for overseas paper trading." 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."
) )

View File

@@ -302,8 +302,7 @@ 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"]
# NASD is mapped to NAS for the price inquiry API (same as ranking API). assert params["EXCD"] == "NAS" # NASD → NAS via _PRICE_EXCHANGE_MAP
assert params["EXCD"] == "NAS"
assert params["SYMB"] == "AAPL" assert params["SYMB"] == "AAPL"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -522,58 +521,32 @@ class TestExtractRankingRows:
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: 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: def test_price_map_equals_ranking_map(self) -> None:
assert _PRICE_EXCHANGE_MAP is _RANKING_EXCHANGE_MAP assert _PRICE_EXCHANGE_MAP is _RANKING_EXCHANGE_MAP
def test_nasd_maps_to_nas(self) -> None: @pytest.mark.parametrize("original,expected", [
assert _PRICE_EXCHANGE_MAP["NASD"] == "NAS" ("NASD", "NAS"),
("NYSE", "NYS"),
def test_amex_maps_to_ams(self) -> None: ("AMEX", "AMS"),
assert _PRICE_EXCHANGE_MAP["AMEX"] == "AMS" ])
def test_us_exchange_code_mapping(self, original: str, expected: str) -> None:
def test_nyse_maps_to_nys(self) -> None: assert _PRICE_EXCHANGE_MAP[original] == expected
assert _PRICE_EXCHANGE_MAP["NYSE"] == "NYS"
@pytest.mark.asyncio @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 self, overseas_broker: OverseasBroker
) -> None: ) -> None:
"""AMEX should be sent as AMS to the price API.""" """NASD → NAS must be sent to HHDFS00000300."""
mock_resp = AsyncMock() mock_resp = AsyncMock()
mock_resp.status = 200 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 = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp)) mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session) _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") await overseas_broker.get_overseas_price("NASD", "AAPL")
@@ -581,37 +554,90 @@ class TestPriceExchangeMap:
assert params["EXCD"] == "NAS" assert params["EXCD"] == "NAS"
# --------------------------------------------------------------------------- class TestOrderRtCdCheck:
# PAPER_OVERSEAS_CASH config default """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: class TestPaperOverseasCash:
"""Test PAPER_OVERSEAS_CASH config setting (issue #151)."""
def test_default_value(self) -> None: def test_default_value(self) -> None:
settings = Settings( settings = Settings(
KIS_APP_KEY="x", KIS_APP_KEY="k",
KIS_APP_SECRET="x", KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01", KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="x", GEMINI_API_KEY="g",
) )
assert settings.PAPER_OVERSEAS_CASH == 50000.0 assert settings.PAPER_OVERSEAS_CASH == 50000.0
def test_can_be_set_via_env(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_env_override(self) -> None:
monkeypatch.setenv("PAPER_OVERSEAS_CASH", "100000.0") import os
os.environ["PAPER_OVERSEAS_CASH"] = "25000"
settings = Settings( settings = Settings(
KIS_APP_KEY="x", KIS_APP_KEY="k",
KIS_APP_SECRET="x", KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01", 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: def test_zero_disables_fallback(self) -> None:
import os
os.environ["PAPER_OVERSEAS_CASH"] = "0"
settings = Settings( settings = Settings(
KIS_APP_KEY="x", KIS_APP_KEY="k",
KIS_APP_SECRET="x", KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01", KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="x", GEMINI_API_KEY="g",
PAPER_OVERSEAS_CASH=0.0,
) )
assert settings.PAPER_OVERSEAS_CASH == 0.0 assert settings.PAPER_OVERSEAS_CASH == 0.0
del os.environ["PAPER_OVERSEAS_CASH"]