File size: 2,816 Bytes
558db1e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import numpy as np
import pandas as pd
import cvxpy as cp
from config import logger, Color
from constraints import make_nearest_psd

def exact_risk_parity_allocation(cov_matrix: pd.DataFrame, silent: bool = False) -> pd.Series:
    """
    Computes the Exact True Risk Parity (Equal Risk Contribution - ERC) portfolio.
    
    Instead of using heuristic or graph-theoretic approaches (like HRP), this uses
    the strictly convex Spinu logarithmic barrier formulation to guarantee mathematically 
    exact equal marginal risk contributions.
    
    The optimization problem is:
    min 0.5 * x^T * Sigma * x - c * sum(ln(x))
    subject to x >= 0
    
    The optimal portfolio weights are then w = x / sum(x).
    
    Args:
        cov_matrix: The asset covariance matrix (N x N)
        silent: If True, suppresses console logs
        
    Returns:
        pd.Series containing the normalized optimal Risk Parity weights.
    """
    if not silent:
        print(f" {Color.DIM}ℹ Solving Exact True Risk Parity (ERC) formulation via CVXPY...{Color.RESET}", end="", flush=True)
        
    tickers = list(cov_matrix.columns)
    n = len(tickers)
    
    # Ensure strict positive semi-definiteness for CVXPY convex solver
    sigma_vals = make_nearest_psd(cov_matrix.values)
    
    # Define variables
    x = cp.Variable(n, nonneg=True)
    
    # Spinu Objective: 0.5 * x^T * Sigma * x - sum(ln(x))
    # We use cp.sum(cp.log(x)) as the barrier. Note that cp.log enforces strict positivity internally.
    risk = 0.5 * cp.quad_form(x, cp.psd_wrap(sigma_vals))
    barrier = cp.sum(cp.log(x))
    
    objective = cp.Minimize(risk - barrier)
    
    # We do not constrain sum(x) == 1 here. We solve unconstrained (besides positivity)
    # and then normalize the raw allocations x into portfolio weights w.
    prob = cp.Problem(objective)
    
    try:
        # Solve using ECOS or SCS since it involves logs
        prob.solve(solver=cp.SCS, max_iters=5000, eps=1e-5)
    except Exception as e:
        logger.error(f"SCS Failed for Exact Risk Parity: {e}. Retrying with ECOS...")
        try:
            prob.solve(solver=cp.ECOS)
        except Exception as e2:
            raise RuntimeError(f"Exact Risk Parity optimization failed: {e2}")
            
    if prob.status not in [cp.OPTIMAL, cp.OPTIMAL_INACCURATE] or x.value is None:
        raise RuntimeError(f"Exact Risk Parity did not converge. Status: {prob.status}")
        
    # Normalize to get valid portfolio weights summing to 1
    raw_x = x.value
    weights = raw_x / np.sum(raw_x)
    
    # Clean up near-zero rounding errors
    weights[weights < 1e-6] = 0.0
    weights = weights / np.sum(weights)
    
    if not silent:
        print(f" {Color.GREEN}done.{Color.RESET}")
        
    return pd.Series(weights, index=tickers)