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