← Назад
""" 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()