Coverage for book/marimo/notebooks/Experiment5.py: 100%

100 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-09 08:57 +0000

1# /// script 

2# requires-python = ">=3.12" 

3# dependencies = [ 

4# "marimo==0.24.0", 

5# "numpy==2.4.6", 

6# "plotly==6.9.0", 

7# "polars==1.44.1", 

8# "jquantstats==0.11.0", 

9# "tinycta==0.14.0" 

10# ] 

11# 

12# [tool.ty.environment] 

13# # ``from preamble import ...`` resolves at runtime via the sys.path.insert in the 

14# # setup cell below. ty analyses a PEP 723 script in isolation from the project, so 

15# # pyproject.toml's [tool.ty.environment] never reaches this file and the path has 

16# # to be declared here. Preserve this table if marimo rewrites the header. 

17# extra-paths = ["."] 

18# /// 

19 

20"""Experiment 5: Advanced CTA strategy with correlation-based optimization. 

21 

22This module implements a sophisticated trend-following strategy that 

23incorporates dynamic conditional correlation (DCC) and matrix optimization 

24techniques to improve portfolio construction and risk management. 

25""" 

26 

27import marimo 

28 

29__generated_with = "0.23.9" 

30app = marimo.App() 

31 

32with app.setup: 

33 import sys 

34 from pathlib import Path 

35 

36 import marimo as mo 

37 import numpy as np 

38 import polars as pl 

39 from jquantstats import Portfolio 

40 from tinycta.linalg import inv_a_norm, solve 

41 from tinycta.osc import osc 

42 from tinycta.signal import shrink2id 

43 from tinycta.util import vol_adj 

44 

45 sys.path.insert(0, str(Path(__file__).parent)) 

46 

47 from preamble import date_col, load_prices 

48 

49 prices = load_prices(__file__) 

50 prices_only = prices.drop(date_col) 

51 assets = prices_only.columns 

52 

53 

54@app.cell(hide_code=True) 

55def _(): 

56 mo.md(r""" 

57 # CTA 5.0 - Optimization 2.0 

58 """) 

59 return 

60 

61 

62@app.function 

63def f(price: "pl.Expr", fast: int = 32, slow: int = 96, vola: int = 32, clip: float = 4.2) -> "pl.Expr": 

64 """Return the tanh oscillator of vol-adjusted cumulative price.""" 

65 return osc(vol_adj(price, vola=vola, clip=clip, min_samples=300).cum_sum(), fast=fast, slow=slow).tanh() 

66 

67 

68@app.cell 

69def _(): 

70 fast = mo.ui.slider(4, 192, step=4, value=32, label="Fast Moving Average") 

71 slow = mo.ui.slider(4, 192, step=4, value=96, label="Slow Moving Average") 

72 vola = mo.ui.slider(4, 192, step=4, value=32, label="Volatility") 

73 winsor = mo.ui.slider(1.0, 6.0, step=0.1, value=4.2, label="Winsorizing") 

74 corr = mo.ui.slider(50, 500, step=10, value=200, label="Correlation") 

75 shrinkage = mo.ui.slider(0.0, 1.0, step=0.05, value=0.5, label="Shrinkage") 

76 

77 mo.vstack([fast, slow, vola, winsor, corr, shrinkage]) 

78 return corr, shrinkage, vola, winsor 

79 

80 

81@app.function 

82def ewm_covariance(returns_adj: "pl.DataFrame", *, corr: int) -> "np.ndarray": 

83 """EWM covariance tensor of the vol-adjusted returns (the Engle-DCC numerator). 

84 

85 ``cov_t(i, j) = ewm_t(r_i * r_j) - ewm_t(r_i) * ewm_t(r_j)``, evaluated for 

86 every day; the result has shape ``(n_rows, n_assets, n_assets)`` and is 

87 symmetric in ``(i, j)``. 

88 """ 

89 columns = returns_adj.columns 

90 n_assets = len(columns) 

91 n_rows = len(returns_adj) 

92 

93 ewm_means_np = returns_adj.select(pl.all().ewm_mean(com=corr, min_samples=int(corr))).to_numpy() 

94 

95 pair_indices = [(i, j) for i in range(n_assets) for j in range(i, n_assets)] 

96 ewm_prod_np = returns_adj.select( 

97 [ 

98 (pl.col(columns[i]) * pl.col(columns[j])) 

99 .fill_nan(None) 

100 .ewm_mean(com=corr, min_samples=int(corr)) 

101 .alias(f"p{i}_{j}") 

102 for i, j in pair_indices 

103 ] 

104 ).to_numpy() 

105 

106 cov_np = np.full((n_rows, n_assets, n_assets), np.nan) 

107 for _k, (_i, _j) in enumerate(pair_indices): 

108 _cov = ewm_prod_np[:, _k] - ewm_means_np[:, _i] * ewm_means_np[:, _j] 

109 cov_np[:, _i, _j] = _cov 

110 cov_np[:, _j, _i] = _cov 

111 return cov_np 

112 

113 

114@app.function 

115def correlation_from_covariance(cov_np: "np.ndarray") -> "np.ndarray": 

116 """Normalize a per-day covariance tensor to a correlation tensor. 

117 

118 Days with non-positive variance keep their NaN off-diagonals; the diagonal 

119 is forced to 1 wherever the variance is positive. 

120 """ 

121 n_assets = cov_np.shape[1] 

122 _var = cov_np[:, np.arange(n_assets), np.arange(n_assets)] 

123 with np.errstate(invalid="ignore", divide="ignore"): 

124 _denom = np.sqrt(_var[:, :, None] * _var[:, None, :]) 

125 cor_3d: np.ndarray = cov_np / _denom 

126 for _k in range(n_assets): 

127 cor_3d[_var[:, _k] > 0, _k, _k] = 1.0 

128 return cor_3d 

129 

130 

131@app.function 

132def dcc_correlation(prices_only: "pl.DataFrame", *, vola: int, clip: float, corr: int) -> "np.ndarray": 

133 """Engle-DCC per-day correlation tensor, shape ``(n_rows, n_assets, n_assets)``.""" 

134 returns_adj = prices_only.select(vol_adj(pl.all(), vola=vola, clip=clip, min_samples=300)) 

135 cov_np = ewm_covariance(returns_adj, corr=corr) 

136 return correlation_from_covariance(cov_np) 

137 

138 

139@app.function 

140def positions( 

141 cor_3d: "np.ndarray", mu: "np.ndarray", vo: "np.ndarray", prices_np: "np.ndarray", *, shrinkage: float 

142) -> "np.ndarray": 

143 """Per-day risk-parity positions from the shrunk DCC correlation tensor. 

144 

145 Each day, the correlation matrix is shrunk towards the identity, restricted 

146 to the assets with a finite price, and used to solve for the risk-scaled 

147 position. Days with no live assets or a singular/zero norm are left flat. 

148 """ 

149 n_rows, n_assets = prices_np.shape 

150 pos_matrix = np.zeros((n_rows, n_assets)) 

151 for _n in range(n_rows): 

152 _mask = np.isfinite(prices_np[_n]) 

153 if _mask.sum() == 0: 

154 continue 

155 _full_shrunk = shrink2id(cor_3d[_n], lamb=shrinkage) 

156 _matrix = _full_shrunk[_mask, :][:, _mask] 

157 _expected_mu = np.nan_to_num(mu[_n][_mask]) 

158 _expected_vo = np.nan_to_num(vo[_n][_mask]) 

159 _norm = inv_a_norm(_expected_mu, _matrix) 

160 if _norm == 0 or np.isnan(_norm): 

161 continue 

162 _risk_pos = solve(_matrix, _expected_mu) / _norm 

163 pos_matrix[_n, _mask] = np.nan_to_num(1e6 * _risk_pos / _expected_vo, nan=0.0) 

164 return pos_matrix 

165 

166 

167@app.cell 

168def _(corr, shrinkage, vola, winsor): 

169 # EWM correlation (DCC by Engle), then per-day risk-parity positions. 

170 cor_3d = dcc_correlation(prices_only, vola=vola.value, clip=winsor.value, corr=corr.value) 

171 

172 mu = prices_only.select(f(pl.all(), fast=32, slow=96, vola=vola.value, clip=winsor.value)).to_numpy() 

173 vo = prices_only.select( 

174 pl.all().fill_nan(None).pct_change().ewm_std(com=vola.value, min_samples=int(vola.value)) 

175 ).to_numpy() 

176 

177 pos_matrix = positions(cor_3d, mu, vo, prices_only.to_numpy(), shrinkage=shrinkage.value) 

178 

179 portfolio = Portfolio.from_cash_position( 

180 prices=prices, 

181 cash_position=pl.concat( 

182 [prices.select(date_col), pl.from_numpy(pos_matrix, schema=dict.fromkeys(assets, pl.Float64))], 

183 how="horizontal_extend", 

184 ), 

185 aum=1e8, 

186 ) 

187 return (portfolio,) 

188 

189 

190@app.cell 

191def _(portfolio): 

192 print(portfolio.stats.sharpe()) 

193 return 

194 

195 

196@app.cell(hide_code=True) 

197def _(): 

198 mo.md(r""" 

199 # Conclusions 

200 * Dramatic relative improvements observable despite using the same signals as in previous Experiment. 

201 * Main difference here is to take advantage of cross-correlations in the risk measurement. 

202 * Possible to add constraints on individual assets or groups of them. 

203 * Possible to reflect trading costs in objective with regularization terms (Ridge, Lars, Elastic Nets, ...) 

204 """) 

205 return 

206 

207 

208@app.cell 

209def _(portfolio): 

210 portfolio.plots.snapshot() 

211 return 

212 

213 

214@app.cell 

215def _(portfolio): 

216 portfolio.stats.summary() 

217 return 

218 

219 

220@app.cell 

221def _(): 

222 return 

223 

224 

225if __name__ == "__main__": 

226 app.run()