Skip to content

Risk‑Parity Portfolios for Nepali Investors: From Data to Deployment

You manage capital exposed to the Nepal Stock Exchange (NEPSE) alongside global crypto assets like Bitcoin and Ethereum, but static asset allocation leaves your portfolio dangerously vulnerable to hidden volatility concentrations. If you want to move beyond equal-weight guessing and build a system where every asset contributes equally to total portfolio risk, this post shows you how to engineer a production-grade risk-parity allocation pipeline using Python, SciPy, and real market data.

Key Takeaways

  • Risk parity optimizes weights so that each asset contributes an identical proportion to total portfolio variance, neutralizing dominance by high-volatility instruments.
  • Using the yfinance library documentation makes fetching multi-asset historical prices straightforward, but handling missing date alignment requires explicit vectorization.
  • Constrained non-linear optimization via scipy.optimize.minimize reliably computes exact risk-budget weights under long-only and fully-invested constraints.

The Problem

Most portfolio management tools available to retail and institutional investors in Nepal rely on simple capital weighting or manual spreadsheet calculators. These methods fail because they ignore the underlying covariance structure of assets. An asset with a massive standard deviation can dominate portfolio risk even if its nominal capital allocation is small. To solve this, you need an automated script that queries price history, estimates the annualized covariance matrix, and runs an optimizer to equalize risk contributions across NEPSE and global assets.

Data and Sources

This implementation ingests historical daily adjusted close prices using Yahoo Finance tickers. For domestic equities, we track the NEPSE index (^NEPSE), and for global diversification, we pull Bitcoin (BTC-USD) and Ethereum (ETH-USD) historical series. You can consult the Risk-parity theory on Wikipedia for mathematical background on volatility budgeting. Data accessed on 2026-10-15.

Step 1 — Data Ingestion & Caching

The first sub-problem is fetching historical price data reliably without hammering remote endpoints during local script iteration. We solve this by wrapping the download call in a retry decorator and caching the raw pricing frame locally to a CSV.

import pandas as pd
import yfinance as yf
from tenacity import retry, stop_after_attempt, wait_fixed

@retry(stop=stop_after_attempt(3), wait=wait_fixed(2))
def fetch_market_data(tickers, period='5y'):
    df = yf.download(tickers, period=period, progress=False)['Adj Close']
    return df

Step 2 — Preprocessing & Alignment

Raw multi-asset price feeds often contain mismatched trading calendars due to global holidays and weekend crypto trading versus local exchange closures. We compute log returns and drop rows containing missing values to guarantee a synchronized covariance matrix.

import numpy as np

def preprocess_prices(df):
    df_clean = df.dropna()
    returns = np.log(df_clean / df_clean.shift(1)).dropna()
    return returns

Step 3 — Covariance & Expected Return Estimation

To optimize portfolio risk, we must transform daily log returns into annualized statistical parameters. We multiply the sample covariance matrix by 252 trading days to scale variance correctly for annual risk-budgeting calculations.

def estimate_statistics(returns):
    cov_matrix = returns.cov() * 252
    mean_returns = returns.mean() * 252
    return cov_matrix, mean_returns

Step 4 — Risk‑Parity Objective & Solver

The core optimization challenge is minimizing the sum of squared differences between each asset's risk contribution and the target equal risk share. We define a helper function to calculate individual risk contributions and pass it into SciPy's Sequential Least Squares Programming (SLSQP) optimizer.

from scipy.optimize import minimize

def get_risk_contributions(weights, cov_matrix):
    portfolio_var = weights.T @ cov_matrix @ weights
    portfolio_vol = np.sqrt(portfolio_var)
    marginal_contrib = cov_matrix @ weights
    risk_contrib = weights * marginal_contrib / portfolio_vol
    return risk_contrib

def risk_parity_objective(weights, cov_matrix):
    risk_contrib = get_risk_contributions(weights, cov_matrix)
    target_risk = risk_contrib.sum() / len(weights)
    return np.sum((risk_contrib - target_risk)**2)

def optimize_portfolio(cov_matrix):
    num_assets = len(cov_matrix)
    init_weights = np.array([1.0 / num_assets] * num_assets)
    bounds = tuple((0.0, 1.0) for _ in range(num_assets))
    constraints = ({'type': 'eq', 'fun': lambda w: np.sum(w) - 1.0})
    
    result = minimize(
        risk_parity_objective,
        init_weights,
        args=(cov_matrix,),
        method='SLSQP',
        bounds=bounds,
        constraints=constraints
    )
    return result.x

Step 5 — Validation & Benchmarking

Once the solver converges, you must verify that the resulting weights actually distribute risk equally. We compute the final portfolio volatility and print individual percentage risk contributions to ensure the optimizer honored the constraints.

def validate_allocation(weights, cov_matrix):
    rc = get_risk_contributions(weights, cov_matrix)
    pct_rc = rc / rc.sum() * 100
    portfolio_vol = np.sqrt(weights.T @ cov_matrix @ weights)
    return portfolio_vol, pct_rc

Step 6 — Result Export & Visualization

Auditability requires persisting optimization outputs. We save the final asset weights and their corresponding percentage risk contributions to disk while generating a summary visualization for reporting.

import matplotlib.pyplot as plt

def export_and_plot(weights, pct_rc, tickers):
    df_out = pd.DataFrame({'Weight': weights, 'Risk Contribution (%)': pct_rc}, index=tickers)
    df_out.to_csv('risk_parity_weights.csv')
    
    fig, ax = plt.subplots(figsize=(8, 5))
    df_out[['Weight', 'Risk Contribution (%)']].plot(kind='bar', ax=ax)
    ax.set_title('Risk-Parity Portfolio Allocation & Risk Budgets')
    plt.tight_layout()
    plt.savefig('output.png')
    plt.close()

Complete Script

The full runnable script combining all steps:

#!/usr/bin/env python3
import sys
import numpy as np
import pandas as pd
import yfinance as yf
from scipy.optimize import minimize
import matplotlib.pyplot as plt
from tenacity import retry, stop_after_attempt, wait_fixed

@retry(stop=stop_after_attempt(3), wait=wait_fixed(2))
def fetch_market_data(tickers, period='5y'):
    df = yf.download(tickers, period=period, progress=False)['Adj Close']
    if df.empty:
        raise ValueError("Fetched DataFrame is empty.")
    return df

def preprocess_prices(df):
    df_clean = df.dropna()
    if df_clean.empty:
        raise ValueError("DataFrame is empty after dropping missing values.")
    returns = np.log(df_clean / df_clean.shift(1)).dropna()
    return returns

def estimate_statistics(returns):
    cov_matrix = returns.cov() * 252
    mean_returns = returns.mean() * 252
    return cov_matrix, mean_returns

def get_risk_contributions(weights, cov_matrix):
    portfolio_var = weights.T @ cov_matrix @ weights
    if portfolio_var == 0:
        return np.zeros_like(weights)
    portfolio_vol = np.sqrt(portfolio_var)
    marginal_contrib = cov_matrix @ weights
    risk_contrib = weights * marginal_contrib / portfolio_vol
    return risk_contrib

def risk_parity_objective(weights, cov_matrix):
    risk_contrib = get_risk_contributions(weights, cov_matrix)
    target_risk = risk_contrib.sum() / len(weights)
    return np.sum((risk_contrib - target_risk)**2)

def optimize_portfolio(cov_matrix):
    num_assets = len(cov_matrix)
    init_weights = np.array([1.0 / num_assets] * num_assets)
    bounds = tuple((0.0, 1.0) for _ in range(num_assets))
    constraints = ({'type': 'eq', 'fun': lambda w: np.sum(w) - 1.0})
    
    result = minimize(
        risk_parity_objective,
        init_weights,
        args=(cov_matrix,),
        method='SLSQP',
        bounds=bounds,
        constraints=constraints
    )
    if not result.success:
        raise RuntimeError(f"Optimization failed: {result.message}")
    return result.x

def validate_allocation(weights, cov_matrix):
    rc = get_risk_contributions(weights, cov_matrix)
    pct_rc = rc / rc.sum() * 100
    portfolio_vol = np.sqrt(weights.T @ cov_matrix @ weights)
    return portfolio_vol, pct_rc

def export_and_plot(weights, pct_rc, tickers):
    df_out = pd.DataFrame({'Weight': weights, 'Risk Contribution (%)': pct_rc}, index=tickers)
    df_out.to_csv('risk_parity_weights.csv')
    
    fig, ax = plt.subplots(figsize=(8, 5))
    df_out.plot(kind='bar', ax=ax)
    ax.set_title('Risk-Parity Portfolio Allocation & Risk Budgets')
    ax.set_ylabel('Ratio / Percentage')
    plt.xticks(rotation=0)
    plt.tight_layout()
    plt.savefig('output.png')
    plt.close()

def main():
    tickers = ['^NEPSE', 'BTC-USD', 'ETH-USD']
    print(f"Fetching data for tickers: {tickers}")
    try:
        raw_df = fetch_market_data(tickers)
        returns = preprocess_prices(raw_df)
        cov_matrix, mean_returns = estimate_statistics(returns)
        
        print("Running risk-parity optimization...")
        weights = optimize_portfolio(cov_matrix)
        
        vol, pct_rc = validate_allocation(weights, cov_matrix)
        print(f"Portfolio Annual Volatility: {vol:.4f}")
        for t, w, r in zip(tickers, weights, pct_rc):
            print(f"Asset: {t:<10} Weight: {w:.4f} Risk Contribution: {r:.2f}%")
            
        export_and_plot(weights, pct_rc, tickers)
        print("Successfully exported results and generated output.png")
        
    except Exception as e:
        print(f"Execution failed: {e}", file=sys.stderr)
        sys.exit(1)

if __name__ == '__main__':
    main()

Expected Output

When you run the script successfully, you will see the console log printing out optimized asset weights alongside their exact percentage risk contributions, confirming that each instrument contributes approximately 33.33% to the total portfolio volatility. It will also generate `risk_parity_weights.csv` and `output.png` in your working directory.

Limitations and Tradeoffs

Relying on a three-asset universe (NEPSE, BTC, ETH) severely restricts global diversification options. Scaling this model to a larger basket of stocks or macroeconomic indicators increases covariance matrix estimation noise. Furthermore, historical covariance assumes stationarity, which breaks down during severe liquidity crunches or regulatory regime shifts. Finally, this pipeline ignores transaction costs and bid-ask spreads, meaning frequent monthly rebalancing could erode nominal alpha.

Frequently Asked Questions

Why use risk parity instead of mean-variance optimization?

Mean-variance optimization is notoriously sensitive to small changes in expected returns, often resulting in concentrated, unstable portfolios. Risk parity ignores expected return estimates entirely and focuses exclusively on balancing volatility contributions, making it far more robust in practice.

How do I handle currency conversion between NPR and USD assets?

If you execute trades across foreign and domestic brokers, you must normalize all price series into a single base currency—typically USD or NPR—using historical USD/NPR exchange rate series prior to calculating log returns.

Can I add transaction cost penalties directly into the SciPy optimizer?

Yes. You can modify the objective function to penalize absolute differences between current portfolio weights and target weights multiplied by an estimated transaction cost coefficient.

What I'd Change

If I were deploying this into a live production environment for a Nepalese asset management firm, I would completely discard historical sample covariance in favor of an Exponentially Weighted Moving Average (EWMA) or Ledoit-Wolf shrinkage estimator to better capture regime shifts. Relying on raw 5-year sample covariance leaves your allocation blind to sudden structural breaks in domestic monetary policy.

Post a Comment

Hi! How can we help you? Send us a message and we'll get back to you.