Files
auto-stock/backend/tests/test_display_data_fixes_20261008.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

385 lines
19 KiB
Python

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