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

@@ -907,6 +907,155 @@ def load_data_fdr(
return bars_by_code, index_bars, all_dates
# ---------------------------------------------------------------------------
# point-in-time 유니버스 (DB, 생존편향 제거) — load_data_fdr 의 하드코딩 34종목 대체
# ---------------------------------------------------------------------------
# 규칙(생존편향 제거):
# 1) 기준일(= 시뮬레이션 시작일 전일) 시점에 stock_daily_prices 에 존재한 종목만 후보. 이후 폐지된 종목 포함.
# 2) 순위는 기준일 직전 PIT_LIQUIDITY_LOOKBACK_DAYS 일 평균 거래대금(종가×거래량). 미래 정보 미사용.
# 3) 폐지 판정: 마지막 실제 봉이 데이터 종료일보다 PIT_DELIST_GRACE_DAYS 일 넘게 앞서면 폐지로 본다.
# 폐지 종목은 마지막 봉 다음 거래일에 종가 = 마지막 종가 × 회수율 인 종결 봉을 둔다.
# 회수율 기본 0.0(전액 손실, 최소 1원) — 합병·이전상장은 손실이 아닐 수 있으나 상방 편향을 막는 쪽을 택했다.
# 한계(추정): 기간 중 신규 상장 종목은 포함되지 않는다(정적 유니버스 시뮬레이터 제약).
PIT_LIQUIDITY_LOOKBACK_DAYS = 90
PIT_MIN_HISTORY_BARS = 215 # 워밍업 340일 ≈ 230거래일 내에서 충족 가능한 값
PIT_DELIST_GRACE_DAYS = 30
PIT_DEFAULT_TOP_N = 200
PIT_DEFAULT_DELIST_RECOVERY = 0.0
def rank_pit_universe_v2(rows: List[Tuple[str, str, float, int]], top_n: int) -> List[Tuple[str, str]]:
"""(code, name, avg_traded_value, bar_count) -> 거래대금 상위 top_n [(code, name)]. 이력 부족 종목 제외."""
ok = [r for r in rows if r[3] >= PIT_MIN_HISTORY_BARS and r[2] is not None and r[2] > 0]
ok.sort(key=lambda r: (-r[2], r[0]))
return [(r[0], r[1] or r[0]) for r in ok[:top_n]]
def apply_delisting_writeoff_v2(
bars_by_code: Dict[str, List[BarData]],
data_end: date,
recovery: float = PIT_DEFAULT_DELIST_RECOVERY,
grace_days: int = PIT_DELIST_GRACE_DAYS,
) -> List[str]:
"""폐지로 보이는 종목에 종결 봉을 추가한다. 반환: 폐지 처리된 종목 코드.
종결 봉 날짜는 data_end 이전 달력일 중 마지막 봉 다음 날(calendar +1)이 아니라,
all_dates 를 오염시키지 않도록 호출자가 all_dates 계산 전에 호출해도 되게 last+1일로 둔다.
"""
recovery = min(max(float(recovery), 0.0), 1.0)
delisted: List[str] = []
for code, bl in bars_by_code.items():
if not bl:
continue
last = bl[-1]
if (data_end - last.trade_date).days <= grace_days:
continue
px = max(1.0, float(last.close_price) * recovery)
bl.append(BarData(
trade_date=last.trade_date + timedelta(days=1),
open_price=px, high_price=px, low_price=px, close_price=px,
volume=0, stock_code=code, stock_name=last.stock_name,
))
delisted.append(code)
return delisted
def _db_url_for_asyncpg_v2() -> str:
url = os.environ.get("DATABASE_URL", "")
if not url:
raise RuntimeError("DATABASE_URL 미설정 (읽기 전용 계정 사용)")
return url.replace("postgresql+asyncpg://", "postgresql://")
async def _fetch_pit_v2(asof: date, start: date, end: date, top_n: int):
import asyncpg
conn = await asyncpg.connect(_db_url_for_asyncpg_v2(), ssl=False)
try:
ranking = await conn.fetch(
"""
SELECT stock_code, max(stock_name) AS name,
avg(close_price::float8 * volume) FILTER (WHERE trade_date > $2) AS tv,
count(*) AS n
FROM stock_daily_prices
WHERE trade_date <= $1 AND trade_date >= $3
GROUP BY stock_code
HAVING max(trade_date) > $2
""",
asof, asof - timedelta(days=PIT_LIQUIDITY_LOOKBACK_DAYS), start,
)
picked = rank_pit_universe_v2(
[(r["stock_code"], r["name"], r["tv"], r["n"]) for r in ranking], top_n,
)
codes = [c for c, _ in picked]
rows = await conn.fetch(
"""SELECT stock_code, trade_date, open_price, high_price, low_price, close_price, volume
FROM stock_daily_prices
WHERE stock_code = ANY($1::text[]) AND trade_date BETWEEN $2 AND $3
AND close_price > 0
ORDER BY stock_code, trade_date""",
codes, start, end,
)
return picked, rows
finally:
await conn.close()
def load_data_db_pit_v2(
start_date: str,
end_date: str,
sim_start: date,
top_n: int = PIT_DEFAULT_TOP_N,
recovery: float = PIT_DEFAULT_DELIST_RECOVERY,
index_code: str = KODEX200_CODE,
) -> Tuple[Dict[str, List[BarData]], List[BarData], List[date], Dict[str, Tuple[str, str]]]:
"""DB 기반 point-in-time 유니버스 로드 (SELECT 전용). 지수는 기존처럼 FDR.
Returns: (bars_by_code, index_bars, all_dates, universe)
"""
import asyncio
d0 = datetime.strptime(start_date, "%Y-%m-%d").date()
d1 = datetime.strptime(end_date, "%Y-%m-%d").date()
picked, rows = asyncio.run(_fetch_pit_v2(sim_start - timedelta(days=1), d0, d1, top_n))
names = dict(picked)
bars_by_code: Dict[str, List[BarData]] = {}
for r in rows:
bars_by_code.setdefault(r["stock_code"], []).append(BarData(
trade_date=r["trade_date"],
open_price=float(r["open_price"] or r["close_price"]),
high_price=float(r["high_price"] or r["close_price"]),
low_price=float(r["low_price"] or r["close_price"]),
close_price=float(r["close_price"]),
volume=int(r["volume"] or 0),
stock_code=r["stock_code"], stock_name=names.get(r["stock_code"], ""),
))
delisted = apply_delisting_writeoff_v2(bars_by_code, d1, recovery)
print(f" PIT 유니버스: {len(bars_by_code)}종목 (폐지 처리 {len(delisted)}, 회수율 {recovery})")
idx_df = fdr.DataReader(index_code, start_date, end_date)
if idx_df is None or idx_df.empty:
print("FAIL (KODEX200 데이터 없음)")
sys.exit(1)
index_bars = [BarData(
trade_date=(dt.date() if hasattr(dt, "date") else dt),
open_price=float(row.get("Open", 0)), high_price=float(row.get("High", 0)),
low_price=float(row.get("Low", 0)), close_price=float(row.get("Close", 0)),
volume=int(row.get("Volume", 0)), stock_code=index_code, stock_name="KODEX 200",
) for dt, row in idx_df.iterrows()]
# 거래일 달력은 지수 기준(종결 봉 날짜가 달력을 늘리지 않게 한다) + 실제 봉 날짜
idx_dates = {b.trade_date for b in index_bars}
all_dates = sorted(idx_dates)
for code, bl in bars_by_code.items():
# 종결 봉이 달력 밖이면 다음 거래일로 이동
if delisted and code in delisted and bl[-1].trade_date not in idx_dates:
nxt = next((d for d in all_dates if d > bl[-2].trade_date), None)
if nxt is None:
bl.pop()
else:
bl[-1].trade_date = nxt
universe = {c: (names.get(c, c), "KOSPI") for c in bars_by_code}
return bars_by_code, index_bars, all_dates, universe
def align_bars_to_dates(
bars_by_code: Dict[str, List[BarData]],
all_dates: List[date],
@@ -1491,6 +1640,7 @@ def save_results_json(
end_date: str,
initial_capital: float,
output_dir: str,
universe: Optional[Dict[str, Tuple[str, str]]] = None,
):
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"backtest_{start_date}_{end_date}_{ts}.json"
@@ -1542,7 +1692,7 @@ def save_results_json(
"end_date": end_date,
"initial_capital": initial_capital,
"strategy": "RSI+BB Mean Reversion (CLAUDE.md Section 6)",
"universe": {k: {"name": v[0], "market": v[1]} for k, v in BACKTEST_UNIVERSE.items()},
"universe": {k: {"name": v[0], "market": v[1]} for k, v in (universe if universe is not None else BACKTEST_UNIVERSE).items()},
"params": {
"rsi_period": RSI_PERIOD,
"bb_period": BB_PERIOD,
@@ -1582,6 +1732,11 @@ def main():
parser.add_argument("--start", type=str, default="2023-03-25", help="시작일 (YYYY-MM-DD)")
parser.add_argument("--end", type=str, default="2026-03-25", help="종료일 (YYYY-MM-DD)")
parser.add_argument("--capital", type=int, default=INITIAL_CAPITAL, help="초기 자본금 (원)")
parser.add_argument("--universe", choices=["fdr", "db-pit"], default="fdr",
help="fdr=하드코딩 34종목(생존편향 있음), db-pit=DB point-in-time(폐지 종목 포함)")
parser.add_argument("--top-n", type=int, default=PIT_DEFAULT_TOP_N, help="db-pit 거래대금 상위 N")
parser.add_argument("--delist-recovery", type=float, default=PIT_DEFAULT_DELIST_RECOVERY,
help="폐지 종목 회수율(0~1, 기본 0=전액 손실)")
args = parser.parse_args()
start_date = args.start
@@ -1593,7 +1748,7 @@ def main():
print(" Auto-Stock RSI+BB 평균회귀 백테스트")
print(f" 기간: {start_date} ~ {end_date}")
print(f" 자본: {initial_capital:,}원")
print(f" 종목: {len(BACKTEST_UNIVERSE)}종목")
print(f" 종목: {args.top_n if args.universe == 'db-pit' else len(BACKTEST_UNIVERSE)}종목 (유니버스={args.universe})")
print("=" * 60)
t0 = time.time()
@@ -1606,9 +1761,16 @@ def main():
from datetime import timedelta as _td
_sim_start = _dt.strptime(start_date, "%Y-%m-%d").date()
_warmup_start = (_sim_start - _td(days=340)).strftime("%Y-%m-%d")
bars_by_code, index_bars, all_dates = load_data_fdr(
BACKTEST_UNIVERSE, _warmup_start, end_date,
)
universe = BACKTEST_UNIVERSE
if args.universe == "db-pit":
bars_by_code, index_bars, all_dates, universe = load_data_db_pit_v2(
_warmup_start, end_date, _sim_start, args.top_n, args.delist_recovery,
)
else:
print(" [경고] fdr 유니버스는 현재 상장 종목뿐 — 생존편향. §8.4 재실행 근거로 쓰지 말 것.")
bars_by_code, index_bars, all_dates = load_data_fdr(
BACKTEST_UNIVERSE, _warmup_start, end_date,
)
if not bars_by_code:
print("\n [ERROR] 데이터 수집 실패. 종목 데이터를 가져올 수 없습니다.")
@@ -1623,14 +1785,14 @@ def main():
# 3. 시뮬레이션
print(f"\n 시뮬레이션 시작...")
metrics, all_trades, equity_curve, drawdown_curve, open_positions, mode_changes = run_simulation(
aligned, index_bars, all_dates, initial_capital, BACKTEST_UNIVERSE,
aligned, index_bars, all_dates, initial_capital, universe,
sim_start_date=_sim_start,
)
elapsed = time.time() - t0
# 4. 종목별 집계
stock_summary = per_stock_summary(all_trades, BACKTEST_UNIVERSE)
stock_summary = per_stock_summary(all_trades, universe)
# 적응형 모드 전환 이력 출력
if mode_changes:
@@ -1658,6 +1820,7 @@ def main():
metrics, all_trades, equity_curve, drawdown_curve,
stock_summary, open_positions,
start_date, end_date, initial_capital, output_dir,
universe=universe,
)