| """Inspect raw legacy snapshots or audit the supported real-history dataset.
|
|
|
| Inspection never turns status/gate acceptance or missing outcomes into labels.
|
| Training is provided by Build-MarketModel.ps1 and Train_MarketModel.mq5 only.
|
| """
|
| from __future__ import annotations
|
| import argparse
|
| from collections import Counter, defaultdict
|
| import csv
|
| from datetime import datetime
|
| import json
|
| import math
|
| import os
|
| from pathlib import Path
|
| from typing import Any
|
|
|
| COMMON_FILES_DIR = Path(os.environ.get("APPDATA", "")) / "MetaQuotes" / "Terminal" / "Common" / "Files"
|
| SNAPSHOT_PATTERN = "feature_batch_*.txt"
|
| MARKET_BASE_FEATURES = (
|
| "mm_signed_logret1", "mm_signed_logret3", "mm_signed_logret12", "mm_signed_zscore12",
|
| "mm_return_std12", "mm_range", "mm_signed_body", "mm_spread",
|
| "mm_hour_sin", "mm_hour_cos", "mm_direction", "mm_atr14",
|
| )
|
| ACTIVE_FEATURES = ("active_symbol_flag", "active_ret1", "active_ret3", "active_zscore")
|
| RETIRED_TRAINING_MESSAGE = (
|
| "Legacy snapshot-label training is retired. Gate/status acceptance is not "
|
| "profitability, and absent future outcomes must stay unknown. Run "
|
| "ML/Build-MarketModel.ps1 (or snapshot_train.py) for the supported real-history "
|
| "universal ONNX model with causal shared features, train-only scaling, and "
|
| "chronological horizon-purged evaluation."
|
| )
|
|
|
|
|
| def _read_text_lines_with_fallback(path: str | Path) -> list[str]:
|
| data = Path(path).read_bytes()
|
| if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
| return data.decode("utf-16").splitlines()
|
| candidates = ("utf-16-le", "utf-16-be") if b"\x00" in data else ("utf-8-sig", "cp1252")
|
| for encoding in candidates:
|
| try:
|
| text = data.decode(encoding)
|
| except UnicodeDecodeError:
|
| continue
|
| if "\x00" not in text:
|
| return text.splitlines()
|
| raise ValueError(f"Snapshot has unsupported or malformed encoding: {path}")
|
|
|
|
|
| def parse_snapshot_file(path: str | Path) -> dict[str, str]:
|
| record: dict[str, str] = {}
|
| for raw in _read_text_lines_with_fallback(path):
|
| line = raw.strip()
|
| if not line:
|
| continue
|
| if ":" not in line:
|
| raise ValueError(f"Malformed snapshot key/value line in {path}")
|
| key, value = line.split(":", 1)
|
| key = key.strip().lower()
|
| if not key or key in record:
|
| raise ValueError(f"Empty or duplicate snapshot field {key!r} in {path}")
|
| record[key] = value.strip()
|
| record["__source_file"] = Path(path).name
|
| record["__source_path"] = str(Path(path).resolve())
|
| return record
|
|
|
|
|
| def list_snapshot_files(snapshot_dir: str | Path | None = None, pattern: str = SNAPSHOT_PATTERN) -> list[str]:
|
| return [str(path) for path in sorted(Path(snapshot_dir or COMMON_FILES_DIR).glob(pattern)) if path.is_file()]
|
|
|
|
|
| def inspect_snapshots(snapshot_dir: str | Path | None = None, limit: int | None = None) -> dict[str, Any]:
|
| files = list_snapshot_files(snapshot_dir)
|
| if limit is not None:
|
| if limit <= 0:
|
| raise ValueError("limit must be positive")
|
| files = files[-limit:]
|
| records = [parse_snapshot_file(path) for path in files]
|
| return {
|
| "records": len(records),
|
| "symbols": sorted({record.get("symbol", "unknown") for record in records}),
|
| "status_counts_for_diagnostics_only": dict(Counter(record.get("status", "unknown") for record in records)),
|
| "training_supported": False,
|
| "training_path": "ML/Build-MarketModel.ps1",
|
| "reason": RETIRED_TRAINING_MESSAGE,
|
| }
|
|
|
|
|
| def audit_market_dataset(path: str | Path, train_end: str | None = None,
|
| validation_end: str | None = None, test_end: str | None = None,
|
| model_data: str | Path | None = None) -> dict[str, Any]:
|
| """Verify observed labels, paired timestamps and purged chronological splits.
|
|
|
| The CSV is sparse: twelve causal base features plus the active symbol's
|
| four interactions. It is an audit, not an alternative trainer or imputer.
|
| All timestamps use the broker-native MQL bar-time coordinate consistently.
|
| """
|
| # Artifact metadata is authoritative unless a caller explicitly overrides
|
| # a boundary. Historical fixture defaults apply only without a manifest.
|
| metadata_path = Path(model_data) if model_data is not None else Path(path).resolve().with_name("model_data.json")
|
| metadata = {}
|
| if model_data is not None or metadata_path.is_file():
|
| with metadata_path.open("r", encoding="utf-8-sig") as handle:
|
| metadata = json.load(handle)
|
| if not isinstance(metadata, dict):
|
| raise ValueError("model_data.json must contain a metadata object")
|
| keys = ("training_end", "validation_end", "test_end")
|
| fallback = ("2025.07.01", "2025.10.01", "2026.01.01")
|
| resolved = []
|
| for explicit, key, default in zip((train_end, validation_end, test_end), keys, fallback):
|
| if explicit is not None:
|
| value = explicit
|
| elif metadata:
|
| if not isinstance(metadata.get(key), str) or not metadata[key]:
|
| raise ValueError(f"Model metadata is missing a valid {key} split boundary")
|
| value = metadata[key]
|
| else:
|
| value = default
|
| resolved.append(value)
|
| boundaries = [datetime.strptime(value, "%Y.%m.%d") for value in resolved]
|
| if not boundaries[0] < boundaries[1] < boundaries[2]:
|
| raise ValueError("Split boundaries must increase chronologically")
|
| required = {"decision_time", "target_end", "split", "symbol", "direction", "label", "net_log_return", "entry_spread_points", "exit_spread_points"}
|
| counts: Counter[str] = Counter()
|
| classes: dict[str, Counter[int]] = defaultdict(Counter)
|
| groups: dict[tuple[str, datetime], set[int]] = defaultdict(set)
|
| timestamp_splits: dict[datetime, str] = {}
|
| symbols: set[str] = set()
|
| rows = 0
|
| with Path(path).open("r", encoding="utf-8-sig", newline="") as handle:
|
| reader = csv.DictReader(handle)
|
| fields = set(reader.fieldnames or ())
|
| if not required <= fields:
|
| raise ValueError(f"Market dataset missing columns: {sorted(required - fields)}")
|
| feature_fields = [field for field in fields if field.startswith("mm_") or field.startswith("active_")]
|
| if not set(MARKET_BASE_FEATURES + ACTIVE_FEATURES) <= fields or set(field for field in feature_fields if field.startswith("mm_")) != set(MARKET_BASE_FEATURES):
|
| raise ValueError("Market dataset must include the twelve shared causal base features")
|
| for line, row in enumerate(reader, start=2):
|
| try:
|
| decision = datetime.strptime(row["decision_time"], "%Y.%m.%d %H:%M")
|
| target_end = datetime.strptime(row["target_end"], "%Y.%m.%d %H:%M")
|
| split = row["split"]
|
| symbol = row["symbol"].strip()
|
| direction = int(row["direction"])
|
| label = int(row["label"])
|
| net_return = float(row["net_log_return"])
|
| numbers = [float(row[field]) for field in feature_fields]
|
| spreads = [float(row[field]) for field in ("entry_spread_points", "exit_spread_points")]
|
| except (KeyError, TypeError, ValueError) as error:
|
| raise ValueError(f"Unknown/malformed observed value on line {line}: {error}") from error
|
| if not symbol or direction not in (0, 1) or label not in (0, 1):
|
| raise ValueError(f"Invalid symbol/direction/label on line {line}")
|
| if not all(math.isfinite(value) for value in [net_return, *numbers, *spreads]) or min(spreads) < 0:
|
| raise ValueError(f"Nonfinite feature/outcome or negative spread on line {line}")
|
| if label != int(net_return > 0):
|
| raise ValueError(f"Label is inconsistent with observed spread-adjusted return on line {line}")
|
| direction_value = 1.0 if direction == 0 else -1.0
|
| if float(row["mm_direction"]) != direction_value or float(row["active_symbol_flag"]) != 1.0:
|
| raise ValueError(f"Direction or active-symbol feature contract violated on line {line}")
|
| volatility = float(row["mm_return_std12"])
|
| if volatility < 0.0:
|
| raise ValueError(f"Negative return volatility on line {line}")
|
| expected = (float(row["mm_signed_logret1"]) / max(volatility, 1e-8),
|
| float(row["mm_signed_logret3"]) / max(volatility, 1e-8),
|
| float(row["mm_signed_zscore12"]))
|
| actual = tuple(float(row[name]) for name in ACTIVE_FEATURES[1:])
|
| if any(not math.isclose(left, right, rel_tol=1e-9, abs_tol=1e-9) for left, right in zip(actual, expected)):
|
| raise ValueError(f"Sparse symbol interactions mismatch causal base features on line {line}")
|
| if target_end <= decision:
|
| raise ValueError(f"Future outcome must end after decision on line {line}")
|
| if split == "train":
|
| valid = decision < boundaries[0] and target_end < boundaries[0]
|
| elif split == "validation":
|
| valid = boundaries[0] <= decision < boundaries[1] and target_end < boundaries[1]
|
| elif split == "test":
|
| valid = boundaries[1] <= decision < boundaries[2] and target_end < boundaries[2]
|
| else:
|
| valid = False
|
| if not valid:
|
| raise ValueError(f"Unknown split or outcome crossing a split boundary on line {line}")
|
| if decision in timestamp_splits and timestamp_splits[decision] != split:
|
| raise ValueError(f"A decision timestamp is split across partitions on line {line}")
|
| timestamp_splits[decision] = split
|
| group = groups[(symbol, decision)]
|
| if direction in group:
|
| raise ValueError(f"Duplicate symbol/time/direction on line {line}")
|
| group.add(direction)
|
| counts[split] += 1
|
| classes[split][label] += 1
|
| symbols.add(symbol)
|
| rows += 1
|
| if not rows:
|
| raise ValueError("No real-history rows to audit")
|
| if any(directions != {0, 1} for directions in groups.values()):
|
| raise ValueError("Each symbol/decision group must retain both BUY and SELL observations")
|
| if any(set(classes[split]) != {0, 1} for split in ("train", "validation", "test")):
|
| raise ValueError("Each chronological split must contain observed positive and negative outcomes")
|
| return {"rows": rows, "symbols": sorted(symbols), "split_counts": dict(counts),
|
| "class_counts": {key: dict(value) for key, value in classes.items()},
|
| "split_boundaries": dict(zip(keys, resolved)),
|
| "model_data": str(metadata_path.resolve()) if metadata else None,
|
| "audit_passed": True}
|
|
|
|
|
| # Old import callers fail clearly instead of changing return types silently.
|
| def label_from_status(*args: Any, **kwargs: Any) -> None:
|
| raise RuntimeError(RETIRED_TRAINING_MESSAGE)
|
|
|
|
|
| def labels_from_future_prices(*args: Any, **kwargs: Any) -> None:
|
| raise RuntimeError(RETIRED_TRAINING_MESSAGE)
|
|
|
|
|
| def load_snapshot_dataframe(*args: Any, **kwargs: Any) -> None:
|
| raise RuntimeError(RETIRED_TRAINING_MESSAGE)
|
|
|
|
|
| def build_feature_matrix(*args: Any, **kwargs: Any) -> None:
|
| raise RuntimeError(RETIRED_TRAINING_MESSAGE)
|
|
|
|
|
| def main(argv: list[str] | None = None) -> int:
|
| parser = argparse.ArgumentParser(description=__doc__)
|
| source = parser.add_mutually_exclusive_group()
|
| source.add_argument("--snapshot_dir")
|
| source.add_argument("--market-csv")
|
| parser.add_argument("--limit", type=int)
|
| parser.add_argument("--model-data", help="Model metadata JSON; defaults to model_data.json beside --market-csv")
|
| parser.add_argument("--train-end", help="Override the model metadata training boundary")
|
| parser.add_argument("--validation-end", help="Override the model metadata validation boundary")
|
| parser.add_argument("--test-end", help="Override the model metadata test boundary")
|
| args = parser.parse_args(argv)
|
| try:
|
| report = audit_market_dataset(args.market_csv, args.train_end, args.validation_end, args.test_end, args.model_data) if args.market_csv else inspect_snapshots(args.snapshot_dir, args.limit)
|
| print(json.dumps(report, indent=2))
|
| except (OSError, ValueError) as error:
|
| parser.exit(1, f"Dataset audit failed: {error}\n")
|
| return 0
|
|
|
|
|
| if __name__ == "__main__":
|
| raise SystemExit(main())
|