- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강 - trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성) - korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계) - swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소) - swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램 - daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거 - main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC) - 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경) - 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기 - CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기) - 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
597 lines
26 KiB
Python
597 lines
26 KiB
Python
"""2026-10-09 실시간 스트림·차트 감사 결함 회귀 (H-3/H-4/H-5/H-6, M-1/M-5/M-7, L-3/L-4/L-5/L-6/L-9).
|
|
|
|
외부 호출 없음: KIS WS 는 가짜 connect, KIS REST·네이버·Redis·DB 는 전부 목.
|
|
"""
|
|
import asyncio
|
|
from datetime import datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
import pytz
|
|
|
|
from app.api.endpoints import market_data as md
|
|
from app.services import kis_realtime_stream as krs
|
|
from app.services.websocket_manager import WebSocketManager
|
|
|
|
KST = pytz.timezone("Asia/Seoul")
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
class _FakeKisWS:
|
|
"""KIS WS 대역: 주어진 프레임을 내보낸 뒤 정상 종료(ConnectionClosedOK 와 동일하게 iteration 종료)."""
|
|
|
|
def __init__(self, frames=()):
|
|
self.frames = list(frames)
|
|
self.sent = []
|
|
|
|
async def send(self, msg):
|
|
self.sent.append(msg)
|
|
|
|
def __aiter__(self):
|
|
return self._gen()
|
|
|
|
async def _gen(self):
|
|
for f in self.frames:
|
|
await asyncio.sleep(0)
|
|
yield f
|
|
|
|
|
|
class _FakeManager:
|
|
def __init__(self, subscribed=(), selected=()):
|
|
self.subscribed = set(subscribed)
|
|
self.selected = set(selected)
|
|
self.updates = []
|
|
|
|
def get_all_subscribed_codes(self):
|
|
return set(self.subscribed)
|
|
|
|
def get_selected_codes_v2(self):
|
|
return set(self.selected)
|
|
|
|
async def send_market_update(self, code, data):
|
|
self.updates.append((code, data))
|
|
|
|
|
|
def _stream(manager):
|
|
s = krs.KisRealtimeStream()
|
|
s._approval_key = "k"
|
|
return s, patch.object(krs, "websocket_manager", manager)
|
|
|
|
|
|
def _parse_sent(sent):
|
|
import json
|
|
out = []
|
|
for m in sent:
|
|
j = json.loads(m)
|
|
out.append((j["header"]["tr_type"], j["body"]["input"]["tr_id"], j["body"]["input"]["tr_key"]))
|
|
return out
|
|
|
|
|
|
async def _no_sleep(*_a, **_k):
|
|
return None
|
|
|
|
|
|
# ── M-1 다건 체결 프레임 ─────────────────────────────────────────────────────
|
|
def _tick_record(code, price, vol):
|
|
f = ["0"] * 46
|
|
f[0], f[1], f[2], f[5] = code, "101500", str(price), "1.5"
|
|
f[7], f[8], f[9] = "100", "110", "95"
|
|
f[13], f[14] = str(vol), "999"
|
|
return f
|
|
|
|
|
|
def test_multi_record_tick_uses_last_record():
|
|
body = "^".join(_tick_record("005930", 101, 10) + _tick_record("005930", 105, 20))
|
|
parsed = krs._parse_realtime_message(f"0|H0STCNT0|002|{body}")
|
|
assert parsed["price"] == 105.0 and parsed["volume"] == 20
|
|
assert parsed["open"] == 100.0 and parsed["high"] == 110.0 and parsed["low"] == 95.0
|
|
|
|
|
|
def test_single_record_tick_unchanged():
|
|
body = "^".join(_tick_record("000660", 200, 7))
|
|
parsed = krs._parse_realtime_message(f"0|H0STCNT0|001|{body}")
|
|
assert parsed["stock_code"] == "000660" and parsed["price"] == 200.0 and parsed["volume"] == 7
|
|
|
|
|
|
# ── H-4 구독 한도·우선순위 ───────────────────────────────────────────────────
|
|
def test_target_registrations_priority_quote_only_selected_and_no_us():
|
|
targets, dropped = krs._build_target_registrations(
|
|
{"005930", "000660", "AAPL", "BRK.B", "0004V0"}, {"000660"}, 41)
|
|
assert targets[0] == (krs.TR_TICK, "000660") and targets[1] == (krs.TR_QUOTE, "000660")
|
|
quotes = [c for tr, c in targets if tr == krs.TR_QUOTE]
|
|
assert quotes == ["000660"]
|
|
codes = {c for _, c in targets}
|
|
assert "AAPL" not in codes and "BRK.B" not in codes and "0004V0" in codes
|
|
assert dropped == []
|
|
|
|
|
|
def test_target_registrations_budget_cut_keeps_selected():
|
|
holdings = {f"{i:06d}" for i in range(1, 60)}
|
|
targets, dropped = krs._build_target_registrations(holdings | {"999999"}, {"999999"}, 41)
|
|
assert len(targets) == 41 and len(dropped) == 20 # 선택 체결+호가 2 + 보유 59 = 61 → 41 유지
|
|
assert targets[:2] == [(krs.TR_TICK, "999999"), (krs.TR_QUOTE, "999999")]
|
|
|
|
|
|
def test_sync_sends_unsubscribe_before_subscribe():
|
|
mgr = _FakeManager(subscribed={"000660"}, selected=set())
|
|
s, p = _stream(mgr)
|
|
s._registered = {(krs.TR_TICK, "005930"), (krs.TR_QUOTE, "005930")}
|
|
ws = _FakeKisWS()
|
|
with p, patch.object(krs.asyncio, "sleep", _no_sleep):
|
|
_run(s._sync_subscriptions(ws))
|
|
sent = _parse_sent(ws.sent)
|
|
assert [t for t, _, _ in sent] == ["2", "2", "1"]
|
|
assert sent[-1] == ("1", krs.TR_TICK, "000660")
|
|
assert s._registered == {(krs.TR_TICK, "000660")} and s._subscribed_codes == {"000660"}
|
|
|
|
|
|
def test_ack_failure_removes_registration_and_backs_off():
|
|
mgr = _FakeManager(subscribed={"005930"})
|
|
s, p = _stream(mgr)
|
|
ws = _FakeKisWS()
|
|
with p, patch.object(krs.asyncio, "sleep", _no_sleep):
|
|
_run(s._sync_subscriptions(ws))
|
|
assert (krs.TR_TICK, "005930") in s._registered
|
|
s._handle_ack({"header": {"tr_id": krs.TR_TICK, "tr_key": "005930"},
|
|
"body": {"rt_cd": "1", "msg_cd": "OPSP0008", "msg1": "MAX SUBSCRIBE OVER"}})
|
|
assert (krs.TR_TICK, "005930") not in s._registered
|
|
assert s._subscribed_codes == set()
|
|
ws.sent.clear()
|
|
_run(s._sync_subscriptions(ws)) # 백오프 중 — 재전송 안 함
|
|
assert ws.sent == []
|
|
s._ack_fail_until[(krs.TR_TICK, "005930")] = 0.0 # 백오프 만료
|
|
_run(s._sync_subscriptions(ws))
|
|
assert _parse_sent(ws.sent) == [("1", krs.TR_TICK, "005930")]
|
|
|
|
|
|
def test_ack_already_subscribed_is_success():
|
|
s, _ = _stream(_FakeManager())
|
|
s._registered = {(krs.TR_TICK, "005930")}
|
|
s._handle_ack({"header": {"tr_id": krs.TR_TICK, "tr_key": "005930"},
|
|
"body": {"rt_cd": "1", "msg1": "ALREADY IN SUBSCRIBE"}})
|
|
assert (krs.TR_TICK, "005930") in s._registered
|
|
|
|
|
|
# ── H-3 정상 close 후 재구독 / H-5 별도 동기화 태스크 ──────────────────────────
|
|
def test_normal_close_clears_state_and_resubscribes_on_reconnect():
|
|
mgr = _FakeManager(subscribed={"005930"})
|
|
s, p = _stream(mgr)
|
|
sessions = []
|
|
|
|
class _Ctx:
|
|
def __init__(self, ws):
|
|
self.ws = ws
|
|
|
|
async def __aenter__(self):
|
|
return self.ws
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
def _connect(url, **kwargs):
|
|
assert krs._ws_header_kwarg_name() in kwargs
|
|
ws = _FakeKisWS(frames=['{"header":{"tr_id":"PINGPONG"}}'])
|
|
sessions.append(ws)
|
|
if len(sessions) >= 2:
|
|
s._running = False
|
|
return _Ctx(ws)
|
|
|
|
async def _key_ok():
|
|
s._approval_key = "k"
|
|
return True
|
|
|
|
fake_ws_mod = SimpleNamespace(connect=_connect, __version__="15.0.1")
|
|
with p, patch.object(krs, "websockets", fake_ws_mod), \
|
|
patch.object(krs.asyncio, "sleep", _no_sleep), \
|
|
patch.object(s, "_ensure_approval_key", _key_ok):
|
|
_run(s.run())
|
|
assert len(sessions) == 2
|
|
for ws in sessions: # 두 번째 세션도 처음부터 구독 메시지를 보냈다
|
|
assert ("1", krs.TR_TICK, "005930") in _parse_sent([m for m in ws.sent if "approval_key" in m])
|
|
assert s._registered == set() and s._subscribed_codes == set()
|
|
|
|
|
|
def test_sync_loop_wakes_on_notify_without_incoming_frames():
|
|
mgr = _FakeManager(subscribed=set())
|
|
s, p = _stream(mgr)
|
|
ws = _FakeKisWS()
|
|
|
|
async def scenario():
|
|
task = asyncio.create_task(s._sync_loop(ws))
|
|
await asyncio.sleep(0)
|
|
mgr.subscribed = {"005930"}
|
|
s.notify_subscription_change()
|
|
for _ in range(50):
|
|
await asyncio.sleep(0)
|
|
if ws.sent:
|
|
break
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
with p, patch.object(krs, "_SYNC_INTERVAL", 3600):
|
|
_run(scenario())
|
|
assert _parse_sent(ws.sent) == [("1", krs.TR_TICK, "005930")]
|
|
|
|
|
|
# ── L-6 수신 루프는 큐잉만, 분배는 별도 ───────────────────────────────────────
|
|
def test_receive_path_only_enqueues_and_dispatch_coalesces():
|
|
mgr = _FakeManager(subscribed={"005930"})
|
|
s, p = _stream(mgr)
|
|
ws = _FakeKisWS()
|
|
cache = AsyncMock()
|
|
t1 = "0|H0STCNT0|001|" + "^".join(_tick_record("005930", 101, 1))
|
|
t2 = "0|H0STCNT0|001|" + "^".join(_tick_record("005930", 102, 2))
|
|
|
|
async def scenario():
|
|
await s._on_raw_message(ws, t1)
|
|
await s._on_raw_message(ws, t2)
|
|
assert cache.await_count == 0 and mgr.updates == [] # 수신 경로에서 Redis/전송 없음
|
|
n = await s._dispatch_once()
|
|
return n
|
|
|
|
with p, patch.object(krs, "_cache_realtime_data", cache):
|
|
n = _run(scenario())
|
|
assert n == 1 and len(mgr.updates) == 1 and mgr.updates[0][1]["price"] == 102.0
|
|
|
|
|
|
def test_dispatch_redis_timeout_does_not_block_send():
|
|
mgr = _FakeManager(subscribed={"005930"})
|
|
s, p = _stream(mgr)
|
|
|
|
async def slow_cache(*a, **k):
|
|
await asyncio.sleep(10)
|
|
|
|
with p, patch.object(krs, "_cache_realtime_data", slow_cache), \
|
|
patch.object(krs, "_REDIS_WRITE_TIMEOUT", 0.01):
|
|
_run(s._dispatch_one("tick", "005930", {"price": 1}))
|
|
assert mgr.updates == [("005930", {"price": 1})]
|
|
|
|
|
|
# ── L-9 websockets 버전별 헤더 인자 ───────────────────────────────────────────
|
|
def test_ws_header_kwarg_by_version():
|
|
with patch.object(krs, "websockets", SimpleNamespace(__version__="12.0")):
|
|
assert krs._ws_header_kwarg_name() == "extra_headers"
|
|
with patch.object(krs, "websockets", SimpleNamespace(__version__="15.0.1")):
|
|
assert krs._ws_header_kwarg_name() == "additional_headers"
|
|
|
|
|
|
# ── L-5 / L-6 websocket_manager 전송 ──────────────────────────────────────────
|
|
class _FakeClientWS:
|
|
def __init__(self, delay=0.0, fail=False):
|
|
self.delay, self.fail, self.sent = delay, fail, []
|
|
|
|
async def send_text(self, m):
|
|
if self.delay:
|
|
await asyncio.sleep(self.delay)
|
|
if self.fail:
|
|
raise RuntimeError("closed")
|
|
self.sent.append(m)
|
|
|
|
|
|
def test_market_update_without_subscribers_is_not_broadcast():
|
|
m = WebSocketManager()
|
|
a = _FakeClientWS()
|
|
m.active_connections["a"] = a
|
|
m._subscriptions["a"] = {"000660"}
|
|
_run(m.send_market_update("005930", {"price": 1}))
|
|
assert a.sent == []
|
|
|
|
|
|
def test_market_update_slow_client_times_out_and_others_receive():
|
|
from app.services import websocket_manager as wm
|
|
m = WebSocketManager()
|
|
slow, fast = _FakeClientWS(delay=10), _FakeClientWS()
|
|
m.active_connections.update({"slow": slow, "fast": fast})
|
|
m._subscriptions.update({"slow": {"005930"}, "fast": {"005930"}})
|
|
with patch.object(wm, "_MARKET_UPDATE_SEND_TIMEOUT", 0.01):
|
|
_run(m.send_market_update("005930", {"price": 1}))
|
|
assert len(fast.sent) == 1 and "slow" not in m.active_connections and "fast" in m.active_connections
|
|
|
|
|
|
def test_selected_codes_only_for_active_subscribed():
|
|
m = WebSocketManager()
|
|
m.active_connections["a"] = _FakeClientWS()
|
|
m._subscriptions["a"] = {"005930"}
|
|
m.set_selected_codes_v2("a", ["005930"])
|
|
m.set_selected_codes_v2("gone", ["000660"])
|
|
assert m.get_selected_codes_v2() == {"005930"}
|
|
m.disconnect("a")
|
|
assert m.get_selected_codes_v2() == set()
|
|
|
|
|
|
# ── H-6 샘플 난수 차트 금지 / 해외 차트 ──────────────────────────────────────
|
|
class _NaverCallBlocked(BaseException):
|
|
"""네이버 실호출 차단. BaseException 이라 market_data 의 `except Exception` 폴백에 삼켜지지 않는다."""
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_chart_cache():
|
|
md.chart_cache.clear()
|
|
# 파일 단위 안전망: 개별 테스트가 패치를 빠뜨려도 실제 네이버(fchart) 접속 불가.
|
|
# 개별 테스트의 patch(...) 는 이 목을 덮어쓴다.
|
|
with patch("app.services.naver_finance.get_naver_daily_chart",
|
|
AsyncMock(side_effect=_NaverCallBlocked("네이버 실호출 금지"))), \
|
|
patch("app.core.redis.setex_json", AsyncMock(return_value=True)):
|
|
yield
|
|
md.chart_cache.clear()
|
|
|
|
|
|
def test_no_data_returns_empty_with_reason_not_sample():
|
|
with patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=[])), \
|
|
patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=None)):
|
|
res = _run(md.get_chart_data_cached_v2("005930", "D", 200))
|
|
legacy = _run(md.get_chart_data_cached("005930", "D", 200))
|
|
assert res == {"data": [], "is_sample": False, "reason": "no_data", "source": None}
|
|
assert legacy == []
|
|
|
|
|
|
def test_overseas_ticker_uses_overseas_chart():
|
|
kis = SimpleNamespace(get_overseas_daily_chart=AsyncMock(return_value={"output2": [
|
|
{"xymd": "20261008", "open": "10", "high": "12", "low": "9", "clos": "11", "tvol": "100"},
|
|
{"xymd": "20261007", "open": "9", "high": "10", "low": "8", "clos": "10", "tvol": "50"},
|
|
{"xymd": "20261006", "open": "0", "high": "0", "low": "0", "clos": "0", "tvol": "0"},
|
|
]}), get_current_price=AsyncMock(side_effect=AssertionError("국내 경로 호출 금지")))
|
|
db = AsyncMock(side_effect=AssertionError("DB 호출 금지"))
|
|
with patch.object(md, "korea_investment_api", kis), patch.object(md, "_fetch_chart_from_db", db):
|
|
res = _run(md.get_chart_data_cached_v2("AAPL", "D", 200))
|
|
assert [b["date"] for b in res["data"]] == ["20261007", "20261008"]
|
|
assert res["reason"] is None and res["is_sample"] is False
|
|
|
|
|
|
def test_overseas_chart_failure_reason():
|
|
kis = SimpleNamespace(get_overseas_daily_chart=AsyncMock(side_effect=RuntimeError("down")))
|
|
with patch.object(md, "korea_investment_api", kis):
|
|
res = _run(md.get_chart_data_cached_v2("TSLA", "D", 100))
|
|
assert res["data"] == [] and res["reason"] == "overseas_chart_unavailable"
|
|
|
|
|
|
def test_overseas_ticker_detection():
|
|
assert md._is_overseas_ticker("AAPL") and md._is_overseas_ticker("BRK.B")
|
|
assert not md._is_overseas_ticker("005930") and not md._is_overseas_ticker("0004V0")
|
|
assert not md._is_overseas_ticker("")
|
|
|
|
|
|
def test_ws_chart_response_fields_and_bad_limit():
|
|
with patch.object(md, "get_chart_data_cached_v2",
|
|
AsyncMock(return_value={"data": [], "is_sample": False,
|
|
"reason": "no_data", "source": None})) as g:
|
|
resp = _run(md.build_ws_chart_response_v2("005930", "D", "abc"))
|
|
assert g.await_args.args[2] == 200 # L-3 변환 불가 → 기본값
|
|
assert resp == {"type": "chart_data", "stock_code": "005930", "period": "D", "data": [],
|
|
"is_sample": False, "reason": "no_data", "error": None}
|
|
with patch.object(md, "get_chart_data_cached_v2", AsyncMock(side_effect=RuntimeError("x"))):
|
|
resp = _run(md.build_ws_chart_response_v2("005930", "W", 50))
|
|
assert resp["data"] == [] and resp["reason"] == "chart_fetch_failed" and resp["error"] == "RuntimeError"
|
|
|
|
|
|
def test_parse_chart_limit_bounds():
|
|
assert md.parse_chart_limit_v2(None) == 200
|
|
assert md.parse_chart_limit_v2("9999") == 240
|
|
assert md.parse_chart_limit_v2(-5) == 1
|
|
assert md.parse_chart_limit_v2("50") == 50
|
|
|
|
|
|
# ── M-5 DB 0 OHLC 필터 ───────────────────────────────────────────────────────
|
|
def test_db_chart_filters_zero_ohlc_rows():
|
|
rows = [ # newest first
|
|
SimpleNamespace(trade_date=datetime(2026, 10, 8), open_price=10, high_price=12, low_price=9,
|
|
close_price=11, volume=5),
|
|
SimpleNamespace(trade_date=datetime(2026, 10, 7), open_price=0, high_price=0, low_price=0,
|
|
close_price=0, volume=0),
|
|
SimpleNamespace(trade_date=datetime(2026, 10, 6), open_price=9, high_price=10, low_price=None,
|
|
close_price=9, volume=1),
|
|
SimpleNamespace(trade_date=datetime(2026, 10, 5), open_price=8, high_price=9, low_price=7,
|
|
close_price=8, volume=2),
|
|
]
|
|
|
|
class _Res:
|
|
def scalars(self):
|
|
return SimpleNamespace(all=lambda: rows)
|
|
|
|
class _Sess:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
async def execute(self, *_a, **_k):
|
|
return _Res()
|
|
|
|
def add(self, *_a):
|
|
raise AssertionError("쓰기 금지")
|
|
|
|
async def commit(self):
|
|
raise AssertionError("쓰기 금지")
|
|
|
|
with patch("app.core.database.AsyncSessionLocal", lambda: _Sess()):
|
|
out = _run(md._fetch_chart_from_db("005930", 10))
|
|
assert [b["date"] for b in out] == ["20261005", "20261008"]
|
|
|
|
|
|
# ── M-7 Redis tick 우선 / 캐시 ttl ───────────────────────────────────────────
|
|
def test_today_bar_prefers_redis_tick_over_kis():
|
|
now = KST.localize(datetime(2026, 10, 8, 11, 0))
|
|
db = [{"date": "20261007", "open": 1, "high": 1, "low": 1, "close": 1, "volume": 1}]
|
|
kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("KIS 호출 금지")))
|
|
tick = {"price": 44100.0, "open": 44500.0, "high": 44800.0, "low": 43900.0, "volume": 7777}
|
|
with patch.object(md, "korea_investment_api", kis), \
|
|
patch("app.services.naver_finance.get_naver_daily_chart",
|
|
AsyncMock(side_effect=AssertionError("네이버 실호출 금지"))), \
|
|
patch("app.core.redis.get_json", AsyncMock(return_value=tick)):
|
|
bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now))
|
|
assert live and bars[-1] == {"date": "20261008", "open": 44500.0, "high": 44800.0,
|
|
"low": 43900.0, "close": 44100.0, "volume": 7777}
|
|
|
|
|
|
def test_chart_never_calls_kis_when_tick_missing_or_incomplete():
|
|
"""R1: tick 이 없거나 OHLC 불완전해도 차트 경로는 KIS REST 를 호출하지 않는다(당일봉 없음)."""
|
|
now = KST.localize(datetime(2026, 10, 8, 11, 0))
|
|
kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("KIS 호출 금지")))
|
|
for tick in (None, {"price": 100.0}):
|
|
db = [{"date": "20261007", "open": 1, "high": 1, "low": 1, "close": 1, "volume": 1}]
|
|
with patch.object(md, "korea_investment_api", kis), \
|
|
patch("app.services.naver_finance.get_naver_daily_chart",
|
|
AsyncMock(return_value=None)), \
|
|
patch("app.core.redis.get_json", AsyncMock(return_value=tick)):
|
|
bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now))
|
|
assert live is False and bars[-1]["date"] == "20261007"
|
|
assert kis.get_current_price.await_count == 0
|
|
|
|
|
|
def test_chart_cache_fresh_respects_entry_ttl():
|
|
md.chart_cache["005930__D__200"] = {"timestamp": datetime.now() - timedelta(seconds=60),
|
|
"data": [], "ttl": 30}
|
|
assert md.is_chart_cache_fresh_v2("005930", "D", 200) is False
|
|
md.chart_cache["005930__D__200"]["ttl"] = 600
|
|
assert md.is_chart_cache_fresh_v2("005930", "D", 200) is True
|
|
|
|
|
|
# ── L-4 네이버 폴백 주/월봉 조회 기간 ─────────────────────────────────────────
|
|
def test_naver_fallback_weekly_requests_enough_days_and_keeps_all_rows():
|
|
daily = []
|
|
d = datetime(2025, 1, 6)
|
|
while len(daily) < 300:
|
|
if d.weekday() < 5:
|
|
daily.append({"stck_bsop_date": d.strftime("%Y%m%d"), "stck_oprc": "10", "stck_hgpr": "11",
|
|
"stck_lwpr": "9", "stck_clpr": "10", "acml_vol": "1"})
|
|
d += timedelta(days=1)
|
|
naver = AsyncMock(return_value=daily)
|
|
with patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=[])), \
|
|
patch("app.services.naver_finance.get_naver_daily_chart", naver), \
|
|
patch.object(md, "_merge_recent_daily_bars", AsyncMock(side_effect=lambda c, b: (b, False))):
|
|
res = _run(md.get_chart_data_cached_v2("005930", "W", 50))
|
|
assert naver.await_args_list[0].kwargs["days"] == 50 * 7
|
|
assert len(res["data"]) == 50 # 300 거래일 = 60주 → 최신 50주 (과거: 50일 → 10주)
|
|
|
|
|
|
def test_in_session_without_tick_uses_short_cache_ttl():
|
|
"""장중 tick 미도착(live=False)이어도 당일봉 없는 결과를 10분 캐시하지 않는다."""
|
|
bars = [{"date": "20250103", "open": 1, "high": 2, "low": 1, "close": 1, "volume": 1}]
|
|
naver = AsyncMock(return_value=None)
|
|
with patch("app.services.naver_finance.get_naver_daily_chart", naver), \
|
|
patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=list(bars))), \
|
|
patch.object(md, "_merge_recent_daily_bars", AsyncMock(side_effect=lambda c, b: (b, False))), \
|
|
patch.object(md, "_chart_session_state", lambda now: ("20250103", True)):
|
|
_run(md.get_chart_data_cached_v2("005930", "D", 200))
|
|
assert md.chart_cache["005930__D__200"]["ttl"] == md._CHART_LIVE_TTL
|
|
md.chart_cache.clear()
|
|
with patch("app.services.naver_finance.get_naver_daily_chart", naver), \
|
|
patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=list(bars))), \
|
|
patch.object(md, "_merge_recent_daily_bars", AsyncMock(side_effect=lambda c, b: (b, False))), \
|
|
patch.object(md, "_chart_session_state", lambda now: ("20250103", False)), \
|
|
patch.object(md, "_is_post_close_window", lambda now: False): # R9: 실제 시계 15:30~15:40 창 제거(시계 의존 flake)
|
|
_run(md.get_chart_data_cached_v2("005930", "D", 200))
|
|
assert md.chart_cache["005930__D__200"]["ttl"] == md._CHART_CACHE_TTL
|
|
|
|
|
|
# ── 라운드3: 다종목 프레임 / 즉시-close 백오프 ─────────────────────────────────
|
|
def test_mixed_code_frame_enqueues_each_code_latest():
|
|
mgr = _FakeManager(subscribed={"005930", "000660"})
|
|
s, p = _stream(mgr)
|
|
body = "^".join(_tick_record("005930", 101, 1) + _tick_record("000660", 200, 2)
|
|
+ _tick_record("005930", 103, 3))
|
|
with p:
|
|
_run(s._on_raw_message(_FakeKisWS(), f"0|H0STCNT0|003|{body}"))
|
|
got = {k[1]: v["price"] for k, v in s._latest.items()}
|
|
assert got == {"005930": 103.0, "000660": 200.0}
|
|
|
|
|
|
def test_instant_close_backoff_grows_and_reissues_once_per_streak():
|
|
mgr = _FakeManager(subscribed=set())
|
|
s, p = _stream(mgr)
|
|
sessions, sleeps, invalidations, alerts = [], [], [], []
|
|
|
|
class _Ctx:
|
|
async def __aenter__(self):
|
|
return _FakeKisWS(frames=[])
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
def _connect(url, **kwargs):
|
|
sessions.append(1)
|
|
if len(sessions) >= 7:
|
|
s._running = False
|
|
return _Ctx()
|
|
|
|
async def _key_ok():
|
|
s._approval_key = "k"
|
|
return True
|
|
|
|
async def _sleep(d, *a, **k):
|
|
sleeps.append(d)
|
|
|
|
async def _alert(total):
|
|
alerts.append(total)
|
|
|
|
fake_ws_mod = SimpleNamespace(connect=_connect, __version__="15.0.1")
|
|
with p, patch.object(krs, "websockets", fake_ws_mod), \
|
|
patch.object(krs.asyncio, "sleep", _sleep), \
|
|
patch.object(krs, "_invalidate_cached_approval_key", lambda: invalidations.append(1)), \
|
|
patch.object(krs, "_alert_instant_close_v2", _alert), \
|
|
patch.object(s, "_ensure_approval_key", _key_ok):
|
|
_run(s.run())
|
|
base = krs._RECONNECT_WAIT
|
|
assert sleeps[:4] == [min(base * 2 ** i, krs._RECONNECT_MAX_WAIT) for i in range(4)]
|
|
# 7회 close, streak=3 → 재발급 2회(3·6번째)이지 6회가 아니다
|
|
assert len(invalidations) == 7 // krs._NORMAL_CLOSE_REISSUE_STREAK
|
|
assert alerts == [krs._NORMAL_CLOSE_REISSUE_STREAK]
|
|
|
|
|
|
# ── R8 daybar 누적(저유동 종목 당일봉 유지) ──────────────────────────────────
|
|
def _store_patches(store):
|
|
async def _get(key):
|
|
return store.get(key)
|
|
|
|
async def _set(key, ttl, data):
|
|
store[key] = data
|
|
store["__ttl__" + key] = ttl
|
|
return True
|
|
return patch("app.core.redis.get_json", _get), patch("app.core.redis.setex_json", _set)
|
|
|
|
|
|
def test_daybar_persists_when_tick_expired_and_accumulates_extremes():
|
|
store = {}
|
|
g, s = _store_patches(store)
|
|
with g, s:
|
|
t1 = {"price": 100.0, "open": 98.0, "high": 105.0, "low": 97.0, "volume": 10}
|
|
store["tick:009420"] = t1
|
|
b1 = _run(md._today_bar_from_tick("009420", "20261008"))
|
|
assert b1["open"] == 98.0 and b1["high"] == 105.0
|
|
# tick TTL 만료 → daybar 로 당일봉 유지
|
|
del store["tick:009420"]
|
|
b2 = _run(md._today_bar_from_tick("009420", "20261008"))
|
|
assert b2 == b1
|
|
# 새 tick 이 더 낮은 low 이면 누적 극값 유지 + 신규 갱신
|
|
store["tick:009420"] = {"price": 101.0, "open": 98.0, "high": 104.0, "low": 99.0, "volume": 12}
|
|
b3 = _run(md._today_bar_from_tick("009420", "20261008"))
|
|
assert b3["high"] == 105.0 and b3["low"] == 97.0 and b3["close"] == 101.0
|
|
assert store["__ttl__daybar:009420"] >= md._DAYBAR_MIN_TTL
|
|
|
|
|
|
def test_daybar_ignores_previous_day_value():
|
|
store = {"daybar:009420": {"date": "20261007", "price": 1.0, "open": 1.0, "high": 1.0, "low": 1.0, "volume": 1}}
|
|
g, s = _store_patches(store)
|
|
with g, s:
|
|
assert _run(md._today_bar_from_tick("009420", "20261008")) is None
|
|
|
|
|
|
def test_post_close_window_uses_short_cache_ttl():
|
|
bars = [{"date": "20261008", "open": 1, "high": 2, "low": 1, "close": 1, "volume": 1}]
|
|
now = KST.localize(datetime(2026, 10, 8, 15, 35))
|
|
assert md._is_post_close_window(now)
|
|
assert not md._is_post_close_window(KST.localize(datetime(2026, 10, 8, 15, 45)))
|
|
with patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=list(bars))), \
|
|
patch.object(md, "_merge_recent_daily_bars", AsyncMock(side_effect=lambda c, b: (b, False))), \
|
|
patch.object(md, "_chart_session_state", lambda n: ("20261008", False)), \
|
|
patch.object(md, "datetime", SimpleNamespace(now=lambda tz=None: now if tz else now.replace(tzinfo=None),
|
|
strptime=datetime.strptime)):
|
|
_run(md.get_chart_data_cached_v2("005930", "D", 200))
|
|
assert md.chart_cache["005930__D__200"]["ttl"] == md._CHART_LIVE_TTL
|