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
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
|