feat: Implement re-entry analytics system with fresh TradingView data
- Add market data cache service (5min expiry) for storing TradingView metrics - Create /api/trading/market-data webhook endpoint for continuous data updates - Add /api/analytics/reentry-check endpoint for validating manual trades - Update execute endpoint to auto-cache metrics from incoming signals - Enhance Telegram bot with pre-execution analytics validation - Support --force flag to override analytics blocks - Use fresh ADX/ATR/RSI data when available, fallback to historical - Apply performance modifiers: -20 for losing streaks, +10 for winning - Minimum re-entry score 55 (vs 60 for new signals) - Fail-open design: proceeds if analytics unavailable - Show data freshness and source in Telegram responses - Add comprehensive setup guide in docs/guides/REENTRY_ANALYTICS_QUICKSTART.md Phase 1 implementation for smart manual trade validation.
This commit is contained in:
90
.github/copilot-instructions.md
vendored
90
.github/copilot-instructions.md
vendored
@@ -30,6 +30,13 @@
|
||||
|
||||
**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).
|
||||
|
||||
**Re-Entry Analytics System:** Manual trades are validated before execution using fresh TradingView data:
|
||||
- Market data cached from TradingView signals (5min expiry)
|
||||
- `/api/analytics/reentry-check` scores re-entry based on fresh metrics + recent performance
|
||||
- Telegram bot blocks low-quality re-entries unless `--force` flag used
|
||||
- Uses real TradingView ADX/ATR/RSI when available, falls back to historical data
|
||||
- Penalty for recent losing trades, bonus for winning streaks
|
||||
|
||||
## Critical Components
|
||||
|
||||
### 1. Signal Quality Scoring (`lib/trading/signal-quality.ts`)
|
||||
@@ -87,18 +94,23 @@ await positionManager.addTrade(activeTrade)
|
||||
**Manual trade commands via plain text:**
|
||||
```python
|
||||
# User sends plain text message (not slash commands)
|
||||
"long sol" → Opens SOL-PERP long position
|
||||
"short eth" → Opens ETH-PERP short position
|
||||
"long btc" → Opens BTC-PERP long position
|
||||
"long sol" → Validates via analytics, then opens SOL-PERP long
|
||||
"short eth" → Validates via analytics, then opens ETH-PERP short
|
||||
"long btc --force" → Skips analytics validation, opens BTC-PERP long immediately
|
||||
```
|
||||
|
||||
**Key behaviors:**
|
||||
- MessageHandler processes all text messages (not just commands)
|
||||
- Maps user-friendly symbols (sol, eth, btc) to Drift format (SOL-PERP, etc.)
|
||||
- Calls `/api/trading/execute` directly with preset healthy metrics (ATR=1.0, ADX=25, RSI=50, volumeRatio=1.2)
|
||||
- **Analytics validation:** Calls `/api/analytics/reentry-check` before execution
|
||||
- Blocks trades with score <55 unless `--force` flag used
|
||||
- Uses fresh TradingView data (<5min old) when available
|
||||
- Falls back to historical metrics with penalty
|
||||
- Considers recent trade performance (last 3 trades)
|
||||
- Calls `/api/trading/execute` directly with preset healthy metrics (ATR=0.45, ADX=32, RSI=58/42)
|
||||
- Bypasses n8n workflow and TradingView requirements
|
||||
- 60-second timeout for API calls
|
||||
- Responds with trade confirmation or error message
|
||||
- Responds with trade confirmation or analytics rejection message
|
||||
|
||||
**Status command:**
|
||||
```python
|
||||
@@ -218,13 +230,15 @@ const driftSymbol = normalizeTradingViewSymbol(body.symbol)
|
||||
7. Add to Position Manager if applicable
|
||||
|
||||
**Key endpoints:**
|
||||
- `/api/trading/execute` - Main entry point from n8n (production, requires auth)
|
||||
- `/api/trading/execute` - Main entry point from n8n (production, requires auth), **auto-caches market data**
|
||||
- `/api/trading/check-risk` - Pre-execution validation (duplicate check, quality score, **per-symbol cooldown**, rate limits, **symbol enabled check**)
|
||||
- `/api/trading/test` - Test trades from settings UI (no auth required, **respects symbol enable/disable**)
|
||||
- `/api/trading/close` - Manual position closing
|
||||
- `/api/trading/positions` - Query open positions from Drift
|
||||
- `/api/trading/market-data` - Webhook for TradingView market data updates (GET for debug, POST for data)
|
||||
- `/api/settings` - Get/update config (writes to .env file, **includes per-symbol settings**)
|
||||
- `/api/analytics/last-trade` - Fetch most recent trade details for dashboard (includes quality score)
|
||||
- `/api/analytics/reentry-check` - **Validate manual re-entry** with fresh TradingView data + recent performance
|
||||
- `/api/analytics/version-comparison` - Compare performance across signal quality logic versions (v1/v2/v3)
|
||||
- `/api/restart` - Create restart flag for watch-restart.sh script
|
||||
|
||||
@@ -442,6 +456,70 @@ trade.realizedPnL += actualRealizedPnL // NOT: result.realizedPnL from SDK
|
||||
- **Types:** Define interfaces in same file as implementation (not separate types directory)
|
||||
- **Console logs:** Use emojis for visual scanning: 🎯 🚀 ✅ ❌ 💰 📊 🛡️
|
||||
|
||||
## Re-Entry Analytics System (Phase 1)
|
||||
|
||||
**Purpose:** Validate manual Telegram trades using fresh TradingView data + recent performance analysis
|
||||
|
||||
**Components:**
|
||||
1. **Market Data Cache** (`lib/trading/market-data-cache.ts`)
|
||||
- Singleton service storing TradingView metrics
|
||||
- 5-minute expiry on cached data
|
||||
- Tracks: ATR, ADX, RSI, volume ratio, price position, timeframe
|
||||
|
||||
2. **Market Data Webhook** (`app/api/trading/market-data/route.ts`)
|
||||
- Receives TradingView alerts every 1-5 minutes
|
||||
- POST: Updates cache with fresh metrics
|
||||
- GET: View cached data (debugging)
|
||||
|
||||
3. **Re-Entry Check Endpoint** (`app/api/analytics/reentry-check/route.ts`)
|
||||
- Validates manual trade requests
|
||||
- Uses fresh TradingView data if available (<5min old)
|
||||
- Falls back to historical metrics from last trade
|
||||
- Scores signal quality + applies performance modifiers:
|
||||
- **-20 points** if last 3 trades lost money (avgPnL < -5%)
|
||||
- **+10 points** if last 3 trades won (avgPnL > +5%, WR >= 66%)
|
||||
- **-5 points** for stale data, **-10 points** for no data
|
||||
- Minimum score: 55 (vs 60 for new signals)
|
||||
|
||||
4. **Auto-Caching** (`app/api/trading/execute/route.ts`)
|
||||
- Every trade signal from TradingView auto-caches metrics
|
||||
- Ensures fresh data available for manual re-entries
|
||||
|
||||
5. **Telegram Integration** (`telegram_command_bot.py`)
|
||||
- Calls `/api/analytics/reentry-check` before executing manual trades
|
||||
- Shows data freshness ("✅ FRESH 23s old" vs "⚠️ Historical")
|
||||
- Blocks low-quality re-entries unless `--force` flag used
|
||||
- Fail-open: Proceeds if analytics check fails
|
||||
|
||||
**User Flow:**
|
||||
```
|
||||
User: "long sol"
|
||||
↓ Check cache for SOL-PERP
|
||||
↓ Fresh data? → Use real TradingView metrics
|
||||
↓ Stale/missing? → Use historical + penalty
|
||||
↓ Score quality + recent performance
|
||||
↓ Score >= 55? → Execute
|
||||
↓ Score < 55? → Block (unless --force)
|
||||
```
|
||||
|
||||
**TradingView Setup:**
|
||||
Create alerts that fire every 1-5 minutes with this webhook message:
|
||||
```json
|
||||
{
|
||||
"action": "market_data",
|
||||
"symbol": "{{ticker}}",
|
||||
"timeframe": "{{interval}}",
|
||||
"atr": {{ta.atr(14)}},
|
||||
"adx": {{ta.dmi(14, 14)}},
|
||||
"rsi": {{ta.rsi(14)}},
|
||||
"volumeRatio": {{volume / ta.sma(volume, 20)}},
|
||||
"pricePosition": {{(close - ta.lowest(low, 100)) / (ta.highest(high, 100) - ta.lowest(low, 100)) * 100}},
|
||||
"currentPrice": {{close}}
|
||||
}
|
||||
```
|
||||
|
||||
Webhook URL: `https://your-domain.com/api/trading/market-data`
|
||||
|
||||
## Per-Symbol Trading Controls
|
||||
|
||||
**Purpose:** Independent enable/disable toggles and position sizing for SOL and ETH to support different trading strategies (e.g., ETH for data collection at minimal size, SOL for profit generation).
|
||||
|
||||
237
app/api/analytics/reentry-check/route.ts
Normal file
237
app/api/analytics/reentry-check/route.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMarketDataCache } from '@/lib/trading/market-data-cache'
|
||||
import { getPrismaClient } from '@/lib/database/trades'
|
||||
import { scoreSignalQuality } from '@/lib/trading/signal-quality'
|
||||
|
||||
/**
|
||||
* Re-Entry Analytics Endpoint
|
||||
*
|
||||
* Validates manual trades using:
|
||||
* 1. Fresh TradingView market data (if available)
|
||||
* 2. Recent trade performance (last 3 trades for symbol + direction)
|
||||
* 3. Signal quality scoring with performance modifiers
|
||||
*
|
||||
* Called by Telegram bot before executing manual "long sol" / "short eth" commands
|
||||
*/
|
||||
|
||||
interface ReentryAnalytics {
|
||||
should_enter: boolean
|
||||
score: number
|
||||
reason: string
|
||||
data_source: 'tradingview_real' | 'fallback_historical' | 'no_data'
|
||||
data_age_seconds?: number
|
||||
metrics: {
|
||||
atr: number
|
||||
adx: number
|
||||
rsi: number
|
||||
volumeRatio: number
|
||||
pricePosition: number
|
||||
timeframe: string
|
||||
recentTradeStats: {
|
||||
last3Trades: number
|
||||
winRate: number
|
||||
avgPnL: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { symbol, direction } = body
|
||||
|
||||
if (!symbol || !direction) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing symbol or direction' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!['long', 'short'].includes(direction)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Direction must be "long" or "short"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`🔍 Analyzing re-entry for ${direction.toUpperCase()} ${symbol}`)
|
||||
|
||||
// 1. Try to get REAL market data from TradingView cache
|
||||
const marketCache = getMarketDataCache()
|
||||
const cachedData = marketCache.get(symbol)
|
||||
|
||||
let metrics: any
|
||||
let dataSource: 'tradingview_real' | 'fallback_historical' | 'no_data'
|
||||
let dataAgeSeconds: number | undefined
|
||||
|
||||
if (cachedData) {
|
||||
// Use REAL TradingView data (less than 5min old)
|
||||
dataAgeSeconds = Math.round((Date.now() - cachedData.timestamp) / 1000)
|
||||
dataSource = 'tradingview_real'
|
||||
|
||||
console.log(`✅ Using real TradingView data (${dataAgeSeconds}s old)`)
|
||||
metrics = {
|
||||
atr: cachedData.atr,
|
||||
adx: cachedData.adx,
|
||||
rsi: cachedData.rsi,
|
||||
volumeRatio: cachedData.volumeRatio,
|
||||
pricePosition: cachedData.pricePosition,
|
||||
timeframe: cachedData.timeframe
|
||||
}
|
||||
} else {
|
||||
// Fallback to most recent trade metrics
|
||||
console.log(`⚠️ No fresh TradingView data, using historical metrics from last trade`)
|
||||
const prisma = getPrismaClient()
|
||||
const lastTrade = await prisma.trade.findFirst({
|
||||
where: { symbol },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
}) as any // Trade type has optional metric fields
|
||||
|
||||
if (lastTrade && lastTrade.atr && lastTrade.adx && lastTrade.rsi) {
|
||||
dataSource = 'fallback_historical'
|
||||
const tradeAge = Math.round((Date.now() - lastTrade.createdAt.getTime()) / 1000)
|
||||
console.log(`📊 Using metrics from last trade (${tradeAge}s ago)`)
|
||||
metrics = {
|
||||
atr: lastTrade.atr,
|
||||
adx: lastTrade.adx,
|
||||
rsi: lastTrade.rsi,
|
||||
volumeRatio: lastTrade.volumeRatio || 1.2,
|
||||
pricePosition: lastTrade.pricePosition || 50,
|
||||
timeframe: '5'
|
||||
}
|
||||
} else {
|
||||
// No data available at all
|
||||
console.log(`❌ No market data available for ${symbol}`)
|
||||
dataSource = 'no_data'
|
||||
metrics = {
|
||||
atr: 1.0,
|
||||
adx: 20,
|
||||
rsi: direction === 'long' ? 45 : 55,
|
||||
volumeRatio: 1.2,
|
||||
pricePosition: 50,
|
||||
timeframe: '5'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get recent trade performance for this symbol + direction
|
||||
const prisma = getPrismaClient()
|
||||
const recentTrades = await prisma.trade.findMany({
|
||||
where: {
|
||||
symbol,
|
||||
direction,
|
||||
exitTime: { not: null },
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000) // Last 24h
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3
|
||||
})
|
||||
|
||||
const last3Count = recentTrades.length
|
||||
const winningTrades = recentTrades.filter((t: any) => (t.realizedPnL || 0) > 0)
|
||||
const winRate = last3Count > 0 ? (winningTrades.length / last3Count) * 100 : 0
|
||||
const avgPnL = last3Count > 0
|
||||
? recentTrades.reduce((sum: number, t: any) => sum + (t.realizedPnL || 0), 0) / last3Count
|
||||
: 0
|
||||
|
||||
console.log(`📊 Recent performance: ${last3Count} trades, ${winRate.toFixed(0)}% WR, ${avgPnL.toFixed(2)}% avg P&L`)
|
||||
|
||||
// 3. Score the re-entry with real/fallback metrics
|
||||
const qualityResult = scoreSignalQuality({
|
||||
atr: metrics.atr,
|
||||
adx: metrics.adx,
|
||||
rsi: metrics.rsi,
|
||||
volumeRatio: metrics.volumeRatio,
|
||||
pricePosition: metrics.pricePosition,
|
||||
direction: direction as 'long' | 'short'
|
||||
})
|
||||
|
||||
let finalScore = qualityResult.score
|
||||
|
||||
// 4. Apply recent performance modifiers
|
||||
if (last3Count >= 2 && avgPnL < -5) {
|
||||
finalScore -= 20
|
||||
console.log(`⚠️ Recent trades losing (${avgPnL.toFixed(2)}% avg) - applying -20 penalty`)
|
||||
}
|
||||
|
||||
if (last3Count >= 2 && avgPnL > 5 && winRate >= 66) {
|
||||
finalScore += 10
|
||||
console.log(`✨ Recent trades winning (${winRate.toFixed(0)}% WR) - applying +10 bonus`)
|
||||
}
|
||||
|
||||
// 5. Penalize if using stale/no data
|
||||
if (dataSource === 'fallback_historical') {
|
||||
finalScore -= 5
|
||||
console.log(`⚠️ Using historical data - applying -5 penalty`)
|
||||
} else if (dataSource === 'no_data') {
|
||||
finalScore -= 10
|
||||
console.log(`⚠️ No market data available - applying -10 penalty`)
|
||||
}
|
||||
|
||||
// 6. Determine if should enter
|
||||
const MIN_REENTRY_SCORE = 55
|
||||
const should_enter = finalScore >= MIN_REENTRY_SCORE
|
||||
|
||||
let reason = ''
|
||||
if (!should_enter) {
|
||||
if (dataSource === 'no_data') {
|
||||
reason = `No market data available (score: ${finalScore})`
|
||||
} else if (dataSource === 'fallback_historical') {
|
||||
reason = `Using stale data (score: ${finalScore})`
|
||||
} else if (finalScore < MIN_REENTRY_SCORE) {
|
||||
reason = `Quality score too low (${finalScore} < ${MIN_REENTRY_SCORE})`
|
||||
}
|
||||
|
||||
if (last3Count >= 2 && avgPnL < -5) {
|
||||
reason += `. Recent ${direction} trades losing (${avgPnL.toFixed(2)}% avg)`
|
||||
}
|
||||
} else {
|
||||
reason = `Quality score acceptable (${finalScore}/${MIN_REENTRY_SCORE})`
|
||||
|
||||
if (dataSource === 'tradingview_real') {
|
||||
reason += ` [✅ FRESH TradingView data: ${dataAgeSeconds}s old]`
|
||||
} else if (dataSource === 'fallback_historical') {
|
||||
reason += ` [⚠️ Historical data - consider waiting for fresh signal]`
|
||||
} else {
|
||||
reason += ` [❌ No data - risky entry]`
|
||||
}
|
||||
|
||||
if (winRate >= 66 && last3Count >= 2) {
|
||||
reason += `. Recent win rate: ${winRate.toFixed(0)}%`
|
||||
}
|
||||
}
|
||||
|
||||
const response: ReentryAnalytics = {
|
||||
should_enter,
|
||||
score: finalScore,
|
||||
reason,
|
||||
data_source: dataSource,
|
||||
data_age_seconds: dataAgeSeconds,
|
||||
metrics: {
|
||||
...metrics,
|
||||
recentTradeStats: {
|
||||
last3Trades: last3Count,
|
||||
winRate,
|
||||
avgPnL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📊 Re-entry analysis complete:`, {
|
||||
should_enter,
|
||||
score: finalScore,
|
||||
data_source: dataSource
|
||||
})
|
||||
|
||||
return NextResponse.json(response)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Re-entry analysis error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { scoreSignalQuality, SignalQualityResult } from '@/lib/trading/signal-qu
|
||||
export interface RiskCheckRequest {
|
||||
symbol: string
|
||||
direction: 'long' | 'short'
|
||||
timeframe?: string // e.g., '5', '15', '60', '1D'
|
||||
// Optional context metrics from TradingView
|
||||
atr?: number
|
||||
adx?: number
|
||||
@@ -58,7 +57,6 @@ function shouldAllowScaling(
|
||||
pricePosition: newSignal.pricePosition,
|
||||
direction: newSignal.direction,
|
||||
minScore: config.minScaleQualityScore,
|
||||
timeframe: newSignal.timeframe,
|
||||
})
|
||||
|
||||
// 2. Check quality score (higher bar than initial entry)
|
||||
@@ -148,9 +146,8 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
|
||||
const config = getMergedConfig()
|
||||
|
||||
// Check for existing positions on the same symbol
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
await positionManager.reconcileTrade(body.symbol)
|
||||
const existingTrades = Array.from(positionManager.getActiveTrades().values())
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const existingTrades = Array.from(positionManager.getActiveTrades().values())
|
||||
const existingPosition = existingTrades.find(trade => trade.symbol === body.symbol)
|
||||
|
||||
if (existingPosition) {
|
||||
@@ -273,8 +270,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
|
||||
volumeRatio: body.volumeRatio || 0,
|
||||
pricePosition: body.pricePosition || 0,
|
||||
direction: body.direction,
|
||||
minScore: 60, // Hardcoded threshold
|
||||
timeframe: body.timeframe,
|
||||
minScore: 60 // Hardcoded threshold
|
||||
})
|
||||
|
||||
if (!qualityScore.passed) {
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initializeDriftService } from '@/lib/drift/client'
|
||||
import { openPosition, placeExitOrders } from '@/lib/drift/orders'
|
||||
import { normalizeTradingViewSymbol, calculateDynamicTp2 } from '@/config/trading'
|
||||
import { normalizeTradingViewSymbol } from '@/config/trading'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||
import { createTrade, updateTradeExit } from '@/lib/database/trades'
|
||||
import { scoreSignalQuality } from '@/lib/trading/signal-quality'
|
||||
import { getMarketDataCache } from '@/lib/trading/market-data-cache'
|
||||
|
||||
export interface ExecuteTradeRequest {
|
||||
symbol: string // TradingView symbol (e.g., 'SOLUSDT')
|
||||
@@ -35,8 +36,6 @@ export interface ExecuteTradeResponse {
|
||||
direction?: 'long' | 'short'
|
||||
entryPrice?: number
|
||||
positionSize?: number
|
||||
requestedPositionSize?: number
|
||||
fillCoveragePercent?: number
|
||||
leverage?: number
|
||||
stopLoss?: number
|
||||
takeProfit1?: number
|
||||
@@ -88,6 +87,23 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
const driftSymbol = normalizeTradingViewSymbol(body.symbol)
|
||||
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
|
||||
const config = getMergedConfig()
|
||||
|
||||
@@ -180,16 +196,8 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
|
||||
// Update Position Manager tracking
|
||||
const timesScaled = (sameDirectionPosition.timesScaled || 0) + 1
|
||||
const actualScaleNotional = scaleResult.fillNotionalUSD ?? scaleSize
|
||||
const totalScaleAdded = (sameDirectionPosition.totalScaleAdded || 0) + actualScaleNotional
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
const totalScaleAdded = (sameDirectionPosition.totalScaleAdded || 0) + scaleSize
|
||||
const newTotalSize = sameDirectionPosition.currentSize + (scaleResult.fillSize || 0)
|
||||
|
||||
// Update the trade tracking (simplified - just update the active trade object)
|
||||
sameDirectionPosition.timesScaled = timesScaled
|
||||
@@ -279,20 +287,20 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
|
||||
// Calculate requested position size with leverage
|
||||
const requestedPositionSizeUSD = positionSize * leverage
|
||||
// Calculate position size with leverage
|
||||
const positionSizeUSD = positionSize * leverage
|
||||
|
||||
console.log(`💰 Opening ${body.direction} position:`)
|
||||
console.log(` Symbol: ${driftSymbol}`)
|
||||
console.log(` Base size: $${positionSize}`)
|
||||
console.log(` Leverage: ${leverage}x`)
|
||||
console.log(` Requested notional: $${requestedPositionSizeUSD}`)
|
||||
console.log(` Total position: $${positionSizeUSD}`)
|
||||
|
||||
// Open position
|
||||
const openResult = await openPosition({
|
||||
symbol: driftSymbol,
|
||||
direction: body.direction,
|
||||
sizeUSD: requestedPositionSizeUSD,
|
||||
sizeUSD: positionSizeUSD,
|
||||
slippageTolerance: config.slippageTolerance,
|
||||
})
|
||||
|
||||
@@ -310,7 +318,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
// CRITICAL: Check for phantom trade (position opened but size mismatch)
|
||||
if (openResult.isPhantom) {
|
||||
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)}`)
|
||||
|
||||
// Save phantom trade to database for analysis
|
||||
@@ -322,7 +330,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
volumeRatio: body.volumeRatio || 0,
|
||||
pricePosition: body.pricePosition || 0,
|
||||
direction: body.direction,
|
||||
timeframe: body.timeframe,
|
||||
})
|
||||
|
||||
await createTrade({
|
||||
@@ -330,7 +337,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
symbol: driftSymbol,
|
||||
direction: body.direction,
|
||||
entryPrice: openResult.fillPrice!,
|
||||
positionSizeUSD: requestedPositionSizeUSD,
|
||||
positionSizeUSD: positionSizeUSD,
|
||||
leverage: config.leverage,
|
||||
stopLossPrice: 0, // Not applicable for phantom
|
||||
takeProfit1Price: 0,
|
||||
@@ -347,11 +354,10 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
volumeAtEntry: body.volumeRatio,
|
||||
pricePositionAtEntry: body.pricePosition,
|
||||
signalQualityScore: qualityResult.score,
|
||||
signalQualityVersion: 'v3', // Stricter logic with ADX > 18 requirement
|
||||
// Phantom-specific fields
|
||||
status: 'phantom',
|
||||
isPhantom: true,
|
||||
expectedSizeUSD: requestedPositionSizeUSD,
|
||||
expectedSizeUSD: positionSizeUSD,
|
||||
actualSizeUSD: openResult.actualSizeUSD,
|
||||
phantomReason: 'ORACLE_PRICE_MISMATCH', // Likely cause based on logs
|
||||
})
|
||||
@@ -365,7 +371,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
{
|
||||
success: false,
|
||||
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 }
|
||||
)
|
||||
@@ -373,20 +379,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
|
||||
// Calculate stop loss and take profit prices
|
||||
const entryPrice = openResult.fillPrice!
|
||||
const actualPositionSizeUSD = openResult.fillNotionalUSD ?? 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(
|
||||
entryPrice,
|
||||
@@ -420,15 +412,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
body.direction
|
||||
)
|
||||
|
||||
const dynamicTp2Percent = calculateDynamicTp2(
|
||||
entryPrice,
|
||||
body.atr || 0, // ATR from TradingView signal
|
||||
config
|
||||
)
|
||||
|
||||
const tp2Price = calculatePrice(
|
||||
entryPrice,
|
||||
dynamicTp2Percent,
|
||||
config.takeProfit2Percent,
|
||||
body.direction
|
||||
)
|
||||
|
||||
@@ -436,7 +422,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
console.log(` Entry: $${entryPrice.toFixed(4)}`)
|
||||
console.log(` SL: $${stopLossPrice.toFixed(4)} (${config.stopLossPercent}%)`)
|
||||
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
|
||||
const emergencyStopPrice = calculatePrice(
|
||||
@@ -453,13 +439,13 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
direction: body.direction,
|
||||
entryPrice,
|
||||
entryTime: Date.now(),
|
||||
positionSize: actualPositionSizeUSD,
|
||||
positionSize: positionSizeUSD,
|
||||
leverage: config.leverage,
|
||||
stopLossPrice,
|
||||
tp1Price,
|
||||
tp2Price,
|
||||
emergencyStopPrice,
|
||||
currentSize: actualPositionSizeUSD,
|
||||
currentSize: positionSizeUSD,
|
||||
tp1Hit: false,
|
||||
tp2Hit: false,
|
||||
slMovedToBreakeven: false,
|
||||
@@ -478,8 +464,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
originalAdx: body.adx, // Store for scaling validation
|
||||
timesScaled: 0,
|
||||
totalScaleAdded: 0,
|
||||
atrAtEntry: body.atr,
|
||||
runnerTrailingPercent: undefined,
|
||||
priceCheckCount: 0,
|
||||
lastPrice: entryPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
@@ -492,7 +476,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
try {
|
||||
const exitRes = await placeExitOrders({
|
||||
symbol: driftSymbol,
|
||||
positionSizeUSD: actualPositionSizeUSD,
|
||||
positionSizeUSD: positionSizeUSD,
|
||||
entryPrice: entryPrice,
|
||||
tp1Price,
|
||||
tp2Price,
|
||||
@@ -529,16 +513,14 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
symbol: driftSymbol,
|
||||
direction: body.direction,
|
||||
entryPrice: entryPrice,
|
||||
positionSize: actualPositionSizeUSD,
|
||||
requestedPositionSize: requestedPositionSizeUSD,
|
||||
fillCoveragePercent: Number(fillCoverage.toFixed(2)),
|
||||
positionSize: positionSizeUSD,
|
||||
leverage: config.leverage,
|
||||
stopLoss: stopLossPrice,
|
||||
takeProfit1: tp1Price,
|
||||
takeProfit2: tp2Price,
|
||||
stopLossPercent: config.stopLossPercent,
|
||||
tp1Percent: config.takeProfit1Percent,
|
||||
tp2Percent: dynamicTp2Percent,
|
||||
tp2Percent: config.takeProfit2Percent,
|
||||
entrySlippage: openResult.slippage,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
@@ -558,7 +540,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
volumeRatio: body.volumeRatio || 0,
|
||||
pricePosition: body.pricePosition || 0,
|
||||
direction: body.direction,
|
||||
timeframe: body.timeframe,
|
||||
})
|
||||
|
||||
await createTrade({
|
||||
@@ -566,7 +547,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
symbol: driftSymbol,
|
||||
direction: body.direction,
|
||||
entryPrice,
|
||||
positionSizeUSD: actualPositionSizeUSD,
|
||||
positionSizeUSD: positionSizeUSD,
|
||||
leverage: config.leverage,
|
||||
stopLossPrice,
|
||||
takeProfit1Price: tp1Price,
|
||||
@@ -591,9 +572,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
volumeAtEntry: body.volumeRatio,
|
||||
pricePositionAtEntry: body.pricePosition,
|
||||
signalQualityScore: qualityResult.score,
|
||||
signalQualityVersion: 'v3', // Stricter logic with ADX > 18 requirement for extreme positions
|
||||
expectedSizeUSD: requestedPositionSizeUSD,
|
||||
actualSizeUSD: actualPositionSizeUSD,
|
||||
})
|
||||
|
||||
console.log(`💾 Trade saved with quality score: ${qualityResult.score}/100`)
|
||||
|
||||
145
app/api/trading/market-data/route.ts
Normal file
145
app/api/trading/market-data/route.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMarketDataCache } from '@/lib/trading/market-data-cache'
|
||||
|
||||
/**
|
||||
* Market Data Webhook Endpoint
|
||||
*
|
||||
* Receives real-time metrics from TradingView alerts.
|
||||
* Called every 1-5 minutes per symbol to keep cache fresh.
|
||||
*
|
||||
* TradingView Alert 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}},
|
||||
* "timestamp": {{timenow}}
|
||||
* }
|
||||
*
|
||||
* Webhook URL: https://your-domain.com/api/trading/market-data
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize TradingView symbol format to Drift format
|
||||
*/
|
||||
function normalizeTradingViewSymbol(tvSymbol: string): string {
|
||||
if (tvSymbol.includes('-PERP')) return tvSymbol
|
||||
|
||||
const symbolMap: Record<string, string> = {
|
||||
'SOLUSDT': 'SOL-PERP',
|
||||
'SOLUSD': 'SOL-PERP',
|
||||
'SOL': 'SOL-PERP',
|
||||
'ETHUSDT': 'ETH-PERP',
|
||||
'ETHUSD': 'ETH-PERP',
|
||||
'ETH': 'ETH-PERP',
|
||||
'BTCUSDT': 'BTC-PERP',
|
||||
'BTCUSD': 'BTC-PERP',
|
||||
'BTC': 'BTC-PERP'
|
||||
}
|
||||
|
||||
return symbolMap[tvSymbol.toUpperCase()] || `${tvSymbol.toUpperCase()}-PERP`
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
console.log('📡 Received market data webhook:', {
|
||||
action: body.action,
|
||||
symbol: body.symbol,
|
||||
atr: body.atr,
|
||||
adx: body.adx
|
||||
})
|
||||
|
||||
// Validate it's a market data update
|
||||
if (body.action !== 'market_data') {
|
||||
console.log(`❌ Invalid action: ${body.action} (expected "market_data")`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action - expected "market_data"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!body.symbol) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing symbol' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const driftSymbol = normalizeTradingViewSymbol(body.symbol)
|
||||
|
||||
// Store in cache
|
||||
const marketCache = getMarketDataCache()
|
||||
marketCache.set(driftSymbol, {
|
||||
symbol: driftSymbol,
|
||||
atr: Number(body.atr) || 0,
|
||||
adx: Number(body.adx) || 0,
|
||||
rsi: Number(body.rsi) || 50,
|
||||
volumeRatio: Number(body.volumeRatio) || 1.0,
|
||||
pricePosition: Number(body.pricePosition) || 50,
|
||||
currentPrice: Number(body.currentPrice) || 0,
|
||||
timestamp: Date.now(),
|
||||
timeframe: body.timeframe || '5'
|
||||
})
|
||||
|
||||
console.log(`✅ Market data cached for ${driftSymbol}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
symbol: driftSymbol,
|
||||
message: 'Market data cached successfully',
|
||||
expiresInSeconds: 300
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Market data webhook error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET endpoint to view currently cached data (for debugging)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const marketCache = getMarketDataCache()
|
||||
const availableSymbols = marketCache.getAvailableSymbols()
|
||||
|
||||
const cacheData: Record<string, any> = {}
|
||||
|
||||
for (const symbol of availableSymbols) {
|
||||
const data = marketCache.get(symbol)
|
||||
if (data) {
|
||||
const ageSeconds = marketCache.getDataAge(symbol)
|
||||
cacheData[symbol] = {
|
||||
...data,
|
||||
ageSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
availableSymbols,
|
||||
count: availableSymbols.length,
|
||||
cache: cacheData
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Market data GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initializeDriftService } from '@/lib/drift/client'
|
||||
import { openPosition, placeExitOrders } from '@/lib/drift/orders'
|
||||
import { normalizeTradingViewSymbol, calculateDynamicTp2 } from '@/config/trading'
|
||||
import { normalizeTradingViewSymbol } from '@/config/trading'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||
import { createTrade } from '@/lib/database/trades'
|
||||
@@ -96,13 +96,13 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
}
|
||||
|
||||
// Calculate position size with leverage
|
||||
const requestedPositionSizeUSD = positionSize * leverage
|
||||
const requestedPositionSizeUSD = positionSize * leverage
|
||||
|
||||
console.log(`💰 Opening ${direction} position:`)
|
||||
console.log(` Symbol: ${driftSymbol}`)
|
||||
console.log(` Base size: $${positionSize}`)
|
||||
console.log(` Leverage: ${leverage}x`)
|
||||
console.log(` Requested notional: $${requestedPositionSizeUSD}`)
|
||||
console.log(` Base size: $${positionSize}`)
|
||||
console.log(` Leverage: ${leverage}x`)
|
||||
console.log(` Requested notional: $${requestedPositionSizeUSD}`)
|
||||
|
||||
// Open position
|
||||
const openResult = await openPosition({
|
||||
@@ -125,10 +125,8 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
|
||||
// Calculate stop loss and take profit prices
|
||||
const entryPrice = openResult.fillPrice!
|
||||
const actualPositionSizeUSD = openResult.fillNotionalUSD ?? requestedPositionSizeUSD
|
||||
const filledBaseSize = openResult.fillSize !== undefined
|
||||
? Math.abs(openResult.fillSize)
|
||||
: (entryPrice > 0 ? actualPositionSizeUSD / entryPrice : 0)
|
||||
const filledBaseSize = openResult.fillSize ?? (requestedPositionSizeUSD > 0 ? requestedPositionSizeUSD / entryPrice : 0)
|
||||
const actualPositionSizeUSD = openResult.actualSizeUSD ?? (filledBaseSize * entryPrice)
|
||||
const fillCoverage = requestedPositionSizeUSD > 0
|
||||
? (actualPositionSizeUSD / requestedPositionSizeUSD) * 100
|
||||
: 100
|
||||
@@ -172,18 +170,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
direction
|
||||
)
|
||||
|
||||
// Use ATR-based dynamic TP2 with simulated ATR for testing
|
||||
const simulatedATR = entryPrice * 0.008 // Simulate 0.8% ATR for testing
|
||||
|
||||
const dynamicTp2Percent = calculateDynamicTp2(
|
||||
entryPrice,
|
||||
simulatedATR,
|
||||
config
|
||||
)
|
||||
|
||||
const tp2Price = calculatePrice(
|
||||
entryPrice,
|
||||
dynamicTp2Percent,
|
||||
config.takeProfit2Percent,
|
||||
direction
|
||||
)
|
||||
|
||||
@@ -191,7 +180,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
console.log(` Entry: $${entryPrice.toFixed(4)}`)
|
||||
console.log(` SL: $${stopLossPrice.toFixed(4)} (${config.stopLossPercent}%)`)
|
||||
console.log(` TP1: $${tp1Price.toFixed(4)} (${config.takeProfit1Percent}%)`)
|
||||
console.log(` TP2: $${tp2Price.toFixed(4)} (${dynamicTp2Percent.toFixed(2)}% - ATR-based test)`)
|
||||
console.log(` TP2: $${tp2Price.toFixed(4)} (${config.takeProfit2Percent}%)`)
|
||||
|
||||
// Calculate emergency stop
|
||||
const emergencyStopPrice = calculatePrice(
|
||||
@@ -229,8 +218,6 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
maxAdverseExcursion: 0,
|
||||
maxFavorablePrice: entryPrice,
|
||||
maxAdversePrice: entryPrice,
|
||||
atrAtEntry: undefined,
|
||||
runnerTrailingPercent: undefined,
|
||||
priceCheckCount: 0,
|
||||
lastPrice: entryPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
@@ -303,7 +290,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
symbol: driftSymbol,
|
||||
direction: direction,
|
||||
entryPrice,
|
||||
positionSizeUSD: actualPositionSizeUSD,
|
||||
positionSizeUSD: actualPositionSizeUSD,
|
||||
leverage: leverage,
|
||||
stopLossPrice,
|
||||
takeProfit1Price: tp1Price,
|
||||
|
||||
243
docs/guides/REENTRY_ANALYTICS_QUICKSTART.md
Normal file
243
docs/guides/REENTRY_ANALYTICS_QUICKSTART.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Re-Entry Analytics System - Quick Setup Guide
|
||||
|
||||
## 🎯 What You Just Got
|
||||
|
||||
A smart validation system for manual Telegram trades that uses fresh TradingView data to prevent bad entries.
|
||||
|
||||
## 📊 How It Works
|
||||
|
||||
### 1. Data Collection (Automatic)
|
||||
- Every trade signal from TradingView auto-caches metrics
|
||||
- Cache expires after 5 minutes
|
||||
- Includes: ATR, ADX, RSI, volume ratio, price position
|
||||
|
||||
### 2. Manual Trade Flow
|
||||
```
|
||||
You: "long sol"
|
||||
↓
|
||||
Bot checks /api/analytics/reentry-check
|
||||
↓
|
||||
✅ Fresh TradingView data (<5min old)?
|
||||
→ Use real metrics, score quality
|
||||
↓
|
||||
⚠️ Stale/no data?
|
||||
→ Use historical metrics, apply penalty
|
||||
↓
|
||||
Score >= 55? → Execute trade
|
||||
Score < 55? → Block (suggest --force)
|
||||
↓
|
||||
You: "long sol --force" → Override and execute
|
||||
```
|
||||
|
||||
### 3. Performance Modifiers
|
||||
- **-20 points**: Last 3 trades lost money (avgPnL < -5%)
|
||||
- **+10 points**: Last 3 trades won (avgPnL > +5%, WR >= 66%)
|
||||
- **-5 points**: Using stale data
|
||||
- **-10 points**: No data available
|
||||
|
||||
## 🚀 Setup Steps
|
||||
|
||||
### Step 1: Deploy Updated Code
|
||||
```bash
|
||||
cd /home/icke/traderv4
|
||||
|
||||
# Build and restart
|
||||
docker compose build trading-bot
|
||||
docker compose up -d trading-bot
|
||||
|
||||
# Restart Telegram bot
|
||||
docker compose restart telegram-bot
|
||||
```
|
||||
|
||||
### Step 2: Create TradingView Market Data Alerts
|
||||
|
||||
For **each symbol** (SOL, ETH, BTC), create a separate alert:
|
||||
|
||||
**Alert Name:** "Market Data - SOL 5min"
|
||||
|
||||
**Condition:**
|
||||
```
|
||||
ta.change(time("1"))
|
||||
```
|
||||
(Fires every bar close on 1-5min chart)
|
||||
|
||||
**Alert Message (JSON):**
|
||||
```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
|
||||
```
|
||||
|
||||
**Frequency:** Every 1-5 minutes (recommend 5min to save alert quota)
|
||||
|
||||
**Repeat for:** SOL-PERP, ETH-PERP, BTC-PERP
|
||||
|
||||
### Step 3: Test the System
|
||||
|
||||
```bash
|
||||
# Check if market data endpoint is accessible
|
||||
curl http://localhost:3001/api/trading/market-data
|
||||
|
||||
# Should return available symbols and cache data
|
||||
```
|
||||
|
||||
### Step 4: Test via Telegram
|
||||
|
||||
```
|
||||
You: "long sol"
|
||||
|
||||
✅ Analytics check passed (68/100)
|
||||
Data: tradingview_real (23s old)
|
||||
Proceeding with LONG SOL...
|
||||
|
||||
✅ OPENED LONG SOL
|
||||
Entry: $162.45
|
||||
Size: $2100.00 @ 10x
|
||||
TP1: $162.97 TP2: $163.59 SL: $160.00
|
||||
```
|
||||
|
||||
**Or if analytics blocks:**
|
||||
|
||||
```
|
||||
You: "long sol"
|
||||
|
||||
🛑 Analytics suggest NOT entering LONG SOL
|
||||
|
||||
Reason: Recent long trades losing (-2.4% avg)
|
||||
Score: 45/100
|
||||
Data: ✅ tradingview_real (23s old)
|
||||
|
||||
Use `long sol --force` to override
|
||||
```
|
||||
|
||||
**Override with --force:**
|
||||
|
||||
```
|
||||
You: "long sol --force"
|
||||
|
||||
⚠️ Skipping analytics check...
|
||||
|
||||
✅ OPENED LONG SOL (FORCED)
|
||||
Entry: $162.45
|
||||
...
|
||||
```
|
||||
|
||||
## 📊 View Cached Data
|
||||
|
||||
```bash
|
||||
# Check what's in cache
|
||||
curl http://localhost:3001/api/trading/market-data
|
||||
|
||||
# Response shows:
|
||||
{
|
||||
"success": true,
|
||||
"availableSymbols": ["SOL-PERP", "ETH-PERP"],
|
||||
"count": 2,
|
||||
"cache": {
|
||||
"SOL-PERP": {
|
||||
"atr": 0.45,
|
||||
"adx": 32.1,
|
||||
"rsi": 58.3,
|
||||
"ageSeconds": 23
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Adjust Thresholds (if needed)
|
||||
|
||||
Edit `app/api/analytics/reentry-check/route.ts`:
|
||||
|
||||
```typescript
|
||||
const MIN_REENTRY_SCORE = 55 // Lower = more permissive
|
||||
|
||||
// Performance modifiers
|
||||
if (last3Count >= 2 && avgPnL < -5) {
|
||||
finalScore -= 20 // Penalty for losing streak
|
||||
}
|
||||
|
||||
if (last3Count >= 2 && avgPnL > 5 && winRate >= 66) {
|
||||
finalScore += 10 // Bonus for winning streak
|
||||
}
|
||||
```
|
||||
|
||||
### Cache Expiry
|
||||
|
||||
Edit `lib/trading/market-data-cache.ts`:
|
||||
|
||||
```typescript
|
||||
private readonly MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes
|
||||
```
|
||||
|
||||
## 🎯 Benefits
|
||||
|
||||
✅ **Prevents revenge trading** - Blocks entry after consecutive losses
|
||||
✅ **Uses real data** - Fresh TradingView metrics, not guessed
|
||||
✅ **Data-driven** - Considers recent performance, not just current signal
|
||||
✅ **Override capability** - `--force` flag for manual judgment
|
||||
✅ **Fail-open** - If analytics fails, trade proceeds (not overly restrictive)
|
||||
✅ **Transparent** - Shows data age and source in responses
|
||||
|
||||
## 📈 Next Steps
|
||||
|
||||
1. **Monitor effectiveness:**
|
||||
- Track how many trades are blocked
|
||||
- Compare win rate of allowed vs forced trades
|
||||
- Adjust thresholds based on data
|
||||
|
||||
2. **Add more symbols:**
|
||||
- Create market data alerts for any new symbols
|
||||
- System auto-adapts to new cache entries
|
||||
|
||||
3. **Phase 2 (Future):**
|
||||
- Time-based cooldown (no re-entry within 10min of exit)
|
||||
- Trend reversal detection (check if price crossed MA)
|
||||
- Volatility spike filter (ATR expansion = risky)
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
**No fresh data available:**
|
||||
- Check TradingView alerts are firing
|
||||
- Verify webhook URL is correct
|
||||
- Check Docker logs: `docker logs -f trading-bot-v4`
|
||||
|
||||
**Analytics check fails:**
|
||||
- Trade proceeds anyway (fail-open design)
|
||||
- Check logs for error details
|
||||
- Verify Prisma database connection
|
||||
|
||||
**--force always needed:**
|
||||
- Lower MIN_REENTRY_SCORE threshold
|
||||
- Check if TradingView alerts are updating cache
|
||||
- Review penalty logic (may be too aggressive)
|
||||
|
||||
## 📝 Files Created/Modified
|
||||
|
||||
**New Files:**
|
||||
- `lib/trading/market-data-cache.ts` - Cache service
|
||||
- `app/api/trading/market-data/route.ts` - Webhook endpoint
|
||||
- `app/api/analytics/reentry-check/route.ts` - Validation logic
|
||||
|
||||
**Modified Files:**
|
||||
- `app/api/trading/execute/route.ts` - Auto-cache metrics
|
||||
- `telegram_command_bot.py` - Pre-execution analytics check
|
||||
- `.github/copilot-instructions.md` - Documentation
|
||||
|
||||
---
|
||||
|
||||
**Ready to use!** Send `long sol` in Telegram to test the system.
|
||||
@@ -52,7 +52,6 @@ export interface CreateTradeParams {
|
||||
volumeAtEntry?: number
|
||||
pricePositionAtEntry?: number
|
||||
signalQualityScore?: number
|
||||
signalQualityVersion?: string // Track which scoring logic version was used
|
||||
// Phantom trade fields
|
||||
status?: string
|
||||
isPhantom?: boolean
|
||||
@@ -76,7 +75,6 @@ export interface UpdateTradeStateParams {
|
||||
maxAdverseExcursion?: number
|
||||
maxFavorablePrice?: number
|
||||
maxAdversePrice?: number
|
||||
runnerTrailingPercent?: number
|
||||
}
|
||||
|
||||
export interface UpdateTradeExitParams {
|
||||
@@ -237,7 +235,6 @@ export async function updateTradeState(params: UpdateTradeStateParams) {
|
||||
maxAdverseExcursion: params.maxAdverseExcursion,
|
||||
maxFavorablePrice: params.maxFavorablePrice,
|
||||
maxAdversePrice: params.maxAdversePrice,
|
||||
runnerTrailingPercent: params.runnerTrailingPercent,
|
||||
lastUpdate: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface OpenPositionResult {
|
||||
transactionSignature?: string
|
||||
fillPrice?: number
|
||||
fillSize?: number
|
||||
fillNotionalUSD?: number
|
||||
slippage?: number
|
||||
error?: string
|
||||
isPhantom?: boolean // Position opened but size mismatch detected
|
||||
@@ -46,8 +45,6 @@ export interface ClosePositionResult {
|
||||
closePrice?: number
|
||||
closedSize?: number
|
||||
realizedPnL?: number
|
||||
fullyClosed?: boolean
|
||||
remainingSize?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -127,7 +124,6 @@ export async function openPosition(
|
||||
transactionSignature: mockTxSig,
|
||||
fillPrice: oraclePrice,
|
||||
fillSize: baseAssetSize,
|
||||
fillNotionalUSD: baseAssetSize * oraclePrice,
|
||||
slippage: 0,
|
||||
}
|
||||
}
|
||||
@@ -183,22 +179,19 @@ export async function openPosition(
|
||||
|
||||
if (position && position.side !== 'none') {
|
||||
const fillPrice = position.entryPrice
|
||||
const filledBaseSize = Math.abs(position.size)
|
||||
const fillNotionalUSD = filledBaseSize * fillPrice
|
||||
const slippage = Math.abs((fillPrice - oraclePrice) / oraclePrice) * 100
|
||||
|
||||
// CRITICAL: Validate actual position size vs expected
|
||||
// Phantom trade detection: Check if position is significantly smaller than expected
|
||||
const actualSizeUSD = position.size * fillPrice
|
||||
const expectedSizeUSD = params.sizeUSD
|
||||
const sizeRatio = expectedSizeUSD > 0 ? fillNotionalUSD / expectedSizeUSD : 1
|
||||
const sizeRatio = actualSizeUSD / expectedSizeUSD
|
||||
|
||||
console.log(`💰 Fill details:`)
|
||||
console.log(` Fill price: $${fillPrice.toFixed(4)}`)
|
||||
console.log(` Filled base size: ${filledBaseSize.toFixed(4)} ${params.symbol.split('-')[0]}`)
|
||||
console.log(` Filled notional: $${fillNotionalUSD.toFixed(2)}`)
|
||||
console.log(` Slippage: ${slippage.toFixed(3)}%`)
|
||||
console.log(` Expected size: $${expectedSizeUSD.toFixed(2)}`)
|
||||
console.log(` Actual size: $${fillNotionalUSD.toFixed(2)}`)
|
||||
console.log(` Actual size: $${actualSizeUSD.toFixed(2)}`)
|
||||
console.log(` Size ratio: ${(sizeRatio * 100).toFixed(1)}%`)
|
||||
|
||||
// Flag as phantom if actual size is less than 50% of expected
|
||||
@@ -207,7 +200,7 @@ export async function openPosition(
|
||||
if (isPhantom) {
|
||||
console.error(`🚨 PHANTOM POSITION DETECTED!`)
|
||||
console.error(` Expected: $${expectedSizeUSD.toFixed(2)}`)
|
||||
console.error(` Actual: $${fillNotionalUSD.toFixed(2)}`)
|
||||
console.error(` Actual: $${actualSizeUSD.toFixed(2)}`)
|
||||
console.error(` This indicates the order was rejected or partially filled by Drift`)
|
||||
}
|
||||
|
||||
@@ -215,11 +208,10 @@ export async function openPosition(
|
||||
success: true,
|
||||
transactionSignature: txSig,
|
||||
fillPrice,
|
||||
fillSize: filledBaseSize,
|
||||
fillNotionalUSD,
|
||||
fillSize: position.size, // Use actual size from Drift, not calculated
|
||||
slippage,
|
||||
isPhantom,
|
||||
actualSizeUSD: fillNotionalUSD,
|
||||
actualSizeUSD,
|
||||
}
|
||||
} else {
|
||||
// Position not found yet (may be DRY_RUN mode)
|
||||
@@ -231,7 +223,6 @@ export async function openPosition(
|
||||
transactionSignature: txSig,
|
||||
fillPrice: oraclePrice,
|
||||
fillSize: baseAssetSize,
|
||||
fillNotionalUSD: baseAssetSize * oraclePrice,
|
||||
slippage: 0,
|
||||
}
|
||||
}
|
||||
@@ -500,24 +491,19 @@ export async function closePosition(
|
||||
}
|
||||
|
||||
// Calculate size to close
|
||||
const sizeToClose = position.size * (params.percentToClose / 100)
|
||||
const remainingSize = position.size - sizeToClose
|
||||
let sizeToClose = position.size * (params.percentToClose / 100)
|
||||
|
||||
// CRITICAL: Check if remaining position would be below Drift minimum
|
||||
// If so, Drift will force-close the entire position anyway
|
||||
// Better to detect this upfront and return fullyClosed=true
|
||||
const willForceFullClose = remainingSize > 0 && remainingSize < marketConfig.minOrderSize
|
||||
|
||||
if (willForceFullClose && params.percentToClose < 100) {
|
||||
console.log(`⚠️ WARNING: Remaining size ${remainingSize.toFixed(4)} would be below Drift minimum ${marketConfig.minOrderSize}`)
|
||||
console.log(` Drift will force-close entire position. Proceeding with 100% close.`)
|
||||
console.log(` 💡 TIP: Increase position size or decrease TP2 close % to enable runner`)
|
||||
// CRITICAL FIX: If calculated size is below minimum, close 100% instead
|
||||
// This prevents "runner" positions from being too small to close
|
||||
if (sizeToClose < marketConfig.minOrderSize) {
|
||||
console.log(`⚠️ Calculated close size ${sizeToClose.toFixed(4)} is below minimum ${marketConfig.minOrderSize}`)
|
||||
console.log(` Forcing 100% close to avoid Drift rejection`)
|
||||
sizeToClose = position.size // Close entire position
|
||||
}
|
||||
|
||||
console.log(`📝 Close order details:`)
|
||||
console.log(` Current position: ${position.size.toFixed(4)} ${position.side}`)
|
||||
console.log(` Closing: ${params.percentToClose}% (${sizeToClose.toFixed(4)})`)
|
||||
console.log(` Remaining after close: ${remainingSize.toFixed(4)}`)
|
||||
console.log(` Entry price: $${position.entryPrice.toFixed(4)}`)
|
||||
console.log(` Unrealized P&L: $${position.unrealizedPnL.toFixed(2)}`)
|
||||
|
||||
@@ -532,18 +518,10 @@ export async function closePosition(
|
||||
console.log('🧪 DRY RUN MODE: Simulating close order (not executing on blockchain)')
|
||||
|
||||
// Calculate realized P&L with leverage (default 10x in dry run)
|
||||
// For LONG: profit when exit > entry → (exit - entry) / entry
|
||||
// For SHORT: profit when exit < entry → (entry - exit) / entry
|
||||
const priceDiff = position.side === 'long'
|
||||
? (oraclePrice - position.entryPrice) // Long: profit when price rises
|
||||
: (position.entryPrice - oraclePrice) // Short: profit when price falls
|
||||
|
||||
const profitPercent = (priceDiff / position.entryPrice) * 100
|
||||
const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
|
||||
const closedNotional = sizeToClose * oraclePrice
|
||||
const leverage = 10
|
||||
const collateral = closedNotional / leverage
|
||||
const realizedPnL = collateral * (profitPercent / 100) * leverage
|
||||
const accountPnLPercent = profitPercent * leverage
|
||||
const realizedPnL = (closedNotional * profitPercent) / 100
|
||||
const accountPnLPercent = profitPercent * 10 // display using default leverage
|
||||
|
||||
const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
@@ -591,13 +569,8 @@ export async function closePosition(
|
||||
console.log('✅ Transaction confirmed on-chain')
|
||||
|
||||
// Calculate realized P&L with leverage
|
||||
// For LONG: profit when exit > entry → (exit - entry) / entry
|
||||
// For SHORT: profit when exit < entry → (entry - exit) / entry
|
||||
const priceDiff = position.side === 'long'
|
||||
? (oraclePrice - position.entryPrice) // Long: profit when price rises
|
||||
: (position.entryPrice - oraclePrice) // Short: profit when price falls
|
||||
|
||||
const profitPercent = (priceDiff / position.entryPrice) * 100
|
||||
// CRITICAL: P&L must account for leverage and be calculated on USD notional, not base asset size
|
||||
const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
|
||||
|
||||
// Get leverage from user account (defaults to 10x if not found)
|
||||
let leverage = 10
|
||||
@@ -611,10 +584,9 @@ export async function closePosition(
|
||||
console.log('⚠️ Could not determine leverage from account, using 10x default')
|
||||
}
|
||||
|
||||
// Calculate closed notional value (USD) and actual P&L with leverage
|
||||
// Calculate closed notional value (USD)
|
||||
const closedNotional = sizeToClose * oraclePrice
|
||||
const collateral = closedNotional / leverage
|
||||
const realizedPnL = collateral * (profitPercent / 100) * leverage // Leveraged P&L
|
||||
const realizedPnL = (closedNotional * profitPercent) / 100
|
||||
const accountPnLPercent = profitPercent * leverage
|
||||
|
||||
console.log(`💰 Close details:`)
|
||||
@@ -623,21 +595,13 @@ export async function closePosition(
|
||||
console.log(` Closed notional: $${closedNotional.toFixed(2)}`)
|
||||
console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`)
|
||||
|
||||
// Check remaining position size after close
|
||||
const updatedPosition = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||||
const actualRemainingSize = updatedPosition ? Math.abs(updatedPosition.size) : 0
|
||||
const fullyClosed = !updatedPosition || actualRemainingSize === 0 || willForceFullClose
|
||||
|
||||
if (fullyClosed) {
|
||||
// If closing 100%, cancel all remaining orders for this market
|
||||
if (params.percentToClose === 100) {
|
||||
console.log('🗑️ Position fully closed, cancelling remaining orders...')
|
||||
const cancelResult = await cancelAllOrders(params.symbol)
|
||||
if (cancelResult.success && (cancelResult.cancelledCount || 0) > 0) {
|
||||
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
|
||||
console.log(`✅ Cancelled ${cancelResult.cancelledCount} orders`)
|
||||
}
|
||||
} else if (params.percentToClose === 100) {
|
||||
console.log(
|
||||
`⚠️ Requested 100% close but ${actualRemainingSize.toFixed(4)} base remains on-chain`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -646,8 +610,6 @@ export async function closePosition(
|
||||
closePrice: oraclePrice,
|
||||
closedSize: sizeToClose,
|
||||
realizedPnL,
|
||||
fullyClosed,
|
||||
remainingSize: actualRemainingSize,
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
117
lib/trading/market-data-cache.ts
Normal file
117
lib/trading/market-data-cache.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Market Data Cache Service
|
||||
*
|
||||
* Purpose: Stores real-time TradingView metrics for manual trade validation.
|
||||
* Data flows: TradingView → /api/trading/market-data → Cache → Re-entry checks
|
||||
*
|
||||
* Cache expiry: 5 minutes (configurable)
|
||||
*/
|
||||
|
||||
export interface MarketMetrics {
|
||||
symbol: string // "SOL-PERP", "ETH-PERP", "BTC-PERP"
|
||||
atr: number // Average True Range (volatility %)
|
||||
adx: number // Average Directional Index (trend strength)
|
||||
rsi: number // Relative Strength Index (momentum)
|
||||
volumeRatio: number // Current volume / average volume
|
||||
pricePosition: number // Position in recent range (0-100%)
|
||||
currentPrice: number // Latest close price
|
||||
timestamp: number // Unix timestamp (ms)
|
||||
timeframe: string // "5" for 5min, "60" for 1h, etc.
|
||||
}
|
||||
|
||||
class MarketDataCache {
|
||||
private cache: Map<string, MarketMetrics> = new Map()
|
||||
private readonly MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
/**
|
||||
* Store fresh market data from TradingView
|
||||
*/
|
||||
set(symbol: string, metrics: MarketMetrics): void {
|
||||
this.cache.set(symbol, metrics)
|
||||
console.log(
|
||||
`📊 Cached market data for ${symbol}: ` +
|
||||
`ADX=${metrics.adx.toFixed(1)} ` +
|
||||
`ATR=${metrics.atr.toFixed(2)}% ` +
|
||||
`RSI=${metrics.rsi.toFixed(1)} ` +
|
||||
`Vol=${metrics.volumeRatio.toFixed(2)}x`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached data if still fresh (<5min old)
|
||||
* Returns null if stale or missing
|
||||
*/
|
||||
get(symbol: string): MarketMetrics | null {
|
||||
const data = this.cache.get(symbol)
|
||||
|
||||
if (!data) {
|
||||
console.log(`⚠️ No cached data for ${symbol}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const ageSeconds = Math.round((Date.now() - data.timestamp) / 1000)
|
||||
|
||||
if (Date.now() - data.timestamp > this.MAX_AGE_MS) {
|
||||
console.log(`⏰ Cached data for ${symbol} is stale (${ageSeconds}s old, max 300s)`)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log(`✅ Using fresh TradingView data for ${symbol} (${ageSeconds}s old)`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fresh data exists without retrieving it
|
||||
*/
|
||||
has(symbol: string): boolean {
|
||||
const data = this.cache.get(symbol)
|
||||
if (!data) return false
|
||||
|
||||
return Date.now() - data.timestamp <= this.MAX_AGE_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached symbols with fresh data
|
||||
*/
|
||||
getAvailableSymbols(): string[] {
|
||||
const now = Date.now()
|
||||
const freshSymbols: string[] = []
|
||||
|
||||
for (const [symbol, data] of this.cache.entries()) {
|
||||
if (now - data.timestamp <= this.MAX_AGE_MS) {
|
||||
freshSymbols.push(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
return freshSymbols
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age of cached data in seconds (for debugging)
|
||||
*/
|
||||
getDataAge(symbol: string): number | null {
|
||||
const data = this.cache.get(symbol)
|
||||
if (!data) return null
|
||||
|
||||
return Math.round((Date.now() - data.timestamp) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
console.log('🗑️ Market data cache cleared')
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let marketDataCache: MarketDataCache | null = null
|
||||
|
||||
export function getMarketDataCache(): MarketDataCache {
|
||||
if (!marketDataCache) {
|
||||
marketDataCache = new MarketDataCache()
|
||||
console.log('🔧 Initialized Market Data Cache (5min expiry)')
|
||||
}
|
||||
return marketDataCache
|
||||
}
|
||||
@@ -35,7 +35,6 @@ export interface ActiveTrade {
|
||||
slMovedToBreakeven: boolean
|
||||
slMovedToProfit: boolean
|
||||
trailingStopActive: boolean
|
||||
runnerTrailingPercent?: number // Latest dynamic trailing percent applied
|
||||
|
||||
// P&L tracking
|
||||
realizedPnL: number
|
||||
@@ -53,7 +52,6 @@ export interface ActiveTrade {
|
||||
originalAdx?: number // ADX at initial entry (for scaling validation)
|
||||
timesScaled?: number // How many times position has been scaled
|
||||
totalScaleAdded?: number // Total USD added through scaling
|
||||
atrAtEntry?: number // ATR (absolute) when trade was opened
|
||||
|
||||
// Monitoring
|
||||
priceCheckCount: number
|
||||
@@ -119,7 +117,6 @@ export class PositionManager {
|
||||
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
|
||||
slMovedToProfit: pmState?.slMovedToProfit ?? false,
|
||||
trailingStopActive: pmState?.trailingStopActive ?? false,
|
||||
runnerTrailingPercent: pmState?.runnerTrailingPercent,
|
||||
realizedPnL: pmState?.realizedPnL ?? 0,
|
||||
unrealizedPnL: pmState?.unrealizedPnL ?? 0,
|
||||
peakPnL: pmState?.peakPnL ?? 0,
|
||||
@@ -128,7 +125,6 @@ export class PositionManager {
|
||||
maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0,
|
||||
maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice,
|
||||
maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice,
|
||||
atrAtEntry: dbTrade.atrAtEntry ?? undefined,
|
||||
priceCheckCount: 0,
|
||||
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
@@ -136,12 +132,6 @@ export class PositionManager {
|
||||
|
||||
this.activeTrades.set(activeTrade.id, activeTrade)
|
||||
console.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`)
|
||||
|
||||
// Consistency check: if TP1 hit but SL not moved to breakeven, fix it now
|
||||
if (activeTrade.tp1Hit && !activeTrade.slMovedToBreakeven) {
|
||||
console.log(`🔧 Detected inconsistent state: TP1 hit but SL not at breakeven. Fixing now...`)
|
||||
await this.handlePostTp1Adjustments(activeTrade, 'recovery after restore')
|
||||
}
|
||||
}
|
||||
|
||||
if (this.activeTrades.size > 0) {
|
||||
@@ -213,22 +203,6 @@ export class PositionManager {
|
||||
return Array.from(this.activeTrades.values())
|
||||
}
|
||||
|
||||
async reconcileTrade(symbol: string): Promise<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
|
||||
*/
|
||||
@@ -359,7 +333,12 @@ export class PositionManager {
|
||||
trade.tp1Hit = true
|
||||
trade.currentSize = positionSizeUSD
|
||||
|
||||
await this.handlePostTp1Adjustments(trade, 'on-chain TP1 detection')
|
||||
// Move SL to breakeven after TP1
|
||||
trade.stopLossPrice = trade.entryPrice
|
||||
trade.slMovedToBreakeven = true
|
||||
console.log(`🛡️ Stop loss moved to breakeven: $${trade.stopLossPrice.toFixed(4)}`)
|
||||
|
||||
await this.saveTradeState(trade)
|
||||
|
||||
} else if (trade.tp1Hit && !trade.tp2Hit && reductionPercent >= 85) {
|
||||
// TP2 fired (total should be ~95% closed, 5% runner left)
|
||||
@@ -367,22 +346,19 @@ export class PositionManager {
|
||||
trade.tp2Hit = true
|
||||
trade.currentSize = positionSizeUSD
|
||||
trade.trailingStopActive = true
|
||||
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade)
|
||||
console.log(
|
||||
`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%`
|
||||
)
|
||||
console.log(`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with ${this.config.trailingStopPercent}% trailing stop`)
|
||||
|
||||
await this.saveTradeState(trade)
|
||||
|
||||
// CRITICAL: Don't return early! Continue monitoring the runner position
|
||||
// The trailing stop logic at line 732 needs to run
|
||||
|
||||
} else {
|
||||
// Partial fill detected but unclear which TP - just update size
|
||||
console.log(`⚠️ Unknown partial fill detected - updating tracked size to $${positionSizeUSD.toFixed(2)}`)
|
||||
trade.currentSize = positionSizeUSD
|
||||
await this.saveTradeState(trade)
|
||||
}
|
||||
|
||||
// Continue monitoring the remaining position
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Check for entry price mismatch (NEW position opened)
|
||||
@@ -404,10 +380,10 @@ export class PositionManager {
|
||||
trade.lastPrice,
|
||||
trade.direction
|
||||
)
|
||||
const accountPnL = profitPercent * trade.leverage
|
||||
const estimatedPnL = (trade.currentSize * accountPnL) / 100
|
||||
const accountPnLPercent = profitPercent * trade.leverage
|
||||
const estimatedPnL = (trade.currentSize * profitPercent) / 100
|
||||
|
||||
console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnL.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`)
|
||||
console.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnLPercent.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`)
|
||||
|
||||
try {
|
||||
await updateTradeExit({
|
||||
@@ -466,41 +442,22 @@ export class PositionManager {
|
||||
// CRITICAL: Use trade state flags, not current price (on-chain orders filled in the past!)
|
||||
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL'
|
||||
|
||||
// Calculate P&L first (set to 0 for phantom trades)
|
||||
let realizedPnL = 0
|
||||
let exitPrice = currentPrice
|
||||
|
||||
// Include any previously realized profit (e.g., from TP1 partial close)
|
||||
const previouslyRealized = trade.realizedPnL
|
||||
let runnerRealized = 0
|
||||
let runnerProfitPercent = 0
|
||||
if (!wasPhantom) {
|
||||
// For external closures, try to estimate a more realistic exit price
|
||||
// Manual closures may happen at significantly different prices than current market
|
||||
const unrealizedPnL = trade.unrealizedPnL || 0
|
||||
const positionSizeUSD = trade.positionSize
|
||||
|
||||
if (Math.abs(unrealizedPnL) > 1 && positionSizeUSD > 0) {
|
||||
// If we have meaningful unrealized P&L, back-calculate the likely exit price
|
||||
// This is more accurate than using volatile current market price
|
||||
const impliedProfitPercent = (unrealizedPnL / positionSizeUSD) * 100 / trade.leverage
|
||||
exitPrice = trade.direction === 'long'
|
||||
? trade.entryPrice * (1 + impliedProfitPercent / 100)
|
||||
: trade.entryPrice * (1 - impliedProfitPercent / 100)
|
||||
|
||||
console.log(`📊 Estimated exit price based on unrealized P&L:`)
|
||||
console.log(` Unrealized P&L: $${unrealizedPnL.toFixed(2)}`)
|
||||
console.log(` Market price: $${currentPrice.toFixed(6)}`)
|
||||
console.log(` Estimated exit: $${exitPrice.toFixed(6)}`)
|
||||
|
||||
realizedPnL = unrealizedPnL
|
||||
} else {
|
||||
// Fallback to current price calculation
|
||||
const profitPercent = this.calculateProfitPercent(
|
||||
trade.entryPrice,
|
||||
currentPrice,
|
||||
trade.direction
|
||||
)
|
||||
const accountPnL = profitPercent * trade.leverage
|
||||
realizedPnL = (sizeForPnL * accountPnL) / 100
|
||||
}
|
||||
runnerProfitPercent = this.calculateProfitPercent(
|
||||
trade.entryPrice,
|
||||
currentPrice,
|
||||
trade.direction
|
||||
)
|
||||
runnerRealized = (sizeForPnL * runnerProfitPercent) / 100
|
||||
}
|
||||
|
||||
const totalRealizedPnL = previouslyRealized + runnerRealized
|
||||
trade.realizedPnL = totalRealizedPnL
|
||||
console.log(` Realized P&L snapshot → Previous: $${previouslyRealized.toFixed(2)} | Runner: $${runnerRealized.toFixed(2)} (Δ${runnerProfitPercent.toFixed(2)}%) | Total: $${totalRealizedPnL.toFixed(2)}`)
|
||||
|
||||
// Determine exit reason from trade state and P&L
|
||||
if (trade.tp2Hit) {
|
||||
@@ -509,14 +466,14 @@ export class PositionManager {
|
||||
} else if (trade.tp1Hit) {
|
||||
// TP1 was hit, position should be 25% size, but now fully closed
|
||||
// This means either TP2 filled or runner got stopped out
|
||||
exitReason = realizedPnL > 0 ? 'TP2' : 'SL'
|
||||
exitReason = totalRealizedPnL > 0 ? 'TP2' : 'SL'
|
||||
} else {
|
||||
// No TPs hit yet - either SL or TP1 filled just now
|
||||
// Use P&L to determine: positive = TP, negative = SL
|
||||
if (realizedPnL > trade.positionSize * 0.005) {
|
||||
if (totalRealizedPnL > trade.positionSize * 0.005) {
|
||||
// More than 0.5% profit - must be TP1
|
||||
exitReason = 'TP1'
|
||||
} else if (realizedPnL < 0) {
|
||||
} else if (totalRealizedPnL < 0) {
|
||||
// Loss - must be SL
|
||||
exitReason = 'SL'
|
||||
}
|
||||
@@ -528,9 +485,9 @@ export class PositionManager {
|
||||
try {
|
||||
await updateTradeExit({
|
||||
positionId: trade.positionId,
|
||||
exitPrice: exitPrice, // Use estimated exit price, not current market price
|
||||
exitPrice: currentPrice,
|
||||
exitReason,
|
||||
realizedPnL,
|
||||
realizedPnL: totalRealizedPnL,
|
||||
exitOrderTx: 'ON_CHAIN_ORDER',
|
||||
holdTimeSeconds,
|
||||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||||
@@ -540,7 +497,7 @@ export class PositionManager {
|
||||
maxFavorablePrice: trade.maxFavorablePrice,
|
||||
maxAdversePrice: trade.maxAdversePrice,
|
||||
})
|
||||
console.log(`💾 External closure recorded: ${exitReason} at $${exitPrice.toFixed(6)} | P&L: $${realizedPnL.toFixed(2)}`)
|
||||
console.log(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${totalRealizedPnL.toFixed(2)}`)
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save external closure:', dbError)
|
||||
}
|
||||
@@ -551,31 +508,15 @@ export class PositionManager {
|
||||
}
|
||||
|
||||
// Position exists but size mismatch (partial close by TP1?)
|
||||
const onChainBaseSize = Math.abs(position.size)
|
||||
const onChainSizeUSD = onChainBaseSize * currentPrice
|
||||
const trackedSizeUSD = trade.currentSize
|
||||
|
||||
if (trackedSizeUSD > 0 && onChainSizeUSD < trackedSizeUSD * 0.95) { // 5% tolerance
|
||||
const expectedBaseSize = trackedSizeUSD / currentPrice
|
||||
console.log(`⚠️ Position size mismatch: tracking $${trackedSizeUSD.toFixed(2)} (~${expectedBaseSize.toFixed(4)} units) but on-chain shows $${onChainSizeUSD.toFixed(2)} (${onChainBaseSize.toFixed(4)} units)`)
|
||||
if (position.size < trade.currentSize * 0.95) { // 5% tolerance
|
||||
console.log(`⚠️ Position size mismatch: expected ${trade.currentSize}, got ${position.size}`)
|
||||
|
||||
// CRITICAL: If mismatch is extreme (>50%), this is a phantom trade
|
||||
const sizeRatio = trackedSizeUSD > 0 ? onChainSizeUSD / trackedSizeUSD : 0
|
||||
const sizeRatio = (position.size * currentPrice) / trade.currentSize
|
||||
if (sizeRatio < 0.5) {
|
||||
const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000
|
||||
const probablyPartialRunner = trade.tp1Hit || tradeAgeSeconds > 60
|
||||
|
||||
if (probablyPartialRunner) {
|
||||
console.log(`🛠️ Detected stray remainder (${(sizeRatio * 100).toFixed(1)}%) after on-chain exit - forcing market close`)
|
||||
trade.currentSize = onChainSizeUSD
|
||||
await this.saveTradeState(trade)
|
||||
await this.executeExit(trade, 100, 'manual', currentPrice)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`)
|
||||
console.log(` Expected: $${trackedSizeUSD.toFixed(2)}`)
|
||||
console.log(` Actual: $${onChainSizeUSD.toFixed(2)}`)
|
||||
console.log(` Expected: $${trade.currentSize.toFixed(2)}`)
|
||||
console.log(` Actual: $${(position.size * currentPrice).toFixed(2)}`)
|
||||
|
||||
// Close as phantom trade
|
||||
try {
|
||||
@@ -603,15 +544,10 @@ export class PositionManager {
|
||||
return
|
||||
}
|
||||
|
||||
// Update current size to match reality and run TP1 adjustments if needed
|
||||
trade.currentSize = onChainSizeUSD
|
||||
if (!trade.tp1Hit) {
|
||||
trade.tp1Hit = true
|
||||
await this.handlePostTp1Adjustments(trade, 'on-chain TP1 size sync')
|
||||
} else {
|
||||
await this.saveTradeState(trade)
|
||||
}
|
||||
return
|
||||
// Update current size to match reality (convert base asset size to USD using current price)
|
||||
trade.currentSize = position.size * currentPrice
|
||||
trade.tp1Hit = true
|
||||
await this.saveTradeState(trade)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -636,8 +572,8 @@ export class PositionManager {
|
||||
trade.direction
|
||||
)
|
||||
|
||||
const accountPnL = profitPercent * trade.leverage
|
||||
trade.unrealizedPnL = (trade.currentSize * accountPnL) / 100
|
||||
const accountPnL = profitPercent * trade.leverage
|
||||
trade.unrealizedPnL = (trade.currentSize * profitPercent) / 100
|
||||
|
||||
// Track peak P&L (MFE - Maximum Favorable Excursion)
|
||||
if (trade.unrealizedPnL > trade.peakPnL) {
|
||||
@@ -702,7 +638,56 @@ export class PositionManager {
|
||||
// Move SL based on breakEvenTriggerPercent setting
|
||||
trade.tp1Hit = true
|
||||
trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100)
|
||||
await this.handlePostTp1Adjustments(trade, 'software TP1 execution')
|
||||
const newStopLossPrice = this.calculatePrice(
|
||||
trade.entryPrice,
|
||||
this.config.breakEvenTriggerPercent, // Use configured breakeven level
|
||||
trade.direction
|
||||
)
|
||||
trade.stopLossPrice = newStopLossPrice
|
||||
trade.slMovedToBreakeven = true
|
||||
|
||||
console.log(`🔒 SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`)
|
||||
|
||||
// CRITICAL: Cancel old on-chain SL orders and place new ones at updated price
|
||||
try {
|
||||
console.log('🗑️ Cancelling old stop loss orders...')
|
||||
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
|
||||
const cancelResult = await cancelAllOrders(trade.symbol)
|
||||
if (cancelResult.success) {
|
||||
console.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} old orders`)
|
||||
|
||||
// Place new SL orders at breakeven/profit level for remaining position
|
||||
console.log(`🛡️ Placing new SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`)
|
||||
const exitOrdersResult = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
positionSizeUSD: trade.currentSize,
|
||||
entryPrice: trade.entryPrice,
|
||||
tp1Price: trade.tp2Price, // Only TP2 remains
|
||||
tp2Price: trade.tp2Price, // Dummy, won't be used
|
||||
stopLossPrice: newStopLossPrice,
|
||||
tp1SizePercent: 100, // Close remaining 25% at TP2
|
||||
tp2SizePercent: 0,
|
||||
direction: trade.direction,
|
||||
useDualStops: this.config.useDualStops,
|
||||
softStopPrice: trade.direction === 'long'
|
||||
? newStopLossPrice * 1.005 // 0.5% above for long
|
||||
: newStopLossPrice * 0.995, // 0.5% below for short
|
||||
hardStopPrice: newStopLossPrice,
|
||||
})
|
||||
|
||||
if (exitOrdersResult.success) {
|
||||
console.log('✅ New SL orders placed on-chain at updated price')
|
||||
} else {
|
||||
console.error('❌ Failed to place new SL orders:', exitOrdersResult.error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update on-chain SL orders:', error)
|
||||
// Don't fail the TP1 exit if SL update fails - software monitoring will handle it
|
||||
}
|
||||
|
||||
// Save state after TP1
|
||||
await this.saveTradeState(trade)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -727,39 +712,42 @@ export class PositionManager {
|
||||
await this.saveTradeState(trade)
|
||||
}
|
||||
|
||||
// 5. TP2 Hit - Activate runner (no close, just start trailing)
|
||||
// 5. Take profit 2 (remaining position)
|
||||
if (trade.tp1Hit && !trade.tp2Hit && this.shouldTakeProfit2(currentPrice, trade)) {
|
||||
console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}% - Activating 25% runner!`)
|
||||
console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
|
||||
|
||||
// Mark TP2 as hit and activate trailing stop on full remaining 25%
|
||||
trade.tp2Hit = true
|
||||
trade.peakPrice = currentPrice
|
||||
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade)
|
||||
// Calculate how much to close based on TP2 size percent
|
||||
const percentToClose = this.config.takeProfit2SizePercent
|
||||
|
||||
console.log(
|
||||
`🏃 Runner activated on full remaining position: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% | trailing buffer ${trade.runnerTrailingPercent?.toFixed(3)}%`
|
||||
)
|
||||
await this.executeExit(trade, percentToClose, 'TP2', currentPrice)
|
||||
|
||||
// Save state after TP2 activation
|
||||
await this.saveTradeState(trade)
|
||||
// If some position remains, mark TP2 as hit and activate trailing stop
|
||||
if (percentToClose < 100) {
|
||||
trade.tp2Hit = true
|
||||
trade.currentSize = trade.currentSize * ((100 - percentToClose) / 100)
|
||||
|
||||
console.log(`🏃 Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`)
|
||||
|
||||
// Save state after TP2
|
||||
await this.saveTradeState(trade)
|
||||
}
|
||||
|
||||
return
|
||||
} // 6. Trailing stop for runner (after TP2 activation)
|
||||
}
|
||||
|
||||
// 6. Trailing stop for runner (after TP2)
|
||||
if (trade.tp2Hit && this.config.useTrailingStop) {
|
||||
// Check if trailing stop should be activated
|
||||
if (!trade.trailingStopActive && profitPercent >= this.config.trailingStopActivation) {
|
||||
trade.trailingStopActive = true
|
||||
trade.runnerTrailingPercent = this.getRunnerTrailingPercent(trade)
|
||||
console.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`)
|
||||
}
|
||||
|
||||
// If trailing stop is active, adjust SL dynamically
|
||||
if (trade.trailingStopActive) {
|
||||
const trailingPercent = this.getRunnerTrailingPercent(trade)
|
||||
trade.runnerTrailingPercent = trailingPercent
|
||||
const trailingStopPrice = this.calculatePrice(
|
||||
trade.peakPrice,
|
||||
-trailingPercent, // Trail below peak
|
||||
-this.config.trailingStopPercent, // Trail below peak
|
||||
trade.direction
|
||||
)
|
||||
|
||||
@@ -772,7 +760,7 @@ export class PositionManager {
|
||||
const oldSL = trade.stopLossPrice
|
||||
trade.stopLossPrice = trailingStopPrice
|
||||
|
||||
console.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)} → ${trailingStopPrice.toFixed(4)} (${trailingPercent.toFixed(3)}% below peak $${trade.peakPrice.toFixed(4)})`)
|
||||
console.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)} → ${trailingStopPrice.toFixed(4)} (${this.config.trailingStopPercent}% below peak $${trade.peakPrice.toFixed(4)})`)
|
||||
|
||||
// Save state after trailing SL update (every 10 updates to avoid spam)
|
||||
if (trade.priceCheckCount % 10 === 0) {
|
||||
@@ -813,35 +801,18 @@ export class PositionManager {
|
||||
return
|
||||
}
|
||||
|
||||
const closePriceForCalc = result.closePrice || currentPrice
|
||||
const closedSizeBase = result.closedSize || 0
|
||||
const closedUSD = closedSizeBase * closePriceForCalc
|
||||
const wasForcedFullClose = !!result.fullyClosed && percentToClose < 100
|
||||
const treatAsFullClose = percentToClose >= 100 || result.fullyClosed
|
||||
|
||||
// Calculate actual P&L based on entry vs exit price
|
||||
const profitPercent = this.calculateProfitPercent(trade.entryPrice, closePriceForCalc, trade.direction)
|
||||
const actualRealizedPnL = (closedUSD * profitPercent) / 100
|
||||
|
||||
// Update trade state
|
||||
if (treatAsFullClose) {
|
||||
trade.realizedPnL += actualRealizedPnL
|
||||
trade.currentSize = 0
|
||||
trade.trailingStopActive = false
|
||||
|
||||
if (reason === 'TP2') {
|
||||
trade.tp2Hit = true
|
||||
}
|
||||
if (reason === 'TP1') {
|
||||
trade.tp1Hit = true
|
||||
}
|
||||
|
||||
if (percentToClose >= 100) {
|
||||
// Full close - remove from monitoring
|
||||
trade.realizedPnL += result.realizedPnL || 0
|
||||
|
||||
// Save to database (only for valid exit reasons)
|
||||
if (reason !== 'error') {
|
||||
try {
|
||||
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
||||
await updateTradeExit({
|
||||
positionId: trade.positionId,
|
||||
exitPrice: closePriceForCalc,
|
||||
exitPrice: result.closePrice || currentPrice,
|
||||
exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency',
|
||||
realizedPnL: trade.realizedPnL,
|
||||
exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE',
|
||||
@@ -856,23 +827,25 @@ export class PositionManager {
|
||||
console.log('💾 Trade saved to database')
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save trade exit to database:', dbError)
|
||||
// Don't fail the close if database fails
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await this.removeTrade(trade.id)
|
||||
const closeLabel = wasForcedFullClose
|
||||
? '✅ Forced full close (below Drift minimum)'
|
||||
: '✅ Position closed'
|
||||
console.log(`${closeLabel} | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`)
|
||||
console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`)
|
||||
} else {
|
||||
// Partial close (TP1) - calculate P&L for partial amount
|
||||
const partialRealizedPnL = (closedUSD * profitPercent) / 100
|
||||
trade.realizedPnL += partialRealizedPnL
|
||||
// Partial close (TP1)
|
||||
trade.realizedPnL += result.realizedPnL || 0
|
||||
// result.closedSize is returned in base asset units (e.g., SOL), convert to USD using closePrice
|
||||
const closePriceForCalc = result.closePrice || currentPrice
|
||||
const closedSizeBase = result.closedSize || 0
|
||||
const closedUSD = closedSizeBase * closePriceForCalc
|
||||
trade.currentSize = Math.max(0, trade.currentSize - closedUSD)
|
||||
|
||||
console.log(
|
||||
`✅ Partial close executed | Realized: $${partialRealizedPnL.toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}`
|
||||
)
|
||||
console.log(`✅ Partial close executed | Realized: $${(result.realizedPnL || 0).toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}`)
|
||||
|
||||
// Persist updated trade state so analytics reflect partial profits immediately
|
||||
await this.saveTradeState(trade)
|
||||
}
|
||||
|
||||
// TODO: Send notification
|
||||
@@ -962,131 +935,6 @@ export class PositionManager {
|
||||
console.log('✅ All positions closed')
|
||||
}
|
||||
|
||||
refreshConfig(): void {
|
||||
this.config = getMergedConfig()
|
||||
console.log('⚙️ Position manager config refreshed from environment')
|
||||
}
|
||||
|
||||
private getRunnerTrailingPercent(trade: ActiveTrade): number {
|
||||
const fallbackPercent = this.config.trailingStopPercent
|
||||
const atrValue = trade.atrAtEntry ?? 0
|
||||
const entryPrice = trade.entryPrice
|
||||
|
||||
if (atrValue <= 0 || entryPrice <= 0 || !Number.isFinite(entryPrice)) {
|
||||
return fallbackPercent
|
||||
}
|
||||
|
||||
const atrPercentOfPrice = (atrValue / entryPrice) * 100
|
||||
if (!Number.isFinite(atrPercentOfPrice) || atrPercentOfPrice <= 0) {
|
||||
return fallbackPercent
|
||||
}
|
||||
|
||||
const rawPercent = atrPercentOfPrice * this.config.trailingStopAtrMultiplier
|
||||
const boundedPercent = Math.min(
|
||||
this.config.trailingStopMaxPercent,
|
||||
Math.max(this.config.trailingStopMinPercent, rawPercent)
|
||||
)
|
||||
|
||||
return boundedPercent > 0 ? boundedPercent : fallbackPercent
|
||||
}
|
||||
|
||||
private async handlePostTp1Adjustments(trade: ActiveTrade, context: string): Promise<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)}`)
|
||||
|
||||
await this.refreshExitOrders(trade, {
|
||||
stopLossPrice: newStopLossPrice,
|
||||
tp1Price: trade.tp2Price,
|
||||
tp1SizePercent: 100,
|
||||
tp2Price: trade.tp2Price,
|
||||
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)
|
||||
*/
|
||||
@@ -1103,7 +951,6 @@ export class PositionManager {
|
||||
unrealizedPnL: trade.unrealizedPnL,
|
||||
peakPnL: trade.peakPnL,
|
||||
lastPrice: trade.lastPrice,
|
||||
runnerTrailingPercent: trade.runnerTrailingPercent,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to save trade state:', error)
|
||||
@@ -1111,6 +958,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
|
||||
*/
|
||||
|
||||
@@ -14,18 +14,14 @@ export interface SignalQualityResult {
|
||||
/**
|
||||
* Calculate signal quality score based on technical indicators
|
||||
*
|
||||
* TIMEFRAME-AWARE SCORING:
|
||||
* 5min charts naturally have lower ADX/ATR than higher timeframes
|
||||
*
|
||||
* Scoring breakdown:
|
||||
* - Base: 50 points
|
||||
* - ATR (volatility): -20 to +10 points (5min: 0.25-0.7% is healthy)
|
||||
* - ADX (trend strength): -15 to +15 points (5min: 15+ is trending)
|
||||
* - ATR (volatility): -20 to +10 points
|
||||
* - ADX (trend strength): -15 to +15 points
|
||||
* - RSI (momentum): -10 to +10 points
|
||||
* - Volume: -10 to +15 points
|
||||
* - Price position: -15 to +5 points
|
||||
* - Volume breakout bonus: +10 points
|
||||
* - Anti-chop filter: -20 points (5min only, extreme chop)
|
||||
*
|
||||
* Total range: ~15-115 points (realistically 30-100)
|
||||
* Threshold: 60 points minimum for execution
|
||||
@@ -38,92 +34,38 @@ export function scoreSignalQuality(params: {
|
||||
pricePosition: number
|
||||
direction: 'long' | 'short'
|
||||
minScore?: number // Configurable minimum score threshold
|
||||
timeframe?: string // e.g., '5', '15', '60', '1D'
|
||||
}): SignalQualityResult {
|
||||
let score = 50 // Base score
|
||||
const reasons: string[] = []
|
||||
|
||||
// Detect 5-minute timeframe
|
||||
const is5min = params.timeframe === '5' || params.timeframe === 'manual'
|
||||
|
||||
// ATR check - TIMEFRAME AWARE
|
||||
// ATR check (volatility gate: 0.15% - 2.5%)
|
||||
if (params.atr > 0) {
|
||||
if (is5min) {
|
||||
// 5min: lower thresholds, more lenient
|
||||
if (params.atr < 0.2) {
|
||||
score -= 15
|
||||
reasons.push(`ATR too low (${params.atr.toFixed(2)}% - dead market)`)
|
||||
} else if (params.atr > 1.5) {
|
||||
score -= 20
|
||||
reasons.push(`ATR too high (${params.atr.toFixed(2)}% - too volatile)`)
|
||||
} else if (params.atr >= 0.2 && params.atr < 0.35) {
|
||||
score += 5
|
||||
reasons.push(`ATR acceptable (${params.atr.toFixed(2)}%)`)
|
||||
} else {
|
||||
score += 10
|
||||
reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`)
|
||||
}
|
||||
if (params.atr < 0.15) {
|
||||
score -= 15
|
||||
reasons.push(`ATR too low (${params.atr.toFixed(2)}% - dead market)`)
|
||||
} else if (params.atr > 2.5) {
|
||||
score -= 20
|
||||
reasons.push(`ATR too high (${params.atr.toFixed(2)}% - too volatile)`)
|
||||
} else if (params.atr >= 0.15 && params.atr < 0.4) {
|
||||
score += 5
|
||||
reasons.push(`ATR moderate (${params.atr.toFixed(2)}%)`)
|
||||
} else {
|
||||
// Higher timeframes: stricter requirements
|
||||
if (params.atr < 0.15) {
|
||||
score -= 15
|
||||
reasons.push(`ATR too low (${params.atr.toFixed(2)}% - dead market)`)
|
||||
} else if (params.atr > 2.5) {
|
||||
score -= 20
|
||||
reasons.push(`ATR too high (${params.atr.toFixed(2)}% - too volatile)`)
|
||||
} else if (params.atr >= 0.15 && params.atr < 0.4) {
|
||||
score += 5
|
||||
reasons.push(`ATR moderate (${params.atr.toFixed(2)}%)`)
|
||||
} else {
|
||||
score += 10
|
||||
reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`)
|
||||
}
|
||||
score += 10
|
||||
reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`)
|
||||
}
|
||||
}
|
||||
|
||||
// ADX check - TIMEFRAME AWARE
|
||||
// ADX check (trend strength: want >18)
|
||||
if (params.adx > 0) {
|
||||
if (is5min) {
|
||||
// 5min: ADX 15+ is actually trending, 20+ is strong
|
||||
// High volume can compensate for lower ADX in breakouts/breakdowns
|
||||
const hasStrongVolume = params.volumeRatio > 1.2
|
||||
|
||||
if (params.adx > 22) {
|
||||
score += 15
|
||||
reasons.push(`Strong 5min trend (ADX ${params.adx.toFixed(1)})`)
|
||||
} else if (params.adx < 12) {
|
||||
// Reduce penalty if strong volume present (breakdown/breakout in progress)
|
||||
if (hasStrongVolume) {
|
||||
score -= 5
|
||||
reasons.push(`Lower 5min ADX (${params.adx.toFixed(1)}) but strong volume compensates`)
|
||||
} else {
|
||||
score -= 15
|
||||
reasons.push(`Weak 5min trend (ADX ${params.adx.toFixed(1)})`)
|
||||
}
|
||||
} else {
|
||||
score += 5
|
||||
reasons.push(`Moderate 5min trend (ADX ${params.adx.toFixed(1)})`)
|
||||
}
|
||||
if (params.adx > 25) {
|
||||
score += 15
|
||||
reasons.push(`Strong trend (ADX ${params.adx.toFixed(1)})`)
|
||||
} else if (params.adx < 18) {
|
||||
score -= 15
|
||||
reasons.push(`Weak trend (ADX ${params.adx.toFixed(1)})`)
|
||||
} else {
|
||||
// Higher timeframes: stricter ADX requirements
|
||||
const hasStrongVolume = params.volumeRatio > 1.2
|
||||
|
||||
if (params.adx > 25) {
|
||||
score += 15
|
||||
reasons.push(`Strong trend (ADX ${params.adx.toFixed(1)})`)
|
||||
} else if (params.adx < 18) {
|
||||
// Reduce penalty if strong volume present
|
||||
if (hasStrongVolume) {
|
||||
score -= 5
|
||||
reasons.push(`Lower ADX (${params.adx.toFixed(1)}) but strong volume compensates`)
|
||||
} else {
|
||||
score -= 15
|
||||
reasons.push(`Weak trend (ADX ${params.adx.toFixed(1)})`)
|
||||
}
|
||||
} else {
|
||||
score += 5
|
||||
reasons.push(`Moderate trend (ADX ${params.adx.toFixed(1)})`)
|
||||
}
|
||||
score += 5
|
||||
reasons.push(`Moderate trend (ADX ${params.adx.toFixed(1)})`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +104,7 @@ export function scoreSignalQuality(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// Price position check (avoid chasing vs breakout/breakdown detection)
|
||||
// Price position check (avoid chasing vs breakout detection)
|
||||
if (params.pricePosition > 0) {
|
||||
if (params.direction === 'long' && params.pricePosition > 95) {
|
||||
// High volume breakout at range top can be good
|
||||
@@ -173,35 +115,14 @@ export function scoreSignalQuality(params: {
|
||||
score -= 15
|
||||
reasons.push(`Price near top of range (${params.pricePosition.toFixed(0)}%) - risky long`)
|
||||
}
|
||||
} else if (params.direction === 'short' && params.pricePosition < 15) {
|
||||
// Shorts near range bottom (< 15%) require strong confirmation
|
||||
// Require STRONG trend (ADX > 18) to avoid false breakdowns in choppy ranges
|
||||
// OR very bearish RSI (< 35) indicating strong momentum continuation
|
||||
const hasStrongTrend = params.adx > 18
|
||||
const isVeryBearish = params.rsi > 0 && params.rsi < 35
|
||||
const hasGoodVolume = params.volumeRatio > 1.2
|
||||
|
||||
if ((hasGoodVolume && hasStrongTrend) || isVeryBearish) {
|
||||
} else if (params.direction === 'short' && params.pricePosition < 5) {
|
||||
// High volume breakdown at range bottom can be good
|
||||
if (params.volumeRatio > 1.4) {
|
||||
score += 5
|
||||
reasons.push(`Valid breakdown at range bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x, ADX ${params.adx.toFixed(1)}, RSI ${params.rsi.toFixed(1)})`)
|
||||
reasons.push(`Volume breakdown at range bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`)
|
||||
} else {
|
||||
score -= 15
|
||||
reasons.push(`Price near bottom (${params.pricePosition.toFixed(0)}%) - need ADX > 18 or RSI < 35 for breakdown`)
|
||||
}
|
||||
} else if (params.direction === 'long' && params.pricePosition < 15) {
|
||||
// Longs near range bottom (< 15%) require strong reversal confirmation
|
||||
// Require STRONG trend (ADX > 18) to avoid catching falling knives
|
||||
// OR very bullish RSI (> 60) after bounce showing momentum shift
|
||||
const hasStrongTrend = params.adx > 18
|
||||
const isVeryBullish = params.rsi > 0 && params.rsi > 60
|
||||
const hasGoodVolume = params.volumeRatio > 1.2
|
||||
|
||||
if ((hasGoodVolume && hasStrongTrend) || isVeryBullish) {
|
||||
score += 5
|
||||
reasons.push(`Potential reversal at bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x, ADX ${params.adx.toFixed(1)}, RSI ${params.rsi.toFixed(1)})`)
|
||||
} else {
|
||||
score -= 15
|
||||
reasons.push(`Price near bottom (${params.pricePosition.toFixed(0)}%) - need ADX > 18 or RSI > 60 for reversal`)
|
||||
reasons.push(`Price near bottom of range (${params.pricePosition.toFixed(0)}%) - risky short`)
|
||||
}
|
||||
} else {
|
||||
score += 5
|
||||
@@ -214,12 +135,6 @@ export function scoreSignalQuality(params: {
|
||||
score += 10
|
||||
reasons.push(`Volume breakout compensates for low ATR`)
|
||||
}
|
||||
|
||||
// ANTI-CHOP FILTER for 5min (extreme penalty for sideways chop)
|
||||
if (is5min && params.adx < 10 && params.atr < 0.25 && params.volumeRatio < 0.9) {
|
||||
score -= 20
|
||||
reasons.push(`⛔ Extreme chop detected (ADX ${params.adx.toFixed(1)}, ATR ${params.atr.toFixed(2)}%, Vol ${params.volumeRatio.toFixed(2)}x)`)
|
||||
}
|
||||
|
||||
const minScore = params.minScore || 60
|
||||
const passed = score >= minScore
|
||||
|
||||
@@ -102,12 +102,6 @@ model Trade {
|
||||
signalStrength String? // "strong", "moderate", "weak"
|
||||
timeframe String? // "5", "15", "60"
|
||||
|
||||
// Signal quality logic version tracking
|
||||
signalQualityVersion String? @default("v1") // Track which scoring logic was used
|
||||
// v1: Original logic with price position < 5% threshold
|
||||
// v2: Added volume compensation for low ADX (2025-11-07)
|
||||
// v3: Stricter - price position < 15% requires ADX > 18 (2025-11-07)
|
||||
|
||||
// Status
|
||||
status String @default("open") // "open", "closed", "failed", "phantom"
|
||||
isTestTrade Boolean @default(false) // Flag test trades for exclusion from analytics
|
||||
|
||||
@@ -530,7 +530,7 @@ async def trade_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
|
||||
|
||||
async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Execute manual long/short commands sent as plain text."""
|
||||
"""Execute manual long/short commands sent as plain text with analytics validation."""
|
||||
|
||||
if update.message is None:
|
||||
return
|
||||
@@ -541,6 +541,12 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
text = update.message.text.strip().lower()
|
||||
|
||||
parts = text.split()
|
||||
|
||||
# Check for --force flag
|
||||
force_trade = '--force' in parts
|
||||
if force_trade:
|
||||
parts.remove('--force')
|
||||
|
||||
if len(parts) != 2:
|
||||
return
|
||||
|
||||
@@ -553,6 +559,76 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
if not symbol_info:
|
||||
return
|
||||
|
||||
# Convert to Drift format for analytics check
|
||||
drift_symbol_map = {
|
||||
'sol': 'SOL-PERP',
|
||||
'eth': 'ETH-PERP',
|
||||
'btc': 'BTC-PERP'
|
||||
}
|
||||
drift_symbol = drift_symbol_map.get(symbol_key)
|
||||
|
||||
# 🆕 PHASE 1: Check analytics before executing (unless forced)
|
||||
if not force_trade:
|
||||
try:
|
||||
print(f"🔍 Checking re-entry analytics for {direction.upper()} {drift_symbol}", flush=True)
|
||||
|
||||
analytics_response = requests.post(
|
||||
f"{TRADING_BOT_URL}/api/analytics/reentry-check",
|
||||
json={'symbol': drift_symbol, 'direction': direction},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if analytics_response.ok:
|
||||
analytics = analytics_response.json()
|
||||
|
||||
if not analytics.get('should_enter'):
|
||||
# Build rejection message with data source info
|
||||
data_source = analytics.get('data_source', 'unknown')
|
||||
data_age = analytics.get('data_age_seconds')
|
||||
|
||||
data_emoji = {
|
||||
'tradingview_real': '✅',
|
||||
'fallback_historical': '⚠️',
|
||||
'no_data': '❌'
|
||||
}
|
||||
data_icon = data_emoji.get(data_source, '❓')
|
||||
|
||||
data_age_text = f" ({data_age}s old)" if data_age else ""
|
||||
|
||||
message = (
|
||||
f"🛑 *Analytics suggest NOT entering {direction.upper()} {symbol_info['label']}*\n\n"
|
||||
f"*Reason:* {analytics.get('reason', 'Unknown')}\n"
|
||||
f"*Score:* {analytics.get('score', 0)}/100\n"
|
||||
f"*Data:* {data_icon} {data_source}{data_age_text}\n\n"
|
||||
f"Use `{text} --force` to override"
|
||||
)
|
||||
|
||||
await update.message.reply_text(message, parse_mode='Markdown')
|
||||
print(f"❌ Trade blocked by analytics (score: {analytics.get('score')})", flush=True)
|
||||
return
|
||||
|
||||
# Analytics passed - show confirmation
|
||||
data_age = analytics.get('data_age_seconds')
|
||||
data_source = analytics.get('data_source', 'unknown')
|
||||
data_age_text = f" ({data_age}s old)" if data_age else ""
|
||||
|
||||
confirm_message = (
|
||||
f"✅ *Analytics check passed ({analytics.get('score')}/100)*\n"
|
||||
f"Data: {data_source}{data_age_text}\n"
|
||||
f"Proceeding with {direction.upper()} {symbol_info['label']}..."
|
||||
)
|
||||
|
||||
await update.message.reply_text(confirm_message, parse_mode='Markdown')
|
||||
print(f"✅ Analytics passed (score: {analytics.get('score')})", flush=True)
|
||||
else:
|
||||
# Analytics endpoint failed - proceed with trade (fail-open)
|
||||
print(f"⚠️ Analytics check failed ({analytics_response.status_code}) - proceeding anyway", flush=True)
|
||||
|
||||
except Exception as analytics_error:
|
||||
# Analytics check error - proceed with trade (fail-open)
|
||||
print(f"⚠️ Analytics error: {analytics_error} - proceeding anyway", flush=True)
|
||||
|
||||
# Execute the trade
|
||||
metrics = MANUAL_METRICS[direction]
|
||||
|
||||
payload = {
|
||||
@@ -568,7 +644,7 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
}
|
||||
|
||||
try:
|
||||
print(f"🚀 Manual trade: {direction.upper()} {symbol_info['label']}", flush=True)
|
||||
print(f"🚀 Manual trade: {direction.upper()} {symbol_info['label']}{' (FORCED)' if force_trade else ''}", flush=True)
|
||||
|
||||
response = requests.post(
|
||||
f"{TRADING_BOT_URL}/api/trading/execute",
|
||||
@@ -609,8 +685,10 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
tp2_text = f"${tp2:.4f}" if tp2 is not None else 'n/a'
|
||||
sl_text = f"${sl:.4f}" if sl is not None else 'n/a'
|
||||
|
||||
force_indicator = " (FORCED)" if force_trade else ""
|
||||
|
||||
success_message = (
|
||||
f"✅ OPENED {direction.upper()} {symbol_info['label']}\n"
|
||||
f"✅ OPENED {direction.upper()} {symbol_info['label']}{force_indicator}\n"
|
||||
f"Entry: {entry_text}\n"
|
||||
f"Size: {size_text}\n"
|
||||
f"TP1: {tp1_text}\nTP2: {tp2_text}\nSL: {sl_text}"
|
||||
|
||||
Reference in New Issue
Block a user