quantred 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|