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,234 @@
1
+ """Proposers: where the *creativity* enters the research loop.
2
+
3
+ A :class:`Proposer` turns a goal + the history so far into the next
4
+ :class:`Proposal` - either a parameter config to try ("tune") or new strategy
5
+ *code* to author ("code"). The agent loop and all guardrails are proposer-agnostic,
6
+ so the LLM-backed :class:`LLMProposer` and a deterministic test double share one
7
+ contract.
8
+
9
+ Each proposal must carry a one-paragraph **hypothesis** (the economic/behavioral
10
+ rationale). No rationale -> the agent rejects it unevaluated (a research-hygiene
11
+ rule; see the research-agent guide in the engineering docs).
12
+
13
+ :class:`LLMProposer` is provider-agnostic - it drives any
14
+ :class:`~quantred.research.llm.LLMClient` (Claude, GPT, or a local Ollama model), so
15
+ the same loop runs against a hosted frontier model or one on your laptop.
16
+ """
17
+
18
+ from abc import ABC, abstractmethod
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from quantred.research.llm import LLMClient, build_llm_client
23
+
24
+
25
+ @dataclass
26
+ class Proposal:
27
+ """One experiment the agent should try next."""
28
+
29
+ hypothesis: str # WHY this should work (required by hygiene)
30
+ kind: str = "tune" # "tune" (config) | "code" (new subclass)
31
+ strategy: Optional[str] = None # registered strategy to tune (kind == "tune")
32
+ params: Dict[str, Any] = field(default_factory=dict) # config overrides (kind == "tune")
33
+ code: Optional[str] = None # source of a new Strategy subclass (kind == "code")
34
+ tuned_params: List[str] = field(default_factory=list) # which params this varies (hygiene cap)
35
+ parent_id: Optional[str] = None # lineage: the candidate this mutates, if any
36
+ tokens_used: int = 0 # reported LLM token cost, for the budget
37
+ #: Why the reply could not be read as a proposal at all (unparseable text, a field of
38
+ #: the wrong type). Set instead of returning ``None``, which means "nothing more to
39
+ #: try": a model that answered badly once still has ideas, and the round is spent.
40
+ malformed: Optional[str] = None
41
+
42
+
43
+ @dataclass
44
+ class ProposalContext:
45
+ """What the proposer sees each round (never includes holdout data)."""
46
+
47
+ goal: str
48
+ strategy: str
49
+ param_ranges: Dict[str, Any]
50
+ history: List[Dict[str, Any]] # compact summary of prior trials + OOS scores
51
+ incumbent: Optional[Dict[str, Any]] # current best candidate, if any
52
+ round_index: int
53
+
54
+
55
+ class Proposer(ABC):
56
+ """Turns the goal + history into the next proposal."""
57
+
58
+ @abstractmethod
59
+ def propose(self, context: ProposalContext) -> Optional[Proposal]:
60
+ """Return the next proposal, or ``None`` to signal "nothing more to try"."""
61
+
62
+
63
+ class FixedProposer(Proposer):
64
+ """Deterministic proposer that replays a fixed list - for tests and replays."""
65
+
66
+ def __init__(self, proposals: List[Proposal]):
67
+ self._proposals = list(proposals)
68
+
69
+ def propose(self, context: ProposalContext) -> Optional[Proposal]:
70
+ if context.round_index >= len(self._proposals):
71
+ return None
72
+ return self._proposals[context.round_index]
73
+
74
+
75
+ class LLMProposer(Proposer):
76
+ """Proposer backed by any :class:`~quantred.research.llm.LLMClient`.
77
+
78
+ Asks the model for a JSON proposal: a hypothesis plus either param overrides
79
+ within the supplied ``PARAM_RANGES`` (``kind="tune"``) or, when
80
+ ``allow_code_gen`` is set, the source of a brand-new ``Strategy`` subclass
81
+ (``kind="code"``). The model id is pinned on the client and logged by the
82
+ agent's audit journal, so every proposal is replayable.
83
+
84
+ Code proposals are *untrusted text* until :mod:`quantred.research.sandbox` has
85
+ validated them - this class only transports them.
86
+ """
87
+
88
+ def __init__(self, client: LLMClient, max_tokens: int = 1024, *, allow_code_gen: bool = False):
89
+ self.client = client
90
+ self.max_tokens = max_tokens
91
+ self.allow_code_gen = allow_code_gen
92
+
93
+ def propose(self, context: ProposalContext) -> Optional[Proposal]:
94
+ import json
95
+
96
+ system = _CODEGEN_SYSTEM_PROMPT if self.allow_code_gen else _SYSTEM_PROMPT
97
+ response = self.client.complete(system, _build_user_prompt(context), self.max_tokens)
98
+ try:
99
+ payload = json.loads(_extract_json(response.text))
100
+ except (ValueError, KeyError):
101
+ return self._malformed("unparseable reply", response.tokens, context)
102
+ if not isinstance(payload, dict):
103
+ return self._malformed("unparseable reply: expected a JSON object", response.tokens, context)
104
+
105
+ hypothesis = payload.get("hypothesis", "")
106
+ if not isinstance(hypothesis, str):
107
+ return self._malformed(
108
+ f"hypothesis must be text, got {_type_name(hypothesis)}", response.tokens, context
109
+ )
110
+
111
+ if payload.get("kind") == "code":
112
+ code = payload.get("code", "")
113
+ if not isinstance(code, str):
114
+ return self._malformed(f"code must be text, got {_type_name(code)}", response.tokens, context)
115
+ return Proposal(
116
+ hypothesis=hypothesis,
117
+ kind="code",
118
+ strategy=context.strategy,
119
+ code=code,
120
+ tokens_used=response.tokens,
121
+ )
122
+
123
+ params = payload.get("params", {})
124
+ if not isinstance(params, dict):
125
+ return self._malformed(
126
+ f"params must be an object of name: value, got {_type_name(params)}",
127
+ response.tokens,
128
+ context,
129
+ hypothesis,
130
+ )
131
+ return Proposal(
132
+ hypothesis=hypothesis,
133
+ kind="tune",
134
+ strategy=context.strategy,
135
+ params=params,
136
+ tuned_params=list(params),
137
+ tokens_used=response.tokens,
138
+ )
139
+
140
+ @staticmethod
141
+ def _malformed(reason: str, tokens: int, context: ProposalContext, hypothesis: str = "") -> Proposal:
142
+ return Proposal(
143
+ hypothesis=hypothesis,
144
+ kind="tune",
145
+ strategy=context.strategy,
146
+ tokens_used=tokens,
147
+ malformed=reason,
148
+ )
149
+
150
+
151
+ def _type_name(value: Any) -> str:
152
+ """A JSON-flavoured name for a decoded value, for a message a human reads."""
153
+ if value is None:
154
+ return "null"
155
+ if isinstance(value, bool):
156
+ return "a boolean"
157
+ if isinstance(value, (int, float)):
158
+ return "a number"
159
+ if isinstance(value, str):
160
+ return "text"
161
+ return "a list" if isinstance(value, list) else type(value).__name__
162
+
163
+
164
+ def build_proposer(
165
+ provider: str = "anthropic",
166
+ model: Optional[str] = None,
167
+ *,
168
+ client: Optional[LLMClient] = None,
169
+ max_tokens: int = 1024,
170
+ allow_code_gen: bool = False,
171
+ **kwargs,
172
+ ) -> LLMProposer:
173
+ """Build an :class:`LLMProposer` for a provider (anthropic | openai | ollama)."""
174
+ llm = client or build_llm_client(provider, model, **kwargs)
175
+ return LLMProposer(llm, max_tokens=max_tokens, allow_code_gen=allow_code_gen)
176
+
177
+
178
+ _SYSTEM_PROMPT = (
179
+ "You are a quantitative research assistant proposing the next backtest experiment. "
180
+ "Change at most 5 parameters. Give a one-paragraph hypothesis of WHY the change should "
181
+ "improve OUT-OF-SAMPLE performance - in-sample gains do not count. "
182
+ 'Respond ONLY with JSON: {"hypothesis": "...", "params": {"name": value, ...}}.'
183
+ )
184
+
185
+ #: System prompt for sessions run with ``--allow-code-gen``. The model may either
186
+ #: tune the incumbent or author a new mechanism; the sandbox enforces the contract
187
+ #: described here, so a violation is a rejected proposal, not a broken run.
188
+ _CODEGEN_SYSTEM_PROMPT = (
189
+ "You are a quantitative research assistant proposing the next backtest experiment. "
190
+ "You may respond with EITHER of two proposal kinds.\n\n"
191
+ '1. Tune the existing strategy: {"kind": "tune", "hypothesis": "...", '
192
+ '"params": {"name": value, ...}} - at most 5 parameters, each within PARAM_RANGES.\n\n'
193
+ '2. Author a NEW strategy mechanism: {"kind": "code", "hypothesis": "...", "code": "<python source>"}.\n\n'
194
+ "Propose 'code' when the history suggests the parameter space is exhausted and a different "
195
+ "mechanism is needed; propose 'tune' when the incumbent looks refinable.\n\n"
196
+ "Generated code runs in a restricted sandbox and MUST satisfy this contract or it is rejected:\n"
197
+ "- Define exactly one concrete subclass of `Strategy` (from quantred.strategies.base).\n"
198
+ "- Imports are limited to: pandas, numpy, math, quantred.indicators.indicators, quantred.strategies.base. "
199
+ "No os, sys, io, requests, or file access of any kind.\n"
200
+ "- Give the class a docstring stating the hypothesis (required).\n"
201
+ "- Declare a PARAM_RANGES ClassVar dict with AT MOST 5 SEARCHABLE params. A param is "
202
+ "searchable when its spec has min, max, and step; a spec with only a default is fixed and "
203
+ "does NOT count against the cap. Each spec needs type ('int'|'float'), default, description.\n"
204
+ "- PARAM_RANGES MUST include risk_per_trade, stop_loss, and take_profit (fractional distances). "
205
+ "The execution path reads these on every bar; omitting them is rejected. Pin them with a "
206
+ "default only if you would rather spend the searchable budget on your mechanism.\n"
207
+ "- Set TIMEFRAME (e.g. '1Day') and call super().__init__(config) after setting config['timeframe'].\n"
208
+ "- Implement all four abstract hooks: initialize(), process_data(data) -> DataFrame, "
209
+ "calculate_scores(data) -> Series, calculate_required_lookback() -> int.\n"
210
+ "- calculate_scores returns ONE continuous conviction score per bar; the base class derives "
211
+ "BUY/SELL/HOLD from its sign. Do not emit discrete signals yourself.\n"
212
+ "- The class must construct from its own declared defaults.\n\n"
213
+ "Give a one-paragraph hypothesis of WHY this should improve OUT-OF-SAMPLE performance - "
214
+ "in-sample gains do not count. Respond ONLY with the JSON object."
215
+ )
216
+
217
+
218
+ def _build_user_prompt(context: ProposalContext) -> str:
219
+ import json
220
+
221
+ return (
222
+ f"GOAL: {context.goal}\n"
223
+ f"STRATEGY: {context.strategy}\n"
224
+ f"TUNABLE PARAM_RANGES (stay within bounds):\n{json.dumps(context.param_ranges, default=str)}\n"
225
+ f"HISTORY (prior out-of-sample results):\n{json.dumps(context.history, default=str)}\n"
226
+ )
227
+
228
+
229
+ def _extract_json(text: str) -> str:
230
+ """Pull the first {...} block out of an LLM response."""
231
+ start, end = text.find("{"), text.rfind("}")
232
+ if start == -1 or end == -1 or end < start:
233
+ raise ValueError("no JSON object in response")
234
+ return text[start : end + 1]
@@ -0,0 +1,242 @@
1
+ """Naming what went wrong when a model provider refuses or cannot be reached.
2
+
3
+ One classification, used twice: by the opt-in readiness probe, and by the research
4
+ loop, because a key that passes a probe can still run out of credit twenty minutes
5
+ into a session. Both surfaces must name the same failure the same way, so the
6
+ mapping lives here and imports nothing heavy - the provider SDKs are never imported,
7
+ the exceptions are recognised by their shape (``status_code``, ``code``, ``type``,
8
+ class names).
9
+
10
+ **Structured fields decide; the message is the last resort.** Both SDKs carry a
11
+ machine-readable error code/type (``insufficient_quota`` against
12
+ ``rate_limit_exceeded``; ``billing_error``, ``rate_limit_error``,
13
+ ``overloaded_error``). A message is free text that can mention billing for any reason -
14
+ a rate-limit reply that links the billing page is still a rate limit - so wording is
15
+ consulted only when no structured field settles the question.
16
+
17
+ **Messages never carry the exception's own text.** A vendor error string can echo
18
+ request parameters, and the request carries the key. The detail is composed here from
19
+ the category, the HTTP status and the exception's type name.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import http.client
25
+ import socket
26
+ import urllib.error
27
+ from typing import Any, List, Literal, Optional, Tuple
28
+
29
+ ProbeCategory = Literal[
30
+ "ok",
31
+ "auth",
32
+ "quota",
33
+ "rate_limited",
34
+ "unavailable",
35
+ "entitlement",
36
+ "unreachable",
37
+ "timeout",
38
+ "model_not_found",
39
+ "unknown",
40
+ ]
41
+
42
+ #: Categories worth sending again: the condition is the provider's or the network's and
43
+ #: passes by itself. ``quota`` (an empty balance), ``auth``, ``entitlement`` and
44
+ #: ``model_not_found`` are not - repeating the request cannot change the answer, only
45
+ #: bill or hammer an account. ``timeout`` is left out too: a provider that did not answer
46
+ #: in the configured time is not helped by waiting that long again, three times.
47
+ RETRYABLE: Tuple[str, ...] = ("rate_limited", "unavailable", "unreachable")
48
+
49
+ #: Structured ``code`` / ``type`` values (OpenAI and Anthropic) and the category each settles.
50
+ _STRUCTURED = {
51
+ "insufficient_quota": "quota",
52
+ "billing_error": "quota",
53
+ "rate_limit_exceeded": "rate_limited",
54
+ "rate_limit_error": "rate_limited",
55
+ "overloaded_error": "unavailable",
56
+ "server_error": "unavailable",
57
+ "api_error": "unavailable",
58
+ "authentication_error": "auth",
59
+ "invalid_api_key": "auth",
60
+ "permission_error": "entitlement",
61
+ "not_found_error": "model_not_found",
62
+ "model_not_found": "model_not_found",
63
+ }
64
+
65
+ #: Wording that marks a 400/429 as "out of credit" - consulted only when no structured
66
+ #: field settled it. Anthropic reports an empty balance as a plain 400 with no code.
67
+ _CREDIT_MARKERS = (
68
+ "insufficient_quota",
69
+ "credit balance",
70
+ "exceeded your current quota",
71
+ )
72
+
73
+ _HTTP_STATUS = {
74
+ 401: ("auth", "the provider rejected the credentials (HTTP 401)"),
75
+ 402: ("quota", "credit exhausted (HTTP 402) - the account has no usable balance"),
76
+ 403: ("entitlement", "the credentials are valid but not permitted to use this model (HTTP 403)"),
77
+ 404: ("model_not_found", "the provider has no such model (HTTP 404)"),
78
+ }
79
+
80
+
81
+ class ModelProviderError(RuntimeError):
82
+ """A provider call failed in a way the user can act on.
83
+
84
+ ``category`` is one of :data:`ProbeCategory`; ``detail`` is safe to print.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ provider: str,
90
+ category: ProbeCategory,
91
+ detail: str,
92
+ *,
93
+ attempts: int = 1,
94
+ advised_wait: Optional[float] = None,
95
+ ):
96
+ super().__init__(f"model provider {provider}: {category} - {detail}")
97
+ self.provider = provider
98
+ self.category = category
99
+ self.detail = detail
100
+ #: Requests actually sent before giving up (1 means no retry happened) and the
101
+ #: wait the provider asked for, if any. Carried so the message can say what
102
+ #: happened instead of what the policy usually does.
103
+ self.attempts = attempts
104
+ self.advised_wait = advised_wait
105
+
106
+
107
+ def _class_names(exc: BaseException) -> set:
108
+ return {cls.__name__ for cls in type(exc).__mro__}
109
+
110
+
111
+ def _status_of(exc: BaseException) -> Optional[int]:
112
+ for attr in ("status_code", "code", "status"):
113
+ value = getattr(exc, attr, None)
114
+ if isinstance(value, int) and not isinstance(value, bool):
115
+ return value
116
+ return None
117
+
118
+
119
+ def _structured_values(exc: BaseException) -> List[str]:
120
+ """The machine-readable code/type strings an error carries, lower-cased."""
121
+ values: List[Any] = [getattr(exc, "code", None), getattr(exc, "type", None)]
122
+ body = getattr(exc, "body", None)
123
+ for level in (body, body.get("error") if isinstance(body, dict) else None):
124
+ if isinstance(level, dict):
125
+ values.extend((level.get("code"), level.get("type")))
126
+ return [v.lower() for v in values if isinstance(v, str)]
127
+
128
+
129
+ def _message_text(exc: BaseException) -> str:
130
+ """Free text that might say "credit" - used only to decide, never to print."""
131
+ parts = [str(exc)]
132
+ body = getattr(exc, "body", None)
133
+ if body is not None:
134
+ parts.append(str(body))
135
+ return " ".join(parts).lower()
136
+
137
+
138
+ def _structured_category(exc: BaseException) -> Optional[str]:
139
+ for value in _structured_values(exc):
140
+ if value in _STRUCTURED:
141
+ return _STRUCTURED[value]
142
+ return None
143
+
144
+
145
+ def is_provider_exception(exc: BaseException) -> bool:
146
+ """Whether ``exc`` is a vendor/network failure rather than a bug in this code.
147
+
148
+ Recognised by shape, like the classifier: an HTTP status carried by the exception,
149
+ an SDK ``API*Error`` class, or a socket/urllib failure. Anything else - an
150
+ ``AttributeError`` parsing a reply, a ``KeyError`` - is ours and keeps its traceback.
151
+ """
152
+ if isinstance(
153
+ exc,
154
+ (
155
+ urllib.error.URLError,
156
+ TimeoutError,
157
+ socket.timeout,
158
+ socket.gaierror,
159
+ ConnectionError,
160
+ http.client.HTTPException,
161
+ ),
162
+ ):
163
+ return True
164
+ if _status_of(exc) is not None:
165
+ return True
166
+ return bool(_class_names(exc) & {"APIError", "APIStatusError", "APIConnectionError", "APITimeoutError"})
167
+
168
+
169
+ def retry_after_seconds(exc: BaseException) -> Optional[float]:
170
+ """The provider's own ``Retry-After`` (seconds), when the error carries one."""
171
+ headers = getattr(getattr(exc, "response", None), "headers", None) or getattr(exc, "headers", None)
172
+ if headers is None:
173
+ return None
174
+ try:
175
+ value = headers.get("retry-after")
176
+ except AttributeError:
177
+ return None
178
+ try:
179
+ return float(value) if value is not None else None
180
+ except (TypeError, ValueError):
181
+ return None
182
+
183
+
184
+ def classify_provider_error(exc: BaseException) -> Tuple[ProbeCategory, str]:
185
+ """Map a provider exception to ``(category, safe detail)``.
186
+
187
+ Duck-typed so it needs no SDK. Order: transport failures by type, then the
188
+ structured code/type, then the HTTP status, and only for a 400/429 that nothing
189
+ structured explained, the message wording. ``quota`` means credit (a billing
190
+ action); ``rate_limited`` and ``unavailable`` mean wait.
191
+ """
192
+ names = _class_names(exc)
193
+
194
+ if isinstance(exc, TimeoutError) or isinstance(exc, socket.timeout) or "APITimeoutError" in names:
195
+ return "timeout", "the provider did not answer in time"
196
+ if isinstance(exc, urllib.error.URLError) and not isinstance(exc, urllib.error.HTTPError):
197
+ reason = exc.reason
198
+ if isinstance(reason, (TimeoutError, socket.timeout)):
199
+ return "timeout", "the provider did not answer in time"
200
+ return "unreachable", "the connection failed before any response"
201
+
202
+ status = _status_of(exc)
203
+ suffix = f" (HTTP {status})" if status is not None else ""
204
+ structured = _structured_category(exc)
205
+ if structured == "quota":
206
+ return "quota", f"credit exhausted{suffix} - the account has no usable balance"
207
+ if structured == "rate_limited":
208
+ return "rate_limited", f"rate limited{suffix} - too many requests, not an empty balance; wait"
209
+ if structured == "unavailable":
210
+ return "unavailable", f"the provider is overloaded or failing{suffix}; try again shortly"
211
+ if structured in ("auth", "entitlement", "model_not_found") and status in (None, 400, 401, 403, 404):
212
+ return _HTTP_STATUS[{"auth": 401, "entitlement": 403, "model_not_found": 404}[structured]]
213
+
214
+ if status in _HTTP_STATUS:
215
+ return _HTTP_STATUS[status]
216
+ if status == 429 or status == 400:
217
+ # No structured field settled it: the message is all there is.
218
+ if any(marker in _message_text(exc) for marker in _CREDIT_MARKERS):
219
+ return "quota", f"credit exhausted{suffix} - the account has no usable balance"
220
+ if status == 429:
221
+ return "rate_limited", f"rate limited{suffix} - too many requests, not an empty balance; wait"
222
+ if status is not None and status >= 500:
223
+ return "unavailable", f"the provider is overloaded or failing{suffix}; try again shortly"
224
+
225
+ if "APIConnectionError" in names or isinstance(exc, (ConnectionError, OSError)):
226
+ return "unreachable", "the connection failed before any response"
227
+
228
+ return "unknown", f"unexpected {type(exc).__name__}{suffix}"
229
+
230
+
231
+ def as_provider_error(
232
+ provider: str, exc: BaseException, *, detail: Optional[Any] = None, attempts: int = 1
233
+ ) -> ModelProviderError:
234
+ """Wrap ``exc`` as a :class:`ModelProviderError`, optionally overriding the detail."""
235
+ category, classified = classify_provider_error(exc)
236
+ return ModelProviderError(
237
+ provider,
238
+ category,
239
+ str(detail) if detail else classified,
240
+ attempts=attempts,
241
+ advised_wait=retry_after_seconds(exc),
242
+ )