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:
- Separate methods: Economic rules and latent HMM states are reported independently
- - Crypto-Adjusted Thresholds: Bear ≤ -30% (20d+), Bull > -20% (vol <60%), Expansion +30%/mo
+ - Crypto-Adjusted Thresholds: Bear ≤ -30% (20d+), Bull > -20% (vol <60%), Expansion +15%/30d after deep drawdown
- Timeline Chart: Interactive regime visualization with price history
@@ -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'