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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user