import { sendTelegramMessage } from '../data/telegramNotifier.js';
// Spread configuration
export function getSpread(symbol) {
switch (symbol) {
case 'BTCUSDT': return 25.0;
case 'ETHUSDT': return 5.0;
case 'XAUUSD': return 0.7;
case 'EURUSD':
case 'GBPUSD':
case 'USDCAD':
return 0.00007; // 0.7 pips
default:
return 0;
}
}
// Commission configuration
export function calculateCommission(symbol, entryPrice, lots) {
if (symbol === 'BTCUSDT' || symbol === 'ETHUSDT') {
// Crypto: 0.04% per trade = 0.08% round-turn commission
return 0.0008 * entryPrice * lots;
} else {
// Forex/Metals: $5 per lot round-turn commission
return 5.0 * lots;
}
}
export class TradeManager {
constructor({ onDailyLossUpdate, isServer = false, db = null }) {
this.isServer = isServer;
this.db = db;
this.onDailyLossUpdate = onDailyLossUpdate;
this.activeTrades = [];
this.tradeHistory = [];
this.dailyLosses = 0;
this.accountBalance = 5000.0;
this.lastExecutedCandleTime = {};
this.lastClosedTime = {};
this.lastResetDate = new Date().toDateString();
}
/**
* Add a trade from a signal
*/
async takeTrade(signal, candleTime = null) {
if (!this.isServer) {
try {
const response = await fetch('/api/take-trade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signal }),
});
const result = await response.json();
if (result.success && result.trade) {
console.log('[Client TradeManager] Trade submitted successfully to server:', result.trade);
if (globalThis.syncTerminalState) await globalThis.syncTerminalState();
return result.trade;
}
} catch (err) {
console.error('[Client TradeManager] Failed to submit trade to server:', err);
}
return null;
}
this._checkDailyReset();
// Exclude if already running an active trade on this symbol
const active = this.activeTrades.some(t => t.symbol === signal.symbol && t.status === 'active');
if (active) return null;
// Exclude if the symbol is in a 10-minute cooldown
if (this.isSymbolCoolingDown(signal.symbol)) {
console.warn(`[TradeManager] Cannot take trade on ${signal.symbol}: cooling down.`);
return null;
}
const spread = getSpread(signal.symbol);
const commission = calculateCommission(signal.symbol, signal.entry, signal.lotSize);
// Entry price is pre-adjusted for spread during signal generation
const entryPrice = signal.entry;
const trade = {
// Non-colliding dynamic ID prevents duplicate entries on rapid executions
id: Date.now() + Math.floor(Math.random() * 100000),
type: signal.type,
symbol: signal.symbol,
entry: entryPrice,
sl: signal.sl,
tp1: signal.tp1,
tp2: signal.tp2,
lotSize: signal.lotSize,
riskAmount: signal.riskAmount,
time: Date.now(),
// Initial current price: LONG trades exit at Bid (signal.entry), SHORT trades exit at Ask (signal.entry + spread)
currentPrice: signal.type === 'LONG' ? signal.entry : signal.entry + spread,
pnl: 0,
currentRR: 0,
commission: commission,
status: 'active',
slMoved: false,
quality: signal.quality || 'A',
initialRiskDist: Math.abs(signal.entry - signal.sl),
};
// Debit commission immediately from the closed balance
this.accountBalance -= commission;
this._saveAccountBalance();
this.activeTrades.push(trade);
this._saveActiveTrades();
// Record execution candle time to block same-candle re-entry
if (candleTime) {
this.lastExecutedCandleTime[signal.symbol] = candleTime;
this._saveLastExecutedCandleTime();
}
// Send Telegram Entry Notification
const decs = this._getDecimals(trade.symbol);
const confluencesHtml = signal.confluences && signal.confluences.length > 0
? signal.confluences.map(c => `⢠${c}`).join('\n')
: '⢠Smart Money Confluence Setup';
const msg = `šØ NEW AUTOPILOT TRADE EXECUTED\n\n` +
`Setup Quality: Grade ${trade.quality} Setup\n` +
`Symbol: ${trade.symbol}\n` +
`Direction: ${trade.type}\n` +
`Lot Size: ${trade.lotSize.toFixed(2)} lots\n` +
`Entry Price: $${trade.entry.toFixed(decs)}\n` +
`Stop Loss (SL): $${trade.sl.toFixed(decs)}\n` +
`Take Profit 1 (TP1): $${trade.tp1.toFixed(decs)}\n` +
`Take Profit 2 (TP2): $${trade.tp2.toFixed(decs)}\n\n` +
`š Risk Configuration:\n` +
`⢠Expected Risk: $${trade.riskAmount.toFixed(2)} (FundingPips Compliant)\n` +
`⢠Stop Loss Distance: ${Math.abs(trade.entry - trade.sl).toFixed(decs)} price units\n\n` +
`š” Trade Confluences Scanned:\n${confluencesHtml}`;
sendTelegramMessage(msg);
return trade;
}
/**
* Update all trades with current prices
*/
updatePrices(currentPrice, symbol) {
this._checkDailyReset();
const spread = getSpread(symbol);
for (const trade of this.activeTrades) {
if (trade.symbol !== symbol) continue;
if (trade.status !== 'active') continue; // Skip already closed trades in memory loop
// SHORT trades exit/value at Ask price (Bid + Spread)
const valuationPrice = trade.type === 'SHORT' ? currentPrice + spread : currentPrice;
trade.currentPrice = valuationPrice;
const direction = trade.type === 'LONG' ? 1 : -1;
const priceDiff = (valuationPrice - trade.entry) * direction;
const slDist = trade.initialRiskDist || Math.abs(trade.entry - trade.sl);
// Calculate total P&L (realized + remaining)
const remainingPnL = this._calculatePnL(trade, valuationPrice);
// Net of commission so per-trade P&L matches the actual balance impact.
trade.pnl = (trade.realizedPnL || 0) + remainingPnL - (trade.commission || 0);
// Guard against micro-stop division anomalies in older trades
let minThreshold = 0.00005; // half a pip for Forex
if (trade.symbol === 'BTCUSDT' || trade.symbol === 'ETHUSDT') {
minThreshold = 0.1; // 10 cents for crypto
} else if (trade.symbol === 'XAUUSD') {
minThreshold = 0.05; // 5 cents for gold
}
trade.currentRR = slDist >= minThreshold ? priceDiff / slDist : 0;
// Only the authoritative server engine opens/closes positions. The browser
// client is display-only (refreshed from the server every few seconds), so
// it must never close trades or mutate balance/history locally.
if (!this.isServer) continue;
// Check auto SL/TP hit or partial close at TP1. Closes fill at the EXACT
// SL/TP level (not an overshooting tick) so realized risk stays bounded.
if (trade.type === 'LONG') {
if (valuationPrice <= trade.sl) {
const reason = trade.partialClosed ? 'Trailing Stop' : 'SL Hit';
this._closeTrade(trade, reason, trade.sl);
} else if (valuationPrice >= trade.tp2) {
if (!trade.partialClosed) {
this._triggerPartialClose(trade, trade.tp1);
}
this._closeTrade(trade, 'TP2 Hit', trade.tp2);
} else if (valuationPrice >= trade.tp1 && !trade.partialClosed) {
this._triggerPartialClose(trade, trade.tp1);
}
} else {
if (valuationPrice >= trade.sl) {
const reason = trade.partialClosed ? 'Trailing Stop' : 'SL Hit';
this._closeTrade(trade, reason, trade.sl);
} else if (valuationPrice <= trade.tp2) {
if (!trade.partialClosed) {
this._triggerPartialClose(trade, trade.tp1);
}
this._closeTrade(trade, 'TP2 Hit', trade.tp2);
} else if (valuationPrice <= trade.tp1 && !trade.partialClosed) {
this._triggerPartialClose(trade, trade.tp1);
}
}
}
this._render();
}
/**
* Update exit analysis from the exit manager
*/
updateExitAnalysis(tradeId, analysis) {
const trade = this.activeTrades.find(t => t.id === tradeId);
if (!trade || !analysis || trade.status !== 'active') return; // Guard against updating closed trades
if (analysis.warnings && analysis.warnings.length > 0) {
trade.warnings = analysis.warnings;
} else {
trade.warnings = [];
}
if (analysis.suggestion) {
trade.suggestion = analysis.suggestion;
trade.suggestionText = analysis.reason;
} else {
trade.suggestion = null;
trade.suggestionText = null;
}
// Apply a trailed stop on the runner (ratchet only ā never loosen, never cross
// back to the loss side of breakeven). Authoritative on the server engine.
if (analysis.newSL != null && trade.partialClosed && this.isServer) {
const better = trade.type === 'LONG'
? (analysis.newSL > trade.sl && analysis.newSL >= trade.entry)
: (analysis.newSL < trade.sl && analysis.newSL <= trade.entry);
if (better) {
trade.sl = analysis.newSL;
trade.slMoved = true;
this._saveActiveTrades();
}
}
// Warnings/suggestions are ephemeral display state recomputed every tick, so
// just re-render ā no need to write them to the database on every tick.
this._render();
}
_generatePostMortem(trade) {
const decs = this._getDecimals(trade.symbol);
const lines = [];
// Factual invalidation summary ā no fabricated narrative.
lines.push(`⢠Invalidation: Price hit the Stop Loss at $${trade.sl.toFixed(decs)} (exit $${(trade.exitPrice ?? trade.sl).toFixed(decs)}), invalidating the ${trade.type} setup.`);
if (trade.partialClosed) {
const realized = trade.realizedPnL || 0;
lines.push(`⢠Partial Banked First: TP1 was reached ā 70% closed for +$${realized.toFixed(2)} before the runner was stopped, so this was not a full-risk loss.`);
}
// Real warning signals the exit analyser flagged before the stop (from analyzeExit).
if (Array.isArray(trade.warnings) && trade.warnings.length > 0) {
lines.push(`⢠Warning signs flagged before the stop:`);
for (const w of trade.warnings) lines.push(` ā ${w}`);
} else {
lines.push(`⢠No reversal warnings were flagged before the stop ā price simply traded to the predefined invalidation level.`);
}
return lines.join('\n');
}
_closeTrade(trade, reason, exitPriceOverride = null) {
if (trade.status === 'closed') return; // Double close safety lock guard
trade.status = 'closed';
trade.closeReason = reason;
trade.closeTime = Date.now();
// Fill at the exact SL/TP level when provided, so realized P&L matches the
// intended risk instead of an overshooting live tick.
trade.exitPrice = (exitPriceOverride !== null) ? exitPriceOverride : trade.currentPrice;
// Record close time to trigger 10-minute cooldown
this.lastClosedTime[trade.symbol] = Date.now();
this._saveLastClosedTime();
// Credit/debit ONLY the remaining portion P&L to balance
const remainingPnL = this._calculatePnL(trade, trade.exitPrice);
this.accountBalance += remainingPnL;
this._saveAccountBalance();
// Final P&L = realized partial + remaining portion, NET of commission (so stats,
// win/loss counts and the Telegram "Net Realized P&L" are actually net).
trade.pnl = (trade.realizedPnL || 0) + remainingPnL - (trade.commission || 0);
if (trade.pnl < 0) {
this.dailyLosses++;
this._saveDailyLosses();
if (this.onDailyLossUpdate) {
this.onDailyLossUpdate(this.dailyLosses);
}
}
this.tradeHistory.push({ ...trade });
this.activeTrades = this.activeTrades.filter(t => t.id !== trade.id);
this._saveActiveTrades();
this._saveTradeHistory();
// Send Telegram Exit Notification
const decs = this._getDecimals(trade.symbol);
const profitSign = trade.pnl >= 0 ? '+' : '';
let postMortemHtml = '';
if (reason.includes('SL Hit')) {
postMortemHtml = `\n\nš Smart Post-Mortem Audit (What went wrong?):\n` + this._generatePostMortem(trade);
}
const header = trade.pnl >= 0
? `š TRADE CLOSED (${reason.toUpperCase()})`
: `ā TRADE CLOSED (${reason.toUpperCase()})`;
const msg = `${header}\n\n` +
`Symbol: ${trade.symbol}\n` +
`Exit Price: $${trade.exitPrice.toFixed(decs)}\n` +
`Exit Reason: ${trade.closeReason}\n` +
`Net Realized P&L: ${profitSign}$${trade.pnl.toFixed(2)}\n\n` +
`š Account Update:\n` +
`⢠New Balance: $${this.accountBalance.toFixed(2)}\n` +
`⢠Status: Position fully liquidated. Cooldown period active for 10 minutes.${postMortemHtml}`;
sendTelegramMessage(msg);
this._render();
}
/**
* Manually close a trade
*/
async manualClose(tradeId) {
if (!this.isServer) {
try {
const response = await fetch('/api/manual-close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tradeId }),
});
const result = await response.json();
if (result.success) {
console.log(`[Client TradeManager] Close requested for trade ${tradeId}.`);
if (globalThis.syncTerminalState) await globalThis.syncTerminalState();
}
} catch (err) {
console.error(`[Client TradeManager] Failed to close trade ${tradeId}:`, err);
}
return;
}
const trade = this.activeTrades.find(t => t.id === tradeId);
if (trade) {
this._closeTrade(trade, 'Manual Close');
}
}
_calculatePnL(trade, currentPrice) {
return this._calculatePnLForLots(trade, currentPrice, trade.lotSize);
}
_calculatePnLForLots(trade, currentPrice, lotSize) {
const direction = trade.type === 'LONG' ? 1 : -1;
const diff = (currentPrice - trade.entry) * direction;
const sym = trade.symbol;
if (sym === 'BTCUSDT' || sym === 'ETHUSDT') {
return diff * lotSize;
} else if (sym === 'XAUUSD') {
return diff * lotSize * 100;
} else if (sym === 'EURUSD' || sym === 'GBPUSD') {
return (diff / 0.0001) * lotSize * 10;
} else if (sym === 'USDCAD') {
return (diff / 0.0001) * lotSize * (10 / currentPrice);
}
return 0;
}
_triggerPartialClose(trade, exitPrice) {
if (trade.partialClosed) return;
const partialLotSize = trade.lotSize * 0.7;
const partialPnL = this._calculatePnLForLots(trade, exitPrice, partialLotSize);
trade.realizedPnL = (trade.realizedPnL || 0) + partialPnL;
// Credit realized partial P&L to account balance
this.accountBalance += partialPnL;
this._saveAccountBalance();
// Reduce remaining lot size by 70% (leaving 30% active)
trade.lotSize = trade.lotSize * 0.3;
trade.partialClosed = true;
trade.partialExitPrice = exitPrice;
trade.partialExitTime = Date.now();
// Trail Stop Loss to Breakeven (entry price) to secure a risk-free trade
trade.sl = trade.entry;
trade.slMoved = true;
// Persist the reduced lot size + breakeven SL right away so a server restart
// between the partial and the final close keeps the correct state.
this._saveActiveTrades();
trade.suggestion = 'MOVE_SL';
trade.suggestionText = `TP1 reached ā 70% quantity closed at $${exitPrice.toFixed(this._getDecimals(trade.symbol))} (+$${partialPnL.toFixed(2)}). SL moved to Breakeven.`;
console.log(`[TradeManager] 70% Partial close triggered for ${trade.symbol} at $${exitPrice}: realized +$${partialPnL.toFixed(2)}. SL moved to Breakeven ($${trade.entry}).`);
// Send Telegram TP1 Partial Close Notification
const decs = this._getDecimals(trade.symbol);
const msg = `š° TP1 PARTIAL CLOSE REACHED\n\n` +
`Symbol: ${trade.symbol}\n` +
`Target Hit: TP1 reached at $${exitPrice.toFixed(decs)}\n` +
`Realized Profit: +$${partialPnL.toFixed(2)}\n\n` +
`š¦ Volume Realization Details:\n` +
`⢠Closed Quantity (70%): ${partialLotSize.toFixed(2)} lots\n` +
`⢠Remaining Quantity (30%): ${trade.lotSize.toFixed(2)} lots\n\n` +
`š”ļø Risk-Free Status Active:\n` +
`⢠Stop Loss has been automatically trailed to Breakeven ($${trade.entry.toFixed(decs)}).\n` +
`⢠Maximum risk on this position is now $0.00.`;
sendTelegramMessage(msg);
}
_render() {
if (this.isServer) return;
const list = document.getElementById('trade-list');
if (!list) return;
if (this.activeTrades.length === 0) {
list.innerHTML = `
No active trades