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