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.
- strategy_backtest_agent/__init__.py +1 -0
- strategy_backtest_agent/ai/__init__.py +1 -0
- strategy_backtest_agent/ai/models.py +271 -0
- strategy_backtest_agent/ai/parser.py +290 -0
- strategy_backtest_agent/ai/prompts.py +162 -0
- strategy_backtest_agent/backtester.py +159 -0
- strategy_backtest_agent/cli.py +347 -0
- strategy_backtest_agent/comparison/__init__.py +9 -0
- strategy_backtest_agent/comparison/cli.py +69 -0
- strategy_backtest_agent/comparison/engine.py +203 -0
- strategy_backtest_agent/comparison/formatter.py +71 -0
- strategy_backtest_agent/comparison/models.py +148 -0
- strategy_backtest_agent/costs/__init__.py +1 -0
- strategy_backtest_agent/costs/cli.py +35 -0
- strategy_backtest_agent/costs/engine.py +103 -0
- strategy_backtest_agent/costs/formatter.py +39 -0
- strategy_backtest_agent/costs/models.py +81 -0
- strategy_backtest_agent/demo.py +56 -0
- strategy_backtest_agent/doctor.py +215 -0
- strategy_backtest_agent/execution.py +86 -0
- strategy_backtest_agent/execution_cli.py +34 -0
- strategy_backtest_agent/indicators.py +137 -0
- strategy_backtest_agent/init_cli.py +87 -0
- strategy_backtest_agent/market_data.py +103 -0
- strategy_backtest_agent/metrics.py +51 -0
- strategy_backtest_agent/models.py +179 -0
- strategy_backtest_agent/reports/__init__.py +1 -0
- strategy_backtest_agent/reports/ai.py +212 -0
- strategy_backtest_agent/reports/cli.py +102 -0
- strategy_backtest_agent/reports/evidence.py +572 -0
- strategy_backtest_agent/reports/inputs.py +224 -0
- strategy_backtest_agent/reports/models.py +147 -0
- strategy_backtest_agent/reports/render.py +457 -0
- strategy_backtest_agent/reports/validation.py +712 -0
- strategy_backtest_agent/rules/__init__.py +5 -0
- strategy_backtest_agent/rules/evaluator.py +160 -0
- strategy_backtest_agent/rules/formatter.py +33 -0
- strategy_backtest_agent/rules/history.py +82 -0
- strategy_backtest_agent/rules/indicator_specs.py +97 -0
- strategy_backtest_agent/rules/models.py +105 -0
- strategy_backtest_agent/sensitivity/__init__.py +1 -0
- strategy_backtest_agent/sensitivity/analysis.py +131 -0
- strategy_backtest_agent/sensitivity/cache.py +27 -0
- strategy_backtest_agent/sensitivity/cli.py +49 -0
- strategy_backtest_agent/sensitivity/engine.py +110 -0
- strategy_backtest_agent/sensitivity/formatter.py +132 -0
- strategy_backtest_agent/sensitivity/grid.py +245 -0
- strategy_backtest_agent/sensitivity/models.py +180 -0
- strategy_backtest_agent/strategy.py +39 -0
- strategy_backtest_agent/validation/__init__.py +1 -0
- strategy_backtest_agent/validation/cli.py +64 -0
- strategy_backtest_agent/validation/engine.py +125 -0
- strategy_backtest_agent/validation/formatter.py +63 -0
- strategy_backtest_agent/validation/models.py +76 -0
- strategy_backtest_agent/walk_forward/__init__.py +1 -0
- strategy_backtest_agent/walk_forward/cli.py +66 -0
- strategy_backtest_agent/walk_forward/engine.py +188 -0
- strategy_backtest_agent/walk_forward/formatter.py +78 -0
- strategy_backtest_agent/walk_forward/models.py +136 -0
- strategy_backtest_agent-1.1.0.dist-info/METADATA +525 -0
- strategy_backtest_agent-1.1.0.dist-info/RECORD +64 -0
- strategy_backtest_agent-1.1.0.dist-info/WHEEL +4 -0
- strategy_backtest_agent-1.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|