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

@@ -39,6 +39,18 @@ async def check_opening_range_volatility() -> bool:
return is_opening_range_period()
def _bars_oldest_first(rows, date_key: str) -> list:
"""KIS 일봉 output2 를 과거→최근 순으로 정렬한다.
KIS 는 최신 봉이 앞(newest-first)이고 30행을 준다. 날짜 키가 모두 있으면
날짜 오름차순으로, 없으면 KIS 기본 순서(최신 우선)로 보고 뒤집는다.
"""
rows = [r for r in (rows or []) if isinstance(r, dict)]
if rows and all(r.get(date_key) for r in rows):
return sorted(rows, key=lambda r: str(r.get(date_key)))
return list(reversed(rows))
async def check_surge_prevention(
stock_code: str,
is_domestic: bool,
@@ -69,13 +81,25 @@ async def check_surge_prevention(
)
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 4:
chart_data = _bars_oldest_first(
chart_data, "stck_bsop_date" if is_domestic else "xymd"
)
# 오늘 봉(장중 미완성)이 포함되면 [-4:] 첫 봉 = 3거래일 전,
# 아직 없으면 [-3:] 첫 봉 = 3거래일 전(오늘 기준).
_win = 4
if is_domestic and chart_data:
_today = now_kst().strftime("%Y%m%d")
if str(chart_data[-1].get("stck_bsop_date")) != _today:
_win = 3
chart_data = chart_data[-_win:]
if len(chart_data) >= _win:
closes = (
[float(item.get("stck_clpr", 0)) for item in chart_data[-4:]]
[float(item.get("stck_clpr", 0)) for item in chart_data]
if is_domestic
else [float(item.get("clos", 0)) for item in chart_data[-4:]]
else [float(item.get("clos", 0)) for item in chart_data]
)
if len(closes) >= 4:
if len(closes) >= _win:
price_3days_ago = closes[0]
recent_gain = (
((current_price - price_3days_ago) / price_3days_ago) * 100
@@ -83,14 +107,14 @@ async def check_surge_prevention(
)
try:
highs = (
[float(item.get("stck_hgpr", 0)) for item in chart_data[-4:]]
[float(item.get("stck_hgpr", 0)) for item in chart_data]
if is_domestic
else [float(item.get("high", 0)) for item in chart_data[-4:]]
else [float(item.get("high", 0)) for item in chart_data]
)
lows = (
[float(item.get("stck_lwpr", 0)) for item in chart_data[-4:]]
[float(item.get("stck_lwpr", 0)) for item in chart_data]
if is_domestic
else [float(item.get("low", 0)) for item in chart_data[-4:]]
else [float(item.get("low", 0)) for item in chart_data]
)
daily_ranges = [
abs(highs[i] - lows[i]) for i in range(len(closes))
@@ -156,16 +180,29 @@ async def check_trend_filter(
if ma20 is None or ma60 is None:
if is_domestic:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
closes_local = [float(it.get("stck_clpr", 0)) for it in (chart_resp.get("output") or [])]
_rows = _bars_oldest_first(
chart_resp.get("output2") or chart_resp.get("output") or [], "stck_bsop_date"
)
closes_local = [float(it.get("stck_clpr", 0)) for it in _rows]
else:
chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D")
closes_local = [float(it.get("clos", 0)) for it in (chart_resp.get("output2") or [])]
_rows = _bars_oldest_first(chart_resp.get("output2") or [], "xymd")
closes_local = [float(it.get("clos", 0)) for it in _rows]
if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG):
ma20 = _ma(closes_local, TREND_MA_SHORT)
ma60 = _ma(closes_local, TREND_MA_LONG)
ema20 = _ema(closes_local, TREND_MA_SHORT)
ema60 = _ema(closes_local, TREND_MA_LONG)
elif not (ma20 and ma60):
# 데이터 부족(KIS 폴백 30봉)은 하락추세 증거가 아니다. 산출 불가 시 통과시키되
# 로그를 남긴다(전 비-평균회귀 매수 전면 차단 방지).
warn_throttled(
logger, f"trade_helpers:trend_nodata:{stock_code}",
f"[TREND_FILTER] {stock_code} MA{TREND_MA_LONG} 산출 불가"
f"(봉 {len(closes_local)}<{max(TREND_MA_SHORT, TREND_MA_LONG)}) -> 필터 통과(데이터 부족)",
)
return {"skip": False}
ma_ok = bool(
ma20 and ma60