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
|
||||
// Using RMA + ATR displacement method
|
||||
|
||||
import { INTERVALS } from '../core/constants.js';
|
||||
|
||||
const SIGNAL_TYPES = {
|
||||
BUY: 'buy',
|
||||
SELL: 'sell'
|
||||
@ -14,47 +16,61 @@ const SIGNAL_COLORS = {
|
||||
|
||||
class BaseIndicator {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.id = config.id;
|
||||
this.type = config.type;
|
||||
this.name = config.name;
|
||||
this.params = config.params || {};
|
||||
this.timeframe = config.timeframe || '1m';
|
||||
this.timeframe = config.timeframe || 'chart';
|
||||
this.series = [];
|
||||
this.visible = config.visible !== false;
|
||||
this.cachedResults = null;
|
||||
this.cachedMeta = null;
|
||||
this.lastSignalTimestamp = null;
|
||||
this.lastSignalType = null;
|
||||
|
||||
if (config.cachedResults === undefined) config.cachedResults = null;
|
||||
if (config.cachedMeta === undefined) config.cachedMeta = null;
|
||||
if (config.cachedTimeframe === undefined) config.cachedTimeframe = null;
|
||||
if (config.isFetching === undefined) config.isFetching = false;
|
||||
if (config.lastProcessedTime === undefined) config.lastProcessedTime = 0;
|
||||
}
|
||||
|
||||
get cachedResults() { return this.config.cachedResults; }
|
||||
set cachedResults(v) { this.config.cachedResults = v; }
|
||||
get cachedMeta() { return this.config.cachedMeta; }
|
||||
set cachedMeta(v) { this.config.cachedMeta = v; }
|
||||
get cachedTimeframe() { return this.config.cachedTimeframe; }
|
||||
set cachedTimeframe(v) { this.config.cachedTimeframe = v; }
|
||||
get isFetching() { return this.config.isFetching; }
|
||||
set isFetching(v) { this.config.isFetching = v; }
|
||||
get lastProcessedTime() { return this.config.lastProcessedTime; }
|
||||
set lastProcessedTime(v) { this.config.lastProcessedTime = v; }
|
||||
}
|
||||
|
||||
// Calculate RMA (Rolling Moving Average - Wilder's method)
|
||||
// Recreates Pine Script's ta.rma() exactly
|
||||
// Optimized RMA that can start from a previous state
|
||||
function calculateRMAIncremental(sourceValue, prevRMA, length) {
|
||||
if (prevRMA === null || isNaN(prevRMA)) return sourceValue;
|
||||
const alpha = 1 / length;
|
||||
return alpha * sourceValue + (1 - alpha) * prevRMA;
|
||||
}
|
||||
|
||||
// Calculate RMA for a full array with stable initialization
|
||||
function calculateRMA(sourceArray, length) {
|
||||
const rma = new Array(sourceArray.length).fill(null);
|
||||
let sum = 0;
|
||||
const alpha = 1 / length;
|
||||
|
||||
// PineScript implicitly rounds float lengths for SMA initialization
|
||||
const smaLength = Math.round(length);
|
||||
|
||||
for (let i = 0; i < sourceArray.length; i++) {
|
||||
if (i < smaLength - 1) {
|
||||
// Accumulate first N-1 bars
|
||||
sum += sourceArray[i];
|
||||
} else if (i === smaLength - 1) {
|
||||
// On the Nth bar, the first RMA value is the SMA
|
||||
sum += sourceArray[i];
|
||||
rma[i] = sum / smaLength;
|
||||
} else {
|
||||
// Subsequent bars use the RMA formula
|
||||
const prevRMA = rma[i - 1];
|
||||
rma[i] = (prevRMA === null || isNaN(prevRMA))
|
||||
? alpha * sourceArray[i]
|
||||
? sourceArray[i]
|
||||
: alpha * sourceArray[i] + (1 - alpha) * prevRMA;
|
||||
}
|
||||
}
|
||||
|
||||
return rma;
|
||||
}
|
||||
|
||||
@ -93,12 +109,76 @@ function calculateHurstSignal(indicator, lastCandle, prevCandle, values, prevVal
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEffectiveTimeframe(params) {
|
||||
return params.timeframe === 'chart' ? window.dashboard?.currentInterval || '1m' : params.timeframe;
|
||||
}
|
||||
|
||||
function intervalToSeconds(interval) {
|
||||
const amount = parseInt(interval);
|
||||
const unit = interval.replace(/[0-9]/g, '');
|
||||
|
||||
switch (unit) {
|
||||
case 'm': return amount * 60;
|
||||
case 'h': return amount * 3600;
|
||||
case 'd': return amount * 86400;
|
||||
case 'w': return amount * 604800;
|
||||
case 'M': return amount * 2592000;
|
||||
default: return 60;
|
||||
}
|
||||
}
|
||||
|
||||
async function getCandlesForTimeframe(tf, startTime, endTime) {
|
||||
const url = `/api/v1/candles?symbol=BTC&interval=${tf}&start=${startTime.toISOString()}&end=${endTime.toISOString()}&limit=5000`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch candles for ${tf}:`, response.status, response.statusText);
|
||||
return [];
|
||||
}
|
||||
const data = await response.json();
|
||||
// API returns newest first (desc), but indicators need oldest first (asc)
|
||||
// Also convert time to numeric seconds to match targetCandles
|
||||
return (data.candles || []).reverse().map(c => ({
|
||||
...c,
|
||||
time: Math.floor(new Date(c.time).getTime() / 1000)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Robust forward filling for MTF data.
|
||||
* @param {Array} results - MTF results (e.g. 5m)
|
||||
* @param {Array} targetCandles - Chart candles (e.g. 1m)
|
||||
*/
|
||||
function forwardFillResults(results, targetCandles) {
|
||||
if (!results || results.length === 0) {
|
||||
return new Array(targetCandles.length).fill(null);
|
||||
}
|
||||
|
||||
const filled = new Array(targetCandles.length).fill(null);
|
||||
let resIdx = 0;
|
||||
|
||||
for (let i = 0; i < targetCandles.length; i++) {
|
||||
const targetTime = targetCandles[i].time;
|
||||
|
||||
// Advance result index while next result time is <= target time
|
||||
while (resIdx < results.length - 1 && results[resIdx + 1].time <= targetTime) {
|
||||
resIdx++;
|
||||
}
|
||||
|
||||
// If the current result is valid for this target time, use it
|
||||
// (result time must be <= target time)
|
||||
if (results[resIdx] && results[resIdx].time <= targetTime) {
|
||||
filled[i] = results[resIdx];
|
||||
}
|
||||
}
|
||||
|
||||
return filled;
|
||||
}
|
||||
|
||||
export class HurstBandsIndicator extends BaseIndicator {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.lastSignalTimestamp = null;
|
||||
this.lastSignalType = null;
|
||||
|
||||
if (!this.params.timeframe) this.params.timeframe = 'chart';
|
||||
if (!this.params.markerBuyShape) this.params.markerBuyShape = 'custom';
|
||||
if (!this.params.markerSellShape) this.params.markerSellShape = 'custom';
|
||||
if (!this.params.markerBuyColor) this.params.markerBuyColor = '#9e9e9e';
|
||||
@ -108,59 +188,207 @@ export class HurstBandsIndicator extends BaseIndicator {
|
||||
}
|
||||
|
||||
calculate(candles) {
|
||||
const effectiveTf = getEffectiveTimeframe(this.params);
|
||||
const lastCandle = candles[candles.length - 1];
|
||||
|
||||
// Case 1: Different timeframe (MTF)
|
||||
if (effectiveTf !== window.dashboard?.currentInterval && this.params.timeframe !== 'chart') {
|
||||
// If we have cached results, try to forward fill them to match the current candle count
|
||||
if (this.cachedResults && this.cachedTimeframe === effectiveTf) {
|
||||
// If results are stale (last result time is behind last candle time), trigger background fetch
|
||||
const lastResult = this.cachedResults[this.cachedResults.length - 1];
|
||||
const needsFetch = !this.isFetching && (!lastResult || lastCandle.time > lastResult.time + (intervalToSeconds(effectiveTf) / 2));
|
||||
|
||||
if (needsFetch) {
|
||||
this._fetchAndCalculateMtf(effectiveTf, candles);
|
||||
}
|
||||
|
||||
// If length matches exactly and params haven't changed, return
|
||||
if (this.cachedResults.length === candles.length && !this.shouldRecalculate()) {
|
||||
return this.cachedResults;
|
||||
}
|
||||
|
||||
// If length differs (e.g. new 1m candle but 5m not fetched yet), forward fill
|
||||
const filled = forwardFillResults(this.cachedResults.filter(r => r !== null), candles);
|
||||
this.cachedResults = filled;
|
||||
return filled;
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
if (!this.isFetching) {
|
||||
this._fetchAndCalculateMtf(effectiveTf, candles);
|
||||
}
|
||||
return new Array(candles.length).fill(null);
|
||||
}
|
||||
|
||||
// Case 2: Same timeframe as chart (Incremental or Full)
|
||||
// Check if we can do incremental update
|
||||
if (this.cachedResults &&
|
||||
this.cachedResults.length > 0 &&
|
||||
this.cachedTimeframe === effectiveTf &&
|
||||
!this.shouldRecalculate() &&
|
||||
candles.length >= this.cachedResults.length &&
|
||||
candles[this.cachedResults.length - 1].time === this.cachedResults[this.cachedResults.length - 1].time) {
|
||||
|
||||
// Only calculate new candles
|
||||
if (candles.length > this.cachedResults.length) {
|
||||
const newResults = this._calculateIncremental(candles, this.cachedResults);
|
||||
this.cachedResults = newResults;
|
||||
}
|
||||
return this.cachedResults;
|
||||
}
|
||||
|
||||
// Full calculation
|
||||
const results = this._calculateCore(candles);
|
||||
this.cachedTimeframe = effectiveTf;
|
||||
this.updateCachedMeta(this.params);
|
||||
this.cachedResults = results;
|
||||
return results;
|
||||
}
|
||||
|
||||
_calculateCore(candles) {
|
||||
const mcl_t = this.params.period || 30;
|
||||
const mcm = this.params.multiplier || 1.8;
|
||||
|
||||
const mcl = mcl_t / 2;
|
||||
|
||||
// FIX: PineScript rounds implicit floats for history references [].
|
||||
// 15/2 = 7.5. Pine rounds this to 8. Math.floor gives 7.
|
||||
const mcl_2 = Math.round(mcl / 2);
|
||||
|
||||
const results = new Array(candles.length).fill(null);
|
||||
const closes = candles.map(c => c.close);
|
||||
|
||||
// True Range for ATR
|
||||
const trArray = candles.map((d, i) => {
|
||||
const prevClose = i > 0 ? candles[i - 1].close : null;
|
||||
const high = d.high;
|
||||
const low = d.low;
|
||||
|
||||
if (prevClose === null || prevClose === undefined || isNaN(prevClose)) {
|
||||
return high - low;
|
||||
}
|
||||
return Math.max(
|
||||
high - low,
|
||||
Math.abs(high - prevClose),
|
||||
Math.abs(low - prevClose)
|
||||
);
|
||||
if (prevClose === null || isNaN(prevClose)) return d.high - d.low;
|
||||
return Math.max(d.high - d.low, Math.abs(d.high - prevClose), Math.abs(d.low - prevClose));
|
||||
});
|
||||
|
||||
const ma_mcl = calculateRMA(closes, mcl);
|
||||
const atr = calculateRMA(trArray, mcl);
|
||||
|
||||
for (let i = 0; i < candles.length; i++) {
|
||||
const src = closes[i];
|
||||
const mcm_off = mcm * (atr[i] || 0);
|
||||
|
||||
const historicalIndex = i - mcl_2;
|
||||
const historical_ma = historicalIndex >= 0 ? ma_mcl[historicalIndex] : null;
|
||||
|
||||
const centerLine = (historical_ma === null || historical_ma === undefined || isNaN(historical_ma)) ? src : historical_ma;
|
||||
const centerLine = (historical_ma === null || isNaN(historical_ma)) ? closes[i] : historical_ma;
|
||||
|
||||
results[i] = {
|
||||
time: candles[i].time,
|
||||
upper: centerLine + mcm_off,
|
||||
lower: centerLine - mcm_off
|
||||
lower: centerLine - mcm_off,
|
||||
ma: ma_mcl[i], // Store intermediate state for incremental updates
|
||||
atr: atr[i]
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
_calculateIncremental(candles, oldResults) {
|
||||
const mcl_t = this.params.period || 30;
|
||||
const mcm = this.params.multiplier || 1.8;
|
||||
const mcl = mcl_t / 2;
|
||||
const mcl_2 = Math.round(mcl / 2);
|
||||
|
||||
const results = [...oldResults];
|
||||
const startIndex = oldResults.length;
|
||||
|
||||
for (let i = startIndex; i < candles.length; i++) {
|
||||
const close = candles[i].close;
|
||||
const prevClose = candles[i-1].close;
|
||||
const tr = Math.max(candles[i].high - candles[i].low, Math.abs(candles[i].high - prevClose), Math.abs(candles[i].low - prevClose));
|
||||
|
||||
const prevMA = results[i-1]?.ma;
|
||||
const prevATR = results[i-1]?.atr;
|
||||
|
||||
const currentMA = calculateRMAIncremental(close, prevMA, mcl);
|
||||
const currentATR = calculateRMAIncremental(tr, prevATR, mcl);
|
||||
|
||||
// For displaced center line, we still need the MA from i - mcl_2
|
||||
// Since i >= oldResults.length, i - mcl_2 might be in the old results
|
||||
let historical_ma = null;
|
||||
const historicalIndex = i - mcl_2;
|
||||
if (historicalIndex >= 0) {
|
||||
historical_ma = historicalIndex < startIndex ? results[historicalIndex].ma : null; // In this simple incremental, we don't look ahead
|
||||
}
|
||||
|
||||
const centerLine = (historical_ma === null || isNaN(historical_ma)) ? close : historical_ma;
|
||||
const mcm_off = mcm * (currentATR || 0);
|
||||
|
||||
results[i] = {
|
||||
time: candles[i].time,
|
||||
upper: centerLine + mcm_off,
|
||||
lower: centerLine - mcm_off,
|
||||
ma: currentMA,
|
||||
atr: currentATR
|
||||
};
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async _fetchAndCalculateMtf(effectiveTf, targetCandles) {
|
||||
this.isFetching = true;
|
||||
try {
|
||||
console.log(`[Hurst] Fetching MTF data for ${effectiveTf}...`);
|
||||
const chartData = window.dashboard?.allData?.get(window.dashboard?.currentInterval) || targetCandles;
|
||||
if (!chartData || chartData.length === 0) {
|
||||
console.warn('[Hurst] No chart data available for timeframe fetch');
|
||||
this.isFetching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate warmup needed (period + half width)
|
||||
const mcl_t = this.params.period || 30;
|
||||
const warmupBars = mcl_t * 2; // Extra buffer
|
||||
const tfSeconds = intervalToSeconds(effectiveTf);
|
||||
const warmupOffsetSeconds = warmupBars * tfSeconds;
|
||||
|
||||
// Candles endpoint expects ISO strings or timestamps.
|
||||
// chartData[0].time is the earliest candle on chart.
|
||||
const startTime = new Date((chartData[0].time - warmupOffsetSeconds) * 1000);
|
||||
const endTime = new Date(chartData[chartData.length - 1].time * 1000);
|
||||
|
||||
const tfCandles = await getCandlesForTimeframe(effectiveTf, startTime, endTime);
|
||||
if (tfCandles.length === 0) {
|
||||
console.warn(`[Hurst] No candles fetched for ${effectiveTf}`);
|
||||
this.isFetching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Hurst] Fetched ${tfCandles.length} candles for ${effectiveTf}. Calculating...`);
|
||||
const tfResults = this._calculateCore(tfCandles);
|
||||
const finalResults = forwardFillResults(tfResults, targetCandles);
|
||||
|
||||
// Persist results on the config object
|
||||
this.cachedResults = finalResults;
|
||||
this.cachedTimeframe = effectiveTf;
|
||||
this.updateCachedMeta(this.params);
|
||||
|
||||
console.log(`[Hurst] MTF calculation complete for ${effectiveTf}. Triggering redraw.`);
|
||||
|
||||
// Trigger a redraw of the dashboard to show the new data
|
||||
if (window.drawIndicatorsOnChart) {
|
||||
window.drawIndicatorsOnChart();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Hurst] Error in _fetchAndCalculateMtf:', err);
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
getMetadata() {
|
||||
return {
|
||||
name: 'Hurst Bands',
|
||||
description: 'Cyclic price channels based on Hurst theory',
|
||||
inputs: [
|
||||
{
|
||||
name: 'timeframe',
|
||||
label: 'Timeframe',
|
||||
type: 'select',
|
||||
default: 'chart',
|
||||
options: ['chart', ...INTERVALS],
|
||||
labels: { chart: '(Main Chart)' }
|
||||
},
|
||||
{ name: 'period', label: 'Hurst Cycle Length (mcl_t)', type: 'number', default: 30, min: 5, max: 200 },
|
||||
{ name: 'multiplier', label: 'Multiplier (mcm)', type: 'number', default: 1.8, min: 0.5, max: 10, step: 0.1 }
|
||||
],
|
||||
@ -174,6 +402,20 @@ export class HurstBandsIndicator extends BaseIndicator {
|
||||
displayMode: 'overlay'
|
||||
};
|
||||
}
|
||||
|
||||
shouldRecalculate() {
|
||||
const effectiveTf = getEffectiveTimeframe(this.params);
|
||||
return this.cachedTimeframe !== effectiveTf ||
|
||||
(this.cachedMeta && (this.cachedMeta.period !== this.params.period ||
|
||||
this.cachedMeta.multiplier !== this.params.multiplier));
|
||||
}
|
||||
|
||||
updateCachedMeta(params) {
|
||||
this.cachedMeta = {
|
||||
period: params.period,
|
||||
multiplier: params.multiplier
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { calculateHurstSignal };
|
||||
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 { TimezoneConfig } from '../config/timezone.js';
|
||||
|
||||
export class SeriesMarkersPrimitive {
|
||||
constructor(markers) {
|
||||
this._markers = markers || [];
|
||||
this._paneViews = [new MarkersPaneView(this)];
|
||||
}
|
||||
|
||||
setMarkers(markers) {
|
||||
this._markers = markers;
|
||||
if (this._requestUpdate) {
|
||||
this._requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
attached(param) {
|
||||
this._chart = param.chart;
|
||||
this._series = param.series;
|
||||
this._requestUpdate = param.requestUpdate;
|
||||
this._requestUpdate();
|
||||
}
|
||||
|
||||
detached() {
|
||||
this._chart = undefined;
|
||||
this._series = undefined;
|
||||
this._requestUpdate = undefined;
|
||||
}
|
||||
|
||||
updateAllViews() {
|
||||
this._requestUpdate?.();
|
||||
}
|
||||
|
||||
paneViews() {
|
||||
return this._paneViews;
|
||||
}
|
||||
}
|
||||
|
||||
class MarkersPaneView {
|
||||
constructor(source) {
|
||||
this._source = source;
|
||||
}
|
||||
|
||||
renderer() {
|
||||
return new MarkersRenderer(this._source);
|
||||
}
|
||||
|
||||
zOrder() {
|
||||
return 'top';
|
||||
}
|
||||
}
|
||||
|
||||
class MarkersRenderer {
|
||||
constructor(source) {
|
||||
this._source = source;
|
||||
}
|
||||
|
||||
draw(target) {
|
||||
if (!this._source._chart || !this._source._series) return;
|
||||
|
||||
target.useBitmapCoordinateSpace((scope) => {
|
||||
const ctx = scope.context;
|
||||
const series = this._source._series;
|
||||
const chart = this._source._chart;
|
||||
const markers = this._source._markers;
|
||||
|
||||
// Adjust coordinates to bitmap space based on pixel ratio
|
||||
const ratio = scope.horizontalPixelRatio;
|
||||
|
||||
ctx.save();
|
||||
|
||||
for (const marker of markers) {
|
||||
const timeCoordinate = chart.timeScale().timeToCoordinate(marker.time);
|
||||
if (timeCoordinate === null) continue;
|
||||
|
||||
// Figure out price coordinate
|
||||
let price = marker.price || marker.value;
|
||||
|
||||
// If price wasn't specified but we have the series data, grab the candle high/low
|
||||
if (!price && window.dashboard && window.dashboard.allData) {
|
||||
const data = window.dashboard.allData.get(window.dashboard.currentInterval);
|
||||
if (data) {
|
||||
const candle = data.find(d => d.time === marker.time);
|
||||
if (candle) {
|
||||
price = marker.position === 'aboveBar' ? candle.high : candle.low;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!price) continue;
|
||||
|
||||
const priceCoordinate = series.priceToCoordinate(price);
|
||||
if (priceCoordinate === null) continue;
|
||||
|
||||
const x = timeCoordinate * ratio;
|
||||
const size = 5 * ratio;
|
||||
const margin = 15 * ratio;
|
||||
const isAbove = marker.position === 'aboveBar';
|
||||
const y = (isAbove ? priceCoordinate * ratio - margin : priceCoordinate * ratio + margin);
|
||||
|
||||
ctx.fillStyle = marker.color || '#26a69a';
|
||||
ctx.beginPath();
|
||||
|
||||
const shape = marker.shape || (isAbove ? 'arrowDown' : 'arrowUp');
|
||||
|
||||
if (shape === 'arrowUp' || shape === 'triangleUp') {
|
||||
ctx.moveTo(x, y - size);
|
||||
ctx.lineTo(x - size, y + size);
|
||||
ctx.lineTo(x + size, y + size);
|
||||
} else if (shape === 'arrowDown' || shape === 'triangleDown') {
|
||||
ctx.moveTo(x, y + size);
|
||||
ctx.lineTo(x - size, y - size);
|
||||
ctx.lineTo(x + size, y - size);
|
||||
} else if (shape === 'circle') {
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
} else if (shape === 'square') {
|
||||
ctx.rect(x - size, y - size, size * 2, size * 2);
|
||||
} else if (shape === 'custom' && marker.text) {
|
||||
ctx.font = `${Math.round(14 * ratio)}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(marker.text, x, y);
|
||||
continue;
|
||||
}
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
return TimezoneConfig.formatDate(timestamp);
|
||||
}
|
||||
@ -91,8 +219,20 @@ constructor() {
|
||||
|
||||
setAvgPriceData(data) {
|
||||
if (this.avgPriceSeries) {
|
||||
this.avgPriceSeries.setData(data || []);
|
||||
this.chart.removeSeries(this.avgPriceSeries);
|
||||
}
|
||||
|
||||
// Recreate series to apply custom colors per point via LineSeries data
|
||||
this.avgPriceSeries = this.chart.addSeries(LightweightCharts.LineSeries, {
|
||||
lineWidth: 2,
|
||||
lineStyle: LightweightCharts.LineStyle.Solid,
|
||||
lastValueVisible: true,
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false,
|
||||
title: 'Avg Price',
|
||||
});
|
||||
|
||||
this.avgPriceSeries.setData(data || []);
|
||||
}
|
||||
|
||||
clearAvgPriceData() {
|
||||
@ -509,7 +649,12 @@ async loadNewData() {
|
||||
this.lastCandleTimestamp = latest.time;
|
||||
|
||||
chartData.forEach(candle => {
|
||||
if (candle.time >= lastTimestamp) {
|
||||
if (candle.time >= lastTimestamp &&
|
||||
!Number.isNaN(candle.time) &&
|
||||
!Number.isNaN(candle.open) &&
|
||||
!Number.isNaN(candle.high) &&
|
||||
!Number.isNaN(candle.low) &&
|
||||
!Number.isNaN(candle.close)) {
|
||||
this.candleSeries.update(candle);
|
||||
}
|
||||
});
|
||||
@ -700,78 +845,16 @@ async loadSignals() {
|
||||
// Re-sort combined markers by time
|
||||
markers.sort((a, b) => a.time - b.time);
|
||||
|
||||
// If we have a marker controller, update markers through it
|
||||
if (this.markerController) {
|
||||
try {
|
||||
this.markerController.setMarkers(markers);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('[SignalMarkers] setMarkers error:', e.message);
|
||||
this.markerController = null;
|
||||
// Use custom primitive for markers in v5
|
||||
try {
|
||||
if (!this.markerPrimitive) {
|
||||
this.markerPrimitive = new SeriesMarkersPrimitive();
|
||||
this.candleSeries.attachPrimitive(this.markerPrimitive);
|
||||
}
|
||||
this.markerPrimitive.setMarkers(markers);
|
||||
} catch (e) {
|
||||
console.warn('[SignalMarkers] setMarkers primitive error:', e.message);
|
||||
}
|
||||
|
||||
// Clear price lines
|
||||
if (this.markerPriceLines) {
|
||||
this.markerPriceLines.forEach(ml => {
|
||||
try { this.candleSeries.removePriceLine(ml); } catch (e) {}
|
||||
});
|
||||
this.markerPriceLines = [];
|
||||
}
|
||||
|
||||
if (markers.length === 0) return;
|
||||
|
||||
// Create new marker controller
|
||||
if (typeof LightweightCharts.createSeriesMarkers === 'function') {
|
||||
try {
|
||||
this.markerController = LightweightCharts.createSeriesMarkers(this.candleSeries, markers);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('[SignalMarkers] createSeriesMarkers error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use price lines
|
||||
this.addMarkerPriceLines(markers);
|
||||
}
|
||||
|
||||
addMarkerPriceLines(markers) {
|
||||
if (this.markerPriceLines) {
|
||||
this.markerPriceLines.forEach(ml => {
|
||||
try { this.candleSeries.removePriceLine(ml); } catch (e) {}
|
||||
});
|
||||
}
|
||||
this.markerPriceLines = [];
|
||||
|
||||
const recentMarkers = markers.slice(-20);
|
||||
|
||||
recentMarkers.forEach(m => {
|
||||
const isBuy = m.position === 'belowBar';
|
||||
const price = isBuy ? this.getMarkerLowPrice(m.time) : this.getMarkerHighPrice(m.time);
|
||||
|
||||
const priceLine = this.candleSeries.createPriceLine({
|
||||
price: price,
|
||||
color: m.color,
|
||||
lineWidth: 2,
|
||||
lineStyle: LightweightCharts.LineStyle.Dashed,
|
||||
axisLabelVisible: true,
|
||||
title: m.text
|
||||
});
|
||||
|
||||
this.markerPriceLines.push(priceLine);
|
||||
});
|
||||
}
|
||||
|
||||
getMarkerLowPrice(time) {
|
||||
const candles = this.allData.get(this.currentInterval);
|
||||
const candle = candles?.find(c => c.time === time);
|
||||
return candle ? candle.low * 0.995 : 0;
|
||||
}
|
||||
|
||||
getMarkerHighPrice(time) {
|
||||
const candles = this.allData.get(this.currentInterval);
|
||||
const candle = candles?.find(c => c.time === time);
|
||||
return candle ? candle.high * 1.005 : 0;
|
||||
}
|
||||
|
||||
renderTA() {
|
||||
@ -954,6 +1037,9 @@ switchTimeframe(interval) {
|
||||
|
||||
window.clearSimulationResults?.();
|
||||
window.updateTimeframeDisplay?.();
|
||||
|
||||
// Notify indicators of timeframe change for recalculation
|
||||
window.onTimeframeChange?.(interval);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -201,8 +201,20 @@ export class HTSVisualizer {
|
||||
}
|
||||
|
||||
const candleSeries = this.candleData?.series;
|
||||
if (candleSeries && typeof candleSeries.setMarkers === 'function') {
|
||||
candleSeries.setMarkers(markers);
|
||||
if (candleSeries) {
|
||||
try {
|
||||
if (typeof candleSeries.setMarkers === 'function') {
|
||||
candleSeries.setMarkers(markers);
|
||||
} else if (typeof SeriesMarkersPrimitive !== 'undefined') {
|
||||
if (!this.markerPrimitive) {
|
||||
this.markerPrimitive = new SeriesMarkersPrimitive();
|
||||
candleSeries.attachPrimitive(this.markerPrimitive);
|
||||
}
|
||||
this.markerPrimitive.setMarkers(markers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[HTS] Error setting markers:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
|
||||
@ -112,6 +112,23 @@ export function setActiveIndicators(indicators) {
|
||||
|
||||
window.getActiveIndicators = getActiveIndicators;
|
||||
|
||||
async function onTimeframeChange(newInterval) {
|
||||
const indicators = getActiveIndicators();
|
||||
for (const indicator of indicators) {
|
||||
if (indicator.params.timeframe === 'chart' && typeof indicator.shouldRecalculate === 'function') {
|
||||
if (indicator.shouldRecalculate()) {
|
||||
try {
|
||||
await window.renderIndicator(indicator.id);
|
||||
} catch (err) {
|
||||
console.error(`[onTimeframeChange] Failed to recalculate ${indicator.name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.onTimeframeChange = onTimeframeChange;
|
||||
|
||||
// Render main panel
|
||||
export function renderIndicatorPanel() {
|
||||
const container = document.getElementById('indicatorPanel');
|
||||
@ -315,7 +332,10 @@ function renderIndicatorConfig(indicator, meta) {
|
||||
<label>${input.label}</label>
|
||||
${input.type === 'select' ?
|
||||
`<select onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '${input.name}', this.value)">
|
||||
${input.options.map(o => `<option value="${o}" ${indicator.params[input.name] === o ? 'selected' : ''}>${o}</option>`).join('')}
|
||||
${input.options.map(o => {
|
||||
const label = input.labels?.[o] || o;
|
||||
return `<option value="${o}" ${indicator.params[input.name] === o ? 'selected' : ''}>${label}</option>`;
|
||||
}).join('')}
|
||||
</select>` :
|
||||
`<input
|
||||
type="number"
|
||||
@ -547,6 +567,7 @@ window.updateIndicatorSetting = function(id, key, value) {
|
||||
indicator.params[key] = value;
|
||||
indicator.lastSignalTimestamp = null;
|
||||
indicator.lastSignalType = null;
|
||||
indicator.cachedResults = null; // Clear cache when params change
|
||||
drawIndicatorsOnChart();
|
||||
};
|
||||
|
||||
@ -753,6 +774,7 @@ function addIndicator(type) {
|
||||
// Override with Hurst-specific defaults
|
||||
if (type === 'hurst') {
|
||||
params._lineWidth = 1;
|
||||
params.timeframe = 'chart';
|
||||
params.markerBuyShape = 'custom';
|
||||
params.markerSellShape = 'custom';
|
||||
params.markerBuyColor = '#9e9e9e';
|
||||
@ -789,19 +811,21 @@ function saveUserPresets() {
|
||||
}
|
||||
|
||||
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
|
||||
// Recalculate with current TF candles
|
||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: START`);
|
||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: Input candles = ${candles.length}`);
|
||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: Calling instance.calculate()...`);
|
||||
// Recalculate with current TF candles (or use cached if they exist and are the correct length)
|
||||
let results = indicator.cachedResults;
|
||||
if (!results || !Array.isArray(results) || results.length !== candles.length) {
|
||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: Calling instance.calculate()...`);
|
||||
results = instance.calculate(candles);
|
||||
indicator.cachedResults = results;
|
||||
}
|
||||
|
||||
const results = instance.calculate(candles);
|
||||
|
||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: calculate() returned ${results?.length || 0} results`);
|
||||
console.log(`[renderIndicatorOnPane] ${indicator.name}: Expected ${candles.length} results, got ${results?.length || 0}`);
|
||||
if (!results || !Array.isArray(results)) {
|
||||
console.error(`[renderIndicatorOnPane] ${indicator.name}: Failed to get valid results (got ${typeof results})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length !== candles.length) {
|
||||
console.error(`[renderIndicatorOnPane] ${indicator.name}: MISMATCH! Expected ${candles.length} results but got ${results.length}`);
|
||||
console.error(`[renderIndicatorOnPane] ${indicator.name}: This means instance.calculate() is not returning the correct number of results!`);
|
||||
}
|
||||
|
||||
// Clear previous series for this indicator
|
||||
@ -817,8 +841,19 @@ function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, li
|
||||
const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
|
||||
const lineWidth = indicator.params._lineWidth || 1;
|
||||
|
||||
const firstNonNull = results?.find(r => r !== null && r !== undefined);
|
||||
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
|
||||
// Improved detection of object-based results (multiple plots)
|
||||
const firstNonNull = Array.isArray(results) ? results.find(r => r !== null && r !== undefined) : null;
|
||||
let isObjectResult = firstNonNull && typeof firstNonNull === 'object' && !Array.isArray(firstNonNull);
|
||||
|
||||
// Fallback: If results are all null (e.g. during warmup or MTF fetch),
|
||||
// use metadata to determine if it SHOULD be an object result
|
||||
if (!firstNonNull && meta.plots && meta.plots.length > 1) {
|
||||
isObjectResult = true;
|
||||
}
|
||||
// Also check if the only plot has a specific ID that isn't just a number
|
||||
if (!firstNonNull && meta.plots && meta.plots.length === 1 && meta.plots[0].id !== 'value') {
|
||||
isObjectResult = true;
|
||||
}
|
||||
|
||||
let plotsCreated = 0;
|
||||
let dataPointsAdded = 0;
|
||||
@ -842,7 +877,7 @@ function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, li
|
||||
value = results[i];
|
||||
}
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
if (value !== null && value !== undefined && typeof value === 'number' && Number.isFinite(value)) {
|
||||
if (firstDataIndex === -1) {
|
||||
firstDataIndex = i;
|
||||
}
|
||||
@ -1033,12 +1068,20 @@ export function drawIndicatorsOnChart() {
|
||||
const instance = new IndicatorClass(ind);
|
||||
const meta = instance.getMetadata();
|
||||
|
||||
// Store calculated results and metadata for signal calculation
|
||||
const results = instance.calculate(candles);
|
||||
ind.cachedResults = results;
|
||||
// Store calculated results and metadata for signal calculation
|
||||
let results = ind.cachedResults;
|
||||
if (!results || !Array.isArray(results) || results.length !== candles.length) {
|
||||
try {
|
||||
results = instance.calculate(candles);
|
||||
ind.cachedResults = results;
|
||||
} catch (err) {
|
||||
console.error(`[Indicators] Failed to calculate ${ind.name}:`, err);
|
||||
results = [];
|
||||
}
|
||||
}
|
||||
ind.cachedMeta = meta;
|
||||
|
||||
const validResults = results.filter(r => r !== null && r !== undefined);
|
||||
const validResults = Array.isArray(results) ? results.filter(r => r !== null && r !== undefined) : [];
|
||||
const warmupPeriod = ind.params?.period || 44;
|
||||
console.log(`[Indicators] ${ind.name}: ${validResults.length} valid results (need ${warmupPeriod} candles warmup)`);
|
||||
|
||||
|
||||
@ -240,6 +240,7 @@ export function addIndicator(type) {
|
||||
|
||||
// Set Hurst-specific defaults
|
||||
if (type === 'hurst') {
|
||||
params.timeframe = 'chart';
|
||||
params.markerBuyShape = 'custom';
|
||||
params.markerSellShape = 'custom';
|
||||
params.markerBuyColor = '#9e9e9e';
|
||||
@ -492,13 +493,17 @@ export function drawIndicatorsOnChart() {
|
||||
}
|
||||
|
||||
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
|
||||
const results = instance.calculate(candles);
|
||||
let results = instance.calculate(candles);
|
||||
if (!results || !Array.isArray(results)) {
|
||||
console.error(`[renderIndicatorOnPane] ${indicator.name}: Failed to get valid results (got ${typeof results})`);
|
||||
return;
|
||||
}
|
||||
indicator.series = [];
|
||||
|
||||
const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
|
||||
const lineWidth = indicator.params._lineWidth || 1;
|
||||
|
||||
const firstNonNull = results?.find(r => r !== null && r !== undefined);
|
||||
const firstNonNull = Array.isArray(results) ? results.find(r => r !== null && r !== undefined) : null;
|
||||
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
|
||||
|
||||
meta.plots.forEach((plot, plotIdx) => {
|
||||
|
||||
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);
|
||||
|
||||
const IndicatorClass = IndicatorRegistry[indicator.type];
|
||||
if (!IndicatorClass) {
|
||||
continue;
|
||||
// Use cache if available
|
||||
let results = indicator.cachedResults;
|
||||
if (!results || !Array.isArray(results) || results.length !== candles.length) {
|
||||
const IndicatorClass = IndicatorRegistry[indicator.type];
|
||||
if (!IndicatorClass) {
|
||||
continue;
|
||||
}
|
||||
const instance = new IndicatorClass(indicator);
|
||||
results = instance.calculate(candles);
|
||||
}
|
||||
|
||||
const instance = new IndicatorClass(indicator);
|
||||
const results = instance.calculate(candles);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
if (!results || !Array.isArray(results) || results.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -96,14 +96,17 @@ function calculateHistoricalCrossovers(activeIndicators, candles) {
|
||||
activeIndicators.forEach(indicator => {
|
||||
const indicatorType = indicator.type || indicator.indicatorType;
|
||||
|
||||
// Recalculate indicator values for all candles
|
||||
const IndicatorClass = IndicatorRegistry[indicatorType];
|
||||
if (!IndicatorClass) return;
|
||||
// Recalculate indicator values for all candles (use cache if valid)
|
||||
let results = indicator.cachedResults;
|
||||
if (!results || !Array.isArray(results) || results.length !== candles.length) {
|
||||
const IndicatorClass = IndicatorRegistry[indicatorType];
|
||||
if (!IndicatorClass) return;
|
||||
const instance = new IndicatorClass(indicator);
|
||||
results = instance.calculate(candles);
|
||||
// Don't save back to cache here, let drawIndicatorsOnChart be the source of truth for cache
|
||||
}
|
||||
|
||||
const instance = new IndicatorClass(indicator);
|
||||
const results = instance.calculate(candles);
|
||||
|
||||
if (!results || results.length === 0) return;
|
||||
if (!results || !Array.isArray(results) || results.length === 0) return;
|
||||
|
||||
// Find the most recent crossover by going backwards from the newest candle
|
||||
// candles are sorted oldest first, newest last
|
||||
@ -276,7 +279,7 @@ export function calculateAllIndicatorSignals() {
|
||||
let results = indicator.cachedResults;
|
||||
let meta = indicator.cachedMeta;
|
||||
|
||||
if (!results || !meta || results.length !== candles.length) {
|
||||
if (!results || !meta || !Array.isArray(results) || results.length !== candles.length) {
|
||||
const instance = new IndicatorClass(indicator);
|
||||
meta = instance.getMetadata();
|
||||
results = instance.calculate(candles);
|
||||
@ -284,8 +287,8 @@ export function calculateAllIndicatorSignals() {
|
||||
indicator.cachedMeta = meta;
|
||||
}
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
console.log('[Signals] No results for indicator:', indicator.type);
|
||||
if (!results || !Array.isArray(results) || results.length === 0) {
|
||||
console.log('[Signals] No valid results for indicator:', indicator.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import { IndicatorRegistry, getSignalFunction } from '../indicators/index.js';
|
||||
import { getStrategy, registerStrategy } from '../strategies/index.js';
|
||||
import { PingPongStrategy } from '../strategies/ping-pong.js';
|
||||
|
||||
// Register available strategies
|
||||
registerStrategy('ping_pong', PingPongStrategy);
|
||||
|
||||
let activeIndicators = [];
|
||||
let simulationResults = null;
|
||||
let equitySeries = null;
|
||||
let equityChart = null;
|
||||
let posSeries = null;
|
||||
let posSizeChart = null;
|
||||
|
||||
const STORAGE_KEY = 'ping_pong_settings';
|
||||
|
||||
function formatDisplayDate(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
@ -20,49 +17,6 @@ function formatDisplayDate(timestamp) {
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function parseDisplayDate(str) {
|
||||
if (!str) return null;
|
||||
const regex = /^(\d{2})\/(\d{2})\/(\d{4})\s(\d{2}):(\d{2})$/;
|
||||
const match = str.trim().match(regex);
|
||||
if (!match) return null;
|
||||
const [_, day, month, year, hours, minutes] = match;
|
||||
return new Date(year, month - 1, day, hours, minutes);
|
||||
}
|
||||
|
||||
function getSavedSettings() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return null;
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const settings = {
|
||||
startDate: document.getElementById('simStartDate').value,
|
||||
stopDate: document.getElementById('simStopDate').value,
|
||||
contractType: document.getElementById('simContractType').value,
|
||||
direction: document.getElementById('simDirection').value,
|
||||
capital: document.getElementById('simCapital').value,
|
||||
exchangeLeverage: document.getElementById('simExchangeLeverage').value,
|
||||
maxEffectiveLeverage: document.getElementById('simMaxEffectiveLeverage').value,
|
||||
posSize: document.getElementById('simPosSize').value,
|
||||
tp: document.getElementById('simTP').value
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
|
||||
const btn = document.getElementById('saveSimSettings');
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Saved!';
|
||||
btn.style.color = '#26a69a';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.style.color = '';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
export function initStrategyPanel() {
|
||||
window.renderStrategyPanel = renderStrategyPanel;
|
||||
renderStrategyPanel();
|
||||
@ -88,94 +42,23 @@ export function renderStrategyPanel() {
|
||||
if (!container) return;
|
||||
|
||||
activeIndicators = window.getActiveIndicators?.() || [];
|
||||
const saved = getSavedSettings();
|
||||
|
||||
// Format initial values for display
|
||||
let startDisplay = saved?.startDate || '01/01/2026 00:00';
|
||||
let stopDisplay = saved?.stopDate || '';
|
||||
|
||||
// If the saved value is in ISO format (from previous version), convert it
|
||||
if (startDisplay.includes('T')) {
|
||||
startDisplay = formatDisplayDate(new Date(startDisplay));
|
||||
}
|
||||
if (stopDisplay.includes('T')) {
|
||||
stopDisplay = formatDisplayDate(new Date(stopDisplay));
|
||||
// For now, we only have Ping-Pong. Later we can add a strategy selector.
|
||||
const currentStrategyId = 'ping_pong';
|
||||
const strategy = getStrategy(currentStrategyId);
|
||||
|
||||
if (!strategy) {
|
||||
container.innerHTML = `<div class="sidebar-section">Strategy not found.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<span>⚙️</span> Ping-Pong Strategy
|
||||
<span>⚙️</span> ${strategy.name} Strategy
|
||||
</div>
|
||||
<div class="sidebar-section-content">
|
||||
<div class="sim-input-group">
|
||||
<label>Start Date & Time</label>
|
||||
<input type="text" id="simStartDate" class="sim-input" value="${startDisplay}" placeholder="DD/MM/YYYY HH:MM">
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<label>Stop Date & Time (Optional)</label>
|
||||
<input type="text" id="simStopDate" class="sim-input" value="${stopDisplay}" placeholder="DD/MM/YYYY HH:MM">
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<label>Contract Type</label>
|
||||
<select id="simContractType" class="sim-input">
|
||||
<option value="linear" ${saved?.contractType === 'linear' ? 'selected' : ''}>Linear (USDT-Margined)</option>
|
||||
<option value="inverse" ${saved?.contractType === 'inverse' ? 'selected' : ''}>Inverse (Coin-Margined)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<label>Direction</label>
|
||||
<select id="simDirection" class="sim-input">
|
||||
<option value="long" ${saved?.direction === 'long' ? 'selected' : ''}>Long</option>
|
||||
<option value="short" ${saved?.direction === 'short' ? 'selected' : ''}>Short</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<label>Initial Capital ($)</label>
|
||||
<input type="number" id="simCapital" class="sim-input" value="${saved?.capital || '10000'}" min="1">
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<label>Exchange Leverage (Ping Size Multiplier)</label>
|
||||
<input type="number" id="simExchangeLeverage" class="sim-input" value="${saved?.exchangeLeverage || '1'}" min="1" max="100">
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<label>Max Effective Leverage (Total Account Cap)</label>
|
||||
<input type="number" id="simMaxEffectiveLeverage" class="sim-input" value="${saved?.maxEffectiveLeverage || '5'}" min="1" max="100">
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<label>Position Size ($ Margin per Ping)</label>
|
||||
<input type="number" id="simPosSize" class="sim-input" value="${saved?.posSize || '10'}" min="1">
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<label>Take Profit (%)</label>
|
||||
<input type="number" id="simTP" class="sim-input" value="${saved?.tp || '15'}" step="0.1" min="0.1">
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
<label style="margin-bottom: 0;">Open Signal Indicators</label>
|
||||
<button class="action-btn-text" id="saveSimSettings" style="font-size: 10px; color: #00bcd4; background: none; border: none; cursor: pointer; padding: 0;">Save Defaults</button>
|
||||
</div>
|
||||
<div class="indicator-checklist" id="openSignalsList">
|
||||
${renderIndicatorChecklist('open')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sim-input-group">
|
||||
<label>Close Signal Indicators (Empty = Accumulation)</label>
|
||||
<div class="indicator-checklist" id="closeSignalsList">
|
||||
${renderIndicatorChecklist('close')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${strategy.renderUI(activeIndicators, formatDisplayDate)}
|
||||
<button class="sim-run-btn" id="runSimulationBtn">Run Simulation</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -185,319 +68,27 @@ export function renderStrategyPanel() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('runSimulationBtn').addEventListener('click', runSimulation);
|
||||
document.getElementById('saveSimSettings').addEventListener('click', saveSettings);
|
||||
}
|
||||
|
||||
function renderIndicatorChecklist(prefix) {
|
||||
if (activeIndicators.length === 0) {
|
||||
return '<div style="padding: 8px; color: var(--tv-text-secondary); font-size: 11px;">No active indicators on chart</div>';
|
||||
// Attach strategy specific listeners (like disabling dropdowns when auto-detect is on)
|
||||
if (strategy.attachListeners) {
|
||||
strategy.attachListeners();
|
||||
}
|
||||
|
||||
return activeIndicators.map(ind => `
|
||||
<label class="checklist-item">
|
||||
<input type="checkbox" data-id="${ind.id}" class="sim-${prefix}-check">
|
||||
<span>${ind.name}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('runSimulationBtn').addEventListener('click', () => {
|
||||
strategy.runSimulation(activeIndicators, displayResults);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSimulation() {
|
||||
const btn = document.getElementById('runSimulationBtn');
|
||||
btn.disabled = true;
|
||||
const originalBtnText = btn.textContent;
|
||||
btn.textContent = 'Preparing Data...';
|
||||
|
||||
try {
|
||||
const startVal = document.getElementById('simStartDate').value;
|
||||
const stopVal = document.getElementById('simStopDate').value;
|
||||
|
||||
const config = {
|
||||
startDate: new Date(startVal).getTime() / 1000,
|
||||
stopDate: stopVal ? new Date(stopVal).getTime() / 1000 : Math.floor(Date.now() / 1000),
|
||||
contractType: document.getElementById('simContractType').value,
|
||||
direction: document.getElementById('simDirection').value,
|
||||
capital: parseFloat(document.getElementById('simCapital').value),
|
||||
exchangeLeverage: parseFloat(document.getElementById('simExchangeLeverage').value),
|
||||
maxEffectiveLeverage: parseFloat(document.getElementById('simMaxEffectiveLeverage').value),
|
||||
posSize: parseFloat(document.getElementById('simPosSize').value),
|
||||
tp: parseFloat(document.getElementById('simTP').value) / 100,
|
||||
openIndicators: Array.from(document.querySelectorAll('.sim-open-check:checked')).map(el => el.dataset.id),
|
||||
closeIndicators: Array.from(document.querySelectorAll('.sim-close-check:checked')).map(el => el.dataset.id)
|
||||
};
|
||||
|
||||
if (config.openIndicators.length === 0) {
|
||||
alert('Please choose at least one indicator for opening positions.');
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.dashboard?.currentInterval || '1d';
|
||||
|
||||
// 1. Ensure data is loaded for the range
|
||||
let allCandles = window.dashboard?.allData?.get(interval) || [];
|
||||
|
||||
const earliestInCache = allCandles.length > 0 ? allCandles[0].time : Infinity;
|
||||
const latestInCache = allCandles.length > 0 ? allCandles[allCandles.length - 1].time : -Infinity;
|
||||
|
||||
if (config.startDate < earliestInCache || config.stopDate > latestInCache) {
|
||||
btn.textContent = 'Fetching from Server...';
|
||||
console.log(`[Simulation] Data gap detected. Range: ${config.startDate}-${config.stopDate}, Cache: ${earliestInCache}-${latestInCache}`);
|
||||
|
||||
const startISO = new Date(config.startDate * 1000).toISOString();
|
||||
const stopISO = new Date(config.stopDate * 1000).toISOString();
|
||||
|
||||
const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${interval}&start=${startISO}&end=${stopISO}&limit=10000`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.candles && data.candles.length > 0) {
|
||||
const fetchedCandles = data.candles.reverse().map(c => ({
|
||||
time: Math.floor(new Date(c.time).getTime() / 1000),
|
||||
open: parseFloat(c.open),
|
||||
high: parseFloat(c.high),
|
||||
low: parseFloat(c.low),
|
||||
close: parseFloat(c.close),
|
||||
volume: parseFloat(c.volume || 0)
|
||||
}));
|
||||
|
||||
// Merge with existing data
|
||||
allCandles = window.dashboard.mergeData(allCandles, fetchedCandles);
|
||||
window.dashboard.allData.set(interval, allCandles);
|
||||
window.dashboard.candleSeries.setData(allCandles);
|
||||
|
||||
// Recalculate indicators
|
||||
btn.textContent = 'Calculating Indicators...';
|
||||
window.drawIndicatorsOnChart?.();
|
||||
// Wait a bit for indicators to calculate (they usually run in background/setTimeout)
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
btn.textContent = 'Simulating...';
|
||||
|
||||
// Filter candles by the exact range
|
||||
const simCandles = allCandles.filter(c => c.time >= config.startDate && c.time <= config.stopDate);
|
||||
|
||||
if (simCandles.length === 0) {
|
||||
alert('No data available for the selected range.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate indicator signals
|
||||
const indicatorSignals = {};
|
||||
for (const indId of [...new Set([...config.openIndicators, ...config.closeIndicators])]) {
|
||||
const ind = activeIndicators.find(a => a.id === indId);
|
||||
if (!ind) continue;
|
||||
|
||||
const signalFunc = getSignalFunction(ind.type);
|
||||
const results = ind.cachedResults;
|
||||
|
||||
if (results && signalFunc) {
|
||||
indicatorSignals[indId] = simCandles.map(candle => {
|
||||
const idx = allCandles.findIndex(c => c.time === candle.time);
|
||||
if (idx < 1) return null;
|
||||
const values = typeof results[idx] === 'object' && results[idx] !== null ? results[idx] : { ma: results[idx] };
|
||||
const prevValues = typeof results[idx-1] === 'object' && results[idx-1] !== null ? results[idx-1] : { ma: results[idx-1] };
|
||||
return signalFunc(ind, allCandles[idx], allCandles[idx-1], values, prevValues);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Simulation loop
|
||||
const startPrice = simCandles[0].open;
|
||||
let balanceBtc = config.contractType === 'inverse' ? config.capital / startPrice : 0;
|
||||
let balanceUsd = config.contractType === 'linear' ? config.capital : 0;
|
||||
|
||||
let equityData = { usd: [], btc: [] };
|
||||
let totalQty = 0; // Linear: BTC, Inverse: USD Contracts
|
||||
let avgPrice = 0;
|
||||
let avgPriceData = [];
|
||||
let posSizeData = { btc: [], usd: [] };
|
||||
let trades = [];
|
||||
|
||||
const PARTIAL_EXIT_PCT = 0.15;
|
||||
const MIN_POSITION_VALUE_USD = 15;
|
||||
|
||||
for (let i = 0; i < simCandles.length; i++) {
|
||||
const candle = simCandles[i];
|
||||
const price = candle.close;
|
||||
let actionTakenInThisCandle = false;
|
||||
|
||||
// 1. Check TP
|
||||
if (totalQty > 0) {
|
||||
let isTP = false;
|
||||
let exitPrice = price;
|
||||
if (config.direction === 'long') {
|
||||
if (candle.high >= avgPrice * (1 + config.tp)) {
|
||||
isTP = true;
|
||||
exitPrice = avgPrice * (1 + config.tp);
|
||||
}
|
||||
} else {
|
||||
if (candle.low <= avgPrice * (1 - config.tp)) {
|
||||
isTP = true;
|
||||
exitPrice = avgPrice * (1 - config.tp);
|
||||
}
|
||||
}
|
||||
|
||||
if (isTP) {
|
||||
let qtyToClose = totalQty * PARTIAL_EXIT_PCT;
|
||||
let remainingQty = totalQty - qtyToClose;
|
||||
let remainingValueUsd = config.contractType === 'linear' ? remainingQty * exitPrice : remainingQty;
|
||||
let reason = 'TP (Partial)';
|
||||
|
||||
// Minimum size check
|
||||
if (remainingValueUsd < MIN_POSITION_VALUE_USD) {
|
||||
qtyToClose = totalQty;
|
||||
reason = 'TP (Full - Min Size)';
|
||||
}
|
||||
|
||||
let pnl;
|
||||
if (config.contractType === 'linear') {
|
||||
pnl = config.direction === 'long' ? (exitPrice - avgPrice) * qtyToClose : (avgPrice - exitPrice) * qtyToClose;
|
||||
balanceUsd += pnl;
|
||||
} else {
|
||||
pnl = config.direction === 'long'
|
||||
? qtyToClose * (1/avgPrice - 1/exitPrice)
|
||||
: qtyToClose * (1/exitPrice - 1/avgPrice);
|
||||
balanceBtc += pnl;
|
||||
}
|
||||
|
||||
totalQty -= qtyToClose;
|
||||
trades.push({
|
||||
type: config.direction, recordType: 'exit', time: candle.time,
|
||||
entryPrice: avgPrice, exitPrice: exitPrice, pnl: pnl, reason: reason,
|
||||
currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty,
|
||||
currentQty: config.contractType === 'linear' ? totalQty : totalQty / price
|
||||
});
|
||||
actionTakenInThisCandle = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Close Signals
|
||||
if (!actionTakenInThisCandle && totalQty > 0 && config.closeIndicators.length > 0) {
|
||||
const hasCloseSignal = config.closeIndicators.some(id => {
|
||||
const sig = indicatorSignals[id][i];
|
||||
if (!sig) return false;
|
||||
return config.direction === 'long' ? sig.type === 'sell' : sig.type === 'buy';
|
||||
});
|
||||
|
||||
if (hasCloseSignal) {
|
||||
let qtyToClose = totalQty * PARTIAL_EXIT_PCT;
|
||||
let remainingQty = totalQty - qtyToClose;
|
||||
let remainingValueUsd = config.contractType === 'linear' ? remainingQty * price : remainingQty;
|
||||
let reason = 'Signal (Partial)';
|
||||
|
||||
// Minimum size check
|
||||
if (remainingValueUsd < MIN_POSITION_VALUE_USD) {
|
||||
qtyToClose = totalQty;
|
||||
reason = 'Signal (Full - Min Size)';
|
||||
}
|
||||
|
||||
let pnl;
|
||||
if (config.contractType === 'linear') {
|
||||
pnl = config.direction === 'long' ? (price - avgPrice) * qtyToClose : (avgPrice - price) * qtyToClose;
|
||||
balanceUsd += pnl;
|
||||
} else {
|
||||
pnl = config.direction === 'long'
|
||||
? qtyToClose * (1/avgPrice - 1/price)
|
||||
: qtyToClose * (1/price - 1/avgPrice);
|
||||
balanceBtc += pnl;
|
||||
}
|
||||
|
||||
totalQty -= qtyToClose;
|
||||
trades.push({
|
||||
type: config.direction, recordType: 'exit', time: candle.time,
|
||||
entryPrice: avgPrice, exitPrice: price, pnl: pnl, reason: reason,
|
||||
currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty,
|
||||
currentQty: config.contractType === 'linear' ? totalQty : totalQty / price
|
||||
});
|
||||
actionTakenInThisCandle = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Current Equity for Margin Check
|
||||
let currentEquityBtc, currentEquityUsd;
|
||||
if (config.contractType === 'linear') {
|
||||
const upnlUsd = totalQty > 0 ? (config.direction === 'long' ? (price - avgPrice) : (avgPrice - price)) * totalQty : 0;
|
||||
currentEquityUsd = balanceUsd + upnlUsd;
|
||||
currentEquityBtc = currentEquityUsd / price;
|
||||
} else {
|
||||
const upnlBtc = totalQty > 0 ? (config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice)) : 0;
|
||||
currentEquityBtc = balanceBtc + upnlBtc;
|
||||
currentEquityUsd = currentEquityBtc * price;
|
||||
}
|
||||
|
||||
// 3. Check Open Signals
|
||||
if (!actionTakenInThisCandle) {
|
||||
const hasOpenSignal = config.openIndicators.some(id => {
|
||||
const sig = indicatorSignals[id][i];
|
||||
if (!sig) return false;
|
||||
return config.direction === 'long' ? sig.type === 'buy' : sig.type === 'sell';
|
||||
});
|
||||
|
||||
if (hasOpenSignal) {
|
||||
const entryValUsd = config.posSize * config.exchangeLeverage;
|
||||
const currentNotionalBtc = config.contractType === 'linear' ? totalQty : totalQty / price;
|
||||
const entryNotionalBtc = entryValUsd / price;
|
||||
|
||||
const projectedEffectiveLeverage = (currentNotionalBtc + entryNotionalBtc) / Math.max(currentEquityBtc, 0.0000001);
|
||||
|
||||
if (projectedEffectiveLeverage <= config.maxEffectiveLeverage) {
|
||||
if (config.contractType === 'linear') {
|
||||
const entryQty = entryValUsd / price;
|
||||
avgPrice = ((totalQty * avgPrice) + (entryQty * price)) / (totalQty + entryQty);
|
||||
totalQty += entryQty;
|
||||
} else {
|
||||
// Inverse: totalQty is USD contracts
|
||||
avgPrice = (totalQty + entryValUsd) / ((totalQty / avgPrice || 0) + (entryValUsd / price));
|
||||
totalQty += entryValUsd;
|
||||
}
|
||||
|
||||
trades.push({
|
||||
type: config.direction, recordType: 'entry', time: candle.time,
|
||||
entryPrice: price, reason: 'Entry',
|
||||
currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty,
|
||||
currentQty: config.contractType === 'linear' ? totalQty : totalQty / price
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final Equity Recording
|
||||
let finalEquityBtc, finalEquityUsd;
|
||||
if (config.contractType === 'linear') {
|
||||
const upnl = totalQty > 0 ? (config.direction === 'long' ? (price - avgPrice) : (avgPrice - price)) * totalQty : 0;
|
||||
finalEquityUsd = balanceUsd + upnl;
|
||||
finalEquityBtc = finalEquityUsd / price;
|
||||
} else {
|
||||
const upnl = totalQty > 0 ? (config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice)) : 0;
|
||||
finalEquityBtc = balanceBtc + upnl;
|
||||
finalEquityUsd = finalEquityBtc * price;
|
||||
}
|
||||
|
||||
equityData.usd.push({ time: candle.time, value: finalEquityUsd });
|
||||
equityData.btc.push({ time: candle.time, value: finalEquityBtc });
|
||||
|
||||
if (totalQty > 0.000001) avgPriceData.push({ time: candle.time, value: avgPrice });
|
||||
posSizeData.btc.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty : totalQty / price });
|
||||
posSizeData.usd.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty * price : totalQty });
|
||||
}
|
||||
|
||||
displayResults(trades, equityData, config, simCandles[simCandles.length-1].close, avgPriceData, posSizeData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Simulation] Error:', error);
|
||||
alert('Simulation failed.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Run Simulation';
|
||||
}
|
||||
}
|
||||
// Keep the display logic here so all strategies can use the same rendering for results
|
||||
let equitySeries = null;
|
||||
let equityChart = null;
|
||||
let posSeries = null;
|
||||
let posSizeChart = null;
|
||||
let tradeMarkers = [];
|
||||
|
||||
function displayResults(trades, equityData, config, endPrice, avgPriceData, posSizeData) {
|
||||
const resultsDiv = document.getElementById('simulationResults');
|
||||
resultsDiv.style.display = 'block';
|
||||
|
||||
// Update main chart with avg price
|
||||
if (window.dashboard) {
|
||||
window.dashboard.setAvgPriceData(avgPriceData);
|
||||
}
|
||||
@ -574,7 +165,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
||||
|
||||
// Create Charts
|
||||
const initCharts = () => {
|
||||
// Equity Chart
|
||||
const equityContainer = document.getElementById('equityChart');
|
||||
if (equityContainer) {
|
||||
equityContainer.innerHTML = '';
|
||||
@ -588,12 +178,12 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
tickMarkFormatter: (time, tickMarkType, locale) => {
|
||||
return TimezoneConfig.formatTickMark(time);
|
||||
return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
localization: {
|
||||
timeFormatter: (timestamp) => {
|
||||
return TimezoneConfig.formatDate(timestamp * 1000);
|
||||
return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString();
|
||||
},
|
||||
},
|
||||
handleScroll: true,
|
||||
@ -611,7 +201,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
||||
equityChart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
// Pos Size Chart
|
||||
const posSizeContainer = document.getElementById('posSizeChart');
|
||||
if (posSizeContainer) {
|
||||
posSizeContainer.innerHTML = '';
|
||||
@ -625,12 +214,12 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
tickMarkFormatter: (time, tickMarkType, locale) => {
|
||||
return TimezoneConfig.formatTickMark(time);
|
||||
return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
localization: {
|
||||
timeFormatter: (timestamp) => {
|
||||
return TimezoneConfig.formatDate(timestamp * 1000);
|
||||
return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString();
|
||||
},
|
||||
},
|
||||
handleScroll: true,
|
||||
@ -651,7 +240,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
||||
if (label) label.textContent = 'Position Size (USD)';
|
||||
}
|
||||
|
||||
// Sync Time Scales
|
||||
if (equityChart && posSizeChart) {
|
||||
let isSyncing = false;
|
||||
|
||||
@ -667,7 +255,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
||||
posSizeChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(posSizeChart, equityChart));
|
||||
}
|
||||
|
||||
// Sync to Main Chart on Click
|
||||
const syncToMain = (param) => {
|
||||
if (!param.time || !window.dashboard || !window.dashboard.chart) return;
|
||||
|
||||
@ -675,7 +262,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
||||
const currentRange = timeScale.getVisibleRange();
|
||||
if (!currentRange) return;
|
||||
|
||||
// Calculate current width to preserve zoom level
|
||||
const width = currentRange.to - currentRange.from;
|
||||
const halfWidth = width / 2;
|
||||
|
||||
@ -691,12 +277,10 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
||||
|
||||
setTimeout(initCharts, 100);
|
||||
|
||||
// Toggle Logic
|
||||
resultsDiv.querySelectorAll('.toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const unit = btn.dataset.unit;
|
||||
|
||||
// Sync all toggle button groups
|
||||
resultsDiv.querySelectorAll(`.toggle-btn`).forEach(b => {
|
||||
if (b.dataset.unit === unit) b.classList.add('active');
|
||||
else b.classList.remove('active');
|
||||
@ -729,8 +313,6 @@ function displayResults(trades, equityData, config, endPrice, avgPriceData, posS
|
||||
});
|
||||
}
|
||||
|
||||
let tradeMarkers = [];
|
||||
|
||||
function toggleSimulationMarkers(trades) {
|
||||
if (tradeMarkers.length > 0) {
|
||||
clearSimulationMarkers();
|
||||
@ -744,7 +326,6 @@ function toggleSimulationMarkers(trades) {
|
||||
const qtyVal = t.currentQty !== undefined ? `${t.currentQty.toFixed(4)} BTC` : '0';
|
||||
const sizeStr = ` (${usdVal} / ${qtyVal})`;
|
||||
|
||||
// Entry marker
|
||||
if (t.recordType === 'entry') {
|
||||
markers.push({
|
||||
time: t.time,
|
||||
@ -755,7 +336,6 @@ function toggleSimulationMarkers(trades) {
|
||||
});
|
||||
}
|
||||
|
||||
// Exit marker
|
||||
if (t.recordType === 'exit') {
|
||||
markers.push({
|
||||
time: t.time,
|
||||
@ -767,7 +347,6 @@ function toggleSimulationMarkers(trades) {
|
||||
}
|
||||
});
|
||||
|
||||
// Sort markers by time
|
||||
markers.sort((a, b) => a.time - b.time);
|
||||
|
||||
if (window.dashboard) {
|
||||
@ -788,4 +367,4 @@ window.clearSimulationResults = function() {
|
||||
const resultsDiv = document.getElementById('simulationResults');
|
||||
if (resultsDiv) resultsDiv.style.display = 'none';
|
||||
clearSimulationMarkers();
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user