Files
Ottimizzatore/Ottimizzatore Quant Best Europe v.2.py
2026-05-24 22:40:09 +02:00

562 lines
22 KiB
Python

# -*- coding: utf-8 -*-
"""
Ottimizzatore Quant Best Europe - versione ripulita con confronto Wealth-overview.
Output principali:
1) Output/AAAAMMGG Pesi ottimizzati Quant Best Europe.xlsx
2) Output/10040912.538.xlsx
Il secondo file confronta i pesi target generati dall'ottimizzatore con il file
Input/AAAAMMGG_HHMM_Wealth-overview.xlsx piu' recente, ignorando le righe con
Asset class = Liquidity e usando solo la colonna "% of net assets".
"""
import os
import re
import sys
import logging
from datetime import datetime
from pathlib import Path
import cvxpy as cp
import numpy as np
import pandas as pd
import yaml
from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError
from pypfopt import objective_functions as _obj
from pypfopt import risk_models
from pypfopt.efficient_frontier import EfficientFrontier
from pypfopt.exceptions import OptimizationError
# =========================
# PARAMETRI GENERALI
# =========================
BASE_DIR = Path(__file__).resolve().parent
INPUT_DIR = BASE_DIR / "Input"
OUTPUT_DIR = BASE_DIR / "Output"
CONFIG_FILE = BASE_DIR / "config.yaml"
CONNECTION_FILE = BASE_DIR / "connection.txt"
UNIVERSE_FILE = INPUT_DIR / "Universo per Quant Best Europe.xlsx"
TEMPLATE_GUARDIAN_FILE = INPUT_DIR / "Template_Guardian.xls"
OUTPUT_PORTFOLIO_CODE = "Quant BE"
TARGET_PORTFOLIO_NAME = "VAR6_3Y"
DAYS_PER_YEAR = 252
RISKFREE_RATE = 0.02
DB_OBSERVATIONS = 1305
PTF_CURRENCY = "EUR"
# I pesi del file import gestionale sono espressi in percentuale.
# Esempio: peso target 4.75 = 4.75% = 0.0475.
TARGET_WEIGHT_MULTIPLIER = 95.0
# Soglia minima per non produrre ordini irrilevanti per arrotondamenti.
MIN_TRADE_WEIGHT = 0.000001 # 0.0001% del NAV in forma decimale
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
OUTPUT_DIR.mkdir(exist_ok=True)
INPUT_DIR.mkdir(exist_ok=True)
# =========================
# PATCH PYPORTFOLIOOPT
# =========================
def portfolio_variance_psdwrap(w, cov_matrix):
"""Versione piu' robusta di portfolio_variance: evita errori ARPACK in CVXPY."""
variance = cp.quad_form(w, cp.psd_wrap(cov_matrix))
return _obj._objective_value(w, variance)
_obj.portfolio_variance = portfolio_variance_psdwrap
# =========================
# UTILITY DI BASE
# =========================
def read_key_value_file(path: Path) -> dict:
"""Legge file testuali nel formato key=value."""
if not path.exists():
logger.error("File mancante: %s", path)
sys.exit(1)
params = {}
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
key, value = line.split("=", 1)
params[key.strip()] = value.strip()
return params
def load_targets_and_limits(config_file: Path):
"""Legge target di volatilita' e limiti per asset class da config.yaml."""
if not config_file.exists():
logger.error("File di configurazione mancante: %s", config_file)
sys.exit(1)
with config_file.open("r", encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
vt_cfg = cfg.get("volatility_targets", {})
vt_list = vt_cfg.get("default", []) if isinstance(vt_cfg, dict) else vt_cfg
if not vt_list:
logger.error("Sezione 'volatility_targets' mancante o vuota in config.yaml.")
sys.exit(1)
volatility_targets = {
(int(item["years"]), float(item["target_vol"])): item["name"]
for item in vt_list
if {"years", "target_vol", "name"}.issubset(item)
}
if not volatility_targets:
logger.error("Nessun target di volatilita' valido trovato in config.yaml.")
sys.exit(1)
asset_class_limits = cfg.get("asset_class_limits") or {}
if not asset_class_limits:
logger.error("Sezione 'asset_class_limits' mancante o vuota in config.yaml.")
sys.exit(1)
return volatility_targets, {k: float(v) for k, v in asset_class_limits.items()}
def build_engine(connection_file: Path):
"""Crea e verifica la connessione al database."""
params = read_key_value_file(connection_file)
username = params.get("username")
password = params.get("password")
host = params.get("host")
port = params.get("port", "1433")
database = params.get("database")
connection_string = (
f"mssql+pyodbc://{username}:{password}@{host}:{port}/{database}"
"?driver=ODBC+Driver+17+for+SQL+Server"
)
try:
engine = create_engine(connection_string)
with engine.connect() as connection:
connection.execute(text("SELECT 1"))
logger.info("Connessione al database riuscita.")
return engine
except SQLAlchemyError as exc:
logger.error("Errore durante la connessione al database: %s", exc)
sys.exit(1)
def regularize_covariance(cov_df: pd.DataFrame, ridge_factor: float = 1e-6) -> pd.DataFrame:
"""Simmetrizza la covarianza e aggiunge un piccolo ridge diagonale."""
if cov_df.empty:
return cov_df
sigma = cov_df.values.astype(float)
sigma = 0.5 * (sigma + sigma.T)
n = sigma.shape[0]
trace = np.trace(sigma)
eps = ridge_factor * (trace / n) if np.isfinite(trace) and n > 0 else ridge_factor
return pd.DataFrame(sigma + eps * np.eye(n), index=cov_df.index, columns=cov_df.columns)
def validate_universe(df_universe: pd.DataFrame) -> None:
required_cols = ["ISIN", "Nome", "Categoria", "Asset Class", "Bloomberg Ticker", "PesoMax", "PesoFisso"]
missing = [c for c in required_cols if c not in df_universe.columns]
if missing:
logger.error("Colonne mancanti nel file universo: %s", ", ".join(missing))
sys.exit(1)
duplicated = df_universe["ISIN"][df_universe["ISIN"].duplicated()].dropna().unique().tolist()
if duplicated:
logger.warning("ISIN duplicati nel file universo: %s", duplicated)
if df_universe["ISIN"].isna().any():
logger.error("Il file universo contiene righe con ISIN mancante.")
sys.exit(1)
# =========================
# LETTURA INPUT E RENDIMENTI
# =========================
def load_universe(path: Path) -> pd.DataFrame:
if not path.exists():
logger.error("File universo mancante: %s", path)
sys.exit(1)
# Legge tutte le colonne per consentire l'uso della colonna E come Bloomberg Ticker.
# Il file atteso contiene ora:
# A ISIN | B Nome | C Categoria | D Asset Class | E Bloomberg Ticker | F PesoMax | G PesoFisso
df = pd.read_excel(path, dtype=str)
df.columns = [str(c).strip() for c in df.columns]
# Se in futuro il nome della colonna E venisse modificato, la normalizziamo comunque
# a "Bloomberg Ticker" per evitare rotture a valle.
if "Bloomberg Ticker" not in df.columns:
if len(df.columns) >= 5:
df = df.rename(columns={df.columns[4]: "Bloomberg Ticker"})
else:
logger.error("Il file universo non contiene la colonna E con il ticker Bloomberg.")
sys.exit(1)
validate_universe(df)
df["ISIN"] = df["ISIN"].astype(str).str.strip()
df["Bloomberg Ticker"] = df["Bloomberg Ticker"].fillna("").astype(str).str.strip()
df["PesoMax"] = pd.to_numeric(df["PesoMax"], errors="coerce")
df["PesoFisso"] = pd.to_numeric(df["PesoFisso"], errors="coerce")
return df
def load_returns(engine, universe: pd.DataFrame, end_date: pd.Timestamp) -> pd.DataFrame:
"""Carica i rendimenti giornalieri dei titoli dell'universo dal database."""
start_date = end_date - pd.DateOffset(years=5)
all_dates = pd.date_range(start=start_date, end=end_date, freq="B").normalize()
returns = pd.DataFrame(index=all_dates)
for isin in universe["ISIN"].dropna().unique():
logger.info("Caricamento rendimenti: %s", isin)
procedure_call = f"EXEC opt_RendimentoGiornaliero1_ALL @ISIN = '{isin}', @n = {DB_OBSERVATIONS}, @PtfCurr = {PTF_CURRENCY}"
try:
tmp = pd.read_sql_query(procedure_call, engine)
except SQLAlchemyError as exc:
logger.warning("Errore nella stored procedure per %s: %s", isin, exc)
continue
if tmp.empty:
logger.warning("Nessun rendimento recuperato per %s.", isin)
continue
tmp["Px_Date"] = pd.to_datetime(tmp["Px_Date"], errors="coerce").dt.normalize()
tmp = tmp.dropna(subset=["Px_Date"]).set_index("Px_Date")
returns[isin] = (tmp["RendimentoGiornaliero"] / 100.0).reindex(all_dates)
logger.info("%s: %d osservazioni valide.", isin, returns[isin].count())
if returns.empty:
logger.error("Nessun dato di rendimento recuperato dal database.")
sys.exit(1)
high_na = returns.isna().mean()[lambda s: s > 0.20]
if not high_na.empty:
logger.warning("Colonne con oltre 20%% di NaN prima del fill: %s", high_na.to_dict())
return returns.fillna(0.0)
# =========================
# OTTIMIZZAZIONE
# =========================
def add_weight_constraints(ef: EfficientFrontier, universe: pd.DataFrame, columns: pd.Index, asset_class_limits: dict) -> None:
"""Applica vincoli per PesoFisso/PesoMax, Categoria e Asset Class."""
for _, row in universe.iterrows():
isin = row["ISIN"]
if isin not in columns:
continue
idx = columns.get_loc(isin)
fixed_weight = row.get("PesoFisso")
max_weight = row.get("PesoMax")
if pd.notna(fixed_weight):
ef.add_constraint(lambda w, idx=idx, val=float(fixed_weight): w[idx] == val)
elif pd.notna(max_weight):
ef.add_constraint(lambda w, idx=idx, val=float(max_weight): w[idx] <= val)
for category, max_weight in universe.groupby("Categoria")["PesoMax"].max().dropna().items():
idxs = [columns.get_loc(isin) for isin in universe.loc[universe["Categoria"] == category, "ISIN"] if isin in columns]
if idxs:
ef.add_constraint(lambda w, idxs=idxs, val=float(max_weight): sum(w[i] for i in idxs) <= val)
for asset_class, max_weight in asset_class_limits.items():
idxs = [columns.get_loc(isin) for isin in universe.loc[universe["Asset Class"] == asset_class, "ISIN"] if isin in columns]
if idxs:
ef.add_constraint(lambda w, idxs=idxs, val=float(max_weight): sum(w[i] for i in idxs) <= val)
def run_optimizations(returns: pd.DataFrame, universe: pd.DataFrame, volatility_targets: dict, asset_class_limits: dict, end_date: pd.Timestamp) -> pd.DataFrame:
"""Esegue le ottimizzazioni per tutti i target configurati."""
optimized_weights = pd.DataFrame(index=returns.columns)
for (years, target_vol), name in volatility_targets.items():
period_start = end_date - pd.DateOffset(years=years)
period_returns = returns.loc[period_start:end_date]
annual_returns = period_returns.mean() * DAYS_PER_YEAR
annual_cov = risk_models.sample_cov(period_returns, returns_data=True)
annual_cov = regularize_covariance(annual_cov)
ef = EfficientFrontier(annual_returns, annual_cov)
add_weight_constraints(ef, universe, period_returns.columns, asset_class_limits)
try:
ef.efficient_risk(target_volatility=target_vol)
weights = pd.Series(ef.clean_weights()).reindex(returns.columns).fillna(0.0)
optimized_weights[name] = weights
exp_ret, exp_vol, sharpe = ef.portfolio_performance(verbose=False, risk_free_rate=RISKFREE_RATE)
logger.info("%s: return %.2f%% | vol %.2f%% | sharpe %.2f", name, exp_ret * 100, exp_vol * 100, sharpe)
except OptimizationError as exc:
logger.warning("Ottimizzazione fallita per %s: %s", name, exc)
optimized_weights[name] = 0.0
return optimized_weights
# =========================
# EXPORT PESI TARGET
# =========================
def build_target_weights_export(optimized_weights: pd.DataFrame, universe: pd.DataFrame, target_name: str) -> pd.DataFrame:
"""Costruisce il DataFrame del file 'Pesi ottimizzati Quant Best Europe'."""
if target_name not in optimized_weights.columns:
logger.error("Target portfolio '%s' non presente fra i risultati: %s", target_name, list(optimized_weights.columns))
sys.exit(1)
universe_info = universe.set_index("ISIN")[["Nome", "Bloomberg Ticker"]].to_dict("index")
rows = []
for isin, weight in optimized_weights[target_name].items():
if weight > 0:
info = universe_info.get(isin, {})
rows.append({
"cod_por": OUTPUT_PORTFOLIO_CODE,
"ISIN": isin,
"des_tit": info.get("Nome", ""),
"Bloomberg Ticker": info.get("Bloomberg Ticker", ""),
"peso": float(weight * TARGET_WEIGHT_MULTIPLIER),
})
return pd.DataFrame(rows, columns=["cod_por", "ISIN", "des_tit", "Bloomberg Ticker", "peso"])
def export_target_weights(target_df: pd.DataFrame, output_dir: Path, date_tag: str) -> Path:
path = output_dir / f"{date_tag} Pesi ottimizzati Quant Best Europe.xlsx"
export_columns = ["cod_por", "ISIN", "des_tit", "peso"]
with pd.ExcelWriter(path, engine="openpyxl", mode="w") as writer:
target_df.reindex(columns=export_columns).to_excel(writer, sheet_name=OUTPUT_PORTFOLIO_CODE, index=False)
logger.info("File pesi target salvato: %s", path)
return path
# =========================
# CONFRONTO CON WEALTH-OVERVIEW
# =========================
def find_latest_wealth_overview(input_dir: Path) -> Path:
"""Trova il Wealth-overview piu' recente nella cartella Input."""
pattern = re.compile(r"^(\d{8})_(\d{4})_Wealth-overview\.xlsx$", re.IGNORECASE)
candidates = []
for path in input_dir.glob("*_Wealth-overview.xlsx"):
match = pattern.match(path.name)
if match:
candidates.append((match.group(1), match.group(2), path))
if not candidates:
logger.error("Nessun file AAAAMMGG_HHMM_Wealth-overview.xlsx trovato in %s", input_dir)
sys.exit(1)
candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
selected = candidates[0][2]
logger.info("Wealth-overview selezionato: %s", selected)
return selected
def load_current_portfolio_from_wealth(path: Path) -> pd.DataFrame:
"""Legge il Wealth-overview, esclude Liquidity e usa la colonna '% of net assets'."""
raw = pd.read_excel(path, sheet_name=0)
raw.columns = [str(c).strip() for c in raw.columns]
required = ["Asset class", "Description", "ISIN / IBAN", "Position currency", "% of net assets"]
missing = [c for c in required if c not in raw.columns]
if missing:
logger.error("Colonne mancanti nel Wealth-overview %s: %s", path.name, missing)
sys.exit(1)
df = raw.copy()
df["Asset class"] = df["Asset class"].astype(str).str.strip()
df = df[df["Asset class"].str.casefold() != "liquidity"]
df = df[df["ISIN / IBAN"].notna()].copy()
df["ISIN"] = df["ISIN / IBAN"].astype(str).str.strip()
df["current_weight"] = pd.to_numeric(df["% of net assets"], errors="coerce").fillna(0.0)
df["Description"] = df["Description"].fillna("").astype(str)
df["TradingCurrency"] = df["Position currency"].fillna("EUR").astype(str).str.strip().replace("", "EUR")
grouped = (
df.groupby("ISIN", as_index=False)
.agg(
current_weight=("current_weight", "sum"),
Description=("Description", "first"),
TradingCurrency=("TradingCurrency", "first"),
AssetClass=("Asset class", "first"),
)
)
return grouped
def build_orders(target_df: pd.DataFrame, current_df: pd.DataFrame) -> pd.DataFrame:
"""Costruisce ordini Buy/Sell confrontando target e portafoglio corrente."""
target = target_df.copy()
target["target_weight"] = pd.to_numeric(target["peso"], errors="coerce").fillna(0.0) / 100.0
if "Bloomberg Ticker" not in target.columns:
target["Bloomberg Ticker"] = ""
target["Bloomberg Ticker"] = target["Bloomberg Ticker"].fillna("").astype(str).str.strip()
target = target[["ISIN", "des_tit", "Bloomberg Ticker", "target_weight"]]
merged = current_df.merge(target, on="ISIN", how="outer")
merged["current_weight"] = merged["current_weight"].fillna(0.0)
merged["target_weight"] = merged["target_weight"].fillna(0.0)
merged["Description"] = merged["des_tit"].combine_first(merged["Description"]).fillna("")
merged["TradingCurrency"] = merged["TradingCurrency"].fillna("EUR").replace("", "EUR")
merged["Bloomberg Ticker"] = merged["Bloomberg Ticker"].fillna("").astype(str).str.strip()
merged["delta_weight"] = merged["target_weight"] - merged["current_weight"]
order_columns = [
"Order", "ISIN", "TradingCurrency", "Description", "Bloomberg Ticker",
"Limit Price", "Quantity", "Close Position", "Amount", "Weight", "Notes",
]
rows = []
for _, row in merged.iterrows():
delta = float(row["delta_weight"])
current_weight = float(row["current_weight"])
target_weight = float(row["target_weight"])
if abs(delta) <= MIN_TRADE_WEIGHT:
continue
base = {
"ISIN": row["ISIN"],
"TradingCurrency": row["TradingCurrency"] or "EUR",
"Description": row["Description"],
"Bloomberg Ticker": "",
"Limit Price": "",
"Quantity": "",
"Close Position": "",
"Amount": "",
"Weight": "",
"Notes": "",
}
if delta > 0:
base["Order"] = "Buy"
base["Weight"] = round(delta, 10)
# Per i BUY di titoli non gia' presenti in portafoglio, valorizza la colonna E
# del file 10040912.538.xlsx con il Bloomberg Ticker letto dalla colonna E
# dell'universo. Per i BUY di titoli gia' presenti, la colonna resta vuota.
if current_weight <= MIN_TRADE_WEIGHT:
base["Bloomberg Ticker"] = row["Bloomberg Ticker"]
elif target_weight <= MIN_TRADE_WEIGHT and current_weight > MIN_TRADE_WEIGHT:
base["Order"] = "Sell"
base["Close Position"] = "Yes"
else:
base["Order"] = "Sell"
base["Weight"] = round(abs(delta), 10)
rows.append(base)
orders = pd.DataFrame(rows, columns=order_columns)
if orders.empty:
return orders
order_rank = {("Sell", "Yes"): 0, ("Sell", ""): 1, ("Buy", ""): 2}
orders["_rank"] = orders.apply(lambda r: order_rank.get((r["Order"], r["Close Position"]), 9), axis=1)
orders = orders.sort_values(["_rank", "ISIN"], kind="stable").drop(columns="_rank").reset_index(drop=True)
return orders
def instruction_rows() -> list:
return [
["IMPORTANTE", "NON CAMBIARE IL NOME DEL FILE!!"],
["", ""],
["Campi obbligatori:", ""],
["Order", "Selezionare dal menu a tendina 'Buy' o 'Sell'"],
["TradingCurrency", "Selezionare la currency dello strumento"],
["Description", "Compilare con il nome del titolo - serve per controllo"],
["", ""],
["Campi alternativi obbligatori:", ""],
["ISIN", "Indicare il codice ISIN del titolo"],
["Bloomberg Ticker", "Compilare con il ticker Bloomberg completo"],
["", ""],
["Campi facoltativi:", ""],
["Limit Price", "Se vuoto, si intende ordine a mercato"],
["Quantity", "Se vuoto, compilare uno fra Close Position, Amount o Weight"],
["Close Position", "Usare 'Yes' solo per chiudere integralmente una posizione"],
["Amount", "Compilare solo se Quantity e Weight restano vuoti"],
["Weight", "Peso aggiuntivo rispetto al portafoglio gia' presente, espresso in forma decimale"],
["Notes", "Eventuali annotazioni per il Middle Office"],
]
def example_rows() -> list:
return [
["Order", "ISIN", "TradingCurrency", "Description", "Bloomberg Ticker", "Limit Price", "Quantity", "Close Position", "Amount", "Weight", "Notes"],
["Sell", "CA00217Y1043", "CAD", "ATS Corporation", "", "", "", "Yes", "", "", ""],
["Buy", "FR0000121014", "EUR", "LVMH", "", "", "", "", "", 0.10, ""],
["Buy", "", "USD", "Apple", "AAPL US Equity", "", "", "", 10000, "", ""],
]
def caption_rows() -> list:
return [
["Order Type", "", "In Portfolio", "", "Trading Currency", "", "Close Position"],
["Buy", "", "Yes", "", "EUR", "", "Yes"],
["Sell", "", "No", "", "USD", "", ""],
["", "", "", "", "GBP", "", ""],
["", "", "", "", "CAD", "", ""],
["", "", "", "", "CHF", "", ""],
["", "", "", "", "JPY", "", ""],
]
def export_orders_workbook(orders_df: pd.DataFrame, output_dir: Path, date_tag: str) -> Path:
"""Esporta un file ordini analogo a 10040912.538.xlsx."""
output_path = output_dir / "10040912.538.xlsx"
with pd.ExcelWriter(output_path, engine="openpyxl", mode="w") as writer:
orders_df.to_excel(writer, sheet_name="Orders", index=False)
pd.DataFrame(instruction_rows()).to_excel(writer, sheet_name="Instructions", index=False, header=False)
pd.DataFrame(example_rows()[1:], columns=example_rows()[0]).to_excel(writer, sheet_name="Example", index=False)
pd.DataFrame(caption_rows()).to_excel(writer, sheet_name="Caption", index=False, header=False)
workbook = writer.book
for ws in workbook.worksheets:
ws.freeze_panes = "A2" if ws.title in {"Orders", "Example"} else None
for column_cells in ws.columns:
max_len = max(len(str(cell.value)) if cell.value is not None else 0 for cell in column_cells)
ws.column_dimensions[column_cells[0].column_letter].width = min(max(max_len + 2, 10), 45)
logger.info("File ordini salvato: %s", output_path)
return output_path
# =========================
# MAIN
# =========================
def main() -> None:
date_tag = datetime.now().strftime("%Y%m%d")
end_date = pd.Timestamp.now().normalize() - pd.Timedelta(days=1)
universe = load_universe(UNIVERSE_FILE)
volatility_targets, asset_class_limits = load_targets_and_limits(CONFIG_FILE)
engine = build_engine(CONNECTION_FILE)
returns = load_returns(engine, universe, end_date)
optimized_weights = run_optimizations(returns, universe, volatility_targets, asset_class_limits, end_date)
target_df = build_target_weights_export(optimized_weights, universe, TARGET_PORTFOLIO_NAME)
export_target_weights(target_df, OUTPUT_DIR, date_tag)
wealth_path = find_latest_wealth_overview(INPUT_DIR)
current_df = load_current_portfolio_from_wealth(wealth_path)
orders_df = build_orders(target_df, current_df)
export_orders_workbook(orders_df, OUTPUT_DIR, date_tag)
logger.info("Processo completato. Ordini generati: %d", len(orders_df))
if __name__ == "__main__":
main()