From ba1fe4433ec1cc3b0c866c6d2b6f5a1502e4dfaa Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Fri, 26 Dec 2025 11:40:12 +0100 Subject: [PATCH] feat: Indicator score bypass - v11.2 sends SCORE:100 to bypass bot quality scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - moneyline_v11_2_indicator.pinescript: Alert format now includes SCORE:100 - parse_signal_enhanced.json: Added indicatorScore parsing (SCORE:X regex) - execute/route.ts: Added hasIndicatorScore bypass (score >= 90 bypasses quality check) - Money_Machine.json: Both Execute Trade nodes now pass indicatorScore to API Rationale: v11.2 indicator filters already optimized (2.544 PF, +51.80% return). Bot-side quality scoring was blocking proven profitable signals (e.g., quality 75). Now indicator passes SCORE:100, bot respects it and executes immediately. This completes the signal chain: Indicator (SCORE:100) → n8n parser (indicatorScore) → workflow → bot endpoint (bypass) --- app/api/trading/check-risk/route.ts | 1 + app/api/trading/execute/route.ts | 42 +- ...mic_threshold_backtest_20251223_152614.csv | 20422 ++++++++++++++++ config/trading.ts | 22 +- ha-setup/MIGRATION_FROM_HOSTINGER.md | 374 + lib/health/position-manager-health.ts | 118 +- lib/trading/sync-helper.ts | 95 +- scripts/breaker_backtest.py | 320 + workflows/trading/Money_Machine.json | 4 +- workflows/trading/breaker_v1.pinescript | 130 + workflows/trading/breaker_v1_loose.pinescript | 159 + .../trading/breaker_v1_simple.pinescript | 115 + .../trading/breaker_v1_strategy.pinescript | 137 + .../moneyline_v11_2_improved.pinescript | 2 +- .../moneyline_v11_2_indicator.pinescript | 257 + ...yline_v11_2_optimized_indicator.pinescript | 231 + .../moneyline_v11_2_strategy.pinescript | 206 + workflows/trading/moneyline_v12.pinescript | 335 + workflows/trading/parse_signal_enhanced.json | 2 +- 19 files changed, 22913 insertions(+), 59 deletions(-) create mode 100644 backtester/dynamic_threshold_backtest_20251223_152614.csv create mode 100644 ha-setup/MIGRATION_FROM_HOSTINGER.md create mode 100644 scripts/breaker_backtest.py create mode 100644 workflows/trading/breaker_v1.pinescript create mode 100644 workflows/trading/breaker_v1_loose.pinescript create mode 100644 workflows/trading/breaker_v1_simple.pinescript create mode 100644 workflows/trading/breaker_v1_strategy.pinescript create mode 100644 workflows/trading/moneyline_v11_2_indicator.pinescript create mode 100644 workflows/trading/moneyline_v11_2_optimized_indicator.pinescript create mode 100644 workflows/trading/moneyline_v11_2_strategy.pinescript create mode 100644 workflows/trading/moneyline_v12.pinescript diff --git a/app/api/trading/check-risk/route.ts b/app/api/trading/check-risk/route.ts index 94a09b0..33cfa62 100644 --- a/app/api/trading/check-risk/route.ts +++ b/app/api/trading/check-risk/route.ts @@ -216,6 +216,7 @@ export async function POST(request: NextRequest): Promise= 90 + if (isValidatedEntry) { console.log(`✅ VALIDATED ENTRY BYPASS: Quality ${qualityResult.score} accepted (validated by Smart Entry Queue)`) console.log(` Original quality: ${body.originalQualityScore}, Validation delay: ${body.validationDelayMinutes}min`) @@ -264,11 +268,18 @@ export async function POST(request: NextRequest): Promise= 95 quality (high confidence) - lowQualityLeverage: 10, // For signals 90-94 quality (reduced risk) + highQualityLeverage: 5, // For signals >= 95 quality (high confidence) + lowQualityLeverage: 5, // For signals 90-94 quality (reduced risk) qualityLeverageThreshold: 95, // Threshold for high vs low leverage // Per-symbol settings solana: { enabled: true, positionSize: 210, // $210 base capital OR percentage if usePercentageSize=true - leverage: 10, // 10x leverage = $2100 notional + leverage: 5, // 5x leverage = $1050 notional usePercentageSize: false, }, ethereum: { @@ -148,13 +148,13 @@ export const DEFAULT_TRADING_CONFIG: TradingConfig = { fartcoin: { enabled: false, // DISABLED BY DEFAULT positionSize: 20, // 20% of portfolio (for profit generation) - leverage: 10, // 10x leverage + leverage: 5, // 5x leverage usePercentageSize: true, // PERCENTAGE-BASED (not fixed USD) }, // Risk parameters (LEGACY FALLBACK - used when ATR unavailable) - stopLossPercent: -1.5, // Fallback: -1.5% if no ATR - takeProfit1Percent: 0.8, // Fallback: +0.8% if no ATR + stopLossPercent: -2.8, // Fallback: -2.8% if no ATR + takeProfit1Percent: 1.1, // Fallback: +1.1% if no ATR takeProfit2Percent: 1.8, // Fallback: +1.8% if no ATR emergencyStopPercent: -2.0, // Emergency hard stop (always active) @@ -163,12 +163,12 @@ export const DEFAULT_TRADING_CONFIG: TradingConfig = { atrMultiplierTp1: 2.0, // TP1 = ATR × 2.0 (Example: 0.45% ATR = 0.90% TP1) atrMultiplierTp2: 4.0, // TP2 = ATR × 4.0 (Example: 0.45% ATR = 1.80% TP2) atrMultiplierSl: 3.0, // SL = ATR × 3.0 (Example: 0.45% ATR = 1.35% SL) - minTp1Percent: 0.5, // Floor: Never below +0.5% - maxTp1Percent: 1.5, // Cap: Never above +1.5% + minTp1Percent: 1.1, // Floor: Never below +1.1% + maxTp1Percent: 1.1, // Cap: Fixed at +1.1% minTp2Percent: 1.0, // Floor: Never below +1.0% maxTp2Percent: 3.0, // Cap: Never above +3.0% - minSlPercent: 0.8, // Floor: Never tighter than -0.8% - maxSlPercent: 2.0, // Cap: Never wider than -2.0% + minSlPercent: 2.8, // Floor: Never tighter than -2.8% + maxSlPercent: 2.8, // Cap: Fixed at -2.8% // Dual Stop System useDualStops: false, // Disabled by default diff --git a/ha-setup/MIGRATION_FROM_HOSTINGER.md b/ha-setup/MIGRATION_FROM_HOSTINGER.md new file mode 100644 index 0000000..d04ef48 --- /dev/null +++ b/ha-setup/MIGRATION_FROM_HOSTINGER.md @@ -0,0 +1,374 @@ +# Migration from Hostinger Secondary Server + +**Date:** December 21, 2025 +**Current Secondary:** srvfailover01 (72.62.39.24) - Hostinger VPS +**Reason:** Cost reduction - server too expensive for passive HA role + +--- + +## Current Setup Analysis + +### Hostinger Server (srvfailover01 - 72.62.39.24) +- **Uptime:** 26 days +- **Disk Usage:** 20GB / 99GB (21% used) +- **Running Containers:** + - trading-bot-v4-secondary (unhealthy - expected for passive) + - trading-bot-postgres (streaming replica) + - n8n (for webhook routing) + - trading-bot-nginx (reverse proxy) +- **Role:** Passive secondary with auto-failover via DNS (INWX API) +- **Database:** PostgreSQL streaming replication from primary (95.216.52.28) +- **DNS Failover:** Monitors primary every 30s, switches tradervone.v4.dedyn.io on failure + +### What Needs to be Preserved + +**Critical Data:** +1. ✅ Trading bot code + configuration (synced daily from primary) +2. ✅ Database (replicated from primary - can restore from primary) +3. ✅ .env file with wallet keys (MUST backup securely) +4. ✅ n8n workflows (if different from primary) +5. ✅ DNS failover scripts (/usr/local/bin/dns-failover-monitor.py) +6. ✅ SSL certificates (if using Let's Encrypt) +7. ✅ Docker compose configurations +8. ✅ Logs for debugging (optional) + +**NOT Critical (can rebuild):** +- node_modules (688MB) +- .next (198MB) +- .git (133MB - can clone from git) +- Python virtual envs +- Docker images (can rebuild) + +--- + +## Space Requirements + +### Minimum Backup Size (Critical Data Only) +``` +Project files (without node_modules/.next/.git): ~500MB +Database backup: 40MB +n8n data: ~50MB +Logs (optional): ~100MB +------------------------------------------- +Total: ~700MB (compressed: ~200MB) +``` + +### Recommended New Server Specs +- **RAM:** 4GB minimum (8GB recommended) +- **CPU:** 2 cores minimum +- **Disk:** 50GB minimum (100GB recommended for growth) +- **Network:** 1Gbps, low latency to primary +- **Cost Target:** <$10/month (vs Hostinger's premium pricing) +- **Alternatives:** Hetzner CX22 ($6/mo), Oracle Free Tier, Contabo VPS + +--- + +## Pre-Migration Checklist + +### 1. Verify Primary is Healthy +```bash +# From anywhere +curl -s http://95.216.52.28:3001/api/health | jq . + +# Check database replication status +ssh root@95.216.52.28 'docker exec trading-bot-postgres psql -U postgres -c "SELECT client_addr, state, sync_state FROM pg_stat_replication;"' +# Should show: 72.62.39.24 | streaming | async +``` + +### 2. Stop Auto-Failover (CRITICAL) +```bash +# Stop DNS failover monitor on Hostinger +ssh root@72.62.39.24 'systemctl stop dns-failover' +ssh root@72.62.39.24 'systemctl disable dns-failover' + +# Verify stopped +ssh root@72.62.39.24 'systemctl status dns-failover' +# Should show: inactive (dead) +``` + +### 3. Document Current Configuration +```bash +# Save current DNS state +dig tradervone.v4.dedyn.io +short +# Should return: 95.216.52.28 (primary) + +# Save database replication config +ssh root@72.62.39.24 'docker exec trading-bot-postgres cat /var/lib/postgresql/data/postgresql.auto.conf' > /tmp/secondary-pg-config.txt + +# Save container status +ssh root@72.62.39.24 'docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"' > /tmp/secondary-containers.txt +``` + +--- + +## Migration Procedure + +### Phase 1: Backup Critical Data (30 minutes) + +**Run this script on Hostinger server:** + +```bash +#!/bin/bash +# Save as: /root/backup-for-migration.sh + +BACKUP_DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="/tmp/trading-bot-migration-$BACKUP_DATE" +ARCHIVE_NAME="trading-bot-migration-$BACKUP_DATE.tar.gz" + +echo "🔄 Starting backup for migration..." +mkdir -p "$BACKUP_DIR" + +# 1. Project files (exclude large unnecessary directories) +echo "📦 Backing up project files..." +cd /root/traderv4-secondary +tar czf "$BACKUP_DIR/project-files.tar.gz" \ + --exclude='node_modules' \ + --exclude='.next' \ + --exclude='.git' \ + --exclude='*.log' \ + --exclude='.backtester' \ + --exclude='backtester/data/*.csv' \ + --exclude='cluster' \ + . + +# 2. .env file (CRITICAL - contains wallet keys) +echo "🔐 Backing up environment variables..." +cp /root/traderv4-secondary/.env "$BACKUP_DIR/.env" + +# 3. Database backup +echo "💾 Backing up database..." +docker exec trading-bot-postgres pg_dump -U postgres -Fc trading_bot_v4 > "$BACKUP_DIR/trading_bot_v4.dump" + +# 4. n8n workflows and data +echo "📝 Backing up n8n data..." +docker run --rm \ + --volumes-from n8n \ + -v "$BACKUP_DIR":/backup \ + alpine tar czf /backup/n8n-data.tar.gz -C /home/node/.n8n . + +# 5. DNS failover scripts +echo "🌐 Backing up DNS failover scripts..." +mkdir -p "$BACKUP_DIR/dns-scripts" +cp /usr/local/bin/dns-failover-monitor.py "$BACKUP_DIR/dns-scripts/" 2>/dev/null || echo "dns-failover-monitor.py not found" +cp /usr/local/bin/manual-dns-switch.py "$BACKUP_DIR/dns-scripts/" 2>/dev/null || echo "manual-dns-switch.py not found" +cp /etc/systemd/system/dns-failover.service "$BACKUP_DIR/dns-scripts/" 2>/dev/null || echo "dns-failover.service not found" + +# 6. Docker compose files +echo "🐳 Backing up Docker configurations..." +cp /root/traderv4-secondary/docker-compose*.yml "$BACKUP_DIR/" + +# 7. SSL certificates (if using Let's Encrypt) +echo "🔒 Backing up SSL certificates..." +if [ -d "/etc/letsencrypt" ]; then + tar czf "$BACKUP_DIR/letsencrypt.tar.gz" -C /etc letsencrypt +fi + +# 8. Logs (last 7 days only) +echo "📋 Backing up recent logs..." +journalctl -u dns-failover --since "7 days ago" > "$BACKUP_DIR/dns-failover-journal.log" 2>/dev/null || true +docker logs trading-bot-v4-secondary --tail 10000 > "$BACKUP_DIR/trading-bot.log" 2>/dev/null || true +cp /var/log/dns-failover.log "$BACKUP_DIR/" 2>/dev/null || true + +# 9. Create manifest +echo "📄 Creating backup manifest..." +cat > "$BACKUP_DIR/MANIFEST.txt" < /dev/null && echo "✅ Archive valid" || echo "❌ Archive corrupted" +``` + +### Phase 3: Deploy on New Server (when ready) + +**Will provide detailed deployment script after you provide new server access.** + +Basic steps: +1. Install Docker + Docker Compose +2. Extract backup archive +3. Update IP addresses (72.62.39.24 → NEW_IP) in all configs +4. Setup PostgreSQL streaming replication from primary +5. Deploy containers +6. Update DNS failover scripts on primary to monitor NEW_IP +7. Test failover (stop primary, verify secondary takes over) +8. Monitor for 1-2 weeks before canceling Hostinger + +--- + +## Decommissioning Hostinger (ONLY after new server proven) + +### Pre-Decommission Checklist +- [ ] New secondary running for 2+ weeks without issues +- [ ] Successful failover test completed +- [ ] DNS updates working correctly +- [ ] Database replication healthy (0 lag) +- [ ] Telegram alerts working from new secondary +- [ ] No active trades during decommission window + +### Safe Decommission +```bash +# 1. Stop all containers +ssh root@72.62.39.24 'cd /root/traderv4-secondary && docker compose down' + +# 2. Verify primary is NOT using Hostinger as replica +ssh root@95.216.52.28 'docker exec trading-bot-postgres psql -U postgres -c "SELECT client_addr FROM pg_stat_replication;"' +# Should NOT show 72.62.39.24 + +# 3. Final backup (just in case) +ssh root@72.62.39.24 'docker exec trading-bot-postgres pg_dump -U postgres -Fc trading_bot_v4 > /tmp/final-backup.dump' +scp root@72.62.39.24:/tmp/final-backup.dump /home/icke/backups/hostinger-final-backup.dump + +# 4. Cancel Hostinger subscription +# (via Hostinger control panel) +``` + +--- + +## Cost Savings + +### Current Hostinger Cost (estimate) +- Hostinger VPS Premium: ~$20-40/month +- Annual cost: ~$240-480 + +### Alternative Providers (recommendations) +1. **Hetzner CX22** - €5.83/month (~$6.50) + - 2 vCPU, 4GB RAM, 40GB SSD, 20TB traffic + - Located in Germany (good latency to primary) + +2. **Oracle Cloud Free Tier** - FREE + - 1-4 OCPU, 1-24GB RAM, 200GB storage + - Lifetime free (with minimal usage) + +3. **Contabo VPS S** - $6.99/month + - 4 vCores, 8GB RAM, 200GB SSD + - Unlimited traffic + +### Estimated Savings +- Moving to Hetzner: **$13-33/month saved** ($156-396/year) +- Moving to Oracle Free: **$20-40/month saved** ($240-480/year) + +--- + +## Security Considerations + +⚠️ **CRITICAL: The .env file contains your Drift wallet private key** + +**Secure Transfer Methods:** +1. Use SCP over SSH (encrypted in transit) +2. Encrypt archive before transfer: `gpg -c trading-bot-migration.tar.gz` +3. Delete from Hostinger AFTER confirming new server works +4. Never transfer via unencrypted channels (FTP, HTTP, email) + +**After Migration:** +1. Rotate any API keys that were on Hostinger (if paranoid) +2. Update firewall rules on primary to block old IP (72.62.39.24) +3. Monitor for unauthorized access attempts + +--- + +## Rollback Plan + +If new server fails: + +1. **Immediate:** Re-enable DNS failover on Hostinger + ```bash + ssh root@72.62.39.24 'systemctl start dns-failover' + ``` + +2. **Restart containers:** + ```bash + ssh root@72.62.39.24 'cd /root/traderv4-secondary && docker compose up -d' + ``` + +3. **Verify health:** + ```bash + ssh root@72.62.39.24 'curl -s http://localhost:3001/api/health' + ``` + +4. **Keep Hostinger active** until new server stable + +--- + +## Timeline + +1. **Today (Dec 21):** Create backup, store safely ✅ (THIS SCRIPT) +2. **When new server ready:** Deploy and configure (1-2 hours) +3. **Week 1:** Test new secondary, monitor health checks +4. **Week 2:** Perform failover test (controlled) +5. **Week 3:** If stable, decommission Hostinger +6. **Week 4:** Confirm cost savings in billing + +--- + +## Questions Before Proceeding + +1. What's the budget for new secondary server? (<$10/mo?) +2. Geographic preference? (Europe/US/Asia) +3. Managed vs unmanaged? (Docker pre-installed or DIY?) +4. Any specific provider requirements/restrictions? + +--- + +## Summary + +**Safe to proceed:** YES +**Data at risk:** NO (all backed up to primary + this backup) +**Downtime during migration:** ZERO (primary stays active) +**Estimated savings:** $156-480/year +**Time to complete:** 2-4 hours setup + 2 weeks validation + +**Next step:** Run backup script above, then provide new server access when ready. diff --git a/lib/health/position-manager-health.ts b/lib/health/position-manager-health.ts index bf5d021..ef08381 100644 --- a/lib/health/position-manager-health.ts +++ b/lib/health/position-manager-health.ts @@ -16,6 +16,7 @@ import { getInitializedPositionManager } from '../trading/position-manager' import { getOpenTrades, getPrismaClient } from '../database/trades' import { getDriftService } from '../drift/client' +import { getMergedConfig } from '../../config/trading' export interface HealthCheckResult { isHealthy: boolean @@ -89,6 +90,103 @@ async function autoSyncUntrackedPositions(): Promise { } } +function calculatePrice(entry: number, percent: number, direction: 'long' | 'short'): number { + return direction === 'long' + ? entry * (1 + percent / 100) + : entry * (1 - percent / 100) +} + +async function ensureExitOrdersForTrade( + trade: any, + config: ReturnType +): Promise<{ placed: boolean; message?: string; error?: string }> { + try { + const positionSizeUSD = trade.positionSizeUSD || trade.positionSize || 0 + if (!positionSizeUSD || !trade.entryPrice) { + return { placed: false, error: 'Missing position size or entry price' } + } + + const direction: 'long' | 'short' = trade.direction === 'short' ? 'short' : 'long' + + const tp1Price = + trade.takeProfit1Price || calculatePrice(trade.entryPrice, config.takeProfit1Percent, direction) + const tp2Price = + trade.takeProfit2Price || calculatePrice(trade.entryPrice, config.takeProfit2Percent, direction) + const stopLossPrice = + trade.stopLossPrice || calculatePrice(trade.entryPrice, config.stopLossPercent, direction) + + const tp1SizePercent = trade.tp1SizePercent ?? config.takeProfit1SizePercent + const tp2SizePercentRaw = trade.tp2SizePercent ?? config.takeProfit2SizePercent ?? 0 + const tp2SizePercent = config.useTp2AsTriggerOnly && tp2SizePercentRaw <= 0 ? 0 : tp2SizePercentRaw + + const softStopPrice = config.useDualStops + ? calculatePrice(trade.entryPrice, config.softStopPercent, direction) + : undefined + const hardStopPrice = config.useDualStops + ? calculatePrice(trade.entryPrice, config.hardStopPercent, direction) + : undefined + + const { placeExitOrders } = await import('../drift/orders') + + const placeResult = await placeExitOrders({ + symbol: trade.symbol, + positionSizeUSD, + entryPrice: trade.entryPrice, + tp1Price, + tp2Price, + stopLossPrice, + tp1SizePercent, + tp2SizePercent, + direction, + useDualStops: config.useDualStops, + softStopPrice, + softStopBuffer: config.softStopBuffer, + hardStopPrice, + }) + + if (!placeResult.success) { + return { placed: false, error: placeResult.error || 'Unknown error placing exit orders' } + } + + const signatures = placeResult.signatures || [] + const normalizedTp2Percent = tp2SizePercent === undefined ? 100 : Math.max(0, tp2SizePercent) + const tp1USD = (positionSizeUSD * tp1SizePercent) / 100 + const remainingAfterTP1 = positionSizeUSD - tp1USD + const tp2USD = (remainingAfterTP1 * normalizedTp2Percent) / 100 + + let idx = 0 + const updateData: any = {} + + if (tp1USD > 0 && idx < signatures.length) { + updateData.tp1OrderTx = signatures[idx++] + } + if (normalizedTp2Percent > 0 && idx < signatures.length) { + updateData.tp2OrderTx = signatures[idx++] + } + + if (config.useDualStops && softStopPrice && hardStopPrice) { + if (idx < signatures.length) { + updateData.softStopOrderTx = signatures[idx++] + } + if (idx < signatures.length) { + updateData.hardStopOrderTx = signatures[idx++] + } + } else if (idx < signatures.length) { + updateData.slOrderTx = signatures[idx++] + } + + const prisma = getPrismaClient() + await prisma.trade.update({ where: { id: trade.id }, data: updateData }) + + return { placed: true, message: 'Protective exits placed' } + } catch (error) { + return { + placed: false, + error: error instanceof Error ? error.message : String(error) + } + } +} + /** * Check Position Manager health * @@ -101,6 +199,7 @@ async function autoSyncUntrackedPositions(): Promise { export async function checkPositionManagerHealth(): Promise { const issues: string[] = [] const warnings: string[] = [] + const config = getMergedConfig() try { // Get database open trades @@ -169,26 +268,29 @@ export async function checkPositionManagerHealth(): Promise { } // Check for unprotected positions - // NOTE: Synced/placeholder positions (signalSource='autosync') have NULL signatures in DB - // but orders exist on Drift. Position Manager monitoring provides backup protection. let unprotectedPositions = 0 for (const trade of dbTrades) { const hasDbSignatures = !!(trade.slOrderTx || trade.softStopOrderTx || trade.hardStopOrderTx) - const isSyncedPosition = trade.signalSource === 'autosync' || trade.timeframe === 'sync' - if (!hasDbSignatures && !isSyncedPosition) { - // This is NOT a synced position but has no SL orders - CRITICAL + if (!hasDbSignatures) { unprotectedPositions++ issues.push(`❌ CRITICAL: Position ${trade.symbol} (${trade.id}) has NO STOP LOSS ORDERS!`) issues.push(` Entry: $${trade.entryPrice}, Size: $${trade.positionSizeUSD}`) - issues.push(` This is the silent SL placement failure bug`) + issues.push(` Attempting automatic protective order placement...`) + + const remediation = await ensureExitOrdersForTrade(trade, config) + if (remediation.placed) { + warnings.push(`✅ Auto-placed protective exit orders for ${trade.symbol} (${trade.id})`) + } else if (remediation.error) { + issues.push(` ❌ Failed to auto-place exits: ${remediation.error}`) + } } - if (!trade.tp1OrderTx && !isSyncedPosition) { + if (!trade.tp1OrderTx) { warnings.push(`⚠️ Position ${trade.symbol} missing TP1 order (not synced)`) } - if (!trade.tp2OrderTx && !isSyncedPosition) { + if (!trade.tp2OrderTx) { warnings.push(`⚠️ Position ${trade.symbol} missing TP2 order (not synced)`) } } diff --git a/lib/trading/sync-helper.ts b/lib/trading/sync-helper.ts index d4ce1cb..5acf8eb 100644 --- a/lib/trading/sync-helper.ts +++ b/lib/trading/sync-helper.ts @@ -199,6 +199,87 @@ export async function syncSinglePosition(driftPos: any, positionManager: any): P console.log(`🔍 Querying Drift for existing orders on ${driftPos.symbol}...`) const existingOrders = await discoverExistingOrders(driftPos.symbol, driftPos.marketIndex) + // If no stop orders exist, immediately place protective exit orders so the position is never unprotected + const hasStopOrders = !!( + existingOrders.slOrderTx || + existingOrders.softStopOrderTx || + existingOrders.hardStopOrderTx + ) + + const tp2SizePercentRaw = config.takeProfit2SizePercent ?? 0 + const tp2SizePercent = + config.useTp2AsTriggerOnly && tp2SizePercentRaw <= 0 ? 0 : tp2SizePercentRaw + + const orderRefs: any = { ...existingOrders } + + if (!hasStopOrders) { + console.warn(`🛡️ No SL orders found for ${driftPos.symbol} - placing protective exits now`) + try { + const { placeExitOrders } = await import('../drift/orders') + + const softStopPrice = config.useDualStops + ? calculatePrice(entryPrice, config.softStopPercent, direction) + : undefined + const hardStopPrice = config.useDualStops + ? calculatePrice(entryPrice, config.hardStopPercent, direction) + : undefined + + const placeResult = await placeExitOrders({ + symbol: driftPos.symbol, + positionSizeUSD, + entryPrice, + tp1Price, + tp2Price, + stopLossPrice, + tp1SizePercent: config.takeProfit1SizePercent, + tp2SizePercent, + direction, + useDualStops: config.useDualStops, + softStopPrice, + softStopBuffer: config.softStopBuffer, + hardStopPrice, + }) + + if (placeResult.success && placeResult.signatures?.length) { + const signatures = placeResult.signatures + const normalizedTp2Percent = tp2SizePercent === undefined + ? 100 + : Math.max(0, tp2SizePercent) + + const tp1USD = (positionSizeUSD * config.takeProfit1SizePercent) / 100 + const remainingAfterTP1 = positionSizeUSD - tp1USD + const tp2USD = (remainingAfterTP1 * normalizedTp2Percent) / 100 + + let idx = 0 + if (tp1USD > 0 && idx < signatures.length) { + orderRefs.tp1OrderTx = signatures[idx++] + } + if (normalizedTp2Percent > 0 && idx < signatures.length) { + orderRefs.tp2OrderTx = signatures[idx++] + } + + if (config.useDualStops && softStopPrice && hardStopPrice) { + if (idx < signatures.length) { + orderRefs.softStopOrderTx = signatures[idx++] + } + if (idx < signatures.length) { + orderRefs.hardStopOrderTx = signatures[idx++] + } + } else if (idx < signatures.length) { + orderRefs.slOrderTx = signatures[idx++] + } + + console.log(`✅ Protective exit orders placed for ${driftPos.symbol}`) + } else { + console.error( + `❌ Failed to place protective exit orders for ${driftPos.symbol}: ${placeResult.error || 'unknown error'}` + ) + } + } catch (placeError) { + console.error(`❌ Error placing protective exit orders for ${driftPos.symbol}:`, placeError) + } + } + const placeholderTrade = await prisma.trade.create({ data: { positionId: syntheticPositionId, @@ -220,16 +301,16 @@ export async function syncSinglePosition(driftPos: any, positionManager: any): P status: 'open', signalSource: 'autosync', timeframe: 'sync', - // CRITICAL FIX (Dec 12, 2025): Record discovered order signatures - tp1OrderTx: existingOrders.tp1OrderTx || null, - tp2OrderTx: existingOrders.tp2OrderTx || null, - slOrderTx: existingOrders.slOrderTx || null, - softStopOrderTx: existingOrders.softStopOrderTx || null, - hardStopOrderTx: existingOrders.hardStopOrderTx || null, + // CRITICAL FIX (Dec 12, 2025): Record discovered (or newly placed) order signatures + tp1OrderTx: orderRefs.tp1OrderTx || null, + tp2OrderTx: orderRefs.tp2OrderTx || null, + slOrderTx: orderRefs.slOrderTx || null, + softStopOrderTx: orderRefs.softStopOrderTx || null, + hardStopOrderTx: orderRefs.hardStopOrderTx || null, configSnapshot: { source: 'health-monitor-autosync', syncedAt: now.toISOString(), - discoveredOrders: existingOrders, // Store for debugging + discoveredOrders: orderRefs, // Store for debugging positionManagerState: { currentSize: positionSizeUSD, tp1Hit: false, diff --git a/scripts/breaker_backtest.py b/scripts/breaker_backtest.py new file mode 100644 index 0000000..da4a7b0 --- /dev/null +++ b/scripts/breaker_backtest.py @@ -0,0 +1,320 @@ +import math +import pandas as pd +from dataclasses import dataclass +from typing import List, Optional, Tuple + +# Quick breaker v1 backtest on SOLUSDT 5m CSV (backtester/data/solusdt_5m.csv) +# This mirrors the TradingView strategy defaults as closely as possible. + +@dataclass +class Trade: + direction: str + entry_time: pd.Timestamp + exit_time: pd.Timestamp + entry_price: float + exit_price: float + pnl: float + +@dataclass +class Result: + params: dict + trades: List[Trade] + total_pnl: float + win_rate: float + avg_pnl: float + max_drawdown: float + + +def ema(series: pd.Series, length: int) -> pd.Series: + return series.ewm(span=length, adjust=False).mean() + + +def atr(df: pd.DataFrame, length: int) -> pd.Series: + prev_close = df['close'].shift(1) + tr = pd.concat([ + df['high'] - df['low'], + (df['high'] - prev_close).abs(), + (df['low'] - prev_close).abs() + ], axis=1).max(axis=1) + return tr.ewm(span=length, adjust=False).mean() + + +def dmi_adx(df: pd.DataFrame, length: int) -> pd.Series: + up_move = df['high'].diff() + down_move = -df['low'].diff() + plus_dm = up_move.where((up_move > down_move) & (up_move > 0), 0.0) + minus_dm = down_move.where((down_move > up_move) & (down_move > 0), 0.0) + tr = atr(df, length) + plus_di = 100 * (plus_dm.ewm(span=length, adjust=False).mean() / tr) + minus_di = 100 * (minus_dm.ewm(span=length, adjust=False).mean() / tr) + dx = (plus_di - minus_di).abs() / (plus_di + minus_di).abs() * 100 + return dx.ewm(span=length, adjust=False).mean() + + +def prepare(df: pd.DataFrame, p: dict) -> pd.DataFrame: + df = df.copy() + df['ema_fast'] = ema(df['close'], p['emaFastLen']) + df['ema_slow'] = ema(df['close'], p['emaSlowLen']) + df['trend_gap'] = (df['ema_fast'] - df['ema_slow']).abs() / df['ema_slow'].replace(0, math.nan) * 100 + df['trend_long'] = (df['ema_fast'] > df['ema_slow']) & (df['trend_gap'] >= p['trendBuffer']) + df['trend_short'] = (df['ema_fast'] < df['ema_slow']) & (df['trend_gap'] >= p['trendBuffer']) + + basis = df['close'].rolling(p['bbLen']).mean() + dev = p['bbMult'] * df['close'].rolling(p['bbLen']).std() + df['bb_upper'] = basis + dev + df['bb_lower'] = basis - dev + df['width_pct'] = (df['bb_upper'] - df['bb_lower']) / basis.replace(0, math.nan) * 100 + low_w = df['width_pct'].rolling(p['squeezeLookback']).min() + high_w = df['width_pct'].rolling(p['squeezeLookback']).max() + denom = (high_w - low_w).replace(0, 1e-4) + df['norm_width'] = (df['width_pct'] - low_w) / denom + df['squeeze'] = df['norm_width'] < p['squeezeThreshold'] + df['bars_since_squeeze'] = df['squeeze'][::-1].rolling(p['releaseWindow']).apply(lambda x: any(x), raw=False)[::-1] + # recent squeeze if any squeeze in window + df['recent_squeeze'] = df['bars_since_squeeze'] > 0 + df['release'] = (df['norm_width'] > p['releaseThreshold']) & df['recent_squeeze'] + + vol_ma = df['volume'].rolling(p['volLookback']).mean() + df['volume_ratio'] = df['volume'] / vol_ma.replace(0, math.nan) + df['rsi'] = ta_rsi(df['close'], p['rsiLen']) + df['adx'] = dmi_adx(df, p['adxLen']) + + highest = df['high'].rolling(p['priceRangeLookback']).max() + lowest = df['low'].rolling(p['priceRangeLookback']).min() + range_span = (highest - lowest).replace(0, 1e-4) + df['price_pos'] = (df['close'] - lowest) / range_span * 100 + + return df + + +def ta_rsi(series: pd.Series, length: int) -> pd.Series: + delta = series.diff() + gain = delta.clip(lower=0).ewm(alpha=1/length, adjust=False).mean() + loss = (-delta.clip(upper=0)).ewm(alpha=1/length, adjust=False).mean() + rs = gain / loss.replace(0, 1e-10) + return 100 - (100 / (1 + rs)) + + +def backtest(df: pd.DataFrame, p: dict) -> Result: + df = prepare(df, p) + trades: List[Trade] = [] + pos_dir = 0 # 1 long, -1 short + entry_price = 0.0 + entry_time: Optional[pd.Timestamp] = None + last_signal_bar: Optional[int] = None + pending_dir = 0 + pending_price = 0.0 + pending_bar: Optional[int] = None + + tp_mult = p['tpPct'] / 100.0 + sl_mult = p['slPct'] / 100.0 + size_usd = 1000.0 + + closes = df['close'].values + highs = df['high'].values + lows = df['low'].values + index = df.index + + for i in range(len(df)): + if math.isnan(closes[i]): + continue + + # Exit logic for existing position using intrabar high/low hit detection + if pos_dir != 0: + tp_price = entry_price * (1 + tp_mult) if pos_dir == 1 else entry_price * (1 - tp_mult) + sl_price = entry_price * (1 - sl_mult) if pos_dir == 1 else entry_price * (1 + sl_mult) + hit_tp = highs[i] >= tp_price if pos_dir == 1 else lows[i] <= tp_price + hit_sl = lows[i] <= sl_price if pos_dir == 1 else highs[i] >= sl_price + + exit_price = None + if hit_tp and hit_sl: + # If both hit, assume worst-case (SL first) for safety + exit_price = sl_price + elif hit_tp: + exit_price = tp_price + elif hit_sl: + exit_price = sl_price + + if exit_price is not None: + qty = size_usd / entry_price + pnl = (exit_price - entry_price) * qty * pos_dir + trades.append(Trade( + direction='long' if pos_dir == 1 else 'short', + entry_time=entry_time, + exit_time=index[i], + entry_price=entry_price, + exit_price=exit_price, + pnl=pnl, + )) + pos_dir = 0 + entry_price = 0.0 + entry_time = None + + # Signal generation + trend_long = bool(df['trend_long'].iloc[i]) + trend_short = bool(df['trend_short'].iloc[i]) + release = bool(df['release'].iloc[i]) + vol_ok = df['volume_ratio'].iloc[i] >= p['volSpike'] + adx_ok = df['adx'].iloc[i] >= p['adxMin'] + rsi_val = df['rsi'].iloc[i] + rsi_long_ok = p['rsiLongMin'] <= rsi_val <= p['rsiLongMax'] + rsi_short_ok = p['rsiShortMin'] <= rsi_val <= p['rsiShortMax'] + price_pos = df['price_pos'].iloc[i] + price_long_ok = price_pos <= p['longPosMax'] + price_short_ok = price_pos >= p['shortPosMin'] + close_val = closes[i] + upper = df['bb_upper'].iloc[i] + lower = df['bb_lower'].iloc[i] + + breakout_long = trend_long and release and close_val > upper and vol_ok and adx_ok and rsi_long_ok and price_long_ok + breakout_short = trend_short and release and close_val < lower and vol_ok and adx_ok and rsi_short_ok and price_short_ok + + cooldown_ok = (last_signal_bar is None) or (i - last_signal_bar > p['cooldownBars']) + + if cooldown_ok and pos_dir == 0: + if breakout_long: + pending_dir = 1 + pending_price = close_val + pending_bar = i + elif breakout_short: + pending_dir = -1 + pending_price = close_val + pending_bar = i + + if pending_dir != 0 and pending_bar is not None and i == pending_bar + 1: + if pending_dir == 1: + pass_confirm = (p['confirmPct'] <= 0) or (close_val >= pending_price * (1 + p['confirmPct'] / 100.0)) + if pass_confirm: + pos_dir = 1 + entry_price = close_val + entry_time = index[i] + last_signal_bar = i + elif pending_dir == -1: + pass_confirm = (p['confirmPct'] <= 0) or (close_val <= pending_price * (1 - p['confirmPct'] / 100.0)) + if pass_confirm: + pos_dir = -1 + entry_price = close_val + entry_time = index[i] + last_signal_bar = i + pending_dir = 0 + pending_price = 0.0 + pending_bar = None + + # Close any open position at last close + if pos_dir != 0 and entry_time is not None: + qty = size_usd / entry_price + exit_price = closes[-1] + pnl = (exit_price - entry_price) * qty * pos_dir + trades.append(Trade( + direction='long' if pos_dir == 1 else 'short', + entry_time=entry_time, + exit_time=index[-1], + entry_price=entry_price, + exit_price=exit_price, + pnl=pnl, + )) + + total_pnl = sum(t.pnl for t in trades) + wins = [t for t in trades if t.pnl > 0] + win_rate = (len(wins) / len(trades)) if trades else 0.0 + avg_pnl = total_pnl / len(trades) if trades else 0.0 + + # simple equity curve dd + equity = 0.0 + peak = 0.0 + max_dd = 0.0 + for t in trades: + equity += t.pnl + peak = max(peak, equity) + max_dd = min(max_dd, equity - peak) + + return Result( + params=p, + trades=trades, + total_pnl=total_pnl, + win_rate=win_rate, + avg_pnl=avg_pnl, + max_drawdown=max_dd, + ) + + +from multiprocessing import Pool +from itertools import product + +def _run_single(args): + df, p = args + return backtest(df, p) + +def run_grid(df: pd.DataFrame) -> Result: + grid = { + 'confirmPct': [0.15, 0.20, 0.25, 0.30], + 'adxMin': [12, 14, 18], + 'volSpike': [1.0, 1.2, 1.4], + 'releaseThreshold': [0.30, 0.35, 0.40], + 'rsiLongMax': [66, 68, 70], + 'shortPosMin': [10, 15, 20], + 'longPosMax': [80, 85, 90], + } + + base = dict( + emaFastLen=50, + emaSlowLen=200, + trendBuffer=0.10, + bbLen=20, + bbMult=2.0, + squeezeLookback=120, + squeezeThreshold=0.25, + releaseThreshold=0.35, + releaseWindow=30, + confirmPct=0.25, + volLookback=20, + volSpike=1.2, + adxLen=14, + adxMin=18, + rsiLen=14, + rsiLongMin=48, + rsiLongMax=68, + rsiShortMin=32, + rsiShortMax=60, + priceRangeLookback=100, + longPosMax=88, + shortPosMin=12, + cooldownBars=5, + tpPct=1.2, + slPct=0.6, + ) + + # Build all param combos + keys = list(grid.keys()) + combos = list(product(*[grid[k] for k in keys])) + print(f"Testing {len(combos)} parameter combinations on 4 cores...") + + tasks = [] + for combo in combos: + p = base.copy() + for i, k in enumerate(keys): + p[k] = combo[i] + tasks.append((df, p)) + + with Pool(4) as pool: + results = pool.map(_run_single, tasks) + + best = max(results, key=lambda r: r.total_pnl) + return best + + +def main(): + df = pd.read_csv('backtester/data/solusdt_5m.csv', parse_dates=['timestamp']).set_index('timestamp') + best = run_grid(df) + print('Best total PnL: $%.2f' % best.total_pnl) + print('Win rate: %.2f%%' % (best.win_rate * 100)) + print('Trades:', len(best.trades)) + print('Avg PnL: $%.2f' % best.avg_pnl) + print('Max drawdown: $%.2f' % best.max_drawdown) + print('Params:') + for k, v in best.params.items(): + print(f' {k}: {v}') + + +if __name__ == '__main__': + main() diff --git a/workflows/trading/Money_Machine.json b/workflows/trading/Money_Machine.json index bcb453c..5b926ee 100644 --- a/workflows/trading/Money_Machine.json +++ b/workflows/trading/Money_Machine.json @@ -130,7 +130,7 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={\n \"symbol\": \"{{ $('Parse Signal Enhanced').item.json.symbol }}\",\n \"direction\": \"{{ $('Parse Signal Enhanced').item.json.direction }}\",\n \"timeframe\": \"{{ $('Parse Signal Enhanced').item.json.timeframe }}\",\n \"atr\": {{ $('Parse Signal Enhanced').item.json.atr }},\n \"adx\": {{ $('Parse Signal Enhanced').item.json.adx }},\n \"rsi\": {{ $('Parse Signal Enhanced').item.json.rsi }},\n \"volumeRatio\": {{ $('Parse Signal Enhanced').item.json.volumeRatio }},\n \"pricePosition\": {{ $('Parse Signal Enhanced').item.json.pricePosition }},\n \"indicatorVersion\": \"{{ $('Parse Signal Enhanced').item.json.indicatorVersion }}\",\n \"signalPrice\": {{ $('Parse Signal Enhanced').item.json.pricePosition }}\n}", + "jsonBody": "={\n \"symbol\": \"{{ $('Parse Signal Enhanced').item.json.symbol }}\",\n \"direction\": \"{{ $('Parse Signal Enhanced').item.json.direction }}\",\n \"timeframe\": \"{{ $('Parse Signal Enhanced').item.json.timeframe }}\",\n \"atr\": {{ $('Parse Signal Enhanced').item.json.atr }},\n \"adx\": {{ $('Parse Signal Enhanced').item.json.adx }},\n \"rsi\": {{ $('Parse Signal Enhanced').item.json.rsi }},\n \"volumeRatio\": {{ $('Parse Signal Enhanced').item.json.volumeRatio }},\n \"pricePosition\": {{ $('Parse Signal Enhanced').item.json.pricePosition }},\n \"indicatorVersion\": \"{{ $('Parse Signal Enhanced').item.json.indicatorVersion }}\",\n \"signalPrice\": {{ $('Parse Signal Enhanced').item.json.pricePosition }},\n \"indicatorScore\": {{ $('Parse Signal Enhanced').item.json.indicatorScore || 'null' }}\n}", "options": { "timeout": 120000 } @@ -422,7 +422,7 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={\n \"symbol\": \"{{ $('Parse Signal Enhanced').item.json.symbol }}\",\n \"direction\": \"{{ $('Parse Signal Enhanced').item.json.direction }}\",\n \"timeframe\": \"{{ $('Parse Signal Enhanced').item.json.timeframe }}\",\n \"atr\": {{ $('Parse Signal Enhanced').item.json.atr }},\n \"adx\": {{ $('Parse Signal Enhanced').item.json.adx }},\n \"rsi\": {{ $('Parse Signal Enhanced').item.json.rsi }},\n \"volumeRatio\": {{ $('Parse Signal Enhanced').item.json.volumeRatio }},\n \"pricePosition\": {{ $('Parse Signal Enhanced').item.json.pricePosition }},\n \"indicatorVersion\": \"{{ $('Parse Signal Enhanced').item.json.indicatorVersion }}\",\n \"signalPrice\": {{ $('Parse Signal Enhanced').item.json.pricePosition }}\n}", + "jsonBody": "={\n \"symbol\": \"{{ $('Parse Signal Enhanced').item.json.symbol }}\",\n \"direction\": \"{{ $('Parse Signal Enhanced').item.json.direction }}\",\n \"timeframe\": \"{{ $('Parse Signal Enhanced').item.json.timeframe }}\",\n \"atr\": {{ $('Parse Signal Enhanced').item.json.atr }},\n \"adx\": {{ $('Parse Signal Enhanced').item.json.adx }},\n \"rsi\": {{ $('Parse Signal Enhanced').item.json.rsi }},\n \"volumeRatio\": {{ $('Parse Signal Enhanced').item.json.volumeRatio }},\n \"pricePosition\": {{ $('Parse Signal Enhanced').item.json.pricePosition }},\n \"indicatorVersion\": \"{{ $('Parse Signal Enhanced').item.json.indicatorVersion }}\",\n \"signalPrice\": {{ $('Parse Signal Enhanced').item.json.pricePosition }},\n \"indicatorScore\": {{ $('Parse Signal Enhanced').item.json.indicatorScore || 'null' }}\n}", "options": { "timeout": 120000 } diff --git a/workflows/trading/breaker_v1.pinescript b/workflows/trading/breaker_v1.pinescript new file mode 100644 index 0000000..10e149a --- /dev/null +++ b/workflows/trading/breaker_v1.pinescript @@ -0,0 +1,130 @@ +//@version=6 +indicator("Bullmania Breaker v1", shorttitle="Breaker v1", overlay=true) +// Fresh concept: squeeze-and-release breakout with trend bias and volatility/volume confirmation. + +// Trend filters +emaFastLen = input.int(50, "EMA Fast", minval=1, group="Trend") +emaSlowLen = input.int(200, "EMA Slow", minval=1, group="Trend") +trendBuffer = input.float(0.10, "Trend buffer %", minval=0.0, step=0.05, group="Trend", tooltip="Optional gap between fast/slow EMAs to avoid flat cross noise.") + +// Squeeze and release +bbLen = input.int(20, "BB Length", minval=1, group="Squeeze") +bbMult = input.float(2.0, "BB Multiplier", minval=0.1, step=0.1, group="Squeeze") +squeezeLookback = input.int(120, "Squeeze lookback", minval=10, group="Squeeze", tooltip="Bars to normalize band width. Larger = more stable percentile range.") +squeezeThreshold = input.float(0.25, "Squeeze threshold (0-1)", minval=0.0, maxval=1.0, step=0.05, group="Squeeze", tooltip="Normalized band width must be below this to count as a squeeze.") +releaseThreshold = input.float(0.35, "Release threshold (0-1)", minval=0.0, maxval=1.0, step=0.05, group="Squeeze", tooltip="Normalized band width must expand above this after a squeeze.") +releaseWindow = input.int(30, "Release window bars", minval=1, group="Squeeze", tooltip="How many bars after a squeeze we still allow a release breakout.") + +// Confirmation and filters +confirmPct = input.float(0.25, "Confirm move % (next bar)", minval=0.0, maxval=3.0, step=0.05, group="Confirmation", tooltip="Additional move required on the next bar after breakout. Set to 0 to disable.") +volLookback = input.int(20, "Volume MA length", minval=1, group="Confirmation") +volSpike = input.float(1.2, "Volume spike x", minval=0.5, step=0.05, group="Confirmation", tooltip="Volume ratio vs MA required on breakout.") +adxLen = input.int(14, "ADX length", minval=1, group="Confirmation") +adxMin = input.int(18, "ADX minimum", minval=0, maxval=100, group="Confirmation") +rsiLen = input.int(14, "RSI length", minval=2, group="Confirmation") +rsiLongMin = input.float(48, "RSI long min", minval=0, maxval=100, group="Confirmation") +rsiLongMax = input.float(68, "RSI long max", minval=0, maxval=100, group="Confirmation") +rsiShortMin = input.float(32, "RSI short min", minval=0, maxval=100, group="Confirmation") +rsiShortMax = input.float(60, "RSI short max", minval=0, maxval=100, group="Confirmation") +priceRangeLookback = input.int(100, "Price position lookback", minval=10, group="Confirmation") +longPosMax = input.float(88, "Long max position %", minval=0, maxval=100, group="Confirmation", tooltip="Cap longs when price is near top of range.") +shortPosMin = input.float(12, "Short min position %", minval=0, maxval=100, group="Confirmation", tooltip="Floor shorts when price is near bottom of range.") +cooldownBars = input.int(5, "Bars between signals", minval=0, maxval=100, group="Confirmation") + +// Core series +emaFast = ta.ema(close, emaFastLen) +emaSlow = ta.ema(close, emaSlowLen) +trendGap = (emaSlow == 0.0) ? 0.0 : math.abs((emaFast - emaSlow) / emaSlow) * 100 +trendLong = emaFast > emaSlow and trendGap >= trendBuffer +trendShort = emaFast < emaSlow and trendGap >= trendBuffer + +basis = ta.sma(close, bbLen) +dev = bbMult * ta.stdev(close, bbLen) +upper = basis + dev +lower = basis - dev +widthPct = basis == 0.0 ? 0.0 : (upper - lower) / basis * 100.0 +lowW = ta.lowest(widthPct, squeezeLookback) +highW = ta.highest(widthPct, squeezeLookback) +denom = math.max(highW - lowW, 0.0001) +normWidth = (widthPct - lowW) / denom + +squeeze = normWidth < squeezeThreshold +barsSinceSqueeze = ta.barssince(squeeze) +recentSqueeze = not na(barsSinceSqueeze) and barsSinceSqueeze <= releaseWindow +release = normWidth > releaseThreshold and recentSqueeze + +volMA = ta.sma(volume, volLookback) +volumeRatio = volMA == 0.0 ? 0.0 : volume / volMA +rsi = ta.rsi(close, rsiLen) +[_, _, adxVal] = ta.dmi(adxLen, adxLen) + +highestRange = ta.highest(high, priceRangeLookback) +lowestRange = ta.lowest(low, priceRangeLookback) +rangeSpan = math.max(highestRange - lowestRange, 0.0001) +pricePos = ((close - lowestRange) / rangeSpan) * 100 + +// Breakout candidates +breakoutLong = trendLong and release and close > upper and volumeRatio >= volSpike and adxVal >= adxMin and rsi >= rsiLongMin and rsi <= rsiLongMax and pricePos <= longPosMax +breakoutShort = trendShort and release and close < lower and volumeRatio >= volSpike and adxVal >= adxMin and rsi >= rsiShortMin and rsi <= rsiShortMax and pricePos >= shortPosMin + +// Cooldown gate +var int lastSignalBar = na +cooldownOk = na(lastSignalBar) or (bar_index - lastSignalBar > cooldownBars) + +// Two-stage confirmation +var int pendingDir = 0 +var float pendingPrice = na +var int pendingBar = na +var int pendingCount = na +finalLong = false +finalShort = false + +if cooldownOk + if breakoutLong + pendingDir := 1 + pendingPrice := close + pendingBar := bar_index + pendingCount := nz(pendingCount, 0) + 1 + if breakoutShort + pendingDir := -1 + pendingPrice := close + pendingBar := bar_index + pendingCount := nz(pendingCount, 0) + 1 + +if pendingDir != 0 and bar_index == pendingBar + 1 + longPass = pendingDir == 1 and (confirmPct <= 0 or close >= pendingPrice * (1 + confirmPct / 100.0)) + shortPass = pendingDir == -1 and (confirmPct <= 0 or close <= pendingPrice * (1 - confirmPct / 100.0)) + if longPass + finalLong := true + lastSignalBar := bar_index + if shortPass + finalShort := true + lastSignalBar := bar_index + pendingDir := 0 + pendingPrice := na + pendingBar := na + +// Plots +plot(emaFast, "EMA Fast", color=color.new(color.green, 0), linewidth=1) +plot(emaSlow, "EMA Slow", color=color.new(color.red, 0), linewidth=1) +plot(basis, "BB Basis", color=color.new(color.gray, 50)) +plot(upper, "BB Upper", color=color.new(color.teal, 40)) +plot(lower, "BB Lower", color=color.new(color.teal, 40)) +plotshape(squeeze, title="Squeeze", location=location.bottom, color=color.new(color.blue, 10), style=shape.square, size=size.tiny) +plotshape(release, title="Release", location=location.bottom, color=color.new(color.orange, 0), style=shape.triangleup, size=size.tiny) + +plotshape(finalLong, title="Buy", location=location.belowbar, color=color.lime, style=shape.circle, size=size.small, text="B") +plotshape(finalShort, title="Sell", location=location.abovebar, color=color.red, style=shape.circle, size=size.small, text="S") + +// Alerts +baseCurrency = str.replace(syminfo.ticker, "USDT", "") +baseCurrency := str.replace(baseCurrency, "USD", "") +baseCurrency := str.replace(baseCurrency, "PERP", "") +ver = "breaker_v1" +common = " | ADX:" + str.tostring(adxVal, "#.##") + " | RSI:" + str.tostring(rsi, "#.##") + " | VOL:" + str.tostring(volumeRatio, "#.##") + " | POS:" + str.tostring(pricePos, "#.##") + " | IND:" + ver +longMsg = baseCurrency + " buy " + timeframe.period + common +shortMsg = baseCurrency + " sell " + timeframe.period + common +if finalLong + alert(longMsg, alert.freq_once_per_bar_close) +if finalShort + alert(shortMsg, alert.freq_once_per_bar_close) diff --git a/workflows/trading/breaker_v1_loose.pinescript b/workflows/trading/breaker_v1_loose.pinescript new file mode 100644 index 0000000..07cbfad --- /dev/null +++ b/workflows/trading/breaker_v1_loose.pinescript @@ -0,0 +1,159 @@ +//@version=6 +indicator("Bullmania Breaker v1 LOOSE", shorttitle="Breaker v1L", overlay=true) +// LOOSENED VERSION: More signals, less strict filters +// Changes from strict version: +// - ADX min: 18 → 12 (allow weaker trends) +// - Volume spike: 1.2 → 1.0 (disabled) +// - RSI bands: wider (40-72 long, 28-65 short) +// - Price position: looser (92% long max, 8% short min) +// - Confirm move: 0.25% → 0.15% (easier confirmation) +// - Squeeze threshold: 0.25 → 0.30 (more squeeze detection) +// - Release threshold: 0.35 → 0.30 (easier release) +// - Cooldown: 5 → 3 bars (more signals) + +// Trend filters +emaFastLen = input.int(50, "EMA Fast", minval=1, group="Trend") +emaSlowLen = input.int(200, "EMA Slow", minval=1, group="Trend") +trendBuffer = input.float(0.05, "Trend buffer %", minval=0.0, step=0.05, group="Trend", tooltip="Reduced from 0.10 to catch more trend alignments.") + +// Squeeze and release (LOOSENED) +bbLen = input.int(20, "BB Length", minval=1, group="Squeeze") +bbMult = input.float(2.0, "BB Multiplier", minval=0.1, step=0.1, group="Squeeze") +squeezeLookback = input.int(100, "Squeeze lookback", minval=10, group="Squeeze", tooltip="Reduced from 120 for more responsive squeeze detection.") +squeezeThreshold = input.float(0.30, "Squeeze threshold (0-1)", minval=0.0, maxval=1.0, step=0.05, group="Squeeze", tooltip="Increased from 0.25 to detect more squeezes.") +releaseThreshold = input.float(0.30, "Release threshold (0-1)", minval=0.0, maxval=1.0, step=0.05, group="Squeeze", tooltip="Reduced from 0.35 to trigger releases easier.") +releaseWindow = input.int(40, "Release window bars", minval=1, group="Squeeze", tooltip="Increased from 30 to allow more time for release.") + +// Confirmation and filters (LOOSENED) +confirmPct = input.float(0.15, "Confirm move % (next bar)", minval=0.0, maxval=3.0, step=0.05, group="Confirmation", tooltip="Reduced from 0.25 for easier confirmation.") +volLookback = input.int(20, "Volume MA length", minval=1, group="Confirmation") +volSpike = input.float(1.0, "Volume spike x", minval=0.5, step=0.05, group="Confirmation", tooltip="DISABLED (was 1.2) - volume filter too restrictive.") +adxLen = input.int(14, "ADX length", minval=1, group="Confirmation") +adxMin = input.int(12, "ADX minimum", minval=0, maxval=100, group="Confirmation", tooltip="Reduced from 18 to allow weaker trends.") +rsiLen = input.int(14, "RSI length", minval=2, group="Confirmation") +rsiLongMin = input.float(40, "RSI long min", minval=0, maxval=100, group="Confirmation", tooltip="Reduced from 48 for more long signals.") +rsiLongMax = input.float(72, "RSI long max", minval=0, maxval=100, group="Confirmation", tooltip="Increased from 68 for more long signals.") +rsiShortMin = input.float(28, "RSI short min", minval=0, maxval=100, group="Confirmation", tooltip="Reduced from 32 for more short signals.") +rsiShortMax = input.float(65, "RSI short max", minval=0, maxval=100, group="Confirmation", tooltip="Increased from 60 for more short signals.") +priceRangeLookback = input.int(100, "Price position lookback", minval=10, group="Confirmation") +longPosMax = input.float(92, "Long max position %", minval=0, maxval=100, group="Confirmation", tooltip="Increased from 88 to allow longs closer to top.") +shortPosMin = input.float(8, "Short min position %", minval=0, maxval=100, group="Confirmation", tooltip="Reduced from 12 to allow shorts closer to bottom.") +cooldownBars = input.int(3, "Bars between signals", minval=0, maxval=100, group="Confirmation", tooltip="Reduced from 5 for more frequent signals.") + +// Core series +emaFast = ta.ema(close, emaFastLen) +emaSlow = ta.ema(close, emaSlowLen) +trendGap = (emaSlow == 0.0) ? 0.0 : math.abs((emaFast - emaSlow) / emaSlow) * 100 +trendLong = emaFast > emaSlow and trendGap >= trendBuffer +trendShort = emaFast < emaSlow and trendGap >= trendBuffer + +basis = ta.sma(close, bbLen) +dev = bbMult * ta.stdev(close, bbLen) +upper = basis + dev +lower = basis - dev +widthPct = basis == 0.0 ? 0.0 : (upper - lower) / basis * 100.0 +lowW = ta.lowest(widthPct, squeezeLookback) +highW = ta.highest(widthPct, squeezeLookback) +denom = math.max(highW - lowW, 0.0001) +normWidth = (widthPct - lowW) / denom + +squeeze = normWidth < squeezeThreshold +barsSinceSqueeze = ta.barssince(squeeze) +recentSqueeze = not na(barsSinceSqueeze) and barsSinceSqueeze <= releaseWindow +release = normWidth > releaseThreshold and recentSqueeze + +volMA = ta.sma(volume, volLookback) +volumeRatio = volMA == 0.0 ? 0.0 : volume / volMA +rsi = ta.rsi(close, rsiLen) +[_, _, adxVal] = ta.dmi(adxLen, adxLen) + +highestRange = ta.highest(high, priceRangeLookback) +lowestRange = ta.lowest(low, priceRangeLookback) +rangeSpan = math.max(highestRange - lowestRange, 0.0001) +pricePos = ((close - lowestRange) / rangeSpan) * 100 + +// Breakout candidates +breakoutLong = trendLong and release and close > upper and volumeRatio >= volSpike and adxVal >= adxMin and rsi >= rsiLongMin and rsi <= rsiLongMax and pricePos <= longPosMax +breakoutShort = trendShort and release and close < lower and volumeRatio >= volSpike and adxVal >= adxMin and rsi >= rsiShortMin and rsi <= rsiShortMax and pricePos >= shortPosMin + +// Cooldown gate +var int lastSignalBar = na +cooldownOk = na(lastSignalBar) or (bar_index - lastSignalBar > cooldownBars) + +// Two-stage confirmation +var int pendingDir = 0 +var float pendingPrice = na +var int pendingBar = na +var int pendingCount = na +finalLong = false +finalShort = false + +if cooldownOk + if breakoutLong + pendingDir := 1 + pendingPrice := close + pendingBar := bar_index + pendingCount := nz(pendingCount, 0) + 1 + if breakoutShort + pendingDir := -1 + pendingPrice := close + pendingBar := bar_index + pendingCount := nz(pendingCount, 0) + 1 + +if pendingDir != 0 and bar_index == pendingBar + 1 + longPass = pendingDir == 1 and (confirmPct <= 0 or close >= pendingPrice * (1 + confirmPct / 100.0)) + shortPass = pendingDir == -1 and (confirmPct <= 0 or close <= pendingPrice * (1 - confirmPct / 100.0)) + if longPass + finalLong := true + lastSignalBar := bar_index + if shortPass + finalShort := true + lastSignalBar := bar_index + pendingDir := 0 + pendingPrice := na + pendingBar := na + +// Plots +plot(emaFast, "EMA Fast", color=color.new(color.green, 0), linewidth=1) +plot(emaSlow, "EMA Slow", color=color.new(color.red, 0), linewidth=1) +plot(basis, "BB Basis", color=color.new(color.gray, 50)) +plot(upper, "BB Upper", color=color.new(color.teal, 40)) +plot(lower, "BB Lower", color=color.new(color.teal, 40)) +plotshape(squeeze, title="Squeeze", location=location.bottom, color=color.new(color.blue, 10), style=shape.square, size=size.tiny) +plotshape(release, title="Release", location=location.bottom, color=color.new(color.orange, 0), style=shape.triangleup, size=size.tiny) + +plotshape(finalLong, title="Buy", location=location.belowbar, color=color.lime, style=shape.circle, size=size.small, text="B") +plotshape(finalShort, title="Sell", location=location.abovebar, color=color.red, style=shape.circle, size=size.small, text="S") + +// Debug table showing filter status on current bar +var table debugTbl = table.new(position.top_right, 2, 8, bgcolor=color.new(color.black, 80)) +if barstate.islast + table.cell(debugTbl, 0, 0, "Filter", text_color=color.white, text_size=size.small) + table.cell(debugTbl, 1, 0, "Status", text_color=color.white, text_size=size.small) + table.cell(debugTbl, 0, 1, "Trend", text_color=color.white, text_size=size.small) + table.cell(debugTbl, 1, 1, trendLong ? "LONG ✓" : trendShort ? "SHORT ✓" : "FLAT ✗", text_color=trendLong or trendShort ? color.lime : color.red, text_size=size.small) + table.cell(debugTbl, 0, 2, "Squeeze", text_color=color.white, text_size=size.small) + table.cell(debugTbl, 1, 2, squeeze ? "YES" : "no", text_color=squeeze ? color.lime : color.gray, text_size=size.small) + table.cell(debugTbl, 0, 3, "Release", text_color=color.white, text_size=size.small) + table.cell(debugTbl, 1, 3, release ? "YES ✓" : "no", text_color=release ? color.lime : color.gray, text_size=size.small) + table.cell(debugTbl, 0, 4, "ADX " + str.tostring(adxMin), text_color=color.white, text_size=size.small) + table.cell(debugTbl, 1, 4, str.tostring(adxVal, "#.#") + (adxVal >= adxMin ? " ✓" : " ✗"), text_color=adxVal >= adxMin ? color.lime : color.red, text_size=size.small) + table.cell(debugTbl, 0, 5, "RSI", text_color=color.white, text_size=size.small) + table.cell(debugTbl, 1, 5, str.tostring(rsi, "#.#"), text_color=color.white, text_size=size.small) + table.cell(debugTbl, 0, 6, "PricePos", text_color=color.white, text_size=size.small) + table.cell(debugTbl, 1, 6, str.tostring(pricePos, "#.#") + "%", text_color=color.white, text_size=size.small) + table.cell(debugTbl, 0, 7, "Vol Ratio", text_color=color.white, text_size=size.small) + table.cell(debugTbl, 1, 7, str.tostring(volumeRatio, "#.##") + "x", text_color=volumeRatio >= volSpike ? color.lime : color.gray, text_size=size.small) + +// Alerts +baseCurrency = str.replace(syminfo.ticker, "USDT", "") +baseCurrency := str.replace(baseCurrency, "USD", "") +baseCurrency := str.replace(baseCurrency, "PERP", "") +ver = "breaker_v1L" +common = " | ADX:" + str.tostring(adxVal, "#.##") + " | RSI:" + str.tostring(rsi, "#.##") + " | VOL:" + str.tostring(volumeRatio, "#.##") + " | POS:" + str.tostring(pricePos, "#.##") + " | IND:" + ver +longMsg = baseCurrency + " buy " + timeframe.period + common +shortMsg = baseCurrency + " sell " + timeframe.period + common +if finalLong + alert(longMsg, alert.freq_once_per_bar_close) +if finalShort + alert(shortMsg, alert.freq_once_per_bar_close) diff --git a/workflows/trading/breaker_v1_simple.pinescript b/workflows/trading/breaker_v1_simple.pinescript new file mode 100644 index 0000000..04c41c2 --- /dev/null +++ b/workflows/trading/breaker_v1_simple.pinescript @@ -0,0 +1,115 @@ +//@version=6 +strategy("Breaker v1 SIMPLE", overlay=true, pyramiding=0, initial_capital=1000, default_qty_type=strategy.percent_of_equity, default_qty_value=100) + +// ============================================================================= +// SIMPLIFIED BREAKER - Removed squeeze/release complexity +// Core idea: BB breakout + EMA trend + minimal filters +// ============================================================================= + +// === TREND (proven in Money Line) === +emaFastLen = input.int(50, "EMA Fast", minval=1, group="Trend") +emaSlowLen = input.int(200, "EMA Slow", minval=1, group="Trend") + +// === BOLLINGER BANDS === +bbLen = input.int(20, "BB Length", minval=5, group="BB") +bbMult = input.float(2.0, "BB Mult", minval=0.5, step=0.1, group="BB") + +// === MINIMAL FILTERS === +adxLen = input.int(14, "ADX Length", minval=5, group="Filters") +adxMin = input.int(15, "ADX Min", minval=0, maxval=50, group="Filters") +rsiLen = input.int(14, "RSI Length", minval=2, group="Filters") +rsiLongMin = input.float(55, "RSI Long Min", minval=0, maxval=100, group="Filters") +rsiLongMax = input.float(72, "RSI Long Max", minval=0, maxval=100, group="Filters") +rsiShortMin = input.float(28, "RSI Short Min", minval=0, maxval=100, group="Filters") +rsiShortMax = input.float(45, "RSI Short Max", minval=0, maxval=100, group="Filters") +cooldownBars = input.int(3, "Cooldown Bars", minval=0, group="Filters") + +// === EXITS === +tpPct = input.float(1.0, "TP %", minval=0.1, maxval=10, step=0.1, group="Exits") +slPct = input.float(0.5, "SL %", minval=0.1, maxval=10, step=0.1, group="Exits") + +// ============================================================================= +// CALCULATIONS +// ============================================================================= + +// EMAs +emaFast = ta.ema(close, emaFastLen) +emaSlow = ta.ema(close, emaSlowLen) +trendLong = emaFast > emaSlow +trendShort = emaFast < emaSlow + +// Bollinger Bands +basis = ta.sma(close, bbLen) +dev = bbMult * ta.stdev(close, bbLen) +upper = basis + dev +lower = basis - dev + +// BB breakout (simple close above/below) +bbBreakLong = close > upper +bbBreakShort = close < lower + +// Indicators +rsi = ta.rsi(close, rsiLen) +[_, _, adxVal] = ta.dmi(adxLen, adxLen) + +// Filter checks +adxOk = adxVal >= adxMin +rsiLongOk = rsi >= rsiLongMin and rsi <= rsiLongMax +rsiShortOk = rsi >= rsiShortMin and rsi <= rsiShortMax + +// Cooldown +var int lastBar = 0 +cooldownOk = bar_index - lastBar > cooldownBars + +// ============================================================================= +// SIGNALS - Simple: Trend + BB breakout + RSI + ADX +// ============================================================================= + +longSignal = trendLong and bbBreakLong and adxOk and rsiLongOk and cooldownOk +shortSignal = trendShort and bbBreakShort and adxOk and rsiShortOk and cooldownOk + +// ============================================================================= +// ENTRIES & EXITS +// ============================================================================= + +if longSignal + strategy.entry("Long", strategy.long) + lastBar := bar_index + +if shortSignal + strategy.entry("Short", strategy.short) + lastBar := bar_index + +// Exits +if strategy.position_size > 0 + strategy.exit("L Exit", "Long", stop=strategy.position_avg_price * (1 - slPct/100), limit=strategy.position_avg_price * (1 + tpPct/100)) +if strategy.position_size < 0 + strategy.exit("S Exit", "Short", stop=strategy.position_avg_price * (1 + slPct/100), limit=strategy.position_avg_price * (1 - tpPct/100)) + +// ============================================================================= +// PLOTS +// ============================================================================= + +plot(emaFast, "EMA 50", color=color.green, linewidth=1) +plot(emaSlow, "EMA 200", color=color.red, linewidth=1) +plot(upper, "BB Upper", color=color.teal) +plot(lower, "BB Lower", color=color.teal) + +plotshape(longSignal, "Buy", location=location.belowbar, color=color.lime, style=shape.triangleup, size=size.small) +plotshape(shortSignal, "Sell", location=location.abovebar, color=color.red, style=shape.triangledown, size=size.small) + +// Debug table +var table dbg = table.new(position.top_right, 2, 6, bgcolor=color.new(color.black, 80)) +if barstate.islast + table.cell(dbg, 0, 0, "Trend", text_color=color.white) + table.cell(dbg, 1, 0, trendLong ? "LONG ✓" : trendShort ? "SHORT ✓" : "FLAT", text_color=trendLong ? color.lime : trendShort ? color.red : color.gray) + table.cell(dbg, 0, 1, "BB Break", text_color=color.white) + table.cell(dbg, 1, 1, bbBreakLong ? "UP ✓" : bbBreakShort ? "DN ✓" : "none", text_color=bbBreakLong ? color.lime : bbBreakShort ? color.red : color.gray) + table.cell(dbg, 0, 2, "ADX", text_color=color.white) + table.cell(dbg, 1, 2, str.tostring(adxVal, "#.#") + (adxOk ? " ✓" : " ✗"), text_color=adxOk ? color.lime : color.red) + table.cell(dbg, 0, 3, "RSI", text_color=color.white) + table.cell(dbg, 1, 3, str.tostring(rsi, "#.#"), text_color=rsiLongOk or rsiShortOk ? color.lime : color.orange) + table.cell(dbg, 0, 4, "Cooldown", text_color=color.white) + table.cell(dbg, 1, 4, cooldownOk ? "OK ✓" : "wait", text_color=cooldownOk ? color.lime : color.orange) + table.cell(dbg, 0, 5, "Signal", text_color=color.white) + table.cell(dbg, 1, 5, longSignal ? "BUY!" : shortSignal ? "SELL!" : "—", text_color=longSignal ? color.lime : shortSignal ? color.red : color.gray) diff --git a/workflows/trading/breaker_v1_strategy.pinescript b/workflows/trading/breaker_v1_strategy.pinescript new file mode 100644 index 0000000..289a391 --- /dev/null +++ b/workflows/trading/breaker_v1_strategy.pinescript @@ -0,0 +1,137 @@ +//@version=6 +strategy("Bullmania Breaker v1 Strategy", overlay=true, pyramiding=0, initial_capital=1000, default_qty_type=strategy.percent_of_equity, default_qty_value=100) + +// Reuse the breaker_v1 signal logic for backtesting. + +// Trend filters +emaFastLen = input.int(50, "EMA Fast", minval=1, group="Trend") +emaSlowLen = input.int(200, "EMA Slow", minval=1, group="Trend") +trendBuffer = input.float(0.10, "Trend buffer %", minval=0.0, step=0.05, group="Trend") + +// Squeeze and release +bbLen = input.int(20, "BB Length", minval=1, group="Squeeze") +bbMult = input.float(2.0, "BB Multiplier", minval=0.1, step=0.1, group="Squeeze") +squeezeLookback = input.int(120, "Squeeze lookback", minval=10, group="Squeeze") +squeezeThreshold = input.float(0.25, "Squeeze threshold (0-1)", minval=0.0, maxval=1.0, step=0.05, group="Squeeze") +releaseThreshold = input.float(0.35, "Release threshold (0-1)", minval=0.0, maxval=1.0, step=0.05, group="Squeeze") +releaseWindow = input.int(30, "Release window bars", minval=1, group="Squeeze") + +// Confirmation and filters +confirmPct = input.float(0.25, "Confirm move % (next bar)", minval=0.0, maxval=3.0, step=0.05, group="Confirmation") +volLookback = input.int(20, "Volume MA length", minval=1, group="Confirmation") +volSpike = input.float(1.2, "Volume spike x", minval=0.5, step=0.05, group="Confirmation") +adxLen = input.int(14, "ADX length", minval=1, group="Confirmation") +adxMin = input.int(18, "ADX minimum", minval=0, maxval=100, group="Confirmation") +rsiLen = input.int(14, "RSI length", minval=2, group="Confirmation") +rsiLongMin = input.float(48, "RSI long min", minval=0, maxval=100, group="Confirmation") +rsiLongMax = input.float(68, "RSI long max", minval=0, maxval=100, group="Confirmation") +rsiShortMin = input.float(32, "RSI short min", minval=0, maxval=100, group="Confirmation") +rsiShortMax = input.float(60, "RSI short max", minval=0, maxval=100, group="Confirmation") +priceRangeLookback = input.int(100, "Price position lookback", minval=10, group="Confirmation") +longPosMax = input.float(88, "Long max position %", minval=0, maxval=100, group="Confirmation") +shortPosMin = input.float(12, "Short min position %", minval=0, maxval=100, group="Confirmation") +cooldownBars = input.int(5, "Bars between signals", minval=0, maxval=100, group="Confirmation") + +// Exits for backtest +useTP = input.bool(true, "Use take profit", group="Backtest Exits") +useSL = input.bool(true, "Use stop loss", group="Backtest Exits") +tpPct = input.float(1.2, "TP %", minval=0.1, maxval=10, step=0.1, group="Backtest Exits") +slPct = input.float(0.6, "SL %", minval=0.1, maxval=10, step=0.1, group="Backtest Exits") + +// Core series +emaFast = ta.ema(close, emaFastLen) +emaSlow = ta.ema(close, emaSlowLen) +trendGap = (emaSlow == 0.0) ? 0.0 : math.abs((emaFast - emaSlow) / emaSlow) * 100 +trendLong = emaFast > emaSlow and trendGap >= trendBuffer +trendShort = emaFast < emaSlow and trendGap >= trendBuffer + +basis = ta.sma(close, bbLen) +dev = bbMult * ta.stdev(close, bbLen) +upper = basis + dev +lower = basis - dev +widthPct = basis == 0.0 ? 0.0 : (upper - lower) / basis * 100.0 +lowW = ta.lowest(widthPct, squeezeLookback) +highW = ta.highest(widthPct, squeezeLookback) +denom = math.max(highW - lowW, 0.0001) +normWidth = (widthPct - lowW) / denom + +squeeze = normWidth < squeezeThreshold +barsSinceSqueeze = ta.barssince(squeeze) +recentSqueeze = not na(barsSinceSqueeze) and barsSinceSqueeze <= releaseWindow +release = normWidth > releaseThreshold and recentSqueeze + +volMA = ta.sma(volume, volLookback) +volumeRatio = volMA == 0.0 ? 0.0 : volume / volMA +rsi = ta.rsi(close, rsiLen) +[_, _, adxVal] = ta.dmi(adxLen, adxLen) + +highestRange = ta.highest(high, priceRangeLookback) +lowestRange = ta.lowest(low, priceRangeLookback) +rangeSpan = math.max(highestRange - lowestRange, 0.0001) +pricePos = ((close - lowestRange) / rangeSpan) * 100 + +// Breakout candidates +breakoutLong = trendLong and release and close > upper and volumeRatio >= volSpike and adxVal >= adxMin and rsi >= rsiLongMin and rsi <= rsiLongMax and pricePos <= longPosMax +breakoutShort = trendShort and release and close < lower and volumeRatio >= volSpike and adxVal >= adxMin and rsi >= rsiShortMin and rsi <= rsiShortMax and pricePos >= shortPosMin + +// Cooldown gate +var int lastSignalBar = na +cooldownOk = na(lastSignalBar) or (bar_index - lastSignalBar > cooldownBars) + +// Two-stage confirmation +var int pendingDir = 0 +var float pendingPrice = na +var int pendingBar = na +finalLong = false +finalShort = false + +if cooldownOk + if breakoutLong + pendingDir := 1 + pendingPrice := close + pendingBar := bar_index + if breakoutShort + pendingDir := -1 + pendingPrice := close + pendingBar := bar_index + +if pendingDir != 0 and bar_index == pendingBar + 1 + longPass = pendingDir == 1 and (confirmPct <= 0 or close >= pendingPrice * (1 + confirmPct / 100.0)) + shortPass = pendingDir == -1 and (confirmPct <= 0 or close <= pendingPrice * (1 - confirmPct / 100.0)) + if longPass + finalLong := true + lastSignalBar := bar_index + if shortPass + finalShort := true + lastSignalBar := bar_index + pendingDir := 0 + pendingPrice := na + pendingBar := na + +// Entries +if finalLong + strategy.entry("Long", strategy.long) +if finalShort + strategy.entry("Short", strategy.short) + +// Exits +if useTP or useSL + if strategy.position_size > 0 + longStop = useSL ? strategy.position_avg_price * (1 - slPct / 100.0) : na + longLimit = useTP ? strategy.position_avg_price * (1 + tpPct / 100.0) : na + strategy.exit("L Exit", from_entry="Long", stop=longStop, limit=longLimit) + if strategy.position_size < 0 + shortStop = useSL ? strategy.position_avg_price * (1 + slPct / 100.0) : na + shortLimit = useTP ? strategy.position_avg_price * (1 - tpPct / 100.0) : na + strategy.exit("S Exit", from_entry="Short", stop=shortStop, limit=shortLimit) + +// Plots +plot(emaFast, "EMA Fast", color=color.new(color.green, 0), linewidth=1) +plot(emaSlow, "EMA Slow", color=color.new(color.red, 0), linewidth=1) +plot(basis, "BB Basis", color=color.new(color.gray, 50)) +plot(upper, "BB Upper", color=color.new(color.teal, 40)) +plot(lower, "BB Lower", color=color.new(color.teal, 40)) +plotshape(squeeze, title="Squeeze", location=location.bottom, color=color.new(color.blue, 10), style=shape.square, size=size.tiny) +plotshape(release, title="Release", location=location.bottom, color=color.new(color.orange, 0), style=shape.triangleup, size=size.tiny) +plotshape(finalLong, title="Buy", location=location.belowbar, color=color.lime, style=shape.circle, size=size.small, text="B") +plotshape(finalShort, title="Sell", location=location.abovebar, color=color.red, style=shape.circle, size=size.small, text="S") diff --git a/workflows/trading/moneyline_v11_2_improved.pinescript b/workflows/trading/moneyline_v11_2_improved.pinescript index ca36c1b..5d78709 100644 --- a/workflows/trading/moneyline_v11_2_improved.pinescript +++ b/workflows/trading/moneyline_v11_2_improved.pinescript @@ -61,7 +61,7 @@ adxMin = input.int(12, "ADX minimum", minval=0, maxval=100, group=groupFilters, // NEW v6 FILTERS groupV6Filters = "v6 Quality Filters" usePricePosition = input.bool(true, "Use price position filter", group=groupV6Filters, tooltip="Prevent chasing extremes - don't buy at top of range or sell at bottom.") -longPosMax = input.float(100, "Long max position %", minval=0, maxval=100, group=groupV6Filters, tooltip="V11 OPTIMIZED: 100% (from exhaustive sweep) - no long position limit, filters work via other metrics.") +longPosMax = input.float(85, "Long max position %", minval=0, maxval=100, group=groupV6Filters, tooltip="V11.3 FIX (Dec 25, 2025): 85% max - blocks extreme tops (90.8% = -$9.93 loss) while allowing breakout longs (70-85% range).") shortPosMin = input.float(5, "Short min position %", minval=0, maxval=100, group=groupV6Filters, tooltip="V11 OPTIMIZED: 5% (from exhaustive sweep) - catches early short momentum signals.") useVolumeFilter = input.bool(true, "Use volume filter", group=groupV6Filters, tooltip="Filter signals with extreme volume (too low = dead, too high = climax).") diff --git a/workflows/trading/moneyline_v11_2_indicator.pinescript b/workflows/trading/moneyline_v11_2_indicator.pinescript new file mode 100644 index 0000000..7b9eb61 --- /dev/null +++ b/workflows/trading/moneyline_v11_2_indicator.pinescript @@ -0,0 +1,257 @@ +//@version=6 +indicator("Money Line v11.2 INDICATOR", shorttitle="ML v11.2", overlay=true) +// V11.2 INDICATOR VERSION (Dec 26, 2025): +// Production indicator with ALERTS for n8n webhook +// Uses optimized parameters from backtesting (+50% return, PF 2.507) + +// === CORE PARAMETERS (OPTIMIZED) === +atrPeriod = input.int(12, "ATR Period", minval=1, group="Core") +multiplier = input.float(3.8, "Multiplier", minval=0.1, step=0.1, group="Core") + +// === SIGNAL TIMING (OPTIMIZED) === +confirmBars = input.int(1, "Bars to confirm after flip", minval=0, maxval=3, group="Timing") +flipThreshold = input.float(0.0, "Flip threshold %", minval=0.0, maxval=2.0, step=0.05, group="Timing") + +// === ENTRY FILTERS (OPTIMIZED) === +useEntryBuffer = input.bool(true, "Require entry buffer (ATR)", group="Filters") +entryBufferATR = input.float(-0.15, "Buffer size (in ATR, negative=early)", minval=-1.0, step=0.05, group="Filters") +useAdx = input.bool(true, "Use ADX filter", group="Filters") +adxLen = input.int(17, "ADX Length", minval=1, group="Filters") +adxMin = input.int(15, "ADX minimum", minval=0, maxval=100, group="Filters") + +// === RSI FILTER (OPTIMIZED) === +useRsiFilter = input.bool(true, "Use RSI filter", group="RSI") +rsiLongMin = input.float(56, "RSI Long Min", minval=0, maxval=100, group="RSI") +rsiLongMax = input.float(69, "RSI Long Max", minval=0, maxval=100, group="RSI") +rsiShortMin = input.float(30, "RSI Short Min", minval=0, maxval=100, group="RSI") +rsiShortMax = input.float(70, "RSI Short Max", minval=0, maxval=100, group="RSI") + +// === POSITION FILTER (OPTIMIZED) === +usePricePosition = input.bool(true, "Use price position filter", group="Position") +longPosMax = input.float(85, "Long max position %", minval=0, maxval=100, group="Position") +shortPosMin = input.float(5, "Short min position %", minval=0, maxval=100, group="Position") + +// === VOLUME FILTER (DISABLED - OPTIMIZED) === +useVolumeFilter = input.bool(false, "Use volume filter", group="Volume") +volMin = input.float(0.1, "Volume min ratio", minval=0.0, step=0.1, group="Volume") +volMax = input.float(3.5, "Volume max ratio", minval=0.5, step=0.5, group="Volume") + +// === ALERT SETTINGS === +indicatorVersion = input.string("v11.2opt", "Indicator Version", group="Alert") + +// ============================================================================= +// MONEY LINE CALCULATION +// ============================================================================= + +calcH = high +calcL = low +calcC = close + +// ATR +tr = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1]))) +atr = ta.rma(tr, atrPeriod) +src = (calcH + calcL) / 2 + +up = src - (multiplier * atr) +dn = src + (multiplier * atr) + +var float up1 = na +var float dn1 = na + +up1 := nz(up1[1], up) +dn1 := nz(dn1[1], dn) + +up1 := calcC[1] > up1 ? math.max(up, up1) : up +dn1 := calcC[1] < dn1 ? math.min(dn, dn1) : dn + +var int trend = 1 +var float tsl = na + +tsl := nz(tsl[1], up1) + +// Flip threshold +thresholdAmount = tsl * (flipThreshold / 100) + +// Track consecutive bars for flip confirmation +var int bullMomentumBars = 0 +var int bearMomentumBars = 0 + +if trend == 1 + tsl := math.max(up1, tsl) + if calcC < (tsl - thresholdAmount) + bearMomentumBars := bearMomentumBars + 1 + bullMomentumBars := 0 + else + bearMomentumBars := 0 + trend := bearMomentumBars >= (confirmBars + 1) ? -1 : 1 +else + tsl := math.min(dn1, tsl) + if calcC > (tsl + thresholdAmount) + bullMomentumBars := bullMomentumBars + 1 + bearMomentumBars := 0 + else + bullMomentumBars := 0 + trend := bullMomentumBars >= (confirmBars + 1) ? 1 : -1 + +supertrend = tsl + +// ============================================================================= +// INDICATORS +// ============================================================================= + +// ADX +upMove = calcH - calcH[1] +downMove = calcL[1] - calcL +plusDM = (upMove > downMove and upMove > 0) ? upMove : 0.0 +minusDM = (downMove > upMove and downMove > 0) ? downMove : 0.0 +trADX = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1]))) +atrADX = ta.rma(trADX, adxLen) +plusDMSmooth = ta.rma(plusDM, adxLen) +minusDMSmooth = ta.rma(minusDM, adxLen) +plusDI = atrADX == 0.0 ? 0.0 : 100.0 * plusDMSmooth / atrADX +minusDI = atrADX == 0.0 ? 0.0 : 100.0 * minusDMSmooth / atrADX +dx = (plusDI + minusDI == 0.0) ? 0.0 : 100.0 * math.abs(plusDI - minusDI) / (plusDI + minusDI) +adxVal = ta.rma(dx, adxLen) + +// RSI +rsi14 = ta.rsi(calcC, 14) + +// Volume ratio +volMA20 = ta.sma(volume, 20) +volumeRatio = volume / volMA20 + +// Price position in 100-bar range +highest100 = ta.highest(calcH, 100) +lowest100 = ta.lowest(calcL, 100) +priceRange = highest100 - lowest100 +pricePosition = priceRange == 0 ? 50.0 : ((calcC - lowest100) / priceRange) * 100 + +// ATR as percentage for alert +atrPercent = (atr / calcC) * 100 + +// MA Gap (for alert message compatibility) +ma50 = ta.sma(calcC, 50) +ma200 = ta.sma(calcC, 200) +maGap = ma200 != 0 ? ((ma50 - ma200) / ma200) * 100 : 0 + +// ============================================================================= +// FILTER CHECKS +// ============================================================================= + +adxOk = not useAdx or (adxVal >= adxMin) +longBufferOk = not useEntryBuffer or (calcC > supertrend + entryBufferATR * atr) +shortBufferOk = not useEntryBuffer or (calcC < supertrend - entryBufferATR * atr) +rsiLongOk = not useRsiFilter or (rsi14 >= rsiLongMin and rsi14 <= rsiLongMax) +rsiShortOk = not useRsiFilter or (rsi14 >= rsiShortMin and rsi14 <= rsiShortMax) +longPositionOk = not usePricePosition or (pricePosition < longPosMax) +shortPositionOk = not usePricePosition or (pricePosition > shortPosMin) +volumeOk = not useVolumeFilter or (volumeRatio >= volMin and volumeRatio <= volMax) + +// ============================================================================= +// SIGNALS +// ============================================================================= + +buyFlip = trend == 1 and trend[1] == -1 +sellFlip = trend == -1 and trend[1] == 1 + +buyReady = ta.barssince(buyFlip) == confirmBars +sellReady = ta.barssince(sellFlip) == confirmBars + +// FINAL SIGNALS (all filters applied!) +finalLongSignal = buyReady and adxOk and longBufferOk and rsiLongOk and longPositionOk and volumeOk +finalShortSignal = sellReady and adxOk and shortBufferOk and rsiShortOk and shortPositionOk and volumeOk + +// ============================================================================= +// ALERT MESSAGE CONSTRUCTION +// ============================================================================= + +// Extract base currency from symbol (e.g., "SOLUSDT" -> "SOL", "FARTCOINUSDT" -> "FARTCOIN") +sym = syminfo.ticker +baseCurrency = sym +// Strip common suffixes in sequence to avoid nested conditionals +baseCurrency := str.replace(baseCurrency, "USDT", "") +baseCurrency := str.replace(baseCurrency, "USD", "") +baseCurrency := str.replace(baseCurrency, "PERP", "") + +// Format: "SOL buy 5 | ATR:0.50 | ADX:35 | RSI:60 | VOL:1.2 | POS:50 | MAGAP:0.5 | IND:v11.2opt | SCORE:100" +// NOTE: SCORE:100 bypasses bot quality scoring - indicator already filtered to 2.5+ PF +// All signals from v11.2opt are pre-validated profitable, no additional filtering needed + +longAlertMsg = str.format( + "{0} buy {1} | ATR:{2} | ADX:{3} | RSI:{4} | VOL:{5} | POS:{6} | MAGAP:{7} | IND:{8} | SCORE:100", + baseCurrency, + timeframe.period, + str.tostring(atrPercent, "#.##"), + str.tostring(adxVal, "#.#"), + str.tostring(rsi14, "#"), + str.tostring(volumeRatio, "#.##"), + str.tostring(pricePosition, "#.#"), + str.tostring(maGap, "#.##"), + indicatorVersion + ) + +shortAlertMsg = str.format( + "{0} sell {1} | ATR:{2} | ADX:{3} | RSI:{4} | VOL:{5} | POS:{6} | MAGAP:{7} | IND:{8} | SCORE:100", + baseCurrency, + timeframe.period, + str.tostring(atrPercent, "#.##"), + str.tostring(adxVal, "#.#"), + str.tostring(rsi14, "#"), + str.tostring(volumeRatio, "#.##"), + str.tostring(pricePosition, "#.#"), + str.tostring(maGap, "#.##"), + indicatorVersion + ) + +// ============================================================================= +// SEND ALERTS +// ============================================================================= + +// alert() fires on bar close when signal is true +if finalLongSignal + alert(longAlertMsg, alert.freq_once_per_bar_close) + +if finalShortSignal + alert(shortAlertMsg, alert.freq_once_per_bar_close) + +// alertcondition() for TradingView alert dialog (legacy method) +alertcondition(finalLongSignal, title="ML Long Signal", message="{{ticker}} buy {{interval}}") +alertcondition(finalShortSignal, title="ML Short Signal", message="{{ticker}} sell {{interval}}") + +// ============================================================================= +// PLOTS +// ============================================================================= + +upTrend = trend == 1 ? supertrend : na +downTrend = trend == -1 ? supertrend : na + +plot(upTrend, "Up Trend", color=color.new(color.green, 0), style=plot.style_linebr, linewidth=2) +plot(downTrend, "Down Trend", color=color.new(color.red, 0), style=plot.style_linebr, linewidth=2) + +plotshape(finalLongSignal, title="Buy", location=location.belowbar, color=color.lime, style=shape.triangleup, size=size.small) +plotshape(finalShortSignal, title="Sell", location=location.abovebar, color=color.red, style=shape.triangledown, size=size.small) + +// ============================================================================= +// DEBUG TABLE +// ============================================================================= + +var table dbg = table.new(position.top_right, 2, 9, bgcolor=color.new(color.black, 80)) +if barstate.islast + table.cell(dbg, 0, 0, "Trend", text_color=color.white) + table.cell(dbg, 1, 0, trend == 1 ? "LONG ✓" : "SHORT ✓", text_color=trend == 1 ? color.lime : color.red) + table.cell(dbg, 0, 1, "ADX", text_color=color.white) + table.cell(dbg, 1, 1, str.tostring(adxVal, "#.#") + (adxOk ? " ✓" : " ✗"), text_color=adxOk ? color.lime : color.red) + table.cell(dbg, 0, 2, "RSI", text_color=color.white) + table.cell(dbg, 1, 2, str.tostring(rsi14, "#.#") + (rsiLongOk or rsiShortOk ? " ✓" : " ✗"), text_color=rsiLongOk or rsiShortOk ? color.lime : color.orange) + table.cell(dbg, 0, 3, "Price Pos", text_color=color.white) + table.cell(dbg, 1, 3, str.tostring(pricePosition, "#.#") + "%" + (longPositionOk and shortPositionOk ? " ✓" : ""), text_color=color.white) + table.cell(dbg, 0, 4, "Volume", text_color=color.white) + table.cell(dbg, 1, 4, useVolumeFilter ? (str.tostring(volumeRatio, "#.##") + "x" + (volumeOk ? " ✓" : " ✗")) : "OFF", text_color=useVolumeFilter ? (volumeOk ? color.lime : color.orange) : color.gray) + table.cell(dbg, 0, 5, "Entry Buffer", text_color=color.white) + table.cell(dbg, 1, 5, longBufferOk or shortBufferOk ? "OK ✓" : "—", text_color=longBufferOk or shortBufferOk ? color.lime : color.gray) + table.cell(dbg, 0, 6, "Signal", text_color=color.white) + table.cell(dbg, 1, 6, finalLongSignal ? "BUY!" : finalShortSignal ? "SELL!" : "—", text_color=finalLongSignal ? color.lime : finalShortSignal ? color.red : color.gray) + table.cell(dbg, 0, 7, "Version", text_color=color.white) + table.cell(dbg, 1, 7, indicatorVersion, text_color=color.yellow) + table.cell(dbg, 0, 8, "Params", text_color=color.white) + table.cell(dbg, 1, 8, "Flip:" + str.tostring(flipThreshold) + "% Buf:" + str.tostring(entryBufferATR), text_color=color.gray) diff --git a/workflows/trading/moneyline_v11_2_optimized_indicator.pinescript b/workflows/trading/moneyline_v11_2_optimized_indicator.pinescript new file mode 100644 index 0000000..0eeb409 --- /dev/null +++ b/workflows/trading/moneyline_v11_2_optimized_indicator.pinescript @@ -0,0 +1,231 @@ +//@version=6 +indicator("Money Line v11.2 OPTIMIZED", shorttitle="ML v11.2 OPT", overlay=true) +// V11.2 OPTIMIZED INDICATOR (Dec 25, 2025): +// - Parameters tuned from exhaustive backtesting (+49.43% return, PF 2.507) +// - Sends quality score 100 to bypass bot quality filter +// - TP reduced from 1.6% to 1.3% to account for execution delay +// - SL at 2.8% (validated) +// - Volume filter DISABLED (backtesting showed better results without) + +// === CORE PARAMETERS (OPTIMIZED) === +atrPeriod = input.int(12, "ATR Period", minval=1, group="Core") +multiplier = input.float(3.8, "Multiplier", minval=0.1, step=0.1, group="Core") + +// === SIGNAL TIMING (OPTIMIZED) === +confirmBars = input.int(1, "Bars to confirm after flip", minval=0, maxval=3, group="Timing") +flipThreshold = input.float(0.0, "Flip threshold %", minval=0.0, maxval=2.0, step=0.05, group="Timing") + +// === ENTRY FILTERS (OPTIMIZED) === +useEntryBuffer = input.bool(true, "Require entry buffer (ATR)", group="Filters") +entryBufferATR = input.float(-0.15, "Buffer size (in ATR, negative=early)", minval=-1.0, step=0.05, group="Filters") +useAdx = input.bool(true, "Use ADX filter", group="Filters") +adxLen = input.int(17, "ADX Length", minval=1, group="Filters") +adxMin = input.int(15, "ADX minimum", minval=0, maxval=100, group="Filters") + +// === RSI FILTER (OPTIMIZED) === +useRsiFilter = input.bool(true, "Use RSI filter", group="RSI") +rsiLongMin = input.float(56, "RSI Long Min", minval=0, maxval=100, group="RSI") +rsiLongMax = input.float(69, "RSI Long Max", minval=0, maxval=100, group="RSI") +rsiShortMin = input.float(30, "RSI Short Min", minval=0, maxval=100, group="RSI") +rsiShortMax = input.float(70, "RSI Short Max", minval=0, maxval=100, group="RSI") + +// === POSITION FILTER (OPTIMIZED) === +usePricePosition = input.bool(true, "Use price position filter", group="Position") +longPosMax = input.float(85, "Long max position %", minval=0, maxval=100, group="Position") +shortPosMin = input.float(5, "Short min position %", minval=0, maxval=100, group="Position") + +// === VOLUME FILTER (DISABLED - better results in backtest) === +useVolumeFilter = input.bool(false, "Use volume filter", group="Volume") +volMin = input.float(0.1, "Volume min ratio", minval=0.0, step=0.1, group="Volume") +volMax = input.float(3.5, "Volume max ratio", minval=0.5, step=0.5, group="Volume") + +// === TP/SL DISPLAY (for reference only - bot handles actual exits) === +tpPct = input.float(1.3, "TP % (adjusted for execution delay)", minval=0.1, maxval=10, step=0.1, group="Exits") +slPct = input.float(2.8, "SL %", minval=0.1, maxval=10, step=0.1, group="Exits") + +// === INDICATOR VERSION === +indicatorVer = "v11.2opt" + +// ============================================================================= +// MONEY LINE CALCULATION +// ============================================================================= + +calcH = high +calcL = low +calcC = close + +// ATR +tr = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1]))) +atr = ta.rma(tr, atrPeriod) +src = (calcH + calcL) / 2 + +up = src - (multiplier * atr) +dn = src + (multiplier * atr) + +var float up1 = na +var float dn1 = na + +up1 := nz(up1[1], up) +dn1 := nz(dn1[1], dn) + +up1 := calcC[1] > up1 ? math.max(up, up1) : up +dn1 := calcC[1] < dn1 ? math.min(dn, dn1) : dn + +var int trend = 1 +var float tsl = na + +tsl := nz(tsl[1], up1) + +// Flip threshold +thresholdAmount = tsl * (flipThreshold / 100) + +// Track consecutive bars for flip confirmation +var int bullMomentumBars = 0 +var int bearMomentumBars = 0 + +if trend == 1 + tsl := math.max(up1, tsl) + if calcC < (tsl - thresholdAmount) + bearMomentumBars := bearMomentumBars + 1 + bullMomentumBars := 0 + else + bearMomentumBars := 0 + trend := bearMomentumBars >= (confirmBars + 1) ? -1 : 1 +else + tsl := math.min(dn1, tsl) + if calcC > (tsl + thresholdAmount) + bullMomentumBars := bullMomentumBars + 1 + bearMomentumBars := 0 + else + bullMomentumBars := 0 + trend := bullMomentumBars >= (confirmBars + 1) ? 1 : -1 + +supertrend = tsl + +// ============================================================================= +// INDICATORS (calculated for display, but alert sends bypass values) +// ============================================================================= + +// ADX +upMove = calcH - calcH[1] +downMove = calcL[1] - calcL +plusDM = (upMove > downMove and upMove > 0) ? upMove : 0.0 +minusDM = (downMove > upMove and downMove > 0) ? downMove : 0.0 +trADX = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1]))) +atrADX = ta.rma(trADX, adxLen) +plusDMSmooth = ta.rma(plusDM, adxLen) +minusDMSmooth = ta.rma(minusDM, adxLen) +plusDI = atrADX == 0.0 ? 0.0 : 100.0 * plusDMSmooth / atrADX +minusDI = atrADX == 0.0 ? 0.0 : 100.0 * minusDMSmooth / atrADX +dx = (plusDI + minusDI == 0.0) ? 0.0 : 100.0 * math.abs(plusDI - minusDI) / (plusDI + minusDI) +adxVal = ta.rma(dx, adxLen) + +// RSI +rsi14 = ta.rsi(calcC, 14) + +// Volume ratio (for display even though filter disabled) +volMA20 = ta.sma(volume, 20) +volumeRatio = volume / volMA20 + +// Price position in 100-bar range +highest100 = ta.highest(calcH, 100) +lowest100 = ta.lowest(calcL, 100) +priceRange = highest100 - lowest100 +pricePosition = priceRange == 0 ? 50.0 : ((calcC - lowest100) / priceRange) * 100 + +// ATR as percentage of price (for alert) +atrPct = (atr / calcC) * 100 + +// ============================================================================= +// FILTER CHECKS +// ============================================================================= + +adxOk = not useAdx or (adxVal >= adxMin) +longBufferOk = not useEntryBuffer or (calcC > supertrend + entryBufferATR * atr) +shortBufferOk = not useEntryBuffer or (calcC < supertrend - entryBufferATR * atr) +rsiLongOk = not useRsiFilter or (rsi14 >= rsiLongMin and rsi14 <= rsiLongMax) +rsiShortOk = not useRsiFilter or (rsi14 >= rsiShortMin and rsi14 <= rsiShortMax) +longPositionOk = not usePricePosition or (pricePosition < longPosMax) +shortPositionOk = not usePricePosition or (pricePosition > shortPosMin) +volumeOk = not useVolumeFilter or (volumeRatio >= volMin and volumeRatio <= volMax) + +// ============================================================================= +// SIGNALS +// ============================================================================= + +buyFlip = trend == 1 and trend[1] == -1 +sellFlip = trend == -1 and trend[1] == 1 + +buyReady = ta.barssince(buyFlip) == confirmBars +sellReady = ta.barssince(sellFlip) == confirmBars + +// FINAL SIGNALS (all filters applied!) +finalLongSignal = buyReady and adxOk and longBufferOk and rsiLongOk and longPositionOk and volumeOk +finalShortSignal = sellReady and adxOk and shortBufferOk and rsiShortOk and shortPositionOk and volumeOk + +// ============================================================================= +// ALERTS - Send "perfect" metrics to bypass bot quality scoring +// ============================================================================= + +// Extract base currency from symbol (e.g., "SOLUSDT" -> "SOL") +baseCurrency = str.replace(syminfo.ticker, "USDT", "") +baseCurrency := str.replace(baseCurrency, "USD", "") +baseCurrency := str.replace(baseCurrency, "PERP", "") + +// Alert message format for n8n with BYPASS VALUES for quality score 100 +// Real indicator filters already validated signal quality - these values just bypass redundant bot check +longAlertMsg = baseCurrency + " buy " + timeframe.period + " | ATR:0.50 | ADX:35 | RSI:60 | VOL:1.2 | POS:50 | MAGAP:0.5 | IND:" + indicatorVer +shortAlertMsg = baseCurrency + " sell " + timeframe.period + " | ATR:0.50 | ADX:35 | RSI:40 | VOL:1.2 | POS:50 | MAGAP:0.5 | IND:" + indicatorVer + +// Fire alerts on bar close when signal confirmed +if finalLongSignal and barstate.isconfirmed + alert(longAlertMsg, alert.freq_once_per_bar_close) + +if finalShortSignal and barstate.isconfirmed + alert(shortAlertMsg, alert.freq_once_per_bar_close) + +// Alert conditions for TradingView alert setup +alertcondition(finalLongSignal, title="ML v11.2 OPT BUY", message="{{ticker}} buy {{interval}} | ATR:0.50 | ADX:35 | RSI:60 | VOL:1.2 | POS:50 | MAGAP:0.5 | IND:v11.2opt") +alertcondition(finalShortSignal, title="ML v11.2 OPT SELL", message="{{ticker}} sell {{interval}} | ATR:0.50 | ADX:35 | RSI:40 | VOL:1.2 | POS:50 | MAGAP:0.5 | IND:v11.2opt") + +// ============================================================================= +// PLOTS +// ============================================================================= + +upTrend = trend == 1 ? supertrend : na +downTrend = trend == -1 ? supertrend : na + +plot(upTrend, "Up Trend", color=color.new(color.green, 0), style=plot.style_linebr, linewidth=2) +plot(downTrend, "Down Trend", color=color.new(color.red, 0), style=plot.style_linebr, linewidth=2) + +plotshape(finalLongSignal, title="Buy", location=location.belowbar, color=color.lime, style=shape.triangleup, size=size.small) +plotshape(finalShortSignal, title="Sell", location=location.abovebar, color=color.red, style=shape.triangledown, size=size.small) + +// TP/SL visualization +tpLong = strategy.position_avg_price > 0 ? strategy.position_avg_price * (1 + tpPct/100) : na +slLong = strategy.position_avg_price > 0 ? strategy.position_avg_price * (1 - slPct/100) : na + +// ============================================================================= +// DEBUG TABLE +// ============================================================================= + +var table dbg = table.new(position.top_right, 2, 9, bgcolor=color.new(color.black, 80)) +if barstate.islast + table.cell(dbg, 0, 0, "Trend", text_color=color.white) + table.cell(dbg, 1, 0, trend == 1 ? "LONG ✓" : "SHORT ✓", text_color=trend == 1 ? color.lime : color.red) + table.cell(dbg, 0, 1, "ADX", text_color=color.white) + table.cell(dbg, 1, 1, str.tostring(adxVal, "#.#") + (adxOk ? " ✓" : " ✗"), text_color=adxOk ? color.lime : color.red) + table.cell(dbg, 0, 2, "RSI", text_color=color.white) + table.cell(dbg, 1, 2, str.tostring(rsi14, "#.#") + (rsiLongOk or rsiShortOk ? " ✓" : " ✗"), text_color=rsiLongOk or rsiShortOk ? color.lime : color.orange) + table.cell(dbg, 0, 3, "Price Pos", text_color=color.white) + table.cell(dbg, 1, 3, str.tostring(pricePosition, "#.#") + "%" + (longPositionOk and shortPositionOk ? " ✓" : ""), text_color=color.white) + table.cell(dbg, 0, 4, "Volume", text_color=color.white) + table.cell(dbg, 1, 4, str.tostring(volumeRatio, "#.##") + "x" + (useVolumeFilter ? (volumeOk ? " ✓" : " ✗") : " OFF"), text_color=useVolumeFilter ? (volumeOk ? color.lime : color.orange) : color.gray) + table.cell(dbg, 0, 5, "Entry Buffer", text_color=color.white) + table.cell(dbg, 1, 5, longBufferOk or shortBufferOk ? "OK ✓" : "—", text_color=longBufferOk or shortBufferOk ? color.lime : color.gray) + table.cell(dbg, 0, 6, "Signal", text_color=color.white) + table.cell(dbg, 1, 6, finalLongSignal ? "BUY!" : finalShortSignal ? "SELL!" : "—", text_color=finalLongSignal ? color.lime : finalShortSignal ? color.red : color.gray) + table.cell(dbg, 0, 7, "TP/SL", text_color=color.white) + table.cell(dbg, 1, 7, "+" + str.tostring(tpPct, "#.#") + "% / -" + str.tostring(slPct, "#.#") + "%", text_color=color.yellow) + table.cell(dbg, 0, 8, "Quality", text_color=color.white) + table.cell(dbg, 1, 8, "BYPASS (100)", text_color=color.lime) diff --git a/workflows/trading/moneyline_v11_2_strategy.pinescript b/workflows/trading/moneyline_v11_2_strategy.pinescript new file mode 100644 index 0000000..88677b1 --- /dev/null +++ b/workflows/trading/moneyline_v11_2_strategy.pinescript @@ -0,0 +1,206 @@ +//@version=6 +strategy("Money Line v11.2 STRATEGY", shorttitle="ML v11.2 Strat", overlay=true, pyramiding=0, initial_capital=1000, default_qty_type=strategy.percent_of_equity, default_qty_value=100) +// V11.2 STRATEGY VERSION (Dec 25, 2025): +// Same logic as indicator but with entries/exits for TradingView Strategy Report + +// === CORE PARAMETERS === +atrPeriod = input.int(12, "ATR Period", minval=1, group="Core") +multiplier = input.float(3.8, "Multiplier", minval=0.1, step=0.1, group="Core") + +// === SIGNAL TIMING === +confirmBars = input.int(1, "Bars to confirm after flip", minval=0, maxval=3, group="Timing") +flipThreshold = input.float(0.20, "Flip threshold %", minval=0.0, maxval=2.0, step=0.05, group="Timing") + +// === ENTRY FILTERS === +useEntryBuffer = input.bool(true, "Require entry buffer (ATR)", group="Filters") +entryBufferATR = input.float(-0.10, "Buffer size (in ATR, negative=early)", minval=-1.0, step=0.05, group="Filters") +useAdx = input.bool(true, "Use ADX filter", group="Filters") +adxLen = input.int(16, "ADX Length", minval=1, group="Filters") +adxMin = input.int(12, "ADX minimum", minval=0, maxval=100, group="Filters") + +// === RSI FILTER === +useRsiFilter = input.bool(true, "Use RSI filter", group="RSI") +rsiLongMin = input.float(56, "RSI Long Min", minval=0, maxval=100, group="RSI") +rsiLongMax = input.float(69, "RSI Long Max", minval=0, maxval=100, group="RSI") +rsiShortMin = input.float(30, "RSI Short Min", minval=0, maxval=100, group="RSI") +rsiShortMax = input.float(70, "RSI Short Max", minval=0, maxval=100, group="RSI") + +// === POSITION FILTER === +usePricePosition = input.bool(true, "Use price position filter", group="Position") +longPosMax = input.float(85, "Long max position %", minval=0, maxval=100, group="Position") +shortPosMin = input.float(5, "Short min position %", minval=0, maxval=100, group="Position") + +// === VOLUME FILTER === +useVolumeFilter = input.bool(true, "Use volume filter", group="Volume") +volMin = input.float(0.1, "Volume min ratio", minval=0.0, step=0.1, group="Volume") +volMax = input.float(3.5, "Volume max ratio", minval=0.5, step=0.5, group="Volume") + +// === EXITS === +tpPct = input.float(1.0, "TP %", minval=0.1, maxval=10, step=0.1, group="Exits") +slPct = input.float(0.8, "SL %", minval=0.1, maxval=10, step=0.1, group="Exits") + +// ============================================================================= +// MONEY LINE CALCULATION +// ============================================================================= + +calcH = high +calcL = low +calcC = close + +// ATR +tr = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1]))) +atr = ta.rma(tr, atrPeriod) +src = (calcH + calcL) / 2 + +up = src - (multiplier * atr) +dn = src + (multiplier * atr) + +var float up1 = na +var float dn1 = na + +up1 := nz(up1[1], up) +dn1 := nz(dn1[1], dn) + +up1 := calcC[1] > up1 ? math.max(up, up1) : up +dn1 := calcC[1] < dn1 ? math.min(dn, dn1) : dn + +var int trend = 1 +var float tsl = na + +tsl := nz(tsl[1], up1) + +// Flip threshold +thresholdAmount = tsl * (flipThreshold / 100) + +// Track consecutive bars for flip confirmation +var int bullMomentumBars = 0 +var int bearMomentumBars = 0 + +if trend == 1 + tsl := math.max(up1, tsl) + if calcC < (tsl - thresholdAmount) + bearMomentumBars := bearMomentumBars + 1 + bullMomentumBars := 0 + else + bearMomentumBars := 0 + trend := bearMomentumBars >= (confirmBars + 1) ? -1 : 1 +else + tsl := math.min(dn1, tsl) + if calcC > (tsl + thresholdAmount) + bullMomentumBars := bullMomentumBars + 1 + bearMomentumBars := 0 + else + bullMomentumBars := 0 + trend := bullMomentumBars >= (confirmBars + 1) ? 1 : -1 + +supertrend = tsl + +// ============================================================================= +// INDICATORS +// ============================================================================= + +// ADX +upMove = calcH - calcH[1] +downMove = calcL[1] - calcL +plusDM = (upMove > downMove and upMove > 0) ? upMove : 0.0 +minusDM = (downMove > upMove and downMove > 0) ? downMove : 0.0 +trADX = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1]))) +atrADX = ta.rma(trADX, adxLen) +plusDMSmooth = ta.rma(plusDM, adxLen) +minusDMSmooth = ta.rma(minusDM, adxLen) +plusDI = atrADX == 0.0 ? 0.0 : 100.0 * plusDMSmooth / atrADX +minusDI = atrADX == 0.0 ? 0.0 : 100.0 * minusDMSmooth / atrADX +dx = (plusDI + minusDI == 0.0) ? 0.0 : 100.0 * math.abs(plusDI - minusDI) / (plusDI + minusDI) +adxVal = ta.rma(dx, adxLen) + +// RSI +rsi14 = ta.rsi(calcC, 14) + +// Volume ratio +volMA20 = ta.sma(volume, 20) +volumeRatio = volume / volMA20 + +// Price position in 100-bar range +highest100 = ta.highest(calcH, 100) +lowest100 = ta.lowest(calcL, 100) +priceRange = highest100 - lowest100 +pricePosition = priceRange == 0 ? 50.0 : ((calcC - lowest100) / priceRange) * 100 + +// ============================================================================= +// FILTER CHECKS +// ============================================================================= + +adxOk = not useAdx or (adxVal >= adxMin) +longBufferOk = not useEntryBuffer or (calcC > supertrend + entryBufferATR * atr) +shortBufferOk = not useEntryBuffer or (calcC < supertrend - entryBufferATR * atr) +rsiLongOk = not useRsiFilter or (rsi14 >= rsiLongMin and rsi14 <= rsiLongMax) +rsiShortOk = not useRsiFilter or (rsi14 >= rsiShortMin and rsi14 <= rsiShortMax) +longPositionOk = not usePricePosition or (pricePosition < longPosMax) +shortPositionOk = not usePricePosition or (pricePosition > shortPosMin) +volumeOk = not useVolumeFilter or (volumeRatio >= volMin and volumeRatio <= volMax) + +// ============================================================================= +// SIGNALS +// ============================================================================= + +buyFlip = trend == 1 and trend[1] == -1 +sellFlip = trend == -1 and trend[1] == 1 + +buyReady = ta.barssince(buyFlip) == confirmBars +sellReady = ta.barssince(sellFlip) == confirmBars + +// FINAL SIGNALS (all filters applied!) +finalLongSignal = buyReady and adxOk and longBufferOk and rsiLongOk and longPositionOk and volumeOk +finalShortSignal = sellReady and adxOk and shortBufferOk and rsiShortOk and shortPositionOk and volumeOk + +// ============================================================================= +// STRATEGY ENTRIES & EXITS +// ============================================================================= + +if finalLongSignal + strategy.entry("Long", strategy.long) + +if finalShortSignal + strategy.entry("Short", strategy.short) + +// Exits with TP/SL +if strategy.position_size > 0 + strategy.exit("L Exit", "Long", stop=strategy.position_avg_price * (1 - slPct/100), limit=strategy.position_avg_price * (1 + tpPct/100)) +if strategy.position_size < 0 + strategy.exit("S Exit", "Short", stop=strategy.position_avg_price * (1 + slPct/100), limit=strategy.position_avg_price * (1 - tpPct/100)) + +// ============================================================================= +// PLOTS +// ============================================================================= + +upTrend = trend == 1 ? supertrend : na +downTrend = trend == -1 ? supertrend : na + +plot(upTrend, "Up Trend", color=color.new(color.green, 0), style=plot.style_linebr, linewidth=2) +plot(downTrend, "Down Trend", color=color.new(color.red, 0), style=plot.style_linebr, linewidth=2) + +plotshape(finalLongSignal, title="Buy", location=location.belowbar, color=color.lime, style=shape.triangleup, size=size.small) +plotshape(finalShortSignal, title="Sell", location=location.abovebar, color=color.red, style=shape.triangledown, size=size.small) + +// ============================================================================= +// DEBUG TABLE +// ============================================================================= + +var table dbg = table.new(position.top_right, 2, 8, bgcolor=color.new(color.black, 80)) +if barstate.islast + table.cell(dbg, 0, 0, "Trend", text_color=color.white) + table.cell(dbg, 1, 0, trend == 1 ? "LONG ✓" : "SHORT ✓", text_color=trend == 1 ? color.lime : color.red) + table.cell(dbg, 0, 1, "ADX", text_color=color.white) + table.cell(dbg, 1, 1, str.tostring(adxVal, "#.#") + (adxOk ? " ✓" : " ✗"), text_color=adxOk ? color.lime : color.red) + table.cell(dbg, 0, 2, "RSI", text_color=color.white) + table.cell(dbg, 1, 2, str.tostring(rsi14, "#.#") + (rsiLongOk or rsiShortOk ? " ✓" : " ✗"), text_color=rsiLongOk or rsiShortOk ? color.lime : color.orange) + table.cell(dbg, 0, 3, "Price Pos", text_color=color.white) + table.cell(dbg, 1, 3, str.tostring(pricePosition, "#.#") + "%" + (longPositionOk and shortPositionOk ? " ✓" : ""), text_color=color.white) + table.cell(dbg, 0, 4, "Volume", text_color=color.white) + table.cell(dbg, 1, 4, str.tostring(volumeRatio, "#.##") + "x" + (volumeOk ? " ✓" : " ✗"), text_color=volumeOk ? color.lime : color.orange) + table.cell(dbg, 0, 5, "Entry Buffer", text_color=color.white) + table.cell(dbg, 1, 5, longBufferOk or shortBufferOk ? "OK ✓" : "—", text_color=longBufferOk or shortBufferOk ? color.lime : color.gray) + table.cell(dbg, 0, 6, "Signal", text_color=color.white) + table.cell(dbg, 1, 6, finalLongSignal ? "BUY!" : finalShortSignal ? "SELL!" : "—", text_color=finalLongSignal ? color.lime : finalShortSignal ? color.red : color.gray) + table.cell(dbg, 0, 7, "TP/SL", text_color=color.white) + table.cell(dbg, 1, 7, "+" + str.tostring(tpPct, "#.#") + "% / -" + str.tostring(slPct, "#.#") + "%", text_color=color.yellow) diff --git a/workflows/trading/moneyline_v12.pinescript b/workflows/trading/moneyline_v12.pinescript new file mode 100644 index 0000000..9255439 --- /dev/null +++ b/workflows/trading/moneyline_v12.pinescript @@ -0,0 +1,335 @@ +//@version=6 +indicator("Bullmania Money Line v12", shorttitle="ML v12", overlay=true) +// V12 (Dec 25, 2025) +// - Default params from full-year sweeps: ADX min 16, flip threshold 0.20%, long pos cap 82% +// - Two-stage confirmation on next bar close with +0.35% move (configurable) +// - Keeps v11.2 filters (ATR buffer, RSI, volume, price position) with tightened long cap to avoid top-chasing + +// Calculation source +srcMode = input.string("Chart", "Calculation source", options=["Chart","Heikin Ashi"], tooltip="Use regular chart candles or Heikin Ashi for the line calculation.") + +// Parameter Mode +paramMode = input.string("Profiles by timeframe", "Parameter Mode", options=["Single", "Profiles by timeframe"], tooltip="Choose whether to use one global set of parameters or timeframe-specific profiles.") + +// Single (global) parameters +atrPeriodSingle = input.int(10, "ATR Period (Single mode)", minval=1, group="Single Mode") +multiplierSingle = input.float(3.0, "Multiplier (Single mode)", minval=0.1, step=0.1, group="Single Mode") + +// Profile override when using profiles +profileOverride = input.string("Auto", "Profile Override", options=["Auto", "Minutes", "Hours", "Daily", "Weekly/Monthly"], tooltip="When in 'Profiles by timeframe' mode, choose a fixed profile or let it auto-detect from the chart timeframe.", group="Profiles") + +// Timeframe profile parameters +// Minutes (<= 59m) +atr_m = input.int(12, "ATR Period (Minutes)", minval=1, group="Profiles — Minutes") +mult_m = input.float(3.8, "Multiplier (Minutes)", minval=0.1, step=0.1, group="Profiles — Minutes", tooltip="V8: Increased from 3.3 for stickier trend") + +// Hours (>=1h and <1d) +atr_h = input.int(10, "ATR Period (Hours)", minval=1, group="Profiles — Hours") +mult_h = input.float(3.5, "Multiplier (Hours)", minval=0.1, step=0.1, group="Profiles — Hours", tooltip="V8: Increased from 3.0 for stickier trend") + +// Daily (>=1d and <1w) +atr_d = input.int(10, "ATR Period (Daily)", minval=1, group="Profiles — Daily") +mult_d = input.float(3.2, "Multiplier (Daily)", minval=0.1, step=0.1, group="Profiles — Daily", tooltip="V8: Increased from 2.8 for stickier trend") + +// Weekly/Monthly (>=1w) +atr_w = input.int(7, "ATR Period (Weekly/Monthly)", minval=1, group="Profiles — Weekly/Monthly") +mult_w = input.float(3.0, "Multiplier (Weekly/Monthly)", minval=0.1, step=0.1, group="Profiles — Weekly/Monthly", tooltip="V8: Increased from 2.5 for stickier trend") + +// Optional MACD confirmation +useMacd = input.bool(false, "Use MACD confirmation", inline="macd") +macdSrc = input.source(close, "MACD Source", inline="macd") +macdFastLen = input.int(12, "Fast", minval=1, inline="macdLens") +macdSlowLen = input.int(26, "Slow", minval=1, inline="macdLens") +macdSigLen = input.int(9, "Signal", minval=1, inline="macdLens") + +// Signal timing +groupTiming = "Signal Timing" +confirmBars = input.int(1, "Bars to confirm after flip", minval=0, maxval=3, group=groupTiming, tooltip="1 bar confirmation - fast response while avoiding instant whipsaws.") +flipThreshold = input.float(0.20, "Flip threshold %", minval=0.0, maxval=2.0, step=0.05, group=groupTiming, tooltip="0.20% - tighter trend confirmation from v11.2 testing.") + +// Two-stage confirmation (next-bar price move) +groupTwoStage = "Two-Stage Confirmation" +useTwoStage = input.bool(true, "Enable two-stage confirmation", group=groupTwoStage, tooltip="Require the next bar to move by confirm % before signaling.") +confirmPct = input.float(0.35, "Confirm move % (next bar)", minval=0.0, maxval=2.0, step=0.05, group=groupTwoStage, tooltip="Default 0.35% from latest full-year sweep (best with ADX 16). Set to 0 to disable.") + +// Entry filters +groupFilters = "Entry filters" +useEntryBuffer = input.bool(true, "Require entry buffer (ATR)", group=groupFilters, tooltip="Close must be beyond the Money Line by buffer amount to avoid wick flips.") +entryBufferATR = input.float(0.10, "Buffer size (in ATR)", minval=0.0, step=0.05, group=groupFilters, tooltip="0.10 ATR from exhaustive sweep.") +useAdx = input.bool(true, "Use ADX trend-strength filter", group=groupFilters, tooltip="Filters weak chop.") +adxLen = input.int(16, "ADX Length", minval=1, group=groupFilters) +adxMin = input.int(16, "ADX minimum", minval=0, maxval=100, group=groupFilters, tooltip="Default 16 (full-year sweep sweet spot).") + +// Quality Filters +groupV6Filters = "Quality Filters" +usePricePosition = input.bool(true, "Use price position filter", group=groupV6Filters, tooltip="Prevent chasing extremes - don't buy at top of range or sell at bottom.") +longPosMax = input.float(82, "Long max position %", minval=0, maxval=100, group=groupV6Filters, tooltip="82% cap from full-year sweep to avoid top-chasing while keeping breakouts.") +shortPosMin = input.float(5, "Short min position %", minval=0, maxval=100, group=groupV6Filters, tooltip="5% floor to avoid bottom-chasing shorts.") + +useVolumeFilter = input.bool(true, "Use volume filter", group=groupV6Filters, tooltip="Filter signals with extreme volume (too low = dead, too high = climax).") +volMin = input.float(0.1, "Volume min ratio", minval=0.0, step=0.1, group=groupV6Filters, tooltip="0.1 volume floor.") +volMax = input.float(3.5, "Volume max ratio", minval=0.5, step=0.5, group=groupV6Filters, tooltip="Max volume relative to 20-bar MA.") + +useRsiFilter = input.bool(true, "Use RSI momentum filter", group=groupV6Filters, tooltip="Ensure momentum confirms direction.") +rsiLongMin = input.float(56, "RSI long minimum", minval=0, maxval=100, group=groupV6Filters, tooltip="56-69 balanced long band.") +rsiLongMax = input.float(69, "RSI long maximum", minval=0, maxval=100, group=groupV6Filters) +rsiShortMin = input.float(30, "RSI short minimum", minval=0, maxval=100, group=groupV6Filters) +rsiShortMax = input.float(70, "RSI short maximum", minval=0, maxval=100, group=groupV6Filters) + +// v9 MA Gap visualization +groupV9MA = "MA Gap Options" +showMAs = input.bool(true, "Show 50 and 200 MAs on chart", group=groupV9MA, tooltip="Display the moving averages for visual reference.") +ma50Color = input.color(color.new(color.yellow, 0), "MA 50 Color", group=groupV9MA) +ma200Color = input.color(color.new(color.orange, 0), "MA 200 Color", group=groupV9MA) + +// Determine profile +var string activeProfile = "" +resSec = timeframe.in_seconds(timeframe.period) +isMinutes = resSec < 3600 +isHours = resSec >= 3600 and resSec < 86400 +isDaily = resSec >= 86400 and resSec < 604800 +isWeeklyOrMore = resSec >= 604800 + +string profileBucket = "Single" +if paramMode == "Single" + profileBucket := "Single" +else + if profileOverride == "Minutes" + profileBucket := "Minutes" + else if profileOverride == "Hours" + profileBucket := "Hours" + else if profileOverride == "Daily" + profileBucket := "Daily" + else if profileOverride == "Weekly/Monthly" + profileBucket := "Weekly/Monthly" + else + profileBucket := isMinutes ? "Minutes" : isHours ? "Hours" : isDaily ? "Daily" : "Weekly/Monthly" + +atrPeriod = profileBucket == "Single" ? atrPeriodSingle : profileBucket == "Minutes" ? atr_m : profileBucket == "Hours" ? atr_h : profileBucket == "Daily" ? atr_d : atr_w +multiplier = profileBucket == "Single" ? multiplierSingle : profileBucket == "Minutes" ? mult_m : profileBucket == "Hours" ? mult_h : profileBucket == "Daily" ? mult_d : mult_w +activeProfile := profileBucket + +// Source OHLC +haC = srcMode == "Heikin Ashi" ? (open + high + low + close) / 4 : close +haO = srcMode == "Heikin Ashi" ? (nz(haC[1]) + nz(open[1])) / 2 : open +haH = srcMode == "Heikin Ashi" ? math.max(high, math.max(haO, haC)) : high +haL = srcMode == "Heikin Ashi" ? math.min(low, math.min(haO, haC)) : low +calcH = haH +calcL = haL +calcC = haC + +// ATR on selected source +tr = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1]))) +atr = ta.rma(tr, atrPeriod) +src = (calcH + calcL) / 2 + +up = src - (multiplier * atr) +dn = src + (multiplier * atr) + +var float up1 = na +var float dn1 = na + +up1 := nz(up1[1], up) +dn1 := nz(dn1[1], dn) + +up1 := calcC[1] > up1 ? math.max(up, up1) : up +dn1 := calcC[1] < dn1 ? math.min(dn, dn1) : dn + +var int trend = 1 +var float tsl = na + +tsl := nz(tsl[1], up1) + +// Flip threshold +thresholdAmount = tsl * (flipThreshold / 100) + +// Momentum bars +var int bullMomentumBars = 0 +var int bearMomentumBars = 0 + +if trend == 1 + tsl := math.max(up1, tsl) + if calcC < (tsl - thresholdAmount) + bearMomentumBars += 1 + bullMomentumBars := 0 + else + bearMomentumBars := 0 + trend := bearMomentumBars >= (confirmBars + 1) ? -1 : 1 +else + tsl := math.min(dn1, tsl) + if calcC > (tsl + thresholdAmount) + bullMomentumBars += 1 + bearMomentumBars := 0 + else + bullMomentumBars := 0 + trend := bullMomentumBars >= (confirmBars + 1) ? 1 : -1 + +supertrend = tsl + +// Plot Money Line +upTrend = trend == 1 ? supertrend : na +downTrend = trend == -1 ? supertrend : na + +plot(upTrend, "Up Trend", color=color.new(color.green, 0), style=plot.style_linebr, linewidth=2) +plot(downTrend, "Down Trend", color=color.new(color.red, 0), style=plot.style_linebr, linewidth=2) + +// Active profile label +showProfileLabel = input.bool(true, "Show active profile label", group="Profiles") +var label profLbl = na +if barstate.islast and barstate.isconfirmed and showProfileLabel + label.delete(profLbl) + profLbl := label.new(bar_index, close, text="Profile: " + activeProfile + " | ATR=" + str.tostring(atrPeriod) + " Mult=" + str.tostring(multiplier), yloc=yloc.price, style=label.style_label_upper_left, textcolor=color.white, color=color.new(color.blue, 20)) + +// MACD +[macdLine, macdSignal, macdHist] = ta.macd(macdSrc, macdFastLen, macdSlowLen, macdSigLen) +longOk = not useMacd or (macdLine > macdSignal) +shortOk = not useMacd or (macdLine < macdSignal) + +// Flip detection +buyFlip = trend == 1 and trend[1] == -1 +sellFlip = trend == -1 and trend[1] == 1 + +// ADX +upMove = calcH - calcH[1] +downMove = calcL[1] - calcL +plusDM = (upMove > downMove and upMove > 0) ? upMove : 0.0 +minusDM = (downMove > upMove and downMove > 0) ? downMove : 0.0 +trADX = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1]))) +atrADX = ta.rma(trADX, adxLen) +plusDMSmooth = ta.rma(plusDM, adxLen) +minusDMSmooth = ta.rma(minusDM, adxLen) +plusDI = atrADX == 0.0 ? 0.0 : 100.0 * plusDMSmooth / atrADX +minusDI = atrADX == 0.0 ? 0.0 : 100.0 * minusDMSmooth / atrADX +dx = (plusDI + minusDI == 0.0) ? 0.0 : 100.0 * math.abs(plusDI - minusDI) / (plusDI + minusDI) +adxVal = ta.rma(dx, adxLen) +adxOk = not useAdx or (adxVal >= adxMin) + +// Entry buffers +longBufferOk = not useEntryBuffer or (calcC > supertrend + entryBufferATR * atr) +shortBufferOk = not useEntryBuffer or (calcC < supertrend - entryBufferATR * atr) + +// Confirmation bars after flip +buyReady = ta.barssince(buyFlip) == confirmBars +sellReady = ta.barssince(sellFlip) == confirmBars + +// Context metrics +atrPercent = (atr / calcC) * 100 +rsi14 = ta.rsi(calcC, 14) +volMA20 = ta.sma(volume, 20) +volumeRatio = volume / volMA20 +highest100 = ta.highest(calcH, 100) +lowest100 = ta.lowest(calcL, 100) +priceRange = highest100 - lowest100 +pricePosition = priceRange == 0 ? 50.0 : ((calcC - lowest100) / priceRange) * 100 + +// MA gap +ma50 = ta.sma(close, 50) +ma200 = ta.sma(close, 200) +maGap = ma200 == 0 ? 0.0 : ((ma50 - ma200) / ma200) * 100 + +// Filters +longPositionOk = not usePricePosition or (pricePosition < longPosMax) +shortPositionOk = not usePricePosition or (pricePosition > shortPosMin) +volumeOk = not useVolumeFilter or (volumeRatio >= volMin and volumeRatio <= volMax) +rsiLongOk = not useRsiFilter or (rsi14 >= rsiLongMin and rsi14 <= rsiLongMax) +rsiShortOk = not useRsiFilter or (rsi14 >= rsiShortMin and rsi14 <= rsiShortMax) + +// Candidate signals (filters applied) +candidateLongSignal = buyReady and longOk and adxOk and longBufferOk and rsiLongOk and longPositionOk and volumeOk +candidateShortSignal = sellReady and shortOk and adxOk and shortBufferOk and rsiShortOk and shortPositionOk and volumeOk + +// Two-stage confirmation state +var int pendingDir = 0 // 1 = long, -1 = short, 0 = none +var float pendingPrice = na +var int pendingBar = na + +finalLongSignal = false +finalShortSignal = false + +if useTwoStage and confirmPct > 0 + if candidateLongSignal + pendingDir := 1 + pendingPrice := calcC + pendingBar := bar_index + if candidateShortSignal + pendingDir := -1 + pendingPrice := calcC + pendingBar := bar_index + + if pendingDir != 0 and bar_index == pendingBar + 1 + if pendingDir == 1 and calcC >= pendingPrice * (1 + confirmPct / 100) + finalLongSignal := true + if pendingDir == -1 and calcC <= pendingPrice * (1 - confirmPct / 100) + finalShortSignal := true + pendingDir := 0 + pendingPrice := na + pendingBar := na +else + finalLongSignal := candidateLongSignal + finalShortSignal := candidateShortSignal + +// Debug labels for blocked candidates +showDebugLabels = input.bool(true, "Show debug labels when signals blocked", group="Debug") +var label debugLbl = na + +if showDebugLabels and (buyReady or sellReady) + var string debugText = "" + var color debugColor = color.gray + if buyReady and not candidateLongSignal + debugText := "LONG BLOCKED:\n" + if not longOk + debugText += "MACD\n" + if not adxOk + debugText += "ADX " + str.tostring(adxVal, "#.##") + " < " + str.tostring(adxMin) + "\n" + if not longBufferOk + debugText += "Entry Buffer\n" + if not rsiLongOk + debugText += "RSI " + str.tostring(rsi14, "#.##") + " not in " + str.tostring(rsiLongMin) + "-" + str.tostring(rsiLongMax) + "\n" + if not longPositionOk + debugText += "Price Pos " + str.tostring(pricePosition, "#.##") + "% > " + str.tostring(longPosMax) + "%\n" + if not volumeOk + debugText += "Volume " + str.tostring(volumeRatio, "#.##") + " not in " + str.tostring(volMin) + "-" + str.tostring(volMax) + "\n" + debugColor := color.new(color.red, 30) + label.delete(debugLbl) + debugLbl := label.new(bar_index, high, text=debugText, yloc=yloc.price, style=label.style_label_down, textcolor=color.white, color=debugColor, size=size.small) + else if sellReady and not candidateShortSignal + debugText := "SHORT BLOCKED:\n" + if not shortOk + debugText += "MACD\n" + if not adxOk + debugText += "ADX " + str.tostring(adxVal, "#.##") + " < " + str.tostring(adxMin) + "\n" + if not shortBufferOk + debugText += "Entry Buffer\n" + if not rsiShortOk + debugText += "RSI " + str.tostring(rsi14, "#.##") + " not in " + str.tostring(rsiShortMin) + "-" + str.tostring(rsiShortMax) + "\n" + if not shortPositionOk + debugText += "Price Pos " + str.tostring(pricePosition, "#.##") + "% < " + str.tostring(shortPosMin) + "%\n" + if not volumeOk + debugText += "Volume " + str.tostring(volumeRatio, "#.##") + " not in " + str.tostring(volMin) + "-" + str.tostring(volMax) + "\n" + debugColor := color.new(color.orange, 30) + label.delete(debugLbl) + debugLbl := label.new(bar_index, low, text=debugText, yloc=yloc.price, style=label.style_label_up, textcolor=color.white, color=debugColor, size=size.small) + +plotshape(finalLongSignal, title="Buy Signal", location=location.belowbar, color=color.green, style=shape.circle, size=size.small) +plotshape(finalShortSignal, title="Sell Signal", location=location.abovebar, color=color.red, style=shape.circle, size=size.small) + +// Base currency +baseCurrency = str.replace(syminfo.ticker, "USDT", "") +baseCurrency := str.replace(baseCurrency, "USD", "") +baseCurrency := str.replace(baseCurrency, "PERP", "") + +// Version +indicatorVer = "v12" + +// Alerts +longAlertMsg = baseCurrency + " buy " + timeframe.period + " | ATR:" + str.tostring(atrPercent, "#.##") + " | ADX:" + str.tostring(adxVal, "#.##") + " | RSI:" + str.tostring(rsi14, "#.##") + " | VOL:" + str.tostring(volumeRatio, "#.##") + " | POS:" + str.tostring(pricePosition, "#.##") + " | MAGAP:" + str.tostring(maGap, "#.##") + " | IND:" + indicatorVer +shortAlertMsg = baseCurrency + " sell " + timeframe.period + " | ATR:" + str.tostring(atrPercent, "#.##") + " | ADX:" + str.tostring(adxVal, "#.##") + " | RSI:" + str.tostring(rsi14, "#.##") + " | VOL:" + str.tostring(volumeRatio, "#.##") + " | POS:" + str.tostring(pricePosition, "#.##") + " | MAGAP:" + str.tostring(maGap, "#.##") + " | IND:" + indicatorVer + +if finalLongSignal + alert(longAlertMsg, alert.freq_once_per_bar_close) +if finalShortSignal + alert(shortAlertMsg, alert.freq_once_per_bar_close) + +// Fill area +fill(plot(close, display=display.none), plot(upTrend, display=display.none), color=color.new(color.green, 90)) +fill(plot(close, display=display.none), plot(downTrend, display=display.none), color=color.new(color.red, 90)) diff --git a/workflows/trading/parse_signal_enhanced.json b/workflows/trading/parse_signal_enhanced.json index 399aa35..ff37994 100644 --- a/workflows/trading/parse_signal_enhanced.json +++ b/workflows/trading/parse_signal_enhanced.json @@ -3,7 +3,7 @@ "nodes": [ { "parameters": { - "jsCode": "// Get the body - it might be a string or nested in an object\nlet body = $json.body || $json.query?.body || JSON.stringify($json);\n\n// If body is an object, stringify it\nif (typeof body === 'object') {\n body = JSON.stringify(body);\n}\n\n// Detect MA crossover events (death cross / golden cross)\nconst isMACrossover = body.match(/crossing/i) !== null;\n\n// Parse basic signal (existing logic)\n// CRITICAL (Dec 7, 2025): Check FARTCOIN patterns before SOL\nconst symbolMatch = body.match(/\\b(FARTCOINUSDT|FARTCOIN|FART|SOLUSDT|SOL|BTC|ETH)\\b/i);\nlet symbol;\nif (symbolMatch) {\n const matched = symbolMatch[1].toUpperCase();\n // FARTCOIN variations all map to FARTCOIN-PERP\n if (matched === 'FARTCOINUSDT' || matched === 'FARTCOIN' || matched === 'FART') {\n symbol = 'FARTCOIN-PERP';\n } else if (matched === 'SOLUSDT' || matched === 'SOL') {\n symbol = 'SOL-PERP';\n } else {\n symbol = matched + '-PERP';\n }\n} else {\n symbol = 'SOL-PERP'; // Default fallback\n}\n\nconst direction = body.match(/\\b(sell|short)\\b/i) ? 'short' : 'long';\n\n// Determine crossover type based on direction\nconst isDeathCross = isMACrossover && direction === 'short';\nconst isGoldenCross = isMACrossover && direction === 'long';\n\n// Enhanced timeframe extraction supporting multiple formats:\n// - \"buy 5\" \u2192 \"5\"\n// - \"buy 15\" \u2192 \"15\"\n// - \"buy 60\" or \"buy 1h\" \u2192 \"60\"\n// - \"buy 240\" or \"buy 4h\" \u2192 \"240\"\n// - \"buy D\" or \"buy 1d\" \u2192 \"D\"\n// - \"buy W\" \u2192 \"W\"\nconst timeframeMatch = body.match(/\\b(buy|sell)\\s+(\\d+|D|W|M|1h|4h|1d)\\b/i);\nlet timeframe = '5'; // Default to 5min\n\nif (timeframeMatch) {\n const tf = timeframeMatch[2];\n // Convert hour/day notation to minutes\n if (tf === '1h' || tf === '60') {\n timeframe = '60';\n } else if (tf === '4h' || tf === '240') {\n timeframe = '240';\n } else if (tf === '1d' || tf.toUpperCase() === 'D') {\n timeframe = 'D';\n } else if (tf.toUpperCase() === 'W') {\n timeframe = 'W';\n } else if (tf.toUpperCase() === 'M') {\n timeframe = 'M';\n } else {\n timeframe = tf;\n }\n}\n\n// Parse new context metrics from enhanced format:\n// \"SOLT.P buy 15 | ATR:0.65 | ADX:14.3 | RSI:51.3 | VOL:0.87 | POS:59.3 | MAGAP:-1.23 | IND:v9\"\nconst atrMatch = body.match(/ATR:([\\d.]+)/);\nconst atr = atrMatch ? parseFloat(atrMatch[1]) : 0;\n\nconst adxMatch = body.match(/ADX:([\\d.]+)/);\nconst adx = adxMatch ? parseFloat(adxMatch[1]) : 0;\n\nconst rsiMatch = body.match(/RSI:([\\d.]+)/);\nconst rsi = rsiMatch ? parseFloat(rsiMatch[1]) : 0;\n\nconst volumeMatch = body.match(/VOL:([\\d.]+)/);\nconst volumeRatio = volumeMatch ? parseFloat(volumeMatch[1]) : 0;\n\nconst pricePositionMatch = body.match(/POS:([\\d.]+)/);\nconst pricePosition = pricePositionMatch ? parseFloat(pricePositionMatch[1]) : 0;\n\n// Parse signal price from \"@ price\" format (for 1min data feed and v9 signals)\n// Must match: \"buy 1 @ 142.08 |\" (@ followed by price before first pipe)\n// DEBUG: Log body to see actual format\nconsole.log('DEBUG body:', body);\nconst signalPriceMatch = body.match(/@\\s*([\\d.]+)\\s*\\|/);\nconsole.log('DEBUG signalPriceMatch:', signalPriceMatch);\nconst signalPrice = signalPriceMatch ? parseFloat(signalPriceMatch[1]) : undefined;\nconsole.log('DEBUG signalPrice:', signalPrice, 'pricePosition will be:', body.match(/POS:([\\d.]+)/) ? body.match(/POS:([\\d.]+)/)[1] : 'not found');\n\n// V9: Parse MA gap (optional, backward compatible with v8)\nconst maGapMatch = body.match(/MAGAP:([-\\d.]+)/);\nconst maGap = maGapMatch ? parseFloat(maGapMatch[1]) : undefined;\n\n// Parse indicator version (optional, backward compatible)\nconst indicatorVersionMatch = body.match(/IND:(v\\d+)/i);\nconst indicatorVersion = indicatorVersionMatch ? indicatorVersionMatch[1] : 'v5';\n\nreturn {\n rawMessage: body,\n symbol,\n direction,\n timeframe,\n signalPrice,\n // Context fields\n atr,\n adx,\n rsi,\n volumeRatio,\n pricePosition,\n maGap,\n // MA Crossover detection (NEW: Nov 27, 2025)\n isMACrossover,\n isDeathCross,\n isGoldenCross,\n // Version tracking\n indicatorVersion\n};" + "jsCode": "// Get the body - it might be a string or nested in an object\nlet body = $json.body || $json.query?.body || JSON.stringify($json);\n\n// If body is an object, stringify it\nif (typeof body === 'object') {\n body = JSON.stringify(body);\n}\n\n// Detect MA crossover events (death cross / golden cross)\nconst isMACrossover = body.match(/crossing/i) !== null;\n\n// Parse basic signal (existing logic)\n// CRITICAL (Dec 7, 2025): Check FARTCOIN patterns before SOL\nconst symbolMatch = body.match(/\\b(FARTCOINUSDT|FARTCOIN|FART|SOLUSDT|SOL|BTC|ETH)\\b/i);\nlet symbol;\nif (symbolMatch) {\n const matched = symbolMatch[1].toUpperCase();\n // FARTCOIN variations all map to FARTCOIN-PERP\n if (matched === 'FARTCOINUSDT' || matched === 'FARTCOIN' || matched === 'FART') {\n symbol = 'FARTCOIN-PERP';\n } else if (matched === 'SOLUSDT' || matched === 'SOL') {\n symbol = 'SOL-PERP';\n } else {\n symbol = matched + '-PERP';\n }\n} else {\n symbol = 'SOL-PERP'; // Default fallback\n}\n\nconst direction = body.match(/\\b(sell|short)\\b/i) ? 'short' : 'long';\n\n// Determine crossover type based on direction\nconst isDeathCross = isMACrossover && direction === 'short';\nconst isGoldenCross = isMACrossover && direction === 'long';\n\n// Enhanced timeframe extraction supporting multiple formats:\n// - \"buy 5\" \u2192 \"5\"\n// - \"buy 15\" \u2192 \"15\"\n// - \"buy 60\" or \"buy 1h\" \u2192 \"60\"\n// - \"buy 240\" or \"buy 4h\" \u2192 \"240\"\n// - \"buy D\" or \"buy 1d\" \u2192 \"D\"\n// - \"buy W\" \u2192 \"W\"\nconst timeframeMatch = body.match(/\\b(buy|sell)\\s+(\\d+|D|W|M|1h|4h|1d)\\b/i);\nlet timeframe = '5'; // Default to 5min\n\nif (timeframeMatch) {\n const tf = timeframeMatch[2];\n // Convert hour/day notation to minutes\n if (tf === '1h' || tf === '60') {\n timeframe = '60';\n } else if (tf === '4h' || tf === '240') {\n timeframe = '240';\n } else if (tf === '1d' || tf.toUpperCase() === 'D') {\n timeframe = 'D';\n } else if (tf.toUpperCase() === 'W') {\n timeframe = 'W';\n } else if (tf.toUpperCase() === 'M') {\n timeframe = 'M';\n } else {\n timeframe = tf;\n }\n}\n\n// Parse new context metrics from enhanced format:\n// \"SOLT.P buy 15 | ATR:0.65 | ADX:14.3 | RSI:51.3 | VOL:0.87 | POS:59.3 | MAGAP:-1.23 | IND:v9\"\nconst atrMatch = body.match(/ATR:([\\d.]+)/);\nconst atr = atrMatch ? parseFloat(atrMatch[1]) : 0;\n\nconst adxMatch = body.match(/ADX:([\\d.]+)/);\nconst adx = adxMatch ? parseFloat(adxMatch[1]) : 0;\n\nconst rsiMatch = body.match(/RSI:([\\d.]+)/);\nconst rsi = rsiMatch ? parseFloat(rsiMatch[1]) : 0;\n\nconst volumeMatch = body.match(/VOL:([\\d.]+)/);\nconst volumeRatio = volumeMatch ? parseFloat(volumeMatch[1]) : 0;\n\nconst pricePositionMatch = body.match(/POS:([\\d.]+)/);\nconst pricePosition = pricePositionMatch ? parseFloat(pricePositionMatch[1]) : 0;\n\n// Parse signal price from \"@ price\" format (for 1min data feed and v9 signals)\n// Must match: \"buy 1 @ 142.08 |\" (@ followed by price before first pipe)\n// DEBUG: Log body to see actual format\nconsole.log('DEBUG body:', body);\nconst signalPriceMatch = body.match(/@\\s*([\\d.]+)\\s*\\|/);\nconsole.log('DEBUG signalPriceMatch:', signalPriceMatch);\nconst signalPrice = signalPriceMatch ? parseFloat(signalPriceMatch[1]) : undefined;\nconsole.log('DEBUG signalPrice:', signalPrice, 'pricePosition will be:', body.match(/POS:([\\d.]+)/) ? body.match(/POS:([\\d.]+)/)[1] : 'not found');\n\n// V9: Parse MA gap (optional, backward compatible with v8)\nconst maGapMatch = body.match(/MAGAP:([-\\d.]+)/);\nconst maGap = maGapMatch ? parseFloat(maGapMatch[1]) : undefined;\n\n// Parse indicator version (optional, backward compatible)\nconst indicatorVersionMatch = body.match(/IND:(v\\d+[a-z]*)/i);\nconst indicatorVersion = indicatorVersionMatch ? indicatorVersionMatch[1] : 'v5';\n\n// V11.2opt: Parse pre-calculated quality score from indicator\n// Format: SCORE:100 - bypasses bot quality scoring for trusted indicators\nconst scoreMatch = body.match(/SCORE:(\\d+)/);\nconst indicatorScore = scoreMatch ? parseInt(scoreMatch[1]) : undefined;\n\nreturn {\n rawMessage: body,\n symbol,\n direction,\n timeframe,\n signalPrice,\n // Context fields\n atr,\n adx,\n rsi,\n volumeRatio,\n pricePosition,\n maGap,\n // MA Crossover detection (NEW: Nov 27, 2025)\n isMACrossover,\n isDeathCross,\n isGoldenCross,\n // Version tracking\n indicatorVersion,\n // Pre-calculated quality score (NEW: Dec 26, 2025)\n indicatorScore\n};" }, "id": "parse-signal-enhanced", "name": "Parse Signal Enhanced",