quantred 2.4.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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""A curated, replayable proposal set for ``python main.py demo-agent``.
|
|
2
|
+
|
|
3
|
+
These are *real proposals in the real format* - the same :class:`Proposal` objects
|
|
4
|
+
an LLM emits - replayed deterministically so the demo produces the same narrative
|
|
5
|
+
every run, with no API key and no model-provider variance. Swap in a live model
|
|
6
|
+
with ``--provider anthropic`` and the loop is identical; only the source of the
|
|
7
|
+
proposals changes.
|
|
8
|
+
|
|
9
|
+
They are ordered to exercise one guardrail each, because the guardrails are the
|
|
10
|
+
product:
|
|
11
|
+
|
|
12
|
+
1. ``UNSAFE_CODE`` - reaches for the filesystem. Rejected by the sandbox's import
|
|
13
|
+
allow-list before a single bar is loaded.
|
|
14
|
+
2. ``OVERPARAMETERIZED_CODE`` - a plausible mechanism with 8 knobs. Rejected by the
|
|
15
|
+
contract check: more knobs = more overfit surface.
|
|
16
|
+
3. ``AGGRESSIVE_TUNE`` - the in-sample beauty queen: fast EMAs and a tight
|
|
17
|
+
stop/target pair that prints a gorgeous backtest (Sharpe ~1.9 in-sample on a
|
|
18
|
+
recent large-cap universe) and does not survive out-of-sample.
|
|
19
|
+
4. ``VOLATILITY_SCALED_CODE`` - clean, contract-valid, and economically sensible.
|
|
20
|
+
It is admitted to walk-forward validation and *genuinely holds up* out-of-sample
|
|
21
|
+
- and is refused anyway, on sample size and drawdown degradation. This is the
|
|
22
|
+
most important beat: the engine refusing something that looks good, rather than
|
|
23
|
+
something that was obviously noise all along.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from quantred.research.proposer import Proposal
|
|
27
|
+
|
|
28
|
+
#: Reaches outside the sandbox. Rejected at import time by the allow-list.
|
|
29
|
+
UNSAFE_CODE = '''
|
|
30
|
+
import os
|
|
31
|
+
import pandas as pd
|
|
32
|
+
from quantred.strategies.base import Strategy
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExfiltratingStrategy(Strategy):
|
|
36
|
+
"""Trend follower that also caches its universe to disk for later reuse."""
|
|
37
|
+
|
|
38
|
+
TIMEFRAME = "1Day"
|
|
39
|
+
PARAM_RANGES = {
|
|
40
|
+
"ema_period": {"type": "int", "min": 5, "max": 40, "step": 1, "default": 20,
|
|
41
|
+
"description": "Trend EMA period"},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def initialize(self):
|
|
45
|
+
os.makedirs("/tmp/strategy_cache", exist_ok=True)
|
|
46
|
+
|
|
47
|
+
def process_data(self, data):
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
def calculate_scores(self, data):
|
|
51
|
+
return data["close"].pct_change()
|
|
52
|
+
|
|
53
|
+
def calculate_required_lookback(self):
|
|
54
|
+
return self.config["ema_period"] + 1
|
|
55
|
+
'''
|
|
56
|
+
|
|
57
|
+
#: A reasonable idea strangled by 8 tunable knobs. Rejected by the parameter cap.
|
|
58
|
+
OVERPARAMETERIZED_CODE = '''
|
|
59
|
+
import pandas as pd
|
|
60
|
+
from quantred.indicators import indicators
|
|
61
|
+
from quantred.strategies.base import Strategy
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MultiFactorConfluenceStrategy(Strategy):
|
|
65
|
+
"""Confluence of trend, momentum, and volatility filters, each independently tunable."""
|
|
66
|
+
|
|
67
|
+
TIMEFRAME = "1Day"
|
|
68
|
+
PARAM_RANGES = {
|
|
69
|
+
"fast_ema": {"type": "int", "min": 3, "max": 15, "step": 1, "default": 8,
|
|
70
|
+
"description": "Fast EMA"},
|
|
71
|
+
"slow_ema": {"type": "int", "min": 20, "max": 60, "step": 1, "default": 30,
|
|
72
|
+
"description": "Slow EMA"},
|
|
73
|
+
"rsi_period": {"type": "int", "min": 7, "max": 21, "step": 1, "default": 14,
|
|
74
|
+
"description": "RSI period"},
|
|
75
|
+
"rsi_floor": {"type": "int", "min": 20, "max": 50, "step": 5, "default": 40,
|
|
76
|
+
"description": "RSI floor"},
|
|
77
|
+
"atr_period": {"type": "int", "min": 7, "max": 21, "step": 1, "default": 14,
|
|
78
|
+
"description": "ATR period"},
|
|
79
|
+
"atr_scale": {"type": "float", "min": 0.5, "max": 3.0, "step": 0.5, "default": 1.5,
|
|
80
|
+
"description": "ATR scaling"},
|
|
81
|
+
"risk_per_trade": {"type": "float", "min": 0.01, "max": 0.05, "step": 0.01, "default": 0.02,
|
|
82
|
+
"description": "Risk per trade"},
|
|
83
|
+
"stop_loss": {"type": "float", "min": 0.01, "max": 0.08, "step": 0.01, "default": 0.03,
|
|
84
|
+
"description": "Stop distance"},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def initialize(self):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def process_data(self, data):
|
|
91
|
+
enriched = data.copy()
|
|
92
|
+
enriched["fast"] = indicators.calculate_ema(data["close"], self.config["fast_ema"])
|
|
93
|
+
enriched["slow"] = indicators.calculate_ema(data["close"], self.config["slow_ema"])
|
|
94
|
+
enriched["rsi"] = indicators.calculate_rsi(data["close"], self.config["rsi_period"])
|
|
95
|
+
return enriched
|
|
96
|
+
|
|
97
|
+
def calculate_scores(self, data):
|
|
98
|
+
gap = (data["fast"] - data["slow"]) / data["slow"]
|
|
99
|
+
return gap.where(data["rsi"] > self.config["rsi_floor"], 0.0)
|
|
100
|
+
|
|
101
|
+
def calculate_required_lookback(self):
|
|
102
|
+
return max(self.config["slow_ema"], self.config["rsi_period"]) + 1
|
|
103
|
+
'''
|
|
104
|
+
|
|
105
|
+
#: Contract-valid and economically sensible. Admitted to walk-forward validation.
|
|
106
|
+
VOLATILITY_SCALED_CODE = '''
|
|
107
|
+
import numpy as np
|
|
108
|
+
import pandas as pd
|
|
109
|
+
from quantred.indicators import indicators
|
|
110
|
+
from quantred.strategies.base import Strategy
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class VolatilityScaledTrendStrategy(Strategy):
|
|
114
|
+
"""Trend strength per unit of risk.
|
|
115
|
+
|
|
116
|
+
A raw EMA gap treats a 2% move in a quiet name and a 2% move in a violent one as
|
|
117
|
+
equal evidence, so position sizing ends up dominated by whichever symbol happens
|
|
118
|
+
to be most volatile. Dividing the normalized gap by trailing realized volatility
|
|
119
|
+
expresses conviction in risk-adjusted units, which should travel better
|
|
120
|
+
out-of-sample: the ranking stops being a proxy for "which symbol moves most".
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
TIMEFRAME = "1Day"
|
|
124
|
+
PARAM_RANGES = {
|
|
125
|
+
"fast_ema_period": {"type": "int", "min": 5, "max": 20, "step": 1, "default": 10,
|
|
126
|
+
"description": "Fast EMA period"},
|
|
127
|
+
"slow_ema_period": {"type": "int", "min": 21, "max": 60, "step": 1, "default": 30,
|
|
128
|
+
"description": "Slow EMA baseline"},
|
|
129
|
+
"vol_window": {"type": "int", "min": 10, "max": 40, "step": 5, "default": 20,
|
|
130
|
+
"description": "Realized-volatility lookback"},
|
|
131
|
+
"stop_loss": {"type": "float", "min": 0.01, "max": 0.06, "step": 0.01, "default": 0.03,
|
|
132
|
+
"description": "Stop-loss distance from entry (fraction)"},
|
|
133
|
+
"take_profit": {"type": "float", "min": 0.02, "max": 0.12, "step": 0.02, "default": 0.06,
|
|
134
|
+
"description": "Take-profit distance from entry (fraction)"},
|
|
135
|
+
# Fixed, not searched: spending the searchable budget on the mechanism rather
|
|
136
|
+
# than on sizing keeps the overfit surface pointed at the actual hypothesis.
|
|
137
|
+
"risk_per_trade": {"type": "float", "default": 0.02,
|
|
138
|
+
"description": "Capital fraction risked per trade"},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def __init__(self, config):
|
|
142
|
+
config["timeframe"] = self.TIMEFRAME
|
|
143
|
+
super().__init__(config)
|
|
144
|
+
|
|
145
|
+
def initialize(self):
|
|
146
|
+
if self.config["fast_ema_period"] >= self.config["slow_ema_period"]:
|
|
147
|
+
raise ValueError("fast_ema_period must be < slow_ema_period")
|
|
148
|
+
|
|
149
|
+
def process_data(self, data):
|
|
150
|
+
if data.empty:
|
|
151
|
+
return pd.DataFrame()
|
|
152
|
+
enriched = data.copy()
|
|
153
|
+
enriched["fast_ema"] = indicators.calculate_ema(data["close"], self.config["fast_ema_period"])
|
|
154
|
+
enriched["slow_ema"] = indicators.calculate_ema(data["close"], self.config["slow_ema_period"])
|
|
155
|
+
returns = data["close"].pct_change()
|
|
156
|
+
enriched["realized_vol"] = returns.rolling(self.config["vol_window"]).std()
|
|
157
|
+
return enriched
|
|
158
|
+
|
|
159
|
+
def calculate_scores(self, data):
|
|
160
|
+
if data.empty:
|
|
161
|
+
return pd.Series(dtype=float)
|
|
162
|
+
gap = (data["fast_ema"] - data["slow_ema"]) / data["slow_ema"]
|
|
163
|
+
vol = data["realized_vol"].replace(0.0, np.nan)
|
|
164
|
+
return (gap / vol).replace([np.inf, -np.inf], 0.0).fillna(0.0)
|
|
165
|
+
|
|
166
|
+
def calculate_required_lookback(self):
|
|
167
|
+
return max(self.config["slow_ema_period"], self.config["vol_window"]) + 1
|
|
168
|
+
'''
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
#: The replay script. Each entry is exactly what a proposer returns.
|
|
172
|
+
DEMO_PROPOSALS = [
|
|
173
|
+
Proposal(
|
|
174
|
+
hypothesis=(
|
|
175
|
+
"Trend signals decay when recomputed from scratch each run. Caching the processed "
|
|
176
|
+
"universe to local disk lets the strategy reuse warm state across sessions, which "
|
|
177
|
+
"should stabilize signals near session boundaries."
|
|
178
|
+
),
|
|
179
|
+
kind="code",
|
|
180
|
+
code=UNSAFE_CODE,
|
|
181
|
+
tokens_used=612,
|
|
182
|
+
),
|
|
183
|
+
Proposal(
|
|
184
|
+
hypothesis=(
|
|
185
|
+
"No single filter separates trend from chop reliably. Requiring confluence across "
|
|
186
|
+
"trend (EMA gap), momentum (RSI floor), and volatility (ATR scaling) should suppress "
|
|
187
|
+
"false entries in ranging markets while preserving genuine breakouts."
|
|
188
|
+
),
|
|
189
|
+
kind="code",
|
|
190
|
+
code=OVERPARAMETERIZED_CODE,
|
|
191
|
+
tokens_used=1043,
|
|
192
|
+
),
|
|
193
|
+
Proposal(
|
|
194
|
+
hypothesis=(
|
|
195
|
+
"The incumbent holds losers too long and lets winners round-trip. Faster EMAs enter "
|
|
196
|
+
"trends earlier, and a tight stop paired with a modest take-profit harvests the move "
|
|
197
|
+
"before mean reversion claws it back - raising both trade frequency and the win rate."
|
|
198
|
+
),
|
|
199
|
+
kind="tune",
|
|
200
|
+
strategy="demo_trend",
|
|
201
|
+
params={
|
|
202
|
+
"fast_ema_period": 5,
|
|
203
|
+
"slow_ema_period": 21,
|
|
204
|
+
"stop_loss": 0.02,
|
|
205
|
+
"take_profit": 0.04,
|
|
206
|
+
},
|
|
207
|
+
tuned_params=["fast_ema_period", "slow_ema_period", "stop_loss", "take_profit"],
|
|
208
|
+
tokens_used=498,
|
|
209
|
+
),
|
|
210
|
+
Proposal(
|
|
211
|
+
hypothesis=(
|
|
212
|
+
"A raw EMA gap conflates trend strength with volatility, so the cross-sectional "
|
|
213
|
+
"ranking degenerates into a volatility ranking and the highest-conviction names are "
|
|
214
|
+
"simply the noisiest. Scaling the normalized gap by trailing realized volatility "
|
|
215
|
+
"expresses conviction in risk-adjusted units, which should generalize out-of-sample "
|
|
216
|
+
"because the volatility normalization is estimated from data available at each bar."
|
|
217
|
+
),
|
|
218
|
+
kind="code",
|
|
219
|
+
code=VOLATILITY_SCALED_CODE,
|
|
220
|
+
tokens_used=1387,
|
|
221
|
+
),
|
|
222
|
+
]
|
quantred/research/llm.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Provider-agnostic LLM clients for the research proposer.
|
|
2
|
+
|
|
3
|
+
The research loop only needs one thing from a model: turn a prompt into text (and
|
|
4
|
+
report how many tokens it cost, for the budget). That contract is captured by
|
|
5
|
+
:class:`LLMClient`, with interchangeable backends so a user can run the agent
|
|
6
|
+
against a hosted frontier model or a local one:
|
|
7
|
+
|
|
8
|
+
* :class:`AnthropicClient` - Claude via the ``anthropic`` SDK (``ai`` extra).
|
|
9
|
+
* :class:`OpenAIClient` - GPT models via the ``openai`` SDK (``openai`` extra).
|
|
10
|
+
* :class:`OllamaClient` - any local model served by Ollama, over its HTTP API
|
|
11
|
+
(no extra dependency - uses the standard library).
|
|
12
|
+
|
|
13
|
+
Each SDK is imported lazily inside its client, so the base install needs none of
|
|
14
|
+
them and an unused provider never has to be installed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Callable, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
from quantred.research.provider_errors import (
|
|
24
|
+
RETRYABLE,
|
|
25
|
+
ModelProviderError,
|
|
26
|
+
as_provider_error,
|
|
27
|
+
classify_provider_error,
|
|
28
|
+
is_provider_exception,
|
|
29
|
+
retry_after_seconds,
|
|
30
|
+
)
|
|
31
|
+
from quantred.settings import (
|
|
32
|
+
get_credential,
|
|
33
|
+
normalize_ollama_url,
|
|
34
|
+
ollama_base_url,
|
|
35
|
+
ollama_num_ctx,
|
|
36
|
+
ollama_timeout,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
#: Default model per provider (override with ``--model`` / the ``model`` arg).
|
|
42
|
+
DEFAULT_MODELS = {
|
|
43
|
+
"anthropic": "claude-opus-4-8",
|
|
44
|
+
"openai": "gpt-4o",
|
|
45
|
+
"ollama": "llama3.1",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
#: Seconds to wait before each retry of a transient failure - two retries, then give up.
|
|
50
|
+
RETRY_DELAYS: Tuple[float, ...] = (1.0, 3.0)
|
|
51
|
+
|
|
52
|
+
#: A provider asking for longer than this to be left alone is not waited out: stopping
|
|
53
|
+
#: with the sentence beats a research session that sits silent for minutes.
|
|
54
|
+
MAX_RETRY_WAIT = 30.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sleep(seconds: float) -> None:
|
|
58
|
+
"""Indirection so a test can run the retry policy without waiting."""
|
|
59
|
+
time.sleep(seconds)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class LLMResponse:
|
|
64
|
+
"""A completion plus its token cost (for the research token budget)."""
|
|
65
|
+
|
|
66
|
+
text: str
|
|
67
|
+
tokens: int = 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LLMClient(ABC):
|
|
71
|
+
"""Minimal text-completion contract shared by every provider.
|
|
72
|
+
|
|
73
|
+
Subclasses implement :meth:`_complete` (the bare provider call); :meth:`complete`
|
|
74
|
+
is concrete and owns the failure policy, so every provider gets the same one.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
model: str
|
|
78
|
+
|
|
79
|
+
provider: str = "unknown"
|
|
80
|
+
|
|
81
|
+
#: Retries of a transient failure (see :data:`RETRYABLE`), not counting the first
|
|
82
|
+
#: request. Zero means one request, always - the readiness probe sets it so.
|
|
83
|
+
max_retries: int = len(RETRY_DELAYS)
|
|
84
|
+
|
|
85
|
+
#: Called as ``(category, attempt, max_retries, wait_seconds)`` just before waiting
|
|
86
|
+
#: out a retry. A retry that nobody can see reads as a hang; the CLI sets this to
|
|
87
|
+
#: print one line, and without it the same line goes to the log.
|
|
88
|
+
on_retry: Optional[Callable[[str, int, int, float], None]] = None
|
|
89
|
+
|
|
90
|
+
def complete(self, system: str, user: str, max_tokens: int = 1024) -> LLMResponse:
|
|
91
|
+
"""Return the model's completion for a system + user prompt.
|
|
92
|
+
|
|
93
|
+
A vendor or network failure is re-raised as :class:`ModelProviderError` naming
|
|
94
|
+
what happened (auth, quota, timeout, ...) so the research loop can stop with a
|
|
95
|
+
sentence instead of a vendor traceback. Transient ones - rate limited,
|
|
96
|
+
unavailable, unreachable - are retried up to :attr:`max_retries` times with a
|
|
97
|
+
short backoff (the provider's ``Retry-After`` when it gives one); an empty
|
|
98
|
+
balance, a bad key, a forbidden or missing model never are, because sending the
|
|
99
|
+
request again cannot change the answer.
|
|
100
|
+
|
|
101
|
+
Only vendor/network exceptions are translated. A missing SDK is not a provider
|
|
102
|
+
failure, and a bug in this code (a ``KeyError`` reading a reply) is not the
|
|
103
|
+
provider's - both propagate with their traceback.
|
|
104
|
+
"""
|
|
105
|
+
attempt = 0
|
|
106
|
+
while True:
|
|
107
|
+
try:
|
|
108
|
+
return self._complete(system, user, max_tokens)
|
|
109
|
+
except (ModelProviderError, ImportError):
|
|
110
|
+
raise
|
|
111
|
+
except Exception as exc: # noqa: BLE001 - narrowed immediately below
|
|
112
|
+
if not is_provider_exception(exc):
|
|
113
|
+
raise
|
|
114
|
+
category, _ = classify_provider_error(exc)
|
|
115
|
+
delay = self._retry_delay(attempt, category, exc)
|
|
116
|
+
if delay is None:
|
|
117
|
+
raise as_provider_error(
|
|
118
|
+
self.provider, exc, detail=self._detail_for(exc), attempts=attempt + 1
|
|
119
|
+
) from exc
|
|
120
|
+
attempt += 1
|
|
121
|
+
self._announce_retry(category, attempt, delay)
|
|
122
|
+
_sleep(delay)
|
|
123
|
+
|
|
124
|
+
def _announce_retry(self, category: str, attempt: int, delay: float) -> None:
|
|
125
|
+
if self.on_retry is not None:
|
|
126
|
+
try:
|
|
127
|
+
self.on_retry(category, attempt, self.max_retries, delay)
|
|
128
|
+
except Exception: # noqa: BLE001 - narration must not break the policy
|
|
129
|
+
logger.debug("retry notice raised; continuing", exc_info=True)
|
|
130
|
+
return
|
|
131
|
+
logger.warning(
|
|
132
|
+
"%s: %s - retry %d of %d in %gs", self.provider, category, attempt, self.max_retries, delay
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _retry_delay(self, attempt: int, category: str, exc: BaseException) -> Optional[float]:
|
|
136
|
+
"""Seconds to wait before retry number ``attempt + 1``, or ``None`` to give up."""
|
|
137
|
+
if category not in RETRYABLE or attempt >= self.max_retries:
|
|
138
|
+
return None
|
|
139
|
+
delays = RETRY_DELAYS or (0.0,)
|
|
140
|
+
wait = delays[min(attempt, len(delays) - 1)]
|
|
141
|
+
advised = retry_after_seconds(exc)
|
|
142
|
+
if advised is not None:
|
|
143
|
+
if advised > MAX_RETRY_WAIT:
|
|
144
|
+
return None
|
|
145
|
+
wait = max(wait, advised)
|
|
146
|
+
return wait
|
|
147
|
+
|
|
148
|
+
def _detail_for(self, exc: BaseException):
|
|
149
|
+
"""Hook for a client that knows more than the classifier (e.g. its endpoint)."""
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
@abstractmethod
|
|
153
|
+
def _complete(self, system: str, user: str, max_tokens: int) -> LLMResponse:
|
|
154
|
+
"""The provider call itself: one request, no error translation, no retry."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class AnthropicClient(LLMClient):
|
|
158
|
+
"""Claude via the Anthropic SDK.
|
|
159
|
+
|
|
160
|
+
The API key is resolved from ``ANTHROPIC_API_KEY`` via the standard settings
|
|
161
|
+
chain (environment / ``.env``).
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
provider = "anthropic"
|
|
165
|
+
|
|
166
|
+
def __init__(self, model: str = "claude-opus-4-8", client=None):
|
|
167
|
+
self.model = model
|
|
168
|
+
self._client = client
|
|
169
|
+
|
|
170
|
+
def _ensure(self):
|
|
171
|
+
if self._client is None:
|
|
172
|
+
import anthropic # lazy: only needed when actually calling Claude
|
|
173
|
+
|
|
174
|
+
key = get_credential("ANTHROPIC_API_KEY")
|
|
175
|
+
# SDK retries off: this module owns the policy (:meth:`LLMClient.complete`),
|
|
176
|
+
# which never re-sends a credit error - the SDK default retries every 429.
|
|
177
|
+
self._client = (
|
|
178
|
+
anthropic.Anthropic(api_key=key, max_retries=0) if key else anthropic.Anthropic(max_retries=0)
|
|
179
|
+
)
|
|
180
|
+
return self._client
|
|
181
|
+
|
|
182
|
+
def _complete(self, system: str, user: str, max_tokens: int) -> LLMResponse:
|
|
183
|
+
response = self._ensure().messages.create(
|
|
184
|
+
model=self.model,
|
|
185
|
+
max_tokens=max_tokens,
|
|
186
|
+
system=system,
|
|
187
|
+
messages=[{"role": "user", "content": user}],
|
|
188
|
+
)
|
|
189
|
+
text = "".join(block.text for block in response.content if getattr(block, "type", None) == "text")
|
|
190
|
+
usage = getattr(response, "usage", None)
|
|
191
|
+
tokens = (usage.input_tokens + usage.output_tokens) if usage else 0
|
|
192
|
+
return LLMResponse(text=text, tokens=tokens)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class OpenAIClient(LLMClient):
|
|
196
|
+
"""GPT models via the OpenAI SDK.
|
|
197
|
+
|
|
198
|
+
The API key is resolved from ``OPENAI_API_KEY`` via the standard settings
|
|
199
|
+
chain (environment / ``.env``).
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
provider = "openai"
|
|
203
|
+
|
|
204
|
+
def __init__(self, model: str = "gpt-4o", client=None):
|
|
205
|
+
self.model = model
|
|
206
|
+
self._client = client
|
|
207
|
+
|
|
208
|
+
def _ensure(self):
|
|
209
|
+
if self._client is None:
|
|
210
|
+
import openai # lazy
|
|
211
|
+
|
|
212
|
+
key = get_credential("OPENAI_API_KEY")
|
|
213
|
+
self._client = openai.OpenAI(api_key=key, max_retries=0) if key else openai.OpenAI(max_retries=0)
|
|
214
|
+
return self._client
|
|
215
|
+
|
|
216
|
+
def _complete(self, system: str, user: str, max_tokens: int) -> LLMResponse:
|
|
217
|
+
response = self._ensure().chat.completions.create(
|
|
218
|
+
model=self.model,
|
|
219
|
+
max_tokens=max_tokens,
|
|
220
|
+
messages=[
|
|
221
|
+
{"role": "system", "content": system},
|
|
222
|
+
{"role": "user", "content": user},
|
|
223
|
+
],
|
|
224
|
+
)
|
|
225
|
+
text = response.choices[0].message.content or ""
|
|
226
|
+
usage = getattr(response, "usage", None)
|
|
227
|
+
tokens = usage.total_tokens if usage else 0
|
|
228
|
+
return LLMResponse(text=text, tokens=tokens)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class OllamaClient(LLMClient):
|
|
232
|
+
"""Any local model served by Ollama, via its HTTP API (no SDK dependency).
|
|
233
|
+
|
|
234
|
+
No API key. Endpoint, timeout and context length come from ``OLLAMA_BASE_URL``,
|
|
235
|
+
``OLLAMA_TIMEOUT`` and ``OLLAMA_NUM_CTX`` (see :mod:`quantred.settings`); the
|
|
236
|
+
context length is sent only when set.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
provider = "ollama"
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
model: str = "llama3.1",
|
|
244
|
+
base_url: Optional[str] = None,
|
|
245
|
+
timeout: Optional[float] = None,
|
|
246
|
+
num_ctx: Optional[int] = None,
|
|
247
|
+
):
|
|
248
|
+
self.model = model
|
|
249
|
+
self.base_url = normalize_ollama_url(base_url) if base_url else ollama_base_url()
|
|
250
|
+
self.timeout = timeout if timeout is not None else ollama_timeout()
|
|
251
|
+
self.num_ctx = num_ctx if num_ctx is not None else ollama_num_ctx()
|
|
252
|
+
|
|
253
|
+
def _detail_for(self, exc: BaseException):
|
|
254
|
+
category, _ = classify_provider_error(exc)
|
|
255
|
+
if category == "unreachable":
|
|
256
|
+
return f"nothing answered at {self.base_url}"
|
|
257
|
+
if category == "timeout":
|
|
258
|
+
return f"no response from {self.base_url} within {self.timeout:g}s (OLLAMA_TIMEOUT)"
|
|
259
|
+
if category == "model_not_found":
|
|
260
|
+
return f"{self.base_url} has no model named {self.model!r}"
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
def _complete(self, system: str, user: str, max_tokens: int) -> LLMResponse:
|
|
264
|
+
import json
|
|
265
|
+
import urllib.request
|
|
266
|
+
|
|
267
|
+
options = {"num_predict": max_tokens}
|
|
268
|
+
if self.num_ctx is not None:
|
|
269
|
+
options["num_ctx"] = self.num_ctx
|
|
270
|
+
payload = {
|
|
271
|
+
"model": self.model,
|
|
272
|
+
"messages": [
|
|
273
|
+
{"role": "system", "content": system},
|
|
274
|
+
{"role": "user", "content": user},
|
|
275
|
+
],
|
|
276
|
+
"stream": False,
|
|
277
|
+
"options": options,
|
|
278
|
+
}
|
|
279
|
+
request = urllib.request.Request(
|
|
280
|
+
f"{self.base_url}/api/chat",
|
|
281
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
282
|
+
headers={"Content-Type": "application/json"},
|
|
283
|
+
)
|
|
284
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310 - local URL
|
|
285
|
+
raw = response.read().decode("utf-8")
|
|
286
|
+
try:
|
|
287
|
+
data = json.loads(raw)
|
|
288
|
+
except ValueError as exc:
|
|
289
|
+
# A proxy's HTML page or a half-started server: the endpoint's fault, and
|
|
290
|
+
# worth saying so rather than showing a JSON traceback.
|
|
291
|
+
raise ModelProviderError(
|
|
292
|
+
self.provider,
|
|
293
|
+
"unavailable",
|
|
294
|
+
f"{self.base_url} answered with something that is not an Ollama reply",
|
|
295
|
+
) from exc
|
|
296
|
+
text = data.get("message", {}).get("content", "")
|
|
297
|
+
tokens = int(data.get("prompt_eval_count", 0)) + int(data.get("eval_count", 0))
|
|
298
|
+
return LLMResponse(text=text, tokens=tokens)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def build_llm_client(provider: str = "anthropic", model: str = None, **kwargs) -> LLMClient:
|
|
302
|
+
"""Construct an :class:`LLMClient` for ``provider`` (anthropic | openai | ollama)."""
|
|
303
|
+
key = provider.lower()
|
|
304
|
+
chosen = model or DEFAULT_MODELS.get(key)
|
|
305
|
+
if key == "anthropic":
|
|
306
|
+
return AnthropicClient(chosen, **kwargs)
|
|
307
|
+
if key == "openai":
|
|
308
|
+
return OpenAIClient(chosen, **kwargs)
|
|
309
|
+
if key == "ollama":
|
|
310
|
+
return OllamaClient(chosen, **kwargs)
|
|
311
|
+
raise ValueError(f"Unknown LLM provider {provider!r}. Choose from {sorted(DEFAULT_MODELS)}.")
|