""" ffd_cross_validate.py ===================== Cross-validate FFDEngine.mqh against the Python afml implementation. Workflow -------- 1. Run FFDValidation.mq5 in MetaTrader 5. It writes a CSV to the terminal's MQL5/Files/ folder (default path shown below). 2. Run this script, pointing --csv at that file. 3. The script recomputes FFD values in Python and compares them bar-by-bar. Usage ----- python ffd_cross_validate.py --csv path/to/ffd_validation_EURUSD_H1_d0.40.csv Requirements: numpy, pandas, scipy (for ADF test) """ import argparse from pathlib import Path import numpy as np import pandas as pd # --------------------------------------------------------------------------- # FFD weight and computation functions (afml parity) # --------------------------------------------------------------------------- def get_weights_ffd(d: float, thres: float, lim: int = 100_000) -> np.ndarray: """ Compute the fixed-width FFD weight vector. Matches afml.get_weights_ffd() exactly. Returns a 1-D array, oldest-lag first (i.e., after reversal), so index 0 = smallest weight. Parameters ---------- d : Fractional-differencing order. thres : Weight cutoff. Iteration stops when |w_k| < thres. lim : Hard upper bound on the number of weights (safety guard). """ weights = [1.0] k = 1 while True: w_ = -weights[-1] * (d - k + 1) / k if abs(w_) < thres: break weights.append(w_) k += 1 if k == lim: break return np.array(weights[::-1]) # 1-D, oldest-lag first def frac_diff_ffd_from_csv( close: np.ndarray, d: float, thres: float, use_log: bool = True, ) -> np.ndarray: """ Compute FFD values for a chronological price array. Replicates the MQL5 ComputeBuffer() logic so the two can be compared element-by-element. NaN fills the first `width` positions. Parameters ---------- close : 1-D array, chronological (oldest first). d : Fractional-differencing order. thres : Weight cutoff threshold. use_log : Apply ln(max(p, 1e-8)) before differencing. """ weights = get_weights_ffd(d, thres) # shape (n,), oldest-lag first width = len(weights) - 1 n = len(close) if use_log: values = np.log(np.maximum(close, 1e-8)) else: values = close.copy().astype(float) out = np.full(n, np.nan) for i in range(width, n): window = values[i - width : i + 1] # length = width + 1, oldest first out[i] = np.dot(weights, window) # weights[0]*oldest + ... + weights[width]*newest return out # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser(description="Cross-validate MQL5 FFD against Python") parser.add_argument("--csv", required=True, help="Path to ffd_validation_*.csv from FFDValidation.mq5") parser.add_argument("--d", type=float, default=None, help="Override d (default: inferred from filename)") parser.add_argument("--thres",type=float, default=1e-5, help="Weight threshold (default: 1e-5)") parser.add_argument("--no-log", action="store_true", help="Disable log transform") args = parser.parse_args() csv_path = Path(args.csv) if not csv_path.exists(): raise FileNotFoundError(csv_path) # Infer d from filename if not supplied if args.d is None: import re m = re.search(r"_d([\d.]+)\.csv$", csv_path.name) if not m: raise ValueError("Could not infer d from filename. Pass --d explicitly.") d = float(m.group(1)) else: d = args.d use_log = not args.no_log thres = args.thres print(f"\nParameters: d={d} threshold={thres} use_log={use_log}") print(f"CSV: {csv_path}\n") # --- Load CSV produced by FFDValidation.mq5 --- # The CSV contains only rows where ffd != EMPTY_VALUE, starting at bar=width. df = pd.read_csv(csv_path, parse_dates=["datetime"]) df = df.sort_values("bar_index").reset_index(drop=True) # Reconstruct the full close array (including the leading width bars that # were omitted from the CSV). We need the full price history to recompute. # The CSV bar_index tells us the absolute position of each row. # bar 0..width-1 are absent; bar width is the first row. first_bar = int(df["bar_index"].iloc[0]) total_bars = int(df["bar_index"].iloc[-1]) + 1 # Compute weights to find expected width weights = get_weights_ffd(d, thres) width = len(weights) - 1 print(f"Weight vector: width={width} min_bars={width + 1}") if first_bar != width: print(f"WARNING: expected first data bar at index {width}, " f"but CSV starts at {first_bar}. Check that d and thres match.") # We can only validate bars present in the CSV; we need the corresponding # close prices. The CSV contains close for those rows. To reconstruct the # full window for bar `width`, we need the prior `width` bars — which the # validation script did NOT export (they were EMPTY_VALUE rows). Therefore, # we validate using a rolling approach: for each bar i in the CSV, compute # Python FFD using only the close prices in the CSV, shifted by first_bar. # # This works because the CSV exports bars [width, N-1], and for bar width # the window is exactly [0, width], i.e., all bars from bar 0 onward. # We assume the user ran the script with enough bars that close[0] is the # bar_index=0 bar. We reconstruct close[0..first_bar-1] via the FFD formula # cannot — so instead we compare the Python computation on the exported # data directly. close_exported = df["close"].values.astype(float) ffd_mql5 = df["ffd"].values.astype(float) # Recompute Python FFD on the exported close prices. # Since the CSV starts at bar `first_bar`, the window for the first # exported bar requires prices[0..width] — all of which are in the CSV # (bar first_bar is the first row; it needs width prior bars which are # NOT in the CSV). Therefore this direct approach only works for bars # beyond the first exported bar. # # Correct approach: run Python FFD on the full reconstructed close # array. Since we don't have bars 0..width-1 in the CSV, we validate # only the *relative* differences between consecutive FFD values, OR # we ask the user to export close[] including the lookback bars. # # Pragmatic solution: the validation script already exports bar_index for # each row. We validate by computing FFD on the exported close[] as if # index 0 is bar 0 of the exported slice (which is bar `width` of the full # series). Bars exported from width onward form a contiguous slice; the # Python FFD computed on THAT slice starting at bar 0 of the slice equals # the MQL5 value at the corresponding bar — because both use the same price # history. The first valid Python output on this slice is at index `width` # of the slice, which corresponds to bar 2*width of the full series. # # To validate from the very first exported bar, we need all bars in the # CSV (rows 0..N-1 correspond to close[0..N-1] in the slice starting at # bar `first_bar`). This gives us enough data if N > width. n_exported = len(close_exported) if n_exported < width + 1: print(f"ERROR: Not enough exported bars ({n_exported}) to recompute " f"even one Python FFD value (need at least {width + 1}). " f"Increase InpBars in FFDValidation.mq5.") return # Compute Python FFD on the exported slice. ffd_py_on_slice = frac_diff_ffd_from_csv(close_exported, d, thres, use_log) # The first valid Python value on the slice is at index `width`. # That corresponds to CSV row `width` = MQL5 bar `first_bar + width`. valid_mask = ~np.isnan(ffd_py_on_slice) py_valid = ffd_py_on_slice[valid_mask] mql5_aligned = ffd_mql5[valid_mask] # align by dropping the first `width` CSV rows n_compared = len(py_valid) if n_compared == 0: print("No bars available for comparison after aligning lookback.") return diffs = np.abs(py_valid - mql5_aligned) print(f"Bars compared: {n_compared} (of {n_exported} exported bars)") print(f"Max |diff|: {diffs.max():.4e}") print(f"Mean |diff|: {diffs.mean():.4e}") print(f"Bars within 1e-10: {(diffs < 1e-10).sum()} / {n_compared}") print(f"Bars within 1e-12: {(diffs < 1e-12).sum()} / {n_compared}") threshold_pass = 1e-10 if diffs.max() < threshold_pass: print(f"\nPASS — max difference {diffs.max():.2e} < {threshold_pass:.0e}") else: print(f"\nFAIL — max difference {diffs.max():.2e} >= {threshold_pass:.0e}") # Show the 5 worst-offending bars worst = np.argsort(diffs)[::-1][:5] print("\nTop 5 discrepancies:") print(f" {'slice_idx':>10} {'mql5_ffd':>18} {'py_ffd':>18} {'|diff|':>12}") for idx in worst: row_idx = np.where(valid_mask)[0][idx] print(f" {row_idx:>10} {mql5_aligned[idx]:>18.12f}" f" {py_valid[idx]:>18.12f} {diffs[idx]:>12.4e}") # Informational: first few matched values print("\nFirst 5 matched values (slice index = exported row):") print(f" {'slice_idx':>10} {'mql5_ffd':>20} {'py_ffd':>20} {'|diff|':>12}") for j in range(min(5, n_compared)): row_idx = np.where(valid_mask)[0][j] print(f" {row_idx:>10} {mql5_aligned[j]:>20.12f}" f" {py_valid[j]:>20.12f} {diffs[j]:>12.4e}") if __name__ == "__main__": main()