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:
@ -2,6 +2,8 @@
|
|||||||
// Based on J.M. Hurst's cyclic price channel theory
|
// Based on J.M. Hurst's cyclic price channel theory
|
||||||
// Using RMA + ATR displacement method
|
// Using RMA + ATR displacement method
|
||||||
|
|
||||||
|
import { INTERVALS } from '../core/constants.js';
|
||||||
|
|
||||||
const SIGNAL_TYPES = {
|
const SIGNAL_TYPES = {
|
||||||
BUY: 'buy',
|
BUY: 'buy',
|
||||||
SELL: 'sell'
|
SELL: 'sell'
|
||||||
@ -14,47 +16,61 @@ const SIGNAL_COLORS = {
|
|||||||
|
|
||||||
class BaseIndicator {
|
class BaseIndicator {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
this.id = config.id;
|
this.id = config.id;
|
||||||
this.type = config.type;
|
this.type = config.type;
|
||||||
this.name = config.name;
|
this.name = config.name;
|
||||||
this.params = config.params || {};
|
this.params = config.params || {};
|
||||||
this.timeframe = config.timeframe || '1m';
|
this.timeframe = config.timeframe || 'chart';
|
||||||
this.series = [];
|
this.series = [];
|
||||||
this.visible = config.visible !== false;
|
this.visible = config.visible !== false;
|
||||||
this.cachedResults = null;
|
|
||||||
this.cachedMeta = null;
|
if (config.cachedResults === undefined) config.cachedResults = null;
|
||||||
this.lastSignalTimestamp = null;
|
if (config.cachedMeta === undefined) config.cachedMeta = null;
|
||||||
this.lastSignalType = 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)
|
// Optimized RMA that can start from a previous state
|
||||||
// Recreates Pine Script's ta.rma() exactly
|
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) {
|
function calculateRMA(sourceArray, length) {
|
||||||
const rma = new Array(sourceArray.length).fill(null);
|
const rma = new Array(sourceArray.length).fill(null);
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
const alpha = 1 / length;
|
const alpha = 1 / length;
|
||||||
|
|
||||||
// PineScript implicitly rounds float lengths for SMA initialization
|
|
||||||
const smaLength = Math.round(length);
|
const smaLength = Math.round(length);
|
||||||
|
|
||||||
for (let i = 0; i < sourceArray.length; i++) {
|
for (let i = 0; i < sourceArray.length; i++) {
|
||||||
if (i < smaLength - 1) {
|
if (i < smaLength - 1) {
|
||||||
// Accumulate first N-1 bars
|
|
||||||
sum += sourceArray[i];
|
sum += sourceArray[i];
|
||||||
} else if (i === smaLength - 1) {
|
} else if (i === smaLength - 1) {
|
||||||
// On the Nth bar, the first RMA value is the SMA
|
|
||||||
sum += sourceArray[i];
|
sum += sourceArray[i];
|
||||||
rma[i] = sum / smaLength;
|
rma[i] = sum / smaLength;
|
||||||
} else {
|
} else {
|
||||||
// Subsequent bars use the RMA formula
|
|
||||||
const prevRMA = rma[i - 1];
|
const prevRMA = rma[i - 1];
|
||||||
rma[i] = (prevRMA === null || isNaN(prevRMA))
|
rma[i] = (prevRMA === null || isNaN(prevRMA))
|
||||||
? alpha * sourceArray[i]
|
? sourceArray[i]
|
||||||
: alpha * sourceArray[i] + (1 - alpha) * prevRMA;
|
: alpha * sourceArray[i] + (1 - alpha) * prevRMA;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rma;
|
return rma;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,12 +109,76 @@ function calculateHurstSignal(indicator, lastCandle, prevCandle, values, prevVal
|
|||||||
return null;
|
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 {
|
export class HurstBandsIndicator extends BaseIndicator {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
super(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.markerBuyShape) this.params.markerBuyShape = 'custom';
|
||||||
if (!this.params.markerSellShape) this.params.markerSellShape = 'custom';
|
if (!this.params.markerSellShape) this.params.markerSellShape = 'custom';
|
||||||
if (!this.params.markerBuyColor) this.params.markerBuyColor = '#9e9e9e';
|
if (!this.params.markerBuyColor) this.params.markerBuyColor = '#9e9e9e';
|
||||||
@ -108,59 +188,207 @@ export class HurstBandsIndicator extends BaseIndicator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
calculate(candles) {
|
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 mcl_t = this.params.period || 30;
|
||||||
const mcm = this.params.multiplier || 1.8;
|
const mcm = this.params.multiplier || 1.8;
|
||||||
|
|
||||||
const mcl = mcl_t / 2;
|
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 mcl_2 = Math.round(mcl / 2);
|
||||||
|
|
||||||
const results = new Array(candles.length).fill(null);
|
const results = new Array(candles.length).fill(null);
|
||||||
const closes = candles.map(c => c.close);
|
const closes = candles.map(c => c.close);
|
||||||
|
|
||||||
|
// True Range for ATR
|
||||||
const trArray = candles.map((d, i) => {
|
const trArray = candles.map((d, i) => {
|
||||||
const prevClose = i > 0 ? candles[i - 1].close : null;
|
const prevClose = i > 0 ? candles[i - 1].close : null;
|
||||||
const high = d.high;
|
if (prevClose === null || isNaN(prevClose)) return d.high - d.low;
|
||||||
const low = d.low;
|
return Math.max(d.high - d.low, Math.abs(d.high - prevClose), Math.abs(d.low - prevClose));
|
||||||
|
|
||||||
if (prevClose === null || prevClose === undefined || isNaN(prevClose)) {
|
|
||||||
return high - low;
|
|
||||||
}
|
|
||||||
return Math.max(
|
|
||||||
high - low,
|
|
||||||
Math.abs(high - prevClose),
|
|
||||||
Math.abs(low - prevClose)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ma_mcl = calculateRMA(closes, mcl);
|
const ma_mcl = calculateRMA(closes, mcl);
|
||||||
const atr = calculateRMA(trArray, mcl);
|
const atr = calculateRMA(trArray, mcl);
|
||||||
|
|
||||||
for (let i = 0; i < candles.length; i++) {
|
for (let i = 0; i < candles.length; i++) {
|
||||||
const src = closes[i];
|
|
||||||
const mcm_off = mcm * (atr[i] || 0);
|
const mcm_off = mcm * (atr[i] || 0);
|
||||||
|
|
||||||
const historicalIndex = i - mcl_2;
|
const historicalIndex = i - mcl_2;
|
||||||
const historical_ma = historicalIndex >= 0 ? ma_mcl[historicalIndex] : null;
|
const historical_ma = historicalIndex >= 0 ? ma_mcl[historicalIndex] : null;
|
||||||
|
const centerLine = (historical_ma === null || isNaN(historical_ma)) ? closes[i] : historical_ma;
|
||||||
const centerLine = (historical_ma === null || historical_ma === undefined || isNaN(historical_ma)) ? src : historical_ma;
|
|
||||||
|
|
||||||
results[i] = {
|
results[i] = {
|
||||||
|
time: candles[i].time,
|
||||||
upper: centerLine + mcm_off,
|
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;
|
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() {
|
getMetadata() {
|
||||||
return {
|
return {
|
||||||
name: 'Hurst Bands',
|
name: 'Hurst Bands',
|
||||||
description: 'Cyclic price channels based on Hurst theory',
|
description: 'Cyclic price channels based on Hurst theory',
|
||||||
inputs: [
|
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: '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 }
|
{ 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'
|
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 };
|
export { calculateHurstSignal };
|
||||||
9
src/api/dashboard/static/js/strategies/index.js
Normal file
9
src/api/dashboard/static/js/strategies/index.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export const StrategyRegistry = {};
|
||||||
|
|
||||||
|
export function registerStrategy(name, strategyModule) {
|
||||||
|
StrategyRegistry[name] = strategyModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStrategy(name) {
|
||||||
|
return StrategyRegistry[name];
|
||||||
|
}
|
||||||
612
src/api/dashboard/static/js/strategies/ping-pong.js
Normal file
612
src/api/dashboard/static/js/strategies/ping-pong.js
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -4,6 +4,134 @@ import { calculateSignalMarkers } from './signal-markers.js';
|
|||||||
import { updateIndicatorCandles } from './indicators-panel-new.js';
|
import { updateIndicatorCandles } from './indicators-panel-new.js';
|
||||||
import { TimezoneConfig } from '../config/timezone.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) {
|
function formatDate(timestamp) {
|
||||||
return TimezoneConfig.formatDate(timestamp);
|
return TimezoneConfig.formatDate(timestamp);
|
||||||
}
|
}
|
||||||
@ -91,8 +219,20 @@ constructor() {
|
|||||||
|
|
||||||
setAvgPriceData(data) {
|
setAvgPriceData(data) {
|
||||||
if (this.avgPriceSeries) {
|
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() {
|
clearAvgPriceData() {
|
||||||
@ -509,7 +649,12 @@ async loadNewData() {
|
|||||||
this.lastCandleTimestamp = latest.time;
|
this.lastCandleTimestamp = latest.time;
|
||||||
|
|
||||||
chartData.forEach(candle => {
|
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);
|
this.candleSeries.update(candle);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -700,78 +845,16 @@ async loadSignals() {
|
|||||||
// Re-sort combined markers by time
|
// Re-sort combined markers by time
|
||||||
markers.sort((a, b) => a.time - b.time);
|
markers.sort((a, b) => a.time - b.time);
|
||||||
|
|
||||||
// If we have a marker controller, update markers through it
|
// Use custom primitive for markers in v5
|
||||||
if (this.markerController) {
|
try {
|
||||||
try {
|
if (!this.markerPrimitive) {
|
||||||
this.markerController.setMarkers(markers);
|
this.markerPrimitive = new SeriesMarkersPrimitive();
|
||||||
return;
|
this.candleSeries.attachPrimitive(this.markerPrimitive);
|
||||||
} catch (e) {
|
|
||||||
console.warn('[SignalMarkers] setMarkers error:', e.message);
|
|
||||||
this.markerController = null;
|
|
||||||
}
|
}
|
||||||
|
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() {
|
renderTA() {
|
||||||
@ -954,6 +1037,9 @@ switchTimeframe(interval) {
|
|||||||
|
|
||||||
window.clearSimulationResults?.();
|
window.clearSimulationResults?.();
|
||||||
window.updateTimeframeDisplay?.();
|
window.updateTimeframeDisplay?.();
|
||||||
|
|
||||||
|
// Notify indicators of timeframe change for recalculation
|
||||||
|
window.onTimeframeChange?.(interval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -201,8 +201,20 @@ export class HTSVisualizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candleSeries = this.candleData?.series;
|
const candleSeries = this.candleData?.series;
|
||||||
if (candleSeries && typeof candleSeries.setMarkers === 'function') {
|
if (candleSeries) {
|
||||||
candleSeries.setMarkers(markers);
|
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;
|
return markers;
|
||||||
|
|||||||
@ -112,6 +112,23 @@ export function setActiveIndicators(indicators) {
|
|||||||
|
|
||||||
window.getActiveIndicators = getActiveIndicators;
|
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
|
// Render main panel
|
||||||
export function renderIndicatorPanel() {
|
export function renderIndicatorPanel() {
|
||||||
const container = document.getElementById('indicatorPanel');
|
const container = document.getElementById('indicatorPanel');
|
||||||
@ -315,7 +332,10 @@ function renderIndicatorConfig(indicator, meta) {
|
|||||||
<label>${input.label}</label>
|
<label>${input.label}</label>
|
||||||
${input.type === 'select' ?
|
${input.type === 'select' ?
|
||||||
`<select onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '${input.name}', this.value)">
|
`<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>` :
|
</select>` :
|
||||||
`<input
|
`<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -547,6 +567,7 @@ window.updateIndicatorSetting = function(id, key, value) {
|
|||||||
indicator.params[key] = value;
|
indicator.params[key] = value;
|
||||||
indicator.lastSignalTimestamp = null;
|
indicator.lastSignalTimestamp = null;
|
||||||
indicator.lastSignalType = null;
|
indicator.lastSignalType = null;
|
||||||
|
indicator.cachedResults = null; // Clear cache when params change
|
||||||
drawIndicatorsOnChart();
|
drawIndicatorsOnChart();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -753,6 +774,7 @@ function addIndicator(type) {
|
|||||||
// Override with Hurst-specific defaults
|
// Override with Hurst-specific defaults
|
||||||
if (type === 'hurst') {
|
if (type === 'hurst') {
|
||||||
params._lineWidth = 1;
|
params._lineWidth = 1;
|
||||||
|
params.timeframe = 'chart';
|
||||||
params.markerBuyShape = 'custom';
|
params.markerBuyShape = 'custom';
|
||||||
params.markerSellShape = 'custom';
|
params.markerSellShape = 'custom';
|
||||||
params.markerBuyColor = '#9e9e9e';
|
params.markerBuyColor = '#9e9e9e';
|
||||||
@ -789,19 +811,21 @@ function saveUserPresets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
|
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
|
||||||
// Recalculate with current TF candles
|
// Recalculate with current TF candles (or use cached if they exist and are the correct length)
|
||||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: START`);
|
let results = indicator.cachedResults;
|
||||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: Input candles = ${candles.length}`);
|
if (!results || !Array.isArray(results) || results.length !== candles.length) {
|
||||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: Calling instance.calculate()...`);
|
console.log(`[renderIndicatorOnPane] ${indicator.name}: Calling instance.calculate()...`);
|
||||||
|
results = instance.calculate(candles);
|
||||||
|
indicator.cachedResults = results;
|
||||||
|
}
|
||||||
|
|
||||||
const results = instance.calculate(candles);
|
if (!results || !Array.isArray(results)) {
|
||||||
|
console.error(`[renderIndicatorOnPane] ${indicator.name}: Failed to get valid results (got ${typeof results})`);
|
||||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: calculate() returned ${results?.length || 0} results`);
|
return;
|
||||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: Expected ${candles.length} results, got ${results?.length || 0}`);
|
}
|
||||||
|
|
||||||
if (results.length !== candles.length) {
|
if (results.length !== candles.length) {
|
||||||
console.error(`[renderIndicatorOnPane] ${indicator.name}: MISMATCH! Expected ${candles.length} results but got ${results.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
|
// 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 lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
|
||||||
const lineWidth = indicator.params._lineWidth || 1;
|
const lineWidth = indicator.params._lineWidth || 1;
|
||||||
|
|
||||||
const firstNonNull = results?.find(r => r !== null && r !== undefined);
|
// Improved detection of object-based results (multiple plots)
|
||||||
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
|
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 plotsCreated = 0;
|
||||||
let dataPointsAdded = 0;
|
let dataPointsAdded = 0;
|
||||||
@ -842,7 +877,7 @@ function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, li
|
|||||||
value = results[i];
|
value = results[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value !== null && value !== undefined) {
|
if (value !== null && value !== undefined && typeof value === 'number' && Number.isFinite(value)) {
|
||||||
if (firstDataIndex === -1) {
|
if (firstDataIndex === -1) {
|
||||||
firstDataIndex = i;
|
firstDataIndex = i;
|
||||||
}
|
}
|
||||||
@ -1033,12 +1068,20 @@ export function drawIndicatorsOnChart() {
|
|||||||
const instance = new IndicatorClass(ind);
|
const instance = new IndicatorClass(ind);
|
||||||
const meta = instance.getMetadata();
|
const meta = instance.getMetadata();
|
||||||
|
|
||||||
// Store calculated results and metadata for signal calculation
|
// Store calculated results and metadata for signal calculation
|
||||||
const results = instance.calculate(candles);
|
let results = ind.cachedResults;
|
||||||
ind.cachedResults = results;
|
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;
|
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;
|
const warmupPeriod = ind.params?.period || 44;
|
||||||
console.log(`[Indicators] ${ind.name}: ${validResults.length} valid results (need ${warmupPeriod} candles warmup)`);
|
console.log(`[Indicators] ${ind.name}: ${validResults.length} valid results (need ${warmupPeriod} candles warmup)`);
|
||||||
|
|
||||||
|
|||||||
@ -240,6 +240,7 @@ export function addIndicator(type) {
|
|||||||
|
|
||||||
// Set Hurst-specific defaults
|
// Set Hurst-specific defaults
|
||||||
if (type === 'hurst') {
|
if (type === 'hurst') {
|
||||||
|
params.timeframe = 'chart';
|
||||||
params.markerBuyShape = 'custom';
|
params.markerBuyShape = 'custom';
|
||||||
params.markerSellShape = 'custom';
|
params.markerSellShape = 'custom';
|
||||||
params.markerBuyColor = '#9e9e9e';
|
params.markerBuyColor = '#9e9e9e';
|
||||||
@ -492,13 +493,17 @@ export function drawIndicatorsOnChart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
|
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 = [];
|
indicator.series = [];
|
||||||
|
|
||||||
const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
|
const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
|
||||||
const lineWidth = indicator.params._lineWidth || 1;
|
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';
|
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
|
||||||
|
|
||||||
meta.plots.forEach((plot, plotIdx) => {
|
meta.plots.forEach((plot, plotIdx) => {
|
||||||
|
|||||||
117
src/api/dashboard/static/js/ui/markers-plugin.js
Normal file
117
src/api/dashboard/static/js/ui/markers-plugin.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,15 +15,18 @@ export function calculateSignalMarkers(candles) {
|
|||||||
|
|
||||||
console.log('[SignalMarkers] Processing indicator:', indicator.type, 'showMarkers:', indicator.params.showMarkers);
|
console.log('[SignalMarkers] Processing indicator:', indicator.type, 'showMarkers:', indicator.params.showMarkers);
|
||||||
|
|
||||||
const IndicatorClass = IndicatorRegistry[indicator.type];
|
// Use cache if available
|
||||||
if (!IndicatorClass) {
|
let results = indicator.cachedResults;
|
||||||
continue;
|
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);
|
if (!results || !Array.isArray(results) || results.length === 0) {
|
||||||
const results = instance.calculate(candles);
|
|
||||||
|
|
||||||
if (!results || results.length === 0) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -96,14 +96,17 @@ function calculateHistoricalCrossovers(activeIndicators, candles) {
|
|||||||
activeIndicators.forEach(indicator => {
|
activeIndicators.forEach(indicator => {
|
||||||
const indicatorType = indicator.type || indicator.indicatorType;
|
const indicatorType = indicator.type || indicator.indicatorType;
|
||||||
|
|
||||||
// Recalculate indicator values for all candles
|
// Recalculate indicator values for all candles (use cache if valid)
|
||||||
const IndicatorClass = IndicatorRegistry[indicatorType];
|
let results = indicator.cachedResults;
|
||||||
if (!IndicatorClass) return;
|
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);
|
if (!results || !Array.isArray(results) || results.length === 0) return;
|
||||||
const results = instance.calculate(candles);
|
|
||||||
|
|
||||||
if (!results || results.length === 0) return;
|
|
||||||
|
|
||||||
// Find the most recent crossover by going backwards from the newest candle
|
// Find the most recent crossover by going backwards from the newest candle
|
||||||
// candles are sorted oldest first, newest last
|
// candles are sorted oldest first, newest last
|
||||||
@ -276,7 +279,7 @@ export function calculateAllIndicatorSignals() {
|
|||||||
let results = indicator.cachedResults;
|
let results = indicator.cachedResults;
|
||||||
let meta = indicator.cachedMeta;
|
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);
|
const instance = new IndicatorClass(indicator);
|
||||||
meta = instance.getMetadata();
|
meta = instance.getMetadata();
|
||||||
results = instance.calculate(candles);
|
results = instance.calculate(candles);
|
||||||
@ -284,8 +287,8 @@ export function calculateAllIndicatorSignals() {
|
|||||||
indicator.cachedMeta = meta;
|
indicator.cachedMeta = meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!results || results.length === 0) {
|
if (!results || !Array.isArray(results) || results.length === 0) {
|
||||||
console.log('[Signals] No results for indicator:', indicator.type);
|
console.log('[Signals] No valid results for indicator:', indicator.type);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 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) {
|
function formatDisplayDate(timestamp) {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
@ -20,49 +17,6 @@ function formatDisplayDate(timestamp) {
|
|||||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
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() {
|
export function initStrategyPanel() {
|
||||||
window.renderStrategyPanel = renderStrategyPanel;
|
window.renderStrategyPanel = renderStrategyPanel;
|
||||||
renderStrategyPanel();
|
renderStrategyPanel();
|
||||||
@ -88,94 +42,23 @@ export function renderStrategyPanel() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
activeIndicators = window.getActiveIndicators?.() || [];
|
activeIndicators = window.getActiveIndicators?.() || [];
|
||||||
const saved = getSavedSettings();
|
|
||||||
|
|
||||||
// Format initial values for display
|
// For now, we only have Ping-Pong. Later we can add a strategy selector.
|
||||||
let startDisplay = saved?.startDate || '01/01/2026 00:00';
|
const currentStrategyId = 'ping_pong';
|
||||||
let stopDisplay = saved?.stopDate || '';
|
const strategy = getStrategy(currentStrategyId);
|
||||||
|
|
||||||
// If the saved value is in ISO format (from previous version), convert it
|
if (!strategy) {
|
||||||
if (startDisplay.includes('T')) {
|
container.innerHTML = `<div class="sidebar-section">Strategy not found.</div>`;
|
||||||
startDisplay = formatDisplayDate(new Date(startDisplay));
|
return;
|
||||||
}
|
|
||||||
if (stopDisplay.includes('T')) {
|
|
||||||
stopDisplay = formatDisplayDate(new Date(stopDisplay));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="sidebar-section-header">
|
<div class="sidebar-section-header">
|
||||||
<span>⚙️</span> Ping-Pong Strategy
|
<span>⚙️</span> ${strategy.name} Strategy
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-section-content">
|
<div class="sidebar-section-content">
|
||||||
<div class="sim-input-group">
|
${strategy.renderUI(activeIndicators, formatDisplayDate)}
|
||||||
<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>
|
|
||||||
|
|
||||||
<button class="sim-run-btn" id="runSimulationBtn">Run Simulation</button>
|
<button class="sim-run-btn" id="runSimulationBtn">Run Simulation</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -185,319 +68,27 @@ export function renderStrategyPanel() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('runSimulationBtn').addEventListener('click', runSimulation);
|
// Attach strategy specific listeners (like disabling dropdowns when auto-detect is on)
|
||||||
document.getElementById('saveSimSettings').addEventListener('click', saveSettings);
|
if (strategy.attachListeners) {
|
||||||
}
|
strategy.attachListeners();
|
||||||
|
|
||||||
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>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return activeIndicators.map(ind => `
|
document.getElementById('runSimulationBtn').addEventListener('click', () => {
|
||||||
<label class="checklist-item">
|
strategy.runSimulation(activeIndicators, displayResults);
|
||||||
<input type="checkbox" data-id="${ind.id}" class="sim-${prefix}-check">
|
});
|
||||||
<span>${ind.name}</span>
|
|
||||||
</label>
|
|
||||||
`).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSimulation() {
|
// Keep the display logic here so all strategies can use the same rendering for results
|
||||||
const btn = document.getElementById('runSimulationBtn');
|
let equitySeries = null;
|
||||||
btn.disabled = true;
|
let equityChart = null;
|
||||||
const originalBtnText = btn.textContent;
|
let posSeries = null;
|
||||||
btn.textContent = 'Preparing Data...';
|
let posSizeChart = null;
|
||||||
|
let tradeMarkers = [];
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayResults(trades, equityData, config, endPrice, avgPriceData, posSizeData) {
|
function displayResults(trades, equityData, config, endPrice, avgPriceData, posSizeData) {
|
||||||
const resultsDiv = document.getElementById('simulationResults');
|
const resultsDiv = document.getElementById('simulationResults');
|
||||||
resultsDiv.style.display = 'block';
|
resultsDiv.style.display = 'block';
|
||||||
|
|
||||||
// Update main chart with avg price
|
|
||||||
if (window.dashboard) {
|
if (window.dashboard) {
|
||||||
window.dashboard.setAvgPriceData(avgPriceData);
|
window.dashboard.setAvgPriceData(avgPriceData);
|
||||||
}
|
}
|
||||||
@ -574,7 +165,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
|||||||
|
|
||||||
// Create Charts
|
// Create Charts
|
||||||
const initCharts = () => {
|
const initCharts = () => {
|
||||||
// Equity Chart
|
|
||||||
const equityContainer = document.getElementById('equityChart');
|
const equityContainer = document.getElementById('equityChart');
|
||||||
if (equityContainer) {
|
if (equityContainer) {
|
||||||
equityContainer.innerHTML = '';
|
equityContainer.innerHTML = '';
|
||||||
@ -588,12 +178,12 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
|||||||
timeVisible: true,
|
timeVisible: true,
|
||||||
secondsVisible: false,
|
secondsVisible: false,
|
||||||
tickMarkFormatter: (time, tickMarkType, locale) => {
|
tickMarkFormatter: (time, tickMarkType, locale) => {
|
||||||
return TimezoneConfig.formatTickMark(time);
|
return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
localization: {
|
localization: {
|
||||||
timeFormatter: (timestamp) => {
|
timeFormatter: (timestamp) => {
|
||||||
return TimezoneConfig.formatDate(timestamp * 1000);
|
return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handleScroll: true,
|
handleScroll: true,
|
||||||
@ -611,7 +201,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
|||||||
equityChart.timeScale().fitContent();
|
equityChart.timeScale().fitContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pos Size Chart
|
|
||||||
const posSizeContainer = document.getElementById('posSizeChart');
|
const posSizeContainer = document.getElementById('posSizeChart');
|
||||||
if (posSizeContainer) {
|
if (posSizeContainer) {
|
||||||
posSizeContainer.innerHTML = '';
|
posSizeContainer.innerHTML = '';
|
||||||
@ -625,12 +214,12 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
|||||||
timeVisible: true,
|
timeVisible: true,
|
||||||
secondsVisible: false,
|
secondsVisible: false,
|
||||||
tickMarkFormatter: (time, tickMarkType, locale) => {
|
tickMarkFormatter: (time, tickMarkType, locale) => {
|
||||||
return TimezoneConfig.formatTickMark(time);
|
return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
localization: {
|
localization: {
|
||||||
timeFormatter: (timestamp) => {
|
timeFormatter: (timestamp) => {
|
||||||
return TimezoneConfig.formatDate(timestamp * 1000);
|
return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handleScroll: true,
|
handleScroll: true,
|
||||||
@ -651,7 +240,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
|||||||
if (label) label.textContent = 'Position Size (USD)';
|
if (label) label.textContent = 'Position Size (USD)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync Time Scales
|
|
||||||
if (equityChart && posSizeChart) {
|
if (equityChart && posSizeChart) {
|
||||||
let isSyncing = false;
|
let isSyncing = false;
|
||||||
|
|
||||||
@ -667,7 +255,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
|||||||
posSizeChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(posSizeChart, equityChart));
|
posSizeChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(posSizeChart, equityChart));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync to Main Chart on Click
|
|
||||||
const syncToMain = (param) => {
|
const syncToMain = (param) => {
|
||||||
if (!param.time || !window.dashboard || !window.dashboard.chart) return;
|
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();
|
const currentRange = timeScale.getVisibleRange();
|
||||||
if (!currentRange) return;
|
if (!currentRange) return;
|
||||||
|
|
||||||
// Calculate current width to preserve zoom level
|
|
||||||
const width = currentRange.to - currentRange.from;
|
const width = currentRange.to - currentRange.from;
|
||||||
const halfWidth = width / 2;
|
const halfWidth = width / 2;
|
||||||
|
|
||||||
@ -691,12 +277,10 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
|||||||
|
|
||||||
setTimeout(initCharts, 100);
|
setTimeout(initCharts, 100);
|
||||||
|
|
||||||
// Toggle Logic
|
|
||||||
resultsDiv.querySelectorAll('.toggle-btn').forEach(btn => {
|
resultsDiv.querySelectorAll('.toggle-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
const unit = btn.dataset.unit;
|
const unit = btn.dataset.unit;
|
||||||
|
|
||||||
// Sync all toggle button groups
|
|
||||||
resultsDiv.querySelectorAll(`.toggle-btn`).forEach(b => {
|
resultsDiv.querySelectorAll(`.toggle-btn`).forEach(b => {
|
||||||
if (b.dataset.unit === unit) b.classList.add('active');
|
if (b.dataset.unit === unit) b.classList.add('active');
|
||||||
else b.classList.remove('active');
|
else b.classList.remove('active');
|
||||||
@ -729,8 +313,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let tradeMarkers = [];
|
|
||||||
|
|
||||||
function toggleSimulationMarkers(trades) {
|
function toggleSimulationMarkers(trades) {
|
||||||
if (tradeMarkers.length > 0) {
|
if (tradeMarkers.length > 0) {
|
||||||
clearSimulationMarkers();
|
clearSimulationMarkers();
|
||||||
@ -744,7 +326,6 @@ function toggleSimulationMarkers(trades) {
|
|||||||
const qtyVal = t.currentQty !== undefined ? `${t.currentQty.toFixed(4)} BTC` : '0';
|
const qtyVal = t.currentQty !== undefined ? `${t.currentQty.toFixed(4)} BTC` : '0';
|
||||||
const sizeStr = ` (${usdVal} / ${qtyVal})`;
|
const sizeStr = ` (${usdVal} / ${qtyVal})`;
|
||||||
|
|
||||||
// Entry marker
|
|
||||||
if (t.recordType === 'entry') {
|
if (t.recordType === 'entry') {
|
||||||
markers.push({
|
markers.push({
|
||||||
time: t.time,
|
time: t.time,
|
||||||
@ -755,7 +336,6 @@ function toggleSimulationMarkers(trades) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exit marker
|
|
||||||
if (t.recordType === 'exit') {
|
if (t.recordType === 'exit') {
|
||||||
markers.push({
|
markers.push({
|
||||||
time: t.time,
|
time: t.time,
|
||||||
@ -767,7 +347,6 @@ function toggleSimulationMarkers(trades) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort markers by time
|
|
||||||
markers.sort((a, b) => a.time - b.time);
|
markers.sort((a, b) => a.time - b.time);
|
||||||
|
|
||||||
if (window.dashboard) {
|
if (window.dashboard) {
|
||||||
@ -788,4 +367,4 @@ window.clearSimulationResults = function() {
|
|||||||
const resultsDiv = document.getElementById('simulationResults');
|
const resultsDiv = document.getElementById('simulationResults');
|
||||||
if (resultsDiv) resultsDiv.style.display = 'none';
|
if (resultsDiv) resultsDiv.style.display = 'none';
|
||||||
clearSimulationMarkers();
|
clearSimulationMarkers();
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user