diff --git a/api/ml_crypto_endpoints.py b/api/ml_crypto_endpoints.py index a86c1d2a..64e60b11 100644 --- a/api/ml_crypto_endpoints.py +++ b/api/ml_crypto_endpoints.py @@ -13,6 +13,8 @@ from datetime import datetime import logging import time +import hashlib +import json from api.utils import success_response, error_response from services.ml.models.btc_regime_detector import BTCRegimeDetector @@ -139,22 +141,21 @@ async def get_crypto_regime_history( try: logger.info(f"GET /api/ml/crypto/regime-history - symbol={symbol}, lookback_days={lookback_days}") - # Check cache first + # A refresh may revise today's close without changing the final date. + history = price_history.get_cached_history(symbol, days=lookback_days) + if history is None or len(history) == 0: + return error_response(f"No historical data available for {symbol}", code=404) + history_revision = hashlib.sha256(json.dumps(history, separators=(',', ':')).encode()).hexdigest() cache_key = f"{symbol}_{lookback_days}" if cache_key in _regime_history_cache: cached_data, cache_time = _regime_history_cache[cache_key] - if time.time() - cache_time < _CACHE_TTL: + if time.time() - cache_time < _CACHE_TTL and cached_data.get('source_history_revision') == history_revision: logger.debug(f"[Cache HIT] Returning cached regime history for {cache_key}") return success_response(cached_data) else: logger.debug(f"[Cache EXPIRED] Removing stale cache for {cache_key}") del _regime_history_cache[cache_key] - # Get data - history = price_history.get_cached_history(symbol, days=lookback_days) - if history is None or len(history) == 0: - return error_response(f"No historical data available for {symbol}", code=404) - data = pd.DataFrame(history, columns=['timestamp', 'close']) data['timestamp'] = pd.to_datetime(data['timestamp'], unit='s') data.set_index('timestamp', inplace=True) @@ -228,6 +229,7 @@ async def get_crypto_regime_history( 'regime_ids': regime_ids, 'symbol': symbol, 'lookback_days': lookback_days, + 'source_history_revision': history_revision, 'regime_id_mapping': label_mapping, 'economic_mapping_verified': False, 'id_encoding': 'Rule diagnostics 0-3; unmapped HMM states 4-7', diff --git a/services/ml/models/btc_regime_detector.py b/services/ml/models/btc_regime_detector.py index 70186dd5..68e0e590 100644 --- a/services/ml/models/btc_regime_detector.py +++ b/services/ml/models/btc_regime_detector.py @@ -49,7 +49,7 @@ def __init__(self, num_regimes: int = 4): 0: 'Sustained bear market with significant drawdown (>30%)', 1: 'Market pullback or high volatility period (10-30% drawdown)', 2: 'Stable uptrend with low drawdown and moderate volatility', - 3: 'Strong post-crash recovery at +30%/month or higher' + 3: 'Recovery of at least +15% over 30 days after a deep drawdown' } # HMM model (trained on features) diff --git a/static/market-regimes.html b/static/market-regimes.html index c8f13591..6dbe0e02 100644 --- a/static/market-regimes.html +++ b/static/market-regimes.html @@ -190,7 +190,7 @@

Market Regimes

-- -- - DD ≤ -30%, 20d + Loading... ⟠ Ethereum @@ -199,7 +199,7 @@

Market Regimes

-- -- - DD ≤ -30%, 20d + Loading... @@ -608,6 +608,7 @@

Historical Regime Timeline

if (btcData) { const btc = btcData.data || btcData; if (btc.current_regime) { + document.getElementById('comparison-btc-trigger').textContent = btc.rule_diagnostic?.reason || btc.rule_reason || 'No economic rule matched'; const btcEl = document.getElementById('comparison-btc-regime'); btcEl.textContent = btc.current_regime; btcEl.className = 'regime-chip ' + getRegimeClass(btc.current_regime); @@ -619,6 +620,7 @@

Historical Regime Timeline

if (ethData) { const eth = ethData.data || ethData; if (eth.current_regime) { + document.getElementById('comparison-eth-trigger').textContent = eth.rule_diagnostic?.reason || eth.rule_reason || 'No economic rule matched'; const ethEl = document.getElementById('comparison-eth-regime'); ethEl.textContent = eth.current_regime; ethEl.className = 'regime-chip ' + getRegimeClass(eth.current_regime); diff --git a/static/modules/eth-regime-chart.js b/static/modules/eth-regime-chart.js index cb24068b..e834843b 100644 --- a/static/modules/eth-regime-chart.js +++ b/static/modules/eth-regime-chart.js @@ -94,7 +94,8 @@ async function loadETHRegimeData(lookbackDays) { } 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(' '); + `${historyResult.data.dates?.length || 0} observed days; ${historyResult.data.unknown_days || 0} Unknown days.`, + historyResult.data.dates?.length ? `Latest observation: ${historyResult.data.dates.at(-1)} — ${historyResult.data.regimes?.at(-1) || 'Unknown'}.` : ''].filter(Boolean).join(' '); } // Create timeline chart @@ -151,7 +152,8 @@ function createTimelineChart(historyData) { borderWidth: 3, fill: false, tension: 0.1, - pointRadius: 0, + pointRadius: context => context.dataIndex === dates.length - 1 ? 4 : 0, + pointBackgroundColor: context => ETH_REGIME_CONFIG.regimeColors[regimes[context.dataIndex]]?.border || '#627eea', pointHoverRadius: 5, order: 1 // Draw on top of annotations } @@ -247,8 +249,10 @@ function createRegimeBoxAnnotations(dates, regimes) { // Create box annotation for this regime period annotations[`regime_${startIndex}_${endIndex}`] = { type: 'box', - xMin: dates[startIndex], - xMax: dates[endIndex], + // Daily observations occupy half a day on either side of their timestamp. + // Equal endpoints would make a one-day regime invisible. + xMin: Date.parse(dates[startIndex]) - 12 * 60 * 60 * 1000, + xMax: Date.parse(dates[endIndex]) + 12 * 60 * 60 * 1000, yScaleID: 'y', backgroundColor: regimeConfig.bg, borderColor: 'transparent', diff --git a/static/tests/eth-regime-chart.test.js b/static/tests/eth-regime-chart.test.js index 24b44909..5a7be803 100644 --- a/static/tests/eth-regime-chart.test.js +++ b/static/tests/eth-regime-chart.test.js @@ -30,3 +30,17 @@ test('API reason is visible, stale chart is destroyed, and a successful retry cl expect(document.getElementById('eth-regime-error-message').style.display).toBe('none'); expect(document.getElementById('eth-regime-chart-container').classList.contains('loading')).toBe(false); }); + +test('a one-day Expansion has a nonzero zone and a visible latest-observation marker',async()=>{ + const revised = {...history, regimes:['Bear Market','Bull Market','Expansion']}; + fetch.mockResolvedValue({ok:true,status:200,json:async()=>({ok:true,data:revised})}); + await initializeETHRegimeChart(); + const [,config] = Chart.mock.calls.at(-1); + const box = config.options.plugins.annotation.annotations.regime_2_2; + expect(box.xMax-box.xMin).toBe(24*60*60*1000); + expect(box.backgroundColor).toBe('rgba(59, 130, 246, 0.2)'); + expect(config.data.datasets[0].pointRadius({dataIndex:2})).toBe(4); + expect(config.data.datasets[0].pointBackgroundColor({dataIndex:2})).toBe('#3b82f6'); + expect(config.data.datasets[0].pointRadius({dataIndex:1})).toBe(0); + expect(document.getElementById('eth-regime-history-note').textContent).toContain('Latest observation: 2026-10-01 — Expansion'); +}); diff --git a/tests/unit/test_crypto_regime_without_hmm.py b/tests/unit/test_crypto_regime_without_hmm.py index b472505d..91fa6df0 100644 --- a/tests/unit/test_crypto_regime_without_hmm.py +++ b/tests/unit/test_crypto_regime_without_hmm.py @@ -77,3 +77,36 @@ async def predict_regime(self, **kwargs): assert data['confidence'] is None assert data['hmm_availability'] == 'Unavailable' assert data['hmm_unavailability_reason'] == 'No compatible ETH artifact' + + +@pytest.mark.asyncio +async def test_history_cache_invalidates_when_same_day_close_is_revised(monkeypatch): + import api.ml_crypto_endpoints as module + index = pd.date_range('2026-10-01', periods=2) + history = [(int(d.timestamp()), 100.0) for d in index] + prepare = AsyncMock() + class Detector: + async def prepare_regime_features(self, **kwargs): + await prepare() + return pd.DataFrame({'drawdown_from_peak': [-0.5, -0.4], + 'days_since_peak': 30, 'trend_30d': [0.08, 0.154 if history[-1][1] > 100 else 0.14], + 'market_volatility': 0.3}, index=index) + def load_model(self, name): + 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', {}) + async def get(): + response = await module.get_crypto_regime_history(symbol='ETH', lookback_days=365) + assert response.status_code == 200 + return json.loads(response.body)['data'] + first = await get() + assert first['regimes'][-1] == 'Bull Market' + assert await get() == first + assert prepare.await_count == 1 + history[-1] = (history[-1][0], 101.0) # Same date, changed close. + revised = await get() + assert revised['dates'] == first['dates'] + assert revised['regimes'][-1] == 'Expansion' + assert revised['source_history_revision'] != first['source_history_revision'] + assert prepare.await_count == 2