|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|