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:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -187,10 +187,44 @@ async def get_naver_stock_list(
_CHART_URL = "https://fchart.stock.naver.com/siseJson.naver"
# 호가단위 계수에서 제외할 ETF/ETN/벤치마크 코드. 종목명을 모르는 이 모듈은 코드로만 판정하므로
# 배치/호출부가 register_etf_codes_v2 로 알려주거나 get_naver_daily_chart_v2(is_etf=True) 로 넘긴다.
_BENCHMARK_ETF_CODES = frozenset({"069500", "122630", "252670", "114800"})
_registered_etf_codes: set = set()
def register_etf_codes_v2(codes) -> int:
"""ETF/ETN 코드를 등록해 이후 차트 조회의 호가단위 계수에서 제외한다. 등록 후 총 개수 반환."""
for c in codes or []:
if c:
_registered_etf_codes.add(str(c).strip())
return len(_registered_etf_codes)
def is_non_stock_code_v2(stock_code: str) -> bool:
"""주식 호가단위로 검사하면 안 되는 코드인가(벤치마크·등록 ETF·6자리 숫자가 아닌 코드)."""
code = str(stock_code or "").strip()
return (
code in _BENCHMARK_ETF_CODES
or code in _registered_etf_codes
or not (len(code) == 6 and code.isdigit())
)
async def get_naver_daily_chart(
stock_code: str,
days: int = 80,
client: Optional[httpx.AsyncClient] = None,
) -> Optional[List[Dict]]:
"""기존 시그니처 유지 래퍼. ETF 여부는 코드 기반(is_non_stock_code_v2)으로 판정한다."""
return await get_naver_daily_chart_v2(stock_code, days=days, client=client)
async def get_naver_daily_chart_v2(
stock_code: str,
days: int = 80,
client: Optional[httpx.AsyncClient] = None,
is_etf: Optional[bool] = None,
) -> Optional[List[Dict]]:
"""네이버 일봉 차트 → KIS 형식 dict 리스트 (oldest first).
@@ -260,7 +294,11 @@ async def get_naver_daily_chart(
return None
# 최신 days개만
return result[-days:] if len(result) > days else result
result = result[-days:] if len(result) > days else result
# 호가단위 의심 봉 표식(값 불변). ETF/벤치마크는 호가단위가 달라 계수에서 제외.
etf = is_non_stock_code_v2(stock_code) if is_etf is None else bool(is_etf)
annotate_naver_bars_v2(result, is_etf=etf)
return result
except Exception as exc:
warn_throttled(logger, "naver_finance:264", "네이버 차트 조회 실패 %s: %s", stock_code, exc)
@@ -270,6 +308,111 @@ async def get_naver_daily_chart(
await client.aclose()
# ─────────────────────────────────────────────────────────────────────────────
# 네이버 일봉 종가 신뢰성 검증 (KRX 호가단위 / KRX 종가 대조)
# 운영 DB 실측: 네이버 종가에 KRX 호가단위로 체결 불가능한 값이 섞이고 전일 종가가 KRX와 다르다.
# 원인(NXT 통합 체결가 여부)은 미확인(추정). 값은 바꾸지 않고 의심 봉을 계수·노출만 한다.
# ─────────────────────────────────────────────────────────────────────────────
_naver_close_stats: Dict[str, int] = {
"checked": 0, "off_tick": 0, "krx_compared": 0, "krx_mismatch": 0,
}
def krx_tick_size(price: float) -> int:
"""KRX 주식 호가단위(2023-01 통합 기준). ETF/ETN 은 별도(5원 등)라 호출부가 제외해야 한다."""
if price < 2000:
return 1
if price < 5000:
return 5
if price < 20000:
return 10
if price < 50000:
return 50
if price < 200000:
return 100
if price < 500000:
return 500
return 1000
def is_krx_tick_valid(price: float) -> bool:
"""가격이 KRX 주식 호가단위 배수인가. 0 이하/비정상 값은 False."""
try:
p = int(round(float(price)))
except (TypeError, ValueError):
return False
if p <= 0 or abs(float(price) - p) > 1e-9:
return False
return p % krx_tick_size(p) == 0
def annotate_naver_bars_v2(rows: Optional[List[Dict]], is_etf: bool = False) -> Optional[List[Dict]]:
"""각 봉에 종가 호가단위 적합 여부(`krx_tick_ok`)를 붙이고 통계를 누적한다.
값(종가 등)은 변경하지 않는다. ETF/ETN 은 호가단위가 달라 검사에서 제외한다.
"""
if not rows or is_etf:
return rows
for r in rows:
try:
ok = is_krx_tick_valid(float(r.get("stck_clpr", 0)))
except (TypeError, ValueError):
ok = False
r["krx_tick_ok"] = ok
_naver_close_stats["checked"] += 1
if not ok:
_naver_close_stats["off_tick"] += 1
return rows
def compare_naver_with_krx_v2(naver_rows: Optional[List[Dict]], krx_rows: Optional[List[Dict]]) -> Dict[str, int]:
"""같은 날짜의 네이버 종가를 KRX 종가(stck_bsop_date/stck_clpr 형식)와 대조한다.
불일치 봉에는 `krx_close_mismatch=True` 를 붙이고 통계를 누적한다. 값은 바꾸지 않는다.
"""
krx = {}
for r in krx_rows or []:
try:
krx[str(r["stck_bsop_date"])] = float(r["stck_clpr"])
except (KeyError, TypeError, ValueError):
continue
compared = mismatch = 0
for r in naver_rows or []:
k = krx.get(str(r.get("stck_bsop_date")))
if k is None:
continue
try:
n = float(r.get("stck_clpr", 0))
except (TypeError, ValueError):
continue
compared += 1
bad = abs(n - k) > 1e-9
r["krx_close_mismatch"] = bad
if bad:
mismatch += 1
_naver_close_stats["krx_compared"] += compared
_naver_close_stats["krx_mismatch"] += mismatch
return {"compared": compared, "mismatch": mismatch}
def get_naver_close_quality() -> Dict[str, float]:
"""/health 노출용 누적 통계. off_tick_ratio·krx_mismatch_ratio 는 표본 0이면 0.0."""
st = dict(_naver_close_stats)
st["off_tick_ratio"] = (st["off_tick"] / st["checked"]) if st["checked"] else 0.0
st["krx_mismatch_ratio"] = (st["krx_mismatch"] / st["krx_compared"]) if st["krx_compared"] else 0.0
return st
def naver_close_quality_alert_v2(max_krx_mismatch_ratio: float = 0.05, min_samples: int = 20) -> Optional[str]:
"""KRX 대조 불일치 비율이 임계 초과면 경보 문구, 아니면 None. /health/detail 배선용(main.py 몫)."""
q = get_naver_close_quality()
if q["krx_compared"] >= min_samples and q["krx_mismatch_ratio"] > max_krx_mismatch_ratio:
return (f"네이버-KRX 종가 불일치 {q['krx_mismatch_ratio']:.1%} "
f"({q['krx_mismatch']}/{q['krx_compared']}) > {max_krx_mismatch_ratio:.0%}")
return None
# ── 세계지수 크롤링 ──────────────────────────────────────────────────────────────
# 네이버 세계지수 심볼 매핑
@@ -446,3 +589,29 @@ async def get_stock_fundamentals(stock_code: str) -> Optional[Dict]:
warn_throttled(logger, "naver_finance:444", "[FUNDAMENTALS] %s 조회 실패: %s", stock_code, e)
_FUNDAMENTALS_CACHE[stock_code] = (_now, None)
return None
# ── 종목명 단건 조회 (KIS 시세 응답에 종목명이 없을 때의 폴백) ─────────────────────
_STOCK_NAME_CACHE: Dict[str, Tuple[float, str]] = {}
_STOCK_NAME_TTL = 86400.0 # 성공 캐시 24h (종목명은 거의 불변)
_STOCK_NAME_NEG_TTL = 300.0 # 실패 캐시 5분 (장애 시 요청마다 외부 호출 방지)
async def get_naver_stock_name(stock_code: str) -> str:
"""네이버 m.stock basic API 로 종목명 조회. 실패 시 "" (호출측이 다른 폴백 사용)."""
import time as _t
_now = _t.monotonic()
_c = _STOCK_NAME_CACHE.get(stock_code)
if _c and _now - _c[0] < (_STOCK_NAME_TTL if _c[1] else _STOCK_NAME_NEG_TTL):
return _c[1]
name = ""
try:
url = f"https://m.stock.naver.com/api/stock/{stock_code}/basic"
async with httpx.AsyncClient(verify=True, timeout=2.5, headers=_HEADERS) as client:
resp = await client.get(url)
if resp.status_code == 200:
name = str((resp.json() or {}).get("stockName") or "").strip()
except Exception as e:
warn_throttled(logger, "naver_finance:stock_name", "[NAME] %s 종목명 조회 실패: %s", stock_code, e)
_STOCK_NAME_CACHE[stock_code] = (_now, name)
return name