TL;DR – Cross-asset arbitrage exploits pricing relationships across different asset classes. This guide covers convertible bond arbitrage, capital structure arbitrage, basis trading, and multi-asset signal generation with production implementations.
Opportunities arise from:
Key insight: Different markets price the same risk differently.
Convertible bond: Bond + embedded call option on stock.
Arbitrage: Buy undervalued convertible, hedge equity risk.
P&L sources:
1import numpy as np
2from scipy.stats import norm
3import matplotlib.pyplot as plt
4
5class ConvertibleBond:
6 """Convertible bond pricing and Greeks."""
7
8 def __init__(self, face_value, coupon_rate, maturity, conversion_ratio,
9 call_price=None, put_price=None):
10 """
11 Args:
12 face_value: Bond face value
13 coupon_rate: Annual coupon rate
14 maturity: Years to maturity
15 conversion_ratio: Shares per bond
16 call_price: Issuer call price (optional)
17 put_price: Investor put price (optional)
18 """
19 self.face_value = face_value
20 self.coupon_rate = coupon_rate
21 self.maturity = maturity
22 self.conversion_ratio = conversion_ratio
23 self.call_price = call_price
24 self.put_price = put_price
25
26 def straight_bond_value(self, r, credit_spread):
27 """
28 Value as straight bond (no conversion option).
29
30 Args:
31 r: Risk-free rate
32 credit_spread: Credit spread over risk-free
33 """
34 ytm = r + credit_spread
35 coupon = self.face_value * self.coupon_rate
36
37 # PV of coupons
38 pv_coupons = coupon * (1 - (1 + ytm)**(-self.maturity)) / ytm
39
40 # PV of principal
41 pv_principal = self.face_value / (1 + ytm)**self.maturity
42
43 return pv_coupons + pv_principal
44
45 def conversion_value(self, stock_price):
46 """Value if converted to stock immediately."""
47 return self.conversion_ratio * stock_price
48
49 def option_value(self, stock_price, r, sigma, credit_spread):
50 """
51 Value of embedded call option using Black-Scholes.
52
53 Simplified: assumes European option at maturity.
54 """
55 # Conversion strike
56 K = self.face_value / self.conversion_ratio
57
58 # Adjust for dividends (simplified: reduce stock price)
59 dividend_yield = 0.02 # Assume 2% dividend yield
60 S_adj = stock_price * np.exp(-dividend_yield * self.maturity)
61
62 # Black-Scholes
63 d1 = (np.log(S_adj/K) + (r + 0.5*sigma**2)*self.maturity) / (sigma*np.sqrt(self.maturity))
64 d2 = d1 - sigma*np.sqrt(self.maturity)
65
66 call_value = (S_adj * norm.cdf(d1) -
67 K * np.exp(-r*self.maturity) * norm.cdf(d2))
68
69 return call_value * self.conversion_ratio
70
71 def price(self, stock_price, r, sigma, credit_spread):
72 """
73 Total convertible bond price.
74
75 Price = max(straight_bond_value, conversion_value) + option_time_value
76 """
77 bond_floor = self.straight_bond_value(r, credit_spread)
78 conversion_val = self.conversion_value(stock_price)
79 option_val = self.option_value(stock_price, r, sigma, credit_spread)
80
81 # Convertible value
82 cb_value = bond_floor + option_val
83
84 # Apply call/put constraints
85 if self.call_price and cb_value > self.call_price:
86 cb_value = self.call_price
87 if self.put_price and cb_value < self.put_price:
88 cb_value = self.put_price
89
90 return cb_value
91
92 def delta(self, stock_price, r, sigma, credit_spread, ds=0.01):
93 """Calculate delta (sensitivity to stock price)."""
94 price_up = self.price(stock_price + ds, r, sigma, credit_spread)
95 price_down = self.price(stock_price - ds, r, sigma, credit_spread)
96 return (price_up - price_down) / (2 * ds)
97
98 def gamma(self, stock_price, r, sigma, credit_spread, ds=0.01):
99 """Calculate gamma (convexity)."""
100 price_mid = self.price(stock_price, r, sigma, credit_spread)
101 price_up = self.price(stock_price + ds, r, sigma, credit_spread)
102 price_down = self.price(stock_price - ds, r, sigma, credit_spread)
103 return (price_up - 2*price_mid + price_down) / (ds**2)
104
105# Example
106cb = ConvertibleBond(
107 face_value=1000,
108 coupon_rate=0.03,
109 maturity=5,
110 conversion_ratio=20, # 20 shares per bond
111 call_price=1200,
112 put_price=950
113)
114
115# Parameters
116stock_prices = np.linspace(30, 80, 100)
117r = 0.05
118sigma = 0.30
119credit_spread = 0.02
120
121# Calculate prices and Greeks
122cb_prices = [cb.price(S, r, sigma, credit_spread) for S in stock_prices]
123bond_floors = [cb.straight_bond_value(r, credit_spread) for _ in stock_prices]
124conversion_values = [cb.conversion_value(S) for S in stock_prices]
125deltas = [cb.delta(S, r, sigma, credit_spread) for S in stock_prices]
126
127# Plot
128fig, axes = plt.subplots(2, 1, figsize=(14, 10))
129
130axes[0].plot(stock_prices, cb_prices, 'b-', linewidth=2, label='Convertible Bond')
131axes[0].plot(stock_prices, bond_floors, 'r--', label='Bond Floor')
132axes[0].plot(stock_prices, conversion_values, 'g--', label='Conversion Value')
133axes[0].set_xlabel('Stock Price')
134axes[0].set_ylabel('Value')
135axes[0].set_title('Convertible Bond Pricing')
136axes[0].legend()
137axes[0].grid(True, alpha=0.3)
138
139axes[1].plot(stock_prices, deltas, 'b-', linewidth=2)
140axes[1].set_xlabel('Stock Price')
141axes[1].set_ylabel('Delta')
142axes[1].set_title('Convertible Bond Delta (Hedge Ratio)')
143axes[1].grid(True, alpha=0.3)
144
145plt.tight_layout()
146plt.show()
1471class ConvertibleArbitrage:
2 """
3 Convertible bond arbitrage strategy.
4
5 Long convertible bond, short delta-hedged stock.
6 """
7
8 def __init__(self, cb, initial_stock_price, r, sigma, credit_spread):
9 self.cb = cb
10 self.stock_price = initial_stock_price
11 self.r = r
12 self.sigma = sigma
13 self.credit_spread = credit_spread
14
15 # Initial positions
16 self.cb_position = 1 # Long 1 bond
17 self.cb_price = cb.price(initial_stock_price, r, sigma, credit_spread)
18
19 # Delta hedge
20 self.delta = cb.delta(initial_stock_price, r, sigma, credit_spread)
21 self.stock_position = -self.delta * cb.conversion_ratio
22
23 self.total_pnl = 0
24 self.pnl_history = []
25
26 def update(self, new_stock_price, rehedge=True):
27 """
28 Update positions with new stock price.
29
30 Args:
31 new_stock_price: New stock price
32 rehedge: Whether to rehedge delta
33 """
34 # Calculate new CB price
35 new_cb_price = self.cb.price(new_stock_price, self.r, self.sigma, self.credit_spread)
36
37 # P&L from CB
38 cb_pnl = (new_cb_price - self.cb_price) * self.cb_position
39
40 # P&L from stock hedge
41 stock_pnl = (new_stock_price - self.stock_price) * self.stock_position
42
43 # Total P&L
44 daily_pnl = cb_pnl + stock_pnl
45 self.total_pnl += daily_pnl
46
47 # Rehedge if needed
48 rehedge_cost = 0
49 if rehedge:
50 new_delta = self.cb.delta(new_stock_price, self.r, self.sigma, self.credit_spread)
51 target_stock_position = -new_delta * self.cb.conversion_ratio
52 rehedge_quantity = target_stock_position - self.stock_position
53
54 # Transaction cost
55 rehedge_cost = abs(rehedge_quantity) * 0.01
56 self.total_pnl -= rehedge_cost
57
58 self.stock_position = target_stock_position
59 self.delta = new_delta
60
61 # Update state
62 self.stock_price = new_stock_price
63 self.cb_price = new_cb_price
64 self.pnl_history.append(self.total_pnl)
65
66 return {
67 'daily_pnl': daily_pnl,
68 'total_pnl': self.total_pnl,
69 'rehedge_cost': rehedge_cost
70 }
71
72# Simulate
73np.random.seed(42)
74n_days = 252
75S0 = 50
76
77# Generate stock price path
78returns = np.random.normal(0, 0.02, n_days)
79stock_prices = S0 * np.exp(np.cumsum(returns))
80
81# Run strategy
82strategy = ConvertibleArbitrage(cb, S0, r=0.05, sigma=0.30, credit_spread=0.02)
83
84for S in stock_prices:
85 strategy.update(S, rehedge=True)
86
87# Plot
88plt.figure(figsize=(14, 8))
89
90plt.subplot(2, 1, 1)
91plt.plot(stock_prices)
92plt.ylabel('Stock Price')
93plt.title('Stock Price Path')
94plt.grid(True, alpha=0.3)
95
96plt.subplot(2, 1, 2)
97plt.plot(strategy.pnl_history)
98plt.xlabel('Days')
99plt.ylabel('Cumulative P&L')
100plt.title('Convertible Arbitrage P&L')
101plt.grid(True, alpha=0.3)
102
103plt.tight_layout()
104plt.show()
105
106print("Final P&L: ${:,.2f}".format(strategy.total_pnl))
107Merton model: Equity is a call option on firm assets.
Relationship: CDS spreads and equity volatility are linked.
Arbitrage: When CDS is cheap relative to equity put options.
1class CapitalStructureArbitrage:
2 """
3 Capital structure arbitrage using Merton model.
4
5 Trade CDS vs equity options based on implied default probability.
6 """
7
8 @staticmethod
9 def merton_default_probability(S, D, r, sigma, T):
10 """
11 Calculate default probability using Merton model.
12
13 Args:
14 S: Equity value
15 D: Debt face value
16 r: Risk-free rate
17 sigma: Equity volatility
18 T: Time horizon
19
20 Returns:
21 Default probability
22 """
23 # Distance to default
24 d2 = (np.log(S/D) + (r - 0.5*sigma**2)*T) / (sigma*np.sqrt(T))
25
26 # Default probability = N(-d2)
27 default_prob = norm.cdf(-d2)
28
29 return default_prob
30
31 @staticmethod
32 def cds_spread_from_default_prob(default_prob, recovery_rate=0.40):
33 """
34 Implied CDS spread from default probability.
35
36 Simplified: spread ≈ (1 - recovery) * default_prob / (1 - default_prob)
37 """
38 return (1 - recovery_rate) * default_prob / (1 - default_prob)
39
40 @staticmethod
41 def find_arbitrage(S, D, r, equity_vol, cds_spread, T=1, recovery=0.40):
42 """
43 Find arbitrage opportunity between CDS and equity.
44
45 Returns:
46 Signal: 'long_cds_short_equity' or 'short_cds_long_equity' or 'none'
47 """
48 # Implied default probability from equity
49 equity_implied_default = CapitalStructureArbitrage.merton_default_probability(
50 S, D, r, equity_vol, T
51 )
52
53 # Implied CDS spread from equity
54 equity_implied_cds = CapitalStructureArbitrage.cds_spread_from_default_prob(
55 equity_implied_default, recovery
56 )
57
58 # Compare with market CDS spread
59 spread_diff = cds_spread - equity_implied_cds
60
61 # Arbitrage signal
62 threshold = 0.005 # 50 bps
63
64 if spread_diff > threshold:
65 # CDS expensive relative to equity
66 return {
67 'signal': 'short_cds_long_put',
68 'spread_diff': spread_diff,
69 'equity_implied_default': equity_implied_default,
70 'cds_implied_default': cds_spread / (1 - recovery)
71 }
72 elif spread_diff < -threshold:
73 # CDS cheap relative to equity
74 return {
75 'signal': 'long_cds_short_put',
76 'spread_diff': spread_diff,
77 'equity_implied_default': equity_implied_default,
78 'cds_implied_default': cds_spread / (1 - recovery)
79 }
80 else:
81 return {'signal': 'none', 'spread_diff': spread_diff}
82
83# Example
84S = 100 # Equity value
85D = 80 # Debt face value
86r = 0.05
87equity_vol = 0.30
88cds_spread = 0.03 # 300 bps
89
90arb = CapitalStructureArbitrage.find_arbitrage(S, D, r, equity_vol, cds_spread)
91
92print("Arbitrage signal:", arb['signal'])
93print("Spread difference:", arb['spread_diff'])
94if 'equity_implied_default' in arb:
95 print("Equity-implied default prob:", arb['equity_implied_default'])
96 print("CDS-implied default prob:", arb['cds_implied_default'])
97Basis = Futures Price - Spot Price
Fair basis = Spot × (r - dividend_yield) × T
Arbitrage: When actual basis ≠ fair basis.
1class BasisTrade:
2 """
3 Futures-spot basis trading.
4
5 Exploit deviations from cost-of-carry relationship.
6 """
7
8 def __init__(self, spot_price, futures_price, r, dividend_yield, T):
9 """
10 Args:
11 spot_price: Current spot price
12 futures_price: Current futures price
13 r: Risk-free rate
14 dividend_yield: Dividend yield
15 T: Time to futures expiry (years)
16 """
17 self.spot_price = spot_price
18 self.futures_price = futures_price
19 self.r = r
20 self.dividend_yield = dividend_yield
21 self.T = T
22
23 def fair_futures_price(self):
24 """Calculate fair futures price using cost-of-carry."""
25 return self.spot_price * np.exp((self.r - self.dividend_yield) * self.T)
26
27 def basis(self):
28 """Calculate actual basis."""
29 return self.futures_price - self.spot_price
30
31 def fair_basis(self):
32 """Calculate fair basis."""
33 return self.fair_futures_price() - self.spot_price
34
35 def mispricing(self):
36 """Calculate mispricing (actual - fair basis)."""
37 return self.basis() - self.fair_basis()
38
39 def generate_signal(self, threshold=0.01):
40 """
41 Generate trading signal.
42
43 Args:
44 threshold: Minimum mispricing to trade (as fraction of spot)
45 """
46 mispricing = self.mispricing()
47 mispricing_pct = mispricing / self.spot_price
48
49 if mispricing_pct > threshold:
50 # Futures overpriced: sell futures, buy spot
51 return {
52 'signal': 'sell_futures_buy_spot',
53 'mispricing': mispricing,
54 'mispricing_pct': mispricing_pct
55 }
56 elif mispricing_pct < -threshold:
57 # Futures underpriced: buy futures, sell spot
58 return {
59 'signal': 'buy_futures_sell_spot',
60 'mispricing': mispricing,
61 'mispricing_pct': mispricing_pct
62 }
63 else:
64 return {'signal': 'none', 'mispricing': mispricing}
65
66# Example
67basis_trade = BasisTrade(
68 spot_price=100,
69 futures_price=102,
70 r=0.05,
71 dividend_yield=0.02,
72 T=0.25 # 3 months
73)
74
75signal = basis_trade.generate_signal(threshold=0.005)
76
77print("Fair futures price:", basis_trade.fair_futures_price())
78print("Actual futures price:", basis_trade.futures_price)
79print("Mispricing:", signal['mispricing'])
80print("Signal:", signal['signal'])
811class CrossAssetMomentum:
2 """
3 Momentum strategy across multiple asset classes.
4
5 Ranks assets by momentum, allocates to top performers.
6 """
7
8 def __init__(self, lookback=60, n_top=5):
9 self.lookback = lookback
10 self.n_top = n_top
11
12 def calculate_momentum(self, prices):
13 """
14 Calculate momentum scores for all assets.
15
16 Args:
17 prices: DataFrame with asset prices
18
19 Returns:
20 DataFrame with momentum scores
21 """
22 # Calculate returns over lookback period
23 returns = prices.pct_change(self.lookback)
24
25 # Rank by returns
26 ranks = returns.rank(axis=1, ascending=False)
27
28 return ranks
29
30 def generate_weights(self, prices):
31 """
32 Generate portfolio weights.
33
34 Long top N assets, equal-weighted.
35 """
36 ranks = self.calculate_momentum(prices)
37
38 weights = pd.DataFrame(0.0, index=prices.index, columns=prices.columns)
39
40 for idx in ranks.index:
41 top_assets = ranks.loc[idx].nsmallest(self.n_top).index
42 weights.loc[idx, top_assets] = 1.0 / self.n_top
43
44 return weights
45
46# Example: Multi-asset universe
47np.random.seed(42)
48n_days = 500
49n_assets = 10
50
51# Generate prices for different asset classes
52asset_names = ['Equity1', 'Equity2', 'Bond1', 'Bond2', 'FX1', 'FX2',
53 'Commodity1', 'Commodity2', 'Crypto1', 'Crypto2']
54
55# Different volatilities for different asset classes
56vols = [0.20, 0.20, 0.05, 0.05, 0.10, 0.10, 0.25, 0.25, 0.50, 0.50]
57
58prices_dict = {}
59for i, asset in enumerate(asset_names):
60 returns = np.random.normal(0.0005, vols[i]/np.sqrt(252), n_days)
61 prices_dict[asset] = 100 * np.exp(np.cumsum(returns))
62
63prices_multi = pd.DataFrame(prices_dict)
64
65# Run strategy
66cross_asset_mom = CrossAssetMomentum(lookback=60, n_top=3)
67weights = cross_asset_mom.generate_weights(prices_multi)
68
69# Calculate portfolio returns
70returns_multi = prices_multi.pct_change()
71portfolio_returns = (weights.shift(1) * returns_multi).sum(axis=1)
72cum_returns = (1 + portfolio_returns).cumprod()
73
74plt.figure(figsize=(14, 6))
75plt.plot(cum_returns)
76plt.xlabel('Days')
77plt.ylabel('Cumulative Return')
78plt.title('Cross-Asset Momentum Strategy')
79plt.grid(True, alpha=0.3)
80plt.show()
81
82print("Final return:", (cum_returns.iloc[-1] - 1) * 100, "%")
83print("Sharpe ratio:", portfolio_returns.mean() / portfolio_returns.std() * np.sqrt(252))
84Cross-asset arbitrage requires deep understanding of pricing relationships across markets. Focus on robust models, monitor funding costs carefully, and implement proper risk limits. Always validate theoretical relationships with empirical data.
Technical Writer
NordVarg Team is a software engineer at NordVarg specializing in high-performance financial systems and type-safe programming.
Get weekly insights on building high-performance financial systems, latest industry trends, and expert tips delivered straight to your inbox.