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
16 changes: 9 additions & 7 deletions api/ml_crypto_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion services/ml/models/btc_regime_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions static/market-regimes.html
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ <h1>Market Regimes</h1>
</td>
<td style="padding: 0.75rem; border: 1px solid var(--theme-border);" id="comparison-btc-confidence">--</td>
<td style="padding: 0.75rem; border: 1px solid var(--theme-border);" id="comparison-btc-method">--</td>
<td style="padding: 0.75rem; border: 1px solid var(--theme-border);">DD ≤ -30%, 20d</td>
<td id="comparison-btc-trigger" style="padding: 0.75rem; border: 1px solid var(--theme-border);">Loading...</td>
</tr>
<tr>
<td style="padding: 0.75rem; border: 1px solid var(--theme-border); font-weight: 600;">⟠ Ethereum</td>
Expand All @@ -199,7 +199,7 @@ <h1>Market Regimes</h1>
</td>
<td style="padding: 0.75rem; border: 1px solid var(--theme-border);" id="comparison-eth-confidence">--</td>
<td style="padding: 0.75rem; border: 1px solid var(--theme-border);" id="comparison-eth-method">--</td>
<td style="padding: 0.75rem; border: 1px solid var(--theme-border);">DD ≤ -30%, 20d</td>
<td id="comparison-eth-trigger" style="padding: 0.75rem; border: 1px solid var(--theme-border);">Loading...</td>
</tr>
</tbody>
</table>
Expand Down Expand Up @@ -608,6 +608,7 @@ <h4 style="margin: 0;">Historical Regime Timeline</h4>
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);
Expand All @@ -619,6 +620,7 @@ <h4 style="margin: 0;">Historical Regime Timeline</h4>
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);
Expand Down
12 changes: 8 additions & 4 deletions static/modules/eth-regime-chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions static/tests/eth-regime-chart.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
33 changes: 33 additions & 0 deletions tests/unit/test_crypto_regime_without_hmm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading