Compare commits
5 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e550f8b58 | ||
| c76e2dfed5 | |||
|
|
24fa22e77b | ||
| cd1579058c | |||
| 45b48fa7cd |
@@ -304,25 +304,45 @@ class KISBroker:
|
|||||||
await self._rate_limiter.acquire()
|
await self._rate_limiter.acquire()
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
|
|
||||||
# TR_ID for volume ranking
|
if ranking_type == "volume":
|
||||||
tr_id = "FHPST01710000" if ranking_type == "volume" else "FHPST01710100"
|
# 거래량순위: FHPST01710000 / /quotations/volume-rank
|
||||||
headers = await self._auth_headers(tr_id)
|
tr_id = "FHPST01710000"
|
||||||
|
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||||
params = {
|
params: dict[str, str] = {
|
||||||
"FID_COND_MRKT_DIV_CODE": "J", # Stock/ETF/ETN
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
"FID_COND_SCR_DIV_CODE": "20001", # Volume surge
|
"FID_COND_SCR_DIV_CODE": "20171",
|
||||||
"FID_INPUT_ISCD": "0000", # All stocks
|
"FID_INPUT_ISCD": "0000",
|
||||||
"FID_DIV_CLS_CODE": "0", # All types
|
"FID_DIV_CLS_CODE": "0",
|
||||||
"FID_BLNG_CLS_CODE": "0",
|
"FID_BLNG_CLS_CODE": "0",
|
||||||
"FID_TRGT_CLS_CODE": "111111111",
|
"FID_TRGT_CLS_CODE": "111111111",
|
||||||
"FID_TRGT_EXLS_CLS_CODE": "000000",
|
"FID_TRGT_EXLS_CLS_CODE": "0000000000",
|
||||||
"FID_INPUT_PRICE_1": "0",
|
"FID_INPUT_PRICE_1": "0",
|
||||||
"FID_INPUT_PRICE_2": "0",
|
"FID_INPUT_PRICE_2": "0",
|
||||||
"FID_VOL_CNT": "0",
|
"FID_VOL_CNT": "0",
|
||||||
"FID_INPUT_DATE_1": "",
|
"FID_INPUT_DATE_1": "",
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
# 등락률순위: FHPST01700000 / /ranking/fluctuation (소문자 파라미터)
|
||||||
|
tr_id = "FHPST01700000"
|
||||||
|
url = f"{self._base_url}/uapi/domestic-stock/v1/ranking/fluctuation"
|
||||||
|
params = {
|
||||||
|
"fid_cond_mrkt_div_code": "J",
|
||||||
|
"fid_cond_scr_div_code": "20170",
|
||||||
|
"fid_input_iscd": "0000",
|
||||||
|
"fid_rank_sort_cls_code": "0000",
|
||||||
|
"fid_input_cnt_1": str(limit),
|
||||||
|
"fid_prc_cls_code": "0",
|
||||||
|
"fid_input_price_1": "0",
|
||||||
|
"fid_input_price_2": "0",
|
||||||
|
"fid_vol_cnt": "0",
|
||||||
|
"fid_trgt_cls_code": "0",
|
||||||
|
"fid_trgt_exls_cls_code": "0",
|
||||||
|
"fid_div_cls_code": "0",
|
||||||
|
"fid_rsfl_rate1": "0",
|
||||||
|
"fid_rsfl_rate2": "0",
|
||||||
|
}
|
||||||
|
|
||||||
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
headers = await self._auth_headers(tr_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session.get(url, headers=headers, params=params) as resp:
|
async with session.get(url, headers=headers, params=params) as resp:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
87
src/main.py
87
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")
|
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,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -296,3 +296,82 @@ class TestHashKey:
|
|||||||
mock_acquire.assert_called_once()
|
mock_acquire.assert_called_once()
|
||||||
|
|
||||||
await broker.close()
|
await broker.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_market_rankings — TR_ID, path, params (issue #155)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ranking_mock(items: list[dict]) -> AsyncMock:
|
||||||
|
"""Build a mock HTTP response returning ranking items."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output": items})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchMarketRankings:
|
||||||
|
"""Verify correct TR_ID, API path, and params per ranking_type (issue #155)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def broker(self, settings) -> KISBroker:
|
||||||
|
b = KISBroker(settings)
|
||||||
|
b._access_token = "tok"
|
||||||
|
b._token_expires_at = float("inf")
|
||||||
|
b._rate_limiter.acquire = AsyncMock()
|
||||||
|
return b
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_volume_uses_correct_tr_id_and_path(self, broker: KISBroker) -> None:
|
||||||
|
mock_resp = _make_ranking_mock([])
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
||||||
|
await broker.fetch_market_rankings(ranking_type="volume")
|
||||||
|
|
||||||
|
call_kwargs = mock_get.call_args
|
||||||
|
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
|
||||||
|
headers = call_kwargs[1].get("headers", {})
|
||||||
|
params = call_kwargs[1].get("params", {})
|
||||||
|
|
||||||
|
assert "volume-rank" in url
|
||||||
|
assert headers.get("tr_id") == "FHPST01710000"
|
||||||
|
assert params.get("FID_COND_SCR_DIV_CODE") == "20171"
|
||||||
|
assert params.get("FID_TRGT_EXLS_CLS_CODE") == "0000000000"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fluctuation_uses_correct_tr_id_and_path(self, broker: KISBroker) -> None:
|
||||||
|
mock_resp = _make_ranking_mock([])
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
||||||
|
await broker.fetch_market_rankings(ranking_type="fluctuation")
|
||||||
|
|
||||||
|
call_kwargs = mock_get.call_args
|
||||||
|
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
|
||||||
|
headers = call_kwargs[1].get("headers", {})
|
||||||
|
params = call_kwargs[1].get("params", {})
|
||||||
|
|
||||||
|
assert "ranking/fluctuation" in url
|
||||||
|
assert headers.get("tr_id") == "FHPST01700000"
|
||||||
|
assert params.get("fid_cond_scr_div_code") == "20170"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"mksc_shrn_iscd": "005930",
|
||||||
|
"hts_kor_isnm": "삼성전자",
|
||||||
|
"stck_prpr": "75000",
|
||||||
|
"acml_vol": "10000000",
|
||||||
|
"prdy_ctrt": "2.5",
|
||||||
|
"vol_inrt": "150",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_resp = _make_ranking_mock(items)
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
|
||||||
|
result = await broker.fetch_market_rankings(ranking_type="volume")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["stock_code"] == "005930"
|
||||||
|
assert result[0]["price"] == 75000.0
|
||||||
|
assert result[0]["change_rate"] == 2.5
|
||||||
|
|||||||
@@ -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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user