Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions api/ml_crypto_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -225,16 +231,21 @@ 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
_regime_history_cache[cache_key] = (response_data, time.time())
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:
Expand Down
30 changes: 26 additions & 4 deletions services/ml/models/btc_regime_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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)

Expand Down
21 changes: 14 additions & 7 deletions static/market-regimes.html
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ <h5>Rule Trigger</h5>
</div>
<div class="legend-item">
<span class="regime-chip expansion" style="display: inline-block; padding: 0.25rem 0.75rem; border-radius: var(--radius-sm); font-size: 0.85rem;">Expansion</span>
<small style="display: block; margin-top: 0.25rem; color: var(--theme-text-muted);">Recovery +30%/mo</small>
<small style="display: block; margin-top: 0.25rem; color: var(--theme-text-muted);">Recovery +15%/30d after deep drawdown</small>
</div>
</div>

Expand Down Expand Up @@ -464,14 +464,15 @@ <h4 style="margin: 0;">Historical Regime Timeline</h4>
<canvas id="eth-regime-timeline-chart" style="position: absolute; top: 0; left: 0; height: 100%; width: 100%;" role="img" aria-label="Ethereum regime detection timeline chart"></canvas>
</div>
<div id="eth-regime-error-message" class="error-state" style="display: none;"></div>
<p id="eth-regime-history-note" aria-live="polite" style="color: var(--theme-text-secondary); font-size: 0.875rem;"></p>
</div>

<!-- Info Note -->
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--theme-bg); border-left: 3px solid var(--success); border-radius: var(--radius-sm);">
<strong><svg class="sf-icon" width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor" role="img" aria-label="Info" focusable="false" style="vertical-align:-.15em"><use href="/static/assets/icons/heroicons.svg#information-circle"></use></svg> About Ethereum Detection:</strong>
<ul style="margin: 0.5rem 0 0 1.5rem; color: var(--theme-text-muted); font-size: 0.9rem;">
<li><strong>Separate methods:</strong> Economic rules and latent HMM states are reported independently</li>
<li><strong>Crypto-Adjusted Thresholds:</strong> Bear &le; -30% (20d+), Bull &gt; -20% (vol &lt;60%), Expansion +30%/mo</li>
<li><strong>Crypto-Adjusted Thresholds:</strong> Bear &le; -30% (20d+), Bull &gt; -20% (vol &lt;60%), Expansion +15%/30d after deep drawdown</li>
<li><strong>Timeline Chart:</strong> Interactive regime visualization with price history</li>
</ul>
</div>
Expand Down Expand Up @@ -531,7 +532,7 @@ <h4 style="margin: 0;">Historical Regime Timeline</h4>
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');
Expand All @@ -550,9 +551,15 @@ <h4 style="margin: 0;">Historical Regime Timeline</h4>
}
} 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);
Expand Down Expand Up @@ -605,7 +612,7 @@ <h4 style="margin: 0;">Historical Regime Timeline</h4>
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';
}
}

Expand All @@ -616,7 +623,7 @@ <h4 style="margin: 0;">Historical Regime Timeline</h4>
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';
}
}

Expand Down
19 changes: 16 additions & 3 deletions static/modules/eth-regime-chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
32 changes: 32 additions & 0 deletions static/tests/eth-regime-chart.test.js
Original file line number Diff line number Diff line change
@@ -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='<div class="eth-regime-timeframe-selector"><button class="active" data-days="365">1Y</button></div><div id="eth-regime-chart-container"><canvas id="eth-regime-timeline-chart"></canvas></div><div id="eth-regime-error-message"></div><p id="eth-regime-history-note"></p>';
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);
});
Loading
Loading