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

52 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 3: Advanced CTA strategy with price filtering and oscillators. 

21 

22This module implements a more sophisticated trend-following strategy that 

23incorporates price filtering to handle outliers and oscillators with proper 

24scaling for more consistent signal generation across different assets. 

25""" 

26 

27import marimo 

28 

29__generated_with = "0.23.1" 

30app = marimo.App() 

31 

32with app.setup: 

33 import sys 

34 from pathlib import Path 

35 

36 import marimo as mo 

37 import polars as pl 

38 from jquantstats import Portfolio 

39 from tinycta.osc import osc 

40 from tinycta.util import vol_adj 

41 

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

43 

44 from preamble import date_col, load_prices 

45 

46 prices = load_prices(__file__) 

47 prices_only = prices.drop(date_col) 

48 

49 

50@app.cell(hide_code=True) 

51def _(): 

52 mo.md(r"""# CTA 3.0""") 

53 return 

54 

55 

56@app.cell(hide_code=True) 

57def _(): 

58 mo.md( 

59 r""" 

60 We use the system: 

61 $$\mathrm{CashPosition}=\frac{f(\mathrm{Price})}{\mathrm{Volatility(Returns)}}$$ 

62 

63 This is very problematic: 

64 * Prices may live on very different scales, hence trying to find a 

65 more universal function $f$ is almost impossible. The sign-function was 

66 a good choice as the results don't depend on the scale of the argument. 

67 * Price may come with all sorts of spikes/outliers/problems. 

68 """ 

69 ) 

70 return 

71 

72 

73@app.cell(hide_code=True) 

74def _(): 

75 mo.md( 

76 r""" 

77 We need a simple price filter process 

78 * We compute volatility-adjusted returns, filter them and compute prices from those returns. 

79 * Don't call it Winsorizing in Switzerland. We apply Huber functions. 

80 """ 

81 ) 

82 return 

83 

84 

85@app.cell(hide_code=True) 

86def _(): 

87 mo.md( 

88 r""" 

89 ### Oscillators 

90 * All prices are now following a standard arithmetic Brownian 

91 motion with std $1$. 

92 * What we want is the difference of two moving means (exponentially weighted) 

93 to have a constant std regardless of the two lengths. 

94 * An oscillator is the **scaled difference of two moving averages**. 

95 """ 

96 ) 

97 return 

98 

99 

100@app.function 

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

102 """Return the tanh oscillator of vol-adjusted cumulative price, divided by volatility.""" 

103 price_adj = vol_adj(price, vola=vola, clip=clip, min_samples=300).cum_sum() 

104 mu = osc(price_adj, fast=fast, slow=slow).tanh() 

105 vol = price.pct_change().ewm_std(com=slow, min_samples=300) 

106 return mu / vol 

107 

108 

109@app.cell 

110def _(): 

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

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

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

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

115 

116 mo.vstack([fast, slow, vola, winsor]) 

117 

118 return fast, slow, vola, winsor 

119 

120 

121@app.cell 

122def _(fast, slow, vola, winsor): 

123 signals = prices_only.select( 

124 (f(pl.all(), fast=fast.value, slow=slow.value, vola=vola.value, clip=winsor.value) * 1e5) 

125 .fill_nan(0.0) 

126 .fill_null(0.0) 

127 ) 

128 portfolio = Portfolio.from_cash_position(prices=prices, cash_position=signals, aum=1e8) 

129 return (portfolio,) 

130 

131 

132@app.cell 

133def _(portfolio): 

134 print(portfolio.stats.sharpe()) 

135 

136 

137@app.cell 

138def _(portfolio): 

139 fig = portfolio.plots.snapshot() 

140 fig 

141 return 

142 

143 

144if __name__ == "__main__": 

145 app.run()