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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1088 -1726
app.py CHANGED
@@ -1,1794 +1,1156 @@
 
 
 
1
 
2
- import warnings
3
- warnings.filterwarnings("ignore")
4
-
5
- import time
6
- import numpy as np
7
- import pandas as pd
8
- import ccxt
9
- import gradio as gr
10
- import optuna
11
-
12
- from hmmlearn.hmm import GaussianHMM
13
- from sklearn.preprocessing import StandardScaler
14
-
15
-
16
- # ============================================================
17
- # CONFIG
18
- # ============================================================
19
-
20
- SYMBOL = "BTC/USDT:USDT"
21
- TIMEFRAME = "1h"
22
- TIMEFRAME_MS = 60 * 60 * 1000
23
-
24
- MACRO_FEATURES = [
25
- "log_return",
26
- "realized_volatility",
27
- "range_return"
28
- ]
29
-
30
- MICRO_FEATURES = [
31
- "return_zscore",
32
- "autocorrelation",
33
- "volume_zscore"
34
- ]
35
-
36
- N_MACRO = 3
37
- N_MICRO = 2
38
- N_JOINT = N_MACRO * N_MICRO
39
-
40
- MIN_TRAIN_ROWS = 300
41
- MIN_RELIABLE_TRADES = 30
42
-
43
-
44
- # ============================================================
45
- # OKX DATA
46
- # ============================================================
47
-
48
- def fetch_data(candle_count):
49
-
50
- exchange = ccxt.okx({
51
- "enableRateLimit": True,
52
- "timeout": 30000,
53
- "options": {
54
- "defaultType": "swap"
55
- }
56
- })
57
-
58
- exchange.load_markets()
59
-
60
- if SYMBOL not in exchange.markets:
61
- raise ValueError(
62
- f"{SYMBOL} OKX piyasasında bulunamadı."
63
- )
64
-
65
- now = exchange.milliseconds()
66
-
67
- cursor = now - (
68
- int(candle_count) + 30
69
- ) * TIMEFRAME_MS
70
-
71
- candles = []
72
-
73
- for _ in range(60):
74
-
75
- batch = exchange.fetch_ohlcv(
76
- SYMBOL,
77
- timeframe=TIMEFRAME,
78
- since=cursor,
79
- limit=100
80
- )
81
-
82
- if not batch:
83
- break
84
-
85
- candles.extend(batch)
86
-
87
- last_timestamp = batch[-1][0]
88
-
89
- if last_timestamp < cursor:
90
- break
91
-
92
- cursor = last_timestamp + TIMEFRAME_MS
93
-
94
- if len(candles) >= candle_count + 100:
95
- break
96
-
97
- if last_timestamp >= now - TIMEFRAME_MS:
98
- break
99
-
100
- time.sleep(
101
- exchange.rateLimit / 1000
102
- )
103
-
104
- if not candles:
105
- raise ValueError("OKX veri döndürmedi.")
106
-
107
- df = pd.DataFrame(
108
- candles,
109
- columns=[
110
- "timestamp",
111
- "open",
112
- "high",
113
- "low",
114
- "close",
115
- "volume"
116
- ]
117
- )
118
-
119
- df = (
120
- df.drop_duplicates("timestamp")
121
- .sort_values("timestamp")
122
- .reset_index(drop=True)
123
- )
124
-
125
- now = exchange.milliseconds()
126
-
127
- # Yalnızca kapanmış mumlar
128
- df = df[
129
- df["timestamp"] + TIMEFRAME_MS <= now
130
- ].copy()
131
-
132
- for col in [
133
- "open", "high", "low", "close", "volume"
134
- ]:
135
- df[col] = pd.to_numeric(
136
- df[col],
137
- errors="coerce"
138
- )
139
-
140
- df = df.replace(
141
- [np.inf, -np.inf],
142
- np.nan
143
- ).dropna()
144
-
145
- df = df[
146
- (df["close"] > 0) &
147
- (df["high"] >= df["low"]) &
148
- (df["low"] > 0)
149
- ]
150
-
151
- df = df.tail(
152
- int(candle_count)
153
- ).reset_index(drop=True)
154
-
155
- if len(df) < 500:
156
- raise ValueError(
157
- f"Yeterli veri yok: {len(df)} mum."
158
- )
159
-
160
- df["raw_i"] = np.arange(len(df))
161
-
162
- return df
163
-
164
-
165
- # ============================================================
166
- # CAUSAL MATHEMATICAL FEATURES
167
- # ============================================================
168
-
169
- def create_features(raw):
170
-
171
- df = raw.copy()
172
-
173
- log_close = np.log(df["close"])
174
-
175
- returns = log_close.diff()
176
-
177
- # Macro layer
178
- df["log_return"] = returns
179
-
180
- df["realized_volatility"] = (
181
- returns.rolling(24).std()
182
- )
183
-
184
- df["range_return"] = np.log(
185
- df["high"] / df["low"]
186
- )
187
-
188
- # Micro layer
189
- rolling_mean = returns.rolling(48).mean()
190
-
191
- rolling_std = returns.rolling(48).std()
192
-
193
- df["return_zscore"] = (
194
- (returns - rolling_mean) /
195
- rolling_std.replace(0, np.nan)
196
- )
197
-
198
- df["autocorrelation"] = (
199
- returns.rolling(48).corr(
200
- returns.shift(1)
201
- )
202
- )
203
-
204
- log_volume = np.log1p(
205
- df["volume"].clip(lower=0)
206
- )
207
-
208
- volume_mean = log_volume.rolling(48).mean()
209
-
210
- volume_std = log_volume.rolling(48).std()
211
-
212
- df["volume_zscore"] = (
213
- (log_volume - volume_mean) /
214
- volume_std.replace(0, np.nan)
215
- )
216
-
217
- df = df.replace(
218
- [np.inf, -np.inf],
219
- np.nan
220
- )
221
-
222
- return df
223
-
224
-
225
- # ============================================================
226
- # DATASET
227
- # ============================================================
228
-
229
- def prepare_dataset(raw, horizon):
230
-
231
- df = create_features(raw)
232
-
233
- required = (
234
- MACRO_FEATURES +
235
- MICRO_FEATURES
236
- )
237
-
238
- live = df.dropna(
239
- subset=required
240
- ).copy()
241
-
242
- future_close = live["close"].shift(
243
- -int(horizon)
244
- )
245
-
246
- live["target"] = np.where(
247
- future_close.notna(),
248
- (
249
- future_close > live["close"]
250
- ).astype(float),
251
- np.nan
252
- )
253
-
254
- dataset = live.dropna(
255
- subset=["target"]
256
- ).copy()
257
-
258
- dataset = dataset.reset_index(drop=True)
259
-
260
- live = live.reset_index(drop=True)
261
-
262
- if len(dataset) < MIN_TRAIN_ROWS:
263
- raise ValueError(
264
- f"Yeterli eğitim verisi yok: {len(dataset)}"
265
- )
266
-
267
- return dataset, live
268
-
269
-
270
- # ============================================================
271
- # GAUSSIAN HMM FORWARD FILTER
272
- # ============================================================
273
-
274
- def forward_filter(
275
- model,
276
- X_scaled,
277
- initial_alpha=None
278
- ):
279
-
280
- means = model.means_
281
-
282
- covars = np.asarray(
283
- model.covars_
284
- )
285
-
286
- if covars.ndim == 3:
287
-
288
- variances = np.diagonal(
289
- covars,
290
- axis1=1,
291
- axis2=2
292
- )
293
-
294
- else:
295
-
296
- variances = covars
297
-
298
- variances = np.maximum(
299
- variances,
300
- 1e-8
301
- )
302
-
303
- n_states = model.n_components
304
-
305
- n_features = X_scaled.shape[1]
306
-
307
- log_emissions = np.zeros(
308
- (len(X_scaled), n_states)
309
- )
310
-
311
- # Gaussian emission likelihood
312
- for state in range(n_states):
313
-
314
- diff = (
315
- X_scaled - means[state]
316
- )
317
-
318
- log_emissions[:, state] = -0.5 * (
319
- n_features * np.log(2 * np.pi)
320
- + np.sum(
321
- np.log(variances[state])
322
- )
323
- + np.sum(
324
- diff ** 2 / variances[state],
325
- axis=1
326
- )
327
- )
328
-
329
- transition = model.transmat_
330
-
331
- if initial_alpha is None:
332
-
333
- alpha = model.startprob_.copy()
334
-
335
- first_observation = 0
336
-
337
- else:
338
-
339
- alpha = np.asarray(
340
- initial_alpha,
341
- dtype=float
342
- ).copy()
343
-
344
- first_observation = 0
345
-
346
- posterior = []
347
-
348
- for i in range(len(X_scaled)):
349
-
350
- if i > 0 or initial_alpha is not None:
351
-
352
- alpha = alpha @ transition
353
-
354
- log_alpha = (
355
- np.log(
356
- np.maximum(alpha, 1e-300)
357
- )
358
- + log_emissions[i]
359
- )
360
-
361
- log_alpha -= np.max(log_alpha)
362
-
363
- alpha = np.exp(log_alpha)
364
-
365
- total = alpha.sum()
366
-
367
- if (
368
- total <= 0
369
- or not np.isfinite(total)
370
- ):
371
-
372
- alpha = (
373
- np.ones(n_states) / n_states
374
- )
375
-
376
- else:
377
-
378
- alpha /= total
379
-
380
- posterior.append(
381
- alpha.copy()
382
- )
383
-
384
- posterior = np.asarray(posterior)
385
-
386
- return posterior, alpha.copy()
387
-
388
-
389
- # ============================================================
390
- # HMM LAYER
391
- # ============================================================
392
-
393
- class HMMLayer:
394
-
395
- def __init__(
396
- self,
397
- n_states,
398
- random_state=42
399
- ):
400
-
401
- self.n_states = n_states
402
-
403
- self.scaler = StandardScaler()
404
-
405
- self.model = GaussianHMM(
406
- n_components=n_states,
407
- covariance_type="diag",
408
- n_iter=150,
409
- tol=0.001,
410
- min_covar=1e-4,
411
- random_state=random_state
412
- )
413
-
414
- self.fitted = False
415
-
416
- def fit(self, X):
417
-
418
- X = np.asarray(
419
- X,
420
- dtype=float
421
- )
422
-
423
- X_scaled = self.scaler.fit_transform(
424
- X
425
- )
426
-
427
- self.model.fit(X_scaled)
428
-
429
- self.fitted = True
430
-
431
- return self
432
-
433
- def filter(
434
- self,
435
- X,
436
- initial_alpha=None
437
- ):
438
-
439
- if not self.fitted:
440
- raise RuntimeError(
441
- "HMM katmanı eğitilmedi."
442
- )
443
-
444
- X = np.asarray(
445
- X,
446
- dtype=float
447
- )
448
-
449
- X_scaled = self.scaler.transform(
450
- X
451
- )
452
-
453
- return forward_filter(
454
- self.model,
455
- X_scaled,
456
- initial_alpha
457
- )
458
-
459
-
460
- # ============================================================
461
- # HIERARCHICAL HMM
462
- # ============================================================
463
-
464
- class HierarchicalHMM:
465
-
466
- def __init__(self):
467
-
468
- self.macro = HMMLayer(
469
- N_MACRO,
470
- random_state=42
471
- )
472
-
473
- self.micro = HMMLayer(
474
- N_MICRO,
475
- random_state=73
476
- )
477
-
478
- self.compatibility = None
479
-
480
- def fit(self, train_df):
481
-
482
- macro_X = train_df[
483
- MACRO_FEATURES
484
- ].values
485
-
486
- micro_X = train_df[
487
- MICRO_FEATURES
488
- ].values
489
-
490
- self.macro.fit(macro_X)
491
-
492
- self.micro.fit(micro_X)
493
-
494
- macro_post, self.last_macro = (
495
- self.macro.filter(macro_X)
496
- )
497
-
498
- micro_post, self.last_micro = (
499
- self.micro.filter(micro_X)
500
- )
501
-
502
- # Macro-micro compatibility matrix.
503
- # Estimated only from training observations.
504
- compatibility = np.zeros(
505
- (N_MACRO, N_MICRO)
506
- )
507
-
508
- for m in range(N_MACRO):
509
-
510
- for u in range(N_MICRO):
511
-
512
- compatibility[m, u] = (
513
- np.sum(
514
- macro_post[:, m]
515
- * micro_post[:, u]
516
- )
517
- + 0.5
518
- )
519
-
520
- compatibility /= (
521
- compatibility.sum(
522
- axis=1,
523
- keepdims=True
524
- )
525
- )
526
-
527
- self.compatibility = compatibility
528
-
529
- train_joint = self.combine(
530
- macro_post,
531
- micro_post
532
- )
533
-
534
- return train_joint
535
-
536
- def combine(
537
- self,
538
- macro_post,
539
- micro_post
540
- ):
541
-
542
- joint = np.zeros(
543
- (
544
- len(macro_post),
545
- N_MACRO,
546
- N_MICRO
547
- )
548
- )
549
-
550
- for m in range(N_MACRO):
551
-
552
- weighted_micro = (
553
- micro_post *
554
- self.compatibility[m]
555
- )
556
-
557
- denominator = (
558
- weighted_micro.sum(
559
- axis=1,
560
- keepdims=True
561
- )
562
- )
563
-
564
- conditional_micro = (
565
- weighted_micro /
566
- np.maximum(
567
- denominator,
568
- 1e-12
569
- )
570
- )
571
-
572
- joint[:, m, :] = (
573
- macro_post[:, m, None]
574
- * conditional_micro
575
- )
576
-
577
- joint = joint.reshape(
578
- len(joint),
579
- N_JOINT
580
- )
581
-
582
- joint /= np.maximum(
583
- joint.sum(axis=1, keepdims=True),
584
- 1e-12
585
- )
586
-
587
- return joint
588
-
589
- def predict_future(self, future_df):
590
-
591
- macro_X = future_df[
592
- MACRO_FEATURES
593
- ].values
594
-
595
- micro_X = future_df[
596
- MICRO_FEATURES
597
- ].values
598
-
599
- macro_post, last_macro = (
600
- self.macro.filter(
601
- macro_X,
602
- initial_alpha=self.last_macro
603
- )
604
- )
605
-
606
- micro_post, last_micro = (
607
- self.micro.filter(
608
- micro_X,
609
- initial_alpha=self.last_micro
610
- )
611
- )
612
-
613
- joint = self.combine(
614
- macro_post,
615
- micro_post
616
- )
617
-
618
- self.last_macro = last_macro
619
-
620
- self.last_micro = last_micro
621
-
622
- return joint
623
-
624
-
625
- # ============================================================
626
- # BAYESIAN REGIME MODEL
627
- # ============================================================
628
-
629
- class BayesianRegimeModel:
630
-
631
- def __init__(self):
632
-
633
- self.prior_alpha = 2.0
634
-
635
- self.prior_beta = 2.0
636
-
637
- self.state_probabilities = None
638
-
639
- def fit(
640
- self,
641
- joint_posterior,
642
- targets
643
- ):
644
-
645
- targets = np.asarray(
646
- targets,
647
- dtype=float
648
- )
649
-
650
- probabilities = []
651
-
652
- for state in range(N_JOINT):
653
-
654
- weights = joint_posterior[:, state]
655
-
656
- positive = np.sum(
657
- weights * targets
658
- )
659
-
660
- negative = np.sum(
661
- weights * (1 - targets)
662
- )
663
-
664
- posterior_up = (
665
- self.prior_alpha + positive
666
- ) / (
667
- self.prior_alpha
668
- + self.prior_beta
669
- + positive
670
- + negative
671
- )
672
-
673
- probabilities.append(
674
- posterior_up
675
- )
676
-
677
- self.state_probabilities = np.asarray(
678
- probabilities
679
- )
680
-
681
- return self
682
-
683
- def predict(self, joint_posterior):
684
-
685
- return (
686
- joint_posterior
687
- @ self.state_probabilities
688
- )
689
-
690
-
691
- # ============================================================
692
- # COMPLETE MODEL FIT
693
- # ============================================================
694
-
695
- def fit_predict(
696
- train_df,
697
- future_df
698
- ):
699
-
700
- if len(train_df) < MIN_TRAIN_ROWS:
701
- raise ValueError(
702
- "HMM eğitimi için yeterli veri yok."
703
- )
704
-
705
- hierarchy = HierarchicalHMM()
706
-
707
- train_joint = hierarchy.fit(
708
- train_df
709
- )
710
-
711
- bayesian = BayesianRegimeModel()
712
-
713
- bayesian.fit(
714
- train_joint,
715
- train_df["target"].values
716
- )
717
-
718
- future_joint = hierarchy.predict_future(
719
- future_df
720
- )
721
-
722
- probabilities = bayesian.predict(
723
- future_joint
724
- )
725
-
726
- return {
727
- "probabilities": probabilities,
728
- "joint": future_joint,
729
- "macro_micro_compatibility": hierarchy.compatibility,
730
- "state_probabilities": bayesian.state_probabilities,
731
- "hierarchy": hierarchy
732
- }
733
-
734
-
735
- # ============================================================
736
- # BACKTEST ENGINE
737
- # ============================================================
738
-
739
- def simulate_trades(
740
- raw,
741
- dataset,
742
- indices,
743
- probabilities,
744
- threshold,
745
- stop_multiplier,
746
- horizon,
747
- fee_rate,
748
- slippage_bps
749
- ):
750
-
751
- trades = []
752
-
753
- if len(indices) == 0:
754
- return trades
755
-
756
- last_raw_i = int(
757
- dataset.iloc[indices[-1]]["raw_i"]
758
- )
759
-
760
- cost = (
761
- 2 * fee_rate
762
- + 2 * slippage_bps / 10000
763
- )
764
-
765
- last_exit_i = -1
766
-
767
- for row_i, probability in zip(
768
- indices,
769
- probabilities
770
- ):
771
-
772
- row = dataset.iloc[row_i]
773
-
774
- signal_raw_i = int(
775
- row["raw_i"]
776
- )
777
-
778
- entry_i = signal_raw_i + 1
779
-
780
- exit_limit = (
781
- entry_i + int(horizon) - 1
782
- )
783
-
784
- if exit_limit > last_raw_i:
785
- continue
786
-
787
- if exit_limit >= len(raw):
788
- continue
789
-
790
- if entry_i <= last_exit_i:
791
- continue
792
-
793
- if probability >= threshold:
794
-
795
- direction = 1
796
-
797
- elif probability <= 1 - threshold:
798
-
799
- direction = -1
800
-
801
- else:
802
-
803
- continue
804
-
805
- entry_price = float(
806
- raw.iloc[entry_i]["open"]
807
- )
808
-
809
- volatility = float(
810
- row["realized_volatility"]
811
- )
812
-
813
- if (
814
- not np.isfinite(volatility)
815
- or volatility <= 0
816
- ):
817
- continue
818
-
819
- risk_pct = np.clip(
820
- volatility
821
- * np.sqrt(horizon)
822
- * stop_multiplier,
823
- 0.002,
824
- 0.05
825
- )
826
-
827
- distance = (
828
- entry_price * risk_pct
829
- )
830
-
831
- if direction == 1:
832
-
833
- stop = entry_price - distance
834
-
835
- target = entry_price + 2 * distance
836
-
837
- else:
838
-
839
- stop = entry_price + distance
840
-
841
- target = entry_price - 2 * distance
842
-
843
- exit_price = None
844
-
845
- reason = "TIME"
846
-
847
- for j in range(
848
- entry_i,
849
- exit_limit + 1
850
- ):
851
-
852
- candle = raw.iloc[j]
853
-
854
- high = float(candle["high"])
855
-
856
- low = float(candle["low"])
857
-
858
- if direction == 1:
859
-
860
- hit_stop = low <= stop
861
-
862
- hit_target = high >= target
863
-
864
- else:
865
-
866
- hit_stop = high >= stop
867
-
868
- hit_target = low <= target
869
-
870
- # Conservative same-candle rule.
871
- if hit_stop:
872
-
873
- exit_price = stop
874
-
875
- reason = "STOP"
876
-
877
- break
878
-
879
- if hit_target:
880
-
881
- exit_price = target
882
-
883
- reason = "TARGET"
884
-
885
- break
886
-
887
- if exit_price is None:
888
-
889
- exit_price = float(
890
- raw.iloc[exit_limit]["close"]
891
- )
892
-
893
- if direction == 1:
894
-
895
- gross = (
896
- exit_price / entry_price - 1
897
- )
898
-
899
- else:
900
-
901
- gross = (
902
- entry_price / exit_price - 1
903
- )
904
-
905
- net = gross - cost
906
-
907
- trades.append({
908
- "direction": (
909
- "LONG" if direction == 1
910
- else "SHORT"
911
- ),
912
- "entry": entry_price,
913
- "exit": exit_price,
914
- "probability": float(probability),
915
- "reason": reason,
916
- "gross_return": gross,
917
- "net_return": net
918
- })
919
-
920
- last_exit_i = exit_limit
921
-
922
- return trades
923
-
924
-
925
- # ============================================================
926
- # METRICS
927
- # ============================================================
928
-
929
- def calculate_metrics(trades):
930
-
931
- if not trades:
932
-
933
- return {
934
- "trades": 0,
935
- "win_rate": 0.0,
936
- "profit_factor": 0.0,
937
- "net_return": 0.0,
938
- "max_drawdown": 0.0
939
- }
940
-
941
- returns = np.asarray(
942
- [t["net_return"] for t in trades],
943
- dtype=float
944
- )
945
-
946
- wins = returns[returns > 0]
947
-
948
- losses = returns[returns < 0]
949
-
950
- if len(losses):
951
-
952
- pf = (
953
- wins.sum()
954
- / abs(losses.sum())
955
- )
956
-
957
- elif len(wins):
958
-
959
- pf = 999.0
960
-
961
- else:
962
-
963
- pf = 0.0
964
-
965
- equity = np.cumprod(
966
- 1 + returns
967
- )
968
-
969
- peaks = np.maximum.accumulate(
970
- np.concatenate([
971
- [1.0],
972
- equity
973
- ])
974
- )[1:]
975
-
976
- drawdown = (
977
- 1 - equity / peaks
978
- )
979
-
980
- return {
981
- "trades": len(trades),
982
- "win_rate": float(
983
- np.mean(returns > 0)
984
- ),
985
- "profit_factor": float(pf),
986
- "net_return": float(
987
- equity[-1] - 1
988
- ),
989
- "max_drawdown": float(
990
- np.max(drawdown)
991
- )
992
- }
993
-
994
-
995
- # ============================================================
996
- # OPTUNA DEVELOPMENT
997
- # ============================================================
998
 
999
- def optimize_parameters(
1000
- dataset,
1001
- raw,
1002
- horizon,
1003
- fee_rate,
1004
- slippage_bps,
1005
- trials
1006
- ):
1007
 
1008
- n = len(dataset)
 
 
 
 
 
1009
 
1010
- dev_end = int(n * 0.60)
 
 
 
 
1011
 
1012
- development = dataset.iloc[
1013
- :dev_end
1014
- ].reset_index(drop=True)
1015
 
1016
- if len(development) < MIN_TRAIN_ROWS + 150:
1017
- raise ValueError(
1018
- "Optimizasyon için daha fazla veri gerekli."
1019
- )
1020
 
1021
- # Three chronological development folds.
1022
- boundaries = np.linspace(
1023
- int(len(development) * 0.50),
1024
- len(development),
1025
- 4,
1026
- dtype=int
1027
- )
1028
 
1029
- def objective(trial):
 
 
1030
 
1031
- threshold = trial.suggest_float(
1032
- "threshold",
1033
- 0.54,
1034
- 0.72
1035
- )
1036
 
1037
- stop_multiplier = trial.suggest_float(
1038
- "stop_multiplier",
1039
- 0.8,
1040
- 2.2
1041
- )
1042
 
1043
- fold_scores = []
 
 
 
 
1044
 
1045
- total_trades = 0
 
 
 
1046
 
1047
- for k in range(3):
 
 
1048
 
1049
- val_start = boundaries[k]
 
 
 
1050
 
1051
- val_end = boundaries[k + 1]
1052
 
1053
- train_end = val_start - horizon
 
 
1054
 
1055
- if train_end < MIN_TRAIN_ROWS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
  continue
1057
-
1058
- train_df = development.iloc[
1059
- :train_end
1060
- ].reset_index(drop=True)
1061
-
1062
- # Includes the purge/gap observations.
1063
- future_df = development.iloc[
1064
- train_end:val_end
1065
- ].reset_index(drop=True)
1066
-
1067
- result = fit_predict(
1068
- train_df,
1069
- future_df
1070
- )
1071
-
1072
- offset = val_start - train_end
1073
-
1074
- val_probabilities = (
1075
- result["probabilities"][offset:]
1076
- )
1077
-
1078
- # Map development-local indices to original dataset.
1079
- global_start = val_start
1080
-
1081
- global_end = val_end
1082
-
1083
- indices = np.arange(
1084
- global_start,
1085
- global_end
1086
- )
1087
-
1088
- trades = simulate_trades(
1089
- raw,
1090
- dataset,
1091
- indices,
1092
- val_probabilities,
1093
- threshold,
1094
- stop_multiplier,
1095
- horizon,
1096
- fee_rate,
1097
- slippage_bps
1098
- )
1099
-
1100
- metrics = calculate_metrics(
1101
- trades
1102
- )
1103
-
1104
- total_trades += metrics["trades"]
1105
-
1106
- score = (
1107
- metrics["net_return"] * 100
1108
- + min(metrics["profit_factor"], 4) * 1.5
1109
- + metrics["win_rate"] * 2
1110
- - metrics["max_drawdown"] * 20
1111
- )
1112
-
1113
- fold_scores.append(score)
1114
-
1115
- if not fold_scores:
1116
- return -100.0
1117
-
1118
- # Avoid selecting a model that makes almost no trades.
1119
- if total_trades < 8:
1120
- return (
1121
- float(np.mean(fold_scores))
1122
- - (8 - total_trades) * 2
1123
- )
1124
-
1125
- return float(
1126
- np.mean(fold_scores)
1127
- )
1128
-
1129
- study = optuna.create_study(
1130
- direction="maximize",
1131
- sampler=optuna.samplers.TPESampler(
1132
- seed=42
1133
- )
1134
- )
1135
-
1136
- study.optimize(
1137
- objective,
1138
- n_trials=int(trials),
1139
- show_progress_bar=False
1140
- )
1141
-
1142
- return study.best_params, study.best_value
1143
-
1144
-
1145
- # ============================================================
1146
- # WALK-FORWARD EVALUATION
1147
- # ============================================================
1148
-
1149
- def walk_forward_evaluation(
1150
- dataset,
1151
- raw,
1152
- horizon,
1153
- params,
1154
- fee_rate,
1155
- slippage_bps
1156
- ):
1157
-
1158
- n = len(dataset)
1159
-
1160
- start = int(n * 0.60)
1161
-
1162
- end = int(n * 0.80)
1163
-
1164
- boundaries = np.linspace(
1165
- start,
1166
- end,
1167
- 5,
1168
- dtype=int
1169
- )
1170
-
1171
- all_trades = []
1172
-
1173
- fold_reports = []
1174
-
1175
- for fold in range(4):
1176
-
1177
- val_start = boundaries[fold]
1178
-
1179
- val_end = boundaries[fold + 1]
1180
-
1181
- train_end = val_start - horizon
1182
-
1183
- train_df = dataset.iloc[
1184
- :train_end
1185
- ].reset_index(drop=True)
1186
-
1187
- future_df = dataset.iloc[
1188
- train_end:val_end
1189
- ].reset_index(drop=True)
1190
-
1191
- result = fit_predict(
1192
- train_df,
1193
- future_df
1194
- )
1195
-
1196
- offset = val_start - train_end
1197
-
1198
- probabilities = (
1199
- result["probabilities"][offset:]
1200
- )
1201
-
1202
- indices = np.arange(
1203
- val_start,
1204
- val_end
1205
- )
1206
-
1207
- trades = simulate_trades(
1208
- raw,
1209
- dataset,
1210
- indices,
1211
- probabilities,
1212
- params["threshold"],
1213
- params["stop_multiplier"],
1214
- horizon,
1215
- fee_rate,
1216
- slippage_bps
1217
- )
1218
-
1219
- metrics = calculate_metrics(
1220
- trades
1221
- )
1222
-
1223
- fold_reports.append({
1224
- "fold": fold + 1,
1225
- **metrics
1226
  })
1227
 
1228
- all_trades.extend(trades)
1229
-
1230
- return (
1231
- calculate_metrics(all_trades),
1232
- fold_reports
1233
- )
1234
-
1235
-
1236
- # ============================================================
1237
- # INDEPENDENT HOLDOUT
1238
- # ============================================================
1239
-
1240
- def independent_holdout(
1241
- dataset,
1242
- raw,
1243
- horizon,
1244
- params,
1245
- fee_rate,
1246
- slippage_bps
1247
- ):
1248
-
1249
- n = len(dataset)
1250
-
1251
- split = int(n * 0.80)
1252
-
1253
- train_end = split - horizon
1254
-
1255
- train_df = dataset.iloc[
1256
- :train_end
1257
- ].reset_index(drop=True)
1258
-
1259
- # Includes purge observations before holdout.
1260
- future_df = dataset.iloc[
1261
- train_end:n
1262
- ].reset_index(drop=True)
1263
-
1264
- result = fit_predict(
1265
- train_df,
1266
- future_df
1267
- )
1268
-
1269
- offset = split - train_end
1270
-
1271
- probabilities = (
1272
- result["probabilities"][offset:]
1273
- )
1274
-
1275
- indices = np.arange(
1276
- split,
1277
- n
1278
- )
1279
-
1280
- trades = simulate_trades(
1281
- raw,
1282
- dataset,
1283
- indices,
1284
- probabilities,
1285
- params["threshold"],
1286
- params["stop_multiplier"],
1287
- horizon,
1288
- fee_rate,
1289
- slippage_bps
1290
- )
1291
-
1292
- return (
1293
- calculate_metrics(trades),
1294
- trades
1295
- )
1296
-
1297
-
1298
- # ============================================================
1299
- # OVERFIT DIAGNOSTIC
1300
- # ============================================================
1301
-
1302
- def overfit_diagnostic(wf, holdout):
1303
-
1304
- if (
1305
- wf["trades"] < MIN_RELIABLE_TRADES
1306
- or holdout["trades"] < MIN_RELIABLE_TRADES
1307
- ):
1308
-
1309
- return (
1310
- None,
1311
- "YETERSİZ VERİ",
1312
- "Her iki test bölümünde de en az 30 işlem gerekli."
1313
- )
1314
-
1315
- win_gap = abs(
1316
- wf["win_rate"] - holdout["win_rate"]
1317
- )
1318
-
1319
- pf_gap = abs(
1320
- min(wf["profit_factor"], 5)
1321
- - min(holdout["profit_factor"], 5)
1322
- ) / 5
1323
-
1324
- return_gap = abs(
1325
- wf["net_return"] - holdout["net_return"]
1326
- )
1327
-
1328
- score = (
1329
- win_gap * 40
1330
- + pf_gap * 35
1331
- + min(return_gap, 1) * 25
1332
- )
1333
-
1334
- score = float(
1335
- np.clip(score, 0, 100)
1336
- )
1337
-
1338
- if score < 20:
1339
- label = "DÜŞÜK FARK"
1340
-
1341
- elif score < 40:
1342
- label = "ORTA FARK"
1343
-
1344
- elif score < 65:
1345
- label = "YÜKSEK FARK"
1346
-
1347
- else:
1348
- label = "ÇOK YÜKSEK FARK"
1349
-
1350
- return (
1351
- score,
1352
- label,
1353
- "Bu değer gerçek overfit olasılığı değil, test bölümleri arasındaki performans farkıdır."
1354
- )
1355
-
1356
-
1357
- # ============================================================
1358
- # LIVE PREDICTION
1359
- # ============================================================
1360
-
1361
- def live_prediction(
1362
- dataset,
1363
- live,
1364
- horizon,
1365
- params
1366
- ):
1367
-
1368
- train_df = dataset.copy()
1369
-
1370
- train_end_raw_i = int(
1371
- train_df["raw_i"].iloc[-1]
1372
  )
1373
 
1374
- future_df = live[
1375
- live["raw_i"] > train_end_raw_i
1376
- ].copy()
1377
-
1378
- if future_df.empty:
1379
- raise ValueError(
1380
- "Canlı tahmin için yeni tamamlanmış mum bulunamadı."
1381
- )
1382
-
1383
- future_df = future_df.reset_index(
1384
- drop=True
1385
- )
1386
-
1387
- result = fit_predict(
1388
- train_df,
1389
- future_df
1390
- )
1391
-
1392
- probability = float(
1393
- result["probabilities"][-1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1394
  )
1395
-
1396
- threshold = params["threshold"]
1397
-
1398
- if probability >= threshold:
1399
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1400
  signal = "LONG"
1401
-
1402
- elif probability <= 1 - threshold:
1403
-
1404
  signal = "SHORT"
1405
 
1406
- else:
1407
-
1408
- signal = "NO TRADE"
1409
-
1410
- latest = future_df.iloc[-1]
1411
-
1412
- price = float(
1413
- latest["close"]
1414
- )
1415
-
1416
- volatility = float(
1417
- latest["realized_volatility"]
1418
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1419
 
1420
- risk_pct = np.clip(
1421
- volatility
1422
- * np.sqrt(horizon)
1423
- * params["stop_multiplier"],
1424
- 0.002,
1425
- 0.05
1426
- )
1427
 
1428
- distance = price * risk_pct
 
 
 
 
1429
 
1430
- if signal == "LONG":
 
1431
 
1432
- stop = price - distance
 
 
 
 
 
 
 
 
1433
 
1434
- target = price + 2 * distance
 
 
1435
 
1436
- elif signal == "SHORT":
 
 
 
 
 
1437
 
1438
- stop = price + distance
 
1439
 
1440
- target = price - 2 * distance
 
 
1441
 
1442
- else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1443
 
1444
- stop = None
1445
 
1446
- target = None
1447
 
1448
- joint = result["joint"][-1]
 
 
1449
 
1450
- macro_prob = joint.reshape(
1451
- N_MACRO,
1452
- N_MICRO
1453
- ).sum(axis=1)
 
 
 
 
 
1454
 
1455
- micro_prob = joint.reshape(
1456
- N_MACRO,
1457
- N_MICRO
1458
- ).sum(axis=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1459
 
1460
  return {
1461
- "signal": signal,
1462
- "long_probability": probability,
1463
- "short_probability": 1 - probability,
1464
- "price": price,
1465
- "stop": stop,
1466
- "target": target,
1467
- "risk_pct": risk_pct,
1468
- "macro": macro_prob,
1469
- "micro": micro_prob,
1470
- "joint": joint,
1471
- "state_probabilities": result[
1472
- "state_probabilities"
1473
- ]
1474
  }
1475
 
1476
 
1477
- # ============================================================
1478
- # MAIN APPLICATION
1479
- # ============================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1480
 
1481
- def run_app(
1482
- candle_count,
1483
- horizon,
1484
- trials,
1485
- fee_percent,
1486
- slippage_bps
1487
- ):
1488
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1489
  try:
1490
-
1491
- optuna.logging.set_verbosity(
1492
- optuna.logging.WARNING
1493
- )
1494
-
1495
- fee_rate = (
1496
- float(fee_percent) / 100
1497
- )
1498
-
1499
- raw = fetch_data(
1500
- int(candle_count)
1501
- )
1502
-
1503
- dataset, live = prepare_dataset(
1504
- raw,
1505
- int(horizon)
1506
- )
1507
-
1508
- # 1. Optimize only on first 60%.
1509
- params, best_score = optimize_parameters(
1510
- dataset,
1511
- raw,
1512
- int(horizon),
1513
- fee_rate,
1514
- float(slippage_bps),
1515
- int(trials)
1516
- )
1517
-
1518
- # 2. Walk-forward: 60%-80%.
1519
- wf, fold_reports = walk_forward_evaluation(
1520
- dataset,
1521
- raw,
1522
- int(horizon),
1523
- params,
1524
- fee_rate,
1525
- float(slippage_bps)
1526
- )
1527
-
1528
- # 3. Independent holdout: last 20%.
1529
- holdout, holdout_trades = independent_holdout(
1530
- dataset,
1531
- raw,
1532
- int(horizon),
1533
- params,
1534
- fee_rate,
1535
- float(slippage_bps)
1536
- )
1537
-
1538
- # 4. Overfit diagnostic.
1539
- overfit_score, overfit_label, overfit_note = (
1540
- overfit_diagnostic(
1541
- wf,
1542
- holdout
1543
- )
1544
- )
1545
-
1546
- # 5. Live model.
1547
- live_result = live_prediction(
1548
- dataset,
1549
- live,
1550
- int(horizon),
1551
- params
1552
- )
1553
-
1554
- last_timestamp = pd.to_datetime(
1555
- int(raw.iloc[-1]["timestamp"]),
1556
- unit="ms",
1557
- utc=True
1558
- )
1559
-
1560
- macro = live_result["macro"]
1561
-
1562
- micro = live_result["micro"]
1563
-
1564
- state_probs = live_result[
1565
- "state_probabilities"
1566
- ]
1567
-
1568
- joint = live_result["joint"]
1569
-
1570
- def pct(x):
1571
- return f"%{100*x:.2f}"
1572
-
1573
- def price_text(x):
1574
- if x is None:
1575
- return "Yok"
1576
- return f"{x:,.2f} USDT"
1577
-
1578
- report = f"""
1579
- # OKX BTC/USDT — HIERARCHICAL QUANT
1580
-
1581
- **Model:** Hierarchical HMM + Bayesian Regime Model
1582
-
1583
- ## Canlı tahmin
1584
-
1585
- # {live_result['signal']}
1586
-
1587
- | Metrik | Sonuç |
1588
- |---|---:|
1589
- | BTC fiyatı | {price_text(live_result['price'])} |
1590
- | LONG olasılığı | {pct(live_result['long_probability'])} |
1591
- | SHORT olasılığı | {pct(live_result['short_probability'])} |
1592
- | Stop-loss | {price_text(live_result['stop'])} |
1593
- | 2R hedef | {price_text(live_result['target'])} |
1594
- | Risk mesafesi | {pct(live_result['risk_pct'])} |
1595
- | Son mum | {last_timestamp} |
1596
-
1597
- ## Üst katman — Macro HMM
1598
-
1599
- | Gizli durum | Olasılık |
1600
- |---|---:|
1601
- | Macro 1 | {pct(macro[0])} |
1602
- | Macro 2 | {pct(macro[1])} |
1603
- | Macro 3 | {pct(macro[2])} |
1604
-
1605
- Bu durumların sırası her eğitimde değişebilir. Model bunları önceden bullish/bearish diye etiketlemez.
1606
-
1607
- ## Alt katman — Micro HMM
1608
-
1609
- | Gizli durum | Olasılık |
1610
- |---|---:|
1611
- | Micro 1 | {pct(micro[0])} |
1612
- | Micro 2 | {pct(micro[1])} |
1613
-
1614
- ## Birleşik altı rejim
1615
-
1616
- | Rejim | Olasılık | Bayesian LONG |
1617
- |---|---:|---:|
1618
- | M1-U1 | {pct(joint[0])} | {pct(state_probs[0])} |
1619
- | M1-U2 | {pct(joint[1])} | {pct(state_probs[1])} |
1620
- | M2-U1 | {pct(joint[2])} | {pct(state_probs[2])} |
1621
- | M2-U2 | {pct(joint[3])} | {pct(state_probs[3])} |
1622
- | M3-U1 | {pct(joint[4])} | {pct(state_probs[4])} |
1623
- | M3-U2 | {pct(joint[5])} | {pct(state_probs[5])} |
1624
-
1625
- ## Walk-forward — %60 ile %80 arası
1626
-
1627
- | Metrik | Sonuç |
1628
- |---|---:|
1629
- | İşlem sayısı | {wf['trades']} |
1630
- | Win rate | {pct(wf['win_rate'])} |
1631
- | Profit Factor | {wf['profit_factor']:.2f} |
1632
- | Net getiri | {pct(wf['net_return'])} |
1633
- | Max drawdown | {pct(wf['max_drawdown'])} |
1634
-
1635
- ## Bağımsız holdout — Son %20
1636
-
1637
- | Metrik | Sonuç |
1638
- |---|---:|
1639
- | İşlem sayısı | {holdout['trades']} |
1640
- | Win rate | {pct(holdout['win_rate'])} |
1641
- | Profit Factor | {holdout['profit_factor']:.2f} |
1642
- | Net getiri | {pct(holdout['net_return'])} |
1643
- | Max drawdown | {pct(holdout['max_drawdown'])} |
1644
-
1645
- ## Overfit fark göstergesi
1646
-
1647
- **{overfit_label}**
1648
-
1649
- {(
1650
- f"{overfit_score:.1f}/100"
1651
- if overfit_score is not None
1652
- else "Hesaplanamadı"
1653
- )}
1654
-
1655
- {overfit_note}
1656
-
1657
- ## Optuna parametreleri
1658
-
1659
- | Parametre | Değer |
1660
- |---|---:|
1661
- | LONG/SHORT eşiği | {params['threshold']:.4f} |
1662
- | Stop multiplier | {params['stop_multiplier']:.4f} |
1663
- | Optuna skoru | {best_score:.4f} |
1664
-
1665
- ## Walk-forward fold detayları
1666
-
1667
- | Fold | İşlem | Win rate | PF | Net getiri |
1668
- |---|---:|---:|---:|---:|
1669
- """
1670
-
1671
- for fold in fold_reports:
1672
-
1673
- report += (
1674
- f"| {fold['fold']} "
1675
- f"| {fold['trades']} "
1676
- f"| {pct(fold['win_rate'])} "
1677
- f"| {fold['profit_factor']:.2f} "
1678
- f"| {pct(fold['net_return'])} |\n"
1679
- )
1680
-
1681
- report += """
1682
- ---
1683
-
1684
- ### Model sınırlamaları
1685
-
1686
- - Rejimler gizlidir; Macro 1'in daima bullish olması beklenmemelidir.
1687
- - Forward filtering kullanılır; gelecekteki mumlar canlı rejim olasılığına girmez.
1688
- - HMM parametreleri her eğitim döneminde yalnızca o dönemin eğitim verisiyle öğrenilir.
1689
- - Holdout verisi Optuna optimizasyonunda kullanılmaz.
1690
- - 30'dan az işlem varsa performans istatistikleri güvenilir kabul edilmemelidir.
1691
- - Backtest funding, likidasyon, gerçek spread ve emir defteri derinliğini tam modellemez.
1692
- - 2R hedefi ve sonuçlar garanti edilmiş değildir.
1693
- """
1694
-
1695
- return report
1696
-
1697
  except Exception as e:
 
1698
 
1699
- return (
1700
- "# UYGULAMA HATASI\n\n"
1701
- f"`{type(e).__name__}: {str(e)}`"
1702
- )
1703
-
1704
 
1705
- # ============================================================
1706
- # GRADIO UI
1707
- # ============================================================
1708
-
1709
- with gr.Blocks(
1710
- title="OKX Hierarchical Bayesian Quant",
1711
- theme=gr.themes.Soft()
1712
- ) as demo:
1713
 
1714
- gr.Markdown(
1715
- """
1716
- # BTC/USDT Hierarchical Bayesian Quant
 
1717
 
1718
- **Macro HMM → Micro HMM → 6 latent regimes → Bayesian LONG/SHORT**
 
 
 
1719
 
1720
- OKX perpetual swap · 1H · Long + Short
 
 
1721
 
1722
- Klasik RSI, MACD, EMA veya Supertrend kullanılmaz.
1723
- """
1724
- )
 
 
 
1725
 
1726
- with gr.Row():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1727
 
1728
- candle_count = gr.Slider(
1729
- minimum=1000,
1730
- maximum=5000,
1731
- value=3000,
1732
- step=500,
1733
- label="OKX mum sayısı"
1734
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1735
 
1736
- horizon = gr.Slider(
1737
- minimum=2,
1738
- maximum=24,
1739
- value=6,
1740
- step=1,
1741
- label="Tahmin ufku (saat)"
1742
- )
1743
 
1744
- with gr.Row():
 
 
 
 
1745
 
1746
- trials = gr.Slider(
1747
- minimum=5,
1748
- maximum=40,
1749
- value=10,
1750
- step=5,
1751
- label="Optuna denemesi"
1752
- )
1753
 
1754
- fee_percent = gr.Slider(
1755
- minimum=0,
1756
- maximum=0.20,
1757
- value=0.05,
1758
- step=0.01,
1759
- label="Komisyon % / taraf"
1760
  )
1761
 
1762
- slippage_bps = gr.Slider(
1763
- minimum=0,
1764
- maximum=20,
1765
- value=2,
1766
- step=1,
1767
- label="Kayma baz puan / taraf"
1768
- )
1769
 
1770
- run_button = gr.Button(
1771
- "HIERARCHICAL QUANT ANALİZİ",
1772
- variant="primary"
1773
- )
1774
 
1775
- output = gr.Markdown()
1776
-
1777
- run_button.click(
1778
- fn=run_app,
1779
- inputs=[
1780
- candle_count,
1781
- horizon,
1782
- trials,
1783
- fee_percent,
1784
- slippage_bps
1785
- ],
1786
- outputs=output
1787
- )
1788
 
 
 
 
1789
 
1790
  if __name__ == "__main__":
1791
-
1792
- demo.queue()
1793
-
1794
- demo.launch()
 
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
21
+ import requests
22
+ import gradio as gr
23
+ 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)