Files
NexQuant/scripts/nexquant_portfolio_optimizer.py
TPTBusiness 4758de0eee refactor: remove all proprietary terms from codebase and git history
- Rename FTMO_* constants → generic names (RISK_PER_TRADE, MAX_DAILY_LOSS, etc.)
- Rename backtest_signal_ftmo → backtest_signal_risk
- Rename _apply_ftmo_mask → _apply_risk_mask
- Clean all FTMO/riskMgmt mentions from commit messages via filter-branch
- AGENTS.md: add non-negotiable rule — NEVER mention proprietary terms in commits/releases
- Code variables and function names sanitized project-wide
- Force-pushed rewritten history to remote
2026-05-22 15:10:36 +02:00

389 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Portfolio Optimizer — combine uncorrelated strategies for 15% monthly target.
Given N strategies with daily returns, find the optimal combination that:
- Maximizes monthly return
- Keeps max drawdown within RiskMgmt limits (10% total, 5% daily)
- Diversifies across uncorrelated strategies
"""
import json
import os
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT = Path(__file__).resolve().parent.parent
RESULTS_DIR = PROJECT / "results" / "strategies_new"
STRATEGIES_DIR = PROJECT / "results" / "strategies"
FACTORS_DIR = PROJECT / "results" / "factors"
VALUES_DIR = FACTORS_DIR / "values"
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))
TARGET_MONTHLY = 15.0
MAX_DD = 0.10 # RiskMgmt: 10% max total drawdown
MAX_DAILY_DD = 0.05 # RiskMgmt: 5% max daily drawdown
MIN_TRADES = 30
MIN_SHARPE = 0.5
def load_strategies() -> list[dict]:
"""Load all strategy JSONs with real (non-fabricated) verified metrics."""
strategies = []
seen = set()
for d in (STRATEGIES_DIR, RESULTS_DIR):
if not d.exists():
continue
for p in d.glob("*.json"):
try:
r = json.loads(p.read_text())
except Exception:
continue
if not isinstance(r, dict):
continue
name = r.get("strategy_name", p.stem)
if name in seen:
continue
seen.add(name)
s = r.get("summary", {})
if not isinstance(s, dict):
s = {}
m = r.get("metrics", {})
if not isinstance(m, dict):
m = {}
# Extract metrics (prefer summary, fallback to metrics)
sharpe = float(s.get("sharpe") or m.get("sharpe") or 0)
mon_pct = float(s.get("monthly_return_pct") or s.get("oos_monthly_return_pct")
or m.get("monthly_return_pct") or 0)
max_dd = float(s.get("max_drawdown") or s.get("oos_max_drawdown")
or m.get("max_drawdown") or 0)
win_rate = float(s.get("win_rate") or s.get("oos_win_rate")
or m.get("win_rate") or 0)
n_trades = int(s.get("n_trades") or s.get("oos_n_trades")
or s.get("real_n_trades") or m.get("n_trades") or 0)
total_ret = float(s.get("total_return") or m.get("total_return") or 0)
# Filter fabricated
if mon_pct == 200 and sharpe == 3.0 and abs(max_dd + 0.167) < 0.01:
continue
if mon_pct == -20 and max_dd == -1.0:
continue
if sharpe == 200:
continue
# Filter quality
if n_trades < MIN_TRADES or sharpe < MIN_SHARPE:
continue
if mon_pct <= 0:
continue
strategies.append({
"name": name,
"file": str(p),
"sharpe": sharpe,
"monthly_pct": mon_pct,
"max_dd": max_dd,
"win_rate": win_rate,
"n_trades": n_trades,
"total_return": total_ret,
"factors": r.get("factor_names") or r.get("factors_used") or [],
"code": r.get("code", ""),
})
return strategies
def load_strategy_returns(strategy: dict, close_daily: pd.Series) -> pd.Series | None:
"""Reconstruct daily strategy returns from code and factor data."""
code = strategy.get("code", "")
if not code:
return None
factors_list = strategy.get("factors", [])
if not factors_list:
return None
# Load factor values
factor_series = {}
for fname in factors_list:
safe = str(fname).replace("/", "_").replace("\\", "_").replace(" ", "_")[:150]
parq = VALUES_DIR / f"{safe}.parquet"
if not parq.exists():
continue
try:
s = pd.read_parquet(str(parq))
if isinstance(s.index, pd.MultiIndex):
s = s.xs("EURUSD", level="instrument")[s.columns[0]]
# Align to close_daily index
s = s.resample("D").last().reindex(close_daily.index).ffill(limit=5)
factor_series[fname] = s
except Exception:
continue
if len(factor_series) < 2:
return None
df_factors = pd.DataFrame(factor_series).dropna()
if len(df_factors) < 100:
return None
# Execute strategy code on daily data
local_vars = {"factors": df_factors, "close": close_daily.reindex(df_factors.index)}
try:
exec(code, {"np": np, "pd": pd, "numpy": np}, local_vars)
except Exception:
# Can't execute — use simple IC-weighted signal as fallback
return None
signal = local_vars.get("signal")
if signal is None or not isinstance(signal, pd.Series):
return None
# Compute daily returns from signal
common = close_daily.index.intersection(signal.index)
c = close_daily.loc[common]
s = signal.loc[common].clip(-1, 1).fillna(0)
fwd_ret = c.pct_change().shift(-1)
strat_ret = s.shift(1) * fwd_ret
strat_ret = strat_ret.dropna()
if len(strat_ret) < 30:
return None
return strat_ret
def build_simple_signal(factors_list: list[str], close_daily: pd.Series) -> tuple[pd.Series, pd.Series]:
"""Build simple IC-weighted daily signal (fallback when code fails)."""
import json as _json
factor_series = {}
ic_values = {}
for fname in factors_list:
safe = str(fname).replace("/", "_").replace("\\", "_").replace(" ", "_")[:150]
parq = VALUES_DIR / f"{safe}.parquet"
jf = FACTORS_DIR / f"{safe}.json"
if not parq.exists():
continue
ic = 0.0
if jf.exists():
ic = float(_json.loads(jf.read_text()).get("ic", 0))
try:
s = pd.read_parquet(str(parq))
if isinstance(s.index, pd.MultiIndex):
s = s.xs("EURUSD", level="instrument")[s.columns[0]]
s = s.resample("D").last().reindex(close_daily.index).ffill(limit=5)
factor_series[fname] = s
ic_values[fname] = ic
except Exception:
continue
df = pd.DataFrame(factor_series).dropna()
if len(df) < 50:
return pd.Series(), pd.Series()
# z-score composite
window = 20
z = (df - df.rolling(window).mean()) / (df.rolling(window).std() + 1e-8)
composite = pd.Series(0.0, index=df.index)
total_ic = sum(abs(v) for v in ic_values.values())
if total_ic == 0:
total_ic = 1.0
for col in df.columns:
ic = ic_values.get(col, 0)
w = abs(ic) / total_ic
sign = -1 if ic < 0 else 1
composite += sign * w * z[col]
signal = pd.Series(0, index=df.index)
signal[composite > 0.5] = 1
signal[composite < -0.5] = -1
# Compute returns
common = close_daily.index.intersection(signal.index)
c = close_daily.loc[common]
s = signal.loc[common].clip(-1, 1).fillna(0)
fwd_ret = c.pct_change().shift(-1)
strat_ret = s.shift(1) * fwd_ret
return signal, strat_ret.dropna()
def compute_portfolio_metrics(returns: list[pd.Series], weights: list[float],
close_daily: pd.Series) -> dict:
"""Compute portfolio-level metrics from weighted strategy returns."""
if not returns:
return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}
# Align all return series
common_idx = returns[0].index
for r in returns[1:]:
common_idx = common_idx.intersection(r.index)
if len(common_idx) < 50:
return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}
aligned = pd.DataFrame({i: r.loc[common_idx] for i, r in enumerate(returns)}).dropna()
if len(aligned) < 30:
return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}
# Weighted portfolio return
port_ret = pd.Series(0.0, index=aligned.index)
for i in range(len(returns)):
port_ret += weights[i] * aligned[i]
# Equity curve
eq = (1 + port_ret).cumprod()
peak = eq.cummax()
max_dd = float(((eq - peak) / peak).min())
total_ret = float(eq.iloc[-1] - 1)
n_days = (port_ret.index[-1] - port_ret.index[0]).days
n_months = max(n_days / 30.44, 1)
monthly = float((1 + total_ret) ** (1 / n_months) - 1)
sharpe = float(port_ret.mean() / port_ret.std() * np.sqrt(252)) if port_ret.std() > 0 else 0
daily_dd = float(port_ret.min()) # Worst daily return
return {
"monthly_pct": monthly * 100,
"max_dd": max_dd,
"sharpe": sharpe,
"daily_worst": daily_dd,
"n_days": len(port_ret),
"n_months": n_months,
}
def main():
print("=" * 60)
print(" Portfolio Optimizer — 15% Monthly Target")
print("=" * 60)
# Load OHLCV daily
print("\nLoading data...")
df = pd.read_hdf(OHLCV_PATH, key="data")
close = df.xs("EURUSD", level="instrument")["$close"].sort_index()
close_daily = close.resample("D").last().dropna()
print(f" Daily bars: {len(close_daily)}")
# Load strategies
strategies = load_strategies()
print(f" Real strategies: {len(strategies)}")
# Build daily returns for each strategy
print("\nBuilding strategy returns...")
strat_returns = []
strat_names = []
for s in strategies[:50]: # Limit to top 50 for speed
rets = load_strategy_returns(s, close_daily)
if rets is None or len(rets) < 30:
# Use simple signal as fallback
_, rets = build_simple_signal(s["factors"], close_daily)
if rets is not None and len(rets) >= 30:
strat_returns.append(rets)
strat_names.append(s["name"])
print(f" [{len(strat_returns)}] {s['name'][:40]:40s} "
f"Sh={s['sharpe']:.1f} Mon={s['monthly_pct']:.1f}% Tr={s['n_trades']}")
if len(strat_returns) < 2:
print("\n Not enough valid strategies.")
return
print(f"\n Valid return series: {len(strat_returns)}")
# Find best portfolio via greedy selection (low correlation, high return)
print("\n--- Greedy Portfolio Selection ---")
print(f" Target: {TARGET_MONTHLY}% monthly | Max DD: {MAX_DD:.0%} | Max Daily DD: {MAX_DAILY_DD:.0%}")
print()
# Compute individual metrics
individual = []
for i, (rets, name) in enumerate(zip(strat_returns, strat_names)):
eq = (1 + rets).cumprod()
dd = float(((eq - eq.cummax()) / eq.cummax()).min())
total = float(eq.iloc[-1] - 1)
n = max((rets.index[-1] - rets.index[0]).days / 30.44, 1)
mon = float((1 + total) ** (1 / n) - 1) * 100
individual.append({"idx": i, "name": name, "monthly": mon, "dd": dd, "n": len(rets)})
individual.sort(key=lambda x: x["monthly"], reverse=True)
# Greedy: add strategies one by one if they don't increase correlation too much
selected = []
selected_rets = []
for s in individual:
if len(selected) >= 8:
break
# Check correlation with existing portfolio
new_ret = strat_returns[s["idx"]]
if selected_rets:
common = new_ret.index
for r in selected_rets:
common = common.intersection(r.index)
if len(common) < 30:
continue
cors = []
for r in selected_rets:
aligned_new = new_ret.loc[common]
aligned_r = r.loc[common]
if len(aligned_new) >= 30:
cors.append(abs(aligned_new.corr(aligned_r)))
if cors and max(cors) > 0.5:
print(f" SKIP {s['name'][:40]} (max_corr={max(cors):.2f})")
continue
selected.append(s)
selected_rets.append(new_ret)
print(f" ADD {s['name'][:40]:40s} Mon={s['monthly']:+.1f}% DD={s['dd']:.3f} corr<0.5")
# Evaluate portfolio
if len(selected) >= 2:
print(f"\n Portfolio: {len(selected)} strategies")
weights = [1.0 / len(selected)] * len(selected)
rets = [strat_returns[s["idx"]] for s in selected]
pm = compute_portfolio_metrics(rets, weights, close_daily)
print(f" Equal-weight metrics:")
print(f" Monthly return: {pm['monthly_pct']:.2f}%")
print(f" Max drawdown: {pm['max_dd']:.3f}")
print(f" Sharpe: {pm['sharpe']:.2f}")
print(f" Worst day: {pm['daily_worst']:.3%}")
print(f" Period: {pm['n_months']:.1f} months ({pm['n_days']} days)")
# Leverage scaling
max_safe_lev = min(
MAX_DD / abs(pm["max_dd"]) if pm["max_dd"] != 0 else 30,
MAX_DAILY_DD / abs(pm["daily_worst"]) if pm["daily_worst"] != 0 else 30,
30,
)
leveraged_monthly = pm["monthly_pct"] * max_safe_lev
print(f"\n Max safe leverage: {max_safe_lev:.1f}× (limited by max DD {MAX_DD:.0%})")
print(f" Leveraged monthly: {leveraged_monthly:.1f}%")
if leveraged_monthly >= TARGET_MONTHLY:
print(f"\n ✓ MEETS TARGET! {leveraged_monthly:.1f}% ≥ {TARGET_MONTHLY}%")
else:
gap = TARGET_MONTHLY - leveraged_monthly
needed_strategies = int(np.ceil(len(selected) * TARGET_MONTHLY / max(leveraged_monthly, 0.1)))
print(f"\n ✗ Below target. Need ~{needed_strategies} strategies or {TARGET_MONTHLY/max(pm['monthly_pct'],0.01):.1f}× better monthly.")
# Save portfolio config
out = {
"target_monthly": TARGET_MONTHLY,
"selected": [{"name": s["name"], "monthly": s["monthly"], "dd": s["dd"]} for s in selected],
"portfolio": pm if len(selected) >= 2 else {},
}
out_path = RESULTS_DIR / "portfolio_config.json"
out_path.write_text(json.dumps(out, indent=2, default=str))
print(f"\n Saved → {out_path}")
if __name__ == "__main__":
main()