From ceb84c3bc1d1defcbfc4402df39e932216b55a82 Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Thu, 27 Nov 2025 08:08:37 +0100 Subject: [PATCH] feat: Revenge system enhancements #4 and #10 - IMPLEMENTED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhancement #4: Failed Revenge Tracking - Added 3 database fields: revengeOutcome, revengePnL, revengeFailedReason - Added updateRevengeOutcome() method in stop-hunt-tracker.ts - Position Manager hooks revenge trade closes, records outcome - Enables data-driven analysis of revenge success rate Enhancement #10: Metadata Persistence - Added 4 database fields: firstCrossTime, lowestInZone, highestInZone, zoneResetCount - Migrated 90-second zone tracking from in-memory to database - Rewrote shouldExecuteRevenge() with database persistence - Container restarts now preserve exact zone tracking state Technical Details: - Prisma schema updated with 7 new StopHunt fields - Added signalSource field to ActiveTrade interface - All zone metadata persisted in real-time to database - Build verified successful (no TypeScript errors) Files Changed: - prisma/schema.prisma (StopHunt model + index) - lib/trading/stop-hunt-tracker.ts (DB persistence + outcome tracking) - lib/trading/position-manager.ts (revenge hook + interface) - docs/REVENGE_ENHANCEMENTS_EXPLAINED.md (comprehensive guide) Pending User Decision: - Enhancement #1: ADX confirmation (3 options explained in docs) - Enhancement #6: SL distance validation (2ร— ATR recommended) Status: Ready for deployment after Prisma migration Date: Nov 27, 2025 --- .github/prompts/general prompt.prompt.md | 3 + cleanup_trading_bot.sh | 93 +++++++ docs/REVENGE_ENHANCEMENTS_EXPLAINED.md | 341 +++++++++++++++++++++++ lib/trading/position-manager.ts | 21 ++ lib/trading/stop-hunt-tracker.ts | 149 +++++++--- prisma/schema.prisma | 12 + 6 files changed, 586 insertions(+), 33 deletions(-) create mode 100755 cleanup_trading_bot.sh create mode 100644 docs/REVENGE_ENHANCEMENTS_EXPLAINED.md diff --git a/.github/prompts/general prompt.prompt.md b/.github/prompts/general prompt.prompt.md index 42bc443..8f90d35 100644 --- a/.github/prompts/general prompt.prompt.md +++ b/.github/prompts/general prompt.prompt.md @@ -10,6 +10,9 @@ MANDATORY FIRST STEPS: - This is 4,400+ lines of critical context - Every section matters - shortcuts cause financial losses - Pay special attention to Common Pitfalls (60+ documented bugs) + - Clean up after yourself in code and documentation + - keep user data secure and private + - keep a clean structure for future developers 2. UNDERSTAND THE VERIFICATION ETHOS - NEVER say "done", "fixed", "working" without 100% verification diff --git a/cleanup_trading_bot.sh b/cleanup_trading_bot.sh new file mode 100755 index 0000000..cbb59e9 --- /dev/null +++ b/cleanup_trading_bot.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Trading Bot v4 - Automated Docker Cleanup Script +# Runs after builds to keep only last 2 images and prune dangling resources +# Usage: ./cleanup_trading_bot.sh (or add to cron: 0 2 * * * /home/icke/traderv4/cleanup_trading_bot.sh) + +set -e + +echo "๐Ÿงน Trading Bot v4 - Docker Cleanup" +echo "======================================" +echo "" + +# Function to format bytes to human-readable +format_size() { + numfmt --to=iec-i --suffix=B "$1" 2>/dev/null || echo "$1 bytes" +} + +# Check disk space before cleanup +echo "๐Ÿ“Š Disk space BEFORE cleanup:" +df -h / | tail -1 +echo "" + +# Get current space usage +BEFORE_IMAGES=$(docker system df --format "{{.Size}}" | head -1 | sed 's/[^0-9.]//g' | awk '{print $1}') +BEFORE_BUILD_CACHE=$(docker system df --format "{{.Size}}" | sed -n 3p | sed 's/[^0-9.]//g' | awk '{print $1}') + +echo "๐Ÿ’พ Docker resources BEFORE cleanup:" +docker system df +echo "" + +# Keep only last 2 trading-bot images (for rollback safety) +echo "๐Ÿ—‘๏ธ Step 1: Keeping last 2 trading-bot images, removing older ones..." +IMAGES_TO_REMOVE=$(docker images traderv4-trading-bot --format "{{.ID}}" | tail -n +3) +if [ -n "$IMAGES_TO_REMOVE" ]; then + echo "$IMAGES_TO_REMOVE" | xargs docker rmi -f 2>/dev/null || true + echo "โœ… Removed $(echo "$IMAGES_TO_REMOVE" | wc -l) old trading-bot image(s)" +else + echo "โœ… No old trading-bot images to remove (keeping last 2)" +fi +echo "" + +# Remove dangling images (untagged layers from builds) +echo "๐Ÿ—‘๏ธ Step 2: Removing dangling images..." +DANGLING=$(docker images -f "dangling=true" -q) +if [ -n "$DANGLING" ]; then + docker image prune -f + echo "โœ… Removed dangling images" +else + echo "โœ… No dangling images found" +fi +echo "" + +# Prune build cache (biggest space saver - 40GB typical) +echo "๐Ÿ—‘๏ธ Step 3: Pruning build cache..." +docker builder prune -f +echo "โœ… Build cache pruned" +echo "" + +# Optional: Remove dangling volumes (be careful - only if no important data) +echo "๐Ÿ—‘๏ธ Step 4: Checking for dangling volumes..." +DANGLING_VOLUMES=$(docker volume ls -f "dangling=true" -q | grep -v "trading-bot-postgres" || true) +if [ -n "$DANGLING_VOLUMES" ]; then + echo "โš ๏ธ Found dangling volumes (excluding postgres):" + echo "$DANGLING_VOLUMES" + echo "โš ๏ธ Skipping volume cleanup for safety (run 'docker volume prune -f' manually if needed)" +else + echo "โœ… No dangling volumes found" +fi +echo "" + +# Show space saved +echo "๐Ÿ“Š Disk space AFTER cleanup:" +df -h / | tail -1 +echo "" + +echo "๐Ÿ’พ Docker resources AFTER cleanup:" +docker system df +echo "" + +# Calculate space freed (approximate) +AFTER_IMAGES=$(docker system df --format "{{.Size}}" | head -1 | sed 's/[^0-9.]//g' | awk '{print $1}') +AFTER_BUILD_CACHE=$(docker system df --format "{{.Size}}" | sed -n 3p | sed 's/[^0-9.]//g' | awk '{print $1}') + +echo "โœ… Cleanup complete!" +echo "" +echo "๐Ÿ’ก Tips:" +echo " - Run after each build: docker compose build && ./cleanup_trading_bot.sh" +echo " - Add to cron: 0 2 * * * /home/icke/traderv4/cleanup_trading_bot.sh" +echo " - BuildKit auto-cleanup kicks in at 20GB threshold (daemon.json)" +echo "" +echo "๐Ÿ”’ Safety measures:" +echo " - Keeps last 2 trading-bot images for rollback" +echo " - Never touches named volumes (postgres data safe)" +echo " - Never removes running containers" diff --git a/docs/REVENGE_ENHANCEMENTS_EXPLAINED.md b/docs/REVENGE_ENHANCEMENTS_EXPLAINED.md new file mode 100644 index 0000000..ff619e3 --- /dev/null +++ b/docs/REVENGE_ENHANCEMENTS_EXPLAINED.md @@ -0,0 +1,341 @@ +# Revenge Trade System - Enhancement Explanations + +**Status:** Enhancements #4 and #10 IMPLEMENTED (Nov 27, 2025) +**Pending:** Enhancements #1 and #6 awaiting user decision + +--- + +## โœ… IMPLEMENTED: Enhancement #4 - Failed Revenge Tracking + +**Problem Solved:** System had no way to analyze revenge trade success rate or learn from failures. + +**Implementation:** +- **Database Fields Added:** + - `revengeOutcome` (String): TP1, TP2, SL, TRAILING_SL, manual, emergency + - `revengePnL` (Float): Actual P&L from revenge trade + - `revengeFailedReason` (String): Why it failed (e.g., "stopped_again", "manually_closed") + +- **Code Changes:** + - `stop-hunt-tracker.ts`: Added `updateRevengeOutcome()` helper method + - `position-manager.ts`: Added hook in `executeExit()` to record outcome when revenge trade closes + - Automatic detection: Checks `signalSource === 'stop_hunt_revenge'` + +**How It Works:** +```typescript +// When revenge trade closes (TP1, TP2, SL, etc.) +if (trade.signalSource === 'stop_hunt_revenge') { + await tracker.updateRevengeOutcome({ + revengeTradeId: trade.id, + outcome: 'SL', // or 'TP1', 'TP2', etc. + pnl: -138.35, // Actual profit/loss + failedReason: 'stopped_again' // If outcome = SL + }) +} +``` + +**Benefits:** +- **Analytics:** Query database to see revenge win rate vs regular trades +- **Learning:** Identify which conditions lead to successful revenge (ADX, time of day, etc.) +- **Optimization:** Data-driven improvements to revenge system parameters + +**Example SQL Analysis:** +```sql +-- Revenge trade success rate +SELECT + COUNT(*) as total_revenge_trades, + SUM(CASE WHEN "revengeOutcome" IN ('TP1', 'TP2') THEN 1 ELSE 0 END) as wins, + SUM(CASE WHEN "revengeOutcome" = 'SL' THEN 1 ELSE 0 END) as losses, + ROUND(AVG("revengePnL"), 2) as avg_pnl, + ROUND(SUM("revengePnL"), 2) as total_pnl +FROM "StopHunt" +WHERE "revengeExecuted" = true; + +-- Compare original stop vs revenge outcome +SELECT + "symbol", + "direction", + "originalQualityScore", + "stopLossAmount" as original_loss, + "revengePnL" as revenge_result, + ("stopLossAmount" + "revengePnL") as net_result +FROM "StopHunt" +WHERE "revengeExecuted" = true +ORDER BY net_result DESC; +``` + +--- + +## โœ… IMPLEMENTED: Enhancement #10 - Metadata Persistence + +**Problem Solved:** Container restarts lost in-memory zone tracking data (firstCrossTime, high/low tracking, reset count). + +**Implementation:** +- **Database Fields Added:** + - `firstCrossTime` (DateTime): When price first entered revenge zone + - `lowestInZone` (Float): Lowest price while in zone (for analysis) + - `highestInZone` (Float): Highest price while in zone (for analysis) + - `zoneResetCount` (Int): How many times price left and re-entered zone + +- **Code Changes:** + - Rewrote `shouldExecuteRevenge()` to use database persistence instead of in-memory `revengeMetadata` + - All zone tracking now written to database in real-time + - Container restart = continues tracking from database state + +**How It Works (90-Second Confirmation):** +```typescript +// BEFORE (LOST ON RESTART): +revengeMetadata.set(stopHunt.id, { + firstCrossTime: Date.now(), + lowestInZone: currentPrice, + highestInZone: currentPrice, + resetCount: 0 +}) + +// AFTER (SURVIVES RESTART): +await prisma.stopHunt.update({ + where: { id: stopHunt.id }, + data: { + firstCrossTime: new Date(), + lowestInZone: currentPrice, + highestInZone: currentPrice, + zoneResetCount: 0 + } +}) +``` + +**90-Second Zone Logic (Persisted):** +1. **First Cross:** Price enters revenge zone โ†’ record `firstCrossTime` to DB +2. **Sustained:** Every 30s check: Still in zone? Update `lowestInZone`/`highestInZone` +3. **Timer Check:** If in zone for 90+ seconds โ†’ Execute revenge +4. **Reset:** If price leaves zone โ†’ clear `firstCrossTime`, increment `zoneResetCount` +5. **Container Restart:** Read from DB, continue tracking from exact state + +**Benefits:** +- **Reliability:** No data loss on container restarts (bot can restart during 90s window) +- **Analysis:** Zone behavior patterns visible in database (how often resets, price extremes) +- **Debugging:** Full audit trail of zone entry/exit timestamps + +--- + +## โ“ PENDING: Enhancement #1 - ADX Confirmation + +**Problem:** System doesn't validate trend strength before revenge entry. Could re-enter when trend already weakened (ADX dropped from 26 to 15). + +**Two Options for Implementation:** + +### Option A: Fetch Fresh ADX from Cache (RECOMMENDED) + +**How It Works:** +```typescript +// In shouldExecuteRevenge(), before executing revenge: +const cache = getMarketDataCache() +const freshData = cache.get(stopHunt.symbol) + +if (!freshData || !freshData.adx) { + console.log('โš ๏ธ No fresh ADX data, skipping validation') + // Proceed without ADX check OR block revenge (user choice) +} else if (freshData.adx < 20) { + console.log(`โš ๏ธ ADX weakened: ${freshData.adx.toFixed(1)} < 20, blocking revenge`) + return false // Don't revenge weak trends +} else { + console.log(`โœ… ADX confirmation: ${freshData.adx.toFixed(1)} (strong trend, revenge approved)`) + // Continue with revenge execution +} +``` + +**Data Source:** +- **TradingView sends market data every 1-5 minutes** to `/api/trading/market-data` +- Cache stores: ADX, ATR, RSI, volumeRatio, pricePosition, currentPrice +- Cache expiry: 5 minutes (fresh data or null) + +**Pros:** +- โœ… Real-time trend validation (what's happening NOW, not 2 hours ago) +- โœ… Adapts to changing conditions (trend weakening = skip revenge) +- โœ… Uses existing infrastructure (market-data-cache.ts already working) +- โœ… Same data source as re-entry analytics (proven reliable) + +**Cons:** +- โŒ Requires TradingView sending data reliably every 1-5min +- โŒ Cache could be stale if TradingView alert fails +- โŒ Need fallback logic when cache empty + +**Fallback Strategy:** +```typescript +if (!freshData || freshData.timestamp < Date.now() - 300000) { + // No data or stale (>5min old) + // Option 1: Proceed without ADX check (less strict) + // Option 2: Block revenge (more conservative) + // Option 3: Use originalADX threshold (hybrid) +} +``` + +--- + +### Option B: Compare to Original ADX (SIMPLER) + +**How It Works:** +```typescript +// Just validate original signal had strong ADX +if (stopHunt.originalADX && stopHunt.originalADX < 20) { + console.log(`โš ๏ธ Original ADX was weak (${stopHunt.originalADX.toFixed(1)}), blocking revenge`) + return false +} +// No current ADX check - assumes trend still valid +``` + +**Pros:** +- โœ… Simple, no external dependencies +- โœ… Always available (stored at stop-out time) +- โœ… Filters weak original signals (quality control) + +**Cons:** +- โŒ Doesn't validate current trend strength +- โŒ Revenge could enter when trend already died +- โŒ Less sophisticated than real-time validation + +**Use Case:** +- Best as **fallback** when fresh ADX unavailable +- Or as **minimum filter** (block quality <85 + ADX <20 at origin) + +--- + +### Recommendation: Hybrid Approach + +**Best of Both Worlds:** +```typescript +// 1. Filter at recording time (don't even create StopHunt if ADX was weak) +if (trade.adxAtEntry < 20) { + console.log('โš ๏ธ ADX too weak for revenge system, skipping') + return // Don't record stop hunt +} + +// 2. Validate at revenge time (check current ADX before entering) +const freshData = cache.get(stopHunt.symbol) +if (freshData && freshData.adx < 20) { + console.log(`โš ๏ธ ADX weakened: ${freshData.adx} < 20, waiting...`) + return false +} + +// 3. Fallback if no fresh data (use original as proxy) +if (!freshData && stopHunt.originalADX < 23) { + console.log('โš ๏ธ No fresh ADX, original was borderline, being conservative') + return false +} +``` + +**Effect:** +- Layer 1: Block weak signals at origin (ADX <20 at stop-out) +- Layer 2: Real-time validation before revenge entry (current ADX <20) +- Layer 3: Conservative fallback when no fresh data (original <23) + +--- + +## โ“ PENDING: Enhancement #6 - Stop Loss Distance Validation + +**Problem:** Revenge enters too close to original stop-loss price, gets stopped by same wick/volatility. + +**Example Scenario:** +- Original LONG entry: $141.37 +- Stop-out: $142.48 (1.11 points above entry) +- Revenge entry: $141.50 (0.98 points above stop-out) +- Problem: Only 0.13 points between revenge entry and stop zone = instant re-stop + +**Solution: Require Minimum Safe Distance** + +```typescript +// In shouldExecuteRevenge(), before executing revenge: + +// Calculate distance from entry to stop zone +const slDistance = stopHunt.direction === 'long' + ? currentPrice - stopHunt.stopHuntPrice // How much room BELOW entry + : stopHunt.stopHuntPrice - currentPrice // How much room ABOVE entry + +// Require minimum 2ร— ATR breathing room +const minSafeDistance = stopHunt.originalATR * 2.0 // Conservative multiplier + +if (slDistance < minSafeDistance) { + console.log(`โš ๏ธ Too close to stop zone:`) + console.log(` Distance: $${slDistance.toFixed(2)}`) + console.log(` Required: $${minSafeDistance.toFixed(2)} (2ร— ATR)`) + console.log(` Waiting for deeper reversal...`) + return false // Don't revenge yet +} + +console.log(`โœ… Safe distance confirmed: $${slDistance.toFixed(2)} > $${minSafeDistance.toFixed(2)}`) +``` + +**Visual Example (SOL @ ATR 0.60):** +``` +Original Stop: $142.48 (LONG stopped out) + โ†‘ + | 2ร— ATR = $1.20 minimum distance + โ†“ +Safe Zone: $141.28 or lower (revenge approved) +Current: $141.50 โŒ TOO CLOSE (only $0.78 away) +Current: $141.00 โœ… SAFE (1.48 away > 1.20 required) +``` + +**Multiplier Options:** +- **1.5ร— ATR**: Aggressive (tight entry, higher re-stop risk) +- **2.0ร— ATR**: Balanced (RECOMMENDED - proven with trailing stop system) +- **2.5ร— ATR**: Conservative (waits for deep reversal, may miss some) + +**Trade-offs:** +- โœ… **Reduces re-stop-outs:** Revenge enters with breathing room +- โœ… **ATR-adaptive:** SOL (high vol) vs BTC (low vol) automatically adjusted +- โŒ **Misses shallow reversals:** Some revenge opportunities skip if not deep enough +- โŒ **Tighter windows:** 4-hour revenge window = less time for deep reversal + +**Real-World Test (Nov 26 incident):** +- Original stop: $138.00 (LONG) +- Immediate reversal: $136.32 (1.68 distance = 2.8ร— ATR) +- Result: Would PASS validation โœ… +- Retest bounce: $137.50 (0.50 distance = 0.83ร— ATR) +- Result: Would BLOCK โŒ (prevented re-stop-out) + +**Data to Monitor:** +```sql +-- After implementing: Track how often we block vs success rate +SELECT + COUNT(*) as total_opportunities, + SUM(CASE WHEN blocked_by_distance THEN 1 ELSE 0 END) as blocked_count, + SUM(CASE WHEN revengeExecuted THEN 1 ELSE 0 END) as executed_count, + AVG(CASE WHEN revengeExecuted THEN revengePnL ELSE NULL END) as avg_pnl +FROM stop_hunt_analysis_view; +``` + +--- + +## Decision Required + +**Enhancement #1 (ADX Confirmation):** +- [ ] Option A: Fetch fresh ADX from cache (real-time validation) +- [ ] Option B: Use original ADX only (simple filter) +- [ ] Option C: Hybrid approach (both layers) + +**Enhancement #6 (SL Distance Validation):** +- [ ] Implement with 2.0ร— ATR multiplier (recommended) +- [ ] Use different multiplier: _____ร— ATR +- [ ] Skip this enhancement (too restrictive) + +**Questions for User:** +1. **ADX source:** Fresh cache vs original vs hybrid? +2. **TradingView data flow:** Are market-data alerts reliable (1-5min frequency)? +3. **Distance multiplier:** 2.0ร— ATR acceptable or adjust? +4. **Risk tolerance:** Prefer catching more revenge trades (looser filters) or better win rate (stricter filters)? + +--- + +## Next Steps After Decision + +1. **If approved:** Implement #1 and #6 with chosen options +2. **Deploy:** Docker rebuild with all 4 enhancements +3. **Monitor:** First quality 85+ stop-out will test all systems +4. **Analyze:** After 10-20 revenge trades, review success rate and adjust + +**Files Modified (Ready to Deploy):** +- โœ… `prisma/schema.prisma` (7 new fields) +- โœ… `lib/trading/stop-hunt-tracker.ts` (DB persistence + outcome tracking) +- โœ… `lib/trading/position-manager.ts` (revenge outcome hook + signalSource field) +- โณ Build successful, awaiting user decision on #1 and #6 + diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index 661e156..51f10cf 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -26,6 +26,7 @@ export interface ActiveTrade { atrAtEntry?: number // ATR value at entry for ATR-based trailing stop adxAtEntry?: number // ADX value at entry for trend strength multiplier signalQualityScore?: number // Quality score for stop hunt tracking + signalSource?: string // Trade source: 'tradingview', 'manual', 'stop_hunt_revenge' // Targets stopLossPrice: number @@ -1551,6 +1552,26 @@ export class PositionManager { maxAdversePrice: trade.maxAdversePrice, }) console.log('๐Ÿ’พ Trade saved to database') + + // ๐Ÿ”ฅ REVENGE OUTCOME TRACKING (Enhancement #4 - Nov 27, 2025) + // If this was a revenge trade, record the outcome in StopHunt table + if (trade.signalSource === 'stop_hunt_revenge') { + try { + const { getStopHuntTracker } = await import('./stop-hunt-tracker') + const tracker = getStopHuntTracker() + + await tracker.updateRevengeOutcome({ + revengeTradeId: trade.id, + outcome: reason as string, + pnl: trade.realizedPnL, + failedReason: reason === 'SL' ? 'stopped_again' : undefined + }) + console.log(`๐Ÿ”ฅ Revenge outcome recorded: ${reason} (P&L: $${trade.realizedPnL.toFixed(2)})`) + } catch (revengeError) { + console.error('โŒ Failed to record revenge outcome:', revengeError) + // Don't fail trade closure if revenge tracking fails + } + } } catch (dbError) { console.error('โŒ Failed to save trade exit to database:', dbError) // Don't fail the close if database fails diff --git a/lib/trading/stop-hunt-tracker.ts b/lib/trading/stop-hunt-tracker.ts index e9252dc..cad6a54 100644 --- a/lib/trading/stop-hunt-tracker.ts +++ b/lib/trading/stop-hunt-tracker.ts @@ -34,6 +34,17 @@ interface StopHuntRecord { revengeExpiresAt: Date highestPriceAfterStop: number | null lowestPriceAfterStop: number | null + + // Zone tracking persistence (Enhancement #10) + firstCrossTime: Date | null + lowestInZone: number | null + highestInZone: number | null + zoneResetCount: number + + // Revenge outcome tracking (Enhancement #4) + revengeOutcome: string | null + revengePnL: number | null + revengeFailedReason: string | null } let trackerInstance: StopHuntTracker | null = null @@ -197,7 +208,7 @@ export class StopHuntTracker { }) // Check revenge conditions (now requires sustained move, not just wick) - const shouldRevenge = this.shouldExecuteRevenge(stopHunt, currentPrice) + const shouldRevenge = await this.shouldExecuteRevenge(stopHunt, currentPrice) if (shouldRevenge) { console.log(`๐Ÿ”ฅ REVENGE CONDITIONS MET: ${stopHunt.symbol} ${stopHunt.direction.toUpperCase()}`) @@ -212,9 +223,14 @@ export class StopHuntTracker { /** * Determine if revenge entry conditions are met * + * ENHANCED (Nov 27, 2025): Database-persisted zone tracking + * - OLD: In-memory metadata lost on container restart + * - NEW: Persists firstCrossTime to database, survives restarts + * - Tracks zone entry/exit behavior for analysis + * * ENHANCED (Nov 26, 2025): Candle close confirmation * - OLD: Enters immediately when price crosses entry (gets stopped by retest) - * - NEW: Requires price to STAY below/above entry for 60+ seconds + * - NEW: Requires price to STAY below/above entry for 90+ seconds * - This simulates "candle close" confirmation without needing TradingView data * - Prevents entering on wicks that bounce back * @@ -222,37 +238,41 @@ export class StopHuntTracker { * - Original SHORT entry: $136.32, stopped at $138.00 * - Price wicked to $136.20 then bounced to $137.50 * - OLD system: Would enter $136.32, stop at $137.96, LOSE AGAIN - * - NEW system: Requires price below $136.32 for 60s before entry + * - NEW system: Requires price below $136.32 for 90s before entry * - Result: Enters safely after confirmation, rides to $144.50 (+$530!) */ - private shouldExecuteRevenge(stopHunt: StopHuntRecord, currentPrice: number): boolean { + private async shouldExecuteRevenge(stopHunt: StopHuntRecord, currentPrice: number): Promise { const { direction, stopHuntPrice, originalEntryPrice } = stopHunt - // Track how long price has been in revenge zone const now = Date.now() - const metadata = (stopHunt as any).revengeMetadata || {} if (direction === 'long') { // Long stopped out above entry โ†’ Revenge when price drops back below entry const crossedBackDown = currentPrice < originalEntryPrice * 0.995 // 0.5% buffer if (crossedBackDown) { - // Price is in revenge zone - track duration - if (!metadata.firstCrossTime) { - metadata.firstCrossTime = now - metadata.lowestInZone = currentPrice - // Update metadata in memory (not persisting to DB to avoid spam) - ;(stopHunt as any).revengeMetadata = metadata - console.log(` โฑ๏ธ LONG revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 60s confirmation...`) + // Price is in revenge zone - persist to database + if (!stopHunt.firstCrossTime) { + await this.prisma.stopHunt.update({ + where: { id: stopHunt.id }, + data: { + firstCrossTime: new Date(), + lowestInZone: currentPrice, + } + }) + console.log(` โฑ๏ธ LONG revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 90s confirmation...`) return false } // Update lowest price in zone - metadata.lowestInZone = Math.min(metadata.lowestInZone, currentPrice) - ;(stopHunt as any).revengeMetadata = metadata + const currentLowest = Math.min(stopHunt.lowestInZone || currentPrice, currentPrice) + await this.prisma.stopHunt.update({ + where: { id: stopHunt.id }, + data: { lowestInZone: currentLowest } + }) - // Check if we've been in zone for 90+ seconds (1.5 minutes - partial candle confirmation) - const timeInZone = now - metadata.firstCrossTime + // Check if we've been in zone for 90+ seconds (1.5 minutes) + const timeInZone = now - stopHunt.firstCrossTime.getTime() if (timeInZone >= 90000) { // 90 seconds = 1.5 minutes console.log(` โœ… LONG revenge: Price held below entry for ${(timeInZone/60000).toFixed(1)}min, confirmed!`) console.log(` Entry ${originalEntryPrice.toFixed(2)} โ†’ Current ${currentPrice.toFixed(2)}`) @@ -262,10 +282,17 @@ export class StopHuntTracker { return false } } else { - // Price left revenge zone - reset timer - if (metadata.firstCrossTime) { + // Price left revenge zone - reset timer and increment counter + if (stopHunt.firstCrossTime) { console.log(` โŒ LONG revenge: Price bounced back up to ${currentPrice.toFixed(2)}, resetting timer`) - ;(stopHunt as any).revengeMetadata = {} + await this.prisma.stopHunt.update({ + where: { id: stopHunt.id }, + data: { + firstCrossTime: null, + lowestInZone: null, + zoneResetCount: { increment: 1 } + } + }) } return false } @@ -274,21 +301,28 @@ export class StopHuntTracker { const crossedBackUp = currentPrice > originalEntryPrice * 1.005 // 0.5% buffer if (crossedBackUp) { - // Price is in revenge zone - track duration - if (!metadata.firstCrossTime) { - metadata.firstCrossTime = now - metadata.highestInZone = currentPrice - ;(stopHunt as any).revengeMetadata = metadata - console.log(` โฑ๏ธ SHORT revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 60s confirmation...`) + // Price is in revenge zone - persist to database + if (!stopHunt.firstCrossTime) { + await this.prisma.stopHunt.update({ + where: { id: stopHunt.id }, + data: { + firstCrossTime: new Date(), + highestInZone: currentPrice, + } + }) + console.log(` โฑ๏ธ SHORT revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 90s confirmation...`) return false } // Update highest price in zone - metadata.highestInZone = Math.max(metadata.highestInZone, currentPrice) - ;(stopHunt as any).revengeMetadata = metadata + const currentHighest = Math.max(stopHunt.highestInZone || currentPrice, currentPrice) + await this.prisma.stopHunt.update({ + where: { id: stopHunt.id }, + data: { highestInZone: currentHighest } + }) - // Check if we've been in zone for 90+ seconds (1.5 minutes - fast but filters wicks) - const timeInZone = now - metadata.firstCrossTime + // Check if we've been in zone for 90+ seconds (1.5 minutes) + const timeInZone = now - stopHunt.firstCrossTime.getTime() if (timeInZone >= 90000) { // 90 seconds = 1.5 minutes console.log(` โœ… SHORT revenge: Price held above entry for ${(timeInZone/60000).toFixed(1)}min, confirmed!`) console.log(` Entry ${originalEntryPrice.toFixed(2)} โ†’ Current ${currentPrice.toFixed(2)}`) @@ -298,10 +332,17 @@ export class StopHuntTracker { return false } } else { - // Price left revenge zone - reset timer - if (metadata.firstCrossTime) { + // Price left revenge zone - reset timer and increment counter + if (stopHunt.firstCrossTime) { console.log(` โŒ SHORT revenge: Price dropped back to ${currentPrice.toFixed(2)}, resetting timer`) - ;(stopHunt as any).revengeMetadata = {} + await this.prisma.stopHunt.update({ + where: { id: stopHunt.id }, + data: { + firstCrossTime: null, + highestInZone: null, + zoneResetCount: { increment: 1 } + } + }) } return false } @@ -414,6 +455,48 @@ Reversal Confirmed: Price crossed back through entry } } + /** + * Update revenge trade outcome when it closes + * Called by Position Manager when revenge trade exits + */ + async updateRevengeOutcome(params: { + revengeTradeId: string + outcome: string // "TP1", "TP2", "SL", "TRAILING_SL" + pnl: number + failedReason?: string + }): Promise { + try { + // Find stop hunt by revenge trade ID + const stopHunt = await this.prisma.stopHunt.findFirst({ + where: { revengeTradeId: params.revengeTradeId } + }) + + if (!stopHunt) { + console.log(`โš ๏ธ No stop hunt found for revenge trade ${params.revengeTradeId}`) + return + } + + await this.prisma.stopHunt.update({ + where: { id: stopHunt.id }, + data: { + revengeOutcome: params.outcome, + revengePnL: params.pnl, + revengeFailedReason: params.failedReason || null, + } + }) + + const emoji = params.outcome.includes('TP') ? 'โœ…' : 'โŒ' + console.log(`${emoji} REVENGE OUTCOME: ${params.outcome} (${params.pnl >= 0 ? '+' : ''}$${params.pnl.toFixed(2)})`) + + if (params.failedReason) { + console.log(` Reason: ${params.failedReason}`) + } + + } catch (error) { + console.error('โŒ Error updating revenge outcome:', error) + } + } + /** * Expire stop hunts that are past their 4-hour window */ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1641cb3..0f5f67e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -242,10 +242,22 @@ model StopHunt { highestPriceAfterStop Float? // Track if stop hunt reverses lowestPriceAfterStop Float? // Track if stop hunt reverses + // Zone tracking persistence (Nov 27, 2025 - Enhancement #10) + firstCrossTime DateTime? // When price entered revenge zone + lowestInZone Float? // Lowest price while in zone (LONG) + highestInZone Float? // Highest price while in zone (SHORT) + zoneResetCount Int @default(0) // How many times price left zone + + // Revenge outcome tracking (Nov 27, 2025 - Enhancement #4) + revengeOutcome String? // "TP1", "TP2", "SL", "TRAILING_SL", null (pending) + revengePnL Float? // Realized P&L from revenge trade + revengeFailedReason String? // Why revenge failed: "stopped_again", "chop", "insufficient_capital" + @@index([symbol]) @@index([revengeExecuted]) @@index([revengeWindowExpired]) @@index([stopHuntTime]) + @@index([revengeOutcome]) } // Performance analytics (daily aggregates)