diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 48f4a6d..5032733 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,7 +19,7 @@ - BTC and other symbols fall back to global settings (`MAX_POSITION_SIZE_USD`, `LEVERAGE`) - **Priority:** Per-symbol ENV → Market config → Global ENV → Defaults -**Signal Quality System:** Filters trades based on 5 metrics (ATR, ADX, RSI, volumeRatio, pricePosition) scored 0-100. Minimum score threshold configurable via `MIN_SIGNAL_QUALITY_SCORE` env var (default: 65, editable via settings page). Scores stored in database for future optimization. +**Signal Quality System:** Filters trades based on 5 metrics (ATR, ADX, RSI, volumeRatio, pricePosition) scored 0-100. Only trades scoring 60+ are executed. Scores stored in database for future optimization. **Timeframe-Aware Scoring:** Signal quality thresholds adjust based on timeframe (5min vs daily): - 5min: ADX 12+ trending (vs 18+ for daily), ATR 0.2-0.7% healthy (vs 0.4%+ for daily) @@ -30,30 +30,12 @@ **Manual Trading via Telegram:** Send plain-text messages like `long sol`, `short eth`, `long btc` to open positions instantly (bypasses n8n, calls `/api/trading/execute` directly with preset healthy metrics). -## Recent Critical Fixes (2024-11-10) - -### Runner System - Three Cascading Bugs Fixed -The TP2-as-runner feature was broken by three separate bugs: - -1. **P&L Calculation Bug (65x inflation)** - `lib/drift/orders.ts`, `lib/trading/position-manager.ts` - - Calculated P&L on notional ($2,100) instead of collateral ($210) - - Database showed +$1,345, reality was -$806 loss - - Fix: `collateralUSD = notional / leverage`, calculate P&L on collateral - -2. **Post-TP1 Logic Bug** - `lib/trading/position-manager.ts` lines 1010-1030 - - Placed TP order at TP2 price after TP1 hit (closed position instead of trailing) - - Fix: Check `if (config.takeProfit2SizePercent === 0)` to skip TP orders - -3. **JavaScript || Operator Bug** - `app/api/trading/execute/route.ts`, `test/route.ts` - - `config.takeProfit2SizePercent || 100` treated 0 as falsy → returned 100 - - Fix: Use `??` (nullish coalescing) instead of `||` for numeric defaults - -### Anti-Chop Filter V2 - Range-Bound Detection -- **Problem:** Flip-flop trades in sideways markets (stopped out in 8-24 seconds) -- **Fix:** -25 points when price position <40% AND ADX <25 (both conditions) -- **Location:** `lib/trading/signal-quality.ts` lines 145-165 -- **Impact:** Win rate 43.8% → 55.6%, profit per trade +86% -- **Backtest:** Would have blocked all 3 flip-flop trades from today +**Re-Entry Analytics System:** Manual trades are validated before execution using fresh TradingView data: +- Market data cached from TradingView signals (5min expiry) +- `/api/analytics/reentry-check` scores re-entry based on fresh metrics + recent performance +- Telegram bot blocks low-quality re-entries unless `--force` flag used +- Uses real TradingView ADX/ATR/RSI when available, falls back to historical data +- Penalty for recent losing trades, bonus for winning streaks ## Critical Components @@ -76,14 +58,11 @@ scoreSignalQuality({ **Price position penalties (all timeframes):** - Long at 90-95%+ range: -15 to -30 points (chasing highs) - Short at <5-10% range: -15 to -30 points (chasing lows) -- **ANTI-CHOP (v2024-11-10):** Price position <40% + ADX <25 = -25 points (RANGE-BOUND CHOP) - - Prevents flip-flop losses from entering range extremes - - Targets sideways markets where price is low in range but trend is weak - - Backtest: 43.8% → 55.6% win rate, 86% higher profit per trade +- Prevents flip-flop losses from entering range extremes **Key behaviors:** - Returns score 0-100 and detailed breakdown object -- Minimum score threshold configurable via `config.minSignalQualityScore` (default: 65) +- Minimum score 60 required to execute trade - Called by both `/api/trading/check-risk` and `/api/trading/execute` - Scores saved to database for post-trade analysis @@ -115,18 +94,23 @@ await positionManager.addTrade(activeTrade) **Manual trade commands via plain text:** ```python # User sends plain text message (not slash commands) -"long sol" → Opens SOL-PERP long position -"short eth" → Opens ETH-PERP short position -"long btc" → Opens BTC-PERP long position +"long sol" → Validates via analytics, then opens SOL-PERP long +"short eth" → Validates via analytics, then opens ETH-PERP short +"long btc --force" → Skips analytics validation, opens BTC-PERP long immediately ``` **Key behaviors:** - MessageHandler processes all text messages (not just commands) - Maps user-friendly symbols (sol, eth, btc) to Drift format (SOL-PERP, etc.) -- Calls `/api/trading/execute` directly with preset healthy metrics (ATR=1.0, ADX=25, RSI=50, volumeRatio=1.2) +- **Analytics validation:** Calls `/api/analytics/reentry-check` before execution + - Blocks trades with score <55 unless `--force` flag used + - Uses fresh TradingView data (<5min old) when available + - Falls back to historical metrics with penalty + - Considers recent trade performance (last 3 trades) +- Calls `/api/trading/execute` directly with preset healthy metrics (ATR=0.45, ADX=32, RSI=58/42) - Bypasses n8n workflow and TradingView requirements - 60-second timeout for API calls -- Responds with trade confirmation or error message +- Responds with trade confirmation or analytics rejection message **Status command:** ```python @@ -152,6 +136,7 @@ const health = await driftService.getAccountHealth() - `openPosition()` - Opens market position with transaction confirmation - `closePosition()` - Closes position with transaction confirmation - `placeExitOrders()` - Places TP/SL orders on-chain +- `cancelAllOrders()` - Cancels all reduce-only orders for a market **CRITICAL: Transaction Confirmation Pattern** Both `openPosition()` and `closePosition()` MUST confirm transactions on-chain: @@ -168,6 +153,46 @@ console.log('✅ Transaction confirmed on-chain') ``` Without this, the SDK returns signatures for transactions that never execute, causing phantom trades/closes. +**CRITICAL: Drift SDK position.size is USD, not tokens** +The Drift SDK returns `position.size` as USD notional value, NOT token quantity: +```typescript +// WRONG: Multiply by price (inflates by 156x for SOL at $157) +const positionSizeUSD = position.size * currentPrice + +// CORRECT: Use directly as USD value +const positionSizeUSD = Math.abs(position.size) +``` +This affects Position Manager's TP1 detection - if calculated incorrectly, TP1 will never trigger because expected size won't match actual size. + +**Solana RPC Rate Limiting with Exponential Backoff** +Solana RPC endpoints return 429 errors under load. Always use retry logic for order operations: +```typescript +export async function retryWithBackoff( + operation: () => Promise, + maxRetries: number = 3, + initialDelay: number = 2000 +): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await operation() + } catch (error: any) { + if (error?.message?.includes('429') && attempt < maxRetries - 1) { + const delay = initialDelay * Math.pow(2, attempt) + console.log(`⏳ Rate limited, retrying in ${delay/1000}s... (attempt ${attempt + 1}/${maxRetries})`) + await new Promise(resolve => setTimeout(resolve, delay)) + continue + } + throw error + } + } + throw new Error('Max retries exceeded') +} + +// Usage in cancelAllOrders +await retryWithBackoff(() => driftClient.cancelOrders(...)) +``` +Without this, order cancellations fail silently during TP1→breakeven order updates, leaving ghost orders that cause incorrect fills. + **Dual Stop System** (USE_DUAL_STOPS=true): ```typescript // Soft stop: TRIGGER_LIMIT at -1.5% (avoids wicks) @@ -246,14 +271,16 @@ const driftSymbol = normalizeTradingViewSymbol(body.symbol) 7. Add to Position Manager if applicable **Key endpoints:** -- `/api/trading/execute` - Main entry point from n8n (production, requires auth) +- `/api/trading/execute` - Main entry point from n8n (production, requires auth), **auto-caches market data** - `/api/trading/check-risk` - Pre-execution validation (duplicate check, quality score, **per-symbol cooldown**, rate limits, **symbol enabled check**) - `/api/trading/test` - Test trades from settings UI (no auth required, **respects symbol enable/disable**) -- `/api/trading/close` - Manual position closing +- `/api/trading/close` - Manual position closing (requires symbol normalization) +- `/api/trading/cancel-orders` - **Manual order cleanup** (for stuck/ghost orders after rate limit failures) - `/api/trading/positions` - Query open positions from Drift -- `/api/trading/sync-positions` - **Re-sync Position Manager with actual Drift positions** (no auth, for recovery from partial fills/restarts) +- `/api/trading/market-data` - Webhook for TradingView market data updates (GET for debug, POST for data) - `/api/settings` - Get/update config (writes to .env file, **includes per-symbol settings**) - `/api/analytics/last-trade` - Fetch most recent trade details for dashboard (includes quality score) +- `/api/analytics/reentry-check` - **Validate manual re-entry** with fresh TradingView data + recent performance - `/api/analytics/version-comparison` - Compare performance across signal quality logic versions (v1/v2/v3) - `/api/restart` - Create restart flag for watch-restart.sh script @@ -401,102 +428,68 @@ docker exec trading-bot-postgres psql -U postgres -d trading_bot_v4 -c "\dt" 6. **Type errors with Prisma:** The Trade type from Prisma is only available AFTER `npx prisma generate` - use explicit types or `// @ts-ignore` carefully -7. **Hardcoded config values:** NEVER use hardcoded values for configurable settings in API endpoints. Always read from `config.minSignalQualityScore` or similar config properties. Settings changed via the UI won't take effect if endpoints use hardcoded values. +7. **Quality score duplication:** Signal quality calculation exists in BOTH `check-risk` and `execute` endpoints - keep logic synchronized -8. **Quality score duplication:** Signal quality calculation exists in BOTH `check-risk` and `execute` endpoints - keep logic synchronized - -9. **TP2-as-Runner configuration:** +8. **TP2-as-Runner configuration:** - `takeProfit2SizePercent: 0` means "TP2 activates trailing stop, no position close" - This creates 25% runner (vs old 5% system) for better profit capture - `TAKE_PROFIT_2_PERCENT=0.7` sets TP2 trigger price, `TAKE_PROFIT_2_SIZE_PERCENT` should be 0 - Settings UI correctly shows "TP2 activates trailing stop" instead of size percentage -10. **P&L calculation CRITICAL:** Use actual entry vs exit price calculation, not SDK values: +9. **P&L calculation CRITICAL:** Use actual entry vs exit price calculation, not SDK values: ```typescript const profitPercent = this.calculateProfitPercent(trade.entryPrice, exitPrice, trade.direction) const actualRealizedPnL = (closedSizeUSD * profitPercent) / 100 trade.realizedPnL += actualRealizedPnL // NOT: result.realizedPnL from SDK ``` -11. **Transaction confirmation CRITICAL:** Both `openPosition()` AND `closePosition()` MUST call `connection.confirmTransaction()` after `placePerpOrder()`. Without this, the SDK returns transaction signatures that aren't confirmed on-chain, causing "phantom trades" or "phantom closes". Always check `confirmation.value.err` before proceeding. +10. **Transaction confirmation CRITICAL:** Both `openPosition()` AND `closePosition()` MUST call `connection.confirmTransaction()` after `placePerpOrder()`. Without this, the SDK returns transaction signatures that aren't confirmed on-chain, causing "phantom trades" or "phantom closes". Always check `confirmation.value.err` before proceeding. -12. **Execution order matters:** When creating trades via API endpoints, the order MUST be: +11. **Execution order matters:** When creating trades via API endpoints, the order MUST be: 1. Open position + place exit orders 2. Save to database (`createTrade()`) 3. Add to Position Manager (`positionManager.addTrade()`) If Position Manager is added before database save, race conditions occur where monitoring checks before the trade exists in DB. -13. **New trade grace period:** Position Manager skips "external closure" detection for trades <30 seconds old because Drift positions take 5-10 seconds to propagate after opening. Without this grace period, new positions are immediately detected as "closed externally" and cancelled. +12. **New trade grace period:** Position Manager skips "external closure" detection for trades <30 seconds old because Drift positions take 5-10 seconds to propagate after opening. Without this grace period, new positions are immediately detected as "closed externally" and cancelled. -14. **Drift minimum position sizes:** Actual minimums differ from documentation: +13. **Drift minimum position sizes:** Actual minimums differ from documentation: - SOL-PERP: 0.1 SOL (~$5-15 depending on price) - ETH-PERP: 0.01 ETH (~$38-40 at $4000/ETH) - BTC-PERP: 0.0001 BTC (~$10-12 at $100k/BTC) Always calculate: `minOrderSize × currentPrice` must exceed Drift's $4 minimum. Add buffer for price movement. -15. **Exit reason detection bug:** Position Manager was using current price to determine exit reason, but on-chain orders filled at a DIFFERENT price in the past. Now uses `trade.tp1Hit` / `trade.tp2Hit` flags and realized P&L to correctly identify whether TP1, TP2, or SL triggered. Prevents profitable trades being mislabeled as "SL" exits. +14. **Exit reason detection bug:** Position Manager was using current price to determine exit reason, but on-chain orders filled at a DIFFERENT price in the past. Now uses `trade.tp1Hit` / `trade.tp2Hit` flags and realized P&L to correctly identify whether TP1, TP2, or SL triggered. Prevents profitable trades being mislabeled as "SL" exits. -16. **Per-symbol cooldown:** Cooldown period is per-symbol, NOT global. ETH trade at 10:00 does NOT block SOL trade at 10:01. Each coin (SOL/ETH/BTC) has independent cooldown timer to avoid missing opportunities on different assets. +15. **Per-symbol cooldown:** Cooldown period is per-symbol, NOT global. ETH trade at 10:00 does NOT block SOL trade at 10:01. Each coin (SOL/ETH/BTC) has independent cooldown timer to avoid missing opportunities on different assets. -17. **Timeframe-aware scoring crucial:** Signal quality thresholds MUST adjust for 5min vs higher timeframes: +16. **Timeframe-aware scoring crucial:** Signal quality thresholds MUST adjust for 5min vs higher timeframes: - 5min charts naturally have lower ADX (12-22 healthy) and ATR (0.2-0.7% healthy) than daily charts - Without timeframe awareness, valid 5min breakouts get blocked as "low quality" - Anti-chop filter applies -20 points for extreme sideways regardless of timeframe - Always pass `timeframe` parameter from TradingView alerts to `scoreSignalQuality()` -18. **Price position chasing causes flip-flops:** Opening longs at 90%+ range or shorts at <10% range reliably loses money: +17. **Price position chasing causes flip-flops:** Opening longs at 90%+ range or shorts at <10% range reliably loses money: - Database analysis showed overnight flip-flop losses all had price position 9-94% (chasing extremes) - These trades had valid ADX (16-18) but entered at worst possible time - Quality scoring now penalizes -15 to -30 points for range extremes - Prevents rapid reversals when price is already overextended -19. **TradingView ADX minimum for 5min:** Set ADX filter to 15 (not 20+) in TradingView alerts for 5min charts: +18. **TradingView ADX minimum for 5min:** Set ADX filter to 15 (not 20+) in TradingView alerts for 5min charts: - Higher timeframes can use ADX 20+ for strong trends - 5min charts need lower threshold to catch valid breakouts - Bot's quality scoring provides second-layer filtering with context-aware metrics - Two-stage filtering (TradingView + bot) prevents both overtrading and missing valid signals -20. **Prisma Decimal type handling:** Raw SQL queries return Prisma `Decimal` objects, not plain numbers: +19. **Prisma Decimal type handling:** Raw SQL queries return Prisma `Decimal` objects, not plain numbers: - Use `any` type for numeric fields in `$queryRaw` results: `total_pnl: any` - Convert with `Number()` before returning to frontend: `totalPnL: Number(stat.total_pnl) || 0` - Frontend uses `.toFixed()` which doesn't exist on Decimal objects - Applies to all aggregations: SUM(), AVG(), ROUND() - all return Decimal types - Example: `/api/analytics/version-comparison` converts all numeric fields -21. **JavaScript || vs ?? operators CRITICAL:** When setting default values for numeric config, ALWAYS use `??` (nullish coalescing): - ```typescript - // WRONG - treats 0 as falsy: - tp2SizePercent: config.takeProfit2SizePercent || 100 // 0 becomes 100! - - // CORRECT - only null/undefined are nullish: - tp2SizePercent: config.takeProfit2SizePercent ?? 100 // 0 stays 0 - ``` - - `||` treats `0`, `false`, `""`, `null`, `undefined` as falsy - - `??` only treats `null` and `undefined` as nullish - - Critical for runner system: `TAKE_PROFIT_2_SIZE_PERCENT=0` must be respected - - This bug caused TP2 orders to be placed at 100% despite config setting 0 - - Applies to ALL numeric config values where 0 is valid (TP sizes, leverage, thresholds) - -22. **Range-bound chop detection:** The anti-chop filter V2 (implemented 2024-11-10) prevents flip-flop losses: - - Detection: Price position <40% of range + ADX <25 = weak range-bound market - - Penalty: -25 points to signal quality score - - Why: Trades entering early in range with weak trend get whipsawed in seconds - - Evidence: Backtest showed 5 flip-flop trades (8-24 second holds) all had this pattern - - Result: Win rate improved from 43.8% to 55.6%, profit per trade +86% - - Implementation: `lib/trading/signal-quality.ts` checks both conditions before price position scoring - -23. **Position Manager sync issues:** Partial fills from on-chain orders can cause Position Manager to lose tracking: - - Symptom: Database shows position "closed", but Drift shows position still open without stop loss - - Cause: On-chain orders partially fill (0.29 SOL × 3 times), Position Manager closes database record, but remainder stays open - - Impact: Remaining position has NO software-based stop loss protection (only relies on on-chain orders) - - Solution: Use `/api/trading/sync-positions` endpoint to re-sync Position Manager with actual Drift positions - - Access: Settings UI "Sync Positions" button (orange), or CLI `scripts/sync-positions.sh` - - When: After manual Telegram trades, bot restarts, rate limiting issues, or suspected tracking loss - - Recovery: Endpoint fetches actual Drift positions, re-adds missing ones to Position Manager with calculated TP/SL - - Documentation: See `docs/guides/POSITION_SYNC_GUIDE.md` for details - ## File Conventions - **API routes:** `app/api/[feature]/[action]/route.ts` (Next.js 15 App Router) @@ -505,6 +498,70 @@ trade.realizedPnL += actualRealizedPnL // NOT: result.realizedPnL from SDK - **Types:** Define interfaces in same file as implementation (not separate types directory) - **Console logs:** Use emojis for visual scanning: 🎯 🚀 ✅ ❌ 💰 📊 🛡️ +## Re-Entry Analytics System (Phase 1) + +**Purpose:** Validate manual Telegram trades using fresh TradingView data + recent performance analysis + +**Components:** +1. **Market Data Cache** (`lib/trading/market-data-cache.ts`) + - Singleton service storing TradingView metrics + - 5-minute expiry on cached data + - Tracks: ATR, ADX, RSI, volume ratio, price position, timeframe + +2. **Market Data Webhook** (`app/api/trading/market-data/route.ts`) + - Receives TradingView alerts every 1-5 minutes + - POST: Updates cache with fresh metrics + - GET: View cached data (debugging) + +3. **Re-Entry Check Endpoint** (`app/api/analytics/reentry-check/route.ts`) + - Validates manual trade requests + - Uses fresh TradingView data if available (<5min old) + - Falls back to historical metrics from last trade + - Scores signal quality + applies performance modifiers: + - **-20 points** if last 3 trades lost money (avgPnL < -5%) + - **+10 points** if last 3 trades won (avgPnL > +5%, WR >= 66%) + - **-5 points** for stale data, **-10 points** for no data + - Minimum score: 55 (vs 60 for new signals) + +4. **Auto-Caching** (`app/api/trading/execute/route.ts`) + - Every trade signal from TradingView auto-caches metrics + - Ensures fresh data available for manual re-entries + +5. **Telegram Integration** (`telegram_command_bot.py`) + - Calls `/api/analytics/reentry-check` before executing manual trades + - Shows data freshness ("✅ FRESH 23s old" vs "⚠️ Historical") + - Blocks low-quality re-entries unless `--force` flag used + - Fail-open: Proceeds if analytics check fails + +**User Flow:** +``` +User: "long sol" + ↓ Check cache for SOL-PERP + ↓ Fresh data? → Use real TradingView metrics + ↓ Stale/missing? → Use historical + penalty + ↓ Score quality + recent performance + ↓ Score >= 55? → Execute + ↓ Score < 55? → Block (unless --force) +``` + +**TradingView Setup:** +Create alerts that fire every 1-5 minutes with this webhook message: +```json +{ + "action": "market_data", + "symbol": "{{ticker}}", + "timeframe": "{{interval}}", + "atr": {{ta.atr(14)}}, + "adx": {{ta.dmi(14, 14)}}, + "rsi": {{ta.rsi(14)}}, + "volumeRatio": {{volume / ta.sma(volume, 20)}}, + "pricePosition": {{(close - ta.lowest(low, 100)) / (ta.highest(high, 100) - ta.lowest(low, 100)) * 100}}, + "currentPrice": {{close}} +} +``` + +Webhook URL: `https://your-domain.com/api/trading/market-data` + ## Per-Symbol Trading Controls **Purpose:** Independent enable/disable toggles and position sizing for SOL and ETH to support different trading strategies (e.g., ETH for data collection at minimal size, SOL for profit generation). diff --git a/app/api/trading/check-risk/route.ts b/app/api/trading/check-risk/route.ts index 6a412c6..8a5b591 100644 --- a/app/api/trading/check-risk/route.ts +++ b/app/api/trading/check-risk/route.ts @@ -264,17 +264,6 @@ export async function POST(request: NextRequest): Promise 0 if (hasContextMetrics) { - console.log('🔍 Risk check for:', { - symbol: body.symbol, - direction: body.direction, - timeframe: body.timeframe, // DEBUG: Check if timeframe is received - atr: body.atr, - adx: body.adx, - rsi: body.rsi, - volumeRatio: body.volumeRatio, - pricePosition: body.pricePosition - }) - const qualityScore = scoreSignalQuality({ atr: body.atr || 0, adx: body.adx || 0, @@ -283,7 +272,7 @@ export async function POST(request: NextRequest): Promise 0) { - const coverage = (actualScaleNotional / scaleSize) * 100 - if (coverage < 99.5) { - console.log(`⚠️ Scale fill coverage: ${coverage.toFixed(2)}% of requested $${scaleSize.toFixed(2)}`) - } - } + const totalScaleAdded = (sameDirectionPosition.totalScaleAdded || 0) + scaleSize + const newTotalSize = sameDirectionPosition.currentSize + (scaleResult.fillSize || 0) // Update the trade tracking (simplified - just update the active trade object) sameDirectionPosition.timesScaled = timesScaled @@ -285,20 +287,20 @@ export async function POST(request: NextRequest): Promise setTimeout(resolve, 2000)) } - // Calculate requested position size with leverage - const requestedPositionSizeUSD = positionSize * leverage + // Calculate position size with leverage + const positionSizeUSD = positionSize * leverage console.log(`💰 Opening ${body.direction} position:`) console.log(` Symbol: ${driftSymbol}`) console.log(` Base size: $${positionSize}`) console.log(` Leverage: ${leverage}x`) - console.log(` Requested notional: $${requestedPositionSizeUSD}`) + console.log(` Total position: $${positionSizeUSD}`) // Open position const openResult = await openPosition({ symbol: driftSymbol, direction: body.direction, - sizeUSD: requestedPositionSizeUSD, + sizeUSD: positionSizeUSD, slippageTolerance: config.slippageTolerance, }) @@ -316,7 +318,7 @@ export async function POST(request: NextRequest): Promise 0 ? actualPositionSizeUSD / entryPrice : 0) - const fillCoverage = requestedPositionSizeUSD > 0 - ? (actualPositionSizeUSD / requestedPositionSizeUSD) * 100 - : 100 - - console.log('📏 Fill results:') - console.log(` Filled base size: ${filledBaseSize.toFixed(4)} ${driftSymbol.split('-')[0]}`) - console.log(` Filled notional: $${actualPositionSizeUSD.toFixed(2)}`) - if (fillCoverage < 99.5) { - console.log(` ⚠️ Partial fill: ${fillCoverage.toFixed(2)}% of requested size`) - } const stopLossPrice = calculatePrice( entryPrice, @@ -425,15 +413,9 @@ export async function POST(request: NextRequest): Promise { console.log('🔄 Position sync requested...') const config = getMergedConfig() - const driftService = await getDriftService() + const driftService = await initializeDriftService() const positionManager = await getInitializedPositionManager() const prisma = getPrismaClient() diff --git a/app/api/trading/test/route.ts b/app/api/trading/test/route.ts index f791490..7030d74 100644 --- a/app/api/trading/test/route.ts +++ b/app/api/trading/test/route.ts @@ -235,8 +235,6 @@ export async function POST(request: NextRequest): Promise 0 && options.tp1Price > 0) { + // Place TP1 LIMIT reduce-only + if (tp1USD > 0) { const baseAmount = usdToBase(tp1USD) if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) { const orderParams: any = { @@ -315,8 +315,8 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise< } } - // Place TP2 LIMIT reduce-only (skip if tp2Price is 0 - runner system) - if (tp2USD > 0 && options.tp2Price > 0) { + // Place TP2 LIMIT reduce-only + if (tp2USD > 0) { const baseAmount = usdToBase(tp2USD) if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) { const orderParams: any = { @@ -517,23 +517,19 @@ export async function closePosition( if (isDryRun) { console.log('🧪 DRY RUN MODE: Simulating close order (not executing on blockchain)') - // Calculate realized P&L with leverage + // Calculate realized P&L with leverage (default 10x in dry run) const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1) const closedNotional = sizeToClose * oraclePrice + const realizedPnL = (closedNotional * profitPercent) / 100 + const accountPnLPercent = profitPercent * 10 // display using default leverage - // CRITICAL FIX: closedNotional is leveraged position size, must calculate P&L on collateral - const leverage = 10 // Default for dry run - const collateralUsed = closedNotional / leverage - const accountPnLPercent = profitPercent * leverage - const realizedPnL = (collateralUsed * accountPnLPercent) / 100 + const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}` console.log(`💰 Simulated close:`) console.log(` Close price: $${oraclePrice.toFixed(4)}`) - console.log(` Profit %: ${profitPercent.toFixed(3)}% → Account P&L (${leverage}x): ${accountPnLPercent.toFixed(2)}%`) + console.log(` Profit %: ${profitPercent.toFixed(3)}% → Account P&L (10x): ${accountPnLPercent.toFixed(2)}%`) console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`) - const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}` - return { success: true, transactionSignature: mockTxSig, @@ -573,7 +569,7 @@ export async function closePosition( console.log('✅ Transaction confirmed on-chain') // Calculate realized P&L with leverage - // CRITICAL: P&L must account for leverage and be calculated on collateral, not notional + // CRITICAL: P&L must account for leverage and be calculated on USD notional, not base asset size const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1) // Get leverage from user account (defaults to 10x if not found) @@ -588,11 +584,10 @@ export async function closePosition( console.log('⚠️ Could not determine leverage from account, using 10x default') } - // Calculate closed notional value (USD) and convert to collateral + // Calculate closed notional value (USD) const closedNotional = sizeToClose * oraclePrice - const collateralUsed = closedNotional / leverage // CRITICAL FIX: Calculate P&L on collateral - const accountPnLPercent = profitPercent * leverage // Account P&L includes leverage - const realizedPnL = (collateralUsed * accountPnLPercent) / 100 + const realizedPnL = (closedNotional * profitPercent) / 100 + const accountPnLPercent = profitPercent * leverage console.log(`💰 Close details:`) console.log(` Close price: $${oraclePrice.toFixed(4)}`) diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index 7891529..e79f7f5 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -35,7 +35,6 @@ export interface ActiveTrade { slMovedToBreakeven: boolean slMovedToProfit: boolean trailingStopActive: boolean - runnerTrailingPercent?: number // Latest dynamic trailing percent applied // P&L tracking realizedPnL: number @@ -53,7 +52,6 @@ export interface ActiveTrade { originalAdx?: number // ADX at initial entry (for scaling validation) timesScaled?: number // How many times position has been scaled totalScaleAdded?: number // Total USD added through scaling - atrAtEntry?: number // ATR (absolute) when trade was opened // Monitoring priceCheckCount: number @@ -119,7 +117,6 @@ export class PositionManager { slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false, slMovedToProfit: pmState?.slMovedToProfit ?? false, trailingStopActive: pmState?.trailingStopActive ?? false, - runnerTrailingPercent: pmState?.runnerTrailingPercent, realizedPnL: pmState?.realizedPnL ?? 0, unrealizedPnL: pmState?.unrealizedPnL ?? 0, peakPnL: pmState?.peakPnL ?? 0, @@ -128,7 +125,6 @@ export class PositionManager { maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0, maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice, maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice, - atrAtEntry: dbTrade.atrAtEntry ?? undefined, priceCheckCount: 0, lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice, lastUpdateTime: Date.now(), @@ -136,12 +132,6 @@ export class PositionManager { this.activeTrades.set(activeTrade.id, activeTrade) console.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`) - - // Consistency check: if TP1 hit but SL not moved to breakeven, fix it now - if (activeTrade.tp1Hit && !activeTrade.slMovedToBreakeven) { - console.log(`🔧 Detected inconsistent state: TP1 hit but SL not at breakeven. Fixing now...`) - await this.handlePostTp1Adjustments(activeTrade, 'recovery after restore') - } } if (this.activeTrades.size > 0) { @@ -213,22 +203,6 @@ export class PositionManager { return Array.from(this.activeTrades.values()) } - async reconcileTrade(symbol: string): Promise { - const trade = Array.from(this.activeTrades.values()).find(t => t.symbol === symbol) - if (!trade) { - return - } - - try { - const driftService = getDriftService() - const marketConfig = getMarketConfig(symbol) - const oraclePrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex) - await this.checkTradeConditions(trade, oraclePrice) - } catch (error) { - console.error(`⚠️ Failed to reconcile trade for ${symbol}:`, error) - } - } - /** * Get specific trade */ @@ -342,13 +316,16 @@ export class PositionManager { console.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`) } else { // Position exists - check if size changed (TP1/TP2 filled) - const positionSizeUSD = position.size * currentPrice + // CRITICAL FIX: position.size from Drift SDK is already in USD notional value + const positionSizeUSD = Math.abs(position.size) // Drift SDK returns negative for shorts const trackedSizeUSD = trade.currentSize const sizeDiffPercent = Math.abs(positionSizeUSD - trackedSizeUSD) / trackedSizeUSD * 100 + console.log(`📊 Position check: Drift=$${positionSizeUSD.toFixed(2)} Tracked=$${trackedSizeUSD.toFixed(2)} Diff=${sizeDiffPercent.toFixed(1)}%`) + // If position size reduced significantly, TP orders likely filled if (positionSizeUSD < trackedSizeUSD * 0.9 && sizeDiffPercent > 10) { - console.log(`📊 Position size changed: tracking $${trackedSizeUSD.toFixed(2)} but found $${positionSizeUSD.toFixed(2)}`) + console.log(`✅ Position size reduced: tracking $${trackedSizeUSD.toFixed(2)} → found $${positionSizeUSD.toFixed(2)}`) // Detect which TP filled based on size reduction const reductionPercent = ((trackedSizeUSD - positionSizeUSD) / trade.positionSize) * 100 @@ -359,7 +336,12 @@ export class PositionManager { trade.tp1Hit = true trade.currentSize = positionSizeUSD - await this.handlePostTp1Adjustments(trade, 'on-chain TP1 detection') + // Move SL to breakeven after TP1 + trade.stopLossPrice = trade.entryPrice + trade.slMovedToBreakeven = true + console.log(`🛡️ Stop loss moved to breakeven: $${trade.stopLossPrice.toFixed(4)}`) + + await this.saveTradeState(trade) } else if (trade.tp1Hit && !trade.tp2Hit && reductionPercent >= 85) { // TP2 fired (total should be ~95% closed, 5% runner left) @@ -367,22 +349,19 @@ export class PositionManager { trade.tp2Hit = true trade.currentSize = positionSizeUSD trade.trailingStopActive = true - trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade) - console.log( - `🏃 Runner active: $${positionSizeUSD.toFixed(2)} with trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%` - ) + console.log(`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with ${this.config.trailingStopPercent}% trailing stop`) await this.saveTradeState(trade) + // CRITICAL: Don't return early! Continue monitoring the runner position + // The trailing stop logic at line 732 needs to run + } else { // Partial fill detected but unclear which TP - just update size console.log(`⚠️ Unknown partial fill detected - updating tracked size to $${positionSizeUSD.toFixed(2)}`) trade.currentSize = positionSizeUSD await this.saveTradeState(trade) } - - // Continue monitoring the remaining position - return } // CRITICAL: Check for entry price mismatch (NEW position opened) @@ -404,10 +383,10 @@ export class PositionManager { trade.lastPrice, trade.direction ) - const accountPnL = profitPercent * trade.leverage - const estimatedPnL = (trade.currentSize * accountPnL) / 100 + const accountPnLPercent = profitPercent * trade.leverage + const estimatedPnL = (trade.currentSize * profitPercent) / 100 - console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnL.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`) + console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnLPercent.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`) try { await updateTradeExit({ @@ -448,7 +427,10 @@ export class PositionManager { // trade.currentSize may already be 0 if on-chain orders closed the position before // Position Manager detected it, causing zero P&L bug // HOWEVER: If this was a phantom trade (extreme size mismatch), set P&L to 0 - const sizeForPnL = trade.currentSize > 0 ? trade.currentSize : trade.positionSize + // CRITICAL FIX: Use tp1Hit flag to determine which size to use for P&L calculation + // - If tp1Hit=false: First closure, calculate on full position size + // - If tp1Hit=true: Runner closure, calculate on tracked remaining size + const sizeForPnL = trade.tp1Hit ? trade.currentSize : trade.positionSize // Check if this was a phantom trade by looking at the last known on-chain size // If last on-chain size was <50% of expected, this is a phantom @@ -457,7 +439,8 @@ export class PositionManager { console.log(`📊 External closure detected - Position size tracking:`) console.log(` Original size: $${trade.positionSize.toFixed(2)}`) console.log(` Tracked current size: $${trade.currentSize.toFixed(2)}`) - console.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)}`) + console.log(` TP1 hit: ${trade.tp1Hit}`) + console.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)} (${trade.tp1Hit ? 'runner' : 'full position'})`) if (wasPhantom) { console.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`) } @@ -466,41 +449,22 @@ export class PositionManager { // CRITICAL: Use trade state flags, not current price (on-chain orders filled in the past!) let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL' - // Calculate P&L first (set to 0 for phantom trades) - let realizedPnL = 0 - let exitPrice = currentPrice - + // Include any previously realized profit (e.g., from TP1 partial close) + const previouslyRealized = trade.realizedPnL + let runnerRealized = 0 + let runnerProfitPercent = 0 if (!wasPhantom) { - // For external closures, try to estimate a more realistic exit price - // Manual closures may happen at significantly different prices than current market - const unrealizedPnL = trade.unrealizedPnL || 0 - const positionSizeUSD = trade.positionSize - - if (Math.abs(unrealizedPnL) > 1 && positionSizeUSD > 0) { - // If we have meaningful unrealized P&L, back-calculate the likely exit price - // This is more accurate than using volatile current market price - const impliedProfitPercent = (unrealizedPnL / positionSizeUSD) * 100 / trade.leverage - exitPrice = trade.direction === 'long' - ? trade.entryPrice * (1 + impliedProfitPercent / 100) - : trade.entryPrice * (1 - impliedProfitPercent / 100) - - console.log(`📊 Estimated exit price based on unrealized P&L:`) - console.log(` Unrealized P&L: $${unrealizedPnL.toFixed(2)}`) - console.log(` Market price: $${currentPrice.toFixed(6)}`) - console.log(` Estimated exit: $${exitPrice.toFixed(6)}`) - - realizedPnL = unrealizedPnL - } else { - // Fallback to current price calculation - const profitPercent = this.calculateProfitPercent( - trade.entryPrice, - currentPrice, - trade.direction - ) - const accountPnL = profitPercent * trade.leverage - realizedPnL = (sizeForPnL * accountPnL) / 100 - } + runnerProfitPercent = this.calculateProfitPercent( + trade.entryPrice, + currentPrice, + trade.direction + ) + runnerRealized = (sizeForPnL * runnerProfitPercent) / 100 } + + const totalRealizedPnL = previouslyRealized + runnerRealized + trade.realizedPnL = totalRealizedPnL + console.log(` Realized P&L snapshot → Previous: $${previouslyRealized.toFixed(2)} | Runner: $${runnerRealized.toFixed(2)} (Δ${runnerProfitPercent.toFixed(2)}%) | Total: $${totalRealizedPnL.toFixed(2)}`) // Determine exit reason from trade state and P&L if (trade.tp2Hit) { @@ -509,14 +473,14 @@ export class PositionManager { } else if (trade.tp1Hit) { // TP1 was hit, position should be 25% size, but now fully closed // This means either TP2 filled or runner got stopped out - exitReason = realizedPnL > 0 ? 'TP2' : 'SL' + exitReason = totalRealizedPnL > 0 ? 'TP2' : 'SL' } else { // No TPs hit yet - either SL or TP1 filled just now // Use P&L to determine: positive = TP, negative = SL - if (realizedPnL > trade.positionSize * 0.005) { + if (totalRealizedPnL > trade.positionSize * 0.005) { // More than 0.5% profit - must be TP1 exitReason = 'TP1' - } else if (realizedPnL < 0) { + } else if (totalRealizedPnL < 0) { // Loss - must be SL exitReason = 'SL' } @@ -528,9 +492,9 @@ export class PositionManager { try { await updateTradeExit({ positionId: trade.positionId, - exitPrice: exitPrice, // Use estimated exit price, not current market price + exitPrice: currentPrice, exitReason, - realizedPnL, + realizedPnL: totalRealizedPnL, exitOrderTx: 'ON_CHAIN_ORDER', holdTimeSeconds, maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)), @@ -540,7 +504,7 @@ export class PositionManager { maxFavorablePrice: trade.maxFavorablePrice, maxAdversePrice: trade.maxAdversePrice, }) - console.log(`💾 External closure recorded: ${exitReason} at $${exitPrice.toFixed(6)} | P&L: $${realizedPnL.toFixed(2)}`) + console.log(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${totalRealizedPnL.toFixed(2)}`) } catch (dbError) { console.error('❌ Failed to save external closure:', dbError) } @@ -551,31 +515,50 @@ export class PositionManager { } // Position exists but size mismatch (partial close by TP1?) - const onChainBaseSize = Math.abs(position.size) - const onChainSizeUSD = onChainBaseSize * currentPrice - const trackedSizeUSD = trade.currentSize - - if (trackedSizeUSD > 0 && onChainSizeUSD < trackedSizeUSD * 0.95) { // 5% tolerance - const expectedBaseSize = trackedSizeUSD / currentPrice - console.log(`⚠️ Position size mismatch: tracking $${trackedSizeUSD.toFixed(2)} (~${expectedBaseSize.toFixed(4)} units) but on-chain shows $${onChainSizeUSD.toFixed(2)} (${onChainBaseSize.toFixed(4)} units)`) + if (position.size < trade.currentSize * 0.95) { // 5% tolerance + console.log(`⚠️ Position size mismatch: expected ${trade.currentSize}, got ${position.size}`) + + // CRITICAL: Check if position direction changed (signal flip, not TP1!) + const positionDirection = position.side === 'long' ? 'long' : 'short' + if (positionDirection !== trade.direction) { + console.log(`🔄 DIRECTION CHANGE DETECTED: ${trade.direction} → ${positionDirection}`) + console.log(` This is a signal flip, not TP1! Closing old position as manual.`) + + // Calculate actual P&L on full position + const profitPercent = this.calculateProfitPercent(trade.entryPrice, currentPrice, trade.direction) + const actualPnL = (trade.positionSize * profitPercent) / 100 + + try { + const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000) + await updateTradeExit({ + positionId: trade.positionId, + exitPrice: currentPrice, + exitReason: 'manual', + realizedPnL: actualPnL, + exitOrderTx: 'SIGNAL_FLIP', + holdTimeSeconds, + maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)), + maxGain: Math.max(0, trade.maxFavorableExcursion), + maxFavorableExcursion: trade.maxFavorableExcursion, + maxAdverseExcursion: trade.maxAdverseExcursion, + maxFavorablePrice: trade.maxFavorablePrice, + maxAdversePrice: trade.maxAdversePrice, + }) + console.log(`💾 Signal flip closure recorded: P&L $${actualPnL.toFixed(2)}`) + } catch (dbError) { + console.error('❌ Failed to save signal flip closure:', dbError) + } + + await this.removeTrade(trade.id) + return + } // CRITICAL: If mismatch is extreme (>50%), this is a phantom trade - const sizeRatio = trackedSizeUSD > 0 ? onChainSizeUSD / trackedSizeUSD : 0 + const sizeRatio = (position.size * currentPrice) / trade.currentSize if (sizeRatio < 0.5) { - const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000 - const probablyPartialRunner = trade.tp1Hit || tradeAgeSeconds > 60 - - if (probablyPartialRunner) { - console.log(`🛠️ Detected stray remainder (${(sizeRatio * 100).toFixed(1)}%) after on-chain exit - forcing market close`) - trade.currentSize = onChainSizeUSD - await this.saveTradeState(trade) - await this.executeExit(trade, 100, 'manual', currentPrice) - return - } - console.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`) - console.log(` Expected: $${trackedSizeUSD.toFixed(2)}`) - console.log(` Actual: $${onChainSizeUSD.toFixed(2)}`) + console.log(` Expected: $${trade.currentSize.toFixed(2)}`) + console.log(` Actual: $${(position.size * currentPrice).toFixed(2)}`) // Close as phantom trade try { @@ -603,15 +586,10 @@ export class PositionManager { return } - // Update current size to match reality and run TP1 adjustments if needed - trade.currentSize = onChainSizeUSD - if (!trade.tp1Hit) { - trade.tp1Hit = true - await this.handlePostTp1Adjustments(trade, 'on-chain TP1 size sync') - } else { - await this.saveTradeState(trade) - } - return + // Update current size to match reality (convert base asset size to USD using current price) + trade.currentSize = position.size * currentPrice + trade.tp1Hit = true + await this.saveTradeState(trade) } } catch (error) { @@ -636,8 +614,8 @@ export class PositionManager { trade.direction ) - const accountPnL = profitPercent * trade.leverage - trade.unrealizedPnL = (trade.currentSize * accountPnL) / 100 + const accountPnL = profitPercent * trade.leverage + trade.unrealizedPnL = (trade.currentSize * profitPercent) / 100 // Track peak P&L (MFE - Maximum Favorable Excursion) if (trade.unrealizedPnL > trade.peakPnL) { @@ -702,7 +680,56 @@ export class PositionManager { // Move SL based on breakEvenTriggerPercent setting trade.tp1Hit = true trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100) - await this.handlePostTp1Adjustments(trade, 'software TP1 execution') + const newStopLossPrice = this.calculatePrice( + trade.entryPrice, + this.config.breakEvenTriggerPercent, // Use configured breakeven level + trade.direction + ) + trade.stopLossPrice = newStopLossPrice + trade.slMovedToBreakeven = true + + console.log(`🔒 SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`) + + // CRITICAL: Cancel old on-chain SL orders and place new ones at updated price + try { + console.log('🗑️ Cancelling old stop loss orders...') + const { cancelAllOrders, placeExitOrders } = await import('../drift/orders') + const cancelResult = await cancelAllOrders(trade.symbol) + if (cancelResult.success) { + console.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} old orders`) + + // Place new SL orders at breakeven/profit level for remaining position + console.log(`🛡️ Placing new SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`) + const exitOrdersResult = await placeExitOrders({ + symbol: trade.symbol, + positionSizeUSD: trade.currentSize, + entryPrice: trade.entryPrice, + tp1Price: trade.tp2Price, // Only TP2 remains + tp2Price: trade.tp2Price, // Dummy, won't be used + stopLossPrice: newStopLossPrice, + tp1SizePercent: 100, // Close remaining 25% at TP2 + tp2SizePercent: 0, + direction: trade.direction, + useDualStops: this.config.useDualStops, + softStopPrice: trade.direction === 'long' + ? newStopLossPrice * 1.005 // 0.5% above for long + : newStopLossPrice * 0.995, // 0.5% below for short + hardStopPrice: newStopLossPrice, + }) + + if (exitOrdersResult.success) { + console.log('✅ New SL orders placed on-chain at updated price') + } else { + console.error('❌ Failed to place new SL orders:', exitOrdersResult.error) + } + } + } catch (error) { + console.error('❌ Failed to update on-chain SL orders:', error) + // Don't fail the TP1 exit if SL update fails - software monitoring will handle it + } + + // Save state after TP1 + await this.saveTradeState(trade) return } @@ -727,39 +754,42 @@ export class PositionManager { await this.saveTradeState(trade) } - // 5. TP2 Hit - Activate runner (no close, just start trailing) + // 5. Take profit 2 (remaining position) if (trade.tp1Hit && !trade.tp2Hit && this.shouldTakeProfit2(currentPrice, trade)) { - console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}% - Activating 25% runner!`) + console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`) - // Mark TP2 as hit and activate trailing stop on full remaining 25% - trade.tp2Hit = true - trade.peakPrice = currentPrice - trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade) + // Calculate how much to close based on TP2 size percent + const percentToClose = this.config.takeProfit2SizePercent - console.log( - `🏃 Runner activated on full remaining position: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% | trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%` - ) + await this.executeExit(trade, percentToClose, 'TP2', currentPrice) - // Save state after TP2 activation - await this.saveTradeState(trade) + // If some position remains, mark TP2 as hit and activate trailing stop + if (percentToClose < 100) { + trade.tp2Hit = true + trade.currentSize = trade.currentSize * ((100 - percentToClose) / 100) + + console.log(`🏃 Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`) + + // Save state after TP2 + await this.saveTradeState(trade) + } return - } // 6. Trailing stop for runner (after TP2 activation) + } + + // 6. Trailing stop for runner (after TP2) if (trade.tp2Hit && this.config.useTrailingStop) { // Check if trailing stop should be activated if (!trade.trailingStopActive && profitPercent >= this.config.trailingStopActivation) { trade.trailingStopActive = true - trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade) console.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`) } // If trailing stop is active, adjust SL dynamically if (trade.trailingStopActive) { - const trailingPercent = this.getRunnerTrailingPercent(trade) - trade.runnerTrailingPercent = trailingPercent const trailingStopPrice = this.calculatePrice( trade.peakPrice, - -trailingPercent, // Trail below peak + -this.config.trailingStopPercent, // Trail below peak trade.direction ) @@ -772,7 +802,7 @@ export class PositionManager { const oldSL = trade.stopLossPrice trade.stopLossPrice = trailingStopPrice - console.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)} → ${trailingStopPrice.toFixed(4)} (${trailingPercent.toFixed(3)}% below peak $${trade.peakPrice.toFixed(4)})`) + console.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)} → ${trailingStopPrice.toFixed(4)} (${this.config.trailingStopPercent}% below peak $${trade.peakPrice.toFixed(4)})`) // Save state after trailing SL update (every 10 updates to avoid spam) if (trade.priceCheckCount % 10 === 0) { @@ -813,37 +843,18 @@ export class PositionManager { return } - const closePriceForCalc = result.closePrice || currentPrice - const closedSizeBase = result.closedSize || 0 - const closedUSD = closedSizeBase * closePriceForCalc - const treatAsFullClose = percentToClose >= 100 - - // Calculate actual P&L based on entry vs exit price - // CRITICAL: closedUSD is NOTIONAL value (with leverage), must calculate based on collateral - const profitPercent = this.calculateProfitPercent(trade.entryPrice, closePriceForCalc, trade.direction) - const collateralUSD = closedUSD / trade.leverage // Convert notional to actual collateral used - const accountPnLPercent = profitPercent * trade.leverage // Account P&L includes leverage effect - const actualRealizedPnL = (collateralUSD * accountPnLPercent) / 100 - // Update trade state - if (treatAsFullClose) { - trade.realizedPnL += actualRealizedPnL - trade.currentSize = 0 - trade.trailingStopActive = false - - if (reason === 'TP2') { - trade.tp2Hit = true - } - if (reason === 'TP1') { - trade.tp1Hit = true - } - + if (percentToClose >= 100) { + // Full close - remove from monitoring + trade.realizedPnL += result.realizedPnL || 0 + + // Save to database (only for valid exit reasons) if (reason !== 'error') { try { const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000) await updateTradeExit({ positionId: trade.positionId, - exitPrice: closePriceForCalc, + exitPrice: result.closePrice || currentPrice, exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency', realizedPnL: trade.realizedPnL, exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE', @@ -858,23 +869,25 @@ export class PositionManager { console.log('💾 Trade saved to database') } catch (dbError) { console.error('❌ Failed to save trade exit to database:', dbError) + // Don't fail the close if database fails } } - + await this.removeTrade(trade.id) console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`) } else { - // Partial close (TP1) - calculate P&L for partial amount - // CRITICAL: Same fix as above - closedUSD is notional, must use collateral - const partialCollateralUSD = closedUSD / trade.leverage - const partialAccountPnL = profitPercent * trade.leverage - const partialRealizedPnL = (partialCollateralUSD * partialAccountPnL) / 100 - trade.realizedPnL += partialRealizedPnL + // Partial close (TP1) + trade.realizedPnL += result.realizedPnL || 0 + // result.closedSize is returned in base asset units (e.g., SOL), convert to USD using closePrice + const closePriceForCalc = result.closePrice || currentPrice + const closedSizeBase = result.closedSize || 0 + const closedUSD = closedSizeBase * closePriceForCalc trade.currentSize = Math.max(0, trade.currentSize - closedUSD) - console.log( - `✅ Partial close executed | Realized: $${partialRealizedPnL.toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}` - ) + console.log(`✅ Partial close executed | Realized: $${(result.realizedPnL || 0).toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}`) + + // Persist updated trade state so analytics reflect partial profits immediately + await this.saveTradeState(trade) } // TODO: Send notification @@ -964,150 +977,6 @@ export class PositionManager { console.log('✅ All positions closed') } - refreshConfig(): void { - this.config = getMergedConfig() - console.log('⚙️ Position manager config refreshed from environment') - } - - private getRunnerTrailingPercent(trade: ActiveTrade): number { - const fallbackPercent = this.config.trailingStopPercent - const atrValue = trade.atrAtEntry ?? 0 - const entryPrice = trade.entryPrice - - if (atrValue <= 0 || entryPrice <= 0 || !Number.isFinite(entryPrice)) { - return fallbackPercent - } - - const atrPercentOfPrice = (atrValue / entryPrice) * 100 - if (!Number.isFinite(atrPercentOfPrice) || atrPercentOfPrice <= 0) { - return fallbackPercent - } - - const rawPercent = atrPercentOfPrice * this.config.trailingStopAtrMultiplier - const boundedPercent = Math.min( - this.config.trailingStopMaxPercent, - Math.max(this.config.trailingStopMinPercent, rawPercent) - ) - - return boundedPercent > 0 ? boundedPercent : fallbackPercent - } - - private async handlePostTp1Adjustments(trade: ActiveTrade, context: string): Promise { - if (trade.currentSize <= 0) { - console.log(`⚠️ Skipping TP1 adjustments for ${trade.symbol} (${context}) because current size is $${trade.currentSize.toFixed(2)}`) - await this.saveTradeState(trade) - return - } - - const newStopLossPrice = this.calculatePrice( - trade.entryPrice, - this.config.breakEvenTriggerPercent, - trade.direction - ) - - trade.stopLossPrice = newStopLossPrice - trade.slMovedToBreakeven = true - - console.log(`🔒 (${context}) SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`) - - // CRITICAL FIX: For runner system (tp2SizePercent=0), don't place any TP orders - // The remaining 25% should only have stop loss and be managed by software trailing stop - const shouldPlaceTpOrders = this.config.takeProfit2SizePercent > 0 - - if (shouldPlaceTpOrders) { - // Traditional system: place TP2 order for remaining position - await this.refreshExitOrders(trade, { - stopLossPrice: newStopLossPrice, - tp1Price: trade.tp2Price, - tp1SizePercent: 100, - tp2Price: trade.tp2Price, - tp2SizePercent: 0, - context, - }) - } else { - // Runner system: Only place stop loss, no TP orders - // The 25% runner will be managed by software trailing stop - console.log(`🏃 Runner system active - placing ONLY stop loss at breakeven, no TP orders`) - await this.refreshExitOrders(trade, { - stopLossPrice: newStopLossPrice, - tp1Price: 0, // No TP1 order - tp1SizePercent: 0, - tp2Price: 0, // No TP2 order - tp2SizePercent: 0, - context, - }) - } - - await this.saveTradeState(trade) - } - - private async refreshExitOrders( - trade: ActiveTrade, - options: { - stopLossPrice: number - tp1Price: number - tp1SizePercent: number - tp2Price?: number - tp2SizePercent?: number - context: string - } - ): Promise { - if (trade.currentSize <= 0) { - console.log(`⚠️ Skipping exit order refresh for ${trade.symbol} (${options.context}) because tracked size is zero`) - return - } - - try { - console.log(`🗑️ (${options.context}) Cancelling existing exit orders before refresh...`) - const { cancelAllOrders, placeExitOrders } = await import('../drift/orders') - const cancelResult = await cancelAllOrders(trade.symbol) - if (cancelResult.success) { - console.log(`✅ (${options.context}) Cancelled ${cancelResult.cancelledCount || 0} old orders`) - } else { - console.warn(`⚠️ (${options.context}) Failed to cancel old orders: ${cancelResult.error}`) - } - - const tp2Price = options.tp2Price ?? options.tp1Price - const tp2SizePercent = options.tp2SizePercent ?? 0 - - const refreshParams: any = { - symbol: trade.symbol, - positionSizeUSD: trade.currentSize, - entryPrice: trade.entryPrice, - tp1Price: options.tp1Price, - tp2Price, - stopLossPrice: options.stopLossPrice, - tp1SizePercent: options.tp1SizePercent, - tp2SizePercent, - direction: trade.direction, - useDualStops: this.config.useDualStops, - } - - if (this.config.useDualStops) { - const softStopBuffer = this.config.softStopBuffer ?? 0.4 - const softStopPrice = trade.direction === 'long' - ? options.stopLossPrice * (1 + softStopBuffer / 100) - : options.stopLossPrice * (1 - softStopBuffer / 100) - - refreshParams.softStopPrice = softStopPrice - refreshParams.softStopBuffer = softStopBuffer - refreshParams.hardStopPrice = options.stopLossPrice - } - - console.log(`🛡️ (${options.context}) Placing refreshed exit orders: size=$${trade.currentSize.toFixed(2)} SL=${options.stopLossPrice.toFixed(4)} TP=${options.tp1Price.toFixed(4)}`) - const exitOrdersResult = await placeExitOrders(refreshParams) - - if (exitOrdersResult.success) { - console.log(`✅ (${options.context}) Exit orders refreshed on-chain`) - } else { - console.error(`❌ (${options.context}) Failed to place refreshed exit orders: ${exitOrdersResult.error}`) - } - } catch (error) { - console.error(`❌ (${options.context}) Error refreshing exit orders:`, error) - // Monitoring loop will still enforce SL logic even if on-chain refresh fails - } - } - /** * Save trade state to database (for persistence across restarts) */ @@ -1131,6 +1000,14 @@ export class PositionManager { } } + /** + * Reload configuration from merged sources (used after settings updates) + */ + refreshConfig(partial?: Partial): void { + this.config = getMergedConfig(partial) + console.log('🔄 Position Manager config refreshed') + } + /** * Get monitoring status */ diff --git a/lib/trading/signal-quality.ts b/lib/trading/signal-quality.ts index fc99d0f..2a53544 100644 --- a/lib/trading/signal-quality.ts +++ b/lib/trading/signal-quality.ts @@ -140,16 +140,8 @@ export function scoreSignalQuality(params: { } // Price position check (avoid chasing vs breakout detection) - // CRITICAL: Low price position (< 40%) + weak trend (ADX < 25) = range-bound chop if (params.pricePosition > 0) { - const isWeakTrend = params.adx > 0 && params.adx < 25 - const isLowInRange = params.pricePosition < 40 - - // ANTI-CHOP: Heavily penalize range-bound entries - if (isLowInRange && isWeakTrend) { - score -= 25 - reasons.push(`⚠️ RANGE-BOUND CHOP: Low position (${params.pricePosition.toFixed(0)}%) + weak trend (ADX ${params.adx.toFixed(1)}) = high whipsaw risk`) - } else if (params.direction === 'long' && params.pricePosition > 95) { + if (params.direction === 'long' && params.pricePosition > 95) { // High volume breakout at range top can be good if (params.volumeRatio > 1.4) { score += 5 @@ -173,15 +165,13 @@ export function scoreSignalQuality(params: { } } - // Volume breakout bonus - ONLY if trend is strong enough (not choppy) - // Removed old logic that conflicted with anti-chop filter - // Old bonus was rewarding high volume even during choppy markets - if (params.volumeRatio > 1.8 && params.atr < 0.6 && params.adx > 18) { + // Volume breakout bonus (high volume can override other weaknesses) + if (params.volumeRatio > 1.8 && params.atr < 0.6) { score += 10 - reasons.push(`Volume breakout compensates for low ATR (ADX ${params.adx.toFixed(1)} confirms trend)`) + reasons.push(`Volume breakout compensates for low ATR`) } - const minScore = params.minScore || 65 + const minScore = params.minScore || 60 const passed = score >= minScore return { diff --git a/workflows/trading/Money_Machine.json b/workflows/trading/Money_Machine.json index 82fb231..ca64bf1 100644 --- a/workflows/trading/Money_Machine.json +++ b/workflows/trading/Money_Machine.json @@ -367,7 +367,7 @@ }, { "parameters": { - "jsCode": "// Get the body - it might be a string or nested in an object\nlet body = $json.body || $json.query?.body || JSON.stringify($json);\n\n// If body is an object, stringify it\nif (typeof body === 'object') {\n body = JSON.stringify(body);\n}\n\n// Parse basic signal (existing logic)\nconst symbolMatch = body.match(/\\b(SOL|BTC|ETH)/i);\nconst symbol = symbolMatch ? symbolMatch[1].toUpperCase() + '-PERP' : 'SOL-PERP';\n\nconst direction = body.match(/\\b(sell|short)\\b/i) ? 'short' : 'long';\n\n// Updated regex to match formats: \"ETH buy 15\", \"SOL buy .P 5\", etc.\n// The .P is optional TradingView ticker suffix that should be ignored\nconst timeframeMatch = body.match(/\\b(buy|sell)\\s+(?:\\.P\\s+)?(\\d+|D|W|M)\\b/i);\nconst timeframe = timeframeMatch ? timeframeMatch[2] : '5';\n\n// Parse new context metrics from enhanced format:\n// \"ETH buy 15 | ATR:1.85 | ADX:28.3 | RSI:62.5 | VOL:1.45 | POS:75.3\"\nconst atrMatch = body.match(/ATR:([\\d.]+)/);\nconst atr = atrMatch ? parseFloat(atrMatch[1]) : 0;\n\nconst adxMatch = body.match(/ADX:([\\d.]+)/);\nconst adx = adxMatch ? parseFloat(adxMatch[1]) : 0;\n\nconst rsiMatch = body.match(/RSI:([\\d.]+)/);\nconst rsi = rsiMatch ? parseFloat(rsiMatch[1]) : 0;\n\nconst volumeMatch = body.match(/VOL:([\\d.]+)/);\nconst volumeRatio = volumeMatch ? parseFloat(volumeMatch[1]) : 0;\n\nconst pricePositionMatch = body.match(/POS:([\\d.]+)/);\nconst pricePosition = pricePositionMatch ? parseFloat(pricePositionMatch[1]) : 0;\n\nreturn {\n rawMessage: body,\n symbol,\n direction,\n timeframe,\n // New context fields\n atr,\n adx,\n rsi,\n volumeRatio,\n pricePosition\n};" + "jsCode": "// Get the body - it might be a string or nested in an object\nlet body = $json.body || $json.query?.body || JSON.stringify($json);\n\n// If body is an object, stringify it\nif (typeof body === 'object') {\n body = JSON.stringify(body);\n}\n\n// Parse basic signal (existing logic)\nconst symbolMatch = body.match(/\\b(SOL|BTC|ETH)/i);\nconst symbol = symbolMatch ? symbolMatch[1].toUpperCase() + '-PERP' : 'SOL-PERP';\n\nconst direction = body.match(/\\b(sell|short)\\b/i) ? 'short' : 'long';\n\n// Updated regex to match new format: \"ETH buy 15\" (no .P)\nconst timeframeMatch = body.match(/\\b(buy|sell)\\s+(\\d+|D|W|M)\\b/i);\nconst timeframe = timeframeMatch ? timeframeMatch[2] : '5';\n\n// Parse new context metrics from enhanced format:\n// \"ETH buy 15 | ATR:1.85 | ADX:28.3 | RSI:62.5 | VOL:1.45 | POS:75.3\"\nconst atrMatch = body.match(/ATR:([\\d.]+)/);\nconst atr = atrMatch ? parseFloat(atrMatch[1]) : 0;\n\nconst adxMatch = body.match(/ADX:([\\d.]+)/);\nconst adx = adxMatch ? parseFloat(adxMatch[1]) : 0;\n\nconst rsiMatch = body.match(/RSI:([\\d.]+)/);\nconst rsi = rsiMatch ? parseFloat(rsiMatch[1]) : 0;\n\nconst volumeMatch = body.match(/VOL:([\\d.]+)/);\nconst volumeRatio = volumeMatch ? parseFloat(volumeMatch[1]) : 0;\n\nconst pricePositionMatch = body.match(/POS:([\\d.]+)/);\nconst pricePosition = pricePositionMatch ? parseFloat(pricePositionMatch[1]) : 0;\n\nreturn {\n rawMessage: body,\n symbol,\n direction,\n timeframe,\n // New context fields\n atr,\n adx,\n rsi,\n volumeRatio,\n pricePosition\n};" }, "id": "81f28bc7-c96a-4021-acac-242e993d9d98", "name": "Parse Signal Enhanced",