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
quantred/settings.py ADDED
@@ -0,0 +1,571 @@
1
+ """One place credentials and runtime settings come from - resolved, validated,
2
+ and with a clear error when something's missing.
3
+
4
+ Every setting is resolved in the same predictable order:
5
+
6
+ 1. **Process environment variables** - the standard, 12-factor way.
7
+ 2. **A ``.env`` file** in the project root, loaded into the environment on first
8
+ use. No dependency: a tiny built-in parser handles ``KEY=value`` lines, and
9
+ real environment variables already set always win (standard dotenv behavior).
10
+
11
+ Alpaca credentials are required for any command that touches the market
12
+ (``scan``/``backtest``/``live``/``optimize``/...); the offline ``demo`` command
13
+ and the test suite need nothing. LLM provider keys for the research agent flow
14
+ through the very same resolver, so there is a single, obvious answer to "where
15
+ does this credential come from?"
16
+ """
17
+
18
+ import logging
19
+ import os
20
+ import re
21
+ import shutil
22
+ import sys
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Dict, Optional, Tuple
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ #: Repo root (this file is ``<root>/quantred/settings.py``). Meaningful only in a
30
+ #: checkout — an installed copy lives in site-packages, where this points at nothing
31
+ #: useful, which is exactly why the state root below is resolved separately.
32
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
33
+
34
+
35
+ #: The distribution name in ``pyproject.toml``. A checkout is identified by this
36
+ #: rather than by the mere presence of a ``pyproject.toml`` — every Python project
37
+ #: has one of those, and mistaking someone else's for ours would put our journal in
38
+ #: their repository.
39
+ DISTRIBUTION_NAME = "quantred"
40
+
41
+ #: Names this project was previously distributed under. A checkout of an older
42
+ #: revision still declares one of these, and must still be recognised as ours.
43
+ LEGACY_DISTRIBUTION_NAMES: Tuple[str, ...] = ("tradeflow-engine",)
44
+
45
+ #: The executable a reader types. The one definition of the command's name: argparse's
46
+ #: ``prog``, the version banner, the MCP launch command and every printed instruction
47
+ #: read it, so renaming the executable is this line and not a hunt through the
48
+ #: package. Deliberately separate from the import package (``quantred``), the
49
+ #: entry-point groups and :data:`DISTRIBUTION_NAME`, none of which a rename of the
50
+ #: command changes.
51
+ CLI_NAME = "quantred"
52
+
53
+ #: The product's display name, as a reader sees it in banners, report titles and
54
+ #: footers, chart titles and server log lines. Distinct from :data:`CLI_NAME` (what is
55
+ #: typed) and from the import package, which a rebrand does not touch.
56
+ BRAND = "QuantRed"
57
+
58
+ #: Executables that still launch this copy after :data:`CLI_NAME` changes. Never
59
+ #: printed as an instruction; looked up only where something must *find* a script
60
+ #: that exists on disk (an MCP client config written under an older name). This
61
+ #: distribution installs none of them: the ``tradeflow`` script comes from the final
62
+ #: ``tradeflow-engine`` release, which depends on this one, so it exists beside the
63
+ #: interpreter only when that package is installed.
64
+ LEGACY_CLI_NAMES: Tuple[str, ...] = ("tradeflow",)
65
+
66
+ #: The name this copy's MCP server reports to a client, and the key its client
67
+ #: configuration is written under. One definition for the server, the printed JSON
68
+ #: and the ``claude mcp add`` line.
69
+ MCP_SERVER_NAME = "quantred"
70
+
71
+ #: The documentation root. Pages are served from the site root (there is no ``/docs/``
72
+ #: prefix), so a link built from this constant and a page path is a page that exists.
73
+ DOCS_URL = "https://tradeflow.mk-dir.com"
74
+ #: Where the source lives: the one place an installed copy can get the example pack.
75
+ REPOSITORY_URL = "https://github.com/makedirectory/quantred"
76
+
77
+ #: The entry-point groups an installed pack registers strategies and scanners under.
78
+ #: Here, at the bottom layer, because both the registry that loads them and the
79
+ #: install hint that names the packages providing them need the same two strings.
80
+ STRATEGY_ENTRY_POINT_GROUP = "quantred.strategies"
81
+ SCANNER_ENTRY_POINT_GROUP = "quantred.scanners"
82
+
83
+ #: The groups packs registered under before the import package was renamed. Still read,
84
+ #: never written: a pack declared against the old names keeps registering without an
85
+ #: edit, and the import shim the old distribution ships lets its imports resolve.
86
+ LEGACY_ENTRY_POINT_GROUPS: Dict[str, Tuple[str, ...]] = {
87
+ STRATEGY_ENTRY_POINT_GROUP: ("tradeflow.strategies",),
88
+ SCANNER_ENTRY_POINT_GROUP: ("tradeflow.scanners",),
89
+ }
90
+
91
+
92
+ def entry_point_groups(group: str) -> Tuple[str, ...]:
93
+ """``group`` and every older name it was registered under, current name first."""
94
+ return (group, *LEGACY_ENTRY_POINT_GROUPS.get(group, ()))
95
+
96
+
97
+ def is_own_distribution(name: str) -> bool:
98
+ """Whether a distribution ``name`` is this project, under its current or an old name.
99
+
100
+ A receipt or entry point written while the project was still ``tradeflow-engine``
101
+ describes this copy, not a pack someone else installed; counting it as a pack would
102
+ tell a reader to re-add the engine to its own environment.
103
+ """
104
+ return name.lower().replace("_", "-") in (DISTRIBUTION_NAME, *LEGACY_DISTRIBUTION_NAMES)
105
+
106
+
107
+ def _looks_like_checkout(path: Path) -> bool:
108
+ """Whether ``path`` is a QuantRed working copy rather than any old directory.
109
+
110
+ Matched against :data:`DISTRIBUTION_NAME` so a rename cannot silently break
111
+ detection, and so someone else's ``pyproject.toml`` is not mistaken for ours.
112
+
113
+ The names in :data:`LEGACY_DISTRIBUTION_NAMES` count too: a working copy from before
114
+ the rename declares the old one, and refusing to recognise it would give a developer
115
+ in it the installed copy's instructions (``quantred demo`` and a ``uv tool`` install
116
+ line) in place of ``python main.py`` and ``uv sync``. That is all this decides; where
117
+ state lives is :func:`state_root`'s question and does not read it.
118
+
119
+ Only the ``[project]`` table's ``name`` counts. A ``name = "quantred"`` line under
120
+ some other table - an index called ``quantred``, say - belongs to a project that
121
+ merely mentions us. (``tomllib`` would be exact, but it is 3.11+ and this supports
122
+ 3.10.)
123
+ """
124
+ manifest = path / "pyproject.toml"
125
+ try:
126
+ text = manifest.read_text()
127
+ except OSError:
128
+ return False
129
+ names = {DISTRIBUTION_NAME, *LEGACY_DISTRIBUTION_NAMES}
130
+ table = ""
131
+ for line in text.splitlines():
132
+ header = re.match(r"^\s*(\[\[?)\s*([^\[\]]*?)\s*\]\]?\s*(#.*)?$", line)
133
+ if header:
134
+ table = header.group(2) if header.group(1) == "[" else ""
135
+ continue
136
+ if table == "project":
137
+ # Valid TOML allows indentation and single quotes; both are still the name.
138
+ found = re.match(r"""^\s*name\s*=\s*(["'])([^"']*)\1\s*(#.*)?$""", line)
139
+ if found and found.group(2) in names:
140
+ return True
141
+ return False
142
+
143
+
144
+ def invocation(command: str, *, make_target: Optional[str] = None) -> str:
145
+ """How to run ``command`` on the copy that is actually running.
146
+
147
+ An installed copy has no ``main.py`` and no Makefile, so "python main.py verdict"
148
+ and "make demo" send the reader looking for files that were never there.
149
+
150
+ Lives in ``settings`` because it is not a CLI concern and is needed below the
151
+ services: a service that tells a reader how to fetch something, and this module's
152
+ own missing-credentials message, need the same answer. A second copy of this
153
+ decision is how one surface starts printing instructions another knows are wrong.
154
+ ``services.setup.invocation`` and the CLI's ``_invocation`` delegate here.
155
+ """
156
+ if not started_from_checkout_shim():
157
+ return f"{invoked_legacy_name() or CLI_NAME} {command}"
158
+ return f"make {make_target}" if make_target else f"python main.py {command}"
159
+
160
+
161
+ def invoked_legacy_name() -> Optional[str]:
162
+ """The old command name this process was started as, when it is the only one on PATH.
163
+
164
+ A ``uv tool`` install upgraded in place from the old distribution exposes only
165
+ ``tradeflow``: the reader types it, and an instruction saying ``quantred init``
166
+ names a command their shell does not have. So the instruction echoes the name that
167
+ ran - but only while :data:`CLI_NAME` is genuinely absent, so a reader who has both
168
+ is told the current name. ``None`` otherwise.
169
+ """
170
+ started = Path(sys.argv[0]).stem if sys.argv and sys.argv[0] else ""
171
+ if started in LEGACY_CLI_NAMES and shutil.which(CLI_NAME) is None:
172
+ return started
173
+ return None
174
+
175
+
176
+ def started_from_checkout_shim() -> bool:
177
+ """Whether this process was started the contributor's way - ``python main.py`` or a
178
+ ``make`` target - from a working copy, so ``make``/``main.py`` instructions fit.
179
+
180
+ A checkout's virtual environment also carries the installed command, and someone
181
+ who typed it was told to run ``python main.py cache warm``, which fails anywhere
182
+ but the repository root. So the instruction echoes how the process was started:
183
+ the installed command's name when that is what ran, the shim otherwise. Anything
184
+ unrecognised (a test runner, ``python -c``) keeps the checkout's own spelling.
185
+ """
186
+ if not running_from_checkout() or in_container():
187
+ return False
188
+ started = Path(sys.argv[0]).stem if sys.argv and sys.argv[0] else ""
189
+ return started not in (CLI_NAME, *LEGACY_CLI_NAMES)
190
+
191
+
192
+ #: Set by the project's Dockerfile. The image carries `main.py` and `pyproject.toml`, so
193
+ #: it looks like a checkout, but it has no Makefile and no `.env.example`: a container
194
+ #: told to run `make init` was sent to files that were never copied in.
195
+ CONTAINER_ENV = "QUANTRED_CONTAINER"
196
+
197
+
198
+ def in_container() -> bool:
199
+ """Whether this process runs in the project's own image."""
200
+ return bool(os.environ.get(CONTAINER_ENV))
201
+
202
+
203
+ def running_from_checkout() -> bool:
204
+ """Whether the running ``quantred`` package was imported from a working copy.
205
+
206
+ Decided by where the package itself lives, never by the working directory: an
207
+ installed copy run from inside some checkout of this repository is still an
208
+ installed copy, and telling its user to ``uv sync`` there is wrong. It also means
209
+ there is no ``Path.cwd()`` on the path of the flatten report, the halt message or
210
+ the missing-credentials message, which raises when the directory was deleted -
211
+ in code that runs after money has already moved.
212
+
213
+ Deliberately separate from :func:`state_root`. *How the software was reached*
214
+ decides which instructions make sense - ``make demo`` against ``quantred demo``.
215
+ *Where state lives* is a safety question with a different answer. Conflating them
216
+ is how a message meant for a contributor reached an installed user, and it would
217
+ now also send a contributor the wrong install command.
218
+ """
219
+ return _looks_like_checkout(PROJECT_ROOT)
220
+
221
+
222
+ def state_root() -> Path:
223
+ """Where the journal, trial store, bar cache, and configs live.
224
+
225
+ Resolved most explicit first:
226
+
227
+ 1. ``TRADEFLOW_HOME``, when set - the escape hatch, what a container mounts, and
228
+ how a contributor opts back in to checkout-local state.
229
+ 2. ``~/.tradeflow`` otherwise. Always, including inside a checkout.
230
+
231
+ **The default does not depend on how you got here, and it never writes into a
232
+ working copy.** It used to: a checkout was its own state root, so a contributor's
233
+ ``logs/`` kept working the way it always had. That was convenient and it was wrong
234
+ in two directions at once. A private strategy's trial evidence, saved configs and
235
+ live position ledger accumulated inside a git working tree, protected by nothing
236
+ but ``.gitignore``; and ``git clean -xd`` would take the lot, live ledger included.
237
+
238
+ A user arriving through PyPI or over MCP may never learn there is a working tree,
239
+ a ``logs/`` directory or an ignore file, so a warning cannot carry this. The safe
240
+ root has to be the one they get without knowing to ask - which means it cannot be
241
+ something ``init`` arranges, because ``init`` may never be run.
242
+
243
+ The multiple-testing correction still rests on **one** journal per root: a campaign
244
+ split across two of them deflates its Sharpe against half the evidence it should,
245
+ and nothing errors. That is why the escape hatch is a single explicit variable
246
+ rather than a per-pack or per-project rule.
247
+
248
+ ``init --check`` prints the result, because "where did my trials go" must never be
249
+ a question a user has to reverse-engineer - and after this change it is a question
250
+ every existing contributor will have.
251
+ """
252
+ override = os.environ.get("TRADEFLOW_HOME")
253
+ if override:
254
+ return Path(override).expanduser()
255
+ return Path.home() / ".tradeflow"
256
+
257
+
258
+ def git_worktree_containing(path: Path) -> Optional[Path]:
259
+ """The git working tree ``path`` sits inside, or ``None``.
260
+
261
+ State inside a working tree is exposed to everything that acts on a repository:
262
+ an ignore file one edit away from disclosing it, an archive of the tree, and
263
+ ``git clean -xd``, which deletes it outright.
264
+ """
265
+ try:
266
+ candidate = path.expanduser().resolve()
267
+ except OSError:
268
+ return None
269
+ for directory in (candidate, *candidate.parents):
270
+ if (directory / ".git").exists():
271
+ return directory
272
+ return None
273
+
274
+
275
+ def orphaned_checkout_state() -> Optional[Path]:
276
+ """A checkout's own state directory that is no longer the active root.
277
+
278
+ Returned so it can be *said out loud*. Contributors accumulated journals and trial
279
+ stores inside their working copies for as long as a checkout was its own root, and
280
+ silently resolving somewhere else would deflate their next campaign against none of
281
+ that evidence without erroring - the exact failure :func:`state_root` exists to
282
+ prevent, caused by the fix for a different one.
283
+ """
284
+ if not _looks_like_checkout(PROJECT_ROOT):
285
+ return None
286
+ logs = PROJECT_ROOT / "logs"
287
+ try:
288
+ if state_root().expanduser().resolve() == PROJECT_ROOT.resolve():
289
+ return None
290
+ has_evidence = any(
291
+ (logs / name).exists()
292
+ for name in ("research_journal.jsonl", "trials.db", "position_ledger.jsonl")
293
+ )
294
+ except OSError:
295
+ return None
296
+ return PROJECT_ROOT if has_evidence else None
297
+
298
+
299
+ def trial_journal_path() -> Path:
300
+ """Where the research journal lives — one definition, for every reader and writer.
301
+
302
+ It was defined twice, in ``services.audit`` and ``store.trials``, kept in step by a
303
+ comment. Had they diverged the store would have indexed a different file from the
304
+ one being written, and the multiple-testing correction would have deflated against
305
+ half its evidence — with nothing erroring, because both paths are valid.
306
+
307
+ Here rather than in either of them because ``settings`` is the layer both already
308
+ depend on; the reverse would be a cycle.
309
+ """
310
+ return state_path("logs", "research_journal.jsonl")
311
+
312
+
313
+ def state_path(*parts: str) -> Path:
314
+ """A path under the state root, with its parent directory created.
315
+
316
+ Callers ask for ``state_path("logs", "research_journal.jsonl")`` rather than
317
+ building a relative path, so there is one answer to where state lives.
318
+ """
319
+ path = state_root().joinpath(*parts)
320
+ path.parent.mkdir(parents=True, exist_ok=True)
321
+ return path
322
+
323
+
324
+ #: Where credentials are read from. In a checkout this is the repo's own ``.env``,
325
+ #: exactly as before; an installed copy reads ``~/.tradeflow/.env``, since there is
326
+ #: no repo to put one in.
327
+ ENV_PATH = state_root() / ".env"
328
+
329
+ #: Values that mean "not actually filled in" (the ``.env.example`` placeholders).
330
+ _PLACEHOLDERS = frozenset({"", "<APCA_API_KEY_ID>", "<APCA_API_SECRET_KEY>"})
331
+
332
+ _dotenv_loaded = False
333
+
334
+
335
+ class SettingsError(RuntimeError):
336
+ """Raised when required configuration is missing or malformed.
337
+
338
+ Carries a human-readable, actionable message; CLI entry points print it and
339
+ exit cleanly rather than dumping a traceback.
340
+ """
341
+
342
+
343
+ def _load_dotenv_once() -> None:
344
+ """Parse ``.env`` into ``os.environ`` exactly once (no-op if absent).
345
+
346
+ Minimal on purpose: ``KEY=value`` lines, ``#`` comments and blanks skipped,
347
+ surrounding quotes stripped. Existing environment variables are never
348
+ overwritten, so an explicit ``export`` still takes precedence over the file.
349
+ """
350
+ global _dotenv_loaded
351
+ if _dotenv_loaded:
352
+ return
353
+ _dotenv_loaded = True
354
+ if not ENV_PATH.exists():
355
+ return
356
+ for raw in ENV_PATH.read_text().splitlines():
357
+ line = raw.strip()
358
+ if not line or line.startswith("#") or "=" not in line:
359
+ continue
360
+ key, _, value = line.partition("=")
361
+ os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
362
+
363
+
364
+ def get_credential(name: str, default: Optional[str] = None) -> Optional[str]:
365
+ """Resolve a single named setting from the environment (or ``.env``).
366
+
367
+ Used for LLM provider keys (``ANTHROPIC_API_KEY`` ...) and the Alpaca keys
368
+ alike, so every credential follows one rule.
369
+ """
370
+ _load_dotenv_once()
371
+ return os.environ.get(name) or default
372
+
373
+
374
+ #: Ollama's default local endpoint.
375
+ DEFAULT_OLLAMA_URL = "http://localhost:11434"
376
+ DEFAULT_OLLAMA_TIMEOUT = 120.0
377
+
378
+
379
+ def normalize_ollama_url(url: str) -> str:
380
+ """The server root, whether the user gave it bare or as a full chat/generate URL.
381
+
382
+ People copy the URL from the API reference, which names the endpoint, and the client
383
+ appends the endpoint itself - so ``.../api/chat`` would otherwise become
384
+ ``.../api/chat/api/chat`` and fail as a 404 that reads like a missing model.
385
+ """
386
+ cleaned = url.strip().rstrip("/")
387
+ for suffix in ("/api/chat", "/api/generate"):
388
+ if cleaned.endswith(suffix):
389
+ cleaned = cleaned[: -len(suffix)]
390
+ break
391
+ return cleaned.rstrip("/")
392
+
393
+
394
+ def ollama_base_url() -> str:
395
+ """``OLLAMA_BASE_URL`` (environment / ``.env``), normalised; no network."""
396
+ return normalize_ollama_url(get_credential("OLLAMA_BASE_URL", DEFAULT_OLLAMA_URL) or DEFAULT_OLLAMA_URL)
397
+
398
+
399
+ def parse_ollama_timeout(raw: Optional[str]) -> float:
400
+ """``OLLAMA_TIMEOUT`` text as seconds; blank means the default.
401
+
402
+ One parser for the settings reader and for ``init``, so a value the wizard accepts
403
+ is exactly one a research run will accept.
404
+ """
405
+ if raw is None or not raw.strip():
406
+ return DEFAULT_OLLAMA_TIMEOUT
407
+ try:
408
+ value = float(raw)
409
+ except ValueError:
410
+ value = float("nan")
411
+ if not value > 0 or value == float("inf"):
412
+ raise SettingsError(f"OLLAMA_TIMEOUT must be a positive number of seconds, got {raw!r}.")
413
+ return value
414
+
415
+
416
+ def parse_ollama_num_ctx(raw: Optional[str]) -> Optional[int]:
417
+ """``OLLAMA_NUM_CTX`` text as a token count; blank means send nothing (``None``)."""
418
+ if raw is None or not raw.strip():
419
+ return None
420
+ try:
421
+ value = int(raw)
422
+ except ValueError:
423
+ value = 0
424
+ if value <= 0:
425
+ raise SettingsError(f"OLLAMA_NUM_CTX must be a positive whole number of tokens, got {raw!r}.")
426
+ return value
427
+
428
+
429
+ def ollama_timeout() -> float:
430
+ """``OLLAMA_TIMEOUT`` in seconds. A slow local GPU can need minutes per response."""
431
+ return parse_ollama_timeout(get_credential("OLLAMA_TIMEOUT"))
432
+
433
+
434
+ def ollama_num_ctx() -> Optional[int]:
435
+ """``OLLAMA_NUM_CTX``: the context length to request, or ``None`` to send nothing.
436
+
437
+ Never inferred - a model's own default is the right behaviour until someone with a
438
+ memory-constrained machine says otherwise.
439
+ """
440
+ return parse_ollama_num_ctx(get_credential("OLLAMA_NUM_CTX"))
441
+
442
+
443
+ def _parse_bool(value: object, default: bool = True) -> bool:
444
+ if isinstance(value, bool):
445
+ return value
446
+ if value is None:
447
+ return default
448
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
449
+
450
+
451
+ def _resolve_paper_trade() -> bool:
452
+ """Resolve ``PAPER_TRADE`` (defaults to ``True`` - the safe choice)."""
453
+ _load_dotenv_once()
454
+ if "PAPER_TRADE" in os.environ:
455
+ return _parse_bool(os.environ["PAPER_TRADE"])
456
+ return True
457
+
458
+
459
+ def paper_trade_mode() -> bool:
460
+ """Whether orders would go to the paper account, without needing credentials.
461
+
462
+ Separate from :func:`load_settings` because reporting the broker mode must work in
463
+ a preflight that has not yet proved it can reach a venue - refusing to say "this is
464
+ paper" because the keys are missing would withhold the one fact most worth printing
465
+ before an order path starts.
466
+ """
467
+ return _resolve_paper_trade()
468
+
469
+
470
+ #: Feeds a live run may be pinned to. ``sip`` is the full consolidated tape;
471
+ #: ``iex`` is a single venue, free but partial.
472
+ DATA_FEEDS = ("iex", "sip", "delayed_sip")
473
+
474
+
475
+ def data_feed() -> Optional[str]:
476
+ """Which Alpaca market-data feed to pin, or ``None`` to leave the SDK's defaults.
477
+
478
+ ``None`` is deliberately the default and must stay that way. Pinning a feed
479
+ globally would mean an entitled account silently trading a partial venue, or a
480
+ delayed tape, with nothing in the output to say so - the failure this exists to
481
+ prevent, inverted. A run that needs a specific feed says so explicitly.
482
+ """
483
+ _load_dotenv_once()
484
+ value = (os.environ.get("ALPACA_DATA_FEED") or "").strip().lower()
485
+ if not value:
486
+ return None
487
+ if value not in DATA_FEEDS:
488
+ raise SettingsError(
489
+ f"ALPACA_DATA_FEED={value!r} is not a feed this supports ({', '.join(DATA_FEEDS)})."
490
+ )
491
+ return value
492
+
493
+
494
+ @dataclass(frozen=True)
495
+ class Settings:
496
+ """Validated runtime settings - currently the Alpaca account credentials."""
497
+
498
+ alpaca_key: str
499
+ alpaca_secret: str
500
+ paper_trade: bool = True
501
+
502
+
503
+ #: Where free paper-trading keys come from, said the same way everywhere.
504
+ ALPACA_KEYS_URL = "https://app.alpaca.markets/ (Paper Account -> API Keys)"
505
+
506
+
507
+ def _missing_credentials_message(missing: list) -> str:
508
+ """What to do about missing keys, phrased for the copy that is running.
509
+
510
+ This is the first thing most people see, and it used to tell an installed user
511
+ to copy a ``.env.example`` they do not have and run a ``make`` target that does
512
+ not exist — two dead ends and no mention of the one command that actually fixes
513
+ it. A pip-installed copy has no repository, so any instruction that assumes one
514
+ is worse than no instruction: it sends someone looking for a file that was never
515
+ there.
516
+ """
517
+ setup_path = state_root() / ".env"
518
+ lines = [
519
+ "Missing Alpaca credentials: " + ", ".join(missing) + ".",
520
+ # Said outright because the reader has usually just run the keyless demo, and
521
+ # nothing else told them why this command is different.
522
+ "Fetching market data and reaching the broker both need them; most research commands "
523
+ "(not `research` or `demo-agent`) can run --offline from bars already in the local cache without them.",
524
+ ]
525
+
526
+ if started_from_checkout_shim():
527
+ lines += [
528
+ "",
529
+ " make init guided setup (or copy .env.example to .env)",
530
+ " make demo no keys needed — the full pipeline on synthetic data",
531
+ ]
532
+ elif in_container():
533
+ # The compose file mounts the host's .env read-only at the state root, so `init`
534
+ # inside the container could not write it; the keys go in on the host.
535
+ lines += [
536
+ "",
537
+ f" Put them in the .env beside docker-compose.yml on the host; compose mounts it "
538
+ f"read-only at {setup_path}.",
539
+ f" {invocation('demo'):<25} no keys needed — the full pipeline on synthetic data",
540
+ ]
541
+ else:
542
+ lines += [
543
+ "",
544
+ f" {invocation('init'):<25} guided setup — writes " + str(setup_path),
545
+ f" {invocation('demo'):<25} no keys needed — the full pipeline on synthetic data",
546
+ ]
547
+ lines += [
548
+ "",
549
+ f"Free paper-trading keys: {ALPACA_KEYS_URL}",
550
+ "Environment variables (APCA_API_KEY_ID / APCA_API_SECRET_KEY) are honored too.",
551
+ ]
552
+ return "\n".join(lines)
553
+
554
+
555
+ def load_settings() -> Settings:
556
+ """Resolve and validate the Alpaca credentials, or raise :class:`SettingsError`.
557
+
558
+ Raising (rather than returning partial settings) means no command can run
559
+ against the market with half-configured keys.
560
+ """
561
+ key = get_credential("APCA_API_KEY_ID")
562
+ secret = get_credential("APCA_API_SECRET_KEY")
563
+
564
+ missing = [
565
+ name
566
+ for name, value in (("APCA_API_KEY_ID", key), ("APCA_API_SECRET_KEY", secret))
567
+ if not value or value in _PLACEHOLDERS
568
+ ]
569
+ if missing:
570
+ raise SettingsError(_missing_credentials_message(missing))
571
+ return Settings(alpaca_key=key, alpaca_secret=secret, paper_trade=_resolve_paper_trade())
File without changes