3VVM commited on
Commit
007eead
Β·
verified Β·
1 Parent(s): e653ca3

Upload 10 files

Browse files
Files changed (3) hide show
  1. README.md +9 -5
  2. app.py +779 -0
  3. requirements.txt +4 -0
README.md CHANGED
@@ -1,10 +1,14 @@
1
  ---
2
  title: Vekalm
3
- emoji: πŸš€
4
- colorFrom: pink
5
- colorTo: yellow
6
- sdk: docker
 
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
  title: Vekalm
3
+ emoji: πŸ“ˆ
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 5.9.1
8
+ app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # Vekalm β€” Forex + Crypto Signal Engine
13
+
14
+ Gradio-based signal engine (Yahoo Finance data, no API key needed).
app.py ADDED
@@ -0,0 +1,779 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ============================================================================
3
+ PROFESSIONAL FOREX + CRYPTO INDICATOR & SIGNAL ENGINE (single-file)
4
+ ============================================================================
5
+ Target : Hugging Face Free Tier (2 vCPU / 16 GB RAM, no GPU)
6
+ Data source : Yahoo Finance via `yfinance` (free, no API key)
7
+ UI : Gradio (works as a Hugging Face Space out of the box)
8
+
9
+ DESIGN GUARANTEES
10
+ -----------------
11
+ * NO lookahead bias : any analysis at time T uses ONLY candles whose
12
+ close time is <= T. The forming candle is dropped.
13
+ * NO repainting : swing pivots require N confirmation bars, all
14
+ higher-timeframe data is shifted by one full
15
+ HTF bar before being merged down.
16
+ * Memory efficient : float32 columns, bounded cache, capped history.
17
+ * CPU efficient : fully vectorised numpy/pandas indicator and
18
+ scoring pipeline (no per-bar Python loops except
19
+ the trade simulator, which is O(n)).
20
+
21
+ HONEST LIMITATIONS
22
+ ------------------
23
+ * Yahoo Finance intraday history is limited (~7 days for 1m,
24
+ ~60 days for 2m-1h). Deep intraday backtests are not possible
25
+ with free data.
26
+ * Forex volume from Yahoo is unreliable/absent -> volume confirmation
27
+ is automatically skipped when volume is missing.
28
+ * No signal engine can guarantee profit. This engine maximises
29
+ robustness via multi-factor confirmation, but markets are
30
+ non-stationary: past performance never guarantees future results.
31
+ ============================================================================
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import math
37
+ import threading
38
+ import time as _time
39
+ from dataclasses import dataclass, field
40
+ from datetime import datetime, timedelta, timezone
41
+ from typing import Optional
42
+
43
+ import numpy as np
44
+ import pandas as pd
45
+
46
+ try:
47
+ import yfinance as yf
48
+ except ImportError as _e: # pragma: no cover
49
+ raise SystemExit("Missing dependency. Run: pip install yfinance gradio pandas numpy") from _e
50
+
51
+ # ============================================================================
52
+ # 1. CONFIGURATION
53
+ # ============================================================================
54
+
55
+ # timeframe -> (yfinance base interval, resample rule or None, pandas offset)
56
+ TIMEFRAMES: dict[str, tuple[str, Optional[str], timedelta]] = {
57
+ "1m": ("1m", None, timedelta(minutes=1)),
58
+ "2m": ("2m", None, timedelta(minutes=2)),
59
+ "5m": ("5m", None, timedelta(minutes=5)),
60
+ "10m": ("5m", "10min", timedelta(minutes=10)), # resampled from 5m
61
+ "15m": ("15m", None, timedelta(minutes=15)),
62
+ "30m": ("30m", None, timedelta(minutes=30)),
63
+ "1h": ("60m", None, timedelta(hours=1)),
64
+ "2h": ("60m", "2h", timedelta(hours=2)), # resampled from 1h
65
+ "1d": ("1d", None, timedelta(days=1)),
66
+ "2d": ("1d", "2D", timedelta(days=2)), # resampled from 1d
67
+ "5d": ("1d", "5D", timedelta(days=5)), # resampled from 1d
68
+ }
69
+
70
+ # max lookback yfinance allows per base interval
71
+ MAX_PERIOD: dict[str, str] = {
72
+ "1m": "7d", "2m": "60d", "5m": "60d", "15m": "60d",
73
+ "30m": "60d", "60m": "730d", "1d": "max",
74
+ }
75
+
76
+ # higher timeframe used for multi-timeframe confirmation
77
+ HTF_MAP: dict[str, str] = {
78
+ "1m": "15m", "2m": "15m", "5m": "30m", "10m": "1h", "15m": "1h",
79
+ "30m": "2h", "1h": "1d", "2h": "1d", "1d": "5d", "2d": "5d", "5d": "5d",
80
+ }
81
+
82
+ RISK_PROFILES: dict[str, dict[str, float]] = {
83
+ "Conservative": {"sl_atr": 2.0, "rr": 1.5, "min_adx": 22.0},
84
+ "Balanced": {"sl_atr": 1.5, "rr": 2.0, "min_adx": 18.0},
85
+ "Aggressive": {"sl_atr": 1.2, "rr": 2.5, "min_adx": 14.0},
86
+ }
87
+
88
+ PIVOT_CONFIRM = 3 # bars needed to confirm a swing pivot (anti-repaint)
89
+ MAX_ROWS = 20_000 # hard cap on candles kept in memory per dataframe
90
+ CACHE_TTL = 55.0 # seconds a downloaded dataframe stays fresh
91
+ CACHE_MAX_ENTRIES = 24 # bounded cache -> bounded memory
92
+
93
+ POPULAR_SYMBOLS = [
94
+ "EURUSD=X", "GBPUSD=X", "USDJPY=X", "AUDUSD=X", "USDCAD=X", "USDCHF=X",
95
+ "NZDUSD=X", "EURGBP=X", "EURJPY=X", "GBPJPY=X",
96
+ "BTC-USD", "ETH-USD", "SOL-USD", "BNB-USD", "XRP-USD", "ADA-USD",
97
+ "DOGE-USD", "AVAX-USD", "DOT-USD", "LINK-USD",
98
+ ]
99
+
100
+ # ============================================================================
101
+ # 2. DATA FEED (download + resample + bounded cache)
102
+ # ============================================================================
103
+
104
+ class DataFeed:
105
+ """Thread-safe, TTL-bounded market data feed built on yfinance."""
106
+
107
+ def __init__(self) -> None:
108
+ self._cache: dict[tuple[str, str], tuple[float, pd.DataFrame]] = {}
109
+ self._lock = threading.Lock()
110
+
111
+ # ---- public ------------------------------------------------------
112
+
113
+ def get(self, symbol: str, timeframe: str) -> pd.DataFrame:
114
+ """Return an OHLCV dataframe (UTC index) for symbol/timeframe."""
115
+ symbol = symbol.strip().upper()
116
+ if timeframe not in TIMEFRAMES:
117
+ raise ValueError(f"Unsupported timeframe '{timeframe}'.")
118
+ key = (symbol, timeframe)
119
+ now = _time.time()
120
+ with self._lock:
121
+ hit = self._cache.get(key)
122
+ if hit and now - hit[0] < CACHE_TTL:
123
+ return hit[1]
124
+ df = self._download(symbol, timeframe)
125
+ with self._lock:
126
+ if len(self._cache) >= CACHE_MAX_ENTRIES:
127
+ oldest = min(self._cache, key=lambda k: self._cache[k][0])
128
+ self._cache.pop(oldest, None)
129
+ self._cache[key] = (now, df)
130
+ return df
131
+
132
+ # ---- internals ---------------------------------------------------
133
+
134
+ @staticmethod
135
+ def _download(symbol: str, timeframe: str) -> pd.DataFrame:
136
+ base, rule, _ = TIMEFRAMES[timeframe]
137
+ period = MAX_PERIOD[base]
138
+ raw = yf.download(
139
+ tickers=symbol, interval=base, period=period,
140
+ auto_adjust=False, progress=False, threads=False,
141
+ )
142
+ if raw is None or raw.empty:
143
+ raise ValueError(
144
+ f"No data returned for '{symbol}'. "
145
+ "Check the symbol (forex: 'EURUSD=X', crypto: 'BTC-USD')."
146
+ )
147
+ if isinstance(raw.columns, pd.MultiIndex):
148
+ raw.columns = raw.columns.get_level_values(0)
149
+ df = raw[["Open", "High", "Low", "Close", "Volume"]].copy()
150
+ df.columns = ["open", "high", "low", "close", "volume"]
151
+ df = df.dropna(subset=["open", "high", "low", "close"])
152
+ # normalise index to UTC
153
+ idx = pd.DatetimeIndex(df.index)
154
+ df.index = idx.tz_convert("UTC") if idx.tz is not None else idx.tz_localize("UTC")
155
+ df = df[~df.index.duplicated(keep="last")].sort_index()
156
+ if rule:
157
+ df = (
158
+ df.resample(rule, label="left", closed="left")
159
+ .agg({"open": "first", "high": "max", "low": "min",
160
+ "close": "last", "volume": "sum"})
161
+ .dropna(subset=["open", "high", "low", "close"])
162
+ )
163
+ for c in ("open", "high", "low", "close", "volume"):
164
+ df[c] = df[c].astype(np.float32)
165
+ if len(df) > MAX_ROWS:
166
+ df = df.iloc[-MAX_ROWS:]
167
+ return df
168
+
169
+
170
+ FEED = DataFeed()
171
+
172
+
173
+ def slice_no_lookahead(df: pd.DataFrame, timeframe: str,
174
+ as_of: Optional[datetime]) -> pd.DataFrame:
175
+ """
176
+ Keep only candles fully CLOSED at `as_of`.
177
+ A candle opening at t with interval dt is usable only if t + dt <= as_of.
178
+ Passing None means "now" (the forming candle is still dropped).
179
+ """
180
+ dt = TIMEFRAMES[timeframe][2]
181
+ cutoff = (as_of or datetime.now(timezone.utc))
182
+ if cutoff.tzinfo is None:
183
+ cutoff = cutoff.replace(tzinfo=timezone.utc)
184
+ return df[df.index + dt <= cutoff]
185
+
186
+ # ============================================================================
187
+ # 3. INDICATORS (all vectorised, all causal)
188
+ # ============================================================================
189
+
190
+ def _ema(s: pd.Series, n: int) -> pd.Series:
191
+ return s.ewm(span=n, adjust=False, min_periods=n).mean()
192
+
193
+
194
+ def _rma(s: pd.Series, n: int) -> pd.Series:
195
+ """Wilder's smoothing (used by RSI / ATR / ADX)."""
196
+ return s.ewm(alpha=1.0 / n, adjust=False, min_periods=n).mean()
197
+
198
+
199
+ def add_indicators(df: pd.DataFrame) -> pd.DataFrame:
200
+ """Attach every indicator column. Pure function of PAST data only."""
201
+ o, h, l, c, v = df["open"], df["high"], df["low"], df["close"], df["volume"]
202
+
203
+ df["ema20"] = _ema(c, 20)
204
+ df["ema50"] = _ema(c, 50)
205
+ df["ema200"] = _ema(c, 200)
206
+
207
+ # RSI(14)
208
+ delta = c.diff()
209
+ gain = _rma(delta.clip(lower=0), 14)
210
+ loss = _rma((-delta).clip(lower=0), 14)
211
+ rs = gain / loss.replace(0, np.nan)
212
+ df["rsi"] = 100 - 100 / (1 + rs)
213
+
214
+ # MACD(12,26,9)
215
+ macd = _ema(c, 12) - _ema(c, 26)
216
+ sig = macd.ewm(span=9, adjust=False, min_periods=9).mean()
217
+ df["macd"], df["macd_sig"], df["macd_hist"] = macd, sig, macd - sig
218
+
219
+ # True range / ATR(14)
220
+ prev_c = c.shift(1)
221
+ tr = pd.concat([h - l, (h - prev_c).abs(), (l - prev_c).abs()], axis=1).max(axis=1)
222
+ df["atr"] = _rma(tr, 14)
223
+
224
+ # ADX(14)
225
+ up, dn = h.diff(), -l.diff()
226
+ plus_dm = pd.Series(np.where((up > dn) & (up > 0), up, 0.0), index=df.index)
227
+ minus_dm = pd.Series(np.where((dn > up) & (dn > 0), dn, 0.0), index=df.index)
228
+ atr_safe = df["atr"].replace(0, np.nan)
229
+ pdi = 100 * _rma(plus_dm, 14) / atr_safe
230
+ mdi = 100 * _rma(minus_dm, 14) / atr_safe
231
+ dx = 100 * (pdi - mdi).abs() / (pdi + mdi).replace(0, np.nan)
232
+ df["pdi"], df["mdi"], df["adx"] = pdi, mdi, _rma(dx, 14)
233
+
234
+ # Bollinger(20, 2)
235
+ mid = c.rolling(20).mean()
236
+ std = c.rolling(20).std(ddof=0)
237
+ df["bb_mid"], df["bb_up"], df["bb_lo"] = mid, mid + 2 * std, mid - 2 * std
238
+ df["bb_width"] = (df["bb_up"] - df["bb_lo"]) / mid.replace(0, np.nan)
239
+
240
+ # Rolling VWAP(50) β€” session VWAP is meaningless for 24h markets
241
+ has_volume = bool((v > 0).mean() > 0.5)
242
+ df.attrs["has_volume"] = has_volume
243
+ if has_volume:
244
+ tp = (h + l + c) / 3
245
+ pv = (tp * v).rolling(50).sum()
246
+ vv = v.rolling(50).sum().replace(0, np.nan)
247
+ df["vwap"] = pv / vv
248
+ df["vol_ma"] = v.rolling(20).mean()
249
+ else:
250
+ df["vwap"] = np.nan
251
+ df["vol_ma"] = np.nan
252
+
253
+ # --- Confirmed swing pivots (anti-repaint: confirmed PIVOT_CONFIRM bars later)
254
+ k = PIVOT_CONFIRM
255
+ win = 2 * k + 1
256
+ roll_max = h.rolling(win, center=True).max()
257
+ roll_min = l.rolling(win, center=True).min()
258
+ is_ph = (h == roll_max)
259
+ is_pl = (l == roll_min)
260
+ # value known only k bars AFTER the pivot -> shift by k
261
+ ph = h.where(is_ph).shift(k)
262
+ pl = l.where(is_pl).shift(k)
263
+ df["last_ph"] = ph.ffill() # most recent CONFIRMED swing high
264
+ df["last_pl"] = pl.ffill() # most recent CONFIRMED swing low
265
+ df["prev_ph"] = ph.dropna().shift(1).reindex(df.index).ffill()
266
+ df["prev_pl"] = pl.dropna().shift(1).reindex(df.index).ffill()
267
+
268
+ # --- Candlestick patterns (vectorised, causal)
269
+ body = (c - o).abs()
270
+ rng = (h - l).replace(0, np.nan)
271
+ upper = h - np.maximum(c, o)
272
+ lower = np.minimum(c, o) - l
273
+ po, pc = o.shift(1), c.shift(1)
274
+ df["pat_bull_engulf"] = ((pc < po) & (c > o) & (c >= po) & (o <= pc)).astype(np.int8)
275
+ df["pat_bear_engulf"] = ((pc > po) & (c < o) & (c <= po) & (o >= pc)).astype(np.int8)
276
+ df["pat_hammer"] = ((lower > 2 * body) & (upper < body) & (body / rng < 0.4)).astype(np.int8)
277
+ df["pat_star"] = ((upper > 2 * body) & (lower < body) & (body / rng < 0.4)).astype(np.int8)
278
+ return df
279
+
280
+ # ============================================================================
281
+ # 4. SCORING ENGINE (vectorised multi-factor confluence, -100 .. +100)
282
+ # ============================================================================
283
+
284
+ WEIGHTS = {
285
+ "trend": 28.0, "momentum": 22.0, "structure": 20.0,
286
+ "location": 14.0, "pattern": 8.0, "volume": 8.0,
287
+ }
288
+
289
+
290
+ def compute_scores(df: pd.DataFrame) -> pd.DataFrame:
291
+ """Attach per-factor scores in [-1, 1] and total 'score' in [-100, 100]."""
292
+ c = df["close"]
293
+ n = len(df)
294
+ z = lambda: np.zeros(n, dtype=np.float32)
295
+
296
+ # ---- Trend: EMA stack + ADX direction
297
+ t = z()
298
+ t += np.where(c > df["ema20"], 0.25, -0.25)
299
+ t += np.where(df["ema20"] > df["ema50"], 0.25, -0.25)
300
+ t += np.where(df["ema50"] > df["ema200"], 0.25, -0.25)
301
+ adx_dir = np.sign((df["pdi"] - df["mdi"]).to_numpy(np.float32))
302
+ adx_str = np.clip((df["adx"].to_numpy(np.float32) - 15) / 25, 0, 1)
303
+ t += 0.25 * adx_dir * adx_str
304
+ df["s_trend"] = np.clip(t, -1, 1)
305
+
306
+ # ---- Momentum: RSI regime + MACD histogram
307
+ rsi = df["rsi"].to_numpy(np.float32)
308
+ m = z()
309
+ m += np.clip((rsi - 50) / 25, -1, 1) * 0.5
310
+ hist = df["macd_hist"].to_numpy(np.float32)
311
+ atr = df["atr"].to_numpy(np.float32)
312
+ with np.errstate(divide="ignore", invalid="ignore"):
313
+ m += np.clip(np.nan_to_num(hist / (atr * 0.5)), -1, 1) * 0.5
314
+ # fade extremes: overbought longs / oversold shorts are penalised
315
+ m = np.where((rsi > 74) & (m > 0), m * 0.3, m)
316
+ m = np.where((rsi < 26) & (m < 0), m * 0.3, m)
317
+ df["s_momentum"] = np.clip(m, -1, 1)
318
+
319
+ # ---- Market structure: HH/HL vs LH/LL from confirmed pivots
320
+ hh = (df["last_ph"] > df["prev_ph"]).to_numpy()
321
+ hl = (df["last_pl"] > df["prev_pl"]).to_numpy()
322
+ lh = (df["last_ph"] < df["prev_ph"]).to_numpy()
323
+ ll = (df["last_pl"] < df["prev_pl"]).to_numpy()
324
+ s = z()
325
+ s = np.where(hh & hl, 1.0, s)
326
+ s = np.where(lh & ll, -1.0, s)
327
+ s = np.where(hh & ~hl, 0.4, s)
328
+ s = np.where(ll & ~lh, -0.4, s)
329
+ df["s_structure"] = s
330
+
331
+ # ---- Location: distance to confirmed S/R + Bollinger position
332
+ cv = c.to_numpy(np.float32)
333
+ atr_safe = np.where(atr > 0, atr, np.nan)
334
+ d_res = (df["last_ph"].to_numpy(np.float32) - cv) / atr_safe # room above
335
+ d_sup = (cv - df["last_pl"].to_numpy(np.float32)) / atr_safe # room below
336
+ loc = z()
337
+ loc += np.clip(np.nan_to_num(d_res) / 3, -1, 1) * 0.5 # room to run up
338
+ loc -= np.clip(np.nan_to_num(d_sup) / 3, -1, 1) * 0.5 # room to run down
339
+ bb_rng = (df["bb_up"] - df["bb_lo"]).to_numpy(np.float32)
340
+ with np.errstate(divide="ignore", invalid="ignore"):
341
+ bb_pos = np.nan_to_num((cv - df["bb_lo"].to_numpy(np.float32)) / bb_rng, nan=0.5)
342
+ loc += (0.5 - np.clip(bb_pos, 0, 1)) # cheap = bullish location
343
+ df["s_location"] = np.clip(loc, -1, 1)
344
+
345
+ # ---- Candle patterns
346
+ p = z()
347
+ p += 0.6 * df["pat_bull_engulf"].to_numpy(np.float32)
348
+ p -= 0.6 * df["pat_bear_engulf"].to_numpy(np.float32)
349
+ p += 0.4 * df["pat_hammer"].to_numpy(np.float32)
350
+ p -= 0.4 * df["pat_star"].to_numpy(np.float32)
351
+ df["s_pattern"] = np.clip(p, -1, 1)
352
+
353
+ # ---- Volume confirmation (skipped when volume is unavailable)
354
+ if df.attrs.get("has_volume"):
355
+ with np.errstate(divide="ignore", invalid="ignore"):
356
+ vr = np.nan_to_num(df["volume"].to_numpy(np.float32)
357
+ / df["vol_ma"].to_numpy(np.float32), nan=1.0)
358
+ candle_dir = np.sign((df["close"] - df["open"]).to_numpy(np.float32))
359
+ df["s_volume"] = np.clip(candle_dir * np.clip(vr - 1, -0.8, 1.5), -1, 1)
360
+ vwap_bias = np.sign(cv - df["vwap"].to_numpy(np.float32))
361
+ df["s_volume"] = np.clip(df["s_volume"] + 0.25 * np.nan_to_num(vwap_bias), -1, 1)
362
+ else:
363
+ df["s_volume"] = 0.0
364
+
365
+ total = z()
366
+ for name, w in WEIGHTS.items():
367
+ total += w * df[f"s_{name}"].to_numpy(np.float32)
368
+ df["score"] = np.clip(total, -100, 100)
369
+
370
+ # ---- Regime / noise filters (kill signals in bad conditions)
371
+ bbw = df["bb_width"].to_numpy(np.float32)
372
+ bbw_med = df["bb_width"].rolling(100, min_periods=30).median().to_numpy(np.float32)
373
+ squeeze = bbw < bbw_med * 0.55 # dead / illiquid market
374
+ atr_pct = atr / np.where(cv > 0, cv, np.nan)
375
+ spike = atr_pct > np.nanmedian(atr_pct) * 4 # abnormal volatility spike
376
+ df["filter_ok"] = (~squeeze & ~np.nan_to_num(spike, nan=True)).astype(np.int8)
377
+ return df
378
+
379
+
380
+ def htf_trend_series(symbol: str, base_tf: str,
381
+ base_index: pd.DatetimeIndex) -> pd.Series:
382
+ """
383
+ Higher-timeframe trend (-1/0/+1) aligned onto the base timeframe index.
384
+ Shifted by ONE full HTF bar so only fully closed HTF candles are used.
385
+ """
386
+ htf = HTF_MAP[base_tf]
387
+ try:
388
+ hdf = FEED.get(symbol, htf)
389
+ except Exception:
390
+ return pd.Series(0.0, index=base_index)
391
+ if len(hdf) < 60:
392
+ return pd.Series(0.0, index=base_index)
393
+ e_fast = _ema(hdf["close"], 20)
394
+ e_slow = _ema(hdf["close"], 50)
395
+ trend = pd.Series(
396
+ np.sign((e_fast - e_slow).to_numpy(np.float32)), index=hdf.index
397
+ ).shift(1) # anti-lookahead shift
398
+ # HTF bar timestamped t is usable only after t + htf_interval
399
+ trend.index = trend.index + TIMEFRAMES[htf][2]
400
+ return trend.reindex(trend.index.union(base_index)).ffill().reindex(base_index).fillna(0.0)
401
+
402
+ # ============================================================================
403
+ # 5. SIGNAL CONSTRUCTION
404
+ # ============================================================================
405
+
406
+ @dataclass
407
+ class Signal:
408
+ symbol: str
409
+ timeframe: str
410
+ time: pd.Timestamp
411
+ action: str # BUY / SELL / HOLD
412
+ confidence: float # 0..100
413
+ score: float
414
+ entry: float = 0.0
415
+ stop: float = 0.0
416
+ target: float = 0.0
417
+ rr: float = 0.0
418
+ reasons: list[str] = field(default_factory=list)
419
+ components: dict[str, float] = field(default_factory=dict)
420
+ htf_trend: float = 0.0
421
+ filtered: bool = False
422
+
423
+
424
+ def build_signal(df: pd.DataFrame, symbol: str, timeframe: str,
425
+ threshold: float, risk: str,
426
+ htf: pd.Series) -> Signal:
427
+ """Build the signal for the LAST (most recent fully closed) candle."""
428
+ row = df.iloc[-1]
429
+ ts = df.index[-1]
430
+ profile = RISK_PROFILES[risk]
431
+ score = float(row["score"])
432
+ comp = {k: round(float(row[f"s_{k}"]), 2) for k in WEIGHTS}
433
+ ht = float(htf.iloc[-1]) if len(htf) else 0.0
434
+
435
+ sig = Signal(symbol=symbol, timeframe=timeframe, time=ts, action="HOLD",
436
+ confidence=0.0, score=round(score, 1), components=comp, htf_trend=ht)
437
+
438
+ reasons: list[str] = []
439
+ direction = 1 if score >= threshold else (-1 if score <= -threshold else 0)
440
+
441
+ if direction == 0:
442
+ reasons.append(f"Confluence score {score:+.1f} inside neutral band Β±{threshold:.0f}.")
443
+ if not bool(row["filter_ok"]):
444
+ sig.filtered = True
445
+ reasons.append("Regime filter active: volatility squeeze or abnormal spike β€” standing aside.")
446
+ direction = 0
447
+ if direction != 0 and float(row["adx"]) < profile["min_adx"]:
448
+ reasons.append(f"ADX {row['adx']:.1f} below {profile['min_adx']:.0f} minimum ({risk}) β€” trend too weak.")
449
+ direction = 0
450
+ if direction != 0 and ht != 0 and np.sign(direction) != np.sign(ht):
451
+ reasons.append(f"Higher timeframe ({HTF_MAP[timeframe]}) trend opposes the signal β€” vetoed.")
452
+ direction = 0
453
+
454
+ if direction == 0:
455
+ sig.reasons = reasons or ["No edge detected at this candle."]
456
+ return sig
457
+
458
+ atr = float(row["atr"])
459
+ entry = float(row["close"])
460
+ sl_dist = profile["sl_atr"] * atr
461
+ # anchor stop behind confirmed structure when it is tighter than pure ATR
462
+ if direction > 0 and not math.isnan(row["last_pl"]):
463
+ sl_dist = min(sl_dist * 1.4, max(sl_dist, entry - float(row["last_pl"]) + 0.25 * atr))
464
+ if direction < 0 and not math.isnan(row["last_ph"]):
465
+ sl_dist = min(sl_dist * 1.4, max(sl_dist, float(row["last_ph"]) - entry + 0.25 * atr))
466
+ tp_dist = sl_dist * profile["rr"]
467
+
468
+ sig.action = "BUY" if direction > 0 else "SELL"
469
+ sig.entry = entry
470
+ sig.stop = entry - direction * sl_dist
471
+ sig.target = entry + direction * tp_dist
472
+ sig.rr = round(tp_dist / sl_dist, 2)
473
+
474
+ conf = min(abs(score), 100.0)
475
+ if ht != 0 and np.sign(ht) == direction:
476
+ conf = min(conf + 10, 100)
477
+ reasons.append(f"Higher timeframe ({HTF_MAP[timeframe]}) trend agrees (+10 confidence).")
478
+ sig.confidence = round(conf, 1)
479
+
480
+ label = {"trend": "Trend", "momentum": "Momentum", "structure": "Structure",
481
+ "location": "S/R location", "pattern": "Candles", "volume": "Volume"}
482
+ for k, val in comp.items():
483
+ if val * direction > 0.15:
484
+ reasons.append(f"{label[k]} supports {sig.action} ({val:+.2f}).")
485
+ elif val * direction < -0.15:
486
+ reasons.append(f"{label[k]} disagrees ({val:+.2f}) β€” sized into confidence.")
487
+ reasons.append(f"ADX {row['adx']:.1f} confirms a tradeable trend regime.")
488
+ sig.reasons = reasons
489
+ return sig
490
+
491
+ # ============================================================================
492
+ # 6. BACKTESTER (bar-by-bar replay of the exact same signal logic)
493
+ # ============================================================================
494
+
495
+ @dataclass
496
+ class BacktestResult:
497
+ trades: pd.DataFrame
498
+ stats: dict[str, float | int | str]
499
+ monthly: pd.DataFrame
500
+
501
+
502
+ def run_backtest(df: pd.DataFrame, htf: pd.Series, threshold: float,
503
+ risk: str, max_hold: int = 200) -> BacktestResult:
504
+ """
505
+ Replays history: a signal on candle i is entered at the OPEN of candle
506
+ i+1 (no cheating), then walked forward until SL/TP/max-hold using
507
+ conservative intrabar assumptions (if both SL & TP touch -> count SL).
508
+ Results are reported in R-multiples (risk-normalised, symbol agnostic).
509
+ """
510
+ profile = RISK_PROFILES[risk]
511
+ sc = df["score"].to_numpy(np.float32)
512
+ adx = df["adx"].to_numpy(np.float32)
513
+ fok = df["filter_ok"].to_numpy(bool)
514
+ atr = df["atr"].to_numpy(np.float32)
515
+ o = df["open"].to_numpy(np.float32)
516
+ h = df["high"].to_numpy(np.float32)
517
+ l = df["low"].to_numpy(np.float32)
518
+ c = df["close"].to_numpy(np.float32)
519
+ ht = htf.reindex(df.index).fillna(0.0).to_numpy(np.float32)
520
+
521
+ long_ok = (sc >= threshold) & fok & (adx >= profile["min_adx"]) & (ht >= 0)
522
+ short_ok = (sc <= -threshold) & fok & (adx >= profile["min_adx"]) & (ht <= 0)
523
+
524
+ rows = []
525
+ n = len(df)
526
+ i = 200 # warmup for EMA200
527
+ while i < n - 2:
528
+ d = 1 if long_ok[i] else (-1 if short_ok[i] else 0)
529
+ if d == 0 or not np.isfinite(atr[i]) or atr[i] <= 0:
530
+ i += 1
531
+ continue
532
+ entry = o[i + 1]
533
+ sl_dist = profile["sl_atr"] * atr[i]
534
+ tp_dist = sl_dist * profile["rr"]
535
+ sl = entry - d * sl_dist
536
+ tp = entry + d * tp_dist
537
+ outcome, exit_px, j = 0.0, c[min(i + max_hold, n - 1)], i + 1
538
+ for j in range(i + 1, min(i + 1 + max_hold, n)):
539
+ hit_sl = l[j] <= sl if d > 0 else h[j] >= sl
540
+ hit_tp = h[j] >= tp if d > 0 else l[j] <= tp
541
+ if hit_sl: # conservative: SL first
542
+ outcome, exit_px = -1.0, sl
543
+ break
544
+ if hit_tp:
545
+ outcome, exit_px = profile["rr"], tp
546
+ break
547
+ else:
548
+ j = min(i + max_hold, n - 1)
549
+ exit_px = c[j]
550
+ outcome = d * (exit_px - entry) / sl_dist
551
+ rows.append({
552
+ "entry_time": df.index[i + 1], "exit_time": df.index[j],
553
+ "side": "BUY" if d > 0 else "SELL", "entry": float(entry),
554
+ "exit": float(exit_px), "r_multiple": round(float(outcome), 3),
555
+ "bars_held": int(j - i),
556
+ })
557
+ i = j + 1 # one position at a time
558
+
559
+ trades = pd.DataFrame(rows)
560
+ if trades.empty:
561
+ return BacktestResult(trades, {"trades": 0, "note": "No trades generated β€” try a lower threshold."}, pd.DataFrame())
562
+
563
+ r = trades["r_multiple"].to_numpy()
564
+ wins, losses = r[r > 0], r[r <= 0]
565
+ equity = np.cumsum(r)
566
+ peak = np.maximum.accumulate(equity)
567
+ dd = equity - peak
568
+ gross_w = wins.sum() if len(wins) else 0.0
569
+ gross_l = -losses.sum() if len(losses) else 0.0
570
+
571
+ def _streak(mask: np.ndarray) -> int:
572
+ best = cur = 0
573
+ for m in mask:
574
+ cur = cur + 1 if m else 0
575
+ best = max(best, cur)
576
+ return best
577
+
578
+ stats = {
579
+ "trades": int(len(r)),
580
+ "win_rate_%": round(100 * len(wins) / len(r), 1),
581
+ "loss_rate_%": round(100 * len(losses) / len(r), 1),
582
+ "profit_factor": round(gross_w / gross_l, 2) if gross_l > 0 else float("inf"),
583
+ "expectancy_R": round(float(r.mean()), 3),
584
+ "avg_win_R": round(float(wins.mean()), 3) if len(wins) else 0.0,
585
+ "avg_loss_R": round(float(losses.mean()), 3) if len(losses) else 0.0,
586
+ "total_R": round(float(r.sum()), 2),
587
+ "max_drawdown_R": round(float(dd.min()), 2),
588
+ "sharpe_like": round(float(r.mean() / r.std() * math.sqrt(len(r))), 2) if r.std() > 0 else 0.0,
589
+ "max_consec_wins": _streak(r > 0),
590
+ "max_consec_losses": _streak(r <= 0),
591
+ "avg_bars_held": round(float(trades["bars_held"].mean()), 1),
592
+ }
593
+ monthly = (
594
+ trades.set_index("entry_time")["r_multiple"]
595
+ .resample("ME").agg(["count", "sum"])
596
+ .rename(columns={"count": "trades", "sum": "net_R"}).round(2)
597
+ )
598
+ monthly = monthly[monthly["trades"] > 0]
599
+ return BacktestResult(trades, stats, monthly)
600
+
601
+ # ============================================================================
602
+ # 7. HIGH-LEVEL API
603
+ # ============================================================================
604
+
605
+ def analyze(symbol: str, timeframe: str, as_of_text: str,
606
+ threshold: float, risk: str) -> Signal:
607
+ as_of = _parse_time(as_of_text)
608
+ df = slice_no_lookahead(FEED.get(symbol, timeframe), timeframe, as_of)
609
+ if len(df) < 220:
610
+ raise ValueError(
611
+ f"Only {len(df)} closed candles available at that time β€” need β‰₯220 "
612
+ "(EMA200 warmup). Pick a later time or a smaller timeframe."
613
+ )
614
+ df = compute_scores(add_indicators(df.copy()))
615
+ htf = htf_trend_series(symbol, timeframe, df.index)
616
+ return build_signal(df, symbol.upper(), timeframe, threshold, risk, htf)
617
+
618
+
619
+ def backtest(symbol: str, timeframe: str, threshold: float, risk: str,
620
+ lookback_bars: int) -> BacktestResult:
621
+ df = slice_no_lookahead(FEED.get(symbol, timeframe), timeframe, None)
622
+ if lookback_bars > 0:
623
+ df = df.iloc[-min(lookback_bars, len(df)):]
624
+ if len(df) < 260:
625
+ raise ValueError(f"Only {len(df)} candles available β€” not enough for a meaningful backtest.")
626
+ df = compute_scores(add_indicators(df.copy()))
627
+ htf = htf_trend_series(symbol, timeframe, df.index)
628
+ return run_backtest(df, htf, threshold, risk)
629
+
630
+
631
+ def _parse_time(text: str) -> Optional[datetime]:
632
+ text = (text or "").strip()
633
+ if not text or text.lower() in ("now", "latest", "live"):
634
+ return None
635
+ for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%d-%m-%Y %H:%M", "%H:%M"):
636
+ try:
637
+ dt = datetime.strptime(text, fmt)
638
+ if fmt == "%H:%M": # today at HH:MM UTC
639
+ now = datetime.now(timezone.utc)
640
+ return now.replace(hour=dt.hour, minute=dt.minute, second=0, microsecond=0)
641
+ return dt.replace(tzinfo=timezone.utc)
642
+ except ValueError:
643
+ continue
644
+ raise ValueError("Time format not recognised. Use 'YYYY-MM-DD HH:MM' (UTC), 'HH:MM', or leave blank for live.")
645
+
646
+ # ============================================================================
647
+ # 8. GRADIO UI
648
+ # ============================================================================
649
+
650
+ def _fmt_price(x: float) -> str:
651
+ if x == 0:
652
+ return "-"
653
+ return f"{x:.6f}" if abs(x) < 10 else f"{x:,.4f}" if abs(x) < 1000 else f"{x:,.2f}"
654
+
655
+
656
+ def _signal_md(s: Signal) -> str:
657
+ icon = {"BUY": "🟒", "SELL": "πŸ”΄", "HOLD": "🟑"}[s.action]
658
+ lines = [
659
+ f"## {icon} {s.action} β€” {s.symbol} ({s.timeframe})",
660
+ f"**Candle close (UTC):** {s.time}",
661
+ f"**Confluence score:** {s.score:+.1f} / Β±100 | **Confidence:** {s.confidence:.1f}%",
662
+ "",
663
+ ]
664
+ if s.action != "HOLD":
665
+ lines += [
666
+ "| Entry | Stop Loss | Take Profit | R : R |",
667
+ "|---|---|---|---|",
668
+ f"| {_fmt_price(s.entry)} | {_fmt_price(s.stop)} | {_fmt_price(s.target)} | 1 : {s.rr} |",
669
+ "",
670
+ ]
671
+ lines.append("**Factor breakdown** (βˆ’1 bearish … +1 bullish):")
672
+ lines.append("| Trend | Momentum | Structure | Location | Candles | Volume | HTF |")
673
+ lines.append("|---|---|---|---|---|---|---|")
674
+ c = s.components
675
+ htf_lbl = "Up" if s.htf_trend > 0 else "Down" if s.htf_trend < 0 else "Flat"
676
+ lines.append(
677
+ f"| {c['trend']:+.2f} | {c['momentum']:+.2f} | {c['structure']:+.2f} "
678
+ f"| {c['location']:+.2f} | {c['pattern']:+.2f} | {c['volume']:+.2f} | {htf_lbl} |"
679
+ )
680
+ lines.append("\n**Reasoning:**")
681
+ lines += [f"- {r}" for r in s.reasons]
682
+ lines.append("\n> ⚠️ Educational tool. No signal engine guarantees profit β€” always manage risk.")
683
+ return "\n".join(lines)
684
+
685
+
686
+ def _stats_md(res: BacktestResult, symbol: str, timeframe: str) -> str:
687
+ if not res.stats.get("trades"):
688
+ return f"### Backtest β€” {symbol} ({timeframe})\n\n{res.stats.get('note', 'No trades.')}"
689
+ st = res.stats
690
+ lines = [
691
+ f"### Backtest β€” {symbol} ({timeframe})",
692
+ "",
693
+ "| Metric | Value | Metric | Value |",
694
+ "|---|---|---|---|",
695
+ f"| Trades | {st['trades']} | Win rate | {st['win_rate_%']}% |",
696
+ f"| Profit factor | {st['profit_factor']} | Expectancy | {st['expectancy_R']} R |",
697
+ f"| Total | {st['total_R']} R | Max drawdown | {st['max_drawdown_R']} R |",
698
+ f"| Avg win | {st['avg_win_R']} R | Avg loss | {st['avg_loss_R']} R |",
699
+ f"| Sharpe-like | {st['sharpe_like']} | Avg bars held | {st['avg_bars_held']} |",
700
+ f"| Max consec. wins | {st['max_consec_wins']} | Max consec. losses | {st['max_consec_losses']} |",
701
+ ]
702
+ if not res.monthly.empty:
703
+ lines += ["", "**Monthly:**", "", "| Month | Trades | Net R |", "|---|---|---|"]
704
+ for ts, row in res.monthly.iterrows():
705
+ lines.append(f"| {ts.strftime('%Y-%m')} | {int(row['trades'])} | {row['net_R']:+.2f} |")
706
+ lines.append("\n> Results are in R-multiples with conservative intrabar assumptions (ambiguous bars count as losses). Free Yahoo data limits intraday history depth.")
707
+ return "\n".join(lines)
708
+
709
+
710
+ def launch_ui() -> None:
711
+ import gradio as gr
712
+
713
+ def ui_analyze(symbol, custom, timeframe, when, threshold, risk):
714
+ try:
715
+ sym = (custom or "").strip() or symbol
716
+ return _signal_md(analyze(sym, timeframe, when, float(threshold), risk))
717
+ except Exception as e:
718
+ return f"### ❌ Error\n\n{e}"
719
+
720
+ def ui_backtest(symbol, custom, timeframe, threshold, risk, bars):
721
+ try:
722
+ sym = (custom or "").strip() or symbol
723
+ res = backtest(sym, timeframe, float(threshold), risk, int(bars))
724
+ trades_df = res.trades.tail(200) if not res.trades.empty else pd.DataFrame()
725
+ return _stats_md(res, sym.upper(), timeframe), trades_df
726
+ except Exception as e:
727
+ return f"### ❌ Error\n\n{e}", pd.DataFrame()
728
+
729
+ def ui_compare(symbol, custom, threshold, risk, bars):
730
+ sym = (custom or "").strip() or symbol
731
+ rows = []
732
+ for tf in ("15m", "30m", "1h", "1d"):
733
+ try:
734
+ st = backtest(sym, tf, float(threshold), risk, int(bars)).stats
735
+ if st.get("trades"):
736
+ rows.append({"timeframe": tf, **{k: st[k] for k in
737
+ ("trades", "win_rate_%", "profit_factor", "expectancy_R", "total_R", "max_drawdown_R")}})
738
+ else:
739
+ rows.append({"timeframe": tf, "trades": 0})
740
+ except Exception as e:
741
+ rows.append({"timeframe": tf, "trades": f"error: {e}"})
742
+ return pd.DataFrame(rows)
743
+
744
+ with gr.Blocks(title="Forex + Crypto Signal Engine") as demo:
745
+ gr.Markdown("# πŸ“ˆ Forex + Crypto Signal Engine\n"
746
+ "Multi-factor confluence signals with strict no-lookahead / no-repaint guarantees. "
747
+ "Data: Yahoo Finance (free). Educational use only.")
748
+ with gr.Row():
749
+ sym_dd = gr.Dropdown(POPULAR_SYMBOLS, value="BTC-USD", label="Symbol")
750
+ sym_tx = gr.Textbox(label="…or custom symbol", placeholder="e.g. EURUSD=X, PEPE24478-USD")
751
+ tf_dd = gr.Dropdown(list(TIMEFRAMES), value="15m", label="Timeframe")
752
+ with gr.Row():
753
+ thr = gr.Slider(15, 70, value=35, step=1, label="Signal threshold (higher = fewer, stronger signals)")
754
+ risk = gr.Radio(list(RISK_PROFILES), value="Balanced", label="Risk profile")
755
+
756
+ with gr.Tab("πŸ” Signal (live or historical)"):
757
+ when = gr.Textbox(label="Analysis time β€” UTC (blank = live)",
758
+ placeholder="YYYY-MM-DD HH:MM or HH:MM or blank")
759
+ btn_a = gr.Button("Analyze", variant="primary")
760
+ out_a = gr.Markdown()
761
+ btn_a.click(ui_analyze, [sym_dd, sym_tx, tf_dd, when, thr, risk], out_a)
762
+
763
+ with gr.Tab("πŸ“Š Backtest"):
764
+ bars = gr.Slider(300, MAX_ROWS, value=3000, step=100, label="Lookback (bars)")
765
+ btn_b = gr.Button("Run backtest", variant="primary")
766
+ out_b = gr.Markdown()
767
+ out_t = gr.Dataframe(label="Trades (last 200)")
768
+ btn_b.click(ui_backtest, [sym_dd, sym_tx, tf_dd, thr, risk, bars], [out_b, out_t])
769
+
770
+ with gr.Tab("βš–οΈ Timeframe comparison"):
771
+ btn_c = gr.Button("Compare 15m / 30m / 1h / 1d", variant="primary")
772
+ out_c = gr.Dataframe(label="Per-timeframe backtest summary")
773
+ btn_c.click(ui_compare, [sym_dd, sym_tx, thr, risk, bars], out_c)
774
+
775
+ demo.queue(max_size=8).launch(server_name="0.0.0.0", server_port=7860)
776
+
777
+
778
+ if __name__ == "__main__":
779
+ launch_ui()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=4.44
2
+ yfinance>=0.2.40
3
+ pandas>=2.2
4
+ numpy>=1.26