diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..8e28c52
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,22 @@
+# Only the API code is needed β keep context minimal
+.git/
+.venv/
+venv/
+__pycache__/
+*.pyc
+node_modules/
+.next/
+*.log
+*.pkl
+*.joblib
+docs/
+archive/
+backtests/
+tests/
+scripts/
+models/
+logs/
+data/
+.env
+.env.local
+*.md
diff --git a/.env.docker.example b/.env.docker.example
new file mode 100644
index 0000000..d97cf64
--- /dev/null
+++ b/.env.docker.example
@@ -0,0 +1,39 @@
+# ===========================================
+# XAUBot AI - Docker Environment Configuration
+# ===========================================
+
+# ============== MT5 CONNECTION ==============
+# Your MetaTrader 5 account credentials
+MT5_LOGIN=your_mt5_login
+MT5_PASSWORD=your_mt5_password
+MT5_SERVER=your_mt5_server
+MT5_PATH=/path/to/mt5/terminal
+
+# ============== TRADING CONFIG ==============
+SYMBOL=XAUUSD
+CAPITAL=10000
+
+# ============== DATABASE ==============
+DB_HOST=postgres
+DB_PORT=5432
+DB_USER=trading_bot
+DB_PASSWORD=trading_bot_2026
+DB_NAME=trading_db
+
+# ============== TELEGRAM (Optional) ==============
+TELEGRAM_BOT_TOKEN=
+TELEGRAM_CHAT_ID=
+
+# ============== PORTS ==============
+# Ports accessible from host machine
+API_PORT=8000 # Trading API (FastAPI)
+DASHBOARD_PORT=3000 # Web Dashboard (Next.js)
+DB_PORT=5432 # PostgreSQL
+PGADMIN_PORT=5050 # pgAdmin (optional)
+
+# ============== PGADMIN (Optional) ==============
+PGADMIN_EMAIL=admin@trading.local
+PGADMIN_PASSWORD=admin123
+
+# ============== TIMEZONE ==============
+TZ=Asia/Jakarta
diff --git a/DOCKER-INTEGRATION.md b/DOCKER-INTEGRATION.md
new file mode 100644
index 0000000..3d3e609
--- /dev/null
+++ b/DOCKER-INTEGRATION.md
@@ -0,0 +1,255 @@
+# Dashboard Integration with Existing Docker Setup
+
+## π― Overview
+
+Dashboard dan API telah diintegrasikan ke dalam Docker setup yang **sudah ada**. Database PostgreSQL yang sudah running **TIDAK AKAN DIGANGGU**.
+
+## β
Existing Setup (Tidak Berubah)
+
+Yang sudah jalan dan **tetap aman**:
+- β
`trading_bot_db` - PostgreSQL database
+- β
`trading_bot_network` - Docker network
+- β
Database schema dengan 7 tables (trades, signals, dll)
+- β
Volume `postgres_data` untuk persistence
+
+## π New Services Added
+
+Layanan baru yang ditambahkan:
+1. **trading-api** - FastAPI backend untuk dashboard
+2. **dashboard** - Next.js web interface
+3. **pgadmin** - Database management (optional)
+
+## π Quick Start
+
+### Option 1: Gunakan Helper Script (Recommended)
+
+```cmd
+# Tambahkan dashboard ke setup yang sudah ada
+docker-add-dashboard.bat
+```
+
+Script ini akan:
+1. Check database yang sudah running
+2. Build API & Dashboard services
+3. Start kedua services baru
+4. Connect ke database & network yang sudah ada
+
+### Option 2: Manual Docker Compose
+
+```cmd
+# Build hanya services baru
+docker-compose build trading-api dashboard
+
+# Start hanya services baru
+docker-compose up -d trading-api dashboard
+```
+
+## π Access Points
+
+Setelah services running:
+- **Dashboard:** http://localhost:3000
+- **API:** http://localhost:8000
+- **API Docs:** http://localhost:8000/docs
+- **Database:** localhost:5432 (sudah running)
+
+## π§ Service Management
+
+### Check Status
+```cmd
+# Lihat status semua services
+docker-status.bat
+
+# Atau manual
+docker-compose ps
+```
+
+### View Logs
+```cmd
+# Logs dashboard
+docker-compose logs -f dashboard
+
+# Logs API
+docker-compose logs -f trading-api
+
+# Logs database
+docker-compose logs -f postgres
+```
+
+### Restart Services
+```cmd
+# Restart hanya dashboard
+docker-compose restart dashboard
+
+# Restart hanya API
+docker-compose restart trading-api
+
+# Restart semua (termasuk database)
+docker-compose restart
+```
+
+### Remove Dashboard (Keep Database)
+```cmd
+# Hapus dashboard tapi tetap keep database
+docker-remove-dashboard.bat
+
+# Atau manual
+docker-compose stop trading-api dashboard
+docker-compose rm -f trading-api dashboard
+```
+
+## π Service Architecture
+
+```
+βββββββββββββββββββββββββββββββββββββββββββββββ
+β trading_bot_network β
+βββββββββββββββββββββββββββββββββββββββββββββββ€
+β β
+β π Dashboard (NEW) β
+β Port: 3000 β
+β ββ> http://trading-api:8000 β
+β β
+β π Trading API (NEW) β
+β Port: 8000 β
+β ββ> postgres:5432 β
+β β
+β ποΈ PostgreSQL (EXISTING - NO CHANGE) β
+β Port: 5432 β
+β Status: Already Running β
+β Volume: postgres_data β
+β β
+βββββββββββββββββββββββββββββββββββββββββββββββ
+```
+
+## π Environment Variables
+
+Edit `.env` untuk konfigurasi:
+
+```env
+# MT5 (Required for API)
+MT5_LOGIN=your_login
+MT5_PASSWORD=your_password
+MT5_SERVER=your_server
+MT5_PATH=C:/Program Files/MetaTrader 5/terminal64.exe
+
+# Trading
+SYMBOL=XAUUSD
+CAPITAL=10000
+
+# Database (Already configured)
+DB_USER=trading_bot
+DB_PASSWORD=trading_bot_2026
+DB_NAME=trading_db
+
+# Ports
+API_PORT=8000
+DASHBOARD_PORT=3000
+DB_PORT=5432
+```
+
+## π Troubleshooting
+
+### Dashboard tidak bisa connect ke API
+
+**Check API health:**
+```cmd
+curl http://localhost:8000/api/health
+```
+
+**View API logs:**
+```cmd
+docker-compose logs -f trading-api
+```
+
+### API tidak bisa connect ke database
+
+**Check database:**
+```cmd
+docker exec trading_bot_db pg_isready -U trading_bot
+```
+
+**Check network:**
+```cmd
+docker network inspect trading_bot_network
+```
+
+### Port conflict
+
+Edit `.env` untuk ganti port:
+```env
+API_PORT=8001
+DASHBOARD_PORT=3001
+```
+
+Then restart:
+```cmd
+docker-compose down trading-api dashboard
+docker-compose up -d trading-api dashboard
+```
+
+## πΎ Data Persistence
+
+**Database data tetap aman:**
+- Volume `postgres_data` tetap ada
+- Hapus container tidak hapus data
+- Data tersimpan di Docker volume
+
+**Check volume:**
+```cmd
+docker volume ls | findstr postgres
+docker volume inspect trading_bot_postgres_data
+```
+
+## π Updates
+
+**Update code dan rebuild:**
+```cmd
+# Pull latest code
+git pull
+
+# Rebuild services baru
+docker-compose build trading-api dashboard
+
+# Restart
+docker-compose up -d trading-api dashboard
+```
+
+**Database tidak perlu rebuild** karena schema sudah ada.
+
+## β οΈ Important Notes
+
+1. **Database tidak boleh dihapus** - Data trades ada di sini
+2. **Jangan run `docker-compose down -v`** - Ini akan hapus volumes
+3. **Untuk stop semua:** `docker-compose stop` (data aman)
+4. **Untuk restart:** `docker-compose restart` atau `docker-compose up -d`
+
+## π Files Structure
+
+```
+xaubot-ai/
+βββ docker-compose.yml # Main orchestration (UPDATED)
+βββ Dockerfile # API image (NEW)
+βββ .env # Environment config
+βββ .dockerignore # Build exclusions
+βββ docker-add-dashboard.bat # Add dashboard script (NEW)
+βββ docker-remove-dashboard.bat # Remove dashboard script (NEW)
+βββ docker-status.bat # Status check script (NEW)
+βββ docker/
+β βββ init-db/
+β βββ 01-schema.sql # Database schema (EXISTING)
+βββ web-dashboard/
+ βββ Dockerfile # Dashboard image (NEW)
+ βββ .dockerignore # Build exclusions
+```
+
+## π― Summary
+
+β
**Database tetap jalan** - Tidak ada perubahan
+β
**Services baru ditambahkan** - API & Dashboard
+β
**Data aman** - Volume persistence
+β
**Easy management** - Helper scripts
+β
**Independent** - Bisa start/stop tanpa ganggu database
+
+---
+
+**Integration completed:** Feb 6, 2026
+**Status:** Dashboard integrated with existing Docker setup β¨
diff --git a/DOCKER-SETUP-SUMMARY.md b/DOCKER-SETUP-SUMMARY.md
new file mode 100644
index 0000000..9c446e1
--- /dev/null
+++ b/DOCKER-SETUP-SUMMARY.md
@@ -0,0 +1,401 @@
+# XAUBot AI - Docker Integration Summary
+
+## β
Completed Tasks
+
+### 1. **Created Dockerfile for Next.js Dashboard**
+- Multi-stage build for optimization
+- Standalone output for minimal image size
+- Production-ready configuration
+- Non-root user for security
+
+**Location:** `web-dashboard/Dockerfile`
+
+### 2. **Created Dockerfile for Python Trading API**
+- Python 3.11-slim base image
+- FastAPI server with health checks
+- Proper dependency management
+- Volume mounts for data/logs/models
+
+**Location:** `Dockerfile` (root directory)
+
+### 3. **Updated Docker Compose Configuration**
+- 4 services: postgres, trading-api, dashboard, pgadmin
+- Proper service dependencies and health checks
+- Custom bridge network for inter-service communication
+- Environment variable support via .env file
+- Volume persistence for database and pgadmin
+
+**Location:** `docker-compose.yml`
+
+### 4. **Created Environment Configuration**
+- Template with all required variables
+- Clear documentation for each setting
+- Default values for non-sensitive configs
+
+**Location:** `.env.docker.example`
+
+### 5. **Created Docker Ignore Files**
+- Excludes unnecessary files from images
+- Reduces build context size
+- Improves build performance
+
+**Locations:**
+- `web-dashboard/.dockerignore`
+- `.dockerignore` (root)
+
+### 6. **Created Helper Scripts**
+
+#### Windows Batch Scripts:
+- `docker-start.bat` - Start all services
+- `docker-stop.bat` - Stop services with options
+- `docker-logs.bat` - View service logs
+
+#### Linux/Mac Shell Scripts:
+- `docker-start.sh` - Start all services
+- `docker-stop.sh` - Stop services with options
+- `docker-logs.sh` - View service logs
+
+### 7. **Updated Next.js Configuration**
+- Enabled standalone output for Docker
+- Optimized for production builds
+
+**Location:** `web-dashboard/next.config.ts`
+
+### 8. **Created Comprehensive Documentation**
+- Complete Docker setup guide
+- Architecture diagram
+- Service management commands
+- Troubleshooting section
+- Security best practices
+- Performance tuning tips
+
+**Location:** `DOCKER.md`
+
+### 9. **Updated Main README**
+- Added Docker deployment section as recommended method
+- Clear quick start instructions
+- Links to full documentation
+
+**Location:** `README.md`
+
+## ποΈ Architecture
+
+```
+ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β Docker Network β
+β (trading_bot_network) β
+ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
+β β
+β βββββββββββββββββββ ββββββββββββββββββββ β
+β β Dashboard ββββββββββΆβ Trading API β β
+β β (Next.js) β HTTP β (FastAPI) β β
+β β Port: 3000 β β Port: 8000 β β
+β βββββββββββββββββββ ββββββββββ¬ββββββββββ β
+β β β
+β β PostgreSQL β
+β β Protocol β
+β β β
+β ββββββββββΌββββββββββ β
+β β PostgreSQL β β
+β β Database β β
+β β Port: 5432 β β
+β ββββββββββββββββββββ β
+β β
+β βββββββββββββββββββ (Optional - Admin Profile) β
+β β pgAdmin β β
+β β Port: 5050 β β
+β βββββββββββββββββββ β
+ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ β Exposed Ports
+ localhost:3000 (Dashboard)
+ localhost:8000 (API)
+ localhost:5432 (Database)
+ localhost:5050 (pgAdmin)
+```
+
+## π Quick Start Guide
+
+### 1. Initial Setup (One-time)
+
+```bash
+# Navigate to project
+cd "Smart Automatic Trading BOT + AI"
+
+# Create environment file
+copy .env.docker.example .env
+
+# Edit .env with your MT5 credentials
+notepad .env
+```
+
+**Required credentials in .env:**
+```env
+MT5_LOGIN=your_login
+MT5_PASSWORD=your_password
+MT5_SERVER=your_server
+MT5_PATH=/path/to/mt5/terminal
+```
+
+### 2. Start Services (Windows)
+
+**Option A: Using helper script (Recommended)**
+```cmd
+REM Start core services
+docker-start.bat
+
+REM Or start with pgAdmin
+docker-start.bat --admin
+```
+
+**Option B: Manual docker-compose**
+```cmd
+REM Build and start
+docker-compose up -d
+
+REM With pgAdmin
+docker-compose --profile admin up -d
+```
+
+### 3. Access the Dashboard
+
+Open your browser and go to:
+- **Dashboard:** http://localhost:3000
+
+You'll see:
+- Real-time price updates
+- Account balance and equity
+- Trading signals (SMC + ML)
+- Market regime
+- Open positions
+- Risk status
+- Activity logs
+
+### 4. Check Other Services
+
+- **API Docs:** http://localhost:8000/docs
+- **API Health:** http://localhost:8000/api/health
+- **API Status:** http://localhost:8000/api/status
+- **pgAdmin:** http://localhost:5050 (if started with --admin)
+
+## π Common Commands
+
+### View Logs
+```cmd
+REM All services
+docker-logs.bat
+
+REM Specific service
+docker-logs.bat trading-api
+docker-logs.bat dashboard
+docker-logs.bat postgres
+```
+
+### Check Status
+```cmd
+docker-compose ps
+```
+
+### Restart Services
+```cmd
+REM Restart all
+docker-compose restart
+
+REM Restart specific
+docker-compose restart trading-api
+docker-compose restart dashboard
+```
+
+### Stop Services
+```cmd
+REM Stop (keeps data)
+docker-stop.bat
+
+REM Stop and remove containers (keeps data)
+docker-stop.bat --remove
+
+REM Stop and remove everything including data (β οΈ DANGER!)
+docker-stop.bat --clean
+```
+
+### Update Code and Rebuild
+```cmd
+REM Pull latest code
+git pull
+
+REM Rebuild and restart
+docker-compose build
+docker-compose up -d
+```
+
+## π§ Configuration
+
+### Port Configuration
+
+Default ports can be changed in `.env`:
+
+```env
+API_PORT=8000 # Trading API
+DASHBOARD_PORT=3000 # Web Dashboard
+DB_PORT=5432 # PostgreSQL
+PGADMIN_PORT=5050 # pgAdmin
+```
+
+### Environment Variables
+
+All configuration is in `.env`:
+
+| Category | Variables |
+|----------|-----------|
+| **MT5** | MT5_LOGIN, MT5_PASSWORD, MT5_SERVER, MT5_PATH |
+| **Trading** | SYMBOL, CAPITAL |
+| **Database** | DB_USER, DB_PASSWORD, DB_NAME |
+| **Telegram** | TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID |
+| **Ports** | API_PORT, DASHBOARD_PORT, DB_PORT, PGADMIN_PORT |
+
+## π Troubleshooting
+
+### Dashboard Shows "Connection Error"
+
+**Check if API is running:**
+```cmd
+curl http://localhost:8000/api/health
+```
+
+**View API logs:**
+```cmd
+docker-logs.bat trading-api
+```
+
+### Port Already in Use
+
+**Find what's using the port:**
+```cmd
+netstat -ano | findstr :3000
+netstat -ano | findstr :8000
+```
+
+**Change port in .env:**
+```env
+DASHBOARD_PORT=3001
+API_PORT=8001
+```
+
+**Restart services:**
+```cmd
+docker-compose down
+docker-compose up -d
+```
+
+### Can't Connect to MT5
+
+1. Check credentials in `.env`
+2. Ensure MT5 terminal is accessible
+3. View API logs for connection errors:
+ ```cmd
+ docker-logs.bat trading-api
+ ```
+
+### Database Connection Issues
+
+**Check database health:**
+```cmd
+docker-compose ps postgres
+```
+
+**Test connection:**
+```cmd
+docker exec -it trading_bot_db pg_isready -U trading_bot
+```
+
+**View database logs:**
+```cmd
+docker-logs.bat postgres
+```
+
+## π Monitoring
+
+### View Real-time Logs
+```cmd
+REM Follow all logs
+docker-compose logs -f
+
+REM Follow specific service
+docker-compose logs -f trading-api
+```
+
+### Check Resource Usage
+```cmd
+docker stats
+```
+
+### Service Health
+```cmd
+REM All services
+docker-compose ps
+
+REM Detailed info
+docker inspect trading_bot_api
+docker inspect trading_bot_dashboard
+```
+
+## π Security Notes
+
+1. **Never commit .env file** - It contains sensitive credentials
+2. **Change default passwords** - Especially for database and pgAdmin
+3. **Use strong passwords** - For all services
+4. **Limit port exposure** - Only expose ports you need
+5. **Keep Docker updated** - Regular security updates
+
+## π File Structure
+
+```
+xaubot-ai/
+βββ Dockerfile # Python API Docker image
+βββ docker-compose.yml # Service orchestration
+βββ .env # Environment variables (DO NOT COMMIT)
+βββ .env.docker.example # Environment template
+βββ .dockerignore # Files to exclude from build
+βββ docker-start.bat # Windows start script
+βββ docker-stop.bat # Windows stop script
+βββ docker-logs.bat # Windows logs script
+βββ docker-start.sh # Linux/Mac start script
+βββ docker-stop.sh # Linux/Mac stop script
+βββ docker-logs.sh # Linux/Mac logs script
+βββ DOCKER.md # Full Docker documentation
+βββ web-dashboard/
+ βββ Dockerfile # Next.js dashboard image
+ βββ .dockerignore # Dashboard build exclusions
+ βββ next.config.ts # Next.js config (standalone output)
+```
+
+## π― Benefits of Docker Setup
+
+β
**Easy Setup** - One command to start everything
+β
**Consistent Environment** - Same setup on any machine
+β
**Isolated Services** - No conflicts with other software
+β
**Easy Updates** - Rebuild and restart to update
+β
**Production Ready** - Same setup for dev and production
+β
**Automatic Restarts** - Services auto-restart on crash
+β
**Health Monitoring** - Built-in health checks
+β
**Volume Persistence** - Data survives container restarts
+
+## π Additional Resources
+
+- **Full Documentation:** [DOCKER.md](DOCKER.md)
+- **Styling Guide:** [web-dashboard/STYLING-GUIDE.md](web-dashboard/STYLING-GUIDE.md)
+- **Docker Docs:** https://docs.docker.com
+- **Docker Compose:** https://docs.docker.com/compose
+
+## π Support
+
+If you encounter issues:
+
+1. Check the logs: `docker-logs.bat`
+2. Verify services: `docker-compose ps`
+3. Review troubleshooting section in [DOCKER.md](DOCKER.md)
+4. Check service health: `curl http://localhost:8000/api/health`
+
+---
+
+**Setup completed:** Feb 6, 2026
+**Ready to deploy!** π
diff --git a/DOCKER.md b/DOCKER.md
new file mode 100644
index 0000000..9b29394
--- /dev/null
+++ b/DOCKER.md
@@ -0,0 +1,437 @@
+# XAUBot AI - Docker Setup Guide
+
+Complete guide to running the XAUBot AI trading system with Docker.
+
+## π Prerequisites
+
+- Docker Engine 20.10+
+- Docker Compose 2.0+
+- 4GB+ RAM available
+- MetaTrader 5 account credentials
+
+## ποΈ Architecture
+
+The Docker setup includes 4 services:
+
+```
+βββββββββββββββββββββββββββββββββββββββββββββββββββ
+β Host Machine β
+βββββββββββββββββββββββββββββββββββββββββββββββββββ€
+β β
+β ββββββββββββββββ ββββββββββββββββ β
+β β Dashboard βββββββΆβ Trading API β β
+β β Next.js β β FastAPI β β
+β β Port: 3000 β β Port: 8000 β β
+β ββββββββββββββββ ββββββββ¬ββββββββ β
+β β β
+β ββββββββΌββββββββ β
+β β PostgreSQL β β
+β β Port: 5432 β β
+β ββββββββββββββββ β
+β β
+β ββββββββββββββββ (Optional - Profile: admin) β
+β β pgAdmin β β
+β β Port: 5050 β β
+β ββββββββββββββββ β
+βββββββββββββββββββββββββββββββββββββββββββββββββββ
+```
+
+### Services
+
+1. **postgres** - PostgreSQL 16 database for trade logging
+2. **trading-api** - Python FastAPI backend serving trading data
+3. **dashboard** - Next.js web interface for monitoring
+4. **pgadmin** - Database management UI (optional, admin profile)
+
+## π Quick Start
+
+### 1. Clone & Setup
+
+```bash
+cd "Smart Automatic Trading BOT + AI"
+
+# Copy environment template
+cp .env.docker.example .env
+```
+
+### 2. Configure Environment
+
+Edit `.env` file with your credentials:
+
+```bash
+# Required
+MT5_LOGIN=your_login
+MT5_PASSWORD=your_password
+MT5_SERVER=your_server
+MT5_PATH=/path/to/mt5
+
+# Optional - adjust ports if needed
+API_PORT=8000
+DASHBOARD_PORT=3000
+DB_PORT=5432
+```
+
+### 3. Start Services
+
+**Option A: All services (without pgAdmin)**
+```bash
+docker-compose up -d
+```
+
+**Option B: All services including pgAdmin**
+```bash
+docker-compose --profile admin up -d
+```
+
+**Option C: Specific services only**
+```bash
+# Just database and API
+docker-compose up -d postgres trading-api
+
+# Add dashboard
+docker-compose up -d dashboard
+```
+
+### 4. Access Services
+
+- **Dashboard**: http://localhost:3000
+- **Trading API**: http://localhost:8000
+- **API Docs**: http://localhost:8000/docs
+- **pgAdmin**: http://localhost:5050 (if using admin profile)
+- **PostgreSQL**: localhost:5432
+
+## π Service Management
+
+### View Logs
+
+```bash
+# All services
+docker-compose logs -f
+
+# Specific service
+docker-compose logs -f dashboard
+docker-compose logs -f trading-api
+docker-compose logs -f postgres
+
+# Last 50 lines
+docker-compose logs --tail=50 trading-api
+```
+
+### Check Status
+
+```bash
+# List running containers
+docker-compose ps
+
+# Check health
+docker-compose ps --format json | jq '.[].Health'
+
+# Detailed status
+docker inspect trading_bot_api
+```
+
+### Restart Services
+
+```bash
+# Restart all
+docker-compose restart
+
+# Restart specific service
+docker-compose restart trading-api
+docker-compose restart dashboard
+```
+
+### Stop Services
+
+```bash
+# Stop all (keeps data)
+docker-compose stop
+
+# Stop and remove containers (keeps data)
+docker-compose down
+
+# Stop and remove everything including volumes (β οΈ deletes data!)
+docker-compose down -v
+```
+
+## π§ Development & Debugging
+
+### Access Container Shell
+
+```bash
+# Trading API container
+docker exec -it trading_bot_api bash
+
+# Dashboard container
+docker exec -it trading_bot_dashboard sh
+
+# Database
+docker exec -it trading_bot_db psql -U trading_bot -d trading_db
+```
+
+### Rebuild After Code Changes
+
+```bash
+# Rebuild all
+docker-compose build
+
+# Rebuild specific service
+docker-compose build trading-api
+docker-compose build dashboard
+
+# Rebuild and restart
+docker-compose up -d --build
+```
+
+### View Resource Usage
+
+```bash
+# CPU, Memory, Network
+docker stats
+
+# Specific container
+docker stats trading_bot_api
+```
+
+## ποΈ Database Management
+
+### Connect to PostgreSQL
+
+```bash
+# Via Docker
+docker exec -it trading_bot_db psql -U trading_bot -d trading_db
+
+# Via host (if port exposed)
+psql -h localhost -p 5432 -U trading_bot -d trading_db
+```
+
+### Backup Database
+
+```bash
+# Create backup
+docker exec trading_bot_db pg_dump -U trading_bot trading_db > backup_$(date +%Y%m%d).sql
+
+# Restore backup
+docker exec -i trading_bot_db psql -U trading_bot -d trading_db < backup_20260206.sql
+```
+
+### Using pgAdmin
+
+1. Start with admin profile:
+ ```bash
+ docker-compose --profile admin up -d
+ ```
+
+2. Open http://localhost:5050
+
+3. Login:
+ - Email: admin@trading.local
+ - Password: admin123
+
+4. Add Server:
+ - Host: postgres
+ - Port: 5432
+ - Database: trading_db
+ - Username: trading_bot
+ - Password: trading_bot_2026
+
+## π Troubleshooting
+
+### Container Won't Start
+
+```bash
+# Check logs
+docker-compose logs trading-api
+
+# Check events
+docker events --filter container=trading_bot_api
+
+# Inspect container
+docker inspect trading_bot_api
+```
+
+### Port Already in Use
+
+```bash
+# Find what's using the port
+netstat -ano | findstr :3000
+netstat -ano | findstr :8000
+
+# Change port in .env
+DASHBOARD_PORT=3001
+API_PORT=8001
+
+# Restart
+docker-compose down
+docker-compose up -d
+```
+
+### API Can't Connect to MT5
+
+1. Check MT5 credentials in `.env`
+2. Ensure MT5 terminal is running (if running on host)
+3. Check container logs:
+ ```bash
+ docker-compose logs trading-api | grep MT5
+ ```
+
+### Dashboard Shows Connection Error
+
+1. Check if API is healthy:
+ ```bash
+ curl http://localhost:8000/api/health
+ ```
+
+2. Check API logs:
+ ```bash
+ docker-compose logs trading-api
+ ```
+
+3. Verify API_URL in dashboard:
+ ```bash
+ docker exec -it trading_bot_dashboard env | grep API
+ ```
+
+### Database Connection Issues
+
+```bash
+# Check if postgres is healthy
+docker-compose ps postgres
+
+# Test connection
+docker exec -it trading_bot_db pg_isready -U trading_bot
+
+# Check logs
+docker-compose logs postgres
+```
+
+## π Security Best Practices
+
+1. **Change Default Passwords**
+ ```bash
+ # In .env
+ DB_PASSWORD=strong_password_here
+ PGADMIN_PASSWORD=another_strong_password
+ ```
+
+2. **Don't Expose Unnecessary Ports**
+ ```yaml
+ # In docker-compose.yml, comment out if not needed:
+ # ports:
+ # - "5432:5432" # Only if you need external DB access
+ ```
+
+3. **Use Secrets for Production**
+ ```bash
+ # Use Docker secrets instead of .env
+ docker secret create mt5_password password.txt
+ ```
+
+4. **Restrict Network Access**
+ ```bash
+ # Only expose dashboard port
+ docker-compose up -d postgres trading-api
+ # Then separately: docker-compose up -d dashboard
+ ```
+
+## π Performance Tuning
+
+### Allocate More Resources
+
+```yaml
+# In docker-compose.yml
+services:
+ trading-api:
+ deploy:
+ resources:
+ limits:
+ cpus: '2.0'
+ memory: 2G
+ reservations:
+ cpus: '1.0'
+ memory: 1G
+```
+
+### Optimize Database
+
+```bash
+# Connect to DB
+docker exec -it trading_bot_db psql -U trading_bot -d trading_db
+
+# Run vacuum
+VACUUM ANALYZE;
+
+# Check table sizes
+SELECT schemaname, tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
+FROM pg_tables
+WHERE schemaname = 'public'
+ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
+```
+
+## π Updates & Maintenance
+
+### Update Images
+
+```bash
+# Pull latest base images
+docker-compose pull
+
+# Rebuild
+docker-compose build --no-cache
+
+# Restart
+docker-compose up -d
+```
+
+### Clean Up
+
+```bash
+# Remove unused images
+docker image prune -a
+
+# Remove unused volumes (β οΈ careful!)
+docker volume prune
+
+# Remove everything unused
+docker system prune -a --volumes
+```
+
+## π Environment Variables Reference
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `MT5_LOGIN` | - | MT5 account login |
+| `MT5_PASSWORD` | - | MT5 account password |
+| `MT5_SERVER` | - | MT5 server name |
+| `MT5_PATH` | - | Path to MT5 terminal |
+| `SYMBOL` | XAUUSD | Trading symbol |
+| `CAPITAL` | 10000 | Trading capital |
+| `API_PORT` | 8000 | API port on host |
+| `DASHBOARD_PORT` | 3000 | Dashboard port on host |
+| `DB_PORT` | 5432 | Database port on host |
+| `DB_USER` | trading_bot | Database username |
+| `DB_PASSWORD` | trading_bot_2026 | Database password |
+| `DB_NAME` | trading_db | Database name |
+| `TELEGRAM_BOT_TOKEN` | - | Telegram bot token (optional) |
+| `TELEGRAM_CHAT_ID` | - | Telegram chat ID (optional) |
+
+## π Additional Resources
+
+- **Docker Docs**: https://docs.docker.com
+- **Docker Compose**: https://docs.docker.com/compose
+- **FastAPI**: https://fastapi.tiangolo.com
+- **Next.js**: https://nextjs.org
+
+## π Getting Help
+
+If you encounter issues:
+
+1. Check logs: `docker-compose logs -f`
+2. Verify services: `docker-compose ps`
+3. Check health: `curl http://localhost:8000/api/health`
+4. Review this guide's troubleshooting section
+5. Open an issue on GitHub
+
+---
+
+**Last Updated:** Feb 6, 2026
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bc9c833
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+# Lightweight API server β reads bot_status.json from mounted volume
+FROM python:3.11-slim
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1
+
+WORKDIR /app
+
+# Install only API dependencies
+COPY web-dashboard/api/requirements.txt requirements.txt
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy only the API code
+COPY web-dashboard/api/main.py main.py
+
+# Create data directory (will be overridden by volume mount)
+RUN mkdir -p data
+
+EXPOSE 8000
+
+HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/QUICK-START.md b/QUICK-START.md
new file mode 100644
index 0000000..aeea0c5
--- /dev/null
+++ b/QUICK-START.md
@@ -0,0 +1,161 @@
+# Quick Start - Tambah Dashboard ke Docker Existing
+
+## Status Saat Ini
+
+β
**Docker Compose sudah ada**
+β
**Service `postgres` sudah running** (container: `trading_bot_db`)
+β
**Service `trading-api` dan `dashboard` sudah didefinisikan** tapi belum di-build
+
+## π Cara Menjalankan
+
+### 1. Setup Environment (Kalau Belum)
+
+```cmd
+cd "C:\Users\Administrator\Videos\Smart Automatic Trading BOT + AI"
+
+REM Copy environment template kalau belum ada
+copy .env.docker.example .env
+
+REM Edit dengan MT5 credentials Anda
+notepad .env
+```
+
+Pastikan isi `.env`:
+```env
+MT5_LOGIN=your_login
+MT5_PASSWORD=your_password
+MT5_SERVER=your_server
+MT5_PATH=C:/Program Files/MetaTrader 5/terminal64.exe
+
+SYMBOL=XAUUSD
+CAPITAL=10000
+```
+
+### 2. Build Services Baru
+
+```cmd
+REM Build trading-api dan dashboard
+docker-compose build trading-api dashboard
+```
+
+Ini akan:
+- Build Dockerfile untuk Python API
+- Build Dockerfile untuk Next.js Dashboard
+- Tidak ganggu database yang sudah running
+
+### 3. Start Services Baru
+
+```cmd
+REM Start trading-api dan dashboard
+docker-compose up -d trading-api dashboard
+```
+
+### 4. Check Status
+
+```cmd
+docker-compose ps
+```
+
+Output akan menunjukkan:
+```
+NAME STATUS PORTS
+trading_bot_db Up (healthy) 0.0.0.0:5432->5432/tcp
+trading_bot_api Up (healthy) 0.0.0.0:8000->8000/tcp
+trading_bot_dashboard Up (healthy) 0.0.0.0:3000->3000/tcp
+```
+
+### 5. Akses Dashboard
+
+Buka browser:
+- **Dashboard:** http://localhost:3000
+- **API:** http://localhost:8000
+- **API Docs:** http://localhost:8000/docs
+
+## π Commands Penting
+
+```cmd
+# Lihat logs
+docker-compose logs -f dashboard
+docker-compose logs -f trading-api
+
+# Restart service
+docker-compose restart trading-api
+docker-compose restart dashboard
+
+# Stop service
+docker-compose stop trading-api dashboard
+
+# Start lagi
+docker-compose up -d trading-api dashboard
+
+# Rebuild setelah update code
+docker-compose build trading-api dashboard
+docker-compose up -d trading-api dashboard
+```
+
+## π Troubleshooting
+
+### Build Error
+
+```cmd
+# Clean build
+docker-compose build --no-cache trading-api dashboard
+```
+
+### Service Tidak Start
+
+```cmd
+# Check logs
+docker-compose logs trading-api
+docker-compose logs dashboard
+
+# Check health
+curl http://localhost:8000/api/health
+curl http://localhost:3000
+```
+
+### Port Conflict
+
+Edit `.env`:
+```env
+API_PORT=8001
+DASHBOARD_PORT=3001
+```
+
+Lalu restart:
+```cmd
+docker-compose down trading-api dashboard
+docker-compose up -d trading-api dashboard
+```
+
+## β‘ One-Liner (All in One)
+
+```cmd
+cd "C:\Users\Administrator\Videos\Smart Automatic Trading BOT + AI" && docker-compose build trading-api dashboard && docker-compose up -d trading-api dashboard && docker-compose ps
+```
+
+## π Arsitektur
+
+```
+Docker Compose Project: "smart-automatic-trading-bot-ai"
+βββ postgres (RUNNING) β
+β βββ trading_bot_db
+βββ trading-api (BUILD & START) β‘
+β βββ trading_bot_api
+βββ dashboard (BUILD & START) β‘
+ βββ trading_bot_dashboard
+```
+
+## β
Checklist
+
+- [ ] Copy `.env.docker.example` ke `.env`
+- [ ] Edit `.env` dengan MT5 credentials
+- [ ] Run: `docker-compose build trading-api dashboard`
+- [ ] Run: `docker-compose up -d trading-api dashboard`
+- [ ] Check: `docker-compose ps`
+- [ ] Open: http://localhost:3000
+- [ ] Test API: http://localhost:8000/api/health
+
+---
+
+**That's it!** Simple kan? π
diff --git a/README.md b/README.md
index 2b95f5b..970cdff 100644
--- a/README.md
+++ b/README.md
@@ -119,13 +119,44 @@ xaubot-ai/
## Installation
-### Prerequisites
+### π³ Docker Deployment (Recommended)
+**Quick Start:**
+
+```bash
+# 1. Clone the repository
+git clone https://github.com/GifariKemal/xaubot-ai.git
+cd xaubot-ai
+
+# 2. Configure environment
+cp .env.docker.example .env
+# Edit .env with your MT5 credentials
+
+# 3. Start all services (Windows)
+docker-start.bat
+
+# 3. Start all services (Linux/Mac)
+./docker-start.sh
+```
+
+**Services will be available at:**
+- π Dashboard: http://localhost:3000
+- π API: http://localhost:8000
+- π API Docs: http://localhost:8000/docs
+- ποΈ Database: localhost:5432
+
+**Full Docker documentation:** See [DOCKER.md](DOCKER.md)
+
+---
+
+### π Manual Installation
+
+**Prerequisites:**
- Python 3.11+
- MetaTrader 5 terminal (Windows)
- PostgreSQL (optional, for trade logging)
-### Setup
+**Setup:**
```bash
# Clone the repository
diff --git a/SIMPLE-START.md b/SIMPLE-START.md
new file mode 100644
index 0000000..05617ad
--- /dev/null
+++ b/SIMPLE-START.md
@@ -0,0 +1,170 @@
+# Simple Start Guide - XAUBot AI Dashboard
+
+## π― Cara Tercepat (1 Command)
+
+```cmd
+start-all.bat
+```
+
+Script ini akan:
+1. β
Check database Docker container
+2. π Start Trading API di http://localhost:8000
+3. π Start Dashboard di http://localhost:3000
+
+Dua window akan terbuka otomatis!
+
+## π Manual Start (Jika Perlu)
+
+### Option 1: Start Semua Sekaligus
+```cmd
+start-all.bat
+```
+
+### Option 2: Start Satu-satu
+
+**Terminal 1: API**
+```cmd
+start-api.bat
+```
+
+**Terminal 2: Dashboard**
+```cmd
+start-dashboard.bat
+```
+
+## β
Pre-requisites
+
+### 1. Database (Docker)
+Database harus sudah running:
+```cmd
+# Check status
+docker ps | findstr trading_bot_db
+
+# Start jika belum running
+docker-compose up -d postgres
+```
+
+### 2. Python Environment
+- Python 3.11+ installed
+- Virtual environment akan dibuat otomatis
+
+### 3. Node.js
+- Node.js 18+ installed
+- npm dependencies akan diinstall otomatis
+
+## π Access Points
+
+Setelah start:
+- **Dashboard:** http://localhost:3000
+- **API:** http://localhost:8000
+- **API Docs:** http://localhost:8000/docs
+- **Health Check:** http://localhost:8000/api/health
+- **Status:** http://localhost:8000/api/status
+
+## π Stop Services
+
+Close kedua command windows atau tekan `Ctrl+C` di masing-masing window.
+
+## π Troubleshooting
+
+### API Error: "Module not found"
+
+Install dependencies:
+```cmd
+pip install -r requirements.txt
+```
+
+### Dashboard Error: "Module not found"
+
+Install dependencies:
+```cmd
+cd web-dashboard
+npm install
+```
+
+### Port Already in Use
+
+**Change API Port:**
+Edit `web-dashboard/api/main.py` line terakhir:
+```python
+uvicorn.run(app, host="0.0.0.0", port=8001) # Change 8000 to 8001
+```
+
+**Change Dashboard Port:**
+Edit `web-dashboard/.env.local`:
+```
+NEXT_PUBLIC_API_URL=http://localhost:8001
+```
+
+Then start dashboard on different port:
+```cmd
+cd web-dashboard
+set PORT=3001 && npm run dev
+```
+
+### Database Not Running
+
+Start database:
+```cmd
+docker-compose up -d postgres
+
+# Check status
+docker ps
+```
+
+## π Architecture
+
+```
+βββββββββββββββββββββββββββββββββββββββ
+β Windows Host Machine β
+βββββββββββββββββββββββββββββββββββββββ€
+β β
+β π Dashboard (Port 3000) β
+β npm run dev β
+β β HTTP β
+β π API (Port 8000) β
+β uvicorn main:app β
+β β PostgreSQL β
+β ποΈ Database (Docker) β
+β trading_bot_db β
+β β
+βββββββββββββββββββββββββββββββββββββββ
+```
+
+## π¨ Features
+
+Dashboard akan menampilkan:
+- β° Real-time XAUUSD price
+- π° Account balance & equity
+- π Price history chart
+- π― Trading signals (SMC + ML)
+- π Market regime
+- β οΈ Risk status
+- π Open positions
+- π Activity logs
+
+## π‘ Tips
+
+1. **Auto-start Database:**
+ Tambahkan Docker Desktop ke Windows startup
+
+2. **Keep API Running:**
+ Minimize command windows, jangan close
+
+3. **Monitor Logs:**
+ Lihat output di command windows untuk debug
+
+4. **Quick Restart:**
+ Close windows dan run `start-all.bat` lagi
+
+## π Files
+
+```
+start-all.bat # Start API + Dashboard
+start-api.bat # Start API only
+start-dashboard.bat # Start Dashboard only
+```
+
+---
+
+**Super Simple!** Tinggal double-click `start-all.bat` π
diff --git a/backtests/backtest_26_sell_improvement.py b/backtests/backtest_26_sell_improvement.py
new file mode 100644
index 0000000..4045acb
--- /dev/null
+++ b/backtests/backtest_26_sell_improvement.py
@@ -0,0 +1,1010 @@
+"""
+Backtest #26 β SELL Improvement
+================================
+Base: #24B (19B+20B+22D) β 739 trades, 80.4% WR, $2,235, Sharpe 2.87
+
+Problem: SELL WR 76.5% vs BUY 83.1% (6.6pp gap)
+Goal: Improve SELL quality without reducing BUY performance
+
+Configs:
+ A: Higher SELL confidence threshold (>=0.55 vs no filter)
+ B: SELL requires bearish BOS (not just CHoCH)
+ C: Asymmetric RR (SELL 1:1.2, BUY 1:1.5)
+ D: A+B combined (higher confidence + bearish BOS)
+ E: A+B+C combined (all three improvements)
+
+Usage:
+ python backtests/backtest_26_sell_improvement.py
+"""
+
+import polars as pl
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta, date
+from typing import Dict, List, Tuple, Optional
+from dataclasses import dataclass, field
+from enum import Enum
+import sys
+import os
+from zoneinfo import ZoneInfo
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from src.mt5_connector import MT5Connector
+from src.smc_polars import SMCAnalyzer, SMCSignal
+from src.feature_eng import FeatureEngineer
+from src.regime_detector import MarketRegimeDetector, MarketRegime
+from src.ml_model import TradingModel
+from src.config import get_config
+from src.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence, MarketQuality
+from loguru import logger
+
+logger.remove()
+logger.add(sys.stderr, level="WARNING")
+
+WIB = ZoneInfo("Asia/Jakarta")
+
+
+# βββ Enums & Dataclasses ββββββββββββββββββββββββββββββββββββββ
+
+class TradeResult(Enum):
+ WIN = "WIN"
+ LOSS = "LOSS"
+ BREAKEVEN = "BREAKEVEN"
+
+
+class ExitReason(Enum):
+ TAKE_PROFIT = "take_profit"
+ SMART_TP = "smart_tp"
+ PEAK_PROTECT = "peak_protect"
+ EARLY_EXIT = "early_exit"
+ EARLY_CUT = "early_cut"
+ MAX_LOSS = "max_loss"
+ STALL = "stall"
+ TREND_REVERSAL = "trend_reversal"
+ TIMEOUT = "timeout"
+ WEEKEND_CLOSE = "weekend_close"
+ TRAILING_SL = "trailing_sl"
+ BREAKEVEN_EXIT = "breakeven_exit"
+ DAILY_LIMIT = "daily_limit"
+ REGIME_DANGER = "regime_danger"
+ MARKET_SIGNAL = "market_signal"
+
+
+class TradingMode(Enum):
+ NORMAL = "normal"
+ RECOVERY = "recovery"
+ PROTECTED = "protected"
+ STOPPED = "stopped"
+
+
+@dataclass
+class SimulatedTrade:
+ ticket: int
+ entry_time: datetime
+ exit_time: datetime
+ direction: str
+ entry_price: float
+ exit_price: float
+ stop_loss: float
+ take_profit: float
+ lot_size: float
+ profit_usd: float
+ profit_pips: float
+ result: TradeResult
+ exit_reason: ExitReason
+ smc_confidence: float
+ regime: str
+ session: str
+ signal_reason: str
+ has_bos: bool = False
+ has_choch: bool = False
+ has_fvg: bool = False
+ has_ob: bool = False
+ atr_at_entry: float = 0.0
+ rr_ratio: float = 0.0
+ trading_mode: str = "normal"
+
+
+@dataclass
+class BacktestStats:
+ total_trades: int = 0
+ wins: int = 0
+ losses: int = 0
+ total_profit: float = 0.0
+ total_loss: float = 0.0
+ max_drawdown: float = 0.0
+ max_drawdown_usd: float = 0.0
+ win_rate: float = 0.0
+ profit_factor: float = 0.0
+ avg_win: float = 0.0
+ avg_loss: float = 0.0
+ avg_trade: float = 0.0
+ expectancy: float = 0.0
+ sharpe_ratio: float = 0.0
+ trades: List[SimulatedTrade] = field(default_factory=list)
+ equity_curve: List[float] = field(default_factory=list)
+ avoided_signals: int = 0
+ daily_limit_stops: int = 0
+ recovery_mode_trades: int = 0
+ session_blocked: int = 0
+ sell_filtered: int = 0 # NEW: track SELL-specific filters
+
+
+# βββ SELL Improvement Backtest ββββββββββββββββββββββββββββββββββ
+
+class SellImprovementBacktest:
+ """#24B base + SELL-specific improvements."""
+
+ def __init__(
+ self,
+ capital: float = 5000.0,
+ max_daily_loss_percent: float = 5.0,
+ max_loss_per_trade_percent: float = 1.0,
+ base_lot_size: float = 0.01,
+ max_lot_size: float = 0.02,
+ recovery_lot_size: float = 0.01,
+ trend_reversal_threshold: float = 0.75,
+ max_concurrent_positions: int = 2,
+ min_profit_to_protect: float = 5.0,
+ max_drawdown_from_peak: float = 50.0,
+ trade_cooldown_bars: int = 10,
+ trend_reversal_mult: float = 0.6,
+ # #24B base settings
+ skip_tokyo_london: bool = True,
+ early_cut_momentum: float = -50.0,
+ early_cut_loss_pct: float = 30.0,
+ be_mult: float = 2.0,
+ trail_start_mult: float = 4.0,
+ trail_step_mult: float = 3.0,
+ # βββ #26 SELL IMPROVEMENT PARAMS βββ
+ sell_min_confidence: float = 0.0, # Min confidence for SELL (0 = no filter)
+ sell_require_bos: bool = False, # Require bearish BOS for SELL
+ sell_rr_multiplier: float = 1.0, # Multiply SELL RR (0.8 = 1:1.2 instead of 1:1.5)
+ ):
+ self.capital = capital
+ self.max_daily_loss_usd = capital * (max_daily_loss_percent / 100)
+ self.max_loss_per_trade = capital * (max_loss_per_trade_percent / 100)
+ self.base_lot_size = base_lot_size
+ self.max_lot_size = max_lot_size
+ self.recovery_lot_size = recovery_lot_size
+ self.trend_reversal_threshold = trend_reversal_threshold
+ self.max_concurrent_positions = max_concurrent_positions
+ self.min_profit_to_protect = min_profit_to_protect
+ self.max_drawdown_from_peak = max_drawdown_from_peak
+ self.trade_cooldown_bars = trade_cooldown_bars
+ self.trend_reversal_mult = trend_reversal_mult
+
+ self.skip_tokyo_london = skip_tokyo_london
+ self.early_cut_momentum = early_cut_momentum
+ self.early_cut_loss_pct = early_cut_loss_pct
+ self.be_mult = be_mult
+ self.trail_start_mult = trail_start_mult
+ self.trail_step_mult = trail_step_mult
+
+ # #26 SELL params
+ self.sell_min_confidence = sell_min_confidence
+ self.sell_require_bos = sell_require_bos
+ self.sell_rr_multiplier = sell_rr_multiplier
+
+ config = get_config()
+ self.smc = SMCAnalyzer(
+ swing_length=config.smc.swing_length,
+ ob_lookback=config.smc.ob_lookback,
+ )
+ self.features = FeatureEngineer()
+ self.dynamic_confidence = create_dynamic_confidence()
+
+ self.ml_model = TradingModel(model_path="models/xgboost_model.pkl")
+ try:
+ self.ml_model.load()
+ print(" ML model loaded (for exit evaluation)")
+ except Exception:
+ print(" [WARN] ML model not loaded")
+
+ self.regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ self.regime_detector.load()
+ except Exception:
+ print(" [WARN] HMM model not loaded")
+
+ self._ticket_counter = 2260000
+
+ # ββ Session filter (#19B: skip Tokyo-London) ββ
+
+ def _get_session_from_time(self, dt: datetime) -> Tuple[str, bool, float]:
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib_time = dt.astimezone(WIB)
+ hour = wib_time.hour
+ if 6 <= hour < 15:
+ return "Sydney-Tokyo", True, 0.5
+ elif 15 <= hour < 16:
+ if self.skip_tokyo_london:
+ return "Tokyo-London Overlap", False, 0.0
+ return "Tokyo-London Overlap", True, 0.75
+ elif 16 <= hour < 19:
+ return "London Early", True, 0.8
+ elif 19 <= hour < 24:
+ return "London-NY Overlap (Golden)", True, 1.0
+ elif 0 <= hour < 4:
+ return "NY Session", True, 0.9
+ else:
+ return "Off Hours", False, 0.0
+
+ def _hours_to_golden(self, dt: datetime) -> float:
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ if 19 <= wib.hour < 24:
+ return 0
+ target = wib.replace(hour=19, minute=0, second=0, microsecond=0)
+ if wib.hour >= 19:
+ target += timedelta(days=1)
+ return max(0, (target - wib).total_seconds() / 3600)
+
+ def _is_near_weekend_close(self, dt: datetime) -> bool:
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ if wib.weekday() == 5 and wib.hour >= 4 and wib.minute >= 30:
+ return True
+ return False
+
+ # ββ Lot sizing (synced) ββ
+
+ def _calculate_lot_size(self, confidence, regime, trading_mode, session_mult):
+ if trading_mode == TradingMode.STOPPED:
+ return 0
+ lot = self.base_lot_size
+ if trading_mode in (TradingMode.RECOVERY, TradingMode.PROTECTED):
+ lot = self.recovery_lot_size
+ else:
+ if confidence >= 0.65:
+ lot = self.max_lot_size
+ elif confidence >= 0.55:
+ lot = self.base_lot_size
+ else:
+ lot = self.recovery_lot_size
+ if regime.lower() in ["high_volatility", "crisis"]:
+ lot = self.recovery_lot_size
+ lot = max(0.01, lot * session_mult)
+ return round(lot, 2)
+
+ # ββ Full exit simulation (#24B base) ββ
+
+ def _simulate_trade_exit(
+ self, df, entry_idx, direction, entry_price, take_profit, stop_loss,
+ lot_size, daily_loss_so_far, feature_cols, max_bars=100,
+ ) -> Tuple[float, float, ExitReason, int, float]:
+ pip_value = 10
+
+ highs = df["high"].to_list()
+ lows = df["low"].to_list()
+ closes = df["close"].to_list()
+ times = df["time"].to_list()
+
+ atr = 12.0
+ if "atr" in df.columns:
+ atr_list = df["atr"].to_list()
+ if entry_idx < len(atr_list) and atr_list[entry_idx] is not None:
+ atr = atr_list[entry_idx]
+
+ # βββ #22D: ATR-ADAPTIVE exit levels βββ
+ adaptive_breakeven_pips = atr * self.be_mult
+ adaptive_trail_start_pips = atr * self.trail_start_mult
+ adaptive_trail_step_pips = atr * self.trail_step_mult
+
+ reversal_momentum_threshold = atr * self.trend_reversal_mult
+ min_loss_for_reversal_exit = atr * 0.8
+
+ profit_history = []
+ price_history = []
+ peak_profit = 0.0
+ stall_count = 0
+ reversal_warnings = 0
+
+ current_sl = stop_loss
+ breakeven_moved = False
+
+ if direction == "BUY":
+ target_tp_profit = (take_profit - entry_price) / 0.1 * pip_value * lot_size
+ else:
+ target_tp_profit = (entry_price - take_profit) / 0.1 * pip_value * lot_size
+
+ cached_ml_signal = ""
+ cached_ml_confidence = 0.5
+
+ for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
+ high = highs[i]
+ low = lows[i]
+ close = closes[i]
+ current_time = times[i]
+
+ if direction == "BUY":
+ current_pips = (close - entry_price) / 0.1
+ pip_profit_from_entry = (close - entry_price) / 0.1
+ else:
+ current_pips = (entry_price - close) / 0.1
+ pip_profit_from_entry = (entry_price - close) / 0.1
+ current_profit = current_pips * pip_value * lot_size
+
+ profit_history.append(current_profit)
+ price_history.append(close)
+ if current_profit > peak_profit:
+ peak_profit = current_profit
+
+ bars_since_entry = i - entry_idx
+
+ if bars_since_entry % 4 == 0 and self.ml_model.fitted:
+ try:
+ df_slice = df.head(i + 1)
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ cached_ml_signal = ml_pred.signal
+ cached_ml_confidence = ml_pred.confidence
+ except Exception:
+ pass
+
+ momentum = 0.0
+ if len(profit_history) >= 3:
+ recent = profit_history[-5:] if len(profit_history) >= 5 else profit_history
+ profit_change = recent[-1] - recent[0]
+ momentum = max(-100, min(100, (profit_change / 10) * 50))
+
+ profit_growing = momentum > 0
+
+ # ββββββββββββββββββββββββββββββββββββββββββββββββ
+ # A) SmartPositionManager (ATR-ADAPTIVE)
+ # ββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ # A.0 TP hit
+ if direction == "BUY" and high >= take_profit:
+ pips = (take_profit - entry_price) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+ elif direction == "SELL" and low <= take_profit:
+ pips = (entry_price - take_profit) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+
+ # A.0b Trailing SL hit
+ if breakeven_moved and current_sl > 0:
+ if direction == "BUY" and low <= current_sl:
+ pips = (current_sl - entry_price) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+ elif direction == "SELL" and high >= current_sl:
+ pips = (entry_price - current_sl) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+
+ # A.1 Breakeven (ATR-ADAPTIVE)
+ if pip_profit_from_entry >= adaptive_breakeven_pips and not breakeven_moved:
+ if direction == "BUY":
+ current_sl = entry_price + 2
+ else:
+ current_sl = entry_price - 2
+ breakeven_moved = True
+
+ # A.2 Trailing SL (ATR-ADAPTIVE)
+ if pip_profit_from_entry >= adaptive_trail_start_pips:
+ trail_distance = adaptive_trail_step_pips * 0.1
+ if direction == "BUY":
+ new_trail_sl = close - trail_distance
+ if new_trail_sl > current_sl:
+ current_sl = new_trail_sl
+ else:
+ new_trail_sl = close + trail_distance
+ if current_sl == 0 or new_trail_sl < current_sl:
+ current_sl = new_trail_sl
+
+ # A.3 Peak protect
+ if peak_profit > self.min_profit_to_protect:
+ drawdown_pct = ((peak_profit - current_profit) / peak_profit) * 100 if peak_profit > 0 else 0
+ if drawdown_pct > self.max_drawdown_from_peak:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+
+ # A.4 Market analysis
+ if bars_since_entry % 5 == 0 and bars_since_entry >= 5:
+ if i >= 20:
+ ma_fast = np.mean(closes[i-4:i+1])
+ ma_slow = np.mean(closes[i-19:i+1])
+ trend = "NEUTRAL"
+ if ma_fast > ma_slow * 1.001:
+ trend = "BULLISH"
+ elif ma_fast < ma_slow * 0.999:
+ trend = "BEARISH"
+
+ roc = (closes[i] / closes[max(0,i-4)] - 1) * 100
+ mom_dir = "BULLISH" if roc > 0.3 else ("BEARISH" if roc < -0.3 else "NEUTRAL")
+
+ rsi_val = None
+ if "rsi" in df.columns:
+ rsi_list = df["rsi"].to_list()
+ if i < len(rsi_list):
+ rsi_val = rsi_list[i]
+
+ urgency = 0
+ should_exit = False
+
+ if cached_ml_confidence > 0.75:
+ if direction == "BUY" and cached_ml_signal == "SELL":
+ should_exit = True
+ urgency += 2
+ elif direction == "SELL" and cached_ml_signal == "BUY":
+ should_exit = True
+ urgency += 2
+
+ if rsi_val:
+ if rsi_val > 75 and direction == "BUY":
+ should_exit = True
+ urgency += 2
+ elif rsi_val < 25 and direction == "SELL":
+ should_exit = True
+ urgency += 2
+
+ if direction == "BUY" and trend == "BEARISH" and mom_dir == "BEARISH":
+ should_exit = True
+ urgency += 3
+ elif direction == "SELL" and trend == "BULLISH" and mom_dir == "BULLISH":
+ should_exit = True
+ urgency += 3
+
+ if should_exit and current_profit > self.min_profit_to_protect / 2:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+ if urgency >= 7 and current_profit > 0:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+
+ # A.5 Weekend close
+ if self._is_near_weekend_close(current_time):
+ if current_profit > 0:
+ return current_profit, current_pips, ExitReason.WEEKEND_CLOSE, i, close
+ elif current_profit > -10:
+ return current_profit, current_pips, ExitReason.WEEKEND_CLOSE, i, close
+
+ # ββββββββββββββββββββββββββββββββββββββββββββββββ
+ # B) SmartRiskManager (#20B early cut tuned)
+ # ββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ # B.1 Smart TP
+ if current_profit >= 15:
+ if current_profit >= 40:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if current_profit >= 25 and momentum < -30:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if peak_profit > 30 and current_profit < peak_profit * 0.6:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+ if current_profit >= 20:
+ progress = (current_profit / target_tp_profit) * 100 if target_tp_profit > 0 else 0
+ progress_score = min(40, max(0, progress * 0.4))
+ momentum_score = ((momentum + 100) / 200) * 30
+ time_penalty = min(10, bars_since_entry / 4 * 2)
+ tp_probability = progress_score + momentum_score + 10 - time_penalty
+ if tp_probability < 25:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+
+ # B.2 Smart Early Exit
+ if 5 <= current_profit < 15:
+ if momentum < -50 and cached_ml_confidence >= 0.65:
+ is_reversal = (
+ (direction == "BUY" and cached_ml_signal == "SELL") or
+ (direction == "SELL" and cached_ml_signal == "BUY")
+ )
+ if is_reversal:
+ return current_profit, current_pips, ExitReason.EARLY_EXIT, i, close
+
+ # B.3 Early cut (#20B: momentum < -50 instead of -30)
+ if current_profit < 0:
+ loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
+ if momentum < self.early_cut_momentum and loss_percent_of_max >= self.early_cut_loss_pct:
+ return current_profit, current_pips, ExitReason.EARLY_CUT, i, close
+
+ # B.4 Trend Reversal
+ is_ml_reversal = False
+ if direction == "BUY" and cached_ml_signal == "SELL" and cached_ml_confidence >= self.trend_reversal_threshold:
+ is_ml_reversal = True
+ reversal_warnings += 1
+ elif direction == "SELL" and cached_ml_signal == "BUY" and cached_ml_confidence >= self.trend_reversal_threshold:
+ is_ml_reversal = True
+ reversal_warnings += 1
+
+ loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4)
+ if is_ml_reversal and current_profit < -8 and loss_moderate:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+ if reversal_warnings >= 3 and current_profit < -10:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ # B.5 Max loss
+ if current_profit <= -(self.max_loss_per_trade * 0.50):
+ htg = self._hours_to_golden(current_time)
+ if htg <= 1 and htg > 0 and momentum > -40:
+ pass
+ else:
+ return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
+
+ # B.6 Stall detection
+ if len(profit_history) >= 10:
+ recent_range = max(profit_history[-10:]) - min(profit_history[-10:])
+ if recent_range < 3 and current_profit < -15:
+ stall_count += 1
+ if stall_count >= 5:
+ return current_profit, current_pips, ExitReason.STALL, i, close
+
+ # B.7 Daily loss limit
+ potential_daily_loss = daily_loss_so_far + abs(min(0, current_profit))
+ if potential_daily_loss >= self.max_daily_loss_usd:
+ return current_profit, current_pips, ExitReason.DAILY_LIMIT, i, close
+
+ # ββββββββββββββββββββββββββββββββββββββββββββββββ
+ # C) Time-based exit
+ # ββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ if bars_since_entry >= 16:
+ if current_profit < 5 and not profit_growing:
+ if current_profit >= 0:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ elif current_profit > -15:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+
+ if bars_since_entry >= 24:
+ if current_profit < 10 or not profit_growing:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+
+ if bars_since_entry >= 32:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+
+ # C.2 ATR trend reversal
+ if bars_since_entry > 10:
+ recent_closes = closes[i-5:i+1]
+ mom = recent_closes[-1] - recent_closes[0]
+ if direction == "BUY" and mom < -reversal_momentum_threshold:
+ if current_profit < -min_loss_for_reversal_exit:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+ elif direction == "SELL" and mom > reversal_momentum_threshold:
+ if current_profit < -min_loss_for_reversal_exit:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ final_idx = min(entry_idx + max_bars - 1, len(df) - 1)
+ final_price = closes[final_idx]
+ if direction == "BUY":
+ pips = (final_price - entry_price) / 0.1
+ else:
+ pips = (entry_price - final_price) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TIMEOUT, final_idx, final_price
+
+ # ββ Main run ββ
+
+ def run(self, df, start_date=None, end_date=None, initial_capital=5000.0):
+ stats = BacktestStats()
+ capital = initial_capital
+ peak_capital = initial_capital
+ stats.equity_curve.append(capital)
+
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ consecutive_losses = 0
+ trading_mode = TradingMode.NORMAL
+ current_date = None
+
+ feature_cols = []
+ if self.ml_model.fitted and self.ml_model.feature_names:
+ feature_cols = [f for f in self.ml_model.feature_names if f in df.columns]
+
+ times = df["time"].to_list()
+ start_idx = next((i for i, t in enumerate(times) if t >= start_date), 100) if start_date else 100
+ end_idx = next((i for i, t in enumerate(times) if t > end_date), len(df) - 100) if end_date else len(df) - 100
+
+ last_trade_idx = -self.trade_cooldown_bars * 2
+
+ print(f" #26 SELL filters: min_conf={self.sell_min_confidence}, require_bos={self.sell_require_bos}, rr_mult={self.sell_rr_multiplier}")
+ print(f" Date range: {times[start_idx]} to {times[end_idx - 1]}")
+ print(f" Total bars: {end_idx - start_idx}")
+
+ for i in range(start_idx, end_idx):
+ if i - last_trade_idx < self.trade_cooldown_bars:
+ continue
+
+ current_time = times[i]
+
+ trade_date = current_time.date() if hasattr(current_time, 'date') else current_time
+ if current_date is None or trade_date != current_date:
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ current_date = trade_date
+ if consecutive_losses < 2:
+ trading_mode = TradingMode.NORMAL
+
+ if trading_mode == TradingMode.STOPPED:
+ continue
+
+ session_name, can_trade, lot_mult = self._get_session_from_time(current_time)
+ if not can_trade:
+ if session_name == "Tokyo-London Overlap":
+ stats.session_blocked += 1
+ continue
+
+ if hasattr(current_time, 'weekday') and current_time.weekday() >= 5:
+ continue
+
+ df_slice = df.head(i + 1)
+
+ regime = "normal"
+ try:
+ if self.regime_detector.fitted:
+ regime_state = self.regime_detector.get_current_state(df_slice)
+ if regime_state:
+ regime = regime_state.regime.value
+ if regime_state.regime == MarketRegime.CRISIS:
+ continue
+ if regime_state.recommendation == "SLEEP":
+ continue
+ except Exception:
+ pass
+
+ try:
+ ml_signal = ""
+ ml_confidence = 0.5
+ if self.ml_model.fitted and feature_cols:
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ ml_signal = ml_pred.signal
+ ml_confidence = ml_pred.confidence
+
+ market_analysis = self.dynamic_confidence.analyze_market(
+ session=session_name, regime=regime, volatility="medium",
+ trend_direction=regime, has_smc_signal=True,
+ ml_signal=ml_signal, ml_confidence=ml_confidence,
+ )
+ if market_analysis.quality == MarketQuality.AVOID:
+ stats.avoided_signals += 1
+ continue
+ except Exception:
+ pass
+
+ try:
+ smc_signal = self.smc.generate_signal(df_slice)
+ except Exception:
+ continue
+
+ if smc_signal is None:
+ continue
+
+ # βββ Detect SMC components (direction-aware for #26) βββ
+ recent_df = df_slice.tail(10)
+ recent_bos = recent_df["bos"].to_list() if "bos" in df_slice.columns else []
+ recent_choch = recent_df["choch"].to_list() if "choch" in df_slice.columns else []
+ recent_fvg_bull = recent_df["is_fvg_bull"].to_list() if "is_fvg_bull" in df_slice.columns else []
+ recent_fvg_bear = recent_df["is_fvg_bear"].to_list() if "is_fvg_bear" in df_slice.columns else []
+ recent_obs = recent_df["ob"].to_list() if "ob" in df_slice.columns else []
+
+ has_bos = 1 in recent_bos or -1 in recent_bos
+ has_choch = 1 in recent_choch or -1 in recent_choch
+ has_fvg = any(recent_fvg_bull) or any(recent_fvg_bear)
+ has_ob = 1 in recent_obs or -1 in recent_obs
+
+ # Direction-specific BOS check for SELL filter
+ has_bearish_bos = -1 in recent_bos
+
+ atr_at_entry = 12.0
+ if "atr" in df_slice.columns:
+ atr_val = df_slice.tail(1)["atr"].item()
+ if atr_val is not None and atr_val > 0:
+ atr_at_entry = atr_val
+
+ confidence = smc_signal.confidence
+ ml_agrees = (
+ (smc_signal.signal_type == "BUY" and ml_signal == "BUY") or
+ (smc_signal.signal_type == "SELL" and ml_signal == "SELL")
+ )
+ if ml_agrees:
+ confidence = (smc_signal.confidence + ml_confidence) / 2
+ if regime == "high_volatility":
+ confidence *= 0.9
+
+ # βββ #26 SELL FILTERS βββ
+ if smc_signal.signal_type == "SELL":
+ # Filter A: Min confidence for SELL
+ if self.sell_min_confidence > 0 and confidence < self.sell_min_confidence:
+ stats.sell_filtered += 1
+ continue
+
+ # Filter B: Require bearish BOS for SELL
+ if self.sell_require_bos and not has_bearish_bos:
+ stats.sell_filtered += 1
+ continue
+
+ lot_size = self._calculate_lot_size(confidence, regime, trading_mode, lot_mult)
+ if lot_size <= 0:
+ continue
+
+ if trading_mode == TradingMode.RECOVERY:
+ stats.recovery_mode_trades += 1
+
+ entry_price = smc_signal.entry_price
+ take_profit_price = smc_signal.take_profit
+ stop_loss_price = smc_signal.stop_loss
+ risk = abs(entry_price - stop_loss_price)
+ rr = abs(take_profit_price - entry_price) / risk if risk > 0 else 0
+
+ # βββ #26 Filter C: Asymmetric RR for SELL βββ
+ if smc_signal.signal_type == "SELL" and self.sell_rr_multiplier != 1.0:
+ # Tighter TP for SELL (e.g., 0.8 = 1:1.2 instead of 1:1.5)
+ new_rr = rr * self.sell_rr_multiplier
+ take_profit_price = entry_price - (risk * new_rr)
+ rr = new_rr
+
+ profit, pips, exit_reason, exit_idx, exit_price = self._simulate_trade_exit(
+ df=df, entry_idx=i, direction=smc_signal.signal_type,
+ entry_price=entry_price, take_profit=take_profit_price,
+ stop_loss=stop_loss_price, lot_size=lot_size,
+ daily_loss_so_far=daily_loss, feature_cols=feature_cols,
+ )
+
+ self._ticket_counter += 1
+ result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
+
+ trade = SimulatedTrade(
+ ticket=self._ticket_counter,
+ entry_time=current_time,
+ exit_time=times[exit_idx] if exit_idx < len(times) else times[-1],
+ direction=smc_signal.signal_type,
+ entry_price=entry_price, exit_price=exit_price,
+ stop_loss=stop_loss_price, take_profit=take_profit_price,
+ lot_size=lot_size, profit_usd=profit, profit_pips=pips,
+ result=result, exit_reason=exit_reason,
+ smc_confidence=confidence, regime=regime,
+ session=session_name, signal_reason=smc_signal.reason,
+ has_bos=has_bos, has_choch=has_choch,
+ has_fvg=has_fvg, has_ob=has_ob,
+ atr_at_entry=atr_at_entry, rr_ratio=rr,
+ trading_mode=trading_mode.value,
+ )
+ stats.trades.append(trade)
+
+ stats.total_trades += 1
+ daily_trades += 1
+ capital += profit
+
+ if profit > 0:
+ stats.wins += 1
+ stats.total_profit += profit
+ daily_profit += profit
+ consecutive_losses = 0
+ if trading_mode == TradingMode.RECOVERY:
+ trading_mode = TradingMode.NORMAL
+ else:
+ stats.losses += 1
+ stats.total_loss += abs(profit)
+ daily_loss += abs(profit)
+ consecutive_losses += 1
+
+ if daily_loss >= self.max_daily_loss_usd:
+ trading_mode = TradingMode.STOPPED
+ stats.daily_limit_stops += 1
+ elif consecutive_losses >= 3 or daily_loss >= self.max_daily_loss_usd * 0.6:
+ trading_mode = TradingMode.PROTECTED
+ elif consecutive_losses >= 2:
+ trading_mode = TradingMode.RECOVERY
+
+ if capital > peak_capital:
+ peak_capital = capital
+ drawdown_pct = (peak_capital - capital) / peak_capital * 100
+ drawdown_usd = peak_capital - capital
+ if drawdown_pct > stats.max_drawdown:
+ stats.max_drawdown = drawdown_pct
+ stats.max_drawdown_usd = drawdown_usd
+
+ stats.equity_curve.append(capital)
+ last_trade_idx = exit_idx
+
+ if stats.total_trades % 100 == 0:
+ print(f" {stats.total_trades} trades processed...")
+
+ if stats.total_trades > 0:
+ stats.win_rate = stats.wins / stats.total_trades * 100
+ stats.avg_win = stats.total_profit / stats.wins if stats.wins > 0 else 0
+ stats.avg_loss = stats.total_loss / stats.losses if stats.losses > 0 else 0
+ stats.avg_trade = (stats.total_profit - stats.total_loss) / stats.total_trades
+ stats.profit_factor = stats.total_profit / stats.total_loss if stats.total_loss > 0 else float("inf")
+ win_prob = stats.wins / stats.total_trades
+ loss_prob = stats.losses / stats.total_trades
+ stats.expectancy = (win_prob * stats.avg_win) - (loss_prob * stats.avg_loss)
+ returns = [t.profit_usd for t in stats.trades]
+ if len(returns) > 1:
+ avg_return = np.mean(returns)
+ std_return = np.std(returns)
+ stats.sharpe_ratio = (avg_return / std_return) * np.sqrt(252) if std_return > 0 else 0
+
+ return stats
+
+
+# βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def main():
+ print("=" * 70)
+ print("XAUBOT AI β #26 SELL Improvement")
+ print("Base: #24B | Modified: SELL-specific filters")
+ print("=" * 70)
+
+ config = get_config()
+ mt5 = MT5Connector(
+ login=config.mt5_login, password=config.mt5_password,
+ server=config.mt5_server, path=config.mt5_path,
+ )
+ mt5.connect()
+ print(f"\nConnected to MT5")
+
+ print("Fetching XAUUSD M15 historical data...")
+ df = mt5.get_market_data(symbol="XAUUSD", timeframe="M15", count=50000)
+ if len(df) == 0:
+ print("ERROR: No data")
+ mt5.disconnect()
+ return
+
+ print(f" Received {len(df)} bars")
+ times = df["time"].to_list()
+ print(f" Data range: {times[0]} to {times[-1]}")
+
+ end_date = datetime.now()
+ start_date = datetime(2025, 8, 1)
+ data_start = times[0]
+ if hasattr(data_start, 'replace') and data_start.tzinfo:
+ start_date = start_date.replace(tzinfo=data_start.tzinfo)
+ end_date = end_date.replace(tzinfo=data_start.tzinfo)
+ if data_start > start_date:
+ start_date = data_start + timedelta(days=5)
+
+ print(f"\n Backtest period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
+
+ print("\nCalculating indicators...")
+ features = FeatureEngineer()
+ smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ df = features.calculate_all(df, include_ml_features=True)
+ df = smc.calculate_all(df)
+
+ regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ regime_detector.load()
+ df = regime_detector.predict(df)
+ print(" HMM regime loaded")
+ except Exception:
+ print(" [WARN] HMM not available")
+ print(" Indicators calculated")
+
+ baseline_24b_pnl = 2235.0 # #24B result
+
+ # βββ CONFIGS βββ
+ configs = [
+ # (name, sell_min_conf, sell_require_bos, sell_rr_mult)
+ ("A: SELL conf>=0.55", 0.55, False, 1.0),
+ ("B: SELL require BOS", 0.0, True, 1.0),
+ ("C: SELL RR 1:1.2", 0.0, False, 0.8),
+ ("D: A+B (conf+BOS)", 0.55, True, 1.0),
+ ("E: A+B+C (all)", 0.55, True, 0.8),
+ ]
+
+ all_results = []
+
+ for cfg_name, sell_conf, sell_bos, sell_rr in configs:
+ print(f"\n{'=' * 60}")
+ print(f" Config: {cfg_name}")
+
+ bt = SellImprovementBacktest(
+ sell_min_confidence=sell_conf,
+ sell_require_bos=sell_bos,
+ sell_rr_multiplier=sell_rr,
+ )
+ stats = bt.run(df=df, start_date=start_date, end_date=end_date, initial_capital=5000.0)
+ net_pnl = stats.total_profit - stats.total_loss
+ diff = net_pnl - baseline_24b_pnl
+
+ # Direction breakdown
+ buy_trades = [t for t in stats.trades if t.direction == "BUY"]
+ sell_trades = [t for t in stats.trades if t.direction == "SELL"]
+ buy_wins = sum(1 for t in buy_trades if t.result == TradeResult.WIN)
+ sell_wins = sum(1 for t in sell_trades if t.result == TradeResult.WIN)
+ buy_wr = buy_wins / len(buy_trades) * 100 if buy_trades else 0
+ sell_wr = sell_wins / len(sell_trades) * 100 if sell_trades else 0
+ buy_pnl = sum(t.profit_usd for t in buy_trades)
+ sell_pnl = sum(t.profit_usd for t in sell_trades)
+
+ print(f"\n [{cfg_name}] Results:")
+ print(f" Trades: {stats.total_trades} | WR: {stats.win_rate:.1f}%")
+ print(f" Net PnL: ${net_pnl:,.2f} | PF: {stats.profit_factor:.2f}")
+ print(f" Max DD: {stats.max_drawdown:.1f}% | Sharpe: {stats.sharpe_ratio:.2f}")
+ print(f" SELL filtered: {stats.sell_filtered}")
+ print(f" BUY: {len(buy_trades)} trades, {buy_wr:.1f}% WR, ${buy_pnl:,.2f}")
+ print(f" SELL: {len(sell_trades)} trades, {sell_wr:.1f}% WR, ${sell_pnl:,.2f}")
+ print(f" vs #24B: ${diff:+,.2f}")
+
+ all_results.append((cfg_name, stats, net_pnl, diff, len(buy_trades), buy_wr, buy_pnl, len(sell_trades), sell_wr, sell_pnl))
+
+ # βββ FINAL SUMMARY βββ
+ print(f"\n{'=' * 70}")
+ print("#26 SELL IMPROVEMENT β ALL CONFIGURATIONS")
+ print("=" * 70)
+
+ print(f"\n {'Config':<25} {'Trades':>6} {'WR':>6} {'Net PnL':>10} {'DD':>6} {'Sharpe':>7} {'PF':>5} {'Filt':>5} {'vs #24B':>10}")
+ print(f" {'-' * 85}")
+ print(f" {'#1 BASELINE':<25} {'686':>6} {'72.2%':>6} {'$1,449.86':>10} {'5.4%':>6} {'1.98':>7} {'1.52':>5} {'β':>5} {'β':>10}")
+ print(f" {'#24B (base)':<25} {'739':>6} {'80.4%':>6} {'$2,235.00':>10} {'3.4%':>6} {'2.87':>7} {'1.77':>5} {'β':>5} {'β':>10}")
+ for cfg_name, stats, net_pnl, diff, _, _, _, _, _, _ in all_results:
+ print(f" {cfg_name:<25} {stats.total_trades:>6} {stats.win_rate:>5.1f}% ${net_pnl:>9,.2f} {stats.max_drawdown:>5.1f}% {stats.sharpe_ratio:>7.2f} {stats.profit_factor:>5.2f} {stats.sell_filtered:>5} ${diff:>+9,.2f}")
+
+ best_pnl = -999999
+ best_name = ""
+ best_stats = None
+ best_data = None
+ for entry in all_results:
+ cfg_name, stats, net_pnl = entry[0], entry[1], entry[2]
+ if net_pnl > best_pnl:
+ best_pnl = net_pnl
+ best_name = cfg_name
+ best_stats = stats
+ best_data = entry
+
+ print(f"\n Best config: {best_name}")
+
+ # Direction breakdown for best
+ print(f"\n Direction (best config):")
+ _, _, _, _, buy_n, buy_wr, buy_pnl, sell_n, sell_wr, sell_pnl = best_data
+ print(f" BUY: {buy_n} trades, {buy_wr:.1f}% WR, ${buy_pnl:,.2f}")
+ print(f" SELL: {sell_n} trades, {sell_wr:.1f}% WR, ${sell_pnl:,.2f}")
+
+ # Direction comparison table
+ print(f"\n SELL Performance Comparison:")
+ print(f" {'Config':<25} {'SELL Trades':>11} {'SELL WR':>8} {'SELL PnL':>10} {'BUY WR':>7}")
+ print(f" {'-' * 65}")
+ print(f" {'#24B (base)':<25} {'~125':>11} {'76.5%':>8} {'$496':>10} {'83.1%':>7}")
+ for cfg_name, stats, net_pnl, diff, buy_n, buy_wr, buy_pnl, sell_n, sell_wr, sell_pnl in all_results:
+ print(f" {cfg_name:<25} {sell_n:>11} {sell_wr:>7.1f}% ${sell_pnl:>9,.2f} {buy_wr:>6.1f}%")
+
+ # Exit reasons for best
+ print(f"\n Exit Reasons (best config):")
+ exit_counts = {}
+ for t in best_stats.trades:
+ r = t.exit_reason.value
+ exit_counts[r] = exit_counts.get(r, 0) + 1
+ for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
+ pct = count / best_stats.total_trades * 100 if best_stats.total_trades > 0 else 0
+ print(f" {reason:20s}: {count} ({pct:.1f}%)")
+
+ # Save
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "26_sell_improvement_results")
+ os.makedirs(output_dir, exist_ok=True)
+
+ log_path = os.path.join(output_dir, f"sell_improve_{timestamp}.log")
+ with open(log_path, "w") as f:
+ f.write(f"#26 SELL Improvement Results\n")
+ f.write(f"Generated: {datetime.now()}\n")
+ f.write(f"Base: #24B (739 trades, 80.4% WR, $2,235)\n\n")
+ for cfg_name, stats, net_pnl, diff, buy_n, buy_wr, buy_pnl, sell_n, sell_wr, sell_pnl in all_results:
+ f.write(f" {cfg_name}: {stats.total_trades} trades, {stats.win_rate:.1f}% WR, "
+ f"${net_pnl:,.2f}, DD: {stats.max_drawdown:.1f}%, "
+ f"Sharpe: {stats.sharpe_ratio:.2f}, PF: {stats.profit_factor:.2f}, "
+ f"SELL filtered: {stats.sell_filtered}, "
+ f"BUY: {buy_n}@{buy_wr:.1f}%/${buy_pnl:,.2f}, "
+ f"SELL: {sell_n}@{sell_wr:.1f}%/${sell_pnl:,.2f}, "
+ f"vs #24B: ${diff:+,.2f}\n")
+ f.write(f"\nBest: {best_name}\n")
+ print(f" Log saved: {log_path}")
+
+ try:
+ from backtests.backtest_01_smc_only import generate_xlsx_report as gen_xlsx
+ xlsx_path = os.path.join(output_dir, f"sell_improve_{timestamp}.xlsx")
+ gen_xlsx(best_stats, xlsx_path, start_date, end_date)
+ print(f"\n Report saved: {xlsx_path}")
+ except Exception as e:
+ print(f" [WARN] XLSX: {e}")
+
+ mt5.disconnect()
+
+ print(f"\n{'=' * 70}")
+ print(f"Output: {output_dir}")
+ print(f" Log: {os.path.basename(log_path)}")
+ print("=" * 70)
+ print("Backtest complete!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backtests/backtest_27_regime_aware_entry.py b/backtests/backtest_27_regime_aware_entry.py
new file mode 100644
index 0000000..b305055
--- /dev/null
+++ b/backtests/backtest_27_regime_aware_entry.py
@@ -0,0 +1,1004 @@
+"""
+Backtest #27 β Regime-Aware Entry
+==================================
+Base: #24B (19B+20B+22D) β 739 trades, 80.4% WR, $2,235, Sharpe 2.87
+
+HMM regime (low/medium/high vol) is only used for exits currently.
+Goal: Use regime to FILTER entries and improve quality.
+
+Configs:
+ A: Regime-conditional confidence thresholds
+ low_vol=0.50, medium=0.55, high_vol=0.70
+ B: Skip SELL in low volatility (gold grinds up in calm markets, SELL fails)
+ C: Skip ALL trades in high volatility (only trade low+medium vol)
+ D: A+B combined (regime thresholds + skip SELL in low vol)
+ E: Regime-direction matrix (skip worst combos: SELL in low vol, BUY in high vol crisis)
+
+Usage:
+ python backtests/backtest_27_regime_aware_entry.py
+"""
+
+import polars as pl
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta, date
+from typing import Dict, List, Tuple, Optional
+from dataclasses import dataclass, field
+from enum import Enum
+import sys
+import os
+from zoneinfo import ZoneInfo
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from src.mt5_connector import MT5Connector
+from src.smc_polars import SMCAnalyzer, SMCSignal
+from src.feature_eng import FeatureEngineer
+from src.regime_detector import MarketRegimeDetector, MarketRegime
+from src.ml_model import TradingModel
+from src.config import get_config
+from src.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence, MarketQuality
+from loguru import logger
+
+logger.remove()
+logger.add(sys.stderr, level="WARNING")
+
+WIB = ZoneInfo("Asia/Jakarta")
+
+
+# βββ Enums & Dataclasses ββββββββββββββββββββββββββββββββββββββ
+
+class TradeResult(Enum):
+ WIN = "WIN"
+ LOSS = "LOSS"
+ BREAKEVEN = "BREAKEVEN"
+
+
+class ExitReason(Enum):
+ TAKE_PROFIT = "take_profit"
+ SMART_TP = "smart_tp"
+ PEAK_PROTECT = "peak_protect"
+ EARLY_EXIT = "early_exit"
+ EARLY_CUT = "early_cut"
+ MAX_LOSS = "max_loss"
+ STALL = "stall"
+ TREND_REVERSAL = "trend_reversal"
+ TIMEOUT = "timeout"
+ WEEKEND_CLOSE = "weekend_close"
+ TRAILING_SL = "trailing_sl"
+ BREAKEVEN_EXIT = "breakeven_exit"
+ DAILY_LIMIT = "daily_limit"
+ REGIME_DANGER = "regime_danger"
+ MARKET_SIGNAL = "market_signal"
+
+
+class TradingMode(Enum):
+ NORMAL = "normal"
+ RECOVERY = "recovery"
+ PROTECTED = "protected"
+ STOPPED = "stopped"
+
+
+@dataclass
+class SimulatedTrade:
+ ticket: int
+ entry_time: datetime
+ exit_time: datetime
+ direction: str
+ entry_price: float
+ exit_price: float
+ stop_loss: float
+ take_profit: float
+ lot_size: float
+ profit_usd: float
+ profit_pips: float
+ result: TradeResult
+ exit_reason: ExitReason
+ smc_confidence: float
+ regime: str
+ session: str
+ signal_reason: str
+ has_bos: bool = False
+ has_choch: bool = False
+ has_fvg: bool = False
+ has_ob: bool = False
+ atr_at_entry: float = 0.0
+ rr_ratio: float = 0.0
+ trading_mode: str = "normal"
+
+
+@dataclass
+class BacktestStats:
+ total_trades: int = 0
+ wins: int = 0
+ losses: int = 0
+ total_profit: float = 0.0
+ total_loss: float = 0.0
+ max_drawdown: float = 0.0
+ max_drawdown_usd: float = 0.0
+ win_rate: float = 0.0
+ profit_factor: float = 0.0
+ avg_win: float = 0.0
+ avg_loss: float = 0.0
+ avg_trade: float = 0.0
+ expectancy: float = 0.0
+ sharpe_ratio: float = 0.0
+ trades: List[SimulatedTrade] = field(default_factory=list)
+ equity_curve: List[float] = field(default_factory=list)
+ avoided_signals: int = 0
+ daily_limit_stops: int = 0
+ recovery_mode_trades: int = 0
+ session_blocked: int = 0
+ regime_filtered: int = 0
+
+
+# βββ Regime-Aware Entry Backtest ββββββββββββββββββββββββββββββ
+
+class RegimeAwareBacktest:
+ """#24B base + regime-aware entry filtering."""
+
+ def __init__(
+ self,
+ capital: float = 5000.0,
+ max_daily_loss_percent: float = 5.0,
+ max_loss_per_trade_percent: float = 1.0,
+ base_lot_size: float = 0.01,
+ max_lot_size: float = 0.02,
+ recovery_lot_size: float = 0.01,
+ trend_reversal_threshold: float = 0.75,
+ max_concurrent_positions: int = 2,
+ min_profit_to_protect: float = 5.0,
+ max_drawdown_from_peak: float = 50.0,
+ trade_cooldown_bars: int = 10,
+ trend_reversal_mult: float = 0.6,
+ # #24B base settings
+ skip_tokyo_london: bool = True,
+ early_cut_momentum: float = -50.0,
+ early_cut_loss_pct: float = 30.0,
+ be_mult: float = 2.0,
+ trail_start_mult: float = 4.0,
+ trail_step_mult: float = 3.0,
+ # βββ #27 REGIME-AWARE ENTRY PARAMS βββ
+ regime_conf_thresholds: Dict[str, float] = None, # {regime: min_confidence}
+ skip_sell_in_low_vol: bool = False,
+ skip_all_in_high_vol: bool = False,
+ regime_direction_filter: Dict[str, List[str]] = None, # {regime: [blocked_directions]}
+ ):
+ self.capital = capital
+ self.max_daily_loss_usd = capital * (max_daily_loss_percent / 100)
+ self.max_loss_per_trade = capital * (max_loss_per_trade_percent / 100)
+ self.base_lot_size = base_lot_size
+ self.max_lot_size = max_lot_size
+ self.recovery_lot_size = recovery_lot_size
+ self.trend_reversal_threshold = trend_reversal_threshold
+ self.max_concurrent_positions = max_concurrent_positions
+ self.min_profit_to_protect = min_profit_to_protect
+ self.max_drawdown_from_peak = max_drawdown_from_peak
+ self.trade_cooldown_bars = trade_cooldown_bars
+ self.trend_reversal_mult = trend_reversal_mult
+
+ self.skip_tokyo_london = skip_tokyo_london
+ self.early_cut_momentum = early_cut_momentum
+ self.early_cut_loss_pct = early_cut_loss_pct
+ self.be_mult = be_mult
+ self.trail_start_mult = trail_start_mult
+ self.trail_step_mult = trail_step_mult
+
+ # #27 Regime params
+ self.regime_conf_thresholds = regime_conf_thresholds or {}
+ self.skip_sell_in_low_vol = skip_sell_in_low_vol
+ self.skip_all_in_high_vol = skip_all_in_high_vol
+ self.regime_direction_filter = regime_direction_filter or {}
+
+ config = get_config()
+ self.smc = SMCAnalyzer(
+ swing_length=config.smc.swing_length,
+ ob_lookback=config.smc.ob_lookback,
+ )
+ self.features = FeatureEngineer()
+ self.dynamic_confidence = create_dynamic_confidence()
+
+ self.ml_model = TradingModel(model_path="models/xgboost_model.pkl")
+ try:
+ self.ml_model.load()
+ print(" ML model loaded (for exit evaluation)")
+ except Exception:
+ print(" [WARN] ML model not loaded")
+
+ self.regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ self.regime_detector.load()
+ except Exception:
+ print(" [WARN] HMM model not loaded")
+
+ self._ticket_counter = 2270000
+
+ # ββ Session filter (#19B) ββ
+
+ def _get_session_from_time(self, dt: datetime) -> Tuple[str, bool, float]:
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib_time = dt.astimezone(WIB)
+ hour = wib_time.hour
+ if 6 <= hour < 15:
+ return "Sydney-Tokyo", True, 0.5
+ elif 15 <= hour < 16:
+ if self.skip_tokyo_london:
+ return "Tokyo-London Overlap", False, 0.0
+ return "Tokyo-London Overlap", True, 0.75
+ elif 16 <= hour < 19:
+ return "London Early", True, 0.8
+ elif 19 <= hour < 24:
+ return "London-NY Overlap (Golden)", True, 1.0
+ elif 0 <= hour < 4:
+ return "NY Session", True, 0.9
+ else:
+ return "Off Hours", False, 0.0
+
+ def _hours_to_golden(self, dt: datetime) -> float:
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ if 19 <= wib.hour < 24:
+ return 0
+ target = wib.replace(hour=19, minute=0, second=0, microsecond=0)
+ if wib.hour >= 19:
+ target += timedelta(days=1)
+ return max(0, (target - wib).total_seconds() / 3600)
+
+ def _is_near_weekend_close(self, dt: datetime) -> bool:
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ if wib.weekday() == 5 and wib.hour >= 4 and wib.minute >= 30:
+ return True
+ return False
+
+ # ββ Lot sizing ββ
+
+ def _calculate_lot_size(self, confidence, regime, trading_mode, session_mult):
+ if trading_mode == TradingMode.STOPPED:
+ return 0
+ lot = self.base_lot_size
+ if trading_mode in (TradingMode.RECOVERY, TradingMode.PROTECTED):
+ lot = self.recovery_lot_size
+ else:
+ if confidence >= 0.65:
+ lot = self.max_lot_size
+ elif confidence >= 0.55:
+ lot = self.base_lot_size
+ else:
+ lot = self.recovery_lot_size
+ if regime.lower() in ["high_volatility", "crisis"]:
+ lot = self.recovery_lot_size
+ lot = max(0.01, lot * session_mult)
+ return round(lot, 2)
+
+ # ββ Exit simulation (identical to #24B) ββ
+
+ def _simulate_trade_exit(
+ self, df, entry_idx, direction, entry_price, take_profit, stop_loss,
+ lot_size, daily_loss_so_far, feature_cols, max_bars=100,
+ ) -> Tuple[float, float, ExitReason, int, float]:
+ pip_value = 10
+ highs = df["high"].to_list()
+ lows = df["low"].to_list()
+ closes = df["close"].to_list()
+ times = df["time"].to_list()
+
+ atr = 12.0
+ if "atr" in df.columns:
+ atr_list = df["atr"].to_list()
+ if entry_idx < len(atr_list) and atr_list[entry_idx] is not None:
+ atr = atr_list[entry_idx]
+
+ adaptive_breakeven_pips = atr * self.be_mult
+ adaptive_trail_start_pips = atr * self.trail_start_mult
+ adaptive_trail_step_pips = atr * self.trail_step_mult
+ reversal_momentum_threshold = atr * self.trend_reversal_mult
+ min_loss_for_reversal_exit = atr * 0.8
+
+ profit_history = []
+ peak_profit = 0.0
+ stall_count = 0
+ reversal_warnings = 0
+ current_sl = stop_loss
+ breakeven_moved = False
+
+ if direction == "BUY":
+ target_tp_profit = (take_profit - entry_price) / 0.1 * pip_value * lot_size
+ else:
+ target_tp_profit = (entry_price - take_profit) / 0.1 * pip_value * lot_size
+
+ cached_ml_signal = ""
+ cached_ml_confidence = 0.5
+
+ for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
+ high = highs[i]
+ low = lows[i]
+ close = closes[i]
+ current_time = times[i]
+
+ if direction == "BUY":
+ current_pips = (close - entry_price) / 0.1
+ pip_profit_from_entry = current_pips
+ else:
+ current_pips = (entry_price - close) / 0.1
+ pip_profit_from_entry = current_pips
+ current_profit = current_pips * pip_value * lot_size
+
+ profit_history.append(current_profit)
+ if current_profit > peak_profit:
+ peak_profit = current_profit
+
+ bars_since_entry = i - entry_idx
+
+ if bars_since_entry % 4 == 0 and self.ml_model.fitted:
+ try:
+ df_slice = df.head(i + 1)
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ cached_ml_signal = ml_pred.signal
+ cached_ml_confidence = ml_pred.confidence
+ except Exception:
+ pass
+
+ momentum = 0.0
+ if len(profit_history) >= 3:
+ recent = profit_history[-5:] if len(profit_history) >= 5 else profit_history
+ profit_change = recent[-1] - recent[0]
+ momentum = max(-100, min(100, (profit_change / 10) * 50))
+ profit_growing = momentum > 0
+
+ # A.0 TP hit
+ if direction == "BUY" and high >= take_profit:
+ pips = (take_profit - entry_price) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+ elif direction == "SELL" and low <= take_profit:
+ pips = (entry_price - take_profit) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+
+ # A.0b Trailing SL hit
+ if breakeven_moved and current_sl > 0:
+ if direction == "BUY" and low <= current_sl:
+ pips = (current_sl - entry_price) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+ elif direction == "SELL" and high >= current_sl:
+ pips = (entry_price - current_sl) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+
+ # A.1 Breakeven
+ if pip_profit_from_entry >= adaptive_breakeven_pips and not breakeven_moved:
+ current_sl = entry_price + (2 if direction == "BUY" else -2)
+ breakeven_moved = True
+
+ # A.2 Trailing SL
+ if pip_profit_from_entry >= adaptive_trail_start_pips:
+ trail_distance = adaptive_trail_step_pips * 0.1
+ if direction == "BUY":
+ new_trail_sl = close - trail_distance
+ if new_trail_sl > current_sl:
+ current_sl = new_trail_sl
+ else:
+ new_trail_sl = close + trail_distance
+ if current_sl == 0 or new_trail_sl < current_sl:
+ current_sl = new_trail_sl
+
+ # A.3 Peak protect
+ if peak_profit > self.min_profit_to_protect:
+ drawdown_pct = ((peak_profit - current_profit) / peak_profit) * 100 if peak_profit > 0 else 0
+ if drawdown_pct > self.max_drawdown_from_peak:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+
+ # A.4 Market analysis
+ if bars_since_entry % 5 == 0 and bars_since_entry >= 5 and i >= 20:
+ ma_fast = np.mean(closes[i-4:i+1])
+ ma_slow = np.mean(closes[i-19:i+1])
+ trend = "BULLISH" if ma_fast > ma_slow * 1.001 else ("BEARISH" if ma_fast < ma_slow * 0.999 else "NEUTRAL")
+ roc = (closes[i] / closes[max(0,i-4)] - 1) * 100
+ mom_dir = "BULLISH" if roc > 0.3 else ("BEARISH" if roc < -0.3 else "NEUTRAL")
+
+ rsi_val = None
+ if "rsi" in df.columns:
+ rsi_list = df["rsi"].to_list()
+ if i < len(rsi_list):
+ rsi_val = rsi_list[i]
+
+ urgency = 0
+ should_exit = False
+ if cached_ml_confidence > 0.75:
+ if (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY"):
+ should_exit = True; urgency += 2
+ if rsi_val:
+ if (rsi_val > 75 and direction == "BUY") or (rsi_val < 25 and direction == "SELL"):
+ should_exit = True; urgency += 2
+ if (direction == "BUY" and trend == "BEARISH" and mom_dir == "BEARISH") or \
+ (direction == "SELL" and trend == "BULLISH" and mom_dir == "BULLISH"):
+ should_exit = True; urgency += 3
+
+ if should_exit and current_profit > self.min_profit_to_protect / 2:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+ if urgency >= 7 and current_profit > 0:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+
+ # A.5 Weekend close
+ if self._is_near_weekend_close(current_time):
+ if current_profit > 0 or current_profit > -10:
+ return current_profit, current_pips, ExitReason.WEEKEND_CLOSE, i, close
+
+ # B.1 Smart TP
+ if current_profit >= 15:
+ if current_profit >= 40:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if current_profit >= 25 and momentum < -30:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if peak_profit > 30 and current_profit < peak_profit * 0.6:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+ if current_profit >= 20:
+ progress = (current_profit / target_tp_profit) * 100 if target_tp_profit > 0 else 0
+ progress_score = min(40, max(0, progress * 0.4))
+ momentum_score = ((momentum + 100) / 200) * 30
+ time_penalty = min(10, bars_since_entry / 4 * 2)
+ tp_probability = progress_score + momentum_score + 10 - time_penalty
+ if tp_probability < 25:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+
+ # B.2 Smart Early Exit
+ if 5 <= current_profit < 15:
+ if momentum < -50 and cached_ml_confidence >= 0.65:
+ is_reversal = (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY")
+ if is_reversal:
+ return current_profit, current_pips, ExitReason.EARLY_EXIT, i, close
+
+ # B.3 Early cut
+ if current_profit < 0:
+ loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
+ if momentum < self.early_cut_momentum and loss_percent_of_max >= self.early_cut_loss_pct:
+ return current_profit, current_pips, ExitReason.EARLY_CUT, i, close
+
+ # B.4 Trend Reversal
+ is_ml_reversal = False
+ if (direction == "BUY" and cached_ml_signal == "SELL" and cached_ml_confidence >= self.trend_reversal_threshold) or \
+ (direction == "SELL" and cached_ml_signal == "BUY" and cached_ml_confidence >= self.trend_reversal_threshold):
+ is_ml_reversal = True
+ reversal_warnings += 1
+ loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4)
+ if is_ml_reversal and current_profit < -8 and loss_moderate:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+ if reversal_warnings >= 3 and current_profit < -10:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ # B.5 Max loss
+ if current_profit <= -(self.max_loss_per_trade * 0.50):
+ htg = self._hours_to_golden(current_time)
+ if htg <= 1 and htg > 0 and momentum > -40:
+ pass
+ else:
+ return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
+
+ # B.6 Stall
+ if len(profit_history) >= 10:
+ recent_range = max(profit_history[-10:]) - min(profit_history[-10:])
+ if recent_range < 3 and current_profit < -15:
+ stall_count += 1
+ if stall_count >= 5:
+ return current_profit, current_pips, ExitReason.STALL, i, close
+
+ # B.7 Daily loss limit
+ potential_daily_loss = daily_loss_so_far + abs(min(0, current_profit))
+ if potential_daily_loss >= self.max_daily_loss_usd:
+ return current_profit, current_pips, ExitReason.DAILY_LIMIT, i, close
+
+ # C) Time-based
+ if bars_since_entry >= 16 and current_profit < 5 and not profit_growing:
+ if current_profit >= 0 or current_profit > -15:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 24 and (current_profit < 10 or not profit_growing):
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 32:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+
+ # C.2 ATR trend reversal
+ if bars_since_entry > 10:
+ recent_closes = closes[i-5:i+1]
+ mom = recent_closes[-1] - recent_closes[0]
+ if (direction == "BUY" and mom < -reversal_momentum_threshold) or \
+ (direction == "SELL" and mom > reversal_momentum_threshold):
+ if current_profit < -min_loss_for_reversal_exit:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ final_idx = min(entry_idx + max_bars - 1, len(df) - 1)
+ final_price = closes[final_idx]
+ pips = ((final_price - entry_price) if direction == "BUY" else (entry_price - final_price)) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TIMEOUT, final_idx, final_price
+
+ # ββ Main run ββ
+
+ def run(self, df, start_date=None, end_date=None, initial_capital=5000.0):
+ stats = BacktestStats()
+ capital = initial_capital
+ peak_capital = initial_capital
+ stats.equity_curve.append(capital)
+
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ consecutive_losses = 0
+ trading_mode = TradingMode.NORMAL
+ current_date = None
+
+ feature_cols = []
+ if self.ml_model.fitted and self.ml_model.feature_names:
+ feature_cols = [f for f in self.ml_model.feature_names if f in df.columns]
+
+ times = df["time"].to_list()
+ start_idx = next((i for i, t in enumerate(times) if t >= start_date), 100) if start_date else 100
+ end_idx = next((i for i, t in enumerate(times) if t > end_date), len(df) - 100) if end_date else len(df) - 100
+
+ last_trade_idx = -self.trade_cooldown_bars * 2
+
+ print(f" #27 Regime filters: thresholds={self.regime_conf_thresholds}, skip_sell_low={self.skip_sell_in_low_vol}, skip_high={self.skip_all_in_high_vol}, dir_filter={self.regime_direction_filter}")
+ print(f" Date range: {times[start_idx]} to {times[end_idx - 1]}")
+ print(f" Total bars: {end_idx - start_idx}")
+
+ for i in range(start_idx, end_idx):
+ if i - last_trade_idx < self.trade_cooldown_bars:
+ continue
+
+ current_time = times[i]
+ trade_date = current_time.date() if hasattr(current_time, 'date') else current_time
+ if current_date is None or trade_date != current_date:
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ current_date = trade_date
+ if consecutive_losses < 2:
+ trading_mode = TradingMode.NORMAL
+
+ if trading_mode == TradingMode.STOPPED:
+ continue
+
+ session_name, can_trade, lot_mult = self._get_session_from_time(current_time)
+ if not can_trade:
+ if session_name == "Tokyo-London Overlap":
+ stats.session_blocked += 1
+ continue
+
+ if hasattr(current_time, 'weekday') and current_time.weekday() >= 5:
+ continue
+
+ df_slice = df.head(i + 1)
+
+ regime = "normal"
+ try:
+ if self.regime_detector.fitted:
+ regime_state = self.regime_detector.get_current_state(df_slice)
+ if regime_state:
+ regime = regime_state.regime.value
+ if regime_state.regime == MarketRegime.CRISIS:
+ continue
+ if regime_state.recommendation == "SLEEP":
+ continue
+ except Exception:
+ pass
+
+ # βββ #27 REGIME FILTER: Skip all in high vol βββ
+ if self.skip_all_in_high_vol and regime == "high_volatility":
+ stats.regime_filtered += 1
+ continue
+
+ try:
+ ml_signal = ""
+ ml_confidence = 0.5
+ if self.ml_model.fitted and feature_cols:
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ ml_signal = ml_pred.signal
+ ml_confidence = ml_pred.confidence
+
+ market_analysis = self.dynamic_confidence.analyze_market(
+ session=session_name, regime=regime, volatility="medium",
+ trend_direction=regime, has_smc_signal=True,
+ ml_signal=ml_signal, ml_confidence=ml_confidence,
+ )
+ if market_analysis.quality == MarketQuality.AVOID:
+ stats.avoided_signals += 1
+ continue
+ except Exception:
+ pass
+
+ try:
+ smc_signal = self.smc.generate_signal(df_slice)
+ except Exception:
+ continue
+
+ if smc_signal is None:
+ continue
+
+ recent_df = df_slice.tail(10)
+ recent_bos = recent_df["bos"].to_list() if "bos" in df_slice.columns else []
+ recent_choch = recent_df["choch"].to_list() if "choch" in df_slice.columns else []
+ recent_fvg_bull = recent_df["is_fvg_bull"].to_list() if "is_fvg_bull" in df_slice.columns else []
+ recent_fvg_bear = recent_df["is_fvg_bear"].to_list() if "is_fvg_bear" in df_slice.columns else []
+ recent_obs = recent_df["ob"].to_list() if "ob" in df_slice.columns else []
+
+ has_bos = 1 in recent_bos or -1 in recent_bos
+ has_choch = 1 in recent_choch or -1 in recent_choch
+ has_fvg = any(recent_fvg_bull) or any(recent_fvg_bear)
+ has_ob = 1 in recent_obs or -1 in recent_obs
+
+ atr_at_entry = 12.0
+ if "atr" in df_slice.columns:
+ atr_val = df_slice.tail(1)["atr"].item()
+ if atr_val is not None and atr_val > 0:
+ atr_at_entry = atr_val
+
+ confidence = smc_signal.confidence
+ ml_agrees = (
+ (smc_signal.signal_type == "BUY" and ml_signal == "BUY") or
+ (smc_signal.signal_type == "SELL" and ml_signal == "SELL")
+ )
+ if ml_agrees:
+ confidence = (smc_signal.confidence + ml_confidence) / 2
+ if regime == "high_volatility":
+ confidence *= 0.9
+
+ # βββ #27 REGIME FILTERS βββ
+
+ # Filter A: Regime-conditional confidence thresholds
+ if self.regime_conf_thresholds:
+ min_conf = self.regime_conf_thresholds.get(regime, 0.0)
+ if min_conf > 0 and confidence < min_conf:
+ stats.regime_filtered += 1
+ continue
+
+ # Filter B: Skip SELL in low volatility
+ if self.skip_sell_in_low_vol and regime == "low_volatility" and smc_signal.signal_type == "SELL":
+ stats.regime_filtered += 1
+ continue
+
+ # Filter E: Regime-direction matrix
+ if self.regime_direction_filter:
+ blocked_dirs = self.regime_direction_filter.get(regime, [])
+ if smc_signal.signal_type in blocked_dirs:
+ stats.regime_filtered += 1
+ continue
+
+ lot_size = self._calculate_lot_size(confidence, regime, trading_mode, lot_mult)
+ if lot_size <= 0:
+ continue
+
+ if trading_mode == TradingMode.RECOVERY:
+ stats.recovery_mode_trades += 1
+
+ entry_price = smc_signal.entry_price
+ take_profit_price = smc_signal.take_profit
+ stop_loss_price = smc_signal.stop_loss
+ risk = abs(entry_price - stop_loss_price)
+ rr = abs(take_profit_price - entry_price) / risk if risk > 0 else 0
+
+ profit, pips, exit_reason, exit_idx, exit_price = self._simulate_trade_exit(
+ df=df, entry_idx=i, direction=smc_signal.signal_type,
+ entry_price=entry_price, take_profit=take_profit_price,
+ stop_loss=stop_loss_price, lot_size=lot_size,
+ daily_loss_so_far=daily_loss, feature_cols=feature_cols,
+ )
+
+ self._ticket_counter += 1
+ result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
+
+ trade = SimulatedTrade(
+ ticket=self._ticket_counter,
+ entry_time=current_time,
+ exit_time=times[exit_idx] if exit_idx < len(times) else times[-1],
+ direction=smc_signal.signal_type,
+ entry_price=entry_price, exit_price=exit_price,
+ stop_loss=stop_loss_price, take_profit=take_profit_price,
+ lot_size=lot_size, profit_usd=profit, profit_pips=pips,
+ result=result, exit_reason=exit_reason,
+ smc_confidence=confidence, regime=regime,
+ session=session_name, signal_reason=smc_signal.reason,
+ has_bos=has_bos, has_choch=has_choch,
+ has_fvg=has_fvg, has_ob=has_ob,
+ atr_at_entry=atr_at_entry, rr_ratio=rr,
+ trading_mode=trading_mode.value,
+ )
+ stats.trades.append(trade)
+
+ stats.total_trades += 1
+ daily_trades += 1
+ capital += profit
+
+ if profit > 0:
+ stats.wins += 1
+ stats.total_profit += profit
+ daily_profit += profit
+ consecutive_losses = 0
+ if trading_mode == TradingMode.RECOVERY:
+ trading_mode = TradingMode.NORMAL
+ else:
+ stats.losses += 1
+ stats.total_loss += abs(profit)
+ daily_loss += abs(profit)
+ consecutive_losses += 1
+
+ if daily_loss >= self.max_daily_loss_usd:
+ trading_mode = TradingMode.STOPPED
+ stats.daily_limit_stops += 1
+ elif consecutive_losses >= 3 or daily_loss >= self.max_daily_loss_usd * 0.6:
+ trading_mode = TradingMode.PROTECTED
+ elif consecutive_losses >= 2:
+ trading_mode = TradingMode.RECOVERY
+
+ if capital > peak_capital:
+ peak_capital = capital
+ drawdown_pct = (peak_capital - capital) / peak_capital * 100
+ drawdown_usd = peak_capital - capital
+ if drawdown_pct > stats.max_drawdown:
+ stats.max_drawdown = drawdown_pct
+ stats.max_drawdown_usd = drawdown_usd
+
+ stats.equity_curve.append(capital)
+ last_trade_idx = exit_idx
+
+ if stats.total_trades % 100 == 0:
+ print(f" {stats.total_trades} trades processed...")
+
+ if stats.total_trades > 0:
+ stats.win_rate = stats.wins / stats.total_trades * 100
+ stats.avg_win = stats.total_profit / stats.wins if stats.wins > 0 else 0
+ stats.avg_loss = stats.total_loss / stats.losses if stats.losses > 0 else 0
+ stats.avg_trade = (stats.total_profit - stats.total_loss) / stats.total_trades
+ stats.profit_factor = stats.total_profit / stats.total_loss if stats.total_loss > 0 else float("inf")
+ win_prob = stats.wins / stats.total_trades
+ loss_prob = stats.losses / stats.total_trades
+ stats.expectancy = (win_prob * stats.avg_win) - (loss_prob * stats.avg_loss)
+ returns = [t.profit_usd for t in stats.trades]
+ if len(returns) > 1:
+ avg_return = np.mean(returns)
+ std_return = np.std(returns)
+ stats.sharpe_ratio = (avg_return / std_return) * np.sqrt(252) if std_return > 0 else 0
+
+ return stats
+
+
+# βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def main():
+ print("=" * 70)
+ print("XAUBOT AI β #27 Regime-Aware Entry")
+ print("Base: #24B | Modified: Regime-based entry filtering")
+ print("=" * 70)
+
+ config = get_config()
+ mt5 = MT5Connector(
+ login=config.mt5_login, password=config.mt5_password,
+ server=config.mt5_server, path=config.mt5_path,
+ )
+ mt5.connect()
+ print(f"\nConnected to MT5")
+
+ print("Fetching XAUUSD M15 historical data...")
+ df = mt5.get_market_data(symbol="XAUUSD", timeframe="M15", count=50000)
+ if len(df) == 0:
+ print("ERROR: No data")
+ mt5.disconnect()
+ return
+
+ print(f" Received {len(df)} bars")
+ times = df["time"].to_list()
+ print(f" Data range: {times[0]} to {times[-1]}")
+
+ end_date = datetime.now()
+ start_date = datetime(2025, 8, 1)
+ data_start = times[0]
+ if hasattr(data_start, 'replace') and data_start.tzinfo:
+ start_date = start_date.replace(tzinfo=data_start.tzinfo)
+ end_date = end_date.replace(tzinfo=data_start.tzinfo)
+ if data_start > start_date:
+ start_date = data_start + timedelta(days=5)
+
+ print(f"\n Backtest period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
+
+ print("\nCalculating indicators...")
+ features = FeatureEngineer()
+ smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ df = features.calculate_all(df, include_ml_features=True)
+ df = smc.calculate_all(df)
+
+ regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ regime_detector.load()
+ df = regime_detector.predict(df)
+ print(" HMM regime loaded")
+ except Exception:
+ print(" [WARN] HMM not available")
+ print(" Indicators calculated")
+
+ # ββ First: analyze regime distribution in #24B baseline ββ
+ print("\n Regime distribution analysis (pre-backtest)...")
+ regime_col = df["regime_name"].to_list() if "regime_name" in df.columns else []
+ if regime_col:
+ from collections import Counter
+ regime_counts = Counter(r for r in regime_col if r is not None)
+ total_bars = sum(regime_counts.values())
+ for r, c in sorted(regime_counts.items(), key=lambda x: -x[1]):
+ print(f" {r}: {c} bars ({c/total_bars*100:.1f}%)")
+
+ baseline_24b_pnl = 2235.0
+
+ # βββ CONFIGS βββ
+ configs = [
+ {
+ "name": "A: Regime thresholds",
+ "regime_conf_thresholds": {"low_volatility": 0.50, "medium_volatility": 0.55, "high_volatility": 0.70},
+ "skip_sell_in_low_vol": False,
+ "skip_all_in_high_vol": False,
+ "regime_direction_filter": {},
+ },
+ {
+ "name": "B: Skip SELL in low vol",
+ "regime_conf_thresholds": {},
+ "skip_sell_in_low_vol": True,
+ "skip_all_in_high_vol": False,
+ "regime_direction_filter": {},
+ },
+ {
+ "name": "C: Skip all high vol",
+ "regime_conf_thresholds": {},
+ "skip_sell_in_low_vol": False,
+ "skip_all_in_high_vol": True,
+ "regime_direction_filter": {},
+ },
+ {
+ "name": "D: A+B combined",
+ "regime_conf_thresholds": {"low_volatility": 0.50, "medium_volatility": 0.55, "high_volatility": 0.70},
+ "skip_sell_in_low_vol": True,
+ "skip_all_in_high_vol": False,
+ "regime_direction_filter": {},
+ },
+ {
+ "name": "E: Direction matrix",
+ "regime_conf_thresholds": {},
+ "skip_sell_in_low_vol": False,
+ "skip_all_in_high_vol": False,
+ "regime_direction_filter": {
+ "low_volatility": ["SELL"], # No SELL in calm markets
+ "high_volatility": ["SELL"], # No SELL in volatile markets (whipsaw)
+ },
+ },
+ ]
+
+ all_results = []
+
+ for cfg in configs:
+ cfg_name = cfg["name"]
+ print(f"\n{'=' * 60}")
+ print(f" Config: {cfg_name}")
+
+ bt = RegimeAwareBacktest(
+ regime_conf_thresholds=cfg["regime_conf_thresholds"],
+ skip_sell_in_low_vol=cfg["skip_sell_in_low_vol"],
+ skip_all_in_high_vol=cfg["skip_all_in_high_vol"],
+ regime_direction_filter=cfg["regime_direction_filter"],
+ )
+ stats = bt.run(df=df, start_date=start_date, end_date=end_date, initial_capital=5000.0)
+ net_pnl = stats.total_profit - stats.total_loss
+ diff = net_pnl - baseline_24b_pnl
+
+ # Direction breakdown
+ buy_trades = [t for t in stats.trades if t.direction == "BUY"]
+ sell_trades = [t for t in stats.trades if t.direction == "SELL"]
+ buy_wins = sum(1 for t in buy_trades if t.result == TradeResult.WIN)
+ sell_wins = sum(1 for t in sell_trades if t.result == TradeResult.WIN)
+ buy_wr = buy_wins / len(buy_trades) * 100 if buy_trades else 0
+ sell_wr = sell_wins / len(sell_trades) * 100 if sell_trades else 0
+ buy_pnl = sum(t.profit_usd for t in buy_trades)
+ sell_pnl = sum(t.profit_usd for t in sell_trades)
+
+ # Regime breakdown
+ regime_stats = {}
+ for t in stats.trades:
+ r = t.regime
+ if r not in regime_stats:
+ regime_stats[r] = {"w": 0, "l": 0, "p": 0.0}
+ if t.result == TradeResult.WIN:
+ regime_stats[r]["w"] += 1
+ else:
+ regime_stats[r]["l"] += 1
+ regime_stats[r]["p"] += t.profit_usd
+
+ print(f"\n [{cfg_name}] Results:")
+ print(f" Trades: {stats.total_trades} | WR: {stats.win_rate:.1f}%")
+ print(f" Net PnL: ${net_pnl:,.2f} | PF: {stats.profit_factor:.2f}")
+ print(f" Max DD: {stats.max_drawdown:.1f}% | Sharpe: {stats.sharpe_ratio:.2f}")
+ print(f" Regime filtered: {stats.regime_filtered}")
+ print(f" BUY: {len(buy_trades)} trades, {buy_wr:.1f}% WR, ${buy_pnl:,.2f}")
+ print(f" SELL: {len(sell_trades)} trades, {sell_wr:.1f}% WR, ${sell_pnl:,.2f}")
+ print(f" Per regime:")
+ for r, d in sorted(regime_stats.items(), key=lambda x: -x[1]["p"]):
+ total = d["w"] + d["l"]
+ wr = d["w"] / total * 100 if total > 0 else 0
+ print(f" {r:<20}: {total:>3} trades, {wr:>5.1f}% WR, ${d['p']:>8,.2f}")
+ print(f" vs #24B: ${diff:+,.2f}")
+
+ all_results.append((cfg_name, stats, net_pnl, diff, len(buy_trades), buy_wr, buy_pnl, len(sell_trades), sell_wr, sell_pnl, regime_stats))
+
+ # βββ FINAL SUMMARY βββ
+ print(f"\n{'=' * 70}")
+ print("#27 REGIME-AWARE ENTRY β ALL CONFIGURATIONS")
+ print("=" * 70)
+
+ print(f"\n {'Config':<25} {'Trades':>6} {'WR':>6} {'Net PnL':>10} {'DD':>6} {'Sharpe':>7} {'PF':>5} {'Filt':>5} {'vs #24B':>10}")
+ print(f" {'-' * 85}")
+ print(f" {'#1 BASELINE':<25} {'686':>6} {'72.2%':>6} {'$1,449.86':>10} {'5.4%':>6} {'1.98':>7} {'1.52':>5} {'β':>5} {'β':>10}")
+ print(f" {'#24B (base)':<25} {'739':>6} {'80.4%':>6} {'$2,235.00':>10} {'3.4%':>6} {'2.87':>7} {'1.77':>5} {'β':>5} {'β':>10}")
+ for cfg_name, stats, net_pnl, diff, _, _, _, _, _, _, _ in all_results:
+ print(f" {cfg_name:<25} {stats.total_trades:>6} {stats.win_rate:>5.1f}% ${net_pnl:>9,.2f} {stats.max_drawdown:>5.1f}% {stats.sharpe_ratio:>7.2f} {stats.profit_factor:>5.2f} {stats.regime_filtered:>5} ${diff:>+9,.2f}")
+
+ best_pnl = -999999
+ best_name = ""
+ best_stats = None
+ best_data = None
+ for entry in all_results:
+ if entry[2] > best_pnl:
+ best_pnl = entry[2]
+ best_name = entry[0]
+ best_stats = entry[1]
+ best_data = entry
+
+ print(f"\n Best config: {best_name}")
+
+ # Exit reasons for best
+ print(f"\n Exit Reasons (best config):")
+ exit_counts = {}
+ for t in best_stats.trades:
+ r = t.exit_reason.value
+ exit_counts[r] = exit_counts.get(r, 0) + 1
+ for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
+ pct = count / best_stats.total_trades * 100 if best_stats.total_trades > 0 else 0
+ print(f" {reason:20s}: {count} ({pct:.1f}%)")
+
+ # Save
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "27_regime_aware_results")
+ os.makedirs(output_dir, exist_ok=True)
+
+ log_path = os.path.join(output_dir, f"regime_aware_{timestamp}.log")
+ with open(log_path, "w") as f:
+ f.write(f"#27 Regime-Aware Entry Results\n")
+ f.write(f"Generated: {datetime.now()}\n")
+ f.write(f"Base: #24B (739 trades, 80.4% WR, $2,235)\n\n")
+ for cfg_name, stats, net_pnl, diff, buy_n, buy_wr, buy_pnl, sell_n, sell_wr, sell_pnl, regime_stats in all_results:
+ f.write(f" {cfg_name}: {stats.total_trades} trades, {stats.win_rate:.1f}% WR, "
+ f"${net_pnl:,.2f}, DD: {stats.max_drawdown:.1f}%, "
+ f"Sharpe: {stats.sharpe_ratio:.2f}, PF: {stats.profit_factor:.2f}, "
+ f"Filtered: {stats.regime_filtered}, "
+ f"BUY: {buy_n}@{buy_wr:.1f}%, SELL: {sell_n}@{sell_wr:.1f}%, "
+ f"vs #24B: ${diff:+,.2f}\n")
+ for r, d in sorted(regime_stats.items(), key=lambda x: -x[1]["p"]):
+ total = d["w"] + d["l"]
+ wr = d["w"] / total * 100 if total > 0 else 0
+ f.write(f" {r}: {total} trades, {wr:.1f}% WR, ${d['p']:,.2f}\n")
+ f.write(f"\nBest: {best_name}\n")
+ print(f" Log saved: {log_path}")
+
+ try:
+ from backtests.backtest_01_smc_only import generate_xlsx_report as gen_xlsx
+ xlsx_path = os.path.join(output_dir, f"regime_aware_{timestamp}.xlsx")
+ gen_xlsx(best_stats, xlsx_path, start_date, end_date)
+ print(f"\n Report saved: {xlsx_path}")
+ except Exception as e:
+ print(f" [WARN] XLSX: {e}")
+
+ mt5.disconnect()
+
+ print(f"\n{'=' * 70}")
+ print(f"Output: {output_dir}")
+ print(f" Log: {os.path.basename(log_path)}")
+ print("=" * 70)
+ print("Backtest complete!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backtests/backtest_28_smart_breakeven.py b/backtests/backtest_28_smart_breakeven.py
new file mode 100644
index 0000000..1422c42
--- /dev/null
+++ b/backtests/backtest_28_smart_breakeven.py
@@ -0,0 +1,919 @@
+"""
+Backtest #28 β Smart Breakeven + Loss Reduction
+=================================================
+Base: #24B (19B+20B+22D) β 739 trades, 80.4% WR, $2,235, Sharpe 2.87
+
+Problem: 31.9% of exits are breakeven β trades that went profitable then
+reversed back to entry. These are dead weight (~$0 profit each).
+Also: 10.4% early_cut + 2.7% max_loss = preventable losses.
+
+Configs:
+ A: Smart BE β lock small profit at entry + 0.3x ATR (not exact entry + $2)
+ B: Smart BE β lock at entry + 0.5x ATR (more aggressive profit lock)
+ C: First-candle adverse exit β if bar 1 goes against >0.5x ATR, cut immediately
+ D: A+C combined (smart BE + first-candle exit)
+ E: B+C combined (aggressive BE + first-candle exit)
+
+Usage:
+ python backtests/backtest_28_smart_breakeven.py
+"""
+
+import polars as pl
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta, date
+from typing import Dict, List, Tuple, Optional
+from dataclasses import dataclass, field
+from enum import Enum
+import sys
+import os
+from zoneinfo import ZoneInfo
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from src.mt5_connector import MT5Connector
+from src.smc_polars import SMCAnalyzer, SMCSignal
+from src.feature_eng import FeatureEngineer
+from src.regime_detector import MarketRegimeDetector, MarketRegime
+from src.ml_model import TradingModel
+from src.config import get_config
+from src.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence, MarketQuality
+from loguru import logger
+
+logger.remove()
+logger.add(sys.stderr, level="WARNING")
+
+WIB = ZoneInfo("Asia/Jakarta")
+
+
+# βββ Enums & Dataclasses ββββββββββββββββββββββββββββββββββββββ
+
+class TradeResult(Enum):
+ WIN = "WIN"
+ LOSS = "LOSS"
+ BREAKEVEN = "BREAKEVEN"
+
+class ExitReason(Enum):
+ TAKE_PROFIT = "take_profit"
+ SMART_TP = "smart_tp"
+ PEAK_PROTECT = "peak_protect"
+ EARLY_EXIT = "early_exit"
+ EARLY_CUT = "early_cut"
+ MAX_LOSS = "max_loss"
+ STALL = "stall"
+ TREND_REVERSAL = "trend_reversal"
+ TIMEOUT = "timeout"
+ WEEKEND_CLOSE = "weekend_close"
+ TRAILING_SL = "trailing_sl"
+ BREAKEVEN_EXIT = "breakeven_exit"
+ DAILY_LIMIT = "daily_limit"
+ REGIME_DANGER = "regime_danger"
+ MARKET_SIGNAL = "market_signal"
+ FIRST_CANDLE_CUT = "first_candle_cut" # NEW
+
+class TradingMode(Enum):
+ NORMAL = "normal"
+ RECOVERY = "recovery"
+ PROTECTED = "protected"
+ STOPPED = "stopped"
+
+@dataclass
+class SimulatedTrade:
+ ticket: int
+ entry_time: datetime
+ exit_time: datetime
+ direction: str
+ entry_price: float
+ exit_price: float
+ stop_loss: float
+ take_profit: float
+ lot_size: float
+ profit_usd: float
+ profit_pips: float
+ result: TradeResult
+ exit_reason: ExitReason
+ smc_confidence: float
+ regime: str
+ session: str
+ signal_reason: str
+ has_bos: bool = False
+ has_choch: bool = False
+ has_fvg: bool = False
+ has_ob: bool = False
+ atr_at_entry: float = 0.0
+ rr_ratio: float = 0.0
+ trading_mode: str = "normal"
+
+@dataclass
+class BacktestStats:
+ total_trades: int = 0
+ wins: int = 0
+ losses: int = 0
+ total_profit: float = 0.0
+ total_loss: float = 0.0
+ max_drawdown: float = 0.0
+ max_drawdown_usd: float = 0.0
+ win_rate: float = 0.0
+ profit_factor: float = 0.0
+ avg_win: float = 0.0
+ avg_loss: float = 0.0
+ avg_trade: float = 0.0
+ expectancy: float = 0.0
+ sharpe_ratio: float = 0.0
+ trades: List[SimulatedTrade] = field(default_factory=list)
+ equity_curve: List[float] = field(default_factory=list)
+ avoided_signals: int = 0
+ daily_limit_stops: int = 0
+ recovery_mode_trades: int = 0
+ session_blocked: int = 0
+ first_candle_cuts: int = 0
+
+
+# βββ Smart Breakeven Backtest βββββββββββββββββββββββββββββββββ
+
+class SmartBreakevenBacktest:
+ """#24B base + smart breakeven and first-candle adverse exit."""
+
+ def __init__(
+ self,
+ capital: float = 5000.0,
+ max_daily_loss_percent: float = 5.0,
+ max_loss_per_trade_percent: float = 1.0,
+ base_lot_size: float = 0.01,
+ max_lot_size: float = 0.02,
+ recovery_lot_size: float = 0.01,
+ trend_reversal_threshold: float = 0.75,
+ max_concurrent_positions: int = 2,
+ min_profit_to_protect: float = 5.0,
+ max_drawdown_from_peak: float = 50.0,
+ trade_cooldown_bars: int = 10,
+ trend_reversal_mult: float = 0.6,
+ # #24B base
+ skip_tokyo_london: bool = True,
+ early_cut_momentum: float = -50.0,
+ early_cut_loss_pct: float = 30.0,
+ be_mult: float = 2.0,
+ trail_start_mult: float = 4.0,
+ trail_step_mult: float = 3.0,
+ # βββ #28 SMART BREAKEVEN PARAMS βββ
+ be_profit_lock_atr_mult: float = 0.0, # Lock profit at entry + X * ATR (0 = use $2 like #24B)
+ first_candle_cut_atr_mult: float = 0.0, # Cut if bar 1 adverse > X * ATR (0 = disabled)
+ ):
+ self.capital = capital
+ self.max_daily_loss_usd = capital * (max_daily_loss_percent / 100)
+ self.max_loss_per_trade = capital * (max_loss_per_trade_percent / 100)
+ self.base_lot_size = base_lot_size
+ self.max_lot_size = max_lot_size
+ self.recovery_lot_size = recovery_lot_size
+ self.trend_reversal_threshold = trend_reversal_threshold
+ self.max_concurrent_positions = max_concurrent_positions
+ self.min_profit_to_protect = min_profit_to_protect
+ self.max_drawdown_from_peak = max_drawdown_from_peak
+ self.trade_cooldown_bars = trade_cooldown_bars
+ self.trend_reversal_mult = trend_reversal_mult
+
+ self.skip_tokyo_london = skip_tokyo_london
+ self.early_cut_momentum = early_cut_momentum
+ self.early_cut_loss_pct = early_cut_loss_pct
+ self.be_mult = be_mult
+ self.trail_start_mult = trail_start_mult
+ self.trail_step_mult = trail_step_mult
+
+ # #28 params
+ self.be_profit_lock_atr_mult = be_profit_lock_atr_mult
+ self.first_candle_cut_atr_mult = first_candle_cut_atr_mult
+
+ config = get_config()
+ self.smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ self.features = FeatureEngineer()
+ self.dynamic_confidence = create_dynamic_confidence()
+
+ self.ml_model = TradingModel(model_path="models/xgboost_model.pkl")
+ try:
+ self.ml_model.load()
+ print(" ML model loaded (for exit evaluation)")
+ except Exception:
+ print(" [WARN] ML model not loaded")
+
+ self.regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ self.regime_detector.load()
+ except Exception:
+ print(" [WARN] HMM model not loaded")
+
+ self._ticket_counter = 2280000
+
+ def _get_session_from_time(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib_time = dt.astimezone(WIB)
+ hour = wib_time.hour
+ if 6 <= hour < 15:
+ return "Sydney-Tokyo", True, 0.5
+ elif 15 <= hour < 16:
+ if self.skip_tokyo_london:
+ return "Tokyo-London Overlap", False, 0.0
+ return "Tokyo-London Overlap", True, 0.75
+ elif 16 <= hour < 19:
+ return "London Early", True, 0.8
+ elif 19 <= hour < 24:
+ return "London-NY Overlap (Golden)", True, 1.0
+ elif 0 <= hour < 4:
+ return "NY Session", True, 0.9
+ else:
+ return "Off Hours", False, 0.0
+
+ def _hours_to_golden(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ if 19 <= wib.hour < 24:
+ return 0
+ target = wib.replace(hour=19, minute=0, second=0, microsecond=0)
+ if wib.hour >= 19:
+ target += timedelta(days=1)
+ return max(0, (target - wib).total_seconds() / 3600)
+
+ def _is_near_weekend_close(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ return wib.weekday() == 5 and wib.hour >= 4 and wib.minute >= 30
+
+ def _calculate_lot_size(self, confidence, regime, trading_mode, session_mult):
+ if trading_mode == TradingMode.STOPPED:
+ return 0
+ lot = self.base_lot_size
+ if trading_mode in (TradingMode.RECOVERY, TradingMode.PROTECTED):
+ lot = self.recovery_lot_size
+ else:
+ if confidence >= 0.65:
+ lot = self.max_lot_size
+ elif confidence >= 0.55:
+ lot = self.base_lot_size
+ else:
+ lot = self.recovery_lot_size
+ if regime.lower() in ["high_volatility", "crisis"]:
+ lot = self.recovery_lot_size
+ lot = max(0.01, lot * session_mult)
+ return round(lot, 2)
+
+ def _simulate_trade_exit(
+ self, df, entry_idx, direction, entry_price, take_profit, stop_loss,
+ lot_size, daily_loss_so_far, feature_cols, max_bars=100,
+ ):
+ pip_value = 10
+ highs = df["high"].to_list()
+ lows = df["low"].to_list()
+ closes = df["close"].to_list()
+ times = df["time"].to_list()
+
+ atr = 12.0
+ if "atr" in df.columns:
+ atr_list = df["atr"].to_list()
+ if entry_idx < len(atr_list) and atr_list[entry_idx] is not None:
+ atr = atr_list[entry_idx]
+
+ adaptive_breakeven_pips = atr * self.be_mult
+ adaptive_trail_start_pips = atr * self.trail_start_mult
+ adaptive_trail_step_pips = atr * self.trail_step_mult
+ reversal_momentum_threshold = atr * self.trend_reversal_mult
+ min_loss_for_reversal_exit = atr * 0.8
+
+ # βββ #28: Smart breakeven profit lock βββ
+ # Instead of entry + $2, lock at entry + (ATR * be_profit_lock_atr_mult)
+ if self.be_profit_lock_atr_mult > 0:
+ be_lock_distance = atr * self.be_profit_lock_atr_mult # in price terms
+ else:
+ be_lock_distance = 2.0 # Original $2 buffer
+
+ # βββ #28: First-candle adverse threshold βββ
+ first_candle_adverse_threshold = 0.0
+ first_candle_cut_triggered = False
+ if self.first_candle_cut_atr_mult > 0:
+ first_candle_adverse_threshold = atr * self.first_candle_cut_atr_mult
+
+ profit_history = []
+ peak_profit = 0.0
+ stall_count = 0
+ reversal_warnings = 0
+ current_sl = stop_loss
+ breakeven_moved = False
+
+ if direction == "BUY":
+ target_tp_profit = (take_profit - entry_price) / 0.1 * pip_value * lot_size
+ else:
+ target_tp_profit = (entry_price - take_profit) / 0.1 * pip_value * lot_size
+
+ cached_ml_signal = ""
+ cached_ml_confidence = 0.5
+
+ for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
+ high = highs[i]
+ low = lows[i]
+ close = closes[i]
+ current_time = times[i]
+
+ if direction == "BUY":
+ current_pips = (close - entry_price) / 0.1
+ pip_profit_from_entry = current_pips
+ else:
+ current_pips = (entry_price - close) / 0.1
+ pip_profit_from_entry = current_pips
+ current_profit = current_pips * pip_value * lot_size
+
+ profit_history.append(current_profit)
+ if current_profit > peak_profit:
+ peak_profit = current_profit
+
+ bars_since_entry = i - entry_idx
+
+ # βββ #28: FIRST-CANDLE ADVERSE EXIT βββ
+ if bars_since_entry == 1 and first_candle_adverse_threshold > 0:
+ if direction == "BUY":
+ adverse_move = entry_price - low # How far price went against us
+ else:
+ adverse_move = high - entry_price
+ if adverse_move > first_candle_adverse_threshold:
+ first_candle_cut_triggered = True
+ pips = current_pips
+ return current_profit, pips, ExitReason.FIRST_CANDLE_CUT, i, close
+
+ if bars_since_entry % 4 == 0 and self.ml_model.fitted:
+ try:
+ df_slice = df.head(i + 1)
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ cached_ml_signal = ml_pred.signal
+ cached_ml_confidence = ml_pred.confidence
+ except Exception:
+ pass
+
+ momentum = 0.0
+ if len(profit_history) >= 3:
+ recent = profit_history[-5:] if len(profit_history) >= 5 else profit_history
+ profit_change = recent[-1] - recent[0]
+ momentum = max(-100, min(100, (profit_change / 10) * 50))
+ profit_growing = momentum > 0
+
+ # A.0 TP hit
+ if direction == "BUY" and high >= take_profit:
+ pips = (take_profit - entry_price) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+ elif direction == "SELL" and low <= take_profit:
+ pips = (entry_price - take_profit) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+
+ # A.0b Trailing SL hit
+ if breakeven_moved and current_sl > 0:
+ if direction == "BUY" and low <= current_sl:
+ pips = (current_sl - entry_price) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+ elif direction == "SELL" and high >= current_sl:
+ pips = (entry_price - current_sl) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+
+ # A.1 Breakeven (#28: SMART β lock profit at entry + ATR*mult)
+ if pip_profit_from_entry >= adaptive_breakeven_pips and not breakeven_moved:
+ if direction == "BUY":
+ current_sl = entry_price + be_lock_distance
+ else:
+ current_sl = entry_price - be_lock_distance
+ breakeven_moved = True
+
+ # A.2 Trailing SL
+ if pip_profit_from_entry >= adaptive_trail_start_pips:
+ trail_distance = adaptive_trail_step_pips * 0.1
+ if direction == "BUY":
+ new_trail_sl = close - trail_distance
+ if new_trail_sl > current_sl:
+ current_sl = new_trail_sl
+ else:
+ new_trail_sl = close + trail_distance
+ if current_sl == 0 or new_trail_sl < current_sl:
+ current_sl = new_trail_sl
+
+ # A.3 Peak protect
+ if peak_profit > self.min_profit_to_protect:
+ drawdown_pct = ((peak_profit - current_profit) / peak_profit) * 100 if peak_profit > 0 else 0
+ if drawdown_pct > self.max_drawdown_from_peak:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+
+ # A.4 Market analysis
+ if bars_since_entry % 5 == 0 and bars_since_entry >= 5 and i >= 20:
+ ma_fast = np.mean(closes[i-4:i+1])
+ ma_slow = np.mean(closes[i-19:i+1])
+ trend = "BULLISH" if ma_fast > ma_slow * 1.001 else ("BEARISH" if ma_fast < ma_slow * 0.999 else "NEUTRAL")
+ roc = (closes[i] / closes[max(0,i-4)] - 1) * 100
+ mom_dir = "BULLISH" if roc > 0.3 else ("BEARISH" if roc < -0.3 else "NEUTRAL")
+
+ rsi_val = None
+ if "rsi" in df.columns:
+ rsi_list = df["rsi"].to_list()
+ if i < len(rsi_list):
+ rsi_val = rsi_list[i]
+
+ urgency = 0
+ should_exit = False
+ if cached_ml_confidence > 0.75:
+ if (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY"):
+ should_exit = True; urgency += 2
+ if rsi_val:
+ if (rsi_val > 75 and direction == "BUY") or (rsi_val < 25 and direction == "SELL"):
+ should_exit = True; urgency += 2
+ if (direction == "BUY" and trend == "BEARISH" and mom_dir == "BEARISH") or \
+ (direction == "SELL" and trend == "BULLISH" and mom_dir == "BULLISH"):
+ should_exit = True; urgency += 3
+
+ if should_exit and current_profit > self.min_profit_to_protect / 2:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+ if urgency >= 7 and current_profit > 0:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+
+ # A.5 Weekend close
+ if self._is_near_weekend_close(current_time):
+ if current_profit > 0 or current_profit > -10:
+ return current_profit, current_pips, ExitReason.WEEKEND_CLOSE, i, close
+
+ # B.1 Smart TP
+ if current_profit >= 15:
+ if current_profit >= 40:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if current_profit >= 25 and momentum < -30:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if peak_profit > 30 and current_profit < peak_profit * 0.6:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+ if current_profit >= 20:
+ progress = (current_profit / target_tp_profit) * 100 if target_tp_profit > 0 else 0
+ progress_score = min(40, max(0, progress * 0.4))
+ momentum_score = ((momentum + 100) / 200) * 30
+ time_penalty = min(10, bars_since_entry / 4 * 2)
+ tp_probability = progress_score + momentum_score + 10 - time_penalty
+ if tp_probability < 25:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+
+ # B.2 Smart Early Exit
+ if 5 <= current_profit < 15:
+ if momentum < -50 and cached_ml_confidence >= 0.65:
+ is_reversal = (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY")
+ if is_reversal:
+ return current_profit, current_pips, ExitReason.EARLY_EXIT, i, close
+
+ # B.3 Early cut
+ if current_profit < 0:
+ loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
+ if momentum < self.early_cut_momentum and loss_percent_of_max >= self.early_cut_loss_pct:
+ return current_profit, current_pips, ExitReason.EARLY_CUT, i, close
+
+ # B.4 Trend Reversal
+ is_ml_reversal = False
+ if (direction == "BUY" and cached_ml_signal == "SELL" and cached_ml_confidence >= self.trend_reversal_threshold) or \
+ (direction == "SELL" and cached_ml_signal == "BUY" and cached_ml_confidence >= self.trend_reversal_threshold):
+ is_ml_reversal = True
+ reversal_warnings += 1
+ loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4)
+ if is_ml_reversal and current_profit < -8 and loss_moderate:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+ if reversal_warnings >= 3 and current_profit < -10:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ # B.5 Max loss
+ if current_profit <= -(self.max_loss_per_trade * 0.50):
+ htg = self._hours_to_golden(current_time)
+ if htg <= 1 and htg > 0 and momentum > -40:
+ pass
+ else:
+ return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
+
+ # B.6 Stall
+ if len(profit_history) >= 10:
+ recent_range = max(profit_history[-10:]) - min(profit_history[-10:])
+ if recent_range < 3 and current_profit < -15:
+ stall_count += 1
+ if stall_count >= 5:
+ return current_profit, current_pips, ExitReason.STALL, i, close
+
+ # B.7 Daily loss limit
+ potential_daily_loss = daily_loss_so_far + abs(min(0, current_profit))
+ if potential_daily_loss >= self.max_daily_loss_usd:
+ return current_profit, current_pips, ExitReason.DAILY_LIMIT, i, close
+
+ # C) Time-based
+ if bars_since_entry >= 16 and current_profit < 5 and not profit_growing:
+ if current_profit >= 0 or current_profit > -15:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 24 and (current_profit < 10 or not profit_growing):
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 32:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+
+ # C.2 ATR trend reversal
+ if bars_since_entry > 10:
+ recent_closes = closes[i-5:i+1]
+ mom = recent_closes[-1] - recent_closes[0]
+ if (direction == "BUY" and mom < -reversal_momentum_threshold) or \
+ (direction == "SELL" and mom > reversal_momentum_threshold):
+ if current_profit < -min_loss_for_reversal_exit:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ final_idx = min(entry_idx + max_bars - 1, len(df) - 1)
+ final_price = closes[final_idx]
+ pips = ((final_price - entry_price) if direction == "BUY" else (entry_price - final_price)) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TIMEOUT, final_idx, final_price
+
+ # ββ Main run (identical to #24B except passes first_candle_cuts) ββ
+
+ def run(self, df, start_date=None, end_date=None, initial_capital=5000.0):
+ stats = BacktestStats()
+ capital = initial_capital
+ peak_capital = initial_capital
+ stats.equity_curve.append(capital)
+
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ consecutive_losses = 0
+ trading_mode = TradingMode.NORMAL
+ current_date = None
+
+ feature_cols = []
+ if self.ml_model.fitted and self.ml_model.feature_names:
+ feature_cols = [f for f in self.ml_model.feature_names if f in df.columns]
+
+ times = df["time"].to_list()
+ start_idx = next((i for i, t in enumerate(times) if t >= start_date), 100) if start_date else 100
+ end_idx = next((i for i, t in enumerate(times) if t > end_date), len(df) - 100) if end_date else len(df) - 100
+
+ last_trade_idx = -self.trade_cooldown_bars * 2
+
+ print(f" #28 BE lock ATR mult: {self.be_profit_lock_atr_mult}, First-candle cut ATR mult: {self.first_candle_cut_atr_mult}")
+ print(f" Date range: {times[start_idx]} to {times[end_idx - 1]}")
+ print(f" Total bars: {end_idx - start_idx}")
+
+ for i in range(start_idx, end_idx):
+ if i - last_trade_idx < self.trade_cooldown_bars:
+ continue
+
+ current_time = times[i]
+ trade_date = current_time.date() if hasattr(current_time, 'date') else current_time
+ if current_date is None or trade_date != current_date:
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ current_date = trade_date
+ if consecutive_losses < 2:
+ trading_mode = TradingMode.NORMAL
+
+ if trading_mode == TradingMode.STOPPED:
+ continue
+
+ session_name, can_trade, lot_mult = self._get_session_from_time(current_time)
+ if not can_trade:
+ if session_name == "Tokyo-London Overlap":
+ stats.session_blocked += 1
+ continue
+
+ if hasattr(current_time, 'weekday') and current_time.weekday() >= 5:
+ continue
+
+ df_slice = df.head(i + 1)
+
+ regime = "normal"
+ try:
+ if self.regime_detector.fitted:
+ regime_state = self.regime_detector.get_current_state(df_slice)
+ if regime_state:
+ regime = regime_state.regime.value
+ if regime_state.regime == MarketRegime.CRISIS:
+ continue
+ if regime_state.recommendation == "SLEEP":
+ continue
+ except Exception:
+ pass
+
+ try:
+ ml_signal = ""
+ ml_confidence = 0.5
+ if self.ml_model.fitted and feature_cols:
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ ml_signal = ml_pred.signal
+ ml_confidence = ml_pred.confidence
+
+ market_analysis = self.dynamic_confidence.analyze_market(
+ session=session_name, regime=regime, volatility="medium",
+ trend_direction=regime, has_smc_signal=True,
+ ml_signal=ml_signal, ml_confidence=ml_confidence,
+ )
+ if market_analysis.quality == MarketQuality.AVOID:
+ stats.avoided_signals += 1
+ continue
+ except Exception:
+ pass
+
+ try:
+ smc_signal = self.smc.generate_signal(df_slice)
+ except Exception:
+ continue
+
+ if smc_signal is None:
+ continue
+
+ recent_df = df_slice.tail(10)
+ recent_bos = recent_df["bos"].to_list() if "bos" in df_slice.columns else []
+ recent_choch = recent_df["choch"].to_list() if "choch" in df_slice.columns else []
+ recent_fvg_bull = recent_df["is_fvg_bull"].to_list() if "is_fvg_bull" in df_slice.columns else []
+ recent_fvg_bear = recent_df["is_fvg_bear"].to_list() if "is_fvg_bear" in df_slice.columns else []
+ recent_obs = recent_df["ob"].to_list() if "ob" in df_slice.columns else []
+
+ has_bos = 1 in recent_bos or -1 in recent_bos
+ has_choch = 1 in recent_choch or -1 in recent_choch
+ has_fvg = any(recent_fvg_bull) or any(recent_fvg_bear)
+ has_ob = 1 in recent_obs or -1 in recent_obs
+
+ atr_at_entry = 12.0
+ if "atr" in df_slice.columns:
+ atr_val = df_slice.tail(1)["atr"].item()
+ if atr_val is not None and atr_val > 0:
+ atr_at_entry = atr_val
+
+ confidence = smc_signal.confidence
+ ml_agrees = (smc_signal.signal_type == "BUY" and ml_signal == "BUY") or \
+ (smc_signal.signal_type == "SELL" and ml_signal == "SELL")
+ if ml_agrees:
+ confidence = (smc_signal.confidence + ml_confidence) / 2
+ if regime == "high_volatility":
+ confidence *= 0.9
+
+ lot_size = self._calculate_lot_size(confidence, regime, trading_mode, lot_mult)
+ if lot_size <= 0:
+ continue
+
+ if trading_mode == TradingMode.RECOVERY:
+ stats.recovery_mode_trades += 1
+
+ entry_price = smc_signal.entry_price
+ take_profit_price = smc_signal.take_profit
+ stop_loss_price = smc_signal.stop_loss
+ risk = abs(entry_price - stop_loss_price)
+ rr = abs(take_profit_price - entry_price) / risk if risk > 0 else 0
+
+ profit, pips, exit_reason, exit_idx, exit_price = self._simulate_trade_exit(
+ df=df, entry_idx=i, direction=smc_signal.signal_type,
+ entry_price=entry_price, take_profit=take_profit_price,
+ stop_loss=stop_loss_price, lot_size=lot_size,
+ daily_loss_so_far=daily_loss, feature_cols=feature_cols,
+ )
+
+ if exit_reason == ExitReason.FIRST_CANDLE_CUT:
+ stats.first_candle_cuts += 1
+
+ self._ticket_counter += 1
+ result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
+
+ trade = SimulatedTrade(
+ ticket=self._ticket_counter,
+ entry_time=current_time,
+ exit_time=times[exit_idx] if exit_idx < len(times) else times[-1],
+ direction=smc_signal.signal_type,
+ entry_price=entry_price, exit_price=exit_price,
+ stop_loss=stop_loss_price, take_profit=take_profit_price,
+ lot_size=lot_size, profit_usd=profit, profit_pips=pips,
+ result=result, exit_reason=exit_reason,
+ smc_confidence=confidence, regime=regime,
+ session=session_name, signal_reason=smc_signal.reason,
+ has_bos=has_bos, has_choch=has_choch,
+ has_fvg=has_fvg, has_ob=has_ob,
+ atr_at_entry=atr_at_entry, rr_ratio=rr,
+ trading_mode=trading_mode.value,
+ )
+ stats.trades.append(trade)
+ stats.total_trades += 1
+ daily_trades += 1
+ capital += profit
+
+ if profit > 0:
+ stats.wins += 1
+ stats.total_profit += profit
+ daily_profit += profit
+ consecutive_losses = 0
+ if trading_mode == TradingMode.RECOVERY:
+ trading_mode = TradingMode.NORMAL
+ else:
+ stats.losses += 1
+ stats.total_loss += abs(profit)
+ daily_loss += abs(profit)
+ consecutive_losses += 1
+
+ if daily_loss >= self.max_daily_loss_usd:
+ trading_mode = TradingMode.STOPPED
+ stats.daily_limit_stops += 1
+ elif consecutive_losses >= 3 or daily_loss >= self.max_daily_loss_usd * 0.6:
+ trading_mode = TradingMode.PROTECTED
+ elif consecutive_losses >= 2:
+ trading_mode = TradingMode.RECOVERY
+
+ if capital > peak_capital:
+ peak_capital = capital
+ drawdown_pct = (peak_capital - capital) / peak_capital * 100
+ drawdown_usd = peak_capital - capital
+ if drawdown_pct > stats.max_drawdown:
+ stats.max_drawdown = drawdown_pct
+ stats.max_drawdown_usd = drawdown_usd
+
+ stats.equity_curve.append(capital)
+ last_trade_idx = exit_idx
+
+ if stats.total_trades % 100 == 0:
+ print(f" {stats.total_trades} trades processed...")
+
+ if stats.total_trades > 0:
+ stats.win_rate = stats.wins / stats.total_trades * 100
+ stats.avg_win = stats.total_profit / stats.wins if stats.wins > 0 else 0
+ stats.avg_loss = stats.total_loss / stats.losses if stats.losses > 0 else 0
+ stats.avg_trade = (stats.total_profit - stats.total_loss) / stats.total_trades
+ stats.profit_factor = stats.total_profit / stats.total_loss if stats.total_loss > 0 else float("inf")
+ win_prob = stats.wins / stats.total_trades
+ loss_prob = stats.losses / stats.total_trades
+ stats.expectancy = (win_prob * stats.avg_win) - (loss_prob * stats.avg_loss)
+ returns = [t.profit_usd for t in stats.trades]
+ if len(returns) > 1:
+ avg_return = np.mean(returns)
+ std_return = np.std(returns)
+ stats.sharpe_ratio = (avg_return / std_return) * np.sqrt(252) if std_return > 0 else 0
+
+ return stats
+
+
+# βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def main():
+ print("=" * 70)
+ print("XAUBOT AI β #28 Smart Breakeven + Loss Reduction")
+ print("Base: #24B | Modified: Smart BE profit lock + first-candle exit")
+ print("=" * 70)
+
+ config = get_config()
+ mt5 = MT5Connector(
+ login=config.mt5_login, password=config.mt5_password,
+ server=config.mt5_server, path=config.mt5_path,
+ )
+ mt5.connect()
+ print(f"\nConnected to MT5")
+
+ print("Fetching XAUUSD M15 historical data...")
+ df = mt5.get_market_data(symbol="XAUUSD", timeframe="M15", count=50000)
+ if len(df) == 0:
+ print("ERROR: No data")
+ mt5.disconnect()
+ return
+
+ print(f" Received {len(df)} bars")
+ times = df["time"].to_list()
+ print(f" Data range: {times[0]} to {times[-1]}")
+
+ end_date = datetime.now()
+ start_date = datetime(2025, 8, 1)
+ data_start = times[0]
+ if hasattr(data_start, 'replace') and data_start.tzinfo:
+ start_date = start_date.replace(tzinfo=data_start.tzinfo)
+ end_date = end_date.replace(tzinfo=data_start.tzinfo)
+ if data_start > start_date:
+ start_date = data_start + timedelta(days=5)
+
+ print(f"\n Backtest period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
+
+ print("\nCalculating indicators...")
+ features = FeatureEngineer()
+ smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ df = features.calculate_all(df, include_ml_features=True)
+ df = smc.calculate_all(df)
+
+ regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ regime_detector.load()
+ df = regime_detector.predict(df)
+ print(" HMM regime loaded")
+ except Exception:
+ print(" [WARN] HMM not available")
+ print(" Indicators calculated")
+
+ baseline_24b_pnl = 2235.0
+
+ # βββ CONFIGS βββ
+ configs = [
+ # (name, be_profit_lock_atr_mult, first_candle_cut_atr_mult)
+ ("A: Smart BE 0.3x ATR", 0.3, 0.0), # Lock small profit
+ ("B: Smart BE 0.5x ATR", 0.5, 0.0), # Lock more profit
+ ("C: First-candle 0.5x ATR", 0.0, 0.5), # Cut if bar 1 adverse
+ ("D: A+C (0.3x BE + FC)", 0.3, 0.5), # Combined
+ ("E: B+C (0.5x BE + FC)", 0.5, 0.5), # Combined aggressive
+ ]
+
+ all_results = []
+
+ for cfg_name, be_lock, fc_cut in configs:
+ print(f"\n{'=' * 60}")
+ print(f" Config: {cfg_name}")
+
+ bt = SmartBreakevenBacktest(
+ be_profit_lock_atr_mult=be_lock,
+ first_candle_cut_atr_mult=fc_cut,
+ )
+ stats = bt.run(df=df, start_date=start_date, end_date=end_date, initial_capital=5000.0)
+ net_pnl = stats.total_profit - stats.total_loss
+ diff = net_pnl - baseline_24b_pnl
+
+ # Count BE exits and their avg profit
+ be_exits = [t for t in stats.trades if t.exit_reason == ExitReason.BREAKEVEN_EXIT]
+ be_avg_profit = np.mean([t.profit_usd for t in be_exits]) if be_exits else 0
+ be_wins = sum(1 for t in be_exits if t.profit_usd > 0)
+
+ buy_trades = [t for t in stats.trades if t.direction == "BUY"]
+ sell_trades = [t for t in stats.trades if t.direction == "SELL"]
+ buy_wins = sum(1 for t in buy_trades if t.result == TradeResult.WIN)
+ sell_wins = sum(1 for t in sell_trades if t.result == TradeResult.WIN)
+ buy_wr = buy_wins / len(buy_trades) * 100 if buy_trades else 0
+ sell_wr = sell_wins / len(sell_trades) * 100 if sell_trades else 0
+
+ print(f"\n [{cfg_name}] Results:")
+ print(f" Trades: {stats.total_trades} | WR: {stats.win_rate:.1f}%")
+ print(f" Net PnL: ${net_pnl:,.2f} | PF: {stats.profit_factor:.2f}")
+ print(f" Max DD: {stats.max_drawdown:.1f}% | Sharpe: {stats.sharpe_ratio:.2f}")
+ print(f" BE exits: {len(be_exits)} (avg ${be_avg_profit:.2f}, {be_wins} profitable)")
+ print(f" First-candle cuts: {stats.first_candle_cuts}")
+ print(f" BUY: {len(buy_trades)}, {buy_wr:.1f}% WR | SELL: {len(sell_trades)}, {sell_wr:.1f}% WR")
+ print(f" vs #24B: ${diff:+,.2f}")
+
+ all_results.append((cfg_name, stats, net_pnl, diff, len(be_exits), be_avg_profit, be_wins, stats.first_candle_cuts))
+
+ # βββ FINAL SUMMARY βββ
+ print(f"\n{'=' * 70}")
+ print("#28 SMART BREAKEVEN β ALL CONFIGURATIONS")
+ print("=" * 70)
+
+ print(f"\n {'Config':<25} {'Trades':>6} {'WR':>6} {'Net PnL':>10} {'DD':>6} {'Sharpe':>7} {'PF':>5} {'BE#':>4} {'BE$':>6} {'FC#':>4} {'vs #24B':>10}")
+ print(f" {'-' * 100}")
+ print(f" {'#24B (base)':<25} {'739':>6} {'80.4%':>6} {'$2,235':>10} {'3.4%':>6} {'2.87':>7} {'1.77':>5} {'236':>4} {'$0.0':>6} {'β':>4} {'β':>10}")
+ for cfg_name, stats, net_pnl, diff, be_n, be_avg, be_w, fc_n in all_results:
+ print(f" {cfg_name:<25} {stats.total_trades:>6} {stats.win_rate:>5.1f}% ${net_pnl:>9,.2f} {stats.max_drawdown:>5.1f}% {stats.sharpe_ratio:>7.2f} {stats.profit_factor:>5.2f} {be_n:>4} ${be_avg:>5.1f} {fc_n:>4} ${diff:>+9,.2f}")
+
+ best_pnl = -999999
+ best_name = ""
+ best_stats = None
+ for entry in all_results:
+ if entry[2] > best_pnl:
+ best_pnl = entry[2]
+ best_name = entry[0]
+ best_stats = entry[1]
+
+ print(f"\n Best config: {best_name}")
+
+ # Exit reasons for best
+ print(f"\n Exit Reasons (best config):")
+ exit_counts = {}
+ for t in best_stats.trades:
+ r = t.exit_reason.value
+ exit_counts[r] = exit_counts.get(r, 0) + 1
+ for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
+ pct = count / best_stats.total_trades * 100 if best_stats.total_trades > 0 else 0
+ print(f" {reason:20s}: {count} ({pct:.1f}%)")
+
+ # Save
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "28_smart_breakeven_results")
+ os.makedirs(output_dir, exist_ok=True)
+
+ log_path = os.path.join(output_dir, f"smart_be_{timestamp}.log")
+ with open(log_path, "w") as f:
+ f.write(f"#28 Smart Breakeven Results\n")
+ f.write(f"Generated: {datetime.now()}\n")
+ f.write(f"Base: #24B (739 trades, 80.4% WR, $2,235)\n\n")
+ for cfg_name, stats, net_pnl, diff, be_n, be_avg, be_w, fc_n in all_results:
+ f.write(f" {cfg_name}: {stats.total_trades} trades, {stats.win_rate:.1f}% WR, "
+ f"${net_pnl:,.2f}, DD: {stats.max_drawdown:.1f}%, "
+ f"Sharpe: {stats.sharpe_ratio:.2f}, PF: {stats.profit_factor:.2f}, "
+ f"BE: {be_n} (avg ${be_avg:.2f}, {be_w} wins), FC cuts: {fc_n}, "
+ f"vs #24B: ${diff:+,.2f}\n")
+ f.write(f"\nBest: {best_name}\n")
+ print(f" Log saved: {log_path}")
+
+ try:
+ from backtests.backtest_01_smc_only import generate_xlsx_report as gen_xlsx
+ xlsx_path = os.path.join(output_dir, f"smart_be_{timestamp}.xlsx")
+ gen_xlsx(best_stats, xlsx_path, start_date, end_date)
+ print(f"\n Report saved: {xlsx_path}")
+ except Exception as e:
+ print(f" [WARN] XLSX: {e}")
+
+ mt5.disconnect()
+
+ print(f"\n{'=' * 70}")
+ print(f"Output: {output_dir}")
+ print(f" Log: {os.path.basename(log_path)}")
+ print("=" * 70)
+ print("Backtest complete!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backtests/backtest_29_confluence_scoring.py b/backtests/backtest_29_confluence_scoring.py
new file mode 100644
index 0000000..9fe7b53
--- /dev/null
+++ b/backtests/backtest_29_confluence_scoring.py
@@ -0,0 +1,953 @@
+"""
+Backtest #29 β Confluence Scoring
+==================================
+Base: #28B (Smart BE 0.5x ATR) β 741 trades, 79.8% WR, $2,464, Sharpe 3.23
+
+Idea: Require minimum number of SMC confirmations (BOS, CHoCH, FVG, OB)
+before entering. Currently any single SMC signal triggers entry. By requiring
+more confirmations, we filter weak signals and keep only high-quality setups.
+
+Configs:
+ A: Min 2 SMC elements (any 2 of BOS/CHoCH/FVG/OB)
+ B: Min confidence >= 0.55 (threshold filter)
+ C: Min confidence >= 0.60
+ D: A+B combined (2 elements + conf >= 0.55)
+ E: Min 3 SMC elements (BOS/CHoCH + FVG or OB)
+
+Usage:
+ python backtests/backtest_29_confluence_scoring.py
+"""
+
+import polars as pl
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta, date
+from typing import Dict, List, Tuple, Optional
+from dataclasses import dataclass, field
+from enum import Enum
+import sys
+import os
+from zoneinfo import ZoneInfo
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from src.mt5_connector import MT5Connector
+from src.smc_polars import SMCAnalyzer, SMCSignal
+from src.feature_eng import FeatureEngineer
+from src.regime_detector import MarketRegimeDetector, MarketRegime
+from src.ml_model import TradingModel
+from src.config import get_config
+from src.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence, MarketQuality
+from loguru import logger
+
+logger.remove()
+logger.add(sys.stderr, level="WARNING")
+
+WIB = ZoneInfo("Asia/Jakarta")
+
+
+# βββ Enums & Dataclasses ββββββββββββββββββββββββββββββββββββββ
+
+class TradeResult(Enum):
+ WIN = "WIN"
+ LOSS = "LOSS"
+ BREAKEVEN = "BREAKEVEN"
+
+class ExitReason(Enum):
+ TAKE_PROFIT = "take_profit"
+ SMART_TP = "smart_tp"
+ PEAK_PROTECT = "peak_protect"
+ EARLY_EXIT = "early_exit"
+ EARLY_CUT = "early_cut"
+ MAX_LOSS = "max_loss"
+ STALL = "stall"
+ TREND_REVERSAL = "trend_reversal"
+ TIMEOUT = "timeout"
+ WEEKEND_CLOSE = "weekend_close"
+ TRAILING_SL = "trailing_sl"
+ BREAKEVEN_EXIT = "breakeven_exit"
+ DAILY_LIMIT = "daily_limit"
+ REGIME_DANGER = "regime_danger"
+ MARKET_SIGNAL = "market_signal"
+
+class TradingMode(Enum):
+ NORMAL = "normal"
+ RECOVERY = "recovery"
+ PROTECTED = "protected"
+ STOPPED = "stopped"
+
+@dataclass
+class SimulatedTrade:
+ ticket: int
+ entry_time: datetime
+ exit_time: datetime
+ direction: str
+ entry_price: float
+ exit_price: float
+ stop_loss: float
+ take_profit: float
+ lot_size: float
+ profit_usd: float
+ profit_pips: float
+ result: TradeResult
+ exit_reason: ExitReason
+ smc_confidence: float
+ regime: str
+ session: str
+ signal_reason: str
+ has_bos: bool = False
+ has_choch: bool = False
+ has_fvg: bool = False
+ has_ob: bool = False
+ atr_at_entry: float = 0.0
+ rr_ratio: float = 0.0
+ trading_mode: str = "normal"
+ smc_element_count: int = 0 # NEW: count of SMC elements
+
+@dataclass
+class BacktestStats:
+ total_trades: int = 0
+ wins: int = 0
+ losses: int = 0
+ total_profit: float = 0.0
+ total_loss: float = 0.0
+ max_drawdown: float = 0.0
+ max_drawdown_usd: float = 0.0
+ win_rate: float = 0.0
+ profit_factor: float = 0.0
+ avg_win: float = 0.0
+ avg_loss: float = 0.0
+ avg_trade: float = 0.0
+ expectancy: float = 0.0
+ sharpe_ratio: float = 0.0
+ trades: List[SimulatedTrade] = field(default_factory=list)
+ equity_curve: List[float] = field(default_factory=list)
+ avoided_signals: int = 0
+ daily_limit_stops: int = 0
+ recovery_mode_trades: int = 0
+ session_blocked: int = 0
+ confluence_filtered: int = 0 # NEW
+
+
+# βββ Confluence Scoring Backtest βββββββββββββββββββββββββββββ
+
+class ConfluenceScoringBacktest:
+ """#28B base + confluence scoring entry filter."""
+
+ def __init__(
+ self,
+ capital: float = 5000.0,
+ max_daily_loss_percent: float = 5.0,
+ max_loss_per_trade_percent: float = 1.0,
+ base_lot_size: float = 0.01,
+ max_lot_size: float = 0.02,
+ recovery_lot_size: float = 0.01,
+ trend_reversal_threshold: float = 0.75,
+ max_concurrent_positions: int = 2,
+ min_profit_to_protect: float = 5.0,
+ max_drawdown_from_peak: float = 50.0,
+ trade_cooldown_bars: int = 10,
+ trend_reversal_mult: float = 0.6,
+ # #24B base
+ skip_tokyo_london: bool = True,
+ early_cut_momentum: float = -50.0,
+ early_cut_loss_pct: float = 30.0,
+ be_mult: float = 2.0,
+ trail_start_mult: float = 4.0,
+ trail_step_mult: float = 3.0,
+ # #28B: Smart breakeven
+ be_profit_lock_atr_mult: float = 0.5,
+ # βββ #29 CONFLUENCE SCORING PARAMS βββ
+ min_smc_elements: int = 0, # Minimum number of SMC elements (BOS, CHoCH, FVG, OB)
+ min_confidence: float = 0.0, # Minimum confidence threshold
+ ):
+ self.capital = capital
+ self.max_daily_loss_usd = capital * (max_daily_loss_percent / 100)
+ self.max_loss_per_trade = capital * (max_loss_per_trade_percent / 100)
+ self.base_lot_size = base_lot_size
+ self.max_lot_size = max_lot_size
+ self.recovery_lot_size = recovery_lot_size
+ self.trend_reversal_threshold = trend_reversal_threshold
+ self.max_concurrent_positions = max_concurrent_positions
+ self.min_profit_to_protect = min_profit_to_protect
+ self.max_drawdown_from_peak = max_drawdown_from_peak
+ self.trade_cooldown_bars = trade_cooldown_bars
+ self.trend_reversal_mult = trend_reversal_mult
+
+ self.skip_tokyo_london = skip_tokyo_london
+ self.early_cut_momentum = early_cut_momentum
+ self.early_cut_loss_pct = early_cut_loss_pct
+ self.be_mult = be_mult
+ self.trail_start_mult = trail_start_mult
+ self.trail_step_mult = trail_step_mult
+ self.be_profit_lock_atr_mult = be_profit_lock_atr_mult
+
+ # #29 params
+ self.min_smc_elements = min_smc_elements
+ self.min_confidence = min_confidence
+
+ config = get_config()
+ self.smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ self.features = FeatureEngineer()
+ self.dynamic_confidence = create_dynamic_confidence()
+
+ self.ml_model = TradingModel(model_path="models/xgboost_model.pkl")
+ try:
+ self.ml_model.load()
+ print(" ML model loaded (for exit evaluation)")
+ except Exception:
+ print(" [WARN] ML model not loaded")
+
+ self.regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ self.regime_detector.load()
+ except Exception:
+ print(" [WARN] HMM model not loaded")
+
+ self._ticket_counter = 2290000
+
+ def _get_session_from_time(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib_time = dt.astimezone(WIB)
+ hour = wib_time.hour
+ if 6 <= hour < 15:
+ return "Sydney-Tokyo", True, 0.5
+ elif 15 <= hour < 16:
+ if self.skip_tokyo_london:
+ return "Tokyo-London Overlap", False, 0.0
+ return "Tokyo-London Overlap", True, 0.75
+ elif 16 <= hour < 19:
+ return "London Early", True, 0.8
+ elif 19 <= hour < 24:
+ return "London-NY Overlap (Golden)", True, 1.0
+ elif 0 <= hour < 4:
+ return "NY Session", True, 0.9
+ else:
+ return "Off Hours", False, 0.0
+
+ def _hours_to_golden(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ if 19 <= wib.hour < 24:
+ return 0
+ target = wib.replace(hour=19, minute=0, second=0, microsecond=0)
+ if wib.hour >= 19:
+ target += timedelta(days=1)
+ return max(0, (target - wib).total_seconds() / 3600)
+
+ def _is_near_weekend_close(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ return wib.weekday() == 5 and wib.hour >= 4 and wib.minute >= 30
+
+ def _calculate_lot_size(self, confidence, regime, trading_mode, session_mult):
+ if trading_mode == TradingMode.STOPPED:
+ return 0
+ lot = self.base_lot_size
+ if trading_mode in (TradingMode.RECOVERY, TradingMode.PROTECTED):
+ lot = self.recovery_lot_size
+ else:
+ if confidence >= 0.65:
+ lot = self.max_lot_size
+ elif confidence >= 0.55:
+ lot = self.base_lot_size
+ else:
+ lot = self.recovery_lot_size
+ if regime.lower() in ["high_volatility", "crisis"]:
+ lot = self.recovery_lot_size
+ lot = max(0.01, lot * session_mult)
+ return round(lot, 2)
+
+ def _simulate_trade_exit(
+ self, df, entry_idx, direction, entry_price, take_profit, stop_loss,
+ lot_size, daily_loss_so_far, feature_cols, max_bars=100,
+ ):
+ pip_value = 10
+ highs = df["high"].to_list()
+ lows = df["low"].to_list()
+ closes = df["close"].to_list()
+ times = df["time"].to_list()
+
+ atr = 12.0
+ if "atr" in df.columns:
+ atr_list = df["atr"].to_list()
+ if entry_idx < len(atr_list) and atr_list[entry_idx] is not None:
+ atr = atr_list[entry_idx]
+
+ adaptive_breakeven_pips = atr * self.be_mult
+ adaptive_trail_start_pips = atr * self.trail_start_mult
+ adaptive_trail_step_pips = atr * self.trail_step_mult
+ reversal_momentum_threshold = atr * self.trend_reversal_mult
+ min_loss_for_reversal_exit = atr * 0.8
+
+ # #28B: Smart breakeven profit lock
+ if self.be_profit_lock_atr_mult > 0:
+ be_lock_distance = atr * self.be_profit_lock_atr_mult
+ else:
+ be_lock_distance = 2.0
+
+ profit_history = []
+ peak_profit = 0.0
+ stall_count = 0
+ reversal_warnings = 0
+ current_sl = stop_loss
+ breakeven_moved = False
+
+ if direction == "BUY":
+ target_tp_profit = (take_profit - entry_price) / 0.1 * pip_value * lot_size
+ else:
+ target_tp_profit = (entry_price - take_profit) / 0.1 * pip_value * lot_size
+
+ cached_ml_signal = ""
+ cached_ml_confidence = 0.5
+
+ for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
+ high = highs[i]
+ low = lows[i]
+ close = closes[i]
+ current_time = times[i]
+
+ if direction == "BUY":
+ current_pips = (close - entry_price) / 0.1
+ pip_profit_from_entry = current_pips
+ else:
+ current_pips = (entry_price - close) / 0.1
+ pip_profit_from_entry = current_pips
+ current_profit = current_pips * pip_value * lot_size
+
+ profit_history.append(current_profit)
+ if current_profit > peak_profit:
+ peak_profit = current_profit
+
+ bars_since_entry = i - entry_idx
+
+ if bars_since_entry % 4 == 0 and self.ml_model.fitted:
+ try:
+ df_slice = df.head(i + 1)
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ cached_ml_signal = ml_pred.signal
+ cached_ml_confidence = ml_pred.confidence
+ except Exception:
+ pass
+
+ momentum = 0.0
+ if len(profit_history) >= 3:
+ recent = profit_history[-5:] if len(profit_history) >= 5 else profit_history
+ profit_change = recent[-1] - recent[0]
+ momentum = max(-100, min(100, (profit_change / 10) * 50))
+ profit_growing = momentum > 0
+
+ # A.0 TP hit
+ if direction == "BUY" and high >= take_profit:
+ pips = (take_profit - entry_price) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+ elif direction == "SELL" and low <= take_profit:
+ pips = (entry_price - take_profit) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+
+ # A.0b Trailing SL hit
+ if breakeven_moved and current_sl > 0:
+ if direction == "BUY" and low <= current_sl:
+ pips = (current_sl - entry_price) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+ elif direction == "SELL" and high >= current_sl:
+ pips = (entry_price - current_sl) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+
+ # A.1 Breakeven (#28B: Smart β lock profit at entry + ATR*0.5)
+ if pip_profit_from_entry >= adaptive_breakeven_pips and not breakeven_moved:
+ if direction == "BUY":
+ current_sl = entry_price + be_lock_distance
+ else:
+ current_sl = entry_price - be_lock_distance
+ breakeven_moved = True
+
+ # A.2 Trailing SL
+ if pip_profit_from_entry >= adaptive_trail_start_pips:
+ trail_distance = adaptive_trail_step_pips * 0.1
+ if direction == "BUY":
+ new_trail_sl = close - trail_distance
+ if new_trail_sl > current_sl:
+ current_sl = new_trail_sl
+ else:
+ new_trail_sl = close + trail_distance
+ if current_sl == 0 or new_trail_sl < current_sl:
+ current_sl = new_trail_sl
+
+ # A.3 Peak protect
+ if peak_profit > self.min_profit_to_protect:
+ drawdown_pct = ((peak_profit - current_profit) / peak_profit) * 100 if peak_profit > 0 else 0
+ if drawdown_pct > self.max_drawdown_from_peak:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+
+ # A.4 Market analysis
+ if bars_since_entry % 5 == 0 and bars_since_entry >= 5 and i >= 20:
+ ma_fast = np.mean(closes[i-4:i+1])
+ ma_slow = np.mean(closes[i-19:i+1])
+ trend = "BULLISH" if ma_fast > ma_slow * 1.001 else ("BEARISH" if ma_fast < ma_slow * 0.999 else "NEUTRAL")
+ roc = (closes[i] / closes[max(0,i-4)] - 1) * 100
+ mom_dir = "BULLISH" if roc > 0.3 else ("BEARISH" if roc < -0.3 else "NEUTRAL")
+
+ rsi_val = None
+ if "rsi" in df.columns:
+ rsi_list = df["rsi"].to_list()
+ if i < len(rsi_list):
+ rsi_val = rsi_list[i]
+
+ urgency = 0
+ should_exit = False
+ if cached_ml_confidence > 0.75:
+ if (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY"):
+ should_exit = True; urgency += 2
+ if rsi_val:
+ if (rsi_val > 75 and direction == "BUY") or (rsi_val < 25 and direction == "SELL"):
+ should_exit = True; urgency += 2
+ if (direction == "BUY" and trend == "BEARISH" and mom_dir == "BEARISH") or \
+ (direction == "SELL" and trend == "BULLISH" and mom_dir == "BULLISH"):
+ should_exit = True; urgency += 3
+
+ if should_exit and current_profit > self.min_profit_to_protect / 2:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+ if urgency >= 7 and current_profit > 0:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+
+ # A.5 Weekend close
+ if self._is_near_weekend_close(current_time):
+ if current_profit > 0 or current_profit > -10:
+ return current_profit, current_pips, ExitReason.WEEKEND_CLOSE, i, close
+
+ # B.1 Smart TP
+ if current_profit >= 15:
+ if current_profit >= 40:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if current_profit >= 25 and momentum < -30:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if peak_profit > 30 and current_profit < peak_profit * 0.6:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+ if current_profit >= 20:
+ progress = (current_profit / target_tp_profit) * 100 if target_tp_profit > 0 else 0
+ progress_score = min(40, max(0, progress * 0.4))
+ momentum_score = ((momentum + 100) / 200) * 30
+ time_penalty = min(10, bars_since_entry / 4 * 2)
+ tp_probability = progress_score + momentum_score + 10 - time_penalty
+ if tp_probability < 25:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+
+ # B.2 Smart Early Exit
+ if 5 <= current_profit < 15:
+ if momentum < -50 and cached_ml_confidence >= 0.65:
+ is_reversal = (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY")
+ if is_reversal:
+ return current_profit, current_pips, ExitReason.EARLY_EXIT, i, close
+
+ # B.3 Early cut
+ if current_profit < 0:
+ loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
+ if momentum < self.early_cut_momentum and loss_percent_of_max >= self.early_cut_loss_pct:
+ return current_profit, current_pips, ExitReason.EARLY_CUT, i, close
+
+ # B.4 Trend Reversal
+ is_ml_reversal = False
+ if (direction == "BUY" and cached_ml_signal == "SELL" and cached_ml_confidence >= self.trend_reversal_threshold) or \
+ (direction == "SELL" and cached_ml_signal == "BUY" and cached_ml_confidence >= self.trend_reversal_threshold):
+ is_ml_reversal = True
+ reversal_warnings += 1
+ loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4)
+ if is_ml_reversal and current_profit < -8 and loss_moderate:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+ if reversal_warnings >= 3 and current_profit < -10:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ # B.5 Max loss
+ if current_profit <= -(self.max_loss_per_trade * 0.50):
+ htg = self._hours_to_golden(current_time)
+ if htg <= 1 and htg > 0 and momentum > -40:
+ pass
+ else:
+ return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
+
+ # B.6 Stall
+ if len(profit_history) >= 10:
+ recent_range = max(profit_history[-10:]) - min(profit_history[-10:])
+ if recent_range < 3 and current_profit < -15:
+ stall_count += 1
+ if stall_count >= 5:
+ return current_profit, current_pips, ExitReason.STALL, i, close
+
+ # B.7 Daily loss limit
+ potential_daily_loss = daily_loss_so_far + abs(min(0, current_profit))
+ if potential_daily_loss >= self.max_daily_loss_usd:
+ return current_profit, current_pips, ExitReason.DAILY_LIMIT, i, close
+
+ # C) Time-based
+ if bars_since_entry >= 16 and current_profit < 5 and not profit_growing:
+ if current_profit >= 0 or current_profit > -15:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 24 and (current_profit < 10 or not profit_growing):
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 32:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+
+ # C.2 ATR trend reversal
+ if bars_since_entry > 10:
+ recent_closes = closes[i-5:i+1]
+ mom = recent_closes[-1] - recent_closes[0]
+ if (direction == "BUY" and mom < -reversal_momentum_threshold) or \
+ (direction == "SELL" and mom > reversal_momentum_threshold):
+ if current_profit < -min_loss_for_reversal_exit:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ final_idx = min(entry_idx + max_bars - 1, len(df) - 1)
+ final_price = closes[final_idx]
+ pips = ((final_price - entry_price) if direction == "BUY" else (entry_price - final_price)) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TIMEOUT, final_idx, final_price
+
+ # ββ Main run ββ
+
+ def run(self, df, start_date=None, end_date=None, initial_capital=5000.0):
+ stats = BacktestStats()
+ capital = initial_capital
+ peak_capital = initial_capital
+ stats.equity_curve.append(capital)
+
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ consecutive_losses = 0
+ trading_mode = TradingMode.NORMAL
+ current_date = None
+
+ feature_cols = []
+ if self.ml_model.fitted and self.ml_model.feature_names:
+ feature_cols = [f for f in self.ml_model.feature_names if f in df.columns]
+
+ times = df["time"].to_list()
+ start_idx = next((i for i, t in enumerate(times) if t >= start_date), 100) if start_date else 100
+ end_idx = next((i for i, t in enumerate(times) if t > end_date), len(df) - 100) if end_date else len(df) - 100
+
+ last_trade_idx = -self.trade_cooldown_bars * 2
+
+ print(f" #29 Min SMC elements: {self.min_smc_elements}, Min confidence: {self.min_confidence}")
+ print(f" Date range: {times[start_idx]} to {times[end_idx - 1]}")
+ print(f" Total bars: {end_idx - start_idx}")
+
+ for i in range(start_idx, end_idx):
+ if i - last_trade_idx < self.trade_cooldown_bars:
+ continue
+
+ current_time = times[i]
+ trade_date = current_time.date() if hasattr(current_time, 'date') else current_time
+ if current_date is None or trade_date != current_date:
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ current_date = trade_date
+ if consecutive_losses < 2:
+ trading_mode = TradingMode.NORMAL
+
+ if trading_mode == TradingMode.STOPPED:
+ continue
+
+ session_name, can_trade, lot_mult = self._get_session_from_time(current_time)
+ if not can_trade:
+ if session_name == "Tokyo-London Overlap":
+ stats.session_blocked += 1
+ continue
+
+ if hasattr(current_time, 'weekday') and current_time.weekday() >= 5:
+ continue
+
+ df_slice = df.head(i + 1)
+
+ regime = "normal"
+ try:
+ if self.regime_detector.fitted:
+ regime_state = self.regime_detector.get_current_state(df_slice)
+ if regime_state:
+ regime = regime_state.regime.value
+ if regime_state.regime == MarketRegime.CRISIS:
+ continue
+ if regime_state.recommendation == "SLEEP":
+ continue
+ except Exception:
+ pass
+
+ try:
+ ml_signal = ""
+ ml_confidence = 0.5
+ if self.ml_model.fitted and feature_cols:
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ ml_signal = ml_pred.signal
+ ml_confidence = ml_pred.confidence
+
+ market_analysis = self.dynamic_confidence.analyze_market(
+ session=session_name, regime=regime, volatility="medium",
+ trend_direction=regime, has_smc_signal=True,
+ ml_signal=ml_signal, ml_confidence=ml_confidence,
+ )
+ if market_analysis.quality == MarketQuality.AVOID:
+ stats.avoided_signals += 1
+ continue
+ except Exception:
+ pass
+
+ try:
+ smc_signal = self.smc.generate_signal(df_slice)
+ except Exception:
+ continue
+
+ if smc_signal is None:
+ continue
+
+ # βββ SMC ELEMENT DETECTION βββ
+ recent_df = df_slice.tail(10)
+ recent_bos = recent_df["bos"].to_list() if "bos" in df_slice.columns else []
+ recent_choch = recent_df["choch"].to_list() if "choch" in df_slice.columns else []
+ recent_fvg_bull = recent_df["is_fvg_bull"].to_list() if "is_fvg_bull" in df_slice.columns else []
+ recent_fvg_bear = recent_df["is_fvg_bear"].to_list() if "is_fvg_bear" in df_slice.columns else []
+ recent_obs = recent_df["ob"].to_list() if "ob" in df_slice.columns else []
+
+ has_bos = 1 in recent_bos or -1 in recent_bos
+ has_choch = 1 in recent_choch or -1 in recent_choch
+ has_fvg = any(recent_fvg_bull) or any(recent_fvg_bear)
+ has_ob = 1 in recent_obs or -1 in recent_obs
+
+ # βββ #29: COUNT SMC ELEMENTS βββ
+ smc_element_count = sum([has_bos, has_choch, has_fvg, has_ob])
+
+ # βββ #29: CONFLUENCE FILTER βββ
+ if self.min_smc_elements > 0 and smc_element_count < self.min_smc_elements:
+ stats.confluence_filtered += 1
+ continue
+
+ atr_at_entry = 12.0
+ if "atr" in df_slice.columns:
+ atr_val = df_slice.tail(1)["atr"].item()
+ if atr_val is not None and atr_val > 0:
+ atr_at_entry = atr_val
+
+ confidence = smc_signal.confidence
+ ml_agrees = (smc_signal.signal_type == "BUY" and ml_signal == "BUY") or \
+ (smc_signal.signal_type == "SELL" and ml_signal == "SELL")
+ if ml_agrees:
+ confidence = (smc_signal.confidence + ml_confidence) / 2
+ if regime == "high_volatility":
+ confidence *= 0.9
+
+ # βββ #29: CONFIDENCE FILTER βββ
+ if self.min_confidence > 0 and confidence < self.min_confidence:
+ stats.confluence_filtered += 1
+ continue
+
+ lot_size = self._calculate_lot_size(confidence, regime, trading_mode, lot_mult)
+ if lot_size <= 0:
+ continue
+
+ if trading_mode == TradingMode.RECOVERY:
+ stats.recovery_mode_trades += 1
+
+ entry_price = smc_signal.entry_price
+ take_profit_price = smc_signal.take_profit
+ stop_loss_price = smc_signal.stop_loss
+ risk = abs(entry_price - stop_loss_price)
+ rr = abs(take_profit_price - entry_price) / risk if risk > 0 else 0
+
+ profit, pips, exit_reason, exit_idx, exit_price = self._simulate_trade_exit(
+ df=df, entry_idx=i, direction=smc_signal.signal_type,
+ entry_price=entry_price, take_profit=take_profit_price,
+ stop_loss=stop_loss_price, lot_size=lot_size,
+ daily_loss_so_far=daily_loss, feature_cols=feature_cols,
+ )
+
+ self._ticket_counter += 1
+ result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
+
+ trade = SimulatedTrade(
+ ticket=self._ticket_counter,
+ entry_time=current_time,
+ exit_time=times[exit_idx] if exit_idx < len(times) else times[-1],
+ direction=smc_signal.signal_type,
+ entry_price=entry_price, exit_price=exit_price,
+ stop_loss=stop_loss_price, take_profit=take_profit_price,
+ lot_size=lot_size, profit_usd=profit, profit_pips=pips,
+ result=result, exit_reason=exit_reason,
+ smc_confidence=confidence, regime=regime,
+ session=session_name, signal_reason=smc_signal.reason,
+ has_bos=has_bos, has_choch=has_choch,
+ has_fvg=has_fvg, has_ob=has_ob,
+ atr_at_entry=atr_at_entry, rr_ratio=rr,
+ trading_mode=trading_mode.value,
+ smc_element_count=smc_element_count,
+ )
+ stats.trades.append(trade)
+ stats.total_trades += 1
+ daily_trades += 1
+ capital += profit
+
+ if profit > 0:
+ stats.wins += 1
+ stats.total_profit += profit
+ daily_profit += profit
+ consecutive_losses = 0
+ if trading_mode == TradingMode.RECOVERY:
+ trading_mode = TradingMode.NORMAL
+ else:
+ stats.losses += 1
+ stats.total_loss += abs(profit)
+ daily_loss += abs(profit)
+ consecutive_losses += 1
+
+ if daily_loss >= self.max_daily_loss_usd:
+ trading_mode = TradingMode.STOPPED
+ stats.daily_limit_stops += 1
+ elif consecutive_losses >= 3 or daily_loss >= self.max_daily_loss_usd * 0.6:
+ trading_mode = TradingMode.PROTECTED
+ elif consecutive_losses >= 2:
+ trading_mode = TradingMode.RECOVERY
+
+ if capital > peak_capital:
+ peak_capital = capital
+ drawdown_pct = (peak_capital - capital) / peak_capital * 100
+ drawdown_usd = peak_capital - capital
+ if drawdown_pct > stats.max_drawdown:
+ stats.max_drawdown = drawdown_pct
+ stats.max_drawdown_usd = drawdown_usd
+
+ stats.equity_curve.append(capital)
+ last_trade_idx = exit_idx
+
+ if stats.total_trades % 100 == 0:
+ print(f" {stats.total_trades} trades processed...")
+
+ if stats.total_trades > 0:
+ stats.win_rate = stats.wins / stats.total_trades * 100
+ stats.avg_win = stats.total_profit / stats.wins if stats.wins > 0 else 0
+ stats.avg_loss = stats.total_loss / stats.losses if stats.losses > 0 else 0
+ stats.avg_trade = (stats.total_profit - stats.total_loss) / stats.total_trades
+ stats.profit_factor = stats.total_profit / stats.total_loss if stats.total_loss > 0 else float("inf")
+ win_prob = stats.wins / stats.total_trades
+ loss_prob = stats.losses / stats.total_trades
+ stats.expectancy = (win_prob * stats.avg_win) - (loss_prob * stats.avg_loss)
+ returns = [t.profit_usd for t in stats.trades]
+ if len(returns) > 1:
+ avg_return = np.mean(returns)
+ std_return = np.std(returns)
+ stats.sharpe_ratio = (avg_return / std_return) * np.sqrt(252) if std_return > 0 else 0
+
+ return stats
+
+
+# βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def main():
+ print("=" * 70)
+ print("XAUBOT AI β #29 Confluence Scoring")
+ print("Base: #28B (Smart BE 0.5x ATR) | Modified: Confluence entry filters")
+ print("=" * 70)
+
+ config = get_config()
+ mt5 = MT5Connector(
+ login=config.mt5_login, password=config.mt5_password,
+ server=config.mt5_server, path=config.mt5_path,
+ )
+ mt5.connect()
+ print(f"\nConnected to MT5")
+
+ print("Fetching XAUUSD M15 historical data...")
+ df = mt5.get_market_data(symbol="XAUUSD", timeframe="M15", count=50000)
+ if len(df) == 0:
+ print("ERROR: No data")
+ mt5.disconnect()
+ return
+
+ print(f" Received {len(df)} bars")
+ times = df["time"].to_list()
+ print(f" Data range: {times[0]} to {times[-1]}")
+
+ end_date = datetime.now()
+ start_date = datetime(2025, 8, 1)
+ data_start = times[0]
+ if hasattr(data_start, 'replace') and data_start.tzinfo:
+ start_date = start_date.replace(tzinfo=data_start.tzinfo)
+ end_date = end_date.replace(tzinfo=data_start.tzinfo)
+ if data_start > start_date:
+ start_date = data_start + timedelta(days=5)
+
+ print(f"\n Backtest period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
+
+ print("\nCalculating indicators...")
+ features = FeatureEngineer()
+ smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ df = features.calculate_all(df, include_ml_features=True)
+ df = smc.calculate_all(df)
+
+ regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ regime_detector.load()
+ df = regime_detector.predict(df)
+ print(" HMM regime loaded")
+ except Exception:
+ print(" [WARN] HMM not available")
+ print(" Indicators calculated")
+
+ # βββ ANALYZE ELEMENT DISTRIBUTION BEFORE BACKTEST βββ
+ print("\n SMC Element Distribution (pre-analysis)...")
+ if "bos" in df.columns:
+ bos_count = df.filter(pl.col("bos") != 0).height
+ choch_count = df.filter(pl.col("choch") != 0).height if "choch" in df.columns else 0
+ fvg_bull = df.filter(pl.col("is_fvg_bull") == True).height if "is_fvg_bull" in df.columns else 0
+ fvg_bear = df.filter(pl.col("is_fvg_bear") == True).height if "is_fvg_bear" in df.columns else 0
+ ob_count = df.filter(pl.col("ob") != 0).height if "ob" in df.columns else 0
+ print(f" BOS: {bos_count} bars | CHoCH: {choch_count} bars | FVG: {fvg_bull + fvg_bear} bars | OB: {ob_count} bars")
+
+ baseline_28b_pnl = 2463.80
+
+ # βββ CONFIGS βββ
+ configs = [
+ # (name, min_smc_elements, min_confidence)
+ ("A: Min 2 SMC elements", 2, 0.0), # Require 2 of BOS/CHoCH/FVG/OB
+ ("B: Min conf >= 0.55", 0, 0.55), # Confidence threshold
+ ("C: Min conf >= 0.60", 0, 0.60), # Higher confidence
+ ("D: 2 elem + conf>=0.55", 2, 0.55), # Combined
+ ("E: Min 3 SMC elements", 3, 0.0), # Strict: 3 out of 4 elements
+ ]
+
+ all_results = []
+
+ for cfg_name, min_elem, min_conf in configs:
+ print(f"\n{'=' * 60}")
+ print(f" Config: {cfg_name}")
+
+ bt = ConfluenceScoringBacktest(
+ min_smc_elements=min_elem,
+ min_confidence=min_conf,
+ )
+ stats = bt.run(df=df, start_date=start_date, end_date=end_date, initial_capital=5000.0)
+ net_pnl = stats.total_profit - stats.total_loss
+ diff = net_pnl - baseline_28b_pnl
+
+ buy_trades = [t for t in stats.trades if t.direction == "BUY"]
+ sell_trades = [t for t in stats.trades if t.direction == "SELL"]
+ buy_wins = sum(1 for t in buy_trades if t.result == TradeResult.WIN)
+ sell_wins = sum(1 for t in sell_trades if t.result == TradeResult.WIN)
+ buy_wr = buy_wins / len(buy_trades) * 100 if buy_trades else 0
+ sell_wr = sell_wins / len(sell_trades) * 100 if sell_trades else 0
+ buy_pnl = sum(t.profit_usd for t in buy_trades)
+ sell_pnl = sum(t.profit_usd for t in sell_trades)
+
+ # Element distribution for trades taken
+ elem_dist = {}
+ for t in stats.trades:
+ c = t.smc_element_count
+ elem_dist[c] = elem_dist.get(c, 0) + 1
+
+ print(f"\n [{cfg_name}] Results:")
+ print(f" Trades: {stats.total_trades} | WR: {stats.win_rate:.1f}%")
+ print(f" Net PnL: ${net_pnl:,.2f} | PF: {stats.profit_factor:.2f}")
+ print(f" Max DD: {stats.max_drawdown:.1f}% | Sharpe: {stats.sharpe_ratio:.2f}")
+ print(f" Confluence filtered: {stats.confluence_filtered}")
+ print(f" BUY: {len(buy_trades)}, {buy_wr:.1f}% WR, ${buy_pnl:,.2f}")
+ print(f" SELL: {len(sell_trades)}, {sell_wr:.1f}% WR, ${sell_pnl:,.2f}")
+ print(f" Element dist: {dict(sorted(elem_dist.items()))}")
+ print(f" vs #28B: ${diff:+,.2f}")
+
+ all_results.append((cfg_name, stats, net_pnl, diff, stats.confluence_filtered, elem_dist))
+
+ # βββ FINAL SUMMARY βββ
+ print(f"\n{'=' * 70}")
+ print("#29 CONFLUENCE SCORING β ALL CONFIGURATIONS")
+ print("=" * 70)
+
+ print(f"\n {'Config':<25} {'Trades':>6} {'WR':>6} {'Net PnL':>10} {'DD':>6} {'Sharpe':>7} {'PF':>5} {'Filt':>5} {'vs #28B':>10}")
+ print(f" {'-' * 90}")
+ print(f" {'#24B (base) ':<25} {'739':>6} {'80.4%':>6} {'$2,235':>10} {'3.4%':>6} {'2.87':>7} {'1.77':>5} {'β':>5} {'β':>10}")
+ print(f" {'#28B (smart BE)':<25} {'741':>6} {'79.8%':>6} {'$2,464':>10} {'3.5%':>6} {'3.23':>7} {'1.83':>5} {'β':>5} {'β':>10}")
+ for cfg_name, stats, net_pnl, diff, filt, elem_dist in all_results:
+ print(f" {cfg_name:<25} {stats.total_trades:>6} {stats.win_rate:>5.1f}% ${net_pnl:>9,.2f} {stats.max_drawdown:>5.1f}% {stats.sharpe_ratio:>7.2f} {stats.profit_factor:>5.2f} {filt:>5} ${diff:>+9,.2f}")
+
+ best_pnl = -999999
+ best_name = ""
+ best_stats = None
+ for entry in all_results:
+ if entry[2] > best_pnl:
+ best_pnl = entry[2]
+ best_name = entry[0]
+ best_stats = entry[1]
+
+ print(f"\n Best config: {best_name}")
+
+ # Element analysis for best config
+ print(f"\n Element Analysis (best config):")
+ for elem_count in sorted(set(t.smc_element_count for t in best_stats.trades)):
+ elem_trades = [t for t in best_stats.trades if t.smc_element_count == elem_count]
+ elem_wins = sum(1 for t in elem_trades if t.result == TradeResult.WIN)
+ elem_wr = elem_wins / len(elem_trades) * 100 if elem_trades else 0
+ elem_pnl = sum(t.profit_usd for t in elem_trades)
+ print(f" {elem_count} elements: {len(elem_trades)} trades, {elem_wr:.1f}% WR, ${elem_pnl:,.2f}")
+
+ # Direction analysis
+ print(f"\n Direction (best config):")
+ buy_trades = [t for t in best_stats.trades if t.direction == "BUY"]
+ sell_trades = [t for t in best_stats.trades if t.direction == "SELL"]
+ buy_wins = sum(1 for t in buy_trades if t.result == TradeResult.WIN)
+ sell_wins = sum(1 for t in sell_trades if t.result == TradeResult.WIN)
+ buy_wr = buy_wins / len(buy_trades) * 100 if buy_trades else 0
+ sell_wr = sell_wins / len(sell_trades) * 100 if sell_trades else 0
+ buy_pnl = sum(t.profit_usd for t in buy_trades)
+ sell_pnl = sum(t.profit_usd for t in sell_trades)
+ print(f" BUY: {len(buy_trades)} trades, {buy_wr:.1f}% WR, ${buy_pnl:,.2f}")
+ print(f" SELL: {len(sell_trades)} trades, {sell_wr:.1f}% WR, ${sell_pnl:,.2f}")
+
+ # Exit reasons
+ print(f"\n Exit Reasons (best config):")
+ exit_counts = {}
+ for t in best_stats.trades:
+ r = t.exit_reason.value
+ exit_counts[r] = exit_counts.get(r, 0) + 1
+ for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
+ pct = count / best_stats.total_trades * 100 if best_stats.total_trades > 0 else 0
+ print(f" {reason:20s}: {count} ({pct:.1f}%)")
+
+ # Save
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "29_confluence_scoring_results")
+ os.makedirs(output_dir, exist_ok=True)
+
+ log_path = os.path.join(output_dir, f"confluence_{timestamp}.log")
+ with open(log_path, "w") as f:
+ f.write(f"#29 Confluence Scoring Results\n")
+ f.write(f"Generated: {datetime.now()}\n")
+ f.write(f"Base: #28B (741 trades, 79.8% WR, $2,464)\n\n")
+ for cfg_name, stats, net_pnl, diff, filt, elem_dist in all_results:
+ f.write(f" {cfg_name}: {stats.total_trades} trades, {stats.win_rate:.1f}% WR, "
+ f"${net_pnl:,.2f}, DD: {stats.max_drawdown:.1f}%, "
+ f"Sharpe: {stats.sharpe_ratio:.2f}, PF: {stats.profit_factor:.2f}, "
+ f"Filtered: {filt}, Elem dist: {dict(sorted(elem_dist.items()))}, "
+ f"vs #28B: ${diff:+,.2f}\n")
+ f.write(f"\nBest: {best_name}\n")
+ print(f" Log saved: {log_path}")
+
+ try:
+ from backtests.backtest_01_smc_only import generate_xlsx_report as gen_xlsx
+ xlsx_path = os.path.join(output_dir, f"confluence_{timestamp}.xlsx")
+ gen_xlsx(best_stats, xlsx_path, start_date, end_date)
+ print(f"\n Report saved: {xlsx_path}")
+ except Exception as e:
+ print(f" [WARN] XLSX: {e}")
+
+ mt5.disconnect()
+
+ print(f"\n{'=' * 70}")
+ print(f"Output: {output_dir}")
+ print(f" Log: {os.path.basename(log_path)}")
+ print("=" * 70)
+ print("Backtest complete!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backtests/backtest_30_dynamic_rr.py b/backtests/backtest_30_dynamic_rr.py
new file mode 100644
index 0000000..acd4703
--- /dev/null
+++ b/backtests/backtest_30_dynamic_rr.py
@@ -0,0 +1,999 @@
+"""
+Backtest #30 β Dynamic Risk-Reward
+====================================
+Base: #28B (Smart BE 0.5x ATR) β 741 trades, 79.8% WR, $2,464, Sharpe 3.23
+
+Idea: Adjust TP distance based on session, ATR, and conditions.
+Currently, SMC uses a fixed RR of 1.5-2.0. What if we:
+- Use tighter TP in Asian session (smaller moves)
+- Use wider TP in Golden session (bigger moves)
+- Scale TP with ATR (high vol = wider TP)
+
+Configs:
+ A: Session-based RR (Golden=2.0x, London=1.5x, Asian=1.0x of SMC TP)
+ B: ATR-scaled TP (TP = entry + direction * ATR * 3.0)
+ C: ATR-scaled TP wider (TP = entry + direction * ATR * 4.0)
+ D: A + ATR floor (session RR but min TP = ATR * 2.5)
+ E: Tighter TP across board (RR mult 0.8 = closer TP for higher hit rate)
+
+Usage:
+ python backtests/backtest_30_dynamic_rr.py
+"""
+
+import polars as pl
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta, date
+from typing import Dict, List, Tuple, Optional
+from dataclasses import dataclass, field
+from enum import Enum
+import sys
+import os
+from zoneinfo import ZoneInfo
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from src.mt5_connector import MT5Connector
+from src.smc_polars import SMCAnalyzer, SMCSignal
+from src.feature_eng import FeatureEngineer
+from src.regime_detector import MarketRegimeDetector, MarketRegime
+from src.ml_model import TradingModel
+from src.config import get_config
+from src.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence, MarketQuality
+from loguru import logger
+
+logger.remove()
+logger.add(sys.stderr, level="WARNING")
+
+WIB = ZoneInfo("Asia/Jakarta")
+
+
+# βββ Enums & Dataclasses ββββββββββββββββββββββββββββββββββββββ
+
+class TradeResult(Enum):
+ WIN = "WIN"
+ LOSS = "LOSS"
+ BREAKEVEN = "BREAKEVEN"
+
+class ExitReason(Enum):
+ TAKE_PROFIT = "take_profit"
+ SMART_TP = "smart_tp"
+ PEAK_PROTECT = "peak_protect"
+ EARLY_EXIT = "early_exit"
+ EARLY_CUT = "early_cut"
+ MAX_LOSS = "max_loss"
+ STALL = "stall"
+ TREND_REVERSAL = "trend_reversal"
+ TIMEOUT = "timeout"
+ WEEKEND_CLOSE = "weekend_close"
+ TRAILING_SL = "trailing_sl"
+ BREAKEVEN_EXIT = "breakeven_exit"
+ DAILY_LIMIT = "daily_limit"
+ REGIME_DANGER = "regime_danger"
+ MARKET_SIGNAL = "market_signal"
+
+class TradingMode(Enum):
+ NORMAL = "normal"
+ RECOVERY = "recovery"
+ PROTECTED = "protected"
+ STOPPED = "stopped"
+
+@dataclass
+class SimulatedTrade:
+ ticket: int
+ entry_time: datetime
+ exit_time: datetime
+ direction: str
+ entry_price: float
+ exit_price: float
+ stop_loss: float
+ take_profit: float
+ lot_size: float
+ profit_usd: float
+ profit_pips: float
+ result: TradeResult
+ exit_reason: ExitReason
+ smc_confidence: float
+ regime: str
+ session: str
+ signal_reason: str
+ has_bos: bool = False
+ has_choch: bool = False
+ has_fvg: bool = False
+ has_ob: bool = False
+ atr_at_entry: float = 0.0
+ rr_ratio: float = 0.0
+ trading_mode: str = "normal"
+ original_tp: float = 0.0 # NEW: track original TP for comparison
+
+@dataclass
+class BacktestStats:
+ total_trades: int = 0
+ wins: int = 0
+ losses: int = 0
+ total_profit: float = 0.0
+ total_loss: float = 0.0
+ max_drawdown: float = 0.0
+ max_drawdown_usd: float = 0.0
+ win_rate: float = 0.0
+ profit_factor: float = 0.0
+ avg_win: float = 0.0
+ avg_loss: float = 0.0
+ avg_trade: float = 0.0
+ expectancy: float = 0.0
+ sharpe_ratio: float = 0.0
+ trades: List[SimulatedTrade] = field(default_factory=list)
+ equity_curve: List[float] = field(default_factory=list)
+ avoided_signals: int = 0
+ daily_limit_stops: int = 0
+ recovery_mode_trades: int = 0
+ session_blocked: int = 0
+ tp_modified: int = 0 # NEW
+
+
+# βββ Dynamic RR Backtest βββββββββββββββββββββββββββββββββββββ
+
+class DynamicRRBacktest:
+ """#28B base + dynamic risk-reward TP adjustment."""
+
+ def __init__(
+ self,
+ capital: float = 5000.0,
+ max_daily_loss_percent: float = 5.0,
+ max_loss_per_trade_percent: float = 1.0,
+ base_lot_size: float = 0.01,
+ max_lot_size: float = 0.02,
+ recovery_lot_size: float = 0.01,
+ trend_reversal_threshold: float = 0.75,
+ max_concurrent_positions: int = 2,
+ min_profit_to_protect: float = 5.0,
+ max_drawdown_from_peak: float = 50.0,
+ trade_cooldown_bars: int = 10,
+ trend_reversal_mult: float = 0.6,
+ # #24B base
+ skip_tokyo_london: bool = True,
+ early_cut_momentum: float = -50.0,
+ early_cut_loss_pct: float = 30.0,
+ be_mult: float = 2.0,
+ trail_start_mult: float = 4.0,
+ trail_step_mult: float = 3.0,
+ # #28B: Smart breakeven
+ be_profit_lock_atr_mult: float = 0.5,
+ # βββ #30 DYNAMIC RR PARAMS βββ
+ session_rr_multipliers: Optional[Dict[str, float]] = None, # session -> TP multiplier
+ atr_tp_mult: float = 0.0, # If > 0, override TP with entry +/- ATR * mult
+ tp_rr_multiplier: float = 1.0, # Global TP distance multiplier
+ atr_tp_floor_mult: float = 0.0, # Minimum TP distance = ATR * this
+ ):
+ self.capital = capital
+ self.max_daily_loss_usd = capital * (max_daily_loss_percent / 100)
+ self.max_loss_per_trade = capital * (max_loss_per_trade_percent / 100)
+ self.base_lot_size = base_lot_size
+ self.max_lot_size = max_lot_size
+ self.recovery_lot_size = recovery_lot_size
+ self.trend_reversal_threshold = trend_reversal_threshold
+ self.max_concurrent_positions = max_concurrent_positions
+ self.min_profit_to_protect = min_profit_to_protect
+ self.max_drawdown_from_peak = max_drawdown_from_peak
+ self.trade_cooldown_bars = trade_cooldown_bars
+ self.trend_reversal_mult = trend_reversal_mult
+
+ self.skip_tokyo_london = skip_tokyo_london
+ self.early_cut_momentum = early_cut_momentum
+ self.early_cut_loss_pct = early_cut_loss_pct
+ self.be_mult = be_mult
+ self.trail_start_mult = trail_start_mult
+ self.trail_step_mult = trail_step_mult
+ self.be_profit_lock_atr_mult = be_profit_lock_atr_mult
+
+ # #30 params
+ self.session_rr_multipliers = session_rr_multipliers or {}
+ self.atr_tp_mult = atr_tp_mult
+ self.tp_rr_multiplier = tp_rr_multiplier
+ self.atr_tp_floor_mult = atr_tp_floor_mult
+
+ config = get_config()
+ self.smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ self.features = FeatureEngineer()
+ self.dynamic_confidence = create_dynamic_confidence()
+
+ self.ml_model = TradingModel(model_path="models/xgboost_model.pkl")
+ try:
+ self.ml_model.load()
+ print(" ML model loaded (for exit evaluation)")
+ except Exception:
+ print(" [WARN] ML model not loaded")
+
+ self.regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ self.regime_detector.load()
+ except Exception:
+ print(" [WARN] HMM model not loaded")
+
+ self._ticket_counter = 2300000
+
+ def _get_session_from_time(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib_time = dt.astimezone(WIB)
+ hour = wib_time.hour
+ if 6 <= hour < 15:
+ return "Sydney-Tokyo", True, 0.5
+ elif 15 <= hour < 16:
+ if self.skip_tokyo_london:
+ return "Tokyo-London Overlap", False, 0.0
+ return "Tokyo-London Overlap", True, 0.75
+ elif 16 <= hour < 19:
+ return "London Early", True, 0.8
+ elif 19 <= hour < 24:
+ return "London-NY Overlap (Golden)", True, 1.0
+ elif 0 <= hour < 4:
+ return "NY Session", True, 0.9
+ else:
+ return "Off Hours", False, 0.0
+
+ def _hours_to_golden(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ if 19 <= wib.hour < 24:
+ return 0
+ target = wib.replace(hour=19, minute=0, second=0, microsecond=0)
+ if wib.hour >= 19:
+ target += timedelta(days=1)
+ return max(0, (target - wib).total_seconds() / 3600)
+
+ def _is_near_weekend_close(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ return wib.weekday() == 5 and wib.hour >= 4 and wib.minute >= 30
+
+ def _calculate_lot_size(self, confidence, regime, trading_mode, session_mult):
+ if trading_mode == TradingMode.STOPPED:
+ return 0
+ lot = self.base_lot_size
+ if trading_mode in (TradingMode.RECOVERY, TradingMode.PROTECTED):
+ lot = self.recovery_lot_size
+ else:
+ if confidence >= 0.65:
+ lot = self.max_lot_size
+ elif confidence >= 0.55:
+ lot = self.base_lot_size
+ else:
+ lot = self.recovery_lot_size
+ if regime.lower() in ["high_volatility", "crisis"]:
+ lot = self.recovery_lot_size
+ lot = max(0.01, lot * session_mult)
+ return round(lot, 2)
+
+ def _adjust_tp(self, direction, entry_price, original_tp, stop_loss, session_name, atr):
+ """#30: Adjust take profit based on session/ATR/multiplier."""
+ tp = original_tp
+ modified = False
+
+ # Method 1: ATR-based TP override
+ if self.atr_tp_mult > 0:
+ if direction == "BUY":
+ tp = entry_price + atr * self.atr_tp_mult
+ else:
+ tp = entry_price - atr * self.atr_tp_mult
+ modified = True
+
+ # Method 2: Session-based multiplier
+ elif self.session_rr_multipliers:
+ mult = self.session_rr_multipliers.get(session_name, 1.0)
+ tp_distance = abs(original_tp - entry_price)
+ new_tp_distance = tp_distance * mult
+ if direction == "BUY":
+ tp = entry_price + new_tp_distance
+ else:
+ tp = entry_price - new_tp_distance
+ if mult != 1.0:
+ modified = True
+
+ # Method 3: Global TP multiplier
+ if self.tp_rr_multiplier != 1.0 and self.atr_tp_mult == 0 and not self.session_rr_multipliers:
+ tp_distance = abs(original_tp - entry_price)
+ new_tp_distance = tp_distance * self.tp_rr_multiplier
+ if direction == "BUY":
+ tp = entry_price + new_tp_distance
+ else:
+ tp = entry_price - new_tp_distance
+ modified = True
+
+ # Floor: ensure minimum TP distance
+ if self.atr_tp_floor_mult > 0:
+ min_tp_distance = atr * self.atr_tp_floor_mult
+ current_tp_distance = abs(tp - entry_price)
+ if current_tp_distance < min_tp_distance:
+ if direction == "BUY":
+ tp = entry_price + min_tp_distance
+ else:
+ tp = entry_price - min_tp_distance
+ modified = True
+
+ return tp, modified
+
+ def _simulate_trade_exit(
+ self, df, entry_idx, direction, entry_price, take_profit, stop_loss,
+ lot_size, daily_loss_so_far, feature_cols, max_bars=100,
+ ):
+ pip_value = 10
+ highs = df["high"].to_list()
+ lows = df["low"].to_list()
+ closes = df["close"].to_list()
+ times = df["time"].to_list()
+
+ atr = 12.0
+ if "atr" in df.columns:
+ atr_list = df["atr"].to_list()
+ if entry_idx < len(atr_list) and atr_list[entry_idx] is not None:
+ atr = atr_list[entry_idx]
+
+ adaptive_breakeven_pips = atr * self.be_mult
+ adaptive_trail_start_pips = atr * self.trail_start_mult
+ adaptive_trail_step_pips = atr * self.trail_step_mult
+ reversal_momentum_threshold = atr * self.trend_reversal_mult
+ min_loss_for_reversal_exit = atr * 0.8
+
+ if self.be_profit_lock_atr_mult > 0:
+ be_lock_distance = atr * self.be_profit_lock_atr_mult
+ else:
+ be_lock_distance = 2.0
+
+ profit_history = []
+ peak_profit = 0.0
+ stall_count = 0
+ reversal_warnings = 0
+ current_sl = stop_loss
+ breakeven_moved = False
+
+ if direction == "BUY":
+ target_tp_profit = (take_profit - entry_price) / 0.1 * pip_value * lot_size
+ else:
+ target_tp_profit = (entry_price - take_profit) / 0.1 * pip_value * lot_size
+
+ cached_ml_signal = ""
+ cached_ml_confidence = 0.5
+
+ for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
+ high = highs[i]
+ low = lows[i]
+ close = closes[i]
+ current_time = times[i]
+
+ if direction == "BUY":
+ current_pips = (close - entry_price) / 0.1
+ pip_profit_from_entry = current_pips
+ else:
+ current_pips = (entry_price - close) / 0.1
+ pip_profit_from_entry = current_pips
+ current_profit = current_pips * pip_value * lot_size
+
+ profit_history.append(current_profit)
+ if current_profit > peak_profit:
+ peak_profit = current_profit
+
+ bars_since_entry = i - entry_idx
+
+ if bars_since_entry % 4 == 0 and self.ml_model.fitted:
+ try:
+ df_slice = df.head(i + 1)
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ cached_ml_signal = ml_pred.signal
+ cached_ml_confidence = ml_pred.confidence
+ except Exception:
+ pass
+
+ momentum = 0.0
+ if len(profit_history) >= 3:
+ recent = profit_history[-5:] if len(profit_history) >= 5 else profit_history
+ profit_change = recent[-1] - recent[0]
+ momentum = max(-100, min(100, (profit_change / 10) * 50))
+ profit_growing = momentum > 0
+
+ # A.0 TP hit
+ if direction == "BUY" and high >= take_profit:
+ pips = (take_profit - entry_price) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+ elif direction == "SELL" and low <= take_profit:
+ pips = (entry_price - take_profit) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+
+ # A.0b Trailing SL hit
+ if breakeven_moved and current_sl > 0:
+ if direction == "BUY" and low <= current_sl:
+ pips = (current_sl - entry_price) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+ elif direction == "SELL" and high >= current_sl:
+ pips = (entry_price - current_sl) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+
+ # A.1 Breakeven (#28B: Smart)
+ if pip_profit_from_entry >= adaptive_breakeven_pips and not breakeven_moved:
+ if direction == "BUY":
+ current_sl = entry_price + be_lock_distance
+ else:
+ current_sl = entry_price - be_lock_distance
+ breakeven_moved = True
+
+ # A.2 Trailing SL
+ if pip_profit_from_entry >= adaptive_trail_start_pips:
+ trail_distance = adaptive_trail_step_pips * 0.1
+ if direction == "BUY":
+ new_trail_sl = close - trail_distance
+ if new_trail_sl > current_sl:
+ current_sl = new_trail_sl
+ else:
+ new_trail_sl = close + trail_distance
+ if current_sl == 0 or new_trail_sl < current_sl:
+ current_sl = new_trail_sl
+
+ # A.3 Peak protect
+ if peak_profit > self.min_profit_to_protect:
+ drawdown_pct = ((peak_profit - current_profit) / peak_profit) * 100 if peak_profit > 0 else 0
+ if drawdown_pct > self.max_drawdown_from_peak:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+
+ # A.4 Market analysis
+ if bars_since_entry % 5 == 0 and bars_since_entry >= 5 and i >= 20:
+ ma_fast = np.mean(closes[i-4:i+1])
+ ma_slow = np.mean(closes[i-19:i+1])
+ trend = "BULLISH" if ma_fast > ma_slow * 1.001 else ("BEARISH" if ma_fast < ma_slow * 0.999 else "NEUTRAL")
+ roc = (closes[i] / closes[max(0,i-4)] - 1) * 100
+ mom_dir = "BULLISH" if roc > 0.3 else ("BEARISH" if roc < -0.3 else "NEUTRAL")
+
+ rsi_val = None
+ if "rsi" in df.columns:
+ rsi_list = df["rsi"].to_list()
+ if i < len(rsi_list):
+ rsi_val = rsi_list[i]
+
+ urgency = 0
+ should_exit = False
+ if cached_ml_confidence > 0.75:
+ if (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY"):
+ should_exit = True; urgency += 2
+ if rsi_val:
+ if (rsi_val > 75 and direction == "BUY") or (rsi_val < 25 and direction == "SELL"):
+ should_exit = True; urgency += 2
+ if (direction == "BUY" and trend == "BEARISH" and mom_dir == "BEARISH") or \
+ (direction == "SELL" and trend == "BULLISH" and mom_dir == "BULLISH"):
+ should_exit = True; urgency += 3
+
+ if should_exit and current_profit > self.min_profit_to_protect / 2:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+ if urgency >= 7 and current_profit > 0:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+
+ # A.5 Weekend close
+ if self._is_near_weekend_close(current_time):
+ if current_profit > 0 or current_profit > -10:
+ return current_profit, current_pips, ExitReason.WEEKEND_CLOSE, i, close
+
+ # B.1 Smart TP
+ if current_profit >= 15:
+ if current_profit >= 40:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if current_profit >= 25 and momentum < -30:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if peak_profit > 30 and current_profit < peak_profit * 0.6:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+ if current_profit >= 20:
+ progress = (current_profit / target_tp_profit) * 100 if target_tp_profit > 0 else 0
+ progress_score = min(40, max(0, progress * 0.4))
+ momentum_score = ((momentum + 100) / 200) * 30
+ time_penalty = min(10, bars_since_entry / 4 * 2)
+ tp_probability = progress_score + momentum_score + 10 - time_penalty
+ if tp_probability < 25:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+
+ # B.2 Smart Early Exit
+ if 5 <= current_profit < 15:
+ if momentum < -50 and cached_ml_confidence >= 0.65:
+ is_reversal = (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY")
+ if is_reversal:
+ return current_profit, current_pips, ExitReason.EARLY_EXIT, i, close
+
+ # B.3 Early cut
+ if current_profit < 0:
+ loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
+ if momentum < self.early_cut_momentum and loss_percent_of_max >= self.early_cut_loss_pct:
+ return current_profit, current_pips, ExitReason.EARLY_CUT, i, close
+
+ # B.4 Trend Reversal
+ is_ml_reversal = False
+ if (direction == "BUY" and cached_ml_signal == "SELL" and cached_ml_confidence >= self.trend_reversal_threshold) or \
+ (direction == "SELL" and cached_ml_signal == "BUY" and cached_ml_confidence >= self.trend_reversal_threshold):
+ is_ml_reversal = True
+ reversal_warnings += 1
+ loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4)
+ if is_ml_reversal and current_profit < -8 and loss_moderate:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+ if reversal_warnings >= 3 and current_profit < -10:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ # B.5 Max loss
+ if current_profit <= -(self.max_loss_per_trade * 0.50):
+ htg = self._hours_to_golden(current_time)
+ if htg <= 1 and htg > 0 and momentum > -40:
+ pass
+ else:
+ return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
+
+ # B.6 Stall
+ if len(profit_history) >= 10:
+ recent_range = max(profit_history[-10:]) - min(profit_history[-10:])
+ if recent_range < 3 and current_profit < -15:
+ stall_count += 1
+ if stall_count >= 5:
+ return current_profit, current_pips, ExitReason.STALL, i, close
+
+ # B.7 Daily loss limit
+ potential_daily_loss = daily_loss_so_far + abs(min(0, current_profit))
+ if potential_daily_loss >= self.max_daily_loss_usd:
+ return current_profit, current_pips, ExitReason.DAILY_LIMIT, i, close
+
+ # C) Time-based
+ if bars_since_entry >= 16 and current_profit < 5 and not profit_growing:
+ if current_profit >= 0 or current_profit > -15:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 24 and (current_profit < 10 or not profit_growing):
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 32:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+
+ # C.2 ATR trend reversal
+ if bars_since_entry > 10:
+ recent_closes = closes[i-5:i+1]
+ mom = recent_closes[-1] - recent_closes[0]
+ if (direction == "BUY" and mom < -reversal_momentum_threshold) or \
+ (direction == "SELL" and mom > reversal_momentum_threshold):
+ if current_profit < -min_loss_for_reversal_exit:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ final_idx = min(entry_idx + max_bars - 1, len(df) - 1)
+ final_price = closes[final_idx]
+ pips = ((final_price - entry_price) if direction == "BUY" else (entry_price - final_price)) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TIMEOUT, final_idx, final_price
+
+ def run(self, df, start_date=None, end_date=None, initial_capital=5000.0):
+ stats = BacktestStats()
+ capital = initial_capital
+ peak_capital = initial_capital
+ stats.equity_curve.append(capital)
+
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ consecutive_losses = 0
+ trading_mode = TradingMode.NORMAL
+ current_date = None
+
+ feature_cols = []
+ if self.ml_model.fitted and self.ml_model.feature_names:
+ feature_cols = [f for f in self.ml_model.feature_names if f in df.columns]
+
+ times = df["time"].to_list()
+ start_idx = next((i for i, t in enumerate(times) if t >= start_date), 100) if start_date else 100
+ end_idx = next((i for i, t in enumerate(times) if t > end_date), len(df) - 100) if end_date else len(df) - 100
+
+ last_trade_idx = -self.trade_cooldown_bars * 2
+
+ sess_rr_str = str(self.session_rr_multipliers) if self.session_rr_multipliers else "none"
+ print(f" #30 Session RR: {sess_rr_str}, ATR TP: {self.atr_tp_mult}, TP mult: {self.tp_rr_multiplier}, ATR floor: {self.atr_tp_floor_mult}")
+ print(f" Date range: {times[start_idx]} to {times[end_idx - 1]}")
+ print(f" Total bars: {end_idx - start_idx}")
+
+ for i in range(start_idx, end_idx):
+ if i - last_trade_idx < self.trade_cooldown_bars:
+ continue
+
+ current_time = times[i]
+ trade_date = current_time.date() if hasattr(current_time, 'date') else current_time
+ if current_date is None or trade_date != current_date:
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ current_date = trade_date
+ if consecutive_losses < 2:
+ trading_mode = TradingMode.NORMAL
+
+ if trading_mode == TradingMode.STOPPED:
+ continue
+
+ session_name, can_trade, lot_mult = self._get_session_from_time(current_time)
+ if not can_trade:
+ if session_name == "Tokyo-London Overlap":
+ stats.session_blocked += 1
+ continue
+
+ if hasattr(current_time, 'weekday') and current_time.weekday() >= 5:
+ continue
+
+ df_slice = df.head(i + 1)
+
+ regime = "normal"
+ try:
+ if self.regime_detector.fitted:
+ regime_state = self.regime_detector.get_current_state(df_slice)
+ if regime_state:
+ regime = regime_state.regime.value
+ if regime_state.regime == MarketRegime.CRISIS:
+ continue
+ if regime_state.recommendation == "SLEEP":
+ continue
+ except Exception:
+ pass
+
+ try:
+ ml_signal = ""
+ ml_confidence = 0.5
+ if self.ml_model.fitted and feature_cols:
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ ml_signal = ml_pred.signal
+ ml_confidence = ml_pred.confidence
+
+ market_analysis = self.dynamic_confidence.analyze_market(
+ session=session_name, regime=regime, volatility="medium",
+ trend_direction=regime, has_smc_signal=True,
+ ml_signal=ml_signal, ml_confidence=ml_confidence,
+ )
+ if market_analysis.quality == MarketQuality.AVOID:
+ stats.avoided_signals += 1
+ continue
+ except Exception:
+ pass
+
+ try:
+ smc_signal = self.smc.generate_signal(df_slice)
+ except Exception:
+ continue
+
+ if smc_signal is None:
+ continue
+
+ recent_df = df_slice.tail(10)
+ recent_bos = recent_df["bos"].to_list() if "bos" in df_slice.columns else []
+ recent_choch = recent_df["choch"].to_list() if "choch" in df_slice.columns else []
+ recent_fvg_bull = recent_df["is_fvg_bull"].to_list() if "is_fvg_bull" in df_slice.columns else []
+ recent_fvg_bear = recent_df["is_fvg_bear"].to_list() if "is_fvg_bear" in df_slice.columns else []
+ recent_obs = recent_df["ob"].to_list() if "ob" in df_slice.columns else []
+
+ has_bos = 1 in recent_bos or -1 in recent_bos
+ has_choch = 1 in recent_choch or -1 in recent_choch
+ has_fvg = any(recent_fvg_bull) or any(recent_fvg_bear)
+ has_ob = 1 in recent_obs or -1 in recent_obs
+
+ atr_at_entry = 12.0
+ if "atr" in df_slice.columns:
+ atr_val = df_slice.tail(1)["atr"].item()
+ if atr_val is not None and atr_val > 0:
+ atr_at_entry = atr_val
+
+ confidence = smc_signal.confidence
+ ml_agrees = (smc_signal.signal_type == "BUY" and ml_signal == "BUY") or \
+ (smc_signal.signal_type == "SELL" and ml_signal == "SELL")
+ if ml_agrees:
+ confidence = (smc_signal.confidence + ml_confidence) / 2
+ if regime == "high_volatility":
+ confidence *= 0.9
+
+ lot_size = self._calculate_lot_size(confidence, regime, trading_mode, lot_mult)
+ if lot_size <= 0:
+ continue
+
+ if trading_mode == TradingMode.RECOVERY:
+ stats.recovery_mode_trades += 1
+
+ entry_price = smc_signal.entry_price
+ original_tp = smc_signal.take_profit
+ stop_loss_price = smc_signal.stop_loss
+
+ # βββ #30: ADJUST TP βββ
+ take_profit_price, tp_was_modified = self._adjust_tp(
+ direction=smc_signal.signal_type,
+ entry_price=entry_price,
+ original_tp=original_tp,
+ stop_loss=stop_loss_price,
+ session_name=session_name,
+ atr=atr_at_entry,
+ )
+ if tp_was_modified:
+ stats.tp_modified += 1
+
+ risk = abs(entry_price - stop_loss_price)
+ rr = abs(take_profit_price - entry_price) / risk if risk > 0 else 0
+
+ profit, pips, exit_reason, exit_idx, exit_price = self._simulate_trade_exit(
+ df=df, entry_idx=i, direction=smc_signal.signal_type,
+ entry_price=entry_price, take_profit=take_profit_price,
+ stop_loss=stop_loss_price, lot_size=lot_size,
+ daily_loss_so_far=daily_loss, feature_cols=feature_cols,
+ )
+
+ self._ticket_counter += 1
+ result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
+
+ trade = SimulatedTrade(
+ ticket=self._ticket_counter,
+ entry_time=current_time,
+ exit_time=times[exit_idx] if exit_idx < len(times) else times[-1],
+ direction=smc_signal.signal_type,
+ entry_price=entry_price, exit_price=exit_price,
+ stop_loss=stop_loss_price, take_profit=take_profit_price,
+ lot_size=lot_size, profit_usd=profit, profit_pips=pips,
+ result=result, exit_reason=exit_reason,
+ smc_confidence=confidence, regime=regime,
+ session=session_name, signal_reason=smc_signal.reason,
+ has_bos=has_bos, has_choch=has_choch,
+ has_fvg=has_fvg, has_ob=has_ob,
+ atr_at_entry=atr_at_entry, rr_ratio=rr,
+ trading_mode=trading_mode.value,
+ original_tp=original_tp,
+ )
+ stats.trades.append(trade)
+ stats.total_trades += 1
+ daily_trades += 1
+ capital += profit
+
+ if profit > 0:
+ stats.wins += 1
+ stats.total_profit += profit
+ daily_profit += profit
+ consecutive_losses = 0
+ if trading_mode == TradingMode.RECOVERY:
+ trading_mode = TradingMode.NORMAL
+ else:
+ stats.losses += 1
+ stats.total_loss += abs(profit)
+ daily_loss += abs(profit)
+ consecutive_losses += 1
+
+ if daily_loss >= self.max_daily_loss_usd:
+ trading_mode = TradingMode.STOPPED
+ stats.daily_limit_stops += 1
+ elif consecutive_losses >= 3 or daily_loss >= self.max_daily_loss_usd * 0.6:
+ trading_mode = TradingMode.PROTECTED
+ elif consecutive_losses >= 2:
+ trading_mode = TradingMode.RECOVERY
+
+ if capital > peak_capital:
+ peak_capital = capital
+ drawdown_pct = (peak_capital - capital) / peak_capital * 100
+ drawdown_usd = peak_capital - capital
+ if drawdown_pct > stats.max_drawdown:
+ stats.max_drawdown = drawdown_pct
+ stats.max_drawdown_usd = drawdown_usd
+
+ stats.equity_curve.append(capital)
+ last_trade_idx = exit_idx
+
+ if stats.total_trades % 100 == 0:
+ print(f" {stats.total_trades} trades processed...")
+
+ if stats.total_trades > 0:
+ stats.win_rate = stats.wins / stats.total_trades * 100
+ stats.avg_win = stats.total_profit / stats.wins if stats.wins > 0 else 0
+ stats.avg_loss = stats.total_loss / stats.losses if stats.losses > 0 else 0
+ stats.avg_trade = (stats.total_profit - stats.total_loss) / stats.total_trades
+ stats.profit_factor = stats.total_profit / stats.total_loss if stats.total_loss > 0 else float("inf")
+ win_prob = stats.wins / stats.total_trades
+ loss_prob = stats.losses / stats.total_trades
+ stats.expectancy = (win_prob * stats.avg_win) - (loss_prob * stats.avg_loss)
+ returns = [t.profit_usd for t in stats.trades]
+ if len(returns) > 1:
+ avg_return = np.mean(returns)
+ std_return = np.std(returns)
+ stats.sharpe_ratio = (avg_return / std_return) * np.sqrt(252) if std_return > 0 else 0
+
+ return stats
+
+
+# βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def main():
+ print("=" * 70)
+ print("XAUBOT AI β #30 Dynamic Risk-Reward")
+ print("Base: #28B (Smart BE 0.5x ATR) | Modified: Dynamic TP adjustment")
+ print("=" * 70)
+
+ config = get_config()
+ mt5 = MT5Connector(
+ login=config.mt5_login, password=config.mt5_password,
+ server=config.mt5_server, path=config.mt5_path,
+ )
+ mt5.connect()
+ print(f"\nConnected to MT5")
+
+ print("Fetching XAUUSD M15 historical data...")
+ df = mt5.get_market_data(symbol="XAUUSD", timeframe="M15", count=50000)
+ if len(df) == 0:
+ print("ERROR: No data")
+ mt5.disconnect()
+ return
+
+ print(f" Received {len(df)} bars")
+ times = df["time"].to_list()
+ print(f" Data range: {times[0]} to {times[-1]}")
+
+ end_date = datetime.now()
+ start_date = datetime(2025, 8, 1)
+ data_start = times[0]
+ if hasattr(data_start, 'replace') and data_start.tzinfo:
+ start_date = start_date.replace(tzinfo=data_start.tzinfo)
+ end_date = end_date.replace(tzinfo=data_start.tzinfo)
+ if data_start > start_date:
+ start_date = data_start + timedelta(days=5)
+
+ print(f"\n Backtest period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
+
+ print("\nCalculating indicators...")
+ features = FeatureEngineer()
+ smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ df = features.calculate_all(df, include_ml_features=True)
+ df = smc.calculate_all(df)
+
+ regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ regime_detector.load()
+ df = regime_detector.predict(df)
+ print(" HMM regime loaded")
+ except Exception:
+ print(" [WARN] HMM not available")
+ print(" Indicators calculated")
+
+ baseline_28b_pnl = 2463.80
+
+ # βββ CONFIGS βββ
+ configs = [
+ ("A: Session RR", {
+ "session_rr_multipliers": {
+ "Sydney-Tokyo": 0.8,
+ "London Early": 1.0,
+ "London-NY Overlap (Golden)": 1.3,
+ "NY Session": 1.0,
+ },
+ }),
+ ("B: ATR TP 3.0x", {
+ "atr_tp_mult": 3.0,
+ }),
+ ("C: ATR TP 4.0x", {
+ "atr_tp_mult": 4.0,
+ }),
+ ("D: Session + ATR floor", {
+ "session_rr_multipliers": {
+ "Sydney-Tokyo": 0.8,
+ "London Early": 1.0,
+ "London-NY Overlap (Golden)": 1.3,
+ "NY Session": 1.0,
+ },
+ "atr_tp_floor_mult": 2.5,
+ }),
+ ("E: Tighter TP 0.8x", {
+ "tp_rr_multiplier": 0.8,
+ }),
+ ]
+
+ all_results = []
+
+ for cfg_name, cfg_params in configs:
+ print(f"\n{'=' * 60}")
+ print(f" Config: {cfg_name}")
+
+ bt = DynamicRRBacktest(**cfg_params)
+ stats = bt.run(df=df, start_date=start_date, end_date=end_date, initial_capital=5000.0)
+ net_pnl = stats.total_profit - stats.total_loss
+ diff = net_pnl - baseline_28b_pnl
+
+ buy_trades = [t for t in stats.trades if t.direction == "BUY"]
+ sell_trades = [t for t in stats.trades if t.direction == "SELL"]
+ buy_wins = sum(1 for t in buy_trades if t.result == TradeResult.WIN)
+ sell_wins = sum(1 for t in sell_trades if t.result == TradeResult.WIN)
+ buy_wr = buy_wins / len(buy_trades) * 100 if buy_trades else 0
+ sell_wr = sell_wins / len(sell_trades) * 100 if sell_trades else 0
+ buy_pnl = sum(t.profit_usd for t in buy_trades)
+ sell_pnl = sum(t.profit_usd for t in sell_trades)
+
+ # TP hit rate
+ tp_hits = sum(1 for t in stats.trades if t.exit_reason == ExitReason.TAKE_PROFIT)
+ tp_rate = tp_hits / stats.total_trades * 100 if stats.total_trades > 0 else 0
+
+ # Avg RR
+ avg_rr = np.mean([t.rr_ratio for t in stats.trades]) if stats.trades else 0
+
+ print(f"\n [{cfg_name}] Results:")
+ print(f" Trades: {stats.total_trades} | WR: {stats.win_rate:.1f}%")
+ print(f" Net PnL: ${net_pnl:,.2f} | PF: {stats.profit_factor:.2f}")
+ print(f" Max DD: {stats.max_drawdown:.1f}% | Sharpe: {stats.sharpe_ratio:.2f}")
+ print(f" TP modified: {stats.tp_modified} | TP hit rate: {tp_rate:.1f}% | Avg RR: {avg_rr:.2f}")
+ print(f" BUY: {len(buy_trades)}, {buy_wr:.1f}% WR, ${buy_pnl:,.2f}")
+ print(f" SELL: {len(sell_trades)}, {sell_wr:.1f}% WR, ${sell_pnl:,.2f}")
+ print(f" vs #28B: ${diff:+,.2f}")
+
+ all_results.append((cfg_name, stats, net_pnl, diff, stats.tp_modified, tp_rate, avg_rr))
+
+ # βββ FINAL SUMMARY βββ
+ print(f"\n{'=' * 70}")
+ print("#30 DYNAMIC RISK-REWARD β ALL CONFIGURATIONS")
+ print("=" * 70)
+
+ print(f"\n {'Config':<25} {'Trades':>6} {'WR':>6} {'Net PnL':>10} {'DD':>6} {'Sharpe':>7} {'PF':>5} {'TP%':>5} {'RR':>5} {'vs #28B':>10}")
+ print(f" {'-' * 95}")
+ print(f" {'#24B (base) ':<25} {'739':>6} {'80.4%':>6} {'$2,235':>10} {'3.4%':>6} {'2.87':>7} {'1.77':>5} {'3.5%':>5} {'1.6':>5} {'β':>10}")
+ print(f" {'#28B (smart BE)':<25} {'741':>6} {'79.8%':>6} {'$2,464':>10} {'3.5%':>6} {'3.23':>7} {'1.83':>5} {'3.0%':>5} {'1.6':>5} {'β':>10}")
+ for cfg_name, stats, net_pnl, diff, tp_mod, tp_rate, avg_rr in all_results:
+ print(f" {cfg_name:<25} {stats.total_trades:>6} {stats.win_rate:>5.1f}% ${net_pnl:>9,.2f} {stats.max_drawdown:>5.1f}% {stats.sharpe_ratio:>7.2f} {stats.profit_factor:>5.2f} {tp_rate:>4.1f}% {avg_rr:>5.2f} ${diff:>+9,.2f}")
+
+ best_pnl = -999999
+ best_name = ""
+ best_stats = None
+ for entry in all_results:
+ if entry[2] > best_pnl:
+ best_pnl = entry[2]
+ best_name = entry[0]
+ best_stats = entry[1]
+
+ print(f"\n Best config: {best_name}")
+
+ # Per-session analysis for best
+ print(f"\n Per-Session (best config):")
+ sessions = set(t.session for t in best_stats.trades)
+ for sess in sorted(sessions):
+ sess_trades = [t for t in best_stats.trades if t.session == sess]
+ sess_wins = sum(1 for t in sess_trades if t.result == TradeResult.WIN)
+ sess_wr = sess_wins / len(sess_trades) * 100 if sess_trades else 0
+ sess_pnl = sum(t.profit_usd for t in sess_trades)
+ print(f" {sess:30s}: {len(sess_trades):>4} trades, {sess_wr:>5.1f}% WR, ${sess_pnl:>8,.2f}")
+
+ # Exit reasons
+ print(f"\n Exit Reasons (best config):")
+ exit_counts = {}
+ for t in best_stats.trades:
+ r = t.exit_reason.value
+ exit_counts[r] = exit_counts.get(r, 0) + 1
+ for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
+ pct = count / best_stats.total_trades * 100 if best_stats.total_trades > 0 else 0
+ print(f" {reason:20s}: {count} ({pct:.1f}%)")
+
+ # Save
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "30_dynamic_rr_results")
+ os.makedirs(output_dir, exist_ok=True)
+
+ log_path = os.path.join(output_dir, f"dynamic_rr_{timestamp}.log")
+ with open(log_path, "w") as f:
+ f.write(f"#30 Dynamic Risk-Reward Results\n")
+ f.write(f"Generated: {datetime.now()}\n")
+ f.write(f"Base: #28B (741 trades, 79.8% WR, $2,464)\n\n")
+ for cfg_name, stats, net_pnl, diff, tp_mod, tp_rate, avg_rr in all_results:
+ f.write(f" {cfg_name}: {stats.total_trades} trades, {stats.win_rate:.1f}% WR, "
+ f"${net_pnl:,.2f}, DD: {stats.max_drawdown:.1f}%, "
+ f"Sharpe: {stats.sharpe_ratio:.2f}, PF: {stats.profit_factor:.2f}, "
+ f"TP modified: {tp_mod}, TP rate: {tp_rate:.1f}%, Avg RR: {avg_rr:.2f}, "
+ f"vs #28B: ${diff:+,.2f}\n")
+ f.write(f"\nBest: {best_name}\n")
+ print(f" Log saved: {log_path}")
+
+ try:
+ from backtests.backtest_01_smc_only import generate_xlsx_report as gen_xlsx
+ xlsx_path = os.path.join(output_dir, f"dynamic_rr_{timestamp}.xlsx")
+ gen_xlsx(best_stats, xlsx_path, start_date, end_date)
+ print(f"\n Report saved: {xlsx_path}")
+ except Exception as e:
+ print(f" [WARN] XLSX: {e}")
+
+ mt5.disconnect()
+
+ print(f"\n{'=' * 70}")
+ print(f"Output: {output_dir}")
+ print(f" Log: {os.path.basename(log_path)}")
+ print("=" * 70)
+ print("Backtest complete!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backtests/backtest_31_multi_tf_h1.py b/backtests/backtest_31_multi_tf_h1.py
new file mode 100644
index 0000000..f929f83
--- /dev/null
+++ b/backtests/backtest_31_multi_tf_h1.py
@@ -0,0 +1,1016 @@
+"""
+Backtest #31 β Multi-Timeframe H1 Confirmation
+================================================
+Base: #28B (Smart BE 0.5x ATR) β 741 trades, 79.8% WR, $2,464, Sharpe 3.23
+
+Idea: Use H1 (1-hour) timeframe for trend confirmation before entering on M15.
+If H1 trend disagrees with M15 signal, skip the trade.
+
+H1 trend detection methods:
+ - EMA alignment (20 EMA vs 50 EMA)
+ - Market structure (last BOS direction)
+ - Price above/below EMA
+
+Configs:
+ A: H1 EMA trend filter (signal must align with H1 20/50 EMA trend)
+ B: H1 price vs EMA20 (BUY only if price > H1 EMA20, SELL only if < H1 EMA20)
+ C: H1 BOS direction (signal must match last H1 BOS direction)
+ D: H1 filter on SELL only (only filter SELL trades with H1 trend)
+ E: H1 filter relaxed (allow if H1 is NEUTRAL, only block opposing trend)
+
+Usage:
+ python backtests/backtest_31_multi_tf_h1.py
+"""
+
+import polars as pl
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta, date
+from typing import Dict, List, Tuple, Optional
+from dataclasses import dataclass, field
+from enum import Enum
+import sys
+import os
+from zoneinfo import ZoneInfo
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from src.mt5_connector import MT5Connector
+from src.smc_polars import SMCAnalyzer, SMCSignal
+from src.feature_eng import FeatureEngineer
+from src.regime_detector import MarketRegimeDetector, MarketRegime
+from src.ml_model import TradingModel
+from src.config import get_config
+from src.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence, MarketQuality
+from loguru import logger
+
+logger.remove()
+logger.add(sys.stderr, level="WARNING")
+
+WIB = ZoneInfo("Asia/Jakarta")
+
+
+# βββ Enums & Dataclasses ββββββββββββββββββββββββββββββββββββββ
+
+class TradeResult(Enum):
+ WIN = "WIN"
+ LOSS = "LOSS"
+ BREAKEVEN = "BREAKEVEN"
+
+class ExitReason(Enum):
+ TAKE_PROFIT = "take_profit"
+ SMART_TP = "smart_tp"
+ PEAK_PROTECT = "peak_protect"
+ EARLY_EXIT = "early_exit"
+ EARLY_CUT = "early_cut"
+ MAX_LOSS = "max_loss"
+ STALL = "stall"
+ TREND_REVERSAL = "trend_reversal"
+ TIMEOUT = "timeout"
+ WEEKEND_CLOSE = "weekend_close"
+ TRAILING_SL = "trailing_sl"
+ BREAKEVEN_EXIT = "breakeven_exit"
+ DAILY_LIMIT = "daily_limit"
+ REGIME_DANGER = "regime_danger"
+ MARKET_SIGNAL = "market_signal"
+
+class TradingMode(Enum):
+ NORMAL = "normal"
+ RECOVERY = "recovery"
+ PROTECTED = "protected"
+ STOPPED = "stopped"
+
+@dataclass
+class SimulatedTrade:
+ ticket: int
+ entry_time: datetime
+ exit_time: datetime
+ direction: str
+ entry_price: float
+ exit_price: float
+ stop_loss: float
+ take_profit: float
+ lot_size: float
+ profit_usd: float
+ profit_pips: float
+ result: TradeResult
+ exit_reason: ExitReason
+ smc_confidence: float
+ regime: str
+ session: str
+ signal_reason: str
+ has_bos: bool = False
+ has_choch: bool = False
+ has_fvg: bool = False
+ has_ob: bool = False
+ atr_at_entry: float = 0.0
+ rr_ratio: float = 0.0
+ trading_mode: str = "normal"
+ h1_trend: str = "NEUTRAL" # NEW
+
+@dataclass
+class BacktestStats:
+ total_trades: int = 0
+ wins: int = 0
+ losses: int = 0
+ total_profit: float = 0.0
+ total_loss: float = 0.0
+ max_drawdown: float = 0.0
+ max_drawdown_usd: float = 0.0
+ win_rate: float = 0.0
+ profit_factor: float = 0.0
+ avg_win: float = 0.0
+ avg_loss: float = 0.0
+ avg_trade: float = 0.0
+ expectancy: float = 0.0
+ sharpe_ratio: float = 0.0
+ trades: List[SimulatedTrade] = field(default_factory=list)
+ equity_curve: List[float] = field(default_factory=list)
+ avoided_signals: int = 0
+ daily_limit_stops: int = 0
+ recovery_mode_trades: int = 0
+ session_blocked: int = 0
+ h1_filtered: int = 0 # NEW
+
+
+# βββ Multi-TF Backtest ββββββββββββββββββββββββββββββββββββββ
+
+class MultiTFBacktest:
+ """#28B base + H1 multi-timeframe confirmation."""
+
+ def __init__(
+ self,
+ capital: float = 5000.0,
+ max_daily_loss_percent: float = 5.0,
+ max_loss_per_trade_percent: float = 1.0,
+ base_lot_size: float = 0.01,
+ max_lot_size: float = 0.02,
+ recovery_lot_size: float = 0.01,
+ trend_reversal_threshold: float = 0.75,
+ max_concurrent_positions: int = 2,
+ min_profit_to_protect: float = 5.0,
+ max_drawdown_from_peak: float = 50.0,
+ trade_cooldown_bars: int = 10,
+ trend_reversal_mult: float = 0.6,
+ # #24B base
+ skip_tokyo_london: bool = True,
+ early_cut_momentum: float = -50.0,
+ early_cut_loss_pct: float = 30.0,
+ be_mult: float = 2.0,
+ trail_start_mult: float = 4.0,
+ trail_step_mult: float = 3.0,
+ # #28B: Smart breakeven
+ be_profit_lock_atr_mult: float = 0.5,
+ # βββ #31 MULTI-TF PARAMS βββ
+ h1_filter_mode: str = "ema", # "ema", "price_vs_ema", "bos", "sell_only", "relaxed"
+ ):
+ self.capital = capital
+ self.max_daily_loss_usd = capital * (max_daily_loss_percent / 100)
+ self.max_loss_per_trade = capital * (max_loss_per_trade_percent / 100)
+ self.base_lot_size = base_lot_size
+ self.max_lot_size = max_lot_size
+ self.recovery_lot_size = recovery_lot_size
+ self.trend_reversal_threshold = trend_reversal_threshold
+ self.max_concurrent_positions = max_concurrent_positions
+ self.min_profit_to_protect = min_profit_to_protect
+ self.max_drawdown_from_peak = max_drawdown_from_peak
+ self.trade_cooldown_bars = trade_cooldown_bars
+ self.trend_reversal_mult = trend_reversal_mult
+
+ self.skip_tokyo_london = skip_tokyo_london
+ self.early_cut_momentum = early_cut_momentum
+ self.early_cut_loss_pct = early_cut_loss_pct
+ self.be_mult = be_mult
+ self.trail_start_mult = trail_start_mult
+ self.trail_step_mult = trail_step_mult
+ self.be_profit_lock_atr_mult = be_profit_lock_atr_mult
+
+ # #31 params
+ self.h1_filter_mode = h1_filter_mode
+
+ config = get_config()
+ self.smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ self.features = FeatureEngineer()
+ self.dynamic_confidence = create_dynamic_confidence()
+
+ self.ml_model = TradingModel(model_path="models/xgboost_model.pkl")
+ try:
+ self.ml_model.load()
+ print(" ML model loaded (for exit evaluation)")
+ except Exception:
+ print(" [WARN] ML model not loaded")
+
+ self.regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ self.regime_detector.load()
+ except Exception:
+ print(" [WARN] HMM model not loaded")
+
+ self._ticket_counter = 2310000
+
+ def _get_session_from_time(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib_time = dt.astimezone(WIB)
+ hour = wib_time.hour
+ if 6 <= hour < 15:
+ return "Sydney-Tokyo", True, 0.5
+ elif 15 <= hour < 16:
+ if self.skip_tokyo_london:
+ return "Tokyo-London Overlap", False, 0.0
+ return "Tokyo-London Overlap", True, 0.75
+ elif 16 <= hour < 19:
+ return "London Early", True, 0.8
+ elif 19 <= hour < 24:
+ return "London-NY Overlap (Golden)", True, 1.0
+ elif 0 <= hour < 4:
+ return "NY Session", True, 0.9
+ else:
+ return "Off Hours", False, 0.0
+
+ def _hours_to_golden(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ if 19 <= wib.hour < 24:
+ return 0
+ target = wib.replace(hour=19, minute=0, second=0, microsecond=0)
+ if wib.hour >= 19:
+ target += timedelta(days=1)
+ return max(0, (target - wib).total_seconds() / 3600)
+
+ def _is_near_weekend_close(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ return wib.weekday() == 5 and wib.hour >= 4 and wib.minute >= 30
+
+ def _calculate_lot_size(self, confidence, regime, trading_mode, session_mult):
+ if trading_mode == TradingMode.STOPPED:
+ return 0
+ lot = self.base_lot_size
+ if trading_mode in (TradingMode.RECOVERY, TradingMode.PROTECTED):
+ lot = self.recovery_lot_size
+ else:
+ if confidence >= 0.65:
+ lot = self.max_lot_size
+ elif confidence >= 0.55:
+ lot = self.base_lot_size
+ else:
+ lot = self.recovery_lot_size
+ if regime.lower() in ["high_volatility", "crisis"]:
+ lot = self.recovery_lot_size
+ lot = max(0.01, lot * session_mult)
+ return round(lot, 2)
+
+ def _get_h1_trend(self, df_h1_slice, mode):
+ """Get H1 trend direction based on filter mode."""
+ if df_h1_slice is None or len(df_h1_slice) < 50:
+ return "NEUTRAL"
+
+ closes = df_h1_slice["close"].to_list()
+
+ if mode in ["ema", "relaxed", "sell_only"]:
+ # EMA 20 vs EMA 50 alignment
+ if len(closes) < 50:
+ return "NEUTRAL"
+ ema20 = self._calc_ema(closes, 20)
+ ema50 = self._calc_ema(closes, 50)
+ if ema20 > ema50 * 1.0005:
+ return "BULLISH"
+ elif ema20 < ema50 * 0.9995:
+ return "BEARISH"
+ return "NEUTRAL"
+
+ elif mode == "price_vs_ema":
+ # Price above/below EMA20
+ if len(closes) < 20:
+ return "NEUTRAL"
+ ema20 = self._calc_ema(closes, 20)
+ current_price = closes[-1]
+ if current_price > ema20 * 1.001:
+ return "BULLISH"
+ elif current_price < ema20 * 0.999:
+ return "BEARISH"
+ return "NEUTRAL"
+
+ elif mode == "bos":
+ # Last H1 BOS direction
+ if "bos" in df_h1_slice.columns:
+ recent_bos = df_h1_slice.tail(10)["bos"].to_list()
+ # Find last non-zero BOS
+ for b in reversed(recent_bos):
+ if b == 1:
+ return "BULLISH"
+ elif b == -1:
+ return "BEARISH"
+ return "NEUTRAL"
+
+ return "NEUTRAL"
+
+ def _calc_ema(self, data, period):
+ """Calculate EMA for given data and period."""
+ if len(data) < period:
+ return data[-1] if data else 0
+ multiplier = 2 / (period + 1)
+ ema = np.mean(data[:period]) # SMA for initial
+ for val in data[period:]:
+ ema = (val - ema) * multiplier + ema
+ return ema
+
+ def _h1_allows_trade(self, h1_trend, signal_direction, mode):
+ """Check if H1 trend allows the trade."""
+ if mode == "relaxed":
+ # Only block if H1 actively opposes
+ if signal_direction == "BUY" and h1_trend == "BEARISH":
+ return False
+ if signal_direction == "SELL" and h1_trend == "BULLISH":
+ return False
+ return True # Allow NEUTRAL
+
+ elif mode == "sell_only":
+ # Only filter SELL trades
+ if signal_direction == "SELL" and h1_trend == "BULLISH":
+ return False
+ return True
+
+ else:
+ # Strict: signal must match H1 trend (NEUTRAL blocks too)
+ if signal_direction == "BUY" and h1_trend != "BULLISH":
+ return False
+ if signal_direction == "SELL" and h1_trend != "BEARISH":
+ return False
+ return True
+
+ def _simulate_trade_exit(
+ self, df, entry_idx, direction, entry_price, take_profit, stop_loss,
+ lot_size, daily_loss_so_far, feature_cols, max_bars=100,
+ ):
+ pip_value = 10
+ highs = df["high"].to_list()
+ lows = df["low"].to_list()
+ closes = df["close"].to_list()
+ times = df["time"].to_list()
+
+ atr = 12.0
+ if "atr" in df.columns:
+ atr_list = df["atr"].to_list()
+ if entry_idx < len(atr_list) and atr_list[entry_idx] is not None:
+ atr = atr_list[entry_idx]
+
+ adaptive_breakeven_pips = atr * self.be_mult
+ adaptive_trail_start_pips = atr * self.trail_start_mult
+ adaptive_trail_step_pips = atr * self.trail_step_mult
+ reversal_momentum_threshold = atr * self.trend_reversal_mult
+ min_loss_for_reversal_exit = atr * 0.8
+
+ if self.be_profit_lock_atr_mult > 0:
+ be_lock_distance = atr * self.be_profit_lock_atr_mult
+ else:
+ be_lock_distance = 2.0
+
+ profit_history = []
+ peak_profit = 0.0
+ stall_count = 0
+ reversal_warnings = 0
+ current_sl = stop_loss
+ breakeven_moved = False
+
+ if direction == "BUY":
+ target_tp_profit = (take_profit - entry_price) / 0.1 * pip_value * lot_size
+ else:
+ target_tp_profit = (entry_price - take_profit) / 0.1 * pip_value * lot_size
+
+ cached_ml_signal = ""
+ cached_ml_confidence = 0.5
+
+ for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
+ high = highs[i]
+ low = lows[i]
+ close = closes[i]
+ current_time = times[i]
+
+ if direction == "BUY":
+ current_pips = (close - entry_price) / 0.1
+ pip_profit_from_entry = current_pips
+ else:
+ current_pips = (entry_price - close) / 0.1
+ pip_profit_from_entry = current_pips
+ current_profit = current_pips * pip_value * lot_size
+
+ profit_history.append(current_profit)
+ if current_profit > peak_profit:
+ peak_profit = current_profit
+
+ bars_since_entry = i - entry_idx
+
+ if bars_since_entry % 4 == 0 and self.ml_model.fitted:
+ try:
+ df_slice = df.head(i + 1)
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ cached_ml_signal = ml_pred.signal
+ cached_ml_confidence = ml_pred.confidence
+ except Exception:
+ pass
+
+ momentum = 0.0
+ if len(profit_history) >= 3:
+ recent = profit_history[-5:] if len(profit_history) >= 5 else profit_history
+ profit_change = recent[-1] - recent[0]
+ momentum = max(-100, min(100, (profit_change / 10) * 50))
+ profit_growing = momentum > 0
+
+ if direction == "BUY" and high >= take_profit:
+ pips = (take_profit - entry_price) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+ elif direction == "SELL" and low <= take_profit:
+ pips = (entry_price - take_profit) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+
+ if breakeven_moved and current_sl > 0:
+ if direction == "BUY" and low <= current_sl:
+ pips = (current_sl - entry_price) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+ elif direction == "SELL" and high >= current_sl:
+ pips = (entry_price - current_sl) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+
+ if pip_profit_from_entry >= adaptive_breakeven_pips and not breakeven_moved:
+ if direction == "BUY":
+ current_sl = entry_price + be_lock_distance
+ else:
+ current_sl = entry_price - be_lock_distance
+ breakeven_moved = True
+
+ if pip_profit_from_entry >= adaptive_trail_start_pips:
+ trail_distance = adaptive_trail_step_pips * 0.1
+ if direction == "BUY":
+ new_trail_sl = close - trail_distance
+ if new_trail_sl > current_sl:
+ current_sl = new_trail_sl
+ else:
+ new_trail_sl = close + trail_distance
+ if current_sl == 0 or new_trail_sl < current_sl:
+ current_sl = new_trail_sl
+
+ if peak_profit > self.min_profit_to_protect:
+ drawdown_pct = ((peak_profit - current_profit) / peak_profit) * 100 if peak_profit > 0 else 0
+ if drawdown_pct > self.max_drawdown_from_peak:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+
+ if bars_since_entry % 5 == 0 and bars_since_entry >= 5 and i >= 20:
+ ma_fast = np.mean(closes[i-4:i+1])
+ ma_slow = np.mean(closes[i-19:i+1])
+ trend = "BULLISH" if ma_fast > ma_slow * 1.001 else ("BEARISH" if ma_fast < ma_slow * 0.999 else "NEUTRAL")
+ roc = (closes[i] / closes[max(0,i-4)] - 1) * 100
+ mom_dir = "BULLISH" if roc > 0.3 else ("BEARISH" if roc < -0.3 else "NEUTRAL")
+
+ rsi_val = None
+ if "rsi" in df.columns:
+ rsi_list = df["rsi"].to_list()
+ if i < len(rsi_list):
+ rsi_val = rsi_list[i]
+
+ urgency = 0
+ should_exit = False
+ if cached_ml_confidence > 0.75:
+ if (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY"):
+ should_exit = True; urgency += 2
+ if rsi_val:
+ if (rsi_val > 75 and direction == "BUY") or (rsi_val < 25 and direction == "SELL"):
+ should_exit = True; urgency += 2
+ if (direction == "BUY" and trend == "BEARISH" and mom_dir == "BEARISH") or \
+ (direction == "SELL" and trend == "BULLISH" and mom_dir == "BULLISH"):
+ should_exit = True; urgency += 3
+
+ if should_exit and current_profit > self.min_profit_to_protect / 2:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+ if urgency >= 7 and current_profit > 0:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+
+ if self._is_near_weekend_close(current_time):
+ if current_profit > 0 or current_profit > -10:
+ return current_profit, current_pips, ExitReason.WEEKEND_CLOSE, i, close
+
+ if current_profit >= 15:
+ if current_profit >= 40:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if current_profit >= 25 and momentum < -30:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if peak_profit > 30 and current_profit < peak_profit * 0.6:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+ if current_profit >= 20:
+ progress = (current_profit / target_tp_profit) * 100 if target_tp_profit > 0 else 0
+ progress_score = min(40, max(0, progress * 0.4))
+ momentum_score = ((momentum + 100) / 200) * 30
+ time_penalty = min(10, bars_since_entry / 4 * 2)
+ tp_probability = progress_score + momentum_score + 10 - time_penalty
+ if tp_probability < 25:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+
+ if 5 <= current_profit < 15:
+ if momentum < -50 and cached_ml_confidence >= 0.65:
+ is_reversal = (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY")
+ if is_reversal:
+ return current_profit, current_pips, ExitReason.EARLY_EXIT, i, close
+
+ if current_profit < 0:
+ loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
+ if momentum < self.early_cut_momentum and loss_percent_of_max >= self.early_cut_loss_pct:
+ return current_profit, current_pips, ExitReason.EARLY_CUT, i, close
+
+ is_ml_reversal = False
+ if (direction == "BUY" and cached_ml_signal == "SELL" and cached_ml_confidence >= self.trend_reversal_threshold) or \
+ (direction == "SELL" and cached_ml_signal == "BUY" and cached_ml_confidence >= self.trend_reversal_threshold):
+ is_ml_reversal = True
+ reversal_warnings += 1
+ loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4)
+ if is_ml_reversal and current_profit < -8 and loss_moderate:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+ if reversal_warnings >= 3 and current_profit < -10:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ if current_profit <= -(self.max_loss_per_trade * 0.50):
+ htg = self._hours_to_golden(current_time)
+ if htg <= 1 and htg > 0 and momentum > -40:
+ pass
+ else:
+ return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
+
+ if len(profit_history) >= 10:
+ recent_range = max(profit_history[-10:]) - min(profit_history[-10:])
+ if recent_range < 3 and current_profit < -15:
+ stall_count += 1
+ if stall_count >= 5:
+ return current_profit, current_pips, ExitReason.STALL, i, close
+
+ potential_daily_loss = daily_loss_so_far + abs(min(0, current_profit))
+ if potential_daily_loss >= self.max_daily_loss_usd:
+ return current_profit, current_pips, ExitReason.DAILY_LIMIT, i, close
+
+ if bars_since_entry >= 16 and current_profit < 5 and not profit_growing:
+ if current_profit >= 0 or current_profit > -15:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 24 and (current_profit < 10 or not profit_growing):
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 32:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+
+ if bars_since_entry > 10:
+ recent_closes = closes[i-5:i+1]
+ mom = recent_closes[-1] - recent_closes[0]
+ if (direction == "BUY" and mom < -reversal_momentum_threshold) or \
+ (direction == "SELL" and mom > reversal_momentum_threshold):
+ if current_profit < -min_loss_for_reversal_exit:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ final_idx = min(entry_idx + max_bars - 1, len(df) - 1)
+ final_price = closes[final_idx]
+ pips = ((final_price - entry_price) if direction == "BUY" else (entry_price - final_price)) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TIMEOUT, final_idx, final_price
+
+ def run(self, df_m15, df_h1, start_date=None, end_date=None, initial_capital=5000.0):
+ stats = BacktestStats()
+ capital = initial_capital
+ peak_capital = initial_capital
+ stats.equity_curve.append(capital)
+
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ consecutive_losses = 0
+ trading_mode = TradingMode.NORMAL
+ current_date = None
+
+ feature_cols = []
+ if self.ml_model.fitted and self.ml_model.feature_names:
+ feature_cols = [f for f in self.ml_model.feature_names if f in df_m15.columns]
+
+ times_m15 = df_m15["time"].to_list()
+ times_h1 = df_h1["time"].to_list() if df_h1 is not None else []
+
+ start_idx = next((i for i, t in enumerate(times_m15) if t >= start_date), 100) if start_date else 100
+ end_idx = next((i for i, t in enumerate(times_m15) if t > end_date), len(df_m15) - 100) if end_date else len(df_m15) - 100
+
+ last_trade_idx = -self.trade_cooldown_bars * 2
+
+ print(f" #31 H1 filter mode: {self.h1_filter_mode}")
+ print(f" H1 bars available: {len(df_h1) if df_h1 is not None else 0}")
+ print(f" Date range: {times_m15[start_idx]} to {times_m15[end_idx - 1]}")
+ print(f" Total bars: {end_idx - start_idx}")
+
+ for i in range(start_idx, end_idx):
+ if i - last_trade_idx < self.trade_cooldown_bars:
+ continue
+
+ current_time = times_m15[i]
+ trade_date = current_time.date() if hasattr(current_time, 'date') else current_time
+ if current_date is None or trade_date != current_date:
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ current_date = trade_date
+ if consecutive_losses < 2:
+ trading_mode = TradingMode.NORMAL
+
+ if trading_mode == TradingMode.STOPPED:
+ continue
+
+ session_name, can_trade, lot_mult = self._get_session_from_time(current_time)
+ if not can_trade:
+ if session_name == "Tokyo-London Overlap":
+ stats.session_blocked += 1
+ continue
+
+ if hasattr(current_time, 'weekday') and current_time.weekday() >= 5:
+ continue
+
+ df_slice = df_m15.head(i + 1)
+
+ regime = "normal"
+ try:
+ if self.regime_detector.fitted:
+ regime_state = self.regime_detector.get_current_state(df_slice)
+ if regime_state:
+ regime = regime_state.regime.value
+ if regime_state.regime == MarketRegime.CRISIS:
+ continue
+ if regime_state.recommendation == "SLEEP":
+ continue
+ except Exception:
+ pass
+
+ try:
+ ml_signal = ""
+ ml_confidence = 0.5
+ if self.ml_model.fitted and feature_cols:
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ ml_signal = ml_pred.signal
+ ml_confidence = ml_pred.confidence
+
+ market_analysis = self.dynamic_confidence.analyze_market(
+ session=session_name, regime=regime, volatility="medium",
+ trend_direction=regime, has_smc_signal=True,
+ ml_signal=ml_signal, ml_confidence=ml_confidence,
+ )
+ if market_analysis.quality == MarketQuality.AVOID:
+ stats.avoided_signals += 1
+ continue
+ except Exception:
+ pass
+
+ try:
+ smc_signal = self.smc.generate_signal(df_slice)
+ except Exception:
+ continue
+
+ if smc_signal is None:
+ continue
+
+ # βββ #31: H1 TREND FILTER βββ
+ h1_trend = "NEUTRAL"
+ if df_h1 is not None and len(times_h1) > 0:
+ # Find H1 bars up to current M15 time
+ h1_idx = 0
+ for j, t in enumerate(times_h1):
+ if t <= current_time:
+ h1_idx = j
+ else:
+ break
+ if h1_idx > 50:
+ df_h1_slice = df_h1.head(h1_idx + 1)
+ h1_trend = self._get_h1_trend(df_h1_slice, self.h1_filter_mode)
+
+ if not self._h1_allows_trade(h1_trend, smc_signal.signal_type, self.h1_filter_mode):
+ stats.h1_filtered += 1
+ continue
+
+ recent_df = df_slice.tail(10)
+ recent_bos = recent_df["bos"].to_list() if "bos" in df_slice.columns else []
+ recent_choch = recent_df["choch"].to_list() if "choch" in df_slice.columns else []
+ recent_fvg_bull = recent_df["is_fvg_bull"].to_list() if "is_fvg_bull" in df_slice.columns else []
+ recent_fvg_bear = recent_df["is_fvg_bear"].to_list() if "is_fvg_bear" in df_slice.columns else []
+ recent_obs = recent_df["ob"].to_list() if "ob" in df_slice.columns else []
+
+ has_bos = 1 in recent_bos or -1 in recent_bos
+ has_choch = 1 in recent_choch or -1 in recent_choch
+ has_fvg = any(recent_fvg_bull) or any(recent_fvg_bear)
+ has_ob = 1 in recent_obs or -1 in recent_obs
+
+ atr_at_entry = 12.0
+ if "atr" in df_slice.columns:
+ atr_val = df_slice.tail(1)["atr"].item()
+ if atr_val is not None and atr_val > 0:
+ atr_at_entry = atr_val
+
+ confidence = smc_signal.confidence
+ ml_agrees = (smc_signal.signal_type == "BUY" and ml_signal == "BUY") or \
+ (smc_signal.signal_type == "SELL" and ml_signal == "SELL")
+ if ml_agrees:
+ confidence = (smc_signal.confidence + ml_confidence) / 2
+ if regime == "high_volatility":
+ confidence *= 0.9
+
+ lot_size = self._calculate_lot_size(confidence, regime, trading_mode, lot_mult)
+ if lot_size <= 0:
+ continue
+
+ if trading_mode == TradingMode.RECOVERY:
+ stats.recovery_mode_trades += 1
+
+ entry_price = smc_signal.entry_price
+ take_profit_price = smc_signal.take_profit
+ stop_loss_price = smc_signal.stop_loss
+ risk = abs(entry_price - stop_loss_price)
+ rr = abs(take_profit_price - entry_price) / risk if risk > 0 else 0
+
+ profit, pips, exit_reason, exit_idx, exit_price = self._simulate_trade_exit(
+ df=df_m15, entry_idx=i, direction=smc_signal.signal_type,
+ entry_price=entry_price, take_profit=take_profit_price,
+ stop_loss=stop_loss_price, lot_size=lot_size,
+ daily_loss_so_far=daily_loss, feature_cols=feature_cols,
+ )
+
+ self._ticket_counter += 1
+ result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
+
+ trade = SimulatedTrade(
+ ticket=self._ticket_counter,
+ entry_time=current_time,
+ exit_time=times_m15[exit_idx] if exit_idx < len(times_m15) else times_m15[-1],
+ direction=smc_signal.signal_type,
+ entry_price=entry_price, exit_price=exit_price,
+ stop_loss=stop_loss_price, take_profit=take_profit_price,
+ lot_size=lot_size, profit_usd=profit, profit_pips=pips,
+ result=result, exit_reason=exit_reason,
+ smc_confidence=confidence, regime=regime,
+ session=session_name, signal_reason=smc_signal.reason,
+ has_bos=has_bos, has_choch=has_choch,
+ has_fvg=has_fvg, has_ob=has_ob,
+ atr_at_entry=atr_at_entry, rr_ratio=rr,
+ trading_mode=trading_mode.value,
+ h1_trend=h1_trend,
+ )
+ stats.trades.append(trade)
+ stats.total_trades += 1
+ daily_trades += 1
+ capital += profit
+
+ if profit > 0:
+ stats.wins += 1
+ stats.total_profit += profit
+ daily_profit += profit
+ consecutive_losses = 0
+ if trading_mode == TradingMode.RECOVERY:
+ trading_mode = TradingMode.NORMAL
+ else:
+ stats.losses += 1
+ stats.total_loss += abs(profit)
+ daily_loss += abs(profit)
+ consecutive_losses += 1
+
+ if daily_loss >= self.max_daily_loss_usd:
+ trading_mode = TradingMode.STOPPED
+ stats.daily_limit_stops += 1
+ elif consecutive_losses >= 3 or daily_loss >= self.max_daily_loss_usd * 0.6:
+ trading_mode = TradingMode.PROTECTED
+ elif consecutive_losses >= 2:
+ trading_mode = TradingMode.RECOVERY
+
+ if capital > peak_capital:
+ peak_capital = capital
+ drawdown_pct = (peak_capital - capital) / peak_capital * 100
+ drawdown_usd = peak_capital - capital
+ if drawdown_pct > stats.max_drawdown:
+ stats.max_drawdown = drawdown_pct
+ stats.max_drawdown_usd = drawdown_usd
+
+ stats.equity_curve.append(capital)
+ last_trade_idx = exit_idx
+
+ if stats.total_trades % 100 == 0:
+ print(f" {stats.total_trades} trades processed...")
+
+ if stats.total_trades > 0:
+ stats.win_rate = stats.wins / stats.total_trades * 100
+ stats.avg_win = stats.total_profit / stats.wins if stats.wins > 0 else 0
+ stats.avg_loss = stats.total_loss / stats.losses if stats.losses > 0 else 0
+ stats.avg_trade = (stats.total_profit - stats.total_loss) / stats.total_trades
+ stats.profit_factor = stats.total_profit / stats.total_loss if stats.total_loss > 0 else float("inf")
+ win_prob = stats.wins / stats.total_trades
+ loss_prob = stats.losses / stats.total_trades
+ stats.expectancy = (win_prob * stats.avg_win) - (loss_prob * stats.avg_loss)
+ returns = [t.profit_usd for t in stats.trades]
+ if len(returns) > 1:
+ avg_return = np.mean(returns)
+ std_return = np.std(returns)
+ stats.sharpe_ratio = (avg_return / std_return) * np.sqrt(252) if std_return > 0 else 0
+
+ return stats
+
+
+# βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def main():
+ print("=" * 70)
+ print("XAUBOT AI β #31 Multi-Timeframe H1 Confirmation")
+ print("Base: #28B (Smart BE 0.5x ATR) | Modified: H1 trend filter")
+ print("=" * 70)
+
+ config = get_config()
+ mt5_conn = MT5Connector(
+ login=config.mt5_login, password=config.mt5_password,
+ server=config.mt5_server, path=config.mt5_path,
+ )
+ mt5_conn.connect()
+ print(f"\nConnected to MT5")
+
+ # Fetch M15 data (primary)
+ print("Fetching XAUUSD M15 historical data...")
+ df_m15 = mt5_conn.get_market_data(symbol="XAUUSD", timeframe="M15", count=50000)
+ if len(df_m15) == 0:
+ print("ERROR: No M15 data")
+ mt5_conn.disconnect()
+ return
+ print(f" M15: {len(df_m15)} bars")
+
+ # Fetch H1 data (for multi-TF)
+ print("Fetching XAUUSD H1 historical data...")
+ df_h1 = mt5_conn.get_market_data(symbol="XAUUSD", timeframe="H1", count=15000)
+ if len(df_h1) == 0:
+ print("WARNING: No H1 data β running without H1 filter")
+ df_h1 = None
+ else:
+ print(f" H1: {len(df_h1)} bars")
+
+ times = df_m15["time"].to_list()
+ print(f" M15 range: {times[0]} to {times[-1]}")
+ if df_h1 is not None:
+ h1_times = df_h1["time"].to_list()
+ print(f" H1 range: {h1_times[0]} to {h1_times[-1]}")
+
+ end_date = datetime.now()
+ start_date = datetime(2025, 8, 1)
+ data_start = times[0]
+ if hasattr(data_start, 'replace') and data_start.tzinfo:
+ start_date = start_date.replace(tzinfo=data_start.tzinfo)
+ end_date = end_date.replace(tzinfo=data_start.tzinfo)
+ if data_start > start_date:
+ start_date = data_start + timedelta(days=5)
+
+ print(f"\n Backtest period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
+
+ # Calculate indicators for M15
+ print("\nCalculating M15 indicators...")
+ features = FeatureEngineer()
+ smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ df_m15 = features.calculate_all(df_m15, include_ml_features=True)
+ df_m15 = smc.calculate_all(df_m15)
+
+ regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ regime_detector.load()
+ df_m15 = regime_detector.predict(df_m15)
+ print(" HMM regime loaded")
+ except Exception:
+ print(" [WARN] HMM not available")
+
+ # Calculate H1 indicators (for BOS mode)
+ if df_h1 is not None:
+ print("Calculating H1 indicators...")
+ df_h1 = features.calculate_all(df_h1, include_ml_features=False)
+ df_h1 = smc.calculate_all(df_h1)
+ print(" H1 indicators calculated")
+
+ print(" All indicators calculated")
+
+ baseline_28b_pnl = 2463.80
+
+ # βββ CONFIGS βββ
+ configs = [
+ ("A: H1 EMA strict", "ema"),
+ ("B: H1 price vs EMA20", "price_vs_ema"),
+ ("C: H1 BOS direction", "bos"),
+ ("D: H1 SELL only", "sell_only"),
+ ("E: H1 relaxed", "relaxed"),
+ ]
+
+ all_results = []
+
+ for cfg_name, h1_mode in configs:
+ print(f"\n{'=' * 60}")
+ print(f" Config: {cfg_name}")
+
+ bt = MultiTFBacktest(h1_filter_mode=h1_mode)
+ stats = bt.run(df_m15=df_m15, df_h1=df_h1, start_date=start_date, end_date=end_date, initial_capital=5000.0)
+ net_pnl = stats.total_profit - stats.total_loss
+ diff = net_pnl - baseline_28b_pnl
+
+ buy_trades = [t for t in stats.trades if t.direction == "BUY"]
+ sell_trades = [t for t in stats.trades if t.direction == "SELL"]
+ buy_wins = sum(1 for t in buy_trades if t.result == TradeResult.WIN)
+ sell_wins = sum(1 for t in sell_trades if t.result == TradeResult.WIN)
+ buy_wr = buy_wins / len(buy_trades) * 100 if buy_trades else 0
+ sell_wr = sell_wins / len(sell_trades) * 100 if sell_trades else 0
+ buy_pnl = sum(t.profit_usd for t in buy_trades)
+ sell_pnl = sum(t.profit_usd for t in sell_trades)
+
+ # H1 trend distribution
+ h1_dist = {}
+ for t in stats.trades:
+ h1_dist[t.h1_trend] = h1_dist.get(t.h1_trend, 0) + 1
+
+ print(f"\n [{cfg_name}] Results:")
+ print(f" Trades: {stats.total_trades} | WR: {stats.win_rate:.1f}%")
+ print(f" Net PnL: ${net_pnl:,.2f} | PF: {stats.profit_factor:.2f}")
+ print(f" Max DD: {stats.max_drawdown:.1f}% | Sharpe: {stats.sharpe_ratio:.2f}")
+ print(f" H1 filtered: {stats.h1_filtered}")
+ print(f" BUY: {len(buy_trades)}, {buy_wr:.1f}% WR, ${buy_pnl:,.2f}")
+ print(f" SELL: {len(sell_trades)}, {sell_wr:.1f}% WR, ${sell_pnl:,.2f}")
+ print(f" H1 trend dist: {dict(sorted(h1_dist.items()))}")
+ print(f" vs #28B: ${diff:+,.2f}")
+
+ all_results.append((cfg_name, stats, net_pnl, diff, stats.h1_filtered, h1_dist))
+
+ # βββ FINAL SUMMARY βββ
+ print(f"\n{'=' * 70}")
+ print("#31 MULTI-TIMEFRAME H1 β ALL CONFIGURATIONS")
+ print("=" * 70)
+
+ print(f"\n {'Config':<25} {'Trades':>6} {'WR':>6} {'Net PnL':>10} {'DD':>6} {'Sharpe':>7} {'PF':>5} {'Filt':>5} {'vs #28B':>10}")
+ print(f" {'-' * 90}")
+ print(f" {'#24B (base) ':<25} {'739':>6} {'80.4%':>6} {'$2,235':>10} {'3.4%':>6} {'2.87':>7} {'1.77':>5} {'β':>5} {'β':>10}")
+ print(f" {'#28B (smart BE)':<25} {'741':>6} {'79.8%':>6} {'$2,464':>10} {'3.5%':>6} {'3.23':>7} {'1.83':>5} {'β':>5} {'β':>10}")
+ for cfg_name, stats, net_pnl, diff, filt, h1_dist in all_results:
+ print(f" {cfg_name:<25} {stats.total_trades:>6} {stats.win_rate:>5.1f}% ${net_pnl:>9,.2f} {stats.max_drawdown:>5.1f}% {stats.sharpe_ratio:>7.2f} {stats.profit_factor:>5.2f} {filt:>5} ${diff:>+9,.2f}")
+
+ best_pnl = -999999
+ best_name = ""
+ best_stats = None
+ for entry in all_results:
+ if entry[2] > best_pnl:
+ best_pnl = entry[2]
+ best_name = entry[0]
+ best_stats = entry[1]
+
+ print(f"\n Best config: {best_name}")
+
+ # H1 trend analysis for best
+ print(f"\n H1 Trend WR Analysis (best config):")
+ for trend_val in sorted(set(t.h1_trend for t in best_stats.trades)):
+ trend_trades = [t for t in best_stats.trades if t.h1_trend == trend_val]
+ trend_wins = sum(1 for t in trend_trades if t.result == TradeResult.WIN)
+ trend_wr = trend_wins / len(trend_trades) * 100 if trend_trades else 0
+ trend_pnl = sum(t.profit_usd for t in trend_trades)
+ print(f" {trend_val:10s}: {len(trend_trades):>4} trades, {trend_wr:>5.1f}% WR, ${trend_pnl:>8,.2f}")
+
+ # Exit reasons
+ print(f"\n Exit Reasons (best config):")
+ exit_counts = {}
+ for t in best_stats.trades:
+ r = t.exit_reason.value
+ exit_counts[r] = exit_counts.get(r, 0) + 1
+ for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
+ pct = count / best_stats.total_trades * 100 if best_stats.total_trades > 0 else 0
+ print(f" {reason:20s}: {count} ({pct:.1f}%)")
+
+ # Save
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "31_multi_tf_h1_results")
+ os.makedirs(output_dir, exist_ok=True)
+
+ log_path = os.path.join(output_dir, f"multi_tf_{timestamp}.log")
+ with open(log_path, "w") as f:
+ f.write(f"#31 Multi-Timeframe H1 Results\n")
+ f.write(f"Generated: {datetime.now()}\n")
+ f.write(f"Base: #28B (741 trades, 79.8% WR, $2,464)\n\n")
+ for cfg_name, stats, net_pnl, diff, filt, h1_dist in all_results:
+ f.write(f" {cfg_name}: {stats.total_trades} trades, {stats.win_rate:.1f}% WR, "
+ f"${net_pnl:,.2f}, DD: {stats.max_drawdown:.1f}%, "
+ f"Sharpe: {stats.sharpe_ratio:.2f}, PF: {stats.profit_factor:.2f}, "
+ f"H1 filtered: {filt}, H1 dist: {dict(sorted(h1_dist.items()))}, "
+ f"vs #28B: ${diff:+,.2f}\n")
+ f.write(f"\nBest: {best_name}\n")
+ print(f" Log saved: {log_path}")
+
+ try:
+ from backtests.backtest_01_smc_only import generate_xlsx_report as gen_xlsx
+ xlsx_path = os.path.join(output_dir, f"multi_tf_{timestamp}.xlsx")
+ gen_xlsx(best_stats, xlsx_path, start_date, end_date)
+ print(f"\n Report saved: {xlsx_path}")
+ except Exception as e:
+ print(f" [WARN] XLSX: {e}")
+
+ mt5_conn.disconnect()
+
+ print(f"\n{'=' * 70}")
+ print(f"Output: {output_dir}")
+ print(f" Log: {os.path.basename(log_path)}")
+ print("=" * 70)
+ print("Backtest complete!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backtests/backtest_32_ml_exit_optimizer.py b/backtests/backtest_32_ml_exit_optimizer.py
new file mode 100644
index 0000000..b806252
--- /dev/null
+++ b/backtests/backtest_32_ml_exit_optimizer.py
@@ -0,0 +1,983 @@
+"""
+Backtest #32 β ML Exit Optimizer
+==================================
+Base: #31B (H1 Price vs EMA20) β 625 trades, 81.8% WR, $2,807, Sharpe 3.97
+
+Idea: Use XGBoost ML predictions more aggressively for exit decisions.
+Currently ML is only used for trend reversal detection in exits.
+What if we:
+- Lower the ML reversal confidence threshold (catch reversals earlier)
+- Use ML to tighten trailing SL when ML opposes
+- Use ML agreement to hold winners longer
+
+Configs:
+ A: Lower ML reversal threshold (0.75 β 0.65) β catch reversals earlier
+ B: ML-tightened trail (if ML opposes, use 2x ATR trail instead of 3x)
+ C: ML hold boost (if ML agrees, extend timeout from 16β24 bars)
+ D: A+B combined (earlier reversal + tighter trail when opposed)
+ E: A+B+C combined (full ML exit optimization)
+
+Usage:
+ python backtests/backtest_32_ml_exit_optimizer.py
+"""
+
+import polars as pl
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta, date
+from typing import Dict, List, Tuple, Optional
+from dataclasses import dataclass, field
+from enum import Enum
+import sys
+import os
+from zoneinfo import ZoneInfo
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from src.mt5_connector import MT5Connector
+from src.smc_polars import SMCAnalyzer, SMCSignal
+from src.feature_eng import FeatureEngineer
+from src.regime_detector import MarketRegimeDetector, MarketRegime
+from src.ml_model import TradingModel
+from src.config import get_config
+from src.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence, MarketQuality
+from loguru import logger
+
+logger.remove()
+logger.add(sys.stderr, level="WARNING")
+
+WIB = ZoneInfo("Asia/Jakarta")
+
+
+# βββ Enums & Dataclasses ββββββββββββββββββββββββββββββββββββββ
+
+class TradeResult(Enum):
+ WIN = "WIN"
+ LOSS = "LOSS"
+ BREAKEVEN = "BREAKEVEN"
+
+class ExitReason(Enum):
+ TAKE_PROFIT = "take_profit"
+ SMART_TP = "smart_tp"
+ PEAK_PROTECT = "peak_protect"
+ EARLY_EXIT = "early_exit"
+ EARLY_CUT = "early_cut"
+ MAX_LOSS = "max_loss"
+ STALL = "stall"
+ TREND_REVERSAL = "trend_reversal"
+ TIMEOUT = "timeout"
+ WEEKEND_CLOSE = "weekend_close"
+ TRAILING_SL = "trailing_sl"
+ BREAKEVEN_EXIT = "breakeven_exit"
+ DAILY_LIMIT = "daily_limit"
+ REGIME_DANGER = "regime_danger"
+ MARKET_SIGNAL = "market_signal"
+
+class TradingMode(Enum):
+ NORMAL = "normal"
+ RECOVERY = "recovery"
+ PROTECTED = "protected"
+ STOPPED = "stopped"
+
+@dataclass
+class SimulatedTrade:
+ ticket: int
+ entry_time: datetime
+ exit_time: datetime
+ direction: str
+ entry_price: float
+ exit_price: float
+ stop_loss: float
+ take_profit: float
+ lot_size: float
+ profit_usd: float
+ profit_pips: float
+ result: TradeResult
+ exit_reason: ExitReason
+ smc_confidence: float
+ regime: str
+ session: str
+ signal_reason: str
+ has_bos: bool = False
+ has_choch: bool = False
+ has_fvg: bool = False
+ has_ob: bool = False
+ atr_at_entry: float = 0.0
+ rr_ratio: float = 0.0
+ trading_mode: str = "normal"
+ h1_trend: str = "NEUTRAL"
+
+@dataclass
+class BacktestStats:
+ total_trades: int = 0
+ wins: int = 0
+ losses: int = 0
+ total_profit: float = 0.0
+ total_loss: float = 0.0
+ max_drawdown: float = 0.0
+ max_drawdown_usd: float = 0.0
+ win_rate: float = 0.0
+ profit_factor: float = 0.0
+ avg_win: float = 0.0
+ avg_loss: float = 0.0
+ avg_trade: float = 0.0
+ expectancy: float = 0.0
+ sharpe_ratio: float = 0.0
+ trades: List[SimulatedTrade] = field(default_factory=list)
+ equity_curve: List[float] = field(default_factory=list)
+ avoided_signals: int = 0
+ daily_limit_stops: int = 0
+ recovery_mode_trades: int = 0
+ session_blocked: int = 0
+ h1_filtered: int = 0
+
+
+# βββ ML Exit Optimizer Backtest ββββββββββββββββββββββββββββββ
+
+class MLExitBacktest:
+ """#31B base + ML exit optimization."""
+
+ def __init__(
+ self,
+ capital: float = 5000.0,
+ max_daily_loss_percent: float = 5.0,
+ max_loss_per_trade_percent: float = 1.0,
+ base_lot_size: float = 0.01,
+ max_lot_size: float = 0.02,
+ recovery_lot_size: float = 0.01,
+ max_concurrent_positions: int = 2,
+ min_profit_to_protect: float = 5.0,
+ max_drawdown_from_peak: float = 50.0,
+ trade_cooldown_bars: int = 10,
+ # #24B base
+ skip_tokyo_london: bool = True,
+ early_cut_momentum: float = -50.0,
+ early_cut_loss_pct: float = 30.0,
+ be_mult: float = 2.0,
+ trail_start_mult: float = 4.0,
+ trail_step_mult: float = 3.0,
+ # #28B: Smart breakeven
+ be_profit_lock_atr_mult: float = 0.5,
+ # βββ #32 ML EXIT OPTIMIZER PARAMS βββ
+ ml_reversal_threshold: float = 0.75, # ML confidence to trigger reversal exit
+ ml_tighten_trail: bool = False, # If ML opposes, tighten trail step
+ ml_tighten_trail_mult: float = 2.0, # Tightened trail step multiplier (vs 3.0 default)
+ ml_hold_boost: bool = False, # If ML agrees, extend timeout
+ ml_hold_timeout_bars: int = 24, # Extended timeout when ML agrees
+ trend_reversal_mult: float = 0.6,
+ ):
+ self.capital = capital
+ self.max_daily_loss_usd = capital * (max_daily_loss_percent / 100)
+ self.max_loss_per_trade = capital * (max_loss_per_trade_percent / 100)
+ self.base_lot_size = base_lot_size
+ self.max_lot_size = max_lot_size
+ self.recovery_lot_size = recovery_lot_size
+ self.max_concurrent_positions = max_concurrent_positions
+ self.min_profit_to_protect = min_profit_to_protect
+ self.max_drawdown_from_peak = max_drawdown_from_peak
+ self.trade_cooldown_bars = trade_cooldown_bars
+ self.trend_reversal_mult = trend_reversal_mult
+
+ self.skip_tokyo_london = skip_tokyo_london
+ self.early_cut_momentum = early_cut_momentum
+ self.early_cut_loss_pct = early_cut_loss_pct
+ self.be_mult = be_mult
+ self.trail_start_mult = trail_start_mult
+ self.trail_step_mult = trail_step_mult
+ self.be_profit_lock_atr_mult = be_profit_lock_atr_mult
+
+ # #32 params
+ self.ml_reversal_threshold = ml_reversal_threshold
+ self.ml_tighten_trail = ml_tighten_trail
+ self.ml_tighten_trail_mult = ml_tighten_trail_mult
+ self.ml_hold_boost = ml_hold_boost
+ self.ml_hold_timeout_bars = ml_hold_timeout_bars
+
+ config = get_config()
+ self.smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ self.features = FeatureEngineer()
+ self.dynamic_confidence = create_dynamic_confidence()
+
+ self.ml_model = TradingModel(model_path="models/xgboost_model.pkl")
+ try:
+ self.ml_model.load()
+ print(" ML model loaded (for exit evaluation)")
+ except Exception:
+ print(" [WARN] ML model not loaded")
+
+ self.regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ self.regime_detector.load()
+ except Exception:
+ print(" [WARN] HMM model not loaded")
+
+ self._ticket_counter = 2320000
+
+ def _get_session_from_time(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib_time = dt.astimezone(WIB)
+ hour = wib_time.hour
+ if 6 <= hour < 15:
+ return "Sydney-Tokyo", True, 0.5
+ elif 15 <= hour < 16:
+ if self.skip_tokyo_london:
+ return "Tokyo-London Overlap", False, 0.0
+ return "Tokyo-London Overlap", True, 0.75
+ elif 16 <= hour < 19:
+ return "London Early", True, 0.8
+ elif 19 <= hour < 24:
+ return "London-NY Overlap (Golden)", True, 1.0
+ elif 0 <= hour < 4:
+ return "NY Session", True, 0.9
+ else:
+ return "Off Hours", False, 0.0
+
+ def _hours_to_golden(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ if 19 <= wib.hour < 24:
+ return 0
+ target = wib.replace(hour=19, minute=0, second=0, microsecond=0)
+ if wib.hour >= 19:
+ target += timedelta(days=1)
+ return max(0, (target - wib).total_seconds() / 3600)
+
+ def _is_near_weekend_close(self, dt):
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=ZoneInfo("UTC"))
+ wib = dt.astimezone(WIB)
+ return wib.weekday() == 5 and wib.hour >= 4 and wib.minute >= 30
+
+ def _calculate_lot_size(self, confidence, regime, trading_mode, session_mult):
+ if trading_mode == TradingMode.STOPPED:
+ return 0
+ lot = self.base_lot_size
+ if trading_mode in (TradingMode.RECOVERY, TradingMode.PROTECTED):
+ lot = self.recovery_lot_size
+ else:
+ if confidence >= 0.65:
+ lot = self.max_lot_size
+ elif confidence >= 0.55:
+ lot = self.base_lot_size
+ else:
+ lot = self.recovery_lot_size
+ if regime.lower() in ["high_volatility", "crisis"]:
+ lot = self.recovery_lot_size
+ lot = max(0.01, lot * session_mult)
+ return round(lot, 2)
+
+ def _calc_ema(self, data, period):
+ if len(data) < period:
+ return data[-1] if data else 0
+ multiplier = 2 / (period + 1)
+ ema = np.mean(data[:period])
+ for val in data[period:]:
+ ema = (val - ema) * multiplier + ema
+ return ema
+
+ def _get_h1_trend(self, df_h1_slice):
+ """#31B: H1 Price vs EMA20."""
+ if df_h1_slice is None or len(df_h1_slice) < 20:
+ return "NEUTRAL"
+ closes = df_h1_slice["close"].to_list()
+ ema20 = self._calc_ema(closes, 20)
+ current_price = closes[-1]
+ if current_price > ema20 * 1.001:
+ return "BULLISH"
+ elif current_price < ema20 * 0.999:
+ return "BEARISH"
+ return "NEUTRAL"
+
+ def _simulate_trade_exit(
+ self, df, entry_idx, direction, entry_price, take_profit, stop_loss,
+ lot_size, daily_loss_so_far, feature_cols, max_bars=100,
+ ):
+ pip_value = 10
+ highs = df["high"].to_list()
+ lows = df["low"].to_list()
+ closes = df["close"].to_list()
+ times = df["time"].to_list()
+
+ atr = 12.0
+ if "atr" in df.columns:
+ atr_list = df["atr"].to_list()
+ if entry_idx < len(atr_list) and atr_list[entry_idx] is not None:
+ atr = atr_list[entry_idx]
+
+ adaptive_breakeven_pips = atr * self.be_mult
+ adaptive_trail_start_pips = atr * self.trail_start_mult
+ adaptive_trail_step_pips = atr * self.trail_step_mult
+ # #32: ML-tightened trail step
+ tightened_trail_step_pips = atr * self.ml_tighten_trail_mult
+ reversal_momentum_threshold = atr * self.trend_reversal_mult
+ min_loss_for_reversal_exit = atr * 0.8
+
+ if self.be_profit_lock_atr_mult > 0:
+ be_lock_distance = atr * self.be_profit_lock_atr_mult
+ else:
+ be_lock_distance = 2.0
+
+ profit_history = []
+ peak_profit = 0.0
+ stall_count = 0
+ reversal_warnings = 0
+ current_sl = stop_loss
+ breakeven_moved = False
+
+ if direction == "BUY":
+ target_tp_profit = (take_profit - entry_price) / 0.1 * pip_value * lot_size
+ else:
+ target_tp_profit = (entry_price - take_profit) / 0.1 * pip_value * lot_size
+
+ cached_ml_signal = ""
+ cached_ml_confidence = 0.5
+ ml_agrees_with_trade = False
+
+ for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
+ high = highs[i]
+ low = lows[i]
+ close = closes[i]
+ current_time = times[i]
+
+ if direction == "BUY":
+ current_pips = (close - entry_price) / 0.1
+ pip_profit_from_entry = current_pips
+ else:
+ current_pips = (entry_price - close) / 0.1
+ pip_profit_from_entry = current_pips
+ current_profit = current_pips * pip_value * lot_size
+
+ profit_history.append(current_profit)
+ if current_profit > peak_profit:
+ peak_profit = current_profit
+
+ bars_since_entry = i - entry_idx
+
+ if bars_since_entry % 4 == 0 and self.ml_model.fitted:
+ try:
+ df_slice = df.head(i + 1)
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ cached_ml_signal = ml_pred.signal
+ cached_ml_confidence = ml_pred.confidence
+ # #32: Track ML agreement
+ ml_agrees_with_trade = (
+ (direction == "BUY" and cached_ml_signal == "BUY") or
+ (direction == "SELL" and cached_ml_signal == "SELL")
+ )
+ except Exception:
+ pass
+
+ momentum = 0.0
+ if len(profit_history) >= 3:
+ recent = profit_history[-5:] if len(profit_history) >= 5 else profit_history
+ profit_change = recent[-1] - recent[0]
+ momentum = max(-100, min(100, (profit_change / 10) * 50))
+ profit_growing = momentum > 0
+
+ # A.0 TP hit
+ if direction == "BUY" and high >= take_profit:
+ pips = (take_profit - entry_price) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+ elif direction == "SELL" and low <= take_profit:
+ pips = (entry_price - take_profit) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
+
+ # A.0b Trailing SL hit
+ if breakeven_moved and current_sl > 0:
+ if direction == "BUY" and low <= current_sl:
+ pips = (current_sl - entry_price) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+ elif direction == "SELL" and high >= current_sl:
+ pips = (entry_price - current_sl) / 0.1
+ reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
+ return pips * pip_value * lot_size, pips, reason, i, current_sl
+
+ # A.1 Breakeven (#28B: Smart)
+ if pip_profit_from_entry >= adaptive_breakeven_pips and not breakeven_moved:
+ if direction == "BUY":
+ current_sl = entry_price + be_lock_distance
+ else:
+ current_sl = entry_price - be_lock_distance
+ breakeven_moved = True
+
+ # A.2 Trailing SL (#32: tighter when ML opposes)
+ if pip_profit_from_entry >= adaptive_trail_start_pips:
+ # #32B: Use tighter trail if ML opposes the trade direction
+ if self.ml_tighten_trail and not ml_agrees_with_trade and cached_ml_confidence >= 0.6:
+ active_trail_step = tightened_trail_step_pips
+ else:
+ active_trail_step = adaptive_trail_step_pips
+
+ trail_distance = active_trail_step * 0.1
+ if direction == "BUY":
+ new_trail_sl = close - trail_distance
+ if new_trail_sl > current_sl:
+ current_sl = new_trail_sl
+ else:
+ new_trail_sl = close + trail_distance
+ if current_sl == 0 or new_trail_sl < current_sl:
+ current_sl = new_trail_sl
+
+ # A.3 Peak protect
+ if peak_profit > self.min_profit_to_protect:
+ drawdown_pct = ((peak_profit - current_profit) / peak_profit) * 100 if peak_profit > 0 else 0
+ if drawdown_pct > self.max_drawdown_from_peak:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+
+ # A.4 Market analysis
+ if bars_since_entry % 5 == 0 and bars_since_entry >= 5 and i >= 20:
+ ma_fast = np.mean(closes[i-4:i+1])
+ ma_slow = np.mean(closes[i-19:i+1])
+ trend = "BULLISH" if ma_fast > ma_slow * 1.001 else ("BEARISH" if ma_fast < ma_slow * 0.999 else "NEUTRAL")
+ roc = (closes[i] / closes[max(0,i-4)] - 1) * 100
+ mom_dir = "BULLISH" if roc > 0.3 else ("BEARISH" if roc < -0.3 else "NEUTRAL")
+
+ rsi_val = None
+ if "rsi" in df.columns:
+ rsi_list = df["rsi"].to_list()
+ if i < len(rsi_list):
+ rsi_val = rsi_list[i]
+
+ urgency = 0
+ should_exit = False
+ if cached_ml_confidence > 0.75:
+ if (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY"):
+ should_exit = True; urgency += 2
+ if rsi_val:
+ if (rsi_val > 75 and direction == "BUY") or (rsi_val < 25 and direction == "SELL"):
+ should_exit = True; urgency += 2
+ if (direction == "BUY" and trend == "BEARISH" and mom_dir == "BEARISH") or \
+ (direction == "SELL" and trend == "BULLISH" and mom_dir == "BULLISH"):
+ should_exit = True; urgency += 3
+
+ if should_exit and current_profit > self.min_profit_to_protect / 2:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+ if urgency >= 7 and current_profit > 0:
+ return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
+
+ # A.5 Weekend close
+ if self._is_near_weekend_close(current_time):
+ if current_profit > 0 or current_profit > -10:
+ return current_profit, current_pips, ExitReason.WEEKEND_CLOSE, i, close
+
+ # B.1 Smart TP
+ if current_profit >= 15:
+ if current_profit >= 40:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if current_profit >= 25 and momentum < -30:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+ if peak_profit > 30 and current_profit < peak_profit * 0.6:
+ return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
+ if current_profit >= 20:
+ progress = (current_profit / target_tp_profit) * 100 if target_tp_profit > 0 else 0
+ progress_score = min(40, max(0, progress * 0.4))
+ momentum_score = ((momentum + 100) / 200) * 30
+ time_penalty = min(10, bars_since_entry / 4 * 2)
+ tp_probability = progress_score + momentum_score + 10 - time_penalty
+ if tp_probability < 25:
+ return current_profit, current_pips, ExitReason.SMART_TP, i, close
+
+ # B.2 Smart Early Exit
+ if 5 <= current_profit < 15:
+ if momentum < -50 and cached_ml_confidence >= 0.65:
+ is_reversal = (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY")
+ if is_reversal:
+ return current_profit, current_pips, ExitReason.EARLY_EXIT, i, close
+
+ # B.3 Early cut
+ if current_profit < 0:
+ loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
+ if momentum < self.early_cut_momentum and loss_percent_of_max >= self.early_cut_loss_pct:
+ return current_profit, current_pips, ExitReason.EARLY_CUT, i, close
+
+ # B.4 Trend Reversal (#32A: configurable ML threshold)
+ is_ml_reversal = False
+ if (direction == "BUY" and cached_ml_signal == "SELL" and cached_ml_confidence >= self.ml_reversal_threshold) or \
+ (direction == "SELL" and cached_ml_signal == "BUY" and cached_ml_confidence >= self.ml_reversal_threshold):
+ is_ml_reversal = True
+ reversal_warnings += 1
+ loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4)
+ if is_ml_reversal and current_profit < -8 and loss_moderate:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+ if reversal_warnings >= 3 and current_profit < -10:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ # B.5 Max loss
+ if current_profit <= -(self.max_loss_per_trade * 0.50):
+ htg = self._hours_to_golden(current_time)
+ if htg <= 1 and htg > 0 and momentum > -40:
+ pass
+ else:
+ return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
+
+ # B.6 Stall
+ if len(profit_history) >= 10:
+ recent_range = max(profit_history[-10:]) - min(profit_history[-10:])
+ if recent_range < 3 and current_profit < -15:
+ stall_count += 1
+ if stall_count >= 5:
+ return current_profit, current_pips, ExitReason.STALL, i, close
+
+ # B.7 Daily loss limit
+ potential_daily_loss = daily_loss_so_far + abs(min(0, current_profit))
+ if potential_daily_loss >= self.max_daily_loss_usd:
+ return current_profit, current_pips, ExitReason.DAILY_LIMIT, i, close
+
+ # C) Time-based (#32C: ML hold boost extends timeout)
+ base_timeout = 16
+ if self.ml_hold_boost and ml_agrees_with_trade and cached_ml_confidence >= 0.6:
+ base_timeout = self.ml_hold_timeout_bars
+
+ if bars_since_entry >= base_timeout and current_profit < 5 and not profit_growing:
+ if current_profit >= 0 or current_profit > -15:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 24 and (current_profit < 10 or not profit_growing):
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+ if bars_since_entry >= 32:
+ return current_profit, current_pips, ExitReason.TIMEOUT, i, close
+
+ # C.2 ATR trend reversal
+ if bars_since_entry > 10:
+ recent_closes = closes[i-5:i+1]
+ mom = recent_closes[-1] - recent_closes[0]
+ if (direction == "BUY" and mom < -reversal_momentum_threshold) or \
+ (direction == "SELL" and mom > reversal_momentum_threshold):
+ if current_profit < -min_loss_for_reversal_exit:
+ return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
+
+ final_idx = min(entry_idx + max_bars - 1, len(df) - 1)
+ final_price = closes[final_idx]
+ pips = ((final_price - entry_price) if direction == "BUY" else (entry_price - final_price)) / 0.1
+ return pips * pip_value * lot_size, pips, ExitReason.TIMEOUT, final_idx, final_price
+
+ def run(self, df_m15, df_h1, start_date=None, end_date=None, initial_capital=5000.0):
+ stats = BacktestStats()
+ capital = initial_capital
+ peak_capital = initial_capital
+ stats.equity_curve.append(capital)
+
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ consecutive_losses = 0
+ trading_mode = TradingMode.NORMAL
+ current_date = None
+
+ feature_cols = []
+ if self.ml_model.fitted and self.ml_model.feature_names:
+ feature_cols = [f for f in self.ml_model.feature_names if f in df_m15.columns]
+
+ times_m15 = df_m15["time"].to_list()
+ times_h1 = df_h1["time"].to_list() if df_h1 is not None else []
+
+ start_idx = next((i for i, t in enumerate(times_m15) if t >= start_date), 100) if start_date else 100
+ end_idx = next((i for i, t in enumerate(times_m15) if t > end_date), len(df_m15) - 100) if end_date else len(df_m15) - 100
+
+ last_trade_idx = -self.trade_cooldown_bars * 2
+
+ print(f" #32 ML reversal threshold: {self.ml_reversal_threshold}, tighten trail: {self.ml_tighten_trail}, hold boost: {self.ml_hold_boost}")
+ print(f" Date range: {times_m15[start_idx]} to {times_m15[end_idx - 1]}")
+ print(f" Total bars: {end_idx - start_idx}")
+
+ for i in range(start_idx, end_idx):
+ if i - last_trade_idx < self.trade_cooldown_bars:
+ continue
+
+ current_time = times_m15[i]
+ trade_date = current_time.date() if hasattr(current_time, 'date') else current_time
+ if current_date is None or trade_date != current_date:
+ daily_loss = 0.0
+ daily_profit = 0.0
+ daily_trades = 0
+ current_date = trade_date
+ if consecutive_losses < 2:
+ trading_mode = TradingMode.NORMAL
+
+ if trading_mode == TradingMode.STOPPED:
+ continue
+
+ session_name, can_trade, lot_mult = self._get_session_from_time(current_time)
+ if not can_trade:
+ if session_name == "Tokyo-London Overlap":
+ stats.session_blocked += 1
+ continue
+
+ if hasattr(current_time, 'weekday') and current_time.weekday() >= 5:
+ continue
+
+ df_slice = df_m15.head(i + 1)
+
+ regime = "normal"
+ try:
+ if self.regime_detector.fitted:
+ regime_state = self.regime_detector.get_current_state(df_slice)
+ if regime_state:
+ regime = regime_state.regime.value
+ if regime_state.regime == MarketRegime.CRISIS:
+ continue
+ if regime_state.recommendation == "SLEEP":
+ continue
+ except Exception:
+ pass
+
+ try:
+ ml_signal = ""
+ ml_confidence = 0.5
+ if self.ml_model.fitted and feature_cols:
+ ml_pred = self.ml_model.predict(df_slice, feature_cols)
+ ml_signal = ml_pred.signal
+ ml_confidence = ml_pred.confidence
+
+ market_analysis = self.dynamic_confidence.analyze_market(
+ session=session_name, regime=regime, volatility="medium",
+ trend_direction=regime, has_smc_signal=True,
+ ml_signal=ml_signal, ml_confidence=ml_confidence,
+ )
+ if market_analysis.quality == MarketQuality.AVOID:
+ stats.avoided_signals += 1
+ continue
+ except Exception:
+ pass
+
+ try:
+ smc_signal = self.smc.generate_signal(df_slice)
+ except Exception:
+ continue
+
+ if smc_signal is None:
+ continue
+
+ # #31B: H1 Price vs EMA20 filter
+ h1_trend = "NEUTRAL"
+ if df_h1 is not None and len(times_h1) > 0:
+ h1_idx = 0
+ for j, t in enumerate(times_h1):
+ if t <= current_time:
+ h1_idx = j
+ else:
+ break
+ if h1_idx > 20:
+ df_h1_slice = df_h1.head(h1_idx + 1)
+ h1_trend = self._get_h1_trend(df_h1_slice)
+
+ # Strict H1 filter: signal must match H1 trend direction
+ if smc_signal.signal_type == "BUY" and h1_trend != "BULLISH":
+ stats.h1_filtered += 1
+ continue
+ if smc_signal.signal_type == "SELL" and h1_trend != "BEARISH":
+ stats.h1_filtered += 1
+ continue
+
+ recent_df = df_slice.tail(10)
+ recent_bos = recent_df["bos"].to_list() if "bos" in df_slice.columns else []
+ recent_choch = recent_df["choch"].to_list() if "choch" in df_slice.columns else []
+ recent_fvg_bull = recent_df["is_fvg_bull"].to_list() if "is_fvg_bull" in df_slice.columns else []
+ recent_fvg_bear = recent_df["is_fvg_bear"].to_list() if "is_fvg_bear" in df_slice.columns else []
+ recent_obs = recent_df["ob"].to_list() if "ob" in df_slice.columns else []
+
+ has_bos = 1 in recent_bos or -1 in recent_bos
+ has_choch = 1 in recent_choch or -1 in recent_choch
+ has_fvg = any(recent_fvg_bull) or any(recent_fvg_bear)
+ has_ob = 1 in recent_obs or -1 in recent_obs
+
+ atr_at_entry = 12.0
+ if "atr" in df_slice.columns:
+ atr_val = df_slice.tail(1)["atr"].item()
+ if atr_val is not None and atr_val > 0:
+ atr_at_entry = atr_val
+
+ confidence = smc_signal.confidence
+ ml_agrees = (smc_signal.signal_type == "BUY" and ml_signal == "BUY") or \
+ (smc_signal.signal_type == "SELL" and ml_signal == "SELL")
+ if ml_agrees:
+ confidence = (smc_signal.confidence + ml_confidence) / 2
+ if regime == "high_volatility":
+ confidence *= 0.9
+
+ lot_size = self._calculate_lot_size(confidence, regime, trading_mode, lot_mult)
+ if lot_size <= 0:
+ continue
+
+ if trading_mode == TradingMode.RECOVERY:
+ stats.recovery_mode_trades += 1
+
+ entry_price = smc_signal.entry_price
+ take_profit_price = smc_signal.take_profit
+ stop_loss_price = smc_signal.stop_loss
+ risk = abs(entry_price - stop_loss_price)
+ rr = abs(take_profit_price - entry_price) / risk if risk > 0 else 0
+
+ profit, pips, exit_reason, exit_idx, exit_price = self._simulate_trade_exit(
+ df=df_m15, entry_idx=i, direction=smc_signal.signal_type,
+ entry_price=entry_price, take_profit=take_profit_price,
+ stop_loss=stop_loss_price, lot_size=lot_size,
+ daily_loss_so_far=daily_loss, feature_cols=feature_cols,
+ )
+
+ self._ticket_counter += 1
+ result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
+
+ trade = SimulatedTrade(
+ ticket=self._ticket_counter,
+ entry_time=current_time,
+ exit_time=times_m15[exit_idx] if exit_idx < len(times_m15) else times_m15[-1],
+ direction=smc_signal.signal_type,
+ entry_price=entry_price, exit_price=exit_price,
+ stop_loss=stop_loss_price, take_profit=take_profit_price,
+ lot_size=lot_size, profit_usd=profit, profit_pips=pips,
+ result=result, exit_reason=exit_reason,
+ smc_confidence=confidence, regime=regime,
+ session=session_name, signal_reason=smc_signal.reason,
+ has_bos=has_bos, has_choch=has_choch,
+ has_fvg=has_fvg, has_ob=has_ob,
+ atr_at_entry=atr_at_entry, rr_ratio=rr,
+ trading_mode=trading_mode.value,
+ h1_trend=h1_trend,
+ )
+ stats.trades.append(trade)
+ stats.total_trades += 1
+ daily_trades += 1
+ capital += profit
+
+ if profit > 0:
+ stats.wins += 1
+ stats.total_profit += profit
+ daily_profit += profit
+ consecutive_losses = 0
+ if trading_mode == TradingMode.RECOVERY:
+ trading_mode = TradingMode.NORMAL
+ else:
+ stats.losses += 1
+ stats.total_loss += abs(profit)
+ daily_loss += abs(profit)
+ consecutive_losses += 1
+
+ if daily_loss >= self.max_daily_loss_usd:
+ trading_mode = TradingMode.STOPPED
+ stats.daily_limit_stops += 1
+ elif consecutive_losses >= 3 or daily_loss >= self.max_daily_loss_usd * 0.6:
+ trading_mode = TradingMode.PROTECTED
+ elif consecutive_losses >= 2:
+ trading_mode = TradingMode.RECOVERY
+
+ if capital > peak_capital:
+ peak_capital = capital
+ drawdown_pct = (peak_capital - capital) / peak_capital * 100
+ drawdown_usd = peak_capital - capital
+ if drawdown_pct > stats.max_drawdown:
+ stats.max_drawdown = drawdown_pct
+ stats.max_drawdown_usd = drawdown_usd
+
+ stats.equity_curve.append(capital)
+ last_trade_idx = exit_idx
+
+ if stats.total_trades % 100 == 0:
+ print(f" {stats.total_trades} trades processed...")
+
+ if stats.total_trades > 0:
+ stats.win_rate = stats.wins / stats.total_trades * 100
+ stats.avg_win = stats.total_profit / stats.wins if stats.wins > 0 else 0
+ stats.avg_loss = stats.total_loss / stats.losses if stats.losses > 0 else 0
+ stats.avg_trade = (stats.total_profit - stats.total_loss) / stats.total_trades
+ stats.profit_factor = stats.total_profit / stats.total_loss if stats.total_loss > 0 else float("inf")
+ win_prob = stats.wins / stats.total_trades
+ loss_prob = stats.losses / stats.total_trades
+ stats.expectancy = (win_prob * stats.avg_win) - (loss_prob * stats.avg_loss)
+ returns = [t.profit_usd for t in stats.trades]
+ if len(returns) > 1:
+ avg_return = np.mean(returns)
+ std_return = np.std(returns)
+ stats.sharpe_ratio = (avg_return / std_return) * np.sqrt(252) if std_return > 0 else 0
+
+ return stats
+
+
+# βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def main():
+ print("=" * 70)
+ print("XAUBOT AI β #32 ML Exit Optimizer")
+ print("Base: #31B (H1 Price vs EMA20) | Modified: ML-enhanced exits")
+ print("=" * 70)
+
+ config = get_config()
+ mt5_conn = MT5Connector(
+ login=config.mt5_login, password=config.mt5_password,
+ server=config.mt5_server, path=config.mt5_path,
+ )
+ mt5_conn.connect()
+ print(f"\nConnected to MT5")
+
+ print("Fetching XAUUSD M15 historical data...")
+ df_m15 = mt5_conn.get_market_data(symbol="XAUUSD", timeframe="M15", count=50000)
+ print(f" M15: {len(df_m15)} bars")
+
+ print("Fetching XAUUSD H1 historical data...")
+ df_h1 = mt5_conn.get_market_data(symbol="XAUUSD", timeframe="H1", count=15000)
+ print(f" H1: {len(df_h1)} bars")
+
+ times = df_m15["time"].to_list()
+ print(f" M15 range: {times[0]} to {times[-1]}")
+
+ end_date = datetime.now()
+ start_date = datetime(2025, 8, 1)
+ data_start = times[0]
+ if hasattr(data_start, 'replace') and data_start.tzinfo:
+ start_date = start_date.replace(tzinfo=data_start.tzinfo)
+ end_date = end_date.replace(tzinfo=data_start.tzinfo)
+ if data_start > start_date:
+ start_date = data_start + timedelta(days=5)
+
+ print(f"\n Backtest period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
+
+ print("\nCalculating M15 indicators...")
+ features = FeatureEngineer()
+ smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
+ df_m15 = features.calculate_all(df_m15, include_ml_features=True)
+ df_m15 = smc.calculate_all(df_m15)
+
+ regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
+ try:
+ regime_detector.load()
+ df_m15 = regime_detector.predict(df_m15)
+ print(" HMM regime loaded")
+ except Exception:
+ print(" [WARN] HMM not available")
+
+ print("Calculating H1 indicators...")
+ df_h1 = features.calculate_all(df_h1, include_ml_features=False)
+ print(" All indicators calculated")
+
+ baseline_31b_pnl = 2806.56
+
+ # βββ CONFIGS βββ
+ configs = [
+ ("A: ML reversal 0.65", {
+ "ml_reversal_threshold": 0.65,
+ }),
+ ("B: ML tighten trail", {
+ "ml_tighten_trail": True,
+ "ml_tighten_trail_mult": 2.0,
+ }),
+ ("C: ML hold boost", {
+ "ml_hold_boost": True,
+ "ml_hold_timeout_bars": 24,
+ }),
+ ("D: A+B combined", {
+ "ml_reversal_threshold": 0.65,
+ "ml_tighten_trail": True,
+ "ml_tighten_trail_mult": 2.0,
+ }),
+ ("E: A+B+C all", {
+ "ml_reversal_threshold": 0.65,
+ "ml_tighten_trail": True,
+ "ml_tighten_trail_mult": 2.0,
+ "ml_hold_boost": True,
+ "ml_hold_timeout_bars": 24,
+ }),
+ ]
+
+ all_results = []
+
+ for cfg_name, cfg_params in configs:
+ print(f"\n{'=' * 60}")
+ print(f" Config: {cfg_name}")
+
+ bt = MLExitBacktest(**cfg_params)
+ stats = bt.run(df_m15=df_m15, df_h1=df_h1, start_date=start_date, end_date=end_date, initial_capital=5000.0)
+ net_pnl = stats.total_profit - stats.total_loss
+ diff = net_pnl - baseline_31b_pnl
+
+ buy_trades = [t for t in stats.trades if t.direction == "BUY"]
+ sell_trades = [t for t in stats.trades if t.direction == "SELL"]
+ buy_wins = sum(1 for t in buy_trades if t.result == TradeResult.WIN)
+ sell_wins = sum(1 for t in sell_trades if t.result == TradeResult.WIN)
+ buy_wr = buy_wins / len(buy_trades) * 100 if buy_trades else 0
+ sell_wr = sell_wins / len(sell_trades) * 100 if sell_trades else 0
+ buy_pnl = sum(t.profit_usd for t in buy_trades)
+ sell_pnl = sum(t.profit_usd for t in sell_trades)
+
+ print(f"\n [{cfg_name}] Results:")
+ print(f" Trades: {stats.total_trades} | WR: {stats.win_rate:.1f}%")
+ print(f" Net PnL: ${net_pnl:,.2f} | PF: {stats.profit_factor:.2f}")
+ print(f" Max DD: {stats.max_drawdown:.1f}% | Sharpe: {stats.sharpe_ratio:.2f}")
+ print(f" BUY: {len(buy_trades)}, {buy_wr:.1f}% WR, ${buy_pnl:,.2f}")
+ print(f" SELL: {len(sell_trades)}, {sell_wr:.1f}% WR, ${sell_pnl:,.2f}")
+ print(f" vs #31B: ${diff:+,.2f}")
+
+ all_results.append((cfg_name, stats, net_pnl, diff))
+
+ # βββ FINAL SUMMARY βββ
+ print(f"\n{'=' * 70}")
+ print("#32 ML EXIT OPTIMIZER β ALL CONFIGURATIONS")
+ print("=" * 70)
+
+ print(f"\n {'Config':<25} {'Trades':>6} {'WR':>6} {'Net PnL':>10} {'DD':>6} {'Sharpe':>7} {'PF':>5} {'vs #31B':>10}")
+ print(f" {'-' * 85}")
+ print(f" {'#24B (base) ':<25} {'739':>6} {'80.4%':>6} {'$2,235':>10} {'3.4%':>6} {'2.87':>7} {'1.77':>5} {'β':>10}")
+ print(f" {'#28B (smart BE)':<25} {'741':>6} {'79.8%':>6} {'$2,464':>10} {'3.5%':>6} {'3.23':>7} {'1.83':>5} {'β':>10}")
+ print(f" {'#31B (H1 filter)':<25} {'625':>6} {'81.8%':>6} {'$2,807':>10} {'2.5%':>6} {'3.97':>7} {'2.19':>5} {'β':>10}")
+ for cfg_name, stats, net_pnl, diff in all_results:
+ print(f" {cfg_name:<25} {stats.total_trades:>6} {stats.win_rate:>5.1f}% ${net_pnl:>9,.2f} {stats.max_drawdown:>5.1f}% {stats.sharpe_ratio:>7.2f} {stats.profit_factor:>5.2f} ${diff:>+9,.2f}")
+
+ best_pnl = -999999
+ best_name = ""
+ best_stats = None
+ for entry in all_results:
+ if entry[2] > best_pnl:
+ best_pnl = entry[2]
+ best_name = entry[0]
+ best_stats = entry[1]
+
+ print(f"\n Best config: {best_name}")
+
+ # Exit reasons
+ print(f"\n Exit Reasons (best config):")
+ exit_counts = {}
+ for t in best_stats.trades:
+ r = t.exit_reason.value
+ exit_counts[r] = exit_counts.get(r, 0) + 1
+ for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
+ pct = count / best_stats.total_trades * 100 if best_stats.total_trades > 0 else 0
+ print(f" {reason:20s}: {count} ({pct:.1f}%)")
+
+ # Save
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "32_ml_exit_results")
+ os.makedirs(output_dir, exist_ok=True)
+
+ log_path = os.path.join(output_dir, f"ml_exit_{timestamp}.log")
+ with open(log_path, "w") as f:
+ f.write(f"#32 ML Exit Optimizer Results\n")
+ f.write(f"Generated: {datetime.now()}\n")
+ f.write(f"Base: #31B (625 trades, 81.8% WR, $2,807)\n\n")
+ for cfg_name, stats, net_pnl, diff in all_results:
+ f.write(f" {cfg_name}: {stats.total_trades} trades, {stats.win_rate:.1f}% WR, "
+ f"${net_pnl:,.2f}, DD: {stats.max_drawdown:.1f}%, "
+ f"Sharpe: {stats.sharpe_ratio:.2f}, PF: {stats.profit_factor:.2f}, "
+ f"vs #31B: ${diff:+,.2f}\n")
+ f.write(f"\nBest: {best_name}\n")
+ print(f" Log saved: {log_path}")
+
+ try:
+ from backtests.backtest_01_smc_only import generate_xlsx_report as gen_xlsx
+ xlsx_path = os.path.join(output_dir, f"ml_exit_{timestamp}.xlsx")
+ gen_xlsx(best_stats, xlsx_path, start_date, end_date)
+ print(f"\n Report saved: {xlsx_path}")
+ except Exception as e:
+ print(f" [WARN] XLSX: {e}")
+
+ mt5_conn.disconnect()
+
+ print(f"\n{'=' * 70}")
+ print(f"Output: {output_dir}")
+ print(f" Log: {os.path.basename(log_path)}")
+ print("=" * 70)
+ print("Backtest complete!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backtests/backtest_live_sync.py b/backtests/backtest_live_sync.py
index c0247fc..60c3026 100644
--- a/backtests/backtest_live_sync.py
+++ b/backtests/backtest_live_sync.py
@@ -4,12 +4,14 @@ Backtest Live Sync - 100% Identical to main_live.py
This backtest MUST be identical to live trading logic.
SYNCED with Critical & Major Fixes (Feb 2025):
-1. SMC Signal: No lookahead bias, current_close entry, min RR 2.0
+1. SMC Signal: No lookahead bias, current_close entry, Fixed RR 1:1.5
2. Pullback Filter: ATR-based thresholds (not hardcoded $2, $1.5)
3. Time-Based Exit: Checks profit_growing + ML agreement before exit
-4. Trend Reversal: ATR-based momentum thresholds
+4. Trend Reversal: ATR-based momentum thresholds (0.6x multiplier)
5. Signal Persistence: Index-based cleanup (prevents memory leak)
6. Calibrated Confidence: Uses SMC's weighted confidence calculation
+7. Dynamic RR: 1.5 (ranging) to 2.0 (strong trend) based on market conditions
+8. SELL Filter: Requires ML agreement + 55% confidence
Synchronized elements:
1. ML Model: XGBoost with same features, 50-bar train/test gap
@@ -25,9 +27,9 @@ Synchronized elements:
6. Position Sizing: Based on ML confidence tiers (0.01-0.02 lot)
7. Trade Cooldown: 20 bars (~5 hours on M15)
8. Exit Logic:
- - TP hit (RR 1:2 enforced)
+ - TP hit (Dynamic RR 1.5-2.0)
- ML reversal (>65% opposite signal)
- - Trend reversal (ATR-based momentum shift)
+ - Trend reversal (ATR * 0.6 momentum shift)
- Smart timeout (checks profit_growing before exit)
- Max loss per trade ($50 default)
diff --git a/docker-add-dashboard.bat b/docker-add-dashboard.bat
new file mode 100644
index 0000000..a37eb13
--- /dev/null
+++ b/docker-add-dashboard.bat
@@ -0,0 +1,64 @@
+@echo off
+REM Add Dashboard & API to existing Docker setup
+
+echo.
+echo ========================================
+echo Adding Dashboard to Existing Setup
+echo ========================================
+echo.
+
+REM Check if .env exists
+if not exist .env (
+ echo WARNING: .env file not found!
+ echo Creating .env from template...
+ copy .env.docker.example .env
+ echo.
+ echo Please edit .env with your MT5 credentials:
+ echo - MT5_LOGIN
+ echo - MT5_PASSWORD
+ echo - MT5_SERVER
+ echo - MT5_PATH
+ echo.
+ pause
+)
+
+REM Check if Docker is running
+docker info >nul 2>&1
+if errorlevel 1 (
+ echo ERROR: Docker is not running!
+ echo Please start Docker Desktop and try again.
+ pause
+ exit /b 1
+)
+
+echo [1/4] Checking existing services...
+docker ps --filter "name=trading_bot_db" --format "table {{.Names}}\t{{.Status}}"
+
+echo.
+echo [2/4] Building new services API and Dashboard...
+docker-compose build trading-api dashboard
+
+echo.
+echo [3/4] Starting new services...
+docker-compose up -d trading-api dashboard
+
+echo.
+echo [4/4] Checking all services...
+docker-compose ps
+
+echo.
+echo ========================================
+echo Dashboard Added Successfully!
+echo ========================================
+echo.
+echo Access Points:
+echo Dashboard: http://localhost:3000
+echo API: http://localhost:8000
+echo API Docs: http://localhost:8000/docs
+echo Database: localhost:5432 (already running)
+echo.
+echo View logs:
+echo docker-compose logs -f dashboard
+echo docker-compose logs -f trading-api
+echo.
+pause
diff --git a/docker-compose.yml b/docker-compose.yml
index 5d2d20f..fc2f6ec 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
# PostgreSQL Database
postgres:
@@ -21,8 +19,57 @@ services:
interval: 10s
timeout: 5s
retries: 5
+ networks:
+ - trading_bot_network
- # pgAdmin (Optional - for database management)
+ # Trading Bot API (reads bot_status.json from shared volume)
+ trading-api:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: trading_bot_api
+ restart: unless-stopped
+ environment:
+ TZ: Asia/Jakarta
+ ports:
+ - "${API_PORT:-8000}:8000"
+ volumes:
+ # Mount data/ so API can read bot_status.json written by the bot on host
+ - ./data:/app/data:ro
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks:
+ - trading_bot_network
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
+
+ # Web Dashboard (Next.js Frontend)
+ dashboard:
+ build:
+ context: ./web-dashboard
+ dockerfile: Dockerfile
+ container_name: trading_bot_dashboard
+ restart: unless-stopped
+ ports:
+ - "${DASHBOARD_PORT:-3000}:3000"
+ depends_on:
+ trading-api:
+ condition: service_healthy
+ networks:
+ - trading_bot_network
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 30s
+
+ # pgAdmin (OPTIONAL - for database management)
pgadmin:
image: dpage/pgadmin4:latest
container_name: trading_bot_pgadmin
@@ -38,6 +85,8 @@ services:
depends_on:
postgres:
condition: service_healthy
+ networks:
+ - trading_bot_network
profiles:
- admin # Only start with: docker-compose --profile admin up
@@ -48,5 +97,6 @@ volumes:
name: trading_bot_pgadmin_data
networks:
- default:
+ trading_bot_network:
name: trading_bot_network
+ driver: bridge
diff --git a/docker-logs.bat b/docker-logs.bat
new file mode 100644
index 0000000..4ad724e
--- /dev/null
+++ b/docker-logs.bat
@@ -0,0 +1,18 @@
+@echo off
+REM XAUBot AI - Docker Logs Viewer (Windows)
+
+set SERVICE=%1
+set LINES=%2
+if "%LINES%"=="" set LINES=100
+
+if "%SERVICE%"=="" (
+ echo Viewing logs for all services...
+ echo Tip: Use 'docker-logs.bat SERVICE [LINES]' to view specific service
+ echo Available: trading-api, dashboard, postgres, pgadmin
+ echo.
+ docker-compose logs -f --tail=%LINES%
+) else (
+ echo Viewing logs for: %SERVICE% last %LINES% lines
+ echo.
+ docker-compose logs -f --tail=%LINES% %SERVICE%
+)
diff --git a/docker-logs.sh b/docker-logs.sh
new file mode 100644
index 0000000..0093f44
--- /dev/null
+++ b/docker-logs.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+# XAUBot AI - Docker Logs Viewer
+
+set -e
+
+SERVICE="$1"
+LINES="${2:-100}"
+
+if [ -z "$SERVICE" ]; then
+ echo "π Viewing logs for all services..."
+ echo "π‘ Tip: Use './docker-logs.sh SERVICE [LINES]' to view specific service"
+ echo " Available: trading-api, dashboard, postgres, pgadmin"
+ echo ""
+ docker-compose logs -f --tail=$LINES
+else
+ echo "π Viewing logs for: $SERVICE (last $LINES lines)"
+ echo ""
+ docker-compose logs -f --tail=$LINES $SERVICE
+fi
diff --git a/docker-remove-dashboard.bat b/docker-remove-dashboard.bat
new file mode 100644
index 0000000..6178e00
--- /dev/null
+++ b/docker-remove-dashboard.bat
@@ -0,0 +1,34 @@
+@echo off
+REM Remove Dashboard & API while keeping database
+
+echo.
+echo ========================================
+echo Removing Dashboard & API Services
+echo ========================================
+echo.
+echo This will stop and remove:
+echo - trading_bot_dashboard
+echo - trading_bot_api
+echo.
+echo Database (trading_bot_db) will remain running.
+echo.
+pause
+
+echo Stopping services...
+docker-compose stop trading-api dashboard
+
+echo.
+echo Removing containers...
+docker-compose rm -f trading-api dashboard
+
+echo.
+echo ========================================
+echo Dashboard Removed!
+echo ========================================
+echo.
+echo Database is still running:
+docker ps --filter "name=trading_bot_db" --format "table {{.Names}}\t{{.Status}}"
+echo.
+echo To add dashboard back: docker-add-dashboard.bat
+echo.
+pause
diff --git a/docker-start.bat b/docker-start.bat
new file mode 100644
index 0000000..68f56a8
--- /dev/null
+++ b/docker-start.bat
@@ -0,0 +1,91 @@
+@echo off
+REM XAUBot AI - Docker Start Script (Windows)
+
+echo.
+echo Starting XAUBot AI Docker Services...
+echo.
+
+REM Check if .env exists
+if not exist .env (
+ echo WARNING: .env file not found!
+ echo Creating .env from template...
+ copy .env.docker.example .env
+ echo.
+ echo .env created. Please edit it with your MT5 credentials:
+ echo - MT5_LOGIN
+ echo - MT5_PASSWORD
+ echo - MT5_SERVER
+ echo - MT5_PATH
+ echo.
+ pause
+)
+
+REM Check if Docker is running
+docker info >nul 2>&1
+if errorlevel 1 (
+ echo ERROR: Docker is not running!
+ echo Please start Docker Desktop and try again.
+ pause
+ exit /b 1
+)
+
+echo Docker is running
+echo.
+
+REM Parse arguments
+set PROFILE_FLAG=
+if "%1"=="--admin" set PROFILE_FLAG=--profile admin
+if "%1"=="-a" set PROFILE_FLAG=--profile admin
+
+if defined PROFILE_FLAG (
+ echo Starting with pgAdmin admin profile...
+) else (
+ echo Starting core services postgres, api, dashboard...
+ echo Tip: Use 'docker-start.bat --admin' to include pgAdmin
+)
+
+echo.
+echo Pulling latest base images...
+docker-compose pull
+
+echo.
+echo Building services...
+docker-compose build
+
+echo.
+echo Starting services...
+docker-compose %PROFILE_FLAG% up -d
+
+echo.
+echo Waiting for services to be healthy...
+timeout /t 10 /nobreak >nul
+
+echo.
+echo Checking service health...
+docker-compose ps
+
+echo.
+echo ========================================
+echo Services started successfully!
+echo ========================================
+echo.
+echo Access Points:
+echo Dashboard: http://localhost:3000
+echo API: http://localhost:8000
+echo API Docs: http://localhost:8000/docs
+echo Database: localhost:5432
+
+if defined PROFILE_FLAG (
+ echo pgAdmin: http://localhost:5050
+)
+
+echo.
+echo Useful Commands:
+echo View logs: docker-compose logs -f
+echo View API logs: docker-compose logs -f trading-api
+echo Stop services: docker-compose down
+echo Restart: docker-compose restart
+echo.
+echo Full documentation: DOCKER.md
+echo.
+pause
diff --git a/docker-start.sh b/docker-start.sh
new file mode 100644
index 0000000..8468a23
--- /dev/null
+++ b/docker-start.sh
@@ -0,0 +1,87 @@
+#!/bin/bash
+# XAUBot AI - Docker Start Script
+
+set -e
+
+echo "π Starting XAUBot AI Docker Services..."
+echo ""
+
+# Check if .env exists
+if [ ! -f .env ]; then
+ echo "β οΈ .env file not found!"
+ echo "π Creating .env from template..."
+ cp .env.docker.example .env
+ echo "β
.env created. Please edit it with your MT5 credentials:"
+ echo " - MT5_LOGIN"
+ echo " - MT5_PASSWORD"
+ echo " - MT5_SERVER"
+ echo " - MT5_PATH"
+ echo ""
+ read -p "Press Enter after editing .env to continue..."
+fi
+
+# Check if Docker is running
+if ! docker info > /dev/null 2>&1; then
+ echo "β Docker is not running!"
+ echo "Please start Docker Desktop and try again."
+ exit 1
+fi
+
+echo "β
Docker is running"
+echo ""
+
+# Parse command line arguments
+PROFILE_FLAG=""
+if [ "$1" == "--admin" ] || [ "$1" == "-a" ]; then
+ PROFILE_FLAG="--profile admin"
+ echo "π Starting with pgAdmin (admin profile)..."
+else
+ echo "π Starting core services (postgres, api, dashboard)..."
+ echo "π‘ Use './docker-start.sh --admin' to include pgAdmin"
+fi
+
+echo ""
+
+# Pull latest images
+echo "π₯ Pulling latest base images..."
+docker-compose pull
+
+echo ""
+echo "π¨ Building services..."
+docker-compose build
+
+echo ""
+echo "π― Starting services..."
+docker-compose $PROFILE_FLAG up -d
+
+echo ""
+echo "β³ Waiting for services to be healthy..."
+sleep 10
+
+# Check health
+echo ""
+echo "π₯ Checking service health..."
+docker-compose ps
+
+echo ""
+echo "β
Services started successfully!"
+echo ""
+echo "π Access Points:"
+echo " Dashboard: http://localhost:3000"
+echo " API: http://localhost:8000"
+echo " API Docs: http://localhost:8000/docs"
+echo " Database: localhost:5432"
+
+if [ "$1" == "--admin" ] || [ "$1" == "-a" ]; then
+ echo " pgAdmin: http://localhost:5050"
+fi
+
+echo ""
+echo "π Useful Commands:"
+echo " View logs: docker-compose logs -f"
+echo " View API logs: docker-compose logs -f trading-api"
+echo " Stop services: docker-compose down"
+echo " Restart: docker-compose restart"
+echo ""
+echo "π Full documentation: DOCKER.md"
+echo ""
diff --git a/docker-status.bat b/docker-status.bat
new file mode 100644
index 0000000..5c00f60
--- /dev/null
+++ b/docker-status.bat
@@ -0,0 +1,55 @@
+@echo off
+REM Check status of all trading bot services
+
+echo.
+echo ========================================
+echo Trading Bot Docker Services Status
+echo ========================================
+echo.
+
+docker-compose ps
+
+echo.
+echo ========================================
+echo Service Health Checks
+echo ========================================
+echo.
+
+echo [Database]
+docker exec trading_bot_db pg_isready -U trading_bot 2>nul
+if %errorlevel%==0 (
+ echo Status: HEALTHY
+) else (
+ echo Status: NOT RUNNING
+)
+
+echo.
+echo [API]
+curl -s http://localhost:8000/api/health >nul 2>&1
+if %errorlevel%==0 (
+ echo Status: HEALTHY
+ echo URL: http://localhost:8000
+) else (
+ echo Status: NOT RUNNING or UNHEALTHY
+)
+
+echo.
+echo [Dashboard]
+curl -s http://localhost:3000 >nul 2>&1
+if %errorlevel%==0 (
+ echo Status: HEALTHY
+ echo URL: http://localhost:3000
+) else (
+ echo Status: NOT RUNNING or UNHEALTHY
+)
+
+echo.
+echo ========================================
+echo Quick Commands
+echo ========================================
+echo View logs: docker-compose logs -f
+echo Restart: docker-compose restart
+echo Stop all: docker-compose stop
+echo Start all: docker-compose up -d
+echo.
+pause
diff --git a/docker-stop.bat b/docker-stop.bat
new file mode 100644
index 0000000..1ffa8fd
--- /dev/null
+++ b/docker-stop.bat
@@ -0,0 +1,44 @@
+@echo off
+REM XAUBot AI - Docker Stop Script (Windows)
+
+echo.
+echo Stopping XAUBot AI Docker Services...
+echo.
+
+if "%1"=="--remove" goto remove
+if "%1"=="-r" goto remove
+if "%1"=="--clean" goto clean
+if "%1"=="-c" goto clean
+goto stop
+
+:remove
+echo Stopping and removing containers...
+docker-compose down
+echo.
+echo Containers stopped and removed
+goto end
+
+:clean
+echo WARNING: This will remove all data including database!
+set /p confirm="Are you sure? (yes/no): "
+if /i "%confirm%"=="yes" (
+ docker-compose down -v
+ echo.
+ echo Containers, networks, and volumes removed
+) else (
+ echo.
+ echo Cancelled
+)
+goto end
+
+:stop
+echo Stopping containers data will be preserved...
+docker-compose stop
+echo.
+echo Containers stopped
+
+:end
+echo.
+echo To restart: docker-start.bat
+echo.
+pause
diff --git a/docker-stop.sh b/docker-stop.sh
new file mode 100644
index 0000000..7c3cd6f
--- /dev/null
+++ b/docker-stop.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+# XAUBot AI - Docker Stop Script
+
+set -e
+
+echo "π Stopping XAUBot AI Docker Services..."
+echo ""
+
+# Parse arguments
+if [ "$1" == "--remove" ] || [ "$1" == "-r" ]; then
+ echo "β οΈ Stopping and removing containers..."
+ docker-compose down
+ echo "β
Containers stopped and removed"
+elif [ "$1" == "--clean" ] || [ "$1" == "-c" ]; then
+ echo "β οΈ WARNING: This will remove all data including database!"
+ read -p "Are you sure? (yes/no): " confirm
+ if [ "$confirm" == "yes" ]; then
+ docker-compose down -v
+ echo "β
Containers, networks, and volumes removed"
+ else
+ echo "β Cancelled"
+ fi
+else
+ echo "π Stopping containers (data will be preserved)..."
+ docker-compose stop
+ echo "β
Containers stopped"
+fi
+
+echo ""
+echo "π To restart: ./docker-start.sh"
+echo ""
diff --git a/main_live.py b/main_live.py
index eceaf60..cbcabec 100644
--- a/main_live.py
+++ b/main_live.py
@@ -525,15 +525,14 @@ class TradingBot:
# --- H1 Multi-Timeframe Bias (Fix 5) ---
def _get_h1_bias(self) -> str:
"""
- Determine H1 higher-timeframe bias using SMC structure.
+ Determine H1 higher-timeframe bias using Price vs EMA20 (#31B).
Returns: "BULLISH", "BEARISH", or "NEUTRAL"
- Logic:
+ Logic (#31B: backtest +$343, WR 81.8%, Sharpe 3.97, DD 2.5%):
- Fetch H1 data (100 bars)
- - Run SMC analysis (BOS, CHoCH, OB, FVG)
- - Last BOS/CHoCH direction = H1 bias
- - If H1 has bullish OB near price β BULLISH zone
- - If H1 has bearish OB near price β BEARISH zone
+ - Calculate EMA20 on H1 closes
+ - If price > EMA20 * 1.001 β BULLISH (allow BUY only)
+ - If price < EMA20 * 0.999 β BEARISH (allow SELL only)
"""
try:
# Cache H1 bias β only update every 4 candles (1 hour) since H1 changes slowly
@@ -550,73 +549,31 @@ class TradingBot:
if len(df_h1) < 20:
return "NEUTRAL"
- # Run SMC on H1 data
- from src.smc_polars import SMCAnalyzer
- h1_smc = SMCAnalyzer(swing_length=5, fvg_min_gap_pips=5.0, ob_lookback=10)
- df_h1 = h1_smc.calculate_all(df_h1)
+ # #31B: Price vs EMA20 method (backtested winner)
+ import numpy as np
+ closes = df_h1["close"].to_list()
+ current_price = closes[-1]
- current_price = df_h1["close"].tail(1).item()
+ # Calculate EMA20
+ period = 20
+ multiplier = 2 / (period + 1)
+ ema = np.mean(closes[:period])
+ for val in closes[period:]:
+ ema = (val - ema) * multiplier + ema
+
+ # Determine bias with small buffer (0.1% threshold)
bias = "NEUTRAL"
-
- # 1. Check last BOS direction on H1
- bos_col = df_h1["bos"].to_list()
- last_bos = 0
- for v in reversed(bos_col[-20:]):
- if v != 0:
- last_bos = v
- break
-
- # 2. Check last CHoCH direction on H1
- choch_col = df_h1["choch"].to_list()
- last_choch = 0
- for v in reversed(choch_col[-20:]):
- if v != 0:
- last_choch = v
- break
-
- # 3. Check if price is near H1 Order Block
- ob_col = df_h1["ob"].to_list()
- highs = df_h1["high"].to_list()
- lows = df_h1["low"].to_list()
- near_bullish_ob = False
- near_bearish_ob = False
-
- for i in range(-10, 0): # Last 10 H1 candles
- idx = len(ob_col) + i
- if idx < 0:
- continue
- ob_val = ob_col[idx]
- if ob_val == 1: # Bullish OB
- # Price within OB zone (low to high of that candle)
- if lows[idx] <= current_price <= highs[idx] * 1.002:
- near_bullish_ob = True
- elif ob_val == -1: # Bearish OB
- if lows[idx] * 0.998 <= current_price <= highs[idx]:
- near_bearish_ob = True
-
- # Determine bias: BOS > CHoCH > OB proximity
- if last_bos == 1:
+ if current_price > ema * 1.001:
bias = "BULLISH"
- elif last_bos == -1:
+ elif current_price < ema * 0.999:
bias = "BEARISH"
- elif last_choch == 1:
- bias = "BULLISH"
- elif last_choch == -1:
- bias = "BEARISH"
-
- # OB proximity can override if no clear structure
- if bias == "NEUTRAL":
- if near_bullish_ob:
- bias = "BULLISH"
- elif near_bearish_ob:
- bias = "BEARISH"
# Cache result
self._h1_bias_cache = bias
self._h1_bias_loop = self._loop_count
if self._loop_count % 4 == 0:
- logger.info(f"H1 Bias: {bias} (BOS={last_bos}, CHoCH={last_choch}, near_bull_OB={near_bullish_ob}, near_bear_OB={near_bearish_ob})")
+ logger.info(f"H1 Bias: {bias} (price={current_price:.2f}, EMA20={ema:.2f})")
return bias
@@ -962,10 +919,18 @@ class TradingBot:
if final_signal is None:
return
- # 10.1 H1 Multi-Timeframe Filter - DISABLED (SMC-only mode)
- # H1 bias still logged for dashboard but does NOT block trades
+ # 10.1 H1 Multi-Timeframe Filter (#31B: Price vs EMA20 β backtest +$343)
+ # BUY only when H1 is BULLISH, SELL only when H1 is BEARISH
if h1_bias != "NEUTRAL":
- logger.info(f"H1 Bias: {h1_bias} (monitoring only, not blocking)")
+ if (final_signal.signal_type == "BUY" and h1_bias != "BULLISH") or \
+ (final_signal.signal_type == "SELL" and h1_bias != "BEARISH"):
+ logger.info(f"H1 Filter: {final_signal.signal_type} blocked (H1={h1_bias})")
+ return
+ logger.info(f"H1 Filter: {final_signal.signal_type} aligned with H1={h1_bias}")
+ else:
+ # H1 NEUTRAL = block both directions (strict mode from backtest)
+ logger.info(f"H1 Filter: {final_signal.signal_type} blocked (H1=NEUTRAL)")
+ return
# 10.5 Check trade cooldown
if self._last_trade_time:
diff --git a/requirements-docker.txt b/requirements-docker.txt
new file mode 100644
index 0000000..75893ef
--- /dev/null
+++ b/requirements-docker.txt
@@ -0,0 +1,38 @@
+# Docker-compatible requirements (Linux)
+# Note: MetaTrader5 is Windows-only, excluded from Docker build
+
+# Core Data Engine (Rust-based, NOT Pandas)
+polars>=1.37.0
+pyarrow>=15.0.0
+
+# Machine Learning
+xgboost>=2.1.0
+scikit-learn>=1.4.0
+hmmlearn>=0.3.3
+joblib>=1.4.0
+
+# Asynchronous Processing
+asyncio-throttle>=1.0.2
+
+# Logging and Monitoring
+loguru>=0.7.2
+
+# Environment Variables
+python-dotenv>=1.0.1
+
+# Numerical Computing
+numpy>=1.26.0
+
+# HTTP Client
+aiohttp>=3.9.0
+
+# PostgreSQL Database
+psycopg2-binary>=2.9.9
+
+# FastAPI for Web Dashboard API
+fastapi>=0.109.0
+uvicorn[standard]>=0.27.0
+pydantic>=2.6.0
+
+# CORS middleware
+python-multipart>=0.0.6
diff --git a/requirements.txt b/requirements.txt
index 53ff2db..d565a94 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -33,6 +33,11 @@ aiohttp>=3.9.0
# PostgreSQL Database
psycopg2-binary>=2.9.9
+# FastAPI for Web Dashboard API
+fastapi>=0.109.0
+uvicorn[standard]>=0.27.0
+pydantic>=2.6.0
+
# Optional: For backtesting
# vectorbt>=0.26.2
diff --git a/src/position_manager.py b/src/position_manager.py
index 248c23e..bf20777 100644
--- a/src/position_manager.py
+++ b/src/position_manager.py
@@ -554,9 +554,10 @@ class SmartPositionManager:
trail_start = self.trail_start_pips
trail_step = self.trail_step_pips
- # 5. Breakeven protection
+ # 5. Breakeven protection (#28B: smart BE locks profit at 0.5*ATR instead of fixed $2)
if pip_profit >= be_pips and current_sl != 0:
- breakeven_sl = entry_price + (1 if is_buy else -1) * 2 # 2 points buffer
+ be_lock_distance = current_atr * 0.5 if (current_atr is not None and current_atr > 0) else 2.0
+ breakeven_sl = entry_price + (1 if is_buy else -1) * be_lock_distance
if is_buy and current_sl < breakeven_sl:
return PositionAction(
diff --git a/src/smc_polars.py b/src/smc_polars.py
index 7e4419f..2d9b63f 100644
--- a/src/smc_polars.py
+++ b/src/smc_polars.py
@@ -135,6 +135,80 @@ class SMCAnalyzer:
# Cap confidence at 0.85 (never 100% certain)
return min(conf, 0.85)
+ def _calculate_dynamic_rr(
+ self,
+ market_structure: int,
+ has_bullish_break: bool,
+ has_bearish_break: bool,
+ has_fvg: bool,
+ has_ob: bool,
+ df: Optional[pl.DataFrame] = None,
+ ) -> float:
+ """
+ Calculate dynamic Risk:Reward ratio based on market conditions.
+
+ Returns RR between 1.5 and 2.0:
+ - 2.0: Strong trend, high confidence -> let profits run
+ - 1.5: Ranging/uncertain -> take profit earlier (higher hit rate)
+
+ Factors considered:
+ 1. Market structure strength (trending vs ranging)
+ 2. Number of confirmations (BOS, FVG, OB)
+ 3. Trend strength (multiple BOS in same direction)
+ 4. Volatility (high vol = lower RR for faster exit)
+ """
+ # Start with base RR
+ rr = 1.5 # Conservative base
+
+ # === Factor 1: Market Structure ===
+ # Strong trend = higher RR
+ if market_structure != 0: # Trending (bullish or bearish)
+ rr += 0.15
+
+ # === Factor 2: Structure Break Confirmation ===
+ if has_bullish_break or has_bearish_break:
+ rr += 0.10 # BOS/CHoCH adds confidence
+
+ # === Factor 3: Entry Zone Confirmation ===
+ if has_fvg:
+ rr += 0.05 # FVG present
+ if has_ob:
+ rr += 0.05 # Order Block present
+
+ # === Factor 4: Trend Strength (multiple BOS) ===
+ if df is not None and "bos" in df.columns:
+ recent_bos = df.tail(20)["bos"].to_list()
+ bos_count = sum(1 for b in recent_bos if b != 0)
+ if bos_count >= 3: # Strong trend with multiple breaks
+ rr += 0.10
+ elif bos_count >= 2:
+ rr += 0.05
+
+ # === Factor 5: Volatility Adjustment ===
+ # High volatility = reduce RR (take profit faster)
+ if df is not None and "atr" in df.columns:
+ atr = df.tail(1)["atr"].item()
+ if atr is not None:
+ # Typical XAUUSD ATR is ~$10-15
+ if atr > 18: # High volatility
+ rr -= 0.15 # Take profit faster
+ elif atr > 15: # Above average volatility
+ rr -= 0.05
+
+ # === Factor 6: Check for ranging market (low BOS count) ===
+ if df is not None and "bos" in df.columns:
+ recent_bos = df.tail(30)["bos"].to_list()
+ bos_count = sum(1 for b in recent_bos if b != 0)
+ if bos_count == 0: # No structure breaks = ranging
+ rr = 1.5 # Use minimum RR in ranging market
+
+ # Clamp RR between 1.5 and 2.0
+ rr = max(1.5, min(2.0, rr))
+
+ logger.debug(f"Dynamic RR: {rr:.2f} (struct={market_structure}, break={has_bullish_break or has_bearish_break}, fvg={has_fvg}, ob={has_ob})")
+
+ return rr
+
def calculate_all(self, df: pl.DataFrame) -> pl.DataFrame:
"""
Calculate all SMC indicators.
@@ -710,9 +784,11 @@ class SMCAnalyzer:
# SL: 1.5-2 ATR distance (protects against noise)
min_sl_distance = 1.5 * atr
- # TP: Must be at least 2x risk (RR 1:2 minimum)
- # With 1.5 ATR SL, TP should be at least 3 ATR
- min_rr_ratio = 2.0 # ENFORCED: Minimum Risk:Reward 1:2
+
+ # === FIXED RR RATIO 1:1.5 ===
+ # Based on backtest analysis: RR 1:2 only hits TP 14% of the time
+ # RR 1:1.5 is more realistic for higher hit rate
+ min_rr_ratio = 1.5
# BULLISH SIGNAL CONDITIONS
# Need: bullish structure OR recent bullish break, AND (FVG OR OB)
@@ -738,7 +814,7 @@ class SMCAnalyzer:
if entry - sl < min_sl_distance:
sl = entry - min_sl_distance
- # FIX: TP at EXACTLY min_rr_ratio (1:2) - ENFORCED
+ # FIXED TP at RR 1:1.5
risk = entry - sl
tp = entry + (risk * min_rr_ratio)
@@ -797,7 +873,7 @@ class SMCAnalyzer:
if sl - entry < min_sl_distance:
sl = entry + min_sl_distance
- # FIX: TP at EXACTLY min_rr_ratio (1:2) - ENFORCED
+ # FIXED TP at RR 1:1.5
risk = sl - entry
tp = entry - (risk * min_rr_ratio)
diff --git a/start-all.bat b/start-all.bat
new file mode 100644
index 0000000..d2a9868
--- /dev/null
+++ b/start-all.bat
@@ -0,0 +1,52 @@
+@echo off
+REM Start API and Dashboard in separate windows
+
+echo.
+echo ========================================
+echo XAUBot AI - Starting All Services
+echo ========================================
+echo.
+
+cd "%~dp0"
+
+REM Check database
+echo [1/3] Checking database...
+docker ps --filter "name=trading_bot_db" --format "{{.Names}}: {{.Status}}" 2>nul
+if errorlevel 1 (
+ echo.
+ echo WARNING: Database not running!
+ echo Please start with: docker-compose up -d postgres
+ echo.
+ pause
+ exit /b 1
+)
+
+echo.
+echo [2/3] Starting API...
+start "Trading API" cmd /k start-api.bat
+
+echo Waiting for API to start...
+timeout /t 5 /nobreak >nul
+
+echo.
+echo [3/3] Starting Dashboard...
+start "Web Dashboard" cmd /k start-dashboard.bat
+
+echo.
+echo ========================================
+echo All Services Started!
+echo ========================================
+echo.
+echo Access Points:
+echo - Dashboard: http://localhost:3000
+echo - API: http://localhost:8000
+echo - API Docs: http://localhost:8000/docs
+echo - Database: localhost:5432
+echo.
+echo Two windows will open:
+echo 1. Trading API (FastAPI)
+echo 2. Web Dashboard (Next.js)
+echo.
+echo Close this window when done.
+echo.
+pause
diff --git a/start-api.bat b/start-api.bat
new file mode 100644
index 0000000..7beb0db
--- /dev/null
+++ b/start-api.bat
@@ -0,0 +1,35 @@
+@echo off
+REM Start Trading API (FastAPI)
+
+echo.
+echo ========================================
+echo Starting Trading API
+echo ========================================
+echo.
+
+cd "%~dp0"
+
+REM Check if virtual environment exists
+if not exist "venv" (
+ echo Creating virtual environment...
+ python -m venv venv
+ echo.
+)
+
+REM Activate virtual environment
+call venv\Scripts\activate.bat
+
+REM Install/update dependencies
+echo Installing dependencies...
+pip install -q fastapi uvicorn pydantic python-dotenv aiohttp
+
+echo.
+echo ========================================
+echo API Starting on http://localhost:8000
+echo ========================================
+echo.
+echo Press Ctrl+C to stop
+echo.
+
+REM Start the API
+python web-dashboard\api\main.py
diff --git a/start-dashboard.bat b/start-dashboard.bat
new file mode 100644
index 0000000..2a59da1
--- /dev/null
+++ b/start-dashboard.bat
@@ -0,0 +1,28 @@
+@echo off
+REM Start Next.js Dashboard
+
+echo.
+echo ========================================
+echo Starting Web Dashboard
+echo ========================================
+echo.
+
+cd "%~dp0web-dashboard"
+
+REM Check if node_modules exists
+if not exist "node_modules" (
+ echo Installing dependencies...
+ npm install
+ echo.
+)
+
+echo.
+echo ========================================
+echo Dashboard Starting on http://localhost:3000
+echo ========================================
+echo.
+echo Press Ctrl+C to stop
+echo.
+
+REM Start dashboard
+npm run dev
diff --git a/web-dashboard/.dockerignore b/web-dashboard/.dockerignore
new file mode 100644
index 0000000..891557b
--- /dev/null
+++ b/web-dashboard/.dockerignore
@@ -0,0 +1,48 @@
+# Dependencies
+node_modules
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# Next.js
+.next/
+out/
+build
+dist
+
+# Testing
+coverage
+
+# Misc
+.DS_Store
+*.pem
+
+# Debug
+*.log
+
+# Local env files
+.env*.local
+.env
+
+# Vercel
+.vercel
+
+# TypeScript
+*.tsbuildinfo
+next-env.d.ts
+
+# IDE
+.vscode
+.idea
+
+# Git
+.git
+.gitignore
+README.md
+
+# Docs
+*.md
+
+# Exclude v3 tailwind config (use @theme in CSS for v4)
+tailwind.config.ts
diff --git a/web-dashboard/Dockerfile b/web-dashboard/Dockerfile
new file mode 100644
index 0000000..02da77a
--- /dev/null
+++ b/web-dashboard/Dockerfile
@@ -0,0 +1,48 @@
+# Multi-stage build for Next.js Dashboard
+FROM node:20-alpine AS base
+
+# Install dependencies only when needed
+FROM base AS deps
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+# Copy package files
+COPY package.json package-lock.json* ./
+RUN npm ci
+
+# Build the source code
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+# NEXT_PUBLIC_API_URL defaults to http://localhost:8000 in use-trading-data.ts
+# The browser fetches from the host machine, not Docker internal network
+ENV NEXT_TELEMETRY_DISABLED=1
+RUN npm run build
+
+# Production image
+FROM base AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+# Copy built files
+COPY --from=builder /app/public ./public
+
+# standalone output includes server.js + required node_modules
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "server.js"]
diff --git a/web-dashboard/MIGRATION-SUMMARY.md b/web-dashboard/MIGRATION-SUMMARY.md
new file mode 100644
index 0000000..c167b9d
--- /dev/null
+++ b/web-dashboard/MIGRATION-SUMMARY.md
@@ -0,0 +1,217 @@
+# Web Dashboard Styling Migration - Summary
+
+## β
Completed Changes
+
+### 1. **Created Tailwind Configuration** (`tailwind.config.ts`)
+- Custom dark theme colors based on SURGE-AI-Trading design
+- Extended color palette with semantic colors (success, warning, danger, info)
+- Custom animations (fade-in, slide-up, shimmer)
+- Custom font families (Inter for sans, JetBrains Mono for mono)
+- Responsive design utilities
+
+### 2. **Updated Global Styles** (`src/app/globals.css`)
+- Dark theme color variables using HSL
+- Custom scrollbar styling
+- Utility classes for:
+ - Text gradient effects
+ - Card variations (glass, hover)
+ - Badge variants (success, warning, danger, info)
+ - Button utilities
+ - Number formatting (font-number)
+ - Price colors (price-up, price-down, price-neutral)
+ - Live pulse indicator
+ - Loading skeleton with shimmer
+ - Input styling
+
+### 3. **Enhanced Utility Functions** (`src/lib/utils.ts`)
+Added comprehensive utility functions:
+- **Formatting:** formatUSD, formatGoldPrice, formatPercent, formatCompact
+- **Date/Time:** formatTime, formatDate, formatDateTime, formatDateTimeWIB, getRelativeTime
+- **Colors:** getValueColor, getValueBgColor, getSignalColor, getSignalBadgeColor
+- **Confidence:** getConfidenceLevel, getConfidenceColor
+- **Helpers:** calcProgress, debounce, generateId, sleep
+
+### 4. **Updated shadcn/ui Components**
+
+#### Badge Component (`src/components/ui/badge.tsx`)
+- Added semantic variants: success, warning, danger, info
+- Improved styling consistency
+- Better hover effects
+
+#### Card Component (`src/components/ui/card.tsx`)
+- Simplified implementation
+- Better border and shadow styling
+- Consistent with shadcn/ui patterns
+
+### 5. **Updated Dashboard Components**
+
+#### PriceCard (`src/components/dashboard/price-card.tsx`)
+- β
Uses `glass` effect
+- β
Uses `formatGoldPrice` and `getValueColor`
+- β
Uses `font-number` for numeric displays
+- β
Uppercase + tracking-wider for title
+- β
Proper semantic colors
+
+#### AccountCard (`src/components/dashboard/account-card.tsx`)
+- β
Uses `glass` effect
+- β
Uses `formatUSD` for currency display
+- β
Uses `getValueColor` for profit/loss
+- β
Uses `font-number` for numeric displays
+- β
Proper border styling with `border-border`
+
+#### SignalCard (`src/components/dashboard/signal-card.tsx`)
+- β
Uses `glass` effect
+- β
Uses `getSignalColor` for signal colors
+- β
Uses `getConfidenceColor` for confidence display
+- β
Improved progress bar colors
+- β
Better probability display formatting
+- β
Uses `font-number` for numeric displays
+
+#### SessionCard (`src/components/dashboard/session-card.tsx`)
+- β
Uses `glass` effect
+- β
Uses semantic badge variants (success/danger)
+- β
Improved golden time indicator with proper colors
+- β
Better visual hierarchy
+- β
Uppercase + tracking-wider for title
+
+#### RiskCard (`src/components/dashboard/risk-card.tsx`)
+- β
Uses `glass` effect
+- β
Uses `formatUSD` for currency display
+- β
Dynamic risk level colors (success/warning/danger)
+- β
Better progress bar with semantic colors
+- β
Improved risk status indicator
+- β
Uses `font-number` for numeric displays
+
+#### RegimeCard (`src/components/dashboard/regime-card.tsx`)
+- β
Uses `glass` effect
+- β
Uses Badge component for regime display
+- β
Uses `getConfidenceColor` for confidence display
+- β
Better regime color mapping (danger/success/info/warning)
+- β
Uses `font-number` for numeric displays
+
+#### Header (`src/components/dashboard/header.tsx`)
+- β
Improved branding with gradient text effect
+- β
Better badge styling with semantic variants
+- β
Added primary color accent box for logo
+- β
Improved time display with proper formatting
+- β
Responsive design (hide time on small screens)
+- β
Uses `font-number` for time display
+
+### 6. **Updated Configuration** (`components.json`)
+- Changed style from "new-york" to "default"
+- Added `tailwind.config.ts` reference
+- Changed baseColor from "neutral" to "slate"
+- Added shadcn registry configuration
+
+### 7. **Created Documentation**
+
+#### STYLING-GUIDE.md
+Comprehensive guide covering:
+- Color system with hex and HSL values
+- Component styling examples
+- Utility classes documentation
+- Utility functions API reference
+- Typography guidelines
+- Responsive design patterns
+- Best practices
+- Example implementations
+- Migration checklist
+
+## π Migration Notes
+
+### Color Changes
+- `text-green-500` β `text-success`
+- `text-red-500` β `text-danger`
+- `text-amber-500` β `text-warning`
+- `text-blue-500` β `text-info`
+- `bg-card/50 backdrop-blur` β `glass`
+
+### Formatting Changes
+- Manual `.toLocaleString()` β `formatUSD()`, `formatGoldPrice()`
+- Manual percentage formatting β `formatPercent()`
+- Manual color logic β `getValueColor()`, `getSignalColor()`
+
+### Component Improvements
+- All cards now use consistent `glass` effect
+- All numeric displays use `font-number` class
+- All titles use `uppercase tracking-wider`
+- Consistent spacing with `space-y-*` utilities
+- Better badge variants with semantic colors
+
+## π¨ Design System
+
+### Primary Colors
+- **Primary:** #6366f1 (Indigo) - Main brand color
+- **Accent:** #8b5cf6 (Purple) - Highlights and accents
+
+### Semantic Colors
+- **Success:** #22c55e (Green) - Positive values, buy signals
+- **Warning:** #f59e0b (Orange) - Caution, hold signals
+- **Danger:** #ef4444 (Red) - Negative values, sell signals
+- **Info:** #3b82f6 (Blue) - Informational content
+
+### Background Hierarchy
+1. `background` (#0a0a0f) - Page background
+2. `surface` (#121218) - Card background
+3. `surface-light` (#1a1a24) - Nested elements
+4. `surface-hover` (#22222e) - Hover states
+
+## π Remaining Components to Migrate
+
+The following components still need to be updated:
+- [ ] `positions-card.tsx`
+- [ ] `log-card.tsx`
+- [ ] `price-chart.tsx`
+- [ ] `equity-chart.tsx`
+
+These should follow the same pattern:
+1. Add `glass` effect to cards
+2. Use utility formatting functions
+3. Apply `font-number` to numbers
+4. Use semantic colors
+5. Apply uppercase + tracking-wider to titles
+
+## π Next Steps
+
+1. **Test the dashboard:**
+ ```bash
+ cd web-dashboard
+ npm run dev
+ ```
+
+2. **Add more shadcn/ui components as needed:**
+ ```bash
+ npx shadcn@latest add tooltip
+ npx shadcn@latest add dialog
+ npx shadcn@latest add dropdown-menu
+ ```
+
+3. **Migrate remaining components** using the patterns in STYLING-GUIDE.md
+
+4. **Consider adding:**
+ - Toast notifications (sonner)
+ - Loading states (spinner)
+ - Error boundaries
+ - Tooltips for detailed info
+
+## π Resources
+
+- **STYLING-GUIDE.md** - Complete styling reference
+- **tailwind.config.ts** - Theme configuration
+- **src/lib/utils.ts** - Utility functions
+- **shadcn/ui docs:** https://ui.shadcn.com
+
+## π― Benefits
+
+1. **Consistent Design** - All components follow the same design system
+2. **Better Maintainability** - Centralized theme and utilities
+3. **Improved Readability** - Semantic colors and proper formatting
+4. **Type Safety** - TypeScript utility functions
+5. **Performance** - Optimized Tailwind CSS with PurgeCSS
+6. **Accessibility** - Better color contrast and semantic HTML
+7. **Developer Experience** - Clear utility functions and documentation
+
+---
+
+**Migration completed:** Feb 6, 2026
+**By:** Claude Sonnet 4.5
diff --git a/web-dashboard/STYLING-GUIDE.md b/web-dashboard/STYLING-GUIDE.md
new file mode 100644
index 0000000..3a84ddd
--- /dev/null
+++ b/web-dashboard/STYLING-GUIDE.md
@@ -0,0 +1,314 @@
+# XAUBot AI Dashboard - Styling Guide
+
+## Overview
+
+The dashboard uses **shadcn/ui** components with **Tailwind CSS** and a custom dark theme inspired by nof1.ai and SURGE-AI-Trading.
+
+## Color System
+
+### Theme Colors
+```typescript
+// Background & Surface
+background: #0a0a0f (HSL: 222 47% 6%)
+surface: #121218 (HSL: 222 25% 7%)
+surface-light: #1a1a24 (HSL: 222 20% 10%)
+surface-hover: #22222e (HSL: 222 18% 14%)
+
+// Primary & Accent
+primary: #6366f1 (Indigo)
+primary-dark: #4f46e5
+accent: #8b5cf6 (Purple)
+
+// Semantic Colors
+success: #22c55e (Green)
+warning: #f59e0b (Orange)
+danger: #ef4444 (Red)
+info: #3b82f6 (Blue)
+
+// Each semantic color has a background variant with 12.5% opacity
+success-bg: #22c55e20
+warning-bg: #f59e0b20
+danger-bg: #ef444420
+info-bg: #3b82f620
+```
+
+### Border & Text
+```typescript
+border: #2a2a3a
+border-light: #3a3a4a
+foreground: #ffffff
+muted-foreground: #a1a1aa
+```
+
+## Component Styling
+
+### Cards
+```tsx
+// Glass effect card (recommended for dashboard)
+
+ ...
+ ...
+
+
+// Custom card utilities
+.glass β bg-surface/80 + backdrop-blur
+.card-custom β bg-surface + rounded-xl + border
+.card-hover β card-custom + hover effect
+```
+
+### Badges
+```tsx
+// Available badge variants
+Primary
+Success
+Warning
+Danger
+Info
+Outline
+```
+
+### Buttons
+```tsx
+// Utility classes for buttons
+className="btn-primary" β Primary button
+className="btn-success" β Success button
+className="btn-danger" β Danger button
+className="btn-outline" β Outline button
+```
+
+## Utility Classes
+
+### Text & Numbers
+```css
+.font-number β font-mono + tabular-nums (for prices, numbers)
+.text-gradient β gradient from primary to accent
+
+.price-up β text-success
+.price-down β text-danger
+.price-neutral β text-muted-foreground
+```
+
+### Animations
+```css
+.animate-pulse-slow β 3s pulse
+.animate-fade-in β fade in effect
+.animate-slide-up β slide up effect
+.animate-shimmer β shimmer loading effect
+.skeleton β loading skeleton with shimmer
+```
+
+### Live Indicators
+```tsx
+// Adds a pulsing dot indicator
+
LIVE
+```
+
+## Utility Functions
+
+### Formatting
+```typescript
+import {
+ formatUSD, // β $1,234.56
+ formatGoldPrice, // β 2345.67
+ formatPercent, // β +2.45%
+ formatCompact, // β 1.2M, 3.4K
+ formatTime, // β 14:23:45
+ formatDate, // β Jan 17, 2026
+ formatDateTime, // β Jan 17, 2026 14:23:45
+ formatDateTimeWIB // β 17 Jan 2026 14:23:45 WIB
+} from '@/lib/utils';
+```
+
+### Color Helpers
+```typescript
+import {
+ getValueColor, // β Returns color class based on +/-
+ getValueBgColor, // β Returns bg color class based on +/-
+ getSignalColor, // β Returns color for BUY/SELL/HOLD
+ getSignalBadgeColor, // β Returns badge variant for signals
+ getConfidenceColor, // β Returns color based on confidence %
+ getConfidenceLevel // β Returns "Very High", "High", etc.
+} from '@/lib/utils';
+```
+
+### Other Utilities
+```typescript
+import {
+ cn, // Merge Tailwind classes
+ calcProgress, // Calculate progress % (capped at 100)
+ debounce, // Debounce function
+ generateId, // Generate unique ID
+ sleep // Async sleep
+} from '@/lib/utils';
+```
+
+## Usage Examples
+
+### Price Display
+```tsx
+import { formatGoldPrice, getValueColor } from '@/lib/utils';
+
+
+ ${formatGoldPrice(price)}
+
+```
+
+### Signal Badge
+```tsx
+import { getSignalBadgeColor } from '@/lib/utils';
+
+
+ {signal}
+
+```
+
+### Confidence Display
+```tsx
+import { getConfidenceColor, getConfidenceLevel } from '@/lib/utils';
+
+const confidencePercent = confidence * 100;
+
+
+ {confidencePercent.toFixed(0)}% - {getConfidenceLevel(confidencePercent)}
+
+```
+
+### Profit/Loss Display
+```tsx
+import { formatUSD, getValueColor } from '@/lib/utils';
+
+
+ {profit >= 0 ? '+' : ''}{formatUSD(profit)}
+
+```
+
+## Typography
+
+### Fonts
+- **Sans:** Inter, system-ui, sans-serif
+- **Mono:** JetBrains Mono, Fira Code, monospace
+
+### Font Classes
+```tsx
+Regular text
+Code or numbers
+Numbers (tabular-nums)
+```
+
+## Responsive Design
+
+The dashboard is optimized for desktop but responsive:
+```tsx
+Desktop only
+Mobile only
+
+ Responsive grid
+
+```
+
+## Adding New shadcn/ui Components
+
+1. Check available components:
+```bash
+npx shadcn@latest view @shadcn
+```
+
+2. Add a component:
+```bash
+npx shadcn@latest add button
+npx shadcn@latest add tooltip
+npx shadcn@latest add dialog
+```
+
+3. Components will be added to `src/components/ui/`
+
+## Best Practices
+
+1. **Always use utility functions** for formatting numbers, dates, and colors
+2. **Use the `cn()` helper** to merge Tailwind classes
+3. **Apply `font-number`** to all numeric displays for consistent monospace formatting
+4. **Use semantic colors** (success, warning, danger, info) instead of raw colors
+5. **Apply `glass` effect** to cards for depth and consistency
+6. **Use uppercase + tracking-wider** for card titles: `className="uppercase tracking-wider"`
+7. **Add proper spacing** with `space-y-*` or `gap-*` utilities
+8. **Keep contrast in mind** - use `text-muted-foreground` for secondary text
+
+## Example Card Component
+
+```tsx
+"use client";
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { TrendingUp } from "lucide-react";
+import { cn, formatUSD, getValueColor } from "@/lib/utils";
+
+interface ExampleCardProps {
+ title: string;
+ value: number;
+ change: number;
+ status: "active" | "inactive";
+}
+
+export function ExampleCard({ title, value, change, status }: ExampleCardProps) {
+ return (
+
+
+
+
+ {title}
+
+
+
+
+ {formatUSD(value)}
+
+
+
+ {change >= 0 ? '+' : ''}{change.toFixed(2)}%
+
+
+ {status}
+
+
+
+
+ );
+}
+```
+
+## Migration Checklist
+
+When updating existing components to the new styling:
+
+- [ ] Replace hardcoded colors with theme colors (text-green-500 β text-success)
+- [ ] Add `glass` class to cards
+- [ ] Use utility formatting functions instead of manual formatting
+- [ ] Apply `font-number` to numeric displays
+- [ ] Use uppercase + tracking-wider for titles
+- [ ] Replace manual color logic with utility functions (getValueColor, etc.)
+- [ ] Update Badge variants to semantic ones (success, warning, danger, info)
+- [ ] Add proper spacing with space-y or gap utilities
+- [ ] Ensure proper use of `cn()` for class merging
+
+## Resources
+
+- shadcn/ui docs: https://ui.shadcn.com
+- Tailwind CSS docs: https://tailwindcss.com
+- Lucide Icons: https://lucide.dev
+
+---
+
+Last updated: Feb 6, 2026
diff --git a/web-dashboard/api/main.py b/web-dashboard/api/main.py
index 83bd15a..c731422 100644
--- a/web-dashboard/api/main.py
+++ b/web-dashboard/api/main.py
@@ -1,42 +1,21 @@
"""
-FastAPI Backend for Web Dashboard
-=================================
+FastAPI Backend for Web Dashboard (Docker-compatible)
+=====================================================
Serves trading bot status data to the web frontend.
+
+Reads from data/bot_status.json which is written by main_live.py.
+This allows the API to run in Docker without needing MT5 (Windows-only).
"""
-import sys
+import json
from pathlib import Path
from datetime import datetime
from zoneinfo import ZoneInfo
-from collections import deque
-import asyncio
-from typing import Optional
-import json
-# Add parent directory to path for imports
-sys.path.insert(0, str(Path(__file__).parent.parent.parent))
-
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from pydantic import BaseModel
-from dotenv import load_dotenv
-load_dotenv()
-
-# Import bot components
-try:
- from src.mt5_connector import MT5Connector
- from src.smc_polars import SMCAnalyzer
- from src.ml_model import TradingModel
- from src.regime_detector import MarketRegimeDetector
- from src.session_filter import SessionFilter
- from src.feature_eng import FeatureEngineer
- from src.config import TradingConfig
-except ImportError as e:
- print(f"Import error: {e}")
- print("Make sure you're running from the correct directory")
-
-app = FastAPI(title="Trading Bot API", version="1.0.0")
+app = FastAPI(title="Trading Bot API", version="2.0.0")
# CORS for frontend
app.add_middleware(
@@ -47,246 +26,68 @@ app.add_middleware(
allow_headers=["*"],
)
-# Global state
-class BotState:
- def __init__(self):
- self.mt5: Optional[MT5Connector] = None
- self.smc: Optional[SMCAnalyzer] = None
- self.ml: Optional[TradingModel] = None
- self.hmm: Optional[MarketRegimeDetector] = None
- self.session: Optional[SessionFilter] = None
- self.feature_eng: Optional[FeatureEngineer] = None
- self.config: Optional[TradingConfig] = None
- self.connected = False
+# Status file path (mounted as volume in Docker)
+STATUS_FILE = Path("/app/data/bot_status.json")
- # History buffers
- self.price_history = deque(maxlen=120)
- self.equity_history = deque(maxlen=120)
- self.balance_history = deque(maxlen=120)
- self.logs = deque(maxlen=50)
-
- # Last known values
- self.last_price = 0.0
- self.last_update = None
-
-state = BotState()
-
-
-def add_log(level: str, message: str):
- """Add log entry to buffer"""
- now = datetime.now(ZoneInfo("Asia/Jakarta"))
- state.logs.append({
- "time": now.strftime("%H:%M:%S"),
- "level": level,
- "message": message
- })
-
-
-@app.on_event("startup")
-async def startup():
- """Initialize bot components on startup"""
- add_log("info", "Starting API server...")
-
- try:
- state.config = TradingConfig()
- state.mt5 = MT5Connector(
- login=state.config.mt5_login,
- password=state.config.mt5_password,
- server=state.config.mt5_server,
- path=state.config.mt5_path,
- )
-
- if state.mt5.connect():
- state.connected = True
- add_log("info", "MT5 connected successfully")
-
- # Initialize components
- state.smc = SMCAnalyzer()
- state.ml = TradingModel(model_path="models/xgboost_model")
- state.ml.load()
- state.hmm = MarketRegimeDetector(model_path="models/hmm_regime")
- state.hmm.load()
- state.session = SessionFilter()
- state.feature_eng = FeatureEngineer()
-
- add_log("info", f"ML Model loaded ({len(state.ml.feature_names)} features)")
- else:
- add_log("error", "Failed to connect to MT5")
-
- except Exception as e:
- add_log("error", f"Startup error: {e}")
-
-
-@app.on_event("shutdown")
-async def shutdown():
- """Cleanup on shutdown"""
- if state.mt5:
- state.mt5.disconnect()
- add_log("info", "API server stopped")
+# Default empty response
+DEFAULT_STATUS = {
+ "timestamp": "00:00:00",
+ "connected": False,
+ "price": 0.0,
+ "spread": 0.0,
+ "priceChange": 0.0,
+ "priceHistory": [],
+ "balance": 0.0,
+ "equity": 0.0,
+ "profit": 0.0,
+ "equityHistory": [],
+ "balanceHistory": [],
+ "session": "Unknown",
+ "isGoldenTime": False,
+ "canTrade": False,
+ "dailyLoss": 0.0,
+ "dailyProfit": 0.0,
+ "consecutiveLosses": 0,
+ "riskPercent": 0.0,
+ "smc": {"signal": "", "confidence": 0.0, "reason": ""},
+ "ml": {"signal": "", "confidence": 0.0, "buyProb": 0.0, "sellProb": 0.0},
+ "regime": {"name": "", "volatility": 0.0, "confidence": 0.0},
+ "positions": [],
+ "logs": [],
+}
@app.get("/api/status")
async def get_status():
- """Get current trading status"""
- wib = ZoneInfo("Asia/Jakarta")
- now = datetime.now(wib)
-
- result = {
- "timestamp": now.strftime("%H:%M:%S"),
- "connected": state.connected,
- "price": 0.0,
- "spread": 0.0,
- "priceChange": 0.0,
- "priceHistory": list(state.price_history),
- "balance": 0.0,
- "equity": 0.0,
- "profit": 0.0,
- "equityHistory": list(state.equity_history),
- "balanceHistory": list(state.balance_history),
- "session": "Unknown",
- "isGoldenTime": 19 <= now.hour < 23,
- "canTrade": False,
- "dailyLoss": 0.0,
- "dailyProfit": 0.0,
- "consecutiveLosses": 0,
- "riskPercent": 0.0,
- "smc": {"signal": "", "confidence": 0.0, "reason": ""},
- "ml": {"signal": "", "confidence": 0.0, "buyProb": 0.0, "sellProb": 0.0},
- "regime": {"name": "", "volatility": 0.0, "confidence": 0.0},
- "positions": [],
- "logs": list(state.logs),
- }
-
- if not state.connected or not state.mt5:
- return result
-
- try:
- # Price
- tick = state.mt5.get_tick(state.config.symbol)
- if tick:
- price = (tick.bid + tick.ask) / 2
- spread = (tick.ask - tick.bid) * 100
-
- # Calculate change
- price_change = price - state.last_price if state.last_price > 0 else 0
- state.last_price = price
-
- # Update history
- state.price_history.append(price)
-
- result["price"] = price
- result["spread"] = spread
- result["priceChange"] = price_change
- result["priceHistory"] = list(state.price_history)
-
- # Account
- balance = state.mt5.account_balance or 0
- equity = state.mt5.account_equity or 0
- profit = equity - balance
-
- state.equity_history.append(equity)
- state.balance_history.append(balance)
-
- result["balance"] = balance
- result["equity"] = equity
- result["profit"] = profit
- result["equityHistory"] = list(state.equity_history)
- result["balanceHistory"] = list(state.balance_history)
-
- # Session
- if state.session:
- session_info = state.session.get_status_report()
- if session_info:
- result["session"] = session_info.get('current_session', 'Unknown')
- can_trade, _, _ = state.session.can_trade()
- result["canTrade"] = can_trade
-
- # Risk state from file
- risk_file = Path("data/risk_state.txt")
- if risk_file.exists():
- content = risk_file.read_text()
- for line in content.strip().split('\n'):
- if ':' in line:
- key, value = line.split(':', 1)
- key = key.strip()
- value = value.strip()
- if key == 'daily_loss':
- result["dailyLoss"] = float(value)
- elif key == 'daily_profit':
- result["dailyProfit"] = float(value)
- elif key == 'consecutive_losses':
- result["consecutiveLosses"] = int(value)
-
- # Calculate risk percent
- max_loss = state.config.capital * (state.config.risk.max_daily_loss / 100)
- if max_loss > 0:
- result["riskPercent"] = (result["dailyLoss"] / max_loss) * 100
-
- # Signals
- df = state.mt5.get_market_data(state.config.symbol, state.config.execution_timeframe, 200)
- if df is not None and len(df) > 50:
- # Feature engineering
- df = state.feature_eng.calculate_all(df, include_ml_features=True)
- df = state.smc.calculate_all(df)
-
- # Regime
- if state.hmm:
- df = state.hmm.predict(df)
- regime = state.hmm.get_current_state(df)
- if regime:
- result["regime"] = {
- "name": regime.regime.value.replace('_', ' ').title(),
- "volatility": regime.volatility,
- "confidence": regime.confidence,
- }
-
- # SMC Signal
- smc_signal = state.smc.generate_signal(df)
- if smc_signal:
- result["smc"] = {
- "signal": smc_signal.signal_type,
- "confidence": smc_signal.confidence,
- "reason": smc_signal.reason or "",
- }
-
- # ML Prediction
- if state.ml and state.ml.fitted:
- available_features = [f for f in state.ml.feature_names if f in df.columns]
- ml_pred = state.ml.predict(df, available_features)
- if ml_pred:
- result["ml"] = {
- "signal": ml_pred.signal,
- "confidence": ml_pred.confidence,
- "buyProb": ml_pred.probability,
- "sellProb": 1.0 - ml_pred.probability,
- }
-
- # Positions
- positions = state.mt5.get_open_positions(state.config.symbol)
- if positions is not None and not positions.is_empty():
- pos_list = []
- for row in positions.iter_rows(named=True):
- pos_list.append({
- "ticket": row.get('ticket', 0),
- "type": "BUY" if row.get('type', 0) == 0 else "SELL",
- "volume": row.get('volume', 0),
- "priceOpen": row.get('price_open', 0),
- "profit": row.get('profit', 0),
- })
- result["positions"] = pos_list
-
- state.last_update = now
-
- except Exception as e:
- add_log("error", f"Status error: {str(e)[:50]}")
+ """Get current trading status from bot's status file."""
+ # Try local path first (non-Docker), then Docker path
+ for path in [STATUS_FILE, Path("data/bot_status.json")]:
+ if path.exists():
+ try:
+ data = json.loads(path.read_text())
+ return data
+ except (json.JSONDecodeError, OSError):
+ continue
+ # No status file β bot not running
+ now = datetime.now(ZoneInfo("Asia/Jakarta"))
+ result = DEFAULT_STATUS.copy()
+ result["timestamp"] = now.strftime("%H:%M:%S")
+ result["logs"] = [
+ {
+ "time": now.strftime("%H:%M:%S"),
+ "level": "warning",
+ "message": "Bot is not running β waiting for bot_status.json",
+ }
+ ]
return result
@app.get("/api/health")
async def health():
- """Health check endpoint"""
- return {"status": "ok", "connected": state.connected}
+ """Health check endpoint."""
+ bot_running = STATUS_FILE.exists() or Path("data/bot_status.json").exists()
+ return {"status": "ok", "bot_running": bot_running}
if __name__ == "__main__":
diff --git a/web-dashboard/api/requirements.txt b/web-dashboard/api/requirements.txt
index 9cbed03..4660ff4 100644
--- a/web-dashboard/api/requirements.txt
+++ b/web-dashboard/api/requirements.txt
@@ -1,4 +1,2 @@
fastapi>=0.109.0
-uvicorn>=0.27.0
-python-dotenv>=1.0.0
-pydantic>=2.5.0
+uvicorn[standard]>=0.27.0
diff --git a/web-dashboard/components.json b/web-dashboard/components.json
index 03909d9..9b52978 100644
--- a/web-dashboard/components.json
+++ b/web-dashboard/components.json
@@ -1,12 +1,12 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
- "style": "new-york",
+ "style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
- "config": "",
+ "config": "tailwind.config.ts",
"css": "src/app/globals.css",
- "baseColor": "neutral",
+ "baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
@@ -19,5 +19,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
- "registries": {}
+ "registries": {
+ "@shadcn": "https://ui.shadcn.com/r"
+ }
}
diff --git a/web-dashboard/next.config.ts b/web-dashboard/next.config.ts
index e9ffa30..f9a8d82 100644
--- a/web-dashboard/next.config.ts
+++ b/web-dashboard/next.config.ts
@@ -1,7 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ // Enable standalone output for Docker
+ output: 'standalone',
+
+ // Disable static optimization for dynamic data
+ experimental: {
+ // Enable if needed for better performance
+ }
};
export default nextConfig;
diff --git a/web-dashboard/src/app/globals.css b/web-dashboard/src/app/globals.css
index 6cf72ed..268eef7 100644
--- a/web-dashboard/src/app/globals.css
+++ b/web-dashboard/src/app/globals.css
@@ -1,125 +1,246 @@
@import "tailwindcss";
-@import "tw-animate-css";
-@custom-variant dark (&:is(.dark *));
+@theme {
+ /* Background layers β soft dark, GitHub Dark Dimmed inspired */
+ --color-background: oklch(0.21 0.01 250);
+ --color-foreground: oklch(0.85 0.01 250);
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
- --color-sidebar-ring: var(--sidebar-ring);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar: var(--sidebar);
- --color-chart-5: var(--chart-5);
- --color-chart-4: var(--chart-4);
- --color-chart-3: var(--chart-3);
- --color-chart-2: var(--chart-2);
- --color-chart-1: var(--chart-1);
- --color-ring: var(--ring);
- --color-input: var(--input);
- --color-border: var(--border);
- --color-destructive: var(--destructive);
- --color-accent-foreground: var(--accent-foreground);
- --color-accent: var(--accent);
- --color-muted-foreground: var(--muted-foreground);
- --color-muted: var(--muted);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-secondary: var(--secondary);
- --color-primary-foreground: var(--primary-foreground);
- --color-primary: var(--primary);
- --color-popover-foreground: var(--popover-foreground);
- --color-popover: var(--popover);
- --color-card-foreground: var(--card-foreground);
- --color-card: var(--card);
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
- --radius-xl: calc(var(--radius) + 4px);
- --radius-2xl: calc(var(--radius) + 8px);
- --radius-3xl: calc(var(--radius) + 12px);
- --radius-4xl: calc(var(--radius) + 16px);
-}
-
-:root {
- --radius: 0.625rem;
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
- --chart-1: oklch(0.646 0.222 41.116);
- --chart-2: oklch(0.6 0.118 184.704);
- --chart-3: oklch(0.398 0.07 227.392);
- --chart-4: oklch(0.828 0.189 84.429);
- --chart-5: oklch(0.769 0.188 70.08);
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
-}
-
-.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.922 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- --chart-1: oklch(0.488 0.243 264.376);
- --chart-2: oklch(0.696 0.17 162.48);
- --chart-3: oklch(0.769 0.188 70.08);
- --chart-4: oklch(0.627 0.265 303.9);
- --chart-5: oklch(0.645 0.246 16.439);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --color-surface: oklch(0.25 0.01 250);
+ --color-surface-light: oklch(0.30 0.008 250);
+ --color-surface-hover: oklch(0.34 0.008 250);
+
+ --color-card: oklch(0.25 0.01 250);
+ --color-card-foreground: oklch(0.85 0.01 250);
+
+ --color-popover: oklch(0.25 0.01 250);
+ --color-popover-foreground: oklch(0.85 0.01 250);
+
+ /* Primary β calm blue */
+ --color-primary: oklch(0.62 0.18 255);
+ --color-primary-foreground: oklch(0.98 0 0);
+ --color-primary-dark: oklch(0.56 0.18 255);
+
+ --color-secondary: oklch(0.30 0.008 250);
+ --color-secondary-foreground: oklch(0.85 0.01 250);
+
+ --color-muted: oklch(0.30 0.008 250);
+ --color-muted-foreground: oklch(0.58 0.01 250);
+
+ --color-accent: oklch(0.62 0.17 290);
+ --color-accent-foreground: oklch(0.98 0 0);
+
+ --color-destructive: oklch(0.62 0.19 25);
+ --color-destructive-foreground: oklch(0.98 0 0);
+
+ /* Borders β gentle, not harsh */
+ --color-border: oklch(0.34 0.008 250);
+ --color-border-light: oklch(0.40 0.006 250);
+
+ --color-input: oklch(0.34 0.008 250);
+ --color-ring: oklch(0.62 0.18 255);
+
+ /* Semantic colors β softer, less saturated */
+ --color-success: oklch(0.68 0.15 155);
+ --color-success-bg: oklch(0.68 0.15 155 / 0.12);
+
+ --color-warning: oklch(0.76 0.14 75);
+ --color-warning-bg: oklch(0.76 0.14 75 / 0.12);
+
+ --color-danger: oklch(0.62 0.19 25);
+ --color-danger-bg: oklch(0.62 0.19 25 / 0.12);
+
+ --color-info: oklch(0.65 0.15 250);
+ --color-info-bg: oklch(0.65 0.15 250 / 0.12);
+
+ /* Charts */
+ --color-chart-1: oklch(0.62 0.18 255);
+ --color-chart-2: oklch(0.68 0.15 155);
+ --color-chart-3: oklch(0.76 0.14 75);
+ --color-chart-4: oklch(0.62 0.17 290);
+ --color-chart-5: oklch(0.62 0.19 25);
+
+ /* Radius */
+ --radius-sm: calc(0.625rem - 4px);
+ --radius-md: calc(0.625rem - 2px);
+ --radius-lg: 0.625rem;
+ --radius-xl: 0.875rem;
+
+ /* Fonts */
+ --font-sans: var(--font-inter), 'Inter', system-ui, sans-serif;
+ --font-mono: var(--font-jetbrains), 'JetBrains Mono', 'Fira Code', monospace;
+
+ /* Animations */
+ --animate-pulse-slow: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ --animate-fade-in: fadeIn 0.4s ease-out;
+ --animate-slide-up: slideUp 0.4s ease-out;
+ --animate-shimmer: shimmer 2s ease-in-out infinite;
}
+/* βββ Base βββ */
@layer base {
* {
- @apply border-border outline-ring/50;
+ border-color: var(--color-border);
+ outline-color: color-mix(in oklch, var(--color-ring) 50%, transparent);
}
- body {
- @apply bg-background text-foreground;
+
+ html {
+ color-scheme: dark;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ html, body {
+ @apply bg-background text-foreground font-sans;
+ height: 100%;
+ overflow: hidden;
+ font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
}
+
+/* βββ Scrollbar βββ */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-border-light);
+}
+
+/* βββ Utilities βββ */
+@layer utilities {
+ /* Glass β soft frosted effect */
+ .glass {
+ background: color-mix(in oklch, var(--color-surface) 90%, transparent);
+ backdrop-filter: blur(10px) saturate(120%);
+ -webkit-backdrop-filter: blur(10px) saturate(120%);
+ border: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
+ box-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.12),
+ 0 0 1px rgba(0, 0, 0, 0.08);
+ transition: border-color 0.2s ease;
+ }
+
+ .glass:hover {
+ border-color: var(--color-border-light);
+ }
+
+ /* Monospace numbers with tabular figures */
+ .font-number {
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.01em;
+ }
+
+ /* Section label */
+ .section-label {
+ @apply text-[11px] font-medium text-muted-foreground uppercase;
+ letter-spacing: 0.1em;
+ }
+
+ /* Signal border accents */
+ .signal-buy {
+ border-left: 3px solid var(--color-success);
+ }
+
+ .signal-sell {
+ border-left: 3px solid var(--color-danger);
+ }
+
+ .signal-hold {
+ border-left: 3px solid var(--color-warning);
+ }
+
+ .signal-none {
+ border-left: 3px solid var(--color-muted);
+ }
+
+ /* Badge variants */
+ .badge-success {
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-bg text-success;
+ }
+
+ .badge-warning {
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning-bg text-warning;
+ }
+
+ .badge-danger {
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-bg text-danger;
+ }
+
+ .badge-info {
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-info-bg text-info;
+ }
+
+ /* Text gradient */
+ .text-gradient {
+ @apply bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent;
+ }
+
+ /* Skeleton */
+ .skeleton {
+ @apply bg-surface-light rounded;
+ animation: shimmer 2s ease-in-out infinite;
+ background: linear-gradient(
+ 90deg,
+ var(--color-surface) 0%,
+ var(--color-surface-light) 50%,
+ var(--color-surface) 100%
+ );
+ background-size: 200% 100%;
+ }
+
+ /* Live pulse dot */
+ .pulse-live::before {
+ content: '';
+ @apply absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-success rounded-full;
+ animation: pulse-dot 2s infinite;
+ }
+
+ .pulse-stale::before {
+ content: '';
+ @apply absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-warning rounded-full;
+ animation: pulse-dot 1.5s infinite;
+ }
+
+ .pulse-dead::before {
+ content: '';
+ @apply absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-danger rounded-full;
+ }
+}
+
+/* βββ Keyframes βββ */
+@keyframes pulse-dot {
+ 0%, 100% {
+ opacity: 1;
+ transform: translateY(-50%) scale(1);
+ }
+ 50% {
+ opacity: 0.4;
+ transform: translateY(-50%) scale(1.8);
+ }
+}
+
+@keyframes fadeIn {
+ 0% { opacity: 0; transform: translateY(8px); }
+ 100% { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes slideUp {
+ 0% { transform: translateY(12px); opacity: 0; }
+ 100% { transform: translateY(0); opacity: 1; }
+}
+
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
diff --git a/web-dashboard/src/app/layout.tsx b/web-dashboard/src/app/layout.tsx
index 60e1009..b0dacd8 100644
--- a/web-dashboard/src/app/layout.tsx
+++ b/web-dashboard/src/app/layout.tsx
@@ -1,20 +1,22 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+const inter = Inter({
+ variable: "--font-inter",
subsets: ["latin"],
+ display: "swap",
});
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
+const jetbrainsMono = JetBrains_Mono({
+ variable: "--font-jetbrains",
subsets: ["latin"],
+ display: "swap",
});
export const metadata: Metadata = {
- title: "AI Trading Bot - Monitor",
- description: "Real-time monitoring dashboard for AI Trading Bot",
+ title: "XAUBOT AI β Trading Monitor",
+ description: "Real-time monitoring dashboard for XAUBOT AI Trading Bot",
};
export default function RootLayout({
@@ -25,7 +27,7 @@ export default function RootLayout({
return (
{children}
diff --git a/web-dashboard/src/app/page.tsx b/web-dashboard/src/app/page.tsx
index 5ab2ce2..5a7336e 100644
--- a/web-dashboard/src/app/page.tsx
+++ b/web-dashboard/src/app/page.tsx
@@ -12,27 +12,41 @@ import {
PositionsCard,
LogCard,
PriceChart,
- EquityChart,
+ SettingsCard,
} from "@/components/dashboard";
import { Skeleton } from "@/components/ui/skeleton";
function LoadingSkeleton() {
return (
-
- {[...Array(8)].map((_, i) => (
-
- ))}
+
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+
+
+
+
);
}
function ErrorDisplay({ message }: { message: string }) {
return (
-
-
-
Connection Error
-
{message}
-
+
+
+
+ !
+
+
Connection Error
+
{message}
+
Make sure the API server is running on port 8000
@@ -43,19 +57,18 @@ function ErrorDisplay({ message }: { message: string }) {
export default function Dashboard() {
const { data, loading, error, dataAge } = useTradingData();
- // Format current time for header
const now = new Date();
- const wibTime = now.toLocaleTimeString('en-US', {
- timeZone: 'Asia/Jakarta',
+ const wibTime = now.toLocaleTimeString("en-US", {
+ timeZone: "Asia/Jakarta",
hour12: false,
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
});
if (loading && !data) {
return (
-
+
@@ -64,7 +77,7 @@ export default function Dashboard() {
if (error && !data) {
return (
-
+
@@ -74,86 +87,116 @@ export default function Dashboard() {
if (!data) return null;
return (
-
+
-
-
- {/* Row 1: Price Chart (full width) */}
-
+
+ {/* ββ Row 1: Status ββ */}
+
- {/* Row 2: Price & Account */}
-
-
+ {/* ββ Row 2: Signals ββ */}
+
+
+
+
+
+
+
+
+
+
+
+ {data.settings ? (
+
+ ) : (
+
+ )}
+
+
- {/* Row 3: Session & Risk */}
-
-
-
- {/* Row 4: SMC & ML */}
-
-
-
- {/* Row 5: Regime & Positions */}
-
-
-
- {/* Row 6: Equity Chart (full width) */}
-
-
- {/* Row 7: Log (full width) */}
-
+ {/* ββ Row 3: Chart + Sidebar (fills remaining) ββ */}
+
-
- {/* Footer Status */}
-
);
}
diff --git a/web-dashboard/src/components/dashboard/account-card.tsx b/web-dashboard/src/components/dashboard/account-card.tsx
index 57937d7..8e5d5f3 100644
--- a/web-dashboard/src/components/dashboard/account-card.tsx
+++ b/web-dashboard/src/components/dashboard/account-card.tsx
@@ -2,39 +2,52 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Wallet } from "lucide-react";
+import { Sparkline } from "./sparkline";
+import { cn, formatUSD, getValueColor } from "@/lib/utils";
interface AccountCardProps {
balance: number;
equity: number;
profit: number;
+ equityHistory?: number[];
}
-export function AccountCard({ balance, equity, profit }: AccountCardProps) {
+export function AccountCard({ balance, equity, profit, equityHistory = [] }: AccountCardProps) {
const isProfit = profit >= 0;
return (
-
-
-
-
- ACCOUNT
+
+
+
+
+ Account
-
+
- Balance
- ${balance.toLocaleString(undefined, { minimumFractionDigits: 2 })}
+ Balance
+ {formatUSD(balance)}
- Equity
- ${equity.toLocaleString(undefined, { minimumFractionDigits: 2 })}
+ Equity
+ {formatUSD(equity)}
-
-
P/L
-
- {isProfit ? '+' : ''}${profit.toFixed(2)}
+
+ P/L
+
+ {isProfit ? "+" : ""}{formatUSD(profit)}
+
+ {equityHistory.length > 2 && (
+
+
+
+ )}
);
diff --git a/web-dashboard/src/components/dashboard/equity-chart.tsx b/web-dashboard/src/components/dashboard/equity-chart.tsx
index 1275f02..2310a6e 100644
--- a/web-dashboard/src/components/dashboard/equity-chart.tsx
+++ b/web-dashboard/src/components/dashboard/equity-chart.tsx
@@ -17,58 +17,68 @@ export function EquityChart({ equityData, balanceData }: EquityChartProps) {
}));
return (
-
-
-
-
- EQUITY vs BALANCE (2H)
+
+
+
+
+ Equity vs Balance (2H)
+ {equityData.length > 0 && (
+
+ ${equityData[equityData.length - 1]?.toFixed(2)}
+
+ )}
-
+
{equityData.length > 1 ? (
-
+
[
`$${value.toFixed(2)}`,
- name === 'equity' ? 'Equity' : 'Balance'
+ name === "equity" ? "Equity" : "Balance",
]}
/>
-
+
) : (
-
- Waiting for data...
+
)}
diff --git a/web-dashboard/src/components/dashboard/header.tsx b/web-dashboard/src/components/dashboard/header.tsx
index df1f536..328d3d3 100644
--- a/web-dashboard/src/components/dashboard/header.tsx
+++ b/web-dashboard/src/components/dashboard/header.tsx
@@ -2,6 +2,7 @@
import { Badge } from "@/components/ui/badge";
import { Bot, Wifi, WifiOff, Clock } from "lucide-react";
+import { cn } from "@/lib/utils";
interface HeaderProps {
connected: boolean;
@@ -10,36 +11,47 @@ interface HeaderProps {
}
export function Header({ connected, lastUpdate, dataAge }: HeaderProps) {
- const isStale = dataAge > 5;
+ const getDataStatus = () => {
+ if (dataAge > 45) return { label: "OFFLINE", variant: "danger" as const, dot: "bg-danger" };
+ if (dataAge > 15) return { label: `STALE ${dataAge.toFixed(0)}s`, variant: "warning" as const, dot: "bg-warning animate-pulse" };
+ return { label: `LIVE ${dataAge.toFixed(1)}s`, variant: "success" as const, dot: "bg-success" };
+ };
+
+ const status = getDataStatus();
return (
-
-
-
-
-
-
AI TRADING BOT
-
MONITOR
+
+
+ {/* Brand */}
+
+
+
+
XAUBOT AI
+
+ Monitor
+
-
- {/* Data Freshness */}
-
-
- {isStale ? `STALE (${dataAge.toFixed(0)}s)` : `LIVE (${dataAge.toFixed(1)}s)`}
+ {/* Status */}
+
+
+
+ {status.label}
- {/* Connection Status */}
-
+
{connected ? : }
- {connected ? 'Connected' : 'Disconnected'}
+ {connected ? "Connected" : "Disconnected"}
- {/* Time */}
-
- {lastUpdate || '--:--:--'} WIB
-
+
+
+
+ {lastUpdate || "--:--:--"}
+
+ WIB
+
diff --git a/web-dashboard/src/components/dashboard/index.ts b/web-dashboard/src/components/dashboard/index.ts
index ce307e3..f8c9499 100644
--- a/web-dashboard/src/components/dashboard/index.ts
+++ b/web-dashboard/src/components/dashboard/index.ts
@@ -1,11 +1,13 @@
-export { PriceCard } from './price-card';
-export { AccountCard } from './account-card';
-export { SessionCard } from './session-card';
-export { RiskCard } from './risk-card';
-export { SignalCard } from './signal-card';
-export { RegimeCard } from './regime-card';
-export { PositionsCard } from './positions-card';
-export { LogCard } from './log-card';
-export { PriceChart } from './price-chart';
-export { EquityChart } from './equity-chart';
-export { Header } from './header';
+export { PriceCard } from "./price-card";
+export { AccountCard } from "./account-card";
+export { SessionCard } from "./session-card";
+export { RiskCard } from "./risk-card";
+export { SignalCard } from "./signal-card";
+export { RegimeCard } from "./regime-card";
+export { PositionsCard } from "./positions-card";
+export { LogCard } from "./log-card";
+export { PriceChart } from "./price-chart";
+export { EquityChart } from "./equity-chart";
+export { Header } from "./header";
+export { Sparkline } from "./sparkline";
+export { SettingsCard } from "./settings-card";
diff --git a/web-dashboard/src/components/dashboard/log-card.tsx b/web-dashboard/src/components/dashboard/log-card.tsx
index 01b7ff3..75c3015 100644
--- a/web-dashboard/src/components/dashboard/log-card.tsx
+++ b/web-dashboard/src/components/dashboard/log-card.tsx
@@ -1,7 +1,6 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { ScrollArea } from "@/components/ui/scroll-area";
import { Terminal } from "lucide-react";
import type { LogEntry } from "@/types/trading";
@@ -12,48 +11,48 @@ interface LogCardProps {
export function LogCard({ logs }: LogCardProps) {
const getLevelColor = (level: string) => {
switch (level) {
- case 'error': return 'text-red-500';
- case 'warn': return 'text-amber-500';
- case 'trade': return 'text-cyan-400';
- default: return 'text-green-400';
+ case "error": return "text-danger";
+ case "warn": return "text-warning";
+ case "trade": return "text-info";
+ default: return "text-success";
}
};
const getLevelBadge = (level: string) => {
switch (level) {
- case 'error': return 'ERR';
- case 'warn': return 'WRN';
- case 'trade': return 'TRD';
- default: return 'INF';
+ case "error": return "ERR";
+ case "warn": return "WRN";
+ case "trade": return "TRD";
+ default: return "INF";
}
};
return (
-
-
-
-
- AI ACTIVITY LOG
+
+
+
+
+ Activity
-
-
+
+
{logs.length === 0 ? (
-
Waiting for activity...
+
Waiting for activity...
) : (
-
+
{logs.map((log, i) => (
-
-
[{log.time}]
-
- [{getLevelBadge(log.level)}]
+
+ {log.time}
+
+ {getLevelBadge(log.level)}
- {log.message}
+ {log.message}
))}
)}
-
+
);
diff --git a/web-dashboard/src/components/dashboard/positions-card.tsx b/web-dashboard/src/components/dashboard/positions-card.tsx
index 3ac7a7e..9d403cd 100644
--- a/web-dashboard/src/components/dashboard/positions-card.tsx
+++ b/web-dashboard/src/components/dashboard/positions-card.tsx
@@ -1,9 +1,9 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
-import { Layers } from "lucide-react";
+import { Layers, Inbox } from "lucide-react";
+import { cn } from "@/lib/utils";
import type { Position } from "@/types/trading";
interface PositionsCardProps {
@@ -12,43 +12,55 @@ interface PositionsCardProps {
export function PositionsCard({ positions }: PositionsCardProps) {
return (
-
-
-
-
- OPEN POSITIONS
+
+
+
+
+ Positions
{positions.length > 0 && (
- {positions.length}
+
+ {positions.length}
+
)}
-
-
- {positions.length === 0 ? (
-
- No open positions
-
- ) : (
-
- {positions.map((pos) => (
-
-
-
- {pos.type}
-
- {pos.volume} @ {pos.priceOpen.toFixed(2)}
-
-
= 0 ? 'text-green-500' : 'text-red-500'}`}>
- {pos.profit >= 0 ? '+' : ''}${pos.profit.toFixed(2)}
+
+ {positions.length === 0 ? (
+
+ ) : (
+
+ {positions.map((pos) => (
+
+
+
+ {pos.type}
+
+
+ {pos.volume} @ {pos.priceOpen.toFixed(2)}
- ))}
-
- )}
-
+
= 0 ? "text-success" : "text-danger"
+ )}>
+ {pos.profit >= 0 ? "+" : ""}${pos.profit.toFixed(2)}
+
+
+ ))}
+
+ )}
);
diff --git a/web-dashboard/src/components/dashboard/price-card.tsx b/web-dashboard/src/components/dashboard/price-card.tsx
index f359899..3cdcc61 100644
--- a/web-dashboard/src/components/dashboard/price-card.tsx
+++ b/web-dashboard/src/components/dashboard/price-card.tsx
@@ -2,43 +2,58 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown } from "lucide-react";
+import { Sparkline } from "./sparkline";
+import { cn, formatGoldPrice, getValueColor } from "@/lib/utils";
interface PriceCardProps {
price: number;
spread: number;
priceChange: number;
+ priceHistory?: number[];
}
-export function PriceCard({ price, spread, priceChange }: PriceCardProps) {
+export function PriceCard({ price, spread, priceChange, priceHistory = [] }: PriceCardProps) {
const isUp = priceChange >= 0;
return (
-
-
-
- PRICE
+
+
+
+ XAUUSD
-
-
- {price.toFixed(2)}
-
- XAUUSD
-
-
- {isUp ? (
-
- ) : (
-
- )}
-
- {isUp ? '+' : ''}{priceChange.toFixed(2)}
+
+
+ ${formatGoldPrice(price)}
-
- Spread: {spread.toFixed(1)} pips
-
+
+
+
+ {isUp ? (
+
+ ) : (
+
+ )}
+
+ {isUp ? "+" : ""}{priceChange.toFixed(2)}
+
+
+
+ {spread.toFixed(1)}p
+
+
+
+ {priceHistory.length > 2 && (
+
+
+
+ )}
);
diff --git a/web-dashboard/src/components/dashboard/price-chart.tsx b/web-dashboard/src/components/dashboard/price-chart.tsx
index 334b08e..bf0671a 100644
--- a/web-dashboard/src/components/dashboard/price-chart.tsx
+++ b/web-dashboard/src/components/dashboard/price-chart.tsx
@@ -1,7 +1,7 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { LineChart, Line, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
+import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
import { TrendingUp } from "lucide-react";
interface PriceChartProps {
@@ -12,48 +12,58 @@ export function PriceChart({ data }: PriceChartProps) {
const chartData = data.map((price, i) => ({ index: i, price }));
return (
-
-
-
-
- PRICE CHART (2H)
+
+
+
+
+ Price Chart (2H)
+ {data.length > 0 && (
+
+ ${data[data.length - 1]?.toFixed(2)}
+
+ )}
-
-
+
+
{data.length > 1 ? (
-
+
-
+
[`$${value.toFixed(2)}`, 'Price']}
+ labelStyle={{ display: "none" }}
+ formatter={(value: number) => [`$${value.toFixed(2)}`, "Price"]}
/>
-
-
+
+
-
-
+
) : (
-
- Waiting for data...
+
)}
diff --git a/web-dashboard/src/components/dashboard/regime-card.tsx b/web-dashboard/src/components/dashboard/regime-card.tsx
index aaa2599..d75449b 100644
--- a/web-dashboard/src/components/dashboard/regime-card.tsx
+++ b/web-dashboard/src/components/dashboard/regime-card.tsx
@@ -1,44 +1,74 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Activity } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Activity, Clock } from "lucide-react";
+import { cn, getConfidenceColor } from "@/lib/utils";
interface RegimeCardProps {
name: string;
volatility: number;
confidence: number;
+ updatedAt?: string;
+ h1Bias?: string;
}
-export function RegimeCard({ name, volatility, confidence }: RegimeCardProps) {
- const getRegimeColor = (regime: string) => {
- if (regime.toLowerCase().includes('high')) return 'text-red-500';
- if (regime.toLowerCase().includes('low')) return 'text-green-500';
- return 'text-amber-500';
+export function RegimeCard({ name, volatility, confidence, updatedAt, h1Bias }: RegimeCardProps) {
+ const getRegimeBadgeVariant = (regime: string) => {
+ const lower = regime.toLowerCase();
+ if (lower.includes("high") || lower.includes("volatile") || lower.includes("crisis")) return "danger";
+ if (lower.includes("low") || lower.includes("ranging")) return "success";
+ if (lower.includes("trend")) return "info";
+ return "warning";
};
+ const confidencePercent = confidence * 100;
+
return (
-
-
-
-
- MARKET REGIME
+
+
+
+
+ Market Regime
+ {updatedAt && (
+
+
+ {updatedAt}
+
+ )}
-
-
-
- {name || '---'}
-
-
+
+
+ {name || "Unknown"}
+
- Volatility
- {volatility.toFixed(2)}
+ Volatility
+ {volatility.toFixed(2)}
- Confidence
- {(confidence * 100).toFixed(0)}%
+ Confidence
+
+ {confidencePercent.toFixed(0)}%
+
+ {h1Bias && (
+
+ H1 Bias
+
+ {h1Bias === "BULLISH" ? "β " : h1Bias === "BEARISH" ? "β " : ""}{h1Bias}
+
+
+ )}
);
diff --git a/web-dashboard/src/components/dashboard/risk-card.tsx b/web-dashboard/src/components/dashboard/risk-card.tsx
index 4ac1d87..b1237b1 100644
--- a/web-dashboard/src/components/dashboard/risk-card.tsx
+++ b/web-dashboard/src/components/dashboard/risk-card.tsx
@@ -1,8 +1,8 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Progress } from "@/components/ui/progress";
-import { ShieldAlert } from "lucide-react";
+import { ShieldAlert, AlertTriangle } from "lucide-react";
+import { cn, formatUSD } from "@/lib/utils";
interface RiskCardProps {
dailyLoss: number;
@@ -12,42 +12,75 @@ interface RiskCardProps {
}
export function RiskCard({ dailyLoss, dailyProfit, consecutiveLosses, riskPercent }: RiskCardProps) {
- const isHighRisk = riskPercent >= 80;
- const isMediumRisk = riskPercent >= 50;
+ const isCritical = riskPercent >= 100;
+ const isHigh = riskPercent >= 80;
+ const isMedium = riskPercent >= 50;
+
+ const getRiskColor = () => {
+ if (isHigh) return "text-danger";
+ if (isMedium) return "text-warning";
+ return "text-success";
+ };
+
+ const getSegmentFill = () => {
+ if (isHigh) return "bg-danger";
+ if (isMedium) return "bg-warning";
+ return "bg-success";
+ };
return (
-
-
-
-
- RISK STATUS
+
+
+
+
+ Risk
+ {isCritical && (
+
+
+ BREACHED
+
+ )}
-
+
- Daily Loss
- ${dailyLoss.toFixed(2)}
+ Daily Loss
+ {formatUSD(dailyLoss)}
- Daily Profit
- ${dailyProfit.toFixed(2)}
+ Daily Profit
+ {formatUSD(dailyProfit)}
- Consec. Losses
- {consecutiveLosses}
+ Consec. Losses
+ = 3 ? "text-warning" : "text-foreground"
+ )}>
+ {consecutiveLosses}
+
-
+
- Risk Used
-
+ Risk Used
+
{riskPercent.toFixed(0)}%
-
diff --git a/web-dashboard/src/components/dashboard/session-card.tsx b/web-dashboard/src/components/dashboard/session-card.tsx
index 41ff17d..f7d3b0f 100644
--- a/web-dashboard/src/components/dashboard/session-card.tsx
+++ b/web-dashboard/src/components/dashboard/session-card.tsx
@@ -2,7 +2,8 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
-import { Clock, Sparkles } from "lucide-react";
+import { Clock, Sparkles, CheckCircle2, XCircle } from "lucide-react";
+import { cn } from "@/lib/utils";
interface SessionCardProps {
session: string;
@@ -11,31 +12,48 @@ interface SessionCardProps {
}
export function SessionCard({ session, isGoldenTime, canTrade }: SessionCardProps) {
+ const getSessionColor = (s: string) => {
+ const lower = s.toLowerCase();
+ if (lower.includes("london")) return "text-info";
+ if (lower.includes("new york") || lower.includes("ny")) return "text-success";
+ if (lower.includes("sydney") || lower.includes("asian")) return "text-accent";
+ return "text-warning";
+ };
+
return (
-
-
-
-
- SESSION
+
+
+
+
+ Session
-
-
-
{session}
+
+
+ {session || "Closed"}
+
+
+
+
+
+ {isGoldenTime ? "Golden Hour" : "Standard Hours"}
+
-
-
-
-
- GOLDEN: {isGoldenTime ? 'YES' : 'NO'}
-
-
-
-
-
-
- {canTrade ? 'CAN TRADE' : 'NO TRADE'}
+
+ {canTrade ? (
+
+ ) : (
+
+ )}
+
+ {canTrade ? "CAN TRADE" : "NO TRADE"}
diff --git a/web-dashboard/src/components/dashboard/settings-card.tsx b/web-dashboard/src/components/dashboard/settings-card.tsx
new file mode 100644
index 0000000..19adc7a
--- /dev/null
+++ b/web-dashboard/src/components/dashboard/settings-card.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Settings2 } from "lucide-react";
+import type { BotSettings } from "@/types/trading";
+
+interface SettingsCardProps {
+ settings: BotSettings;
+}
+
+export function SettingsCard({ settings }: SettingsCardProps) {
+ const rows: { label: string; value: string }[] = [
+ { label: "Mode", value: settings.capitalMode.toUpperCase() },
+ { label: "Capital", value: `$${settings.capital.toLocaleString()}` },
+ { label: "TF", value: `${settings.executionTF}/${settings.trendTF}` },
+ { label: "Risk", value: `${settings.riskPerTrade}%` },
+ { label: "Max Loss", value: `${settings.maxDailyLoss}%` },
+ { label: "Leverage", value: `1:${settings.leverage}` },
+ { label: "Max Lot", value: `${settings.maxLotSize}` },
+ { label: "Max Pos", value: `${settings.maxPositions}` },
+ { label: "R:R", value: `1:${settings.minRR}` },
+ { label: "ML Conf", value: `${(settings.mlConfidence * 100).toFixed(0)}%` },
+ { label: "Cooldown", value: `${settings.cooldownSeconds}s` },
+ { label: "Symbol", value: settings.symbol },
+ ];
+
+ return (
+
+
+
+
+ Bot Settings
+
+
+
+
+ {rows.map((row) => (
+
+ {row.label}
+ {row.value}
+
+ ))}
+
+
+
+ );
+}
diff --git a/web-dashboard/src/components/dashboard/signal-card.tsx b/web-dashboard/src/components/dashboard/signal-card.tsx
index 43038e4..055ac9c 100644
--- a/web-dashboard/src/components/dashboard/signal-card.tsx
+++ b/web-dashboard/src/components/dashboard/signal-card.tsx
@@ -1,8 +1,8 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Progress } from "@/components/ui/progress";
-import { Brain, BarChart3 } from "lucide-react";
+import { Brain, BarChart3, Clock } from "lucide-react";
+import { cn, getSignalColor, getConfidenceColor } from "@/lib/utils";
interface SignalCardProps {
title: string;
@@ -12,54 +12,119 @@ interface SignalCardProps {
detail?: string;
buyProb?: number;
sellProb?: number;
+ updatedAt?: string;
+ threshold?: number;
+ marketQuality?: string;
}
-export function SignalCard({ title, icon, signal, confidence, detail, buyProb, sellProb }: SignalCardProps) {
- const getSignalColor = (sig: string) => {
- if (sig === 'BUY') return 'text-green-500';
- if (sig === 'SELL') return 'text-red-500';
- if (sig === 'HOLD') return 'text-amber-500';
- return 'text-muted-foreground';
+export function SignalCard({
+ title,
+ icon,
+ signal,
+ confidence,
+ detail,
+ buyProb,
+ sellProb,
+ updatedAt,
+ threshold,
+ marketQuality,
+}: SignalCardProps) {
+ const confidencePercent = confidence * 100;
+ const hasSignal = signal && signal.toUpperCase() !== "NO SIGNAL" && signal !== "";
+ const normalized = (signal || "").toUpperCase();
+
+ const getBorderClass = () => {
+ if (normalized === "BUY") return "signal-buy";
+ if (normalized === "SELL") return "signal-sell";
+ if (normalized === "HOLD") return "signal-hold";
+ return "signal-none";
};
- const getProgressColor = (sig: string) => {
- if (sig === 'BUY') return '[&>div]:bg-green-500';
- if (sig === 'SELL') return '[&>div]:bg-red-500';
- if (sig === 'HOLD') return '[&>div]:bg-amber-500';
- return '';
+ const getBarColor = () => {
+ if (normalized === "BUY") return "bg-success";
+ if (normalized === "SELL") return "bg-danger";
+ if (normalized === "HOLD") return "bg-warning";
+ return "bg-muted";
};
return (
-
-
-
- {icon === 'smc' ? : }
+
+
+
+ {icon === "smc" ? : }
{title}
+ {updatedAt && (
+
+
+ {updatedAt}
+
+ )}
-
-
-
- {signal || 'NO SIGNAL'}
-
-
+
+
+ {signal || "NO SIGNAL"}
+
+ {/* Confidence bar */}
- Confidence
- {(confidence * 100).toFixed(0)}%
+ Confidence
+
+ {confidencePercent.toFixed(0)}%
+ {threshold !== undefined && (
+
+ /{(threshold * 100).toFixed(0)}%
+
+ )}
+
-
+
+
+ {threshold !== undefined && (
+
+ )}
+
+ {threshold !== undefined && (
+
+
+ {confidencePercent >= threshold * 100 ? "β Above" : "β Below"} threshold
+
+ {marketQuality && (
+
+ Mkt: {marketQuality}
+
+ )}
+
+ )}
{detail && (
- {detail}
+ {detail}
)}
{buyProb !== undefined && sellProb !== undefined && (
-
-
Buy: {(buyProb * 100).toFixed(0)}%
-
Sell: {(sellProb * 100).toFixed(0)}%
+
+
+ Buy
+ {(buyProb * 100).toFixed(0)}%
+
+
+ Sell
+ {(sellProb * 100).toFixed(0)}%
+
)}
diff --git a/web-dashboard/src/components/dashboard/sparkline.tsx b/web-dashboard/src/components/dashboard/sparkline.tsx
new file mode 100644
index 0000000..8fd0b56
--- /dev/null
+++ b/web-dashboard/src/components/dashboard/sparkline.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
+
+interface SparklineProps {
+ data: number[];
+ color?: string;
+ height?: number;
+}
+
+export function Sparkline({ data, color = "#22c55e", height = 28 }: SparklineProps) {
+ if (data.length < 2) return null;
+
+ const chartData = data.map((v, i) => ({ i, v }));
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web-dashboard/src/components/ui/badge.tsx b/web-dashboard/src/components/ui/badge.tsx
index beb56ed..acb2b9c 100644
--- a/web-dashboard/src/components/ui/badge.tsx
+++ b/web-dashboard/src/components/ui/badge.tsx
@@ -1,23 +1,27 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
-import { Slot } from "radix-ui"
-
import { cn } from "@/lib/utils"
const badgeVariants = cva(
- "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ "inline-flex items-center justify-center rounded-full border border-transparent px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
- "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
- "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
- outline:
- "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- link: "text-primary underline-offset-4 [a&]:hover:underline",
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground border-border",
+ success:
+ "border-transparent bg-success-bg text-success hover:bg-success-bg/80",
+ warning:
+ "border-transparent bg-warning-bg text-warning hover:bg-warning-bg/80",
+ danger:
+ "border-transparent bg-danger-bg text-danger hover:bg-danger-bg/80",
+ info:
+ "border-transparent bg-info-bg text-info hover:bg-info-bg/80",
},
},
defaultVariants: {
@@ -26,22 +30,13 @@ const badgeVariants = cva(
}
)
-function Badge({
- className,
- variant = "default",
- asChild = false,
- ...props
-}: React.ComponentProps<"span"> &
- VariantProps
& { asChild?: boolean }) {
- const Comp = asChild ? Slot.Root : "span"
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+function Badge({ className, variant, ...props }: BadgeProps) {
return (
-
+
)
}
diff --git a/web-dashboard/src/components/ui/card.tsx b/web-dashboard/src/components/ui/card.tsx
index 681ad98..accd904 100644
--- a/web-dashboard/src/components/ui/card.tsx
+++ b/web-dashboard/src/components/ui/card.tsx
@@ -1,92 +1,78 @@
import * as React from "react"
-
import { cn } from "@/lib/utils"
-function Card({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
-function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
-function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
-function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
-function CardAction({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
-function CardContent({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
-function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-export {
- Card,
- CardHeader,
- CardFooter,
- CardTitle,
- CardAction,
- CardDescription,
- CardContent,
-}
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/web-dashboard/src/types/trading.ts b/web-dashboard/src/types/trading.ts
index 7b3edc2..6d57042 100644
--- a/web-dashboard/src/types/trading.ts
+++ b/web-dashboard/src/types/trading.ts
@@ -33,17 +33,20 @@ export interface TradingStatus {
signal: string;
confidence: number;
reason: string;
+ updatedAt?: string;
};
ml: {
signal: string;
confidence: number;
buyProb: number;
sellProb: number;
+ updatedAt?: string;
};
regime: {
name: string;
volatility: number;
confidence: number;
+ updatedAt?: string;
};
// Positions
@@ -51,6 +54,31 @@ export interface TradingStatus {
// Log
logs: LogEntry[];
+
+ // Bot Settings
+ settings?: BotSettings;
+
+ // Entry Conditions
+ h1Bias?: string;
+ dynamicThreshold?: number;
+ marketQuality?: string;
+ marketScore?: number;
+}
+
+export interface BotSettings {
+ capitalMode: string;
+ capital: number;
+ riskPerTrade: number;
+ maxDailyLoss: number;
+ maxPositions: number;
+ maxLotSize: number;
+ leverage: number;
+ executionTF: string;
+ trendTF: string;
+ minRR: number;
+ mlConfidence: number;
+ cooldownSeconds: number;
+ symbol: string;
}
export interface Position {
diff --git a/web-dashboard/tailwind.config.ts b/web-dashboard/tailwind.config.ts
new file mode 100644
index 0000000..62d954f
--- /dev/null
+++ b/web-dashboard/tailwind.config.ts
@@ -0,0 +1,117 @@
+import type { Config } from 'tailwindcss'
+
+const config: Config = {
+ darkMode: ['class', '.dark'],
+ content: [
+ './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/components/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/app/**/*.{js,ts,jsx,tsx,mdx}',
+ ],
+ theme: {
+ extend: {
+ colors: {
+ // Dark theme colors (nof1.ai / SURGE-AI inspired)
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ surface: 'hsl(var(--surface))',
+ 'surface-light': 'hsl(var(--surface-light))',
+ 'surface-hover': 'hsl(var(--surface-hover))',
+
+ card: {
+ DEFAULT: 'hsl(var(--card))',
+ foreground: 'hsl(var(--card-foreground))',
+ },
+ popover: {
+ DEFAULT: 'hsl(var(--popover))',
+ foreground: 'hsl(var(--popover-foreground))',
+ },
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))',
+ dark: 'hsl(var(--primary-dark))',
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))',
+ },
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))',
+ },
+ accent: {
+ DEFAULT: 'hsl(var(--accent))',
+ foreground: 'hsl(var(--accent-foreground))',
+ },
+ destructive: {
+ DEFAULT: 'hsl(var(--destructive))',
+ foreground: 'hsl(var(--destructive-foreground))',
+ },
+ border: {
+ DEFAULT: 'hsl(var(--border))',
+ light: 'hsl(var(--border-light))',
+ },
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+
+ // Semantic colors
+ success: {
+ DEFAULT: 'hsl(var(--success))',
+ bg: 'hsl(var(--success-bg))',
+ },
+ warning: {
+ DEFAULT: 'hsl(var(--warning))',
+ bg: 'hsl(var(--warning-bg))',
+ },
+ danger: {
+ DEFAULT: 'hsl(var(--danger))',
+ bg: 'hsl(var(--danger-bg))',
+ },
+ info: {
+ DEFAULT: 'hsl(var(--info))',
+ bg: 'hsl(var(--info-bg))',
+ },
+
+ // Chart colors
+ chart: {
+ '1': 'hsl(var(--chart-1))',
+ '2': 'hsl(var(--chart-2))',
+ '3': 'hsl(var(--chart-3))',
+ '4': 'hsl(var(--chart-4))',
+ '5': 'hsl(var(--chart-5))',
+ },
+ },
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)',
+ },
+ fontFamily: {
+ sans: ['Inter', 'system-ui', 'sans-serif'],
+ mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
+ },
+ animation: {
+ 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
+ 'fade-in': 'fadeIn 0.3s ease-in-out',
+ 'slide-up': 'slideUp 0.3s ease-out',
+ 'shimmer': 'shimmer 1.5s infinite',
+ },
+ keyframes: {
+ fadeIn: {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' },
+ },
+ slideUp: {
+ '0%': { transform: 'translateY(10px)', opacity: '0' },
+ '100%': { transform: 'translateY(0)', opacity: '1' },
+ },
+ shimmer: {
+ '0%': { backgroundPosition: '-200% 0' },
+ '100%': { backgroundPosition: '200% 0' },
+ },
+ },
+ },
+ },
+ plugins: [],
+}
+
+export default config