Files
auto-stock/backend/tests/test_realtime_chart_fixes_20261009.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

596 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)):
_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