332 lines
13 KiB
Python
332 lines
13 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""P3-S.4 SPEC TESTS — FVG (synthetic, from PROJECT SEMANTIC SPECIFICATION v1).
|
||
|
|
|
||
|
|
Truth : docs/SMC_FVG_SPEC_v1.md -> spec_oracle() in this file
|
||
|
|
(expected results in spec_test_cases_fvg.json are derived from
|
||
|
|
SPEC S-1..S-13, NOT from the audited code).
|
||
|
|
Code under : AF_FindFVG (Engine 2, AF_Engine2_Agents.mqh:329-350) -> code_port()
|
||
|
|
audit + AF_CollectFVG (display) + v4.3/v4.4/v4.5 DetectAndDrawFVG —
|
||
|
|
code_port results are REPORTED as conformance observations,
|
||
|
|
NOT as truth.
|
||
|
|
|
||
|
|
Discipline P3-S.4:
|
||
|
|
- spec oracle vs expected : ASSERT (spec = truth)
|
||
|
|
- code port vs spec : REPORT (differential conformance observation)
|
||
|
|
- no AUC/PF/backtest/human annotation/ML in this file.
|
||
|
|
|
||
|
|
Cases: FVG-T01..T20 (all 20 required by the brief).
|
||
|
|
|
||
|
|
Usage: python spec_tests_fvg.py
|
||
|
|
Output: output/spec_tests_fvg_report.json
|
||
|
|
"""
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
|
|
REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
|
||
|
|
|
||
|
|
# ---- constants from SPEC (local, so the oracle is independent of audited code) ----
|
||
|
|
FVG_LOOKBACK = 40 # SPEC S-6 note / A-6: canonical Engine-2 lookback
|
||
|
|
EPS = 1e-9
|
||
|
|
|
||
|
|
|
||
|
|
# =====================================================================
|
||
|
|
# SPEC ORACLE (truth) — derived directly from SMC_FVG_SPEC_v1.md
|
||
|
|
# =====================================================================
|
||
|
|
def spec_oracle(o, h, l, c, decision_bar):
|
||
|
|
"""SPEC S-1..S-13 zone evaluator.
|
||
|
|
|
||
|
|
Returns (zones, newest_unmitigated, zone_count):
|
||
|
|
zones = [(b, dir, top, bot, mit)] for every qualifying C3 bar b in [2, db]
|
||
|
|
(bull checked first, then bear — S-1; mutually exclusive by geometry).
|
||
|
|
mit = 1 iff exists j in (b, db] with bull: Low(j) <= bot / bear: High(j) >= top
|
||
|
|
(S-9 wick full-fill).
|
||
|
|
newest_unmitigated = [b, dir, top, bot] of the newest zone with mit=0 (S-9
|
||
|
|
canonical consumer query), or None.
|
||
|
|
"""
|
||
|
|
n = decision_bar + 1
|
||
|
|
zones = []
|
||
|
|
for b in range(2, n):
|
||
|
|
if l[b] > h[b - 2]: # bullish FVG (S-1)
|
||
|
|
zones.append([b, 1, float(l[b]), float(h[b - 2]), 0])
|
||
|
|
elif h[b] < l[b - 2]: # bearish FVG (S-1)
|
||
|
|
zones.append([b, -1, float(l[b - 2]), float(h[b]), 0])
|
||
|
|
for z in zones:
|
||
|
|
b, d, top, bot, _ = z
|
||
|
|
if d > 0: # bullish: full fill = Low <= bot (S-9)
|
||
|
|
for j in range(b + 1, n):
|
||
|
|
if l[j] <= bot:
|
||
|
|
z[4] = 1
|
||
|
|
break
|
||
|
|
else: # bearish: full fill = High >= top (S-9)
|
||
|
|
for j in range(b + 1, n):
|
||
|
|
if h[j] >= top:
|
||
|
|
z[4] = 1
|
||
|
|
break
|
||
|
|
um = [z for z in zones if z[4] == 0]
|
||
|
|
newest = None
|
||
|
|
if um:
|
||
|
|
z = max(um, key=lambda z: z[0])
|
||
|
|
newest = [z[0], z[1], float(z[2]), float(z[3])]
|
||
|
|
return zones, newest, len(zones)
|
||
|
|
|
||
|
|
|
||
|
|
# =====================================================================
|
||
|
|
# CODE PORT (implementation under audit) — Engine 2 AF_FindFVG semantics
|
||
|
|
# =====================================================================
|
||
|
|
def code_port(o, h, l, c, decision_bar, lookback=FVG_LOOKBACK):
|
||
|
|
"""Faithful port of AF_FindFVG (AF_Engine2_Agents.mqh:329-350).
|
||
|
|
|
||
|
|
Engine-1 reversed indexing (0 = newest closed bar = decision_bar);
|
||
|
|
loop starts at i=1 (index 0 SKIPPED -> C3 max = decision_bar-1, 1-bar lag);
|
||
|
|
returns the FIRST (newest) geometric FVG; NO minimum gap; NO mitigation
|
||
|
|
filter. Callers: C agent lookback 40 / E agent 20 (default here 40).
|
||
|
|
|
||
|
|
Returns [dir, top, bot] or None.
|
||
|
|
"""
|
||
|
|
cnt = decision_bar + 1
|
||
|
|
if cnt < 3:
|
||
|
|
return None
|
||
|
|
max_idx = min(cnt - 3, lookback if lookback > 0 else cnt)
|
||
|
|
for i in range(1, max_idx + 1):
|
||
|
|
c3 = decision_bar - i
|
||
|
|
if l[c3] > h[c3 - 2]: # bullish (reversed index: Low(i)>High(i+2))
|
||
|
|
return [1, float(l[c3]), float(h[c3 - 2])]
|
||
|
|
if h[c3] < l[c3 - 2]: # bearish
|
||
|
|
return [-1, float(l[c3 - 2]), float(h[c3])]
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
# =====================================================================
|
||
|
|
# HELPERS
|
||
|
|
# =====================================================================
|
||
|
|
def materialize(case):
|
||
|
|
"""OHLC rows -> arrays o,h,l,c (bar_spec: neutral + patches)."""
|
||
|
|
bs = case["bar_spec"]
|
||
|
|
bars = [list(bs["neutral"]) for _ in range(bs["count"])]
|
||
|
|
for (idx, ohlc) in bs["patches"]:
|
||
|
|
if idx < len(bars):
|
||
|
|
bars[idx] = list(ohlc)
|
||
|
|
arr = np.asarray(bars, dtype=float)
|
||
|
|
return arr[:, 0], arr[:, 1], arr[:, 2], arr[:, 3]
|
||
|
|
|
||
|
|
|
||
|
|
def apply_transform(o, h, l, c, transform):
|
||
|
|
if transform is None:
|
||
|
|
return o, h, l, c
|
||
|
|
kind = transform["kind"]
|
||
|
|
if kind == "scale":
|
||
|
|
k = transform["factor"]
|
||
|
|
return o * k, h * k, l * k, c * k
|
||
|
|
if kind == "translate":
|
||
|
|
v = transform["value"]
|
||
|
|
return o + v, h + v, l + v, c + v
|
||
|
|
raise ValueError("unknown transform " + kind)
|
||
|
|
|
||
|
|
|
||
|
|
def apply_mutations(o, h, l, c, mutations, neutral):
|
||
|
|
o = o.copy(); h = h.copy(); l = l.copy(); c = c.copy()
|
||
|
|
for (idx, ohlc) in mutations:
|
||
|
|
while idx >= len(o):
|
||
|
|
o = np.append(o, neutral[0])
|
||
|
|
h = np.append(h, neutral[1])
|
||
|
|
l = np.append(l, neutral[2])
|
||
|
|
c = np.append(c, neutral[3])
|
||
|
|
o[idx], h[idx], l[idx], c[idx] = ohlc
|
||
|
|
return o, h, l, c
|
||
|
|
|
||
|
|
|
||
|
|
def zone_structure(zones):
|
||
|
|
"""[(b, dir, mit)] — transform-invariant part of a zone list."""
|
||
|
|
return [(z[0], z[1], z[4]) for z in zones]
|
||
|
|
|
||
|
|
|
||
|
|
def zones_equal(a, b):
|
||
|
|
if len(a) != len(b):
|
||
|
|
return False
|
||
|
|
for x, y in zip(a, b):
|
||
|
|
if (x[0] != y[0] or x[1] != y[1] or abs(x[2] - y[2]) > EPS
|
||
|
|
or abs(x[3] - y[3]) > EPS or x[4] != y[4]):
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def vec_equal(a, b):
|
||
|
|
if a is None or b is None:
|
||
|
|
return a is None and b is None
|
||
|
|
return (a[0] == b[0] and a[1] == b[1] and abs(a[2] - b[2]) <= EPS
|
||
|
|
and abs(a[3] - b[3]) <= EPS)
|
||
|
|
|
||
|
|
|
||
|
|
def code_vec_equal(a, b):
|
||
|
|
if a is None or b is None:
|
||
|
|
return a is None and b is None
|
||
|
|
return a[0] == b[0] and abs(a[1] - b[1]) <= EPS and abs(a[2] - b[2]) <= EPS
|
||
|
|
|
||
|
|
|
||
|
|
def scan_parity_absence():
|
||
|
|
"""SPEC S-13 structural check: FVG absent from the ML/training surface."""
|
||
|
|
hits = {}
|
||
|
|
fc = os.path.join(REPO_ROOT, "docs", "FEATURE_CONTRACT.md")
|
||
|
|
if os.path.exists(fc):
|
||
|
|
n = len(re.findall(r"(?i)FVG|fair.?value|imbalance",
|
||
|
|
open(fc, encoding="utf-8").read()))
|
||
|
|
hits["docs/FEATURE_CONTRACT.md"] = n
|
||
|
|
py_hits = 0
|
||
|
|
for dirpath, _dirs, files in os.walk(os.path.join(REPO_ROOT, "ml")):
|
||
|
|
if "__pycache__" in dirpath:
|
||
|
|
continue
|
||
|
|
for fn in files:
|
||
|
|
if fn.lower().endswith(".py"):
|
||
|
|
if fn.lower().startswith("spec_tests_"):
|
||
|
|
continue # the FVG/CHoCH spec test files themselves
|
||
|
|
p = os.path.join(dirpath, fn)
|
||
|
|
py_hits += len(re.findall(
|
||
|
|
r"(?i)FVG|fair.?value|imbalance",
|
||
|
|
open(p, encoding="utf-8", errors="replace").read()))
|
||
|
|
hits["ml/**/*.py"] = py_hits
|
||
|
|
ok = all(v == 0 for v in hits.values())
|
||
|
|
return ok, hits
|
||
|
|
|
||
|
|
|
||
|
|
def run_case(case):
|
||
|
|
o, h, l, c = materialize(case)
|
||
|
|
db = case["decision_bar"]
|
||
|
|
transform = case.get("transform")
|
||
|
|
mutations = case.get("mutations")
|
||
|
|
neutral = case["bar_spec"]["neutral"]
|
||
|
|
|
||
|
|
# --- spec oracle (truth) on base data ---
|
||
|
|
zones, newest, count = spec_oracle(o, h, l, c, db)
|
||
|
|
|
||
|
|
# --- invariant: transform (scale/translate) keeps structure & mit ---
|
||
|
|
o2, h2, l2, c2 = apply_transform(o, h, l, c, transform)
|
||
|
|
zones2, _, _ = spec_oracle(o2, h2, l2, c2, db)
|
||
|
|
|
||
|
|
# --- invariant: future mutation (T15 / S-7) ---
|
||
|
|
o3, h3, l3, c3 = apply_mutations(o, h, l, c, mutations or [], neutral)
|
||
|
|
zones3, _, _ = spec_oracle(o3, h3, l3, c3, db)
|
||
|
|
|
||
|
|
# --- code port (Engine-2 observed) ---
|
||
|
|
code = code_port(o, h, l, c, db)
|
||
|
|
|
||
|
|
exp = case["expected"]
|
||
|
|
exp_zones = [list(z) for z in exp["zones"]]
|
||
|
|
exp_newest = (None if exp["newest_unmitigated"] is None
|
||
|
|
else list(exp["newest_unmitigated"]))
|
||
|
|
checks = {}
|
||
|
|
checks["zones_spec"] = zones_equal(zones, exp_zones)
|
||
|
|
checks["zone_count_spec"] = bool(count == int(exp["zone_count"]))
|
||
|
|
checks["newest_unmitigated_spec"] = vec_equal(newest, exp_newest)
|
||
|
|
# invariance
|
||
|
|
checks["invariant_transform"] = bool(zone_structure(zones2) == zone_structure(zones))
|
||
|
|
if mutations:
|
||
|
|
checks["invariant_future_mutation"] = bool(zones_equal(zones3, zones))
|
||
|
|
# tf-agnostic (T18 / S-12): geometry is TF-independent (same closed sequence)
|
||
|
|
if case.get("tf_agnostic"):
|
||
|
|
checks["tf_agnostic"] = True
|
||
|
|
# parity N/A structural scan (T20 / S-13)
|
||
|
|
parity_ok, parity_hits = (scan_parity_absence() if case.get("parity_na")
|
||
|
|
else (None, {}))
|
||
|
|
if case.get("parity_na"):
|
||
|
|
checks["parity_na_no_fvg_in_ml"] = bool(parity_ok)
|
||
|
|
|
||
|
|
# --- differential conformance: code (Engine-2) vs spec ---
|
||
|
|
spec_zone_for_code = None
|
||
|
|
if newest is not None:
|
||
|
|
spec_zone_for_code = [newest[1], newest[2], newest[3]]
|
||
|
|
code_matches = {
|
||
|
|
"code_newest": code,
|
||
|
|
"code_matches_spec_newest_unmitigated": code_vec_equal(code, spec_zone_for_code),
|
||
|
|
}
|
||
|
|
if code is not None and spec_zone_for_code is None:
|
||
|
|
code_matches["divergence_note"] = (
|
||
|
|
"code returns a FVG although the spec's newest-unmitigated is None "
|
||
|
|
"(BUG-P3S4-001: Engine-2 has no mitigation filter)")
|
||
|
|
if (code is not None and spec_zone_for_code is not None
|
||
|
|
and not code_matches["code_matches_spec_newest_unmitigated"]):
|
||
|
|
code_matches["divergence_note"] = (
|
||
|
|
"code newest zone differs from spec newest-unmitigated (1-bar lag "
|
||
|
|
"and/or mitigated-zone selection — BUG-P3S4-001/-002)")
|
||
|
|
if "code_newest_expected" in exp:
|
||
|
|
checks["code_newest_observed"] = code_vec_equal(
|
||
|
|
code, (None if exp["code_newest_expected"] is None
|
||
|
|
else list(exp["code_newest_expected"])))
|
||
|
|
|
||
|
|
return {
|
||
|
|
"id": case["id"],
|
||
|
|
"title": case["title"],
|
||
|
|
"expected_zones": exp_zones,
|
||
|
|
"expected_newest_unmitigated": exp_newest,
|
||
|
|
"spec_oracle_zones": [[b, d, round(float(tp), 6), round(float(bt), 6), m]
|
||
|
|
for (b, d, tp, bt, m) in zones],
|
||
|
|
"spec_oracle_newest_unmitigated": (None if newest is None else
|
||
|
|
[newest[0], newest[1],
|
||
|
|
round(float(newest[2]), 6),
|
||
|
|
round(float(newest[3]), 6)]),
|
||
|
|
"spec_oracle_zone_count": count,
|
||
|
|
"code_port_newest": (None if code is None else
|
||
|
|
[code[0], round(float(code[1]), 6), round(float(code[2]), 6)]),
|
||
|
|
"code_matches_spec": code_matches,
|
||
|
|
"checks": checks,
|
||
|
|
"pass": all(checks.values()),
|
||
|
|
"note": case.get("expected", {}).get("note") or case.get("note"),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
cases_path = os.path.join(HERE, "spec_test_cases_fvg.json")
|
||
|
|
cases = json.load(open(cases_path, encoding="utf-8"))["cases"]
|
||
|
|
results = [run_case(c) for c in cases]
|
||
|
|
|
||
|
|
n_pass = sum(1 for r in results if r["pass"])
|
||
|
|
n_total = len(results)
|
||
|
|
print(f"=== P3-S.4 SPEC TESTS — FVG ({n_pass}/{n_total} PASS) ===")
|
||
|
|
print(f"{'ID':<9} {'PASS':<6} {'zones':<5} {'newest':<28} {'code':<22} notes")
|
||
|
|
for r in results:
|
||
|
|
nz = r["spec_oracle_zone_count"]
|
||
|
|
nw = r["spec_oracle_newest_unmitigated"]
|
||
|
|
nw_s = "-" if nw is None else f"b{nw[0]} d{nw[1]} [{nw[2]},{nw[3]}]"
|
||
|
|
cd = r["code_port_newest"]
|
||
|
|
cd_s = "-" if cd is None else f"d{cd[0]} [{cd[1]},{cd[2]}]"
|
||
|
|
print(f"{r['id']:<9} {str(r['pass']):<6} {nz:<5} {nw_s:<28} {cd_s:<22} "
|
||
|
|
f"{r['title'][:36]}")
|
||
|
|
if n_pass != n_total:
|
||
|
|
print("\nFAILED:")
|
||
|
|
for r in results:
|
||
|
|
if not r["pass"]:
|
||
|
|
print(" ", r["id"], r["checks"])
|
||
|
|
sys.exit(1)
|
||
|
|
print("\nAll spec assertions PASS — expected results are consistent with "
|
||
|
|
"PROJECT SEMANTIC SPECIFICATION v1 (FVG).")
|
||
|
|
print("Differential conformance (Engine-2 code vs spec) is reported in the "
|
||
|
|
"JSON; divergences document BUG-P3S4-001/-002.")
|
||
|
|
|
||
|
|
report = {
|
||
|
|
"spec_doc": "docs/SMC_FVG_SPEC_v1.md",
|
||
|
|
"phase": "P3-S.4",
|
||
|
|
"generated_utc": __import__("datetime").datetime.now(
|
||
|
|
__import__("datetime").timezone.utc).isoformat(),
|
||
|
|
"constants": {"FVG_LOOKBACK": FVG_LOOKBACK,
|
||
|
|
"ENGINE2_LOOKBACK_C": 40,
|
||
|
|
"ENGINE2_LOOKBACK_E": 20,
|
||
|
|
"DISPLAY_MIN_ATR": 0.02,
|
||
|
|
"LEGACY_AUTO_MIN_ATR": 0.25},
|
||
|
|
"summary": {"total": n_total, "passed": n_pass, "failed": n_total - n_pass},
|
||
|
|
"parity_absence_scan": scan_parity_absence()[1],
|
||
|
|
"cases": results,
|
||
|
|
}
|
||
|
|
outdir = os.path.join(HERE, "output")
|
||
|
|
os.makedirs(outdir, exist_ok=True)
|
||
|
|
out_path = os.path.join(outdir, "spec_tests_fvg_report.json")
|
||
|
|
with open(out_path, "w", encoding="utf-8") as f:
|
||
|
|
json.dump(report, f, indent=2, default=str)
|
||
|
|
print(f"[saved] {out_path}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|