strategy-backtest-agent 1.1.0__py3-none-any.whl

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.
Files changed (64) hide show
  1. strategy_backtest_agent/__init__.py +1 -0
  2. strategy_backtest_agent/ai/__init__.py +1 -0
  3. strategy_backtest_agent/ai/models.py +271 -0
  4. strategy_backtest_agent/ai/parser.py +290 -0
  5. strategy_backtest_agent/ai/prompts.py +162 -0
  6. strategy_backtest_agent/backtester.py +159 -0
  7. strategy_backtest_agent/cli.py +347 -0
  8. strategy_backtest_agent/comparison/__init__.py +9 -0
  9. strategy_backtest_agent/comparison/cli.py +69 -0
  10. strategy_backtest_agent/comparison/engine.py +203 -0
  11. strategy_backtest_agent/comparison/formatter.py +71 -0
  12. strategy_backtest_agent/comparison/models.py +148 -0
  13. strategy_backtest_agent/costs/__init__.py +1 -0
  14. strategy_backtest_agent/costs/cli.py +35 -0
  15. strategy_backtest_agent/costs/engine.py +103 -0
  16. strategy_backtest_agent/costs/formatter.py +39 -0
  17. strategy_backtest_agent/costs/models.py +81 -0
  18. strategy_backtest_agent/demo.py +56 -0
  19. strategy_backtest_agent/doctor.py +215 -0
  20. strategy_backtest_agent/execution.py +86 -0
  21. strategy_backtest_agent/execution_cli.py +34 -0
  22. strategy_backtest_agent/indicators.py +137 -0
  23. strategy_backtest_agent/init_cli.py +87 -0
  24. strategy_backtest_agent/market_data.py +103 -0
  25. strategy_backtest_agent/metrics.py +51 -0
  26. strategy_backtest_agent/models.py +179 -0
  27. strategy_backtest_agent/reports/__init__.py +1 -0
  28. strategy_backtest_agent/reports/ai.py +212 -0
  29. strategy_backtest_agent/reports/cli.py +102 -0
  30. strategy_backtest_agent/reports/evidence.py +572 -0
  31. strategy_backtest_agent/reports/inputs.py +224 -0
  32. strategy_backtest_agent/reports/models.py +147 -0
  33. strategy_backtest_agent/reports/render.py +457 -0
  34. strategy_backtest_agent/reports/validation.py +712 -0
  35. strategy_backtest_agent/rules/__init__.py +5 -0
  36. strategy_backtest_agent/rules/evaluator.py +160 -0
  37. strategy_backtest_agent/rules/formatter.py +33 -0
  38. strategy_backtest_agent/rules/history.py +82 -0
  39. strategy_backtest_agent/rules/indicator_specs.py +97 -0
  40. strategy_backtest_agent/rules/models.py +105 -0
  41. strategy_backtest_agent/sensitivity/__init__.py +1 -0
  42. strategy_backtest_agent/sensitivity/analysis.py +131 -0
  43. strategy_backtest_agent/sensitivity/cache.py +27 -0
  44. strategy_backtest_agent/sensitivity/cli.py +49 -0
  45. strategy_backtest_agent/sensitivity/engine.py +110 -0
  46. strategy_backtest_agent/sensitivity/formatter.py +132 -0
  47. strategy_backtest_agent/sensitivity/grid.py +245 -0
  48. strategy_backtest_agent/sensitivity/models.py +180 -0
  49. strategy_backtest_agent/strategy.py +39 -0
  50. strategy_backtest_agent/validation/__init__.py +1 -0
  51. strategy_backtest_agent/validation/cli.py +64 -0
  52. strategy_backtest_agent/validation/engine.py +125 -0
  53. strategy_backtest_agent/validation/formatter.py +63 -0
  54. strategy_backtest_agent/validation/models.py +76 -0
  55. strategy_backtest_agent/walk_forward/__init__.py +1 -0
  56. strategy_backtest_agent/walk_forward/cli.py +66 -0
  57. strategy_backtest_agent/walk_forward/engine.py +188 -0
  58. strategy_backtest_agent/walk_forward/formatter.py +78 -0
  59. strategy_backtest_agent/walk_forward/models.py +136 -0
  60. strategy_backtest_agent-1.1.0.dist-info/METADATA +525 -0
  61. strategy_backtest_agent-1.1.0.dist-info/RECORD +64 -0
  62. strategy_backtest_agent-1.1.0.dist-info/WHEEL +4 -0
  63. strategy_backtest_agent-1.1.0.dist-info/entry_points.txt +3 -0
  64. strategy_backtest_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ """Deterministic backtesting tools for historical cryptocurrency candles."""
@@ -0,0 +1 @@
1
+ """Natural-language configuration parsing, isolated from financial calculations."""
@@ -0,0 +1,271 @@
1
+ """Strict extraction schema and validated RSI or rule configuration."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import AwareDatetime, Field, field_validator, model_validator
6
+
7
+ from strategy_backtest_agent.comparison.models import ComparisonConfig, RankingCriterion
8
+ from strategy_backtest_agent.market_data import TIMEFRAMES
9
+ from strategy_backtest_agent.models import (
10
+ BacktestConfig,
11
+ ExecutionConfig,
12
+ normalize_execution,
13
+ )
14
+ from strategy_backtest_agent.models import RSIStrategy as RSIStrategy
15
+ from strategy_backtest_agent.models import StrictModel as StrictModel
16
+ from strategy_backtest_agent.rules.models import RuleStrategy
17
+ from strategy_backtest_agent.sensitivity.models import ParameterSpec, SensitivityConfig
18
+ from strategy_backtest_agent.validation.models import ValidationConfig
19
+ from strategy_backtest_agent.walk_forward.models import WalkForwardConfig
20
+
21
+
22
+ class AIStrategyConfig(StrictModel):
23
+ symbol: str = Field(pattern=r"^[A-Z0-9]{5,20}$")
24
+ timeframe: str = "1h"
25
+ period_days: int = Field(default=30, gt=0, le=365)
26
+ initial_balance: float = Field(default=10000, gt=0)
27
+ fee: float = Field(default=0.001, ge=0, le=0.05)
28
+ execution: ExecutionConfig | None = None
29
+ strategy: RSIStrategy | RuleStrategy = Field(default_factory=RSIStrategy)
30
+
31
+ @model_validator(mode="after")
32
+ def execution_settings(self) -> "AIStrategyConfig":
33
+ normalize_execution(self, self.execution, "fee", self.fee)
34
+ return self
35
+
36
+ def execution_config(self) -> ExecutionConfig:
37
+ return self.execution or ExecutionConfig(fee_rate=self.fee)
38
+
39
+ @field_validator("timeframe")
40
+ @classmethod
41
+ def supported_timeframe(cls, value: str) -> str:
42
+ if value not in TIMEFRAMES:
43
+ raise ValueError("unsupported timeframe")
44
+ return value
45
+
46
+ def to_backtest_config(self) -> BacktestConfig:
47
+ """Map portfolio settings; rule strategies are passed separately to the engine."""
48
+ legacy = self.strategy if isinstance(self.strategy, RSIStrategy) else RSIStrategy()
49
+ return BacktestConfig.model_validate(
50
+ {
51
+ "symbol": self.symbol,
52
+ "timeframe": self.timeframe,
53
+ "initial_balance": self.initial_balance,
54
+ "trading_fee": self.fee,
55
+ "execution": self.execution,
56
+ "rsi_period": legacy.period,
57
+ "entry_rsi": legacy.entry_below,
58
+ "exit_rsi": legacy.exit_above,
59
+ }
60
+ )
61
+
62
+
63
+ class RSIExtraction(StrictModel):
64
+ # Required nullable fields satisfy Structured Outputs without asking AI to fill defaults.
65
+ period: int | None
66
+ entry_below: float | None
67
+ exit_above: float | None
68
+
69
+
70
+ class ExecutionExtraction(StrictModel):
71
+ fee_rate: float | None
72
+ spread_bps: float | None
73
+ slippage_bps: float | None
74
+
75
+
76
+ class StrategyExtraction(StrictModel):
77
+ symbol: str | None
78
+ timeframe: str | None
79
+ period_days: int | None
80
+ initial_balance: float | None
81
+ fee: float | None
82
+ execution: ExecutionExtraction | None = None
83
+ strategy: RSIExtraction | RuleStrategy | None
84
+
85
+ def validated_config(self) -> AIStrategyConfig:
86
+ """Apply defaults only to omitted values, preserving explicit invalid values."""
87
+ values = self.model_dump(exclude_none=True)
88
+ return AIStrategyConfig.model_validate(values)
89
+
90
+
91
+ class ParserOutput(StrictModel):
92
+ configuration: StrategyExtraction | None
93
+ rejection: (
94
+ Literal[
95
+ "unsupported",
96
+ "ambiguous",
97
+ "missing_symbol",
98
+ "macd",
99
+ "bollinger_bands",
100
+ "short_selling",
101
+ "leverage",
102
+ "stop_loss",
103
+ "take_profit",
104
+ "multiple_conditions",
105
+ "crossover",
106
+ "multiple_symbols",
107
+ "live_trading",
108
+ "prediction",
109
+ ]
110
+ | None
111
+ )
112
+
113
+ @model_validator(mode="after")
114
+ def one_outcome(self) -> "ParserOutput":
115
+ if (self.configuration is None) == (self.rejection is None):
116
+ raise ValueError("parser must return either configuration or rejection")
117
+ return self
118
+
119
+
120
+ class ComparisonStrategyExtraction(StrictModel):
121
+ name: str
122
+ strategy: RuleStrategy
123
+
124
+
125
+ class ComparisonExtraction(StrictModel):
126
+ symbol: str | None
127
+ timeframe: str | None
128
+ period_days: int | None
129
+ initial_balance: float | None
130
+ fee: float | None
131
+ execution: ExecutionExtraction | None = None
132
+ strategies: list[ComparisonStrategyExtraction] = Field(min_length=2, max_length=5)
133
+ evaluation_end: AwareDatetime | None
134
+ sort_by: RankingCriterion | None
135
+
136
+ def validated_config(self) -> ComparisonConfig:
137
+ values = self.model_dump(exclude_none=True)
138
+ # IDs represent request order and are assigned by Python, not invented by the model.
139
+ values["strategies"] = [
140
+ {"id": f"strategy-{i}", **item.model_dump()}
141
+ for i, item in enumerate(self.strategies, start=1)
142
+ ]
143
+ return ComparisonConfig.model_validate(values)
144
+
145
+
146
+ class ComparisonParserOutput(StrictModel):
147
+ configuration: ComparisonExtraction | None
148
+ rejection: (
149
+ Literal[
150
+ "unsupported",
151
+ "ambiguous",
152
+ "missing_symbol",
153
+ "inconsistent_settings",
154
+ "strategy_count",
155
+ ]
156
+ | None
157
+ )
158
+
159
+ @model_validator(mode="after")
160
+ def one_outcome(self) -> "ComparisonParserOutput":
161
+ if (self.configuration is None) == (self.rejection is None):
162
+ raise ValueError("parser must return either configuration or rejection")
163
+ return self
164
+
165
+
166
+ class ValidationExtraction(StrictModel):
167
+ symbol: str | None
168
+ timeframe: str | None
169
+ period_days: int | None
170
+ initial_balance: float | None
171
+ fee: float | None
172
+ execution: ExecutionExtraction | None = None
173
+ strategies: list[ComparisonStrategyExtraction] = Field(min_length=1, max_length=5)
174
+ evaluation_end: AwareDatetime | None
175
+ train_ratio: float | None
176
+
177
+ def validated_config(self) -> ValidationConfig:
178
+ values = self.model_dump(exclude_none=True)
179
+ values["strategies"] = [
180
+ {"id": f"strategy-{i}", **item.model_dump()}
181
+ for i, item in enumerate(self.strategies, start=1)
182
+ ]
183
+ return ValidationConfig.model_validate(values)
184
+
185
+
186
+ class ValidationParserOutput(StrictModel):
187
+ configuration: ValidationExtraction | None
188
+ rejection: (
189
+ Literal[
190
+ "unsupported", "ambiguous", "missing_symbol", "inconsistent_settings", "strategy_count"
191
+ ]
192
+ | None
193
+ )
194
+
195
+ @model_validator(mode="after")
196
+ def one_outcome(self) -> "ValidationParserOutput":
197
+ if (self.configuration is None) == (self.rejection is None):
198
+ raise ValueError("parser must return either configuration or rejection")
199
+ return self
200
+
201
+
202
+ class SensitivityExtraction(StrictModel):
203
+ symbol: str | None
204
+ timeframe: str | None
205
+ period_days: int | None
206
+ initial_balance: float | None
207
+ fee: float | None
208
+ execution: ExecutionExtraction | None = None
209
+ evaluation_end: AwareDatetime | None
210
+ train_ratio: float | None
211
+ base_strategy: RuleStrategy
212
+ parameters: list[ParameterSpec] = Field(min_length=1, max_length=3)
213
+
214
+ def validated_config(self) -> SensitivityConfig:
215
+ return SensitivityConfig.model_validate(self.model_dump(exclude_none=True))
216
+
217
+
218
+ class SensitivityParserOutput(StrictModel):
219
+ configuration: SensitivityExtraction | None
220
+ rejection: Literal["unsupported", "ambiguous", "missing_symbol", "invalid_grid"] | None
221
+
222
+ @model_validator(mode="after")
223
+ def one_outcome(self) -> "SensitivityParserOutput":
224
+ if (self.configuration is None) == (self.rejection is None):
225
+ raise ValueError("parser must return either configuration or rejection")
226
+ return self
227
+
228
+
229
+ class WindowExtraction(StrictModel):
230
+ mode: Literal["rolling", "expanding"] | None
231
+ train_days: int
232
+ test_days: int
233
+ step_days: int | None
234
+ max_folds: int | None
235
+
236
+
237
+ class WalkForwardExtraction(StrictModel):
238
+ symbol: str | None
239
+ timeframe: str | None
240
+ period_days: int | None
241
+ initial_balance: float | None
242
+ fee: float | None
243
+ execution: ExecutionExtraction | None
244
+ strategies: list[ComparisonStrategyExtraction] = Field(min_length=1, max_length=5)
245
+ evaluation_end: AwareDatetime | None
246
+ windows: WindowExtraction
247
+ stitch_oos: bool | None
248
+
249
+ def validated_config(self) -> WalkForwardConfig:
250
+ values = self.model_dump(exclude_none=True)
251
+ values["strategies"] = [
252
+ {"id": f"strategy-{i}", **item.model_dump()}
253
+ for i, item in enumerate(self.strategies, start=1)
254
+ ]
255
+ return WalkForwardConfig.model_validate(values)
256
+
257
+
258
+ class WalkForwardParserOutput(StrictModel):
259
+ configuration: WalkForwardExtraction | None
260
+ rejection: (
261
+ Literal[
262
+ "unsupported", "ambiguous", "missing_symbol", "inconsistent_settings", "strategy_count"
263
+ ]
264
+ | None
265
+ )
266
+
267
+ @model_validator(mode="after")
268
+ def one_outcome(self) -> "WalkForwardParserOutput":
269
+ if (self.configuration is None) == (self.rejection is None):
270
+ raise ValueError("parser must return either configuration or rejection")
271
+ return self
@@ -0,0 +1,290 @@
1
+ """OpenAI Responses boundary; no prices, indicators, or result calculations."""
2
+
3
+ import os
4
+ import re
5
+ from collections.abc import Callable
6
+ from decimal import Decimal
7
+ from typing import TypeVar
8
+
9
+ from openai import (
10
+ APIConnectionError,
11
+ APIError,
12
+ APITimeoutError,
13
+ AuthenticationError,
14
+ OpenAI,
15
+ OpenAIError,
16
+ RateLimitError,
17
+ )
18
+ from pydantic import BaseModel, ValidationError
19
+
20
+ from strategy_backtest_agent.ai.models import (
21
+ AIStrategyConfig,
22
+ ComparisonParserOutput,
23
+ ParserOutput,
24
+ SensitivityParserOutput,
25
+ ValidationParserOutput,
26
+ WalkForwardParserOutput,
27
+ )
28
+ from strategy_backtest_agent.ai.prompts import (
29
+ COMPARISON_PROMPT,
30
+ SENSITIVITY_PROMPT,
31
+ SYSTEM_PROMPT,
32
+ VALIDATION_PROMPT,
33
+ WALK_FORWARD_PROMPT,
34
+ )
35
+ from strategy_backtest_agent.comparison.models import ComparisonConfig
36
+ from strategy_backtest_agent.sensitivity.models import SensitivityConfig
37
+ from strategy_backtest_agent.validation.models import ValidationConfig
38
+ from strategy_backtest_agent.walk_forward.models import WalkForwardConfig
39
+
40
+ DEFAULT_MODEL = "gpt-4.1-mini"
41
+
42
+
43
+ class StrategyParseError(ValueError):
44
+ """A request could not be translated into a supported validated strategy."""
45
+
46
+
47
+ # Conservative early rejection of explicit unsupported concepts, not JSON extraction.
48
+ # The structured model rejection also handles semantics, ambiguity and multiple symbols.
49
+ _UNSUPPORTED = (
50
+ (r"\bichimoku\b", "Ichimoku"),
51
+ (r"\bleverage\b|\b\d+(?:\.\d+)?\s*x\b", "leverage"),
52
+ (r"\bshort(?:s|[- ]selling|\s+position)?\b", "short selling"),
53
+ (r"\bstop[- ]?loss\b", "stop loss"),
54
+ (r"\btake[- ]?profit\b", "take profit"),
55
+ (
56
+ r"\bcross(?:es|ing|over|under)?\b",
57
+ "crossover/crossunder events",
58
+ ),
59
+ (r"\bportfolio\b", "portfolio strategies"),
60
+ (r"\blive\s+trad(?:e|ing)\b", "live trading"),
61
+ (r"\bpredict(?:ion|ions|ing)?\b", "price prediction"),
62
+ )
63
+
64
+
65
+ def create_client() -> OpenAI:
66
+ """Read credentials only from the environment; bound requests and disable retries."""
67
+ key = os.environ.get("OPENAI_API_KEY", "").strip()
68
+ if not key:
69
+ raise StrategyParseError("OPENAI_API_KEY is missing; export it before using ai")
70
+ return OpenAI(api_key=key, base_url="https://api.openai.com/v1", timeout=30, max_retries=0)
71
+
72
+
73
+ OutputT = TypeVar("OutputT", bound=BaseModel)
74
+ ConfigT = TypeVar(
75
+ "ConfigT",
76
+ AIStrategyConfig,
77
+ ComparisonConfig,
78
+ ValidationConfig,
79
+ SensitivityConfig,
80
+ WalkForwardConfig,
81
+ )
82
+
83
+
84
+ def _strategy_configuration(output: ParserOutput) -> AIStrategyConfig:
85
+ if output.rejection is not None:
86
+ raise StrategyParseError(
87
+ f"Request rejected: {output.rejection.replace('_', ' ')}. "
88
+ "Currently supported: long-only RSI, SMA, EMA, MACD, Bollinger and ATR level rules"
89
+ )
90
+ assert output.configuration is not None
91
+ return output.configuration.validated_config()
92
+
93
+
94
+ def _comparison_configuration(output: ComparisonParserOutput) -> ComparisonConfig:
95
+ if output.rejection is not None:
96
+ raise StrategyParseError(f"Request rejected: {output.rejection.replace('_', ' ')}")
97
+ assert output.configuration is not None
98
+ return output.configuration.validated_config()
99
+
100
+
101
+ def parse_strategy(text: str) -> AIStrategyConfig:
102
+ """Extract a supported single-strategy request with the existing defaults."""
103
+ return _parse_request(text, ParserOutput, SYSTEM_PROMPT, _strategy_configuration)
104
+
105
+
106
+ def parse_comparison(text: str) -> ComparisonConfig:
107
+ """One structured request for all comparison rules and shared settings."""
108
+ return _parse_request(
109
+ text,
110
+ ComparisonParserOutput,
111
+ COMPARISON_PROMPT,
112
+ _comparison_configuration,
113
+ max_output_tokens=16384,
114
+ )
115
+
116
+
117
+ def _validation_configuration(output: ValidationParserOutput) -> ValidationConfig:
118
+ if output.rejection is not None:
119
+ raise StrategyParseError(f"Request rejected: {output.rejection.replace('_', ' ')}")
120
+ assert output.configuration is not None
121
+ return output.configuration.validated_config()
122
+
123
+
124
+ def parse_validation(text: str) -> ValidationConfig:
125
+ """One extraction request; holdout execution and results remain entirely in Python."""
126
+ if re.search(
127
+ r"\b(?:random|shuffl\w*|optimi[sz]\w*|tun(?:e|ing)|walk[- ]forward|folds?)\b"
128
+ r"|\brobustness\s+score\b|\b(?:select|choose|pick|winner|best)\b",
129
+ text,
130
+ re.IGNORECASE,
131
+ ):
132
+ raise StrategyParseError(
133
+ "Unsupported validation: optimization, selection or nonchronological split"
134
+ )
135
+ return _parse_request(
136
+ text,
137
+ ValidationParserOutput,
138
+ VALIDATION_PROMPT,
139
+ _validation_configuration,
140
+ max_output_tokens=16384,
141
+ )
142
+
143
+
144
+ def _sensitivity_configuration(output: SensitivityParserOutput) -> SensitivityConfig:
145
+ if output.rejection is not None:
146
+ raise StrategyParseError(f"Request rejected: {output.rejection.replace('_', ' ')}")
147
+ assert output.configuration is not None
148
+ return output.configuration.validated_config()
149
+
150
+
151
+ def parse_sensitivity(text: str) -> SensitivityConfig:
152
+ """Extract one bounded grid; deterministic Python expands and validates every variant."""
153
+ if re.search(
154
+ r"\b(?:random|shuffl\w*|optimi[sz]\w*|tun(?:e|ing)|walk[- ]forward|folds?)\b"
155
+ r"|\brobustness\s+score\b|\b(?:select|choose|pick|winner|best)\b"
156
+ r"|\b(?:evaluate|backtest|run|rank)\b.{0,40}\btest\b",
157
+ text,
158
+ re.IGNORECASE,
159
+ ):
160
+ raise StrategyParseError(
161
+ "Unsupported sensitivity: optimization, selection or Test evaluation"
162
+ )
163
+ return _parse_request(
164
+ text,
165
+ SensitivityParserOutput,
166
+ SENSITIVITY_PROMPT,
167
+ _sensitivity_configuration,
168
+ max_output_tokens=16384,
169
+ )
170
+
171
+
172
+ def _verify_execution_assumptions(text: str, configuration: ConfigT) -> None:
173
+ """Never invent or silently omit named spread/slippage values supplied in text."""
174
+ number = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
175
+ execution = configuration.execution_config()
176
+ for label, value in (("spread", execution.spread_bps), ("slippage", execution.slippage_bps)):
177
+ values: set[Decimal] = set()
178
+ patterns = (
179
+ rf"\b{label}\b\s*(?:of\s*)?(?:[:=]\s*)?({number})\s*(bps|basis\s+points|%)",
180
+ rf"({number})\s*(bps|basis\s+points|%)\s*(?:full\s+)?{label}\b",
181
+ )
182
+ for pattern in patterns:
183
+ for amount, unit in re.findall(pattern, text, re.IGNORECASE):
184
+ values.add(Decimal(amount) * (100 if unit == "%" else 1))
185
+ for amount in re.findall(rf"\b{label}_bps\s*[:=]\s*({number})", text, re.IGNORECASE):
186
+ values.add(Decimal(amount))
187
+ if len(values) > 1:
188
+ raise StrategyParseError(f"Ambiguous execution: conflicting {label} values")
189
+ if values and Decimal(str(value)) not in values:
190
+ raise StrategyParseError(f"Parsed {label} must match the explicit execution assumption")
191
+ if not values and value != 0:
192
+ raise StrategyParseError(
193
+ f"Explicit {label} in bps or percent is required; never estimate costs"
194
+ )
195
+
196
+
197
+ def _parse_request(
198
+ text: str,
199
+ schema: type[OutputT],
200
+ instructions: str,
201
+ convert: Callable[[OutputT], ConfigT],
202
+ *,
203
+ max_output_tokens: int = 4096,
204
+ ) -> ConfigT:
205
+ """Shared SDK boundary, unsupported checks, validation and sanitized errors."""
206
+ if not text.strip():
207
+ raise StrategyParseError("request must not be empty")
208
+ if len(text) > 8000:
209
+ raise StrategyParseError("request must be at most 8000 characters")
210
+ for pattern, label in _UNSUPPORTED:
211
+ if re.search(pattern, text, re.IGNORECASE):
212
+ raise StrategyParseError(
213
+ f"Unsupported strategy: {label}. "
214
+ "Currently supported: long-only RSI, SMA, EMA, MACD, Bollinger and ATR level rules"
215
+ )
216
+ model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL).strip()
217
+ if not model:
218
+ raise StrategyParseError("OPENAI_MODEL must not be empty")
219
+ try:
220
+ with create_client() as client:
221
+ response = client.responses.parse(
222
+ model=model,
223
+ instructions=instructions,
224
+ input=text,
225
+ text_format=schema,
226
+ max_output_tokens=max_output_tokens,
227
+ store=False,
228
+ )
229
+ if response.status != "completed" or response.output_parsed is None:
230
+ raise StrategyParseError(
231
+ "AI did not return a complete structured strategy (or refused)"
232
+ )
233
+ # Revalidate even an SDK model instance; constructed objects must not bypass validation.
234
+ output = schema.model_validate(response.output_parsed.model_dump(warnings=False))
235
+ configuration = convert(output)
236
+ _verify_execution_assumptions(text, configuration)
237
+ if not re.search(
238
+ rf"(?<![A-Za-z0-9]){re.escape(configuration.symbol)}(?![A-Za-z0-9])",
239
+ text,
240
+ re.IGNORECASE,
241
+ ):
242
+ raise StrategyParseError("Parsed symbol must be explicitly present in the request")
243
+ return configuration
244
+ except AuthenticationError:
245
+ raise StrategyParseError("OpenAI authentication failed; check OPENAI_API_KEY") from None
246
+ except RateLimitError:
247
+ raise StrategyParseError("OpenAI rate limit or quota reached; retry later") from None
248
+ except APITimeoutError:
249
+ raise StrategyParseError("OpenAI request timed out; retry later") from None
250
+ except APIConnectionError:
251
+ raise StrategyParseError("OpenAI is unavailable; check your network") from None
252
+ except APIError:
253
+ raise StrategyParseError(
254
+ "OpenAI request failed; check model access and service status"
255
+ ) from None
256
+ except ValidationError as exc:
257
+ messages = "; ".join(
258
+ f"{'.'.join(str(p) for p in error['loc']) or 'configuration'}: {error['msg']}"
259
+ for error in exc.errors()
260
+ )
261
+ raise StrategyParseError(f"Invalid parsed strategy: {messages}") from None
262
+ except (OpenAIError, TypeError, AttributeError, ValueError) as exc:
263
+ if isinstance(exc, StrategyParseError):
264
+ raise
265
+ raise StrategyParseError("AI structured output could not be validated") from None
266
+
267
+
268
+ def _walk_forward_configuration(output: WalkForwardParserOutput) -> WalkForwardConfig:
269
+ if output.rejection is not None:
270
+ raise StrategyParseError(f"Request rejected: {output.rejection.replace('_', ' ')}")
271
+ assert output.configuration is not None
272
+ return output.configuration.validated_config()
273
+
274
+
275
+ def parse_walk_forward(text: str) -> WalkForwardConfig:
276
+ """Extract fixed rules and protocol only; all financial calculations stay in Python."""
277
+ if re.search(
278
+ r"\b(?:random|shuffl\w*|optimi[sz]\w*|tun(?:e|ing)|adaptive|select|choose|pick|winner|best)\b"
279
+ r"|\brobustness\s+score\b",
280
+ text,
281
+ re.IGNORECASE,
282
+ ):
283
+ raise StrategyParseError("Unsupported walk-forward: optimization or strategy selection")
284
+ return _parse_request(
285
+ text,
286
+ WalkForwardParserOutput,
287
+ WALK_FORWARD_PROMPT,
288
+ _walk_forward_configuration,
289
+ max_output_tokens=16384,
290
+ )