Merge pull request 'fix: KR session-aware exchange routing (#409)' (#411) from feature/issue-409-kr-session-exchange-routing into main
All checks were successful
Gitea CI / test (push) Successful in 36s
All checks were successful
Gitea CI / test (push) Successful in 36s
Reviewed-on: #411 Reviewed-by: jihoson <kiparang7th@gmail.com>
This commit was merged in pull request #411.
This commit is contained in:
@@ -400,6 +400,15 @@ class TestFetchMarketRankings:
|
||||
assert result[0]["stock_code"] == "015260"
|
||||
assert result[0]["change_rate"] == 29.74
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_volume_uses_nx_market_code_in_nxt_session(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", session_id="NXT_PRE")
|
||||
|
||||
params = mock_get.call_args[1].get("params", {})
|
||||
assert params.get("FID_COND_MRKT_DIV_CODE") == "NX"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KRX tick unit / round-down helpers (issue #157)
|
||||
@@ -591,6 +600,60 @@ class TestSendOrderTickRounding:
|
||||
body = order_call[1].get("json", {})
|
||||
assert body["ORD_DVSN"] == "01"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_order_sets_exchange_field_from_session(self, broker: KISBroker) -> None:
|
||||
mock_hash = AsyncMock()
|
||||
mock_hash.status = 200
|
||||
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_order = AsyncMock()
|
||||
mock_order.status = 200
|
||||
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
|
||||
with patch.object(
|
||||
broker,
|
||||
"_load_dual_listing_metrics",
|
||||
new=AsyncMock(return_value=(False, None, None, None, None)),
|
||||
):
|
||||
await broker.send_order("005930", "BUY", 1, price=50000, session_id="NXT_PRE")
|
||||
|
||||
order_call = mock_post.call_args_list[1]
|
||||
body = order_call[1].get("json", {})
|
||||
assert body["EXCG_ID_DVSN_CD"] == "NXT"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_order_prefers_nxt_when_dual_listing_spread_is_tighter(
|
||||
self, broker: KISBroker
|
||||
) -> None:
|
||||
mock_hash = AsyncMock()
|
||||
mock_hash.status = 200
|
||||
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_order = AsyncMock()
|
||||
mock_order.status = 200
|
||||
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
|
||||
with patch.object(
|
||||
broker,
|
||||
"_load_dual_listing_metrics",
|
||||
new=AsyncMock(return_value=(True, 0.004, 0.002, 100000.0, 90000.0)),
|
||||
):
|
||||
await broker.send_order("005930", "BUY", 1, price=50000, session_id="KRX_REG")
|
||||
|
||||
order_call = mock_post.call_args_list[1]
|
||||
body = order_call[1].get("json", {})
|
||||
assert body["EXCG_ID_DVSN_CD"] == "NXT"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TR_ID live/paper branching (issues #201, #202, #203)
|
||||
|
||||
40
tests/test_kr_exchange_router.py
Normal file
40
tests/test_kr_exchange_router.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.broker.kr_exchange_router import KRExchangeRouter
|
||||
|
||||
|
||||
def test_ranking_market_code_by_session() -> None:
|
||||
router = KRExchangeRouter()
|
||||
assert router.resolve_for_ranking("KRX_REG") == "J"
|
||||
assert router.resolve_for_ranking("NXT_PRE") == "NX"
|
||||
assert router.resolve_for_ranking("NXT_AFTER") == "NX"
|
||||
|
||||
|
||||
def test_order_exchange_falls_back_to_session_default_on_missing_data() -> None:
|
||||
router = KRExchangeRouter()
|
||||
resolved = router.resolve_for_order(
|
||||
stock_code="0001A0",
|
||||
session_id="NXT_PRE",
|
||||
is_dual_listed=True,
|
||||
spread_krx=None,
|
||||
spread_nxt=None,
|
||||
liquidity_krx=None,
|
||||
liquidity_nxt=None,
|
||||
)
|
||||
assert resolved.exchange_code == "NXT"
|
||||
assert resolved.reason == "fallback_data_unavailable"
|
||||
|
||||
|
||||
def test_order_exchange_uses_spread_preference_for_dual_listing() -> None:
|
||||
router = KRExchangeRouter()
|
||||
resolved = router.resolve_for_order(
|
||||
stock_code="0001A0",
|
||||
session_id="KRX_REG",
|
||||
is_dual_listed=True,
|
||||
spread_krx=0.005,
|
||||
spread_nxt=0.003,
|
||||
liquidity_krx=100000.0,
|
||||
liquidity_nxt=90000.0,
|
||||
)
|
||||
assert resolved.exchange_code == "NXT"
|
||||
assert resolved.reason == "dual_listing_spread"
|
||||
@@ -103,6 +103,33 @@ class TestSmartVolatilityScanner:
|
||||
assert candidates[0].stock_code == "005930"
|
||||
assert candidates[0].signal == "oversold"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_domestic_passes_session_id_to_rankings(
|
||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||
) -> None:
|
||||
fluctuation_rows = [
|
||||
{
|
||||
"stock_code": "005930",
|
||||
"name": "Samsung",
|
||||
"price": 70000,
|
||||
"volume": 5000000,
|
||||
"change_rate": 1.0,
|
||||
"volume_increase_rate": 120,
|
||||
},
|
||||
]
|
||||
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, fluctuation_rows]
|
||||
mock_broker.get_daily_prices.return_value = [
|
||||
{"open": 1, "high": 71000, "low": 69000, "close": 70000, "volume": 1000000},
|
||||
{"open": 1, "high": 70000, "low": 68000, "close": 69000, "volume": 900000},
|
||||
]
|
||||
|
||||
await scanner.scan(domestic_session_id="NXT_PRE")
|
||||
|
||||
first_call = mock_broker.fetch_market_rankings.call_args_list[0]
|
||||
second_call = mock_broker.fetch_market_rankings.call_args_list[1]
|
||||
assert first_call.kwargs["session_id"] == "NXT_PRE"
|
||||
assert second_call.kwargs["session_id"] == "NXT_PRE"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_domestic_finds_momentum_candidate(
|
||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||
|
||||
Reference in New Issue
Block a user