CRITICAL FIX (Nov 30, 2025):
- Dashboard showed 'idle' despite 22+ worker processes running
- Root cause: SSH-based worker detection timing out
- Solution: Check database for running chunks FIRST
Changes:
1. app/api/cluster/status/route.ts:
- Query exploration database before SSH detection
- If running chunks exist, mark workers 'active' even if SSH fails
- Override worker status: 'offline' → 'active' when chunks running
- Log: '✅ Cluster status: ACTIVE (database shows running chunks)'
- Database is source of truth, SSH only for supplementary metrics
2. app/cluster/page.tsx:
- Stop button ALREADY EXISTS (conditionally shown)
- Shows Start when status='idle', Stop when status='active'
- No code changes needed - fixed by status detection
Result:
- Dashboard now shows 'ACTIVE' with 2 workers (correct)
- Workers show 'active' status (was 'offline')
- Stop button automatically visible when cluster active
- System resilient to SSH timeouts/network issues
Verified:
- Container restarted: Nov 30 21:18 UTC
- API tested: Returns status='active', activeWorkers=2
- Logs confirm: Database-first logic working
- Workers confirmed running: 22+ processes on worker1, workers on worker2
219 lines
8.2 KiB
Python
Executable File
219 lines
8.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Trade-Level Analysis - Find Patterns in Losing Trades
|
|
Purpose: Identify what conditions lead to losses
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Dict
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.append(str(PROJECT_ROOT))
|
|
|
|
import pandas as pd
|
|
from pathlib import Path
|
|
from tqdm import tqdm
|
|
from backtester.data_loader import load_csv
|
|
from backtester.indicators.money_line import MoneyLineInputs, money_line_signals
|
|
from backtester.simulator import TradeConfig, simulate_money_line, SimulatedTrade
|
|
|
|
|
|
def analyze_trades(trades: List[SimulatedTrade]) -> Dict:
|
|
"""Deep analysis of trade performance."""
|
|
if not trades:
|
|
return {"error": "No trades to analyze"}
|
|
|
|
winners = [t for t in trades if t.pnl > 0]
|
|
losers = [t for t in trades if t.pnl < 0]
|
|
|
|
print("\n" + "="*80)
|
|
print("TRADE PERFORMANCE BREAKDOWN")
|
|
print("="*80)
|
|
print(f"Total Trades: {len(trades)}")
|
|
print(f"Winners: {len(winners)} ({len(winners)/len(trades)*100:.1f}%)")
|
|
print(f"Losers: {len(losers)} ({len(losers)/len(trades)*100:.1f}%)")
|
|
|
|
if winners:
|
|
print(f"\nWinner Stats:")
|
|
print(f" Avg PnL: ${sum(t.pnl for t in winners)/len(winners):.2f}")
|
|
print(f" Max Win: ${max(t.pnl for t in winners):.2f}")
|
|
print(f" Avg MFE: {sum(t.mfe for t in winners)/len(winners):.2f}%")
|
|
print(f" Avg Bars: {sum(t.bars_held for t in winners)/len(winners):.1f}")
|
|
|
|
if losers:
|
|
print(f"\nLoser Stats:")
|
|
print(f" Avg PnL: ${sum(t.pnl for t in losers)/len(losers):.2f}")
|
|
print(f" Max Loss: ${min(t.pnl for t in losers):.2f}")
|
|
print(f" Avg MAE: {sum(t.mae for t in losers)/len(losers):.2f}%")
|
|
print(f" Avg Bars: {sum(t.bars_held for t in losers)/len(losers):.1f}")
|
|
|
|
# Total P&L stats
|
|
total_pnl = sum(t.pnl for t in trades)
|
|
print(f"\nTotal P&L: ${total_pnl:.2f}")
|
|
print(f"Avg Trade: ${total_pnl/len(trades):.2f}")
|
|
|
|
# MAE/MFE Analysis
|
|
print("\n" + "="*80)
|
|
print("MAE/MFE ANALYSIS (Max Adverse/Favorable Excursion)")
|
|
print("="*80)
|
|
|
|
# Winners that gave back profit
|
|
if winners:
|
|
large_mfe_winners = [t for t in winners if t.mfe > 2.0]
|
|
if large_mfe_winners:
|
|
avg_giveback = sum(t.mfe - (t.pnl / 8100.0 * 100) for t in large_mfe_winners) / len(large_mfe_winners)
|
|
print(f"Winners with >2% MFE: {len(large_mfe_winners)}")
|
|
print(f" Average profit given back: {avg_giveback:.2f}%")
|
|
print(f" 💡 Consider: Wider trailing stop or earlier TP2 trigger")
|
|
|
|
# Losers that could have been winners
|
|
if losers:
|
|
positive_mfe_losers = [t for t in losers if t.mfe > 0.5]
|
|
if positive_mfe_losers:
|
|
avg_peak = sum(t.mfe for t in positive_mfe_losers) / len(positive_mfe_losers)
|
|
print(f"\nLosers that reached >0.5% profit: {len(positive_mfe_losers)}")
|
|
print(f" Average peak profit: {avg_peak:.2f}%")
|
|
print(f" 💡 Consider: Tighter TP1 or better stop management")
|
|
|
|
# Direction analysis
|
|
print("\n" + "="*80)
|
|
print("DIRECTION ANALYSIS")
|
|
print("="*80)
|
|
|
|
longs = [t for t in trades if t.direction == 'long']
|
|
shorts = [t for t in trades if t.direction == 'short']
|
|
|
|
if longs:
|
|
long_wr = sum(1 for t in longs if t.pnl > 0) / len(longs) * 100
|
|
long_pnl = sum(t.pnl for t in longs)
|
|
print(f"LONGS: {len(longs)} trades, {long_wr:.1f}% WR, ${long_pnl:.2f} PnL")
|
|
|
|
if shorts:
|
|
short_wr = sum(1 for t in shorts if t.pnl > 0) / len(shorts) * 100
|
|
short_pnl = sum(t.pnl for t in shorts)
|
|
print(f"SHORTS: {len(shorts)} trades, {short_wr:.1f}% WR, ${short_pnl:.2f} PnL")
|
|
|
|
if longs and shorts:
|
|
if long_wr > short_wr + 10:
|
|
print(f" 💡 LONGs outperform: Consider quality threshold adjustment")
|
|
elif short_wr > long_wr + 10:
|
|
print(f" 💡 SHORTs outperform: Consider quality threshold adjustment")
|
|
|
|
return {
|
|
"total": len(trades),
|
|
"winners": len(winners),
|
|
"losers": len(losers),
|
|
"win_rate": len(winners) / len(trades) * 100,
|
|
"total_pnl": total_pnl
|
|
}
|
|
|
|
|
|
def find_exit_opportunities(trades: List[SimulatedTrade]):
|
|
"""Analyze if different exit strategy could improve results."""
|
|
print("\n" + "="*80)
|
|
print("EXIT STRATEGY ANALYSIS")
|
|
print("="*80)
|
|
|
|
# Current strategy stats
|
|
tp1_hits = sum(1 for t in trades if t.exit_type == 'tp1')
|
|
tp2_hits = sum(1 for t in trades if t.exit_type == 'tp2')
|
|
sl_hits = sum(1 for t in trades if t.exit_type == 'sl')
|
|
max_bars = sum(1 for t in trades if t.exit_type == 'max_bars')
|
|
|
|
print(f"Current Exit Distribution:")
|
|
print(f" TP1: {tp1_hits} ({tp1_hits/len(trades)*100:.1f}%)")
|
|
print(f" TP2: {tp2_hits} ({tp2_hits/len(trades)*100:.1f}%)")
|
|
print(f" SL: {sl_hits} ({sl_hits/len(trades)*100:.1f}%)")
|
|
print(f" Max Bars: {max_bars} ({max_bars/len(trades)*100:.1f}%)")
|
|
|
|
# Simulate tighter TP1
|
|
could_take_tp1 = [t for t in trades if t.mfe >= 0.5 and t.pnl < 0]
|
|
if could_take_tp1:
|
|
saved_pnl = len(could_take_tp1) * 0.005 * 8100 # 0.5% profit instead of loss
|
|
print(f"\n💡 Tighter TP1 (0.5%): Would save {len(could_take_tp1)} losing trades")
|
|
print(f" Estimated improvement: +${saved_pnl:.2f}")
|
|
|
|
# Check if runners are worth it
|
|
tp2_trades = [t for t in trades if t.exit_type == 'tp2']
|
|
if tp2_trades:
|
|
tp2_avg = sum(t.pnl for t in tp2_trades) / len(tp2_trades)
|
|
print(f"\nTP2/Runner Performance:")
|
|
print(f" {len(tp2_trades)} trades reached TP2")
|
|
print(f" Average TP2 profit: ${tp2_avg:.2f}")
|
|
if tp2_avg < 100:
|
|
print(f" 💡 Runners not adding much value - consider 100% TP1 close")
|
|
|
|
|
|
def compare_to_baseline(csv_path: Path, symbol: str, best_inputs: MoneyLineInputs):
|
|
"""Compare best config to baseline."""
|
|
print("\n" + "="*80)
|
|
print("BASELINE COMPARISON")
|
|
print("="*80)
|
|
|
|
print("\nLoading data and running simulations...")
|
|
data_slice = load_csv(Path(csv_path), symbol, "5m")
|
|
df = data_slice.data
|
|
config = TradeConfig(position_size=8100.0, max_bars_per_trade=288)
|
|
|
|
baseline = MoneyLineInputs()
|
|
print("\nRunning baseline config...")
|
|
baseline_result = simulate_money_line(df, symbol, baseline, config)
|
|
print(f" Trades: {len(baseline_result.trades)}")
|
|
print(f" PnL: ${baseline_result.total_pnl:.2f}")
|
|
print(f" WR: {baseline_result.win_rate:.1f}%")
|
|
|
|
print("\nRunning best sweep config...")
|
|
best_result = simulate_money_line(df, symbol, best_inputs, config)
|
|
print(f" Trades: {len(best_result.trades)}")
|
|
print(f" PnL: ${best_result.total_pnl:.2f}")
|
|
print(f" WR: {best_result.win_rate:.1f}%")
|
|
|
|
improvement = best_result.total_pnl - baseline_result.total_pnl
|
|
print(f"\nImprovement: ${improvement:.2f} ({improvement/baseline_result.total_pnl*100:.1f}%)")
|
|
|
|
return best_result.trades
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Trade-level analysis")
|
|
parser.add_argument("--csv", type=Path, required=True, help="Path to OHLCV CSV")
|
|
parser.add_argument("--symbol", default="SOL-PERP", help="Symbol name")
|
|
args = parser.parse_args()
|
|
|
|
print("="*80)
|
|
print("V9 TRADE ANALYSIS")
|
|
print("="*80)
|
|
|
|
# Use "best" config from sweep
|
|
best_inputs = MoneyLineInputs(
|
|
flip_threshold_percent=0.6,
|
|
ma_gap_threshold=0.35,
|
|
momentum_min_adx=23.0,
|
|
momentum_long_max_pos=70.0,
|
|
momentum_short_min_pos=25.0,
|
|
cooldown_bars=2,
|
|
momentum_spacing=3,
|
|
momentum_cooldown=2
|
|
)
|
|
|
|
trades = compare_to_baseline(args.csv, args.symbol, best_inputs)
|
|
analyze_trades(trades)
|
|
find_exit_opportunities(trades)
|
|
|
|
print("\n" + "="*80)
|
|
print("ACTIONABLE RECOMMENDATIONS")
|
|
print("="*80)
|
|
print("Based on this analysis, the next optimization steps should focus on:")
|
|
print("1. Exit strategy improvements (TP1/TP2 levels)")
|
|
print("2. Direction-specific quality thresholds")
|
|
print("3. Stop loss positioning")
|
|
print("4. Entry timing refinement")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|