diff --git a/src/api/dashboard/static/js/indicators/hurst.js b/src/api/dashboard/static/js/indicators/hurst.js
index a582b93..b37c0f4 100644
--- a/src/api/dashboard/static/js/indicators/hurst.js
+++ b/src/api/dashboard/static/js/indicators/hurst.js
@@ -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 };
\ No newline at end of file
diff --git a/src/api/dashboard/static/js/strategies/index.js b/src/api/dashboard/static/js/strategies/index.js
new file mode 100644
index 0000000..a1cc6e1
--- /dev/null
+++ b/src/api/dashboard/static/js/strategies/index.js
@@ -0,0 +1,9 @@
+export const StrategyRegistry = {};
+
+export function registerStrategy(name, strategyModule) {
+ StrategyRegistry[name] = strategyModule;
+}
+
+export function getStrategy(name) {
+ return StrategyRegistry[name];
+}
diff --git a/src/api/dashboard/static/js/strategies/ping-pong.js b/src/api/dashboard/static/js/strategies/ping-pong.js
new file mode 100644
index 0000000..dbf5865
--- /dev/null
+++ b/src/api/dashboard/static/js/strategies/ping-pong.js
@@ -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 '
No active indicators on chart
';
+ }
+
+ return activeIndicators.map(ind => `
+
+ `).join('');
+ };
+
+ const autoDirChecked = saved?.autoDirection === true;
+ const disableManualStr = autoDirChecked ? 'disabled' : '';
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ },
+
+ 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';
+ }
+ }
+};
diff --git a/src/api/dashboard/static/js/ui/chart.js b/src/api/dashboard/static/js/ui/chart.js
index 5e5e14a..7ac055d 100644
--- a/src/api/dashboard/static/js/ui/chart.js
+++ b/src/api/dashboard/static/js/ui/chart.js
@@ -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);
}
}
diff --git a/src/api/dashboard/static/js/ui/hts-visualizer.js b/src/api/dashboard/static/js/ui/hts-visualizer.js
index bebc3dc..f476ec6 100644
--- a/src/api/dashboard/static/js/ui/hts-visualizer.js
+++ b/src/api/dashboard/static/js/ui/hts-visualizer.js
@@ -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;
diff --git a/src/api/dashboard/static/js/ui/indicators-panel-new.js b/src/api/dashboard/static/js/ui/indicators-panel-new.js
index c30713d..5d2038b 100644
--- a/src/api/dashboard/static/js/ui/indicators-panel-new.js
+++ b/src/api/dashboard/static/js/ui/indicators-panel-new.js
@@ -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) {
${input.type === 'select' ?
`` :
` 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)`);
diff --git a/src/api/dashboard/static/js/ui/indicators-panel.js b/src/api/dashboard/static/js/ui/indicators-panel.js
index e103d68..2937588 100644
--- a/src/api/dashboard/static/js/ui/indicators-panel.js
+++ b/src/api/dashboard/static/js/ui/indicators-panel.js
@@ -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) => {
diff --git a/src/api/dashboard/static/js/ui/markers-plugin.js b/src/api/dashboard/static/js/ui/markers-plugin.js
new file mode 100644
index 0000000..8270adc
--- /dev/null
+++ b/src/api/dashboard/static/js/ui/markers-plugin.js
@@ -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();
+ }
+}
diff --git a/src/api/dashboard/static/js/ui/signal-markers.js b/src/api/dashboard/static/js/ui/signal-markers.js
index e80191d..3474f13 100644
--- a/src/api/dashboard/static/js/ui/signal-markers.js
+++ b/src/api/dashboard/static/js/ui/signal-markers.js
@@ -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;
}
diff --git a/src/api/dashboard/static/js/ui/signals-calculator.js b/src/api/dashboard/static/js/ui/signals-calculator.js
index ad97c50..b1e8e62 100644
--- a/src/api/dashboard/static/js/ui/signals-calculator.js
+++ b/src/api/dashboard/static/js/ui/signals-calculator.js
@@ -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;
}
diff --git a/src/api/dashboard/static/js/ui/strategy-panel.js b/src/api/dashboard/static/js/ui/strategy-panel.js
index d7069aa..d768c23 100644
--- a/src/api/dashboard/static/js/ui/strategy-panel.js
+++ b/src/api/dashboard/static/js/ui/strategy-panel.js
@@ -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 = ``;
+ return;
}
container.innerHTML = `
@@ -185,319 +68,27 @@ export function renderStrategyPanel() {
`;
- document.getElementById('runSimulationBtn').addEventListener('click', runSimulation);
- document.getElementById('saveSimSettings').addEventListener('click', saveSettings);
-}
-
-function renderIndicatorChecklist(prefix) {
- if (activeIndicators.length === 0) {
- return 'No active indicators on chart
';
+ // Attach strategy specific listeners (like disabling dropdowns when auto-detect is on)
+ if (strategy.attachListeners) {
+ strategy.attachListeners();
}
-
- return activeIndicators.map(ind => `
-
- `).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();
-};
+};
\ No newline at end of file