@zigrivers/scaffold 3.13.0 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -10
- package/content/knowledge/research/research-architecture.md +385 -0
- package/content/knowledge/research/research-conventions.md +248 -0
- package/content/knowledge/research/research-dev-environment.md +303 -0
- package/content/knowledge/research/research-experiment-loop.md +429 -0
- package/content/knowledge/research/research-experiment-tracking.md +336 -0
- package/content/knowledge/research/research-ml-architecture-search.md +383 -0
- package/content/knowledge/research/research-ml-evaluation.md +407 -0
- package/content/knowledge/research/research-ml-experiment-tracking.md +466 -0
- package/content/knowledge/research/research-ml-training-patterns.md +413 -0
- package/content/knowledge/research/research-observability.md +395 -0
- package/content/knowledge/research/research-overfitting-prevention.md +306 -0
- package/content/knowledge/research/research-project-structure.md +264 -0
- package/content/knowledge/research/research-quant-backtesting.md +326 -0
- package/content/knowledge/research/research-quant-market-data.md +366 -0
- package/content/knowledge/research/research-quant-metrics.md +335 -0
- package/content/knowledge/research/research-quant-requirements.md +223 -0
- package/content/knowledge/research/research-quant-risk.md +469 -0
- package/content/knowledge/research/research-quant-strategy-patterns.md +412 -0
- package/content/knowledge/research/research-requirements.md +201 -0
- package/content/knowledge/research/research-security.md +374 -0
- package/content/knowledge/research/research-sim-compute-management.md +538 -0
- package/content/knowledge/research/research-sim-engine-patterns.md +448 -0
- package/content/knowledge/research/research-sim-parameter-spaces.md +425 -0
- package/content/knowledge/research/research-sim-validation.md +456 -0
- package/content/knowledge/research/research-testing.md +334 -0
- package/content/methodology/research-ml-research.yml +23 -0
- package/content/methodology/research-overlay.yml +65 -0
- package/content/methodology/research-quant-finance.yml +29 -0
- package/content/methodology/research-simulation.yml +23 -0
- package/dist/cli/commands/adopt.d.ts.map +1 -1
- package/dist/cli/commands/adopt.js +30 -8
- package/dist/cli/commands/adopt.js.map +1 -1
- package/dist/cli/commands/adopt.serialization.test.js +49 -0
- package/dist/cli/commands/adopt.serialization.test.js.map +1 -1
- package/dist/cli/commands/adopt.test.js +8 -0
- package/dist/cli/commands/adopt.test.js.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +191 -180
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/complete.d.ts.map +1 -1
- package/dist/cli/commands/complete.js +16 -12
- package/dist/cli/commands/complete.js.map +1 -1
- package/dist/cli/commands/complete.test.js +14 -5
- package/dist/cli/commands/complete.test.js.map +1 -1
- package/dist/cli/commands/init.d.ts +4 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +75 -51
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +33 -27
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/cli/commands/reset.d.ts.map +1 -1
- package/dist/cli/commands/reset.js +44 -40
- package/dist/cli/commands/reset.js.map +1 -1
- package/dist/cli/commands/reset.test.js +42 -20
- package/dist/cli/commands/reset.test.js.map +1 -1
- package/dist/cli/commands/rework.d.ts.map +1 -1
- package/dist/cli/commands/rework.js +16 -12
- package/dist/cli/commands/rework.js.map +1 -1
- package/dist/cli/commands/rework.test.js +12 -3
- package/dist/cli/commands/rework.test.js.map +1 -1
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +318 -298
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/run.test.js +92 -120
- package/dist/cli/commands/run.test.js.map +1 -1
- package/dist/cli/commands/skip.d.ts.map +1 -1
- package/dist/cli/commands/skip.js +19 -15
- package/dist/cli/commands/skip.js.map +1 -1
- package/dist/cli/commands/skip.test.js +22 -11
- package/dist/cli/commands/skip.test.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +3 -1
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/commands/update.test.js +8 -4
- package/dist/cli/commands/update.test.js.map +1 -1
- package/dist/cli/commands/version.d.ts.map +1 -1
- package/dist/cli/commands/version.js +3 -1
- package/dist/cli/commands/version.js.map +1 -1
- package/dist/cli/commands/version.test.js +9 -5
- package/dist/cli/commands/version.test.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init-flag-families.d.ts +6 -1
- package/dist/cli/init-flag-families.d.ts.map +1 -1
- package/dist/cli/init-flag-families.js +32 -1
- package/dist/cli/init-flag-families.js.map +1 -1
- package/dist/cli/init-flag-families.test.js +47 -0
- package/dist/cli/init-flag-families.test.js.map +1 -1
- package/dist/cli/output/interactive.d.ts +1 -0
- package/dist/cli/output/interactive.d.ts.map +1 -1
- package/dist/cli/output/interactive.js +5 -0
- package/dist/cli/output/interactive.js.map +1 -1
- package/dist/cli/shutdown.d.ts +51 -0
- package/dist/cli/shutdown.d.ts.map +1 -0
- package/dist/cli/shutdown.js +199 -0
- package/dist/cli/shutdown.js.map +1 -0
- package/dist/cli/shutdown.test.d.ts +2 -0
- package/dist/cli/shutdown.test.d.ts.map +1 -0
- package/dist/cli/shutdown.test.js +316 -0
- package/dist/cli/shutdown.test.js.map +1 -0
- package/dist/config/schema.d.ts +272 -16
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +25 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/config/schema.test.js +103 -3
- package/dist/config/schema.test.js.map +1 -1
- package/dist/core/assembly/overlay-loader.d.ts +12 -0
- package/dist/core/assembly/overlay-loader.d.ts.map +1 -1
- package/dist/core/assembly/overlay-loader.js +30 -0
- package/dist/core/assembly/overlay-loader.js.map +1 -1
- package/dist/core/assembly/overlay-loader.test.js +66 -1
- package/dist/core/assembly/overlay-loader.test.js.map +1 -1
- package/dist/core/assembly/overlay-state-resolver.d.ts.map +1 -1
- package/dist/core/assembly/overlay-state-resolver.js +48 -19
- package/dist/core/assembly/overlay-state-resolver.js.map +1 -1
- package/dist/core/assembly/overlay-state-resolver.test.js +80 -0
- package/dist/core/assembly/overlay-state-resolver.test.js.map +1 -1
- package/dist/e2e/init.test.js +5 -4
- package/dist/e2e/init.test.js.map +1 -1
- package/dist/e2e/project-type-overlays.test.js +119 -0
- package/dist/e2e/project-type-overlays.test.js.map +1 -1
- package/dist/project/adopt.d.ts.map +1 -1
- package/dist/project/adopt.js +3 -1
- package/dist/project/adopt.js.map +1 -1
- package/dist/project/detectors/disambiguate.js +1 -1
- package/dist/project/detectors/disambiguate.js.map +1 -1
- package/dist/project/detectors/index.d.ts.map +1 -1
- package/dist/project/detectors/index.js +2 -1
- package/dist/project/detectors/index.js.map +1 -1
- package/dist/project/detectors/ml.d.ts.map +1 -1
- package/dist/project/detectors/ml.js +2 -6
- package/dist/project/detectors/ml.js.map +1 -1
- package/dist/project/detectors/research.d.ts +4 -0
- package/dist/project/detectors/research.d.ts.map +1 -0
- package/dist/project/detectors/research.js +141 -0
- package/dist/project/detectors/research.js.map +1 -0
- package/dist/project/detectors/research.test.d.ts +2 -0
- package/dist/project/detectors/research.test.d.ts.map +1 -0
- package/dist/project/detectors/research.test.js +235 -0
- package/dist/project/detectors/research.test.js.map +1 -0
- package/dist/project/detectors/shared-signals.d.ts +3 -0
- package/dist/project/detectors/shared-signals.d.ts.map +1 -0
- package/dist/project/detectors/shared-signals.js +9 -0
- package/dist/project/detectors/shared-signals.js.map +1 -0
- package/dist/project/detectors/types.d.ts +6 -2
- package/dist/project/detectors/types.d.ts.map +1 -1
- package/dist/project/detectors/types.js.map +1 -1
- package/dist/state/lock-manager.d.ts +1 -0
- package/dist/state/lock-manager.d.ts.map +1 -1
- package/dist/state/lock-manager.js +1 -1
- package/dist/state/lock-manager.js.map +1 -1
- package/dist/types/config.d.ts +7 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/wizard/copy/core.d.ts.map +1 -1
- package/dist/wizard/copy/core.js +4 -0
- package/dist/wizard/copy/core.js.map +1 -1
- package/dist/wizard/copy/index.d.ts.map +1 -1
- package/dist/wizard/copy/index.js +2 -0
- package/dist/wizard/copy/index.js.map +1 -1
- package/dist/wizard/copy/research.d.ts +3 -0
- package/dist/wizard/copy/research.d.ts.map +1 -0
- package/dist/wizard/copy/research.js +27 -0
- package/dist/wizard/copy/research.js.map +1 -0
- package/dist/wizard/copy/types.d.ts +5 -1
- package/dist/wizard/copy/types.d.ts.map +1 -1
- package/dist/wizard/flags.d.ts +7 -1
- package/dist/wizard/flags.d.ts.map +1 -1
- package/dist/wizard/questions.d.ts +4 -2
- package/dist/wizard/questions.d.ts.map +1 -1
- package/dist/wizard/questions.js +27 -1
- package/dist/wizard/questions.js.map +1 -1
- package/dist/wizard/questions.test.js +51 -0
- package/dist/wizard/questions.test.js.map +1 -1
- package/dist/wizard/wizard.d.ts +3 -2
- package/dist/wizard/wizard.d.ts.map +1 -1
- package/dist/wizard/wizard.js +3 -1
- package/dist/wizard/wizard.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: research-quant-strategy-patterns
|
|
3
|
+
description: Trading strategy patterns including signal generation, entry/exit rules, position sizing methods, stop-loss patterns, and multi-asset allocation
|
|
4
|
+
topics: [research, quant-finance, strategy, signals, position-sizing, kelly-criterion, stop-loss, allocation, volatility-targeting]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Trading strategies are composed of discrete building blocks that can be mixed, matched, and tested independently: signal generation (what to trade and when), entry/exit rules (how to initiate and close positions), position sizing (how much capital to allocate), and stop-loss management (how to limit downside). Treating each block as a separate, testable component enables systematic exploration of the strategy design space. Resist the temptation to build monolithic strategies -- instead, compose simple, well-tested components.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Build strategies from composable components: signal generators (trend indicators, mean-reversion z-scores, momentum factors), entry/exit rules (confirmation logic, filter conditions, time-based exits), position sizing methods (fixed fraction, Kelly criterion, volatility targeting), and stop-loss patterns (ATR-based, trailing, time-based). Test each component in isolation before combining. Use a signal-to-position pipeline that separates alpha generation from risk management.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Signal Generation Patterns
|
|
16
|
+
|
|
17
|
+
Signals are the core alpha source. They convert market data into directional predictions:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
# strategies/signals/base.py
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
import pandas as pd
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
class SignalGenerator(ABC):
|
|
26
|
+
"""Base class for signal generators."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def generate(self, data: pd.DataFrame) -> pd.Series:
|
|
30
|
+
"""
|
|
31
|
+
Generate trading signals from market data.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Series of signal values. Convention:
|
|
35
|
+
- Positive values = bullish signal (strength)
|
|
36
|
+
- Negative values = bearish signal (strength)
|
|
37
|
+
- Zero = no signal
|
|
38
|
+
"""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MovingAverageCrossover(SignalGenerator):
|
|
43
|
+
"""Dual moving average crossover signal."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, fast_period: int = 10, slow_period: int = 50):
|
|
46
|
+
self.fast_period = fast_period
|
|
47
|
+
self.slow_period = slow_period
|
|
48
|
+
|
|
49
|
+
def generate(self, data: pd.DataFrame) -> pd.Series:
|
|
50
|
+
fast_ma = data["close"].rolling(self.fast_period).mean()
|
|
51
|
+
slow_ma = data["close"].rolling(self.slow_period).mean()
|
|
52
|
+
# Signal strength = normalized distance between MAs
|
|
53
|
+
signal = (fast_ma - slow_ma) / slow_ma
|
|
54
|
+
return signal
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MeanReversionZScore(SignalGenerator):
|
|
58
|
+
"""Z-score based mean reversion signal."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, lookback: int = 20, entry_z: float = 2.0):
|
|
61
|
+
self.lookback = lookback
|
|
62
|
+
self.entry_z = entry_z
|
|
63
|
+
|
|
64
|
+
def generate(self, data: pd.DataFrame) -> pd.Series:
|
|
65
|
+
rolling_mean = data["close"].rolling(self.lookback).mean()
|
|
66
|
+
rolling_std = data["close"].rolling(self.lookback).std()
|
|
67
|
+
z_score = (data["close"] - rolling_mean) / rolling_std
|
|
68
|
+
# Negative z-score = price below mean = buy signal for mean reversion
|
|
69
|
+
signal = -z_score
|
|
70
|
+
return signal
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MomentumFactor(SignalGenerator):
|
|
74
|
+
"""Cross-sectional momentum signal for multi-asset allocation."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, lookback: int = 252, skip_recent: int = 21):
|
|
77
|
+
self.lookback = lookback
|
|
78
|
+
self.skip_recent = skip_recent # Skip most recent month (reversal)
|
|
79
|
+
|
|
80
|
+
def generate(self, data: pd.DataFrame) -> pd.Series:
|
|
81
|
+
# Total return over lookback, excluding recent reversal period
|
|
82
|
+
total_return = (
|
|
83
|
+
data["close"].shift(self.skip_recent)
|
|
84
|
+
/ data["close"].shift(self.lookback)
|
|
85
|
+
- 1
|
|
86
|
+
)
|
|
87
|
+
return total_return
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Entry/Exit Rule Patterns
|
|
91
|
+
|
|
92
|
+
Entry and exit rules add confirmation, filtering, and timing logic on top of raw signals:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# strategies/rules/entry_exit.py
|
|
96
|
+
import pandas as pd
|
|
97
|
+
import numpy as np
|
|
98
|
+
from dataclasses import dataclass
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class TradeSignal:
|
|
102
|
+
"""A confirmed trade signal with entry parameters."""
|
|
103
|
+
direction: int # +1 = long, -1 = short, 0 = no trade
|
|
104
|
+
strength: float # Signal strength for position sizing
|
|
105
|
+
entry_type: str # "market", "limit", "stop"
|
|
106
|
+
limit_price: float | None = None
|
|
107
|
+
stop_price: float | None = None
|
|
108
|
+
|
|
109
|
+
class ConfirmationFilter:
|
|
110
|
+
"""Require multiple signals to agree before entering a trade."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, signals: list[pd.Series], min_agreement: int = 2):
|
|
113
|
+
self.signals = signals
|
|
114
|
+
self.min_agreement = min_agreement
|
|
115
|
+
|
|
116
|
+
def filter(self) -> pd.Series:
|
|
117
|
+
"""Return confirmed signals where enough generators agree."""
|
|
118
|
+
# Count how many signals are positive (bullish) at each point
|
|
119
|
+
bullish = sum((s > 0).astype(int) for s in self.signals)
|
|
120
|
+
bearish = sum((s < 0).astype(int) for s in self.signals)
|
|
121
|
+
|
|
122
|
+
confirmed = pd.Series(0.0, index=self.signals[0].index)
|
|
123
|
+
confirmed[bullish >= self.min_agreement] = 1.0
|
|
124
|
+
confirmed[bearish >= self.min_agreement] = -1.0
|
|
125
|
+
return confirmed
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class VolumeFilter:
|
|
129
|
+
"""Require minimum volume before entering a trade."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, min_volume_ratio: float = 1.5):
|
|
132
|
+
self.min_volume_ratio = min_volume_ratio
|
|
133
|
+
|
|
134
|
+
def apply(self, signal: pd.Series, volume: pd.Series,
|
|
135
|
+
lookback: int = 20) -> pd.Series:
|
|
136
|
+
"""Zero out signals on low-volume bars."""
|
|
137
|
+
avg_volume = volume.rolling(lookback).mean()
|
|
138
|
+
volume_ratio = volume / avg_volume
|
|
139
|
+
filtered = signal.copy()
|
|
140
|
+
filtered[volume_ratio < self.min_volume_ratio] = 0.0
|
|
141
|
+
return filtered
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TimeBasedExit:
|
|
145
|
+
"""Force exit after a maximum holding period."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, max_bars: int = 20):
|
|
148
|
+
self.max_bars = max_bars
|
|
149
|
+
|
|
150
|
+
def apply(self, positions: pd.Series) -> pd.Series:
|
|
151
|
+
"""Apply time-based exit to existing positions."""
|
|
152
|
+
result = positions.copy()
|
|
153
|
+
bars_in_position = 0
|
|
154
|
+
|
|
155
|
+
for i in range(1, len(result)):
|
|
156
|
+
if result.iloc[i] != 0:
|
|
157
|
+
bars_in_position += 1
|
|
158
|
+
if bars_in_position >= self.max_bars:
|
|
159
|
+
result.iloc[i] = 0
|
|
160
|
+
bars_in_position = 0
|
|
161
|
+
else:
|
|
162
|
+
bars_in_position = 0
|
|
163
|
+
|
|
164
|
+
return result
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Position Sizing Methods
|
|
168
|
+
|
|
169
|
+
Position sizing is arguably more important than signal generation. It determines how much capital to risk on each trade:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
# strategies/sizing/position_sizing.py
|
|
173
|
+
import numpy as np
|
|
174
|
+
import pandas as pd
|
|
175
|
+
from abc import ABC, abstractmethod
|
|
176
|
+
|
|
177
|
+
class PositionSizer(ABC):
|
|
178
|
+
"""Base class for position sizing methods."""
|
|
179
|
+
|
|
180
|
+
@abstractmethod
|
|
181
|
+
def size(self, signal_strength: float, price: float,
|
|
182
|
+
portfolio_value: float, **kwargs) -> int:
|
|
183
|
+
"""Return number of shares to trade."""
|
|
184
|
+
...
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class FixedFraction(PositionSizer):
|
|
188
|
+
"""Risk a fixed fraction of portfolio per trade."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, risk_pct: float = 0.02, atr_multiplier: float = 2.0):
|
|
191
|
+
self.risk_pct = risk_pct
|
|
192
|
+
self.atr_multiplier = atr_multiplier
|
|
193
|
+
|
|
194
|
+
def size(self, signal_strength: float, price: float,
|
|
195
|
+
portfolio_value: float, atr: float = 0.0, **kwargs) -> int:
|
|
196
|
+
"""
|
|
197
|
+
Size based on fixed-fraction risk.
|
|
198
|
+
|
|
199
|
+
risk_amount = portfolio * risk_pct
|
|
200
|
+
stop_distance = atr * atr_multiplier
|
|
201
|
+
shares = risk_amount / stop_distance
|
|
202
|
+
"""
|
|
203
|
+
risk_amount = portfolio_value * self.risk_pct
|
|
204
|
+
stop_distance = atr * self.atr_multiplier if atr > 0 else price * 0.02
|
|
205
|
+
if stop_distance == 0:
|
|
206
|
+
return 0
|
|
207
|
+
shares = int(risk_amount / stop_distance)
|
|
208
|
+
return shares
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class KellyCriterion(PositionSizer):
|
|
212
|
+
"""Kelly criterion position sizing with fractional Kelly for safety."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, kelly_fraction: float = 0.25, max_position_pct: float = 0.10):
|
|
215
|
+
self.kelly_fraction = kelly_fraction # Use quarter-Kelly for safety
|
|
216
|
+
self.max_position_pct = max_position_pct
|
|
217
|
+
|
|
218
|
+
def size(self, signal_strength: float, price: float,
|
|
219
|
+
portfolio_value: float, win_rate: float = 0.5,
|
|
220
|
+
avg_win_loss_ratio: float = 1.5, **kwargs) -> int:
|
|
221
|
+
"""
|
|
222
|
+
Kelly criterion: f* = (bp - q) / b
|
|
223
|
+
where b = avg_win/avg_loss, p = win_rate, q = 1 - p
|
|
224
|
+
"""
|
|
225
|
+
b = avg_win_loss_ratio
|
|
226
|
+
p = win_rate
|
|
227
|
+
q = 1 - p
|
|
228
|
+
kelly_pct = (b * p - q) / b if b > 0 else 0.0
|
|
229
|
+
|
|
230
|
+
# Apply fractional Kelly and cap
|
|
231
|
+
position_pct = min(
|
|
232
|
+
kelly_pct * self.kelly_fraction,
|
|
233
|
+
self.max_position_pct,
|
|
234
|
+
)
|
|
235
|
+
position_pct = max(position_pct, 0.0) # Never negative
|
|
236
|
+
|
|
237
|
+
notional = portfolio_value * position_pct
|
|
238
|
+
shares = int(notional / price) if price > 0 else 0
|
|
239
|
+
return shares
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class VolatilityTargeting(PositionSizer):
|
|
243
|
+
"""Size positions to target a specific portfolio volatility."""
|
|
244
|
+
|
|
245
|
+
def __init__(self, target_vol: float = 0.15, lookback: int = 20):
|
|
246
|
+
self.target_vol = target_vol # 15% annualized target
|
|
247
|
+
self.lookback = lookback
|
|
248
|
+
|
|
249
|
+
def size(self, signal_strength: float, price: float,
|
|
250
|
+
portfolio_value: float, returns: pd.Series | None = None,
|
|
251
|
+
**kwargs) -> int:
|
|
252
|
+
"""
|
|
253
|
+
Scale position to achieve target volatility contribution.
|
|
254
|
+
|
|
255
|
+
position_weight = target_vol / (asset_vol * sqrt(252))
|
|
256
|
+
"""
|
|
257
|
+
if returns is None or len(returns) < self.lookback:
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
asset_vol = returns.tail(self.lookback).std() * np.sqrt(252)
|
|
261
|
+
if asset_vol == 0:
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
weight = self.target_vol / asset_vol
|
|
265
|
+
weight = min(weight, 2.0) # Cap at 2x leverage per asset
|
|
266
|
+
notional = portfolio_value * weight * abs(signal_strength)
|
|
267
|
+
shares = int(notional / price) if price > 0 else 0
|
|
268
|
+
return shares
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Stop-Loss Patterns
|
|
272
|
+
|
|
273
|
+
Stop-losses protect against large losses. The stop type should match the strategy timeframe and volatility:
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
# strategies/risk/stop_loss.py
|
|
277
|
+
import pandas as pd
|
|
278
|
+
import numpy as np
|
|
279
|
+
|
|
280
|
+
class ATRStop:
|
|
281
|
+
"""ATR-based stop-loss that adapts to volatility."""
|
|
282
|
+
|
|
283
|
+
def __init__(self, atr_period: int = 14, atr_multiplier: float = 2.0):
|
|
284
|
+
self.atr_period = atr_period
|
|
285
|
+
self.atr_multiplier = atr_multiplier
|
|
286
|
+
|
|
287
|
+
def compute_stops(self, data: pd.DataFrame, direction: int) -> pd.Series:
|
|
288
|
+
"""Compute stop-loss levels based on ATR."""
|
|
289
|
+
atr = self._compute_atr(data)
|
|
290
|
+
if direction == 1: # Long: stop below entry
|
|
291
|
+
stops = data["close"] - atr * self.atr_multiplier
|
|
292
|
+
else: # Short: stop above entry
|
|
293
|
+
stops = data["close"] + atr * self.atr_multiplier
|
|
294
|
+
return stops
|
|
295
|
+
|
|
296
|
+
def _compute_atr(self, data: pd.DataFrame) -> pd.Series:
|
|
297
|
+
high_low = data["high"] - data["low"]
|
|
298
|
+
high_close = (data["high"] - data["close"].shift(1)).abs()
|
|
299
|
+
low_close = (data["low"] - data["close"].shift(1)).abs()
|
|
300
|
+
true_range = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
|
|
301
|
+
return true_range.rolling(self.atr_period).mean()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class TrailingStop:
|
|
305
|
+
"""Trailing stop that locks in profits as price moves favorably."""
|
|
306
|
+
|
|
307
|
+
def __init__(self, trail_pct: float = 0.05):
|
|
308
|
+
self.trail_pct = trail_pct
|
|
309
|
+
|
|
310
|
+
def track(self, prices: pd.Series, direction: int) -> pd.Series:
|
|
311
|
+
"""Track trailing stop level through a position."""
|
|
312
|
+
stops = pd.Series(index=prices.index, dtype=float)
|
|
313
|
+
|
|
314
|
+
if direction == 1: # Long
|
|
315
|
+
peak = prices.iloc[0]
|
|
316
|
+
for i, price in enumerate(prices):
|
|
317
|
+
peak = max(peak, price)
|
|
318
|
+
stops.iloc[i] = peak * (1 - self.trail_pct)
|
|
319
|
+
else: # Short
|
|
320
|
+
trough = prices.iloc[0]
|
|
321
|
+
for i, price in enumerate(prices):
|
|
322
|
+
trough = min(trough, price)
|
|
323
|
+
stops.iloc[i] = trough * (1 + self.trail_pct)
|
|
324
|
+
|
|
325
|
+
return stops
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class TimeStop:
|
|
329
|
+
"""Exit after a maximum holding period regardless of P&L."""
|
|
330
|
+
|
|
331
|
+
def __init__(self, max_bars: int = 20):
|
|
332
|
+
self.max_bars = max_bars
|
|
333
|
+
|
|
334
|
+
def should_exit(self, bars_held: int) -> bool:
|
|
335
|
+
return bars_held >= self.max_bars
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Multi-Asset Allocation
|
|
339
|
+
|
|
340
|
+
When trading multiple instruments, allocation determines the portfolio weights across assets:
|
|
341
|
+
|
|
342
|
+
```python
|
|
343
|
+
# strategies/allocation/multi_asset.py
|
|
344
|
+
import numpy as np
|
|
345
|
+
import pandas as pd
|
|
346
|
+
|
|
347
|
+
def equal_weight(signals: dict[str, float]) -> dict[str, float]:
|
|
348
|
+
"""Equal weight across all assets with active signals."""
|
|
349
|
+
active = {k: v for k, v in signals.items() if v != 0}
|
|
350
|
+
if not active:
|
|
351
|
+
return {k: 0.0 for k in signals}
|
|
352
|
+
weight = 1.0 / len(active)
|
|
353
|
+
return {k: weight * np.sign(v) if v != 0 else 0.0 for k, v in signals.items()}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def signal_weighted(signals: dict[str, float]) -> dict[str, float]:
|
|
357
|
+
"""Weight proportional to signal strength."""
|
|
358
|
+
total = sum(abs(v) for v in signals.values())
|
|
359
|
+
if total == 0:
|
|
360
|
+
return {k: 0.0 for k in signals}
|
|
361
|
+
return {k: v / total for k, v in signals.items()}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def inverse_volatility(
|
|
365
|
+
returns: pd.DataFrame,
|
|
366
|
+
lookback: int = 60,
|
|
367
|
+
) -> dict[str, float]:
|
|
368
|
+
"""Allocate inversely proportional to recent volatility."""
|
|
369
|
+
recent = returns.tail(lookback)
|
|
370
|
+
vols = recent.std() * np.sqrt(252)
|
|
371
|
+
inv_vol = 1.0 / vols.replace(0, np.inf)
|
|
372
|
+
weights = inv_vol / inv_vol.sum()
|
|
373
|
+
return weights.to_dict()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def risk_parity(
|
|
377
|
+
returns: pd.DataFrame,
|
|
378
|
+
lookback: int = 60,
|
|
379
|
+
target_vol: float = 0.10,
|
|
380
|
+
) -> dict[str, float]:
|
|
381
|
+
"""
|
|
382
|
+
Risk parity — equal risk contribution from each asset.
|
|
383
|
+
|
|
384
|
+
Each asset contributes equally to portfolio volatility.
|
|
385
|
+
"""
|
|
386
|
+
recent = returns.tail(lookback)
|
|
387
|
+
cov = recent.cov() * 252
|
|
388
|
+
n = len(cov)
|
|
389
|
+
|
|
390
|
+
# Simple approximation: inverse vol with correlation adjustment
|
|
391
|
+
vols = np.sqrt(np.diag(cov))
|
|
392
|
+
inv_vol = 1.0 / vols
|
|
393
|
+
weights = inv_vol / inv_vol.sum()
|
|
394
|
+
|
|
395
|
+
# Scale to target volatility
|
|
396
|
+
port_vol = np.sqrt(weights @ cov.values @ weights)
|
|
397
|
+
if port_vol > 0:
|
|
398
|
+
weights *= target_vol / port_vol
|
|
399
|
+
|
|
400
|
+
return dict(zip(returns.columns, weights))
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Strategy Composition Pattern
|
|
404
|
+
|
|
405
|
+
Compose strategies from independent, tested components using a pipeline:
|
|
406
|
+
|
|
407
|
+
```
|
|
408
|
+
Signal Generators → Confirmation Filter → Position Sizer → Stop Manager → Allocation
|
|
409
|
+
(alpha) (noise reduction) (risk mgmt) (protection) (portfolio)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Each component is tested in isolation before the pipeline is assembled. This enables systematic A/B testing of individual components (e.g., "does adding a volume filter improve the momentum signal?") without rebuilding the entire strategy.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: research-requirements
|
|
3
|
+
description: Research project requirements including experiment goals, success metrics, iteration budgets, stopping criteria, and hypothesis documentation
|
|
4
|
+
topics: [research, requirements, experiment-goals, metrics, stopping-criteria, hypothesis]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Research projects differ from product engineering in a fundamental way: the requirements are not a list of features to build but a set of questions to answer. A research PRD defines the hypothesis space, the metrics that will determine success or failure, the computational and time budgets for exploration, and the stopping criteria that prevent infinite iteration. Without these constraints, autonomous experiment loops run forever and produce nothing actionable.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
Define research requirements as structured hypotheses with measurable success criteria. Establish iteration budgets (wall-clock time, compute cost, number of runs) and stopping criteria (convergence thresholds, diminishing returns, budget exhaustion) before the first experiment. Document every hypothesis with its rationale, expected outcome, and evaluation method. Use a decision log to record keep/discard decisions with justifications.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### Hypothesis Documentation
|
|
16
|
+
|
|
17
|
+
Every research project starts with one or more hypotheses. Each hypothesis must be specific enough to be falsifiable and must specify how it will be evaluated:
|
|
18
|
+
|
|
19
|
+
```markdown
|
|
20
|
+
# Hypothesis Registry
|
|
21
|
+
|
|
22
|
+
## H-001: Momentum crossover with adaptive lookback
|
|
23
|
+
- **Statement**: A momentum crossover strategy using adaptive lookback periods
|
|
24
|
+
(10-50 day range, optimised per asset) will achieve a Sharpe ratio > 1.5
|
|
25
|
+
on out-of-sample data (2020-2023).
|
|
26
|
+
- **Rationale**: Fixed lookback periods fail to adapt to regime changes.
|
|
27
|
+
Adaptive periods should capture both trending and mean-reverting regimes.
|
|
28
|
+
- **Success criteria**: Sharpe ratio > 1.5, max drawdown < 15%, positive
|
|
29
|
+
returns in at least 3 of 4 years.
|
|
30
|
+
- **Evaluation method**: Walk-forward analysis with 252-day training window,
|
|
31
|
+
63-day test window, no look-ahead bias.
|
|
32
|
+
- **Budget**: 500 experiment runs, 48 hours wall-clock time.
|
|
33
|
+
- **Status**: In progress
|
|
34
|
+
- **Decision**: [pending]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Success Metrics Framework
|
|
38
|
+
|
|
39
|
+
Research metrics must distinguish between primary objectives and guardrails:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# configs/metrics.py
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from typing import Optional
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class MetricDefinition:
|
|
48
|
+
"""A single metric with its target and guardrail thresholds."""
|
|
49
|
+
name: str
|
|
50
|
+
direction: str # "maximize" or "minimize"
|
|
51
|
+
target: float # Primary success threshold
|
|
52
|
+
guardrail: Optional[float] = None # Hard constraint (never violated)
|
|
53
|
+
description: str = ""
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class SuccessCriteria:
|
|
57
|
+
"""Complete success criteria for a research project."""
|
|
58
|
+
primary: MetricDefinition # The one metric to optimize
|
|
59
|
+
secondary: list[MetricDefinition] = field(default_factory=list)
|
|
60
|
+
guardrails: list[MetricDefinition] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
def is_success(self, results: dict[str, float]) -> bool:
|
|
63
|
+
"""Check if results meet all success criteria."""
|
|
64
|
+
# Primary metric must meet target
|
|
65
|
+
primary_val = results[self.primary.name]
|
|
66
|
+
if self.primary.direction == "maximize" and primary_val < self.primary.target:
|
|
67
|
+
return False
|
|
68
|
+
if self.primary.direction == "minimize" and primary_val > self.primary.target:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
# All guardrails must be satisfied
|
|
72
|
+
for g in self.guardrails:
|
|
73
|
+
val = results[g.name]
|
|
74
|
+
if g.direction == "maximize" and val < g.guardrail:
|
|
75
|
+
return False
|
|
76
|
+
if g.direction == "minimize" and val > g.guardrail:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
# Example: trading strategy research
|
|
82
|
+
criteria = SuccessCriteria(
|
|
83
|
+
primary=MetricDefinition(
|
|
84
|
+
name="sharpe_ratio",
|
|
85
|
+
direction="maximize",
|
|
86
|
+
target=1.5,
|
|
87
|
+
description="Risk-adjusted return on out-of-sample data",
|
|
88
|
+
),
|
|
89
|
+
guardrails=[
|
|
90
|
+
MetricDefinition(
|
|
91
|
+
name="max_drawdown",
|
|
92
|
+
direction="minimize",
|
|
93
|
+
target=0.15,
|
|
94
|
+
guardrail=0.25,
|
|
95
|
+
description="Maximum peak-to-trough decline",
|
|
96
|
+
),
|
|
97
|
+
MetricDefinition(
|
|
98
|
+
name="num_trades",
|
|
99
|
+
direction="maximize",
|
|
100
|
+
target=100,
|
|
101
|
+
guardrail=30,
|
|
102
|
+
description="Minimum trades for statistical significance",
|
|
103
|
+
),
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Iteration Budgets
|
|
109
|
+
|
|
110
|
+
Unbounded iteration is the primary failure mode of research projects. Define hard limits before starting:
|
|
111
|
+
|
|
112
|
+
| Budget Type | Example Limit | Enforcement |
|
|
113
|
+
|-------------|--------------|-------------|
|
|
114
|
+
| Run count | 500 experiments | Counter in experiment runner |
|
|
115
|
+
| Wall-clock time | 48 hours | Watchdog timer / cron kill |
|
|
116
|
+
| Compute cost | $200 | Cloud billing alerts |
|
|
117
|
+
| Convergence patience | 50 runs without improvement | Early stopping callback |
|
|
118
|
+
| Human review interval | Every 100 runs | Checkpoint gate |
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# configs/budget.py
|
|
122
|
+
from dataclasses import dataclass
|
|
123
|
+
from datetime import timedelta
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class IterationBudget:
|
|
127
|
+
"""Hard limits on experiment iteration."""
|
|
128
|
+
max_runs: int = 500
|
|
129
|
+
max_wall_time: timedelta = timedelta(hours=48)
|
|
130
|
+
max_cost_usd: float = 200.0
|
|
131
|
+
patience: int = 50 # Runs without improvement before early stop
|
|
132
|
+
checkpoint_interval: int = 100 # Runs between human review gates
|
|
133
|
+
|
|
134
|
+
def is_exhausted(self, runs: int, elapsed: timedelta, cost: float,
|
|
135
|
+
runs_since_improvement: int) -> tuple[bool, str]:
|
|
136
|
+
"""Check if any budget limit has been reached."""
|
|
137
|
+
if runs >= self.max_runs:
|
|
138
|
+
return True, f"Run limit reached ({runs}/{self.max_runs})"
|
|
139
|
+
if elapsed >= self.max_wall_time:
|
|
140
|
+
return True, f"Time limit reached ({elapsed})"
|
|
141
|
+
if cost >= self.max_cost_usd:
|
|
142
|
+
return True, f"Cost limit reached (${cost:.2f}/${self.max_cost_usd})"
|
|
143
|
+
if runs_since_improvement >= self.patience:
|
|
144
|
+
return True, f"Patience exhausted ({runs_since_improvement} runs without improvement)"
|
|
145
|
+
return False, ""
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Stopping Criteria
|
|
149
|
+
|
|
150
|
+
Stopping criteria are decision functions that determine when to halt iteration. They are distinct from budget exhaustion (which is a hard stop) -- stopping criteria detect when further iteration is unlikely to help:
|
|
151
|
+
|
|
152
|
+
1. **Convergence**: The objective metric has plateaued (moving average change < epsilon for N runs).
|
|
153
|
+
2. **Diminishing returns**: Each successive improvement is smaller than the previous. Extrapolate the improvement curve to estimate remaining gain vs. cost.
|
|
154
|
+
3. **Statistical saturation**: Additional runs are not changing the confidence interval on the primary metric.
|
|
155
|
+
4. **Guardrail violation**: A constraint metric has entered an unacceptable range and parameter adjustments are not recovering it.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
import numpy as np
|
|
159
|
+
|
|
160
|
+
def detect_convergence(metric_history: list[float], window: int = 20,
|
|
161
|
+
epsilon: float = 1e-4) -> bool:
|
|
162
|
+
"""Detect if a metric has converged (plateau detection)."""
|
|
163
|
+
if len(metric_history) < window * 2:
|
|
164
|
+
return False
|
|
165
|
+
recent = np.array(metric_history[-window:])
|
|
166
|
+
prior = np.array(metric_history[-2 * window:-window])
|
|
167
|
+
return abs(recent.mean() - prior.mean()) < epsilon
|
|
168
|
+
|
|
169
|
+
def detect_diminishing_returns(improvements: list[float],
|
|
170
|
+
min_improvement: float = 0.001) -> bool:
|
|
171
|
+
"""Detect if improvements are shrinking below a useful threshold."""
|
|
172
|
+
if len(improvements) < 5:
|
|
173
|
+
return False
|
|
174
|
+
recent = improvements[-5:]
|
|
175
|
+
return all(abs(imp) < min_improvement for imp in recent)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Decision Log
|
|
179
|
+
|
|
180
|
+
Every keep/discard decision in the experiment loop must be logged with its rationale. This is the audit trail that makes research reproducible and reviewable:
|
|
181
|
+
|
|
182
|
+
```markdown
|
|
183
|
+
# Decision Log
|
|
184
|
+
|
|
185
|
+
| Run | Hypothesis | Result | Decision | Rationale |
|
|
186
|
+
|-----|-----------|--------|----------|-----------|
|
|
187
|
+
| 042 | H-001 (lookback=20) | Sharpe=1.2, DD=18% | Keep | Best Sharpe so far, DD within guardrail |
|
|
188
|
+
| 043 | H-001 (lookback=10) | Sharpe=0.8, DD=22% | Discard | Sharpe below baseline, high drawdown |
|
|
189
|
+
| 044 | H-001 (lookback=30) | Sharpe=1.3, DD=12% | Keep | New best, low drawdown |
|
|
190
|
+
| 045 | H-002 (mean-revert) | Sharpe=0.4, DD=8% | Discard | Sharpe far below target despite low DD |
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Requirements Anti-Patterns
|
|
194
|
+
|
|
195
|
+
Avoid these common failures in research project requirements:
|
|
196
|
+
|
|
197
|
+
- **Vague success criteria**: "Find a good strategy" is not testable. Specify a number.
|
|
198
|
+
- **No stopping criteria**: "Keep trying until it works" leads to infinite iteration.
|
|
199
|
+
- **Optimising too many metrics**: Pick one primary metric. Everything else is a guardrail.
|
|
200
|
+
- **No out-of-sample plan**: If success criteria are evaluated on training data, the project will "succeed" on noise.
|
|
201
|
+
- **Scope creep during iteration**: The hypothesis should be fixed before iteration starts. If you want to explore a different hypothesis, start a new experiment, don't modify the current one mid-run.
|