194 lines
7.7 KiB
Python
194 lines
7.7 KiB
Python
import argparse
| |||
import os
| |||
import json
| |||
import pickle
| |||
from typing import Optional, List, Dict
| |||
| |||
import numpy as np
| |||
import pandas as pd
| |||
| |||
from dataset import load_features
| |||
from features import enrich_with_yahoo, build_feature_matrix, scale_features
| |||
from model import load_model
| |||
from policy import write_policy
| |||
| |||
| |||
def _resolve_cols(df: pd.DataFrame):
| |||
strat = "strategy" if "strategy" in df.columns else None
| |||
sym = "symbol" if "symbol" in df.columns else ("entry_symbol" if "entry_symbol" in df.columns else None)
| |||
tf = "timeframe" if "timeframe" in df.columns else ("tf" if "tf" in df.columns else None)
| |||
return strat, sym, tf
| |||
| |||
| |||
# Heuristic mapping from probability to scaling multipliers.
| |||
# Note: MQL5 policy parser commits after reading p_win, so we must output
| |||
# sl_scale/tp_scale/trail_atr_mult BEFORE p_win in each slice object.
| |||
def compute_scales(p: float, min_conf: float, neutral: bool = False):
| |||
if neutral or p is None:
| |||
return 1.0, 1.0, 1.0
| |||
try:
| |||
import math
| |||
if math.isnan(p):
| |||
return 1.0, 1.0, 1.0
| |||
except Exception:
| |||
pass
| |||
hi = max(0.80, float(min_conf) + 0.20)
| |||
mid = max(0.60, float(min_conf) + 0.10)
| |||
if p >= hi:
| |||
return 0.85, 1.35, 1.15
| |||
if p >= mid:
| |||
return 0.90, 1.20, 1.10
| |||
if p >= float(min_conf):
| |||
return 1.00, 1.00, 1.00
| |||
return 1.00, 1.00, 1.00
| |||
| |||
| |||
def export_policy(common_dir: Optional[str], model_dir: str, min_conf: float, enrich: bool = True, neutral_scales: bool = False) -> str:
| |||
# Load features from Common Files
| |||
df = load_features(common_dir)
| |||
if enrich:
| |||
tf_minutes = None
| |||
if "timeframe" in df.columns:
| |||
try:
| |||
tf_series = pd.to_numeric(df["timeframe"], errors="coerce").dropna()
| |||
if not tf_series.empty:
| |||
tf_minutes = int(tf_series.mode().iloc[0])
| |||
except Exception:
| |||
tf_minutes = None
| |||
df = enrich_with_yahoo(df, target_timeframe_minutes=tf_minutes, tz="UTC", fill_holidays=True)
| |||
| |||
# Build features matrix with the same preprocessing as training
| |||
X, feat_names = build_feature_matrix(df)
| |||
# Try to load persisted scaler and feature order
| |||
scaler_path = os.path.join(model_dir, "scaler.pkl")
| |||
feats_path = os.path.join(model_dir, "features.json")
| |||
scaler = None
| |||
feature_order: List[str] = feat_names
| |||
if os.path.exists(scaler_path):
| |||
try:
| |||
with open(scaler_path, "rb") as f:
| |||
scaler = pickle.load(f)
| |||
except Exception:
| |||
scaler = None
| |||
if os.path.exists(feats_path):
| |||
try:
| |||
with open(feats_path, "r", encoding="utf-8") as f:
| |||
d = json.load(f)
| |||
if isinstance(d, dict) and "features" in d and isinstance(d["features"], list):
| |||
feature_order = [str(x) for x in d["features"]]
| |||
except Exception:
| |||
pass
| |||
# Align current X to training feature order if possible
| |||
try:
| |||
import numpy as np
| |||
# Build a mapping from current feature list to desired order
| |||
col_to_idx = {c: i for i, c in enumerate(feat_names)}
| |||
idxs = [col_to_idx[c] for c in feature_order if c in col_to_idx]
| |||
X = X[:, idxs]
| |||
except Exception:
| |||
pass
| |||
if scaler is not None:
| |||
Xs = scaler.transform(X)
| |||
else:
| |||
# Fallback: fit a fresh scaler (not ideal, but functional)
| |||
Xs, _ = scale_features(X)
| |||
| |||
# Load model and predict probabilities
| |||
model = load_model(model_dir)
| |||
p_all = model.predict(Xs, verbose=0).ravel()
| |||
| |||
df_probs = df.copy()
| |||
df_probs["p_win"] = p_all
| |||
| |||
s_col, y_col, t_col = _resolve_cols(df_probs)
| |||
slice_probs: List[Dict] = []
| |||
if all(c is not None for c in (s_col, y_col, t_col)):
| |||
# Exact slices: strat+symbol+timeframe
| |||
gb = df_probs.groupby([s_col, y_col, t_col])["p_win"].mean().reset_index()
| |||
for _, row in gb.iterrows():
| |||
p = float(row["p_win"])
| |||
sl_s, tp_s, tr_s = compute_scales(p, min_conf, neutral_scales)
| |||
# Ensure scales come before p_win for the EA parser
| |||
slice_probs.append({
| |||
"strategy": str(row[s_col]),
| |||
"symbol": str(row[y_col]),
| |||
"timeframe": int(row[t_col]) if not pd.isna(row[t_col]) else -1,
| |||
"sl_scale": float(sl_s),
| |||
"tp_scale": float(tp_s),
| |||
"trail_atr_mult": float(tr_s),
| |||
"p_win": p,
| |||
})
| |||
| |||
# Aggregated by strat+symbol across all timeframes (timeframe = -1)
| |||
gb_sym = df_probs.groupby([s_col, y_col])["p_win"].mean().reset_index()
| |||
for _, row in gb_sym.iterrows():
| |||
p = float(row["p_win"])
| |||
sl_s, tp_s, tr_s = compute_scales(p, min_conf, neutral_scales)
| |||
# Skip if an exact slice with tf=-1 already exists (shouldn't) — just add
| |||
slice_probs.append({
| |||
"strategy": str(row[s_col]),
| |||
"symbol": str(row[y_col]),
| |||
"timeframe": -1,
| |||
"sl_scale": float(sl_s),
| |||
"tp_scale": float(tp_s),
| |||
"trail_atr_mult": float(tr_s),
| |||
"p_win": p,
| |||
})
| |||
| |||
# Aggregated by strategy across all symbols/timeframes (symbol = "*", timeframe = -1)
| |||
gb_strat = df_probs.groupby([s_col])["p_win"].mean().reset_index()
| |||
for _, row in gb_strat.iterrows():
| |||
p = float(row["p_win"])
| |||
sl_s, tp_s, tr_s = compute_scales(p, min_conf, neutral_scales)
| |||
slice_probs.append({
| |||
"strategy": str(row[s_col]),
| |||
"symbol": "*",
| |||
"timeframe": -1,
| |||
"sl_scale": float(sl_s),
| |||
"tp_scale": float(tp_s),
| |||
"trail_atr_mult": float(tr_s),
| |||
"p_win": p,
| |||
})
| |||
| |||
# Ensure trainer-side default coverage for known EA strategies
| |||
KNOWN_EA_STRATS = [
| |||
"BollAveragesStrategy",
| |||
"MeanReversionBBStrategy",
| |||
"SuperTrendADXKamaStrategy",
| |||
"RSI2BBReversionStrategy",
| |||
"DonchianATRBreakoutStrategy",
| |||
]
| |||
existing = set()
| |||
for r in slice_probs:
| |||
key = (str(r.get("strategy","")), str(r.get("symbol","")), int(r.get("timeframe", -1)))
| |||
existing.add(key)
| |||
# Add neutral strat-only aggregates where missing
| |||
for strat in KNOWN_EA_STRATS:
| |||
key = (strat, "*", -1)
| |||
if key not in existing:
| |||
# Emit neutral scales explicitly and ensure they appear before p_win
| |||
slice_probs.append({
| |||
"strategy": strat,
| |||
"symbol": "*",
| |||
"timeframe": -1,
| |||
"sl_scale": 1.0,
| |||
"tp_scale": 1.0,
| |||
"trail_atr_mult": 1.0,
| |||
"p_win": 0.50,
| |||
})
| |||
| |||
path = write_policy(min_confidence=float(min_conf), common_dir=common_dir, slice_probs=slice_probs if slice_probs else None)
| |||
return path
| |||
| |||
| |||
if __name__ == "__main__":
| |||
ap = argparse.ArgumentParser(description="Export DualEA policy.json from trained model and features.csv")
| |||
ap.add_argument("--common", type=str, default=None, help="Override Common Files DualEA dir")
| |||
ap.add_argument("--model_dir", type=str, default=os.path.join(os.getcwd(), "artifacts"), help="Directory containing tf_model.keras")
| |||
ap.add_argument("--min_conf", type=float, default=0.55, help="Minimum confidence threshold to enforce in policy.json")
| |||
ap.add_argument("--no_enrich", action="store_true", help="Disable Yahoo enrichment for speed")
| |||
ap.add_argument("--neutral_scales", action="store_true", help="Emit neutral scale fields (1.0)")
| |||
args = ap.parse_args()
| |||
| |||
out = export_policy(args.common, args.model_dir, args.min_conf, enrich=(not args.no_enrich), neutral_scales=args.neutral_scales)
| |||
print(f"Saved policy to {out} (min_confidence={args.min_conf:.2f})")
|