Compare commits
9 Commits
feature/is
...
71ac59794e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ac59794e | ||
| be04820b00 | |||
| 10b6e34d44 | |||
| 58f1106dbd | |||
| cf5072cced | |||
|
|
702653e52e | ||
|
|
db0d966a6a | ||
|
|
eaf509a895 | ||
|
|
854931bed2 |
@@ -42,6 +42,7 @@ class MarketScanner:
|
|||||||
volatility_analyzer: VolatilityAnalyzer,
|
volatility_analyzer: VolatilityAnalyzer,
|
||||||
context_store: ContextStore,
|
context_store: ContextStore,
|
||||||
top_n: int = 5,
|
top_n: int = 5,
|
||||||
|
max_concurrent_scans: int = 1,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the market scanner.
|
"""Initialize the market scanner.
|
||||||
|
|
||||||
@@ -51,12 +52,14 @@ class MarketScanner:
|
|||||||
volatility_analyzer: Volatility analyzer instance
|
volatility_analyzer: Volatility analyzer instance
|
||||||
context_store: Context store for L7 real-time data
|
context_store: Context store for L7 real-time data
|
||||||
top_n: Number of top movers to return per market (default 5)
|
top_n: Number of top movers to return per market (default 5)
|
||||||
|
max_concurrent_scans: Max concurrent stock scans (default 1, fully serialized)
|
||||||
"""
|
"""
|
||||||
self.broker = broker
|
self.broker = broker
|
||||||
self.overseas_broker = overseas_broker
|
self.overseas_broker = overseas_broker
|
||||||
self.analyzer = volatility_analyzer
|
self.analyzer = volatility_analyzer
|
||||||
self.context_store = context_store
|
self.context_store = context_store
|
||||||
self.top_n = top_n
|
self.top_n = top_n
|
||||||
|
self._scan_semaphore = asyncio.Semaphore(max_concurrent_scans)
|
||||||
|
|
||||||
async def scan_stock(
|
async def scan_stock(
|
||||||
self,
|
self,
|
||||||
@@ -83,8 +86,8 @@ class MarketScanner:
|
|||||||
# Convert to orderbook-like structure
|
# Convert to orderbook-like structure
|
||||||
orderbook = {
|
orderbook = {
|
||||||
"output1": {
|
"output1": {
|
||||||
"stck_prpr": price_data.get("output", {}).get("last", "0"),
|
"stck_prpr": price_data.get("output", {}).get("last", "0") or "0",
|
||||||
"acml_vol": price_data.get("output", {}).get("tvol", "0"),
|
"acml_vol": price_data.get("output", {}).get("tvol", "0") or "0",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,8 +142,12 @@ class MarketScanner:
|
|||||||
|
|
||||||
logger.info("Scanning %s market (%d stocks)", market.name, len(stock_codes))
|
logger.info("Scanning %s market (%d stocks)", market.name, len(stock_codes))
|
||||||
|
|
||||||
# Scan all stocks concurrently (with rate limiting handled by broker)
|
# Scan stocks with bounded concurrency to prevent API rate limit burst
|
||||||
tasks = [self.scan_stock(code, market) for code in stock_codes]
|
async def _bounded_scan(code: str) -> VolatilityMetrics | None:
|
||||||
|
async with self._scan_semaphore:
|
||||||
|
return await self.scan_stock(code, market)
|
||||||
|
|
||||||
|
tasks = [_bounded_scan(code) for code in stock_codes]
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
# Filter out failures and sort by momentum score
|
# Filter out failures and sort by momentum score
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class KISBroker:
|
|||||||
|
|
||||||
async def _get_hash_key(self, body: dict[str, Any]) -> str:
|
async def _get_hash_key(self, body: dict[str, Any]) -> str:
|
||||||
"""Request a hash key from KIS for POST request body signing."""
|
"""Request a hash key from KIS for POST request body signing."""
|
||||||
|
await self._rate_limiter.acquire()
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
url = f"{self._base_url}/uapi/hashkey"
|
url = f"{self._base_url}/uapi/hashkey"
|
||||||
headers = {
|
headers = {
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ class Settings(BaseSettings):
|
|||||||
DB_PATH: str = "data/trade_logs.db"
|
DB_PATH: str = "data/trade_logs.db"
|
||||||
|
|
||||||
# Rate Limiting (requests per second for KIS API)
|
# Rate Limiting (requests per second for KIS API)
|
||||||
# Reduced to 5.0 to avoid EGW00201 "초당 거래건수 초과" errors
|
# Conservative limit to avoid EGW00201 "초당 거래건수 초과" errors.
|
||||||
RATE_LIMIT_RPS: float = 5.0
|
# KIS API real limit is ~2 RPS; 2.0 provides maximum safety.
|
||||||
|
RATE_LIMIT_RPS: float = 2.0
|
||||||
|
|
||||||
# Trading mode
|
# Trading mode
|
||||||
MODE: str = Field(default="paper", pattern="^(paper|live)$")
|
MODE: str = Field(default="paper", pattern="^(paper|live)$")
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ async def run(settings: Settings) -> None:
|
|||||||
volatility_analyzer=volatility_analyzer,
|
volatility_analyzer=volatility_analyzer,
|
||||||
context_store=context_store,
|
context_store=context_store,
|
||||||
top_n=5,
|
top_n=5,
|
||||||
|
max_concurrent_scans=1, # Fully serialized to avoid EGW00201
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize latency control system
|
# Initialize latency control system
|
||||||
@@ -549,7 +550,9 @@ async def run(settings: Settings) -> None:
|
|||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass # Normal — timeout means it's time for next cycle
|
pass # Normal — timeout means it's time for next cycle
|
||||||
finally:
|
finally:
|
||||||
|
# Clean up resources
|
||||||
await broker.close()
|
await broker.close()
|
||||||
|
await telegram.close()
|
||||||
db_conn.close()
|
db_conn.close()
|
||||||
logger.info("The Ouroboros rests.")
|
logger.info("The Ouroboros rests.")
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,38 @@ class TestRateLimiter:
|
|||||||
await broker._rate_limiter.acquire()
|
await broker._rate_limiter.acquire()
|
||||||
await broker.close()
|
await broker.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_order_acquires_rate_limiter_twice(self, settings):
|
||||||
|
"""send_order must acquire rate limiter for both hash key and order call."""
|
||||||
|
broker = KISBroker(settings)
|
||||||
|
broker._access_token = "tok"
|
||||||
|
broker._token_expires_at = asyncio.get_event_loop().time() + 3600
|
||||||
|
|
||||||
|
# Mock hash key response
|
||||||
|
mock_hash_resp = AsyncMock()
|
||||||
|
mock_hash_resp.status = 200
|
||||||
|
mock_hash_resp.json = AsyncMock(return_value={"HASH": "abc123"})
|
||||||
|
mock_hash_resp.__aenter__ = AsyncMock(return_value=mock_hash_resp)
|
||||||
|
mock_hash_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
# Mock order response
|
||||||
|
mock_order_resp = AsyncMock()
|
||||||
|
mock_order_resp.status = 200
|
||||||
|
mock_order_resp.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order_resp.__aenter__ = AsyncMock(return_value=mock_order_resp)
|
||||||
|
mock_order_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post", side_effect=[mock_hash_resp, mock_order_resp]
|
||||||
|
):
|
||||||
|
with patch.object(
|
||||||
|
broker._rate_limiter, "acquire", new_callable=AsyncMock
|
||||||
|
) as mock_acquire:
|
||||||
|
await broker.send_order("005930", "BUY", 1, 50000)
|
||||||
|
assert mock_acquire.call_count == 2
|
||||||
|
|
||||||
|
await broker.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Hash Key Generation
|
# Hash Key Generation
|
||||||
@@ -240,3 +272,27 @@ class TestHashKey:
|
|||||||
assert len(hash_key) > 0
|
assert len(hash_key) > 0
|
||||||
|
|
||||||
await broker.close()
|
await broker.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hash_key_acquires_rate_limiter(self, settings):
|
||||||
|
"""_get_hash_key must go through the rate limiter to prevent burst."""
|
||||||
|
broker = KISBroker(settings)
|
||||||
|
broker._access_token = "tok"
|
||||||
|
broker._token_expires_at = asyncio.get_event_loop().time() + 3600
|
||||||
|
|
||||||
|
body = {"CANO": "12345678", "ACNT_PRDT_CD": "01"}
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"HASH": "abc123hash"})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
with patch.object(
|
||||||
|
broker._rate_limiter, "acquire", new_callable=AsyncMock
|
||||||
|
) as mock_acquire:
|
||||||
|
await broker._get_hash_key(body)
|
||||||
|
mock_acquire.assert_called_once()
|
||||||
|
|
||||||
|
await broker.close()
|
||||||
|
|||||||
@@ -430,6 +430,26 @@ class TestOverseasBalanceParsing:
|
|||||||
broker.get_overseas_balance = AsyncMock(return_value={"output2": []})
|
broker.get_overseas_balance = AsyncMock(return_value={"output2": []})
|
||||||
return broker
|
return broker
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseas_broker_with_empty_price(self) -> MagicMock:
|
||||||
|
"""Create mock overseas broker returning empty string for price."""
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": ""}} # Empty string
|
||||||
|
)
|
||||||
|
broker.get_overseas_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"frcr_evlu_tota": "10000.00",
|
||||||
|
"frcr_dncl_amt_2": "5000.00",
|
||||||
|
"frcr_buy_amt_smtl": "4500.00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return broker
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_domestic_broker(self) -> MagicMock:
|
def mock_domestic_broker(self) -> MagicMock:
|
||||||
"""Create minimal mock domestic broker."""
|
"""Create minimal mock domestic broker."""
|
||||||
@@ -595,3 +615,37 @@ class TestOverseasBalanceParsing:
|
|||||||
|
|
||||||
# Verify balance API was called
|
# Verify balance API was called
|
||||||
mock_overseas_broker_with_empty.get_overseas_balance.assert_called_once()
|
mock_overseas_broker_with_empty.get_overseas_balance.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_overseas_price_empty_string(
|
||||||
|
self,
|
||||||
|
mock_domestic_broker: MagicMock,
|
||||||
|
mock_overseas_broker_with_empty_price: MagicMock,
|
||||||
|
mock_brain_hold: MagicMock,
|
||||||
|
mock_risk: MagicMock,
|
||||||
|
mock_db: MagicMock,
|
||||||
|
mock_decision_logger: MagicMock,
|
||||||
|
mock_context_store: MagicMock,
|
||||||
|
mock_criticality_assessor: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
mock_overseas_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test overseas price parsing with empty string (issue #49)."""
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
# Should not raise ValueError, should default to 0.0
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_domestic_broker,
|
||||||
|
overseas_broker=mock_overseas_broker_with_empty_price,
|
||||||
|
brain=mock_brain_hold,
|
||||||
|
risk=mock_risk,
|
||||||
|
db_conn=mock_db,
|
||||||
|
decision_logger=mock_decision_logger,
|
||||||
|
context_store=mock_context_store,
|
||||||
|
criticality_assessor=mock_criticality_assessor,
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="AAPL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify price API was called
|
||||||
|
mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
@@ -338,6 +339,28 @@ class TestMarketScanner:
|
|||||||
assert metrics.stock_code == "AAPL"
|
assert metrics.stock_code == "AAPL"
|
||||||
assert metrics.current_price == 150.50
|
assert metrics.current_price == 150.50
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_stock_overseas_empty_price(
|
||||||
|
self,
|
||||||
|
scanner: MarketScanner,
|
||||||
|
mock_overseas_broker: OverseasBroker,
|
||||||
|
context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
"""Test scanning overseas stock with empty price string (issue #49)."""
|
||||||
|
mock_overseas_broker.get_overseas_price.return_value = {
|
||||||
|
"output": {
|
||||||
|
"last": "", # Empty string
|
||||||
|
"tvol": "", # Empty string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
market = MARKETS["US_NASDAQ"]
|
||||||
|
metrics = await scanner.scan_stock("AAPL", market)
|
||||||
|
|
||||||
|
assert metrics is not None
|
||||||
|
assert metrics.stock_code == "AAPL"
|
||||||
|
assert metrics.current_price == 0.0 # Should default to 0.0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_stock_error_handling(
|
async def test_scan_stock_error_handling(
|
||||||
self,
|
self,
|
||||||
@@ -509,3 +532,45 @@ class TestMarketScanner:
|
|||||||
new_additions = [code for code in updated if code not in current_watchlist]
|
new_additions = [code for code in updated if code not in current_watchlist]
|
||||||
assert len(new_additions) <= 1
|
assert len(new_additions) <= 1
|
||||||
assert len(updated) == len(current_watchlist)
|
assert len(updated) == len(current_watchlist)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_market_respects_concurrency_limit(
|
||||||
|
self,
|
||||||
|
mock_broker: KISBroker,
|
||||||
|
mock_overseas_broker: OverseasBroker,
|
||||||
|
volatility_analyzer: VolatilityAnalyzer,
|
||||||
|
context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
"""scan_market should limit concurrent scans to max_concurrent_scans."""
|
||||||
|
max_concurrent = 2
|
||||||
|
scanner = MarketScanner(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
volatility_analyzer=volatility_analyzer,
|
||||||
|
context_store=context_store,
|
||||||
|
top_n=5,
|
||||||
|
max_concurrent_scans=max_concurrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track peak concurrency
|
||||||
|
active_count = 0
|
||||||
|
peak_count = 0
|
||||||
|
|
||||||
|
original_scan = scanner.scan_stock
|
||||||
|
|
||||||
|
async def tracking_scan(code: str, market: Any) -> VolatilityMetrics:
|
||||||
|
nonlocal active_count, peak_count
|
||||||
|
active_count += 1
|
||||||
|
peak_count = max(peak_count, active_count)
|
||||||
|
await asyncio.sleep(0.05) # Simulate API call duration
|
||||||
|
active_count -= 1
|
||||||
|
return VolatilityMetrics(code, 50000, 500, 1.0, 1.0, 1.0, 1.0, 10.0, 50.0)
|
||||||
|
|
||||||
|
scanner.scan_stock = tracking_scan # type: ignore[method-assign]
|
||||||
|
|
||||||
|
market = MARKETS["KR"]
|
||||||
|
stock_codes = ["001", "002", "003", "004", "005", "006"]
|
||||||
|
|
||||||
|
await scanner.scan_market(market, stock_codes)
|
||||||
|
|
||||||
|
assert peak_count <= max_concurrent
|
||||||
|
|||||||
Reference in New Issue
Block a user