"""Portfolio optimization: MVO, Black-Litterman, HRP, mean-CVaR, robust, max-diversification,
tracking error, turnover-penalized, and the Grinold-Kahn alpha-risk-cost framework.

All functions are pure: data in, results out. No DB access, no API calls.
Requires numpy (core dep) and scipy (core dep).
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple

import numpy as np
from scipy import optimize as sp_opt

EPSILON: float = 1e-15
TRADING_DAYS: int = 252


# ── Result models ────────────────────────────────────────────────────

@dataclass
class OptResult:
    method: str = ""
    weights: Dict[str, float] = field(default_factory=dict)
    expected_return: float = 0.0
    volatility: float = 0.0
    sharpe_ratio: float = 0.0
    info: Dict[str, Any] = field(default_factory=dict)


@dataclass
class BLResult:
    posterior_returns: Dict[str, float] = field(default_factory=dict)
    optimal_weights: Dict[str, float] = field(default_factory=dict)
    tilts: Dict[str, float] = field(default_factory=dict)
    expected_return: float = 0.0
    volatility: float = 0.0
    sharpe_ratio: float = 0.0


@dataclass
class ARCResult:
    """Alpha-Risk-Cost optimisation result."""
    optimal_weights: Dict[str, float] = field(default_factory=dict)
    expected_alpha: float = 0.0
    expected_risk: float = 0.0
    expected_cost: float = 0.0
    information_ratio: float = 0.0
    transfer_coefficient: float = 0.0


@dataclass
class Constraints:
    """Optimisation constraints — every limit is configurable."""
    long_only: bool = False
    max_weight: float = 1.0
    min_weight: float = -1.0
    max_gross_exposure: float = 2.0
    max_net_exposure: float = 1.0
    sector_limits: Optional[Dict[str, float]] = None
    factor_limits: Optional[Dict[str, Tuple[float, float]]] = None
    max_turnover: Optional[float] = None
    max_tracking_error: Optional[float] = None


# ── Helpers ──────────────────────────────────────────────────────────

def _build_matrices(
    returns: Dict[str, List[float]],
    risk_free_rate: float = 0.0,
) -> Tuple[List[str], np.ndarray, np.ndarray]:
    ids = list(returns.keys())
    min_len = min(len(returns[i]) for i in ids)
    R = np.column_stack([np.array(returns[i][-min_len:], dtype=np.float64) for i in ids])
    mu = np.mean(R, axis=0) * TRADING_DAYS
    cov = np.cov(R.T, ddof=1) * TRADING_DAYS
    if cov.ndim == 0:
        cov = np.array([[float(cov)]])
    return ids, mu, cov


def _bounds(n: int, constraints: Optional[Constraints] = None):
    c = constraints or Constraints()
    lo = 0.0 if c.long_only else c.min_weight
    hi = c.max_weight
    return [(lo, hi)] * n


def _metrics(w: np.ndarray, mu: np.ndarray, cov: np.ndarray, rf: float, ids: List[str]) -> OptResult:
    ret = float(w @ mu)
    vol = float(np.sqrt(w @ cov @ w))
    sr = (ret - rf) / vol if vol > EPSILON else 0.0
    return OptResult(
        weights={ids[j]: round(float(w[j]), 6) for j in range(len(ids))},
        expected_return=round(ret * 100, 4),
        volatility=round(vol * 100, 4),
        sharpe_ratio=round(sr, 4),
    )


# ── Core optimisers ─────────────────────────────────────────────────

def mean_variance(
    returns: Dict[str, List[float]],
    *,
    risk_free_rate: float = 0.0,
    target_return: Optional[float] = None,
    constraints: Optional[Constraints] = None,
) -> OptResult:
    """Classic Markowitz mean-variance optimisation.

    When *target_return* is given, finds the minimum-variance portfolio
    achieving that return. Otherwise maximises Sharpe ratio.
    """
    ids, mu, cov = _build_matrices(returns, risk_free_rate)
    n = len(ids)
    bounds = _bounds(n, constraints)
    cons = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
    if target_return is not None:
        cons.append({"type": "eq", "fun": lambda w: w @ mu - target_return})

    def objective(w):
        return float(w @ cov @ w)

    x0 = np.ones(n) / n
    result = sp_opt.minimize(objective, x0, method="SLSQP", bounds=bounds, constraints=cons,
                             options={"maxiter": 500, "ftol": 1e-12})
    w = result.x / max(np.sum(result.x), EPSILON) if np.sum(result.x) > EPSILON else np.ones(n) / n
    r = _metrics(w, mu, cov, risk_free_rate, ids)
    r.method = "mean_variance"
    return r


def min_variance(
    returns: Dict[str, List[float]],
    *,
    constraints: Optional[Constraints] = None,
) -> OptResult:
    """Global minimum variance — no return estimates needed."""
    ids, mu, cov = _build_matrices(returns)
    n = len(ids)
    bounds = _bounds(n, constraints)
    cons = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]

    def objective(w):
        return float(w @ cov @ w)

    x0 = np.ones(n) / n
    result = sp_opt.minimize(objective, x0, method="SLSQP", bounds=bounds, constraints=cons,
                             options={"maxiter": 500, "ftol": 1e-12})
    w = np.abs(result.x)
    w /= max(np.sum(w), EPSILON)
    r = _metrics(w, mu, cov, 0.0, ids)
    r.method = "min_variance"
    return r


def max_sharpe(
    returns: Dict[str, List[float]],
    *,
    risk_free_rate: float = 0.0,
    constraints: Optional[Constraints] = None,
) -> OptResult:
    """Maximum Sharpe ratio (tangency portfolio)."""
    ids, mu, cov = _build_matrices(returns, risk_free_rate)
    n = len(ids)
    bounds = _bounds(n, constraints)
    cons = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]

    def neg_sharpe(w):
        ret = w @ mu
        vol = np.sqrt(w @ cov @ w)
        return -(ret - risk_free_rate) / max(vol, EPSILON)

    x0 = np.ones(n) / n
    result = sp_opt.minimize(neg_sharpe, x0, method="SLSQP", bounds=bounds, constraints=cons,
                             options={"maxiter": 500, "ftol": 1e-12})
    w = result.x
    r = _metrics(w, mu, cov, risk_free_rate, ids)
    r.method = "max_sharpe"
    return r


def risk_parity(
    returns: Dict[str, List[float]],
    *,
    budget: Optional[Dict[str, float]] = None,
    max_iter: int = 500,
    tolerance: float = 1e-10,
) -> OptResult:
    """Risk parity: equal (or budgeted) risk contribution.

    Args:
        returns: ``{asset_id: [daily_returns]}``.
        budget: ``{asset_id: target_risk_pct}`` — if None, equal budget.
        max_iter: Maximum solver iterations.
        tolerance: Convergence tolerance.
    """
    ids, mu, cov = _build_matrices(returns)
    n = len(ids)

    target = np.ones(n) / n
    if budget:
        for j, aid in enumerate(ids):
            if aid in budget:
                target[j] = budget[aid]
        target /= max(np.sum(target), EPSILON)

    w = np.ones(n) / n
    for _ in range(max_iter):
        sigma_w = cov @ w
        port_vol = float(np.sqrt(w @ sigma_w))
        if port_vol < EPSILON:
            break
        rc = w * sigma_w / port_vol
        new_w = w * (target * port_vol / (rc + EPSILON)) ** 0.5
        new_w /= max(np.sum(new_w), EPSILON)
        if np.max(np.abs(new_w - w)) < tolerance:
            w = new_w
            break
        w = new_w

    r = _metrics(w, mu, cov, 0.0, ids)
    r.method = "risk_parity"
    return r


def equal_weight(
    asset_ids: List[str],
) -> OptResult:
    """1/N equal-weight portfolio."""
    n = len(asset_ids)
    w = 1.0 / n if n > 0 else 0.0
    return OptResult(
        method="equal_weight",
        weights={a: round(w, 6) for a in asset_ids},
    )


# ── Advanced optimisers ──────────────────────────────────────────────

def black_litterman(
    market_caps: Dict[str, float],
    cov: np.ndarray,
    views: Dict[str, float],
    view_confidence: Dict[str, float],
    *,
    risk_aversion: float = 2.5,
    tau: float = 0.05,
    asset_ids: Optional[List[str]] = None,
    risk_free_rate: float = 0.0,
) -> BLResult:
    """Black-Litterman (1992) model.

    Combines market equilibrium with active views to produce
    posterior expected returns, then optimises.

    Args:
        market_caps: ``{asset_id: market_cap}``.
        cov: ``(n, n)`` covariance matrix (annualised).
        views: ``{asset_id: expected_return_view}``.
        view_confidence: ``{asset_id: confidence_0_to_1}``.
        risk_aversion: Market risk aversion parameter (delta).
        tau: Uncertainty scaling of the prior (typically 0.01-0.05).
        asset_ids: Asset ordering matching *cov*.
        risk_free_rate: Annual risk-free rate.
    """
    if asset_ids is None:
        asset_ids = list(market_caps.keys())
    n = len(asset_ids)
    Sigma = np.asarray(cov, dtype=np.float64)

    caps = np.array([market_caps.get(a, 0.0) for a in asset_ids], dtype=np.float64)
    w_mkt = caps / max(np.sum(caps), EPSILON)
    pi = risk_aversion * Sigma @ w_mkt

    view_assets = [a for a in asset_ids if a in views]
    k = len(view_assets)
    if k == 0:
        w = w_mkt
        ret = float(w @ pi)
        vol = float(np.sqrt(w @ Sigma @ w))
        return BLResult(
            posterior_returns={asset_ids[j]: round(float(pi[j]), 6) for j in range(n)},
            optimal_weights={asset_ids[j]: round(float(w[j]), 6) for j in range(n)},
            tilts={},
            expected_return=round(ret * 100, 4),
            volatility=round(vol * 100, 4),
            sharpe_ratio=round((ret - risk_free_rate) / max(vol, EPSILON), 4),
        )

    P = np.zeros((k, n))
    Q = np.zeros(k)
    omega_diag = np.zeros(k)
    for i, a in enumerate(view_assets):
        j = asset_ids.index(a)
        P[i, j] = 1.0
        Q[i] = views[a]
        conf = max(min(view_confidence.get(a, 0.5), 0.999), 0.001)
        omega_diag[i] = (1.0 - conf) / conf * (P[i] @ (tau * Sigma) @ P[i])

    Omega = np.diag(omega_diag)
    tau_sigma = tau * Sigma
    tau_sigma_inv = np.linalg.inv(tau_sigma)
    omega_inv = np.linalg.inv(Omega)

    post_cov = np.linalg.inv(tau_sigma_inv + P.T @ omega_inv @ P)
    post_mu = post_cov @ (tau_sigma_inv @ pi + P.T @ omega_inv @ Q)

    try:
        inv_cov = np.linalg.inv(Sigma)
        w_opt = inv_cov @ post_mu
        w_opt /= max(np.sum(np.abs(w_opt)), EPSILON)
    except np.linalg.LinAlgError:
        w_opt = w_mkt

    ret = float(w_opt @ post_mu)
    vol = float(np.sqrt(w_opt @ Sigma @ w_opt))

    return BLResult(
        posterior_returns={asset_ids[j]: round(float(post_mu[j]), 6) for j in range(n)},
        optimal_weights={asset_ids[j]: round(float(w_opt[j]), 6) for j in range(n)},
        tilts={asset_ids[j]: round(float(w_opt[j] - w_mkt[j]), 6) for j in range(n)},
        expected_return=round(ret * 100, 4),
        volatility=round(vol * 100, 4),
        sharpe_ratio=round((ret - risk_free_rate) / max(vol, EPSILON), 4),
    )


def hierarchical_risk_parity(
    returns: Dict[str, List[float]],
    *,
    linkage_method: str = "single",
) -> OptResult:
    """Hierarchical Risk Parity (Lopez de Prado 2016).

    No covariance inversion — robust to estimation error.

    1. Distance matrix from correlation
    2. Hierarchical clustering
    3. Quasi-diagonalise
    4. Recursive bisection

    Args:
        returns: ``{asset_id: [daily_returns]}``.
        linkage_method: ``"single"`` (default), ``"complete"``, ``"average"``,
                        or ``"ward"``.
    """
    from scipy.cluster.hierarchy import linkage as sp_linkage
    from scipy.spatial.distance import squareform

    ids, mu, cov = _build_matrices(returns)
    n = len(ids)

    std = np.sqrt(np.diag(cov))
    corr = cov / np.outer(std + EPSILON, std + EPSILON)
    corr = np.clip(corr, -1, 1)

    dist = np.sqrt(0.5 * (1.0 - corr))
    np.fill_diagonal(dist, 0.0)
    dist = (dist + dist.T) / 2.0
    condensed = squareform(dist, checks=False)

    Z = sp_linkage(condensed, method=linkage_method)

    def _get_quasi_diag(link: np.ndarray, n_items: int) -> list:
        sort_idx = list(range(n_items))
        link = link.astype(int)
        clusters = {i: [i] for i in range(n_items)}
        for i in range(link.shape[0]):
            c1 = link[i, 0]
            c2 = link[i, 1]
            new_id = n_items + i
            clusters[new_id] = clusters[c1] + clusters[c2]
        return clusters[n_items + link.shape[0] - 1]

    sorted_idx = _get_quasi_diag(Z, n)

    def _recursive_bisect(cov_mat: np.ndarray, sorted_items: list) -> np.ndarray:
        w = np.ones(len(sorted_items))
        cluster_items = [sorted_items]
        while len(cluster_items) > 0:
            new_clusters = []
            for cluster in cluster_items:
                if len(cluster) <= 1:
                    continue
                half = len(cluster) // 2
                left = cluster[:half]
                right = cluster[half:]

                left_idx = [sorted_items.index(i) for i in left]
                right_idx = [sorted_items.index(i) for i in right]

                cov_left = cov_mat[np.ix_(left, left)]
                cov_right = cov_mat[np.ix_(right, right)]

                inv_left = 1.0 / max(np.sqrt(_cluster_var(cov_left)), EPSILON)
                inv_right = 1.0 / max(np.sqrt(_cluster_var(cov_right)), EPSILON)
                alpha = inv_left / (inv_left + inv_right)

                for i in left_idx:
                    w[i] *= alpha
                for i in right_idx:
                    w[i] *= (1.0 - alpha)

                if len(left) > 1:
                    new_clusters.append(left)
                if len(right) > 1:
                    new_clusters.append(right)
            cluster_items = new_clusters
        return w

    def _cluster_var(cov_sub: np.ndarray) -> float:
        n_sub = cov_sub.shape[0]
        if n_sub == 1:
            return float(cov_sub[0, 0])
        inv_diag = 1.0 / np.maximum(np.diag(cov_sub), EPSILON)
        w_sub = inv_diag / np.sum(inv_diag)
        return float(w_sub @ cov_sub @ w_sub)

    cov_reordered = cov[np.ix_(sorted_idx, sorted_idx)]
    hrp_w = _recursive_bisect(cov_reordered, list(range(n)))

    final_w = np.zeros(n)
    for i, orig_idx in enumerate(sorted_idx):
        final_w[orig_idx] = hrp_w[i]
    final_w /= max(np.sum(final_w), EPSILON)

    r = _metrics(final_w, mu, cov, 0.0, ids)
    r.method = "hierarchical_risk_parity"
    r.info["linkage_method"] = linkage_method
    return r


def mean_cvar(
    returns: Dict[str, List[float]],
    *,
    confidence: float = 0.95,
    target_return: Optional[float] = None,
    constraints: Optional[Constraints] = None,
) -> OptResult:
    """Minimise Conditional VaR (Expected Shortfall).

    Uses linear programming on empirical scenarios. Better than MVO for
    fat-tailed distributions.

    Args:
        returns: ``{asset_id: [daily_returns]}``.
        confidence: CVaR confidence level (default 0.95).
        target_return: Minimum expected return constraint.
        constraints: Portfolio constraints.
    """
    ids, mu, cov = _build_matrices(returns)
    n = len(ids)
    min_len = min(len(returns[i]) for i in ids)
    R = np.column_stack([np.array(returns[i][-min_len:], dtype=np.float64) for i in ids])
    t = R.shape[0]

    alpha = 1.0 - confidence
    bounds = _bounds(n, constraints)

    cons = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
    if target_return is not None:
        cons.append({"type": "ineq", "fun": lambda w: w @ mu - target_return})

    def cvar_obj(w):
        port_ret = R @ w
        sorted_ret = np.sort(port_ret)
        cutoff = max(int(t * alpha), 1)
        es = -np.mean(sorted_ret[:cutoff])
        return es

    x0 = np.ones(n) / n
    result = sp_opt.minimize(cvar_obj, x0, method="SLSQP", bounds=bounds, constraints=cons,
                             options={"maxiter": 500, "ftol": 1e-12})
    w = result.x
    r = _metrics(w, mu, cov, 0.0, ids)
    r.method = "mean_cvar"
    r.info["confidence"] = confidence
    return r


def robust_mvo(
    returns: Dict[str, List[float]],
    *,
    epsilon: float = 0.1,
    risk_free_rate: float = 0.0,
    constraints: Optional[Constraints] = None,
) -> OptResult:
    """Robust MVO with uncertainty around expected returns.

    Implements a simplified version of Goldfarb & Iyengar (2003):
    maximise ``w'mu - epsilon * ||Sigma^{1/2} w|| - lambda * w'Sigma w``.

    Args:
        returns: ``{asset_id: [daily_returns]}``.
        epsilon: Size of the uncertainty set around mu.
        risk_free_rate: Annual risk-free rate.
        constraints: Portfolio constraints.
    """
    ids, mu, cov = _build_matrices(returns, risk_free_rate)
    n = len(ids)
    bounds = _bounds(n, constraints)
    cons = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]

    try:
        L = np.linalg.cholesky(cov + np.eye(n) * EPSILON)
    except np.linalg.LinAlgError:
        L = np.eye(n) * np.sqrt(np.diag(cov) + EPSILON)

    def objective(w):
        ret = w @ mu
        vol = np.sqrt(w @ cov @ w)
        uncertainty = epsilon * np.linalg.norm(L.T @ w)
        return -(ret - uncertainty - 0.5 * vol ** 2)

    x0 = np.ones(n) / n
    result = sp_opt.minimize(objective, x0, method="SLSQP", bounds=bounds, constraints=cons,
                             options={"maxiter": 500, "ftol": 1e-12})
    w = result.x
    r = _metrics(w, mu, cov, risk_free_rate, ids)
    r.method = "robust_mvo"
    r.info["epsilon"] = epsilon
    return r


def max_diversification(
    returns: Dict[str, List[float]],
    *,
    constraints: Optional[Constraints] = None,
) -> OptResult:
    """Maximum Diversification Ratio (Choueifaty & Coignard 2008).

    Maximise DR = w'sigma / sqrt(w'Sigma w).
    """
    ids, mu, cov = _build_matrices(returns)
    n = len(ids)
    sigma = np.sqrt(np.diag(cov))
    bounds = _bounds(n, constraints)
    cons = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]

    def neg_dr(w):
        port_vol = np.sqrt(w @ cov @ w)
        weighted_vol = w @ sigma
        return -weighted_vol / max(port_vol, EPSILON)

    x0 = np.ones(n) / n
    result = sp_opt.minimize(neg_dr, x0, method="SLSQP", bounds=bounds, constraints=cons,
                             options={"maxiter": 500, "ftol": 1e-12})
    w = result.x
    r = _metrics(w, mu, cov, 0.0, ids)
    r.method = "max_diversification"
    r.info["diversification_ratio"] = round(-neg_dr(w), 4)
    return r


def min_tracking_error(
    returns: Dict[str, List[float]],
    benchmark_weights: Dict[str, float],
    *,
    constraints: Optional[Constraints] = None,
) -> OptResult:
    """Minimise tracking error vs benchmark.

    TE = sqrt((w - w_b)' Sigma (w - w_b)).
    """
    ids, mu, cov = _build_matrices(returns)
    n = len(ids)
    w_b = np.array([benchmark_weights.get(a, 0.0) for a in ids], dtype=np.float64)
    bounds = _bounds(n, constraints)
    cons = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]

    def te(w):
        diff = w - w_b
        return float(diff @ cov @ diff)

    x0 = w_b.copy()
    if np.sum(np.abs(x0)) < EPSILON:
        x0 = np.ones(n) / n
    result = sp_opt.minimize(te, x0, method="SLSQP", bounds=bounds, constraints=cons,
                             options={"maxiter": 500, "ftol": 1e-12})
    w = result.x
    r = _metrics(w, mu, cov, 0.0, ids)
    r.method = "min_tracking_error"
    r.info["tracking_error"] = round(float(np.sqrt((w - w_b) @ cov @ (w - w_b))) * 100, 4)
    return r


def turnover_penalized(
    returns: Dict[str, List[float]],
    current_weights: Dict[str, float],
    *,
    turnover_penalty: float = 0.001,
    risk_free_rate: float = 0.0,
    constraints: Optional[Constraints] = None,
) -> OptResult:
    """MVO with turnover penalty.

    Maximise: ``w'mu - (lambda/2) w'Sigma w - kappa |w - w_0|``

    Args:
        returns: ``{asset_id: [daily_returns]}``.
        current_weights: Current portfolio weights.
        turnover_penalty: Cost per unit of turnover (kappa).
        risk_free_rate: Annual risk-free rate.
        constraints: Portfolio constraints.
    """
    ids, mu, cov = _build_matrices(returns, risk_free_rate)
    n = len(ids)
    w0 = np.array([current_weights.get(a, 0.0) for a in ids], dtype=np.float64)
    bounds = _bounds(n, constraints)
    cons = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]

    def objective(w):
        ret = w @ mu
        risk = 0.5 * w @ cov @ w
        cost = turnover_penalty * np.sum(np.abs(w - w0))
        return -(ret - risk - cost)

    x0 = w0.copy() if np.sum(np.abs(w0)) > EPSILON else np.ones(n) / n
    result = sp_opt.minimize(objective, x0, method="SLSQP", bounds=bounds, constraints=cons,
                             options={"maxiter": 500, "ftol": 1e-12})
    w = result.x
    r = _metrics(w, mu, cov, risk_free_rate, ids)
    r.method = "turnover_penalized"
    r.info["turnover"] = round(float(np.sum(np.abs(w - w0))), 6)
    r.info["turnover_penalty"] = turnover_penalty
    return r


# ── Grinold-Kahn Alpha-Risk-Cost Framework ───────────────────────────

def alpha_risk_cost_optimize(
    alphas: Dict[str, float],
    cov: np.ndarray,
    *,
    current_weights: Optional[Dict[str, float]] = None,
    risk_aversion: float = 1.0,
    cost_model: Optional[Any] = None,
    constraints: Optional[Constraints] = None,
    asset_ids: Optional[List[str]] = None,
) -> ARCResult:
    """The complete alpha-risk-cost optimisation.

    Maximise: ``alpha'w - (lambda/2) w'Sigma w - c(w, w_0)``

    This is THE optimisation every institutional quant desk runs.

    Args:
        alphas: ``{asset_id: expected_alpha}`` (annualised).
        cov: ``(n, n)`` covariance matrix (annualised).
        current_weights: Current weights (for cost computation).
        risk_aversion: Lambda — higher = more risk averse.
        cost_model: Object with ``.estimate(notional, adv, vol)`` method,
                    or None for zero cost.
        constraints: Portfolio constraints.
        asset_ids: Asset ordering matching *cov*.
    """
    if asset_ids is None:
        asset_ids = list(alphas.keys())
    n = len(asset_ids)
    Sigma = np.asarray(cov, dtype=np.float64)[:n, :n]

    alpha_vec = np.array([alphas.get(a, 0.0) for a in asset_ids], dtype=np.float64)
    w0 = np.array([
        (current_weights or {}).get(a, 0.0) for a in asset_ids
    ], dtype=np.float64)

    c = constraints or Constraints()
    lo = 0.0 if c.long_only else c.min_weight
    bounds = [(lo, c.max_weight)] * n
    cons_list = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]

    def objective(w):
        alpha_term = alpha_vec @ w
        risk_term = 0.5 * risk_aversion * w @ Sigma @ w
        cost_term = 0.0
        if cost_model is not None and hasattr(cost_model, "estimate"):
            for j in range(n):
                cost_term += cost_model.estimate(abs(w[j] - w0[j]), 0, 0)
        else:
            cost_term = 0.001 * np.sum(np.abs(w - w0))
        return -(alpha_term - risk_term - cost_term)

    x0 = w0.copy() if np.sum(np.abs(w0)) > EPSILON else np.ones(n) / n
    result = sp_opt.minimize(objective, x0, method="SLSQP", bounds=bounds, constraints=cons_list,
                             options={"maxiter": 500, "ftol": 1e-12})
    w = result.x

    expected_alpha = float(alpha_vec @ w)
    expected_risk = float(np.sqrt(w @ Sigma @ w))
    expected_cost = float(np.sum(np.abs(w - w0)) * 0.001)
    ir = expected_alpha / max(expected_risk, EPSILON)

    unconstrained_w = np.linalg.solve(risk_aversion * Sigma + np.eye(n) * EPSILON, alpha_vec)
    unconstrained_w /= max(np.sum(np.abs(unconstrained_w)), EPSILON)
    tc_num = np.corrcoef(alpha_vec, w * alpha_vec)[0, 1] if n > 1 else 1.0
    tc_den = np.corrcoef(alpha_vec, unconstrained_w * alpha_vec)[0, 1] if n > 1 else 1.0
    tc = tc_num / max(abs(tc_den), EPSILON)

    return ARCResult(
        optimal_weights={asset_ids[j]: round(float(w[j]), 6) for j in range(n)},
        expected_alpha=round(expected_alpha * 100, 4),
        expected_risk=round(expected_risk * 100, 4),
        expected_cost=round(expected_cost * 100, 4),
        information_ratio=round(ir, 4),
        transfer_coefficient=round(float(tc), 4),
    )
