From 899d9174e455ddabf9cbaf469c22f1938991158e Mon Sep 17 00:00:00 2001 From: DiTus Date: Thu, 26 Feb 2026 23:46:33 +0100 Subject: [PATCH] Add indicator signals feature with buy/sell/hold analysis - Add signals-calculator.js module for calculating buy/sell/hold signals for all indicators - Integrate signals into Trend Analysis panel (renamed to Indicator Analysis) - Display individual indicator signals with badges, values, strength bars, and detailed reasoning - Add aggregate summary signal showing overall recommendation from all indicators - Support signals for RSI, MACD, Stochastic, Bollinger Bands, SMA/EMA, ATR, and HTS - Provide tooltips on hover showing indicator value, configuration, and reasoning - Ensure indicators calculate on all available candles, not just recent ones - Cache indicator calculations for performance while recalculating on historical data loads - Style improvements: monospace font, consistent button widths, reduced margins - Add AGENTS.md documentation file with project guidelines --- AGENTS.md | 160 ++++++ src/api/dashboard/static/index.html | 43 +- src/api/dashboard/static/js/ui/chart.js | 123 ++++- .../static/js/ui/indicators-panel-new.js | 68 ++- .../static/js/ui/signals-calculator.js | 499 ++++++++++++++++++ 5 files changed, 870 insertions(+), 23 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/api/dashboard/static/js/ui/signals-calculator.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aad4a91 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,160 @@ +# Agent Development Guidelines + +## Project Overview +This is a Bitcoin trading dashboard with FastAPI backend, PostgreSQL database, and technical analysis features. The system consists of: +- Backend: FastAPI (Python 3.9+) +- Frontend: HTML/JS dashboard with lightweight-charts +- Database: PostgreSQL (TimescaleDB optimized) +- Features: Real-time candle data, technical indicators (SMA, EMA, RSI, MACD, Bollinger Bands), trading strategy simulation, backtesting + +## Build/Lint/Test Commands + +### Setup +```bash +# Create and activate virtual environment +python -m venv venv +venv\Scripts\activate # Windows +# or source venv/bin/activate # Linux/Mac + +# Install dependencies +pip install -r requirements.txt +``` + +### Running Development Server +```bash +# Quick start (Windows) +start_dev.cmd + +# Quick start (Linux/Mac) +chmod +x start_dev.sh +./start_dev.sh + +# Manual start +uvicorn src.api.server:app --reload --host 0.0.0.0 --port 8000 +``` + +### Testing +```bash +# Test database connection +python test_db.py + +# Run single test (no existing test framework found but for any future tests) +python -m pytest .py::test_ -v +``` + +### Environment Setup +Environment variables in `.env` file: +``` +DB_HOST=20.20.20.20 +DB_PORT=5433 +DB_NAME=btc_data +DB_USER=btc_bot +DB_PASSWORD=your_password +``` + +## Code Style Guidelines + +### Python Standards +- Follow PEP 8 style guide +- Use type hints consistently throughout +- Module names should be lowercase with underscores +- Class names should use PascalCase +- Function and variable names should use snake_case +- Constants should use UPPER_CASE +- All functions should have docstrings +- Use meaningful variable names (avoid single letter names except for loop counters) + +### Imports +- Group imports in order: standard library, third-party, local +- Use relative imports for internal modules +- Sort imports alphabetically within each group + +### Error Handling +- Use explicit exception handling with specific exceptions +- Log errors with appropriate context +- Don't suppress exceptions silently +- Use try/except/finally blocks for resource management + +### Naming Conventions +- Classes: PascalCase +- Functions and variables: snake_case +- Constants: UPPER_CASE +- Private methods: _private_method +- Protected attributes: _protected_attribute + +### Documentation +- All public functions should have docstrings in Google style format +- Class docstrings should explain the class purpose and usage +- Complex logic should be commented appropriately +- API endpoints should be documented in docstrings +- Use inline comments for complex operations + +### Data Processing +- Use async/await for database operations +- Handle database connection pooling properly +- Validate incoming data before processing +- Use pydantic models for data validation +- Ensure proper timezone handling for datetime operations + +### Security +- Never log sensitive information (passwords, tokens) +- Use environment variables for configuration +- Validate all input data +- Use prepared statements for database queries to prevent injection + +### Asynchronous Programming +- Use asyncio for concurrent database operations +- Use async context managers for resource management +- Implement timeouts for database operations +- Handle task cancellation appropriately + +### Configuration +- Use pydantic-settings for configuration management +- Load environment variables with python-dotenv +- Provide default values for configuration settings + +### Logging +- Use logging module with appropriate log levels (DEBUG, INFO, WARNING, ERROR) +- Include contextual information in log messages +- Use structured logging where appropriate +- Log exceptions with traceback information + +### Testing +- Write unit tests for core components +- Test database operations asynchronously +- Mock external services where appropriate +- Test both success and failure cases +- Ensure tests are isolated + +## AI Coding Agent Rules + +### File Structure and Organization +- Organize code into logical modules: api, data_collector, strategies, etc. +- Use consistent naming across the codebase +- Follow existing project conventions when adding new features +- Place new code in corresponding directories (src/strategies/ for strategies) + +### Code Quality +- Maintain clean, readable code +- Write efficient code with good performance characteristics +- Follow existing code patterns for consistency +- Ensure proper error handling in all code paths +- Use type hints and validate with mypy when applicable + +### Documentation +- Update docstrings when modifying functions or classes +- Add usage comments for complex logic +- Update README.md if adding major new features +- Document any new environment variables or configuration options + +### Integration +- Respect existing patterns for API endpoints and database access +- Follow established data flow patterns +- Ensure compatibility with existing code when making changes +- Maintain backward compatibility for public APIs + +### Dependencies +- Only add dependencies to requirements.txt when necessary +- Check for conflicts with existing dependencies +- Keep dependency versions pinned to avoid breaking changes +- Avoid adding heavyweight dependencies unless truly required diff --git a/src/api/dashboard/static/index.html b/src/api/dashboard/static/index.html index 774b3df..a84ffc6 100644 --- a/src/api/dashboard/static/index.html +++ b/src/api/dashboard/static/index.html @@ -715,13 +715,16 @@ margin-top: 4px; } - .ta-signal { +.ta-signal { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; - margin-top: 8px; + margin-top: 4px; + min-width: 60px; + text-align: center; + font-family: 'Courier New', monospace; } .ta-signal.buy { @@ -1270,11 +1273,45 @@ } } - @media (max-width: 600px) { +@media (max-width: 600px) { .ta-content { grid-template-columns: 1fr; } } + + /* Signal Styles */ + .ta-summary-badge { + background: var(--tv-bg); + border: 1px solid var(--tv-border); + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + } + + .ta-summary-badge.buy { + background: rgba(38, 166, 154, 0.2); + color: var(--tv-green); + border-color: var(--tv-green); + } + + .ta-summary-badge.sell { + background: rgba(239, 83, 80, 0.2); + color: var(--tv-red); + border-color: var(--tv-red); + } + + .ta-summary-badge.hold { + background: rgba(120, 123, 134, 0.2); + color: var(--tv-text-secondary); + border-color: var(--tv-text-secondary); + } + + .signal-strength-fill { + height: 100%; + transition: width 0.3s ease; + } diff --git a/src/api/dashboard/static/js/ui/chart.js b/src/api/dashboard/static/js/ui/chart.js index a7f7024..34bde29 100644 --- a/src/api/dashboard/static/js/ui/chart.js +++ b/src/api/dashboard/static/js/ui/chart.js @@ -1,7 +1,8 @@ import { INTERVALS, COLORS } from '../core/index.js'; +import { calculateAllIndicatorSignals, calculateSummarySignal } from './signals-calculator.js'; export class TradingDashboard { - constructor() { +constructor() { this.chart = null; this.candleSeries = null; this.currentInterval = '1d'; @@ -10,6 +11,8 @@ export class TradingDashboard { this.isLoading = false; this.hasInitialLoad = false; this.taData = null; + this.indicatorSignals = []; + this.summarySignal = null; this.init(); } @@ -286,7 +289,7 @@ export class TradingDashboard { async loadInitialData() { await Promise.all([ - this.loadData(1000, true), + this.loadData(2000, true), this.loadStats() ]); this.hasInitialLoad = true; @@ -340,14 +343,14 @@ if (data.candles && data.candles.length > 0) { } } - async loadNewData() { +async loadNewData() { if (!this.hasInitialLoad || this.isLoading) return; try { const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=50`); const data = await response.json(); -if (data.candles && data.candles.length > 0) { + if (data.candles && data.candles.length > 0) { const atEdge = this.isAtRightEdge(); const currentSeriesData = this.candleSeries.data(); @@ -373,6 +376,8 @@ if (data.candles && data.candles.length > 0) { const existingData = this.allData.get(this.currentInterval) || []; this.allData.set(this.currentInterval, this.mergeData(existingData, chartData)); + console.log(`[NewData Load] Added ${chartData.length} new candles, total in dataset: ${this.allData.get(this.currentInterval).length}`); + if (atEdge) { this.chart.timeScale().scrollToRealTime(); } @@ -380,10 +385,11 @@ if (data.candles && data.candles.length > 0) { const latest = chartData[chartData.length - 1]; this.updateStats(latest); - // Redraw indicators when new data loads + // Recalculate indicators and signals when new data loads if (window.drawIndicatorsOnChart) { window.drawIndicatorsOnChart(); } + await this.loadSignals(); } } catch (error) { console.error('Error loading new data:', error); @@ -397,7 +403,7 @@ if (data.candles && data.candles.length > 0) { return Array.from(dataMap.values()).sort((a, b) => a.time - b.time); } - onVisibleRangeChange() { +onVisibleRangeChange() { if (!this.hasInitialLoad || this.isLoading) { return; } @@ -408,6 +414,8 @@ if (data.candles && data.candles.length > 0) { } const data = this.candleSeries.data(); + const allData = this.allData.get(this.currentInterval); + if (!data || data.length === 0) { return; } @@ -416,22 +424,37 @@ if (data.candles && data.candles.length > 0) { const bufferSize = visibleBars * 2; const refillThreshold = bufferSize * 0.8; const barsFromLeft = Math.floor(visibleRange.from); + const visibleOldestTime = data[Math.floor(visibleRange.from)]?.time; + const visibleNewestTime = data[Math.ceil(visibleRange.to)]?.time; + + console.log(`[VisibleRange] Visible: ${visibleBars} bars (${data.length} in chart, ${allData?.length || 0} in dataset)`); + console.log(`[VisibleRange] Time range: ${new Date((visibleOldestTime || 0) * 1000).toLocaleDateString()} to ${new Date((visibleNewestTime || 0) * 1000).toLocaleDateString()}`); if (barsFromLeft < refillThreshold) { - console.log(`Buffer low (${barsFromLeft} < ${refillThreshold.toFixed(0)}), silently prefetching ${bufferSize} candles...`); + console.log(`Buffer low (${barsFromLeft} < ${refillThreshold.toFixed(0)}), prefetching ${bufferSize} candles...`); const oldestCandle = data[0]; if (oldestCandle) { this.loadHistoricalData(oldestCandle.time, bufferSize); } } + + // Recalculate indicators when data changes + if (data.length !== allData?.length) { + console.log(`[VisibleRange] Chart data (${data.length}) vs dataset (${allData?.length || 0}) differ, redrawing indicators...`); + } + + window.drawIndicatorsOnChart?.(); + this.loadSignals().catch(e => console.error('Error loading signals:', e)); } - async loadHistoricalData(beforeTime, limit = 1000) { +async loadHistoricalData(beforeTime, limit = 1000) { if (this.isLoading) { return; } this.isLoading = true; + console.log(`[Historical] Loading historical data before ${new Date(beforeTime * 1000).toLocaleDateString()}, limit=${limit}`); + try { const endTime = new Date((beforeTime - 1) * 1000); @@ -459,17 +482,24 @@ if (data.candles && data.candles.length > 0) { const mergedData = this.mergeData(existingData, chartData); this.allData.set(this.currentInterval, mergedData); + console.log(`[Historical] SUCCESS: Added ${chartData.length} candles`); + console.log(`[Historical] Total candles in dataset: ${mergedData.length}`); + console.log(`[Historical] Oldest: ${new Date(mergedData[0]?.time * 1000).toLocaleDateString()}`); + console.log(`[Historical] Newest: ${new Date(mergedData[mergedData.length - 1]?.time * 1000).toLocaleDateString()}`); + this.candleSeries.setData(mergedData); - // Recalculate indicators with the expanded dataset + // Recalculate indicators and signals with the expanded dataset + console.log(`[Historical] Recalculating indicators...`); window.drawIndicatorsOnChart?.(); + await this.loadSignals(); - console.log(`Prefetched ${chartData.length} candles, total: ${mergedData.length}`); + console.log(`[Historical] Indicators recalculated for ${mergedData.length} candles`); } else { - console.log('No more historical data available'); + console.log('[Historical] No more historical data available from database'); } } catch (error) { - console.error('Error loading historical data:', error); + console.error('[Historical] Error loading historical data:', error); } finally { this.isLoading = false; } @@ -492,6 +522,7 @@ async loadTA() { } this.taData = data; + await this.loadSignals(); this.renderTA(); } catch (error) { console.error('Error loading TA:', error); @@ -499,6 +530,17 @@ async loadTA() { } } +async loadSignals() { + try { + this.indicatorSignals = calculateAllIndicatorSignals(); + this.summarySignal = calculateSummarySignal(this.indicatorSignals); + } catch (error) { + console.error('Error loading signals:', error); + this.indicatorSignals = []; + this.summarySignal = null; + } + } + renderTA() { if (!this.taData || this.taData.error) { document.getElementById('taContent').innerHTML = `
${this.taData?.error || 'No data available'}
`; @@ -515,14 +557,35 @@ renderTA() { document.getElementById('taInterval').textContent = this.currentInterval.toUpperCase(); document.getElementById('taLastUpdate').textContent = new Date().toLocaleTimeString(); + const summary = this.summarySignal || {}; + const summarySignalClass = summary.signal || 'hold'; + + const signalsHtml = this.indicatorSignals?.length > 0 ? this.indicatorSignals.map(indSignal => { + const signalIcon = indSignal.signal === 'buy' ? '🟢' : indSignal.signal === 'sell' ? '🔴' : '🟡'; + const signalClass = indSignal.signal || 'hold'; + const valueStr = indSignal.value !== null && indSignal.value !== undefined ? indSignal.value.toFixed(2) : 'N/A'; + const indicatorConfig = indSignal.params ? `\nConfiguration: ${indSignal.params}` : ''; + const tooltipText = `Value: ${valueStr}${indicatorConfig}\n\n${indSignal.reasoning}`; + + return ` +
+ ${indSignal.name} + + ${signalIcon} ${indSignal.signal.toUpperCase()} + +
+ `; + }).join('') : ''; + + const summaryBadge = ''; + document.getElementById('taContent').innerHTML = `
-
Trend Analysis
-
- ${data.trend.direction} ${trendClass === 'bullish' ? '↑' : trendClass === 'bearish' ? '↓' : '→'} +
+ Indicator Analysis + ${summaryBadge}
-
${data.trend.strength}
- ${data.trend.signal} + ${signalsHtml ? signalsHtml : `
No indicators selected. Add indicators from the sidebar panel to view signals.
`}
@@ -542,9 +605,35 @@ renderTA() {
+ +
+
Support / Resistance
+
+ Resistance + ${data.levels.resistance.toFixed(2)} +
+
+ Support + ${data.levels.support.toFixed(2)} +
+
+ +
+
Price Position
+
+
+
+
+ ${data.levels.position_in_range.toFixed(0)}% in range +
+
`; } + renderSignalsSection() { + return ''; + } + async loadStats() { try { const response = await fetch('/api/v1/stats?symbol=BTC'); diff --git a/src/api/dashboard/static/js/ui/indicators-panel-new.js b/src/api/dashboard/static/js/ui/indicators-panel-new.js index 6f0bf9f..40031e1 100644 --- a/src/api/dashboard/static/js/ui/indicators-panel-new.js +++ b/src/api/dashboard/static/js/ui/indicators-panel-new.js @@ -101,6 +101,8 @@ export function setActiveIndicators(indicators) { renderIndicatorPanel(); } +window.getActiveIndicators = getActiveIndicators; + // Render main panel export function renderIndicatorPanel() { const container = document.getElementById('indicatorPanel'); @@ -659,8 +661,20 @@ function saveUserPresets() { function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) { // Recalculate with current TF candles + console.log(`[renderIndicatorOnPane] ${indicator.name}: START`); + console.log(`[renderIndicatorOnPane] ${indicator.name}: Input candles = ${candles.length}`); + console.log(`[renderIndicatorOnPane] ${indicator.name}: Calling instance.calculate()...`); + const results = instance.calculate(candles); + console.log(`[renderIndicatorOnPane] ${indicator.name}: calculate() returned ${results?.length || 0} results`); + console.log(`[renderIndicatorOnPane] ${indicator.name}: Expected ${candles.length} results, got ${results?.length || 0}`); + + if (results.length !== candles.length) { + console.error(`[renderIndicatorOnPane] ${indicator.name}: MISMATCH! Expected ${candles.length} results but got ${results.length}`); + console.error(`[renderIndicatorOnPane] ${indicator.name}: This means instance.calculate() is not returning the correct number of results!`); + } + // Clear previous series for this indicator if (indicator.series && indicator.series.length > 0) { indicator.series.forEach(s => { @@ -678,6 +692,8 @@ function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, li const isObjectResult = firstNonNull && typeof firstNonNull === 'object'; let plotsCreated = 0; + let dataPointsAdded = 0; + meta.plots.forEach((plot, plotIdx) => { if (isObjectResult) { const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null); @@ -687,6 +703,8 @@ function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, li const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff'; const data = []; + let firstDataIndex = -1; + for (let i = 0; i < candles.length; i++) { let value; if (isObjectResult) { @@ -696,6 +714,9 @@ function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, li } if (value !== null && value !== undefined) { + if (firstDataIndex === -1) { + firstDataIndex = i; + } data.push({ time: candles[i].time, value: value @@ -703,7 +724,14 @@ function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, li } } - if (data.length === 0) return; + console.log(`[renderIndicatorOnPane] ${indicator.name} plot ${plotIdx}: ${data.length} data points created, first data at index ${firstDataIndex}/${candles.length}`); + + if (data.length === 0) { + console.log(`[renderIndicatorOnPane] ${indicator.name} plot ${plotIdx}: No data to render`); + return; + } + + console.log(`[renderIndicatorOnPane] ${indicator.name} plot ${plotIdx}: Creating series with ${data.length} data points [${data[0].time} to ${data[data.length - 1].time}]`); let series; let plotLineStyle = lineStyle; @@ -792,12 +820,26 @@ export function drawIndicatorsOnChart() { } const currentInterval = window.dashboard.currentInterval; - const candles = window.dashboard.allData.get(currentInterval); + const candles = window.dashboard?.allData?.get(currentInterval); if (!candles || candles.length === 0) { + console.log('[Indicators] No candles available'); return; } + console.log(`[Indicators] ========== drawIndicatorsOnChart START ==========`); + console.log(`[Indicators] Candles from allData: ${candles.length}`); + console.log(`[Indicators] First candle time: ${candles[0]?.time} (${new Date(candles[0]?.time * 1000).toLocaleDateString()})`); + console.log(`[Indicators] Last candle time: ${candles[candles.length - 1]?.time} (${new Date(candles[candles.length - 1]?.time * 1000).toLocaleDateString()})`); + + const oldestTime = candles[0]?.time; + const newestTime = candles[candles.length - 1]?.time; + const oldestDate = oldestTime ? new Date(oldestTime * 1000).toLocaleDateString() : 'N/A'; + const newestDate = newestTime ? new Date(newestTime * 1000).toLocaleDateString() : 'N/A'; + + console.log(`[Indicators] ========== Redrawing ==========`); + console.log(`[Indicators] Candles: ${candles.length} | Time range: ${oldestDate} (${oldestTime}) to ${newestDate} (${newestTime})`); + // Remove all existing series activeIndicators.forEach(ind => { ind.series?.forEach(s => { @@ -827,9 +869,18 @@ export function drawIndicatorsOnChart() { const IndicatorClass = IR?.[ind.type]; if (!IndicatorClass) return; - const instance = new IndicatorClass({ type: ind.type, params: ind.params, name: ind.name }); + const instance = new IndicatorClass(ind); const meta = instance.getMetadata(); + // Store calculated results and metadata for signal calculation + const results = instance.calculate(candles); + ind.cachedResults = results; + ind.cachedMeta = meta; + + const validResults = results.filter(r => r !== null && r !== undefined); + const warmupPeriod = ind.params?.period || 44; + console.log(`[Indicators] ${ind.name}: ${validResults.length} valid results (need ${warmupPeriod} candles warmup)`); + if (meta.displayMode === 'pane') { paneIndicators.push({ indicator: ind, meta, instance }); } else { @@ -844,21 +895,32 @@ export function drawIndicatorsOnChart() { window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight); + console.log(`[Indicators] ========== Rendering Indicators ==========`); + console.log(`[Indicators] Input candles: ${candles.length} | Panel count: ${totalPanes}`); + overlayIndicators.forEach(({ indicator, meta, instance }) => { + console.log(`[Indicators] Processing overlay: ${indicator.name}`); + console.log(`[Indicators] Before renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap); + console.log(`[Indicators] After renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); }); paneIndicators.forEach(({ indicator, meta, instance }, idx) => { const paneIndex = nextPaneIndex++; indicatorPanes.set(indicator.id, paneIndex); + console.log(`[Indicators] Processing pane: ${indicator.name} (pane ${paneIndex})`); + console.log(`[Indicators] Before renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap); + console.log(`[Indicators] After renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); const pane = window.dashboard.chart.panes()[paneIndex]; if (pane) { pane.setHeight(paneHeight); } }); + + console.log(`[Indicators] ========== drawIndicatorsOnChart END ==========`); } function resetIndicator(id) { diff --git a/src/api/dashboard/static/js/ui/signals-calculator.js b/src/api/dashboard/static/js/ui/signals-calculator.js new file mode 100644 index 0000000..75c1589 --- /dev/null +++ b/src/api/dashboard/static/js/ui/signals-calculator.js @@ -0,0 +1,499 @@ +// Signal Calculator for Technical Indicators +// Calculates buy/hold/sell signals for all active indicators + +import { IndicatorRegistry as IR } from '../indicators/index.js'; + +const SIGNAL_TYPES = { + BUY: 'buy', + SELL: 'sell', + HOLD: 'hold' +}; + +const SIGNAL_COLORS = { + buy: '#26a69a', + hold: '#787b86', + sell: '#ef5350' +}; + +/** + * Calculate signal for a single indicator + * @param {Object} indicator - Indicator object with type, params, etc. + * @param {Array} candles - Recent candle data + * @param {Object} indicatorValues - Computed indicator values for last candle + * @returns {Object} Signal object with type, strength, value, reasoning + */ +function calculateIndicatorSignal(indicator, candles, indicatorValues) { + const lastCandle = candles[candles.length - 1]; + const prevCandle = candles[candles.length - 2]; + + console.log('[calculateIndicatorSignal] Type:', indicator.type, 'Values:', indicatorValues, 'LastCandle:', lastCandle?.close); + + if (!lastCandle) { + return { type: SIGNAL_TYPES.HOLD, strength: 0, value: null, reasoning: 'No data' }; + } + + switch (indicator.type) { + case 'sma': + case 'ema': + case 'ma': + return calculateMASignal(indicator, lastCandle, prevCandle, indicatorValues); + case 'rsi': + return calculateRSISignal(indicator, lastCandle, indicatorValues); + case 'macd': + return calculateMACDSignal(indicator, lastCandle, prevCandle, indicatorValues); + case 'stoch': + return calculateStochSignal(indicator, lastCandle, prevCandle, indicatorValues); + case 'bb': + return calculateBollingerBandsSignal(indicator, lastCandle, indicatorValues); + case 'sma': + case 'ema': + return calculateMASignal(indicator, lastCandle, prevCandle, indicatorValues); + case 'atr': + return calculateATRSignal(indicator, indicatorValues); + case 'hts': + return calculateHTSSignal(indicator, lastCandle, prevCandle, indicatorValues); + default: + return { type: SIGNAL_TYPES.HOLD, strength: 0, value: null, reasoning: 'Unknown indicator type' }; + } +} + +/** + * RSI Signal Calculation + */ +function calculateRSISignal(indicator, lastCandle, indicatorValues) { + const rsi = indicatorValues?.rsi; + const overbought = indicator.params.overbought || 70; + const oversold = indicator.params.oversold || 30; + + if (rsi === null || rsi === undefined) { + return { type: SIGNAL_TYPES.HOLD, strength: 0, value: null, reasoning: 'No RSI data' }; + } + + let signal, strength, reasoning; + + if (rsi <= oversold) { + signal = SIGNAL_TYPES.BUY; + strength = 80 + Math.min((oversold - rsi) * 0.5, 20); + reasoning = `RSI ${rsi.toFixed(1)} is extremely oversold (${oversold}), suggesting the price may be approaching a bottom and potential rebound`; + } else if (rsi >= overbought) { + signal = SIGNAL_TYPES.SELL; + strength = 80 + Math.min((rsi - overbought) * 0.5, 20); + reasoning = `RSI ${rsi.toFixed(1)} is overbought (${overbought}), indicating the asset may be overvalued due for a correction`; + } else if (rsi < 50) { + signal = SIGNAL_TYPES.HOLD; + strength = 30; + reasoning = `RSI ${rsi.toFixed(1)} shows bearish momentum below 50, sellers currently in control`; + } else if (rsi > 50) { + signal = SIGNAL_TYPES.HOLD; + strength = 30; + reasoning = `RSI ${rsi.toFixed(1)} shows bullish momentum above 50, buyers currently in control`; + } else { + signal = SIGNAL_TYPES.HOLD; + strength = 0; + reasoning = 'RSI at 50 indicates neutral market conditions with balanced buying/selling pressure'; + } + + return { type: signal, strength, value: rsi, reasoning }; +} + +/** + * MACD Signal Calculation + */ +function calculateMACDSignal(indicator, lastCandle, prevCandle, values) { + const macd = values?.macd; + const signalLine = values?.signal; + const histogram = values?.histogram; + + if (macd === null || signalLine === null) { + return { type: SIGNAL_TYPES.HOLD, strength: 0, value: null, reasoning: 'No MACD data' }; + } + + let macdSignal, strength, reasoning; + + if (macd > signalLine && histogram > 0) { + macdSignal = SIGNAL_TYPES.BUY; + strength = 75 + Math.min((macd - signalLine) * 10, 25); + reasoning = `MACD (${macd.toFixed(2)}) is above signal line (${signalLine.toFixed(2)}) with positive histogram (${histogram.toFixed(2)}), indicating strong bullish momentum`; + } else if (macd < signalLine && histogram < 0) { + macdSignal = SIGNAL_TYPES.SELL; + strength = 75 + Math.min((signalLine - macd) * 10, 25); + reasoning = `MACD (${macd.toFixed(2)}) is below signal line (${signalLine.toFixed(2)}) with negative histogram (${histogram.toFixed(2)}), indicating strong bearish momentum`; + } else if (macd > 0 && signalLine < 0) { + macdSignal = SIGNAL_TYPES.BUY; + strength = 85; + reasoning = `Bullish crossover: MACD (${macd.toFixed(2)}) crossed above zero while signal (${signalLine.toFixed(2)}) is still negative, potential trend reversal upward`; + } else if (macd < 0 && signalLine > 0) { + macdSignal = SIGNAL_TYPES.SELL; + strength = 85; + reasoning = `Bearish crossover: MACD (${macd.toFixed(2)}) crossed below zero while signal (${signalLine.toFixed(2)}) is still positive, potential trend reversal downward`; + } else { + macdSignal = SIGNAL_TYPES.HOLD; + strength = 30; + reasoning = `MACD (${macd.toFixed(2)}) and signal (${signalLine.toFixed(2)}) are close together with no clear directional bias`; + } + + return { type: macdSignal, strength, value: histogram, reasoning }; +} + +/** + * Stochastic Signal Calculation + */ +function calculateStochSignal(indicator, lastCandle, prevCandle, values) { + const k = values?.k; + const d = values?.d; + + if (k === null || d === null) { + return { type: SIGNAL_TYPES.HOLD, strength: 0, value: null, reasoning: 'No Stochastic data' }; + } + + const prevK = prevCandle?.values?.k; + + let signalType, strength, reasoning; + + if (k < 20 && prevK < 20 && k > d) { + signalType = SIGNAL_TYPES.BUY; + strength = 80; + reasoning = `Strong buy signal: %K (${k.toFixed(1)}) crossed above %D (${d.toFixed(1)}) in oversold territory (<20), likely upward reversal`; + } else if (k > 80 && prevK > 80 && k < d) { + signalType = SIGNAL_TYPES.SELL; + strength = 80; + reasoning = `Strong sell signal: %K (${k.toFixed(1)}) crossed below %D (${d.toFixed(1)}) in overbought territory (>80), likely downward reversal`; + } else if (k < 20) { + signalType = SIGNAL_TYPES.BUY; + strength = 60; + reasoning = `%K (${k.toFixed(1)}) is in oversold zone (<20), price may be near a bottom and ready to bounce`; + } else if (k > 80) { + signalType = SIGNAL_TYPES.SELL; + strength = 60; + reasoning = `%K (${k.toFixed(1)}) is in overbought zone (>80), price may be overextended and ready for correction`; + } else { + signalType = SIGNAL_TYPES.HOLD; + strength = 30; + reasoning = `Stochastic (${k.toFixed(1)}) is in neutral range (20-80) with no clear directional signal`; + } + + return { type: signalType, strength, value: k, reasoning }; +} + +/** + * Bollinger Bands Signal Calculation + */ +function calculateBollingerBandsSignal(indicator, lastCandle, values) { + const upper = values?.upper; + const lower = values?.lower; + const middle = values?.middle; + + if (!upper || !lower || !middle) { + return { type: SIGNAL_TYPES.HOLD, strength: 0, value: null, reasoning: 'No BB data' }; + } + + const price = lastCandle.close; + const range = upper - lower; + const position = (price - lower) / range; + + let signalType, strength, reasoning; + + if (position <= 0.1 || price <= lower) { + signalType = SIGNAL_TYPES.BUY; + strength = Math.floor(70 + (0.1 - position) * 300); + reasoning = `Price (${price.toFixed(2)}) is at or touching the lower Bollinger Band (${lower.toFixed(2)}), potential oversold bounce opportunity`; + } else if (position >= 0.9 || price >= upper) { + signalType = SIGNAL_TYPES.SELL; + strength = Math.floor(70 + (position - 0.9) * 300); + reasoning = `Price (${price.toFixed(2)}) is at or touching the upper Bollinger Band (${upper.toFixed(2)}), potential overextended sell signal`; + } else if (middle && price > middle) { + signalType = SIGNAL_TYPES.HOLD; + strength = 40; + reasoning = `Price (${price.toFixed(2)}) is above the middle band (${middle.toFixed(2)}), generally bullish but not extreme`; + } else { + signalType = SIGNAL_TYPES.HOLD; + strength = 40; + reasoning = `Price (${price.toFixed(2)}) is within normal Bollinger Band range, no extreme signals`; + } + + strength = Math.min(Math.max(strength, 0), 100); + + return { type: signalType, strength, value: position * 100, reasoning }; +} + +/** + * Moving Average Signal Calculation (SMA/EMA) + */ +function calculateMASignal(indicator, lastCandle, prevCandle, values) { + const close = lastCandle.close; + const ma = values?.ma; + + console.log('[calculateMASignal] values:', values, 'ma:', ma, 'close:', close); + + if (!ma && ma !== 0) { + console.log('[calculateMASignal] No valid MA value'); + return { type: SIGNAL_TYPES.HOLD, strength: 0, value: null, reasoning: 'No MA data' }; + } + + const prevClose = prevCandle?.close; + const period = indicator.params?.period || 44; + const maLabel = indicator.name || `MA (${period})`; + + let signalType, strength, reasoning; + + if (close > ma * 1.02) { + signalType = SIGNAL_TYPES.BUY; + strength = Math.min(60 + ((close - ma) / ma) * 500, 100); + reasoning = `Price (${close.toFixed(2)}) is strongly above ${maLabel} (${ma.toFixed(2)}), bullish trend`; + } else if (close < ma * 0.98) { + signalType = SIGNAL_TYPES.SELL; + strength = Math.min(60 + ((ma - close) / ma) * 500, 100); + reasoning = `Price (${close.toFixed(2)}) is strongly below ${maLabel} (${ma.toFixed(2)}), bearish trend`; + } else { + signalType = SIGNAL_TYPES.HOLD; + strength = 30; + reasoning = `Price (${close.toFixed(2)}) is near ${maLabel} (${ma.toFixed(2)}), sideways/consolidating`; + } + + console.log('[calculateMASignal] Result:', signalType, strength); + return { type: signalType, strength, value: close, reasoning }; +} + +/** + * ATR Signal Calculation + */ +function calculateATRSignal(indicator, values) { + const atr = values?.atr; + + if (!atr) { + return { type: SIGNAL_TYPES.HOLD, strength: 0, value: null, reasoning: 'No ATR data' }; + } + + const period = indicator.params?.period || 14; + + // ATR is volatility indicator, used with other signals + let signalType, strength, reasoning; + + if (atr > 0) { + signalType = SIGNAL_TYPES.HOLD; + strength = Math.min(atr * 10, 100); + + if (atr > 100) { + reasoning = `High volatility detected (ATR: ${atr.toFixed(2)}), expect larger moves and wider stop-losses`; + } else if (atr > 50) { + reasoning = `Moderate volatility (ATR: ${atr.toFixed(2)}), normal market conditions`; + } else { + reasoning = `Low volatility (ATR: ${atr.toFixed(2)}), market may be consolidating`; + } + } else { + signalType = SIGNAL_TYPES.HOLD; + strength = 0; + reasoning = 'No volatility data available'; + } + + return { type: signalType, strength, value: atr, reasoning }; +} + +/** + * HTS (Hull Trend System) Signal Calculation + */ +function calculateHTSSignal(indicator, lastCandle, prevCandle, values) { + const fastHigh = values?.fastHigh; + const fastLow = values?.fastLow; + const slowHigh = values?.slowHigh; + const slowLow = values?.slowLow; + + if (!fastHigh || !slowLow) { + return { type: SIGNAL_TYPES.HOLD, strength: 0, value: null, reasoning: 'No HTS data' }; + } + + const price = lastCandle.close; + const midpointLow = (slowHigh[slowHigh.length - 1] + slowLow[slowLow.length - 1]) / 2; + const midpointHigh = (fastHigh[fastHigh.length - 1] + fastLow[fastLow.length - 1]) / 2; + + let signalType, strength, reasoning; + + if (price > midpointHigh) { + signalType = SIGNAL_TYPES.BUY; + strength = Math.min(50 + ((price - midpointHigh) / midpointHigh) * 200, 100); + reasoning = `Price (${price.toFixed(2)}) is above the fast channel (${midpointHigh.toFixed(2)}), strong bullish trend in place`; + } else if (price < midpointLow) { + signalType = SIGNAL_TYPES.SELL; + strength = Math.min(50 + ((midpointLow - price) / midpointLow) * 200, 100); + reasoning = `Price (${price.toFixed(2)}) is below the slow channel (${midpointLow.toFixed(2)}), strong bearish trend in place`; + } else if (midpointHigh > midpointLow) { + signalType = SIGNAL_TYPES.HOLD; + strength = 40; + reasoning = `Fast and slow channels are wide apart (${midpointHigh.toFixed(2)} vs ${midpointLow.toFixed(2)}), trend is established but price is in neutral zone`; + } else { + signalType = SIGNAL_TYPES.HOLD; + strength = 30; + reasoning = `Channels are close together, no clear directional trend yet`; + } + + return { type: signalType, strength, value: price, reasoning }; +} + +/** + * 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 []; + } + + console.log('[Signals] Calculating for', activeIndicators.length, 'indicators with', candles.length, 'candles'); + const signals = []; + + for (const indicator of activeIndicators) { + const IndicatorClass = IR?.[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; + + console.log(`[Signals] ${indicator.name}: indicator.cachedResults length = ${results?.length || 0}`); + + if (!results || !meta || results.length !== candles.length) { + console.log(`[Signals] ${indicator.name}: Results mismatch or missing - recalculating`); + console.log(`[Signals] ${indicator.name}: candles.length=${candles.length}, results.length=${results?.length || 0}`); + const instance = new IndicatorClass(indicator); + meta = instance.getMetadata(); + results = instance.calculate(candles); + console.log(`[Signals] ${indicator.name}: New results length = ${results?.length || 0}`); + indicator.cachedResults = results; + indicator.cachedMeta = meta; + } + + console.log('[Signals]', indicator.type, '- Results length:', results?.length, 'Last result:', results?.[results.length - 1]); + + if (!results || results.length === 0) { + console.log('[Signals] No results for indicator:', indicator.type); + continue; + } + + const lastResult = results[results.length - 1]; + if (lastResult === null || lastResult === undefined) { + console.log('[Signals] No valid last result for indicator:', indicator.type); + continue; + } + + let values; + if (typeof lastResult === 'object' && lastResult !== null && !Array.isArray(lastResult)) { + values = lastResult; + } else if (typeof lastResult === 'number') { + values = { ma: lastResult }; + } else { + console.log('[Signals] Unexpected result type for', indicator.type, ':', typeof lastResult, lastResult); + continue; + } + + if (indicator.type === 'sma') { + console.log('[Signals] SMA result:', lastResult, 'values:', values); + } + + const signal = calculateIndicatorSignal(indicator, candles, values); + + const label = indicator.type?.toUpperCase(); + const params = indicator.params && typeof indicator.params === 'object' + ? Object.entries(indicator.params) + .filter(([k, v]) => !k.startsWith('_') && v !== undefined && v !== null) + .map(([k, v]) => `${k}=${v}`) + .join(', ') + : null; + + signals.push({ + id: indicator.id, + name: meta?.name || indicator.type, + label: label, + params: params || null, + type: indicator.type, + signal: signal.type, + strength: Math.round(signal.strength), + value: signal.value, + reasoning: signal.reasoning, + color: SIGNAL_COLORS[signal.type] +}); + } + + console.log('[Signals] ========== calculateAllIndicatorSignals END =========='); + console.log('[Signals] Total signals calculated:', signals.length); + return signals; +} + +/** + * 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: SIGNAL_TYPES.HOLD, + strength: 0, + reasoning: 'No active indicators', + buyCount: 0, + sellCount: 0, + holdCount: 0 + }; + } + + const buySignals = signals.filter(s => s.signal === SIGNAL_TYPES.BUY); + const sellSignals = signals.filter(s => s.signal === SIGNAL_TYPES.SELL); + const holdSignals = signals.filter(s => s.signal === SIGNAL_TYPES.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 = SIGNAL_TYPES.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 = SIGNAL_TYPES.SELL; + const avgSellStrength = sellWeight / sellCount; + strength = Math.round(avgSellStrength * (sellCount / total)); + reasoning = `${sellCount} sell signals, ${buyCount} buy, ${holdCount} hold`; + } else { + summarySignal = SIGNAL_TYPES.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: SIGNAL_COLORS[summarySignal] + }; + + console.log('[calculateSummarySignal] Result:', result); + return result; +} + +export { SIGNAL_TYPES, SIGNAL_COLORS }; \ No newline at end of file