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

@@ -237,8 +237,62 @@ async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]:
# -------------------------------------------------------------------
CHART_COLLECT_CONCURRENCY = 5 # KIS API 동시 호출 수 (차트 수집용)
# KIS 일봉은 수정주가(FID_ORG_ADJ_PRC=0)라 원주가 테이블 stock_daily_prices 를 오염시킨다
# (change_rate 는 KRX 원주가 기준이라 한 행에 기준이 섞임). 원주가 조회 _v2 가 생기기 전까지 OFF.
CHART_DB_UPSERT_ENABLED = False
CHART_COLLECT_DAYS = 400 # 수집할 캘린더일 수 (약 240 거래일)
_MARKET_CONFIRM_HOUR_MIN = (15, 30) # 이후에는 당일 봉을 확정봉으로 간주
def _bar_cutoff_date_v2(now_kst: datetime) -> date:
"""저장 허용 마지막 거래일. 장 마감(15:30) 전이면 전일까지만(당일 미확정 봉 제외)."""
today = now_kst.date()
if (now_kst.hour, now_kst.minute) >= _MARKET_CONFIRM_HOUR_MIN:
return today
return today - timedelta(days=1)
def _to_int_v2(v) -> int:
return int(float(str(v if v is not None else "0").replace(",", "")))
def _parse_kis_daily_record_v2(
item: dict, stock_code: str, stock_name: str, cutoff_date: date,
) -> Optional[dict]:
"""KIS 일봉 1행 -> DB 레코드. 0/비정상 OHLC·미확정 당일 봉이면 None."""
try:
s = item.get("stck_bsop_date", "")
if not s or len(s) != 8:
return None
td = date(int(s[:4]), int(s[4:6]), int(s[6:8]))
if td > cutoff_date:
return None
o = _to_int_v2(item.get("stck_oprc"))
h = _to_int_v2(item.get("stck_hgpr"))
l = _to_int_v2(item.get("stck_lwpr"))
c = _to_int_v2(item.get("stck_clpr"))
if min(o, h, l, c) <= 0:
return None
if h < l or h < max(o, c) or l > min(o, c):
return None
return {
"stock_code": stock_code,
"stock_name": stock_name,
"trade_date": td,
"open_price": o,
"high_price": h,
"low_price": l,
"close_price": c,
"volume": int(float(str(item.get("acml_vol", 0) or 0).replace(",", ""))),
"change_rate": None,
"market_cap": None,
}
except (ValueError, TypeError):
return None
async def _collect_chart_for_stock(
stock_code: str,
stock_name: str,
@@ -253,6 +307,9 @@ async def _collect_chart_for_stock(
from app.services.korea_investment_api import korea_investment_api
from sqlalchemy.dialects.postgresql import insert as pg_insert
if not CHART_DB_UPSERT_ENABLED:
return stock_code, 0
async with semaphore:
try:
raw_items = await korea_investment_api.get_daily_ohlcv_range(
@@ -262,33 +319,13 @@ async def _collect_chart_for_stock(
return stock_code, 0
records = []
cutoff_date = _bar_cutoff_date_v2(datetime.now(KST))
for item in raw_items:
try:
trade_date_str = item.get("stck_bsop_date", "")
if not trade_date_str or len(trade_date_str) != 8:
continue
td = date(
int(trade_date_str[:4]),
int(trade_date_str[4:6]),
int(trade_date_str[6:8]),
)
close_price = int(float(str(item.get("stck_clpr", "0")).replace(",", "")))
if close_price == 0:
continue
records.append({
"stock_code": stock_code,
"stock_name": stock_name,
"trade_date": td,
"open_price": int(float(str(item.get("stck_oprc", "0")).replace(",", ""))),
"high_price": int(float(str(item.get("stck_hgpr", "0")).replace(",", ""))),
"low_price": int(float(str(item.get("stck_lwpr", "0")).replace(",", ""))),
"close_price": close_price,
"volume": int(item.get("acml_vol", 0)),
"change_rate": None,
"market_cap": None,
})
except (ValueError, TypeError):
continue
rec = _parse_kis_daily_record_v2(
item, stock_code, stock_name, cutoff_date,
)
if rec is not None:
records.append(rec)
if not records:
return stock_code, 0
@@ -331,6 +368,10 @@ async def collect_chart_data_for_targets(
Returns: 총 저장된 행 수
"""
if not CHART_DB_UPSERT_ENABLED:
logger.info("📊 차트 DB 수집 비활성(수정주가 오염 방지) — 건너뜀")
return 0
from app.models.models import Portfolio
# 1. 보유종목 조회