chore: add AGENTS.md with build, lint, test commands and style guidelines

This commit is contained in:
DiTus
2026-03-18 21:17:43 +01:00
parent e98c25efc4
commit 509f8033fa
32 changed files with 10087 additions and 133 deletions

612
js/strategies/ping-pong.js Normal file
View File

@ -0,0 +1,612 @@
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';
}
}
};