chore: add AGENTS.md with build, lint, test commands and style guidelines
This commit is contained in:
1061
js/ui/chart.js
Normal file
1061
js/ui/chart.js
Normal file
File diff suppressed because it is too large
Load Diff
243
js/ui/hts-visualizer.js
Normal file
243
js/ui/hts-visualizer.js
Normal file
@ -0,0 +1,243 @@
|
||||
const HTS_COLORS = {
|
||||
fastHigh: '#00bcd4',
|
||||
fastLow: '#00bcd4',
|
||||
slowHigh: '#f44336',
|
||||
slowLow: '#f44336',
|
||||
bullishZone: 'rgba(38, 166, 154, 0.1)',
|
||||
bearishZone: 'rgba(239, 83, 80, 0.1)',
|
||||
channelRegion: 'rgba(41, 98, 255, 0.05)'
|
||||
};
|
||||
|
||||
let HTSOverlays = [];
|
||||
|
||||
export class HTSVisualizer {
|
||||
constructor(chart, candles) {
|
||||
this.chart = chart;
|
||||
this.candles = candles;
|
||||
this.overlays = [];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.overlays.forEach(overlay => {
|
||||
try {
|
||||
this.chart.removeSeries(overlay.series);
|
||||
} catch (e) { }
|
||||
});
|
||||
this.overlays = [];
|
||||
}
|
||||
|
||||
addHTSChannels(htsData, isAutoHTS = false) {
|
||||
this.clear();
|
||||
|
||||
if (!htsData || htsData.length === 0) return;
|
||||
|
||||
const alpha = isAutoHTS ? 0.3 : 0.3;
|
||||
const lineWidth = isAutoHTS ? 1 : 2;
|
||||
|
||||
const fastHighSeries = this.chart.addSeries(LightweightCharts.LineSeries, {
|
||||
color: `rgba(0, 188, 212, ${alpha})`,
|
||||
lineWidth: lineWidth,
|
||||
lastValueVisible: false,
|
||||
title: 'HTS Fast High' + (isAutoHTS ? ' (Auto)' : ''),
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false
|
||||
});
|
||||
|
||||
const fastLowSeries = this.chart.addSeries(LightweightCharts.LineSeries, {
|
||||
color: `rgba(0, 188, 212, ${alpha})`,
|
||||
lineWidth: lineWidth,
|
||||
lastValueVisible: false,
|
||||
title: 'HTS Fast Low' + (isAutoHTS ? ' (Auto)' : ''),
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false
|
||||
});
|
||||
|
||||
const slowHighSeries = this.chart.addSeries(LightweightCharts.LineSeries, {
|
||||
color: `rgba(244, 67, 54, ${alpha})`,
|
||||
lineWidth: lineWidth + 1,
|
||||
lastValueVisible: false,
|
||||
title: 'HTS Slow High' + (isAutoHTS ? ' (Auto)' : ''),
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false
|
||||
});
|
||||
|
||||
const slowLowSeries = this.chart.addSeries(LightweightCharts.LineSeries, {
|
||||
color: `rgba(244, 67, 54, ${alpha})`,
|
||||
lineWidth: lineWidth + 1,
|
||||
lastValueVisible: false,
|
||||
title: 'HTS Slow Low' + (isAutoHTS ? ' (Auto)' : ''),
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false
|
||||
});
|
||||
|
||||
const fastHighData = htsData.map(h => ({ time: h.time, value: h.fastHigh }));
|
||||
const fastLowData = htsData.map(h => ({ time: h.time, value: h.fastLow }));
|
||||
const slowHighData = htsData.map(h => ({ time: h.time, value: h.slowHigh }));
|
||||
const slowLowData = htsData.map(h => ({ time: h.time, value: h.slowLow }));
|
||||
|
||||
fastHighSeries.setData(fastHighData);
|
||||
fastLowSeries.setData(fastLowData);
|
||||
slowHighSeries.setData(slowHighData);
|
||||
slowLowSeries.setData(slowLowData);
|
||||
|
||||
this.overlays.push(
|
||||
{ series: fastHighSeries, name: 'fastHigh' },
|
||||
{ series: fastLowSeries, name: 'fastLow' },
|
||||
{ series: slowHighSeries, name: 'slowHigh' },
|
||||
{ series: slowLowSeries, name: 'slowLow' }
|
||||
);
|
||||
|
||||
return {
|
||||
fastHigh: fastHighSeries,
|
||||
fastLow: fastLowSeries,
|
||||
slowHigh: slowHighSeries,
|
||||
slowLow: slowLowSeries
|
||||
};
|
||||
}
|
||||
|
||||
addTrendZones(htsData) {
|
||||
if (!htsData || htsData.length < 2) return;
|
||||
|
||||
const trendZones = [];
|
||||
let currentZone = null;
|
||||
|
||||
for (let i = 1; i < htsData.length; i++) {
|
||||
const prev = htsData[i - 1];
|
||||
const curr = htsData[i];
|
||||
|
||||
const prevBullish = prev.fastLow > prev.slowLow && prev.fastHigh > prev.slowHigh;
|
||||
const currBullish = curr.fastLow > curr.slowLow && curr.fastHigh > curr.slowHigh;
|
||||
|
||||
const prevBearish = prev.fastLow < prev.slowLow && prev.fastHigh < prev.slowHigh;
|
||||
const currBearish = curr.fastLow < curr.slowLow && curr.fastHigh < curr.slowHigh;
|
||||
|
||||
if (currBullish && !prevBullish) {
|
||||
currentZone = { type: 'bullish', start: curr.time };
|
||||
} else if (currBearish && !prevBearish) {
|
||||
currentZone = { type: 'bearish', start: curr.time };
|
||||
} else if (!currBullish && !currBearish && currentZone) {
|
||||
currentZone.end = prev.time;
|
||||
trendZones.push({ ...currentZone });
|
||||
currentZone = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentZone) {
|
||||
currentZone.end = htsData[htsData.length - 1].time;
|
||||
trendZones.push(currentZone);
|
||||
}
|
||||
|
||||
trendZones.forEach(zone => {
|
||||
const zoneSeries = this.chart.addSeries(LightweightCharts.AreaSeries, {
|
||||
topColor: zone.type === 'bullish' ? 'rgba(38, 166, 154, 0.02)' : 'rgba(239, 83, 80, 0.02)',
|
||||
bottomColor: zone.type === 'bullish' ? 'rgba(38, 166, 154, 0.02)' : 'rgba(239, 83, 80, 0.02)',
|
||||
lineColor: 'transparent',
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
});
|
||||
|
||||
if (this.candles && this.candles.length > 0) {
|
||||
const maxPrice = Math.max(...this.candles.map(c => c.high)) * 2;
|
||||
const minPrice = Math.min(...this.candles.map(c => c.low)) * 0.5;
|
||||
|
||||
const startTime = zone.start || (this.candles[0]?.time);
|
||||
const endTime = zone.end || (this.candles[this.candles.length - 1]?.time);
|
||||
|
||||
zoneSeries.setData([
|
||||
{ time: startTime, value: minPrice },
|
||||
{ time: startTime, value: maxPrice },
|
||||
{ time: endTime, value: maxPrice },
|
||||
{ time: endTime, value: minPrice }
|
||||
]);
|
||||
}
|
||||
|
||||
this.overlays.push({ series: zoneSeries, name: `trendZone_${zone.type}_${zone.start}` });
|
||||
});
|
||||
}
|
||||
|
||||
addCrossoverMarkers(htsData) {
|
||||
if (!htsData || htsData.length < 2) return;
|
||||
|
||||
const markers = [];
|
||||
|
||||
for (let i = 1; i < htsData.length; i++) {
|
||||
const prev = htsData[i - 1];
|
||||
const curr = htsData[i];
|
||||
|
||||
if (!prev || !curr) continue;
|
||||
|
||||
const price = curr.price;
|
||||
|
||||
const prevFastLow = prev.fastLow;
|
||||
const currFastLow = curr.fastLow;
|
||||
const prevFastHigh = prev.fastHigh;
|
||||
const currFastHigh = curr.fastHigh;
|
||||
const prevSlowLow = prev.slowLow;
|
||||
const currSlowLow = curr.slowLow;
|
||||
const prevSlowHigh = prev.slowHigh;
|
||||
const currSlowHigh = curr.slowHigh;
|
||||
|
||||
if (prevFastLow <= prevSlowLow && currFastLow > currSlowLow && price > currSlowLow) {
|
||||
markers.push({
|
||||
time: curr.time,
|
||||
position: 'belowBar',
|
||||
color: '#26a69a',
|
||||
shape: 'arrowUp',
|
||||
text: 'BUY',
|
||||
size: 1.2
|
||||
});
|
||||
}
|
||||
|
||||
if (prevFastHigh >= prevSlowHigh && currFastHigh < currSlowHigh && price < currSlowHigh) {
|
||||
markers.push({
|
||||
time: curr.time,
|
||||
position: 'aboveBar',
|
||||
color: '#ef5350',
|
||||
shape: 'arrowDown',
|
||||
text: 'SELL',
|
||||
size: 1.2
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const candleSeries = this.candleData?.series;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function addHTSVisualization(chart, candleSeries, htsData, candles, isAutoHTS = false) {
|
||||
const visualizer = new HTSVisualizer(chart, candles);
|
||||
visualizer.candleData = { series: candleSeries };
|
||||
visualizer.addHTSChannels(htsData, isAutoHTS);
|
||||
|
||||
// Disable trend zones to avoid visual clutter
|
||||
// visualizer.addTrendZones(htsData);
|
||||
|
||||
if (window.showCrossoverMarkers !== false) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
visualizer.addCrossoverMarkers(htsData);
|
||||
} catch (e) {
|
||||
console.warn('Crossover markers skipped (API limitation):', e.message);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return visualizer;
|
||||
}
|
||||
14
js/ui/index.js
Normal file
14
js/ui/index.js
Normal file
@ -0,0 +1,14 @@
|
||||
export { TradingDashboard, refreshTA, openAIAnalysis } from './chart.js';
|
||||
export { toggleSidebar, restoreSidebarState } from './sidebar.js';
|
||||
export {
|
||||
renderIndicatorList,
|
||||
addNewIndicator,
|
||||
selectIndicator,
|
||||
renderIndicatorConfig,
|
||||
applyIndicatorConfig,
|
||||
removeIndicator,
|
||||
removeIndicatorByIndex,
|
||||
drawIndicatorsOnChart,
|
||||
getActiveIndicators,
|
||||
setActiveIndicators
|
||||
} from './indicators-panel.js';
|
||||
1200
js/ui/indicators-panel-new.js
Normal file
1200
js/ui/indicators-panel-new.js
Normal file
File diff suppressed because it is too large
Load Diff
868
js/ui/indicators-panel-new.js.bak
Normal file
868
js/ui/indicators-panel-new.js.bak
Normal file
@ -0,0 +1,868 @@
|
||||
import { getAvailableIndicators, IndicatorRegistry as IR } from '../indicators/index.js';
|
||||
|
||||
// State management
|
||||
let activeIndicators = [];
|
||||
let configuringId = null;
|
||||
let searchQuery = '';
|
||||
let selectedCategory = 'all';
|
||||
let nextInstanceId = 1;
|
||||
let listenersAttached = false; // Single flag to track if any listeners are attached
|
||||
|
||||
// Chart pane management
|
||||
let indicatorPanes = new Map();
|
||||
let nextPaneIndex = 1;
|
||||
|
||||
// Presets storage
|
||||
let userPresets = JSON.parse(localStorage.getItem('indicator_presets') || '{}');
|
||||
|
||||
// Categories
|
||||
const CATEGORIES = [
|
||||
{ id: 'all', name: 'All Indicators', icon: '📊' },
|
||||
{ id: 'trend', name: 'Trend', icon: '📊' },
|
||||
{ id: 'momentum', name: 'Momentum', icon: '📈' },
|
||||
{ id: 'volatility', name: 'Volatility', icon: '📉' },
|
||||
{ id: 'volume', name: 'Volume', icon: '🔀' },
|
||||
{ id: 'favorites', name: 'Favorites', icon: '★' }
|
||||
];
|
||||
|
||||
const CATEGORY_MAP = {
|
||||
sma: 'trend', ema: 'trend', hts: 'trend',
|
||||
rsi: 'momentum', macd: 'momentum', stoch: 'momentum',
|
||||
bb: 'volatility', atr: 'volatility',
|
||||
others: 'volume'
|
||||
};
|
||||
|
||||
const DEFAULT_COLORS = ['#2962ff', '#26a69a', '#ef5350', '#ff9800', '#9c27b0', '#00bcd4', '#ffeb3b', '#e91e63'];
|
||||
const LINE_TYPES = ['solid', 'dotted', 'dashed'];
|
||||
|
||||
function getDefaultColor(index) {
|
||||
return DEFAULT_COLORS[index % DEFAULT_COLORS.length];
|
||||
}
|
||||
|
||||
function getIndicatorCategory(indicator) {
|
||||
return CATEGORY_MAP[indicator.type] || 'trend';
|
||||
}
|
||||
|
||||
function getIndicatorLabel(indicator) {
|
||||
const meta = getIndicatorMeta(indicator);
|
||||
if (!meta) return indicator.name;
|
||||
|
||||
const paramParts = meta.inputs.map(input => {
|
||||
const val = indicator.params[input.name];
|
||||
if (val !== undefined && val !== input.default) return val;
|
||||
return null;
|
||||
}).filter(v => v !== null);
|
||||
|
||||
if (paramParts.length > 0) {
|
||||
return `${indicator.name} (${paramParts.join(', ')})`;
|
||||
}
|
||||
return indicator.name;
|
||||
}
|
||||
|
||||
function getIndicatorMeta(indicator) {
|
||||
const IndicatorClass = IR?.[indicator.type];
|
||||
if (!IndicatorClass) return null;
|
||||
const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name });
|
||||
return instance.getMetadata();
|
||||
}
|
||||
|
||||
function groupPlotsByColor(plots) {
|
||||
const groups = {};
|
||||
plots.forEach((plot, idx) => {
|
||||
const groupMap = {
|
||||
'fast': 'Fast', 'slow': 'Slow', 'upper': 'Upper', 'lower': 'Lower',
|
||||
'middle': 'Middle', 'basis': 'Middle', 'signal': 'Signal',
|
||||
'histogram': 'Histogram', 'k': '%K', 'd': '%D'
|
||||
};
|
||||
const groupName = Object.entries(groupMap).find(([k, v]) => plot.id.toLowerCase().includes(k))?.[1] || plot.id;
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = { name: groupName, indices: [], plots: [] };
|
||||
}
|
||||
groups[groupName].indices.push(idx);
|
||||
groups[groupName].plots.push(plot);
|
||||
});
|
||||
return Object.values(groups);
|
||||
}
|
||||
|
||||
export function initIndicatorPanel() {
|
||||
console.log('[IndicatorPanel] Initializing...');
|
||||
renderIndicatorPanel();
|
||||
console.log('[IndicatorPanel] Initialized');
|
||||
}
|
||||
|
||||
export function getActiveIndicators() {
|
||||
return activeIndicators;
|
||||
}
|
||||
|
||||
export function setActiveIndicators(indicators) {
|
||||
activeIndicators = indicators;
|
||||
renderIndicatorPanel();
|
||||
}
|
||||
|
||||
// Render main panel
|
||||
export function renderIndicatorPanel() {
|
||||
const container = document.getElementById('indicatorPanel');
|
||||
if (!container) {
|
||||
console.error('[IndicatorPanel] Container #indicatorPanel not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[IndicatorPanel] Rendering panel, searchQuery:', searchQuery, 'selectedCategory:', selectedCategory);
|
||||
|
||||
const available = getAvailableIndicators();
|
||||
const catalog = available.filter(ind => {
|
||||
if (searchQuery && !ind.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
if (selectedCategory === 'all') return true;
|
||||
if (selectedCategory === 'favorites') return false;
|
||||
const cat = CATEGORY_MAP[ind.type] || 'trend';
|
||||
return cat === selectedCategory;
|
||||
});
|
||||
|
||||
console.log("[IndicatorPanel] Total indicators:", available.length, "Filtered to:", catalog.length);
|
||||
|
||||
const favoriteIds = new Set(userPresets.favorites || []);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="indicator-panel">
|
||||
<!-- Search Bar -->
|
||||
<div class="indicator-search">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
id="indicatorSearch"
|
||||
placeholder="Search indicators..."
|
||||
value="${searchQuery}"
|
||||
autocomplete="off"
|
||||
>
|
||||
${searchQuery ? `<button class="search-clear">×</button>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="category-tabs">
|
||||
${CATEGORIES.map(cat => `
|
||||
<button class="category-tab ${selectedCategory === cat.id ? 'active' : ''}" data-category="${cat.id}">
|
||||
${cat.icon} ${cat.name}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Favorites (if any) -->
|
||||
${[...favoriteIds].length > 0 ? `
|
||||
<div class="indicator-section favorites">
|
||||
<div class="section-title">★ Favorites</div>
|
||||
${[...favoriteIds].map(id => {
|
||||
const ind = available.find(a => {
|
||||
return a.type === id || (activeIndicators.find(ai => ai.id === id)?.type === '');
|
||||
});
|
||||
if (!ind) return '';
|
||||
return renderIndicatorItem(ind, true);
|
||||
}).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Active Indicators -->
|
||||
${activeIndicators.length > 0 ? `
|
||||
<div class="indicator-section active">
|
||||
<div class="section-title">
|
||||
${activeIndicators.length} Active
|
||||
${activeIndicators.length > 0 ? `<button class="clear-all">Clear All</button>` : ''}
|
||||
</div>
|
||||
${activeIndicators.slice().reverse().map(ind => renderActiveIndicator(ind)).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Available Indicators -->
|
||||
${catalog.length > 0 ? `
|
||||
<div class="indicator-section catalog">
|
||||
<div class="section-title">Available Indicators</div>
|
||||
${catalog.map(ind => renderIndicatorItem(ind, false)).join('')}
|
||||
</div>
|
||||
` : `
|
||||
<div class="no-results">
|
||||
No indicators found
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Only setup event listeners once
|
||||
if (!listenersAttached) {
|
||||
setupEventListeners();
|
||||
listenersAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderIndicatorItem(indicator, isFavorite) {
|
||||
const colorDots = '';
|
||||
|
||||
return `
|
||||
<div class="indicator-item ${isFavorite ? 'favorite' : ''}" data-type="${indicator.type}">
|
||||
<div class="indicator-item-main">
|
||||
<span class="indicator-name">${indicator.name}</span>
|
||||
<span class="indicator-desc">${indicator.description || ''}</span>
|
||||
</div>
|
||||
<div class="indicator-actions">
|
||||
<button class="indicator-btn add" data-type="${indicator.type}" title="Add to chart">+</button>
|
||||
${isFavorite ? '' : `
|
||||
<button class="indicator-btn favorite" data-type="${indicator.type}" title="Add to favorites">
|
||||
${userPresets.favorites?.includes(indicator.type) ? '★' : '☆'}
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderActiveIndicator(indicator) {
|
||||
const isExpanded = configuringId === indicator.id;
|
||||
const meta = getIndicatorMeta(indicator);
|
||||
const label = getIndicatorLabel(indicator);
|
||||
const isFavorite = userPresets.favorites?.includes(indicator.type) || false;
|
||||
const showPresets = meta.name && function() {
|
||||
const hasPresets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : [];
|
||||
if (!hasPresets || hasPresets.length === 0) return '';
|
||||
return `<div class="indicator-presets">
|
||||
<button class="preset-indicator" title="${hasPresets.length} saved presets">💾</button>
|
||||
</div>`;
|
||||
}();
|
||||
|
||||
return `
|
||||
<div class="indicator-item active ${isExpanded ? 'expanded' : ''}" data-id="${indicator.id}">
|
||||
<div class="indicator-item-main" onclick="window.toggleIndicatorExpand && window.toggleIndicatorExpand('${indicator.id}');">
|
||||
<div class="drag-handle" title="Drag to reorder">⋮⋮</div>
|
||||
<button class="indicator-btn visible" onclick="event.stopPropagation(); window.toggleIndicatorVisibility && window.toggleIndicatorVisibility('${indicator.id}')" title="${indicator.visible !== false ? 'Hide' : 'Show'}">
|
||||
${indicator.visible !== false ? '👁' : '👁🗨'}
|
||||
</button>
|
||||
<span class="indicator-name">${label}</span>
|
||||
${showPresets}
|
||||
<button class="indicator-btn favorite" onclick="event.stopPropagation(); window.toggleFavorite && window.toggleFavorite('${indicator.type}')" title="Add to favorites">
|
||||
${isFavorite ? '★' : '☆'}
|
||||
</button>
|
||||
<button class="indicator-btn expand ${isExpanded ? 'rotated' : ''}" title="Show settings">
|
||||
${isExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${isExpanded ? `
|
||||
<div class="indicator-config">
|
||||
${typeof renderIndicatorConfig === 'function' ? renderIndicatorConfig(indicator, meta) : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPresetIndicatorIndicator(meta, indicator) {
|
||||
const hasPresets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : [];
|
||||
if (!hasPresets || hasPresets.length === 0) return '';
|
||||
|
||||
return `<button class="preset-indicator" title="${hasPresets.length} saved presets" onclick="event.stopPropagation(); window.showPresets && window.showPresets('${meta.name}')">💾</button>`;
|
||||
}
|
||||
|
||||
function renderIndicatorConfig(indicator, meta) {
|
||||
const plotGroups = groupPlotsByColor(meta?.plots || []);
|
||||
|
||||
return `
|
||||
<div class="config-sections">
|
||||
<!-- Colors -->
|
||||
<div class="config-section">
|
||||
<div class="section-subtitle">Visual Settings</div>
|
||||
${plotGroups.map(group => {
|
||||
const firstIdx = group.indices[0];
|
||||
const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx]?.color || getDefaultColor(activeIndicators.indexOf(indicator));
|
||||
return `
|
||||
<div class="config-row">
|
||||
<label>${group.name} Color</label>
|
||||
<div class="color-picker">
|
||||
<input type="color" id="color_${indicator.id}_${firstIdx}" value="${color}" onchange="window.updateIndicatorColor && window.updateIndicatorColor('${indicator.id}', ${firstIdx}, this.value)">
|
||||
<span class="color-preview" style="background: ${color};"></span>
|
||||
</div>
|
||||
</div>
|
||||
`.trim() + '';
|
||||
}).join('')}
|
||||
|
||||
${indicator.type !== 'rsi' ? `
|
||||
<div class="config-row">
|
||||
<label>Line Type</label>
|
||||
<select onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '_lineType', this.value)">
|
||||
${LINE_TYPES.map(lt => `<option value="${lt}" ${indicator.params._lineType === lt ? 'selected' : ''}>${lt.charAt(0).toUpperCase() + lt.slice(1)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<label>Line Width</label>
|
||||
<input type="range" min="1" max="5" value="${indicator.params._lineWidth || 2}" onchange="this.nextElementSibling.textContent = this.value; window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '_lineWidth', parseInt(this.value))">
|
||||
<span class="range-value">${indicator.params._lineWidth || 2}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${meta?.inputs && meta.inputs.length > 0 ? `
|
||||
<div class="config-section">
|
||||
<div class="section-subtitle">Parameters</div>
|
||||
${meta.inputs.map(input => `
|
||||
<div class="config-row">
|
||||
<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('')}
|
||||
</select>` :
|
||||
`<input
|
||||
type="number"
|
||||
value="${indicator.params[input.name]}"
|
||||
${input.min !== undefined ? `min="${input.min}"` : ''}
|
||||
${input.max !== undefined ? `max="${input.max}"` : ''}
|
||||
${input.step !== undefined ? `step="${input.step}"` : ''}
|
||||
onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '${input.name}', parseFloat(this.value))"
|
||||
>`
|
||||
}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${meta?.inputs && meta.inputs.length > 0 ? `
|
||||
<div class="config-section">
|
||||
<div class="section-subtitle">Parameters</div>
|
||||
${meta.inputs.map(input => `
|
||||
${console.log("[DEBUG] Input:", input.name, "value:", indicator.params[input.name])}`
|
||||
<div class="config-row">
|
||||
<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('')}
|
||||
</select>` :
|
||||
`<input
|
||||
type="number"
|
||||
value="${indicator.params[input.name]}"
|
||||
${input.min !== undefined ? `min="${input.min}"` : ''}
|
||||
${input.max !== undefined ? `max="${input.max}"` : ''}
|
||||
${input.step !== undefined ? `step="${input.step}"` : ''}
|
||||
onchange="window.updateIndicatorSetting && window.updateIndicatorSetting('${indicator.id}', '${input.name}', parseFloat(this.value))"
|
||||
>`
|
||||
}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="config-section">
|
||||
<div class="section-subtitle">
|
||||
Presets
|
||||
<button class="preset-action-btn" onclick="window.savePreset && window.savePreset('${indicator.id}')">+ Save Preset</button>
|
||||
</div>
|
||||
${typeof renderIndicatorPresets === 'function' ? renderIndicatorPresets(indicator, meta) : ''}
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<button class="btn-secondary" onclick="window.resetIndicator && window.resetIndicator('${indicator.id}')">Reset to Defaults</button>
|
||||
<button class="btn-danger" onclick="window.removeIndicator && window.removeIndicator('${indicator.id}')">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderIndicatorPresets(indicator, meta) {
|
||||
const presets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : [];
|
||||
|
||||
return presets.length > 0 ? `
|
||||
<div class="presets-list">
|
||||
${presets.map(p => {
|
||||
const isApplied = meta.inputs.every(input =>
|
||||
(indicator.params[input.name] === (preset.values?.[input.name] ?? input.default))
|
||||
);
|
||||
|
||||
return `
|
||||
<div class="preset-item ${isApplied ? 'applied' : ''}" data-preset="${preset.id}">
|
||||
<span class="preset-label" onclick="window.applyPreset && window.applyPreset('${indicator.id}', '${preset.id}')">${preset.name}</span>
|
||||
<button class="preset-delete" onclick="window.deletePreset && window.deletePreset('${preset.id}')">×</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : '<div class="no-presets">No saved presets</div>';
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
function setupEventListeners() {
|
||||
const container = document.getElementById('indicatorPanel');
|
||||
if (!container) return;
|
||||
|
||||
console.log('[IndicatorPanel] Setting up event listeners...');
|
||||
|
||||
// Single event delegation handler for add button
|
||||
container.addEventListener('click', (e) => {
|
||||
const addBtn = e.target.closest('.indicator-btn.add');
|
||||
if (addBtn) {
|
||||
e.stopPropagation();
|
||||
const type = addBtn.dataset.type;
|
||||
if (type && window.addIndicator) {
|
||||
console.log('[IndicatorPanel] Add button clicked for type:', type);
|
||||
window.addIndicator(type);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Expand/collapse button
|
||||
const expandBtn = e.target.closest('.indicator-btn.expand');
|
||||
if (expandBtn) {
|
||||
e.stopPropagation();
|
||||
const id = expandBtn.dataset.id;
|
||||
if (id && window.toggleIndicatorExpand) {
|
||||
window.toggleIndicatorExpand(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove button
|
||||
const removeBtn = e.target.closest('.indicator-btn.remove');
|
||||
if (removeBtn) {
|
||||
e.stopPropagation();
|
||||
const id = removeBtn.dataset.id;
|
||||
if (id && window.removeIndicatorById) {
|
||||
window.removeIndicatorById(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Favorite button
|
||||
const favoriteBtn = e.target.closest('.indicator-btn.favorite');
|
||||
if (favoriteBtn) {
|
||||
e.stopPropagation();
|
||||
const type = favoriteBtn.dataset.type;
|
||||
if (type && window.toggleFavorite) {
|
||||
window.toggleFavorite(type);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Search input
|
||||
const searchInput = document.getElementById('indicatorSearch');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
searchQuery = e.target.value;
|
||||
renderIndicatorPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Search clear button
|
||||
const searchClear = container.querySelector('.search-clear');
|
||||
if (searchClear) {
|
||||
searchClear.addEventListener('click', (e) => {
|
||||
searchQuery = '';
|
||||
renderIndicatorPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Category tabs
|
||||
document.querySelectorAll('.category-tab').forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
selectedCategory = tab.dataset.category;
|
||||
renderIndicatorPanel();
|
||||
});
|
||||
});
|
||||
|
||||
// Clear all button
|
||||
const clearAllBtn = container.querySelector('.clear-all');
|
||||
if (clearAllBtn) {
|
||||
clearAllBtn.addEventListener('click', () => {
|
||||
window.clearAllIndicators();
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[IndicatorPanel] Event listeners setup complete');
|
||||
}
|
||||
|
||||
// Actions
|
||||
window.toggleIndicatorExpand = function(id) {
|
||||
configuringId = configuringId === id ? null : id;
|
||||
renderIndicatorPanel();
|
||||
};
|
||||
|
||||
window.clearSearch = function() {
|
||||
searchQuery = '';
|
||||
renderIndicatorPanel();
|
||||
};
|
||||
|
||||
window.updateIndicatorColor = function(id, index, color) {
|
||||
const indicator = activeIndicators.find(a => a.id === id);
|
||||
if (!indicator) return;
|
||||
|
||||
indicator.params[`_color_${index}`] = color;
|
||||
drawIndicatorsOnChart();
|
||||
};
|
||||
|
||||
window.updateIndicatorSetting = function(id, key, value) {
|
||||
const indicator = activeIndicators.find(a => a.id === id);
|
||||
if (!indicator) return;
|
||||
|
||||
indicator.params[key] = value;
|
||||
drawIndicatorsOnChart();
|
||||
};
|
||||
|
||||
window.clearAllIndicators = function() {
|
||||
activeIndicators.forEach(ind => {
|
||||
ind.series?.forEach(s => {
|
||||
try { window.dashboard?.chart?.removeSeries(s); } catch(e) {}
|
||||
});
|
||||
});
|
||||
activeIndicators = [];
|
||||
configuringId = null;
|
||||
renderIndicatorPanel();
|
||||
drawIndicatorsOnChart();
|
||||
}
|
||||
|
||||
function removeIndicatorById(id) {
|
||||
const idx = activeIndicators.findIndex(a => a.id === id);
|
||||
if (idx < 0) return;
|
||||
|
||||
activeIndicators[idx].series?.forEach(s => {
|
||||
try { window.dashboard?.chart?.removeSeries(s); } catch(e) {}
|
||||
});
|
||||
|
||||
activeIndicators.splice(idx, 1);
|
||||
|
||||
if (configuringId === id) {
|
||||
configuringId = null;
|
||||
}
|
||||
|
||||
renderIndicatorPanel();
|
||||
drawIndicatorsOnChart();
|
||||
}
|
||||
|
||||
// Presets
|
||||
function getPresetsForIndicator(indicatorName) {
|
||||
if (!userPresets || !userPresets.presets) return [];
|
||||
return userPresets.presets.filter(p => p.indicatorName === indicatorName);
|
||||
}
|
||||
|
||||
window.savePreset = function(id) {
|
||||
const indicator = activeIndicators.find(a => a.id === id);
|
||||
if (!indicator) return;
|
||||
|
||||
const presetName = prompt('Enter preset name:');
|
||||
if (!presetName) return;
|
||||
|
||||
const IndicatorClass = IR?.[indicator.type];
|
||||
if (!IndicatorClass) return;
|
||||
|
||||
const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name });
|
||||
const meta = instance.getMetadata();
|
||||
|
||||
const preset = {
|
||||
id: `preset_${Date.now()}`,
|
||||
name: presetName,
|
||||
indicatorName: meta.name,
|
||||
values: {}
|
||||
};
|
||||
|
||||
meta.inputs.forEach(input => {
|
||||
preset.values[input.name] = indicator.params[input.name];
|
||||
});
|
||||
|
||||
if (!userPresets.presets) userPresets.presets = [];
|
||||
userPresets.presets.push(preset);
|
||||
saveUserPresets();
|
||||
renderIndicatorPanel();
|
||||
|
||||
alert(`Preset "${presetName}" saved!`);
|
||||
};
|
||||
|
||||
window.applyPreset = function(id, presetId) {
|
||||
const allPresets = (userPresets?.presets || []).filter(p => typeof p === 'object' && p.id);
|
||||
const preset = allPresets.find(p => p.id === presetId);
|
||||
if (!preset) return;
|
||||
|
||||
const indicator = activeIndicators.find(a => a.id === id);
|
||||
if (!indicator) return;
|
||||
|
||||
Object.keys(preset.values).forEach(key => {
|
||||
indicator.params[key] = preset.values[key];
|
||||
});
|
||||
|
||||
renderIndicatorPanel();
|
||||
drawIndicatorsOnChart();
|
||||
};
|
||||
|
||||
window.deletePreset = function(presetId) {
|
||||
if (!confirm('Delete this preset?')) return;
|
||||
|
||||
if (userPresets?.presets) {
|
||||
userPresets.presets = userPresets.presets.filter(p => p.id !== presetId);
|
||||
saveUserPresets();
|
||||
renderIndicatorPanel();
|
||||
}
|
||||
};
|
||||
|
||||
window.showPresets = function(indicatorName) {
|
||||
const presets = getPresetsForIndicator(indicatorName);
|
||||
if (presets.length === 0) {
|
||||
alert('No saved presets for this indicator');
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = window.open('', '_blank', 'width=400,height=500');
|
||||
|
||||
let htmlContent =
|
||||
'<html><head><title>Presets - ' + indicatorName + '</title><style>' +
|
||||
'body { font-family: sans-serif; padding: 20px; background: #1e222d; color: #d1d4dc; }' +
|
||||
'.preset { padding: 10px; margin: 5px; background: #131722; border-radius: 4px; }' +
|
||||
'.preset:hover { background: #2a2e39; cursor: pointer; }' +
|
||||
'</style></head><body>' +
|
||||
'<h3>' + indicatorName + ' Presets</h3>';
|
||||
|
||||
presets.forEach(p => {
|
||||
htmlContent += '<div class="preset" onclick="opener.applyPresetFromWindow(' + "'" + p.id + "'" + ')">' + p.name + '</div>';
|
||||
});
|
||||
|
||||
htmlContent += '</body></html>';
|
||||
|
||||
menu.document.write(htmlContent);
|
||||
};
|
||||
|
||||
window.applyPresetFromWindow = function(presetId) {
|
||||
const indicator = activeIndicators.find(a => a.id === configuringId);
|
||||
if (!indicator) return;
|
||||
applyPreset(indicator.id, presetId);
|
||||
};
|
||||
|
||||
function addIndicator(type) {
|
||||
const IndicatorClass = IR?.[type];
|
||||
if (!IndicatorClass) return;
|
||||
|
||||
const id = `${type}_${nextInstanceId++}`;
|
||||
const instance = new IndicatorClass({ type, params: {}, name: '' });
|
||||
const metadata = instance.getMetadata();
|
||||
|
||||
const params = {
|
||||
_lineType: 'solid',
|
||||
_lineWidth: 2
|
||||
};
|
||||
metadata.plots.forEach((plot, idx) => {
|
||||
params[`_color_${idx}`] = plot.color || getDefaultColor(activeIndicators.length + idx);
|
||||
});
|
||||
metadata.inputs.forEach(input => {
|
||||
params[input.name] = input.default;
|
||||
});
|
||||
|
||||
activeIndicators.push({
|
||||
id,
|
||||
type,
|
||||
name: metadata.name,
|
||||
params,
|
||||
plots: metadata.plots,
|
||||
series: [],
|
||||
visible: true
|
||||
});
|
||||
// Don't set configuringId so indicators are NOT expanded by default
|
||||
renderIndicatorPanel();
|
||||
drawIndicatorsOnChart();
|
||||
};
|
||||
|
||||
function saveUserPresets() {
|
||||
localStorage.setItem('indicator_presets', JSON.stringify(userPresets));
|
||||
}
|
||||
|
||||
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
|
||||
const results = instance.calculate(candles);
|
||||
indicator.series = [];
|
||||
|
||||
const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
|
||||
const lineWidth = indicator.params._lineWidth || 2;
|
||||
|
||||
const firstNonNull = results?.find(r => r !== null && r !== undefined);
|
||||
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
|
||||
|
||||
meta.plots.forEach((plot, plotIdx) => {
|
||||
if (isObjectResult) {
|
||||
const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null);
|
||||
if (!hasData) return;
|
||||
}
|
||||
|
||||
const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff';
|
||||
|
||||
const data = [];
|
||||
for (let i = 0; i < candles.length; i++) {
|
||||
let value;
|
||||
if (isObjectResult) {
|
||||
value = results[i]?.[plot.id];
|
||||
} else {
|
||||
value = results[i];
|
||||
}
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
data.push({
|
||||
time: candles[i].time,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length === 0) return;
|
||||
|
||||
let series;
|
||||
let plotLineStyle = lineStyle;
|
||||
if (plot.style === 'dashed') plotLineStyle = LightweightCharts.LineStyle.Dashed;
|
||||
else if (plot.style === 'dotted') plotLineStyle = LightweightCharts.LineStyle.Dotted;
|
||||
else if (plot.style === 'solid') plotLineStyle = LightweightCharts.LineStyle.Solid;
|
||||
|
||||
if (plot.type === 'histogram') {
|
||||
series = window.dashboard.chart.addSeries(LightweightCharts.HistogramSeries, {
|
||||
color: plotColor,
|
||||
priceFormat: { type: 'price', precision: 4, minMove: 0.0001 },
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false
|
||||
}, paneIndex);
|
||||
} else if (plot.type === 'baseline') {
|
||||
series = window.dashboard.chart.addSeries(LightweightCharts.BaselineSeries, {
|
||||
baseValue: { type: 'price', price: plot.baseValue || 0 },
|
||||
topLineColor: plot.topLineColor || plotColor,
|
||||
topFillColor1: plot.topFillColor1 || plotColor,
|
||||
topFillColor2: '#00000000',
|
||||
bottomFillColor1: '#00000000',
|
||||
bottomColor: plot.bottomColor || '#00000000',
|
||||
lineWidth: plot.width || indicator.params._lineWidth || lineWidth,
|
||||
lineStyle: plotLineStyle,
|
||||
title: plot.title || '',
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: plot.lastValueVisible !== false
|
||||
}, paneIndex);
|
||||
} else {
|
||||
series = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, {
|
||||
color: plotColor,
|
||||
lineWidth: plot.width || indicator.params._lineWidth || lineWidth,
|
||||
lineStyle: plotLineStyle,
|
||||
title: plot.title || '',
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: plot.lastValueVisible !== false
|
||||
}, paneIndex);
|
||||
}
|
||||
|
||||
series.setData(data);
|
||||
indicator.series.push(series);
|
||||
|
||||
// Create horizontal band lines for RSI
|
||||
if (meta.name === 'RSI' && indicator.series.length > 0) {
|
||||
const mainSeries = indicator.series[0];
|
||||
const overbought = indicator.params.overbought || 70;
|
||||
const oversold = indicator.params.oversold || 30;
|
||||
|
||||
// Remove existing price lines first
|
||||
while (indicator.bands && indicator.bands.length > 0) {
|
||||
try {
|
||||
indicator.bands.pop();
|
||||
} catch(e) {}
|
||||
}
|
||||
indicator.bands = indicator.bands || [];
|
||||
|
||||
// Create overbought band line
|
||||
indicator.bands.push(mainSeries.createPriceLine({
|
||||
price: overbought,
|
||||
color: '#787B86',
|
||||
lineWidth: 1,
|
||||
lineStyle: LightweightCharts.LineStyle.Dashed,
|
||||
axisLabelVisible: false,
|
||||
title: ''
|
||||
}));
|
||||
|
||||
// Create oversold band line
|
||||
indicator.bands.push(mainSeries.createPriceLine({
|
||||
price: oversold,
|
||||
color: '#787B86',
|
||||
lineWidth: 1,
|
||||
lineStyle: LightweightCharts.LineStyle.Dashed,
|
||||
axisLabelVisible: false,
|
||||
title: ''
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Chart drawing
|
||||
export function drawIndicatorsOnChart() {
|
||||
if (!window.dashboard || !window.dashboard.chart) return;
|
||||
|
||||
activeIndicators.forEach(ind => {
|
||||
ind.series?.forEach(s => {
|
||||
try { window.dashboard.chart.removeSeries(s); } catch(e) {}
|
||||
});
|
||||
});
|
||||
|
||||
const candles = window.dashboard.allData.get(window.dashboard.currentInterval);
|
||||
if (!candles || candles.length === 0) return;
|
||||
|
||||
const lineStyleMap = {
|
||||
'solid': LightweightCharts.LineStyle.Solid,
|
||||
'dotted': LightweightCharts.LineStyle.Dotted,
|
||||
'dashed': LightweightCharts.LineStyle.Dashed
|
||||
};
|
||||
|
||||
indicatorPanes.clear();
|
||||
nextPaneIndex = 1;
|
||||
|
||||
const overlayIndicators = [];
|
||||
const paneIndicators = [];
|
||||
|
||||
activeIndicators.forEach(ind => {
|
||||
const IndicatorClass = IR?.[ind.type];
|
||||
if (!IndicatorClass) return;
|
||||
|
||||
const instance = new IndicatorClass({ type: ind.type, params: ind.params, name: ind.name });
|
||||
const meta = instance.getMetadata();
|
||||
|
||||
if (meta.displayMode === 'pane') {
|
||||
paneIndicators.push({ indicator: ind, meta, instance });
|
||||
} else {
|
||||
overlayIndicators.push({ indicator: ind, meta, instance });
|
||||
}
|
||||
});
|
||||
|
||||
const totalPanes = 1 + paneIndicators.length;
|
||||
const mainPaneHeight = paneIndicators.length > 0 ? 60 : 100;
|
||||
const paneHeight = paneIndicators.length > 0 ? Math.floor(40 / paneIndicators.length) : 0;
|
||||
|
||||
window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight);
|
||||
|
||||
overlayIndicators.forEach(({ indicator, meta, instance }) => {
|
||||
if (indicator.visible === false) {
|
||||
indicator.series = [];
|
||||
return;
|
||||
}
|
||||
|
||||
renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap);
|
||||
});
|
||||
|
||||
paneIndicators.forEach(({ indicator, meta, instance }, idx) => {
|
||||
if (indicator.visible === false) {
|
||||
indicator.series = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const paneIndex = nextPaneIndex++;
|
||||
indicatorPanes.set(indicator.id, paneIndex);
|
||||
|
||||
renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap);
|
||||
|
||||
const pane = window.dashboard.chart.panes()[paneIndex];
|
||||
if (pane) {
|
||||
pane.setHeight(paneHeight);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export functions for module access
|
||||
export { addIndicator, removeIndicatorById };
|
||||
|
||||
// Legacy compatibility functions
|
||||
window.renderIndicatorList = renderIndicatorPanel;
|
||||
window.toggleIndicator = addIndicator;
|
||||
window.showIndicatorConfig = function(id) {
|
||||
const ind = activeIndicators.find(a => a.id === id);
|
||||
if (ind) configuringId = id;
|
||||
renderIndicatorPanel();
|
||||
};
|
||||
window.applyIndicatorConfig = function() {
|
||||
// No-op - config is applied immediately
|
||||
};
|
||||
703
js/ui/indicators-panel.js
Normal file
703
js/ui/indicators-panel.js
Normal file
@ -0,0 +1,703 @@
|
||||
import { getAvailableIndicators, IndicatorRegistry as IR } from '../indicators/index.js';
|
||||
|
||||
let activeIndicators = [];
|
||||
let configuringId = null;
|
||||
let previewingType = null; // type being previewed (not yet added)
|
||||
let nextInstanceId = 1;
|
||||
|
||||
const DEFAULT_COLORS = ['#2962ff', '#26a69a', '#ef5350', '#ff9800', '#9c27b0', '#00bcd4', '#ffeb3b', '#e91e63'];
|
||||
const LINE_TYPES = ['solid', 'dotted', 'dashed'];
|
||||
|
||||
function getDefaultColor(index) {
|
||||
return DEFAULT_COLORS[index % DEFAULT_COLORS.length];
|
||||
}
|
||||
|
||||
function getPlotGroupName(plotId) {
|
||||
if (plotId.toLowerCase().includes('fast')) return 'Fast';
|
||||
if (plotId.toLowerCase().includes('slow')) return 'Slow';
|
||||
if (plotId.toLowerCase().includes('upper')) return 'Upper';
|
||||
if (plotId.toLowerCase().includes('lower')) return 'Lower';
|
||||
if (plotId.toLowerCase().includes('middle') || plotId.toLowerCase().includes('basis')) return 'Middle';
|
||||
if (plotId.toLowerCase().includes('signal')) return 'Signal';
|
||||
if (plotId.toLowerCase().includes('histogram')) return 'Histogram';
|
||||
if (plotId.toLowerCase().includes('k')) return '%K';
|
||||
if (plotId.toLowerCase().includes('d')) return '%D';
|
||||
return plotId;
|
||||
}
|
||||
|
||||
function groupPlotsByColor(plots) {
|
||||
const groups = {};
|
||||
plots.forEach((plot, idx) => {
|
||||
const groupName = getPlotGroupName(plot.id);
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = { name: groupName, indices: [], plots: [] };
|
||||
}
|
||||
groups[groupName].indices.push(idx);
|
||||
groups[groupName].plots.push(plot);
|
||||
});
|
||||
return Object.values(groups);
|
||||
}
|
||||
|
||||
/** Generate a short label for an active indicator showing its key params */
|
||||
function getIndicatorLabel(indicator) {
|
||||
const meta = getIndicatorMeta(indicator);
|
||||
if (!meta) return indicator.name;
|
||||
|
||||
const paramParts = meta.inputs.map(input => {
|
||||
const val = indicator.params[input.name];
|
||||
if (val !== undefined && val !== input.default) return val;
|
||||
if (val !== undefined) return val;
|
||||
return null;
|
||||
}).filter(v => v !== null);
|
||||
|
||||
if (paramParts.length > 0) {
|
||||
return `${indicator.name} (${paramParts.join(', ')})`;
|
||||
}
|
||||
return indicator.name;
|
||||
}
|
||||
|
||||
function getIndicatorMeta(indicator) {
|
||||
const IndicatorClass = IR?.[indicator.type];
|
||||
if (!IndicatorClass) return null;
|
||||
const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name });
|
||||
return instance.getMetadata();
|
||||
}
|
||||
|
||||
export function getActiveIndicators() {
|
||||
return activeIndicators;
|
||||
}
|
||||
|
||||
export function setActiveIndicators(indicators) {
|
||||
activeIndicators = indicators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the indicator catalog (available indicators) and active list.
|
||||
* Catalog items are added via double-click (multiple instances allowed).
|
||||
*/
|
||||
export function renderIndicatorList() {
|
||||
const container = document.getElementById('indicatorList');
|
||||
if (!container) return;
|
||||
|
||||
const available = getAvailableIndicators();
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="indicator-catalog">
|
||||
${available.map(ind => `
|
||||
<div class="indicator-catalog-item ${previewingType === ind.type ? 'previewing' : ''}"
|
||||
title="${ind.description || ''}"
|
||||
data-type="${ind.type}">
|
||||
<span class="indicator-catalog-name">${ind.name}</span>
|
||||
<span class="indicator-catalog-add" data-type="${ind.type}">+</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
${activeIndicators.length > 0 ? `
|
||||
<div class="indicator-active-divider">Active</div>
|
||||
<div class="indicator-active-list">
|
||||
${activeIndicators.map(ind => {
|
||||
const isConfiguring = ind.id === configuringId;
|
||||
const plotGroups = groupPlotsByColor(ind.plots || []);
|
||||
const colorDots = plotGroups.map(group => {
|
||||
const firstIdx = group.indices[0];
|
||||
const color = ind.params[`_color_${firstIdx}`] || '#2962ff';
|
||||
return `<span class="indicator-color-dot" style="background: ${color};"></span>`;
|
||||
}).join('');
|
||||
const label = getIndicatorLabel(ind);
|
||||
|
||||
return `
|
||||
<div class="indicator-active-item ${isConfiguring ? 'configuring' : ''}"
|
||||
data-id="${ind.id}">
|
||||
<span class="indicator-active-eye" data-id="${ind.id}"
|
||||
title="${ind.visible !== false ? 'Hide' : 'Show'}">
|
||||
${ind.visible !== false ? '👁' : '👁🗨'}
|
||||
</span>
|
||||
<span class="indicator-active-name" data-id="${ind.id}">${label}</span>
|
||||
${colorDots}
|
||||
<button class="indicator-config-btn ${isConfiguring ? 'active' : ''}"
|
||||
data-id="${ind.id}" title="Configure">⚙</button>
|
||||
<button class="indicator-remove-btn"
|
||||
data-id="${ind.id}" title="Remove">×</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
// Bind events via delegation
|
||||
container.querySelectorAll('.indicator-catalog-item').forEach(el => {
|
||||
el.addEventListener('click', () => previewIndicator(el.dataset.type));
|
||||
el.addEventListener('dblclick', () => addIndicator(el.dataset.type));
|
||||
});
|
||||
container.querySelectorAll('.indicator-catalog-add').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
addIndicator(el.dataset.type);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.indicator-active-name').forEach(el => {
|
||||
el.addEventListener('click', () => selectIndicatorConfig(el.dataset.id));
|
||||
});
|
||||
container.querySelectorAll('.indicator-config-btn').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectIndicatorConfig(el.dataset.id);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.indicator-remove-btn').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeIndicatorById(el.dataset.id);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.indicator-active-eye').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleVisibility(el.dataset.id);
|
||||
});
|
||||
});
|
||||
|
||||
updateConfigPanel();
|
||||
updateChartLegend();
|
||||
}
|
||||
|
||||
function updateConfigPanel() {
|
||||
const configPanel = document.getElementById('indicatorConfigPanel');
|
||||
const configButtons = document.getElementById('configButtons');
|
||||
if (!configPanel) return;
|
||||
|
||||
configPanel.style.display = 'block';
|
||||
|
||||
// Active indicator config takes priority over preview
|
||||
const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null;
|
||||
|
||||
if (indicator) {
|
||||
renderIndicatorConfig(indicator);
|
||||
if (configButtons) configButtons.style.display = 'flex';
|
||||
} else if (previewingType) {
|
||||
renderPreviewConfig(previewingType);
|
||||
if (configButtons) configButtons.style.display = 'none';
|
||||
} else {
|
||||
const container = document.getElementById('configForm');
|
||||
if (container) {
|
||||
container.innerHTML = '<div style="text-align: center; color: var(--tv-text-secondary); padding: 20px; font-size: 12px;">Click an indicator to preview its settings</div>';
|
||||
}
|
||||
if (configButtons) configButtons.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-click: preview config for a catalog indicator type (read-only) */
|
||||
function previewIndicator(type) {
|
||||
configuringId = null;
|
||||
previewingType = previewingType === type ? null : type;
|
||||
renderIndicatorList();
|
||||
}
|
||||
|
||||
/** Render a read-only preview of an indicator's default config */
|
||||
function renderPreviewConfig(type) {
|
||||
const container = document.getElementById('configForm');
|
||||
if (!container) return;
|
||||
|
||||
const IndicatorClass = IR?.[type];
|
||||
if (!IndicatorClass) return;
|
||||
|
||||
const instance = new IndicatorClass({ type, params: {}, name: '' });
|
||||
const meta = instance.getMetadata();
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="font-size: 11px; color: var(--tv-blue); margin-bottom: 4px; font-weight: 600;">${meta.name}</div>
|
||||
<div style="font-size: 11px; color: var(--tv-text-secondary); margin-bottom: 10px;">${meta.description || ''}</div>
|
||||
|
||||
${meta.inputs.map(input => `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">${input.label}</label>
|
||||
${input.type === 'select' ?
|
||||
`<select class="sim-input" style="font-size: 12px; padding: 6px;" disabled>${input.options.map(o => `<option ${input.default === o ? 'selected' : ''}>${o}</option>`).join('')}</select>` :
|
||||
`<input type="number" class="sim-input" value="${input.default}" ${input.step !== undefined ? `step="${input.step}"` : ''} style="font-size: 12px; padding: 6px;" disabled>`
|
||||
}
|
||||
</div>
|
||||
`).join('')}
|
||||
|
||||
<div style="font-size: 10px; color: var(--tv-text-secondary); margin-top: 8px; text-align: center;">Double-click to add to chart</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Add a new instance of an indicator type */
|
||||
export function addIndicator(type) {
|
||||
const IndicatorClass = IR?.[type];
|
||||
if (!IndicatorClass) return;
|
||||
|
||||
previewingType = null;
|
||||
const id = `${type}_${nextInstanceId++}`;
|
||||
const instance = new IndicatorClass({ type, params: {}, name: '' });
|
||||
const metadata = instance.getMetadata();
|
||||
|
||||
const params = {
|
||||
_lineType: 'solid',
|
||||
_lineWidth: 1
|
||||
};
|
||||
|
||||
// Set Hurst-specific defaults
|
||||
if (type === 'hurst') {
|
||||
params.timeframe = 'chart';
|
||||
params.markerBuyShape = 'custom';
|
||||
params.markerSellShape = 'custom';
|
||||
params.markerBuyColor = '#9e9e9e';
|
||||
params.markerSellColor = '#9e9e9e';
|
||||
params.markerBuyCustom = '▲';
|
||||
params.markerSellCustom = '▼';
|
||||
}
|
||||
|
||||
metadata.plots.forEach((plot, idx) => {
|
||||
params[`_color_${idx}`] = plot.color || getDefaultColor(activeIndicators.length + idx);
|
||||
});
|
||||
metadata.inputs.forEach(input => {
|
||||
params[input.name] = input.default;
|
||||
});
|
||||
|
||||
activeIndicators.push({
|
||||
id,
|
||||
type,
|
||||
name: metadata.name,
|
||||
params,
|
||||
plots: metadata.plots,
|
||||
series: [],
|
||||
visible: true
|
||||
});
|
||||
|
||||
configuringId = id;
|
||||
|
||||
renderIndicatorList();
|
||||
drawIndicatorsOnChart();
|
||||
}
|
||||
|
||||
function selectIndicatorConfig(id) {
|
||||
previewingType = null;
|
||||
if (configuringId === id) {
|
||||
configuringId = null;
|
||||
} else {
|
||||
configuringId = id;
|
||||
}
|
||||
renderIndicatorList();
|
||||
}
|
||||
|
||||
function toggleVisibility(id) {
|
||||
const indicator = activeIndicators.find(a => a.id === id);
|
||||
if (!indicator) return;
|
||||
|
||||
indicator.visible = indicator.visible === false ? true : false;
|
||||
|
||||
// Show/hide all series for this indicator
|
||||
indicator.series?.forEach(s => {
|
||||
try {
|
||||
s.applyOptions({ visible: indicator.visible });
|
||||
} catch(e) {}
|
||||
});
|
||||
|
||||
renderIndicatorList();
|
||||
}
|
||||
|
||||
export function renderIndicatorConfig(indicator) {
|
||||
const container = document.getElementById('configForm');
|
||||
if (!container || !indicator) return;
|
||||
|
||||
const IndicatorClass = IR?.[indicator.type];
|
||||
if (!IndicatorClass) {
|
||||
container.innerHTML = '<div style="color: var(--tv-red);">Error loading indicator</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name });
|
||||
const meta = instance.getMetadata();
|
||||
|
||||
const plotGroups = groupPlotsByColor(meta.plots);
|
||||
|
||||
const colorInputs = plotGroups.map(group => {
|
||||
const firstIdx = group.indices[0];
|
||||
const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx].color || '#2962ff';
|
||||
return `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">${group.name} Color</label>
|
||||
<input type="color" id="config__color_${firstIdx}" value="${color}" style="width: 100%; height: 28px; border: 1px solid var(--tv-border); border-radius: 4px; cursor: pointer; background: var(--tv-bg);">
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="font-size: 11px; color: var(--tv-blue); margin-bottom: 8px; font-weight: 600;">${getIndicatorLabel(indicator)}</div>
|
||||
|
||||
${colorInputs}
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">Line Type</label>
|
||||
<select id="config__lineType" class="sim-input" style="font-size: 12px; padding: 6px;">
|
||||
${LINE_TYPES.map(lt => `<option value="${lt}" ${indicator.params._lineType === lt ? 'selected' : ''}>${lt.charAt(0).toUpperCase() + lt.slice(1)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">Line Width</label>
|
||||
<input type="number" id="config__lineWidth" class="sim-input" value="${indicator.params._lineWidth || 1}" min="1" max="5" style="font-size: 12px; padding: 6px;">
|
||||
</div>
|
||||
|
||||
${meta.inputs.map(input => `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size: 10px; color: var(--tv-text-secondary); text-transform: uppercase; display: block; margin-bottom: 4px;">${input.label}</label>
|
||||
${input.type === 'select' ?
|
||||
`<select id="config_${input.name}" class="sim-input" style="font-size: 12px; padding: 6px;">${input.options.map(o => `<option value="${o}" ${indicator.params[input.name] === o ? 'selected' : ''}>${o}</option>`).join('')}</select>` :
|
||||
`<input type="number" id="config_${input.name}" class="sim-input" value="${indicator.params[input.name]}" ${input.min !== undefined ? `min="${input.min}"` : ''} ${input.max !== undefined ? `max="${input.max}"` : ''} ${input.step !== undefined ? `step="${input.step}"` : ''} style="font-size: 12px; padding: 6px;">`
|
||||
}
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
}
|
||||
|
||||
export function applyIndicatorConfig() {
|
||||
const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null;
|
||||
if (!indicator) return;
|
||||
|
||||
const IndicatorClass = IR?.[indicator.type];
|
||||
if (!IndicatorClass) return;
|
||||
|
||||
const instance = new IndicatorClass({ type: indicator.type, params: {}, name: indicator.name });
|
||||
const meta = instance.getMetadata();
|
||||
|
||||
const plotGroups = groupPlotsByColor(meta.plots);
|
||||
plotGroups.forEach(group => {
|
||||
const firstIdx = group.indices[0];
|
||||
const colorEl = document.getElementById(`config__color_${firstIdx}`);
|
||||
if (colorEl) {
|
||||
const color = colorEl.value;
|
||||
group.indices.forEach(idx => {
|
||||
indicator.params[`_color_${idx}`] = color;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const lineTypeEl = document.getElementById('config__lineType');
|
||||
const lineWidthEl = document.getElementById('config__lineWidth');
|
||||
|
||||
if (lineTypeEl) indicator.params._lineType = lineTypeEl.value;
|
||||
if (lineWidthEl) indicator.params._lineWidth = parseInt(lineWidthEl.value);
|
||||
|
||||
meta.inputs.forEach(input => {
|
||||
const el = document.getElementById(`config_${input.name}`);
|
||||
if (el) {
|
||||
indicator.params[input.name] = input.type === 'select' ? el.value : parseFloat(el.value);
|
||||
}
|
||||
});
|
||||
|
||||
renderIndicatorList();
|
||||
drawIndicatorsOnChart();
|
||||
}
|
||||
|
||||
export function removeIndicator() {
|
||||
if (!configuringId) return;
|
||||
removeIndicatorById(configuringId);
|
||||
}
|
||||
|
||||
export function removeIndicatorById(id) {
|
||||
const idx = activeIndicators.findIndex(a => a.id === id);
|
||||
if (idx < 0) return;
|
||||
|
||||
activeIndicators[idx].series?.forEach(s => {
|
||||
try { window.dashboard?.chart?.removeSeries(s); } catch(e) {}
|
||||
});
|
||||
|
||||
activeIndicators.splice(idx, 1);
|
||||
|
||||
if (configuringId === id) {
|
||||
configuringId = null;
|
||||
}
|
||||
|
||||
renderIndicatorList();
|
||||
drawIndicatorsOnChart();
|
||||
}
|
||||
|
||||
export function removeIndicatorByIndex(index) {
|
||||
if (index < 0 || index >= activeIndicators.length) return;
|
||||
removeIndicatorById(activeIndicators[index].id);
|
||||
}
|
||||
|
||||
let indicatorPanes = new Map();
|
||||
let nextPaneIndex = 1;
|
||||
|
||||
export function drawIndicatorsOnChart() {
|
||||
if (!window.dashboard || !window.dashboard.chart) return;
|
||||
|
||||
activeIndicators.forEach(ind => {
|
||||
ind.series?.forEach(s => {
|
||||
try { window.dashboard.chart.removeSeries(s); } catch(e) {}
|
||||
});
|
||||
});
|
||||
|
||||
const candles = window.dashboard.allData.get(window.dashboard.currentInterval);
|
||||
if (!candles || candles.length === 0) return;
|
||||
|
||||
const lineStyleMap = { 'solid': LightweightCharts.LineStyle.Solid, 'dotted': LightweightCharts.LineStyle.Dotted, 'dashed': LightweightCharts.LineStyle.Dashed };
|
||||
|
||||
indicatorPanes.clear();
|
||||
nextPaneIndex = 1;
|
||||
|
||||
const overlayIndicators = [];
|
||||
const paneIndicators = [];
|
||||
|
||||
activeIndicators.forEach(ind => {
|
||||
const IndicatorClass = IR?.[ind.type];
|
||||
if (!IndicatorClass) return;
|
||||
|
||||
const instance = new IndicatorClass({ type: ind.type, params: ind.params, name: ind.name });
|
||||
const meta = instance.getMetadata();
|
||||
|
||||
if (meta.displayMode === 'pane') {
|
||||
paneIndicators.push({ indicator: ind, meta, instance });
|
||||
} else {
|
||||
overlayIndicators.push({ indicator: ind, meta, instance });
|
||||
}
|
||||
});
|
||||
|
||||
const totalPanes = 1 + paneIndicators.length;
|
||||
const mainPaneHeight = paneIndicators.length > 0 ? 60 : 100;
|
||||
const paneHeight = paneIndicators.length > 0 ? Math.floor(40 / paneIndicators.length) : 0;
|
||||
|
||||
window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight);
|
||||
|
||||
overlayIndicators.forEach(({ indicator, meta, instance }) => {
|
||||
if (indicator.visible === false) {
|
||||
indicator.series = [];
|
||||
return;
|
||||
}
|
||||
|
||||
renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap);
|
||||
});
|
||||
|
||||
paneIndicators.forEach(({ indicator, meta, instance }, idx) => {
|
||||
if (indicator.visible === false) {
|
||||
indicator.series = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const paneIndex = nextPaneIndex++;
|
||||
indicatorPanes.set(indicator.id, paneIndex);
|
||||
|
||||
renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap);
|
||||
|
||||
const pane = window.dashboard.chart.panes()[paneIndex];
|
||||
if (pane) {
|
||||
pane.setHeight(paneHeight);
|
||||
}
|
||||
});
|
||||
|
||||
updateChartLegend();
|
||||
}
|
||||
|
||||
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
|
||||
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 = Array.isArray(results) ? results.find(r => r !== null && r !== undefined) : null;
|
||||
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
|
||||
|
||||
meta.plots.forEach((plot, plotIdx) => {
|
||||
if (isObjectResult) {
|
||||
// Find if this specific plot has any non-null data across all results
|
||||
const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null);
|
||||
if (!hasData) return;
|
||||
}
|
||||
|
||||
// Skip hidden plots
|
||||
if (plot.visible === false) return;
|
||||
|
||||
const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff';
|
||||
|
||||
const data = [];
|
||||
for (let i = 0; i < candles.length; i++) {
|
||||
let value;
|
||||
if (isObjectResult) {
|
||||
value = results[i]?.[plot.id];
|
||||
} else {
|
||||
value = results[i];
|
||||
}
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
data.push({
|
||||
time: candles[i].time,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length === 0) return;
|
||||
|
||||
let series;
|
||||
|
||||
// Determine line style for this specific plot
|
||||
let plotLineStyle = lineStyle;
|
||||
if (plot.style === 'dashed') plotLineStyle = LightweightCharts.LineStyle.Dashed;
|
||||
else if (plot.style === 'dotted') plotLineStyle = LightweightCharts.LineStyle.Dotted;
|
||||
else if (plot.style === 'solid') plotLineStyle = LightweightCharts.LineStyle.Solid;
|
||||
|
||||
if (plot.type === 'histogram') {
|
||||
series = window.dashboard.chart.addSeries(LightweightCharts.HistogramSeries, {
|
||||
color: plotColor,
|
||||
priceFormat: {
|
||||
type: 'price',
|
||||
precision: 4,
|
||||
minMove: 0.0001
|
||||
},
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false
|
||||
}, paneIndex);
|
||||
} else if (plot.type === 'baseline') {
|
||||
series = window.dashboard.chart.addSeries(LightweightCharts.BaselineSeries, {
|
||||
baseValue: { type: 'price', price: plot.baseValue || 0 },
|
||||
topLineColor: plot.topLineColor || plotColor,
|
||||
topFillColor1: plot.topFillColor1 || plotColor,
|
||||
topFillColor2: plot.topFillColor2 || '#00000000',
|
||||
bottomFillColor1: plot.bottomFillColor1 || '#00000000',
|
||||
bottomColor: plot.bottomColor || '#00000000',
|
||||
lineWidth: plot.width !== undefined ? plot.width : lineWidth,
|
||||
lineStyle: plotLineStyle,
|
||||
title: plot.title || '',
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: plot.lastValueVisible !== false
|
||||
}, paneIndex);
|
||||
} else {
|
||||
series = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, {
|
||||
color: plotColor,
|
||||
lineWidth: plot.width !== undefined ? plot.width : lineWidth,
|
||||
lineStyle: plotLineStyle,
|
||||
title: plot.title || '',
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: plot.lastValueVisible !== false
|
||||
}, paneIndex);
|
||||
}
|
||||
|
||||
series.setData(data);
|
||||
series.plotId = plot.id;
|
||||
|
||||
// Skip hidden plots (visible: false)
|
||||
if (plot.visible === false) {
|
||||
series.applyOptions({ visible: false });
|
||||
}
|
||||
|
||||
indicator.series.push(series);
|
||||
});
|
||||
|
||||
// Render gradient zones if available
|
||||
if (meta.gradientZones && indicator.series.length > 0) {
|
||||
// Find the main series to attach zones to
|
||||
let baseSeries = indicator.series[0];
|
||||
|
||||
meta.gradientZones.forEach(zone => {
|
||||
if (zone.from === undefined || zone.to === undefined) return;
|
||||
|
||||
// We use createPriceLine on the series for horizontal bands with custom colors
|
||||
baseSeries.createPriceLine({
|
||||
price: zone.from,
|
||||
color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'),
|
||||
lineWidth: 1,
|
||||
lineStyle: LightweightCharts.LineStyle.Solid,
|
||||
axisLabelVisible: false,
|
||||
title: zone.label || '',
|
||||
});
|
||||
|
||||
if (zone.to !== zone.from) {
|
||||
baseSeries.createPriceLine({
|
||||
price: zone.to,
|
||||
color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'),
|
||||
lineWidth: 1,
|
||||
lineStyle: LightweightCharts.LineStyle.Solid,
|
||||
axisLabelVisible: false,
|
||||
title: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the TradingView-style legend overlay on the chart */
|
||||
export function updateChartLegend() {
|
||||
let legend = document.getElementById('chartIndicatorLegend');
|
||||
if (!legend) {
|
||||
const chartWrapper = document.getElementById('chartWrapper');
|
||||
if (!chartWrapper) return;
|
||||
legend = document.createElement('div');
|
||||
legend.id = 'chartIndicatorLegend';
|
||||
legend.className = 'chart-indicator-legend';
|
||||
chartWrapper.appendChild(legend);
|
||||
}
|
||||
|
||||
if (activeIndicators.length === 0) {
|
||||
legend.innerHTML = '';
|
||||
legend.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
legend.style.display = 'flex';
|
||||
legend.innerHTML = activeIndicators.map(ind => {
|
||||
const label = getIndicatorLabel(ind);
|
||||
const plotGroups = groupPlotsByColor(ind.plots || []);
|
||||
const firstColor = ind.params['_color_0'] || '#2962ff';
|
||||
const dimmed = ind.visible === false;
|
||||
|
||||
return `
|
||||
<div class="legend-item ${dimmed ? 'legend-dimmed' : ''} ${ind.id === configuringId ? 'legend-selected' : ''}"
|
||||
data-id="${ind.id}">
|
||||
<span class="legend-dot" style="background: ${firstColor};"></span>
|
||||
<span class="legend-label">${label}</span>
|
||||
<span class="legend-close" data-id="${ind.id}" title="Remove">×</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Bind legend events
|
||||
legend.querySelectorAll('.legend-item').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('legend-close')) return;
|
||||
selectIndicatorConfig(el.dataset.id);
|
||||
renderIndicatorList();
|
||||
});
|
||||
});
|
||||
legend.querySelectorAll('.legend-close').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeIndicatorById(el.dataset.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy compat: toggleIndicator still works for external callers
|
||||
export function toggleIndicator(type) {
|
||||
addIndicator(type);
|
||||
}
|
||||
|
||||
export function showIndicatorConfig(index) {
|
||||
if (index >= 0 && index < activeIndicators.length) {
|
||||
selectIndicatorConfig(activeIndicators[index].id);
|
||||
}
|
||||
}
|
||||
|
||||
export function showIndicatorConfigByType(type) {
|
||||
const ind = activeIndicators.find(a => a.type === type);
|
||||
if (ind) {
|
||||
selectIndicatorConfig(ind.id);
|
||||
}
|
||||
}
|
||||
|
||||
window.addIndicator = addIndicator;
|
||||
window.toggleIndicator = toggleIndicator;
|
||||
window.showIndicatorConfig = showIndicatorConfig;
|
||||
window.applyIndicatorConfig = applyIndicatorConfig;
|
||||
window.removeIndicator = removeIndicator;
|
||||
window.removeIndicatorById = removeIndicatorById;
|
||||
window.removeIndicatorByIndex = removeIndicatorByIndex;
|
||||
window.drawIndicatorsOnChart = drawIndicatorsOnChart;
|
||||
117
js/ui/markers-plugin.js
Normal file
117
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();
|
||||
}
|
||||
}
|
||||
73
js/ui/sidebar.js
Normal file
73
js/ui/sidebar.js
Normal file
@ -0,0 +1,73 @@
|
||||
export function toggleSidebar() {
|
||||
const sidebar = document.getElementById('rightSidebar');
|
||||
sidebar.classList.toggle('collapsed');
|
||||
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed'));
|
||||
|
||||
// Resize chart after sidebar toggle
|
||||
setTimeout(() => {
|
||||
if (window.dashboard && window.dashboard.chart) {
|
||||
const container = document.getElementById('chart');
|
||||
window.dashboard.chart.applyOptions({
|
||||
width: container.clientWidth,
|
||||
height: container.clientHeight
|
||||
});
|
||||
}
|
||||
}, 350); // Wait for CSS transition
|
||||
}
|
||||
|
||||
export function restoreSidebarState() {
|
||||
const collapsed = localStorage.getItem('sidebar_collapsed') !== 'false'; // Default to collapsed
|
||||
const sidebar = document.getElementById('rightSidebar');
|
||||
if (collapsed && sidebar) {
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
// Tab Management
|
||||
let activeTab = 'indicators';
|
||||
|
||||
export function initSidebarTabs() {
|
||||
const tabs = document.querySelectorAll('.sidebar-tab');
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
switchTab(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function switchTab(tabId) {
|
||||
activeTab = tabId;
|
||||
localStorage.setItem('sidebar_active_tab', tabId);
|
||||
|
||||
document.querySelectorAll('.sidebar-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabId);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.sidebar-tab-panel').forEach(panel => {
|
||||
panel.classList.toggle('active', panel.id === `tab-${tabId}`);
|
||||
});
|
||||
|
||||
if (tabId === 'indicators') {
|
||||
setTimeout(() => {
|
||||
if (window.drawIndicatorsOnChart) {
|
||||
window.drawIndicatorsOnChart();
|
||||
}
|
||||
}, 50);
|
||||
} else if (tabId === 'strategy') {
|
||||
setTimeout(() => {
|
||||
if (window.renderStrategyPanel) {
|
||||
window.renderStrategyPanel();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveTab() {
|
||||
return activeTab;
|
||||
}
|
||||
|
||||
export function restoreSidebarTabState() {
|
||||
const savedTab = localStorage.getItem('sidebar_active_tab') || 'indicators';
|
||||
switchTab(savedTab);
|
||||
}
|
||||
231
js/ui/signal-markers.js
Normal file
231
js/ui/signal-markers.js
Normal file
@ -0,0 +1,231 @@
|
||||
import { IndicatorRegistry } from '../indicators/index.js';
|
||||
|
||||
export function calculateSignalMarkers(candles) {
|
||||
const activeIndicators = window.getActiveIndicators?.() || [];
|
||||
const markers = [];
|
||||
|
||||
if (!candles || candles.length < 2) {
|
||||
return markers;
|
||||
}
|
||||
|
||||
for (const indicator of activeIndicators) {
|
||||
if (indicator.params.showMarkers === false || indicator.params.showMarkers === 'false') {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('[SignalMarkers] Processing indicator:', indicator.type, 'showMarkers:', indicator.params.showMarkers);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (!results || !Array.isArray(results) || results.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indicatorMarkers = findCrossoverMarkers(indicator, candles, results);
|
||||
markers.push(...indicatorMarkers);
|
||||
}
|
||||
|
||||
markers.sort((a, b) => a.time - b.time);
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
function findCrossoverMarkers(indicator, candles, results) {
|
||||
const markers = [];
|
||||
const overbought = indicator.params?.overbought || 70;
|
||||
const oversold = indicator.params?.oversold || 30;
|
||||
const indicatorType = indicator.type;
|
||||
|
||||
const buyColor = indicator.params?.markerBuyColor || '#26a69a';
|
||||
const sellColor = indicator.params?.markerSellColor || '#ef5350';
|
||||
const buyShape = indicator.params?.markerBuyShape || 'arrowUp';
|
||||
const sellShape = indicator.params?.markerSellShape || 'arrowDown';
|
||||
const buyCustom = indicator.params?.markerBuyCustom || '◭';
|
||||
const sellCustom = indicator.params?.markerSellCustom || '▼';
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const candle = candles[i];
|
||||
const prevCandle = candles[i - 1];
|
||||
const result = results[i];
|
||||
const prevResult = results[i - 1];
|
||||
|
||||
if (!result || !prevResult) continue;
|
||||
|
||||
if (indicatorType === 'rsi' || indicatorType === 'stoch') {
|
||||
const rsi = result.rsi ?? result;
|
||||
const prevRsi = prevResult.rsi ?? prevResult;
|
||||
|
||||
if (rsi === undefined || prevRsi === undefined) continue;
|
||||
|
||||
if (prevRsi > overbought && rsi <= overbought) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'aboveBar',
|
||||
color: sellColor,
|
||||
shape: sellShape === 'custom' ? '' : sellShape,
|
||||
text: sellShape === 'custom' ? sellCustom : ''
|
||||
});
|
||||
}
|
||||
|
||||
if (prevRsi < oversold && rsi >= oversold) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'belowBar',
|
||||
color: buyColor,
|
||||
shape: buyShape === 'custom' ? '' : buyShape,
|
||||
text: buyShape === 'custom' ? buyCustom : ''
|
||||
});
|
||||
}
|
||||
} else if (indicatorType === 'macd') {
|
||||
const macd = result.macd ?? result.MACD;
|
||||
const signal = result.signal ?? result.signalLine;
|
||||
const prevMacd = prevResult.macd ?? prevResult.MACD;
|
||||
const prevSignal = prevResult.signal ?? prevResult.signalLine;
|
||||
|
||||
if (macd === undefined || signal === undefined || prevMacd === undefined || prevSignal === undefined) continue;
|
||||
|
||||
const macdAbovePrev = prevMacd > prevSignal;
|
||||
const macdAboveNow = macd > signal;
|
||||
|
||||
if (macdAbovePrev && !macdAboveNow) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'aboveBar',
|
||||
color: sellColor,
|
||||
shape: sellShape === 'custom' ? '' : sellShape,
|
||||
text: sellShape === 'custom' ? sellCustom : ''
|
||||
});
|
||||
}
|
||||
|
||||
if (!macdAbovePrev && macdAboveNow) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'belowBar',
|
||||
color: buyColor,
|
||||
shape: buyShape === 'custom' ? '' : buyShape,
|
||||
text: buyShape === 'custom' ? buyCustom : ''
|
||||
});
|
||||
}
|
||||
} else if (indicatorType === 'bb') {
|
||||
const upper = result.upper ?? result.upperBand;
|
||||
const lower = result.lower ?? result.lowerBand;
|
||||
|
||||
if (upper === undefined || lower === undefined) continue;
|
||||
|
||||
const priceAboveUpperPrev = prevCandle.close > (prevResult.upper ?? prevResult.upperBand);
|
||||
const priceAboveUpperNow = candle.close > upper;
|
||||
|
||||
if (priceAboveUpperPrev && !priceAboveUpperNow) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'aboveBar',
|
||||
color: sellColor,
|
||||
shape: sellShape === 'custom' ? '' : sellShape,
|
||||
text: sellShape === 'custom' ? sellCustom : ''
|
||||
});
|
||||
}
|
||||
|
||||
if (!priceAboveUpperPrev && priceAboveUpperNow) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'belowBar',
|
||||
color: buyColor,
|
||||
shape: buyShape === 'custom' ? '' : buyShape,
|
||||
text: buyShape === 'custom' ? buyCustom : ''
|
||||
});
|
||||
}
|
||||
|
||||
const priceBelowLowerPrev = prevCandle.close < (prevResult.lower ?? prevResult.lowerBand);
|
||||
const priceBelowLowerNow = candle.close < lower;
|
||||
|
||||
if (priceBelowLowerPrev && !priceBelowLowerNow) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'belowBar',
|
||||
color: buyColor,
|
||||
shape: buyShape === 'custom' ? '' : buyShape,
|
||||
text: buyShape === 'custom' ? buyCustom : ''
|
||||
});
|
||||
}
|
||||
|
||||
if (!priceBelowLowerPrev && priceBelowLowerNow) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'aboveBar',
|
||||
color: sellColor,
|
||||
shape: sellShape === 'custom' ? '' : sellShape,
|
||||
text: sellShape === 'custom' ? sellCustom : ''
|
||||
});
|
||||
}
|
||||
} else if (indicatorType === 'hurst') {
|
||||
const upper = result.upper;
|
||||
const lower = result.lower;
|
||||
const prevUpper = prevResult?.upper;
|
||||
const prevLower = prevResult?.lower;
|
||||
|
||||
if (upper === undefined || lower === undefined ||
|
||||
prevUpper === undefined || prevLower === undefined) continue;
|
||||
|
||||
// BUY: price crosses down below lower band (was above, now below)
|
||||
if (prevCandle.close > prevLower && candle.close < lower) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'belowBar',
|
||||
color: buyColor,
|
||||
shape: buyShape === 'custom' ? '' : buyShape,
|
||||
text: buyShape === 'custom' ? buyCustom : ''
|
||||
});
|
||||
}
|
||||
|
||||
// SELL: price crosses down below upper band (was above, now below)
|
||||
if (prevCandle.close > prevUpper && candle.close < upper) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'aboveBar',
|
||||
color: sellColor,
|
||||
shape: sellShape === 'custom' ? '' : sellShape,
|
||||
text: sellShape === 'custom' ? sellCustom : ''
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const ma = result.ma ?? result;
|
||||
const prevMa = prevResult.ma ?? prevResult;
|
||||
|
||||
if (ma === undefined || prevMa === undefined) continue;
|
||||
|
||||
const priceAbovePrev = prevCandle.close > prevMa;
|
||||
const priceAboveNow = candle.close > ma;
|
||||
|
||||
if (priceAbovePrev && !priceAboveNow) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'aboveBar',
|
||||
color: sellColor,
|
||||
shape: sellShape === 'custom' ? '' : sellShape,
|
||||
text: sellShape === 'custom' ? sellCustom : ''
|
||||
});
|
||||
}
|
||||
|
||||
if (!priceAbovePrev && priceAboveNow) {
|
||||
markers.push({
|
||||
time: candle.time,
|
||||
position: 'belowBar',
|
||||
color: buyColor,
|
||||
shape: buyShape === 'custom' ? '' : buyShape,
|
||||
text: buyShape === 'custom' ? buyCustom : ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
367
js/ui/signals-calculator.js
Normal file
367
js/ui/signals-calculator.js
Normal file
@ -0,0 +1,367 @@
|
||||
// Signal Calculator - orchestrates signal calculation using indicator-specific functions
|
||||
// Signal calculation logic is now in each indicator file
|
||||
|
||||
import { IndicatorRegistry, getSignalFunction } from '../indicators/index.js';
|
||||
|
||||
/**
|
||||
* Calculate signal for an indicator
|
||||
* @param {Object} indicator - Indicator configuration
|
||||
* @param {Array} candles - Candle data array
|
||||
* @param {Object} indicatorValues - Computed indicator values for last candle
|
||||
* @param {Object} prevIndicatorValues - Computed indicator values for previous candle
|
||||
* @returns {Object} Signal object with type, strength, value, reasoning
|
||||
*/
|
||||
function calculateIndicatorSignal(indicator, candles, indicatorValues, prevIndicatorValues) {
|
||||
const signalFunction = getSignalFunction(indicator.type);
|
||||
|
||||
if (!signalFunction) {
|
||||
console.warn('[Signals] No signal function for indicator type:', indicator.type);
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastCandle = candles[candles.length - 1];
|
||||
const prevCandle = candles[candles.length - 2];
|
||||
|
||||
return signalFunction(indicator, lastCandle, prevCandle, indicatorValues, prevIndicatorValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate aggregate summary signal from all indicators
|
||||
*/
|
||||
export function calculateSummarySignal(signals) {
|
||||
console.log('[calculateSummarySignal] Input signals:', signals?.length);
|
||||
|
||||
if (!signals || signals.length === 0) {
|
||||
return {
|
||||
signal: 'hold',
|
||||
strength: 0,
|
||||
reasoning: 'No active indicators',
|
||||
buyCount: 0,
|
||||
sellCount: 0,
|
||||
holdCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
const buySignals = signals.filter(s => s.signal === 'buy');
|
||||
const sellSignals = signals.filter(s => s.signal === 'sell');
|
||||
const holdSignals = signals.filter(s => s.signal === 'hold');
|
||||
|
||||
const buyCount = buySignals.length;
|
||||
const sellCount = sellSignals.length;
|
||||
const holdCount = holdSignals.length;
|
||||
const total = signals.length;
|
||||
|
||||
console.log('[calculateSummarySignal] BUY:', buyCount, 'SELL:', sellCount, 'HOLD:', holdCount);
|
||||
|
||||
const buyWeight = buySignals.reduce((sum, s) => sum + (s.strength || 0), 0);
|
||||
const sellWeight = sellSignals.reduce((sum, s) => sum + (s.strength || 0), 0);
|
||||
|
||||
let summarySignal, strength, reasoning;
|
||||
|
||||
if (buyCount > sellCount && buyCount > holdCount) {
|
||||
summarySignal = 'buy';
|
||||
const avgBuyStrength = buyWeight / buyCount;
|
||||
strength = Math.round(avgBuyStrength * (buyCount / total));
|
||||
reasoning = `${buyCount} buy signals, ${sellCount} sell, ${holdCount} hold`;
|
||||
} else if (sellCount > buyCount && sellCount > holdCount) {
|
||||
summarySignal = 'sell';
|
||||
const avgSellStrength = sellWeight / sellCount;
|
||||
strength = Math.round(avgSellStrength * (sellCount / total));
|
||||
reasoning = `${sellCount} sell signals, ${buyCount} buy, ${holdCount} hold`;
|
||||
} else {
|
||||
summarySignal = 'hold';
|
||||
strength = 30;
|
||||
reasoning = `Mixed signals: ${buyCount} buy, ${sellCount} sell, ${holdCount} hold`;
|
||||
}
|
||||
|
||||
const result = {
|
||||
signal: summarySignal,
|
||||
strength: Math.min(Math.max(strength, 0), 100),
|
||||
reasoning,
|
||||
buyCount,
|
||||
sellCount,
|
||||
holdCount,
|
||||
color: summarySignal === 'buy' ? '#26a69a' : summarySignal === 'sell' ? '#ef5350' : '#787b86'
|
||||
};
|
||||
|
||||
console.log('[calculateSummarySignal] Result:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate historical crossovers for all indicators based on full candle history
|
||||
* Finds the last time each indicator crossed from BUY to SELL or SELL to BUY
|
||||
*/
|
||||
function calculateHistoricalCrossovers(activeIndicators, candles) {
|
||||
activeIndicators.forEach(indicator => {
|
||||
const indicatorType = indicator.type || indicator.indicatorType;
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
let lastCrossoverTimestamp = null;
|
||||
let lastSignalType = null;
|
||||
|
||||
// Get indicator-specific parameters
|
||||
const overbought = indicator.params?.overbought || 70;
|
||||
const oversold = indicator.params?.oversold || 30;
|
||||
|
||||
for (let i = candles.length - 1; i > 0; i--) {
|
||||
const candle = candles[i]; // newer candle
|
||||
const prevCandle = candles[i-1]; // older candle
|
||||
|
||||
const result = results[i];
|
||||
const prevResult = results[i-1];
|
||||
|
||||
if (!result || !prevResult) continue;
|
||||
|
||||
// Handle different indicator types
|
||||
if (indicatorType === 'rsi' || indicatorType === 'stoch') {
|
||||
// RSI/Stochastic: check crossing overbought/oversold levels
|
||||
const rsi = result.rsi !== undefined ? result.rsi : result;
|
||||
const prevRsi = prevResult.rsi !== undefined ? prevResult.rsi : prevResult;
|
||||
|
||||
if (rsi === undefined || prevRsi === undefined) continue;
|
||||
|
||||
// SELL: crossed down through overbought (was above, now below)
|
||||
if (prevRsi > overbought && rsi <= overbought) {
|
||||
lastCrossoverTimestamp = candle.time;
|
||||
lastSignalType = 'sell';
|
||||
break;
|
||||
}
|
||||
// BUY: crossed up through oversold (was below, now above)
|
||||
if (prevRsi < oversold && rsi >= oversold) {
|
||||
lastCrossoverTimestamp = candle.time;
|
||||
lastSignalType = 'buy';
|
||||
break;
|
||||
}
|
||||
} else if (indicatorType === 'hurst') {
|
||||
// Hurst Bands: check price crossing bands
|
||||
const upper = result.upper;
|
||||
const lower = result.lower;
|
||||
const prevUpper = prevResult.upper;
|
||||
const prevLower = prevResult.lower;
|
||||
|
||||
if (upper === undefined || lower === undefined ||
|
||||
prevUpper === undefined || prevLower === undefined) continue;
|
||||
|
||||
// BUY: price crossed down below lower band
|
||||
if (prevCandle.close > prevLower && candle.close < lower) {
|
||||
lastCrossoverTimestamp = candle.time;
|
||||
lastSignalType = 'buy';
|
||||
break;
|
||||
}
|
||||
// SELL: price crossed down below upper band
|
||||
if (prevCandle.close > prevUpper && candle.close < upper) {
|
||||
lastCrossoverTimestamp = candle.time;
|
||||
lastSignalType = 'sell';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// MA-style: check price crossing MA
|
||||
const ma = result.ma !== undefined ? result.ma : result;
|
||||
const prevMa = prevResult.ma !== undefined ? prevResult.ma : prevResult;
|
||||
|
||||
if (ma === undefined || prevMa === undefined) continue;
|
||||
|
||||
// Check crossover: price was on one side of MA, now on the other side
|
||||
const priceAbovePrev = prevCandle.close > prevMa;
|
||||
const priceAboveNow = candle.close > ma;
|
||||
|
||||
// SELL signal: price crossed from above to below MA
|
||||
if (priceAbovePrev && !priceAboveNow) {
|
||||
lastCrossoverTimestamp = candle.time;
|
||||
lastSignalType = 'sell';
|
||||
break;
|
||||
}
|
||||
// BUY signal: price crossed from below to above MA
|
||||
if (!priceAbovePrev && priceAboveNow) {
|
||||
lastCrossoverTimestamp = candle.time;
|
||||
lastSignalType = 'buy';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always update the timestamp based on current data
|
||||
// If crossover found use that time, otherwise use last candle time
|
||||
if (lastCrossoverTimestamp) {
|
||||
console.log(`[HistoricalCross] ${indicatorType}: Found ${lastSignalType} crossover at ${new Date(lastCrossoverTimestamp * 1000).toLocaleString()}`);
|
||||
indicator.lastSignalTimestamp = lastCrossoverTimestamp;
|
||||
indicator.lastSignalType = lastSignalType;
|
||||
} else {
|
||||
// No crossover found - use last candle time
|
||||
const lastCandleTime = candles[candles.length - 1]?.time;
|
||||
if (lastCandleTime) {
|
||||
const lastResult = results[results.length - 1];
|
||||
|
||||
if (indicatorType === 'rsi' || indicatorType === 'stoch') {
|
||||
// RSI/Stochastic: use RSI level to determine signal
|
||||
const rsi = lastResult?.rsi !== undefined ? lastResult.rsi : lastResult;
|
||||
if (rsi !== undefined) {
|
||||
indicator.lastSignalType = rsi > overbought ? 'sell' : (rsi < oversold ? 'buy' : null);
|
||||
indicator.lastSignalTimestamp = lastCandleTime;
|
||||
}
|
||||
} else if (indicatorType === 'hurst') {
|
||||
// Hurst Bands: use price vs bands
|
||||
const upper = lastResult?.upper;
|
||||
const lower = lastResult?.lower;
|
||||
const currentPrice = candles[candles.length - 1]?.close;
|
||||
if (upper !== undefined && lower !== undefined && currentPrice !== undefined) {
|
||||
if (currentPrice < lower) {
|
||||
indicator.lastSignalType = 'buy';
|
||||
} else if (currentPrice > upper) {
|
||||
indicator.lastSignalType = 'sell';
|
||||
} else {
|
||||
indicator.lastSignalType = null;
|
||||
}
|
||||
indicator.lastSignalTimestamp = lastCandleTime;
|
||||
}
|
||||
} else {
|
||||
// MA-style: use price vs MA
|
||||
const ma = lastResult?.ma !== undefined ? lastResult.ma : lastResult;
|
||||
if (ma !== undefined) {
|
||||
const isAbove = candles[candles.length - 1].close > ma;
|
||||
indicator.lastSignalType = isAbove ? 'buy' : 'sell';
|
||||
indicator.lastSignalTimestamp = lastCandleTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate signals for all active indicators
|
||||
* @returns {Array} Array of indicator signals
|
||||
*/
|
||||
export function calculateAllIndicatorSignals() {
|
||||
const activeIndicators = window.getActiveIndicators?.() || [];
|
||||
const candles = window.dashboard?.allData?.get(window.dashboard?.currentInterval);
|
||||
|
||||
//console.log('[Signals] ========== calculateAllIndicatorSignals START ==========');
|
||||
console.log('[Signals] Active indicators:', activeIndicators.length, 'Candles:', candles?.length || 0);
|
||||
|
||||
if (!candles || candles.length < 2) {
|
||||
//console.log('[Signals] Insufficient candles available:', candles?.length || 0);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!activeIndicators || activeIndicators.length === 0) {
|
||||
//console.log('[Signals] No active indicators');
|
||||
return [];
|
||||
}
|
||||
|
||||
const signals = [];
|
||||
|
||||
// Calculate crossovers for all indicators based on historical data
|
||||
calculateHistoricalCrossovers(activeIndicators, candles);
|
||||
|
||||
for (const indicator of activeIndicators) {
|
||||
const IndicatorClass = IndicatorRegistry[indicator.type];
|
||||
if (!IndicatorClass) {
|
||||
console.log('[Signals] No class for indicator type:', indicator.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use cached results if available, otherwise calculate
|
||||
let results = indicator.cachedResults;
|
||||
let meta = indicator.cachedMeta;
|
||||
|
||||
if (!results || !meta || !Array.isArray(results) || results.length !== candles.length) {
|
||||
const instance = new IndicatorClass(indicator);
|
||||
meta = instance.getMetadata();
|
||||
results = instance.calculate(candles);
|
||||
indicator.cachedResults = results;
|
||||
indicator.cachedMeta = meta;
|
||||
}
|
||||
|
||||
if (!results || !Array.isArray(results) || results.length === 0) {
|
||||
console.log('[Signals] No valid results for indicator:', indicator.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastResult = results[results.length - 1];
|
||||
const prevResult = results[results.length - 2];
|
||||
if (lastResult === null || lastResult === undefined) {
|
||||
console.log('[Signals] No valid last result for indicator:', indicator.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
let values;
|
||||
let prevValues;
|
||||
if (typeof lastResult === 'object' && lastResult !== null && !Array.isArray(lastResult)) {
|
||||
values = lastResult;
|
||||
prevValues = prevResult;
|
||||
} else if (typeof lastResult === 'number') {
|
||||
values = { ma: lastResult };
|
||||
prevValues = prevResult ? { ma: prevResult } : undefined;
|
||||
} else {
|
||||
console.log('[Signals] Unexpected result type for', indicator.type, ':', typeof lastResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
const signal = calculateIndicatorSignal(indicator, candles, values, prevValues);
|
||||
|
||||
let currentSignal = signal;
|
||||
let lastSignalDate = indicator.lastSignalTimestamp || null;
|
||||
let lastSignalType = indicator.lastSignalType || null;
|
||||
|
||||
if (!currentSignal || !currentSignal.type) {
|
||||
console.log('[Signals] No valid signal for', indicator.type, '- Using last signal if available');
|
||||
|
||||
if (lastSignalType && lastSignalDate) {
|
||||
currentSignal = {
|
||||
type: lastSignalType,
|
||||
strength: 50,
|
||||
value: candles[candles.length - 1]?.close,
|
||||
reasoning: `No crossover (price equals MA)`
|
||||
};
|
||||
} else {
|
||||
console.log('[Signals] No previous signal available - Skipping');
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const currentCandleTimestamp = candles[candles.length - 1].time;
|
||||
|
||||
if (currentSignal.type !== lastSignalType || !lastSignalType) {
|
||||
console.log('[Signals] Signal changed for', indicator.type, ':', lastSignalType, '->', currentSignal.type);
|
||||
lastSignalDate = indicator.lastSignalTimestamp || currentCandleTimestamp;
|
||||
lastSignalType = currentSignal.type;
|
||||
indicator.lastSignalTimestamp = lastSignalDate;
|
||||
indicator.lastSignalType = lastSignalType;
|
||||
}
|
||||
}
|
||||
|
||||
signals.push({
|
||||
id: indicator.id,
|
||||
name: meta?.name || indicator.type,
|
||||
label: indicator.type?.toUpperCase(),
|
||||
params: meta?.inputs && meta.inputs.length > 0
|
||||
? indicator.params[meta.inputs[0].name]
|
||||
: null,
|
||||
type: indicator.type,
|
||||
signal: currentSignal.type,
|
||||
strength: Math.round(currentSignal.strength),
|
||||
value: currentSignal.value,
|
||||
reasoning: currentSignal.reasoning,
|
||||
color: currentSignal.type === 'buy' ? '#26a69a' : currentSignal.type === 'sell' ? '#ef5350' : '#787b86',
|
||||
lastSignalDate: lastSignalDate
|
||||
});
|
||||
}
|
||||
|
||||
//console.log('[Signals] ========== calculateAllIndicatorSignals END ==========');
|
||||
console.log('[Signals] Total signals calculated:', signals.length);
|
||||
return signals;
|
||||
}
|
||||
370
js/ui/strategy-panel.js
Normal file
370
js/ui/strategy-panel.js
Normal file
@ -0,0 +1,370 @@
|
||||
import { getStrategy, registerStrategy } from '../strategies/index.js';
|
||||
import { PingPongStrategy } from '../strategies/ping-pong.js';
|
||||
|
||||
// Register available strategies
|
||||
registerStrategy('ping_pong', PingPongStrategy);
|
||||
|
||||
let activeIndicators = [];
|
||||
|
||||
function formatDisplayDate(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function initStrategyPanel() {
|
||||
window.renderStrategyPanel = renderStrategyPanel;
|
||||
renderStrategyPanel();
|
||||
|
||||
// Listen for indicator changes to update the signal selection list
|
||||
const originalAddIndicator = window.addIndicator;
|
||||
window.addIndicator = function(...args) {
|
||||
const res = originalAddIndicator.apply(this, args);
|
||||
setTimeout(renderStrategyPanel, 100);
|
||||
return res;
|
||||
};
|
||||
|
||||
const originalRemoveIndicator = window.removeIndicatorById;
|
||||
window.removeIndicatorById = function(...args) {
|
||||
const res = originalRemoveIndicator.apply(this, args);
|
||||
setTimeout(renderStrategyPanel, 100);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
export function renderStrategyPanel() {
|
||||
const container = document.getElementById('strategyPanel');
|
||||
if (!container) return;
|
||||
|
||||
activeIndicators = window.getActiveIndicators?.() || [];
|
||||
|
||||
// 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> ${strategy.name} Strategy
|
||||
</div>
|
||||
<div class="sidebar-section-content">
|
||||
${strategy.renderUI(activeIndicators, formatDisplayDate)}
|
||||
<button class="sim-run-btn" id="runSimulationBtn">Run Simulation</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="simulationResults" class="sim-results" style="display: none;">
|
||||
<!-- Results will be injected here -->
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach strategy specific listeners (like disabling dropdowns when auto-detect is on)
|
||||
if (strategy.attachListeners) {
|
||||
strategy.attachListeners();
|
||||
}
|
||||
|
||||
document.getElementById('runSimulationBtn').addEventListener('click', () => {
|
||||
strategy.runSimulation(activeIndicators, displayResults);
|
||||
});
|
||||
}
|
||||
|
||||
// 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';
|
||||
|
||||
if (window.dashboard) {
|
||||
window.dashboard.setAvgPriceData(avgPriceData);
|
||||
}
|
||||
|
||||
const entryTrades = trades.filter(t => t.recordType === 'entry').length;
|
||||
const exitTrades = trades.filter(t => t.recordType === 'exit').length;
|
||||
const profitableTrades = trades.filter(t => t.recordType === 'exit' && t.pnl > 0).length;
|
||||
const winRate = exitTrades > 0 ? (profitableTrades / exitTrades * 100).toFixed(1) : 0;
|
||||
|
||||
const startPrice = equityData.usd[0].value / equityData.btc[0].value;
|
||||
const startBtc = config.capital / startPrice;
|
||||
|
||||
const finalUsd = equityData.usd[equityData.usd.length - 1].value;
|
||||
const finalBtc = finalUsd / endPrice;
|
||||
|
||||
const totalPnlUsd = finalUsd - config.capital;
|
||||
const roi = (totalPnlUsd / config.capital * 100).toFixed(2);
|
||||
|
||||
const roiBtc = ((finalBtc - startBtc) / startBtc * 100).toFixed(2);
|
||||
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">Results</div>
|
||||
<div class="sidebar-section-content">
|
||||
<div class="results-summary">
|
||||
<div class="result-stat">
|
||||
<div class="result-stat-value ${totalPnlUsd >= 0 ? 'positive' : 'negative'}">${roi}%</div>
|
||||
<div class="result-stat-label">ROI (USD)</div>
|
||||
</div>
|
||||
<div class="result-stat">
|
||||
<div class="result-stat-value ${parseFloat(roiBtc) >= 0 ? 'positive' : 'negative'}">${roiBtc}%</div>
|
||||
<div class="result-stat-label">ROI (BTC)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sim-stat-row">
|
||||
<span>Starting Balance</span>
|
||||
<span class="sim-value">$${config.capital.toFixed(0)} / ${startBtc.toFixed(4)} BTC</span>
|
||||
</div>
|
||||
<div class="sim-stat-row">
|
||||
<span>Final Balance</span>
|
||||
<span class="sim-value">$${finalUsd.toFixed(2)} / ${finalBtc.toFixed(4)} BTC</span>
|
||||
</div>
|
||||
<div class="sim-stat-row">
|
||||
<span>Trades (Entry / Exit)</span>
|
||||
<span class="sim-value">${entryTrades} / ${exitTrades}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 12px;">
|
||||
<span style="font-size: 11px; color: var(--tv-text-secondary);">Equity Chart</span>
|
||||
<div class="chart-toggle-group">
|
||||
<button class="toggle-btn active" data-unit="usd">USD</button>
|
||||
<button class="toggle-btn" data-unit="btc">BTC</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="equity-chart-container" id="equityChart"></div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 12px;">
|
||||
<span style="font-size: 11px; color: var(--tv-text-secondary);" id="posSizeLabel">Position Size (BTC)</span>
|
||||
<div class="chart-toggle-group">
|
||||
<button class="toggle-btn active" data-unit="usd">USD</button>
|
||||
<button class="toggle-btn" data-unit="btc">BTC</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="equity-chart-container" id="posSizeChart"></div>
|
||||
|
||||
<div class="results-actions">
|
||||
<button class="action-btn secondary" id="toggleTradeMarkers">Show Markers</button>
|
||||
<button class="action-btn secondary" id="clearSim">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create Charts
|
||||
const initCharts = () => {
|
||||
const equityContainer = document.getElementById('equityChart');
|
||||
if (equityContainer) {
|
||||
equityContainer.innerHTML = '';
|
||||
equityChart = LightweightCharts.createChart(equityContainer, {
|
||||
layout: { background: { color: '#131722' }, textColor: '#d1d4dc' },
|
||||
grid: { vertLines: { visible: false }, horzLines: { color: '#2a2e39' } },
|
||||
rightPriceScale: { borderColor: '#2a2e39', autoScale: true },
|
||||
timeScale: {
|
||||
borderColor: '#2a2e39',
|
||||
visible: true,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
tickMarkFormatter: (time, tickMarkType, locale) => {
|
||||
return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
localization: {
|
||||
timeFormatter: (timestamp) => {
|
||||
return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString();
|
||||
},
|
||||
},
|
||||
handleScroll: true,
|
||||
handleScale: true
|
||||
});
|
||||
|
||||
equitySeries = equityChart.addSeries(LightweightCharts.AreaSeries, {
|
||||
lineColor: totalPnlUsd >= 0 ? '#26a69a' : '#ef5350',
|
||||
topColor: totalPnlUsd >= 0 ? 'rgba(38, 166, 154, 0.4)' : 'rgba(239, 83, 80, 0.4)',
|
||||
bottomColor: 'rgba(0, 0, 0, 0)',
|
||||
lineWidth: 2,
|
||||
});
|
||||
|
||||
equitySeries.setData(equityData['usd']);
|
||||
equityChart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
const posSizeContainer = document.getElementById('posSizeChart');
|
||||
if (posSizeContainer) {
|
||||
posSizeContainer.innerHTML = '';
|
||||
posSizeChart = LightweightCharts.createChart(posSizeContainer, {
|
||||
layout: { background: { color: '#131722' }, textColor: '#d1d4dc' },
|
||||
grid: { vertLines: { visible: false }, horzLines: { color: '#2a2e39' } },
|
||||
rightPriceScale: { borderColor: '#2a2e39', autoScale: true },
|
||||
timeScale: {
|
||||
borderColor: '#2a2e39',
|
||||
visible: true,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
tickMarkFormatter: (time, tickMarkType, locale) => {
|
||||
return window.TimezoneConfig ? window.TimezoneConfig.formatTickMark(time) : new Date(time * 1000).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
localization: {
|
||||
timeFormatter: (timestamp) => {
|
||||
return window.TimezoneConfig ? window.TimezoneConfig.formatDate(timestamp * 1000) : new Date(timestamp * 1000).toLocaleString();
|
||||
},
|
||||
},
|
||||
handleScroll: true,
|
||||
handleScale: true
|
||||
});
|
||||
|
||||
posSeries = posSizeChart.addSeries(LightweightCharts.AreaSeries, {
|
||||
lineColor: '#00bcd4',
|
||||
topColor: 'rgba(0, 188, 212, 0.4)',
|
||||
bottomColor: 'rgba(0, 0, 0, 0)',
|
||||
lineWidth: 2,
|
||||
});
|
||||
|
||||
posSeries.setData(posSizeData['usd']);
|
||||
posSizeChart.timeScale().fitContent();
|
||||
|
||||
const label = document.getElementById('posSizeLabel');
|
||||
if (label) label.textContent = 'Position Size (USD)';
|
||||
}
|
||||
|
||||
if (equityChart && posSizeChart) {
|
||||
let isSyncing = false;
|
||||
|
||||
const syncCharts = (source, target) => {
|
||||
if (isSyncing) return;
|
||||
isSyncing = true;
|
||||
const range = source.timeScale().getVisibleRange();
|
||||
target.timeScale().setVisibleRange(range);
|
||||
isSyncing = false;
|
||||
};
|
||||
|
||||
equityChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(equityChart, posSizeChart));
|
||||
posSizeChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(posSizeChart, equityChart));
|
||||
}
|
||||
|
||||
const syncToMain = (param) => {
|
||||
if (!param.time || !window.dashboard || !window.dashboard.chart) return;
|
||||
|
||||
const timeScale = window.dashboard.chart.timeScale();
|
||||
const currentRange = timeScale.getVisibleRange();
|
||||
if (!currentRange) return;
|
||||
|
||||
const width = currentRange.to - currentRange.from;
|
||||
const halfWidth = width / 2;
|
||||
|
||||
timeScale.setVisibleRange({
|
||||
from: param.time - halfWidth,
|
||||
to: param.time + halfWidth
|
||||
});
|
||||
};
|
||||
|
||||
if (equityChart) equityChart.subscribeClick(syncToMain);
|
||||
if (posSizeChart) posSizeChart.subscribeClick(syncToMain);
|
||||
};
|
||||
|
||||
setTimeout(initCharts, 100);
|
||||
|
||||
resultsDiv.querySelectorAll('.toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const unit = btn.dataset.unit;
|
||||
|
||||
resultsDiv.querySelectorAll(`.toggle-btn`).forEach(b => {
|
||||
if (b.dataset.unit === unit) b.classList.add('active');
|
||||
else b.classList.remove('active');
|
||||
});
|
||||
|
||||
if (equitySeries) {
|
||||
equitySeries.setData(equityData[unit]);
|
||||
equityChart.timeScale().fitContent();
|
||||
}
|
||||
if (posSeries) {
|
||||
posSeries.setData(posSizeData[unit]);
|
||||
posSizeChart.timeScale().fitContent();
|
||||
|
||||
const label = document.getElementById('posSizeLabel');
|
||||
if (label) label.textContent = `Position Size (${unit.toUpperCase()})`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('toggleTradeMarkers').addEventListener('click', () => {
|
||||
toggleSimulationMarkers(trades);
|
||||
});
|
||||
|
||||
document.getElementById('clearSim').addEventListener('click', () => {
|
||||
resultsDiv.style.display = 'none';
|
||||
clearSimulationMarkers();
|
||||
if (window.dashboard) {
|
||||
window.dashboard.clearAvgPriceData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSimulationMarkers(trades) {
|
||||
if (tradeMarkers.length > 0) {
|
||||
clearSimulationMarkers();
|
||||
document.getElementById('toggleTradeMarkers').textContent = 'Show Markers';
|
||||
return;
|
||||
}
|
||||
|
||||
const markers = [];
|
||||
trades.forEach(t => {
|
||||
const usdVal = t.currentUsd !== undefined ? `$${t.currentUsd.toFixed(0)}` : '0';
|
||||
const qtyVal = t.currentQty !== undefined ? `${t.currentQty.toFixed(4)} BTC` : '0';
|
||||
const sizeStr = ` (${usdVal} / ${qtyVal})`;
|
||||
|
||||
if (t.recordType === 'entry') {
|
||||
markers.push({
|
||||
time: t.time,
|
||||
position: t.type === 'long' ? 'belowBar' : 'aboveBar',
|
||||
color: t.type === 'long' ? '#2962ff' : '#9c27b0',
|
||||
shape: t.type === 'long' ? 'arrowUp' : 'arrowDown',
|
||||
text: `Entry ${t.type.toUpperCase()}${sizeStr}`
|
||||
});
|
||||
}
|
||||
|
||||
if (t.recordType === 'exit') {
|
||||
markers.push({
|
||||
time: t.time,
|
||||
position: t.type === 'long' ? 'aboveBar' : 'belowBar',
|
||||
color: t.pnl >= 0 ? '#26a69a' : '#ef5350',
|
||||
shape: t.type === 'long' ? 'arrowDown' : 'arrowUp',
|
||||
text: `Exit ${t.reason}${sizeStr}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
markers.sort((a, b) => a.time - b.time);
|
||||
|
||||
if (window.dashboard) {
|
||||
window.dashboard.setSimulationMarkers(markers);
|
||||
tradeMarkers = markers;
|
||||
document.getElementById('toggleTradeMarkers').textContent = 'Hide Markers';
|
||||
}
|
||||
}
|
||||
|
||||
function clearSimulationMarkers() {
|
||||
if (window.dashboard) {
|
||||
window.dashboard.clearSimulationMarkers();
|
||||
tradeMarkers = [];
|
||||
}
|
||||
}
|
||||
|
||||
window.clearSimulationResults = function() {
|
||||
const resultsDiv = document.getElementById('simulationResults');
|
||||
if (resultsDiv) resultsDiv.style.display = 'none';
|
||||
clearSimulationMarkers();
|
||||
};
|
||||
Reference in New Issue
Block a user