Fix database persistence and add analytics
- Fixed Prisma client not being available in Docker container - Added isTestTrade flag to exclude test trades from analytics - Created analytics views for net positions (matches Drift UI netting) - Added API endpoints: /api/analytics/positions and /api/analytics/stats - Added test trade endpoint: /api/trading/test-db - Updated Dockerfile to properly copy Prisma client from builder stage - Database now successfully stores all trades with full details - Supports position netting calculations to match Drift perpetuals behavior
This commit is contained in:
235
lib/database/views.ts
Normal file
235
lib/database/views.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Database Views and Analytics Queries
|
||||
*
|
||||
* Provides net position calculations and analytics that match what you see on Drift
|
||||
*/
|
||||
|
||||
import { getPrismaClient } from './trades'
|
||||
|
||||
/**
|
||||
* Get net positions across all open trades (matches Drift UI)
|
||||
*
|
||||
* NOTE: Drift perpetuals NET opposite positions in the same market.
|
||||
* If you have both LONG and SHORT positions in SOL-PERP, Drift shows only the net exposure.
|
||||
*/
|
||||
export async function getNetPositions() {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
const openTrades = await prisma.trade.findMany({
|
||||
where: {
|
||||
status: 'open',
|
||||
isTestTrade: false, // Exclude test trades
|
||||
},
|
||||
select: {
|
||||
symbol: true,
|
||||
direction: true,
|
||||
positionSizeUSD: true,
|
||||
entryPrice: true,
|
||||
leverage: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Group by symbol and calculate net positions
|
||||
const netBySymbol = new Map<string, {
|
||||
symbol: string
|
||||
longUSD: number
|
||||
shortUSD: number
|
||||
longSOL: number
|
||||
shortSOL: number
|
||||
netUSD: number
|
||||
netSOL: number
|
||||
netDirection: 'long' | 'short' | 'flat'
|
||||
avgLongEntry: number
|
||||
avgShortEntry: number
|
||||
tradeCount: number
|
||||
}>()
|
||||
|
||||
for (const trade of openTrades) {
|
||||
const existing = netBySymbol.get(trade.symbol) || {
|
||||
symbol: trade.symbol,
|
||||
longUSD: 0,
|
||||
shortUSD: 0,
|
||||
longSOL: 0,
|
||||
shortSOL: 0,
|
||||
netUSD: 0,
|
||||
netSOL: 0,
|
||||
netDirection: 'flat' as const,
|
||||
avgLongEntry: 0,
|
||||
avgShortEntry: 0,
|
||||
tradeCount: 0,
|
||||
}
|
||||
|
||||
const solSize = trade.positionSizeUSD / trade.entryPrice
|
||||
|
||||
if (trade.direction === 'long') {
|
||||
existing.longUSD += trade.positionSizeUSD
|
||||
existing.longSOL += solSize
|
||||
existing.avgLongEntry = existing.longUSD / existing.longSOL
|
||||
} else {
|
||||
existing.shortUSD += trade.positionSizeUSD
|
||||
existing.shortSOL += solSize
|
||||
existing.avgShortEntry = existing.shortUSD / existing.shortSOL
|
||||
}
|
||||
|
||||
existing.tradeCount++
|
||||
netBySymbol.set(trade.symbol, existing)
|
||||
}
|
||||
|
||||
// Calculate net exposure
|
||||
const results = []
|
||||
for (const [symbol, data] of netBySymbol) {
|
||||
data.netSOL = data.longSOL - data.shortSOL
|
||||
data.netUSD = data.longUSD - data.shortUSD
|
||||
|
||||
if (Math.abs(data.netSOL) < 0.001) {
|
||||
data.netDirection = 'flat'
|
||||
} else {
|
||||
data.netDirection = data.netSOL > 0 ? 'long' : 'short'
|
||||
}
|
||||
|
||||
results.push(data)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get individual trades with their contribution to net position
|
||||
*/
|
||||
export async function getTradesWithNetContext() {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
const trades = await prisma.trade.findMany({
|
||||
where: {
|
||||
status: 'open',
|
||||
isTestTrade: false,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
const netPositions = await getNetPositions()
|
||||
const netMap = new Map(netPositions.map(n => [n.symbol, n]))
|
||||
|
||||
return trades.map(trade => {
|
||||
const net = netMap.get(trade.symbol)
|
||||
const solSize = trade.positionSizeUSD / trade.entryPrice
|
||||
|
||||
return {
|
||||
...trade,
|
||||
solSize,
|
||||
netPosition: net ? {
|
||||
netSOL: net.netSOL,
|
||||
netUSD: net.netUSD,
|
||||
netDirection: net.netDirection,
|
||||
contributionPercent: Math.abs((solSize / net.netSOL) * 100),
|
||||
} : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trading statistics (excludes test trades)
|
||||
*/
|
||||
export async function getTradingStats(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, // Real trades only
|
||||
},
|
||||
})
|
||||
|
||||
const testTrades = await prisma.trade.count({
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
isTestTrade: true,
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
const profitFactor = avgLoss !== 0 ? avgWin / Math.abs(avgLoss) : 0
|
||||
|
||||
return {
|
||||
period: `Last ${days} days`,
|
||||
realTrades: {
|
||||
total: trades.length,
|
||||
winning: winning.length,
|
||||
losing: losing.length,
|
||||
winRate: `${winRate.toFixed(1)}%`,
|
||||
totalPnL: `$${totalPnL.toFixed(2)}`,
|
||||
avgWin: `$${avgWin.toFixed(2)}`,
|
||||
avgLoss: `$${avgLoss.toFixed(2)}`,
|
||||
profitFactor: profitFactor.toFixed(2),
|
||||
},
|
||||
testTrades: {
|
||||
count: testTrades,
|
||||
note: 'Excluded from statistics above',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position summary (what you see on Drift vs what's in database)
|
||||
*/
|
||||
export async function getPositionSummary() {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
const openTrades = await prisma.trade.count({
|
||||
where: { status: 'open', isTestTrade: false },
|
||||
})
|
||||
|
||||
const openTestTrades = await prisma.trade.count({
|
||||
where: { status: 'open', isTestTrade: true },
|
||||
})
|
||||
|
||||
const individualPositions = await prisma.trade.findMany({
|
||||
where: { status: 'open', isTestTrade: false },
|
||||
select: {
|
||||
symbol: true,
|
||||
direction: true,
|
||||
positionSizeUSD: true,
|
||||
entryPrice: true,
|
||||
},
|
||||
})
|
||||
|
||||
const totalIndividualUSD = individualPositions.reduce(
|
||||
(sum, t) => sum + t.positionSizeUSD, 0
|
||||
)
|
||||
|
||||
const netPositions = await getNetPositions()
|
||||
const totalNetUSD = netPositions.reduce(
|
||||
(sum, n) => sum + Math.abs(n.netUSD), 0
|
||||
)
|
||||
|
||||
return {
|
||||
summary: {
|
||||
individualTrades: openTrades,
|
||||
testTrades: openTestTrades,
|
||||
totalIndividualExposure: `$${totalIndividualUSD.toFixed(2)}`,
|
||||
netExposure: `$${totalNetUSD.toFixed(2)}`,
|
||||
explanation: 'Drift shows NET exposure (opposite positions cancel out)',
|
||||
},
|
||||
netPositions,
|
||||
individualPositions,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user