|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { apiClient } from '/static/shared/js/core/api-client.js';
|
|
|
import { logger } from '../../shared/js/utils/logger.js';
|
|
|
import { escapeHtml, safeFormatNumber, safeFormatCurrency } from '../../shared/js/utils/sanitizer.js';
|
|
|
|
|
|
class TechnicalAnalysisPage {
|
|
|
constructor() {
|
|
|
this.symbol = 'BTC';
|
|
|
this.timeframe = '4h';
|
|
|
this.currentMode = 'TA_QUICK';
|
|
|
this.chart = null;
|
|
|
this.candlestickSeries = null;
|
|
|
this.volumeSeries = null;
|
|
|
this.rsiSeries = null;
|
|
|
this.macdSeries = null;
|
|
|
this.trendLineSeries = null;
|
|
|
this.supportLineSeries = null;
|
|
|
this.resistanceLineSeries = null;
|
|
|
this.fibonacciLevels = [];
|
|
|
this.indicators = {
|
|
|
rsi: true,
|
|
|
macd: true,
|
|
|
volume: false,
|
|
|
ichimoku: false,
|
|
|
elliott: false
|
|
|
};
|
|
|
this.patterns = {
|
|
|
gartley: true,
|
|
|
butterfly: true,
|
|
|
bat: true,
|
|
|
crab: true,
|
|
|
candlestick: true
|
|
|
};
|
|
|
this.ohlcvData = [];
|
|
|
this.analysisData = null;
|
|
|
this.fundamentalData = null;
|
|
|
this.onchainData = null;
|
|
|
this.riskData = null;
|
|
|
this.retryConfig = {
|
|
|
maxRetries: 3,
|
|
|
baseDelay: 1000,
|
|
|
maxDelay: 5000
|
|
|
};
|
|
|
}
|
|
|
|
|
|
async init() {
|
|
|
try {
|
|
|
console.log('[TechnicalAnalysis] Initializing...');
|
|
|
this.bindEvents();
|
|
|
await this.loadChart();
|
|
|
await this.analyze();
|
|
|
console.log('[TechnicalAnalysis] Ready');
|
|
|
} catch (error) {
|
|
|
logger.error('TechnicalAnalysis', 'Init error:', error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
bindEvents() {
|
|
|
|
|
|
document.querySelectorAll('.mode-tab').forEach(tab => {
|
|
|
tab.addEventListener('click', (e) => {
|
|
|
const mode = e.currentTarget.dataset.mode;
|
|
|
this.switchMode(mode);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
|
|
|
document.getElementById('symbol-input')?.addEventListener('change', (e) => {
|
|
|
this.symbol = e.target.value.toUpperCase();
|
|
|
this.runCurrentModeAnalysis();
|
|
|
});
|
|
|
|
|
|
|
|
|
document.getElementById('timeframe-select')?.addEventListener('change', (e) => {
|
|
|
this.timeframe = e.target.value;
|
|
|
this.runCurrentModeAnalysis();
|
|
|
});
|
|
|
|
|
|
|
|
|
Object.keys(this.indicators).forEach(key => {
|
|
|
const checkbox = document.getElementById(`indicator-${key}`);
|
|
|
if (checkbox) {
|
|
|
checkbox.addEventListener('change', (e) => {
|
|
|
this.indicators[key] = e.target.checked;
|
|
|
this.updateChart();
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
|
|
|
|
|
|
Object.keys(this.patterns).forEach(key => {
|
|
|
const checkbox = document.getElementById(`pattern-${key}`);
|
|
|
if (checkbox) {
|
|
|
checkbox.addEventListener('change', (e) => {
|
|
|
this.patterns[key] = e.target.checked;
|
|
|
this.analyze();
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
|
|
|
|
|
|
document.getElementById('analyze-btn')?.addEventListener('click', () => {
|
|
|
this.analyze();
|
|
|
});
|
|
|
|
|
|
|
|
|
document.getElementById('zoom-in')?.addEventListener('click', () => {
|
|
|
this.chart?.timeScale().zoomIn();
|
|
|
});
|
|
|
document.getElementById('zoom-out')?.addEventListener('click', () => {
|
|
|
this.chart?.timeScale().zoomOut();
|
|
|
});
|
|
|
document.getElementById('reset-chart')?.addEventListener('click', () => {
|
|
|
this.chart?.timeScale().fitContent();
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async loadChart() {
|
|
|
const container = document.getElementById('tradingview-chart');
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
if (!window.LightweightCharts) {
|
|
|
throw new Error('LightweightCharts library not loaded');
|
|
|
}
|
|
|
this.chart = window.LightweightCharts.createChart(container, {
|
|
|
width: container.clientWidth,
|
|
|
height: 600,
|
|
|
layout: {
|
|
|
background: { color: '#0f172a' },
|
|
|
textColor: '#94a3b8',
|
|
|
},
|
|
|
grid: {
|
|
|
vertLines: { color: '#1e293b' },
|
|
|
horzLines: { color: '#1e293b' },
|
|
|
},
|
|
|
timeScale: {
|
|
|
timeVisible: true,
|
|
|
secondsVisible: false,
|
|
|
},
|
|
|
});
|
|
|
|
|
|
|
|
|
const seriesOptions = {
|
|
|
upColor: '#22c55e',
|
|
|
downColor: '#ef4444',
|
|
|
borderVisible: false,
|
|
|
wickUpColor: '#22c55e',
|
|
|
wickDownColor: '#ef4444',
|
|
|
};
|
|
|
|
|
|
|
|
|
if (typeof this.chart.addCandlestickSeries === 'function') {
|
|
|
this.candlestickSeries = this.chart.addCandlestickSeries(seriesOptions);
|
|
|
} else if (typeof this.chart.addSeries === 'function' && window.LightweightCharts && window.LightweightCharts.SeriesType && window.LightweightCharts.SeriesType.Candlestick) {
|
|
|
this.candlestickSeries = this.chart.addSeries(window.LightweightCharts.SeriesType.Candlestick, seriesOptions);
|
|
|
} else if (typeof this.chart.addSeries === 'function') {
|
|
|
try {
|
|
|
this.candlestickSeries = this.chart.addSeries('Candlestick', seriesOptions);
|
|
|
} catch (e) {
|
|
|
console.error('Failed to create candlestick series:', e);
|
|
|
throw new Error('Could not create candlestick series');
|
|
|
}
|
|
|
} else {
|
|
|
throw new Error('No compatible method to create candlestick series found');
|
|
|
}
|
|
|
|
|
|
if (!this.candlestickSeries) {
|
|
|
throw new Error('Failed to create candlestick series');
|
|
|
}
|
|
|
|
|
|
|
|
|
if (this.indicators.volume) {
|
|
|
this.volumeSeries = this.chart.addHistogramSeries({
|
|
|
color: '#3b82f6',
|
|
|
priceFormat: {
|
|
|
type: 'volume',
|
|
|
},
|
|
|
priceScaleId: '',
|
|
|
scaleMargins: {
|
|
|
top: 0.8,
|
|
|
bottom: 0,
|
|
|
},
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async analyze() {
|
|
|
try {
|
|
|
|
|
|
let response;
|
|
|
let retries = 0;
|
|
|
const maxRetries = 2;
|
|
|
|
|
|
while (retries <= maxRetries) {
|
|
|
try {
|
|
|
|
|
|
const url = `/api/ohlcv?symbol=${encodeURIComponent(this.symbol)}&timeframe=${encodeURIComponent(this.timeframe)}&limit=500`;
|
|
|
response = await fetch(url, {
|
|
|
signal: AbortSignal.timeout(15000)
|
|
|
});
|
|
|
|
|
|
if (response.ok) {
|
|
|
break;
|
|
|
}
|
|
|
|
|
|
if (retries < maxRetries && response.status >= 500) {
|
|
|
const delay = Math.min(1000 * Math.pow(2, retries), 5000);
|
|
|
await this.delay(delay);
|
|
|
retries++;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
throw new Error(`Failed to fetch OHLCV data: HTTP ${response.status}`);
|
|
|
} catch (error) {
|
|
|
if (retries < maxRetries && (error.message.includes('timeout') || error.message.includes('network'))) {
|
|
|
const delay = Math.min(1000 * Math.pow(2, retries), 5000);
|
|
|
await this.delay(delay);
|
|
|
retries++;
|
|
|
continue;
|
|
|
}
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!response || !response.ok) {
|
|
|
throw new Error('Failed to fetch OHLCV data after retries');
|
|
|
}
|
|
|
|
|
|
const data = await response.json();
|
|
|
if (!data || typeof data !== 'object') {
|
|
|
throw new Error('Invalid response format');
|
|
|
}
|
|
|
|
|
|
|
|
|
if (data.success === false || data.error === true) {
|
|
|
throw new Error(data.message || 'Failed to fetch OHLCV data');
|
|
|
}
|
|
|
|
|
|
|
|
|
const ohlcvData = data.data || data.ohlcv || [];
|
|
|
if (!Array.isArray(ohlcvData) || ohlcvData.length === 0) {
|
|
|
throw new Error('No OHLCV data available');
|
|
|
}
|
|
|
|
|
|
|
|
|
const firstCandle = ohlcvData[0];
|
|
|
if (!firstCandle || (typeof firstCandle.open === 'undefined' && typeof firstCandle.o === 'undefined')) {
|
|
|
throw new Error('Invalid OHLCV data structure - missing required fields');
|
|
|
}
|
|
|
|
|
|
this.ohlcvData = ohlcvData;
|
|
|
|
|
|
|
|
|
let analysisResponse;
|
|
|
try {
|
|
|
analysisResponse = await apiClient.fetch(
|
|
|
'/api/technical/analyze',
|
|
|
{
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({
|
|
|
symbol: this.symbol,
|
|
|
timeframe: this.timeframe,
|
|
|
ohlcv: this.ohlcvData,
|
|
|
indicators: this.indicators,
|
|
|
patterns: this.patterns
|
|
|
})
|
|
|
},
|
|
|
20000
|
|
|
);
|
|
|
|
|
|
if (analysisResponse.ok) {
|
|
|
const analysisJson = await analysisResponse.json();
|
|
|
if (analysisJson && typeof analysisJson === 'object') {
|
|
|
this.analysisData = analysisJson;
|
|
|
} else {
|
|
|
throw new Error('Invalid analysis response format');
|
|
|
}
|
|
|
} else {
|
|
|
|
|
|
logger.warn('TechnicalAnalysis', `Analysis API returned ${analysisResponse.status}, using local calculation`);
|
|
|
this.analysisData = this.calculateTechnicalAnalysis();
|
|
|
}
|
|
|
} catch (error) {
|
|
|
logger.warn('TechnicalAnalysis', 'Analysis API error, using local calculation:', error);
|
|
|
|
|
|
this.analysisData = this.calculateTechnicalAnalysis();
|
|
|
}
|
|
|
|
|
|
this.updateChart();
|
|
|
this.renderAnalysis();
|
|
|
} catch (error) {
|
|
|
logger.error('TechnicalAnalysis', 'Analysis error:', error);
|
|
|
this.showError('Failed to load analysis. Using fallback calculations.');
|
|
|
this.analysisData = this.calculateTechnicalAnalysis();
|
|
|
this.updateChart();
|
|
|
this.renderAnalysis();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
calculateTechnicalAnalysis() {
|
|
|
|
|
|
return {
|
|
|
support_resistance: this.calculateSupportResistance(),
|
|
|
harmonic_patterns: this.detectHarmonicPatterns(),
|
|
|
elliott_wave: this.analyzeElliottWave(),
|
|
|
candlestick_patterns: this.detectCandlestickPatterns(),
|
|
|
indicators: this.calculateIndicators(),
|
|
|
signals: this.generateSignals()
|
|
|
};
|
|
|
}
|
|
|
|
|
|
calculateSupportResistance() {
|
|
|
const closes = this.ohlcvData.map(c => parseFloat(c.c || c.close));
|
|
|
const highs = this.ohlcvData.map(c => parseFloat(c.h || c.high));
|
|
|
const lows = this.ohlcvData.map(c => parseFloat(c.l || c.low));
|
|
|
|
|
|
|
|
|
const pivots = this.findPivotPoints(highs, lows, closes);
|
|
|
|
|
|
return {
|
|
|
support: pivots.support,
|
|
|
resistance: pivots.resistance,
|
|
|
levels: pivots.levels
|
|
|
};
|
|
|
}
|
|
|
|
|
|
findPivotPoints(highs, lows, closes, period = 5) {
|
|
|
const pivotHighs = [];
|
|
|
const pivotLows = [];
|
|
|
const levels = [];
|
|
|
|
|
|
for (let i = period; i < highs.length - period; i++) {
|
|
|
|
|
|
let isPivotHigh = true;
|
|
|
for (let j = i - period; j <= i + period; j++) {
|
|
|
if (j !== i && highs[j] >= highs[i]) {
|
|
|
isPivotHigh = false;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
if (isPivotHigh) {
|
|
|
pivotHighs.push({ index: i, value: highs[i] });
|
|
|
levels.push({ type: 'resistance', value: highs[i], strength: this.calculateLevelStrength(highs[i], highs) });
|
|
|
}
|
|
|
|
|
|
|
|
|
let isPivotLow = true;
|
|
|
for (let j = i - period; j <= i + period; j++) {
|
|
|
if (j !== i && lows[j] <= lows[i]) {
|
|
|
isPivotLow = false;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
if (isPivotLow) {
|
|
|
pivotLows.push({ index: i, value: lows[i] });
|
|
|
levels.push({ type: 'support', value: lows[i], strength: this.calculateLevelStrength(lows[i], lows) });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
const support = pivotLows.length > 0
|
|
|
? pivotLows.sort((a, b) => a.value - b.value)[0].value
|
|
|
: Math.min(...lows.slice(-50));
|
|
|
|
|
|
const resistance = pivotHighs.length > 0
|
|
|
? pivotHighs.sort((a, b) => b.value - a.value)[0].value
|
|
|
: Math.max(...highs.slice(-50));
|
|
|
|
|
|
return { support, resistance, levels: levels.slice(-10) };
|
|
|
}
|
|
|
|
|
|
calculateLevelStrength(level, prices) {
|
|
|
const touches = prices.filter(p => Math.abs(p - level) / level < 0.01).length;
|
|
|
return Math.min(touches / 3, 1);
|
|
|
}
|
|
|
|
|
|
detectHarmonicPatterns() {
|
|
|
const patterns = [];
|
|
|
const closes = this.ohlcvData.map(c => parseFloat(c.c || c.close));
|
|
|
|
|
|
|
|
|
const gartley = this.detectGartley(closes);
|
|
|
if (gartley) patterns.push(gartley);
|
|
|
|
|
|
|
|
|
const butterfly = this.detectButterfly(closes);
|
|
|
if (butterfly) patterns.push(butterfly);
|
|
|
|
|
|
|
|
|
const bat = this.detectBat(closes);
|
|
|
if (bat) patterns.push(bat);
|
|
|
|
|
|
|
|
|
const crab = this.detectCrab(closes);
|
|
|
if (crab) patterns.push(crab);
|
|
|
|
|
|
return patterns;
|
|
|
}
|
|
|
|
|
|
detectGartley(prices) {
|
|
|
|
|
|
if (prices.length < 5) return null;
|
|
|
|
|
|
const X = prices[prices.length - 5];
|
|
|
const A = prices[prices.length - 4];
|
|
|
const B = prices[prices.length - 3];
|
|
|
const C = prices[prices.length - 2];
|
|
|
const D = prices[prices.length - 1];
|
|
|
|
|
|
const AB = Math.abs((B - A) / (A - X));
|
|
|
const BC = Math.abs((C - B) / (B - A));
|
|
|
const CD = Math.abs((D - C) / (C - B));
|
|
|
|
|
|
|
|
|
if (Math.abs(AB - 0.618) < 0.1 &&
|
|
|
BC > 0.3 && BC < 0.9 &&
|
|
|
Math.abs(CD - 0.786) < 0.1) {
|
|
|
return {
|
|
|
type: 'Gartley',
|
|
|
pattern: 'Bullish',
|
|
|
confidence: 0.75,
|
|
|
points: { X, A, B, C, D }
|
|
|
};
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
detectButterfly(prices) {
|
|
|
if (prices.length < 5) return null;
|
|
|
|
|
|
const X = prices[prices.length - 5];
|
|
|
const A = prices[prices.length - 4];
|
|
|
const B = prices[prices.length - 3];
|
|
|
const C = prices[prices.length - 2];
|
|
|
const D = prices[prices.length - 1];
|
|
|
|
|
|
const AB = Math.abs((B - A) / (A - X));
|
|
|
const BC = Math.abs((C - B) / (B - A));
|
|
|
const CD = Math.abs((D - C) / (C - B));
|
|
|
|
|
|
|
|
|
if (Math.abs(AB - 0.786) < 0.1 &&
|
|
|
BC > 0.3 && BC < 0.9 &&
|
|
|
CD > 1.2 && CD < 1.7) {
|
|
|
return {
|
|
|
type: 'Butterfly',
|
|
|
pattern: 'Bearish',
|
|
|
confidence: 0.70,
|
|
|
points: { X, A, B, C, D }
|
|
|
};
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
detectBat(prices) {
|
|
|
if (prices.length < 5) return null;
|
|
|
|
|
|
const X = prices[prices.length - 5];
|
|
|
const A = prices[prices.length - 4];
|
|
|
const B = prices[prices.length - 3];
|
|
|
const C = prices[prices.length - 2];
|
|
|
const D = prices[prices.length - 1];
|
|
|
|
|
|
const AB = Math.abs((B - A) / (A - X));
|
|
|
const BC = Math.abs((C - B) / (B - A));
|
|
|
const CD = Math.abs((D - C) / (C - B));
|
|
|
|
|
|
|
|
|
if (AB > 0.3 && AB < 0.55 &&
|
|
|
BC > 0.3 && BC < 0.9 &&
|
|
|
Math.abs(CD - 0.886) < 0.1) {
|
|
|
return {
|
|
|
type: 'Bat',
|
|
|
pattern: 'Bullish',
|
|
|
confidence: 0.72,
|
|
|
points: { X, A, B, C, D }
|
|
|
};
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
detectCrab(prices) {
|
|
|
if (prices.length < 5) return null;
|
|
|
|
|
|
const X = prices[prices.length - 5];
|
|
|
const A = prices[prices.length - 4];
|
|
|
const B = prices[prices.length - 3];
|
|
|
const C = prices[prices.length - 2];
|
|
|
const D = prices[prices.length - 1];
|
|
|
|
|
|
const AB = Math.abs((B - A) / (A - X));
|
|
|
const BC = Math.abs((C - B) / (B - A));
|
|
|
const CD = Math.abs((D - C) / (C - B));
|
|
|
|
|
|
|
|
|
if (AB > 0.3 && AB < 0.65 &&
|
|
|
BC > 0.3 && BC < 0.9 &&
|
|
|
Math.abs(CD - 1.618) < 0.15) {
|
|
|
return {
|
|
|
type: 'Crab',
|
|
|
pattern: 'Bearish',
|
|
|
confidence: 0.68,
|
|
|
points: { X, A, B, C, D }
|
|
|
};
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
analyzeElliottWave() {
|
|
|
const closes = this.ohlcvData.map(c => parseFloat(c.c || c.close));
|
|
|
if (closes.length < 34) return null;
|
|
|
|
|
|
|
|
|
const waves = this.identifyWaves(closes);
|
|
|
return {
|
|
|
wave_count: waves.length,
|
|
|
current_wave: waves[waves.length - 1],
|
|
|
pattern: this.determineElliottPattern(waves),
|
|
|
target: this.calculateElliottTarget(waves)
|
|
|
};
|
|
|
}
|
|
|
|
|
|
identifyWaves(prices) {
|
|
|
const waves = [];
|
|
|
let direction = null;
|
|
|
let startIdx = 0;
|
|
|
|
|
|
for (let i = 1; i < prices.length; i++) {
|
|
|
const change = prices[i] - prices[i - 1];
|
|
|
const currentDir = change > 0 ? 'up' : 'down';
|
|
|
|
|
|
if (direction === null) {
|
|
|
direction = currentDir;
|
|
|
} else if (direction !== currentDir) {
|
|
|
waves.push({
|
|
|
direction,
|
|
|
start: startIdx,
|
|
|
end: i - 1,
|
|
|
magnitude: Math.abs(prices[i - 1] - prices[startIdx])
|
|
|
});
|
|
|
startIdx = i - 1;
|
|
|
direction = currentDir;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return waves;
|
|
|
}
|
|
|
|
|
|
determineElliottPattern(waves) {
|
|
|
if (waves.length < 5) return 'Incomplete';
|
|
|
|
|
|
|
|
|
const impulse = waves.slice(-5);
|
|
|
if (impulse.length === 5) {
|
|
|
const wave3 = impulse[2];
|
|
|
const wave1 = impulse[0];
|
|
|
|
|
|
|
|
|
if (wave3.magnitude > wave1.magnitude * 1.618) {
|
|
|
return 'Impulse Wave (5-3-5-3-5)';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return 'Corrective Wave';
|
|
|
}
|
|
|
|
|
|
calculateElliottTarget(waves) {
|
|
|
if (waves.length < 3) return null;
|
|
|
|
|
|
const lastWave = waves[waves.length - 1];
|
|
|
const prevWave = waves[waves.length - 2];
|
|
|
|
|
|
|
|
|
const target = lastWave.magnitude * 1.618;
|
|
|
return {
|
|
|
price: target,
|
|
|
type: lastWave.direction === 'up' ? 'resistance' : 'support'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
detectCandlestickPatterns() {
|
|
|
const patterns = [];
|
|
|
|
|
|
for (let i = 4; i < this.ohlcvData.length; i++) {
|
|
|
const candles = this.ohlcvData.slice(i - 4, i + 1);
|
|
|
|
|
|
|
|
|
if (this.isDoji(candles[candles.length - 1])) {
|
|
|
patterns.push({ type: 'Doji', index: i, signal: 'Reversal' });
|
|
|
}
|
|
|
|
|
|
|
|
|
if (this.isHammer(candles[candles.length - 1])) {
|
|
|
patterns.push({ type: 'Hammer', index: i, signal: 'Bullish' });
|
|
|
}
|
|
|
|
|
|
|
|
|
const engulfing = this.isEngulfing(candles[candles.length - 2], candles[candles.length - 1]);
|
|
|
if (engulfing) {
|
|
|
patterns.push({ type: engulfing, index: i, signal: engulfing.includes('Bullish') ? 'Bullish' : 'Bearish' });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return patterns.slice(-10);
|
|
|
}
|
|
|
|
|
|
isDoji(candle) {
|
|
|
const body = Math.abs(parseFloat(candle.c || candle.close) - parseFloat(candle.o || candle.open));
|
|
|
const range = parseFloat(candle.h || candle.high) - parseFloat(candle.l || candle.low);
|
|
|
return body / range < 0.1 && range > 0;
|
|
|
}
|
|
|
|
|
|
isHammer(candle) {
|
|
|
const body = Math.abs(parseFloat(candle.c || candle.close) - parseFloat(candle.o || candle.open));
|
|
|
const lowerShadow = Math.min(parseFloat(candle.c || candle.close), parseFloat(candle.o || candle.open)) - parseFloat(candle.l || candle.low);
|
|
|
const upperShadow = parseFloat(candle.h || candle.high) - Math.max(parseFloat(candle.c || candle.close), parseFloat(candle.o || candle.open));
|
|
|
return lowerShadow > body * 2 && upperShadow < body * 0.5;
|
|
|
}
|
|
|
|
|
|
isEngulfing(prevCandle, currentCandle) {
|
|
|
const prevBody = Math.abs(parseFloat(prevCandle.c || prevCandle.close) - parseFloat(prevCandle.o || prevCandle.open));
|
|
|
const currBody = Math.abs(parseFloat(currentCandle.c || currentCandle.close) - parseFloat(currentCandle.o || currentCandle.open));
|
|
|
|
|
|
const prevBullish = parseFloat(prevCandle.c || prevCandle.close) > parseFloat(prevCandle.o || prevCandle.open);
|
|
|
const currBullish = parseFloat(currentCandle.c || currentCandle.close) > parseFloat(currentCandle.o || currentCandle.open);
|
|
|
|
|
|
if (currBody > prevBody * 1.5) {
|
|
|
if (!prevBullish && currBullish) {
|
|
|
return 'Bullish Engulfing';
|
|
|
} else if (prevBullish && !currBullish) {
|
|
|
return 'Bearish Engulfing';
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
calculateIndicators() {
|
|
|
const closes = this.ohlcvData.map(c => parseFloat(c.c || c.close));
|
|
|
const volumes = this.ohlcvData.map(c => parseFloat(c.v || c.volume || 0));
|
|
|
|
|
|
return {
|
|
|
rsi: this.calculateRSI(closes),
|
|
|
macd: this.calculateMACD(closes),
|
|
|
ichimoku: this.calculateIchimoku(this.ohlcvData),
|
|
|
sma20: this.calculateSMA(closes, 20),
|
|
|
sma50: this.calculateSMA(closes, 50),
|
|
|
volume_avg: volumes.length > 0 ? volumes.reduce((a, b) => a + b, 0) / volumes.length : 0
|
|
|
};
|
|
|
}
|
|
|
|
|
|
calculateRSI(prices, period = 14) {
|
|
|
if (prices.length < period + 1) return null;
|
|
|
|
|
|
const deltas = [];
|
|
|
for (let i = 1; i < prices.length; i++) {
|
|
|
deltas.push(prices[i] - prices[i - 1]);
|
|
|
}
|
|
|
|
|
|
const gains = deltas.slice(-period).filter(d => d > 0);
|
|
|
const losses = deltas.slice(-period).filter(d => d < 0).map(d => Math.abs(d));
|
|
|
|
|
|
const avgGain = gains.length > 0 ? gains.reduce((a, b) => a + b, 0) / period : 0;
|
|
|
const avgLoss = losses.length > 0 ? losses.reduce((a, b) => a + b, 0) / period : 0;
|
|
|
|
|
|
if (avgLoss === 0) return avgGain > 0 ? 100 : 50;
|
|
|
|
|
|
const rs = avgGain / avgLoss;
|
|
|
return 100 - (100 / (1 + rs));
|
|
|
}
|
|
|
|
|
|
calculateMACD(prices, fast = 12, slow = 26, signal = 9) {
|
|
|
if (prices.length < slow + signal) return null;
|
|
|
|
|
|
const emaFast = this.calculateEMA(prices, fast);
|
|
|
const emaSlow = this.calculateEMA(prices, slow);
|
|
|
|
|
|
if (!emaFast || !emaSlow) return null;
|
|
|
|
|
|
const macdLine = emaFast - emaSlow;
|
|
|
const signalLine = this.calculateEMA([macdLine], signal);
|
|
|
|
|
|
return {
|
|
|
macd: macdLine,
|
|
|
signal: signalLine,
|
|
|
histogram: macdLine - signalLine
|
|
|
};
|
|
|
}
|
|
|
|
|
|
calculateEMA(prices, period) {
|
|
|
if (prices.length < period) return null;
|
|
|
|
|
|
const multiplier = 2 / (period + 1);
|
|
|
let ema = prices.slice(0, period).reduce((a, b) => a + b, 0) / period;
|
|
|
|
|
|
for (let i = period; i < prices.length; i++) {
|
|
|
ema = (prices[i] - ema) * multiplier + ema;
|
|
|
}
|
|
|
|
|
|
return ema;
|
|
|
}
|
|
|
|
|
|
calculateSMA(prices, period) {
|
|
|
if (prices.length < period) return null;
|
|
|
return prices.slice(-period).reduce((a, b) => a + b, 0) / period;
|
|
|
}
|
|
|
|
|
|
calculateIchimoku(ohlcv) {
|
|
|
if (ohlcv.length < 52) return null;
|
|
|
|
|
|
const closes = ohlcv.map(c => parseFloat(c.c || c.close));
|
|
|
const highs = ohlcv.map(c => parseFloat(c.h || c.high));
|
|
|
const lows = ohlcv.map(c => parseFloat(c.l || c.low));
|
|
|
|
|
|
const tenkan = (Math.max(...highs.slice(-9)) + Math.min(...lows.slice(-9))) / 2;
|
|
|
const kijun = (Math.max(...highs.slice(-26)) + Math.min(...lows.slice(-26))) / 2;
|
|
|
const senkouA = (tenkan + kijun) / 2;
|
|
|
const senkouB = (Math.max(...highs.slice(-52)) + Math.min(...lows.slice(-52))) / 2;
|
|
|
const chikou = closes[closes.length - 26];
|
|
|
|
|
|
return {
|
|
|
tenkan,
|
|
|
kijun,
|
|
|
senkouA,
|
|
|
senkouB,
|
|
|
chikou,
|
|
|
cloud: senkouA > senkouB ? 'bullish' : 'bearish'
|
|
|
};
|
|
|
}
|
|
|
|
|
|
generateSignals() {
|
|
|
const indicators = this.calculateIndicators();
|
|
|
const signals = [];
|
|
|
|
|
|
|
|
|
if (indicators.rsi) {
|
|
|
if (indicators.rsi < 30) {
|
|
|
signals.push({ type: 'BUY', source: 'RSI Oversold', strength: 'Strong' });
|
|
|
} else if (indicators.rsi > 70) {
|
|
|
signals.push({ type: 'SELL', source: 'RSI Overbought', strength: 'Strong' });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
if (indicators.macd) {
|
|
|
if (indicators.macd.histogram > 0 && indicators.macd.macd > indicators.macd.signal) {
|
|
|
signals.push({ type: 'BUY', source: 'MACD Bullish Crossover', strength: 'Medium' });
|
|
|
} else if (indicators.macd.histogram < 0 && indicators.macd.macd < indicators.macd.signal) {
|
|
|
signals.push({ type: 'SELL', source: 'MACD Bearish Crossover', strength: 'Medium' });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
const sr = this.calculateSupportResistance();
|
|
|
const lastClose = parseFloat(this.ohlcvData[this.ohlcvData.length - 1].c || this.ohlcvData[this.ohlcvData.length - 1].close);
|
|
|
|
|
|
if (sr.support && lastClose <= sr.support * 1.02) {
|
|
|
signals.push({ type: 'BUY', source: 'Near Support Level', strength: 'Medium' });
|
|
|
}
|
|
|
|
|
|
if (sr.resistance && lastClose >= sr.resistance * 0.98) {
|
|
|
signals.push({ type: 'SELL', source: 'Near Resistance Level', strength: 'Medium' });
|
|
|
}
|
|
|
|
|
|
return signals;
|
|
|
}
|
|
|
|
|
|
updateChart() {
|
|
|
if (!this.chart || !this.candlestickSeries) {
|
|
|
|
|
|
this.loadChart();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (!this.ohlcvData || this.ohlcvData.length === 0) {
|
|
|
logger.warn('TechnicalAnalysis', 'No OHLCV data to display');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const chartData = this.ohlcvData
|
|
|
.filter(candle => {
|
|
|
const close = parseFloat(candle.c || candle.close || 0);
|
|
|
const open = parseFloat(candle.o || candle.open || 0);
|
|
|
const high = parseFloat(candle.h || candle.high || 0);
|
|
|
const low = parseFloat(candle.l || candle.low || 0);
|
|
|
return close > 0 && open > 0 && high > 0 && low > 0 && high >= low;
|
|
|
})
|
|
|
.map(candle => ({
|
|
|
time: Math.floor(parseInt(candle.t || candle.openTime || Date.now()) / 1000),
|
|
|
open: parseFloat(candle.o || candle.open),
|
|
|
high: parseFloat(candle.h || candle.high),
|
|
|
low: parseFloat(candle.l || candle.low),
|
|
|
close: parseFloat(candle.c || candle.close)
|
|
|
}))
|
|
|
.sort((a, b) => a.time - b.time);
|
|
|
|
|
|
if (chartData.length === 0) {
|
|
|
throw new Error('No valid chart data after filtering');
|
|
|
}
|
|
|
|
|
|
this.candlestickSeries.setData(chartData);
|
|
|
this.chart.timeScale().fitContent();
|
|
|
|
|
|
|
|
|
this.drawTrendLines();
|
|
|
|
|
|
|
|
|
this.drawSupportResistance();
|
|
|
|
|
|
|
|
|
if (this.indicators.volume && this.volumeSeries) {
|
|
|
const volumeData = this.ohlcvData.map(candle => ({
|
|
|
time: Math.floor(parseInt(candle.t || candle.openTime) / 1000),
|
|
|
value: parseFloat(candle.v || candle.volume || 0),
|
|
|
color: parseFloat(candle.c || candle.close) >= parseFloat(candle.o || candle.open)
|
|
|
? 'rgba(34, 197, 94, 0.5)'
|
|
|
: 'rgba(239, 68, 68, 0.5)'
|
|
|
}));
|
|
|
this.volumeSeries.setData(volumeData);
|
|
|
}
|
|
|
|
|
|
|
|
|
const lastCandle = this.ohlcvData[this.ohlcvData.length - 1];
|
|
|
if (!lastCandle) {
|
|
|
logger.warn('TechnicalAnalysis', 'No last candle available for price display');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const lastClose = parseFloat(lastCandle.c || lastCandle.close);
|
|
|
if (isNaN(lastClose) || lastClose <= 0) {
|
|
|
logger.warn('TechnicalAnalysis', 'Invalid last close price');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const prevClose = this.ohlcvData.length > 1
|
|
|
? parseFloat(this.ohlcvData[this.ohlcvData.length - 2].c || this.ohlcvData[this.ohlcvData.length - 2].close)
|
|
|
: lastClose;
|
|
|
|
|
|
if (isNaN(prevClose) || prevClose <= 0) {
|
|
|
logger.warn('TechnicalAnalysis', 'Invalid previous close price');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const change = prevClose !== 0 ? ((lastClose - prevClose) / prevClose) * 100 : 0;
|
|
|
|
|
|
const priceEl = document.getElementById('chart-price');
|
|
|
if (priceEl) {
|
|
|
priceEl.textContent = safeFormatNumber(lastClose);
|
|
|
}
|
|
|
|
|
|
const changeEl = document.getElementById('chart-change');
|
|
|
if (changeEl) {
|
|
|
changeEl.textContent = `${change >= 0 ? '+' : ''}${safeFormatNumber(change, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
|
|
|
changeEl.className = `change-display ${change >= 0 ? 'positive' : 'negative'}`;
|
|
|
}
|
|
|
} catch (error) {
|
|
|
logger.error('TechnicalAnalysis', 'Chart update error:', error);
|
|
|
this.showError('Failed to update chart. Please try again.');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
drawTrendLines() {
|
|
|
if (!this.analysisData || !this.chart) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const closes = this.ohlcvData.map(c => parseFloat(c.c || c.close)).filter(v => v > 0);
|
|
|
if (closes.length < 20) return;
|
|
|
|
|
|
const sma20 = this.calculateSMA(closes, 20);
|
|
|
if (!sma20) return;
|
|
|
|
|
|
|
|
|
if (!this.trendLineSeries) {
|
|
|
this.trendLineSeries = this.chart.addLineSeries({
|
|
|
color: '#2dd4bf',
|
|
|
lineWidth: 2,
|
|
|
lineStyle: 2,
|
|
|
title: 'SMA 20'
|
|
|
});
|
|
|
}
|
|
|
|
|
|
|
|
|
const trendData = [];
|
|
|
for (let i = 19; i < this.ohlcvData.length; i++) {
|
|
|
const periodCloses = closes.slice(i - 19, i + 1);
|
|
|
const sma = periodCloses.reduce((a, b) => a + b, 0) / 20;
|
|
|
trendData.push({
|
|
|
time: Math.floor(parseInt(this.ohlcvData[i].t || this.ohlcvData[i].openTime) / 1000),
|
|
|
value: sma
|
|
|
});
|
|
|
}
|
|
|
|
|
|
this.trendLineSeries.setData(trendData);
|
|
|
} catch (error) {
|
|
|
logger.warn('TechnicalAnalysis', 'Failed to draw trend lines:', error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
drawSupportResistance() {
|
|
|
if (!this.analysisData || !this.analysisData.support_resistance || !this.chart) return;
|
|
|
|
|
|
try {
|
|
|
const { support, resistance } = this.analysisData.support_resistance;
|
|
|
if (!support && !resistance) return;
|
|
|
|
|
|
const lastTime = Math.floor(parseInt(this.ohlcvData[this.ohlcvData.length - 1].t || this.ohlcvData[this.ohlcvData.length - 1].openTime) / 1000);
|
|
|
const firstTime = Math.floor(parseInt(this.ohlcvData[0].t || this.ohlcvData[0].openTime) / 1000);
|
|
|
|
|
|
|
|
|
if (support && !this.supportLineSeries) {
|
|
|
this.supportLineSeries = this.chart.addLineSeries({
|
|
|
color: '#ef4444',
|
|
|
lineWidth: 2,
|
|
|
lineStyle: 2,
|
|
|
title: 'Support'
|
|
|
});
|
|
|
this.supportLineSeries.setData([
|
|
|
{ time: firstTime, value: support },
|
|
|
{ time: lastTime, value: support }
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
|
|
|
if (resistance && !this.resistanceLineSeries) {
|
|
|
this.resistanceLineSeries = this.chart.addLineSeries({
|
|
|
color: '#22c55e',
|
|
|
lineWidth: 2,
|
|
|
lineStyle: 2,
|
|
|
title: 'Resistance'
|
|
|
});
|
|
|
this.resistanceLineSeries.setData([
|
|
|
{ time: firstTime, value: resistance },
|
|
|
{ time: lastTime, value: resistance }
|
|
|
]);
|
|
|
}
|
|
|
} catch (error) {
|
|
|
logger.warn('TechnicalAnalysis', 'Failed to draw support/resistance:', error);
|
|
|
}
|
|
|
|
|
|
renderAnalysis() {
|
|
|
if (!this.analysisData) return;
|
|
|
|
|
|
this.renderSupportResistance();
|
|
|
this.renderSignals();
|
|
|
this.renderHarmonicPatterns();
|
|
|
this.renderElliottWave();
|
|
|
this.renderTradeRecommendations();
|
|
|
}
|
|
|
|
|
|
renderSupportResistance() {
|
|
|
const container = document.getElementById('support-resistance-levels');
|
|
|
if (!container || !this.analysisData || !this.analysisData.support_resistance) return;
|
|
|
|
|
|
const { support, resistance, levels } = this.analysisData.support_resistance;
|
|
|
|
|
|
|
|
|
const validLevels = Array.isArray(levels) ? levels.filter(level =>
|
|
|
level && typeof level === 'object' &&
|
|
|
typeof level.value === 'number' && !isNaN(level.value) &&
|
|
|
typeof level.strength === 'number' && !isNaN(level.strength)
|
|
|
) : [];
|
|
|
|
|
|
const supportValue = (support && typeof support === 'number' && !isNaN(support))
|
|
|
? safeFormatNumber(support)
|
|
|
: '—';
|
|
|
const resistanceValue = (resistance && typeof resistance === 'number' && !isNaN(resistance))
|
|
|
? safeFormatNumber(resistance)
|
|
|
: '—';
|
|
|
|
|
|
container.innerHTML = `
|
|
|
<div class="level-item support">
|
|
|
<div class="level-icon">↓</div>
|
|
|
<div class="level-details">
|
|
|
<span class="level-type">Support</span>
|
|
|
<strong class="level-price">${escapeHtml(supportValue)}</strong>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="level-item resistance">
|
|
|
<div class="level-icon">↑</div>
|
|
|
<div class="level-details">
|
|
|
<span class="level-type">Resistance</span>
|
|
|
<strong class="level-price">${escapeHtml(resistanceValue)}</strong>
|
|
|
</div>
|
|
|
</div>
|
|
|
${validLevels.map(level => {
|
|
|
const levelType = escapeHtml(String(level.type || 'support'));
|
|
|
const levelValue = safeFormatNumber(level.value);
|
|
|
const strengthPercent = safeFormatNumber(level.strength * 100, { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
|
return `
|
|
|
<div class="level-item ${levelType}" style="opacity: ${Math.max(0, Math.min(1, level.strength))}">
|
|
|
<div class="level-icon">${levelType === 'support' ? '↓' : '↑'}</div>
|
|
|
<div class="level-details">
|
|
|
<span class="level-type">${levelType === 'support' ? 'Support' : 'Resistance'}</span>
|
|
|
<strong class="level-price">${escapeHtml(levelValue)}</strong>
|
|
|
<span class="level-strength">Strength: ${escapeHtml(strengthPercent)}%</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
}).join('')}
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
renderSignals() {
|
|
|
const container = document.getElementById('trading-signals');
|
|
|
if (!container || !this.analysisData || !this.analysisData.signals) {
|
|
|
if (container) {
|
|
|
container.innerHTML = '<div class="no-signals">No signals detected</div>';
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const signals = Array.isArray(this.analysisData.signals) ? this.analysisData.signals : [];
|
|
|
|
|
|
if (signals.length === 0) {
|
|
|
container.innerHTML = '<div class="no-signals">No signals detected</div>';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
container.innerHTML = signals.map(signal => {
|
|
|
if (!signal || typeof signal !== 'object') return '';
|
|
|
|
|
|
const signalType = String(signal.type || 'HOLD').toUpperCase();
|
|
|
const signalSource = escapeHtml(String(signal.source || 'Unknown'));
|
|
|
const signalStrength = escapeHtml(String(signal.strength || 'Medium'));
|
|
|
const signalClass = escapeHtml(String(signalType).toLowerCase());
|
|
|
const signalIcon = signalType === 'BUY' ? '🟢' : signalType === 'SELL' ? '🔴' : '🟡';
|
|
|
|
|
|
return `
|
|
|
<div class="signal-item ${signalClass}">
|
|
|
<div class="signal-icon">${signalIcon}</div>
|
|
|
<div class="signal-details">
|
|
|
<span class="signal-type">${escapeHtml(signalType)}</span>
|
|
|
<span class="signal-source">${signalSource}</span>
|
|
|
<span class="signal-strength">${signalStrength}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
}).filter(html => html.length > 0).join('') || '<div class="no-signals">No signals detected</div>';
|
|
|
}
|
|
|
|
|
|
renderHarmonicPatterns() {
|
|
|
const container = document.getElementById('harmonic-patterns');
|
|
|
if (!container || !this.analysisData || !this.analysisData.harmonic_patterns) {
|
|
|
if (container) {
|
|
|
container.innerHTML = '<div class="no-patterns">No harmonic patterns detected</div>';
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const patterns = Array.isArray(this.analysisData.harmonic_patterns)
|
|
|
? this.analysisData.harmonic_patterns.filter(p => p && typeof p === 'object')
|
|
|
: [];
|
|
|
|
|
|
if (patterns.length === 0) {
|
|
|
container.innerHTML = '<div class="no-patterns">No harmonic patterns detected</div>';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
container.innerHTML = patterns.map(pattern => {
|
|
|
const patternType = escapeHtml(String(pattern.type || 'Unknown'));
|
|
|
const patternPattern = escapeHtml(String(pattern.pattern || 'Neutral').toLowerCase());
|
|
|
const confidence = typeof pattern.confidence === 'number' && !isNaN(pattern.confidence)
|
|
|
? safeFormatNumber(pattern.confidence * 100, { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
|
|
: '0';
|
|
|
|
|
|
return `
|
|
|
<div class="pattern-item ${patternPattern}">
|
|
|
<div class="pattern-header">
|
|
|
<span class="pattern-type">${patternType}</span>
|
|
|
<span class="pattern-confidence">${escapeHtml(confidence)}%</span>
|
|
|
</div>
|
|
|
<div class="pattern-details">
|
|
|
<span class="pattern-direction">${escapeHtml(String(pattern.pattern || 'Neutral'))}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
}).filter(html => html.length > 0).join('') || '<div class="no-patterns">No harmonic patterns detected</div>';
|
|
|
}
|
|
|
|
|
|
renderElliottWave() {
|
|
|
const container = document.getElementById('elliott-wave');
|
|
|
if (!container || !this.analysisData || !this.analysisData.elliott_wave) {
|
|
|
if (container) {
|
|
|
container.innerHTML = '<div class="no-wave">Elliott Wave analysis not available</div>';
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const wave = this.analysisData.elliott_wave;
|
|
|
if (!wave || typeof wave !== 'object') {
|
|
|
if (container) {
|
|
|
container.innerHTML = '<div class="no-wave">Elliott Wave analysis not available</div>';
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const pattern = escapeHtml(String(wave.pattern || 'Incomplete'));
|
|
|
const waveCount = typeof wave.wave_count === 'number' ? wave.wave_count : 0;
|
|
|
const targetHtml = (wave.target && typeof wave.target === 'object' &&
|
|
|
typeof wave.target.price === 'number' && !isNaN(wave.target.price))
|
|
|
? `
|
|
|
<div class="wave-info">
|
|
|
<span class="wave-label">Target:</span>
|
|
|
<span class="wave-value">${escapeHtml(safeFormatNumber(wave.target.price))} (${escapeHtml(String(wave.target.type || 'unknown'))})</span>
|
|
|
</div>
|
|
|
`
|
|
|
: '';
|
|
|
|
|
|
container.innerHTML = `
|
|
|
<div class="wave-analysis-card">
|
|
|
<div class="wave-info">
|
|
|
<span class="wave-label">Pattern:</span>
|
|
|
<span class="wave-value">${pattern}</span>
|
|
|
</div>
|
|
|
<div class="wave-info">
|
|
|
<span class="wave-label">Wave Count:</span>
|
|
|
<span class="wave-value">${escapeHtml(String(waveCount))}</span>
|
|
|
</div>
|
|
|
${targetHtml}
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
renderTradeRecommendations() {
|
|
|
const container = document.getElementById('trade-recommendations');
|
|
|
if (!container) return;
|
|
|
|
|
|
if (!this.analysisData || !this.ohlcvData || this.ohlcvData.length === 0) {
|
|
|
container.innerHTML = '<div class="no-recommendations">Insufficient data for recommendations</div>';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const signals = Array.isArray(this.analysisData.signals) ? this.analysisData.signals : [];
|
|
|
const sr = (this.analysisData.support_resistance && typeof this.analysisData.support_resistance === 'object')
|
|
|
? this.analysisData.support_resistance
|
|
|
: {};
|
|
|
|
|
|
const lastCandle = this.ohlcvData[this.ohlcvData.length - 1];
|
|
|
const lastClose = (lastCandle && (typeof lastCandle.c === 'number' || typeof lastCandle.close === 'number'))
|
|
|
? parseFloat(lastCandle.c || lastCandle.close)
|
|
|
: 0;
|
|
|
|
|
|
if (lastClose <= 0 || isNaN(lastClose)) {
|
|
|
container.innerHTML = '<div class="no-recommendations">Invalid price data</div>';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const buySignals = signals.filter(s => s && s.type === 'BUY');
|
|
|
const sellSignals = signals.filter(s => s && s.type === 'SELL');
|
|
|
|
|
|
let recommendation = 'HOLD';
|
|
|
let tp = null;
|
|
|
let sl = null;
|
|
|
|
|
|
if (buySignals.length > sellSignals.length) {
|
|
|
recommendation = 'BUY';
|
|
|
tp = (sr.resistance && typeof sr.resistance === 'number' && !isNaN(sr.resistance))
|
|
|
? sr.resistance
|
|
|
: lastClose * 1.05;
|
|
|
sl = (sr.support && typeof sr.support === 'number' && !isNaN(sr.support))
|
|
|
? sr.support
|
|
|
: lastClose * 0.95;
|
|
|
} else if (sellSignals.length > buySignals.length) {
|
|
|
recommendation = 'SELL';
|
|
|
tp = (sr.support && typeof sr.support === 'number' && !isNaN(sr.support))
|
|
|
? sr.support
|
|
|
: lastClose * 0.95;
|
|
|
sl = (sr.resistance && typeof sr.resistance === 'number' && !isNaN(sr.resistance))
|
|
|
? sr.resistance
|
|
|
: lastClose * 1.05;
|
|
|
}
|
|
|
|
|
|
const recommendationClass = escapeHtml(recommendation.toLowerCase());
|
|
|
const confidenceText = signals.length > 0 ? 'High' : 'Low';
|
|
|
const tpValue = tp && typeof tp === 'number' && !isNaN(tp) ? safeFormatNumber(tp) : '—';
|
|
|
const slValue = sl && typeof sl === 'number' && !isNaN(sl) ? safeFormatNumber(sl) : '—';
|
|
|
|
|
|
container.innerHTML = `
|
|
|
<div class="recommendation-card ${recommendationClass}">
|
|
|
<div class="recommendation-header">
|
|
|
<span class="recommendation-type">${escapeHtml(recommendation)}</span>
|
|
|
<span class="recommendation-confidence">${escapeHtml(confidenceText)}</span>
|
|
|
</div>
|
|
|
${recommendation !== 'HOLD' ? `
|
|
|
<div class="recommendation-levels">
|
|
|
<div class="level-item">
|
|
|
<span class="level-label">Take Profit:</span>
|
|
|
<strong class="level-value">${escapeHtml(tpValue)}</strong>
|
|
|
</div>
|
|
|
<div class="level-item">
|
|
|
<span class="level-label">Stop Loss:</span>
|
|
|
<strong class="level-value">${escapeHtml(slValue)}</strong>
|
|
|
</div>
|
|
|
</div>
|
|
|
` : ''}
|
|
|
<div class="recommendation-signals">
|
|
|
<span>${escapeHtml(String(buySignals.length))} Buy Signals</span>
|
|
|
<span>${escapeHtml(String(sellSignals.length))} Sell Signals</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
showError(message) {
|
|
|
this.showNotification(message, 'error');
|
|
|
logger.error('TechnicalAnalysis', message);
|
|
|
}
|
|
|
|
|
|
showSuccess(message) {
|
|
|
this.showNotification(message, 'success');
|
|
|
}
|
|
|
|
|
|
showWarning(message) {
|
|
|
this.showNotification(message, 'warning');
|
|
|
}
|
|
|
|
|
|
showInfo(message) {
|
|
|
this.showNotification(message, 'info');
|
|
|
}
|
|
|
|
|
|
showNotification(message, type = 'info') {
|
|
|
const toast = document.createElement('div');
|
|
|
toast.className = `notification ${type}`;
|
|
|
toast.textContent = message;
|
|
|
toast.style.cssText = `
|
|
|
position: fixed;
|
|
|
top: 20px;
|
|
|
right: 20px;
|
|
|
padding: 16px 24px;
|
|
|
background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.95));
|
|
|
backdrop-filter: blur(10px);
|
|
|
border-radius: 8px;
|
|
|
border-left: 4px solid;
|
|
|
color: var(--text-strong);
|
|
|
z-index: 10000;
|
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
|
min-width: 300px;
|
|
|
max-width: 500px;
|
|
|
animation: slideInRight 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
|
`;
|
|
|
|
|
|
if (type === 'success') toast.style.borderLeftColor = '#22c55e';
|
|
|
else if (type === 'error') toast.style.borderLeftColor = '#ef4444';
|
|
|
else if (type === 'warning') toast.style.borderLeftColor = '#eab308';
|
|
|
else toast.style.borderLeftColor = '#3b82f6';
|
|
|
|
|
|
document.body.appendChild(toast);
|
|
|
|
|
|
setTimeout(() => {
|
|
|
toast.style.animation = 'slideInRight 0.4s ease-out reverse';
|
|
|
setTimeout(() => toast.remove(), 400);
|
|
|
}, 5000);
|
|
|
}
|
|
|
|
|
|
showLoading(message = 'Loading...') {
|
|
|
const container = document.getElementById(`mode-${this.currentMode}`);
|
|
|
if (container) {
|
|
|
container.innerHTML = `
|
|
|
<div class="loading-state">
|
|
|
<div class="loading-spinner"></div>
|
|
|
<p class="loading-message">${message}</p>
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
hideLoading() {
|
|
|
|
|
|
}
|
|
|
|
|
|
renderErrorState(mode, error) {
|
|
|
const container = document.getElementById(`mode-${mode}`);
|
|
|
if (container) {
|
|
|
const errorMessage = error && error.message ? escapeHtml(error.message) : 'An unexpected error occurred';
|
|
|
container.innerHTML = `
|
|
|
<div class="error-state">
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
|
<line x1="12" y1="8" x2="12" y2="12"></line>
|
|
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
|
|
</svg>
|
|
|
<h3>Analysis Failed</h3>
|
|
|
<p>${errorMessage}</p>
|
|
|
<button class="btn btn-primary" onclick="if(window.technicalAnalysisPage){window.technicalAnalysisPage.runCurrentModeAnalysis();}">
|
|
|
Retry Analysis
|
|
|
</button>
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
runCurrentModeAnalysis() {
|
|
|
this.analyze();
|
|
|
}
|
|
|
|
|
|
delay(ms) {
|
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
|
}
|
|
|
|
|
|
async fetchWithRetry(url, options = {}, timeout = 15000, retries = 3) {
|
|
|
for (let i = 0; i < retries; i++) {
|
|
|
try {
|
|
|
const response = await apiClient.fetch(url, options, timeout);
|
|
|
if (response.ok) {
|
|
|
return response;
|
|
|
}
|
|
|
|
|
|
if (i < retries - 1 && response.status >= 500) {
|
|
|
const delayMs = Math.min(this.retryConfig.baseDelay * Math.pow(2, i), this.retryConfig.maxDelay);
|
|
|
await this.delay(delayMs);
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
return response;
|
|
|
} catch (error) {
|
|
|
if (i < retries - 1) {
|
|
|
const delayMs = Math.min(this.retryConfig.baseDelay * Math.pow(2, i), this.retryConfig.maxDelay);
|
|
|
await this.delay(delayMs);
|
|
|
continue;
|
|
|
}
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
throw new Error('Max retries exceeded');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export default TechnicalAnalysisPage;
|
|
|
|
|
|
|