"""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