Compare commits
2 Commits
881a99242d
...
6b1d32a72d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1d32a72d | ||
|
|
1313031acd |
4
.env
4
.env
@@ -105,7 +105,7 @@ TAKE_PROFIT_2_PERCENT=0.7
|
||||
|
||||
# Take Profit 2 Size: What % of remaining position to close at TP2
|
||||
# Example: 100 = close all remaining position
|
||||
TAKE_PROFIT_2_SIZE_PERCENT=80
|
||||
TAKE_PROFIT_2_SIZE_PERCENT=75
|
||||
|
||||
# Emergency Stop: Hard stop if this level is breached
|
||||
# Example: -2.0% on 10x = -20% account loss (rare but protects from flash crashes)
|
||||
@@ -131,7 +131,7 @@ MAX_TRADES_PER_HOUR=20
|
||||
|
||||
# Minimum time between trades in minutes (cooldown period)
|
||||
# Example: 10 = 10 minutes between trades
|
||||
MIN_TIME_BETWEEN_TRADES=10
|
||||
MIN_TIME_BETWEEN_TRADES=1
|
||||
|
||||
# DEX execution settings
|
||||
# Maximum acceptable slippage on market orders (percentage)
|
||||
|
||||
63
.github/copilot-instructions.md
vendored
63
.github/copilot-instructions.md
vendored
@@ -9,10 +9,16 @@
|
||||
**Key Design Principle:** Dual-layer redundancy - every trade has both on-chain orders (Drift) AND software monitoring (Position Manager) as backup.
|
||||
|
||||
**Exit Strategy:** Three-tier scaling system:
|
||||
- TP1 at +1.5%: Close 75% (configurable via `TAKE_PROFIT_1_SIZE_PERCENT`)
|
||||
- TP2 at +3.0%: Close 80% of remaining = 20% total (configurable via `TAKE_PROFIT_2_SIZE_PERCENT`)
|
||||
- TP1 at +0.4%: Close 75% (configurable via `TAKE_PROFIT_1_SIZE_PERCENT`)
|
||||
- TP2 at +0.7%: Close 80% of remaining = 20% total (configurable via `TAKE_PROFIT_2_SIZE_PERCENT`)
|
||||
- Runner: 5% remaining with 0.3% trailing stop (configurable via `TRAILING_STOP_PERCENT`)
|
||||
|
||||
**Per-Symbol Configuration:** SOL and ETH have independent enable/disable toggles and position sizing:
|
||||
- `SOLANA_ENABLED`, `SOLANA_POSITION_SIZE`, `SOLANA_LEVERAGE` (defaults: true, $210, 10x)
|
||||
- `ETHEREUM_ENABLED`, `ETHEREUM_POSITION_SIZE`, `ETHEREUM_LEVERAGE` (defaults: true, $4, 1x)
|
||||
- BTC and other symbols fall back to global settings (`MAX_POSITION_SIZE_USD`, `LEVERAGE`)
|
||||
- **Priority:** Per-symbol ENV → Market config → Global ENV → Defaults
|
||||
|
||||
**Signal Quality System:** Filters trades based on 5 metrics (ATR, ADX, RSI, volumeRatio, pricePosition) scored 0-100. Only trades scoring 60+ are executed. Scores stored in database for future optimization.
|
||||
|
||||
**MAE/MFE Tracking:** Every trade tracks Maximum Favorable Excursion (best profit %) and Maximum Adverse Excursion (worst loss %) updated every 2s. Used for data-driven optimization of TP/SL levels.
|
||||
@@ -118,6 +124,14 @@ Without this, the SDK returns signatures for transactions that never execute, ca
|
||||
|
||||
**Always use:** `getMergedConfig()` to get final config - never read env vars directly in business logic
|
||||
|
||||
**Per-symbol position sizing:** Use `getPositionSizeForSymbol(symbol, config)` which returns `{ size, leverage, enabled }`
|
||||
```typescript
|
||||
const { size, leverage, enabled } = getPositionSizeForSymbol('SOL-PERP', config)
|
||||
if (!enabled) {
|
||||
return NextResponse.json({ success: false, error: 'Symbol trading disabled' }, { status: 400 })
|
||||
}
|
||||
```
|
||||
|
||||
**Symbol normalization:** TradingView sends "SOLUSDT" → must convert to "SOL-PERP" for Drift
|
||||
```typescript
|
||||
const driftSymbol = normalizeTradingViewSymbol(body.symbol)
|
||||
@@ -138,11 +152,11 @@ const driftSymbol = normalizeTradingViewSymbol(body.symbol)
|
||||
|
||||
**Key endpoints:**
|
||||
- `/api/trading/execute` - Main entry point from n8n (production, requires auth)
|
||||
- `/api/trading/check-risk` - Pre-execution validation (duplicate check, quality score, **per-symbol cooldown**, rate limits)
|
||||
- `/api/trading/test` - Test trades from settings UI (no auth required)
|
||||
- `/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/settings` - Get/update config (writes to .env file)
|
||||
- `/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/restart` - Create restart flag for watch-restart.sh script
|
||||
|
||||
@@ -151,10 +165,11 @@ const driftSymbol = normalizeTradingViewSymbol(body.symbol)
|
||||
### Execute Trade (Production)
|
||||
```
|
||||
TradingView alert → n8n Parse Signal Enhanced (extracts metrics)
|
||||
↓ /api/trading/check-risk [validates quality score ≥60, checks duplicates]
|
||||
↓ /api/trading/check-risk [validates quality score ≥60, checks duplicates, per-symbol cooldown]
|
||||
↓ /api/trading/execute
|
||||
↓ normalize symbol (SOLUSDT → SOL-PERP)
|
||||
↓ getMergedConfig()
|
||||
↓ getPositionSizeForSymbol() [check if symbol enabled + get sizing]
|
||||
↓ openPosition() [MARKET order]
|
||||
↓ calculate dual stop prices if enabled
|
||||
↓ placeExitOrders() [on-chain TP1/TP2/SL orders]
|
||||
@@ -309,7 +324,7 @@ docker exec trading-bot-postgres psql -U postgres -d trading_bot_v4 -c "\dt"
|
||||
|
||||
12. **Drift minimum position sizes:** Actual minimums differ from documentation:
|
||||
- SOL-PERP: 0.1 SOL (~$5-15 depending on price)
|
||||
- ETH-PERP: 0.002 ETH (~$7-8 at $4000/ETH) - NOT 0.01 ETH
|
||||
- ETH-PERP: 0.01 ETH (~$38-40 at $4000/ETH)
|
||||
- BTC-PERP: 0.0001 BTC (~$10-12 at $100k/BTC)
|
||||
|
||||
Always calculate: `minOrderSize × currentPrice` must exceed Drift's $4 minimum. Add buffer for price movement.
|
||||
@@ -326,6 +341,40 @@ docker exec trading-bot-postgres psql -U postgres -d trading_bot_v4 -c "\dt"
|
||||
- **Types:** Define interfaces in same file as implementation (not separate types directory)
|
||||
- **Console logs:** Use emojis for visual scanning: 🎯 🚀 ✅ ❌ 💰 📊 🛡️
|
||||
|
||||
## 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).
|
||||
|
||||
**Configuration Priority:**
|
||||
1. **Per-symbol ENV vars** (highest priority)
|
||||
- `SOLANA_ENABLED`, `SOLANA_POSITION_SIZE`, `SOLANA_LEVERAGE`
|
||||
- `ETHEREUM_ENABLED`, `ETHEREUM_POSITION_SIZE`, `ETHEREUM_LEVERAGE`
|
||||
2. **Market-specific config** (from `MARKET_CONFIGS` in config/trading.ts)
|
||||
3. **Global ENV vars** (fallback for BTC and other symbols)
|
||||
- `MAX_POSITION_SIZE_USD`, `LEVERAGE`
|
||||
4. **Default config** (lowest priority)
|
||||
|
||||
**Settings UI:** `app/settings/page.tsx` has dedicated sections:
|
||||
- 💎 Solana section: Toggle + position size + leverage + risk calculator
|
||||
- ⚡ Ethereum section: Toggle + position size + leverage + risk calculator
|
||||
- 💰 Global fallback: For BTC-PERP and future symbols
|
||||
|
||||
**Example usage:**
|
||||
```typescript
|
||||
// In execute/test endpoints
|
||||
const { size, leverage, enabled } = getPositionSizeForSymbol(driftSymbol, config)
|
||||
if (!enabled) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Symbol trading disabled'
|
||||
}, { status: 400 })
|
||||
}
|
||||
```
|
||||
|
||||
**Test buttons:** Settings UI has symbol-specific test buttons:
|
||||
- 💎 Test SOL LONG/SHORT (disabled when `SOLANA_ENABLED=false`)
|
||||
- ⚡ Test ETH LONG/SHORT (disabled when `ETHEREUM_ENABLED=false`)
|
||||
|
||||
## When Making Changes
|
||||
|
||||
1. **Adding new config:** Update DEFAULT_TRADING_CONFIG + getConfigFromEnv() + .env file
|
||||
|
||||
@@ -154,7 +154,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
|
||||
volumeRatio: body.volumeRatio || 0,
|
||||
pricePosition: body.pricePosition || 0,
|
||||
direction: body.direction,
|
||||
minScore: 60 // Default minimum quality score threshold
|
||||
minScore: 60 // Hardcoded threshold
|
||||
})
|
||||
|
||||
if (!qualityScore.passed) {
|
||||
|
||||
@@ -213,6 +213,23 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
trade => trade.symbol === driftSymbol && trade.direction !== body.direction
|
||||
)
|
||||
|
||||
// SAFETY CHECK: Prevent multiple positions on same symbol
|
||||
const sameDirectionPosition = existingTrades.find(
|
||||
trade => trade.symbol === driftSymbol && trade.direction === body.direction
|
||||
)
|
||||
|
||||
if (sameDirectionPosition) {
|
||||
console.log(`⛔ DUPLICATE POSITION BLOCKED: Already have ${body.direction} position on ${driftSymbol}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Duplicate position detected',
|
||||
message: `Already have an active ${body.direction} position on ${driftSymbol}. Close it first.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (oppositePosition) {
|
||||
console.log(`🔄 Signal flip detected! Closing ${oppositePosition.direction} to open ${body.direction}`)
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
*/
|
||||
|
||||
import { getInitializedPositionManager } from '../trading/position-manager'
|
||||
import { initializeDriftService } from '../drift/client'
|
||||
import { getPrismaClient } from '../database/trades'
|
||||
import { getMarketConfig } from '../../config/trading'
|
||||
|
||||
let initStarted = false
|
||||
|
||||
@@ -19,6 +22,9 @@ export async function initializePositionManagerOnStartup() {
|
||||
console.log('🚀 Initializing Position Manager on startup...')
|
||||
|
||||
try {
|
||||
// Validate open trades against Drift positions BEFORE starting Position Manager
|
||||
await validateOpenTrades()
|
||||
|
||||
const manager = await getInitializedPositionManager()
|
||||
const status = manager.getStatus()
|
||||
|
||||
@@ -31,3 +37,82 @@ export async function initializePositionManagerOnStartup() {
|
||||
console.error('❌ Failed to initialize Position Manager on startup:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that open trades in database match actual Drift positions
|
||||
* Closes phantom trades that don't exist on-chain
|
||||
*/
|
||||
async function validateOpenTrades() {
|
||||
try {
|
||||
const prisma = getPrismaClient()
|
||||
const openTrades = await prisma.trade.findMany({
|
||||
where: { status: 'open' },
|
||||
orderBy: { entryTime: 'asc' }
|
||||
})
|
||||
|
||||
if (openTrades.length === 0) {
|
||||
console.log('✅ No open trades to validate')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`🔍 Validating ${openTrades.length} open trade(s) against Drift positions...`)
|
||||
|
||||
const driftService = await initializeDriftService()
|
||||
|
||||
for (const trade of openTrades) {
|
||||
try {
|
||||
const marketConfig = getMarketConfig(trade.symbol)
|
||||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||||
|
||||
// Calculate expected position size in base assets
|
||||
const expectedSizeBase = trade.positionSizeUSD / trade.entryPrice
|
||||
const actualSizeBase = position?.size || 0
|
||||
|
||||
// Check if position exists and size matches (with 50% tolerance for partial fills)
|
||||
const sizeDiff = Math.abs(expectedSizeBase - actualSizeBase)
|
||||
const sizeRatio = actualSizeBase / expectedSizeBase
|
||||
|
||||
if (!position || position.side === 'none' || sizeRatio < 0.5) {
|
||||
console.log(`⚠️ PHANTOM TRADE DETECTED:`)
|
||||
console.log(` Trade ID: ${trade.id.substring(0, 20)}...`)
|
||||
console.log(` Symbol: ${trade.symbol} ${trade.direction}`)
|
||||
console.log(` Expected size: ${expectedSizeBase.toFixed(4)}`)
|
||||
console.log(` Actual size: ${actualSizeBase.toFixed(4)}`)
|
||||
console.log(` Entry: $${trade.entryPrice} at ${trade.entryTime.toISOString()}`)
|
||||
console.log(` 🗑️ Auto-closing phantom trade...`)
|
||||
|
||||
// Close phantom trade
|
||||
await prisma.trade.update({
|
||||
where: { id: trade.id },
|
||||
data: {
|
||||
status: 'closed',
|
||||
exitTime: new Date(),
|
||||
exitReason: 'PHANTOM_TRADE_CLEANUP',
|
||||
exitPrice: trade.entryPrice,
|
||||
realizedPnL: 0,
|
||||
realizedPnLPercent: 0,
|
||||
}
|
||||
})
|
||||
|
||||
console.log(` ✅ Phantom trade closed`)
|
||||
} else if (sizeDiff > expectedSizeBase * 0.1) {
|
||||
console.log(`⚠️ SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}% of expected):`)
|
||||
console.log(` Trade ID: ${trade.id.substring(0, 20)}...`)
|
||||
console.log(` Symbol: ${trade.symbol} ${trade.direction}`)
|
||||
console.log(` Expected: ${expectedSizeBase.toFixed(4)}, Actual: ${actualSizeBase.toFixed(4)}`)
|
||||
console.log(` ℹ️ Will monitor with adjusted size`)
|
||||
} else {
|
||||
console.log(`✅ ${trade.symbol} ${trade.direction}: Size OK (${actualSizeBase.toFixed(4)})`)
|
||||
}
|
||||
|
||||
} catch (posError) {
|
||||
console.error(`❌ Error validating trade ${trade.symbol}:`, posError)
|
||||
// Don't auto-close on error - might be temporary
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in validateOpenTrades:', error)
|
||||
// Don't throw - allow Position Manager to start anyway
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +489,40 @@ export class PositionManager {
|
||||
// Position exists but size mismatch (partial close by TP1?)
|
||||
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 = (position.size * currentPrice) / trade.currentSize
|
||||
if (sizeRatio < 0.5) {
|
||||
console.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`)
|
||||
console.log(` Expected: $${trade.currentSize.toFixed(2)}`)
|
||||
console.log(` Actual: $${(position.size * currentPrice).toFixed(2)}`)
|
||||
|
||||
// Close as phantom trade
|
||||
try {
|
||||
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
||||
await updateTradeExit({
|
||||
positionId: trade.positionId,
|
||||
exitPrice: currentPrice,
|
||||
exitReason: 'manual',
|
||||
realizedPnL: 0,
|
||||
exitOrderTx: 'AUTO_CLEANUP',
|
||||
holdTimeSeconds,
|
||||
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
|
||||
maxGain: Math.max(0, trade.maxFavorableExcursion),
|
||||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||||
maxFavorablePrice: trade.maxFavorablePrice,
|
||||
maxAdversePrice: trade.maxAdversePrice,
|
||||
})
|
||||
console.log(`💾 Phantom trade closed`)
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to close phantom trade:', dbError)
|
||||
}
|
||||
|
||||
await this.removeTrade(trade.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Update current size to match reality (convert base asset size to USD using current price)
|
||||
trade.currentSize = position.size * currentPrice
|
||||
trade.tp1Hit = true
|
||||
|
||||
Reference in New Issue
Block a user