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

@@ -87,9 +87,60 @@ def _parse_exit_type(exit_reason: Optional[str]) -> str:
return "manual"
if "rsi" in reason:
return "rsi_exit"
# [2026-10-09 R1] 기존 분류는 그대로 두고, 그동안 unknown 이던 사유만 새 키워드로 분리한다.
if "목표가" in reason or "target" in reason:
return "take_profit"
if "미청산" in reason or "stale" in reason:
return "stale_close"
if "bb상단" in reason or "bb_upper" in reason or "과매수" in reason:
return "bb_upper_exit"
return "unknown"
# ─────────────────────────────────────────────────────────────────────────────
# 비용 추정 (2026-10-09 R1)
# 운영 trade_history 의 commission IS NULL 인 SELL 은 net_profit_loss 가 비용 미차감(총손익)이다.
# 정의는 trade_helpers.calculate_trade_costs 와 동일(수수료×2 + 매도세). 무거운 import 를 피하려고
# settings 요율을 직접 참조한다(테스트로 동일성 고정).
# ─────────────────────────────────────────────────────────────────────────────
def _is_gross_basis_v2(t) -> bool:
"""commission 이 NULL 이면 비용이 net_profit_loss 에 반영되지 않은 행으로 본다."""
return getattr(t, "commission", None) is None
def _estimated_cost_v2(t) -> float:
"""commission 이 없는 SELL 의 왕복 비용 추정(원). commission 이 있으면 0(이미 차감됨)."""
if not _is_gross_basis_v2(t):
return 0.0
from app.core.config import settings
amount = _to_float(getattr(t, "total_amount", None))
if amount <= 0:
amount = _to_float(getattr(t, "executed_price", None)) * _trade_qty_v2(t)
if amount <= 0:
return 0.0
market = str(getattr(t, "market", None) or settings.DAY_TRADE_DEFAULT_TAX_MARKET)
tax = (settings.KR_SELL_TAX_RATE_KOSDAQ if market.upper() == "KOSDAQ"
else settings.KR_SELL_TAX_RATE_KOSPI)
return round(amount * (2 * settings.KR_COMMISSION_RATE + tax), 0)
def _adjusted_net_v2(t) -> float:
return _to_float(getattr(t, "net_profit_loss", None)) - _estimated_cost_v2(t)
def _cost_adjustment_v2(trades: List[Any]) -> Dict[str, Any]:
gross_rows = [t for t in trades if _is_gross_basis_v2(t)]
est = sum(_estimated_cost_v2(t) for t in gross_rows)
net = sum(_to_float(getattr(t, "net_profit_loss", None)) for t in trades)
return {
"gross_basis_trades": len(gross_rows),
"estimated_cost": round(est, 2),
"estimated_cost_adjusted_net": round(net - est, 2),
"cost_basis_note": "commission NULL 행은 비용 미차감(총손익)으로 보고 추정 비용을 차감한 값을 병기",
}
# ─────────────────────────────────────────────────────────────────────────────
# 리스크 조정 수익률 지표 헬퍼
# ─────────────────────────────────────────────────────────────────────────────
@@ -490,6 +541,7 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
"max_consecutive_loss": max_consecutive_loss,
"risk_reward_ratio": round(abs(avg_win / avg_loss), 4) if avg_loss != 0 else 0.0,
}
summary.update(_cost_adjustment_v2(trades))
# ── 전략별 성과 ──────────────────────────────────────────────────────
by_strategy: Dict[str, list] = {}
@@ -593,6 +645,580 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
}
# ─────────────────────────────────────────────────────────────────────────────
# v2 성과 분석 (2026-10-09 하드닝 R1)
# - 라운드트립(포지션) 단위 귀인: entry_trade_id 가 NULL 이어도 종목별 FIFO 로 BUY 와 연결
# - 시스템 성과(manual/external 제외)와 전체를 분리
# - Sharpe/Sortino/Calmar: KRX 거래일 전체에 0 수익을 채우고 실제 자본(세션 starting_balance) 분모
# 기존 analyze_performance 및 헬퍼 시그니처는 그대로 둔다(호출처 호환).
# ─────────────────────────────────────────────────────────────────────────────
# 시스템 매매가 아닌(수동 판단·외부 유입) strategy_type. 전략 성과에서 분리한다.
NON_SYSTEM_STRATEGY_TYPES = frozenset({"manual", "external"})
# 라운드트립 매칭용 BUY 조회 룩백(일) — 윈도우 이전에 진입한 포지션의 청산을 연결하기 위함
_ROUNDTRIP_BUY_LOOKBACK_DAYS = 90
# 표본이 이보다 적으면 low_sample 로 표시(신뢰구간 병기)
_LOW_SAMPLE_ACTIVE_DAYS = 30
_FALLBACK_CAPITAL_KRW = 10_000_000.0
def _is_system_trade_v2(t) -> bool:
st = (getattr(t, "strategy_type", None) or "").strip().lower()
return st not in NON_SYSTEM_STRATEGY_TYPES
def _trade_qty_v2(t) -> int:
q = getattr(t, "executed_quantity", None) or getattr(t, "order_quantity", None) or 0
try:
return int(q)
except (TypeError, ValueError):
return 0
def _trade_sort_key_v2(t):
ts = getattr(t, "created_at", None) or datetime.min
return (ts, getattr(t, "id", 0) or 0)
def _match_roundtrips_v2(buys: List[Any], sells: List[Any]) -> List[Dict[str, Any]]:
"""BUY/SELL TradeHistory 를 종목별로 시간순 매칭해 SELL 마다 진입 BUY 를 연결한다.
- SELL.entry_trade_id 가 가리키는 BUY(남은 수량 있음)가 있으면 그것을 우선 사용
- 아니면 같은 종목의 가장 오래된 BUY lot(동일 strategy_type 우선) FIFO
- 수량이 여러 lot 에 걸치면 첫 소진 lot 에 귀속
- 매칭 불가(윈도우 밖 진입 등)는 entry_trade_id=None 의 단독 포지션
반환: SELL 순서와 무관한 dict 리스트(각 SELL 1개).
"""
out: List[Dict[str, Any]] = []
# 종목별 이벤트 시간순 처리
events: Dict[str, List[Any]] = defaultdict(list)
for b in buys:
events[b.stock_code].append(("B", b))
for sl in sells:
events[sl.stock_code].append(("S", sl))
for code, evs in events.items():
evs.sort(key=lambda e: (_trade_sort_key_v2(e[1]), 0 if e[0] == "B" else 1))
code_lots: List[List[Any]] = []
for kind, t in evs:
if kind == "B":
code_lots.append([t, _trade_qty_v2(t)])
continue
need = _trade_qty_v2(t)
chosen: Optional[List[Any]] = None
linked_by: Optional[str] = None
link_id = getattr(t, "entry_trade_id", None)
if link_id:
for lot in code_lots:
if lot[0].id == link_id and lot[1] > 0:
chosen = lot
linked_by = "entry_trade_id"
break
if chosen is None:
st = getattr(t, "strategy_type", None)
cands = [l for l in code_lots if l[1] > 0]
same = [l for l in cands if getattr(l[0], "strategy_type", None) == st]
pool = same or cands
chosen = pool[0] if pool else None
if chosen is not None:
linked_by = "fifo"
first_lot = chosen
remaining_need = need
if chosen is not None:
take = min(chosen[1], remaining_need)
chosen[1] -= take
remaining_need -= take
# 초과분은 다음 lot FIFO 로 소진(귀속은 첫 lot)
for lot in code_lots:
if remaining_need <= 0:
break
if lot[1] > 0:
tk = min(lot[1], remaining_need)
lot[1] -= tk
remaining_need -= tk
out.append({"sell": t, "buy": first_lot[0] if first_lot else None, "lot": first_lot,
"linked_by": linked_by})
# 포지션 종료 여부: lot 잔량 0 (lot 객체 공유)
for r in out:
r["lot_remaining"] = r["lot"][1] if r["lot"] is not None else 0
return out
def _sell_cost_basis_v2(sell, buy) -> Optional[float]:
"""해당 SELL 이 청산한 수량의 원가. BUY 체결가 우선, 없으면 매도대금-순손익 역산."""
qty = _trade_qty_v2(sell)
if buy is not None:
bp = _to_float(getattr(buy, "executed_price", None)) or _to_float(getattr(buy, "order_price", None))
if bp > 0 and qty > 0:
return bp * qty
sp = _to_float(getattr(sell, "executed_price", None))
if sp > 0 and qty > 0:
cost = sp * qty - _to_float(getattr(sell, "net_profit_loss", None))
if cost > 0:
return cost
return None
def _holding_bucket_v2(days: Optional[float]) -> str:
if days is None:
return "unknown"
if days < 1:
return "intraday"
if days <= 5:
return "1-5d"
if days <= 20:
return "6-20d"
return "20d+"
def _build_positions_v2(matches: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""SELL 매칭 결과를 진입 BUY 단위 포지션으로 합친다(분할매도 = 1 포지션)."""
groups: Dict[Any, List[Dict[str, Any]]] = {}
for m in matches:
key = m["buy"].id if m["buy"] is not None else ("orphan", m["sell"].id)
groups.setdefault(key, []).append(m)
positions: List[Dict[str, Any]] = []
for key, ms in groups.items():
buy = ms[0]["buy"]
sells = sorted((m["sell"] for m in ms), key=_trade_sort_key_v2)
net = sum(_to_float(s.net_profit_loss) for s in sells)
costs = [_sell_cost_basis_v2(m["sell"], buy) for m in ms]
cost = sum(c for c in costs if c) if any(costs) else None
entry_at = getattr(buy, "created_at", None) if buy is not None else None
last_exit = sells[-1].created_at
holding = None
if entry_at is not None and last_exit is not None:
holding = max((last_exit - entry_at).total_seconds() / 86400.0, 0.0)
ref = buy if buy is not None else sells[0]
closed = buy is None or all(m["lot_remaining"] <= 0 for m in ms)
positions.append({
"entry_trade_id": buy.id if buy is not None else None,
"stock_code": sells[0].stock_code,
"strategy_type": (getattr(ref, "strategy_type", None) or "").strip().lower() or "unknown",
"strategy": _parse_strategy(getattr(ref, "entry_reason", None), getattr(ref, "strategy_type", None)),
"entry_reason": (getattr(buy, "entry_reason", None) or "")[:80] if buy is not None else "",
"exit_types": sorted({
("manual_exit" if not _is_system_trade_v2(s) else _parse_exit_type(s.exit_reason))
for s in sells}),
"manual_exits": sum(1 for s in sells if not _is_system_trade_v2(s)),
"n_exits": len(sells),
"net_pnl": net,
"cost": cost,
"return_pct": (net / cost * 100.0) if cost else None,
"holding_days": holding,
"closed": closed,
"last_exit_at": last_exit,
})
positions.sort(key=lambda p: (p["last_exit_at"] is None, p["last_exit_at"]))
return positions
def _position_stats_v2(positions: List[Dict[str, Any]]) -> Dict[str, Any]:
"""포지션 단위 승률·PF·금액가중 수익률. 부분청산 진행 중(closed=False)은 제외하고 개수만 보고."""
closed = [p for p in positions if p["closed"]]
open_partial = len(positions) - len(closed)
wins = [p for p in closed if p["net_pnl"] > 0]
losses = [p for p in closed if p["net_pnl"] < 0]
gp = sum(p["net_pnl"] for p in wins)
gl = sum(p["net_pnl"] for p in losses)
if gl < 0:
pf = gp / abs(gl)
else:
pf = float("inf") if gp > 0 else 0.0
wr = [p["return_pct"] for p in wins if p["return_pct"] is not None]
lr = [p["return_pct"] for p in losses if p["return_pct"] is not None]
cost_total = sum(p["cost"] for p in closed if p["cost"])
net_total = sum(p["net_pnl"] for p in closed if p["cost"])
# [2026-10-09 R2] 금액 기준 평균손익·손익분기 승률. 레그(SELL 행) 단위 승률은 분할익절로 부풀어
# 주 지표로 쓰지 않는다. 손익분기 승률 = 평균손실/(평균이익+평균손실).
avg_win_amt = (gp / len(wins)) if wins else 0.0
avg_loss_amt = (abs(gl) / len(losses)) if losses else 0.0
be_wr = (avg_loss_amt / (avg_win_amt + avg_loss_amt) * 100.0
if (avg_win_amt + avg_loss_amt) > 0 else None)
# 수동 청산이 닫은(일부라도 수동 매도가 섞인) 봇 포지션은 별도 열로 귀속
man = [p for p in closed if p.get("manual_exits", 0) > 0]
sysx = [p for p in closed if p.get("manual_exits", 0) == 0]
return {
"avg_win_amount": round(avg_win_amt, 2),
"avg_loss_amount": round(-avg_loss_amt, 2),
"payoff_ratio_amount": round(avg_win_amt / avg_loss_amt, 4) if avg_loss_amt > 0 else None,
"breakeven_win_rate": round(be_wr, 2) if be_wr is not None else None,
"manual_exit_positions": len(man),
"manual_exit_net_pnl": round(sum(p["net_pnl"] for p in man), 2),
"system_exit_positions": len(sysx),
"system_exit_net_pnl": round(sum(p["net_pnl"] for p in sysx), 2),
"positions": len(closed),
"open_partial_positions": open_partial,
"win_positions": len(wins),
"loss_positions": len(losses),
"win_rate": round(len(wins) / len(closed) * 100, 2) if closed else 0.0,
"profit_factor": round(min(pf, 99.0), 4),
"net_pnl": round(sum(p["net_pnl"] for p in closed), 2),
"avg_win_pct": round(sum(wr) / len(wr), 4) if wr else 0.0,
"avg_loss_pct": round(sum(lr) / len(lr), 4) if lr else 0.0,
# 금액 가중: sum(순손익)/sum(원가). 단순 평균(%)은 소액 대박에 끌려가므로 병기.
"amount_weighted_return_pct": round(net_total / cost_total * 100.0, 4) if cost_total > 0 else None,
"avg_holding_days": (
round(sum(p["holding_days"] for p in closed if p["holding_days"] is not None)
/ max(1, sum(1 for p in closed if p["holding_days"] is not None)), 2)
if closed else None
),
}
def _group_positions_v2(positions: List[Dict[str, Any]], keyfn) -> Dict[str, Dict[str, Any]]:
groups: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for p in positions:
if not p["closed"]:
continue
groups[str(keyfn(p))].append(p)
out = {}
for k, ps in groups.items():
st = _position_stats_v2(ps)
out[k] = {
"positions": st["positions"],
"win_rate": st["win_rate"],
"net_pnl": st["net_pnl"],
"profit_factor": st["profit_factor"],
"amount_weighted_return_pct": st["amount_weighted_return_pct"],
}
return out
def _summary_from_trades_v2(trades: List[Any]) -> Dict[str, Any]:
"""analyze_performance 의 summary 와 같은 정의(SELL 행 단위, 금액 기준 PF)를 임의 SELL 목록에 적용."""
if not trades:
return _empty_summary()
pl_rates = [_to_float(t.profit_loss_rate) for t in trades]
pl_amounts = [_to_float(t.net_profit_loss) for t in trades]
wins = [r for r in pl_rates if r > 0]
losses = [r for r in pl_rates if r < 0]
avg_win = sum(wins) / len(wins) if wins else 0.0
avg_loss = sum(losses) / len(losses) if losses else 0.0
gp = sum(a for a in pl_amounts if a > 0)
gl = sum(a for a in pl_amounts if a < 0)
if gl < 0:
pf = gp / abs(gl)
else:
pf = float("inf") if gp > 0 else 0.0
return {
"total_trades": len(trades),
"win_trades": len(wins),
"loss_trades": len(losses),
"win_rate": round(len(wins) / len(pl_rates) * 100, 2),
"avg_win_pct": round(avg_win, 4),
"avg_loss_pct": round(avg_loss, 4),
"profit_factor": round(min(pf, 99.0), 4),
"gross_profit": round(gp, 2),
"gross_loss": round(gl, 2),
"net_pnl": round(gp + gl, 2),
"max_consecutive_loss": _calc_max_consecutive_loss(pl_rates),
"risk_reward_ratio": round(abs(avg_win / avg_loss), 4) if avg_loss != 0 else 0.0,
**_cost_adjustment_v2(trades),
}
def _krx_trading_days_v2(start: date, end: date) -> List[date]:
from app.core.market_schedule import is_krx_holiday
days: List[date] = []
d = start
while d <= end:
if d.weekday() < 5 and not is_krx_holiday(d):
days.append(d)
d += timedelta(days=1)
return days
def _daily_returns_v2(
daily_pnl: Dict[date, float],
trading_days: List[date],
capital_by_day: Optional[Dict[date, float]] = None,
fallback_capital: float = _FALLBACK_CAPITAL_KRW,
) -> Dict[str, Any]:
"""KRX 거래일 전체(무거래일=0)를 인덱스로 일별 수익률을 만든다.
분모: 그날 이전(포함) 가장 최근 세션 starting_balance(실제 총자산) — 없으면 fallback_capital
에 누적 실현손익을 더한 값(구 동작). 거래일이 아닌 날의 PnL 이 있으면 그날도 인덱스에 포함.
"""
days = sorted(set(trading_days) | set(daily_pnl.keys()))
cap_days = sorted((capital_by_day or {}).keys())
returns: List[float] = []
equity = fallback_capital
real_cap_days = 0
ci = 0
cur_cap: Optional[float] = None
for d in days:
while ci < len(cap_days) and cap_days[ci] <= d:
v = capital_by_day[cap_days[ci]]
if v and v > 0:
cur_cap = v
ci += 1
pnl = daily_pnl.get(d, 0.0)
base = cur_cap if cur_cap else equity
if cur_cap:
real_cap_days += 1
if base <= 0:
break
returns.append(pnl / base)
equity += pnl
n = len(days)
return {
"returns": returns,
"days": n,
"active_days": sum(1 for d in days if d in daily_pnl and daily_pnl[d] != 0.0),
"capital_source": (
"session_starting_balance" if real_cap_days == n and n > 0
else ("mixed" if real_cap_days > 0 else "fallback_fixed_capital")
),
}
def _sharpe_ci95_v2(sharpe: Optional[float], n: int) -> Optional[List[float]]:
"""Lo(2002) 근사 표준오차 기반 95% 신뢰구간(연환산 Sharpe). 표본 부족이면 None."""
if sharpe is None or n < 2:
return None
sr_d = sharpe / _ANNUALIZE_FACTOR
se = math.sqrt((1.0 + 0.5 * sr_d ** 2) / n) * _ANNUALIZE_FACTOR
return [round(sharpe - 1.96 * se, 4), round(sharpe + 1.96 * se, 4)]
def _risk_metrics_v2(
trades: List[Any],
trading_days: List[date],
capital_by_day: Optional[Dict[date, float]] = None,
) -> Dict[str, Any]:
daily_pnl = _build_daily_pnl_series(trades)
dr = _daily_returns_v2(daily_pnl, trading_days, capital_by_day)
r = dr["returns"]
sharpe = _calc_sharpe_ratio(r)
adj_daily: Dict[date, float] = defaultdict(float)
for t in trades:
ca = getattr(t, "created_at", None)
if ca is None:
continue
adj_daily[ca.date() if isinstance(ca, datetime) else ca] += _adjusted_net_v2(t)
adj_r = _daily_returns_v2(dict(adj_daily), trading_days, capital_by_day)["returns"]
return {
"sharpe_ratio_cost_adjusted": _calc_sharpe_ratio(adj_r),
"sharpe_ratio": sharpe,
"sharpe_ci95": _sharpe_ci95_v2(sharpe, len(r)),
"sortino_ratio": _calc_sortino_ratio(r),
"calmar_ratio": _calc_calmar_ratio(r),
"daily_returns_count": len(r),
"active_trading_days": dr["active_days"],
"capital_source": dr["capital_source"],
"low_sample": dr["active_days"] < _LOW_SAMPLE_ACTIVE_DAYS,
"_returns": r,
}
def _detect_drift_alerts_v2(trades: List[Any], daily_returns: List[float]) -> List[Dict[str, Any]]:
"""_detect_drift_alerts 의 승률/연패 경보는 유지하고, Sharpe/MDD 경보는 0 채움 시계열로 재계산."""
alerts = [a for a in _detect_drift_alerts(trades)
if a.get("type") not in ("rolling_sharpe", "mdd_acceleration")]
recent = daily_returns[-_DRIFT_ROLLING_WINDOW:]
if len(recent) >= _DRIFT_ROLLING_WINDOW:
rs = _calc_sharpe_ratio(recent)
if rs is not None and rs <= _DRIFT_SHARPE_THRESHOLD:
alerts.append({
"type": "rolling_sharpe", "value": rs, "threshold": _DRIFT_SHARPE_THRESHOLD,
"message": f"최근 {_DRIFT_ROLLING_WINDOW}거래일 Sharpe {rs:.2f} (임계값 {_DRIFT_SHARPE_THRESHOLD:.1f})",
})
if len(daily_returns) >= 5:
equity = 1.0
eqs = [equity]
for r in daily_returns:
equity *= (1.0 + r)
eqs.append(equity)
recent_eq = eqs[-6:]
peak, low = max(recent_eq), min(recent_eq)
if peak > 0:
dd = (low - peak) / peak
if dd <= _DRIFT_DRAWDOWN_THRESHOLD:
alerts.append({
"type": "mdd_acceleration", "value": round(dd, 4), "threshold": _DRIFT_DRAWDOWN_THRESHOLD,
"message": f"최근 drawdown {dd*100:.2f}% (임계값 {_DRIFT_DRAWDOWN_THRESHOLD*100:.1f}%)",
})
return alerts
def _hour_kst_v2(ts) -> Optional[str]:
"""DB 타임스탬프는 KST naive 로 통일돼 있다(timeutil). tz-aware 면 KST 로 변환."""
if ts is None or not isinstance(ts, datetime):
return None
if ts.tzinfo is not None:
from app.core.timeutil import KST
ts = ts.astimezone(KST)
return f"{ts.hour:02d}"
def _hour_stats_v2(groups: Dict[str, List[Any]]) -> Dict[str, Dict[str, Any]]:
out: Dict[str, Dict[str, Any]] = {}
for h in sorted(groups):
ts = groups[h]
nets = [_to_float(t.net_profit_loss) for t in ts]
adj = [_adjusted_net_v2(t) for t in ts]
out[h] = {
"count": len(ts),
"win_rate": round(sum(1 for a in adj if a > 0) / len(ts) * 100, 2),
"net_pnl": round(sum(nets), 2),
"estimated_cost_adjusted_net": round(sum(adj), 2),
}
return out
def _compose_performance_v2(
sells: List[Any],
buys: List[Any],
trading_days: List[date],
capital_by_day: Optional[Dict[date, float]],
days: int,
) -> Dict[str, Any]:
"""DB 비의존 순수 조립부(테스트 대상)."""
system_sells = [t for t in sells if _is_system_trade_v2(t)]
external_sells = [t for t in sells if not _is_system_trade_v2(t)]
# [2026-10-09 R1] 수동 매도가 시스템 BUY lot 을 소진하면 그 포지션은 시스템 포지션에 'manual_exit'
# 로 귀속한다(이전에는 시스템 SELL 만 매칭해 lot 이 영구 open 으로 남았다). 전체 SELL 을 한 번에
# 매칭해야 lot 소진 순서가 실제와 같다.
all_matches = _match_roundtrips_v2(buys, sells)
def _buy_is_system(m) -> bool:
return m["buy"] is not None and _is_system_trade_v2(m["buy"])
sys_matches = [m for m in all_matches
if _buy_is_system(m) or (m["buy"] is None and _is_system_trade_v2(m["sell"]))]
ext_matches = [m for m in all_matches if m not in sys_matches]
matches = sys_matches
positions = _build_positions_v2(sys_matches)
ext_positions = _build_positions_v2(ext_matches)
manual_exit_sells = [m["sell"] for m in sys_matches if not _is_system_trade_v2(m["sell"])]
sys_summary = _summary_from_trades_v2(system_sells)
risk = _risk_metrics_v2(system_sells, trading_days, capital_by_day)
daily_returns = risk.pop("_returns")
strat_stats: Dict[str, Any] = {}
by_strat_rows: Dict[str, List[float]] = defaultdict(list)
for t in system_sells:
by_strat_rows[_parse_strategy(t.entry_reason, getattr(t, "strategy_type", None))].append(
_to_float(t.profit_loss_rate))
for k, rates in by_strat_rows.items():
strat_stats[k] = {
"total": len(rates),
"win_rate": round(len([r for r in rates if r > 0]) / len(rates) * 100, 2),
"avg_return": round(sum(rates) / len(rates), 4),
}
exit_rows: Dict[str, List[float]] = defaultdict(list)
for t in system_sells:
exit_rows[_parse_exit_type(t.exit_reason)].append(_to_float(t.profit_loss_rate))
exit_stats = {
k: {"count": len(v), "avg_return": round(sum(v) / len(v), 4),
"win_rate": round(len([r for r in v if r > 0]) / len(v) * 100, 2)}
for k, v in exit_rows.items() if v
}
sys_sell_matches = [m for m in sys_matches if _is_system_trade_v2(m["sell"])]
linked = sum(1 for m in sys_sell_matches if m["buy"] is not None)
explicit = sum(1 for m in sys_sell_matches if m.get("linked_by") == "entry_trade_id")
fifo = sum(1 for m in sys_sell_matches if m.get("linked_by") == "fifo")
n_sys = len(system_sells)
# 시간대(KST naive) 귀인: 청산 시각 / 진입(매칭된 BUY) 시각, 비용 추정 차감 net 병기
by_exit_hour: Dict[str, List[Any]] = defaultdict(list)
by_entry_hour: Dict[str, List[Any]] = defaultdict(list)
for m in sys_sell_matches:
sl = m["sell"]
eh = _hour_kst_v2(getattr(sl, "created_at", None))
if eh is not None:
by_exit_hour[eh].append(sl)
if m["buy"] is not None:
nh = _hour_kst_v2(getattr(m["buy"], "created_at", None))
if nh is not None:
by_entry_hour[nh].append(sl)
return {
"summary": sys_summary, # 시스템(manual/external 제외) SELL(레그) 단위 — 주 지표 아님
"summary_basis": "sell_leg", # 분할익절 레그 단위. 주 지표는 position_summary
"summary_all": _summary_from_trades_v2(sells), # 참고: 수동·외부 포함
"external_summary": _summary_from_trades_v2(external_sells),
"position_summary": _position_stats_v2(positions),
"external_position_summary": _position_stats_v2(ext_positions),
"by_strategy": strat_stats,
"by_strategy_position": _group_positions_v2(positions, lambda p: p["strategy"]),
"by_entry_reason": _group_positions_v2(positions, lambda p: p["entry_reason"] or "unknown"),
"by_holding_period": _group_positions_v2(positions, lambda p: _holding_bucket_v2(p["holding_days"])),
"by_exit_type": exit_stats,
"roundtrip": {
"sells": n_sys,
"matched_to_buy": linked,
"unmatched_sells": n_sys - linked,
"linked_by_entry_trade_id": explicit,
"linked_by_fifo_estimate": fifo,
# FIFO 추정 비중이 높으면 보유기간·진입사유 귀인은 추정치다(운영 SELL 의 entry_trade_id 는 전부 NULL).
"attribution_confidence": (
"high" if n_sys and explicit / n_sys >= 0.8
else ("low_fifo_estimate" if n_sys else "n/a")),
"method": "entry_trade_id 우선, 없으면 종목별 FIFO(동일 strategy_type 우선)",
},
"manual_exit_summary": {
**_summary_from_trades_v2(manual_exit_sells),
"note": "시스템 BUY lot 을 청산한 수동 매도(시스템 SELL summary 에는 미포함, 포지션에는 manual_exit 로 귀속)",
},
"by_exit_hour": _hour_stats_v2(by_exit_hour),
"by_entry_hour": _hour_stats_v2(by_entry_hour),
**risk,
"drift_alerts": _detect_drift_alerts_v2(system_sells, daily_returns),
"period_days": days,
"total_trades": len(system_sells),
}
async def analyze_performance_v2(db: AsyncSession, days: int = 30) -> Dict[str, Any]:
"""analyze_performance 의 보정판. summary 는 manual/external 을 제외한 시스템 성과이며,
포지션(라운드트립) 단위 통계·보유기간/진입사유별 귀인·0 채움 Sharpe 를 추가로 반환한다.
build_insights() 와 호환(summary/by_strategy/by_exit_type/drift_alerts/total_trades 키 유지)."""
from app.models.models import TradingSession
now = now_kst()
since = now - timedelta(days=days)
sells_res = await db.execute(
select(TradeHistory).where(and_(
TradeHistory.order_type == "SELL",
TradeHistory.status.in_(["COMPLETED", "FILLED"]),
TradeHistory.created_at >= since,
)).order_by(TradeHistory.created_at)
)
sells: List[TradeHistory] = sells_res.scalars().all()
buys: List[TradeHistory] = []
if sells:
buys_res = await db.execute(
select(TradeHistory).where(and_(
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(["COMPLETED", "FILLED", "PARTIAL"]),
TradeHistory.created_at >= since - timedelta(days=_ROUNDTRIP_BUY_LOOKBACK_DAYS),
)).order_by(TradeHistory.created_at)
)
buys = buys_res.scalars().all()
capital_by_day: Dict[date, float] = {}
sess_res = await db.execute(
select(TradingSession.session_date, TradingSession.starting_balance)
.where(TradingSession.session_date >= since.date())
)
for d, bal in sess_res.all():
if d is not None and bal is not None and float(bal) > 0:
capital_by_day[d] = float(bal)
trading_days = _krx_trading_days_v2(since.date(), now.date())
perf = _compose_performance_v2(sells, buys, trading_days, capital_by_day, days)
perf["generated_at"] = now.isoformat()
return perf
def _empty_summary() -> Dict[str, Any]:
return {
"total_trades": 0,
@@ -607,6 +1233,9 @@ def _empty_summary() -> Dict[str, Any]:
"net_pnl": 0.0,
"max_consecutive_loss": 0,
"risk_reward_ratio": 0.0,
"gross_basis_trades": 0,
"estimated_cost": 0.0,
"estimated_cost_adjusted_net": 0.0,
}
@@ -639,6 +1268,27 @@ def build_insights(perf: Dict[str, Any]) -> List[Dict[str, str]]:
max_cons_loss = summary.get("max_consecutive_loss", 0)
rr = summary.get("risk_reward_ratio", 0.0)
# [2026-10-09 R2] 포지션(BUY→전량청산) 단위 집계가 있으면 그것을 주 지표로 쓴다. SELL 레그 단위
# 승률/손익비는 분할익절 레그가 '승'을 부풀려 손익분기 승률을 과대 평가하게 만든다.
pos = perf.get("position_summary") or {}
pos_based = bool(pos.get("positions"))
if pos_based:
win_rate = pos.get("win_rate", 0.0)
pf = pos.get("profit_factor", 0.0)
avg_loss = pos.get("avg_loss_pct", 0.0)
_aw = pos.get("avg_win_pct", 0.0)
rr = abs(_aw / avg_loss) if avg_loss else 0.0
_be = pos.get("breakeven_win_rate")
insights.append({"level": "info", "message": (
f"[포지션 단위 {pos['positions']}건] 승률 {win_rate:.1f}%"
+ (f" / 손익분기 승률 {_be:.1f}%" if _be is not None else "")
+ f" / PF {pf:.2f} (레그 단위 승률 {summary.get('win_rate', 0.0):.1f}% 는 참고용)")})
if pos.get("manual_exit_positions"):
insights.append({"level": "info", "message": (
f"수동 청산이 닫은 봇 포지션 {pos['manual_exit_positions']}건 순손익 "
f"{pos.get('manual_exit_net_pnl', 0):,.0f}원 / 시스템 청산 {pos.get('system_exit_positions', 0)}건 "
f"{pos.get('system_exit_net_pnl', 0):,.0f}원 — 봇 성과 판단 시 분리해서 볼 것")})
if win_rate < 40:
insights.append({"level": "danger", "message": f"승률 {win_rate:.1f}% — RSI 과매도 기준을 강화하거나 진입 조건을 추가 필터링하세요."})
elif win_rate < 50: