GoshawkVortexAI commited on
Commit
4494a62
·
verified ·
1 Parent(s): cc01839

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +388 -1071
app.py CHANGED
@@ -1,20 +1,11 @@
1
  """
2
  RCEA Lite v1.0 — Regime-Conditioned Evidence Accumulation
3
- Mathematical trading system with causal HMM regime detection.
4
-
5
- NO supervised ML, NO predictive classifiers.
6
- HMM is used exclusively for probabilistic regime detection (forward filtering only).
7
-
8
- Author: Quantitative Development
9
- License: MIT
10
  """
11
 
12
- import os
13
- import math
14
- import time
15
- import warnings
16
  from datetime import datetime, timezone
17
- from typing import Dict, List, Optional, Tuple
18
 
19
  import numpy as np
20
  import pandas as pd
@@ -24,1133 +15,459 @@ from hmmlearn import hmm
24
 
25
  warnings.filterwarnings("ignore")
26
 
27
- # ---------------------------------------------------------------------------
28
- # Configuration
29
- # ---------------------------------------------------------------------------
30
-
31
- BINANCE_BASE = "https://api.binance.com"
32
- BINANCE_DATA = "https://data-api.binance.vision" # fallback
33
-
34
- REQUEST_TIMEOUT = 15
35
- MAX_RETRIES = 3
36
- RETRY_DELAY = 1.5
37
-
38
- # Trading cost assumptions
39
- COMMISSION_PER_SIDE = 0.001 # 0.10%
40
- SLIPPAGE_PER_SIDE = 0.0005 # 0.05%
41
- TOTAL_COST_PER_TRADE = 2 * (COMMISSION_PER_SIDE + SLIPPAGE_PER_SIDE) # round-trip
42
-
43
- # Regime labels
44
- REGIME_TREND = 0
45
- REGIME_MEAN_REV = 1
46
- REGIME_COMPRESSION = 2
47
  REGIME_NAMES = ["TREND", "MEAN_REVERSION", "COMPRESSION"]
 
 
 
 
 
 
48
 
49
- # HMM parameters (fixed, transparent — no per-run optimisation)
50
- HMM_N_COMPONENTS = 3
51
- HMM_N_ITER = 100
52
- HMM_COVARIANCE_TYPE = "diag"
53
- HMM_RANDOM_STATE = 42
54
-
55
- # Walk-forward
56
- TRAIN_FRACTION = 0.60
57
- PURGE_BARS = 5
58
- EMBARGO_BARS = 3
59
-
60
- # Minimum data requirements
61
- MIN_BARS_FOR_HMM = 200
62
- MIN_BARS_FOR_SIGNAL = 100
63
-
64
- # Leveraged-token exclusion patterns (prefix matching, case-insensitive)
65
- LEVERAGED_PREFIXES = ("3L", "3S", "5L", "5S", "BULL", "BEAR", "UP", "DOWN")
66
- LEVERAGED_SUFFIXES = ("3LUSDT", "3SUSDT", "5LUSDT", "5SUSDT",
67
- "BULLUSDT", "BEARUSDT", "UPUSDT", "DOWNUSDT")
68
-
69
-
70
- # ---------------------------------------------------------------------------
71
- # Utilities
72
- # ---------------------------------------------------------------------------
73
 
74
- def _safe_float(x) -> Optional[float]:
 
75
  try:
76
- v = float(x)
77
- return v if math.isfinite(v) else None
78
  except (TypeError, ValueError):
79
  return None
80
 
81
 
82
- def _sigmoid(x: float) -> float:
83
- if x >= 0:
84
- z = math.exp(-x)
85
- return 1.0 / (1.0 + z)
86
- z = math.exp(x)
87
- return z / (1.0 + z)
88
 
89
 
90
- def _ema(arr: np.ndarray, span: int) -> np.ndarray:
91
- """Exponential moving average, causal (no future data)."""
92
- alpha = 2.0 / (span + 1.0)
93
- out = np.empty_like(arr, dtype=float)
94
- out[0] = arr[0]
95
- for i in range(1, len(arr)):
96
- out[i] = alpha * arr[i] + (1.0 - alpha) * out[i - 1]
97
- return out
98
 
99
 
100
- def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray:
101
- """Average True Range, causal."""
102
- n = len(close)
103
- tr = np.empty(n, dtype=float)
104
- tr[0] = high[0] - low[0]
105
  for i in range(1, n):
106
- tr[i] = max(high[i] - low[i], abs(high[i] - close[i - 1]), abs(low[i] - close[i - 1]))
107
- return _ema(tr, period)
108
 
109
 
110
- def _rsi(close: np.ndarray, period: int = 14) -> np.ndarray:
111
- """RSI, causal, using Wilder smoothing."""
112
- n = len(close)
113
- delta = np.diff(close, prepend=close[0])
114
- gain = np.where(delta > 0, delta, 0.0)
115
- loss = np.where(delta < 0, -delta, 0.0)
116
- avg_gain = _ema(gain, period)
117
- avg_loss = _ema(loss, period)
118
- rs = np.where(avg_loss > 1e-12, avg_gain / avg_loss, 100.0)
119
  return 100.0 - 100.0 / (1.0 + rs)
120
 
121
 
122
- # ---------------------------------------------------------------------------
123
- # Binance data fetching
124
- # ---------------------------------------------------------------------------
125
-
126
- def _request_with_retry(url: str, params: dict, base: str = BINANCE_BASE) -> Optional[list]:
127
- """GET with retries and timeout. Returns JSON list or None."""
128
- for attempt in range(MAX_RETRIES):
129
- try:
130
- r = requests.get(base + url, params=params, timeout=REQUEST_TIMEOUT)
131
- if r.status_code == 200:
132
- data = r.json()
133
- if isinstance(data, list):
134
- return data
135
- return None
136
- if r.status_code == 429 or r.status_code >= 500:
137
- time.sleep(RETRY_DELAY * (attempt + 1))
138
- continue
139
- return None
140
- except (requests.RequestException, ValueError):
141
- time.sleep(RETRY_DELAY * (attempt + 1))
 
 
142
  return None
143
 
144
 
145
- def fetch_usdt_pairs(min_volume_rank: int = 30, max_pairs: int = 100) -> List[str]:
146
- """
147
- Fetch USDT spot trading pairs ranked by 24h quote volume.
148
- Excludes leveraged tokens and non-trading symbols.
149
- """
150
- # 1. Exchange info for status
151
- info = _request_with_retry("/api/v3/exchangeInfo", {})
152
- if info is None:
153
- info = _request_with_retry("/api/v3/exchangeInfo", {}, BINANCE_DATA)
154
- if info is None:
155
- return []
156
-
157
- trading_symbols = set()
158
- for s in info.get("symbols", []):
159
- if s.get("status") != "TRADING":
160
- continue
161
- if s.get("quoteAsset") != "USDT":
162
- continue
163
- sym = s.get("symbol", "")
164
- base = s.get("baseAsset", "")
165
- # Exclude leveraged tokens
166
- if any(base.upper().startswith(p) for p in LEVERAGED_PREFIXES):
167
- continue
168
- if any(sym.upper().endswith(p) for p in LEVERAGED_SUFFIXES):
169
- continue
170
- trading_symbols.add(sym)
171
-
172
- if not trading_symbols:
173
- return []
174
-
175
- # 2. 24h ticker for volume ranking
176
- tickers = _request_with_retry("/api/v3/ticker/24hr", {})
177
- if tickers is None:
178
- tickers = _request_with_retry("/api/v3/ticker/24hr", {}, BINANCE_DATA)
179
- if tickers is None:
180
- return []
181
-
182
- candidates = []
183
- for t in tickers:
184
- sym = t.get("symbol", "")
185
- if sym not in trading_symbols:
186
- continue
187
- qvol = _safe_float(t.get("quoteVolume"))
188
- if qvol is None or qvol <= 0:
189
- continue
190
- candidates.append((sym, qvol))
191
-
192
- candidates.sort(key=lambda x: x[1], reverse=True)
193
- top = [c[0] for c in candidates[:max_pairs]]
194
- return top[:max(min_volume_rank, len(top))]
195
-
196
 
197
- def fetch_klines(symbol: str, interval: str, limit: int = 1000) -> Optional[pd.DataFrame]:
198
- """
199
- Fetch OHLCV klines. Excludes unfinished candle (the most recent if still forming).
200
- Returns DataFrame with columns: open, high, low, close, volume, quote_volume.
201
- """
202
- params = {"symbol": symbol, "interval": interval, "limit": min(limit, 1000)}
203
- raw = _request_with_retry("/api/v3/klines", params)
204
- if raw is None:
205
- raw = _request_with_retry("/api/v3/klines", params, BINANCE_DATA)
206
- if raw is None or len(raw) == 0:
207
- return None
208
 
 
 
 
 
209
  rows = []
210
- now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
211
  for k in raw:
212
- if not isinstance(k, list) or len(k) < 7:
213
- continue
214
- close_time = int(k[6])
215
- # Exclude unfinished candle
216
- if close_time >= now_ms:
217
- continue
218
- o = _safe_float(k[1])
219
- h = _safe_float(k[2])
220
- l = _safe_float(k[3])
221
- c = _safe_float(k[4])
222
- v = _safe_float(k[5])
223
- qv = _safe_float(k[7]) if len(k) > 7 else None
224
- if None in (o, h, l, c, v) or min(o, h, l, c) <= 0:
225
- continue
226
- rows.append({
227
- "open": o, "high": h, "low": l, "close": c,
228
- "volume": v, "quote_volume": qv if qv else 0.0,
229
- })
230
-
231
- if len(rows) < MIN_BARS_FOR_HMM:
232
- return None
233
  return pd.DataFrame(rows)
234
 
235
 
236
- # ---------------------------------------------------------------------------
237
- # Feature engineering (all causal, no future data)
238
- # ---------------------------------------------------------------------------
239
-
240
- def compute_features(df: pd.DataFrame) -> pd.DataFrame:
241
- """
242
- Compute all features used by HMM and regime-conditioned evidence.
243
- Every column uses only data available at or before each bar's close.
244
- """
245
- close = df["close"].values.astype(float)
246
- high = df["high"].values.astype(float)
247
- low = df["low"].values.astype(float)
248
- n = len(close)
249
-
250
- out = pd.DataFrame(index=df.index)
251
- out["close"] = close
252
-
253
- # Returns
254
- ret = np.zeros(n)
255
- ret[1:] = np.diff(close) / close[:-1]
256
- out["ret"] = ret
257
-
258
- # ATR
259
- atr14 = _atr(high, low, close, 14)
260
- out["atr"] = atr14
261
- out["atr_norm"] = atr14 / np.where(close > 0, close, 1.0)
262
-
263
- # EMAs
264
- ema_fast = _ema(close, 12)
265
- ema_slow = _ema(close, 26)
266
- out["ema_fast"] = ema_fast
267
- out["ema_slow"] = ema_slow
268
- out["ema_diff_norm"] = (ema_fast - ema_slow) / np.where(close > 0, close, 1.0)
269
-
270
- # Directional efficiency (Kaufman-like, causal)
271
- lookback = 10
272
- eff = np.zeros(n)
273
- for i in range(lookback, n):
274
- net = close[i] - close[i - lookback]
275
- path = np.sum(np.abs(np.diff(close[i - lookback:i + 1])))
276
  eff[i] = net / path if path > 1e-12 else 0.0
277
- out["efficiency"] = eff
278
-
279
- # Momentum (rate of change)
280
- roc = np.zeros(n)
281
- roc[10:] = (close[10:] - close[:-10]) / np.where(close[:-10] > 0, close[:-10], 1.0)
282
- out["momentum"] = roc
283
-
284
- # ATR-normalised price movement
285
- out["atr_move"] = np.zeros(n)
286
- out["atr_move"][1:] = np.diff(close) / np.where(atr14[1:] > 1e-12, atr14[1:], 1.0)
287
-
288
- # RSI
289
- rsi14 = _rsi(close, 14)
290
- out["rsi"] = rsi14
291
- out["rsi_dev"] = (rsi14 - 50.0) / 50.0
292
-
293
- # Rolling mean & distance
294
- roll_mean = pd.Series(close).rolling(20, min_periods=20).mean().values
295
- out["dist_ma"] = np.where(roll_mean > 0, (close - roll_mean) / roll_mean, 0.0)
296
- out["dist_ma"] = np.nan_to_num(out["dist_ma"], nan=0.0)
297
-
298
- # ATR-normalised extension from rolling mean
299
- out["atr_extension"] = np.where(atr14 > 1e-12, (close - roll_mean) / atr14, 0.0)
300
- out["atr_extension"] = np.nan_to_num(out["atr_extension"], nan=0.0)
301
-
302
- # Volatility contraction: short ATR vs long ATR
303
- atr5 = _atr(high, low, close, 5)
304
- atr20 = _atr(high, low, close, 20)
305
- out["vol_ratio"] = np.where(atr20 > 1e-12, atr5 / atr20, 1.0)
306
-
307
- # Previous range boundaries (causal: use previous bar's high/low)
308
- out["prev_high"] = np.roll(high, 1)
309
- out["prev_low"] = np.roll(low, 1)
310
- out["prev_high"][0] = high[0]
311
- out["prev_low"][0] = low[0]
312
-
313
- # Breakout confirmation vs previous range
314
- out["breakout"] = np.where(
315
- close > out["prev_high"], 1.0,
316
- np.where(close < out["prev_low"], -1.0, 0.0)
317
- )
318
-
319
- # Clean
320
- out = out.replace([np.inf, -np.inf], np.nan).fillna(0.0)
321
- return out
322
-
323
-
324
- # ---------------------------------------------------------------------------
325
- # HMM regime detection (forward filtering only, no Viterbi)
326
- # ---------------------------------------------------------------------------
327
-
328
- def fit_hmm_on_training(features: pd.DataFrame) -> Optional[hmm.GaussianHMM]:
329
- """
330
- Fit a 3-state Gaussian HMM on the training portion only.
331
- Uses fixed, transparent parameters. No per-run optimisation.
332
- """
333
- n_train = max(MIN_BARS_FOR_HMM, int(len(features) * TRAIN_FRACTION))
334
- n_train = min(n_train, len(features) - 20)
335
- if n_train < MIN_BARS_FOR_HMM:
336
- return None
337
-
338
- train_feats = features.iloc[:n_train][
339
- ["ret", "atr_norm", "ema_diff_norm", "efficiency", "rsi_dev", "dist_ma"]
340
- ].values
341
-
342
- # Standardise (fit on train only)
343
- mu = train_feats.mean(axis=0)
344
- sigma = train_feats.std(axis=0)
345
- sigma[sigma < 1e-12] = 1.0
346
- X_train = (train_feats - mu) / sigma
347
-
348
- model = hmm.GaussianHMM(
349
- n_components=HMM_N_COMPONENTS,
350
- covariance_type=HMM_COVARIANCE_TYPE,
351
- n_iter=HMM_N_ITER,
352
- random_state=HMM_RANDOM_STATE,
353
- tol=1e-4,
354
- )
355
- try:
356
- model.fit(X_train)
357
- except Exception:
358
- return None
359
-
360
- # Store standardisation for inference
361
- model._rcea_mu = mu
362
- model._rcea_sigma = sigma
363
- return model
364
-
365
-
366
- def forward_filter_regimes(
367
- model: hmm.GaussianHMM, features: pd.DataFrame
368
- ) -> np.ndarray:
369
- """
370
- Causal forward filtering: P(state_t | observations_1..t).
371
- Uses the forward algorithm ONLY. No Viterbi, no smoothing, no future data.
372
-
373
- Returns array shape (n_bars, 3) of normalised posterior probabilities.
374
- For bars before HMM training data ends, probabilities are set to uniform
375
- (or the model's stationary distribution if available) to avoid look-ahead.
376
- """
377
- n = len(features)
378
- posteriors = np.ones((n, HMM_N_COMPONENTS)) / HMM_N_COMPONENTS
379
-
380
- n_train = max(MIN_BARS_FOR_HMM, int(n * TRAIN_FRACTION))
381
- n_train = min(n_train, n - 20)
382
-
383
- # Bars before training end: use uniform prior (no information)
384
- if n_train < 10:
385
- return posteriors
386
-
387
- feat_cols = ["ret", "atr_norm", "ema_diff_norm", "efficiency", "rsi_dev", "dist_ma"]
388
- X_all = features[feat_cols].values
389
- X_scaled = (X_all - model._rcea_mu) / model._rcea_sigma
390
-
391
- # Forward algorithm manual implementation (log-space to prevent underflow)
392
- log_start = np.log(model.startprob_ + 1e-300)
393
- log_trans = np.log(model.transmat_ + 1e-300)
394
-
395
- # Precompute log emission probabilities for all bars
396
- # For GaussianHMM with diag covariance
397
- n_comp = HMM_N_COMPONENTS
398
- n_feat = X_scaled.shape[1]
399
- log_emit = np.zeros((n, n_comp))
400
- for k in range(n_comp):
401
- mean = model.means_[k]
402
- covar = model.covars_[k]
403
- # diag: covar is 1-D
404
- if covar.ndim > 1:
405
- var = np.diag(covar)
406
- else:
407
- var = covar
408
- var = np.maximum(var, 1e-6)
409
- diff = X_scaled - mean # (n, n_feat)
410
- log_emit[:, k] = -0.5 * np.sum(diff ** 2 / var, axis=1) - 0.5 * np.sum(np.log(2 * np.pi * var))
411
-
412
- # Forward recursion in log space
413
- alpha = np.zeros((n, n_comp))
414
- alpha[0] = log_start + log_emit[0]
415
-
416
  for t in range(1, n):
417
- # logsumexp over previous states
418
- for k in range(n_comp):
419
- terms = alpha[t - 1] + log_trans[:, k]
420
- max_term = np.max(terms)
421
- alpha[t, k] = log_emit[t, k] + max_term + np.log(np.sum(np.exp(terms - max_term)))
422
-
423
- # Normalise to get posteriors
424
  for t in range(n):
425
- a = alpha[t]
426
- max_a = np.max(a)
427
- exp_a = np.exp(a - max_a)
428
- s = np.sum(exp_a)
429
- if s > 1e-300:
430
- posteriors[t] = exp_a / s
431
- else:
432
- posteriors[t] = np.ones(n_comp) / n_comp
433
-
434
- # Only use posteriors from training end onward; before that, uniform
435
- posteriors[:n_train] = np.ones((n_train, n_comp)) / n_comp
436
- return posteriors
437
-
438
-
439
- # ---------------------------------------------------------------------------
440
- # Regime-conditioned evidence
441
- # ---------------------------------------------------------------------------
442
-
443
- def compute_regime_evidence(
444
- features: pd.DataFrame, regime_probs: np.ndarray
445
- ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
446
- """
447
- Compute raw evidence per regime and combine via posterior probabilities.
448
-
449
- Returns:
450
- E_raw: array (n,)
451
- E_accum: array (n,)
452
- P_long: array (n,)
453
- P_short: array (n,)
454
- """
455
- n = len(features)
456
- close = features["close"].values
457
- ema_fast = features["ema_fast"].values
458
- ema_slow = features["ema_slow"].values
459
- efficiency = features["efficiency"].values
460
- momentum = features["momentum"].values
461
- atr_move = features["atr_move"].values
462
- rsi_dev = features["rsi_dev"].values
463
- dist_ma = features["dist_ma"].values
464
- atr_ext = features["atr_extension"].values
465
- vol_ratio = features["vol_ratio"].values
466
- breakout = features["breakout"].values
467
-
468
- # TREND evidence: directional
469
- e_trend = np.zeros(n)
470
- for i in range(n):
471
- score = 0.0
472
- # EMA relationship
473
- if close[i] > ema_fast[i] > ema_slow[i]:
474
- score += 0.4
475
- elif close[i] < ema_fast[i] < ema_slow[i]:
476
- score -= 0.4
477
- # Directional efficiency
478
- score += 0.3 * efficiency[i]
479
- # Momentum
480
- score += 0.3 * np.tanh(momentum[i] * 10)
481
- # ATR-normalised movement
482
- score += 0.2 * np.tanh(atr_move[i])
483
- e_trend[i] = np.tanh(score * 2.0)
484
-
485
- # MEAN REVERSION evidence: contrarian
486
- e_mean = np.zeros(n)
487
  for i in range(n):
488
- score = 0.0
489
- # RSI deviation (invert: high RSI -> short signal)
490
- score -= 0.4 * np.tanh(rsi_dev[i] * 3)
491
- # Distance from rolling mean
492
- score -= 0.4 * np.tanh(dist_ma[i] * 20)
493
- # ATR-normalised extension
494
- score -= 0.3 * np.tanh(atr_ext[i] * 2)
495
- e_mean[i] = np.tanh(score * 2.0)
496
-
497
- # COMPRESSION evidence: breakout direction
498
- e_comp = np.zeros(n)
499
- for i in range(n):
500
- score = 0.0
501
- # Volatility contraction strengthens breakout signal
502
- contraction = max(0.0, 1.0 - vol_ratio[i])
503
- score += breakout[i] * (0.5 + 0.5 * contraction)
504
- e_comp[i] = np.tanh(score * 2.0)
505
-
506
- # Combine using posterior probabilities
507
- E_raw = np.zeros(n)
508
- for i in range(n):
509
- p = regime_probs[i]
510
- E_raw[i] = p[REGIME_TREND] * e_trend[i] + p[REGIME_MEAN_REV] * e_mean[i] + p[REGIME_COMPRESSION] * e_comp[i]
511
-
512
- # Evidence accumulation with memory coefficient
513
- lam = 0.85 # default; can be overridden by UI
514
- E_accum = np.zeros(n)
515
- E_accum[0] = E_raw[0]
516
- for i in range(1, n):
517
- E_accum[i] = lam * E_accum[i - 1] + (1.0 - lam) * E_raw[i]
518
-
519
- # Directional probabilities
520
- k = 4.0
521
- P_long = np.array([_sigmoid(k * e) for e in E_accum])
522
- P_short = 1.0 - P_long
523
-
524
- return E_raw, E_accum, P_long, P_short
525
-
526
-
527
- def generate_signals(
528
- features: pd.DataFrame,
529
- regime_probs: np.ndarray,
530
- E_accum: np.ndarray,
531
- P_long: np.ndarray,
532
- P_short: np.ndarray,
533
- evidence_threshold: float,
534
- min_regime_conf: float,
535
- ) -> List[dict]:
536
- """
537
- Generate LONG/SHORT/NO TRADE signals for the most recent bar.
538
- Returns list of signal dicts (one per symbol, but this function is per-symbol).
539
- """
540
- n = len(features)
541
- signals = []
542
-
543
- # Current bar (last closed)
544
- i = n - 1
545
- if i < MIN_BARS_FOR_SIGNAL:
546
- return signals
547
-
548
- p = regime_probs[i]
549
- dominant_regime = int(np.argmax(p))
550
- regime_conf = float(p[dominant_regime])
551
-
552
- # Apply minimum regime confidence filter
553
- if regime_conf < min_regime_conf:
554
- return signals
555
-
556
- e = E_accum[i]
557
- pl = P_long[i]
558
- ps = P_short[i]
559
-
560
- signal = "NO TRADE"
561
- if e > evidence_threshold and pl > 0.5:
562
- signal = "LONG"
563
- elif e < -evidence_threshold and ps > 0.5:
564
- signal = "SHORT"
565
-
566
- signals.append({
567
- "signal": signal,
568
- "evidence": float(e),
569
- "P_long": float(pl),
570
- "P_short": float(ps),
571
- "regime_probs": {
572
- "TREND": float(p[0]),
573
- "MEAN_REVERSION": float(p[1]),
574
- "COMPRESSION": float(p[2]),
575
- },
576
- "dominant_regime": REGIME_NAMES[dominant_regime],
577
- "regime_conf": regime_conf,
578
- "close": float(features["close"].values[i]),
579
- })
580
- return signals
581
-
582
-
583
- # ---------------------------------------------------------------------------
584
- # Backtest engine
585
- # ---------------------------------------------------------------------------
586
-
587
- def backtest_symbol(
588
- symbol: str,
589
- features: pd.DataFrame,
590
- regime_probs: np.ndarray,
591
- E_accum: np.ndarray,
592
- P_long: np.ndarray,
593
- P_short: np.ndarray,
594
- evidence_threshold: float,
595
- min_regime_conf: float,
596
- max_holding_bars: int,
597
- ) -> List[dict]:
598
- """
599
- Chronological backtest for one symbol. No look-ahead.
600
- ATR-based stop, 2R take-profit. Conservative when stop & target both hit.
601
- """
602
- trades = []
603
- n = len(features)
604
- close = features["close"].values
605
- high = features["high"].values
606
- low = features["low"].values
607
- atr = features["atr"].values
608
-
609
- in_position = False
610
- entry_idx = 0
611
- entry_price = 0.0
612
- direction = 0 # +1 long, -1 short
613
- stop_price = 0.0
614
- target_price = 0.0
615
- regime_at_entry = ""
616
- regime_conf_at_entry = 0.0
617
-
618
- # Start from index MIN_BARS_FOR_SIGNAL to ensure enough history
619
- start = max(MIN_BARS_FOR_SIGNAL, int(n * TRAIN_FRACTION) + PURGE_BARS + EMBARGO_BARS)
620
-
621
  for i in range(start, n):
622
- if not in_position:
623
- # Check for entry signal
624
- p = regime_probs[i]
625
- dominant = int(np.argmax(p))
626
- conf = float(p[dominant])
627
- if conf < min_regime_conf:
628
- continue
629
-
630
- e = E_accum[i]
631
- pl = P_long[i]
632
- ps = P_short[i]
633
-
634
- entry_dir = 0
635
- if e > evidence_threshold and pl > 0.5:
636
- entry_dir = 1
637
- elif e < -evidence_threshold and ps > 0.5:
638
- entry_dir = -1
639
-
640
- if entry_dir == 0:
641
- continue
642
-
643
- # Enter at next bar's open (conservative: signal at close, fill next open)
644
- # But we don't have open in features; use next close? No — use current close + slippage.
645
- # To avoid look-ahead: signal generated at close of bar i, entry at open of bar i+1.
646
- # Since we don't store open, we use close[i] as proxy with slippage.
647
- # This is slightly optimistic (close vs next open), so we add extra slippage.
648
- entry_price = close[i] * (1.0 + SLIPPAGE_PER_SIDE * entry_dir)
649
- atr_val = atr[i]
650
- if atr_val < 1e-12:
651
- continue
652
-
653
- direction = entry_dir
654
- entry_idx = i
655
- in_position = True
656
-
657
- if direction == 1:
658
- stop_price = entry_price - 1.5 * atr_val
659
- target_price = entry_price + 3.0 * atr_val # 2R (1.5 ATR risk, 3.0 ATR reward)
660
- else:
661
- stop_price = entry_price + 1.5 * atr_val
662
- target_price = entry_price - 3.0 * atr_val
663
-
664
- regime_at_entry = REGIME_NAMES[dominant]
665
- regime_conf_at_entry = conf
666
-
667
  else:
668
- # In position: check exit
669
- bars_held = i - entry_idx
670
-
671
- if direction == 1:
672
- # Long
673
- hit_stop = low[i] <= stop_price
674
- hit_target = high[i] >= target_price
675
- else:
676
- # Short
677
- hit_stop = high[i] >= stop_price
678
- hit_target = low[i] <= target_price
679
-
680
- exit_price = None
681
- exit_reason = None
682
-
683
- if hit_stop and hit_target:
684
- # Conservative: assume stop hit first
685
- exit_price = stop_price
686
- exit_reason = "STOP (both touched, conservative)"
687
- elif hit_stop:
688
- exit_price = stop_price
689
- exit_reason = "STOP"
690
- elif hit_target:
691
- exit_price = target_price
692
- exit_reason = "TARGET"
693
- elif bars_held >= max_holding_bars:
694
- exit_price = close[i]
695
- exit_reason = "TIME"
696
-
697
- if exit_price is not None:
698
- # Apply exit slippage
699
- exit_price_adj = exit_price * (1.0 - SLIPPAGE_PER_SIDE * direction)
700
-
701
- # Gross return
702
- if direction == 1:
703
- gross_ret = (exit_price_adj - entry_price) / entry_price
704
- else:
705
- gross_ret = (entry_price - exit_price_adj) / entry_price
706
-
707
- # Net return after round-trip costs
708
- net_ret = gross_ret - TOTAL_COST_PER_TRADE
709
-
710
- trades.append({
711
- "symbol": symbol,
712
- "direction": "LONG" if direction == 1 else "SHORT",
713
- "entry_bar": entry_idx,
714
- "exit_bar": i,
715
- "bars_held": bars_held,
716
- "entry_price": entry_price,
717
- "exit_price": exit_price_adj,
718
- "stop_price": stop_price,
719
- "target_price": target_price,
720
- "exit_reason": exit_reason,
721
- "gross_return": gross_ret,
722
- "net_return": net_ret,
723
- "regime": regime_at_entry,
724
- "regime_conf": regime_conf_at_entry,
725
- "win": net_ret > 0,
726
- })
727
- in_position = False
728
-
729
  return trades
730
 
731
 
732
- # ---------------------------------------------------------------------------
733
- # Performance metrics
734
- # ---------------------------------------------------------------------------
735
-
736
- def compute_metrics(trades: List[dict]) -> dict:
737
- if not trades:
738
- return {
739
- "total_trades": 0, "win_rate": 0.0, "profit_factor": 0.0,
740
- "net_expectancy": 0.0, "avg_win": 0.0, "avg_loss": 0.0,
741
- "max_drawdown": 0.0, "net_compounded_return": 0.0,
742
- "long_trades": 0, "short_trades": 0,
743
- "long_win_rate": 0.0, "short_win_rate": 0.0,
744
- }
745
-
746
- net_rets = np.array([t["net_return"] for t in trades])
747
- wins = net_rets[net_rets > 0]
748
- losses = net_rets[net_rets <= 0]
749
-
750
- total = len(net_rets)
751
- win_rate = len(wins) / total if total > 0 else 0.0
752
- gross_profit = wins.sum() if len(wins) > 0 else 0.0
753
- gross_loss = abs(losses.sum()) if len(losses) > 0 else 0.0
754
- profit_factor = gross_profit / gross_loss if gross_loss > 1e-12 else float("inf")
755
- net_expectancy = net_rets.mean()
756
- avg_win = wins.mean() if len(wins) > 0 else 0.0
757
- avg_loss = losses.mean() if len(losses) > 0 else 0.0
758
-
759
- # Compounded return
760
- equity = np.cumprod(1.0 + net_rets)
761
- net_compounded = equity[-1] - 1.0 if len(equity) > 0 else 0.0
762
-
763
- # Max drawdown
764
- peak = np.maximum.accumulate(equity)
765
- dd = (equity - peak) / peak
766
- max_dd = dd.min() if len(dd) > 0 else 0.0
767
-
768
- # Long/Short
769
- long_trades = [t for t in trades if t["direction"] == "LONG"]
770
- short_trades = [t for t in trades if t["direction"] == "SHORT"]
771
- long_wins = sum(1 for t in long_trades if t["win"])
772
- short_wins = sum(1 for t in short_trades if t["win"])
773
-
774
- return {
775
- "total_trades": total,
776
- "win_rate": win_rate,
777
- "profit_factor": profit_factor,
778
- "net_expectancy": net_expectancy,
779
- "avg_win": avg_win,
780
- "avg_loss": avg_loss,
781
- "max_drawdown": max_dd,
782
- "net_compounded_return": net_compounded,
783
- "long_trades": len(long_trades),
784
- "short_trades": len(short_trades),
785
- "long_win_rate": long_wins / len(long_trades) if long_trades else 0.0,
786
- "short_win_rate": short_wins / len(short_trades) if short_trades else 0.0,
787
- }
788
-
789
-
790
- def regime_breakdown(trades: List[dict]) -> pd.DataFrame:
791
  if not trades:
792
- return pd.DataFrame()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
  rows = []
794
- for regime in REGIME_NAMES:
795
- rt = [t for t in trades if t["regime"] == regime]
796
  if not rt:
797
- rows.append({"Regime": regime, "Trades": 0, "WinRate": 0.0,
798
- "ProfitFactor": 0.0, "NetExp": 0.0})
799
- continue
800
- net = np.array([t["net_return"] for t in rt])
801
- wins = net[net > 0]
802
- losses = net[net <= 0]
803
- gp = wins.sum() if len(wins) else 0
804
- gl = abs(losses.sum()) if len(losses) else 0
805
- pf = gp / gl if gl > 1e-12 else float("inf")
806
- rows.append({
807
- "Regime": regime,
808
- "Trades": len(rt),
809
- "WinRate": len(wins) / len(rt),
810
- "ProfitFactor": pf,
811
- "NetExp": net.mean(),
812
- })
813
  return pd.DataFrame(rows)
814
 
815
 
816
- def coin_breakdown(trades: List[dict]) -> pd.DataFrame:
817
- if not trades:
818
- return pd.DataFrame()
819
- symbols = sorted(set(t["symbol"] for t in trades))
820
  rows = []
821
- for sym in symbols:
822
  st = [t for t in trades if t["symbol"] == sym]
823
- net = np.array([t["net_return"] for t in st])
824
- wins = net[net > 0]
825
- rows.append({
826
- "Symbol": sym,
827
- "Trades": len(st),
828
- "WinRate": len(wins) / len(st) if st else 0.0,
829
- "NetExp": net.mean() if len(net) else 0.0,
830
- "TotalNet": net.sum() if len(net) else 0.0,
831
- })
832
  return pd.DataFrame(rows).sort_values("TotalNet", ascending=False)
833
 
834
 
835
- # ---------------------------------------------------------------------------
836
- # Main pipeline
837
- # ---------------------------------------------------------------------------
 
838
 
839
- def run_rcea_scan(
840
- timeframe: str,
841
- n_coins: int,
842
- evidence_threshold: float,
843
- evidence_memory: float,
844
- min_regime_conf: float,
845
- max_holding: int,
846
- progress=gr.Progress(),
847
- ) -> Tuple[str, str, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
848
- """
849
- Main entry point. Fetches data, runs HMM, generates signals, backtests.
850
- Returns display strings and DataFrames.
851
- """
852
- # Clamp inputs
853
  n_coins = max(30, min(100, int(n_coins)))
854
- evidence_threshold = max(0.0, min(1.0, float(evidence_threshold)))
855
- evidence_memory = max(0.0, min(0.99, float(evidence_memory)))
856
- min_regime_conf = max(0.0, min(1.0, float(min_regime_conf)))
857
- max_holding = max(5, min(200, int(max_holding)))
858
 
859
  if timeframe not in ("15m", "1h", "4h", "1d"):
860
- return "ERROR: Invalid timeframe", "", pd.DataFrame(), pd.DataFrame(), pd.DataFrame()
861
 
862
- # 1. Fetch pairs
863
  progress(0.02, desc="Fetching USDT pairs...")
864
- try:
865
- pairs = fetch_usdt_pairs(n_coins, n_coins)
866
- except Exception as e:
867
- return f"ERROR fetching pairs: {e}", "", pd.DataFrame(), pd.DataFrame(), pd.DataFrame()
868
-
869
  if not pairs:
870
- return "ERROR: No USDT pairs found. Check network connectivity.", "", pd.DataFrame(), pd.DataFrame(), pd.DataFrame()
871
-
872
- # 2. For each pair, fetch klines, compute features, fit HMM, generate signals
873
- all_signals = []
874
- all_trades = []
875
- analyzed = 0
876
- failed = []
877
-
878
- # Limit concurrent processing to avoid rate limits
879
- for idx, sym in enumerate(pairs):
880
- progress((idx + 1) / len(pairs) * 0.9, desc=f"Processing {sym} ({idx+1}/{len(pairs)})")
881
- time.sleep(0.15) # gentle rate limiting
882
-
883
- try:
884
- df = fetch_klines(sym, timeframe, limit=1000)
885
- except Exception:
886
- df = None
887
-
888
- if df is None or len(df) < MIN_BARS_FOR_HMM:
889
- failed.append(sym)
890
- continue
891
-
892
  try:
893
- features = compute_features(df)
894
- model = fit_hmm_on_training(features)
895
- if model is None:
896
- failed.append(sym)
897
- continue
898
-
899
- regime_probs = forward_filter_regimes(model, features)
900
-
901
- # Override lambda for accumulation
902
- _, E_accum, P_long, P_short = compute_regime_evidence(features, regime_probs)
903
-
904
- # Re-accumulate with user lambda
905
- n = len(features)
906
- # Recompute raw evidence (we have E_raw from compute_regime_evidence but it used default lam)
907
- # Recompute raw evidence cleanly
908
- e_trend = np.zeros(n)
909
- e_mean = np.zeros(n)
910
- e_comp = np.zeros(n)
911
- close = features["close"].values
912
- ema_fast = features["ema_fast"].values
913
- ema_slow = features["ema_slow"].values
914
- efficiency = features["efficiency"].values
915
- momentum = features["momentum"].values
916
- atr_move = features["atr_move"].values
917
- rsi_dev = features["rsi_dev"].values
918
- dist_ma = features["dist_ma"].values
919
- atr_ext = features["atr_extension"].values
920
- vol_ratio = features["vol_ratio"].values
921
- breakout = features["breakout"].values
922
-
923
- for i in range(n):
924
- # Trend
925
- s = 0.0
926
- if close[i] > ema_fast[i] > ema_slow[i]: s += 0.4
927
- elif close[i] < ema_fast[i] < ema_slow[i]: s -= 0.4
928
- s += 0.3 * efficiency[i]
929
- s += 0.3 * np.tanh(momentum[i] * 10)
930
- s += 0.2 * np.tanh(atr_move[i])
931
- e_trend[i] = np.tanh(s * 2.0)
932
-
933
- # Mean reversion
934
- s = 0.0
935
- s -= 0.4 * np.tanh(rsi_dev[i] * 3)
936
- s -= 0.4 * np.tanh(dist_ma[i] * 20)
937
- s -= 0.3 * np.tanh(atr_ext[i] * 2)
938
- e_mean[i] = np.tanh(s * 2.0)
939
-
940
- # Compression
941
- contraction = max(0.0, 1.0 - vol_ratio[i])
942
- e_comp[i] = np.tanh(breakout[i] * (0.5 + 0.5 * contraction) * 2.0)
943
-
944
- E_raw = np.zeros(n)
945
- for i in range(n):
946
- p = regime_probs[i]
947
- E_raw[i] = p[0] * e_trend[i] + p[1] * e_mean[i] + p[2] * e_comp[i]
948
-
949
- E_accum = np.zeros(n)
950
- E_accum[0] = E_raw[0]
951
- for i in range(1, n):
952
- E_accum[i] = evidence_memory * E_accum[i - 1] + (1.0 - evidence_memory) * E_raw[i]
953
-
954
- P_long = np.array([_sigmoid(4.0 * e) for e in E_accum])
955
- P_short = 1.0 - P_long
956
-
957
- # Signals for latest bar
958
- sigs = generate_signals(features, regime_probs, E_accum, P_long, P_short,
959
- evidence_threshold, min_regime_conf)
960
- for s in sigs:
961
- s["symbol"] = sym
962
- all_signals.extend(sigs)
963
-
964
- # Backtest
965
- trades = backtest_symbol(sym, features, regime_probs, E_accum, P_long, P_short,
966
- evidence_threshold, min_regime_conf, max_holding)
967
- all_trades.extend(trades)
968
-
969
  analyzed += 1
970
- except Exception:
971
- failed.append(sym)
972
- continue
973
-
974
  progress(1.0, desc="Done")
975
 
976
- # 3. Build report strings
977
- scan_lines = []
978
- scan_lines.append(f"# RCEA Lite Market Scan Report")
979
- scan_lines.append(f"**Timeframe:** {timeframe}")
980
- scan_lines.append(f"**Coins requested:** {n_coins}")
981
- scan_lines.append(f"**Coins successfully analyzed:** {analyzed}")
982
- scan_lines.append(f"**Coins failed / insufficient data:** {len(failed)}")
983
- if failed:
984
- scan_lines.append(f"*Failed symbols: {', '.join(failed[:20])}{'...' if len(failed)>20 else ''}*")
985
- scan_lines.append(f"**Evidence threshold:** {evidence_threshold}")
986
- scan_lines.append(f"**Evidence memory (λ):** {evidence_memory}")
987
- scan_lines.append(f"**Min regime confidence:** {min_regime_conf}")
988
- scan_lines.append(f"**Max holding bars:** {max_holding}")
989
- scan_lines.append(f"**Total trades (backtest):** {len(all_trades)}")
990
- scan_lines.append(f"**Timestamp (UTC):** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')}")
991
-
992
- if len(all_trades) < 30:
993
- scan_lines.append(f"\n⚠️ **WARNING:** Only {len(all_trades)} trades generated. "
994
- f"Target is ≥30 genuine trades. Do NOT interpret metrics as statistically significant.")
995
-
996
- scan_report = "\n".join(scan_lines)
997
-
998
- # 4. Signal table
999
- if all_signals:
1000
  sig_df = pd.DataFrame([{
1001
- "Symbol": s["symbol"],
1002
- "Signal": s["signal"],
1003
  "Evidence": round(s["evidence"], 4),
1004
- "P(Long)": round(s["P_long"], 4),
1005
- "P(Short)": round(s["P_short"], 4),
1006
- "Regime": s["dominant_regime"],
1007
- "Regime Conf": round(s["regime_conf"], 4),
1008
  "P(Trend)": round(s["regime_probs"]["TREND"], 4),
1009
  "P(MeanRev)": round(s["regime_probs"]["MEAN_REVERSION"], 4),
1010
  "P(Compress)": round(s["regime_probs"]["COMPRESSION"], 4),
1011
- "Close": s["close"],
1012
- } for s in all_signals])
1013
- # Sort: actionable signals first
1014
- sig_df["_priority"] = sig_df["Signal"].map({"LONG": 0, "SHORT": 0, "NO TRADE": 1})
1015
- sig_df = sig_df.sort_values(["_priority", "Evidence"], ascending=[True, False]).drop(columns=["_priority"])
1016
  else:
1017
  sig_df = pd.DataFrame(columns=["Symbol", "Signal", "Evidence", "P(Long)", "P(Short)",
1018
- "Regime", "Regime Conf", "P(Trend)", "P(MeanRev)",
1019
- "P(Compress)", "Close"])
1020
-
1021
- # 5. Trade history
1022
- if all_trades:
1023
- trade_df = pd.DataFrame([{
1024
- "Symbol": t["symbol"],
1025
- "Direction": t["direction"],
1026
- "Entry Bar": t["entry_bar"],
1027
- "Exit Bar": t["exit_bar"],
1028
- "Bars Held": t["bars_held"],
1029
- "Entry": round(t["entry_price"], 6),
1030
- "Exit": round(t["exit_price"], 6),
1031
- "Stop": round(t["stop_price"], 6),
1032
- "Target": round(t["target_price"], 6),
1033
- "Exit Reason": t["exit_reason"],
1034
- "Gross Ret": round(t["gross_return"], 6),
1035
- "Net Ret": round(t["net_return"], 6),
1036
- "Regime": t["regime"],
1037
- "Regime Conf": round(t["regime_conf"], 4),
1038
- "Win": t["win"],
1039
- } for t in all_trades])
1040
- trade_df = trade_df.sort_values(["Symbol", "Entry Bar"])
1041
  else:
1042
- trade_df = pd.DataFrame(columns=["Symbol", "Direction", "Entry Bar", "Exit Bar",
1043
- "Bars Held", "Entry", "Exit", "Stop", "Target",
1044
- "Exit Reason", "Gross Ret", "Net Ret",
1045
- "Regime", "Regime Conf", "Win"])
1046
-
1047
- # 6. Metrics
1048
- metrics = compute_metrics(all_trades)
1049
- metrics_lines = []
1050
- metrics_lines.append("# Performance Metrics (net of costs)")
1051
- metrics_lines.append(f"**Total trades:** {metrics['total_trades']}")
1052
- metrics_lines.append(f"**Win rate:** {metrics['win_rate']:.2%}")
1053
- metrics_lines.append(f"**Profit factor:** {metrics['profit_factor']:.3f}")
1054
- metrics_lines.append(f"**Net expectancy:** {metrics['net_expectancy']:.6f}")
1055
- metrics_lines.append(f"**Average win:** {metrics['avg_win']:.6f}")
1056
- metrics_lines.append(f"**Average loss:** {metrics['avg_loss']:.6f}")
1057
- metrics_lines.append(f"**Max drawdown:** {metrics['max_drawdown']:.2%}")
1058
- metrics_lines.append(f"**Net compounded return:** {metrics['net_compounded_return']:.2%}")
1059
- metrics_lines.append(f"**Long trades:** {metrics['long_trades']} (win rate: {metrics['long_win_rate']:.2%})")
1060
- metrics_lines.append(f"**Short trades:** {metrics['short_trades']} (win rate: {metrics['short_win_rate']:.2%})")
1061
- if metrics["total_trades"] < 30:
1062
- metrics_lines.append("\n⚠️ **Insufficient trades for reliable statistics.** Results are descriptive only.")
1063
- metrics_str = "\n".join(metrics_lines)
1064
-
1065
- # 7. Regime & coin breakdowns
1066
- regime_df = regime_breakdown(all_trades)
1067
- coin_df = coin_breakdown(all_trades)
1068
-
1069
- return scan_report, metrics_str, sig_df, trade_df, regime_df, coin_df
1070
-
1071
-
1072
- # ---------------------------------------------------------------------------
1073
- # Gradio UI
1074
- # ---------------------------------------------------------------------------
1075
-
1076
- def build_ui():
1077
- with gr.Blocks(title="RCEA Lite v1.0", theme=gr.themes.Soft()) as demo:
1078
- gr.Markdown("""
1079
- # RCEA Lite v1.0 — Regime-Conditioned Evidence Accumulation
1080
- **Mathematical trading system** | HMM regime detection (causal forward filtering) | No supervised ML
1081
- """)
1082
-
1083
- with gr.Row():
1084
- with gr.Column(scale=1):
1085
- timeframe = gr.Dropdown(
1086
- choices=["15m", "1h", "4h", "1d"],
1087
- value="1h",
1088
- label="Timeframe",
1089
- )
1090
- n_coins = gr.Slider(30, 100, value=30, step=1, label="Number of coins (USDT pairs by 24h volume)")
1091
- evidence_threshold = gr.Slider(0.0, 1.0, value=0.15, step=0.01,
1092
- label="Evidence threshold (|E| > threshold for trade)")
1093
- evidence_memory = gr.Slider(0.0, 0.99, value=0.85, step=0.01,
1094
- label="Evidence memory coefficient (λ)")
1095
- min_regime_conf = gr.Slider(0.0, 1.0, value=0.40, step=0.01,
1096
- label="Minimum regime confidence")
1097
- max_holding = gr.Slider(10, 200, value=48, step=1,
1098
- label="Max holding period (bars)")
1099
- run_btn = gr.Button("🚀 Run RCEA Lite Scan", variant="primary")
1100
-
1101
- with gr.Column(scale=2):
1102
- scan_output = gr.Markdown(label="Market Scan Report")
1103
- metrics_output = gr.Markdown(label="Performance Metrics")
1104
-
1105
- with gr.Tabs():
1106
- with gr.Tab("📡 Current Signals"):
1107
- signals_table = gr.Dataframe(
1108
- label="Long / Short / No Trade signals",
1109
- wrap=True,
1110
- interactive=False,
1111
- )
1112
-
1113
- with gr.Tab("📜 Trade History"):
1114
- trades_table = gr.Dataframe(
1115
- label="Backtest trade history",
1116
- wrap=True,
1117
- interactive=False,
1118
- )
1119
-
1120
- with gr.Tab("📊 Regime Breakdown"):
1121
- regime_table = gr.Dataframe(
1122
- label="Performance by market regime",
1123
- interactive=False,
1124
- )
1125
-
1126
- with gr.Tab("🪙 Coin Breakdown"):
1127
- coin_table = gr.Dataframe(
1128
- label="Performance by coin",
1129
- interactive=False,
1130
- )
1131
-
1132
- run_btn.click(
1133
- fn=run_rcea_scan,
1134
- inputs=[timeframe, n_coins, evidence_threshold, evidence_memory,
1135
- min_regime_conf, max_holding],
1136
- outputs=[scan_output, metrics_output, signals_table, trades_table,
1137
- regime_table, coin_table],
1138
- )
1139
-
1140
- gr.Markdown("""
1141
- ---
1142
- **Disclaimer:** This is a research tool. Past backtest performance does not guarantee future results.
1143
- All returns are net of estimated commissions (0.10% per side) and slippage (0.05% per side).
1144
- HMM is used for regime detection only; no supervised machine-learning prediction is employed.
1145
- """)
1146
-
1147
- return demo
1148
-
1149
 
1150
- # ---------------------------------------------------------------------------
1151
- # Entry point
1152
- # ---------------------------------------------------------------------------
1153
 
1154
  if __name__ == "__main__":
1155
- demo = build_ui()
1156
  demo.launch(server_name="0.0.0.0", server_port=7860)
 
1
  """
2
  RCEA Lite v1.0 — Regime-Conditioned Evidence Accumulation
3
+ Causal HMM regime detection. No supervised ML.
 
 
 
 
 
 
4
  """
5
 
6
+ import math, time, warnings
 
 
 
7
  from datetime import datetime, timezone
8
+ from typing import List, Optional, Tuple
9
 
10
  import numpy as np
11
  import pandas as pd
 
15
 
16
  warnings.filterwarnings("ignore")
17
 
18
+ # ---------- Config ----------
19
+ HOSTS = ["https://api.binance.com", "https://data-api.binance.vision", "https://api.binance.us"]
20
+ TIMEOUT, RETRIES, DELAY = 20, 3, 1.5
21
+ COMMISSION, SLIPPAGE = 0.001, 0.0005
22
+ COST = 2 * (COMMISSION + SLIPPAGE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  REGIME_NAMES = ["TREND", "MEAN_REVERSION", "COMPRESSION"]
24
+ HMM_K = 3
25
+ TRAIN_FRAC, PURGE, EMBARGO = 0.60, 5, 3
26
+ MIN_HMM, MIN_SIG = 200, 100
27
+ LEV_PRE = ("3L", "3S", "5L", "5S", "BULL", "BEAR", "UP", "DOWN")
28
+ LEV_SUF = ("3LUSDT", "3SUSDT", "5LUSDT", "5SUSDT", "BULLUSDT", "BEARUSDT", "UPUSDT", "DOWNUSDT")
29
+ FEAT_COLS = ["ret", "atr_norm", "ema_diff_norm", "efficiency", "rsi_dev", "dist_ma"]
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
+ # ---------- Helpers ----------
33
+ def _f(x):
34
  try:
35
+ v = float(x); return v if math.isfinite(v) else None
 
36
  except (TypeError, ValueError):
37
  return None
38
 
39
 
40
+ def _sig(x):
41
+ return 1.0 / (1.0 + math.exp(-x)) if x >= 0 else math.exp(x) / (1.0 + math.exp(x))
 
 
 
 
42
 
43
 
44
+ def _ema(a, span):
45
+ al = 2.0 / (span + 1.0)
46
+ o = np.empty_like(a, dtype=float); o[0] = a[0]
47
+ for i in range(1, len(a)): o[i] = al * a[i] + (1 - al) * o[i - 1]
48
+ return o
 
 
 
49
 
50
 
51
+ def _atr(h, l, c, p=14):
52
+ n = len(c); tr = np.empty(n)
53
+ tr[0] = h[0] - l[0]
 
 
54
  for i in range(1, n):
55
+ tr[i] = max(h[i] - l[i], abs(h[i] - c[i-1]), abs(l[i] - c[i-1]))
56
+ return _ema(tr, p)
57
 
58
 
59
+ def _rsi(c, p=14):
60
+ d = np.diff(c, prepend=c[0])
61
+ g = np.where(d > 0, d, 0.0); ls = np.where(d < 0, -d, 0.0)
62
+ ag, al = _ema(g, p), _ema(ls, p)
63
+ rs = np.where(al > 1e-12, ag / al, 100.0)
 
 
 
 
64
  return 100.0 - 100.0 / (1.0 + rs)
65
 
66
 
67
+ # ---------- Binance ----------
68
+ def _req(path, params, timeout=TIMEOUT):
69
+ last = None
70
+ for attempt in range(RETRIES):
71
+ for base in HOSTS:
72
+ try:
73
+ r = requests.get(base + path, params=params, timeout=timeout,
74
+ headers={"User-Agent": "RCEA-Lite/1.0"})
75
+ if r.status_code == 200:
76
+ d = r.json()
77
+ if isinstance(d, list): return d
78
+ last = f"non-list {type(d).__name__}"
79
+ elif r.status_code in (418, 429):
80
+ last = f"HTTP {r.status_code}"; time.sleep(5 * (attempt + 1))
81
+ elif r.status_code >= 500:
82
+ last = f"HTTP {r.status_code}"; time.sleep(DELAY * (attempt + 1))
83
+ else:
84
+ last = f"HTTP {r.status_code}: {r.text[:100]}"; break
85
+ except requests.RequestException as e:
86
+ last = f"{type(e).__name__}: {e}"
87
+ time.sleep(DELAY * (attempt + 1))
88
+ print(f"[RCEA] {path} failed: {last}", flush=True)
89
  return None
90
 
91
 
92
+ def fetch_pairs(n=30):
93
+ info = _req("/api/v3/exchangeInfo", {}, timeout=30)
94
+ if not info: return []
95
+ syms = set()
96
+ for s in info:
97
+ if s.get("status") != "TRADING" or s.get("quoteAsset") != "USDT": continue
98
+ sym, base = s.get("symbol", ""), s.get("baseAsset", "").upper()
99
+ if not sym or not base: continue
100
+ if any(base.startswith(p) for p in LEV_PRE): continue
101
+ if any(sym.upper().endswith(p) for p in LEV_SUF): continue
102
+ syms.add(sym)
103
+ tick = _req("/api/v3/ticker/24hr", {})
104
+ if not tick: return []
105
+ cand = []
106
+ for t in tick:
107
+ s = t.get("symbol")
108
+ if s not in syms: continue
109
+ q = _f(t.get("quoteVolume"))
110
+ if q and q > 0: cand.append((s, q))
111
+ cand.sort(key=lambda x: x[1], reverse=True)
112
+ out = [c[0] for c in cand[:n]]
113
+ print(f"[RCEA] {len(out)} pairs resolved", flush=True)
114
+ return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
+ def fetch_klines(sym, interval, limit=1000):
118
+ raw = _req("/api/v3/klines", {"symbol": sym, "interval": interval, "limit": min(limit, 1000)})
119
+ if not raw: return None
120
+ now = int(datetime.now(timezone.utc).timestamp() * 1000)
121
  rows = []
 
122
  for k in raw:
123
+ if not isinstance(k, list) or len(k) < 7: continue
124
+ if int(k[6]) >= now: continue
125
+ o, h, l, c, v = _f(k[1]), _f(k[2]), _f(k[3]), _f(k[4]), _f(k[5])
126
+ if None in (o, h, l, c, v) or min(o, h, l, c) <= 0: continue
127
+ rows.append({"open": o, "high": h, "low": l, "close": c, "volume": v})
128
+ if len(rows) < MIN_HMM: return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  return pd.DataFrame(rows)
130
 
131
 
132
+ # ---------- Features ----------
133
+ def features(df):
134
+ c = df["close"].values.astype(float)
135
+ h = df["high"].values.astype(float)
136
+ lo = df["low"].values.astype(float)
137
+ n = len(c)
138
+ o = pd.DataFrame(index=df.index)
139
+ o["close"] = c
140
+ r = np.zeros(n); r[1:] = np.diff(c) / c[:-1]; o["ret"] = r
141
+ a14 = _atr(h, lo, c, 14); o["atr"] = a14
142
+ o["atr_norm"] = a14 / np.where(c > 0, c, 1.0)
143
+ ef, es = _ema(c, 12), _ema(c, 26)
144
+ o["ema_fast"], o["ema_slow"] = ef, es
145
+ o["ema_diff_norm"] = (ef - es) / np.where(c > 0, c, 1.0)
146
+ lb, eff = 10, np.zeros(n)
147
+ for i in range(lb, n):
148
+ net = c[i] - c[i - lb]; path = np.sum(np.abs(np.diff(c[i - lb:i + 1])))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  eff[i] = net / path if path > 1e-12 else 0.0
150
+ o["efficiency"] = eff
151
+ mom = np.zeros(n); mom[10:] = (c[10:] - c[:-10]) / np.where(c[:-10] > 0, c[:-10], 1.0)
152
+ o["momentum"] = mom
153
+ am = np.zeros(n); am[1:] = np.diff(c) / np.where(a14[1:] > 1e-12, a14[1:], 1.0)
154
+ o["atr_move"] = am
155
+ rs = _rsi(c, 14); o["rsi"] = rs; o["rsi_dev"] = (rs - 50.0) / 50.0
156
+ rm = pd.Series(c).rolling(20, min_periods=20).mean().values
157
+ dm = np.where(rm > 0, (c - rm) / rm, 0.0); o["dist_ma"] = np.nan_to_num(dm)
158
+ ae = np.where(a14 > 1e-12, (c - rm) / a14, 0.0); o["atr_extension"] = np.nan_to_num(ae)
159
+ a5, a20 = _atr(h, lo, c, 5), _atr(h, lo, c, 20)
160
+ o["vol_ratio"] = np.where(a20 > 1e-12, a5 / a20, 1.0)
161
+ ph, pl_ = np.roll(h, 1), np.roll(lo, 1)
162
+ ph[0], pl_[0] = h[0], lo[0]
163
+ o["prev_high"], o["prev_low"] = ph, pl_
164
+ o["breakout"] = np.where(c > ph, 1.0, np.where(c < pl_, -1.0, 0.0))
165
+ return o.replace([np.inf, -np.inf], np.nan).fillna(0.0)
166
+
167
+
168
+ # ---------- HMM ----------
169
+ def fit_hmm(f):
170
+ ntr = min(max(MIN_HMM, int(len(f) * TRAIN_FRAC)), len(f) - 20)
171
+ if ntr < MIN_HMM: return None
172
+ X = f.iloc[:ntr][FEAT_COLS].values
173
+ mu, sd = X.mean(0), X.std(0); sd[sd < 1e-12] = 1.0
174
+ m = hmm.GaussianHMM(n_components=HMM_K, covariance_type="diag",
175
+ n_iter=100, random_state=42, tol=1e-4)
176
+ try: m.fit((X - mu) / sd)
177
+ except Exception: return None
178
+ m._mu, m._sd = mu, sd
179
+ return m
180
+
181
+
182
+ def forward_filter(m, f):
183
+ n = len(f)
184
+ post = np.ones((n, HMM_K)) / HMM_K
185
+ ntr = min(max(MIN_HMM, int(n * TRAIN_FRAC)), n - 20)
186
+ if ntr < 10: return post
187
+ X = (f[FEAT_COLS].values - m._mu) / m._sd
188
+ ls, lt = np.log(m.startprob_ + 1e-300), np.log(m.transmat_ + 1e-300)
189
+ le = np.zeros((n, HMM_K))
190
+ for k in range(HMM_K):
191
+ cov = m.covars_[k]; var = np.diag(cov) if cov.ndim > 1 else cov
192
+ var = np.maximum(var, 1e-6); d = X - m.means_[k]
193
+ le[:, k] = -0.5 * np.sum(d * d / var, 1) - 0.5 * np.sum(np.log(2 * np.pi * var))
194
+ a = np.zeros((n, HMM_K)); a[0] = ls + le[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  for t in range(1, n):
196
+ for k in range(HMM_K):
197
+ terms = a[t - 1] + lt[:, k]; mx = terms.max()
198
+ a[t, k] = le[t, k] + mx + np.log(np.exp(terms - mx).sum())
 
 
 
 
199
  for t in range(n):
200
+ e = np.exp(a[t] - a[t].max()); post[t] = e / e.sum()
201
+ post[:ntr] = 1.0 / HMM_K
202
+ return post
203
+
204
+
205
+ # ---------- Evidence ----------
206
+ def evidence(f, rp, lam):
207
+ n = len(f)
208
+ c = f["close"].values; ef = f["ema_fast"].values; es = f["ema_slow"].values
209
+ eff = f["efficiency"].values; mom = f["momentum"].values; am = f["atr_move"].values
210
+ rd = f["rsi_dev"].values; dm = f["dist_ma"].values; ae = f["atr_extension"].values
211
+ vr = f["vol_ratio"].values; bo = f["breakout"].values
212
+ et, em, ec = np.zeros(n), np.zeros(n), np.zeros(n)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  for i in range(n):
214
+ s = 0.0
215
+ if c[i] > ef[i] > es[i]: s += 0.4
216
+ elif c[i] < ef[i] < es[i]: s -= 0.4
217
+ s += 0.3 * eff[i] + 0.3 * np.tanh(mom[i] * 10) + 0.2 * np.tanh(am[i])
218
+ et[i] = np.tanh(s * 2.0)
219
+ s = -0.4 * np.tanh(rd[i] * 3) - 0.4 * np.tanh(dm[i] * 20) - 0.3 * np.tanh(ae[i] * 2)
220
+ em[i] = np.tanh(s * 2.0)
221
+ contr = max(0.0, 1.0 - vr[i])
222
+ ec[i] = np.tanh(bo[i] * (0.5 + 0.5 * contr) * 2.0)
223
+ raw = rp[:, 0] * et + rp[:, 1] * em + rp[:, 2] * ec
224
+ acc = np.zeros(n); acc[0] = raw[0]
225
+ for i in range(1, n): acc[i] = lam * acc[i - 1] + (1 - lam) * raw[i]
226
+ pl = np.array([_sig(4.0 * x) for x in acc])
227
+ return acc, pl, 1.0 - pl
228
+
229
+
230
+ # ---------- Signal + backtest ----------
231
+ def latest_signal(f, rp, acc, pl, ps, thr, mconf):
232
+ i = len(f) - 1
233
+ if i < MIN_SIG: return None
234
+ p = rp[i]; dom = int(np.argmax(p)); conf = float(p[dom])
235
+ if conf < mconf: return None
236
+ e, l, s = float(acc[i]), float(pl[i]), float(ps[i])
237
+ sig = "LONG" if e > thr and l > 0.5 else "SHORT" if e < -thr and s > 0.5 else "NO TRADE"
238
+ return {"signal": sig, "evidence": e, "P_long": l, "P_short": s,
239
+ "regime_probs": {REGIME_NAMES[j]: float(p[j]) for j in range(HMM_K)},
240
+ "dominant_regime": REGIME_NAMES[dom], "regime_conf": conf,
241
+ "close": float(f["close"].values[i])}
242
+
243
+
244
+ def backtest(sym, f, rp, acc, pl, ps, thr, mconf, maxhold):
245
+ n = len(f); c = f["close"].values; h = f["high"].values; lo = f["low"].values
246
+ atr = f["atr"].values
247
+ trades = []; in_pos = False; ei = eprice = d = sp = tp = 0
248
+ reg = ""; rconf = 0.0
249
+ start = max(MIN_SIG, int(n * TRAIN_FRAC) + PURGE + EMBARGO)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  for i in range(start, n):
251
+ if not in_pos:
252
+ p = rp[i]; dom = int(np.argmax(p)); conf = float(p[dom])
253
+ if conf < mconf: continue
254
+ ed = 1 if acc[i] > thr and pl[i] > 0.5 else -1 if acc[i] < -thr and ps[i] > 0.5 else 0
255
+ if ed == 0 or atr[i] < 1e-12: continue
256
+ eprice = c[i] * (1.0 + SLIPPAGE * ed); d = ed; ei = i; in_pos = True
257
+ if d == 1: sp, tp = eprice - 1.5 * atr[i], eprice + 3.0 * atr[i]
258
+ else: sp, tp = eprice + 1.5 * atr[i], eprice - 3.0 * atr[i]
259
+ reg, rconf = REGIME_NAMES[dom], conf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  else:
261
+ bh = i - ei
262
+ hs = lo[i] <= sp if d == 1 else h[i] >= sp
263
+ ht = h[i] >= tp if d == 1 else lo[i] <= tp
264
+ xp = xr = None
265
+ if hs and ht: xp, xr = sp, "STOP (both, conservative)"
266
+ elif hs: xp, xr = sp, "STOP"
267
+ elif ht: xp, xr = tp, "TARGET"
268
+ elif bh >= maxhold: xp, xr = c[i], "TIME"
269
+ if xp is not None:
270
+ xa = xp * (1.0 - SLIPPAGE * d)
271
+ gr = (xa - eprice) / eprice if d == 1 else (eprice - xa) / eprice
272
+ nr = gr - COST
273
+ trades.append({"symbol": sym, "direction": "LONG" if d == 1 else "SHORT",
274
+ "entry_bar": ei, "exit_bar": i, "bars_held": bh,
275
+ "entry_price": eprice, "exit_price": xa,
276
+ "stop_price": sp, "target_price": tp,
277
+ "exit_reason": xr, "gross_return": gr, "net_return": nr,
278
+ "regime": reg, "regime_conf": rconf, "win": nr > 0})
279
+ in_pos = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  return trades
281
 
282
 
283
+ # ---------- Metrics ----------
284
+ def metrics(trades):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  if not trades:
286
+ return dict(total_trades=0, win_rate=0.0, profit_factor=0.0, net_expectancy=0.0,
287
+ avg_win=0.0, avg_loss=0.0, max_drawdown=0.0, net_compounded_return=0.0,
288
+ long_trades=0, short_trades=0, long_win_rate=0.0, short_win_rate=0.0)
289
+ r = np.array([t["net_return"] for t in trades])
290
+ w, l = r[r > 0], r[r <= 0]
291
+ gp = w.sum() if len(w) else 0.0; gl = abs(l.sum()) if len(l) else 0.0
292
+ eq = np.cumprod(1 + r); peak = np.maximum.accumulate(eq)
293
+ dd = (eq - peak) / peak
294
+ longs = [t for t in trades if t["direction"] == "LONG"]
295
+ shorts = [t for t in trades if t["direction"] == "SHORT"]
296
+ lw = sum(1 for t in longs if t["win"]); sw = sum(1 for t in shorts if t["win"])
297
+ return dict(total_trades=len(r),
298
+ win_rate=len(w) / len(r), profit_factor=gp / gl if gl > 1e-12 else 999.0,
299
+ net_expectancy=float(r.mean()), avg_win=float(w.mean()) if len(w) else 0.0,
300
+ avg_loss=float(l.mean()) if len(l) else 0.0,
301
+ max_drawdown=float(dd.min()) if len(dd) else 0.0,
302
+ net_compounded_return=float(eq[-1] - 1) if len(eq) else 0.0,
303
+ long_trades=len(longs), short_trades=len(shorts),
304
+ long_win_rate=lw / len(longs) if longs else 0.0,
305
+ short_win_rate=sw / len(shorts) if shorts else 0.0)
306
+
307
+
308
+ def regime_table(trades):
309
+ if not trades: return pd.DataFrame(columns=["Regime", "Trades", "WinRate", "PF", "NetExp"])
310
  rows = []
311
+ for reg in REGIME_NAMES:
312
+ rt = [t for t in trades if t["regime"] == reg]
313
  if not rt:
314
+ rows.append({"Regime": reg, "Trades": 0, "WinRate": 0.0, "PF": 0.0, "NetExp": 0.0}); continue
315
+ n = np.array([t["net_return"] for t in rt])
316
+ w, l = n[n > 0], n[n <= 0]
317
+ gp = w.sum() if len(w) else 0.0; gl = abs(l.sum()) if len(l) else 0.0
318
+ rows.append({"Regime": reg, "Trades": len(rt),
319
+ "WinRate": round(len(w) / len(rt), 4),
320
+ "PF": round(gp / gl, 4) if gl > 1e-12 else 999.0,
321
+ "NetExp": round(float(n.mean()), 6)})
 
 
 
 
 
 
 
 
322
  return pd.DataFrame(rows)
323
 
324
 
325
+ def coin_table(trades):
326
+ if not trades: return pd.DataFrame(columns=["Symbol", "Trades", "WinRate", "NetExp", "TotalNet"])
 
 
327
  rows = []
328
+ for sym in sorted({t["symbol"] for t in trades}):
329
  st = [t for t in trades if t["symbol"] == sym]
330
+ n = np.array([t["net_return"] for t in st])
331
+ w = n[n > 0]
332
+ rows.append({"Symbol": sym, "Trades": len(st),
333
+ "WinRate": round(len(w) / len(st), 4),
334
+ "NetExp": round(float(n.mean()), 6),
335
+ "TotalNet": round(float(n.sum()), 6)})
 
 
 
336
  return pd.DataFrame(rows).sort_values("TotalNet", ascending=False)
337
 
338
 
339
+ # ---------- Pipeline (always 6 outputs) ----------
340
+ def run(timeframe, n_coins, thr, lam, mconf, maxhold, progress=gr.Progress()):
341
+ def err(msg, detail=""):
342
+ return msg, detail, pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame()
343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  n_coins = max(30, min(100, int(n_coins)))
345
+ thr = max(0.0, min(1.0, float(thr)))
346
+ lam = max(0.0, min(0.99, float(lam)))
347
+ mconf = max(0.0, min(1.0, float(mconf)))
348
+ maxhold = max(5, min(200, int(maxhold)))
349
 
350
  if timeframe not in ("15m", "1h", "4h", "1d"):
351
+ return err("ERROR: Invalid timeframe")
352
 
 
353
  progress(0.02, desc="Fetching USDT pairs...")
354
+ try: pairs = fetch_pairs(n_coins)
355
+ except Exception as e: return err(f"ERROR fetching pairs: {e}")
 
 
 
356
  if not pairs:
357
+ return err("ERROR: No USDT pairs found.\n\n"
358
+ "Possible causes: Binance API blocked from this region, rate limit, or timeout. "
359
+ "Check Space logs for `[RCEA] ...` messages.")
360
+
361
+ signals, trades, analyzed, failed = [], [], 0, []
362
+ for i, sym in enumerate(pairs):
363
+ progress((i + 1) / len(pairs) * 0.9, desc=f"{sym} ({i+1}/{len(pairs)})")
364
+ time.sleep(0.15)
365
+ df = fetch_klines(sym, timeframe)
366
+ if df is None: failed.append(sym); continue
 
 
 
 
 
 
 
 
 
 
 
 
367
  try:
368
+ f = features(df)
369
+ m = fit_hmm(f)
370
+ if m is None: failed.append(sym); continue
371
+ rp = forward_filter(m, f)
372
+ acc, pl, ps = evidence(f, rp, lam)
373
+ s = latest_signal(f, rp, acc, pl, ps, thr, mconf)
374
+ if s: s["symbol"] = sym; signals.append(s)
375
+ trades.extend(backtest(sym, f, rp, acc, pl, ps, thr, mconf, maxhold))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  analyzed += 1
377
+ except Exception as e:
378
+ print(f"[RCEA] {sym}: {e}", flush=True); failed.append(sym)
 
 
379
  progress(1.0, desc="Done")
380
 
381
+ scan = ["# RCEA Lite Market Scan Report",
382
+ f"**Timeframe:** {timeframe}",
383
+ f"**Coins requested:** {n_coins}",
384
+ f"**Analyzed:** {analyzed}",
385
+ f"**Failed:** {len(failed)}",
386
+ f"**Trades:** {len(trades)}",
387
+ f"**Threshold:** {thr} | **λ:** {lam} | **MinConf:** {mconf} | **MaxHold:** {maxhold}",
388
+ f"**UTC:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')}"]
389
+ if len(trades) < 30:
390
+ scan.append(f"\n⚠️ Only {len(trades)} trades — target ≥30. Metrics are descriptive only.")
391
+ scan_report = "\n".join(scan)
392
+
393
+ if signals:
 
 
 
 
 
 
 
 
 
 
 
394
  sig_df = pd.DataFrame([{
395
+ "Symbol": s["symbol"], "Signal": s["signal"],
 
396
  "Evidence": round(s["evidence"], 4),
397
+ "P(Long)": round(s["P_long"], 4), "P(Short)": round(s["P_short"], 4),
398
+ "Regime": s["dominant_regime"], "Regime Conf": round(s["regime_conf"], 4),
 
 
399
  "P(Trend)": round(s["regime_probs"]["TREND"], 4),
400
  "P(MeanRev)": round(s["regime_probs"]["MEAN_REVERSION"], 4),
401
  "P(Compress)": round(s["regime_probs"]["COMPRESSION"], 4),
402
+ "Close": s["close"]} for s in signals])
403
+ sig_df["_p"] = sig_df["Signal"].map({"LONG": 0, "SHORT": 0, "NO TRADE": 1})
404
+ sig_df = sig_df.sort_values(["_p", "Evidence"], ascending=[True, False]).drop(columns="_p")
 
 
405
  else:
406
  sig_df = pd.DataFrame(columns=["Symbol", "Signal", "Evidence", "P(Long)", "P(Short)",
407
+ "Regime", "Regime Conf", "P(Trend)", "P(MeanRev)",
408
+ "P(Compress)", "Close"])
409
+
410
+ if trades:
411
+ tr_df = pd.DataFrame([{
412
+ "Symbol": t["symbol"], "Direction": t["direction"],
413
+ "Entry Bar": t["entry_bar"], "Exit Bar": t["exit_bar"],
414
+ "Bars": t["bars_held"],
415
+ "Entry": round(t["entry_price"], 6), "Exit": round(t["exit_price"], 6),
416
+ "Stop": round(t["stop_price"], 6), "Target": round(t["target_price"], 6),
417
+ "Reason": t["exit_reason"],
418
+ "Gross": round(t["gross_return"], 6), "Net": round(t["net_return"], 6),
419
+ "Regime": t["regime"], "Regime Conf": round(t["regime_conf"], 4),
420
+ "Win": t["win"]} for t in trades]).sort_values(["Symbol", "Entry Bar"])
 
 
 
 
 
 
 
 
 
421
  else:
422
+ tr_df = pd.DataFrame(columns=["Symbol", "Direction", "Entry Bar", "Exit Bar", "Bars",
423
+ "Entry", "Exit", "Stop", "Target", "Reason",
424
+ "Gross", "Net", "Regime", "Regime Conf", "Win"])
425
+
426
+ m = metrics(trades)
427
+ m_str = "\n".join([
428
+ "# Performance Metrics (net of costs)",
429
+ f"**Total trades:** {m['total_trades']}",
430
+ f"**Win rate:** {m['win_rate']:.2%}",
431
+ f"**Profit factor:** {m['profit_factor']:.3f}",
432
+ f"**Net expectancy:** {m['net_expectancy']:.6f}",
433
+ f"**Avg win / loss:** {m['avg_win']:.6f} / {m['avg_loss']:.6f}",
434
+ f"**Max DD:** {m['max_drawdown']:.2%}",
435
+ f"**Net compounded:** {m['net_compounded_return']:.2%}",
436
+ f"**Long:** {m['long_trades']} ({m['long_win_rate']:.2%}) | "
437
+ f"**Short:** {m['short_trades']} ({m['short_win_rate']:.2%})",
438
+ ])
439
+ if m["total_trades"] < 30:
440
+ m_str += "\n\n⚠️ Insufficient trades for reliable statistics."
441
+
442
+ return scan_report, m_str, sig_df, tr_df, regime_table(trades), coin_table(trades)
443
+
444
+
445
+ # ---------- UI ----------
446
+ with gr.Blocks(title="RCEA Lite v1.0") as demo:
447
+ gr.Markdown("# RCEA Lite v1.0 — Regime-Conditioned Evidence Accumulation\n"
448
+ "HMM regime detection (causal forward filtering) · No supervised ML")
449
+ with gr.Row():
450
+ with gr.Column(scale=1):
451
+ tf = gr.Dropdown(["15m", "1h", "4h", "1d"], value="1h", label="Timeframe")
452
+ nc = gr.Slider(30, 100, value=30, step=1, label="Coins")
453
+ thr_i = gr.Slider(0.0, 1.0, value=0.15, step=0.01, label="Evidence threshold")
454
+ lam_i = gr.Slider(0.0, 0.99, value=0.85, step=0.01, label="Memory λ")
455
+ mc_i = gr.Slider(0.0, 1.0, value=0.40, step=0.01, label="Min regime confidence")
456
+ mh_i = gr.Slider(10, 200, value=48, step=1, label="Max holding (bars)")
457
+ btn = gr.Button("🚀 Run Scan", variant="primary")
458
+ with gr.Column(scale=2):
459
+ scan_out = gr.Markdown(label="Scan Report")
460
+ met_out = gr.Markdown(label="Metrics")
461
+ with gr.Tabs():
462
+ with gr.Tab("📡 Signals"): sig_tbl = gr.Dataframe(interactive=False, wrap=True)
463
+ with gr.Tab("📜 Trades"): tr_tbl = gr.Dataframe(interactive=False, wrap=True)
464
+ with gr.Tab("📊 Regime"): reg_tbl = gr.Dataframe(interactive=False)
465
+ with gr.Tab("🪙 Coins"): coin_tbl = gr.Dataframe(interactive=False)
466
+ btn.click(run, inputs=[tf, nc, thr_i, lam_i, mc_i, mh_i],
467
+ outputs=[scan_out, met_out, sig_tbl, tr_tbl, reg_tbl, coin_tbl])
468
+ gr.Markdown("---\n**Disclaimer:** Research tool. Net of 0.10% commission + 0.05% slippage per side. "
469
+ "No supervised ML prediction is used.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
 
 
 
 
471
 
472
  if __name__ == "__main__":
 
473
  demo.launch(server_name="0.0.0.0", server_port=7860)