"""2026-10-08 표시 데이터 오류 회귀 — 차트 당일봉·종목명·스캐너 필드·AI 분석 일관성·WS 서브프로토콜·호가 캐시.""" import asyncio from datetime import datetime 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.api.endpoints import recommendations as rec KST = pytz.timezone("Asia/Seoul") def _run(coro): return asyncio.run(coro) def _bar(d, c=47800.0): return {"date": d, "open": c, "high": c, "low": c, "close": c, "volume": 1000} # ── 1. 차트 ──────────────────────────────────────────────────────────────── def test_session_state_midday_wed(): now = KST.localize(datetime(2026, 10, 7, 11, 0)) # 수요일 장중 assert md._chart_session_state(now) == ("20261007", True) def test_session_state_before_open_uses_prev_trading_day(): now = KST.localize(datetime(2026, 10, 7, 8, 0)) assert md._chart_session_state(now) == ("20261006", False) def _tick(price, op, hi, lo, vol): return {"price": price, "open": op, "high": hi, "low": lo, "volume": vol} def test_chart_missing_days_filled_and_today_synthesized(): """DB 가 10/06 에서 멈춰도 10/07 은 네이버로 보충, 10/08 장중 봉은 tick 캐시로 합성(R1: KIS REST 미호출).""" now = KST.localize(datetime(2026, 10, 8, 11, 0)) db = [_bar("20261005"), _bar("20261006")] naver = [{"stck_bsop_date": "20261007", "stck_oprc": "45000", "stck_hgpr": "46000", "stck_lwpr": "44000", "stck_clpr": "44500", "acml_vol": "5000"}] kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("차트 경로 KIS 호출 금지"))) with patch.object(md, "korea_investment_api", kis), \ patch("app.core.redis.get_json", AsyncMock(return_value=_tick(44100, 44500, 44800, 43900, 7777))), \ patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) assert [b["date"] for b in bars] == ["20261005", "20261006", "20261007", "20261008"] assert live is True assert bars[-1]["close"] == 44100.0 and bars[-1]["volume"] == 7777 def test_chart_today_bar_replaced_not_duplicated(): now = KST.localize(datetime(2026, 10, 8, 11, 0)) db = [_bar("20261007"), _bar("20261008", 40000.0)] kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("차트 경로 KIS 호출 금지"))) with patch.object(md, "korea_investment_api", kis), \ patch("app.core.redis.get_json", AsyncMock(return_value=_tick(44100, 44500, 44800, 43900, 1))): bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) assert len(bars) == 2 and bars[-1]["close"] == 44100.0 and live def test_chart_after_close_no_synthesis_when_up_to_date(): now = KST.localize(datetime(2026, 10, 8, 17, 0)) db = [_bar("20261007"), _bar("20261008")] kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=AssertionError("호출 금지"))) with patch.object(md, "korea_investment_api", kis): bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) assert bars == db and live is False def test_chart_merge_failure_returns_original(): now = KST.localize(datetime(2026, 10, 8, 11, 0)) db = [_bar("20261006")] kis = SimpleNamespace(get_current_price=AsyncMock(side_effect=RuntimeError("down"))) with patch.object(md, "korea_investment_api", kis), \ patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(side_effect=RuntimeError("x"))): bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) assert bars == db and live is False def _nv(d, c): return {"stck_bsop_date": d, "stck_oprc": str(c), "stck_hgpr": str(c), "stck_lwpr": str(c), "stck_clpr": str(c), "acml_vol": "1"} def test_chart_intraday_naver_partial_today_bar_dropped(): now = KST.localize(datetime(2026, 10, 8, 11, 0)) db = [_bar("20261006")] naver = [_nv("20261007", 47800), _nv("20261008", 47000)] with patch("app.core.redis.get_json", AsyncMock(return_value=None)), \ patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): bars, live = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) assert [b["date"] for b in bars] == ["20261006", "20261007"] and live is False def test_chart_interior_gap_backfilled(): now = KST.localize(datetime(2026, 10, 8, 17, 0)) db = [_bar("20261005"), _bar("20261007"), _bar("20261008")] # 10/06 결손 naver = [_nv("20261005", 47800), _nv("20261006", 47800), _nv("20261007", 47800)] with patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): bars, _ = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) assert [b["date"] for b in bars] == ["20261005", "20261006", "20261007", "20261008"] def test_chart_source_mismatch_skips_merge(): now = KST.localize(datetime(2026, 10, 8, 17, 0)) db = [_bar("20261005", 100000.0), _bar("20261007", 100000.0), _bar("20261008", 100000.0)] naver = [_nv("20261005", 50000), _nv("20261006", 50000), _nv("20261007", 50000)] # 분할 수정주가 with patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): bars, _ = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) assert bars == db def test_chart_small_close_gap_still_fills_missing_bar(): """네이버-KRX 종가 1.3% 차이는 분할이 아니다 → DB 봉 유지 + 누락 날짜만 보충.""" now = KST.localize(datetime(2026, 10, 8, 17, 0)) db = [_bar("20261005", 100000.0), _bar("20261007", 100000.0), _bar("20261008", 100000.0)] naver = [_nv("20261005", 101300), _nv("20261006", 100500), _nv("20261007", 99000)] with patch("app.services.naver_finance.get_naver_daily_chart", AsyncMock(return_value=naver)): bars, _ = _run(md._merge_recent_daily_bars("009420", db, now_kst=now)) assert [b["date"] for b in bars] == ["20261005", "20261006", "20261007", "20261008"] assert bars[0]["close"] == 100000.0 and bars[2]["close"] == 100000.0 assert bars[1]["close"] == 100500.0 # ── 2. 종목명 ────────────────────────────────────────────────────────────── def test_name_prefers_kis_then_falls_back_to_naver(): md._STOCK_NAME_MEM.clear() assert _run(md._resolve_stock_name("111111", "KIS이름")) == "KIS이름" md._STOCK_NAME_MEM.clear() class _BadSession: def __call__(self): raise RuntimeError("db down") with patch("app.core.database.AsyncSessionLocal", _BadSession()), \ patch("app.services.naver_finance.get_naver_stock_name", AsyncMock(return_value="네이버이름")): assert _run(md._resolve_stock_name("009420")) == "네이버이름" assert md._STOCK_NAME_MEM["009420"] == "네이버이름" md._STOCK_NAME_MEM.clear() def test_name_from_kis_output_ignores_blank(): assert md._name_from_kis_output({"hts_kor_isnm": " "}, {"prdt_abrv_name": "한올"}) == "한올" # ── 3,4,6. 스캐너 ────────────────────────────────────────────────────────── def _stock(**kw): base = dict(stock_code="009420", stock_name="한올바이오파마", current_price=44000, change_rate=1.5, volume=1_000_000, rsi=35, adx_14=20, ma5=1, ma20=1, stochastic_k=10, signals=["RSI 과매도 (거래량부족-차단)"], score=3, ml_score=1, ml_confidence=0.7, ml_signal="SELL", analysis_date=datetime(2026, 10, 8).date(), updated_at=datetime(2026, 10, 8, 9, 10, 3), created_at=None) base.update(kw) return SimpleNamespace(**base) def test_scanner_fields_rule_vs_fallback_and_blocks(): r = SimpleNamespace(id=1, name="평균회귀", description="d", priority=1) d_rule = rec._stock_analysis_to_dict(_stock(signals=["RSI 과매도 + 거래량증가"], ml_signal="BUY"), rule=r) assert d_rule["match_source"] == "rule" and d_rule["buy_allowed"] is True assert d_rule["block_reasons"] == [] d_fb = rec._stock_analysis_to_dict(_stock()) assert d_fb["match_source"] == "fallback_high_score" assert d_fb["buy_allowed"] is False assert any("거래량부족-차단" in x for x in d_fb["block_reasons"]) # ML_SELL_SIGNAL_ENABLED=False(기본)에서는 ML SELL 이 매수를 막지 않으므로 사유가 아니다 assert "ML 신호 SELL" not in d_fb["block_reasons"] assert d_fb["ml_blocks_buy"] is False and d_fb["ml_signal"] == "SELL" assert d_fb["stock_code"] == "009420" and d_fb["rule_name"] == "고점수 분석 종목" # 기존 필드 유지 def test_batch_time_is_kst_iso_with_offset(): t = rec._batch_time_iso_v2([_stock()]) assert t == "2026-10-08T09:10:03+09:00" # 시각 정보가 없으면 분석일 00:00 KST t2 = rec._batch_time_iso_v2([_stock(updated_at=None)]) assert t2.endswith("+09:00") and t2.startswith("2026-10-08") def test_overlay_recomputes_price_and_change_rate_without_mutating_original(): item = {"stock_code": "009420", "current_price": 44000, "change_rate": 1.5} result = {"recommendations": [item], "analyzed_stock_list": [dict(item)], "recommendations_by_rule": [{"rule_id": 1, "stocks": [item]}]} async def fake_get_json(key): return {"price": 45000.0, "change_rate": 3.77} if key == "tick:009420" else None with patch("app.core.redis.get_json", fake_get_json): out = _run(rec._overlay_realtime_v2(result)) o = out["recommendations"][0] assert o["current_price"] == 45000 and o["change_rate"] == 3.77 and o["price_source"] == "realtime" assert o["batch_change_rate"] == 1.5 assert item["current_price"] == 44000 # 원본 불변 assert out["recommendations_by_rule"][0]["stocks"][0]["price_source"] == "realtime" def test_overlay_keeps_batch_when_no_tick(): result = {"recommendations": [{"stock_code": "000001", "current_price": 1, "change_rate": 0.1}]} with patch("app.core.redis.get_json", AsyncMock(return_value=None)): out = _run(rec._overlay_realtime_v2(result)) assert out["recommendations"][0]["price_source"] == "batch" assert out["recommendations"][0]["change_rate"] == 0.1 # ── 5. AI 분석 일관성 ────────────────────────────────────────────────────── class _FakeDB: def __init__(self, record): self._r = record async def execute(self, *_a, **_k): r = self._r return SimpleNamespace(scalar_one_or_none=lambda: r) def _ai(record, name_out=""): kis = SimpleNamespace(get_current_price=AsyncMock(return_value={ "rt_cd": "0", "output": {"stck_prpr": "44000", "stck_hgpr": "45000", "stck_lwpr": "43000", "acml_vol": "10", "hts_kor_isnm": name_out}})) md._STOCK_NAME_MEM.clear() with patch.object(md, "korea_investment_api", kis), \ patch.object(md, "_resolve_stock_name", AsyncMock(return_value="한올바이오파마")): return _run(md.get_ai_analysis("009420", db=_FakeDB(record))) def _rec(rsi): return SimpleNamespace(rsi=rsi, ma5=None, ma20=None, ma60=None, stochastic_k=None, stochastic_d=None, bollinger_upper=None, bollinger_lower=None, bollinger_middle=None, atr_14=800) def test_ai_hold_has_no_targets_and_neutral_wording(): out = _ai(_rec(35.0)) # RSI 35 → +0.5/1 = 0.5 → BUY 가 되지 않도록 신호 추가 없이 확인용 # RSI 35 단독은 score 0.5 > 0.3 → BUY. HOLD 를 만들기 위해 약한 신호 케이스를 별도로 검증 assert out["action"] == out["recommendation"] assert "매수권장" not in out["ai_analysis"] assert out["stock_name"] == "한올바이오파마" def test_ai_hold_nulls_targets_and_headline(): # RSI 55: 어떤 신호도 없음 → total_signals=1, score 0 → HOLD out = _ai(_rec(55.0)) assert out["recommendation"] == "HOLD" and out["action"] == "HOLD" assert out["headline"] == "관망" for k in ("target_price", "stop_loss_price", "target_pct", "stop_loss_pct", "risk_reward"): assert out[k] is None, k assert out["suggested_amount"] == 0 def test_ai_buy_keeps_targets_and_headline(): out = _ai(_rec(20.0)) # 과매도 → BUY assert out["recommendation"] == "BUY" and out["headline"] == "매수 추천" assert out["target_price"] and out["stop_loss_price"] and out["stop_loss_price"] < 44000 # ── 7. WS 서브프로토콜 ───────────────────────────────────────────────────── def test_ws_subprotocol_token_extraction(): from app.core.security import extract_ws_subprotocol_token as ex assert ex("autostock.auth, tok.en-1") == ("tok.en-1", "autostock.auth") assert ex("foo, autostock.auth,abc") == ("abc", "autostock.auth") assert ex("autostock.auth") == (None, "autostock.auth") assert ex("other, proto") == (None, "") assert ex(None) == (None, "") # ── 8. 호가 캐시 ─────────────────────────────────────────────────────────── def test_orderbook_ttl_cache_and_coalescing(): md._orderbook_cache.clear() calls = {"n": 0} async def fake_impl(code): calls["n"] += 1 await asyncio.sleep(0.05) return {"stock_code": code, "asks": [{"price": 1, "volume": 1}], "bids": []} async def go(): with patch.object(md, "_get_orderbook_impl", fake_impl): a, b = await asyncio.gather(md.get_orderbook("009420"), md.get_orderbook("009420")) c = await md.get_orderbook("009420") return a, b, c a, b, c = _run(go()) assert calls["n"] == 1 and a is b is c md._orderbook_cache.clear() def test_orderbook_error_not_cached(): md._orderbook_cache.clear() async def bad(code): return {"stock_code": code, "asks": [], "bids": [], "error": "temporarily_unavailable"} async def go(): with patch.object(md, "_get_orderbook_impl", bad): await md.get_orderbook("009420") _run(go()) assert "009420" not in md._orderbook_cache # ── 라운드5: ML 게이트 정의 일치 / 대체 목록 매수 제외 / MR 규칙 병합 ────────── def test_ml_flags_match_trade_executor_gate(monkeypatch): from app.core.config import settings monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", True) monkeypatch.setattr(settings, "ML_SELL_SIGNAL_ENABLED", False) r = SimpleNamespace(id=1, name="n", description="d", priority=1) ok_sig = ["RSI 과매도 + 거래량증가"] hold = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="HOLD", ml_confidence=0.7), rule=r) assert hold["buy_allowed"] is False and hold["ml_blocks_buy"] is True low = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="HOLD", ml_confidence=0.59), rule=r) assert low["buy_allowed"] is True sell = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="SELL", ml_confidence=0.9), rule=r) assert sell["buy_allowed"] is True and sell["ml_blocks_buy"] is False monkeypatch.setattr(settings, "ML_SELL_SIGNAL_ENABLED", True) sell2 = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="SELL", ml_confidence=0.9), rule=r) assert sell2["buy_allowed"] is False monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False) hold2 = rec._stock_analysis_to_dict(_stock(signals=ok_sig, ml_signal="HOLD", ml_confidence=0.9), rule=r) assert hold2["ml_blocks_buy"] is False def test_merge_mr_rule_flag_off_is_noop(monkeypatch): monkeypatch.setattr(rec, "settings", SimpleNamespace(MEAN_REVERSION_RULE_MERGE_ENABLED=False)) base = [rec.Rule(1, "b", "", "BUY", 1, {}, {}, True)] assert rec._merge_json_mean_reversion_rule_v2(base) == base def test_merge_mr_rule_flag_on_adds_once(monkeypatch): monkeypatch.setattr(rec, "settings", SimpleNamespace(MEAN_REVERSION_RULE_MERGE_ENABLED=True)) base = [rec.Rule(1, "b", "", "BUY", 1, {}, {}, True)] merged = rec._merge_json_mean_reversion_rule_v2(base) assert [r.rule_type for r in merged] == ["BUY", "MEAN_REVERSION"] assert merged[1].id == 2 assert rec._merge_json_mean_reversion_rule_v2(merged) == merged # 이미 있으면 불변 def test_fallback_group_is_exit_only_unless_mr_gate_passes(monkeypatch): import asyncio as _a from unittest.mock import AsyncMock, MagicMock s1 = _stock(stock_code="111111", signals=["x"], ml_signal=None) s2 = _stock(stock_code="222222", signals=["x"], ml_signal=None) res = MagicMock(); res.scalars.return_value.all.return_value = [s1, s2] db = MagicMock(); db.execute = AsyncMock(return_value=res) monkeypatch.setattr(rec, "_load_active_rules_from_db", AsyncMock(return_value=[])) monkeypatch.setattr(rec, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"})) monkeypatch.setattr(rec, "is_mean_reversion_candidate", lambda st, c, r: st.stock_code == "222222") out = _a.run(rec.get_rule_based_recommendations_from_db(db)) by = {d["stock_code"]: d for d in out["recommendations"]} assert by["111111"]["_exit_only"] is True and by["111111"]["buy_allowed"] is False assert by["222222"]["_exit_only"] is False # 게이트 예외는 fail-closed def boom(*a): raise RuntimeError("x") monkeypatch.setattr(rec, "is_mean_reversion_candidate", boom) out = _a.run(rec.get_rule_based_recommendations_from_db(db)) assert all(d["_exit_only"] is True for d in out["recommendations"]) def test_name_miss_is_negative_cached_r6(): """이름 없음도 TTL 캐시 — 두 번째 호출은 DB/네이버를 다시 치지 않는다.""" md._STOCK_NAME_MEM.clear() md._STOCK_NAME_MISS.clear() class _CountSession: calls = 0 def __call__(self): _CountSession.calls += 1 raise RuntimeError("db down") naver = AsyncMock(return_value="") with patch("app.core.database.AsyncSessionLocal", _CountSession()), \ patch("app.services.naver_finance.get_naver_stock_name", naver): assert _run(md._resolve_stock_name("999999")) == "" assert _run(md._resolve_stock_name("999999")) == "" assert _CountSession.calls == 1 and naver.await_count == 1 # TTL 경과 후에는 재조회 md._STOCK_NAME_MISS["999999"] -= md._STOCK_NAME_MISS_TTL + 1 _run(md._resolve_stock_name("999999")) assert _CountSession.calls == 2 md._STOCK_NAME_MISS.clear() md._STOCK_NAME_MEM.clear()