← Back
β˜†
"""
Session Grid Backtest v3 β€” All Fixes Applied
===============================================
Changes from v2:
1. ATR-adaptive spacing (0.3-0.5% range, based on ATR)
2. Inventory cap (max 4 levels imbalance) + partial close at 3
3. Leverage 5x (down from 10x)
4. Choppiness Index added to screener
5. Passivbot-style unstucking (gradual reduction at EMA bands)
6. Trailing EMA center (instead of hard recenter)

Compares: v3 (all fixes) vs v2 baseline (0.1%, 10x, no inv mgmt)

Usage:
    python backtest_session_grid_v3.py
"""

import ccxt
import pandas as pd
import numpy as np
import json
import time
from datetime import datetime, timedelta, timezone

# ============================================================
# COINS
# ============================================================
COINS = [
    "ENA/USDT", "PENGU/USDT", "NEAR/USDT", "WLD/USDT", "UNI/USDT",
    "VIRTUAL/USDT", "SOL/USDT", "AVAX/USDT", "1000PEPE/USDT",
    "DOT/USDT", "1000SHIB/USDT", "ICP/USDT", "ETH/USDT", "TON/USDT",
    "LINK/USDT", "DOGE/USDT", "XRP/USDT", "AAVE/USDT", "ADA/USDT",
    "FIL/USDT", "ONDO/USDT", "SUI/USDT", "OP/USDT", "ARB/USDT",
]

TIMEFRAMES = ["15m", "1h"]
LOOKBACK_DAYS = 30

# ============================================================
# V3 CONFIG (ALL FIXES)
# ============================================================
# 1. ATR-adaptive spacing
SPACING_MIN_PCT = 0.3       # min spacing %
SPACING_MAX_PCT = 0.5       # max spacing %
ATR_SPACING_MULT = 0.5      # spacing = ATR(14) / price * 100 * mult
ATR_PERIOD = 14

# Grid structure
GRID_LEVELS = 8             # 8 per side

# 2. Inventory management
INVENTORY_WARN_LEVELS = 3   # at 3 levels imbalance β†’ partial close 50%
INVENTORY_MAX_LEVELS = 5    # at 5 levels β†’ full stop, no more orders on losing side

# 3. Leverage
LEVERAGE = 5                # down from 10x
POSITION_SIZE = 3.0         # $3 per level (notional = $15 at 5x)

# Fees
MAKER_FEE = 0.0002
TAKER_FEE = 0.0004

# 4. Screener with Choppiness Index
BB_PERIOD = 20
BB_STD = 2.0
BB_WIDTH_MIN = 0.3
BB_WIDTH_MAX = 1.5
ADX_MAX_ENTRY = 25
ADX_PERIOD = 14
CHOP_PERIOD = 14
CHOP_MIN_ENTRY = 55         # CHOP > 55 = ranging (entry)
CHOP_MAX_EXIT = 40          # CHOP < 40 = trending (exit trigger)

# Breakout exit (now with CHOP)
BREAKOUT_BB_MULT = 1.5
BREAKOUT_ADX = 28           # slightly lower since we also check CHOP

# 5. Passivbot unstucking
UNSTUCK_THRESHOLD = 4       # inventory levels before unstucking kicks in
UNSTUCK_CLOSE_PCT = 0.25    # close 25% of excess inventory per bar at EMA

# 6. Trailing EMA center
EMA_CENTER_PERIOD = 20      # EMA period for trailing center
CENTER_UPDATE_BARS = 5      # update center every 5 bars

# Session limits
MIN_SESSION_BARS = 10
MAX_SESSION_BARS = {"15m": 192, "1h": 72}

# Risk
DEPOSIT = 50.0
MAX_LOSS_PER_SESSION = 5.0  # $5 max loss (more room with 5x leverage)


# ============================================================
# INDICATORS
# ============================================================

def fetch_klines(exchange, symbol, timeframe, days):
    all_klines = []
    since = exchange.parse8601(
        (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
    )
    limit = 1000
    while True:
        try:
            klines = exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=limit)
        except Exception as e:
            print(f"  Error {symbol} {timeframe}: {e}")
            return None
        if not klines:
            break
        all_klines.extend(klines)
        since = klines[-1][0] + 1
        if len(klines) < limit:
            break
        time.sleep(0.12)

    if not all_klines:
        return None
    df = pd.DataFrame(all_klines, columns=['ts', 'open', 'high', 'low', 'close', 'volume'])
    df['ts'] = pd.to_datetime(df['ts'], unit='ms')
    return df


def calc_atr(df, period=ATR_PERIOD):
    high, low, close = df['high'], df['low'], df['close']
    tr = pd.concat([
        high - low,
        (high - close.shift(1)).abs(),
        (low - close.shift(1)).abs()
    ], axis=1).max(axis=1)
    return tr.ewm(alpha=1/period, min_periods=period).mean()


def calc_bb_width(close, period=BB_PERIOD, std=BB_STD):
    mid = close.rolling(period).mean()
    s = close.rolling(period).std()
    return ((mid + std * s) - (mid - std * s)) / mid * 100


def calc_adx(df, period=ADX_PERIOD):
    high, low, close = df['high'], df['low'], df['close']
    plus_dm = high.diff()
    minus_dm = -low.diff()
    plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0)
    minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0)
    tr = pd.concat([high - low, (high - close.shift(1)).abs(), (low - close.shift(1)).abs()], axis=1).max(axis=1)
    atr = tr.ewm(alpha=1/period, min_periods=period).mean()
    plus_di = 100 * (plus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr)
    minus_di = 100 * (minus_dm.ewm(alpha=1/period, min_periods=period).mean() / atr)
    dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di + 1e-10)
    return dx.ewm(alpha=1/period, min_periods=period).mean()


def calc_choppiness(df, period=CHOP_PERIOD):
    """
    Choppiness Index = 100 * LOG10(SUM(ATR, period) / (highest_high - lowest_low)) / LOG10(period)
    High values (>62) = choppy/ranging. Low values (<38) = trending.
    """
    high, low, close = df['high'], df['low'], df['close']
    tr = pd.concat([
        high - low,
        (high - close.shift(1)).abs(),
        (low - close.shift(1)).abs()
    ], axis=1).max(axis=1)

    atr_sum = tr.rolling(period).sum()
    highest = high.rolling(period).max()
    lowest = low.rolling(period).min()
    hl_range = highest - lowest

    # Avoid division by zero
    hl_range = hl_range.replace(0, np.nan)

    chop = 100 * np.log10(atr_sum / hl_range) / np.log10(period)
    return chop


def calc_ema(series, period):
    return series.ewm(span=period, adjust=False).mean()


def get_atr_spacing(atr_value, price):
    """Calculate ATR-adaptive spacing, clamped to [SPACING_MIN, SPACING_MAX]."""
    if price <= 0:
        return SPACING_MIN_PCT
    raw_pct = (atr_value / price) * 100 * ATR_SPACING_MULT
    return np.clip(raw_pct, SPACING_MIN_PCT, SPACING_MAX_PCT)


# ============================================================
# SCREENER (v3 with Choppiness)
# ============================================================

def is_range_entry_v3(bw, adx, chop):
    """Range entry: BB in sweet spot + ADX low + Choppiness high."""
    bb_ok = BB_WIDTH_MIN <= bw <= BB_WIDTH_MAX
    adx_ok = adx < ADX_MAX_ENTRY
    chop_ok = chop > CHOP_MIN_ENTRY
    return bb_ok and adx_ok and chop_ok


def is_breakout_exit_v3(bw, adx, chop):
    """Breakout exit: BB expanding + (ADX rising OR Choppiness falling)."""
    bb_breakout = bw > BB_WIDTH_MAX * BREAKOUT_BB_MULT
    adx_breakout = adx > BREAKOUT_ADX
    chop_trending = chop < CHOP_MAX_EXIT

    # Need BB expansion + at least one trend confirmation
    return bb_breakout and (adx_breakout or chop_trending)


# ============================================================
# GRID SIMULATION v3
# ============================================================

def simulate_grid_session_v3(df_session, initial_center):
    """
    Grid simulation with ALL v3 fixes:
    - ATR-adaptive spacing
    - Inventory cap + partial close
    - Passivbot unstucking at EMA bands
    - Trailing EMA center
    - 5x leverage
    """
    closes = df_session['close'].values
    highs = df_session['high'].values
    lows = df_session['low'].values
    n = len(closes)

    if n < 2:
        return 0, 0, 0, 0, 0, 0, 0

    # Pre-calculate ATR and EMA for the session
    atr_series = calc_atr(df_session, ATR_PERIOD)
    ema_series = calc_ema(df_session['close'], EMA_CENTER_PERIOD)

    notional = POSITION_SIZE * LEVERAGE  # $15 per level at 5x

    # State
    center = initial_center
    inventory_levels = 0        # +long, -short
    inventory_cost = 0.0
    realized_pnl = 0.0
    round_trips = 0
    total_fees = 0.0
    peak_inventory = 0
    unstuck_closes = 0
    partial_closes = 0

    for i in range(1, n):
        # --- 6. Trailing EMA center ---
        if i % CENTER_UPDATE_BARS == 0 and not pd.isna(ema_series.iloc[i]):
            center = ema_series.iloc[i]

        # --- 1. ATR-adaptive spacing ---
        atr_val = atr_series.iloc[i] if not pd.isna(atr_series.iloc[i]) else atr_series.iloc[max(0, i-1)]
        if pd.isna(atr_val):
            atr_val = abs(closes[i] - closes[i-1])  # fallback
        spacing_pct = get_atr_spacing(atr_val, closes[i])
        spacing = closes[i] * spacing_pct / 100

        if spacing <= 0:
            continue

        prev_close = closes[i-1]
        curr_close = closes[i]

        # Level crossings
        prev_lvl = prev_close / spacing
        curr_lvl = curr_close / spacing
        direction = 1 if curr_lvl > prev_lvl else -1 if curr_lvl < prev_lvl else 0
        levels_crossed = abs(int(curr_lvl) - int(prev_lvl))

        for lc in range(min(levels_crossed, GRID_LEVELS)):
            if direction > 0:
                # Price UP β†’ sell fills
                fill_price = prev_close + spacing * (lc + 0.5)
                fee = notional * MAKER_FEE
                total_fees += fee

                # --- 2. Inventory cap check ---
                if inventory_levels <= -INVENTORY_MAX_LEVELS:
                    break  # don't add more shorts

                if inventory_levels > 0:
                    # Close a long β†’ round-trip
                    rt_pnl = notional * (fill_price - inventory_cost) / inventory_cost - fee
                    realized_pnl += rt_pnl
                    inventory_levels -= 1
                    round_trips += 1
                else:
                    if inventory_levels == 0:
                        inventory_cost = fill_price
                    else:
                        total_cost = abs(inventory_levels) * inventory_cost + fill_price
                        inventory_cost = total_cost / (abs(inventory_levels) + 1)
                    inventory_levels -= 1
                    realized_pnl -= fee

            elif direction < 0:
                # Price DOWN β†’ buy fills
                fill_price = prev_close - spacing * (lc + 0.5)
                if fill_price <= 0:
                    continue
                fee = notional * MAKER_FEE
                total_fees += fee

                if inventory_levels >= INVENTORY_MAX_LEVELS:
                    break  # don't add more longs

                if inventory_levels < 0:
                    rt_pnl = notional * (inventory_cost - fill_price) / inventory_cost - fee
                    realized_pnl += rt_pnl
                    inventory_levels += 1
                    round_trips += 1
                else:
                    if inventory_levels == 0:
                        inventory_cost = fill_price
                    else:
                        total_cost = abs(inventory_levels) * inventory_cost + fill_price
                        inventory_cost = total_cost / (abs(inventory_levels) + 1)
                    inventory_levels += 1
                    realized_pnl -= fee

        # Track peak
        if abs(inventory_levels) > peak_inventory:
            peak_inventory = abs(inventory_levels)

        # --- 2. Partial close at INVENTORY_WARN_LEVELS ---
        if abs(inventory_levels) >= INVENTORY_WARN_LEVELS and inventory_cost > 0:
            levels_to_close = max(1, abs(inventory_levels) - INVENTORY_WARN_LEVELS + 1)
            # Close half of excess
            levels_to_close = max(1, levels_to_close // 2)

            if inventory_levels > 0:
                pnl_per = notional * (curr_close - inventory_cost) / inventory_cost
            else:
                pnl_per = notional * (inventory_cost - curr_close) / inventory_cost

            close_pnl = levels_to_close * pnl_per
            close_fee = levels_to_close * notional * TAKER_FEE
            realized_pnl += close_pnl - close_fee
            total_fees += close_fee

            if inventory_levels > 0:
                inventory_levels -= levels_to_close
            else:
                inventory_levels += levels_to_close

            partial_closes += levels_to_close

        # --- 5. Passivbot unstucking at EMA ---
        if abs(inventory_levels) >= UNSTUCK_THRESHOLD and inventory_cost > 0:
            ema_val = ema_series.iloc[i] if not pd.isna(ema_series.iloc[i]) else center

            # Only unstuck when price is near EMA (controlled loss)
            price_to_ema_pct = abs(curr_close - ema_val) / ema_val * 100
            if price_to_ema_pct < spacing_pct * 2:  # within 2 spacings of EMA
                levels_to_unstuck = max(1, int(abs(inventory_levels) * UNSTUCK_CLOSE_PCT))

                if inventory_levels > 0:
                    pnl_per = notional * (curr_close - inventory_cost) / inventory_cost
                else:
                    pnl_per = notional * (inventory_cost - curr_close) / inventory_cost

                unstuck_pnl = levels_to_unstuck * pnl_per
                unstuck_fee = levels_to_unstuck * notional * TAKER_FEE
                realized_pnl += unstuck_pnl - unstuck_fee
                total_fees += unstuck_fee

                if inventory_levels > 0:
                    inventory_levels -= levels_to_unstuck
                else:
                    inventory_levels += levels_to_unstuck

                unstuck_closes += levels_to_unstuck

        # --- Hard stop check ---
        if inventory_levels != 0 and inventory_cost > 0:
            if inventory_levels > 0:
                unrealized = abs(inventory_levels) * notional * (curr_close - inventory_cost) / inventory_cost
            else:
                unrealized = abs(inventory_levels) * notional * (inventory_cost - curr_close) / inventory_cost

            if realized_pnl + unrealized <= -MAX_LOSS_PER_SESSION:
                close_fee = abs(inventory_levels) * notional * TAKER_FEE
                return (round(realized_pnl + unrealized - close_fee, 4), 0,
                        round(realized_pnl + unrealized - close_fee, 4),
                        round_trips, peak_inventory, partial_closes, unstuck_closes)

    # Session end β†’ close remaining inventory
    inventory_pnl = 0.0
    if inventory_levels != 0 and inventory_cost > 0:
        final = closes[-1]
        if inventory_levels > 0:
            inventory_pnl = abs(inventory_levels) * notional * (final - inventory_cost) / inventory_cost
        else:
            inventory_pnl = abs(inventory_levels) * notional * (inventory_cost - final) / inventory_cost
        close_fee = abs(inventory_levels) * notional * TAKER_FEE
        inventory_pnl -= close_fee

    total_pnl = realized_pnl + inventory_pnl
    return (round(realized_pnl, 4), round(inventory_pnl, 4), round(total_pnl, 4),
            round_trips, peak_inventory, partial_closes, unstuck_closes)


# ============================================================
# BACKTEST RUNNER
# ============================================================

def backtest_coin_tf(df, timeframe):
    bb_width = calc_bb_width(df['close'])
    adx = calc_adx(df)
    chop = calc_choppiness(df)
    ema = calc_ema(df['close'], EMA_CENTER_PERIOD)
    max_bars = MAX_SESSION_BARS.get(timeframe, 96)

    sessions = []
    i = max(BB_PERIOD, ADX_PERIOD, CHOP_PERIOD) + 5
    in_session = False
    session_start = 0
    entry_price = 0

    while i < len(df):
        bw = bb_width.iloc[i]
        ax = adx.iloc[i]
        ch = chop.iloc[i]

        if pd.isna(bw) or pd.isna(ax) or pd.isna(ch):
            i += 1
            continue

        if not in_session:
            if is_range_entry_v3(bw, ax, ch):
                in_session = True
                session_start = i
                entry_price = df['close'].iloc[i]
                i += 1
                continue
        else:
            bars_in = i - session_start
            should_exit = False
            exit_reason = ""

            if bars_in >= MIN_SESSION_BARS and is_breakout_exit_v3(bw, ax, ch):
                should_exit = True
                exit_reason = "breakout"
            elif bars_in >= max_bars:
                should_exit = True
                exit_reason = "max_time"

            if should_exit:
                session_df = df.iloc[session_start:i+1].copy().reset_index(drop=True)
                (real_pnl, inv_pnl, total_pnl,
                 rts, peak_inv, partial_cl, unstuck_cl) = simulate_grid_session_v3(session_df, entry_price)

                # Get ATR spacing at entry for logging
                atr_s = calc_atr(df.iloc[max(0,session_start-ATR_PERIOD):session_start+1], ATR_PERIOD)
                if len(atr_s) > 0 and not pd.isna(atr_s.iloc[-1]):
                    spacing_at_entry = get_atr_spacing(atr_s.iloc[-1], entry_price)
                else:
                    spacing_at_entry = SPACING_MIN_PCT

                sessions.append({
                    "start": str(df['ts'].iloc[session_start]),
                    "end": str(df['ts'].iloc[i]),
                    "bars": bars_in,
                    "entry_price": round(entry_price, 6),
                    "exit_price": round(df['close'].iloc[i], 6),
                    "spacing_pct": round(spacing_at_entry, 3),
                    "realized_pnl": real_pnl,
                    "inventory_pnl": inv_pnl,
                    "total_pnl": total_pnl,
                    "round_trips": rts,
                    "peak_inventory": peak_inv,
                    "partial_closes": partial_cl,
                    "unstuck_closes": unstuck_cl,
                    "exit_reason": exit_reason,
                    "chop_entry": round(ch if not pd.isna(chop.iloc[session_start]) else 0, 1),
                    "chop_exit": round(ch, 1),
                    "bb_entry": round(bb_width.iloc[session_start] if not pd.isna(bb_width.iloc[session_start]) else 0, 3),
                    "adx_entry": round(adx.iloc[session_start] if not pd.isna(adx.iloc[session_start]) else 0, 1),
                })

                in_session = False
                i += 3
                continue
        i += 1

    return sessions


def main():
    print("=" * 70)
    print("SESSION GRID BACKTEST v3 β€” ALL FIXES")
    print("=" * 70)
    print(f"Period: {LOOKBACK_DAYS}d")
    print(f"Spacing: ATR-adaptive {SPACING_MIN_PCT}-{SPACING_MAX_PCT}% (ATRΓ—{ATR_SPACING_MULT})")
    print(f"Leverage: {LEVERAGE}x | Position: ${POSITION_SIZE}/level (notional ${POSITION_SIZE*LEVERAGE})")
    print(f"Inventory: warn@{INVENTORY_WARN_LEVELS}, cap@{INVENTORY_MAX_LEVELS}, unstuck@{UNSTUCK_THRESHOLD}")
    print(f"Screener: BB {BB_WIDTH_MIN}-{BB_WIDTH_MAX}% + ADX<{ADX_MAX_ENTRY} + CHOP>{CHOP_MIN_ENTRY}")
    print(f"Exit: BB>{BB_WIDTH_MAX*BREAKOUT_BB_MULT:.1f}% + (ADX>{BREAKOUT_ADX} or CHOP<{CHOP_MAX_EXIT})")
    print(f"Center: Trailing EMA({EMA_CENTER_PERIOD}) every {CENTER_UPDATE_BARS} bars")
    print(f"Stop: ${MAX_LOSS_PER_SESSION}/session")
    print("=" * 70)

    exchange = ccxt.binanceusdm({
        'enableRateLimit': True,
        'options': {'defaultType': 'future'},
    })

    results = {}

    for tf in TIMEFRAMES:
        print(f"\n{'='*60}")
        print(f"  TIMEFRAME: {tf}")
        print(f"{'='*60}")

        tf_results = []

        for symbol in COINS:
            print(f"\n  {symbol} ({tf})...", end=" ", flush=True)
            df = fetch_klines(exchange, symbol, tf, LOOKBACK_DAYS)

            if df is None or len(df) < 50:
                print("SKIP")
                continue

            sessions = backtest_coin_tf(df, tf)

            if not sessions:
                print(f"0 sessions")
                continue

            total_pnl = sum(s['total_pnl'] for s in sessions)
            real_pnl = sum(s['realized_pnl'] for s in sessions)
            inv_pnl = sum(s['inventory_pnl'] for s in sessions)
            wins = [s for s in sessions if s['total_pnl'] > 0]
            wr = len(wins) / len(sessions) * 100
            total_rts = sum(s['round_trips'] for s in sessions)
            avg_bars = np.mean([s['bars'] for s in sessions])
            avg_spacing = np.mean([s['spacing_pct'] for s in sessions])
            total_partial = sum(s['partial_closes'] for s in sessions)
            total_unstuck = sum(s['unstuck_closes'] for s in sessions)

            print(f"{len(sessions)} sess | PnL ${total_pnl:+.2f} "
                  f"(grid ${real_pnl:+.2f} inv ${inv_pnl:+.2f}) | "
                  f"WR {wr:.0f}% | {total_rts} RTs | "
                  f"avg spacing {avg_spacing:.2f}% | "
                  f"partial:{total_partial} unstuck:{total_unstuck}")

            tf_results.append({
                "symbol": symbol,
                "sessions": len(sessions),
                "total_pnl": round(total_pnl, 2),
                "realized_pnl": round(real_pnl, 2),
                "inventory_pnl": round(inv_pnl, 2),
                "win_rate": round(wr, 1),
                "wins": len(wins),
                "total_round_trips": total_rts,
                "avg_bars": round(avg_bars, 1),
                "avg_spacing_pct": round(avg_spacing, 3),
                "total_partial_closes": total_partial,
                "total_unstuck_closes": total_unstuck,
                "sessions_detail": sessions,
            })

        results[tf] = tf_results

    # ============================================================
    # SUMMARY
    # ============================================================
    print("\n\n" + "=" * 70)
    print("SUMMARY β€” SESSION GRID v3 (ALL FIXES)")
    print("=" * 70)

    for tf in TIMEFRAMES:
        tf_data = results.get(tf, [])
        if not tf_data:
            print(f"\n{tf}: No data")
            continue

        total_pnl = sum(c['total_pnl'] for c in tf_data)
        total_real = sum(c['realized_pnl'] for c in tf_data)
        total_inv = sum(c['inventory_pnl'] for c in tf_data)
        total_sess = sum(c['sessions'] for c in tf_data)
        total_rts = sum(c['total_round_trips'] for c in tf_data)
        total_wins = sum(c['wins'] for c in tf_data)
        coins_profit = sum(1 for c in tf_data if c['total_pnl'] > 0)
        total_partial = sum(c['total_partial_closes'] for c in tf_data)
        total_unstuck = sum(c['total_unstuck_closes'] for c in tf_data)

        wr = total_wins / total_sess * 100 if total_sess > 0 else 0

        print(f"\nπŸ“Š {tf} TIMEFRAME:")
        print(f"   Total PnL: ${total_pnl:+.2f} (grid ${total_real:+.2f}, inventory ${total_inv:+.2f})")
        print(f"   Sessions: {total_sess} (wins {total_wins}, WR {wr:.1f}%)")
        print(f"   Round-trips: {total_rts}")
        print(f"   Profitable coins: {coins_profit}/{len(tf_data)}")
        if total_sess > 0:
            print(f"   Avg PnL/session: ${total_pnl/total_sess:+.3f}")
            print(f"   Avg PnL/day: ${total_pnl / LOOKBACK_DAYS:+.2f}")
        print(f"   Inventory management: {total_partial} partial closes, {total_unstuck} unstucks")

        sorted_coins = sorted(tf_data, key=lambda c: c['total_pnl'], reverse=True)
        print(f"\n   🟒 Top 5:")
        for c in sorted_coins[:5]:
            print(f"      {c['symbol']:15s} ${c['total_pnl']:+8.2f} "
                  f"(grid ${c['realized_pnl']:+.2f} inv ${c['inventory_pnl']:+.2f}) "
                  f"{c['sessions']} sess WR {c['win_rate']:.0f}% "
                  f"{c['total_round_trips']} RTs "
                  f"spacing {c['avg_spacing_pct']:.2f}%")

        if len(sorted_coins) > 5:
            print(f"\n   πŸ”΄ Bottom 5:")
            for c in sorted_coins[-5:]:
                print(f"      {c['symbol']:15s} ${c['total_pnl']:+8.2f} "
                      f"(grid ${c['realized_pnl']:+.2f} inv ${c['inventory_pnl']:+.2f}) "
                      f"{c['sessions']} sess WR {c['win_rate']:.0f}%")

    # Compare timeframes
    print("\n\n" + "=" * 70)
    print("15m vs 1h COMPARISON (v3)")
    print("=" * 70)
    header = f"  {'TF':4s} | {'Total':>9s} | {'Grid':>9s} | {'Inv':>9s} | {'Sess':>5s} | {'WR':>5s} | {'RTs':>5s} | {'$/sess':>7s} | {'$/day':>7s}"
    print(header)
    print(f"  {'-'*4}-+-{'-'*9}-+-{'-'*9}-+-{'-'*9}-+-{'-'*5}-+-{'-'*5}-+-{'-'*5}-+-{'-'*7}-+-{'-'*7}")
    for tf in TIMEFRAMES:
        tf_data = results.get(tf, [])
        tp = sum(c['total_pnl'] for c in tf_data)
        rp = sum(c['realized_pnl'] for c in tf_data)
        ip = sum(c['inventory_pnl'] for c in tf_data)
        sess = sum(c['sessions'] for c in tf_data)
        wins = sum(c['wins'] for c in tf_data)
        rts = sum(c['total_round_trips'] for c in tf_data)
        wr = wins / sess * 100 if sess > 0 else 0
        avg = tp / sess if sess > 0 else 0
        day = tp / LOOKBACK_DAYS
        print(f"  {tf:4s} | ${tp:+8.2f} | ${rp:+8.2f} | ${ip:+8.2f} | {sess:5d} | {wr:4.0f}% | {rts:5d} | ${avg:+6.2f} | ${day:+6.2f}")

    # V2 vs V3 comparison note
    print("\n\n" + "=" * 70)
    print("V2 vs V3 COMPARISON (for reference)")
    print("=" * 70)
    print("  V2 (15m): $-2542 total, 0% WR, 581 sessions, inv loss = 5-8x grid loss")
    print("  V2 (1h):  $-331 total,  0% WR, 56 sessions")
    print("  V3 results above ↑↑↑")

    # Save
    output_file = "/home/app/trading-bot/grid-bot/backtests/results_session_grid_v3.json"
    compact = {}
    for tf in TIMEFRAMES:
        compact[tf] = [{k: v for k, v in c.items() if k != 'sessions_detail'} for c in results.get(tf, [])]
    with open(output_file, 'w') as f:
        json.dump(compact, f, indent=2)
    print(f"\nπŸ’Ύ Saved: {output_file}")


if __name__ == "__main__":
    main()

πŸ“œ Git History

c6f6bd5chore: initial commit β€” version control setup5 weeks ago
Show last diff
Loading...