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