diff --git a/api/ml_crypto_endpoints.py b/api/ml_crypto_endpoints.py index 310ff33f..a86c1d2a 100644 --- a/api/ml_crypto_endpoints.py +++ b/api/ml_crypto_endpoints.py @@ -108,7 +108,7 @@ async def get_crypto_regime( 'data_as_of': result.get('data_as_of') } - response_data.update({key: result.get(key) for key in ("nature", "availability", "probability_kind", "hmm_state", "hmm_state_features", "economic_mapping_verified", "rule_diagnostic")}) + response_data.update({key: result.get(key) for key in ("nature", "availability", "probability_kind", "hmm_state", "hmm_state_features", "economic_mapping_verified", "rule_diagnostic", "hmm_availability", "hmm_unavailability_reason")}) return success_response(response_data) @@ -168,11 +168,12 @@ async def get_crypto_regime_history( # Load symbol-specific HMM model for fallback model_file = f"{symbol.lower()}_regime_hmm.pkl" - if not detector.load_model(model_file): - return error_response("HMM history unavailable: explicit administrator training is required", code=503) - - features_scaled = detector.scaler.transform(features_df[detector.feature_columns]) - hmm_labels = detector.hmm_model.predict(features_scaled) + hmm_available = detector.load_model(model_file) + if hmm_available: + features_scaled = detector.scaler.transform(features_df[detector.feature_columns]) + hmm_labels = detector.hmm_model.predict(features_scaled) + else: + hmm_labels = None # Pre-calculate rolling minimum drawdown for Expansion detection (performance optimization) features_df['lookback_180d_min_dd'] = features_df['drawdown_from_peak'].rolling( @@ -203,15 +204,20 @@ async def get_crypto_regime_history( if rule_result: regime_names.append(rule_result['regime_name']) regime_ids.append(rule_result['regime_id']) - else: - # Fallback to HMM + elif hmm_available: + # Fallback to an actual HMM state, never an economic label. hmm_label = int(hmm_labels[i]) regime_names.append(f"State {chr(65+hmm_label)}") regime_ids.append(4 + hmm_label) + else: + regime_names.append('Unknown') + regime_ids.append(8) # Smooth regime sequence to remove short-lived transitions (<7 days) - regime_ids = smooth_regime_sequence(regime_ids, min_duration=7) - label_mapping = {**dict(enumerate(REGIME_NAMES)), **{4+i: f"State {chr(65+i)}" for i in range(detector.num_regimes)}} + # Without a model, preserve every unknown interval instead of smoothing it away. + if hmm_available: + regime_ids = smooth_regime_sequence(regime_ids, min_duration=7) + label_mapping = {**dict(enumerate(REGIME_NAMES)), **({4+i: f"State {chr(65+i)}" for i in range(detector.num_regimes)} if hmm_available else {8: 'Unknown'})} regime_names = [label_mapping[rid] for rid in regime_ids] # Format response @@ -225,8 +231,12 @@ async def get_crypto_regime_history( 'regime_id_mapping': label_mapping, 'economic_mapping_verified': False, 'id_encoding': 'Rule diagnostics 0-3; unmapped HMM states 4-7', - 'events': get_btc_events(features_df.index.min(), features_df.index.max()), - 'note': 'Hybrid detection (rule-based + HMM fallback) with 7-day minimum duration smoothing.' + 'events': get_btc_events(features_df.index.min(), features_df.index.max()) if symbol == 'BTC' else [], + 'hmm_available': hmm_available, + 'unknown_days': regime_names.count('Unknown'), + 'rule_classified_days': sum(rid < 4 for rid in regime_ids), + 'note': ('Hybrid diagnostics with 7-day retrospective smoothing.' if hmm_available else + 'Partial rule-based history; unmatched periods remain Unknown. No compatible HMM artifact and no state probabilities.'), } # Store in cache @@ -234,7 +244,8 @@ async def get_crypto_regime_history( logger.debug(f"[Cache STORE] Cached regime history for {cache_key}") response_data["retrospective"] = True - response_data["history_limitation"] = "Full-sequence HMM and smoothing use later observations; not real-time decision evidence" + response_data["history_limitation"] = ("Full-sequence HMM and smoothing use later observations; not real-time decision evidence" if hmm_available else + "Rules reconstructed on the requested historical window; unknown intervals retained. Not point-in-time decision evidence.") return success_response(response_data) except Exception as e: diff --git a/services/ml/models/btc_regime_detector.py b/services/ml/models/btc_regime_detector.py index b172847e..70186dd5 100644 --- a/services/ml/models/btc_regime_detector.py +++ b/services/ml/models/btc_regime_detector.py @@ -318,12 +318,37 @@ async def predict_regime( if len(features_df) == 0: raise ValueError("No features available for prediction") + # The economic rules do not depend on a fitted HMM artifact. + rule_based_result = self._detect_regime_rule_based(features_df) + # Load symbol-specific HMM model (BTC and ETH have different distributions) model_file = f"{symbol.lower()}_regime_hmm.pkl" if self.hmm_model is None or getattr(self, "_loaded_path", None) != self.models_dir / model_file: model_loaded = self.load_model(model_file) if not model_loaded: - raise ValueError(f"No compatible HMM artifact for {symbol}; explicit administrator training is required") + reason = f"No compatible HMM artifact for {symbol}; no latent-state probabilities were computed." + rule = rule_based_result if rule_based_result and rule_based_result['rule_priority'] >= 0.85 else None + return { + 'predicted_regime': rule['regime_id'] if rule else None, + 'regime_name': rule['regime_name'] if rule else 'Unknown', + 'confidence': None, + 'regime_info': self.regime_descriptions[rule['regime_id']] if rule else {'description': 'No economic rule matched'}, + 'detection_method': 'rule_based' if rule else 'unavailable', + 'rule_reason': (rule['reason'] + '. ' if rule else 'No economic rule matched the latest observations. ') + reason, + 'regime_probabilities': {}, + 'prediction_date': datetime.now().isoformat(), + 'data_as_of': features_df.index[-1].isoformat(), + 'model_metadata': {'trained_at': None, 'features_used': 0, 'hmm_available': False}, + 'nature': 'diagnostic', + 'availability': 'Partial' if rule else 'Unavailable', + 'probability_kind': 'unavailable', + 'hmm_state': None, + 'hmm_state_features': {}, + 'hmm_availability': 'Unavailable', + 'hmm_unavailability_reason': reason, + 'economic_mapping_verified': False, + 'rule_diagnostic': {k: v for k, v in (rule or {}).items() if k != 'rule_priority'}, + } # Normalize features features_scaled = self.scaler.transform(features_df[self.feature_columns]) @@ -361,9 +386,6 @@ async def predict_regime( } } - # HYBRID SYSTEM: Try rule-based detection first - rule_based_result = self._detect_regime_rule_based(features_df) - # Fuse predictions fused = self._fuse_predictions(rule_based_result, hmm_result) diff --git a/static/market-regimes.html b/static/market-regimes.html index 4fa96c66..c8f13591 100644 --- a/static/market-regimes.html +++ b/static/market-regimes.html @@ -391,7 +391,7 @@
Rule Trigger
Expansion - Recovery +30%/mo + Recovery +15%/30d after deep drawdown
@@ -464,6 +464,7 @@

Historical Regime Timeline

+

@@ -471,7 +472,7 @@

Historical Regime Timeline

About Ethereum Detection: @@ -531,7 +532,7 @@

Historical Regime Timeline

document.getElementById('eth-current-regime-confidence').textContent = data.confidence == null ? 'Unavailable — rule diagnostic' : confidencePercent + '% · state posterior'; const methodBadge = document.getElementById('eth-current-regime-method'); - methodBadge.textContent = detectionMethod === 'rule_based' ? 'Rule-Based' : 'HMM'; + methodBadge.textContent = detectionMethod === 'rule_based' ? 'Rule-Based diagnostic' : detectionMethod === 'hmm' ? 'HMM state' : 'Unavailable'; methodBadge.className = 'detection-method ' + detectionMethod; const reasonCard = document.getElementById('eth-regime-reason-card'); @@ -550,9 +551,15 @@

Historical Regime Timeline

} } else { const errorText = await response.text(); - debugLogger.error('ETH regime API error:', response.status, errorText); - document.getElementById('eth-current-regime-name').textContent = 'API Error'; + let reason = errorText || `ETH regime API returned HTTP ${response.status}`; + try { const body = JSON.parse(errorText); reason = body.error || body.detail || body.message || reason; } catch { /* Keep the actual text response. */ } + if (typeof reason !== 'string') reason = JSON.stringify(reason); + debugLogger.error(`ETH regime API error (${response.status}): ${reason}`); + document.getElementById('eth-current-regime-name').textContent = 'Unavailable'; document.getElementById('eth-current-regime-confidence').textContent = '--'; + document.getElementById('eth-current-regime-method').textContent = 'Unavailable'; + document.getElementById('eth-current-regime-reason').textContent = reason; + document.getElementById('eth-regime-reason-card').style.display = 'block'; } } catch (error) { debugLogger.error('Failed to load ETH regime:', error); @@ -605,7 +612,7 @@

Historical Regime Timeline

btcEl.textContent = btc.current_regime; btcEl.className = 'regime-chip ' + getRegimeClass(btc.current_regime); document.getElementById('comparison-btc-confidence').textContent = (btc.confidence == null ? 'Unavailable' : (btc.confidence == null ? 'Unavailable' : (btc.confidence * 100).toFixed(1)) + '% · state posterior'); - document.getElementById('comparison-btc-method').textContent = btc.detection_method === 'rule_based' ? 'Rule-Based' : 'HMM'; + document.getElementById('comparison-btc-method').textContent = btc.detection_method === 'rule_based' ? 'Rule-Based diagnostic' : btc.detection_method === 'hmm' ? 'HMM state' : 'Unavailable'; } } @@ -616,7 +623,7 @@

Historical Regime Timeline

ethEl.textContent = eth.current_regime; ethEl.className = 'regime-chip ' + getRegimeClass(eth.current_regime); document.getElementById('comparison-eth-confidence').textContent = (eth.confidence == null ? 'Unavailable' : (eth.confidence == null ? 'Unavailable' : (eth.confidence * 100).toFixed(1)) + '% · state posterior'); - document.getElementById('comparison-eth-method').textContent = eth.detection_method === 'rule_based' ? 'Rule-Based' : 'HMM'; + document.getElementById('comparison-eth-method').textContent = eth.detection_method === 'rule_based' ? 'Rule-Based diagnostic' : eth.detection_method === 'hmm' ? 'HMM state' : 'Unavailable'; } } diff --git a/static/modules/eth-regime-chart.js b/static/modules/eth-regime-chart.js index ba700992..cb24068b 100644 --- a/static/modules/eth-regime-chart.js +++ b/static/modules/eth-regime-chart.js @@ -80,12 +80,21 @@ async function loadETHRegimeData(lookbackDays) { // Show loading state showLoadingState(); + const error = document.getElementById('eth-regime-error-message'); + if (error) error.style.display = 'none'; + const note = document.getElementById('eth-regime-history-note'); + if (note) note.textContent = ''; + // Fetch historical timeline const historyResponse = await fetch(`/api/ml/crypto/regime-history?symbol=ETH&lookback_days=${lookbackDays}`); const historyResult = await historyResponse.json(); - if (!historyResult.ok) { - throw new Error(historyResult.error || 'Failed to fetch regime history'); + if (!historyResponse.ok || !historyResult.ok) { + throw new Error(historyResult.error || historyResult.detail || `ETH regime history API returned HTTP ${historyResponse.status}`); + } + if (note) { + note.textContent = [historyResult.data.note, historyResult.data.history_limitation, + `${historyResult.data.dates?.length || 0} observed days; ${historyResult.data.unknown_days || 0} Unknown days.`].filter(Boolean).join(' '); } // Create timeline chart @@ -286,6 +295,10 @@ function hideLoadingState() { */ function showErrorState(errorMessage) { hideLoadingState(); + if (ethRegimeChart) { + ethRegimeChart.destroy(); + ethRegimeChart = null; + } const errorMsg = document.getElementById('eth-regime-error-message'); if (errorMsg) { @@ -300,5 +313,5 @@ function showErrorState(errorMessage) { export function refreshETHRegimeChart() { const activeButton = document.querySelector('.eth-regime-timeframe-selector button.active'); const days = activeButton ? parseInt(activeButton.dataset.days) : ETH_REGIME_CONFIG.defaultTimeframe; - loadETHRegimeData(days); + return loadETHRegimeData(days); } diff --git a/static/tests/eth-regime-chart.test.js b/static/tests/eth-regime-chart.test.js new file mode 100644 index 00000000..24b44909 --- /dev/null +++ b/static/tests/eth-regime-chart.test.js @@ -0,0 +1,32 @@ +import {jest, test, beforeEach, expect} from '@jest/globals'; +import {initializeETHRegimeChart, refreshETHRegimeChart} from '../modules/eth-regime-chart.js'; + +const history = {dates:['2026-09-29','2026-09-30','2026-10-01'], prices:[100,90,110], + regimes:['Bear Market','Unknown','Bull Market'], unknown_days:1, + note:'Partial rule-based history. No compatible HMM artifact.', history_limitation:'Not point-in-time decision evidence.'}; +const ok = () => ({ok:true,status:200,json:async()=>({ok:true,data:history})}); +beforeEach(()=>{ + document.body.innerHTML='

'; + jest.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue({}); + global.Chart = jest.fn(function(){this.destroy=jest.fn();}); + global.fetch = jest.fn(); +}); +test('partial history renders actual Unknown periods and the missing-HMM limitation',async()=>{ + fetch.mockResolvedValue(ok());await initializeETHRegimeChart(); + const [,config] = Chart.mock.calls.at(-1); + expect(config.data.datasets[0].data).toEqual(history.prices); + expect(document.getElementById('eth-regime-history-note').textContent).toContain('No compatible HMM artifact'); + expect(document.getElementById('eth-regime-history-note').textContent).toContain('1 Unknown days'); + expect(config.options.plugins.tooltip.callbacks.label({parsed:{y:90},dataIndex:1})).toContain('Regime: Unknown'); + expect(document.getElementById('eth-regime-error-message').style.display).toBe('none'); +}); +test('API reason is visible, stale chart is destroyed, and a successful retry clears the error',async()=>{ + fetch.mockResolvedValueOnce(ok()).mockResolvedValueOnce({ok:false,status:503,json:async()=>({ok:false,error:'Actual provider unavailable'})}).mockResolvedValueOnce(ok()); + await initializeETHRegimeChart();const chart=Chart.mock.instances.at(-1); + await refreshETHRegimeChart(); + expect(chart.destroy).toHaveBeenCalled(); + expect(document.getElementById('eth-regime-error-message').textContent).toContain('Actual provider unavailable'); + await refreshETHRegimeChart(); + expect(document.getElementById('eth-regime-error-message').style.display).toBe('none'); + expect(document.getElementById('eth-regime-chart-container').classList.contains('loading')).toBe(false); +}); diff --git a/tests/unit/test_crypto_regime_without_hmm.py b/tests/unit/test_crypto_regime_without_hmm.py new file mode 100644 index 00000000..b472505d --- /dev/null +++ b/tests/unit/test_crypto_regime_without_hmm.py @@ -0,0 +1,79 @@ +"""A missing HMM must neither suppress economic rules nor invent a regime.""" +import json +from unittest.mock import AsyncMock + +import pandas as pd +import pytest + +from services.ml.models.btc_regime_detector import BTCRegimeDetector + + +@pytest.mark.asyncio +@pytest.mark.parametrize('trend,expected', [(0.08, 'Bull Market'), (0.01, 'Unknown')]) +async def test_missing_hmm_retains_real_rules_without_training(monkeypatch, trend, expected): + detector = BTCRegimeDetector() + features = pd.DataFrame({'drawdown_from_peak': [-0.1], 'days_since_peak': [30], + 'trend_30d': [trend], 'market_volatility': [0.3]}, + index=pd.to_datetime(['2026-10-01'])) + monkeypatch.setattr(detector, 'prepare_regime_features', AsyncMock(return_value=features)) + monkeypatch.setattr(detector, 'load_model', lambda name: False) + training = AsyncMock(side_effect=AssertionError('Inference must not train')) + monkeypatch.setattr(detector, 'train_hmm', training) + result = await detector.predict_regime('ETH', 365) + assert result['regime_name'] == expected + assert result['confidence'] is None and result['regime_probabilities'] == {} + assert result['hmm_state'] is None and result['hmm_state_features'] == {} + assert result['hmm_availability'] == 'Unavailable' + assert result['data_as_of'] == '2026-10-01T00:00:00' + assert 'No compatible HMM artifact for ETH' in result['rule_reason'] + assert result['availability'] == ('Partial' if expected != 'Unknown' else 'Unavailable') + training.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_partial_eth_history_preserves_unknown_intervals_and_no_btc_events(monkeypatch): + import api.ml_crypto_endpoints as module + index = pd.date_range('2024-03-11', periods=5) + features = pd.DataFrame({'drawdown_from_peak': [-0.5, 0, 0, 0, -0.5], + 'days_since_peak': 30, 'trend_30d': -0.2, 'market_volatility': 0.3}, index=index) + history = [(int(d.timestamp()), 100 + i) for i, d in enumerate(index)] + class Detector: + async def prepare_regime_features(self, **kwargs): + return features.copy() + def load_model(self, name): + assert name == 'eth_regime_hmm.pkl' + return False + monkeypatch.setattr(module, 'BTCRegimeDetector', Detector) + monkeypatch.setattr(module.price_history, 'get_cached_history', lambda *args, **kwargs: history) + monkeypatch.setattr(module, '_regime_history_cache', {}) + response = await module.get_crypto_regime_history(symbol='ETH', lookback_days=365) + data = json.loads(response.body)['data'] + assert response.status_code == 200 + assert data['regimes'] == ['Bear Market', 'Unknown', 'Unknown', 'Unknown', 'Bear Market'] + assert data['regime_ids'] == [0, 8, 8, 8, 0] + assert data['unknown_days'] == 3 and data['rule_classified_days'] == 2 + assert data['regime_id_mapping']['8'] == 'Unknown' + assert data['hmm_available'] is False and data['events'] == [] + assert 'No compatible HMM artifact' in data['note'] + assert data['retrospective'] is True + # A cached response must retain the limitation and unknown intervals. + repeated = await module.get_crypto_regime_history(symbol='ETH', lookback_days=365) + assert json.loads(repeated.body)['data'] == data + + +@pytest.mark.asyncio +async def test_current_endpoint_exposes_missing_model_metadata(monkeypatch): + import api.ml_crypto_endpoints as module + result = {'regime_name': 'Bull Market', 'confidence': None, 'detection_method': 'rule_based', + 'regime_info': {}, 'prediction_date': '2026-10-02', 'model_metadata': {}, + 'hmm_availability': 'Unavailable', 'hmm_unavailability_reason': 'No compatible ETH artifact'} + class Detector: + async def predict_regime(self, **kwargs): + return result + monkeypatch.setattr(module, 'BTCRegimeDetector', Detector) + response = await module.get_crypto_regime(symbol='ETH', lookback_days=365) + data = json.loads(response.body)['data'] + assert data['current_regime'] == 'Bull Market' + assert data['confidence'] is None + assert data['hmm_availability'] == 'Unavailable' + assert data['hmm_unavailability_reason'] == 'No compatible ETH artifact'