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
This commit is contained in:
384
backend/tests/test_display_data_fixes_20261008.py
Normal file
384
backend/tests/test_display_data_fixes_20261008.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user