본문 바로가기
시스템트레이딩

전략의 손익 안정성 지표

by 오늘밤날다 2025. 11. 17.

 

sharp, sortino, carmar 등과 같은 지표들과 함께 쓸 수 있는 지표를 만들고 있다. 수익의 극대화보다 손익이 얼마나 안정적으로 발생하는가에 관점을 두고 내가 가진 아이디어와 ChatGpt의 제안해 주는 것들을 이리저리 섞어서 만든 초안이 나왔다.

 

지금은 너무 많은 것들을 집어넣어서 잡탕볶음밥이 되었는데 한동안 사용해보고 정리가 좀 필요할 것 같다.  당연한 소리지만 백테스트 자료 전체 기간에 대한 평가이기 때문에 평균의 함정이 존재한다. 실전에 투입된 전략의 최근 지표 수치를 일정 기간을 rolling해서 본다면 나름대로 유용할 것 같기도 하다.

 

import pandas as pd
import numpy as np

# Load data
df = pd.read_csv('/mnt/data/result_pvt.csv')

pnl = df['plr']

def _loss_streak_stats(pnl_series: pd.Series):
    loss_flags = (pnl_series < 0).astype(int)
    groups = (pnl_series >= 0).astype(int).cumsum()
    streaks = loss_flags.groupby(groups).sum()
    streaks = streaks[streaks > 0]
    if len(streaks) == 0:
        return 0, 0.0
    return streaks.max(), streaks.mean()


def _equity_curve_stats(pnl_series: pd.Series):
    eq = pnl_series.cumsum()
    x = np.arange(len(eq))
    A = np.vstack([x, np.ones(len(x))]).T
    m, c = np.linalg.lstsq(A, eq, rcond=None)[0]
    eq_pred = m * x + c
    ss_res = np.sum((eq - eq_pred) ** 2)
    ss_tot = np.sum((eq - eq.mean()) ** 2)
    r2 = 1 - ss_res / ss_tot if ss_tot != 0 else np.nan

    # Ulcer Index
    cummax = eq.cummax().replace(0, 1)
    dd = (eq - cummax) / cummax
    ulcer = np.sqrt(np.mean(dd ** 2))

    # Drawdown recovery time: average length between new highs
    at_high = eq == eq.cummax()
    rec_lengths = []
    current = 0
    started = False
    for high in at_high:
        if high:
            if started and current > 0:
                rec_lengths.append(current)
            current = 0
            started = True
        else:
            if started:
                current += 1
    avg_recov = np.mean(rec_lengths) if rec_lengths else 0.0

    return r2, ulcer, avg_recov


def _top_k_concentration(pnl_series: pd.Series, k=5):
    pos = pnl_series[pnl_series > 0]
    if len(pos) == 0:
        return 1.0
    total_pos = pos.sum()
    topk = pos.nlargest(min(k, len(pos))).sum()
    return float(topk / total_pos) if total_pos != 0 else 1.0


def _autocorr_lag1_binary(series: pd.Series):
    if series.nunique() <= 1:
        return 0.0
    s = series.astype(float)
    return s.autocorr(lag=1)


def compute_pcs(pnl_series: pd.Series):
    pnl = pnl_series.fillna(0)
    mean_abs = np.mean(np.abs(pnl))
    std = pnl.std()
    eps = 1e-9

    # ---------- Module 1: Daily PnL Stability (0–20) ----------
    # (a) PnL Stability Score
    if mean_abs > 0:
        raw_stab = 1 - std / mean_abs
        raw_stab = max(-1.0, min(1.0, raw_stab))
        stab_score = (raw_stab + 1) / 2 * 10  # -1→0, 0→5, 1→10
    else:
        stab_score = 0.0

    # (b) PnL Oscillation Index
    pnl_osc = np.mean(np.abs(pnl.diff().fillna(0)))
    if mean_abs > 0:
        osc_ratio = pnl_osc / (mean_abs + eps)
        # ratio <=1 → 10점, ratio >=3 → 0점
        if osc_ratio <= 1:
            osc_score = 10.0
        elif osc_ratio >= 3:
            osc_score = 0.0
        else:
            osc_score = (3 - osc_ratio) / (3 - 1) * 10
    else:
        osc_score = 0.0

    module1 = stab_score + osc_score

    # ---------- Module 2: Drawdown Psychology (0–20) ----------
    dd_freq = (pnl < 0).mean()
    max_ls, avg_ls = _loss_streak_stats(pnl)

    # Drawdown frequency: <=0.2 →10, >=0.5 →0
    if dd_freq <= 0.2:
        dd_freq_score = 10.0
    elif dd_freq >= 0.5:
        dd_freq_score = 0.0
    else:
        dd_freq_score = (0.5 - dd_freq) / (0.5 - 0.2) * 10

    # Max loss streak: <=2 →10, >=10 →0
    if max_ls <= 2:
        max_ls_score = 10.0
    elif max_ls >= 10:
        max_ls_score = 0.0
    else:
        max_ls_score = (10 - max_ls) / (10 - 2) * 10

    # Avg loss streak: <=1.5 →10, >=4 →0
    if avg_ls <= 1.5:
        avg_ls_score = 10.0
    elif avg_ls >= 4:
        avg_ls_score = 0.0
    else:
        avg_ls_score = (4 - avg_ls) / (4 - 1.5) * 10

    module2 = (dd_freq_score + max_ls_score + avg_ls_score) / 3 * 2

    # ---------- Module 3: Tail Risk & Concentration (0–20) ----------
    skew = pnl.skew()
    kurt = pnl.kurtosis()
    conc = _top_k_concentration(pnl, k=5)

    # Skewness: -2→0, +2→10
    if skew <= -2:
        skew_score = 0.0
    elif skew >= 2:
        skew_score = 10.0
    else:
        skew_score = (skew + 2) / 4 * 10

    # Kurtosis: <=5→10, >=50→0
    if kurt <= 5:
        kurt_score = 10.0
    elif kurt >= 50:
        kurt_score = 0.0
    else:
        kurt_score = (50 - kurt) / (50 - 5) * 10

    # Top-k concentration: <=0.3→10, >=0.8→0
    if conc <= 0.3:
        conc_score = 10.0
    elif conc >= 0.8:
        conc_score = 0.0
    else:
        conc_score = (0.8 - conc) / (0.8 - 0.3) * 10

    module3 = (skew_score + kurt_score + conc_score) / 3 * 2

    # ---------- Module 4: Smooth Equity Curve (0–20) ----------
    r2, ulcer, avg_recov = _equity_curve_stats(pnl)

    # R²: <=0.7→0, >=0.95→10
    if np.isnan(r2):
        r2_score = 0.0
    elif r2 <= 0.7:
        r2_score = 0.0
    elif r2 >= 0.95:
        r2_score = 10.0
    else:
        r2_score = (r2 - 0.7) / (0.95 - 0.7) * 10

    # Ulcer Index: <=0.05→10, >=0.3→0
    if ulcer <= 0.05:
        ulcer_score = 10.0
    elif ulcer >= 0.3:
        ulcer_score = 0.0
    else:
        ulcer_score = (0.3 - ulcer) / (0.3 - 0.05) * 10

    # Rolling 30d volatility vs mean_abs
    roll_vol = pnl.rolling(30).std().mean()
    if mean_abs > 0:
        rv_ratio = roll_vol / (mean_abs + eps)
        if rv_ratio <= 1:
            rv_score = 10.0
        elif rv_ratio >= 4:
            rv_score = 0.0
        else:
            rv_score = (4 - rv_ratio) / (4 - 1) * 10
    else:
        rv_score = 0.0

    module4 = (r2_score + ulcer_score + rv_score) / 3 * 2

    # ---------- Module 5: Behavioral Robustness (0–20) ----------
    # Win-rate volatility
    roll_win = (pnl > 0).rolling(30).mean()
    win_vol = roll_win.std()

    # win_vol: <=0.05→10, >=0.25→0
    if win_vol <= 0.05:
        win_vol_score = 10.0
    elif win_vol >= 0.25:
        win_vol_score = 0.0
    else:
        win_vol_score = (0.25 - win_vol) / (0.25 - 0.05) * 10

    # Negative PnL clustering: autocorr of loss flags
    loss_flags = (pnl < 0).astype(int)
    ac = _autocorr_lag1_binary(loss_flags)
    # ac <=0→10, >=0.5→0
    if ac <= 0:
        cluster_score = 10.0
    elif ac >= 0.5:
        cluster_score = 0.0
    else:
        cluster_score = (0.5 - ac) / 0.5 * 10

    # Drawdown recovery time: <=20d→10, >=120d→0
    if avg_recov <= 20:
        recov_score = 10.0
    elif avg_recov >= 120:
        recov_score = 0.0
    else:
        recov_score = (120 - avg_recov) / (120 - 20) * 10

    module5 = (win_vol_score + cluster_score + recov_score) / 3 * 2

    total_pcs = module1 + module2 + module3 + module4 + module5

    return {
        "module1_pnl_stability": module1,
        "module2_dd_psychology": module2,
        "module3_tail_concentration": module3,
        "module4_equity_smoothness": module4,
        "module5_behavioral_robustness": module5,
        "PCS_total": total_pcs,
        "debug": {
            "mean_abs_pnl": mean_abs,
            "std_pnl": std,
            "pnl_osc": pnl_osc,
            "dd_freq": dd_freq,
            "max_loss_streak": max_ls,
            "avg_loss_streak": avg_ls,
            "skew": skew,
            "kurt": kurt,
            "top5_concentration": conc,
            "r2": r2,
            "ulcer": ulcer,
            "avg_recovery_days": avg_recov,
            "win_rate_vol": win_vol,
            "loss_autocorr_lag1": ac,
            "roll30_vol_mean": roll_vol,
        }
    }


pcs_result = compute_pcs(pnl)

 

 

{
'module1_pnl_stability': 7.493388897463882, 
'module2_dd_psychology': 17.463470319634705, 
'module3_tail_concentration': 14.976056445613642, 
'module4_equity_smoothness': 12.52524748965874, 
'module5_behavioral_robustness': 11.37822428810704, 
'PCS_total': 63.83638744047801, 
'debug': {'mean_abs_pnl': 0.005307547283920399, 
'std_pnl': 0.0193269712977618, 
'pnl_osc': 0.007968340175064677, 
'dd_freq': 0.07330016583747927, 
'max_loss_streak': 5, 
'avg_loss_streak': 1.5136986301369864, 
'skew': 2.672903300415681, 
'kurt': 38.911618992107925, 
'top5_concentration': 0.09161073279655127, 
'r2': 0.9545424954376364, 
'ulcer': 0.22633761754283485, 
'avg_recovery_days': 58.787234042553195, 
'win_rate_vol': 0.11624161333497926, 
'loss_autocorr_lag1': 0.28709297484175783, 
'roll30_vol_mean': 0.011929177687905535}
}

 

 

 

항목별 설명은 귀찮아서 데이터를 넣은 후의 결과값에 대한 ChatGPT의 코멘트로 대체... 

 

 

Psychological Comfort Score (PCS) 결과

🔹 모듈별 점수 (0–20점)

  1. Module 1 – Daily PnL Stability
    7.49 / 20
    • 하루 손익의 “규칙성/진동” 관점에서는 중간 정도
    • 변동성 대비 평균 손익이 작고,
      일별 변화폭(pnl_osc)이 mean∣PnL∣|PnL|보다 큰 편이라 “완전 꾸준”까지는 아님
  2. Module 2 – Drawdown Psychology
    17.46 / 20
    • 손실 빈도(dd_freq): 7.33% (낮음)
    • 최대 연속 손실: 5일 (조금 길지만),
      평균 연속 손실: 1.51일
      → 종합하면 “손실이 자주/길게 오지 않는다” 쪽에 가깝다고 평가
  3. Module 3 – Tail Risk & Concentration
    14.98 / 20
    • Skew: +2.67 (양의 꼬리, 좋은 구조)
    • Kurtosis: 38.9 (fat-tail, 급변 가능성 있음 → 감점)
    • 상위 5일 수익 비중: 약 9.2% (0.0916) → 수익이 특정 며칠에 몰려 있지 않음 (좋음)
      → “대박 며칠이 먹여살리는 전략”은 아니라는 의미
  4. Module 4 – Equity Smoothness
    12.53 / 20
    • R²: 0.9545 (매우 매끄러운 우상향 추세)
    • Ulcer Index: 0.226 (DD 스트레스 꽤 있는 편 → 감점)
    • 30일 롤링 볼: mean_abs 대비 조금 높은 편
      → 장기적으로는 예쁜 우상향이지만,
      중간중간 DD 스트레스는 꽤 있는 구조
  5. Module 5 – Behavioral Robustness
    11.38 / 20
    • Win-rate volatility: 0.116 (중간 정도로 흔들림)
    • 손실 발생 autocorr(lag1): 0.287 → 손실이 어느 정도 뭉쳐서 나오는 경향
    • 평균 DD 회복 기간: 약 59일 → 두 달 정도 버텨야 고점 회복
      → “계속 들고 가긴 하겠는데, 회복 구간에서 멘탈이 좀 필요함”

'시스템트레이딩' 카테고리의 다른 글

RSI2 Strategy (NQ)  (0) 2025.11.22
IBS Strategy Update (NQ)  (0) 2025.11.21
Volatility Compression Filter  (0) 2025.11.16
Target-Volatility Strategy - BTCKRW  (0) 2025.11.14
RPSI(Relative Price Strength Index) Strategy  (0) 2025.11.10