Implemented 3 critical risk checks in /api/trading/check-risk: 1. Daily Drawdown Check - Blocks trades if today's P&L < maxDailyDrawdown - Prevents catastrophic daily losses - Currently: -0 limit (configurable via MAX_DAILY_DRAWDOWN) 2. Hourly Trade Limit - Blocks trades if tradesInLastHour >= maxTradesPerHour - Prevents overtrading / algorithm malfunction - Currently: 20 trades/hour (configurable via MAX_TRADES_PER_HOUR) 3. Cooldown Period - Blocks trades if timeSinceLastTrade < minTimeBetweenTrades - Enforces breathing room between trades - Uses minutes (not seconds) thanks to previous commit - Currently: 0 min = disabled (configurable via MIN_TIME_BETWEEN_TRADES) Added database helper functions: - getLastTradeTime() - Returns timestamp of most recent trade - getTradesInLastHour() - Counts trades in last 60 minutes - getTodayPnL() - Sums realized P&L since midnight All checks include detailed logging with values and thresholds. Risk check called by n8n workflow before every trade execution.
428 lines
11 KiB
TypeScript
428 lines
11 KiB
TypeScript
/**
|
|
* Database Service for Trade Tracking and Analytics
|
|
*/
|
|
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
// Singleton Prisma client
|
|
let prisma: PrismaClient | null = null
|
|
|
|
export function getPrismaClient(): PrismaClient {
|
|
if (!prisma) {
|
|
prisma = new PrismaClient({
|
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
|
})
|
|
console.log('✅ Prisma client initialized')
|
|
}
|
|
return prisma
|
|
}
|
|
|
|
export interface CreateTradeParams {
|
|
positionId: string
|
|
symbol: string
|
|
direction: 'long' | 'short'
|
|
entryPrice: number
|
|
entrySlippage?: number
|
|
positionSizeUSD: number
|
|
leverage: number
|
|
stopLossPrice: number
|
|
softStopPrice?: number
|
|
hardStopPrice?: number
|
|
takeProfit1Price: number
|
|
takeProfit2Price: number
|
|
tp1SizePercent: number
|
|
tp2SizePercent: number
|
|
entryOrderTx: string
|
|
tp1OrderTx?: string
|
|
tp2OrderTx?: string
|
|
slOrderTx?: string
|
|
softStopOrderTx?: string
|
|
hardStopOrderTx?: string
|
|
configSnapshot: any
|
|
signalSource?: string
|
|
signalStrength?: string
|
|
timeframe?: string
|
|
isTestTrade?: boolean
|
|
// Market context fields
|
|
expectedEntryPrice?: number
|
|
fundingRateAtEntry?: number
|
|
atrAtEntry?: number
|
|
adxAtEntry?: number
|
|
volumeAtEntry?: number
|
|
}
|
|
|
|
export interface UpdateTradeStateParams {
|
|
positionId: string
|
|
currentSize: number
|
|
tp1Hit: boolean
|
|
slMovedToBreakeven: boolean
|
|
slMovedToProfit: boolean
|
|
stopLossPrice: number
|
|
realizedPnL: number
|
|
unrealizedPnL: number
|
|
peakPnL: number
|
|
lastPrice: number
|
|
maxFavorableExcursion?: number
|
|
maxAdverseExcursion?: number
|
|
maxFavorablePrice?: number
|
|
maxAdversePrice?: number
|
|
}
|
|
|
|
export interface UpdateTradeExitParams {
|
|
positionId: string
|
|
exitPrice: number
|
|
exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency'
|
|
realizedPnL: number
|
|
exitOrderTx: string
|
|
holdTimeSeconds: number
|
|
maxDrawdown?: number
|
|
maxGain?: number
|
|
// MAE/MFE final values
|
|
maxFavorableExcursion?: number
|
|
maxAdverseExcursion?: number
|
|
maxFavorablePrice?: number
|
|
maxAdversePrice?: number
|
|
}
|
|
|
|
export async function createTrade(params: CreateTradeParams) {
|
|
const prisma = getPrismaClient()
|
|
|
|
try {
|
|
// Calculate entry slippage if expected price provided
|
|
let entrySlippagePct: number | undefined
|
|
if (params.expectedEntryPrice && params.entrySlippage !== undefined) {
|
|
entrySlippagePct = params.entrySlippage
|
|
}
|
|
|
|
const trade = await prisma.trade.create({
|
|
data: {
|
|
positionId: params.positionId,
|
|
symbol: params.symbol,
|
|
direction: params.direction,
|
|
entryPrice: params.entryPrice,
|
|
entryTime: new Date(),
|
|
entrySlippage: params.entrySlippage,
|
|
positionSizeUSD: params.positionSizeUSD,
|
|
leverage: params.leverage,
|
|
stopLossPrice: params.stopLossPrice,
|
|
softStopPrice: params.softStopPrice,
|
|
hardStopPrice: params.hardStopPrice,
|
|
takeProfit1Price: params.takeProfit1Price,
|
|
takeProfit2Price: params.takeProfit2Price,
|
|
tp1SizePercent: params.tp1SizePercent,
|
|
tp2SizePercent: params.tp2SizePercent,
|
|
entryOrderTx: params.entryOrderTx,
|
|
tp1OrderTx: params.tp1OrderTx,
|
|
tp2OrderTx: params.tp2OrderTx,
|
|
slOrderTx: params.slOrderTx,
|
|
softStopOrderTx: params.softStopOrderTx,
|
|
hardStopOrderTx: params.hardStopOrderTx,
|
|
configSnapshot: params.configSnapshot,
|
|
signalSource: params.signalSource,
|
|
signalStrength: params.signalStrength,
|
|
timeframe: params.timeframe,
|
|
status: 'open',
|
|
isTestTrade: params.isTestTrade || false,
|
|
// Market context
|
|
expectedEntryPrice: params.expectedEntryPrice,
|
|
entrySlippagePct: entrySlippagePct,
|
|
fundingRateAtEntry: params.fundingRateAtEntry,
|
|
atrAtEntry: params.atrAtEntry,
|
|
adxAtEntry: params.adxAtEntry,
|
|
volumeAtEntry: params.volumeAtEntry,
|
|
},
|
|
})
|
|
|
|
console.log(`📊 Trade record created: ${trade.id}`)
|
|
return trade
|
|
} catch (error) {
|
|
console.error('❌ Failed to create trade record:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update trade when position exits
|
|
*/
|
|
export async function updateTradeExit(params: UpdateTradeExitParams) {
|
|
const prisma = getPrismaClient()
|
|
|
|
try {
|
|
// First fetch the trade to get positionSizeUSD
|
|
const existingTrade = await prisma.trade.findUnique({
|
|
where: { positionId: params.positionId },
|
|
select: { positionSizeUSD: true },
|
|
})
|
|
|
|
if (!existingTrade) {
|
|
throw new Error(`Trade not found: ${params.positionId}`)
|
|
}
|
|
|
|
const trade = await prisma.trade.update({
|
|
where: { positionId: params.positionId },
|
|
data: {
|
|
exitPrice: params.exitPrice,
|
|
exitTime: new Date(),
|
|
exitReason: params.exitReason,
|
|
realizedPnL: params.realizedPnL,
|
|
realizedPnLPercent: (params.realizedPnL / existingTrade.positionSizeUSD) * 100,
|
|
exitOrderTx: params.exitOrderTx,
|
|
holdTimeSeconds: params.holdTimeSeconds,
|
|
maxDrawdown: params.maxDrawdown,
|
|
maxGain: params.maxGain,
|
|
// Save final MAE/MFE values
|
|
maxFavorableExcursion: params.maxFavorableExcursion,
|
|
maxAdverseExcursion: params.maxAdverseExcursion,
|
|
maxFavorablePrice: params.maxFavorablePrice,
|
|
maxAdversePrice: params.maxAdversePrice,
|
|
status: 'closed',
|
|
},
|
|
})
|
|
|
|
console.log(`📊 Trade closed: ${trade.id} | P&L: $${params.realizedPnL.toFixed(2)}`)
|
|
return trade
|
|
} catch (error) {
|
|
console.error('❌ Failed to update trade exit:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update active trade state (for Position Manager persistence)
|
|
*/
|
|
export async function updateTradeState(params: UpdateTradeStateParams) {
|
|
const prisma = getPrismaClient()
|
|
|
|
try {
|
|
const trade = await prisma.trade.update({
|
|
where: { positionId: params.positionId },
|
|
data: {
|
|
// Store Position Manager state in configSnapshot
|
|
configSnapshot: {
|
|
...(await prisma.trade.findUnique({
|
|
where: { positionId: params.positionId },
|
|
select: { configSnapshot: true }
|
|
}))?.configSnapshot as any,
|
|
// Add Position Manager state
|
|
positionManagerState: {
|
|
currentSize: params.currentSize,
|
|
tp1Hit: params.tp1Hit,
|
|
slMovedToBreakeven: params.slMovedToBreakeven,
|
|
slMovedToProfit: params.slMovedToProfit,
|
|
stopLossPrice: params.stopLossPrice,
|
|
realizedPnL: params.realizedPnL,
|
|
unrealizedPnL: params.unrealizedPnL,
|
|
peakPnL: params.peakPnL,
|
|
lastPrice: params.lastPrice,
|
|
maxFavorableExcursion: params.maxFavorableExcursion,
|
|
maxAdverseExcursion: params.maxAdverseExcursion,
|
|
maxFavorablePrice: params.maxFavorablePrice,
|
|
maxAdversePrice: params.maxAdversePrice,
|
|
lastUpdate: new Date().toISOString(),
|
|
}
|
|
}
|
|
},
|
|
})
|
|
|
|
return trade
|
|
} catch (error) {
|
|
console.error('❌ Failed to update trade state:', error)
|
|
// Don't throw - state updates are non-critical
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all open trades (for Position Manager recovery)
|
|
*/
|
|
export async function getOpenTrades() {
|
|
const prisma = getPrismaClient()
|
|
|
|
try {
|
|
const trades = await prisma.trade.findMany({
|
|
where: { status: 'open' },
|
|
orderBy: { entryTime: 'asc' },
|
|
})
|
|
|
|
console.log(`📊 Found ${trades.length} open trades to restore`)
|
|
return trades
|
|
} catch (error) {
|
|
console.error('❌ Failed to get open trades:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the most recent trade entry time (for cooldown checking)
|
|
*/
|
|
export async function getLastTradeTime(): Promise<Date | null> {
|
|
const prisma = getPrismaClient()
|
|
|
|
try {
|
|
const lastTrade = await prisma.trade.findFirst({
|
|
orderBy: { entryTime: 'desc' },
|
|
select: { entryTime: true },
|
|
})
|
|
|
|
return lastTrade?.entryTime || null
|
|
} catch (error) {
|
|
console.error('❌ Failed to get last trade time:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get count of trades in the last hour
|
|
*/
|
|
export async function getTradesInLastHour(): Promise<number> {
|
|
const prisma = getPrismaClient()
|
|
|
|
try {
|
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
|
|
|
|
const count = await prisma.trade.count({
|
|
where: {
|
|
entryTime: {
|
|
gte: oneHourAgo,
|
|
},
|
|
},
|
|
})
|
|
|
|
return count
|
|
} catch (error) {
|
|
console.error('❌ Failed to get trades in last hour:', error)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get total P&L for today
|
|
*/
|
|
export async function getTodayPnL(): Promise<number> {
|
|
const prisma = getPrismaClient()
|
|
|
|
try {
|
|
const startOfDay = new Date()
|
|
startOfDay.setHours(0, 0, 0, 0)
|
|
|
|
const result = await prisma.trade.aggregate({
|
|
where: {
|
|
entryTime: {
|
|
gte: startOfDay,
|
|
},
|
|
status: 'closed',
|
|
},
|
|
_sum: {
|
|
realizedPnL: true,
|
|
},
|
|
})
|
|
|
|
return result._sum.realizedPnL || 0
|
|
} catch (error) {
|
|
console.error('❌ Failed to get today PnL:', error)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add price update for a trade (for tracking max gain/drawdown)
|
|
*/
|
|
export async function addPriceUpdate(
|
|
tradeId: string,
|
|
price: number,
|
|
pnl: number,
|
|
pnlPercent: number
|
|
) {
|
|
const prisma = getPrismaClient()
|
|
|
|
try {
|
|
await prisma.priceUpdate.create({
|
|
data: {
|
|
tradeId,
|
|
price,
|
|
pnl,
|
|
pnlPercent,
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('❌ Failed to add price update:', error)
|
|
// Don't throw - price updates are non-critical
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log system event
|
|
*/
|
|
export async function logSystemEvent(
|
|
eventType: string,
|
|
message: string,
|
|
details?: any
|
|
) {
|
|
const prisma = getPrismaClient()
|
|
|
|
try {
|
|
await prisma.systemEvent.create({
|
|
data: {
|
|
eventType,
|
|
message,
|
|
details: details ? JSON.parse(JSON.stringify(details)) : null,
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('❌ Failed to log system event:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get trade statistics
|
|
*/
|
|
export async function getTradeStats(days: number = 30) {
|
|
const prisma = getPrismaClient()
|
|
|
|
const since = new Date()
|
|
since.setDate(since.getDate() - days)
|
|
|
|
const trades = await prisma.trade.findMany({
|
|
where: {
|
|
createdAt: { gte: since },
|
|
status: 'closed',
|
|
isTestTrade: false, // Exclude test trades from stats
|
|
},
|
|
})
|
|
|
|
const winning = trades.filter((t) => (t.realizedPnL ?? 0) > 0)
|
|
const losing = trades.filter((t) => (t.realizedPnL ?? 0) < 0)
|
|
|
|
const totalPnL = trades.reduce((sum, t) => sum + (t.realizedPnL ?? 0), 0)
|
|
const winRate = trades.length > 0 ? (winning.length / trades.length) * 100 : 0
|
|
|
|
const avgWin = winning.length > 0
|
|
? winning.reduce((sum, t) => sum + (t.realizedPnL ?? 0), 0) / winning.length
|
|
: 0
|
|
|
|
const avgLoss = losing.length > 0
|
|
? losing.reduce((sum, t) => sum + (t.realizedPnL ?? 0), 0) / losing.length
|
|
: 0
|
|
|
|
return {
|
|
totalTrades: trades.length,
|
|
winningTrades: winning.length,
|
|
losingTrades: losing.length,
|
|
winRate: winRate.toFixed(2),
|
|
totalPnL: totalPnL.toFixed(2),
|
|
avgWin: avgWin.toFixed(2),
|
|
avgLoss: avgLoss.toFixed(2),
|
|
profitFactor: avgLoss !== 0 ? (avgWin / Math.abs(avgLoss)).toFixed(2) : 'N/A',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect Prisma client (for graceful shutdown)
|
|
*/
|
|
export async function disconnectPrisma() {
|
|
if (prisma) {
|
|
await prisma.$disconnect()
|
|
prisma = null
|
|
console.log('✅ Prisma client disconnected')
|
|
}
|
|
}
|