Upload 10 files
Browse files- README.md +9 -5
- app.py +779 -0
- requirements.txt +4 -0
README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
| 1 |
---
|
| 2 |
title: Vekalm
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
| 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
|