Italianhype commited on
Commit
04f3333
·
1 Parent(s): 87d2bbc

feat: add executable strategy contract

Browse files
backend/app/services/executable_strategy.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from datetime import datetime
5
+ import hashlib
6
+ import json
7
+ from statistics import mean
8
+ from typing import Any, Mapping, Sequence
9
+
10
+
11
+ SCHEMA_VERSION = "executable-strategy-v1"
12
+ SUPPORTED_ENTRY_RULES = {"breakout_close", "trend_continuation"}
13
+ SUPPORTED_TIMEFRAMES = {"1d", "15m", "5m", "1m"}
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ExecutableStrategySpec:
18
+ schema_version: str
19
+ family: str
20
+ setup_type: str
21
+ required_timeframes: tuple[str, ...]
22
+ execution_timeframe: str
23
+ entry_rule: str
24
+ lookback: int
25
+ minimum_relative_volume: float
26
+ higher_timeframe_min_trend: float
27
+ atr_period: int
28
+ stop_atr_multiple: float
29
+ minimum_stop_percent: float
30
+ target_r_multiple: float
31
+ trailing_atr_multiple: float
32
+ maximum_holding_bars: int
33
+ regime_filter: str = "all"
34
+ market_filter: str = "all"
35
+
36
+ def __post_init__(self) -> None:
37
+ if self.schema_version != SCHEMA_VERSION:
38
+ raise ValueError(f"unsupported strategy schema: {self.schema_version}")
39
+ if self.entry_rule not in SUPPORTED_ENTRY_RULES:
40
+ raise ValueError(f"unsupported entry rule: {self.entry_rule}")
41
+ if not self.required_timeframes or set(self.required_timeframes) - SUPPORTED_TIMEFRAMES:
42
+ raise ValueError("unsupported or empty timeframe stack")
43
+ if self.execution_timeframe not in self.required_timeframes:
44
+ raise ValueError("execution timeframe must be included in required timeframes")
45
+ if not 2 <= self.lookback <= 250:
46
+ raise ValueError("lookback must be between 2 and 250")
47
+ if not 2 <= self.atr_period <= 100:
48
+ raise ValueError("ATR period must be between 2 and 100")
49
+ if not 0.0 <= self.minimum_relative_volume <= 20.0:
50
+ raise ValueError("minimum relative volume is outside supported bounds")
51
+ if not 0.1 <= self.stop_atr_multiple <= 10.0:
52
+ raise ValueError("stop ATR multiple is outside supported bounds")
53
+ if not 0.0 <= self.minimum_stop_percent <= 0.25:
54
+ raise ValueError("minimum stop percent is outside supported bounds")
55
+ if not 0.1 <= self.target_r_multiple <= 20.0:
56
+ raise ValueError("target R multiple is outside supported bounds")
57
+ if not 0.1 <= self.trailing_atr_multiple <= 10.0:
58
+ raise ValueError("trailing ATR multiple is outside supported bounds")
59
+ if not 1 <= self.maximum_holding_bars <= 2_000:
60
+ raise ValueError("maximum holding bars is outside supported bounds")
61
+
62
+ @classmethod
63
+ def from_payload(cls, payload: Mapping[str, Any]) -> "ExecutableStrategySpec":
64
+ return cls(
65
+ schema_version=str(payload.get("schema_version") or SCHEMA_VERSION),
66
+ family=str(payload.get("family") or "unclassified"),
67
+ setup_type=str(payload.get("setup_type") or "unknown"),
68
+ required_timeframes=tuple(str(value) for value in payload.get("required_timeframes") or ("1d",)),
69
+ execution_timeframe=str(payload.get("execution_timeframe") or (payload.get("required_timeframes") or ["1d"])[-1]),
70
+ entry_rule=str(payload.get("entry_rule") or "breakout_close"),
71
+ lookback=int(payload.get("lookback") or 10),
72
+ minimum_relative_volume=float(payload.get("minimum_relative_volume") or 0.0),
73
+ higher_timeframe_min_trend=float(payload.get("higher_timeframe_min_trend") or 0.0),
74
+ atr_period=int(payload.get("atr_period") or 14),
75
+ stop_atr_multiple=float(payload.get("stop_atr_multiple") or 1.0),
76
+ minimum_stop_percent=float(payload.get("minimum_stop_percent") or 0.01),
77
+ target_r_multiple=float(payload.get("target_r_multiple") or 2.0),
78
+ trailing_atr_multiple=float(payload.get("trailing_atr_multiple") or 1.2),
79
+ maximum_holding_bars=int(payload.get("maximum_holding_bars") or 20),
80
+ regime_filter=str(payload.get("regime_filter") or "all"),
81
+ market_filter=str(payload.get("market_filter") or "all"),
82
+ )
83
+
84
+ def to_payload(self) -> dict[str, Any]:
85
+ payload = asdict(self)
86
+ payload["required_timeframes"] = list(self.required_timeframes)
87
+ payload["strategy_fingerprint"] = self.fingerprint
88
+ return payload
89
+
90
+ @property
91
+ def fingerprint(self) -> str:
92
+ payload = asdict(self)
93
+ payload["required_timeframes"] = list(self.required_timeframes)
94
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
95
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class StrategySignalEvaluation:
100
+ status: str
101
+ reason_code: str
102
+ decision_timestamp: datetime | None
103
+ relative_volume: float
104
+ atr: float
105
+ regime: str
106
+ higher_timeframe_trends: dict[str, float]
107
+ evidence: dict[str, Any]
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class TradeGeometry:
112
+ entry_price: float
113
+ stop_price: float
114
+ target_price: float
115
+ trailing_stop: float
116
+ risk_distance: float
117
+ atr: float
118
+
119
+
120
+ class StrategySignalEvaluator:
121
+ """Evaluate a frozen strategy using only bars available at ``as_of``."""
122
+
123
+ def evaluate(
124
+ self,
125
+ spec: ExecutableStrategySpec,
126
+ bars_by_timeframe: Mapping[str, Sequence[Any]],
127
+ *,
128
+ as_of: datetime,
129
+ market: str | None = None,
130
+ ) -> StrategySignalEvaluation:
131
+ histories = {
132
+ timeframe: tuple(
133
+ row
134
+ for row in bars_by_timeframe.get(timeframe, ())
135
+ if getattr(row, "bar_timestamp", as_of) <= as_of
136
+ )
137
+ for timeframe in spec.required_timeframes
138
+ }
139
+ execution = histories.get(spec.execution_timeframe, ())
140
+ decision_timestamp = getattr(execution[-1], "bar_timestamp", None) if execution else None
141
+ minimum_execution_rows = max(spec.lookback + 1, spec.atr_period + 1)
142
+ missing = [
143
+ timeframe
144
+ for timeframe in spec.required_timeframes
145
+ if len(histories.get(timeframe, ())) < (minimum_execution_rows if timeframe == spec.execution_timeframe else 2)
146
+ ]
147
+ if missing:
148
+ return self._result(
149
+ "blocked",
150
+ "INSUFFICIENT_POINT_IN_TIME_CONTEXT",
151
+ decision_timestamp,
152
+ regime="insufficient_data",
153
+ evidence={"missing_timeframes": missing},
154
+ )
155
+
156
+ market_filter = spec.market_filter.removesuffix("_only").upper()
157
+ if spec.market_filter != "all" and market and normalize_market(market) != normalize_market(market_filter):
158
+ return self._result(
159
+ "blocked",
160
+ "MARKET_FILTER_MISMATCH",
161
+ decision_timestamp,
162
+ regime="unknown",
163
+ evidence={"required_market": market_filter, "observed_market": market},
164
+ )
165
+
166
+ regime_history = histories.get("1d") or execution
167
+ regime = market_regime(regime_history)
168
+ expected_regime = spec.regime_filter.removesuffix("_only")
169
+ if spec.regime_filter != "all" and regime != expected_regime:
170
+ return self._result(
171
+ "blocked",
172
+ "REGIME_FILTER_MISMATCH",
173
+ decision_timestamp,
174
+ regime=regime,
175
+ evidence={"required_regime": expected_regime},
176
+ )
177
+
178
+ trends = {
179
+ timeframe: trend_return(history)
180
+ for timeframe, history in histories.items()
181
+ if timeframe != spec.execution_timeframe
182
+ }
183
+ contradictions = [
184
+ timeframe
185
+ for timeframe, trend in trends.items()
186
+ if trend < spec.higher_timeframe_min_trend
187
+ ]
188
+ if contradictions:
189
+ return self._result(
190
+ "blocked",
191
+ "HIGHER_TIMEFRAME_CONTRADICTION",
192
+ decision_timestamp,
193
+ regime=regime,
194
+ trends=trends,
195
+ evidence={"contradicting_timeframes": contradictions},
196
+ )
197
+
198
+ current = execution[-1]
199
+ prior = execution[-(spec.lookback + 1) : -1]
200
+ prior_volumes = [float(getattr(row, "volume", 0.0) or 0.0) for row in prior]
201
+ average_volume = mean(prior_volumes) if prior_volumes else 0.0
202
+ current_volume = float(getattr(current, "volume", 0.0) or 0.0)
203
+ relative_volume = current_volume / average_volume if average_volume > 0 else 0.0
204
+ atr = average_true_range(execution, spec.atr_period)
205
+ evidence = {
206
+ "strategy_fingerprint": spec.fingerprint,
207
+ "as_of": as_of.isoformat(),
208
+ "feature_bar_timestamps": [getattr(row, "bar_timestamp").isoformat() for row in prior],
209
+ "current_bar_timestamp": decision_timestamp.isoformat() if decision_timestamp else None,
210
+ "relative_volume": relative_volume,
211
+ "higher_timeframe_trends": trends,
212
+ }
213
+ if relative_volume < spec.minimum_relative_volume:
214
+ return self._result(
215
+ "waiting",
216
+ "RELATIVE_VOLUME_BELOW_THRESHOLD",
217
+ decision_timestamp,
218
+ relative_volume=relative_volume,
219
+ atr=atr,
220
+ regime=regime,
221
+ trends=trends,
222
+ evidence=evidence,
223
+ )
224
+
225
+ close = float(current.close)
226
+ if spec.entry_rule == "breakout_close":
227
+ triggered = close > max(float(getattr(row, "high", row.close) or row.close) for row in prior)
228
+ else:
229
+ prior_closes = [float(row.close) for row in prior]
230
+ moving_average = mean(prior_closes)
231
+ triggered = close > moving_average and close > float(prior[-1].close) and prior_closes[-1] > prior_closes[0]
232
+ if not triggered:
233
+ return self._result(
234
+ "waiting",
235
+ "ENTRY_NOT_TRIGGERED",
236
+ decision_timestamp,
237
+ relative_volume=relative_volume,
238
+ atr=atr,
239
+ regime=regime,
240
+ trends=trends,
241
+ evidence=evidence,
242
+ )
243
+ return self._result(
244
+ "triggered",
245
+ "ENTRY_TRIGGERED",
246
+ decision_timestamp,
247
+ relative_volume=relative_volume,
248
+ atr=atr,
249
+ regime=regime,
250
+ trends=trends,
251
+ evidence=evidence,
252
+ )
253
+
254
+ def geometry(
255
+ self,
256
+ spec: ExecutableStrategySpec,
257
+ *,
258
+ entry_price: float,
259
+ execution_history: Sequence[Any],
260
+ ) -> TradeGeometry:
261
+ if entry_price <= 0:
262
+ raise ValueError("entry price must be positive")
263
+ atr = average_true_range(execution_history, spec.atr_period)
264
+ risk_distance = max(atr * spec.stop_atr_multiple, entry_price * spec.minimum_stop_percent)
265
+ return TradeGeometry(
266
+ entry_price=entry_price,
267
+ stop_price=entry_price - risk_distance,
268
+ target_price=entry_price + risk_distance * spec.target_r_multiple,
269
+ trailing_stop=entry_price - atr * spec.trailing_atr_multiple,
270
+ risk_distance=risk_distance,
271
+ atr=atr,
272
+ )
273
+
274
+ @staticmethod
275
+ def _result(
276
+ status: str,
277
+ reason_code: str,
278
+ decision_timestamp: datetime | None,
279
+ *,
280
+ relative_volume: float = 0.0,
281
+ atr: float = 0.0,
282
+ regime: str,
283
+ trends: dict[str, float] | None = None,
284
+ evidence: dict[str, Any] | None = None,
285
+ ) -> StrategySignalEvaluation:
286
+ return StrategySignalEvaluation(
287
+ status=status,
288
+ reason_code=reason_code,
289
+ decision_timestamp=decision_timestamp,
290
+ relative_volume=relative_volume,
291
+ atr=atr,
292
+ regime=regime,
293
+ higher_timeframe_trends=trends or {},
294
+ evidence=evidence or {},
295
+ )
296
+
297
+
298
+ def canonical_strategy_spec(setup_type: str) -> ExecutableStrategySpec:
299
+ definitions = {
300
+ "intraday_breakout": {
301
+ "family": "intraday_scalping",
302
+ "required_timeframes": ["1d", "15m", "5m", "1m"],
303
+ "execution_timeframe": "1m",
304
+ "entry_rule": "breakout_close",
305
+ "lookback": 10,
306
+ "minimum_relative_volume": 0.0,
307
+ "minimum_stop_percent": 0.01,
308
+ "maximum_holding_bars": 20,
309
+ },
310
+ "intraday_trend": {
311
+ "family": "intraday_scalping",
312
+ "required_timeframes": ["1d", "15m", "5m", "1m"],
313
+ "execution_timeframe": "5m",
314
+ "entry_rule": "trend_continuation",
315
+ "lookback": 10,
316
+ "minimum_relative_volume": 0.0,
317
+ "minimum_stop_percent": 0.01,
318
+ "maximum_holding_bars": 20,
319
+ },
320
+ "mean_reversion": {
321
+ "family": "mean_reversion",
322
+ "required_timeframes": ["15m", "5m"],
323
+ "execution_timeframe": "5m",
324
+ "entry_rule": "trend_continuation",
325
+ "lookback": 10,
326
+ "minimum_relative_volume": 0.0,
327
+ "minimum_stop_percent": 0.01,
328
+ "maximum_holding_bars": 20,
329
+ },
330
+ "pullback": {
331
+ "family": "pullback",
332
+ "required_timeframes": ["1d", "15m"],
333
+ "execution_timeframe": "15m",
334
+ "entry_rule": "trend_continuation",
335
+ "lookback": 10,
336
+ "minimum_relative_volume": 0.0,
337
+ "minimum_stop_percent": 0.01,
338
+ "maximum_holding_bars": 20,
339
+ },
340
+ "swing_breakout": {
341
+ "family": "breakout",
342
+ "required_timeframes": ["1d"],
343
+ "execution_timeframe": "1d",
344
+ "entry_rule": "breakout_close",
345
+ "lookback": 10,
346
+ "minimum_relative_volume": 0.0,
347
+ "minimum_stop_percent": 0.01,
348
+ "maximum_holding_bars": 20,
349
+ },
350
+ }
351
+ if setup_type not in definitions:
352
+ raise ValueError(f"unsupported setup type: {setup_type}")
353
+ return ExecutableStrategySpec.from_payload(
354
+ {
355
+ "schema_version": SCHEMA_VERSION,
356
+ "setup_type": setup_type,
357
+ "atr_period": 14,
358
+ "stop_atr_multiple": 1.0,
359
+ "target_r_multiple": 2.0,
360
+ "trailing_atr_multiple": 1.2,
361
+ "higher_timeframe_min_trend": 0.0,
362
+ "regime_filter": "all",
363
+ "market_filter": "all",
364
+ **definitions[setup_type],
365
+ }
366
+ )
367
+
368
+
369
+ def average_true_range(history: Sequence[Any], period: int) -> float:
370
+ rows = list(history)[-max(2, period + 1) :]
371
+ if len(rows) < 2:
372
+ return 0.0
373
+ ranges: list[float] = []
374
+ for previous, current in zip(rows, rows[1:]):
375
+ high = float(getattr(current, "high", current.close) or current.close)
376
+ low = float(getattr(current, "low", current.close) or current.close)
377
+ previous_close = float(previous.close)
378
+ ranges.append(max(high - low, abs(high - previous_close), abs(low - previous_close)))
379
+ return max(0.000001, mean(ranges))
380
+
381
+
382
+ def trend_return(history: Sequence[Any]) -> float:
383
+ rows = list(history)
384
+ if len(rows) < 2 or float(rows[0].close) == 0:
385
+ return 0.0
386
+ return float(rows[-1].close) / float(rows[0].close) - 1.0
387
+
388
+
389
+ def market_regime(history: Sequence[Any]) -> str:
390
+ change = trend_return(list(history)[-20:])
391
+ if change > 0.03:
392
+ return "trend_up"
393
+ if change < -0.03:
394
+ return "trend_down"
395
+ return "range_bound"
396
+
397
+
398
+ def normalize_market(value: str) -> str:
399
+ key = str(value or "").strip().upper()
400
+ return {
401
+ "US": "USA",
402
+ "UNITED STATES": "USA",
403
+ "NASDAQ": "USA",
404
+ "NYSE": "USA",
405
+ "EUROPE": "EUROPE",
406
+ }.get(key, key)
backend/tests/test_executable_strategy.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta
4
+ from types import SimpleNamespace
5
+
6
+ import pytest
7
+
8
+ from app.services.executable_strategy import ExecutableStrategySpec, StrategySignalEvaluator
9
+
10
+
11
+ START = datetime(2026, 1, 5, 14, 0)
12
+
13
+
14
+ def bar(index: int, close: float, *, volume: float = 1_000_000, minutes: int = 1):
15
+ return SimpleNamespace(
16
+ bar_timestamp=START + timedelta(minutes=index * minutes),
17
+ open=close - 0.1,
18
+ high=close + 0.3,
19
+ low=close - 0.4,
20
+ close=close,
21
+ volume=volume,
22
+ )
23
+
24
+
25
+ def payload(**overrides) -> dict:
26
+ value = {
27
+ "schema_version": "executable-strategy-v1",
28
+ "family": "intraday_scalping",
29
+ "setup_type": "intraday_breakout",
30
+ "required_timeframes": ["1d", "15m", "5m", "1m"],
31
+ "execution_timeframe": "1m",
32
+ "entry_rule": "breakout_close",
33
+ "lookback": 10,
34
+ "minimum_relative_volume": 1.2,
35
+ "higher_timeframe_min_trend": 0.0,
36
+ "atr_period": 14,
37
+ "stop_atr_multiple": 1.5,
38
+ "minimum_stop_percent": 0.002,
39
+ "target_r_multiple": 2.0,
40
+ "trailing_atr_multiple": 1.2,
41
+ "maximum_holding_bars": 30,
42
+ "regime_filter": "all",
43
+ "market_filter": "all",
44
+ }
45
+ value.update(overrides)
46
+ return value
47
+
48
+
49
+ def aligned_bars(*, final_close: float = 112.0, final_volume: float = 2_000_000) -> dict:
50
+ one_minute = [bar(index, 100.0 + index * 0.2) for index in range(20)]
51
+ one_minute.append(bar(20, final_close, volume=final_volume))
52
+ return {
53
+ "1d": [bar(index - 20, 100.0 + index, minutes=1440) for index in range(21)],
54
+ "15m": [bar(index - 20, 100.0 + index * 0.5, minutes=15) for index in range(21)],
55
+ "5m": [bar(index - 20, 100.0 + index * 0.3, minutes=5) for index in range(21)],
56
+ "1m": one_minute,
57
+ }
58
+
59
+
60
+ def test_strategy_fingerprint_changes_when_executable_parameter_changes():
61
+ first = ExecutableStrategySpec.from_payload(payload(lookback=10))
62
+ second = ExecutableStrategySpec.from_payload(payload(lookback=20))
63
+
64
+ assert first.fingerprint != second.fingerprint
65
+ assert first.to_payload()["lookback"] == 10
66
+ assert second.to_payload()["lookback"] == 20
67
+
68
+
69
+ def test_unsupported_entry_rule_is_rejected_instead_of_counted_as_strategy():
70
+ with pytest.raises(ValueError, match="unsupported entry rule"):
71
+ ExecutableStrategySpec.from_payload(payload(entry_rule="marketing_label_only"))
72
+
73
+
74
+ def test_point_in_time_breakout_requires_volume_and_higher_timeframe_alignment():
75
+ spec = ExecutableStrategySpec.from_payload(payload())
76
+ evaluator = StrategySignalEvaluator()
77
+
78
+ triggered = evaluator.evaluate(spec, aligned_bars(), as_of=START + timedelta(minutes=20))
79
+ weak_volume = evaluator.evaluate(
80
+ spec,
81
+ aligned_bars(final_volume=900_000),
82
+ as_of=START + timedelta(minutes=20),
83
+ )
84
+
85
+ assert triggered.status == "triggered"
86
+ assert triggered.reason_code == "ENTRY_TRIGGERED"
87
+ assert triggered.relative_volume >= 1.2
88
+ assert weak_volume.status == "waiting"
89
+ assert weak_volume.reason_code == "RELATIVE_VOLUME_BELOW_THRESHOLD"
90
+
91
+
92
+ def test_evaluator_ignores_bars_after_as_of_timestamp():
93
+ spec = ExecutableStrategySpec.from_payload(payload(minimum_relative_volume=0.0))
94
+ bars = aligned_bars(final_close=102.0)
95
+ bars["1m"].append(bar(21, 150.0, volume=5_000_000))
96
+
97
+ result = StrategySignalEvaluator().evaluate(
98
+ spec,
99
+ bars,
100
+ as_of=START + timedelta(minutes=20),
101
+ )
102
+
103
+ assert result.status == "waiting"
104
+ assert result.reason_code == "ENTRY_NOT_TRIGGERED"
105
+ assert result.decision_timestamp == START + timedelta(minutes=20)
106
+
107
+
108
+ def test_trade_geometry_uses_strategy_atr_stop_and_target_r():
109
+ spec = ExecutableStrategySpec.from_payload(
110
+ payload(stop_atr_multiple=2.0, minimum_stop_percent=0.001, target_r_multiple=2.5)
111
+ )
112
+ history = aligned_bars()["1m"]
113
+
114
+ geometry = StrategySignalEvaluator().geometry(spec, entry_price=112.0, execution_history=history)
115
+
116
+ expected_distance = max(geometry.atr * 2.0, 112.0 * 0.001)
117
+ assert geometry.stop_price == pytest.approx(112.0 - expected_distance)
118
+ assert geometry.target_price == pytest.approx(112.0 + expected_distance * 2.5)
119
+ assert geometry.risk_distance == pytest.approx(expected_distance)