613 lines
31 KiB
JavaScript
613 lines
31 KiB
JavaScript
import { getSignalFunction } from '../indicators/index.js';
|
|
|
|
const STORAGE_KEY = 'ping_pong_settings';
|
|
|
|
function getSavedSettings() {
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
if (!saved) return null;
|
|
try {
|
|
return JSON.parse(saved);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const PingPongStrategy = {
|
|
id: 'ping_pong',
|
|
name: 'Ping-Pong',
|
|
|
|
saveSettings: function() {
|
|
const settings = {
|
|
startDate: document.getElementById('simStartDate').value,
|
|
stopDate: document.getElementById('simStopDate').value,
|
|
contractType: document.getElementById('simContractType').value,
|
|
direction: document.getElementById('simDirection').value,
|
|
autoDirection: document.getElementById('simAutoDirection').checked,
|
|
capital: document.getElementById('simCapital').value,
|
|
exchangeLeverage: document.getElementById('simExchangeLeverage').value,
|
|
maxEffectiveLeverage: document.getElementById('simMaxEffectiveLeverage').value,
|
|
posSize: document.getElementById('simPosSize').value,
|
|
tp: document.getElementById('simTP').value
|
|
};
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
|
|
const btn = document.getElementById('saveSimSettings');
|
|
if (btn) {
|
|
const originalText = btn.textContent;
|
|
btn.textContent = 'Saved!';
|
|
btn.style.color = '#26a69a';
|
|
setTimeout(() => {
|
|
btn.textContent = originalText;
|
|
btn.style.color = '';
|
|
}, 2000);
|
|
}
|
|
},
|
|
|
|
renderUI: function(activeIndicators, formatDisplayDate) {
|
|
const saved = getSavedSettings();
|
|
|
|
// Format initial values for display
|
|
let startDisplay = saved?.startDate || '01/01/2026 00:00';
|
|
let stopDisplay = saved?.stopDate || '';
|
|
|
|
if (startDisplay.includes('T')) {
|
|
startDisplay = formatDisplayDate(new Date(startDisplay));
|
|
}
|
|
if (stopDisplay.includes('T')) {
|
|
stopDisplay = formatDisplayDate(new Date(stopDisplay));
|
|
}
|
|
|
|
const renderIndicatorChecklist = (prefix) => {
|
|
if (activeIndicators.length === 0) {
|
|
return '<div style="padding: 8px; color: var(--tv-text-secondary); font-size: 11px;">No active indicators on chart</div>';
|
|
}
|
|
|
|
return activeIndicators.map(ind => `
|
|
<label class="checklist-item">
|
|
<input type="checkbox" data-id="${ind.id}" class="sim-${prefix}-check">
|
|
<span>${ind.name}</span>
|
|
</label>
|
|
`).join('');
|
|
};
|
|
|
|
const autoDirChecked = saved?.autoDirection === true;
|
|
const disableManualStr = autoDirChecked ? 'disabled' : '';
|
|
|
|
return `
|
|
<div class="sim-input-group">
|
|
<label>Start Date & Time</label>
|
|
<input type="text" id="simStartDate" class="sim-input" value="${startDisplay}" placeholder="DD/MM/YYYY HH:MM">
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<label>Stop Date & Time (Optional)</label>
|
|
<input type="text" id="simStopDate" class="sim-input" value="${stopDisplay}" placeholder="DD/MM/YYYY HH:MM">
|
|
</div>
|
|
|
|
<div class="sim-input-group" style="background: rgba(38, 166, 154, 0.1); padding: 8px; border-radius: 4px; border: 1px solid rgba(38, 166, 154, 0.2);">
|
|
<label class="checklist-item" style="margin-bottom: 0;">
|
|
<input type="checkbox" id="simAutoDirection" ${autoDirChecked ? 'checked' : ''}>
|
|
<span style="color: #26a69a; font-weight: bold;">Auto-Detect Direction (1D MA44)</span>
|
|
</label>
|
|
<div style="font-size: 10px; color: var(--tv-text-secondary); margin-left: 24px; margin-top: 4px;">
|
|
Price > MA44: LONG (Inverse/BTC Margin)<br>
|
|
Price < MA44: SHORT (Linear/USDT Margin)
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<label>Contract Type (Manual)</label>
|
|
<select id="simContractType" class="sim-input" ${disableManualStr}>
|
|
<option value="linear" ${saved?.contractType === 'linear' ? 'selected' : ''}>Linear (USDT-Margined)</option>
|
|
<option value="inverse" ${saved?.contractType === 'inverse' ? 'selected' : ''}>Inverse (Coin-Margined)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<label>Direction (Manual)</label>
|
|
<select id="simDirection" class="sim-input" ${disableManualStr}>
|
|
<option value="long" ${saved?.direction === 'long' ? 'selected' : ''}>Long</option>
|
|
<option value="short" ${saved?.direction === 'short' ? 'selected' : ''}>Short</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<label>Initial Capital ($)</label>
|
|
<input type="number" id="simCapital" class="sim-input" value="${saved?.capital || '10000'}" min="1">
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<label>Exchange Leverage (Ping Size Multiplier)</label>
|
|
<input type="number" id="simExchangeLeverage" class="sim-input" value="${saved?.exchangeLeverage || '1'}" min="1" max="100">
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<label>Max Effective Leverage (Total Account Cap)</label>
|
|
<input type="number" id="simMaxEffectiveLeverage" class="sim-input" value="${saved?.maxEffectiveLeverage || '5'}" min="1" max="100">
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<label>Position Size ($ Margin per Ping)</label>
|
|
<input type="number" id="simPosSize" class="sim-input" value="${saved?.posSize || '10'}" min="1">
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<label>Take Profit (%)</label>
|
|
<input type="number" id="simTP" class="sim-input" value="${saved?.tp || '15'}" step="0.1" min="0.1">
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
|
<label style="margin-bottom: 0;">Open Signal Indicators</label>
|
|
<button class="action-btn-text" id="saveSimSettings" style="font-size: 10px; color: #00bcd4; background: none; border: none; cursor: pointer; padding: 0;">Save Defaults</button>
|
|
</div>
|
|
<div class="indicator-checklist" id="openSignalsList">
|
|
${renderIndicatorChecklist('open')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sim-input-group">
|
|
<label>Close Signal Indicators (Empty = Accumulation)</label>
|
|
<div class="indicator-checklist" id="closeSignalsList">
|
|
${renderIndicatorChecklist('close')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
attachListeners: function() {
|
|
const autoCheck = document.getElementById('simAutoDirection');
|
|
const contractSelect = document.getElementById('simContractType');
|
|
const dirSelect = document.getElementById('simDirection');
|
|
|
|
if (autoCheck) {
|
|
autoCheck.addEventListener('change', (e) => {
|
|
const isAuto = e.target.checked;
|
|
contractSelect.disabled = isAuto;
|
|
dirSelect.disabled = isAuto;
|
|
});
|
|
}
|
|
|
|
const saveBtn = document.getElementById('saveSimSettings');
|
|
if (saveBtn) saveBtn.addEventListener('click', this.saveSettings.bind(this));
|
|
},
|
|
|
|
runSimulation: async function(activeIndicators, displayResultsCallback) {
|
|
const btn = document.getElementById('runSimulationBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Preparing Data...';
|
|
|
|
try {
|
|
const startVal = document.getElementById('simStartDate').value;
|
|
const stopVal = document.getElementById('simStopDate').value;
|
|
|
|
const config = {
|
|
startDate: new Date(startVal).getTime() / 1000,
|
|
stopDate: stopVal ? new Date(stopVal).getTime() / 1000 : Math.floor(Date.now() / 1000),
|
|
autoDirection: document.getElementById('simAutoDirection').checked,
|
|
contractType: document.getElementById('simContractType').value,
|
|
direction: document.getElementById('simDirection').value,
|
|
capital: parseFloat(document.getElementById('simCapital').value),
|
|
exchangeLeverage: parseFloat(document.getElementById('simExchangeLeverage').value),
|
|
maxEffectiveLeverage: parseFloat(document.getElementById('simMaxEffectiveLeverage').value),
|
|
posSize: parseFloat(document.getElementById('simPosSize').value),
|
|
tp: parseFloat(document.getElementById('simTP').value) / 100,
|
|
openIndicators: Array.from(document.querySelectorAll('.sim-open-check:checked')).map(el => el.dataset.id),
|
|
closeIndicators: Array.from(document.querySelectorAll('.sim-close-check:checked')).map(el => el.dataset.id)
|
|
};
|
|
|
|
if (config.openIndicators.length === 0) {
|
|
alert('Please choose at least one indicator for opening positions.');
|
|
return;
|
|
}
|
|
|
|
const interval = window.dashboard?.currentInterval || '1d';
|
|
|
|
// 1. Ensure data is loaded for the range
|
|
let allCandles = window.dashboard?.allData?.get(interval) || [];
|
|
|
|
const earliestInCache = allCandles.length > 0 ? allCandles[0].time : Infinity;
|
|
const latestInCache = allCandles.length > 0 ? allCandles[allCandles.length - 1].time : -Infinity;
|
|
|
|
if (config.startDate < earliestInCache || config.stopDate > latestInCache) {
|
|
btn.textContent = 'Fetching from Server...';
|
|
|
|
let currentEndISO = new Date(config.stopDate * 1000).toISOString();
|
|
const startISO = new Date(config.startDate * 1000).toISOString();
|
|
let keepFetching = true;
|
|
let newCandlesAdded = false;
|
|
|
|
while (keepFetching) {
|
|
const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${interval}&start=${startISO}&end=${currentEndISO}&limit=10000`);
|
|
const data = await response.json();
|
|
|
|
if (data.candles && data.candles.length > 0) {
|
|
const fetchedCandles = data.candles.reverse().map(c => ({
|
|
time: Math.floor(new Date(c.time).getTime() / 1000),
|
|
open: parseFloat(c.open),
|
|
high: parseFloat(c.high),
|
|
low: parseFloat(c.low),
|
|
close: parseFloat(c.close),
|
|
volume: parseFloat(c.volume || 0)
|
|
}));
|
|
|
|
allCandles = window.dashboard.mergeData(allCandles, fetchedCandles);
|
|
newCandlesAdded = true;
|
|
|
|
// If we received 10000 candles, there might be more. We fetch again using the oldest candle's time - 1s
|
|
if (data.candles.length === 10000) {
|
|
const oldestTime = fetchedCandles[0].time;
|
|
if (oldestTime <= config.startDate) {
|
|
keepFetching = false;
|
|
} else {
|
|
currentEndISO = new Date((oldestTime - 1) * 1000).toISOString();
|
|
btn.textContent = `Fetching older data... (${new Date(oldestTime * 1000).toLocaleDateString()})`;
|
|
}
|
|
} else {
|
|
keepFetching = false;
|
|
}
|
|
} else {
|
|
keepFetching = false;
|
|
}
|
|
}
|
|
|
|
if (newCandlesAdded) {
|
|
window.dashboard.allData.set(interval, allCandles);
|
|
window.dashboard.candleSeries.setData(allCandles);
|
|
|
|
btn.textContent = 'Calculating Indicators...';
|
|
window.drawIndicatorsOnChart?.();
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
}
|
|
|
|
// --- Auto-Direction: Fetch 1D candles for MA(44) ---
|
|
let dailyCandles = [];
|
|
let dailyMaMap = new Map(); // timestamp (midnight UTC) -> MA44 value
|
|
|
|
if (config.autoDirection) {
|
|
btn.textContent = 'Fetching 1D MA(44)...';
|
|
// Fetch 1D candles starting 45 days BEFORE the simulation start date to warm up the MA
|
|
const msPerDay = 24 * 60 * 60 * 1000;
|
|
const dailyStartISO = new Date((config.startDate * 1000) - (45 * msPerDay)).toISOString();
|
|
const stopISO = new Date(config.stopDate * 1000).toISOString();
|
|
|
|
const dailyResponse = await fetch(`/api/v1/candles?symbol=BTC&interval=1d&start=${dailyStartISO}&end=${stopISO}&limit=5000`);
|
|
const dailyData = await dailyResponse.json();
|
|
|
|
if (dailyData.candles && dailyData.candles.length > 0) {
|
|
dailyCandles = dailyData.candles.reverse().map(c => ({
|
|
time: Math.floor(new Date(c.time).getTime() / 1000),
|
|
close: parseFloat(c.close)
|
|
}));
|
|
|
|
// Calculate MA(44)
|
|
const maPeriod = 44;
|
|
for (let i = maPeriod - 1; i < dailyCandles.length; i++) {
|
|
let sum = 0;
|
|
for (let j = 0; j < maPeriod; j++) {
|
|
sum += dailyCandles[i - j].close;
|
|
}
|
|
const maValue = sum / maPeriod;
|
|
// Store the MA value using the midnight UTC timestamp of that day
|
|
dailyMaMap.set(dailyCandles[i].time, maValue);
|
|
}
|
|
} else {
|
|
console.warn('[Simulation] Failed to fetch 1D candles for Auto-Direction. Falling back to manual.');
|
|
config.autoDirection = false;
|
|
}
|
|
}
|
|
// --------------------------------------------------
|
|
|
|
btn.textContent = 'Simulating...';
|
|
|
|
// Filter candles by the exact range
|
|
const simCandles = allCandles.filter(c => c.time >= config.startDate && c.time <= config.stopDate);
|
|
|
|
if (simCandles.length === 0) {
|
|
alert('No data available for the selected range.');
|
|
return;
|
|
}
|
|
|
|
// Calculate indicator signals
|
|
const indicatorSignals = {};
|
|
for (const indId of [...new Set([...config.openIndicators, ...config.closeIndicators])]) {
|
|
const ind = activeIndicators.find(a => a.id === indId);
|
|
if (!ind) continue;
|
|
|
|
const signalFunc = getSignalFunction(ind.type);
|
|
const results = ind.cachedResults;
|
|
|
|
if (results && signalFunc) {
|
|
indicatorSignals[indId] = simCandles.map(candle => {
|
|
const idx = allCandles.findIndex(c => c.time === candle.time);
|
|
if (idx < 1) return null;
|
|
const values = typeof results[idx] === 'object' && results[idx] !== null ? results[idx] : { ma: results[idx] };
|
|
const prevValues = typeof results[idx-1] === 'object' && results[idx-1] !== null ? results[idx-1] : { ma: results[idx-1] };
|
|
return signalFunc(ind, allCandles[idx], allCandles[idx-1], values, prevValues);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Simulation Initial State
|
|
const startPrice = simCandles[0].open;
|
|
|
|
// We maintain a single "walletBalanceUsd" variable as the source of truth for the account size
|
|
let walletBalanceUsd = config.capital;
|
|
|
|
// At any given time, the active margin type determines how we use this balance
|
|
// When LONG (Inverse), we theoretically buy BTC with it.
|
|
// When SHORT (Linear), we just use it as USDT.
|
|
|
|
// Set initial state based on auto or manual
|
|
if (config.autoDirection && dailyMaMap.size > 0) {
|
|
// Find the MA value for the day before start date
|
|
const simStartDayTime = Math.floor(simCandles[0].time / 86400) * 86400; // Midnight UTC
|
|
let closestMA = Array.from(dailyMaMap.entries())
|
|
.filter(([t]) => t <= simStartDayTime)
|
|
.sort((a,b) => b[0] - a[0])[0];
|
|
|
|
if (closestMA) {
|
|
const price = simCandles[0].open;
|
|
if (price > closestMA[1]) {
|
|
config.direction = 'long';
|
|
config.contractType = 'inverse';
|
|
} else {
|
|
config.direction = 'short';
|
|
config.contractType = 'linear';
|
|
}
|
|
}
|
|
}
|
|
|
|
let equityData = { usd: [], btc: [] };
|
|
let totalQty = 0; // Linear: BTC Contracts, Inverse: USD Contracts
|
|
let avgPrice = 0;
|
|
let avgPriceData = [];
|
|
let posSizeData = { btc: [], usd: [] };
|
|
let trades = [];
|
|
|
|
let currentDayStart = Math.floor(simCandles[0].time / 86400) * 86400;
|
|
|
|
const PARTIAL_EXIT_PCT = 0.15;
|
|
const MIN_POSITION_VALUE_USD = 15;
|
|
|
|
for (let i = 0; i < simCandles.length; i++) {
|
|
const candle = simCandles[i];
|
|
const price = candle.close;
|
|
let actionTakenInThisCandle = false;
|
|
|
|
// --- Auto-Direction Daily Check (Midnight UTC) ---
|
|
if (config.autoDirection) {
|
|
const candleDayStart = Math.floor(candle.time / 86400) * 86400;
|
|
if (candleDayStart > currentDayStart) {
|
|
currentDayStart = candleDayStart;
|
|
// It's a new day! Get yesterday's MA(44)
|
|
let closestMA = Array.from(dailyMaMap.entries())
|
|
.filter(([t]) => t < currentDayStart)
|
|
.sort((a,b) => b[0] - a[0])[0];
|
|
|
|
if (closestMA) {
|
|
const maValue = closestMA[1];
|
|
let newDirection = config.direction;
|
|
let newContractType = config.contractType;
|
|
|
|
if (candle.open > maValue) {
|
|
newDirection = 'long';
|
|
newContractType = 'inverse';
|
|
} else {
|
|
newDirection = 'short';
|
|
newContractType = 'linear';
|
|
}
|
|
|
|
// Did the trend flip?
|
|
if (newDirection !== config.direction) {
|
|
// Force close open position at candle.open (market open)
|
|
if (totalQty > 0) {
|
|
let pnlUsd = 0;
|
|
if (config.contractType === 'linear') {
|
|
pnlUsd = config.direction === 'long' ? (candle.open - avgPrice) * totalQty : (avgPrice - candle.open) * totalQty;
|
|
walletBalanceUsd += pnlUsd;
|
|
} else { // inverse
|
|
// PnL in BTC, converted back to USD
|
|
const pnlBtc = config.direction === 'long'
|
|
? totalQty * (1/avgPrice - 1/candle.open)
|
|
: totalQty * (1/candle.open - 1/avgPrice);
|
|
// Inverse margin is BTC, so balance was in BTC.
|
|
// But we maintain walletBalanceUsd, so we just add the USD value of the PNL
|
|
pnlUsd = pnlBtc * candle.open;
|
|
walletBalanceUsd += pnlUsd;
|
|
}
|
|
|
|
trades.push({
|
|
type: config.direction, recordType: 'exit', time: candle.time,
|
|
entryPrice: avgPrice, exitPrice: candle.open, pnl: pnlUsd, reason: 'Force Close (Trend Flip)',
|
|
currentUsd: 0, currentQty: 0
|
|
});
|
|
totalQty = 0;
|
|
avgPrice = 0;
|
|
}
|
|
|
|
// Apply flip
|
|
config.direction = newDirection;
|
|
config.contractType = newContractType;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// ------------------------------------------------
|
|
|
|
// 1. Check TP
|
|
if (totalQty > 0) {
|
|
let isTP = false;
|
|
let exitPrice = price;
|
|
if (config.direction === 'long') {
|
|
if (candle.high >= avgPrice * (1 + config.tp)) {
|
|
isTP = true;
|
|
exitPrice = avgPrice * (1 + config.tp);
|
|
}
|
|
} else {
|
|
if (candle.low <= avgPrice * (1 - config.tp)) {
|
|
isTP = true;
|
|
exitPrice = avgPrice * (1 - config.tp);
|
|
}
|
|
}
|
|
|
|
if (isTP) {
|
|
let qtyToClose = totalQty * PARTIAL_EXIT_PCT;
|
|
let remainingQty = totalQty - qtyToClose;
|
|
let remainingValueUsd = config.contractType === 'linear' ? remainingQty * exitPrice : remainingQty;
|
|
let reason = 'TP (Partial)';
|
|
|
|
if (remainingValueUsd < MIN_POSITION_VALUE_USD) {
|
|
qtyToClose = totalQty;
|
|
reason = 'TP (Full - Min Size)';
|
|
}
|
|
|
|
let pnlUsd;
|
|
if (config.contractType === 'linear') {
|
|
pnlUsd = config.direction === 'long' ? (exitPrice - avgPrice) * qtyToClose : (avgPrice - exitPrice) * qtyToClose;
|
|
walletBalanceUsd += pnlUsd;
|
|
} else {
|
|
const pnlBtc = config.direction === 'long'
|
|
? qtyToClose * (1/avgPrice - 1/exitPrice)
|
|
: qtyToClose * (1/exitPrice - 1/avgPrice);
|
|
pnlUsd = pnlBtc * exitPrice;
|
|
walletBalanceUsd += pnlUsd;
|
|
}
|
|
|
|
totalQty -= qtyToClose;
|
|
trades.push({
|
|
type: config.direction, recordType: 'exit', time: candle.time,
|
|
entryPrice: avgPrice, exitPrice: exitPrice, pnl: pnlUsd, reason: reason,
|
|
currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty,
|
|
currentQty: config.contractType === 'linear' ? totalQty : totalQty / price
|
|
});
|
|
actionTakenInThisCandle = true;
|
|
}
|
|
}
|
|
|
|
// 2. Check Close Signals
|
|
if (!actionTakenInThisCandle && totalQty > 0 && config.closeIndicators.length > 0) {
|
|
const hasCloseSignal = config.closeIndicators.some(id => {
|
|
const sig = indicatorSignals[id][i];
|
|
if (!sig) return false;
|
|
return config.direction === 'long' ? sig.type === 'sell' : sig.type === 'buy';
|
|
});
|
|
|
|
if (hasCloseSignal) {
|
|
let qtyToClose = totalQty * PARTIAL_EXIT_PCT;
|
|
let remainingQty = totalQty - qtyToClose;
|
|
let remainingValueUsd = config.contractType === 'linear' ? remainingQty * price : remainingQty;
|
|
let reason = 'Signal (Partial)';
|
|
|
|
if (remainingValueUsd < MIN_POSITION_VALUE_USD) {
|
|
qtyToClose = totalQty;
|
|
reason = 'Signal (Full - Min Size)';
|
|
}
|
|
|
|
let pnlUsd;
|
|
if (config.contractType === 'linear') {
|
|
pnlUsd = config.direction === 'long' ? (price - avgPrice) * qtyToClose : (avgPrice - price) * qtyToClose;
|
|
walletBalanceUsd += pnlUsd;
|
|
} else {
|
|
const pnlBtc = config.direction === 'long'
|
|
? qtyToClose * (1/avgPrice - 1/price)
|
|
: qtyToClose * (1/price - 1/avgPrice);
|
|
pnlUsd = pnlBtc * price;
|
|
walletBalanceUsd += pnlUsd;
|
|
}
|
|
|
|
totalQty -= qtyToClose;
|
|
trades.push({
|
|
type: config.direction, recordType: 'exit', time: candle.time,
|
|
entryPrice: avgPrice, exitPrice: price, pnl: pnlUsd, reason: reason,
|
|
currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty,
|
|
currentQty: config.contractType === 'linear' ? totalQty : totalQty / price
|
|
});
|
|
actionTakenInThisCandle = true;
|
|
}
|
|
}
|
|
|
|
// Calculate Current Equity for Margin Check
|
|
let currentEquityUsd = walletBalanceUsd;
|
|
if (totalQty > 0) {
|
|
if (config.contractType === 'linear') {
|
|
currentEquityUsd += config.direction === 'long' ? (price - avgPrice) * totalQty : (avgPrice - price) * totalQty;
|
|
} else {
|
|
const upnlBtc = config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice);
|
|
currentEquityUsd += (upnlBtc * price);
|
|
}
|
|
}
|
|
|
|
// 3. Check Open Signals
|
|
if (!actionTakenInThisCandle) {
|
|
const hasOpenSignal = config.openIndicators.some(id => {
|
|
const sig = indicatorSignals[id][i];
|
|
if (!sig) return false;
|
|
return config.direction === 'long' ? sig.type === 'buy' : sig.type === 'sell';
|
|
});
|
|
|
|
if (hasOpenSignal) {
|
|
const entryValUsd = config.posSize * config.exchangeLeverage;
|
|
const currentNotionalUsd = config.contractType === 'linear' ? totalQty * price : totalQty;
|
|
|
|
const projectedEffectiveLeverage = (currentNotionalUsd + entryValUsd) / Math.max(currentEquityUsd, 0.0000001);
|
|
|
|
if (projectedEffectiveLeverage <= config.maxEffectiveLeverage) {
|
|
if (config.contractType === 'linear') {
|
|
const entryQty = entryValUsd / price;
|
|
avgPrice = ((totalQty * avgPrice) + (entryQty * price)) / (totalQty + entryQty);
|
|
totalQty += entryQty;
|
|
} else {
|
|
avgPrice = (totalQty + entryValUsd) / ((totalQty / avgPrice || 0) + (entryValUsd / price));
|
|
totalQty += entryValUsd;
|
|
}
|
|
|
|
trades.push({
|
|
type: config.direction, recordType: 'entry', time: candle.time,
|
|
entryPrice: price, reason: 'Entry',
|
|
currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty,
|
|
currentQty: config.contractType === 'linear' ? totalQty : totalQty / price
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final Equity Recording
|
|
let finalEquityUsd = walletBalanceUsd;
|
|
if (totalQty > 0) {
|
|
if (config.contractType === 'linear') {
|
|
finalEquityUsd += config.direction === 'long' ? (price - avgPrice) * totalQty : (avgPrice - price) * totalQty;
|
|
} else {
|
|
const upnlBtc = config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice);
|
|
finalEquityUsd += (upnlBtc * price);
|
|
}
|
|
}
|
|
let finalEquityBtc = finalEquityUsd / price;
|
|
|
|
equityData.usd.push({ time: candle.time, value: finalEquityUsd });
|
|
equityData.btc.push({ time: candle.time, value: finalEquityBtc });
|
|
|
|
if (totalQty > 0.000001) {
|
|
avgPriceData.push({
|
|
time: candle.time,
|
|
value: avgPrice,
|
|
color: config.direction === 'long' ? '#26a69a' : '#ef5350' // Green for long, Red for short
|
|
});
|
|
}
|
|
posSizeData.btc.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty : totalQty / price });
|
|
posSizeData.usd.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty * price : totalQty });
|
|
}
|
|
|
|
displayResultsCallback(trades, equityData, config, simCandles[simCandles.length-1].close, avgPriceData, posSizeData);
|
|
|
|
} catch (error) {
|
|
console.error('[Simulation] Error:', error);
|
|
alert('Simulation failed.');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Run Simulation';
|
|
}
|
|
}
|
|
};
|