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
This commit is contained in:
3
.github/prompts/general prompt.prompt.md
vendored
3
.github/prompts/general prompt.prompt.md
vendored
@@ -10,6 +10,9 @@ MANDATORY FIRST STEPS:
|
|||||||
- This is 4,400+ lines of critical context
|
- This is 4,400+ lines of critical context
|
||||||
- Every section matters - shortcuts cause financial losses
|
- Every section matters - shortcuts cause financial losses
|
||||||
- Pay special attention to Common Pitfalls (60+ documented bugs)
|
- 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
|
2. UNDERSTAND THE VERIFICATION ETHOS
|
||||||
- NEVER say "done", "fixed", "working" without 100% verification
|
- NEVER say "done", "fixed", "working" without 100% verification
|
||||||
|
|||||||
93
cleanup_trading_bot.sh
Executable file
93
cleanup_trading_bot.sh
Executable file
@@ -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"
|
||||||
341
docs/REVENGE_ENHANCEMENTS_EXPLAINED.md
Normal file
341
docs/REVENGE_ENHANCEMENTS_EXPLAINED.md
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ export interface ActiveTrade {
|
|||||||
atrAtEntry?: number // ATR value at entry for ATR-based trailing stop
|
atrAtEntry?: number // ATR value at entry for ATR-based trailing stop
|
||||||
adxAtEntry?: number // ADX value at entry for trend strength multiplier
|
adxAtEntry?: number // ADX value at entry for trend strength multiplier
|
||||||
signalQualityScore?: number // Quality score for stop hunt tracking
|
signalQualityScore?: number // Quality score for stop hunt tracking
|
||||||
|
signalSource?: string // Trade source: 'tradingview', 'manual', 'stop_hunt_revenge'
|
||||||
|
|
||||||
// Targets
|
// Targets
|
||||||
stopLossPrice: number
|
stopLossPrice: number
|
||||||
@@ -1551,6 +1552,26 @@ export class PositionManager {
|
|||||||
maxAdversePrice: trade.maxAdversePrice,
|
maxAdversePrice: trade.maxAdversePrice,
|
||||||
})
|
})
|
||||||
console.log('💾 Trade saved to database')
|
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) {
|
} catch (dbError) {
|
||||||
console.error('❌ Failed to save trade exit to database:', dbError)
|
console.error('❌ Failed to save trade exit to database:', dbError)
|
||||||
// Don't fail the close if database fails
|
// Don't fail the close if database fails
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ interface StopHuntRecord {
|
|||||||
revengeExpiresAt: Date
|
revengeExpiresAt: Date
|
||||||
highestPriceAfterStop: number | null
|
highestPriceAfterStop: number | null
|
||||||
lowestPriceAfterStop: 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
|
let trackerInstance: StopHuntTracker | null = null
|
||||||
@@ -197,7 +208,7 @@ export class StopHuntTracker {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Check revenge conditions (now requires sustained move, not just wick)
|
// 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) {
|
if (shouldRevenge) {
|
||||||
console.log(`🔥 REVENGE CONDITIONS MET: ${stopHunt.symbol} ${stopHunt.direction.toUpperCase()}`)
|
console.log(`🔥 REVENGE CONDITIONS MET: ${stopHunt.symbol} ${stopHunt.direction.toUpperCase()}`)
|
||||||
@@ -212,9 +223,14 @@ export class StopHuntTracker {
|
|||||||
/**
|
/**
|
||||||
* Determine if revenge entry conditions are met
|
* 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
|
* ENHANCED (Nov 26, 2025): Candle close confirmation
|
||||||
* - OLD: Enters immediately when price crosses entry (gets stopped by retest)
|
* - 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
|
* - This simulates "candle close" confirmation without needing TradingView data
|
||||||
* - Prevents entering on wicks that bounce back
|
* - Prevents entering on wicks that bounce back
|
||||||
*
|
*
|
||||||
@@ -222,37 +238,41 @@ export class StopHuntTracker {
|
|||||||
* - Original SHORT entry: $136.32, stopped at $138.00
|
* - Original SHORT entry: $136.32, stopped at $138.00
|
||||||
* - Price wicked to $136.20 then bounced to $137.50
|
* - Price wicked to $136.20 then bounced to $137.50
|
||||||
* - OLD system: Would enter $136.32, stop at $137.96, LOSE AGAIN
|
* - 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!)
|
* - 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<boolean> {
|
||||||
const { direction, stopHuntPrice, originalEntryPrice } = stopHunt
|
const { direction, stopHuntPrice, originalEntryPrice } = stopHunt
|
||||||
|
|
||||||
// Track how long price has been in revenge zone
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const metadata = (stopHunt as any).revengeMetadata || {}
|
|
||||||
|
|
||||||
if (direction === 'long') {
|
if (direction === 'long') {
|
||||||
// Long stopped out above entry → Revenge when price drops back below entry
|
// Long stopped out above entry → Revenge when price drops back below entry
|
||||||
const crossedBackDown = currentPrice < originalEntryPrice * 0.995 // 0.5% buffer
|
const crossedBackDown = currentPrice < originalEntryPrice * 0.995 // 0.5% buffer
|
||||||
|
|
||||||
if (crossedBackDown) {
|
if (crossedBackDown) {
|
||||||
// Price is in revenge zone - track duration
|
// Price is in revenge zone - persist to database
|
||||||
if (!metadata.firstCrossTime) {
|
if (!stopHunt.firstCrossTime) {
|
||||||
metadata.firstCrossTime = now
|
await this.prisma.stopHunt.update({
|
||||||
metadata.lowestInZone = currentPrice
|
where: { id: stopHunt.id },
|
||||||
// Update metadata in memory (not persisting to DB to avoid spam)
|
data: {
|
||||||
;(stopHunt as any).revengeMetadata = metadata
|
firstCrossTime: new Date(),
|
||||||
console.log(` ⏱️ LONG revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 60s confirmation...`)
|
lowestInZone: currentPrice,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(` ⏱️ LONG revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 90s confirmation...`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update lowest price in zone
|
// Update lowest price in zone
|
||||||
metadata.lowestInZone = Math.min(metadata.lowestInZone, currentPrice)
|
const currentLowest = Math.min(stopHunt.lowestInZone || currentPrice, currentPrice)
|
||||||
;(stopHunt as any).revengeMetadata = metadata
|
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)
|
// Check if we've been in zone for 90+ seconds (1.5 minutes)
|
||||||
const timeInZone = now - metadata.firstCrossTime
|
const timeInZone = now - stopHunt.firstCrossTime.getTime()
|
||||||
if (timeInZone >= 90000) { // 90 seconds = 1.5 minutes
|
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(` ✅ LONG revenge: Price held below entry for ${(timeInZone/60000).toFixed(1)}min, confirmed!`)
|
||||||
console.log(` Entry ${originalEntryPrice.toFixed(2)} → Current ${currentPrice.toFixed(2)}`)
|
console.log(` Entry ${originalEntryPrice.toFixed(2)} → Current ${currentPrice.toFixed(2)}`)
|
||||||
@@ -262,10 +282,17 @@ export class StopHuntTracker {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Price left revenge zone - reset timer
|
// Price left revenge zone - reset timer and increment counter
|
||||||
if (metadata.firstCrossTime) {
|
if (stopHunt.firstCrossTime) {
|
||||||
console.log(` ❌ LONG revenge: Price bounced back up to ${currentPrice.toFixed(2)}, resetting timer`)
|
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
|
return false
|
||||||
}
|
}
|
||||||
@@ -274,21 +301,28 @@ export class StopHuntTracker {
|
|||||||
const crossedBackUp = currentPrice > originalEntryPrice * 1.005 // 0.5% buffer
|
const crossedBackUp = currentPrice > originalEntryPrice * 1.005 // 0.5% buffer
|
||||||
|
|
||||||
if (crossedBackUp) {
|
if (crossedBackUp) {
|
||||||
// Price is in revenge zone - track duration
|
// Price is in revenge zone - persist to database
|
||||||
if (!metadata.firstCrossTime) {
|
if (!stopHunt.firstCrossTime) {
|
||||||
metadata.firstCrossTime = now
|
await this.prisma.stopHunt.update({
|
||||||
metadata.highestInZone = currentPrice
|
where: { id: stopHunt.id },
|
||||||
;(stopHunt as any).revengeMetadata = metadata
|
data: {
|
||||||
console.log(` ⏱️ SHORT revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 60s confirmation...`)
|
firstCrossTime: new Date(),
|
||||||
|
highestInZone: currentPrice,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(` ⏱️ SHORT revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 90s confirmation...`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update highest price in zone
|
// Update highest price in zone
|
||||||
metadata.highestInZone = Math.max(metadata.highestInZone, currentPrice)
|
const currentHighest = Math.max(stopHunt.highestInZone || currentPrice, currentPrice)
|
||||||
;(stopHunt as any).revengeMetadata = metadata
|
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)
|
// Check if we've been in zone for 90+ seconds (1.5 minutes)
|
||||||
const timeInZone = now - metadata.firstCrossTime
|
const timeInZone = now - stopHunt.firstCrossTime.getTime()
|
||||||
if (timeInZone >= 90000) { // 90 seconds = 1.5 minutes
|
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(` ✅ SHORT revenge: Price held above entry for ${(timeInZone/60000).toFixed(1)}min, confirmed!`)
|
||||||
console.log(` Entry ${originalEntryPrice.toFixed(2)} → Current ${currentPrice.toFixed(2)}`)
|
console.log(` Entry ${originalEntryPrice.toFixed(2)} → Current ${currentPrice.toFixed(2)}`)
|
||||||
@@ -298,10 +332,17 @@ export class StopHuntTracker {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Price left revenge zone - reset timer
|
// Price left revenge zone - reset timer and increment counter
|
||||||
if (metadata.firstCrossTime) {
|
if (stopHunt.firstCrossTime) {
|
||||||
console.log(` ❌ SHORT revenge: Price dropped back to ${currentPrice.toFixed(2)}, resetting timer`)
|
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
|
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<void> {
|
||||||
|
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
|
* Expire stop hunts that are past their 4-hour window
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -242,10 +242,22 @@ model StopHunt {
|
|||||||
highestPriceAfterStop Float? // Track if stop hunt reverses
|
highestPriceAfterStop Float? // Track if stop hunt reverses
|
||||||
lowestPriceAfterStop 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([symbol])
|
||||||
@@index([revengeExecuted])
|
@@index([revengeExecuted])
|
||||||
@@index([revengeWindowExpired])
|
@@index([revengeWindowExpired])
|
||||||
@@index([stopHuntTime])
|
@@index([stopHuntTime])
|
||||||
|
@@index([revengeOutcome])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Performance analytics (daily aggregates)
|
// Performance analytics (daily aggregates)
|
||||||
|
|||||||
Reference in New Issue
Block a user