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,1023 @@
1
+ """First-run setup: inspecting, validating, and writing the ``.env`` contract.
2
+
3
+ Pure functions with no prompts and no printing, so the interactive wizard in the
4
+ CLI is a thin sequence over them and every branch is testable offline. The
5
+ prompting lives in ``main.py``; the decisions live here.
6
+
7
+ **Secrets hygiene is the whole point.** A key that reaches this module must not
8
+ reach a log, a journal, an exception message, or a printed summary. Every value
9
+ that comes back out for display is masked, and the validation path is built to
10
+ report *why* it failed without quoting what was submitted.
11
+
12
+ **Never destroy an existing setup.** A ``.env`` is shared dotenv convention and may
13
+ hold keys this project does not own. Rewrites preserve every line this project
14
+ did not touch, byte for byte, and a backup is written before any modification.
15
+ """
16
+
17
+ import logging
18
+ import os
19
+ import shutil
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timedelta
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Literal, Optional, Tuple
24
+
25
+ from quantred.settings import _PLACEHOLDERS, DISTRIBUTION_NAME, ENV_PATH
26
+
27
+ if TYPE_CHECKING: # pragma: no cover
28
+ # Typing only: importing the research package at runtime pulls in the backtest engine,
29
+ # numpy and pandas for a module the doctor imports on every `init`.
30
+ from quantred.research.provider_errors import ProbeCategory
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ #: The keys this project owns. Anything else in a ``.env`` belongs to someone else
35
+ #: and is preserved untouched.
36
+ OWNED_KEYS = (
37
+ "APCA_API_KEY_ID",
38
+ "APCA_API_SECRET_KEY",
39
+ "PAPER_TRADE",
40
+ "ANTHROPIC_API_KEY",
41
+ "OPENAI_API_KEY",
42
+ "OLLAMA_BASE_URL",
43
+ "OLLAMA_TIMEOUT",
44
+ "OLLAMA_NUM_CTX",
45
+ )
46
+
47
+ #: Owned keys that are secrets and so are never printed in full. Everything else owned
48
+ #: (a URL, a timeout) is shown as it is - masking it hides nothing and costs the reader
49
+ #: the value they most want to confirm.
50
+ SECRET_KEYS = ("APCA_API_KEY_ID", "APCA_API_SECRET_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY")
51
+
52
+ #: The model providers ``init`` can set up, and the extra each hosted one needs.
53
+ MODEL_PROVIDERS = ("anthropic", "openai", "ollama")
54
+
55
+ #: The ``.env`` / environment names that mean "an Ollama endpoint was set up".
56
+ _OLLAMA_KEYS = ("OLLAMA_BASE_URL", "OLLAMA_TIMEOUT", "OLLAMA_NUM_CTX")
57
+ PROVIDER_EXTRA = {"anthropic": "ai", "openai": "openai"}
58
+ PROVIDER_KEYS = {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY"}
59
+
60
+ #: The two that must be real for any command that touches the market.
61
+ CREDENTIAL_KEYS = ("APCA_API_KEY_ID", "APCA_API_SECRET_KEY")
62
+
63
+ #: A small, liquid default universe for the optional first cache warm.
64
+ DEFAULT_WARM_UNIVERSE = ("SPY", "AAPL", "MSFT", "NVDA", "AMZN")
65
+
66
+
67
+ _BASE_KEYS = ("APCA_API_KEY_ID", "APCA_API_SECRET_KEY", "PAPER_TRADE")
68
+
69
+
70
+ def invocation(command: str, *, make_target: Optional[str] = None) -> str:
71
+ """How to run ``command`` on the copy that is actually running.
72
+
73
+ Delegates to :func:`quantred.settings.invocation`, which is the definition: it
74
+ moved down a layer because ``settings`` itself prints instructions and cannot
75
+ import a service. This name stays so services and the guard tests keep one
76
+ import path.
77
+ """
78
+ from quantred.settings import invocation as _invocation
79
+
80
+ return _invocation(command, make_target=make_target)
81
+
82
+
83
+ def mcp_launch_command(*, offline: bool = False, cache_dir: Optional[Any] = None) -> List[str]:
84
+ """The command an MCP client should run to start *this* copy's server.
85
+
86
+ One definition for three readers: ``mcp --self-test`` runs it, ``init`` prints it,
87
+ and the documented client config is the same shape. A client spawns the process
88
+ itself - no shell, no working directory, often none of the PATH a terminal has - so
89
+ everything here is absolute. A self-test that passed on a different command than the
90
+ one a reader was told to configure would be reporting on something nobody uses.
91
+
92
+ * **Installed:** the :data:`~quantred.settings.CLI_NAME` script that sits next to the running interpreter
93
+ (a tool environment's own ``bin``), else ``python -m quantred.cli mcp``.
94
+ * **Checkout:** this interpreter and the absolute ``main.py``, which needs neither
95
+ ``uv`` on the client's PATH nor a ``cwd``.
96
+
97
+ ``offline`` appends ``--offline`` (and ``--cache-dir``, made absolute because a
98
+ client has no working directory to resolve it against): the server then reads only
99
+ the bar cache and needs no keys. It is part of the one command so the self-test and
100
+ the printed config cannot check one server and tell a reader to launch another.
101
+ """
102
+ import sys
103
+
104
+ from quantred.settings import CLI_NAME, LEGACY_CLI_NAMES, PROJECT_ROOT, _looks_like_checkout
105
+
106
+ flags: List[str] = []
107
+ if offline:
108
+ flags.append("--offline")
109
+ if cache_dir is not None:
110
+ flags += ["--cache-dir", str(Path(cache_dir).expanduser().resolve())]
111
+
112
+ python = Path(sys.executable)
113
+ main = PROJECT_ROOT / "main.py"
114
+ if _looks_like_checkout(PROJECT_ROOT) and main.is_file():
115
+ return [str(python), str(main), "mcp", *flags]
116
+ for name in (CLI_NAME, *LEGACY_CLI_NAMES):
117
+ for filename in (name, f"{name}.exe"):
118
+ script = python.parent / filename
119
+ if script.is_file():
120
+ return [str(script), "mcp", *flags]
121
+ return [str(python), "-m", "quantred.cli", "mcp", *flags]
122
+
123
+
124
+ def mcp_client_env() -> Dict[str, str]:
125
+ """The environment a client must pass for its server to use *this* state root.
126
+
127
+ ``TRADEFLOW_HOME`` only, and only when it is set: a desktop client does not inherit
128
+ the shell that exported it, so without this a self-test run from that shell passes
129
+ while the client's server journals to the default root - splitting the campaign's
130
+ journal, which deflates the multiple-testing correction against half its evidence
131
+ with nothing erroring. Credentials are never listed: they belong in the ``.env``
132
+ the server reads itself, not in a client config file.
133
+ """
134
+ home = os.environ.get("TRADEFLOW_HOME")
135
+ return {"TRADEFLOW_HOME": str(Path(home).expanduser().resolve())} if home else {}
136
+
137
+
138
+ def credentials_only_in_shell(state: Optional["EnvState"] = None) -> List[str]:
139
+ """Credential keys the shell exports that the ``.env`` does not supply.
140
+
141
+ A client launches the server without the shell's exports, so these are exactly the
142
+ keys a passing self-test cannot vouch for.
143
+ """
144
+ state = state or inspect_env()
145
+ return [
146
+ k
147
+ for k in CREDENTIAL_KEYS
148
+ if not is_placeholder(state.from_environment.get(k)) and is_placeholder(state.values.get(k))
149
+ ]
150
+
151
+
152
+ def credentials_missing(state: Optional["EnvState"] = None) -> List[str]:
153
+ """Credential keys neither the ``.env`` nor the shell supplies."""
154
+ state = state or inspect_env()
155
+ return [
156
+ k
157
+ for k in CREDENTIAL_KEYS
158
+ if is_placeholder(state.values.get(k)) and is_placeholder(state.from_environment.get(k))
159
+ ]
160
+
161
+
162
+ def mcp_client_config(*, offline: bool = False, cache_dir: Optional[Any] = None) -> Dict[str, Any]:
163
+ """The ``mcpServers`` block for :func:`mcp_launch_command`, ready to paste."""
164
+ from quantred.settings import MCP_SERVER_NAME
165
+
166
+ command = mcp_launch_command(offline=offline, cache_dir=cache_dir)
167
+ entry: Dict[str, Any] = {"command": command[0], "args": command[1:]}
168
+ env = mcp_client_env()
169
+ if env:
170
+ entry["env"] = env
171
+ return {"mcpServers": {MCP_SERVER_NAME: entry}}
172
+
173
+
174
+ def is_placeholder(value: Optional[str]) -> bool:
175
+ """Whether a value means "not actually filled in".
176
+
177
+ Defers to ``settings.py``'s own placeholder set rather than re-deciding what
178
+ counts as unset — two definitions of "filled in" will drift, and the one that
179
+ matters is the one the loader enforces.
180
+ """
181
+ return value is None or value.strip() in _PLACEHOLDERS
182
+
183
+
184
+ def mask(value: Optional[str]) -> str:
185
+ """A value in a form safe to print: ``AKFO…9J2X``, or a plain absence marker.
186
+
187
+ The only representation of a secret this module ever emits. Short values are
188
+ masked entirely rather than partially, since revealing most of a short string
189
+ reveals the string.
190
+ """
191
+ if not value:
192
+ return "(not set)"
193
+ if is_placeholder(value):
194
+ return "(placeholder)"
195
+ stripped = value.strip()
196
+ if len(stripped) < 12:
197
+ return "*" * len(stripped)
198
+ return f"{stripped[:4]}…{stripped[-4:]}"
199
+
200
+
201
+ # --------------------------------------------------------------------------- #
202
+ # Inspection
203
+ # --------------------------------------------------------------------------- #
204
+ @dataclass
205
+ class EnvState:
206
+ """What the current ``.env`` (and process environment) actually contains."""
207
+
208
+ path: Path
209
+ exists: bool
210
+ values: Dict[str, str] = field(default_factory=dict)
211
+ from_environment: Dict[str, str] = field(default_factory=dict)
212
+
213
+ @property
214
+ def missing(self) -> List[str]:
215
+ """Credential keys that are absent or still a placeholder."""
216
+ return [k for k in CREDENTIAL_KEYS if is_placeholder(self.resolved(k))]
217
+
218
+ @property
219
+ def complete(self) -> bool:
220
+ return not self.missing
221
+
222
+ def resolved(self, key: str) -> Optional[str]:
223
+ """The value a command would actually see: a real environment variable
224
+ wins over the file, exactly as the settings loader resolves it."""
225
+ return self.from_environment.get(key) or self.values.get(key)
226
+
227
+ def summary(self) -> Dict[str, str]:
228
+ """A printable view. Credentials are masked; settings that are not secrets
229
+ are shown as they are — masking ``PAPER_TRADE=true`` to ``****`` hides
230
+ nothing and costs the reader the one value they most want to confirm."""
231
+ return {
232
+ key: (mask(self.resolved(key)) if key in SECRET_KEYS else (self.resolved(key) or "(not set)"))
233
+ for key in OWNED_KEYS
234
+ # Model settings are optional; listing five "(not set)" rows would bury the
235
+ # three that matter for a reader who never asked for a model.
236
+ if key in _BASE_KEYS or self.resolved(key)
237
+ }
238
+
239
+
240
+ def inspect_env(path: Optional[Any] = None, environ: Optional[Dict[str, str]] = None) -> EnvState:
241
+ """Read the current setup without changing anything.
242
+
243
+ Reports what the file holds *and* what the process environment overrides, so a
244
+ user whose keys come from an exported variable is not told to fill in a file
245
+ that would have no effect.
246
+ """
247
+ env_path = Path(path) if path else ENV_PATH
248
+ environ = os.environ if environ is None else environ
249
+ values: Dict[str, str] = {}
250
+ if env_path.exists():
251
+ for key, value, _ in _parse_env_lines(env_path.read_text().splitlines()):
252
+ if key is not None:
253
+ values[key] = value
254
+ from_environment = {k: environ[k] for k in OWNED_KEYS if environ.get(k)}
255
+ return EnvState(path=env_path, exists=env_path.exists(), values=values, from_environment=from_environment)
256
+
257
+
258
+ def _parse_env_lines(lines: List[str]) -> List[Tuple[Optional[str], str, str]]:
259
+ """Each line as ``(key or None, value, raw)``.
260
+
261
+ ``key is None`` marks a comment or blank — a line to preserve verbatim rather
262
+ than interpret.
263
+ """
264
+ out: List[Tuple[Optional[str], str, str]] = []
265
+ for raw in lines:
266
+ line = raw.strip()
267
+ if not line or line.startswith("#") or "=" not in line:
268
+ out.append((None, "", raw))
269
+ continue
270
+ key, _, value = line.partition("=")
271
+ out.append((key.strip(), value.strip().strip('"').strip("'"), raw))
272
+ return out
273
+
274
+
275
+ # --------------------------------------------------------------------------- #
276
+ # Writing
277
+ # --------------------------------------------------------------------------- #
278
+ def write_env(updates: Dict[str, str], path: Optional[Any] = None, *, backup: bool = True) -> Dict[str, Any]:
279
+ """Apply ``updates`` to the ``.env``, preserving everything else exactly.
280
+
281
+ Three properties this must have, in order of how badly their absence would
282
+ hurt:
283
+
284
+ 1. **Nothing else changes.** Comments, blank lines, ordering, and keys this
285
+ project does not own survive byte for byte. A dotenv file is shared
286
+ territory.
287
+ 2. **A backup exists first.** ``.env.bak`` is written before any modification,
288
+ so a wrong answer at a prompt is recoverable.
289
+ 3. **The write is atomic.** A temp file in the same directory, then a rename —
290
+ an interruption leaves either the old file or the new one, never a torn one
291
+ that the loader would read as half-configured credentials.
292
+
293
+ Returns a masked summary of what changed; never echoes a value.
294
+ """
295
+ env_path = Path(path) if path else ENV_PATH
296
+ env_path.parent.mkdir(parents=True, exist_ok=True)
297
+ existing = env_path.read_text().splitlines() if env_path.exists() else []
298
+
299
+ backup_path = None
300
+ if backup and env_path.exists():
301
+ backup_path = env_path.with_suffix(env_path.suffix + ".bak")
302
+ shutil.copy2(env_path, backup_path)
303
+ _owner_only(backup_path) # a copy of the same secrets, whatever mode the original had
304
+
305
+ remaining = dict(updates)
306
+ out_lines: List[str] = []
307
+ for key, _value, raw in _parse_env_lines(existing):
308
+ if key is not None and key in remaining:
309
+ out_lines.append(f"{key}={remaining.pop(key)}")
310
+ else:
311
+ out_lines.append(raw)
312
+ if remaining:
313
+ if out_lines and out_lines[-1].strip():
314
+ out_lines.append("")
315
+ out_lines.extend(f"{key}={value}" for key, value in remaining.items())
316
+
317
+ temp_path = env_path.with_name(env_path.name + ".tmp")
318
+ # Created owner-only, not chmod'd afterwards: the file holds API keys that can be
319
+ # billed, and a window where it exists world-readable is still a disclosure.
320
+ fd = os.open(temp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
321
+ with os.fdopen(fd, "w") as handle:
322
+ handle.write("\n".join(out_lines) + "\n")
323
+ _owner_only(temp_path) # O_CREAT's mode is ignored for a leftover temp file that already exists
324
+ try:
325
+ os.replace(temp_path, env_path)
326
+ except OSError:
327
+ # The original file is untouched, which is the point; clean up the partial
328
+ # so a later run does not find a stray file holding a copy of a secret.
329
+ temp_path.unlink(missing_ok=True)
330
+ raise
331
+
332
+ return {
333
+ "path": str(env_path),
334
+ "backup": str(backup_path) if backup_path else None,
335
+ # Same rule as EnvState.summary: mask credentials, show plain settings.
336
+ "updated": {key: (mask(value) if key in SECRET_KEYS else value) for key, value in updates.items()},
337
+ }
338
+
339
+
340
+ def _owner_only(path: Path) -> None:
341
+ """Restrict ``path`` to its owner (a no-op where the platform has no such mode)."""
342
+ try:
343
+ os.chmod(path, 0o600)
344
+ except OSError: # pragma: no cover - e.g. a filesystem without POSIX modes
345
+ pass
346
+
347
+
348
+ # --------------------------------------------------------------------------- #
349
+ # Credential validation
350
+ # --------------------------------------------------------------------------- #
351
+ #: The three distinguishable outcomes. "Could not reach Alpaca" is not "keys
352
+ #: rejected", and telling a user on hotel wifi to regenerate working keys is a
353
+ #: worse failure than saying nothing.
354
+ CREDENTIALS_OK = "ok"
355
+ CREDENTIALS_REJECTED = "rejected"
356
+ CREDENTIALS_UNREACHABLE = "unreachable"
357
+
358
+
359
+ @dataclass
360
+ class CredentialCheck:
361
+ status: str
362
+ message: str
363
+
364
+ @property
365
+ def ok(self) -> bool:
366
+ return self.status == CREDENTIALS_OK
367
+
368
+
369
+ def check_credentials(data_client, symbol: str = "SPY") -> CredentialCheck:
370
+ """Validate credentials with one cheap historical-data request.
371
+
372
+ Takes a **data** client, never a trading client — the same structural rule the
373
+ MCP server follows. Validating a key must not require the ability to trade
374
+ with it.
375
+
376
+ The three outcomes are reported distinctly, and no message ever contains the
377
+ submitted credential: an error string from a vendor SDK can echo request
378
+ parameters, so the failure text is composed here rather than passed through.
379
+ """
380
+ end = datetime.now()
381
+ start = end - timedelta(days=7)
382
+ try:
383
+ bars = data_client.get_bars([symbol], "1Day", start, end)
384
+ except Exception as exc: # noqa: BLE001 - the vendor's exception types are its own
385
+ if _looks_like_auth_failure(exc):
386
+ return CredentialCheck(
387
+ CREDENTIALS_REJECTED,
388
+ "Alpaca rejected these credentials. Check that you copied the key and "
389
+ "secret from the Paper Account section (paper and live keys are different).",
390
+ )
391
+ return CredentialCheck(
392
+ CREDENTIALS_UNREACHABLE,
393
+ "Could not reach Alpaca — this looks like a network or rate-limit problem, "
394
+ "not a credential problem. The keys have been saved; try a command again later.",
395
+ )
396
+ if not bars:
397
+ # The request succeeded, so the credentials were accepted; an empty response
398
+ # is a data-availability fact, not an authentication one, and must not be
399
+ # reported as a rejection.
400
+ return CredentialCheck(
401
+ CREDENTIALS_UNREACHABLE,
402
+ f"Reached Alpaca but got no bars for {symbol}. The credentials were accepted; "
403
+ "market data may be unavailable for this window (a holiday, or a delayed feed).",
404
+ )
405
+ return CredentialCheck(CREDENTIALS_OK, "Credentials work — Alpaca returned market data.")
406
+
407
+
408
+ def _looks_like_auth_failure(exc: Exception) -> bool:
409
+ """Whether an exception reads as "rejected" rather than "unreachable".
410
+
411
+ Deliberately conservative: anything ambiguous is reported as unreachable,
412
+ because telling someone their working keys are invalid sends them to
413
+ regenerate credentials that were never the problem.
414
+ """
415
+ text = f"{type(exc).__name__} {exc}".lower()
416
+ return any(
417
+ marker in text
418
+ for marker in ("401", "403", "unauthorized", "forbidden", "invalid key", "authentication")
419
+ )
420
+
421
+
422
+ # --------------------------------------------------------------------------- #
423
+ # Doctor mode
424
+ # --------------------------------------------------------------------------- #
425
+ #: ``ready`` verified locally; ``blocked`` required by some task and missing;
426
+ #: ``optional_missing`` not needed by any task the reader asked about;
427
+ #: ``not_checked`` needs the network or was not requested; ``failed`` the check found a
428
+ #: broken state (an unwritable directory, live trading switched on).
429
+ Status = Literal["ready", "blocked", "optional_missing", "not_checked", "failed"]
430
+
431
+ #: What a reader is trying to do. ``demo`` runs on synthetic data and needs nothing.
432
+ TASKS = ("demo", "data", "research", "mcp")
433
+
434
+ PROVIDERS = ("anthropic", "openai", "ollama")
435
+
436
+ #: provider -> (extra that carries its SDK, credential name); ollama has neither.
437
+ _PROVIDER_REQUIREMENTS = {
438
+ "anthropic": ("ai", "ANTHROPIC_API_KEY"),
439
+ "openai": ("openai", "OPENAI_API_KEY"),
440
+ }
441
+
442
+
443
+ @dataclass(frozen=True)
444
+ class Check:
445
+ name: str
446
+ status: Status
447
+ detail: str
448
+ next_action: Optional[str] = None
449
+ #: Tasks this row gates; empty means informational.
450
+ needed_for: FrozenSet[str] = frozenset()
451
+ #: Extras whose absence this row reports. The doctor prints one install command for
452
+ #: all of them together, because two commands that each replace the environment
453
+ #: undo one another.
454
+ installs: Tuple[str, ...] = ()
455
+
456
+ @property
457
+ def passed(self) -> bool:
458
+ return self.status == "ready"
459
+
460
+
461
+ def run_checks(
462
+ path: Optional[Any] = None,
463
+ environ: Optional[Dict[str, str]] = None,
464
+ *,
465
+ provider: Optional[str] = None,
466
+ probe: Optional["ProbeResult"] = None,
467
+ ) -> List[Check]:
468
+ """Diagnose the current setup: one row per check, all of them evaluated.
469
+
470
+ A doctor, not a linter - it reports every check independently rather than
471
+ stopping at the first miss, because the second and third problems are exactly
472
+ what someone running a diagnostic wants to know about. It writes nothing and
473
+ makes no network call; credential *validity* and model reachability need a
474
+ request and are separate, explicit steps.
475
+
476
+ Each row says which tasks it gates, so :func:`readiness` can answer "can I run a
477
+ backtest?" without a missing model key getting a vote.
478
+ """
479
+ import quantred
480
+
481
+ state = inspect_env(path, environ)
482
+ checks = [
483
+ Check("version", "ready", f"{DISTRIBUTION_NAME} {quantred.__version__}"),
484
+ Check(
485
+ "env file",
486
+ "ready" if state.exists else "optional_missing",
487
+ f"{state.path} exists" if state.exists else f"{state.path} not found",
488
+ None if state.exists else "run `init` to create it, or export the keys as environment variables",
489
+ ),
490
+ ]
491
+ for key in CREDENTIAL_KEYS:
492
+ value = state.resolved(key)
493
+ source = " (from the environment)" if key in state.from_environment else ""
494
+ filled = not is_placeholder(value)
495
+ checks.append(
496
+ Check(
497
+ key,
498
+ "ready" if filled else "blocked",
499
+ f"{mask(value)}{source}"
500
+ if filled
501
+ else f"{mask(value)} - no command that touches the market will run",
502
+ None if filled else "run `init` to enter it, or export it as an environment variable",
503
+ # Not the mcp task: the server starts without keys and serves the catalogue
504
+ # and trial history; its data tools need them or `mcp --offline`. Requiring
505
+ # them here reported "blocked for: mcp" for a server that runs.
506
+ frozenset({"data", "research"}),
507
+ )
508
+ )
509
+ paper = state.resolved("PAPER_TRADE")
510
+ paper_on = paper is None or str(paper).strip().lower() in {"1", "true", "yes", "on"}
511
+ checks.append(
512
+ Check(
513
+ "PAPER_TRADE",
514
+ "ready" if paper_on else "failed",
515
+ "true - orders go to the paper account"
516
+ if paper_on
517
+ else "FALSE - live trading is enabled and orders would use real money",
518
+ None if paper_on else "set PAPER_TRADE=true unless live trading is deliberate",
519
+ )
520
+ )
521
+ checks.append(_writable_check())
522
+ checks.extend(_state_checks())
523
+ checks.extend(_extras_checks())
524
+ checks.extend(_model_checks(state, environ, provider, probe))
525
+ return checks
526
+
527
+
528
+ def _model_checks(
529
+ state: EnvState,
530
+ environ: Optional[Dict[str, str]],
531
+ provider: Optional[str],
532
+ probe: Optional["ProbeResult"] = None,
533
+ ) -> List[Check]:
534
+ """One row per model provider, from local facts only: SDK importable, key present,
535
+ endpoint configured. Nothing here opens a connection.
536
+
537
+ The *selected* provider (``--provider``) gates ``research``; unselected ones are
538
+ informational. With nothing selected a single ``model provider`` row gates research
539
+ on there being at least one provider that is usable. Ollama's reachability cannot be
540
+ known without a request, so a configured one is *unverified* (listed, not blocking)
541
+ until a probe has run; ``probe`` is that result, folded into the selected provider's
542
+ row so the table agrees with the line printed beneath it.
543
+ """
544
+ from quantred.settings import SettingsError, ollama_base_url
545
+ from quantred.utils import extras
546
+
547
+ env = os.environ if environ is None else environ
548
+
549
+ def resolved(key: str) -> Optional[str]:
550
+ return env.get(key) or state.values.get(key)
551
+
552
+ rows: List[Check] = []
553
+ ready_providers: List[str] = []
554
+ ollama_configured: Optional[str] = None
555
+ for name in PROVIDERS:
556
+ selected = provider == name
557
+ gate = frozenset({"research"}) if selected else frozenset()
558
+ label = f"model: {name}"
559
+ if name == "ollama":
560
+ try:
561
+ endpoint = ollama_base_url()
562
+ except SettingsError as exc:
563
+ rows.append(Check(label, "failed", str(exc), "fix the setting named above", gate))
564
+ continue
565
+ configured = any(resolved(k) for k in _OLLAMA_KEYS)
566
+ if configured:
567
+ ollama_configured = endpoint
568
+ if probe is not None and probe.provider == name:
569
+ rows.append(_probed_row(label, probe, f"endpoint {endpoint}", gate))
570
+ continue
571
+ rows.append(
572
+ Check(
573
+ label,
574
+ "not_checked",
575
+ f"{'configured at' if configured else 'default endpoint'} {endpoint} "
576
+ "(reachability not checked; run with --probe-model)",
577
+ None,
578
+ gate,
579
+ )
580
+ )
581
+ continue
582
+ extra, key_name = _PROVIDER_REQUIREMENTS[name]
583
+ sdk_ok = extras.installed(extra)
584
+ key = resolved(key_name)
585
+ key_ok = not is_placeholder(key)
586
+ if sdk_ok and key_ok:
587
+ ready_providers.append(name)
588
+ detail = f"SDK installed; {key_name} {mask(key)}"
589
+ if probe is not None and probe.provider == name:
590
+ rows.append(_probed_row(label, probe, detail, gate))
591
+ continue
592
+ rows.append(Check(label, "ready", detail, None, gate))
593
+ continue
594
+ missing = []
595
+ actions = []
596
+ if not sdk_ok:
597
+ missing.append(f"the '{extra}' extra (SDK not installed)")
598
+ if not key_ok:
599
+ missing.append(key_name)
600
+ actions.append(f"set {key_name} in the environment or in .env")
601
+ status: Status = "blocked" if selected else "optional_missing"
602
+ rows.append(
603
+ Check(
604
+ label,
605
+ status,
606
+ "missing " + " and ".join(missing),
607
+ "\n".join(actions) or None,
608
+ gate,
609
+ () if sdk_ok else (extra,),
610
+ )
611
+ )
612
+
613
+ if provider is None:
614
+ usable = bool(ready_providers)
615
+ if usable:
616
+ row = Check(
617
+ "model provider",
618
+ "ready",
619
+ f"{', '.join(ready_providers)} usable for research",
620
+ None,
621
+ frozenset({"research"}),
622
+ )
623
+ elif ollama_configured:
624
+ row = Check(
625
+ "model provider",
626
+ "not_checked",
627
+ f"ollama configured at {ollama_configured}; unverified - reachability needs a request",
628
+ "add --provider ollama --probe-model to confirm it answers",
629
+ frozenset({"research"}),
630
+ )
631
+ else:
632
+ row = Check(
633
+ "model provider",
634
+ "blocked",
635
+ "no provider is set up for `research`",
636
+ "install an SDK extra and set its key, or configure a local model with `init` "
637
+ "(then confirm it with --provider ollama --probe-model)",
638
+ frozenset({"research"}),
639
+ )
640
+ rows.append(row)
641
+ return rows
642
+
643
+
644
+ def missing_extras(checks: List[Check]) -> List[str]:
645
+ """Every extra some row reports absent, once each and sorted (see :attr:`Check.installs`)."""
646
+ return sorted({name for c in checks if c.status != "ready" for name in c.installs})
647
+
648
+
649
+ def scoped_missing_extras(
650
+ checks: List[Check], task: str, provider: Optional[str] = None
651
+ ) -> Tuple[List[str], List[str]]:
652
+ """``(needed, also)``: the absent extras the asked-about task needs, and the rest.
653
+
654
+ The doctor prints one install command, and printing it for *every* absent extra
655
+ installs a constraint solver, a machine-learning library and the other vendor's SDK
656
+ for someone who asked whether anthropic research is ready. ``needed`` is what the
657
+ selected task cannot run without, plus the selected provider's SDK extra; ``also`` is
658
+ everything else that is absent, listed but not folded into the command.
659
+ """
660
+ absent = missing_extras(checks)
661
+ needed = {name for c in checks if c.status != "ready" and task in c.needed_for for name in c.installs}
662
+ if provider in _PROVIDER_REQUIREMENTS:
663
+ extra = _PROVIDER_REQUIREMENTS[provider][0]
664
+ if extra in absent:
665
+ needed.add(extra)
666
+ return sorted(needed), [name for name in absent if name not in needed]
667
+
668
+
669
+ def _probed_row(label: str, probe: "ProbeResult", detail: str, gate: FrozenSet[str]) -> Check:
670
+ """A provider row after a probe: the probe's verdict replaces "not checked"."""
671
+ if probe.ok:
672
+ return Check(label, "ready", f"{detail}; answered one request ({probe.model})", None, gate)
673
+ return Check(
674
+ label,
675
+ "blocked" if gate else "failed",
676
+ f"{detail}; probe failed: {probe.category}",
677
+ probe.detail,
678
+ gate,
679
+ )
680
+
681
+
682
+ def readiness(checks: List[Check]) -> Dict[str, Dict[str, Any]]:
683
+ """Per task: whether it can run, which rows stop it, and which rows are unverified.
684
+
685
+ ``not_checked`` rows never block - the check could not know - but they are listed so
686
+ "ready" is not read as "confirmed".
687
+ """
688
+ out: Dict[str, Dict[str, Any]] = {}
689
+ for task in TASKS:
690
+ gating = [c for c in checks if task in c.needed_for]
691
+ blocking = [c.name for c in gating if c.status in ("blocked", "failed", "optional_missing")]
692
+ unverified = [c.name for c in gating if c.status == "not_checked"]
693
+ out[task] = {"ready": not blocking, "blocking": blocking, "unverified": unverified}
694
+ return out
695
+
696
+
697
+ def doctor_ok(checks: List[Check], task: str) -> bool:
698
+ """Whether the check passes for ``task``: it is ready and no row is broken."""
699
+ return readiness(checks)[task]["ready"] and not any(c.status == "failed" for c in checks)
700
+
701
+
702
+ def task_next_step(task: str) -> str:
703
+ """The one thing to run once ``task`` is ready, so the check ends on the reader's goal.
704
+
705
+ It used to end on the same line whatever was asked — "`demo` needs nothing; `verdict`
706
+ needs the keys above" — so a reader who had just confirmed the MCP server was told
707
+ about the demo, and nothing about connecting a client.
708
+ """
709
+ from quantred.settings import DOCS_URL
710
+
711
+ steps = {
712
+ "demo": f"`{invocation('demo', make_target='demo')}` - the whole workflow on synthetic data, no keys",
713
+ "data": (
714
+ f"a first verdict on real data - the starter command is in {DOCS_URL}/usage/first-run "
715
+ f"(`{invocation('verdict')} --help` lists every option)"
716
+ ),
717
+ "research": f"`{invocation('research')} --help` - the one command that calls a model",
718
+ "mcp": f"`{invocation('mcp')} --self-test` - it prints the configuration to paste into your client",
719
+ }
720
+ return steps[task]
721
+
722
+
723
+ # --------------------------------------------------------------------------- #
724
+ # Opt-in model probe
725
+ # --------------------------------------------------------------------------- #
726
+ @dataclass(frozen=True)
727
+ class ProbeResult:
728
+ provider: str
729
+ model: str
730
+ category: "ProbeCategory"
731
+ detail: str
732
+
733
+ @property
734
+ def ok(self) -> bool:
735
+ return self.category == "ok"
736
+
737
+
738
+ def probe_blocker(provider: str, environ: Optional[Dict[str, str]] = None) -> Optional[str]:
739
+ """Why a probe cannot be sent at all, or ``None`` when it can.
740
+
741
+ A missing SDK or key is a known condition, not a provider's answer: reporting it as
742
+ a probe result ("unknown") after announcing a billed request says a request was made
743
+ when none could be. Checked first, so the caller can say plainly that nothing was sent.
744
+ """
745
+ from quantred.utils import extras
746
+
747
+ requirement = _PROVIDER_REQUIREMENTS.get(provider.lower())
748
+ if requirement is None: # ollama: no SDK, no key
749
+ return None
750
+ extra, key_name = requirement
751
+ if not extras.installed(extra):
752
+ return f"the SDK for {provider} is not installed (extra: {extra})"
753
+ state = inspect_env(environ=environ)
754
+ if is_placeholder(state.resolved(key_name)):
755
+ return f"{key_name} is not set"
756
+ return None
757
+
758
+
759
+ def probe_model(provider: str, model: Optional[str] = None, *, client=None) -> ProbeResult:
760
+ """One real request, one token, no retries: does this provider answer for this model?
761
+
762
+ Opt-in because it can be billed. Exactly one call is made even on failure; the
763
+ clients are built with SDK retries off, so a credit error is not sent three times.
764
+ ``client`` lets a test supply the call; production builds the same client the
765
+ research loop uses, so the probe tests the path research will take.
766
+ """
767
+ from quantred.research.llm import DEFAULT_MODELS, build_llm_client
768
+ from quantred.research.provider_errors import ModelProviderError, classify_provider_error
769
+
770
+ key = provider.lower()
771
+ if key not in PROVIDERS:
772
+ raise ValueError(f"Unknown provider {provider!r}. Choose from {list(PROVIDERS)}.")
773
+ chosen = model or DEFAULT_MODELS[key]
774
+ try:
775
+ llm = client or build_llm_client(key, chosen)
776
+ llm.max_retries = 0 # single-shot, however the client is configured for research
777
+ llm.complete("Reply with one word.", "ping", max_tokens=1)
778
+ except ModelProviderError as exc:
779
+ return ProbeResult(key, chosen, exc.category, exc.detail)
780
+ except ImportError:
781
+ extra = _PROVIDER_REQUIREMENTS.get(key, ("",))[0]
782
+ return ProbeResult(key, chosen, "unknown", f"the SDK for {key} is not installed (extra: {extra})")
783
+ except Exception as exc: # noqa: BLE001 - a raw exception from an injected client
784
+ category, detail = classify_provider_error(exc)
785
+ return ProbeResult(key, chosen, category, detail)
786
+ return ProbeResult(key, chosen, "ok", "the provider answered one request")
787
+
788
+
789
+ def _state_checks() -> List[Check]:
790
+ """Where this copy keeps its evidence, and whether anything is wrong with that.
791
+
792
+ A user who arrived through PyPI or over MCP may never learn there is a working
793
+ tree, a ``logs/`` directory or an ignore file. These rows are how the file model
794
+ gets explained to them once, in the one command that exists to explain the setup.
795
+ """
796
+ from quantred.execution.ledger import default_ledger_path
797
+ from quantred.settings import (
798
+ git_worktree_containing,
799
+ orphaned_checkout_state,
800
+ state_root,
801
+ trial_journal_path,
802
+ )
803
+
804
+ root = state_root()
805
+ checks = [
806
+ Check(
807
+ "state root", "ready", f"{root}{' (TRADEFLOW_HOME)' if os.environ.get('TRADEFLOW_HOME') else ''}"
808
+ ),
809
+ Check("research journal", "ready", str(trial_journal_path())),
810
+ Check("position ledger", "ready", str(default_ledger_path())),
811
+ ]
812
+
813
+ worktree = git_worktree_containing(root)
814
+ checks.append(
815
+ Check(
816
+ "state outside version control",
817
+ "ready" if worktree is None else "failed",
818
+ "not inside a git working tree"
819
+ if worktree is None
820
+ else (
821
+ f"state root is inside the git working tree at {worktree} — everything "
822
+ "recorded here is one ignore-file edit from disclosure, and `git clean "
823
+ "-xd` would delete it, live position ledger included"
824
+ ),
825
+ None if worktree is None else "set TRADEFLOW_HOME to a directory outside the working tree",
826
+ )
827
+ )
828
+
829
+ orphaned = orphaned_checkout_state()
830
+ if orphaned is not None:
831
+ # Said out loud rather than resolved silently: a contributor whose evidence
832
+ # lived in their checkout would otherwise deflate the next campaign against
833
+ # none of it, with nothing erroring.
834
+ checks.append(
835
+ Check(
836
+ "earlier state found",
837
+ # Informational: nothing is broken, state simply lives elsewhere now. A
838
+ # [FAIL] here exited non-zero even for `--for demo`, which needs no state.
839
+ "not_checked",
840
+ f"{orphaned / 'logs'} holds trials from when a checkout was its own "
841
+ f"state root. It is no longer being read. To keep using it: "
842
+ f"TRADEFLOW_HOME={orphaned}",
843
+ f"export TRADEFLOW_HOME={orphaned} to keep using it",
844
+ )
845
+ )
846
+
847
+ packs = _installed_packs()
848
+ if packs:
849
+ checks.append(Check("private packs installed", "ready", ", ".join(packs)))
850
+ return checks
851
+
852
+
853
+ def example_pack_source() -> Optional[Path]:
854
+ """The example pack's directory, or ``None`` when this copy does not carry it.
855
+
856
+ Present in a source checkout and in the sdist; absent from the wheel, which ships
857
+ only the package. That is deliberate - an installed copy has no use for a second
858
+ project's source tree, and the scaffold says so plainly rather than producing an
859
+ empty directory.
860
+ """
861
+ from quantred.settings import PROJECT_ROOT
862
+
863
+ candidate = PROJECT_ROOT / "example"
864
+ return candidate if (candidate / "pyproject.toml").exists() else None
865
+
866
+
867
+ def _installed_packs() -> List[str]:
868
+ """Strategy and scanner names contributed by installed packages, not built in.
869
+
870
+ Worth naming: it is the difference between "this is the example set" and "your own
871
+ work is running", and it decides whether the state-root warnings above matter.
872
+ """
873
+ try:
874
+ from quantred.services.registry import (
875
+ BUILTIN_SCANNERS,
876
+ BUILTIN_STRATEGIES,
877
+ SCANNERS,
878
+ STRATEGIES,
879
+ )
880
+ except Exception: # noqa: BLE001 - a diagnostic must not fail over its own extras
881
+ return []
882
+ builtin = {*BUILTIN_STRATEGIES, *BUILTIN_SCANNERS}
883
+ return sorted({*STRATEGIES, *SCANNERS} - builtin)
884
+
885
+
886
+ def _writable_check() -> Check:
887
+ """Whether the journal/cache directory can actually be written.
888
+
889
+ A research session that cannot journal silently loses its own trial history,
890
+ which corrupts every multiple-testing correction after it — worth checking
891
+ before the first run rather than discovering later.
892
+ """
893
+ from quantred.services.audit import DEFAULT_TRIAL_JOURNAL
894
+
895
+ directory = Path(DEFAULT_TRIAL_JOURNAL).parent
896
+ # `init --check` says it writes nothing, so directories the probe has to create are
897
+ # removed again; a run creates them for real when it first journals.
898
+ created = [parent for parent in (directory, *directory.parents) if not parent.exists()]
899
+ try:
900
+ directory.mkdir(parents=True, exist_ok=True)
901
+ probe = directory / ".write-probe"
902
+ probe.write_text("")
903
+ probe.unlink()
904
+ except PermissionError as exc:
905
+ # A host permission boundary, not a credentials problem: the fix is a different
906
+ # directory or different ownership, and no key will change it.
907
+ return Check(
908
+ "state directory",
909
+ "failed",
910
+ f"permission denied writing {directory}: {exc.strerror}",
911
+ "point TRADEFLOW_HOME at a directory you can write to, or fix its ownership",
912
+ )
913
+ except OSError as exc:
914
+ return Check(
915
+ "state directory",
916
+ "failed",
917
+ f"cannot write {directory}: {exc.strerror}",
918
+ "point TRADEFLOW_HOME at a directory you can write to",
919
+ )
920
+ for made in created: # deepest first; best effort - a failure here is not unwritability
921
+ try:
922
+ made.rmdir()
923
+ except OSError:
924
+ break
925
+ return Check("state directory", "ready", f"{directory} is writable")
926
+
927
+
928
+ def _extras_checks() -> List[Check]:
929
+ """One row per optional extra, with the exact command to install it - phrased
930
+ for the copy that is running.
931
+
932
+ The map, the probe and the recovery text all live in :mod:`quantred.utils.extras`;
933
+ the wizard configures and the user installs.
934
+ """
935
+ from quantred.utils import extras
936
+
937
+ checks = []
938
+ for name, extra in extras.EXTRAS.items():
939
+ if extras.installed(name):
940
+ checks.append(Check(f"extra: {name}", "ready", f"installed - {extra.unlocks}"))
941
+ continue
942
+ # An extra is `blocked` only where some task cannot run without it. Everything
943
+ # else is optional: absent is a fact about what is unlocked, not a failure.
944
+ gated = frozenset(extra.required_for)
945
+ checks.append(
946
+ Check(
947
+ f"extra: {name}",
948
+ "blocked" if gated else "optional_missing",
949
+ f"not installed - {extra.unlocks}",
950
+ None,
951
+ gated,
952
+ (name,),
953
+ )
954
+ )
955
+ return checks
956
+
957
+
958
+ def validate_model_setting(name: str, value: str) -> str:
959
+ """A model-setting answer in the form that gets written, or ``ValueError`` naming it.
960
+
961
+ The URL and the two numbers reuse ``settings``' own parsers, so the wizard cannot
962
+ accept a value that a research run would later refuse.
963
+ """
964
+ from urllib.parse import urlparse
965
+
966
+ from quantred.settings import (
967
+ SettingsError,
968
+ normalize_ollama_url,
969
+ parse_ollama_num_ctx,
970
+ parse_ollama_timeout,
971
+ )
972
+
973
+ text = value.strip()
974
+ try:
975
+ if name == "OLLAMA_BASE_URL":
976
+ parsed = urlparse(text)
977
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
978
+ raise ValueError(f"OLLAMA_BASE_URL must start with http:// or https://, got {text!r}.")
979
+ return normalize_ollama_url(text)
980
+ if name == "OLLAMA_TIMEOUT":
981
+ parse_ollama_timeout(text)
982
+ elif name == "OLLAMA_NUM_CTX":
983
+ parse_ollama_num_ctx(text)
984
+ except SettingsError as exc:
985
+ raise ValueError(str(exc)) from exc
986
+ return text
987
+
988
+
989
+ def build_updates(
990
+ key: Optional[str] = None,
991
+ secret: Optional[str] = None,
992
+ paper_trade: Optional[bool] = None,
993
+ *,
994
+ anthropic_key: Optional[str] = None,
995
+ openai_key: Optional[str] = None,
996
+ ollama_base_url: Optional[str] = None,
997
+ ollama_timeout: Optional[str] = None,
998
+ ollama_num_ctx: Optional[str] = None,
999
+ ) -> Dict[str, str]:
1000
+ """The ``.env`` updates for the answers given, skipping anything left blank.
1001
+
1002
+ Only what was actually answered is written, so filling in one missing key
1003
+ never rewrites the other - and a model setting left blank never overwrites a key
1004
+ that is already there with nothing. A placeholder counts as blank.
1005
+ """
1006
+ updates: Dict[str, str] = {}
1007
+ if key:
1008
+ updates["APCA_API_KEY_ID"] = key.strip()
1009
+ if secret:
1010
+ updates["APCA_API_SECRET_KEY"] = secret.strip()
1011
+ if paper_trade is not None:
1012
+ updates["PAPER_TRADE"] = "true" if paper_trade else "false"
1013
+ for name, value in (
1014
+ ("ANTHROPIC_API_KEY", anthropic_key),
1015
+ ("OPENAI_API_KEY", openai_key),
1016
+ ("OLLAMA_BASE_URL", ollama_base_url),
1017
+ ("OLLAMA_TIMEOUT", ollama_timeout),
1018
+ ("OLLAMA_NUM_CTX", ollama_num_ctx),
1019
+ ):
1020
+ if value is None or is_placeholder(value):
1021
+ continue
1022
+ updates[name] = validate_model_setting(name, value)
1023
+ return updates