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})")
|