CRITICAL FIX: Use ?? instead of || for tp2SizePercent to allow 0 value

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.
This commit is contained in:
mindesbunister
2025-11-10 19:46:03 +01:00
parent 089308a07e
commit c3a053df63
9 changed files with 411 additions and 529 deletions

View File

@@ -19,7 +19,7 @@
- BTC and other symbols fall back to global settings (`MAX_POSITION_SIZE_USD`, `LEVERAGE`) - BTC and other symbols fall back to global settings (`MAX_POSITION_SIZE_USD`, `LEVERAGE`)
- **Priority:** Per-symbol ENV → Market config → Global ENV → Defaults - **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): **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) - 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). **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) **Re-Entry Analytics System:** Manual trades are validated before execution using fresh TradingView data:
- Market data cached from TradingView signals (5min expiry)
### Runner System - Three Cascading Bugs Fixed - `/api/analytics/reentry-check` scores re-entry based on fresh metrics + recent performance
The TP2-as-runner feature was broken by three separate bugs: - Telegram bot blocks low-quality re-entries unless `--force` flag used
- Uses real TradingView ADX/ATR/RSI when available, falls back to historical data
1. **P&L Calculation Bug (65x inflation)** - `lib/drift/orders.ts`, `lib/trading/position-manager.ts` - Penalty for recent losing trades, bonus for winning streaks
- 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
## Critical Components ## Critical Components
@@ -76,14 +58,11 @@ scoreSignalQuality({
**Price position penalties (all timeframes):** **Price position penalties (all timeframes):**
- Long at 90-95%+ range: -15 to -30 points (chasing highs) - Long at 90-95%+ range: -15 to -30 points (chasing highs)
- Short at <5-10% range: -15 to -30 points (chasing lows) - 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 - 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
**Key behaviors:** **Key behaviors:**
- Returns score 0-100 and detailed breakdown object - 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` - Called by both `/api/trading/check-risk` and `/api/trading/execute`
- Scores saved to database for post-trade analysis - Scores saved to database for post-trade analysis
@@ -115,18 +94,23 @@ await positionManager.addTrade(activeTrade)
**Manual trade commands via plain text:** **Manual trade commands via plain text:**
```python ```python
# User sends plain text message (not slash commands) # User sends plain text message (not slash commands)
"long sol" Opens SOL-PERP long position "long sol" Validates via analytics, then opens SOL-PERP long
"short eth" Opens ETH-PERP short position "short eth" Validates via analytics, then opens ETH-PERP short
"long btc" Opens BTC-PERP long position "long btc --force" Skips analytics validation, opens BTC-PERP long immediately
``` ```
**Key behaviors:** **Key behaviors:**
- MessageHandler processes all text messages (not just commands) - MessageHandler processes all text messages (not just commands)
- Maps user-friendly symbols (sol, eth, btc) to Drift format (SOL-PERP, etc.) - 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 - Bypasses n8n workflow and TradingView requirements
- 60-second timeout for API calls - 60-second timeout for API calls
- Responds with trade confirmation or error message - Responds with trade confirmation or analytics rejection message
**Status command:** **Status command:**
```python ```python
@@ -152,6 +136,7 @@ const health = await driftService.getAccountHealth()
- `openPosition()` - Opens market position with transaction confirmation - `openPosition()` - Opens market position with transaction confirmation
- `closePosition()` - Closes position with transaction confirmation - `closePosition()` - Closes position with transaction confirmation
- `placeExitOrders()` - Places TP/SL orders on-chain - `placeExitOrders()` - Places TP/SL orders on-chain
- `cancelAllOrders()` - Cancels all reduce-only orders for a market
**CRITICAL: Transaction Confirmation Pattern** **CRITICAL: Transaction Confirmation Pattern**
Both `openPosition()` and `closePosition()` MUST confirm transactions on-chain: 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. 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<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
initialDelay: number = 2000
): Promise<T> {
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): **Dual Stop System** (USE_DUAL_STOPS=true):
```typescript ```typescript
// Soft stop: TRIGGER_LIMIT at -1.5% (avoids wicks) // 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 7. Add to Position Manager if applicable
**Key endpoints:** **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/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/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/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/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/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/analytics/version-comparison` - Compare performance across signal quality logic versions (v1/v2/v3)
- `/api/restart` - Create restart flag for watch-restart.sh script - `/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 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 8. **TP2-as-Runner configuration:**
9. **TP2-as-Runner configuration:**
- `takeProfit2SizePercent: 0` means "TP2 activates trailing stop, no position close" - `takeProfit2SizePercent: 0` means "TP2 activates trailing stop, no position close"
- This creates 25% runner (vs old 5% system) for better profit capture - 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 - `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 - 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 ```typescript
const profitPercent = this.calculateProfitPercent(trade.entryPrice, exitPrice, trade.direction) const profitPercent = this.calculateProfitPercent(trade.entryPrice, exitPrice, trade.direction)
const actualRealizedPnL = (closedSizeUSD * profitPercent) / 100 const actualRealizedPnL = (closedSizeUSD * profitPercent) / 100
trade.realizedPnL += actualRealizedPnL // NOT: result.realizedPnL from SDK 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 1. Open position + place exit orders
2. Save to database (`createTrade()`) 2. Save to database (`createTrade()`)
3. Add to Position Manager (`positionManager.addTrade()`) 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. 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) - SOL-PERP: 0.1 SOL (~$5-15 depending on price)
- ETH-PERP: 0.01 ETH (~$38-40 at $4000/ETH) - ETH-PERP: 0.01 ETH (~$38-40 at $4000/ETH)
- BTC-PERP: 0.0001 BTC (~$10-12 at $100k/BTC) - 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. 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 - 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" - Without timeframe awareness, valid 5min breakouts get blocked as "low quality"
- Anti-chop filter applies -20 points for extreme sideways regardless of timeframe - Anti-chop filter applies -20 points for extreme sideways regardless of timeframe
- Always pass `timeframe` parameter from TradingView alerts to `scoreSignalQuality()` - 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) - 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 - These trades had valid ADX (16-18) but entered at worst possible time
- Quality scoring now penalizes -15 to -30 points for range extremes - Quality scoring now penalizes -15 to -30 points for range extremes
- Prevents rapid reversals when price is already overextended - 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 - Higher timeframes can use ADX 20+ for strong trends
- 5min charts need lower threshold to catch valid breakouts - 5min charts need lower threshold to catch valid breakouts
- Bot's quality scoring provides second-layer filtering with context-aware metrics - Bot's quality scoring provides second-layer filtering with context-aware metrics
- Two-stage filtering (TradingView + bot) prevents both overtrading and missing valid signals - 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` - 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` - Convert with `Number()` before returning to frontend: `totalPnL: Number(stat.total_pnl) || 0`
- Frontend uses `.toFixed()` which doesn't exist on Decimal objects - Frontend uses `.toFixed()` which doesn't exist on Decimal objects
- Applies to all aggregations: SUM(), AVG(), ROUND() - all return Decimal types - Applies to all aggregations: SUM(), AVG(), ROUND() - all return Decimal types
- Example: `/api/analytics/version-comparison` converts all numeric fields - 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 ## File Conventions
- **API routes:** `app/api/[feature]/[action]/route.ts` (Next.js 15 App Router) - **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) - **Types:** Define interfaces in same file as implementation (not separate types directory)
- **Console logs:** Use emojis for visual scanning: 🎯 🚀 ✅ ❌ 💰 📊 🛡️ - **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 ## 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). **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).

View File

@@ -264,17 +264,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
const hasContextMetrics = body.atr !== undefined && body.atr > 0 const hasContextMetrics = body.atr !== undefined && body.atr > 0
if (hasContextMetrics) { 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({ const qualityScore = scoreSignalQuality({
atr: body.atr || 0, atr: body.atr || 0,
adx: body.adx || 0, adx: body.adx || 0,
@@ -283,7 +272,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
pricePosition: body.pricePosition || 0, pricePosition: body.pricePosition || 0,
direction: body.direction, direction: body.direction,
timeframe: body.timeframe, // Pass timeframe for context-aware scoring timeframe: body.timeframe, // Pass timeframe for context-aware scoring
minScore: config.minSignalQualityScore // Use config value (editable via settings page) minScore: 60 // Hardcoded threshold
}) })
if (!qualityScore.passed) { if (!qualityScore.passed) {

View File

@@ -8,11 +8,12 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { initializeDriftService } from '@/lib/drift/client' import { initializeDriftService } from '@/lib/drift/client'
import { openPosition, placeExitOrders } from '@/lib/drift/orders' import { openPosition, placeExitOrders } from '@/lib/drift/orders'
import { normalizeTradingViewSymbol, calculateDynamicTp2 } from '@/config/trading' import { normalizeTradingViewSymbol } from '@/config/trading'
import { getMergedConfig } from '@/config/trading' import { getMergedConfig } from '@/config/trading'
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager' import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
import { createTrade, updateTradeExit } from '@/lib/database/trades' import { createTrade, updateTradeExit } from '@/lib/database/trades'
import { scoreSignalQuality } from '@/lib/trading/signal-quality' import { scoreSignalQuality } from '@/lib/trading/signal-quality'
import { getMarketDataCache } from '@/lib/trading/market-data-cache'
export interface ExecuteTradeRequest { export interface ExecuteTradeRequest {
symbol: string // TradingView symbol (e.g., 'SOLUSDT') symbol: string // TradingView symbol (e.g., 'SOLUSDT')
@@ -35,8 +36,6 @@ export interface ExecuteTradeResponse {
direction?: 'long' | 'short' direction?: 'long' | 'short'
entryPrice?: number entryPrice?: number
positionSize?: number positionSize?: number
requestedPositionSize?: number
fillCoveragePercent?: number
leverage?: number leverage?: number
stopLoss?: number stopLoss?: number
takeProfit1?: number takeProfit1?: number
@@ -88,34 +87,29 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
const driftSymbol = normalizeTradingViewSymbol(body.symbol) const driftSymbol = normalizeTradingViewSymbol(body.symbol)
console.log(`📊 Normalized symbol: ${body.symbol}${driftSymbol}`) console.log(`📊 Normalized symbol: ${body.symbol}${driftSymbol}`)
// 🆕 Cache incoming market data from TradingView signals
if (body.atr && body.adx && body.rsi) {
const marketCache = getMarketDataCache()
marketCache.set(driftSymbol, {
symbol: driftSymbol,
atr: body.atr,
adx: body.adx,
rsi: body.rsi,
volumeRatio: body.volumeRatio || 1.0,
pricePosition: body.pricePosition || 50,
currentPrice: body.signalPrice || 0,
timestamp: Date.now(),
timeframe: body.timeframe || '5'
})
console.log(`📊 Market data auto-cached for ${driftSymbol} from trade signal`)
}
// Get trading configuration // Get trading configuration
const config = getMergedConfig() const config = getMergedConfig()
// Initialize Drift service to get account balance // Get symbol-specific position sizing
const driftService = await initializeDriftService() const { getPositionSizeForSymbol } = await import('@/config/trading')
const { size: positionSize, leverage, enabled } = getPositionSizeForSymbol(driftSymbol, config)
// Check account health before trading
const health = await driftService.getAccountHealth()
console.log('💊 Account health:', health)
if (health.freeCollateral <= 0) {
return NextResponse.json(
{
success: false,
error: 'Insufficient collateral',
message: `Free collateral: $${health.freeCollateral.toFixed(2)}`,
},
{ status: 400 }
)
}
// Get symbol-specific position sizing (with percentage support)
const { getActualPositionSizeForSymbol } = await import('@/config/trading')
const { size: positionSize, leverage, enabled, usePercentage } = await getActualPositionSizeForSymbol(
driftSymbol,
config,
health.freeCollateral
)
// Check if trading is enabled for this symbol // Check if trading is enabled for this symbol
if (!enabled) { if (!enabled) {
@@ -134,8 +128,24 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
console.log(` Enabled: ${enabled}`) console.log(` Enabled: ${enabled}`)
console.log(` Position size: $${positionSize}`) console.log(` Position size: $${positionSize}`)
console.log(` Leverage: ${leverage}x`) console.log(` Leverage: ${leverage}x`)
console.log(` Using percentage: ${usePercentage}`)
console.log(` Free collateral: $${health.freeCollateral.toFixed(2)}`) // Initialize Drift service if not already initialized
const driftService = await initializeDriftService()
// Check account health before trading
const health = await driftService.getAccountHealth()
console.log('💊 Account health:', health)
if (health.freeCollateral <= 0) {
return NextResponse.json(
{
success: false,
error: 'Insufficient collateral',
message: `Free collateral: $${health.freeCollateral.toFixed(2)}`,
},
{ status: 400 }
)
}
// AUTO-FLIP: Check for existing opposite direction position // AUTO-FLIP: Check for existing opposite direction position
const positionManager = await getInitializedPositionManager() const positionManager = await getInitializedPositionManager()
@@ -186,16 +196,8 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
// Update Position Manager tracking // Update Position Manager tracking
const timesScaled = (sameDirectionPosition.timesScaled || 0) + 1 const timesScaled = (sameDirectionPosition.timesScaled || 0) + 1
const actualScaleNotional = scaleResult.actualSizeUSD ?? scaleSize const totalScaleAdded = (sameDirectionPosition.totalScaleAdded || 0) + scaleSize
const totalScaleAdded = (sameDirectionPosition.totalScaleAdded || 0) + actualScaleNotional const newTotalSize = sameDirectionPosition.currentSize + (scaleResult.fillSize || 0)
const newTotalSize = sameDirectionPosition.currentSize + actualScaleNotional
if (scaleSize > 0) {
const coverage = (actualScaleNotional / scaleSize) * 100
if (coverage < 99.5) {
console.log(`⚠️ Scale fill coverage: ${coverage.toFixed(2)}% of requested $${scaleSize.toFixed(2)}`)
}
}
// Update the trade tracking (simplified - just update the active trade object) // Update the trade tracking (simplified - just update the active trade object)
sameDirectionPosition.timesScaled = timesScaled sameDirectionPosition.timesScaled = timesScaled
@@ -285,20 +287,20 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
await new Promise(resolve => setTimeout(resolve, 2000)) await new Promise(resolve => setTimeout(resolve, 2000))
} }
// Calculate requested position size with leverage // Calculate position size with leverage
const requestedPositionSizeUSD = positionSize * leverage const positionSizeUSD = positionSize * leverage
console.log(`💰 Opening ${body.direction} position:`) console.log(`💰 Opening ${body.direction} position:`)
console.log(` Symbol: ${driftSymbol}`) console.log(` Symbol: ${driftSymbol}`)
console.log(` Base size: $${positionSize}`) console.log(` Base size: $${positionSize}`)
console.log(` Leverage: ${leverage}x`) console.log(` Leverage: ${leverage}x`)
console.log(` Requested notional: $${requestedPositionSizeUSD}`) console.log(` Total position: $${positionSizeUSD}`)
// Open position // Open position
const openResult = await openPosition({ const openResult = await openPosition({
symbol: driftSymbol, symbol: driftSymbol,
direction: body.direction, direction: body.direction,
sizeUSD: requestedPositionSizeUSD, sizeUSD: positionSizeUSD,
slippageTolerance: config.slippageTolerance, slippageTolerance: config.slippageTolerance,
}) })
@@ -316,7 +318,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
// CRITICAL: Check for phantom trade (position opened but size mismatch) // CRITICAL: Check for phantom trade (position opened but size mismatch)
if (openResult.isPhantom) { if (openResult.isPhantom) {
console.error(`🚨 PHANTOM TRADE DETECTED - Not adding to Position Manager`) console.error(`🚨 PHANTOM TRADE DETECTED - Not adding to Position Manager`)
console.error(` Expected: $${requestedPositionSizeUSD.toFixed(2)}`) console.error(` Expected: $${positionSizeUSD.toFixed(2)}`)
console.error(` Actual: $${openResult.actualSizeUSD?.toFixed(2)}`) console.error(` Actual: $${openResult.actualSizeUSD?.toFixed(2)}`)
// Save phantom trade to database for analysis // Save phantom trade to database for analysis
@@ -336,7 +338,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
symbol: driftSymbol, symbol: driftSymbol,
direction: body.direction, direction: body.direction,
entryPrice: openResult.fillPrice!, entryPrice: openResult.fillPrice!,
positionSizeUSD: requestedPositionSizeUSD, positionSizeUSD: positionSizeUSD,
leverage: config.leverage, leverage: config.leverage,
stopLossPrice: 0, // Not applicable for phantom stopLossPrice: 0, // Not applicable for phantom
takeProfit1Price: 0, takeProfit1Price: 0,
@@ -356,7 +358,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
// Phantom-specific fields // Phantom-specific fields
status: 'phantom', status: 'phantom',
isPhantom: true, isPhantom: true,
expectedSizeUSD: requestedPositionSizeUSD, expectedSizeUSD: positionSizeUSD,
actualSizeUSD: openResult.actualSizeUSD, actualSizeUSD: openResult.actualSizeUSD,
phantomReason: 'ORACLE_PRICE_MISMATCH', // Likely cause based on logs phantomReason: 'ORACLE_PRICE_MISMATCH', // Likely cause based on logs
}) })
@@ -370,7 +372,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
{ {
success: false, success: false,
error: 'Phantom trade detected', error: 'Phantom trade detected',
message: `Position opened but size mismatch detected. Expected $${requestedPositionSizeUSD.toFixed(2)}, got $${openResult.actualSizeUSD?.toFixed(2)}. This usually indicates oracle price was stale or order was rejected by exchange.`, message: `Position opened but size mismatch detected. Expected $${positionSizeUSD.toFixed(2)}, got $${openResult.actualSizeUSD?.toFixed(2)}. This usually indicates oracle price was stale or order was rejected by exchange.`,
}, },
{ status: 500 } { status: 500 }
) )
@@ -378,20 +380,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
// Calculate stop loss and take profit prices // Calculate stop loss and take profit prices
const entryPrice = openResult.fillPrice! const entryPrice = openResult.fillPrice!
const actualPositionSizeUSD = openResult.actualSizeUSD ?? requestedPositionSizeUSD
const filledBaseSize = openResult.fillSize !== undefined
? Math.abs(openResult.fillSize)
: (entryPrice > 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( const stopLossPrice = calculatePrice(
entryPrice, entryPrice,
@@ -425,15 +413,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
body.direction body.direction
) )
const dynamicTp2Percent = calculateDynamicTp2(
entryPrice,
body.atr || 0, // ATR from TradingView signal
config
)
const tp2Price = calculatePrice( const tp2Price = calculatePrice(
entryPrice, entryPrice,
dynamicTp2Percent, config.takeProfit2Percent,
body.direction body.direction
) )
@@ -441,7 +423,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
console.log(` Entry: $${entryPrice.toFixed(4)}`) console.log(` Entry: $${entryPrice.toFixed(4)}`)
console.log(` SL: $${stopLossPrice.toFixed(4)} (${config.stopLossPercent}%)`) console.log(` SL: $${stopLossPrice.toFixed(4)} (${config.stopLossPercent}%)`)
console.log(` TP1: $${tp1Price.toFixed(4)} (${config.takeProfit1Percent}%)`) console.log(` TP1: $${tp1Price.toFixed(4)} (${config.takeProfit1Percent}%)`)
console.log(` TP2: $${tp2Price.toFixed(4)} (${dynamicTp2Percent.toFixed(2)}% - ATR-based)`) console.log(` TP2: $${tp2Price.toFixed(4)} (${config.takeProfit2Percent}%)`)
// Calculate emergency stop // Calculate emergency stop
const emergencyStopPrice = calculatePrice( const emergencyStopPrice = calculatePrice(
@@ -458,13 +440,13 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
direction: body.direction, direction: body.direction,
entryPrice, entryPrice,
entryTime: Date.now(), entryTime: Date.now(),
positionSize: actualPositionSizeUSD, positionSize: positionSizeUSD,
leverage: config.leverage, leverage: config.leverage,
stopLossPrice, stopLossPrice,
tp1Price, tp1Price,
tp2Price, tp2Price,
emergencyStopPrice, emergencyStopPrice,
currentSize: actualPositionSizeUSD, currentSize: positionSizeUSD,
tp1Hit: false, tp1Hit: false,
tp2Hit: false, tp2Hit: false,
slMovedToBreakeven: false, slMovedToBreakeven: false,
@@ -483,8 +465,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
originalAdx: body.adx, // Store for scaling validation originalAdx: body.adx, // Store for scaling validation
timesScaled: 0, timesScaled: 0,
totalScaleAdded: 0, totalScaleAdded: 0,
atrAtEntry: body.atr,
runnerTrailingPercent: undefined,
priceCheckCount: 0, priceCheckCount: 0,
lastPrice: entryPrice, lastPrice: entryPrice,
lastUpdateTime: Date.now(), lastUpdateTime: Date.now(),
@@ -497,13 +477,13 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
try { try {
const exitRes = await placeExitOrders({ const exitRes = await placeExitOrders({
symbol: driftSymbol, symbol: driftSymbol,
positionSizeUSD: actualPositionSizeUSD, positionSizeUSD: positionSizeUSD,
entryPrice: entryPrice, entryPrice: entryPrice,
tp1Price, tp1Price,
tp2Price, tp2Price,
stopLossPrice, stopLossPrice,
tp1SizePercent: config.takeProfit1SizePercent ?? 50, tp1SizePercent: config.takeProfit1SizePercent ?? 75,
tp2SizePercent: config.takeProfit2SizePercent ?? 100, // Use ?? instead of || to allow 0 tp2SizePercent: config.takeProfit2SizePercent ?? 0, // 0 = activate trailing stop, don't close
direction: body.direction, direction: body.direction,
// Dual stop parameters // Dual stop parameters
useDualStops: config.useDualStops, useDualStops: config.useDualStops,
@@ -534,16 +514,14 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
symbol: driftSymbol, symbol: driftSymbol,
direction: body.direction, direction: body.direction,
entryPrice: entryPrice, entryPrice: entryPrice,
positionSize: actualPositionSizeUSD, positionSize: positionSizeUSD,
requestedPositionSize: requestedPositionSizeUSD,
fillCoveragePercent: Number(fillCoverage.toFixed(2)),
leverage: config.leverage, leverage: config.leverage,
stopLoss: stopLossPrice, stopLoss: stopLossPrice,
takeProfit1: tp1Price, takeProfit1: tp1Price,
takeProfit2: tp2Price, takeProfit2: tp2Price,
stopLossPercent: config.stopLossPercent, stopLossPercent: config.stopLossPercent,
tp1Percent: config.takeProfit1Percent, tp1Percent: config.takeProfit1Percent,
tp2Percent: dynamicTp2Percent, tp2Percent: config.takeProfit2Percent,
entrySlippage: openResult.slippage, entrySlippage: openResult.slippage,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
} }
@@ -571,13 +549,13 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
symbol: driftSymbol, symbol: driftSymbol,
direction: body.direction, direction: body.direction,
entryPrice, entryPrice,
positionSizeUSD: actualPositionSizeUSD, positionSizeUSD: positionSizeUSD,
leverage: config.leverage, leverage: config.leverage,
stopLossPrice, stopLossPrice,
takeProfit1Price: tp1Price, takeProfit1Price: tp1Price,
takeProfit2Price: tp2Price, takeProfit2Price: tp2Price,
tp1SizePercent: config.takeProfit1SizePercent || 50, tp1SizePercent: config.takeProfit1SizePercent ?? 75,
tp2SizePercent: config.takeProfit2SizePercent || 100, tp2SizePercent: config.takeProfit2SizePercent ?? 0, // Use ?? to allow 0 for runner system
configSnapshot: config, configSnapshot: config,
entryOrderTx: openResult.transactionSignature!, entryOrderTx: openResult.transactionSignature!,
tp1OrderTx: exitOrderSignatures[0], tp1OrderTx: exitOrderSignatures[0],
@@ -596,8 +574,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
volumeAtEntry: body.volumeRatio, volumeAtEntry: body.volumeRatio,
pricePositionAtEntry: body.pricePosition, pricePositionAtEntry: body.pricePosition,
signalQualityScore: qualityResult.score, signalQualityScore: qualityResult.score,
expectedSizeUSD: requestedPositionSizeUSD,
actualSizeUSD: actualPositionSizeUSD,
}) })
console.log(`💾 Trade saved with quality score: ${qualityResult.score}/100`) console.log(`💾 Trade saved with quality score: ${qualityResult.score}/100`)

View File

@@ -22,7 +22,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
console.log('🔄 Position sync requested...') console.log('🔄 Position sync requested...')
const config = getMergedConfig() const config = getMergedConfig()
const driftService = await getDriftService() const driftService = await initializeDriftService()
const positionManager = await getInitializedPositionManager() const positionManager = await getInitializedPositionManager()
const prisma = getPrismaClient() const prisma = getPrismaClient()

View File

@@ -235,8 +235,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
maxAdverseExcursion: 0, maxAdverseExcursion: 0,
maxFavorablePrice: entryPrice, maxFavorablePrice: entryPrice,
maxAdversePrice: entryPrice, maxAdversePrice: entryPrice,
atrAtEntry: undefined,
runnerTrailingPercent: undefined,
priceCheckCount: 0, priceCheckCount: 0,
lastPrice: entryPrice, lastPrice: entryPrice,
lastUpdateTime: Date.now(), lastUpdateTime: Date.now(),

View File

@@ -293,8 +293,8 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<
// For orders that close a long, the order direction should be SHORT (sell) // For orders that close a long, the order direction should be SHORT (sell)
const orderDirection = options.direction === 'long' ? PositionDirection.SHORT : PositionDirection.LONG const orderDirection = options.direction === 'long' ? PositionDirection.SHORT : PositionDirection.LONG
// Place TP1 LIMIT reduce-only (skip if tp1Price is 0 - runner system) // Place TP1 LIMIT reduce-only
if (tp1USD > 0 && options.tp1Price > 0) { if (tp1USD > 0) {
const baseAmount = usdToBase(tp1USD) const baseAmount = usdToBase(tp1USD)
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) { if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
const orderParams: any = { 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) // Place TP2 LIMIT reduce-only
if (tp2USD > 0 && options.tp2Price > 0) { if (tp2USD > 0) {
const baseAmount = usdToBase(tp2USD) const baseAmount = usdToBase(tp2USD)
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) { if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
const orderParams: any = { const orderParams: any = {
@@ -517,23 +517,19 @@ export async function closePosition(
if (isDryRun) { if (isDryRun) {
console.log('🧪 DRY RUN MODE: Simulating close order (not executing on blockchain)') 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 profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
const closedNotional = sizeToClose * oraclePrice 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 mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}`
const leverage = 10 // Default for dry run
const collateralUsed = closedNotional / leverage
const accountPnLPercent = profitPercent * leverage
const realizedPnL = (collateralUsed * accountPnLPercent) / 100
console.log(`💰 Simulated close:`) console.log(`💰 Simulated close:`)
console.log(` Close price: $${oraclePrice.toFixed(4)}`) 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)}`) console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`)
const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}`
return { return {
success: true, success: true,
transactionSignature: mockTxSig, transactionSignature: mockTxSig,
@@ -573,7 +569,7 @@ export async function closePosition(
console.log('✅ Transaction confirmed on-chain') console.log('✅ Transaction confirmed on-chain')
// Calculate realized P&L with leverage // 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) const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
// Get leverage from user account (defaults to 10x if not found) // 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') 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 closedNotional = sizeToClose * oraclePrice
const collateralUsed = closedNotional / leverage // CRITICAL FIX: Calculate P&L on collateral const realizedPnL = (closedNotional * profitPercent) / 100
const accountPnLPercent = profitPercent * leverage // Account P&L includes leverage const accountPnLPercent = profitPercent * leverage
const realizedPnL = (collateralUsed * accountPnLPercent) / 100
console.log(`💰 Close details:`) console.log(`💰 Close details:`)
console.log(` Close price: $${oraclePrice.toFixed(4)}`) console.log(` Close price: $${oraclePrice.toFixed(4)}`)

View File

@@ -35,7 +35,6 @@ export interface ActiveTrade {
slMovedToBreakeven: boolean slMovedToBreakeven: boolean
slMovedToProfit: boolean slMovedToProfit: boolean
trailingStopActive: boolean trailingStopActive: boolean
runnerTrailingPercent?: number // Latest dynamic trailing percent applied
// P&L tracking // P&L tracking
realizedPnL: number realizedPnL: number
@@ -53,7 +52,6 @@ export interface ActiveTrade {
originalAdx?: number // ADX at initial entry (for scaling validation) originalAdx?: number // ADX at initial entry (for scaling validation)
timesScaled?: number // How many times position has been scaled timesScaled?: number // How many times position has been scaled
totalScaleAdded?: number // Total USD added through scaling totalScaleAdded?: number // Total USD added through scaling
atrAtEntry?: number // ATR (absolute) when trade was opened
// Monitoring // Monitoring
priceCheckCount: number priceCheckCount: number
@@ -119,7 +117,6 @@ export class PositionManager {
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false, slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
slMovedToProfit: pmState?.slMovedToProfit ?? false, slMovedToProfit: pmState?.slMovedToProfit ?? false,
trailingStopActive: pmState?.trailingStopActive ?? false, trailingStopActive: pmState?.trailingStopActive ?? false,
runnerTrailingPercent: pmState?.runnerTrailingPercent,
realizedPnL: pmState?.realizedPnL ?? 0, realizedPnL: pmState?.realizedPnL ?? 0,
unrealizedPnL: pmState?.unrealizedPnL ?? 0, unrealizedPnL: pmState?.unrealizedPnL ?? 0,
peakPnL: pmState?.peakPnL ?? 0, peakPnL: pmState?.peakPnL ?? 0,
@@ -128,7 +125,6 @@ export class PositionManager {
maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0, maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0,
maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice, maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice,
maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice, maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice,
atrAtEntry: dbTrade.atrAtEntry ?? undefined,
priceCheckCount: 0, priceCheckCount: 0,
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice, lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
lastUpdateTime: Date.now(), lastUpdateTime: Date.now(),
@@ -136,12 +132,6 @@ export class PositionManager {
this.activeTrades.set(activeTrade.id, activeTrade) this.activeTrades.set(activeTrade.id, activeTrade)
console.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`) 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) { if (this.activeTrades.size > 0) {
@@ -213,22 +203,6 @@ export class PositionManager {
return Array.from(this.activeTrades.values()) return Array.from(this.activeTrades.values())
} }
async reconcileTrade(symbol: string): Promise<void> {
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 * Get specific trade
*/ */
@@ -342,13 +316,16 @@ export class PositionManager {
console.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`) console.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`)
} else { } else {
// Position exists - check if size changed (TP1/TP2 filled) // 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 trackedSizeUSD = trade.currentSize
const sizeDiffPercent = Math.abs(positionSizeUSD - trackedSizeUSD) / trackedSizeUSD * 100 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 position size reduced significantly, TP orders likely filled
if (positionSizeUSD < trackedSizeUSD * 0.9 && sizeDiffPercent > 10) { 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 // Detect which TP filled based on size reduction
const reductionPercent = ((trackedSizeUSD - positionSizeUSD) / trade.positionSize) * 100 const reductionPercent = ((trackedSizeUSD - positionSizeUSD) / trade.positionSize) * 100
@@ -359,7 +336,12 @@ export class PositionManager {
trade.tp1Hit = true trade.tp1Hit = true
trade.currentSize = positionSizeUSD 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) { } else if (trade.tp1Hit && !trade.tp2Hit && reductionPercent >= 85) {
// TP2 fired (total should be ~95% closed, 5% runner left) // TP2 fired (total should be ~95% closed, 5% runner left)
@@ -367,22 +349,19 @@ export class PositionManager {
trade.tp2Hit = true trade.tp2Hit = true
trade.currentSize = positionSizeUSD trade.currentSize = positionSizeUSD
trade.trailingStopActive = true trade.trailingStopActive = true
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade) console.log(`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with ${this.config.trailingStopPercent}% trailing stop`)
console.log(
`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%`
)
await this.saveTradeState(trade) 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 { } else {
// Partial fill detected but unclear which TP - just update size // Partial fill detected but unclear which TP - just update size
console.log(`⚠️ Unknown partial fill detected - updating tracked size to $${positionSizeUSD.toFixed(2)}`) console.log(`⚠️ Unknown partial fill detected - updating tracked size to $${positionSizeUSD.toFixed(2)}`)
trade.currentSize = positionSizeUSD trade.currentSize = positionSizeUSD
await this.saveTradeState(trade) await this.saveTradeState(trade)
} }
// Continue monitoring the remaining position
return
} }
// CRITICAL: Check for entry price mismatch (NEW position opened) // CRITICAL: Check for entry price mismatch (NEW position opened)
@@ -404,10 +383,10 @@ export class PositionManager {
trade.lastPrice, trade.lastPrice,
trade.direction trade.direction
) )
const accountPnL = profitPercent * trade.leverage const accountPnLPercent = profitPercent * trade.leverage
const estimatedPnL = (trade.currentSize * accountPnL) / 100 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 { try {
await updateTradeExit({ await updateTradeExit({
@@ -448,7 +427,10 @@ export class PositionManager {
// trade.currentSize may already be 0 if on-chain orders closed the position before // trade.currentSize may already be 0 if on-chain orders closed the position before
// Position Manager detected it, causing zero P&L bug // Position Manager detected it, causing zero P&L bug
// HOWEVER: If this was a phantom trade (extreme size mismatch), set P&L to 0 // 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 // 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 // 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(`📊 External closure detected - Position size tracking:`)
console.log(` Original size: $${trade.positionSize.toFixed(2)}`) console.log(` Original size: $${trade.positionSize.toFixed(2)}`)
console.log(` Tracked current size: $${trade.currentSize.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) { if (wasPhantom) {
console.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`) console.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`)
} }
@@ -466,42 +449,23 @@ export class PositionManager {
// CRITICAL: Use trade state flags, not current price (on-chain orders filled in the past!) // 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' let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL'
// Calculate P&L first (set to 0 for phantom trades) // Include any previously realized profit (e.g., from TP1 partial close)
let realizedPnL = 0 const previouslyRealized = trade.realizedPnL
let exitPrice = currentPrice let runnerRealized = 0
let runnerProfitPercent = 0
if (!wasPhantom) { if (!wasPhantom) {
// For external closures, try to estimate a more realistic exit price runnerProfitPercent = this.calculateProfitPercent(
// 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, trade.entryPrice,
currentPrice, currentPrice,
trade.direction trade.direction
) )
const accountPnL = profitPercent * trade.leverage runnerRealized = (sizeForPnL * runnerProfitPercent) / 100
realizedPnL = (sizeForPnL * accountPnL) / 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 // Determine exit reason from trade state and P&L
if (trade.tp2Hit) { if (trade.tp2Hit) {
// TP2 was hit, full position closed (runner stopped or hit target) // TP2 was hit, full position closed (runner stopped or hit target)
@@ -509,14 +473,14 @@ export class PositionManager {
} else if (trade.tp1Hit) { } else if (trade.tp1Hit) {
// TP1 was hit, position should be 25% size, but now fully closed // TP1 was hit, position should be 25% size, but now fully closed
// This means either TP2 filled or runner got stopped out // This means either TP2 filled or runner got stopped out
exitReason = realizedPnL > 0 ? 'TP2' : 'SL' exitReason = totalRealizedPnL > 0 ? 'TP2' : 'SL'
} else { } else {
// No TPs hit yet - either SL or TP1 filled just now // No TPs hit yet - either SL or TP1 filled just now
// Use P&L to determine: positive = TP, negative = SL // 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 // More than 0.5% profit - must be TP1
exitReason = 'TP1' exitReason = 'TP1'
} else if (realizedPnL < 0) { } else if (totalRealizedPnL < 0) {
// Loss - must be SL // Loss - must be SL
exitReason = 'SL' exitReason = 'SL'
} }
@@ -528,9 +492,9 @@ export class PositionManager {
try { try {
await updateTradeExit({ await updateTradeExit({
positionId: trade.positionId, positionId: trade.positionId,
exitPrice: exitPrice, // Use estimated exit price, not current market price exitPrice: currentPrice,
exitReason, exitReason,
realizedPnL, realizedPnL: totalRealizedPnL,
exitOrderTx: 'ON_CHAIN_ORDER', exitOrderTx: 'ON_CHAIN_ORDER',
holdTimeSeconds, holdTimeSeconds,
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)), maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
@@ -540,7 +504,7 @@ export class PositionManager {
maxFavorablePrice: trade.maxFavorablePrice, maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice, 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) { } catch (dbError) {
console.error('❌ Failed to save external closure:', 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?) // Position exists but size mismatch (partial close by TP1?)
const onChainBaseSize = Math.abs(position.size) if (position.size < trade.currentSize * 0.95) { // 5% tolerance
const onChainSizeUSD = onChainBaseSize * currentPrice console.log(`⚠️ Position size mismatch: expected ${trade.currentSize}, got ${position.size}`)
const trackedSizeUSD = trade.currentSize
if (trackedSizeUSD > 0 && onChainSizeUSD < trackedSizeUSD * 0.95) { // 5% tolerance // CRITICAL: Check if position direction changed (signal flip, not TP1!)
const expectedBaseSize = trackedSizeUSD / currentPrice const positionDirection = position.side === 'long' ? 'long' : 'short'
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 (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.`)
// CRITICAL: If mismatch is extreme (>50%), this is a phantom trade // Calculate actual P&L on full position
const sizeRatio = trackedSizeUSD > 0 ? onChainSizeUSD / trackedSizeUSD : 0 const profitPercent = this.calculateProfitPercent(trade.entryPrice, currentPrice, trade.direction)
if (sizeRatio < 0.5) { const actualPnL = (trade.positionSize * profitPercent) / 100
const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000
const probablyPartialRunner = trade.tp1Hit || tradeAgeSeconds > 60
if (probablyPartialRunner) { try {
console.log(`🛠️ Detected stray remainder (${(sizeRatio * 100).toFixed(1)}%) after on-chain exit - forcing market close`) const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
trade.currentSize = onChainSizeUSD await updateTradeExit({
await this.saveTradeState(trade) positionId: trade.positionId,
await this.executeExit(trade, 100, 'manual', currentPrice) 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 return
} }
// CRITICAL: If mismatch is extreme (>50%), this is a phantom trade
const sizeRatio = (position.size * currentPrice) / trade.currentSize
if (sizeRatio < 0.5) {
console.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`) console.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`)
console.log(` Expected: $${trackedSizeUSD.toFixed(2)}`) console.log(` Expected: $${trade.currentSize.toFixed(2)}`)
console.log(` Actual: $${onChainSizeUSD.toFixed(2)}`) console.log(` Actual: $${(position.size * currentPrice).toFixed(2)}`)
// Close as phantom trade // Close as phantom trade
try { try {
@@ -603,16 +586,11 @@ export class PositionManager {
return return
} }
// Update current size to match reality and run TP1 adjustments if needed // Update current size to match reality (convert base asset size to USD using current price)
trade.currentSize = onChainSizeUSD trade.currentSize = position.size * currentPrice
if (!trade.tp1Hit) {
trade.tp1Hit = true trade.tp1Hit = true
await this.handlePostTp1Adjustments(trade, 'on-chain TP1 size sync')
} else {
await this.saveTradeState(trade) await this.saveTradeState(trade)
} }
return
}
} catch (error) { } catch (error) {
// If we can't check position, continue with monitoring (don't want to false-positive) // If we can't check position, continue with monitoring (don't want to false-positive)
@@ -637,7 +615,7 @@ export class PositionManager {
) )
const accountPnL = profitPercent * trade.leverage const accountPnL = profitPercent * trade.leverage
trade.unrealizedPnL = (trade.currentSize * accountPnL) / 100 trade.unrealizedPnL = (trade.currentSize * profitPercent) / 100
// Track peak P&L (MFE - Maximum Favorable Excursion) // Track peak P&L (MFE - Maximum Favorable Excursion)
if (trade.unrealizedPnL > trade.peakPnL) { if (trade.unrealizedPnL > trade.peakPnL) {
@@ -702,7 +680,56 @@ export class PositionManager {
// Move SL based on breakEvenTriggerPercent setting // Move SL based on breakEvenTriggerPercent setting
trade.tp1Hit = true trade.tp1Hit = true
trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100) 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 return
} }
@@ -727,39 +754,42 @@ export class PositionManager {
await this.saveTradeState(trade) 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)) { 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% // Calculate how much to close based on TP2 size percent
const percentToClose = this.config.takeProfit2SizePercent
await this.executeExit(trade, percentToClose, 'TP2', currentPrice)
// If some position remains, mark TP2 as hit and activate trailing stop
if (percentToClose < 100) {
trade.tp2Hit = true trade.tp2Hit = true
trade.peakPrice = currentPrice trade.currentSize = trade.currentSize * ((100 - percentToClose) / 100)
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade)
console.log( console.log(`🏃 Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`)
`🏃 Runner activated on full remaining position: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% | trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%`
)
// Save state after TP2 activation // Save state after TP2
await this.saveTradeState(trade) await this.saveTradeState(trade)
}
return return
} // 6. Trailing stop for runner (after TP2 activation) }
// 6. Trailing stop for runner (after TP2)
if (trade.tp2Hit && this.config.useTrailingStop) { if (trade.tp2Hit && this.config.useTrailingStop) {
// Check if trailing stop should be activated // Check if trailing stop should be activated
if (!trade.trailingStopActive && profitPercent >= this.config.trailingStopActivation) { if (!trade.trailingStopActive && profitPercent >= this.config.trailingStopActivation) {
trade.trailingStopActive = true trade.trailingStopActive = true
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade)
console.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`) console.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`)
} }
// If trailing stop is active, adjust SL dynamically // If trailing stop is active, adjust SL dynamically
if (trade.trailingStopActive) { if (trade.trailingStopActive) {
const trailingPercent = this.getRunnerTrailingPercent(trade)
trade.runnerTrailingPercent = trailingPercent
const trailingStopPrice = this.calculatePrice( const trailingStopPrice = this.calculatePrice(
trade.peakPrice, trade.peakPrice,
-trailingPercent, // Trail below peak -this.config.trailingStopPercent, // Trail below peak
trade.direction trade.direction
) )
@@ -772,7 +802,7 @@ export class PositionManager {
const oldSL = trade.stopLossPrice const oldSL = trade.stopLossPrice
trade.stopLossPrice = trailingStopPrice 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) // Save state after trailing SL update (every 10 updates to avoid spam)
if (trade.priceCheckCount % 10 === 0) { if (trade.priceCheckCount % 10 === 0) {
@@ -813,37 +843,18 @@ export class PositionManager {
return 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 // Update trade state
if (treatAsFullClose) { if (percentToClose >= 100) {
trade.realizedPnL += actualRealizedPnL // Full close - remove from monitoring
trade.currentSize = 0 trade.realizedPnL += result.realizedPnL || 0
trade.trailingStopActive = false
if (reason === 'TP2') {
trade.tp2Hit = true
}
if (reason === 'TP1') {
trade.tp1Hit = true
}
// Save to database (only for valid exit reasons)
if (reason !== 'error') { if (reason !== 'error') {
try { try {
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000) const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
await updateTradeExit({ await updateTradeExit({
positionId: trade.positionId, positionId: trade.positionId,
exitPrice: closePriceForCalc, exitPrice: result.closePrice || currentPrice,
exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency', exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency',
realizedPnL: trade.realizedPnL, realizedPnL: trade.realizedPnL,
exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE', exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE',
@@ -858,23 +869,25 @@ export class PositionManager {
console.log('💾 Trade saved to database') console.log('💾 Trade saved to database')
} catch (dbError) { } catch (dbError) {
console.error('❌ Failed to save trade exit to database:', dbError) console.error('❌ Failed to save trade exit to database:', dbError)
// Don't fail the close if database fails
} }
} }
await this.removeTrade(trade.id) await this.removeTrade(trade.id)
console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`) console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`)
} else { } else {
// Partial close (TP1) - calculate P&L for partial amount // Partial close (TP1)
// CRITICAL: Same fix as above - closedUSD is notional, must use collateral trade.realizedPnL += result.realizedPnL || 0
const partialCollateralUSD = closedUSD / trade.leverage // result.closedSize is returned in base asset units (e.g., SOL), convert to USD using closePrice
const partialAccountPnL = profitPercent * trade.leverage const closePriceForCalc = result.closePrice || currentPrice
const partialRealizedPnL = (partialCollateralUSD * partialAccountPnL) / 100 const closedSizeBase = result.closedSize || 0
trade.realizedPnL += partialRealizedPnL const closedUSD = closedSizeBase * closePriceForCalc
trade.currentSize = Math.max(0, trade.currentSize - closedUSD) trade.currentSize = Math.max(0, trade.currentSize - closedUSD)
console.log( 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)}`)
`✅ Partial close executed | Realized: $${partialRealizedPnL.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 // TODO: Send notification
@@ -964,150 +977,6 @@ export class PositionManager {
console.log('✅ All positions closed') 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<void> {
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<void> {
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) * 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<TradingConfig>): void {
this.config = getMergedConfig(partial)
console.log('🔄 Position Manager config refreshed')
}
/** /**
* Get monitoring status * Get monitoring status
*/ */

View File

@@ -140,16 +140,8 @@ export function scoreSignalQuality(params: {
} }
// Price position check (avoid chasing vs breakout detection) // Price position check (avoid chasing vs breakout detection)
// CRITICAL: Low price position (< 40%) + weak trend (ADX < 25) = range-bound chop
if (params.pricePosition > 0) { if (params.pricePosition > 0) {
const isWeakTrend = params.adx > 0 && params.adx < 25 if (params.direction === 'long' && params.pricePosition > 95) {
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) {
// High volume breakout at range top can be good // High volume breakout at range top can be good
if (params.volumeRatio > 1.4) { if (params.volumeRatio > 1.4) {
score += 5 score += 5
@@ -173,15 +165,13 @@ export function scoreSignalQuality(params: {
} }
} }
// Volume breakout bonus - ONLY if trend is strong enough (not choppy) // Volume breakout bonus (high volume can override other weaknesses)
// Removed old logic that conflicted with anti-chop filter if (params.volumeRatio > 1.8 && params.atr < 0.6) {
// Old bonus was rewarding high volume even during choppy markets
if (params.volumeRatio > 1.8 && params.atr < 0.6 && params.adx > 18) {
score += 10 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 const passed = score >= minScore
return { return {

View File

@@ -367,7 +367,7 @@
}, },
{ {
"parameters": { "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", "id": "81f28bc7-c96a-4021-acac-242e993d9d98",
"name": "Parse Signal Enhanced", "name": "Parse Signal Enhanced",