- CRITICAL BUG: Drift SDK's position.entryPrice RECALCULATES after partial closes
- After TP1, Drift returns COST BASIS of remaining position, NOT original entry
- Example: SHORT @ 38.52 → TP1 @ 70% → Drift shows entry 40.01 (runner's basis)
- Result: Breakeven SL set .50 ABOVE actual entry = guaranteed loss if triggered
Fix:
- Always use database trade.entryPrice for breakeven calculations
- Drift's position.entryPrice = current state (runner cost basis)
- Database entryPrice = original entry (authoritative for breakeven)
- Added logging to show both values for verification
Impact:
- Every TP1 → breakeven transition was using WRONG price
- Locking in losses instead of true breakeven protection
- Financial loss bug affecting every trade with TP1
Files:
- lib/trading/position-manager.ts: Line 513 - use trade.entryPrice not position.entryPrice
- .github/copilot-instructions.md: Added Common Pitfall #43, deprecated old #44
Incident: Nov 16, 02:47 CET - SHORT entry 38.52, breakeven SL set at 40.01
Position closed by ghost detection before SL could trigger (lucky)
Problem: After TP1, SL moved to 'breakeven' but used database entry price,
which can differ from Drift's actual fill price by /bin/bash.10-0.15.
Example:
- DB stored: $139.18291 entry
- Drift actual: $139.07 entry
- SL set to: $139.18 (DB value)
Solution: Query position.entryPrice from Drift SDK when setting breakeven SL.
Drift SDK calculates entry from on-chain data (quoteAssetAmount / baseAssetAmount)
which is more accurate than database stored value.
Code change (lib/trading/position-manager.ts line ~511):
- Before: trade.stopLossPrice = trade.entryPrice
- After: trade.stopLossPrice = position.entryPrice || trade.entryPrice
Impact: TRUE breakeven protection - no slippage losses from price discrepancies.
Related: Common Pitfall #33 (orphaned position restoration with wrong entry price)
Implemented direct Telegram notifications when Position Manager closes positions:
- New helper: lib/notifications/telegram.ts with sendPositionClosedNotification()
- Integrated into Position Manager's executeExit() for all closure types
- Also sends notifications for ghost position cleanups
Notification includes:
- Symbol, direction, entry/exit prices
- P&L amount and percentage
- Position size and hold time
- Exit reason (TP1, TP2, SL, manual, ghost cleanup, etc.)
- MAE/MFE stats (max gain/drawdown during trade)
User request: Receive P&L notifications on position closures via Telegram bot
Previously: Only opening notifications via n8n workflow
Now: All closures (TP/SL/manual/ghost) send notifications directly
User feedback: Time-based cleanup (6 hours) too aggressive for legitimate long-running positions.
Drift API is the authoritative source of truth.
Changes:
- Removed cleanupStalePositions() method entirely
- Removed age-based Layer 1 from validatePositions()
- Updated Layer 2: Now verifies with Drift API before removing position
- All ghost detection now uses Drift blockchain as source of truth
Ghost detection methods:
- Layer 2: Queries Drift after 20 failed close attempts
- Layer 3: Queries Drift every 40 seconds during monitoring
- Periodic validation: Queries Drift every 5 minutes
Result: No premature closures, more reliable ghost detection.
PROBLEM: Ghost positions caused death spirals
- Position Manager tracked 2 positions that were actually closed
- Caused massive rate limit storms (100+ RPC calls)
- Telegram /status showed wrong data
- Periodic validation SKIPPED during rate limiting (fatal flaw)
- Created death spiral: ghosts → rate limits → validation skipped → more rate limits
USER REQUIREMENT: "bot has to work all the time especially when i am not on my laptop"
- System MUST be fully autonomous
- Must self-heal from ghost accumulation
- Cannot rely on manual container restarts
SOLUTION: 3-layer protection system (Nov 15, 2025)
**LAYER 1: Database-based age check**
- Runs every 5 minutes during validation
- Removes positions >6 hours old (likely ghosts)
- Doesn't require RPC calls - ALWAYS works even during rate limiting
- Prevents long-term ghost accumulation
**LAYER 2: Death spiral detector**
- Monitors close attempt failures during rate limiting
- After 20+ failed close attempts (40+ seconds), forces removal
- Breaks rate limit death spirals immediately
- Prevents infinite retry loops
**LAYER 3: Monitoring loop integration**
- Every 20 price checks (~40 seconds), verifies position exists on Drift
- Catches ghosts quickly during normal monitoring
- No 5-minute wait - immediate detection
- Silently skips check during RPC errors (no log spam)
**Key fixes:**
- validatePositions(): Now runs database cleanup FIRST before Drift checks
- Changed 'skipping validation' to 'using database-only validation'
- Added cleanupStalePositions() function (>6h age threshold)
- Added death spiral detection in executeExit() rate limit handler
- Added ghost check in checkTradeConditions() every 20 price updates
- All layers work together - if one fails, others protect
**Impact:**
- System now self-healing - no manual intervention needed
- Ghost positions cleaned within 40-360 seconds (depending on layer)
- Works even during severe rate limiting (Layer 1 always runs)
- Telegram /status always shows correct data
- User can be away from laptop - bot handles itself
**Testing:**
- Container restart cleared ghosts (as expected - DB shows all closed)
- New fixes will prevent future accumulation autonomously
Files changed:
- lib/trading/position-manager.ts (3 layers added)
- CRITICAL BUG: Position Manager only checked SL before TP1
- After TP1 hit, runner had NO stop loss protection
- Added separate SL check for runner (after TP1, before TP2)
- Runner now protected by profit-lock SL on Position Manager
Bug discovered: Runner position with no on-chain orders (below min size)
AND no software protection (SL check skipped after TP1).
Impact: 2.79 runner exposed to unlimited loss for 10+ minutes.
Fix: Added line 881-886 runner SL check in monitoring loop.
CRITICAL BUG: After TP1 filled, Position Manager updated internal
stopLossPrice but NEVER updated the actual on-chain orders on Drift.
Runner had NO real stop loss protection at breakeven.
Fix:
- After TP1 detection, call cancelAllOrders() to remove old orders
- Then call placeExitOrders() with updated SL at breakeven
- Place TP2 as new TP1 for runner (activates trailing at that level)
- Logs: 'Cancelling old exit orders', 'Placing new exit orders'
Impact: Runner now properly protected at breakeven on-chain, not just
in Position Manager tracking.
Found: User screenshot showed SL still at original levels (46.57)
after TP1 hit, when it should have been at entry (42.89).
- Added 5-minute validation interval to Position Manager
- Validates tracked positions against actual Drift state
- Auto-cleanup ghost positions (DB shows open but Drift shows closed)
- Prevents rate limit storms from accumulated ghost positions
- Logs detailed ghost detection: DB state vs Drift state
- Self-healing system requires no manual intervention
Implementation:
- scheduleValidation(): Sets 5-minute timer after monitoring starts
- validatePositions(): Queries each tracked position on Drift
- handleExternalClosure(): Reusable method for ghost cleanup
- Clears interval when monitoring stops
Benefits:
- Prevents ghost position accumulation
- Eliminates need for manual container restarts
- Minimal RPC overhead (1 check per 5 min per position)
- Addresses root cause (state management) not symptom (rate limits)
Fixes:
- Ghost positions from failed DB updates during external closures
- Container restart state sync issues
- Rate limit exhaustion from managing non-existent positions
CRITICAL BUG: Runner had NO stop loss protection between TP1 and TP2!
Impact: Runner position completely unprotected for entire TP1→TP2 window
Risk: Unlimited loss exposure on 25-30% remaining position
Example: SHORT at $141.31, TP1 closed 70% at $140.94, runner has SL at $140.89
- Price rises to $141.98 (way above SL) → NO STOP LOSS CHECK → Losses accumulate
- Should have closed at $140.89 with 0.3% profit locked
Fix: Added explicit stop loss check for runner state (TP1 hit but TP2 not hit)
Log: "🔴 RUNNER STOP LOSS" to distinguish from pre-TP1 stops
Files: lib/trading/position-manager.ts
- Renamed config variable to accurately reflect behavior (locks profit, not breakeven)
- Updated log messages to say 'lock +X% profit' instead of misleading 'breakeven'
- Maintains backwards compatibility (accepts old BREAKEVEN_TRIGGER_PERCENT env var)
- Updated .env with new variable name and explanatory comment
Why: Config was named 'breakeven' but actually locks profit at entry ± X%
For SHORT at $141.51 with 0.3% lock: SL moves to $141.08 (not breakeven $141.51)
This protects remaining runner position after TP1 by allowing small profit giveback
Files changed:
- config/trading.ts: Interface + default + env parsing
- lib/trading/position-manager.ts: Usage + log message
- .env: Variable rename with migration comment
**Problem 1: Rate Limit Cascade**
- Position Manager tried to close repeatedly, overwhelming Helius RPC (10 req/s limit)
- Base retry delay was too aggressive (2s → 4s → 8s)
- No graceful handling when 429 errors occur
**Problem 2: Orphaned Positions After Restart**
- Container restarts lost Position Manager state
- Positions marked 'closed' in DB but still open on Drift (failed close transactions)
- No cross-validation between database and actual Drift positions
**Solutions Implemented:**
1. **Increased retry delays (orders.ts)**:
- Base delay: 2s → 5s (progression now 5s → 10s → 20s)
- Reduces RPC pressure during rate limit situations
- Gives Helius time to recover between retries
- Documented Helius limits: 100 req/s burst, 10 req/s sustained (free tier)
2. **Startup position validation (init-position-manager.ts)**:
- Cross-checks last 24h of 'closed' trades against actual Drift positions
- If DB says closed but Drift shows open → reopens in DB to restore tracking
- Prevents unmonitored positions from existing after container restarts
- Logs detailed mismatch info for debugging
3. **Rate limit-aware exit handling (position-manager.ts)**:
- Detects 429 errors during position close
- Keeps trade in monitoring instead of removing it
- Natural retry on next price update (vs aggressive 2s loop)
- Prevents marking position as closed when transaction actually failed
**Impact:**
- Eliminates orphaned positions after restarts
- Reduces RPC pressure by 2.5x (5s vs 2s base delay)
- Graceful degradation under rate limits
- Position Manager continues monitoring even during temporary RPC issues
**Testing needed:**
- Monitor next container restart to verify position restoration works
- Check rate limit analytics after next close attempt
- Verify no more phantom 'closed' positions when Drift shows open
CRITICAL BUG FIX:
- Position Manager monitoring loop (every 2s) could trigger TP1/TP2 multiple times
- tp1Hit flag was set AFTER async executeExit() completed
- Multiple concurrent executeExit() calls happened before flag was set
- Result: Position closed 6 times (70% close × 6 = entire position + failed attempts)
ROOT CAUSE:
- Race window: ~0.5-1s between check and flag set
- Multiple monitoring loops entered if statement simultaneously
FIX APPLIED:
- Set tp1Hit = true IMMEDIATELY before calling executeExit()
- Same fix for tp2Hit flag
- Prevents concurrent execution by setting flag synchronously
EVIDENCE:
- Test trade at 04:47:09: TP1 triggered 6 times
- First close: Remaining $13.52 (correct 30%)
- Closes 2-6: Remaining $0.00 (closed entire position)
- Position Manager continued tracking $13.02 runner that didn't exist
IMPACT:
- User had unprotected $42.73 position (Position Manager tracking phantom)
- No TP/SL monitoring, no trailing stop
- Had to manually close position
Files changed:
- lib/trading/position-manager.ts: Move tp1Hit/tp2Hit flag setting before async calls
- Prevents race condition on all future trades
Testing required: Execute test trade and verify TP1 triggers only once.
Root Cause:
- Execute endpoint saved to database AFTER adding to Position Manager
- Database save failures were silently caught and ignored
- API returned success even when DB save failed
- Container restarts lost in-memory Position Manager state
- Result: Unprotected positions with no TP/SL monitoring
Fixes Applied:
1. Database-First Pattern (app/api/trading/execute/route.ts):
- MOVED createTrade() BEFORE positionManager.addTrade()
- If database save fails, return HTTP 500 with critical error
- Error message: 'CLOSE POSITION MANUALLY IMMEDIATELY'
- Position Manager only tracks database-persisted trades
- Ensures container restarts can restore all positions
2. Transaction Timeout (lib/drift/orders.ts):
- Added 30s timeout to confirmTransaction() in closePosition()
- Prevents API from hanging during network congestion
- Uses Promise.race() pattern for timeout enforcement
3. Telegram Error Messages (telegram_command_bot.py):
- Parse JSON for ALL responses (not just 200 OK)
- Extract detailed error messages from 'message' field
- Shows critical warnings to user immediately
- Fail-open: proceeds if analytics check fails
4. Position Manager (lib/trading/position-manager.ts):
- Move lastPrice update to TOP of monitoring loop
- Ensures /status endpoint always shows current price
Verification:
- Test trade cmhxj8qxl0000od076m21l58z executed successfully
- Database save completed BEFORE Position Manager tracking
- SL triggered correctly at -$4.21 after 15 minutes
- All protection systems working as expected
Impact:
- Eliminates risk of unprotected positions
- Provides immediate critical warnings if DB fails
- Enables safe container restarts with full position recovery
- Verified with live test trade on production
See: CRITICAL_INCIDENT_UNPROTECTED_POSITION.md for full incident report
Fixed Position Manager incorrectly treating position.size as USD when
Drift SDK actually returns base asset tokens (SOL, ETH, BTC).
Impact:
- FALSE TP1 detections (12.28 SOL misinterpreted as 2.28 USD)
- Stop loss moved to breakeven prematurely
- Runner system activated incorrectly
- Positions stuck in wrong state
Changes:
- Line 322: Convert position.size to USD: position.size * currentPrice
- Line 519: Calculate positionSizeUSD before comparison
- Line 558: Use positionSizeUSD directly (already in USD)
- Line 591: Save positionSizeUSD (no price multiplication needed)
Before: Compared 12.28 tokens < 1950 USD = 99.4% reduction = FALSE TP1
This was causing current trade to think TP1 hit when position is still 100% open.
BUG FOUND:
Line 558: tp2SizePercent: config.takeProfit2SizePercent || 100
When config.takeProfit2SizePercent = 0 (TP2-as-runner system), JavaScript's ||
operator treats 0 as falsy and falls back to 100, causing TP2 to close 100%
of remaining position instead of activating trailing stop.
IMPACT:
- On-chain orders placed correctly (line 481 uses ?? correctly)
- Position Manager reads from DB and expects TP2 to close position
- Result: User sees TWO take-profit orders instead of runner system
FIX:
Changed both tp1SizePercent and tp2SizePercent to use ?? operator:
- tp1SizePercent: config.takeProfit1SizePercent ?? 75
- tp2SizePercent: config.takeProfit2SizePercent ?? 0
This allows 0 value to be saved correctly for TP2-as-runner system.
VERIFICATION NEEDED:
Current open SHORT position in database has tp2SizePercent=100 from before
this fix. Next trade will use correct runner system.
- Add usePercentageSize flag to SymbolSettings and TradingConfig
- Add calculateActualPositionSize() and getActualPositionSizeForSymbol() helpers
- Update execute and test endpoints to calculate position size from free collateral
- Add SOLANA_USE_PERCENTAGE_SIZE, ETHEREUM_USE_PERCENTAGE_SIZE, USE_PERCENTAGE_SIZE env vars
- Configure SOL to use 100% of portfolio (auto-adjusts to available balance)
- Fix TypeScript errors: replace fillNotionalUSD with actualSizeUSD
- Remove signalQualityVersion and fullyClosed references (not in interfaces)
- Add comprehensive documentation in PERCENTAGE_SIZING_FEATURE.md
Benefits:
- Prevents insufficient collateral errors by using available balance
- Auto-scales positions as account grows/shrinks
- Maintains risk proportional to capital
- Flexible per-symbol configuration (SOL percentage, ETH fixed)
- Fix external closure P&L using tp1Hit flag instead of currentSize
- Add direction change detection to prevent false TP1 on signal flips
- Signal flips now recorded with accurate P&L as 'manual' exits
- Add retry logic with exponential backoff for Solana RPC rate limits
- Create /api/trading/cancel-orders endpoint for manual cleanup
- Improves data integrity for win/loss statistics
- Add market data cache service (5min expiry) for storing TradingView metrics
- Create /api/trading/market-data webhook endpoint for continuous data updates
- Add /api/analytics/reentry-check endpoint for validating manual trades
- Update execute endpoint to auto-cache metrics from incoming signals
- Enhance Telegram bot with pre-execution analytics validation
- Support --force flag to override analytics blocks
- Use fresh ADX/ATR/RSI data when available, fallback to historical
- Apply performance modifiers: -20 for losing streaks, +10 for winning
- Minimum re-entry score 55 (vs 60 for new signals)
- Fail-open design: proceeds if analytics unavailable
- Show data freshness and source in Telegram responses
- Add comprehensive setup guide in docs/guides/REENTRY_ANALYTICS_QUICKSTART.md
Phase 1 implementation for smart manual trade validation.
- Add ATR-based dynamic TP2 scaling from 0.7% to 3.0% based on volatility
- New config options: useAtrBasedTargets, atrMultiplierForTp2, minTp2Percent, maxTp2Percent
- Enhanced settings UI with ATR controls and updated risk calculator
- Fix external closure P&L calculation using unrealized P&L instead of volatile current price
- Update execute and test endpoints to use calculateDynamicTp2() function
- Maintain 25% runner system for capturing extended moves (4-5% targets)
- Add environment variables for ATR-based configuration
- Better P&L accuracy for manual position closures
- Fix P&L calculation in Position Manager to use actual entry vs exit price instead of SDK's potentially incorrect realizedPnL
- Calculate actual profit percentage and apply to closed position size for accurate dollar amounts
- Update database record for last trade from incorrect 6.58 to actual .66 P&L
- Update .github/copilot-instructions.md to reflect TP2-as-runner system changes
- Document 25% runner system (5x larger than old 5%) with ATR-based trailing
- Add critical P&L calculation pattern to common pitfalls section
- Mark Phase 5 complete in development roadmap
CHANGE: TP2 now activates trailing stop on full 25% remaining instead
of closing 80% and leaving 5% runner.
Benefits:
- 5x larger runner (25% vs 5%) = 25 vs 05 on 100 position
- Eliminates Drift minimum size issues completely
- Simplifies logic - no more canUseRunner() viability checks
- Better R:R on extended moves
New flow:
- TP1 (+0.4%): Close 75%, keep 25%
- TP2 (+0.7%): Skip close, activate trailing stop on full 25%
- Runner: 25% with ATR-based trailing (0.25-0.9%)
Config change: takeProfit2SizePercent: 80 → 0
Position Manager: Remove canUseRunner logic, activate trailing at TP2 hit
PROBLEM: Runner never activated because Drift force-closes positions below
minimum size. TP2 would close 80% leaving 5% runner (~$105), but Drift
automatically closed the entire position.
SOLUTION:
1. Created runner-calculator.ts with canUseRunner() to check if remaining
size would be above Drift minimums BEFORE executing TP2 close
2. If runner not viable: Skip TP2 close entirely, activate trailing stop
on full 25% remaining (from TP1)
3. If runner viable: Execute TP2 as normal, activate trailing on 5%
Benefits:
- Runner system will now actually work for viable position sizes
- Positions that are too small won't try to force-close below minimums
- Better logs showing why runner did/didn't activate
- Trailing stop works on larger % if runner not viable (better R:R)
Example: $2100 position → $525 after TP1 → $105 runner = VIABLE
$4 ETH position → $1 after TP1 → $0.20 runner = NOT VIABLE
Runner will trail with ATR-based dynamic % (0.25-0.9%) below peak price.
- Position Manager was calculating P&L using tracked size instead of actual on-chain size
- Example: Tracked 100, actual 0.04 SOL () = -99.63% false loss instead of -0.32%
- Fixed external closure detection to use position.size * currentPrice as lastKnownSize
- Manually corrected phantom trade P&L from -092.25 to /bin/bash
- Total P&L corrected: -013.92 → +8.33 (accurate)
- Prevents all future phantom/mismatch trades from wildly incorrect P&L
Modified:
- lib/trading/position-manager.ts lines 421-445 (external closure P&L calculation)
**Feature: Position Scaling**
Allows adding to existing profitable positions when high-quality signals confirm trend strength.
**Configuration (config/trading.ts):**
- enablePositionScaling: false (disabled by default - enable after testing)
- minScaleQualityScore: 75 (higher bar than initial 60)
- minProfitForScale: 0.4% (must be at/past TP1)
- maxScaleMultiplier: 2.0 (max 200% of original size)
- scaleSizePercent: 50% (add 50% of original position)
- minAdxIncrease: 5 (ADX must strengthen)
- maxPricePositionForScale: 70% (don't chase resistance)
**Validation Logic (check-risk endpoint):**
Same-direction signal triggers scaling check if enabled:
1. Quality score ≥75 (stronger than initial entry)
2. Position profitable ≥0.4% (at/past TP1)
3. ADX increased ≥5 points (trend strengthening)
4. Price position <70% (not near resistance)
5. Total size <2x original (risk management)
6. Returns 'allowed: true, reason: Position scaling' if all pass
**Execution (execute endpoint):**
- Opens additional position at scale size (50% of original)
- Updates ActiveTrade: timesScaled, totalScaleAdded, currentSize
- Tracks originalAdx from first entry for comparison
- Returns 'action: scaled' with scale details
**ActiveTrade Interface:**
Added fields:
- originalAdx?: number (for scaling validation)
- timesScaled?: number (track scaling count)
- totalScaleAdded?: number (total USD added)
**Example Scenario:**
1. LONG SOL at $176 (quality: 45, ADX: 13.4) - weak but entered
2. Price hits $176.70 (+0.4%) - at TP1
3. New LONG signal (quality: 78, ADX: 19) - strong confirmation
4. Scaling validation: ✅ Quality 78 ✅ Profit +0.4% ✅ ADX +5.6 ✅ Price 68%
5. Adds 50% more position at $176.70
6. Total position: 150% of original size
**Conservative Design:**
- Disabled by default (requires manual enabling)
- Only scales INTO profitable positions (never averaging down)
- Requires significant quality improvement (75 vs 60)
- Requires trend confirmation (ADX increase)
- Hard cap at 2x original size
- Won't chase near resistance levels
**Next Steps:**
1. Enable in settings: ENABLE_POSITION_SCALING=true
2. Test with small positions first
3. Monitor data: do scaled positions outperform?
4. Adjust thresholds based on results
**Safety:**
- All existing duplicate prevention logic intact
- Flip logic unchanged (still requires quality check)
- Position Manager tracks scaling state
- Can be toggled on/off without code changes
**Root Causes:**
1. Auto-flip logic could create phantom trades if close failed
2. Position size mismatches (0.01 SOL vs 11.92 SOL expected) not caught
3. Multiple trades for same symbol+direction in database
**Preventive Measures:**
1. **Startup Validation (lib/startup/init-position-manager.ts)**
- Validates all open trades against Drift positions on startup
- Auto-closes phantom trades with <50% expected size
- Logs size mismatches for manual review
- Prevents Position Manager from tracking ghost positions
2. **Duplicate Position Prevention (app/api/trading/execute/route.ts)**
- Blocks opening same-direction position on same symbol
- Returns 400 error if duplicate detected
- Only allows auto-flip (opposite direction close + open)
3. **Runtime Phantom Detection (lib/trading/position-manager.ts)**
- Checks position size every 2s monitoring cycle
- Auto-closes if size ratio <50% (extreme mismatch)
- Logs as 'manual' exit with AUTO_CLEANUP tx
- Removes from monitoring immediately
4. **Quality Score Fix (app/api/trading/check-risk/route.ts)**
- Hardcoded minScore=60 (removed non-existent config reference)
**Prevention Summary:**
- ✅ Startup validation catches historical phantoms
- ✅ Duplicate check prevents new phantoms
- ✅ Runtime detection catches size mismatches <30s after they occur
- ✅ All three layers work together for defense-in-depth
Issue: User had LONG (phantom) + SHORT (undersized 0.01 SOL vs 11.92 expected)
Fix: Both detected and closed, bot now clean with 0 active trades
- Add SymbolSettings interface with enabled/positionSize/leverage fields
- Implement per-symbol ENV variables (SOLANA_*, ETHEREUM_*)
- Add SOL and ETH sections to settings UI with enable/disable toggles
- Add symbol-specific test buttons (SOL LONG/SHORT, ETH LONG/SHORT)
- Update execute and test endpoints to check symbol enabled status
- Add real-time risk/reward calculator per symbol
- Rename 'Position Sizing' to 'Global Fallback' for clarity
- Fix position manager P&L calculation for externally closed positions
- Fix zero P&L bug affecting 12 historical trades
- Add SQL scripts for recalculating historical P&L data
- Move archive TypeScript files to .archive to fix build
Defaults:
- SOL: 10 base × 10x leverage = 100 notional (profit trading)
- ETH: base × 1x leverage = notional (data collection)
- Global: 10 × 10x for BTC and other symbols
Configuration priority: Per-symbol ENV > Market config > Global ENV > Defaults
CRITICAL BUG: Position Manager was using current price to determine exit reason,
but on-chain orders filled at a DIFFERENT price in the past!
Example: LONG entry $184.55, TP1 filled at $184.66, but when Position Manager
checked later (price dropped), it saw currentPrice < TP1 and defaulted to 'SL'
Result: Profitable trades incorrectly labeled as SL exits in database
Fix:
- Use trade.tp1Hit and trade.tp2Hit flags to determine exit reason
- If no TP flags set, use realized P&L to distinguish:
- Profit >0.5% = TP1 filled
- Negative P&L = SL filled
- Remove duplicate P&L calculation
This ensures exit reasons match actual on-chain order fills
Added Maximum Favorable/Adverse Excursion tracking:
- Track maxFavorableExcursion: best profit % reached during trade
- Track maxAdverseExcursion: worst loss % reached during trade
- Track maxFavorablePrice and maxAdversePrice
- Update every price check (2s interval)
- Save to database on trade exit for optimization analysis
Benefits:
- Identify if TP levels are too conservative (MFE consistently higher)
- Determine if SL is too tight (MAE < SL but trade recovers)
- Optimize runner size based on how often MFE >> TP2
- Data-driven exit strategy tuning after collecting 10-20 trades
Display in monitoring logs: Shows MFE/MAE % every 20 seconds
CRITICAL: After TP1 closes 75%, the on-chain stop loss orders were NOT being updated
- Position Manager was tracking new SL price internally but not updating Drift orders
- Old SL orders (e.g., $181.69) remained active even after TP1 at $185.28
- This prevented the 'move SL to breakeven after TP1' logic from working
Fix:
- After TP1 hits, cancel ALL old orders on-chain
- Place new SL orders at updated price (breakeven + configured %)
- Place remaining TP2 order for the 25% runner position
- Maintains dual-stop system if enabled
Result: SL will now actually move up on Drift UI after TP1 fires
- Convert closePosition.closedSize (base asset) to USD when updating trade.currentSize
- Fix conversion when position.size detected from Drift: set currentSize = position.size * currentPrice
- Prevent trade.currentSize from being reduced to tiny values due to unit mismatch
- Position Manager now detects TP1/TP2 fills by monitoring position size reductions instead of entry price mismatches
- When position size reduces by ~75%, marks TP1 as filled and updates currentSize
- When position size reduces by ~95%, marks TP2 as filled and activates trailing stop for 5% runner
- Entry price mismatch check now skipped after TP fills (Drift shows weighted average entry price after partial closes)
- Fixes bug where runners were incorrectly closed after TP1/TP2 fired on-chain
- Adds grace period for new trades (<30s) to avoid false positives during blockchain propagation delays
- This unblocks Phase 1 data collection for signal quality optimization (need 10+ trades with MAE/MFE data)
- Add qualityScore to ExecuteTradeResponse interface and response object
- Update analytics page to always show Signal Quality card (N/A if unavailable)
- Fix n8n workflow to pass context metrics and qualityScore to execute endpoint
- Fix timezone in Telegram notifications (Europe/Berlin)
- Fix symbol normalization in /api/trading/close endpoint
- Update Drift ETH-PERP minimum order size (0.002 ETH not 0.01)
- Add transaction confirmation to closePosition() to prevent phantom closes
- Add 30-second grace period for new trades in Position Manager
- Fix execution order: database save before Position Manager.addTrade()
- Update copilot instructions with transaction confirmation pattern
Bug: Position Manager was comparing ANY position on the symbol to the trade being
tracked, without verifying entry price match. When a new position opened, it would
think the old tracked trade 'closed externally' and cancel ALL orders - including
the new position's exit orders.
Fix: Added entry price verification (0.5% tolerance). If position entry price doesn't
match the tracked trade, mark the old trade as 'lost tracking' and remove from
monitoring WITHOUT cancelling orders (they belong to the new position).
This prevents the catastrophic scenario where exit orders are repeatedly cancelled,
leaving positions unprotected.
Bug: MAE/MFE was tracked in memory during trades but not saved to database on exit
Cause: updateTradeExit() wasn't receiving or saving MAE/MFE parameters
Changes:
- Added MAE/MFE fields to UpdateTradeExitParams interface
- Modified updateTradeExit() to save maxFavorableExcursion, maxAdverseExcursion, maxFavorablePrice, maxAdversePrice
- Updated both updateTradeExit() calls in Position Manager to pass MAE/MFE values
- Enhanced exit logging to show final MAE/MFE percentages
Impact: Future trades will now properly save MAE/MFE data for analytics
Note: Past 2 trades (from before this fix) don't have MAE/MFE saved
- Detect on-chain TP2 fills in size mismatch logic and set tp2Hit flag
- Position size thresholds: <30% = TP1, <10% = TP2 (prevents runner from being closed)
- Ensures runner (5-20%) trails properly instead of being market-closed immediately