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.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. 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
+ ]
@@ -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)}.")