โ ะะฐะทะฐะด'use strict';
const prisma = require('./db');
const logger = require('../utils/logger');
const { getContractUnit } = require('./cache');
// โโโ Parse Binance option symbol โโโโโโโโโโโโโโโโโโโโโโโโ
// "BTC-260424-95000-C" โ { underlying: "BTC", expiry: "260424", strike: 95000, optionType: "CALL" }
function parseSymbol(symbol) {
const parts = symbol.split('-');
if (parts.length !== 4) return null;
return {
underlying: parts[0],
expiry: parts[1],
strike: parseFloat(parts[2]),
optionType: parts[3] === 'C' ? 'CALL' : 'PUT',
};
}
// โโโ Log a completed trade โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Called from tradeManager when a trade closes (TP hit, manual close, expired)
async function logTrade({
tradeId,
symbol,
side, // BUY (long option) or SELL (short/wrote)
quantity,
entryPrice,
exitPrice,
entryTime,
exitTime,
source = 'manual',
exitReason = null,
}) {
try {
const parsed = parseSymbol(symbol);
if (!parsed) {
logger.warn(`[TRADE_LOG] Cannot parse symbol: ${symbol}`);
return null;
}
const pnl = side === 'BUY'
? (exitPrice - entryPrice) * quantity
: (entryPrice - exitPrice) * quantity;
const roi = entryPrice > 0
? ((side === 'BUY' ? exitPrice - entryPrice : entryPrice - exitPrice) / entryPrice) * 100
: 0;
const holdMs = new Date(exitTime).getTime() - new Date(entryTime).getTime();
const holdMinutes = Math.round(holdMs / 60000);
const record = await prisma.tradeLog.upsert({
where: { tradeId },
update: {
exitPrice,
exitTime: new Date(exitTime),
pnl,
roi,
holdMinutes,
status: 'CLOSED',
exitReason: exitReason || undefined,
},
create: {
tradeId,
symbol,
underlying: parsed.underlying,
optionType: parsed.optionType,
strike: parsed.strike,
expiry: parsed.expiry,
side,
quantity,
entryPrice,
exitPrice,
entryTime: new Date(entryTime),
exitTime: new Date(exitTime),
pnl,
roi,
holdMinutes,
source,
status: 'CLOSED',
exitReason,
},
});
logger.info(`[TRADE_LOG] Logged: ${symbol} ${side} PnL=$${pnl.toFixed(4)} ROI=${roi.toFixed(1)}% Hold=${holdMinutes}min`);
return record;
} catch (err) {
// Silently handle dupes
if (err.code === 'P2002') {
logger.info(`[TRADE_LOG] Already logged: ${tradeId}`);
return null;
}
logger.error(`[TRADE_LOG] Error: ${err.message}`);
return null;
}
}
// โโโ Log an open trade (no exit yet) โโโโโโโโโโโโโโโโโโโโ
async function logOpenTrade({ tradeId, symbol, side, quantity, entryPrice, entryTime, source = 'manual' }) {
try {
const parsed = parseSymbol(symbol);
if (!parsed) return null;
// Check if already exists and CLOSED โ don't overwrite closed trades
const existing = await prisma.tradeLog.findUnique({ where: { tradeId } });
if (existing) {
if (existing.status === 'CLOSED') {
logger.info(`[TRADE_LOG] Skip open: ${symbol} already CLOSED`);
return existing;
}
// Already OPEN โ update qty/price if changed (e.g. partial fill)
const record = await prisma.tradeLog.update({
where: { tradeId },
data: { quantity, entryPrice, entryTime: new Date(entryTime) },
});
logger.info(`[TRADE_LOG] Open (updated): ${symbol} ${side} qty=${quantity} @ $${entryPrice}`);
return record;
}
const record = await prisma.tradeLog.create({
data: {
tradeId,
symbol,
underlying: parsed.underlying,
optionType: parsed.optionType,
strike: parsed.strike,
expiry: parsed.expiry,
side,
quantity,
entryPrice,
entryTime: new Date(entryTime),
source,
status: 'OPEN',
},
});
logger.info(`[TRADE_LOG] Open (new): ${symbol} ${side} qty=${quantity} @ $${entryPrice}`);
return record;
} catch (err) {
if (err.code === 'P2002') return null;
logger.error(`[TRADE_LOG] logOpen error: ${err.message}`);
return null;
}
}
// โโโ Close an existing open trade โโโโโโโโโโโโโโโโโโโโโโโ
async function closeTrade({ tradeId, exitPrice, exitTime, exitReason = null }) {
try {
const existing = await prisma.tradeLog.findUnique({ where: { tradeId } });
if (!existing || existing.status === 'CLOSED' || existing.status === 'EXPIRED') return null;
const pnl = existing.side === 'BUY'
? (exitPrice - existing.entryPrice) * existing.quantity
: (existing.entryPrice - exitPrice) * existing.quantity;
const roi = existing.entryPrice > 0
? ((existing.side === 'BUY' ? exitPrice - existing.entryPrice : existing.entryPrice - exitPrice) / existing.entryPrice) * 100
: 0;
const holdMs = new Date(exitTime).getTime() - existing.entryTime.getTime();
const holdMinutes = Math.round(holdMs / 60000);
const record = await prisma.tradeLog.update({
where: { tradeId },
data: {
exitPrice,
exitTime: new Date(exitTime),
pnl,
roi,
holdMinutes,
status: 'CLOSED',
exitReason: exitReason || undefined,
},
});
logger.info(`[TRADE_LOG] Closed: ${existing.symbol} PnL=$${pnl.toFixed(4)} ROI=${roi.toFixed(1)}%`);
return record;
} catch (err) {
logger.error(`[TRADE_LOG] closeTrade error: ${err.message}`);
return null;
}
}
// โโโ Get stats for period โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function getStats({ days, underlying } = {}) {
const where = { status: { in: ['CLOSED', 'EXPIRED'] } };
if (days) {
where.exitTime = { gte: new Date(Date.now() - days * 86400000) };
}
if (underlying && underlying !== 'ALL') {
where.underlying = underlying;
}
const trades = await prisma.tradeLog.findMany({
where,
orderBy: { exitTime: 'desc' },
});
if (trades.length === 0) {
return {
totalPnl: 0, winRate: 0, totalTrades: 0,
wins: 0, losses: 0, avgWin: 0, avgLoss: 0,
profitFactor: 0, bestTrade: null, worstTrade: null,
winStreak: 0, lossStreak: 0, currentStreak: 0,
currentStreakType: null, avgHoldMinutes: 0,
};
}
const wins = trades.filter(t => t.pnl > 0);
const losses = trades.filter(t => t.pnl <= 0);
const totalPnl = trades.reduce((s, t) => s + (t.pnl || 0), 0);
const totalWinPnl = wins.reduce((s, t) => s + t.pnl, 0);
const totalLossPnl = losses.reduce((s, t) => s + Math.abs(t.pnl), 0);
const avgWin = wins.length > 0 ? totalWinPnl / wins.length : 0;
const avgLoss = losses.length > 0 ? -(totalLossPnl / losses.length) : 0;
const profitFactor = totalLossPnl > 0 ? totalWinPnl / totalLossPnl : totalWinPnl > 0 ? Infinity : 0;
// Best/worst
const sorted = [...trades].sort((a, b) => (b.pnl || 0) - (a.pnl || 0));
const bestTrade = sorted[0] ? { symbol: sorted[0].symbol, pnl: sorted[0].pnl, roi: sorted[0].roi } : null;
const worstTrade = sorted[sorted.length - 1] ? { symbol: sorted[sorted.length - 1].symbol, pnl: sorted[sorted.length - 1].pnl, roi: sorted[sorted.length - 1].roi } : null;
// Streaks (from most recent)
let winStreak = 0, lossStreak = 0, maxWinStreak = 0, maxLossStreak = 0;
for (const t of trades) { // already ordered desc
if (t.pnl > 0) {
winStreak++;
lossStreak = 0;
} else {
lossStreak++;
winStreak = 0;
}
maxWinStreak = Math.max(maxWinStreak, winStreak);
maxLossStreak = Math.max(maxLossStreak, lossStreak);
}
// Current streak (from most recent trade)
let currentStreak = 0;
const currentStreakType = trades[0]?.pnl > 0 ? 'WIN' : 'LOSS';
for (const t of trades) {
const isWin = t.pnl > 0;
if ((currentStreakType === 'WIN' && isWin) || (currentStreakType === 'LOSS' && !isWin)) {
currentStreak++;
} else {
break;
}
}
const avgHoldMinutes = Math.round(trades.reduce((s, t) => s + (t.holdMinutes || 0), 0) / trades.length);
return {
totalPnl: Math.round(totalPnl * 10000) / 10000,
winRate: Math.round((wins.length / trades.length) * 1000) / 10,
totalTrades: trades.length,
wins: wins.length,
losses: losses.length,
avgWin: Math.round(avgWin * 10000) / 10000,
avgLoss: Math.round(avgLoss * 10000) / 10000,
profitFactor: profitFactor === Infinity ? 999 : Math.round(profitFactor * 100) / 100,
bestTrade,
worstTrade,
winStreak: maxWinStreak,
lossStreak: maxLossStreak,
currentStreak,
currentStreakType,
avgHoldMinutes,
};
}
// โโโ Get journal (paginated) โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function getJournal({ days, underlying, limit = 50, offset = 0 } = {}) {
const where = { status: { in: ['CLOSED', 'EXPIRED'] } };
if (days) {
where.exitTime = { gte: new Date(Date.now() - days * 86400000) };
}
if (underlying && underlying !== 'ALL') {
where.underlying = underlying;
}
const [trades, total] = await Promise.all([
prisma.tradeLog.findMany({
where,
orderBy: { exitTime: 'desc' },
take: limit,
skip: offset,
select: {
id: true,
symbol: true,
underlying: true,
optionType: true,
strike: true,
expiry: true,
side: true,
quantity: true,
entryPrice: true,
exitPrice: true,
entryTime: true,
exitTime: true,
pnl: true,
roi: true,
holdMinutes: true,
source: true,
status: true,
exitReason: true,
},
}),
prisma.tradeLog.count({ where }),
]);
return { trades, total, limit, offset };
}
// โโโ Sync from Binance eAPI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Fetches positions โ for each symbol fetches userTrades โ pairs BUY/SELL โ saves
// Also checks exercise history for expired options
async function syncFromBinance() {
const trading = require('./trading');
const results = { synced: 0, expired: 0, skipped: 0, errors: [] };
try {
// 1. Get symbols from open positions + OPEN trades in DB (closed positions disappear from API)
const positions = await trading.getPositions();
const posSymbols = (positions || []).map(p => p.symbol);
const openTrades = await prisma.tradeLog.findMany({
where: { status: 'OPEN' },
select: { symbol: true, tradeId: true, entryPrice: true, entryTime: true, quantity: true, side: true },
});
const dbSymbols = [...new Set(openTrades.map(t => t.symbol))];
const symbols = [...new Set([...posSymbols, ...dbSymbols])];
// 2. Check exercise history for expired options (OPEN trades not in positions)
const posSymbolSet = new Set(posSymbols);
const expiredSymbols = dbSymbols.filter(s => !posSymbolSet.has(s));
if (expiredSymbols.length > 0) {
logger.info(`[TRADE_LOG] Checking exercise history for ${expiredSymbols.length} possibly expired symbols`);
try {
// Fetch exercise history (last 30 days)
const exerciseHistory = await trading.getExerciseHistory({
startTime: Date.now() - 30 * 86400000,
limit: 100,
});
// Index by symbol
const exerciseMap = {};
for (const ex of (exerciseHistory || [])) {
if (!exerciseMap[ex.symbol]) exerciseMap[ex.symbol] = [];
exerciseMap[ex.symbol].push(ex);
}
// Close expired OPEN trades
for (const sym of expiredSymbols) {
const openForSym = openTrades.filter(t => t.symbol === sym);
const exercises = exerciseMap[sym] || [];
for (const trade of openForSym) {
let exitReason = null;
let exitPrice = 0; // intrinsic value per contract (what you receive back)
let exitTime = null;
const parsed = parseSymbol(sym);
if (exercises.length > 0 && parsed) {
// Found in exercise history โ calculate intrinsic value ourselves
const ex = exercises[0];
const settlementPrice = parseFloat(ex.realStrikePrice || 0);
const strike = parsed.strike;
const unit = getContractUnit(parsed.underlying);
exitTime = new Date(ex.expiryDate || Date.now());
// Calculate intrinsic value per contract
let intrinsic = 0;
if (parsed.optionType === 'CALL') {
intrinsic = Math.max(settlementPrice - strike, 0) * unit;
} else {
intrinsic = Math.max(strike - settlementPrice, 0) * unit;
}
if (intrinsic > 0) {
exitReason = 'EXERCISED';
exitPrice = intrinsic; // what you receive per contract
} else {
exitReason = 'EXPIRED_WORTHLESS';
exitPrice = 0;
}
logger.info(`[TRADE_LOG] Exercise calc: ${sym} settlement=$${settlementPrice} strike=$${strike} type=${parsed.optionType} unit=${unit} intrinsic=$${intrinsic.toFixed(4)}`);
} else if (parsed) {
// Not in exercise history โ check if expiry date has passed
const y = 2000 + parseInt(parsed.expiry.slice(0, 2));
const m = parseInt(parsed.expiry.slice(2, 4)) - 1;
const d = parseInt(parsed.expiry.slice(4, 6));
const expiryDate = new Date(y, m, d, 8, 0, 0); // 08:00 UTC expiry
if (expiryDate < new Date()) {
exitReason = 'EXPIRED_WORTHLESS';
exitPrice = 0;
exitTime = expiryDate;
}
}
if (exitReason && exitTime) {
// PnL: for BUY, profit = exitPrice - entryPrice; for SELL, reversed
const pnl = trade.side === 'BUY'
? (exitPrice - trade.entryPrice) * trade.quantity
: (trade.entryPrice - exitPrice) * trade.quantity;
const roi = trade.entryPrice > 0
? ((trade.side === 'BUY' ? exitPrice - trade.entryPrice : trade.entryPrice - exitPrice) / trade.entryPrice) * 100
: 0;
const holdMs = exitTime.getTime() - new Date(trade.entryTime).getTime();
const holdMinutes = Math.round(holdMs / 60000);
await prisma.tradeLog.update({
where: { tradeId: trade.tradeId },
data: {
exitPrice,
exitTime,
pnl,
roi,
holdMinutes,
status: exitReason === 'EXERCISED' ? 'CLOSED' : 'EXPIRED',
exitReason,
},
});
logger.info(`[TRADE_LOG] ${exitReason}: ${sym} exitPrice=$${exitPrice.toFixed(4)} PnL=$${pnl.toFixed(4)} ROI=${roi.toFixed(1)}%`);
results.expired++;
}
}
}
} catch (err) {
logger.warn(`[TRADE_LOG] Exercise history check failed: ${err.message}`);
results.errors.push(`exercise: ${err.message}`);
}
}
// 3. Sync active symbols via userTrades (existing logic)
if (symbols.length === 0 && expiredSymbols.length === 0) {
logger.info('[TRADE_LOG] Sync: no positions or open trades found');
return results;
}
const activeSymbols = symbols.filter(s => posSymbolSet.has(s) || !expiredSymbols.includes(s));
logger.info(`[TRADE_LOG] Sync: checking ${activeSymbols.length} active symbols (${posSymbols.length} positions)`);
for (const sym of activeSymbols) {
try {
const trades = await trading.getTradeHistory(sym, 100);
if (!trades || trades.length === 0) continue;
// Separate BUYs and SELLs, sorted by time (FIFO)
const buys = trades.filter(t => t.side === 'BUY').sort((a, b) => a.time - b.time);
const sells = trades.filter(t => t.side === 'SELL').sort((a, b) => a.time - b.time);
// Qty-aware FIFO matching: SELLs consume BUY quantities
let sellIdx = 0;
let sellRemaining = 0;
for (const buy of buys) {
const tradeId = `binance-${buy.tradeId || buy.id}`;
let buyQty = parseFloat(buy.quantity);
const entryPrice = parseFloat(buy.price);
const entryTime = new Date(buy.time);
let totalExitQty = 0;
let weightedExitPrice = 0;
let lastExitTime = null;
while (buyQty > 1e-10 && sellIdx < sells.length) {
if (sellRemaining <= 0) {
sellRemaining = parseFloat(sells[sellIdx].quantity);
}
const consumed = Math.min(buyQty, sellRemaining);
const sellPrice = parseFloat(sells[sellIdx].price);
totalExitQty += consumed;
weightedExitPrice += consumed * sellPrice;
lastExitTime = new Date(sells[sellIdx].time);
buyQty -= consumed;
sellRemaining -= consumed;
if (sellRemaining <= 1e-10) {
sellIdx++;
sellRemaining = 0;
}
}
if (totalExitQty > 1e-10) {
const avgExitPrice = weightedExitPrice / totalExitQty;
await logTrade({
tradeId,
symbol: sym,
side: 'BUY',
quantity: parseFloat(buy.quantity),
entryPrice,
exitPrice: avgExitPrice,
entryTime,
exitTime: lastExitTime,
source: 'sync',
exitReason: 'MANUAL_CLOSE',
});
results.synced++;
} else {
await logOpenTrade({
tradeId,
symbol: sym,
side: 'BUY',
quantity: parseFloat(buy.quantity),
entryPrice,
entryTime,
source: 'sync',
});
results.synced++;
}
}
for (let i = sellIdx; i < sells.length; i++) {
const sell = sells[i];
const tradeId = `binance-sell-${sell.tradeId || sell.id}`;
await logOpenTrade({
tradeId,
symbol: sym,
side: 'SELL',
quantity: parseFloat(sell.quantity),
entryPrice: parseFloat(sell.price),
entryTime: new Date(sell.time),
source: 'sync',
});
results.synced++;
}
} catch (err) {
results.errors.push(`${sym}: ${err.message}`);
}
}
logger.info(`[TRADE_LOG] Sync done: ${results.synced} synced, ${results.expired} expired, ${results.errors.length} errors`);
} catch (err) {
logger.error(`[TRADE_LOG] Sync failed: ${err.message}`);
results.errors.push(err.message);
}
return results;
}
module.exports = {
parseSymbol,
logTrade,
logOpenTrade,
closeTrade,
getStats,
getJournal,
syncFromBinance,
};