refactor: modularize dashboard strategies and enhance indicator engine

- Refactored strategy-panel.js to use a modular registry system for trading strategies.
- Introduced PingPongStrategy and moved strategy-specific logic to a new strategies/ directory.
- Enhanced the indicator engine with Multi-Timeframe (MTF) support and robust forward-filling.
- Optimized BaseIndicator and RMA calculations for better performance.
- Updated UI components (chart.js, indicators-panel, signal-markers) to support the new architecture.
- Added markers-plugin.js for improved signal visualization.
This commit is contained in:
DiTus
2026-03-10 11:52:11 +01:00
parent 8b167f8b2c
commit 218f0f5107
11 changed files with 1310 additions and 599 deletions

View File

@ -2,6 +2,8 @@
// Based on J.M. Hurst's cyclic price channel theory
// Using RMA + ATR displacement method
import { INTERVALS } from '../core/constants.js';
const SIGNAL_TYPES = {
BUY: 'buy',
SELL: 'sell'
@ -14,47 +16,61 @@ const SIGNAL_COLORS = {
class BaseIndicator {
constructor(config) {
this.config = config;
this.id = config.id;
this.type = config.type;
this.name = config.name;
this.params = config.params || {};
this.timeframe = config.timeframe || '1m';
this.timeframe = config.timeframe || 'chart';
this.series = [];
this.visible = config.visible !== false;
this.cachedResults = null;
this.cachedMeta = null;
this.lastSignalTimestamp = null;
this.lastSignalType = null;
if (config.cachedResults === undefined) config.cachedResults = null;
if (config.cachedMeta === undefined) config.cachedMeta = null;
if (config.cachedTimeframe === undefined) config.cachedTimeframe = null;
if (config.isFetching === undefined) config.isFetching = false;
if (config.lastProcessedTime === undefined) config.lastProcessedTime = 0;
}
get cachedResults() { return this.config.cachedResults; }
set cachedResults(v) { this.config.cachedResults = v; }
get cachedMeta() { return this.config.cachedMeta; }
set cachedMeta(v) { this.config.cachedMeta = v; }
get cachedTimeframe() { return this.config.cachedTimeframe; }
set cachedTimeframe(v) { this.config.cachedTimeframe = v; }
get isFetching() { return this.config.isFetching; }
set isFetching(v) { this.config.isFetching = v; }
get lastProcessedTime() { return this.config.lastProcessedTime; }
set lastProcessedTime(v) { this.config.lastProcessedTime = v; }
}
// Calculate RMA (Rolling Moving Average - Wilder's method)
// Recreates Pine Script's ta.rma() exactly
// Optimized RMA that can start from a previous state
function calculateRMAIncremental(sourceValue, prevRMA, length) {
if (prevRMA === null || isNaN(prevRMA)) return sourceValue;
const alpha = 1 / length;
return alpha * sourceValue + (1 - alpha) * prevRMA;
}
// Calculate RMA for a full array with stable initialization
function calculateRMA(sourceArray, length) {
const rma = new Array(sourceArray.length).fill(null);
let sum = 0;
const alpha = 1 / length;
// PineScript implicitly rounds float lengths for SMA initialization
const smaLength = Math.round(length);
for (let i = 0; i < sourceArray.length; i++) {
if (i < smaLength - 1) {
// Accumulate first N-1 bars
sum += sourceArray[i];
} else if (i === smaLength - 1) {
// On the Nth bar, the first RMA value is the SMA
sum += sourceArray[i];
rma[i] = sum / smaLength;
} else {
// Subsequent bars use the RMA formula
const prevRMA = rma[i - 1];
rma[i] = (prevRMA === null || isNaN(prevRMA))
? alpha * sourceArray[i]
? sourceArray[i]
: alpha * sourceArray[i] + (1 - alpha) * prevRMA;
}
}
return rma;
}
@ -93,12 +109,76 @@ function calculateHurstSignal(indicator, lastCandle, prevCandle, values, prevVal
return null;
}
function getEffectiveTimeframe(params) {
return params.timeframe === 'chart' ? window.dashboard?.currentInterval || '1m' : params.timeframe;
}
function intervalToSeconds(interval) {
const amount = parseInt(interval);
const unit = interval.replace(/[0-9]/g, '');
switch (unit) {
case 'm': return amount * 60;
case 'h': return amount * 3600;
case 'd': return amount * 86400;
case 'w': return amount * 604800;
case 'M': return amount * 2592000;
default: return 60;
}
}
async function getCandlesForTimeframe(tf, startTime, endTime) {
const url = `/api/v1/candles?symbol=BTC&interval=${tf}&start=${startTime.toISOString()}&end=${endTime.toISOString()}&limit=5000`;
const response = await fetch(url);
if (!response.ok) {
console.error(`Failed to fetch candles for ${tf}:`, response.status, response.statusText);
return [];
}
const data = await response.json();
// API returns newest first (desc), but indicators need oldest first (asc)
// Also convert time to numeric seconds to match targetCandles
return (data.candles || []).reverse().map(c => ({
...c,
time: Math.floor(new Date(c.time).getTime() / 1000)
}));
}
/**
* Robust forward filling for MTF data.
* @param {Array} results - MTF results (e.g. 5m)
* @param {Array} targetCandles - Chart candles (e.g. 1m)
*/
function forwardFillResults(results, targetCandles) {
if (!results || results.length === 0) {
return new Array(targetCandles.length).fill(null);
}
const filled = new Array(targetCandles.length).fill(null);
let resIdx = 0;
for (let i = 0; i < targetCandles.length; i++) {
const targetTime = targetCandles[i].time;
// Advance result index while next result time is <= target time
while (resIdx < results.length - 1 && results[resIdx + 1].time <= targetTime) {
resIdx++;
}
// If the current result is valid for this target time, use it
// (result time must be <= target time)
if (results[resIdx] && results[resIdx].time <= targetTime) {
filled[i] = results[resIdx];
}
}
return filled;
}
export class HurstBandsIndicator extends BaseIndicator {
constructor(config) {
super(config);
this.lastSignalTimestamp = null;
this.lastSignalType = null;
if (!this.params.timeframe) this.params.timeframe = 'chart';
if (!this.params.markerBuyShape) this.params.markerBuyShape = 'custom';
if (!this.params.markerSellShape) this.params.markerSellShape = 'custom';
if (!this.params.markerBuyColor) this.params.markerBuyColor = '#9e9e9e';
@ -108,59 +188,207 @@ export class HurstBandsIndicator extends BaseIndicator {
}
calculate(candles) {
const effectiveTf = getEffectiveTimeframe(this.params);
const lastCandle = candles[candles.length - 1];
// Case 1: Different timeframe (MTF)
if (effectiveTf !== window.dashboard?.currentInterval && this.params.timeframe !== 'chart') {
// If we have cached results, try to forward fill them to match the current candle count
if (this.cachedResults && this.cachedTimeframe === effectiveTf) {
// If results are stale (last result time is behind last candle time), trigger background fetch
const lastResult = this.cachedResults[this.cachedResults.length - 1];
const needsFetch = !this.isFetching && (!lastResult || lastCandle.time > lastResult.time + (intervalToSeconds(effectiveTf) / 2));
if (needsFetch) {
this._fetchAndCalculateMtf(effectiveTf, candles);
}
// If length matches exactly and params haven't changed, return
if (this.cachedResults.length === candles.length && !this.shouldRecalculate()) {
return this.cachedResults;
}
// If length differs (e.g. new 1m candle but 5m not fetched yet), forward fill
const filled = forwardFillResults(this.cachedResults.filter(r => r !== null), candles);
this.cachedResults = filled;
return filled;
}
// Initial fetch
if (!this.isFetching) {
this._fetchAndCalculateMtf(effectiveTf, candles);
}
return new Array(candles.length).fill(null);
}
// Case 2: Same timeframe as chart (Incremental or Full)
// Check if we can do incremental update
if (this.cachedResults &&
this.cachedResults.length > 0 &&
this.cachedTimeframe === effectiveTf &&
!this.shouldRecalculate() &&
candles.length >= this.cachedResults.length &&
candles[this.cachedResults.length - 1].time === this.cachedResults[this.cachedResults.length - 1].time) {
// Only calculate new candles
if (candles.length > this.cachedResults.length) {
const newResults = this._calculateIncremental(candles, this.cachedResults);
this.cachedResults = newResults;
}
return this.cachedResults;
}
// Full calculation
const results = this._calculateCore(candles);
this.cachedTimeframe = effectiveTf;
this.updateCachedMeta(this.params);
this.cachedResults = results;
return results;
}
_calculateCore(candles) {
const mcl_t = this.params.period || 30;
const mcm = this.params.multiplier || 1.8;
const mcl = mcl_t / 2;
// FIX: PineScript rounds implicit floats for history references [].
// 15/2 = 7.5. Pine rounds this to 8. Math.floor gives 7.
const mcl_2 = Math.round(mcl / 2);
const results = new Array(candles.length).fill(null);
const closes = candles.map(c => c.close);
// True Range for ATR
const trArray = candles.map((d, i) => {
const prevClose = i > 0 ? candles[i - 1].close : null;
const high = d.high;
const low = d.low;
if (prevClose === null || prevClose === undefined || isNaN(prevClose)) {
return high - low;
}
return Math.max(
high - low,
Math.abs(high - prevClose),
Math.abs(low - prevClose)
);
if (prevClose === null || isNaN(prevClose)) return d.high - d.low;
return Math.max(d.high - d.low, Math.abs(d.high - prevClose), Math.abs(d.low - prevClose));
});
const ma_mcl = calculateRMA(closes, mcl);
const atr = calculateRMA(trArray, mcl);
for (let i = 0; i < candles.length; i++) {
const src = closes[i];
const mcm_off = mcm * (atr[i] || 0);
const historicalIndex = i - mcl_2;
const historical_ma = historicalIndex >= 0 ? ma_mcl[historicalIndex] : null;
const centerLine = (historical_ma === null || historical_ma === undefined || isNaN(historical_ma)) ? src : historical_ma;
const centerLine = (historical_ma === null || isNaN(historical_ma)) ? closes[i] : historical_ma;
results[i] = {
time: candles[i].time,
upper: centerLine + mcm_off,
lower: centerLine - mcm_off
lower: centerLine - mcm_off,
ma: ma_mcl[i], // Store intermediate state for incremental updates
atr: atr[i]
};
}
return results;
}
_calculateIncremental(candles, oldResults) {
const mcl_t = this.params.period || 30;
const mcm = this.params.multiplier || 1.8;
const mcl = mcl_t / 2;
const mcl_2 = Math.round(mcl / 2);
const results = [...oldResults];
const startIndex = oldResults.length;
for (let i = startIndex; i < candles.length; i++) {
const close = candles[i].close;
const prevClose = candles[i-1].close;
const tr = Math.max(candles[i].high - candles[i].low, Math.abs(candles[i].high - prevClose), Math.abs(candles[i].low - prevClose));
const prevMA = results[i-1]?.ma;
const prevATR = results[i-1]?.atr;
const currentMA = calculateRMAIncremental(close, prevMA, mcl);
const currentATR = calculateRMAIncremental(tr, prevATR, mcl);
// For displaced center line, we still need the MA from i - mcl_2
// Since i >= oldResults.length, i - mcl_2 might be in the old results
let historical_ma = null;
const historicalIndex = i - mcl_2;
if (historicalIndex >= 0) {
historical_ma = historicalIndex < startIndex ? results[historicalIndex].ma : null; // In this simple incremental, we don't look ahead
}
const centerLine = (historical_ma === null || isNaN(historical_ma)) ? close : historical_ma;
const mcm_off = mcm * (currentATR || 0);
results[i] = {
time: candles[i].time,
upper: centerLine + mcm_off,
lower: centerLine - mcm_off,
ma: currentMA,
atr: currentATR
};
}
return results;
}
async _fetchAndCalculateMtf(effectiveTf, targetCandles) {
this.isFetching = true;
try {
console.log(`[Hurst] Fetching MTF data for ${effectiveTf}...`);
const chartData = window.dashboard?.allData?.get(window.dashboard?.currentInterval) || targetCandles;
if (!chartData || chartData.length === 0) {
console.warn('[Hurst] No chart data available for timeframe fetch');
this.isFetching = false;
return;
}
// Calculate warmup needed (period + half width)
const mcl_t = this.params.period || 30;
const warmupBars = mcl_t * 2; // Extra buffer
const tfSeconds = intervalToSeconds(effectiveTf);
const warmupOffsetSeconds = warmupBars * tfSeconds;
// Candles endpoint expects ISO strings or timestamps.
// chartData[0].time is the earliest candle on chart.
const startTime = new Date((chartData[0].time - warmupOffsetSeconds) * 1000);
const endTime = new Date(chartData[chartData.length - 1].time * 1000);
const tfCandles = await getCandlesForTimeframe(effectiveTf, startTime, endTime);
if (tfCandles.length === 0) {
console.warn(`[Hurst] No candles fetched for ${effectiveTf}`);
this.isFetching = false;
return;
}
console.log(`[Hurst] Fetched ${tfCandles.length} candles for ${effectiveTf}. Calculating...`);
const tfResults = this._calculateCore(tfCandles);
const finalResults = forwardFillResults(tfResults, targetCandles);
// Persist results on the config object
this.cachedResults = finalResults;
this.cachedTimeframe = effectiveTf;
this.updateCachedMeta(this.params);
console.log(`[Hurst] MTF calculation complete for ${effectiveTf}. Triggering redraw.`);
// Trigger a redraw of the dashboard to show the new data
if (window.drawIndicatorsOnChart) {
window.drawIndicatorsOnChart();
}
} catch (err) {
console.error('[Hurst] Error in _fetchAndCalculateMtf:', err);
} finally {
this.isFetching = false;
}
}
getMetadata() {
return {
name: 'Hurst Bands',
description: 'Cyclic price channels based on Hurst theory',
inputs: [
{
name: 'timeframe',
label: 'Timeframe',
type: 'select',
default: 'chart',
options: ['chart', ...INTERVALS],
labels: { chart: '(Main Chart)' }
},
{ name: 'period', label: 'Hurst Cycle Length (mcl_t)', type: 'number', default: 30, min: 5, max: 200 },
{ name: 'multiplier', label: 'Multiplier (mcm)', type: 'number', default: 1.8, min: 0.5, max: 10, step: 0.1 }
],
@ -174,6 +402,20 @@ export class HurstBandsIndicator extends BaseIndicator {
displayMode: 'overlay'
};
}
shouldRecalculate() {
const effectiveTf = getEffectiveTimeframe(this.params);
return this.cachedTimeframe !== effectiveTf ||
(this.cachedMeta && (this.cachedMeta.period !== this.params.period ||
this.cachedMeta.multiplier !== this.params.multiplier));
}
updateCachedMeta(params) {
this.cachedMeta = {
period: params.period,
multiplier: params.multiplier
};
}
}
export { calculateHurstSignal };

View File

@ -0,0 +1,9 @@
export const StrategyRegistry = {};
export function registerStrategy(name, strategyModule) {
StrategyRegistry[name] = strategyModule;
}
export function getStrategy(name) {
return StrategyRegistry[name];
}

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';
}
}
};

View File

@ -4,6 +4,134 @@ import { calculateSignalMarkers } from './signal-markers.js';
import { updateIndicatorCandles } from './indicators-panel-new.js';
import { TimezoneConfig } from '../config/timezone.js';
export class SeriesMarkersPrimitive {
constructor(markers) {
this._markers = markers || [];
this._paneViews = [new MarkersPaneView(this)];
}
setMarkers(markers) {
this._markers = markers;
if (this._requestUpdate) {
this._requestUpdate();
}
}
attached(param) {
this._chart = param.chart;
this._series = param.series;
this._requestUpdate = param.requestUpdate;
this._requestUpdate();
}
detached() {
this._chart = undefined;
this._series = undefined;
this._requestUpdate = undefined;
}
updateAllViews() {
this._requestUpdate?.();
}
paneViews() {
return this._paneViews;
}
}
class MarkersPaneView {
constructor(source) {
this._source = source;
}
renderer() {
return new MarkersRenderer(this._source);
}
zOrder() {
return 'top';
}
}
class MarkersRenderer {
constructor(source) {
this._source = source;
}
draw(target) {
if (!this._source._chart || !this._source._series) return;
target.useBitmapCoordinateSpace((scope) => {
const ctx = scope.context;
const series = this._source._series;
const chart = this._source._chart;
const markers = this._source._markers;
// Adjust coordinates to bitmap space based on pixel ratio
const ratio = scope.horizontalPixelRatio;
ctx.save();
for (const marker of markers) {
const timeCoordinate = chart.timeScale().timeToCoordinate(marker.time);
if (timeCoordinate === null) continue;
// Figure out price coordinate
let price = marker.price || marker.value;
// If price wasn't specified but we have the series data, grab the candle high/low
if (!price && window.dashboard && window.dashboard.allData) {
const data = window.dashboard.allData.get(window.dashboard.currentInterval);
if (data) {
const candle = data.find(d => d.time === marker.time);
if (candle) {
price = marker.position === 'aboveBar' ? candle.high : candle.low;
}
}
}
if (!price) continue;
const priceCoordinate = series.priceToCoordinate(price);
if (priceCoordinate === null) continue;
const x = timeCoordinate * ratio;
const size = 5 * ratio;
const margin = 15 * ratio;
const isAbove = marker.position === 'aboveBar';
const y = (isAbove ? priceCoordinate * ratio - margin : priceCoordinate * ratio + margin);
ctx.fillStyle = marker.color || '#26a69a';
ctx.beginPath();
const shape = marker.shape || (isAbove ? 'arrowDown' : 'arrowUp');
if (shape === 'arrowUp' || shape === 'triangleUp') {
ctx.moveTo(x, y - size);
ctx.lineTo(x - size, y + size);
ctx.lineTo(x + size, y + size);
} else if (shape === 'arrowDown' || shape === 'triangleDown') {
ctx.moveTo(x, y + size);
ctx.lineTo(x - size, y - size);
ctx.lineTo(x + size, y - size);
} else if (shape === 'circle') {
ctx.arc(x, y, size, 0, Math.PI * 2);
} else if (shape === 'square') {
ctx.rect(x - size, y - size, size * 2, size * 2);
} else if (shape === 'custom' && marker.text) {
ctx.font = `${Math.round(14 * ratio)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(marker.text, x, y);
continue;
}
ctx.fill();
}
ctx.restore();
});
}
}
function formatDate(timestamp) {
return TimezoneConfig.formatDate(timestamp);
}
@ -91,8 +219,20 @@ constructor() {
setAvgPriceData(data) {
if (this.avgPriceSeries) {
this.avgPriceSeries.setData(data || []);
this.chart.removeSeries(this.avgPriceSeries);
}
// Recreate series to apply custom colors per point via LineSeries data
this.avgPriceSeries = this.chart.addSeries(LightweightCharts.LineSeries, {
lineWidth: 2,
lineStyle: LightweightCharts.LineStyle.Solid,
lastValueVisible: true,
priceLineVisible: false,
crosshairMarkerVisible: false,
title: 'Avg Price',
});
this.avgPriceSeries.setData(data || []);
}
clearAvgPriceData() {
@ -509,7 +649,12 @@ async loadNewData() {
this.lastCandleTimestamp = latest.time;
chartData.forEach(candle => {
if (candle.time >= lastTimestamp) {
if (candle.time >= lastTimestamp &&
!Number.isNaN(candle.time) &&
!Number.isNaN(candle.open) &&
!Number.isNaN(candle.high) &&
!Number.isNaN(candle.low) &&
!Number.isNaN(candle.close)) {
this.candleSeries.update(candle);
}
});
@ -700,78 +845,16 @@ async loadSignals() {
// Re-sort combined markers by time
markers.sort((a, b) => a.time - b.time);
// If we have a marker controller, update markers through it
if (this.markerController) {
try {
this.markerController.setMarkers(markers);
return;
} catch (e) {
console.warn('[SignalMarkers] setMarkers error:', e.message);
this.markerController = null;
// Use custom primitive for markers in v5
try {
if (!this.markerPrimitive) {
this.markerPrimitive = new SeriesMarkersPrimitive();
this.candleSeries.attachPrimitive(this.markerPrimitive);
}
this.markerPrimitive.setMarkers(markers);
} catch (e) {
console.warn('[SignalMarkers] setMarkers primitive error:', e.message);
}
// Clear price lines
if (this.markerPriceLines) {
this.markerPriceLines.forEach(ml => {
try { this.candleSeries.removePriceLine(ml); } catch (e) {}
});
this.markerPriceLines = [];
}
if (markers.length === 0) return;
// Create new marker controller
if (typeof LightweightCharts.createSeriesMarkers === 'function') {
try {
this.markerController = LightweightCharts.createSeriesMarkers(this.candleSeries, markers);
return;
} catch (e) {
console.warn('[SignalMarkers] createSeriesMarkers error:', e.message);
}
}
// Fallback: use price lines
this.addMarkerPriceLines(markers);
}
addMarkerPriceLines(markers) {
if (this.markerPriceLines) {
this.markerPriceLines.forEach(ml => {
try { this.candleSeries.removePriceLine(ml); } catch (e) {}
});
}
this.markerPriceLines = [];
const recentMarkers = markers.slice(-20);
recentMarkers.forEach(m => {
const isBuy = m.position === 'belowBar';
const price = isBuy ? this.getMarkerLowPrice(m.time) : this.getMarkerHighPrice(m.time);
const priceLine = this.candleSeries.createPriceLine({
price: price,
color: m.color,
lineWidth: 2,
lineStyle: LightweightCharts.LineStyle.Dashed,
axisLabelVisible: true,
title: m.text
});
this.markerPriceLines.push(priceLine);
});
}
getMarkerLowPrice(time) {
const candles = this.allData.get(this.currentInterval);
const candle = candles?.find(c => c.time === time);
return candle ? candle.low * 0.995 : 0;
}
getMarkerHighPrice(time) {
const candles = this.allData.get(this.currentInterval);
const candle = candles?.find(c => c.time === time);
return candle ? candle.high * 1.005 : 0;
}
renderTA() {
@ -954,6 +1037,9 @@ switchTimeframe(interval) {
window.clearSimulationResults?.();
window.updateTimeframeDisplay?.();
// Notify indicators of timeframe change for recalculation
window.onTimeframeChange?.(interval);
}
}

View File

@ -201,8 +201,20 @@ export class HTSVisualizer {
}
const candleSeries = this.candleData?.series;
if (candleSeries && typeof candleSeries.setMarkers === 'function') {
candleSeries.setMarkers(markers);
if (candleSeries) {
try {
if (typeof candleSeries.setMarkers === 'function') {
candleSeries.setMarkers(markers);
} else if (typeof SeriesMarkersPrimitive !== 'undefined') {
if (!this.markerPrimitive) {
this.markerPrimitive = new SeriesMarkersPrimitive();
candleSeries.attachPrimitive(this.markerPrimitive);
}
this.markerPrimitive.setMarkers(markers);
}
} catch (e) {
console.warn('[HTS] Error setting markers:', e);
}
}
return markers;

View File

@ -112,6 +112,23 @@ export function setActiveIndicators(indicators) {
window.getActiveIndicators = getActiveIndicators;
async function onTimeframeChange(newInterval) {
const indicators = getActiveIndicators();
for (const indicator of indicators) {
if (indicator.params.timeframe === 'chart' && typeof indicator.shouldRecalculate === 'function') {
if (indicator.shouldRecalculate()) {
try {
await window.renderIndicator(indicator.id);
} catch (err) {
console.error(`[onTimeframeChange] Failed to recalculate ${indicator.name}:`, err);
}
}
}
}
}
window.onTimeframeChange = onTimeframeChange;
// Render main panel
export function renderIndicatorPanel() {
const container = document.getElementById('indicatorPanel');
@ -315,7 +332,10 @@ function renderIndicatorConfig(indicator, meta) {
<label>${input.label}</label>
${input.type === 'select' ?
`<select onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '${input.name}', this.value)">
${input.options.map(o => `<option value="${o}" ${indicator.params[input.name] === o ? 'selected' : ''}>${o}</option>`).join('')}
${input.options.map(o => {
const label = input.labels?.[o] || o;
return `<option value="${o}" ${indicator.params[input.name] === o ? 'selected' : ''}>${label}</option>`;
}).join('')}
</select>` :
`<input
type="number"
@ -547,6 +567,7 @@ window.updateIndicatorSetting = function(id, key, value) {
indicator.params[key] = value;
indicator.lastSignalTimestamp = null;
indicator.lastSignalType = null;
indicator.cachedResults = null; // Clear cache when params change
drawIndicatorsOnChart();
};
@ -753,6 +774,7 @@ function addIndicator(type) {
// Override with Hurst-specific defaults
if (type === 'hurst') {
params._lineWidth = 1;
params.timeframe = 'chart';
params.markerBuyShape = 'custom';
params.markerSellShape = 'custom';
params.markerBuyColor = '#9e9e9e';
@ -789,19 +811,21 @@ function saveUserPresets() {
}
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
// Recalculate with current TF candles
console.log(`[renderIndicatorOnPane] ${indicator.name}: START`);
console.log(`[renderIndicatorOnPane] ${indicator.name}: Input candles = ${candles.length}`);
console.log(`[renderIndicatorOnPane] ${indicator.name}: Calling instance.calculate()...`);
// Recalculate with current TF candles (or use cached if they exist and are the correct length)
let results = indicator.cachedResults;
if (!results || !Array.isArray(results) || results.length !== candles.length) {
console.log(`[renderIndicatorOnPane] ${indicator.name}: Calling instance.calculate()...`);
results = instance.calculate(candles);
indicator.cachedResults = results;
}
const results = instance.calculate(candles);
console.log(`[renderIndicatorOnPane] ${indicator.name}: calculate() returned ${results?.length || 0} results`);
console.log(`[renderIndicatorOnPane] ${indicator.name}: Expected ${candles.length} results, got ${results?.length || 0}`);
if (!results || !Array.isArray(results)) {
console.error(`[renderIndicatorOnPane] ${indicator.name}: Failed to get valid results (got ${typeof results})`);
return;
}
if (results.length !== candles.length) {
console.error(`[renderIndicatorOnPane] ${indicator.name}: MISMATCH! Expected ${candles.length} results but got ${results.length}`);
console.error(`[renderIndicatorOnPane] ${indicator.name}: This means instance.calculate() is not returning the correct number of results!`);
}
// Clear previous series for this indicator
@ -817,8 +841,19 @@ function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, li
const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
const lineWidth = indicator.params._lineWidth || 1;
const firstNonNull = results?.find(r => r !== null && r !== undefined);
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
// Improved detection of object-based results (multiple plots)
const firstNonNull = Array.isArray(results) ? results.find(r => r !== null && r !== undefined) : null;
let isObjectResult = firstNonNull && typeof firstNonNull === 'object' && !Array.isArray(firstNonNull);
// Fallback: If results are all null (e.g. during warmup or MTF fetch),
// use metadata to determine if it SHOULD be an object result
if (!firstNonNull && meta.plots && meta.plots.length > 1) {
isObjectResult = true;
}
// Also check if the only plot has a specific ID that isn't just a number
if (!firstNonNull && meta.plots && meta.plots.length === 1 && meta.plots[0].id !== 'value') {
isObjectResult = true;
}
let plotsCreated = 0;
let dataPointsAdded = 0;
@ -842,7 +877,7 @@ function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, li
value = results[i];
}
if (value !== null && value !== undefined) {
if (value !== null && value !== undefined && typeof value === 'number' && Number.isFinite(value)) {
if (firstDataIndex === -1) {
firstDataIndex = i;
}
@ -1033,12 +1068,20 @@ export function drawIndicatorsOnChart() {
const instance = new IndicatorClass(ind);
const meta = instance.getMetadata();
// Store calculated results and metadata for signal calculation
const results = instance.calculate(candles);
ind.cachedResults = results;
// Store calculated results and metadata for signal calculation
let results = ind.cachedResults;
if (!results || !Array.isArray(results) || results.length !== candles.length) {
try {
results = instance.calculate(candles);
ind.cachedResults = results;
} catch (err) {
console.error(`[Indicators] Failed to calculate ${ind.name}:`, err);
results = [];
}
}
ind.cachedMeta = meta;
const validResults = results.filter(r => r !== null && r !== undefined);
const validResults = Array.isArray(results) ? results.filter(r => r !== null && r !== undefined) : [];
const warmupPeriod = ind.params?.period || 44;
console.log(`[Indicators] ${ind.name}: ${validResults.length} valid results (need ${warmupPeriod} candles warmup)`);

View File

@ -240,6 +240,7 @@ export function addIndicator(type) {
// Set Hurst-specific defaults
if (type === 'hurst') {
params.timeframe = 'chart';
params.markerBuyShape = 'custom';
params.markerSellShape = 'custom';
params.markerBuyColor = '#9e9e9e';
@ -492,13 +493,17 @@ export function drawIndicatorsOnChart() {
}
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
const results = instance.calculate(candles);
let results = instance.calculate(candles);
if (!results || !Array.isArray(results)) {
console.error(`[renderIndicatorOnPane] ${indicator.name}: Failed to get valid results (got ${typeof results})`);
return;
}
indicator.series = [];
const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
const lineWidth = indicator.params._lineWidth || 1;
const firstNonNull = results?.find(r => r !== null && r !== undefined);
const firstNonNull = Array.isArray(results) ? results.find(r => r !== null && r !== undefined) : null;
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
meta.plots.forEach((plot, plotIdx) => {

View File

@ -0,0 +1,117 @@
export class SeriesMarkersPrimitive {
constructor(markers) {
this._markers = markers || [];
this._paneViews = [new MarkersPaneView(this)];
}
setMarkers(markers) {
this._markers = markers;
if (this._requestUpdate) {
this._requestUpdate();
}
}
attached(param) {
this._chart = param.chart;
this._series = param.series;
this._requestUpdate = param.requestUpdate;
this._requestUpdate();
}
detached() {
this._chart = undefined;
this._series = undefined;
this._requestUpdate = undefined;
}
updateAllViews() {}
paneViews() {
return this._paneViews;
}
}
class MarkersPaneView {
constructor(source) {
this._source = source;
}
renderer() {
return new MarkersRenderer(this._source);
}
}
class MarkersRenderer {
constructor(source) {
this._source = source;
}
draw(target) {
if (!this._source._chart || !this._source._series) return;
// Lightweight Charts v5 wraps context
const ctx = target.context;
const series = this._source._series;
const chart = this._source._chart;
const markers = this._source._markers;
ctx.save();
// Ensure markers are sorted by time (usually already done)
for (const marker of markers) {
const timeCoordinate = chart.timeScale().timeToCoordinate(marker.time);
if (timeCoordinate === null) continue;
// To position above or below bar, we need the candle data or we use the marker.value if provided
// For true aboveBar/belowBar without candle data, we might just use series.priceToCoordinate on marker.value
let price = marker.value;
// Fallbacks if no value provided (which our calculator does provide)
if (!price) continue;
const priceCoordinate = series.priceToCoordinate(price);
if (priceCoordinate === null) continue;
const x = timeCoordinate;
const size = 5;
const margin = 12; // Gap between price and marker
const isAbove = marker.position === 'aboveBar';
const y = isAbove ? priceCoordinate - margin : priceCoordinate + margin;
ctx.fillStyle = marker.color || '#26a69a';
ctx.beginPath();
if (marker.shape === 'arrowUp' || (!marker.shape && !isAbove)) {
ctx.moveTo(x, y - size);
ctx.lineTo(x - size, y + size);
ctx.lineTo(x + size, y + size);
} else if (marker.shape === 'arrowDown' || (!marker.shape && isAbove)) {
ctx.moveTo(x, y + size);
ctx.lineTo(x - size, y - size);
ctx.lineTo(x + size, y - size);
} else if (marker.shape === 'circle') {
ctx.arc(x, y, size, 0, Math.PI * 2);
} else if (marker.shape === 'square') {
ctx.rect(x - size, y - size, size * 2, size * 2);
} else if (marker.shape === 'custom' && marker.text) {
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(marker.text, x, y);
continue;
} else {
// Default triangle
if (isAbove) {
ctx.moveTo(x, y + size);
ctx.lineTo(x - size, y - size);
ctx.lineTo(x + size, y - size);
} else {
ctx.moveTo(x, y - size);
ctx.lineTo(x - size, y + size);
ctx.lineTo(x + size, y + size);
}
}
ctx.fill();
}
ctx.restore();
}
}

View File

@ -15,15 +15,18 @@ export function calculateSignalMarkers(candles) {
console.log('[SignalMarkers] Processing indicator:', indicator.type, 'showMarkers:', indicator.params.showMarkers);
const IndicatorClass = IndicatorRegistry[indicator.type];
if (!IndicatorClass) {
continue;
// Use cache if available
let results = indicator.cachedResults;
if (!results || !Array.isArray(results) || results.length !== candles.length) {
const IndicatorClass = IndicatorRegistry[indicator.type];
if (!IndicatorClass) {
continue;
}
const instance = new IndicatorClass(indicator);
results = instance.calculate(candles);
}
const instance = new IndicatorClass(indicator);
const results = instance.calculate(candles);
if (!results || results.length === 0) {
if (!results || !Array.isArray(results) || results.length === 0) {
continue;
}

View File

@ -96,14 +96,17 @@ function calculateHistoricalCrossovers(activeIndicators, candles) {
activeIndicators.forEach(indicator => {
const indicatorType = indicator.type || indicator.indicatorType;
// Recalculate indicator values for all candles
const IndicatorClass = IndicatorRegistry[indicatorType];
if (!IndicatorClass) return;
// Recalculate indicator values for all candles (use cache if valid)
let results = indicator.cachedResults;
if (!results || !Array.isArray(results) || results.length !== candles.length) {
const IndicatorClass = IndicatorRegistry[indicatorType];
if (!IndicatorClass) return;
const instance = new IndicatorClass(indicator);
results = instance.calculate(candles);
// Don't save back to cache here, let drawIndicatorsOnChart be the source of truth for cache
}
const instance = new IndicatorClass(indicator);
const results = instance.calculate(candles);
if (!results || results.length === 0) return;
if (!results || !Array.isArray(results) || results.length === 0) return;
// Find the most recent crossover by going backwards from the newest candle
// candles are sorted oldest first, newest last
@ -276,7 +279,7 @@ export function calculateAllIndicatorSignals() {
let results = indicator.cachedResults;
let meta = indicator.cachedMeta;
if (!results || !meta || results.length !== candles.length) {
if (!results || !meta || !Array.isArray(results) || results.length !== candles.length) {
const instance = new IndicatorClass(indicator);
meta = instance.getMetadata();
results = instance.calculate(candles);
@ -284,8 +287,8 @@ export function calculateAllIndicatorSignals() {
indicator.cachedMeta = meta;
}
if (!results || results.length === 0) {
console.log('[Signals] No results for indicator:', indicator.type);
if (!results || !Array.isArray(results) || results.length === 0) {
console.log('[Signals] No valid results for indicator:', indicator.type);
continue;
}

View File

@ -1,13 +1,10 @@
import { IndicatorRegistry, getSignalFunction } from '../indicators/index.js';
import { getStrategy, registerStrategy } from '../strategies/index.js';
import { PingPongStrategy } from '../strategies/ping-pong.js';
// Register available strategies
registerStrategy('ping_pong', PingPongStrategy);
let activeIndicators = [];
let simulationResults = null;
let equitySeries = null;
let equityChart = null;
let posSeries = null;
let posSizeChart = null;
const STORAGE_KEY = 'ping_pong_settings';
function formatDisplayDate(timestamp) {
if (!timestamp) return '';
@ -20,49 +17,6 @@ function formatDisplayDate(timestamp) {
return `${day}/${month}/${year} ${hours}:${minutes}`;
}
function parseDisplayDate(str) {
if (!str) return null;
const regex = /^(\d{2})\/(\d{2})\/(\d{4})\s(\d{2}):(\d{2})$/;
const match = str.trim().match(regex);
if (!match) return null;
const [_, day, month, year, hours, minutes] = match;
return new Date(year, month - 1, day, hours, minutes);
}
function getSavedSettings() {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return null;
try {
return JSON.parse(saved);
} catch (e) {
return null;
}
}
function saveSettings() {
const settings = {
startDate: document.getElementById('simStartDate').value,
stopDate: document.getElementById('simStopDate').value,
contractType: document.getElementById('simContractType').value,
direction: document.getElementById('simDirection').value,
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');
const originalText = btn.textContent;
btn.textContent = 'Saved!';
btn.style.color = '#26a69a';
setTimeout(() => {
btn.textContent = originalText;
btn.style.color = '';
}, 2000);
}
export function initStrategyPanel() {
window.renderStrategyPanel = renderStrategyPanel;
renderStrategyPanel();
@ -88,94 +42,23 @@ export function renderStrategyPanel() {
if (!container) return;
activeIndicators = window.getActiveIndicators?.() || [];
const saved = getSavedSettings();
// Format initial values for display
let startDisplay = saved?.startDate || '01/01/2026 00:00';
let stopDisplay = saved?.stopDate || '';
// If the saved value is in ISO format (from previous version), convert it
if (startDisplay.includes('T')) {
startDisplay = formatDisplayDate(new Date(startDisplay));
}
if (stopDisplay.includes('T')) {
stopDisplay = formatDisplayDate(new Date(stopDisplay));
// For now, we only have Ping-Pong. Later we can add a strategy selector.
const currentStrategyId = 'ping_pong';
const strategy = getStrategy(currentStrategyId);
if (!strategy) {
container.innerHTML = `<div class="sidebar-section">Strategy not found.</div>`;
return;
}
container.innerHTML = `
<div class="sidebar-section">
<div class="sidebar-section-header">
<span>⚙️</span> Ping-Pong Strategy
<span>⚙️</span> ${strategy.name} Strategy
</div>
<div class="sidebar-section-content">
<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">
<label>Contract Type</label>
<select id="simContractType" class="sim-input">
<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</label>
<select id="simDirection" class="sim-input">
<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>
${strategy.renderUI(activeIndicators, formatDisplayDate)}
<button class="sim-run-btn" id="runSimulationBtn">Run Simulation</button>
</div>
</div>
@ -185,319 +68,27 @@ export function renderStrategyPanel() {
</div>
`;
document.getElementById('runSimulationBtn').addEventListener('click', runSimulation);
document.getElementById('saveSimSettings').addEventListener('click', saveSettings);
}
function 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>';
// Attach strategy specific listeners (like disabling dropdowns when auto-detect is on)
if (strategy.attachListeners) {
strategy.attachListeners();
}
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('');
document.getElementById('runSimulationBtn').addEventListener('click', () => {
strategy.runSimulation(activeIndicators, displayResults);
});
}
async function runSimulation() {
const btn = document.getElementById('runSimulationBtn');
btn.disabled = true;
const originalBtnText = btn.textContent;
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),
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...';
console.log(`[Simulation] Data gap detected. Range: ${config.startDate}-${config.stopDate}, Cache: ${earliestInCache}-${latestInCache}`);
const startISO = new Date(config.startDate * 1000).toISOString();
const stopISO = new Date(config.stopDate * 1000).toISOString();
const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${interval}&start=${startISO}&end=${stopISO}&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)
}));
// Merge with existing data
allCandles = window.dashboard.mergeData(allCandles, fetchedCandles);
window.dashboard.allData.set(interval, allCandles);
window.dashboard.candleSeries.setData(allCandles);
// Recalculate indicators
btn.textContent = 'Calculating Indicators...';
window.drawIndicatorsOnChart?.();
// Wait a bit for indicators to calculate (they usually run in background/setTimeout)
await new Promise(r => setTimeout(r, 500));
}
}
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 loop
const startPrice = simCandles[0].open;
let balanceBtc = config.contractType === 'inverse' ? config.capital / startPrice : 0;
let balanceUsd = config.contractType === 'linear' ? config.capital : 0;
let equityData = { usd: [], btc: [] };
let totalQty = 0; // Linear: BTC, Inverse: USD Contracts
let avgPrice = 0;
let avgPriceData = [];
let posSizeData = { btc: [], usd: [] };
let trades = [];
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;
// 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)';
// Minimum size check
if (remainingValueUsd < MIN_POSITION_VALUE_USD) {
qtyToClose = totalQty;
reason = 'TP (Full - Min Size)';
}
let pnl;
if (config.contractType === 'linear') {
pnl = config.direction === 'long' ? (exitPrice - avgPrice) * qtyToClose : (avgPrice - exitPrice) * qtyToClose;
balanceUsd += pnl;
} else {
pnl = config.direction === 'long'
? qtyToClose * (1/avgPrice - 1/exitPrice)
: qtyToClose * (1/exitPrice - 1/avgPrice);
balanceBtc += pnl;
}
totalQty -= qtyToClose;
trades.push({
type: config.direction, recordType: 'exit', time: candle.time,
entryPrice: avgPrice, exitPrice: exitPrice, pnl: pnl, 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)';
// Minimum size check
if (remainingValueUsd < MIN_POSITION_VALUE_USD) {
qtyToClose = totalQty;
reason = 'Signal (Full - Min Size)';
}
let pnl;
if (config.contractType === 'linear') {
pnl = config.direction === 'long' ? (price - avgPrice) * qtyToClose : (avgPrice - price) * qtyToClose;
balanceUsd += pnl;
} else {
pnl = config.direction === 'long'
? qtyToClose * (1/avgPrice - 1/price)
: qtyToClose * (1/price - 1/avgPrice);
balanceBtc += pnl;
}
totalQty -= qtyToClose;
trades.push({
type: config.direction, recordType: 'exit', time: candle.time,
entryPrice: avgPrice, exitPrice: price, pnl: pnl, 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 currentEquityBtc, currentEquityUsd;
if (config.contractType === 'linear') {
const upnlUsd = totalQty > 0 ? (config.direction === 'long' ? (price - avgPrice) : (avgPrice - price)) * totalQty : 0;
currentEquityUsd = balanceUsd + upnlUsd;
currentEquityBtc = currentEquityUsd / price;
} else {
const upnlBtc = totalQty > 0 ? (config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice)) : 0;
currentEquityBtc = balanceBtc + upnlBtc;
currentEquityUsd = currentEquityBtc * 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 currentNotionalBtc = config.contractType === 'linear' ? totalQty : totalQty / price;
const entryNotionalBtc = entryValUsd / price;
const projectedEffectiveLeverage = (currentNotionalBtc + entryNotionalBtc) / Math.max(currentEquityBtc, 0.0000001);
if (projectedEffectiveLeverage <= config.maxEffectiveLeverage) {
if (config.contractType === 'linear') {
const entryQty = entryValUsd / price;
avgPrice = ((totalQty * avgPrice) + (entryQty * price)) / (totalQty + entryQty);
totalQty += entryQty;
} else {
// Inverse: totalQty is USD contracts
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 finalEquityBtc, finalEquityUsd;
if (config.contractType === 'linear') {
const upnl = totalQty > 0 ? (config.direction === 'long' ? (price - avgPrice) : (avgPrice - price)) * totalQty : 0;
finalEquityUsd = balanceUsd + upnl;
finalEquityBtc = finalEquityUsd / price;
} else {
const upnl = totalQty > 0 ? (config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice)) : 0;
finalEquityBtc = balanceBtc + upnl;
finalEquityUsd = finalEquityBtc * 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 });
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 });
}
displayResults(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';
}
}
// Keep the display logic here so all strategies can use the same rendering for results
let equitySeries = null;
let equityChart = null;
let posSeries = null;
let posSizeChart = null;
let tradeMarkers = [];
function displayResults(trades, equityData, config, endPrice, avgPriceData, posSizeData) {
const resultsDiv = document.getElementById('simulationResults');
resultsDiv.style.display = 'block';
// Update main chart with avg price
if (window.dashboard) {
window.dashboard.setAvgPriceData(avgPriceData);
}
@ -574,7 +165,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
// Create Charts
const initCharts = () => {
// Equity Chart
const equityContainer = document.getElementById('equityChart');
if (equityContainer) {
equityContainer.innerHTML = '';
@ -588,12 +178,12 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
timeVisible: true,
secondsVisible: false,
tickMarkFormatter: (time, tickMarkType, locale) => {
return TimezoneConfig.formatTickMark(time);
return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString();
},
},
localization: {
timeFormatter: (timestamp) => {
return TimezoneConfig.formatDate(timestamp * 1000);
return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString();
},
},
handleScroll: true,
@ -611,7 +201,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
equityChart.timeScale().fitContent();
}
// Pos Size Chart
const posSizeContainer = document.getElementById('posSizeChart');
if (posSizeContainer) {
posSizeContainer.innerHTML = '';
@ -625,12 +214,12 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
timeVisible: true,
secondsVisible: false,
tickMarkFormatter: (time, tickMarkType, locale) => {
return TimezoneConfig.formatTickMark(time);
return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString();
},
},
localization: {
timeFormatter: (timestamp) => {
return TimezoneConfig.formatDate(timestamp * 1000);
return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString();
},
},
handleScroll: true,
@ -651,7 +240,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
if (label) label.textContent = 'Position Size (USD)';
}
// Sync Time Scales
if (equityChart && posSizeChart) {
let isSyncing = false;
@ -667,7 +255,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
posSizeChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(posSizeChart, equityChart));
}
// Sync to Main Chart on Click
const syncToMain = (param) => {
if (!param.time || !window.dashboard || !window.dashboard.chart) return;
@ -675,7 +262,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
const currentRange = timeScale.getVisibleRange();
if (!currentRange) return;
// Calculate current width to preserve zoom level
const width = currentRange.to - currentRange.from;
const halfWidth = width / 2;
@ -691,12 +277,10 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
setTimeout(initCharts, 100);
// Toggle Logic
resultsDiv.querySelectorAll('.toggle-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const unit = btn.dataset.unit;
// Sync all toggle button groups
resultsDiv.querySelectorAll(`.toggle-btn`).forEach(b => {
if (b.dataset.unit === unit) b.classList.add('active');
else b.classList.remove('active');
@ -729,8 +313,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
});
}
let tradeMarkers = [];
function toggleSimulationMarkers(trades) {
if (tradeMarkers.length > 0) {
clearSimulationMarkers();
@ -744,7 +326,6 @@ function toggleSimulationMarkers(trades) {
const qtyVal = t.currentQty !== undefined ? `${t.currentQty.toFixed(4)} BTC` : '0';
const sizeStr = ` (${usdVal} / ${qtyVal})`;
// Entry marker
if (t.recordType === 'entry') {
markers.push({
time: t.time,
@ -755,7 +336,6 @@ function toggleSimulationMarkers(trades) {
});
}
// Exit marker
if (t.recordType === 'exit') {
markers.push({
time: t.time,
@ -767,7 +347,6 @@ function toggleSimulationMarkers(trades) {
}
});
// Sort markers by time
markers.sort((a, b) => a.time - b.time);
if (window.dashboard) {
@ -788,4 +367,4 @@ window.clearSimulationResults = function() {
const resultsDiv = document.getElementById('simulationResults');
if (resultsDiv) resultsDiv.style.display = 'none';
clearSimulationMarkers();
};
};