alphaengine 0.5.0__tar.gz → 0.6.1__tar.gz
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.
- alphaengine-0.6.1/AGENTS.md +55 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/PKG-INFO +12 -4
- {alphaengine-0.5.0 → alphaengine-0.6.1}/README.md +9 -2
- {alphaengine-0.5.0 → alphaengine-0.6.1}/pyproject.toml +2 -1
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/_version.py +19 -1
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/auth.py +16 -1
- alphaengine-0.6.1/src/alphaengine/book.py +158 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/cli.py +462 -107
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/client/agent.py +41 -6
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/client/executor.py +201 -1
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/client/session.py +8 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/commands.py +63 -9
- alphaengine-0.6.1/src/alphaengine/complete.py +71 -0
- alphaengine-0.6.1/src/alphaengine/connectors/__init__.py +75 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/technical.py +6 -4
- alphaengine-0.6.1/src/alphaengine/core/walkforward.py +139 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo.py +12 -0
- alphaengine-0.6.1/src/alphaengine/events.py +195 -0
- alphaengine-0.6.1/src/alphaengine/model.py +500 -0
- alphaengine-0.6.1/src/alphaengine/repl.py +75 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/sweep/runner.py +37 -18
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_agent.py +8 -0
- alphaengine-0.6.1/tests/test_book.py +40 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_cli.py +27 -0
- alphaengine-0.6.1/tests/test_events.py +57 -0
- alphaengine-0.6.1/tests/test_executor_ops.py +62 -0
- alphaengine-0.6.1/tests/test_model.py +96 -0
- alphaengine-0.5.0/src/alphaengine/model.py +0 -248
- {alphaengine-0.5.0 → alphaengine-0.6.1}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/.gitignore +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/LICENSE +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/SECURITY.md +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/scripts/gen_docs.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/__init__.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/__main__.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/agent/answer.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/client/__init__.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/__init__.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/backtest.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/factors.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/pairs.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/performance.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/profile.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/risk.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/screen.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/series_shapes.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/signals.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/stress.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/validation.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo_book.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo_returns.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo_signal.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo_universe.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/loaders.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/study/report.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/study/schema.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_answer.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_client.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_commands.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_goldens.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_loaders.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_screen.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_signals.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_smoke.py +0 -0
- {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_sweep.py +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
## Cursor Cloud specific instructions
|
|
4
|
+
|
|
5
|
+
AlphaEngine is a pure-Python CLI + library (`alphaengine`) for validated quant
|
|
6
|
+
research. There is no GUI; all interaction is via the terminal or
|
|
7
|
+
`import alphaengine`.
|
|
8
|
+
|
|
9
|
+
### Environment
|
|
10
|
+
|
|
11
|
+
- Dependencies live in `.venv` (Python 3.12). Use `.venv/bin/<tool>` or
|
|
12
|
+
`source .venv/bin/activate`. The console script is `.venv/bin/alphaengine`.
|
|
13
|
+
- Installed editable with `dev` and `factors` extras so statsmodels is present.
|
|
14
|
+
|
|
15
|
+
### Lint / test / build / run
|
|
16
|
+
|
|
17
|
+
- Lint: `.venv/bin/python -m ruff check src tests` and
|
|
18
|
+
`.venv/bin/python -m ruff format --check src tests`
|
|
19
|
+
- Types: `.venv/bin/python -m mypy src`
|
|
20
|
+
- Tests: `.venv/bin/python -m pytest` and
|
|
21
|
+
`.venv/bin/python -m pytest tests -m golden -q`
|
|
22
|
+
- Docs guard: `.venv/bin/python scripts/gen_docs.py --check`
|
|
23
|
+
(regenerate with `--write`). A benign `SyntaxWarning: invalid escape sequence '\|'`
|
|
24
|
+
is pre-existing.
|
|
25
|
+
- Offline app: `.venv/bin/alphaengine demo`
|
|
26
|
+
|
|
27
|
+
### Non-obvious gotchas
|
|
28
|
+
|
|
29
|
+
- **Three rungs.** `demo` and the importable library are fully offline. Workflow
|
|
30
|
+
CLI verbs (`diagnose`, `screen`, …, `run <workflow>`) need a portal
|
|
31
|
+
`ae_live_` key (`QUANTOS_API_KEY`). Plain-English mode also needs a **model**
|
|
32
|
+
key. Any OpenAI-compatible key works: Anthropic, OpenAI (`OPENAI_BASE_URL` for
|
|
33
|
+
gateways), Gemini, Groq, OpenRouter, Azure, or
|
|
34
|
+
`ALPHAENGINE_API_KEY`+`ALPHAENGINE_BASE_URL`. `alphaengine models` lists what
|
|
35
|
+
this machine can actually use. Keys may persist in
|
|
36
|
+
`~/.config/alphaengine/credentials.json` (mode 0600) if the user says yes at
|
|
37
|
+
`key <provider>`; env always wins. LLM keys are never sent to QuantOS.
|
|
38
|
+
- **A stop / `marginal` verdict exits 0.** Only a step that could not execute
|
|
39
|
+
exits non-zero. Unauthenticated workflow calls exit 2.
|
|
40
|
+
- **Data never leaves.** Executor and study-report guards refuse lists longer
|
|
41
|
+
than 512 by length. Model telemetry (choices, why, post-guard answers, prompt
|
|
42
|
+
hashes) may POST to `/api/harness/runs/{id}/events` when logged in; a 404 is
|
|
43
|
+
swallowed and the same allowlist is always written to
|
|
44
|
+
`~/.local/share/alphaengine/runs/<id>.jsonl`. Never send prompts, series, or
|
|
45
|
+
keys. `alphaengine trace` reads the local dump.
|
|
46
|
+
- **Goldens are a public contract.** Do not "fix" a golden to land a speedup;
|
|
47
|
+
that is a version bump (`_version.py`).
|
|
48
|
+
- **New compute ops** (`backtest`, `score_backtest`, `cpcv`, `factors`, `pairs`,
|
|
49
|
+
`cointegrated_pairs`, `walk_forward`, `book_overlap`) are on the executor.
|
|
50
|
+
The portal must offer them in a workflow graph or they sit unused; the CLI
|
|
51
|
+
remains useful without them.
|
|
52
|
+
- `sweep(..., jobs=N)` defaults to 1. Greater than 1 is opt-in and must keep
|
|
53
|
+
trial index identity, including failures.
|
|
54
|
+
- The `connectors` extra is lazy: parquet via `pyarrow`, HTTP via `httpx` to a
|
|
55
|
+
URL the caller names. `import alphaengine` must not import httpx.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: alphaengine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts.
|
|
5
5
|
Project-URL: Homepage, https://github.com/quantOSC/alphaengine
|
|
6
6
|
Project-URL: Documentation, https://github.com/quantOSC/alphaengine#readme
|
|
@@ -26,6 +26,7 @@ Requires-Dist: numpy>=1.24
|
|
|
26
26
|
Requires-Dist: scipy>=1.10
|
|
27
27
|
Provides-Extra: agents
|
|
28
28
|
Requires-Dist: anthropic>=0.40; extra == 'agents'
|
|
29
|
+
Requires-Dist: openai>=1.0; extra == 'agents'
|
|
29
30
|
Provides-Extra: connectors
|
|
30
31
|
Requires-Dist: httpx>=0.27; extra == 'connectors'
|
|
31
32
|
Requires-Dist: pyarrow>=15.0; extra == 'connectors'
|
|
@@ -153,8 +154,11 @@ distinction is the whole of the data boundary below.
|
|
|
153
154
|
| `gaps` | what your record says is UNANSWERED, and what closes each one | shell + session |
|
|
154
155
|
| `tonight [--budget N]` | what would run unattended tonight, without running any of it | shell + session |
|
|
155
156
|
| `workflows` | what the server offers, what each needs, and which reproduce | shell + session |
|
|
156
|
-
| `key [quantos \| anthropic \| openai]` | enter a credential now, or see which rungs are unlocked | session |
|
|
157
|
+
| `key [quantos \| anthropic \| openai \| gemini \| groq \| azure \| openrouter \| gateway]` | enter a credential now, or see which rungs are unlocked | session |
|
|
157
158
|
| `commands [verb]` | this directory, or one command in full | shell + session |
|
|
159
|
+
| `models` | which model providers this machine can actually use | shell + session |
|
|
160
|
+
| `model [<provider[:name]>]` | pin the model for this session, or show the pin | session |
|
|
161
|
+
| `trace [run_id]` | local model/run events for a run, hashed prompts only | shell + session |
|
|
158
162
|
|
|
159
163
|
### Do the work
|
|
160
164
|
|
|
@@ -176,8 +180,9 @@ distinction is the whole of the data boundary below.
|
|
|
176
180
|
|
|
177
181
|
| Command | Does | Where |
|
|
178
182
|
|---|---|---|
|
|
183
|
+
| `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
|
|
179
184
|
| `universe <name>` | load a universe you registered in the portal, with its closes | session |
|
|
180
|
-
| `data <file
|
|
185
|
+
| `data <file>` | load a local CSV or parquet without leaving the session | session |
|
|
181
186
|
| `project <module>` | load `data` and `backtest_fn` from a module of yours | session |
|
|
182
187
|
|
|
183
188
|
### Session
|
|
@@ -210,6 +215,7 @@ distinction is the whole of the data boundary below.
|
|
|
210
215
|
| `--label TEXT` | what to call the artifact this run produces |
|
|
211
216
|
| `--input K=V` | a workflow input; repeatable |
|
|
212
217
|
| `--quiet` | only the result, no step narration |
|
|
218
|
+
| `--stream` | print the answer once the citation guard has passed |
|
|
213
219
|
| `--limit N` | how many rows to show (default 25) |
|
|
214
220
|
| `--budget N` | how many runs a night is worth (default 3) |
|
|
215
221
|
|
|
@@ -235,6 +241,8 @@ alphaengine commands
|
|
|
235
241
|
alphaengine run screen_universe --universe sp500
|
|
236
242
|
alphaengine run size_position --data returns.csv
|
|
237
243
|
alphaengine run validate_study --project research.momentum
|
|
244
|
+
alphaengine models
|
|
245
|
+
alphaengine trace
|
|
238
246
|
alphaengine logout
|
|
239
247
|
alphaengine version
|
|
240
248
|
```
|
|
@@ -112,8 +112,11 @@ distinction is the whole of the data boundary below.
|
|
|
112
112
|
| `gaps` | what your record says is UNANSWERED, and what closes each one | shell + session |
|
|
113
113
|
| `tonight [--budget N]` | what would run unattended tonight, without running any of it | shell + session |
|
|
114
114
|
| `workflows` | what the server offers, what each needs, and which reproduce | shell + session |
|
|
115
|
-
| `key [quantos \| anthropic \| openai]` | enter a credential now, or see which rungs are unlocked | session |
|
|
115
|
+
| `key [quantos \| anthropic \| openai \| gemini \| groq \| azure \| openrouter \| gateway]` | enter a credential now, or see which rungs are unlocked | session |
|
|
116
116
|
| `commands [verb]` | this directory, or one command in full | shell + session |
|
|
117
|
+
| `models` | which model providers this machine can actually use | shell + session |
|
|
118
|
+
| `model [<provider[:name]>]` | pin the model for this session, or show the pin | session |
|
|
119
|
+
| `trace [run_id]` | local model/run events for a run, hashed prompts only | shell + session |
|
|
117
120
|
|
|
118
121
|
### Do the work
|
|
119
122
|
|
|
@@ -135,8 +138,9 @@ distinction is the whole of the data boundary below.
|
|
|
135
138
|
|
|
136
139
|
| Command | Does | Where |
|
|
137
140
|
|---|---|---|
|
|
141
|
+
| `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
|
|
138
142
|
| `universe <name>` | load a universe you registered in the portal, with its closes | session |
|
|
139
|
-
| `data <file
|
|
143
|
+
| `data <file>` | load a local CSV or parquet without leaving the session | session |
|
|
140
144
|
| `project <module>` | load `data` and `backtest_fn` from a module of yours | session |
|
|
141
145
|
|
|
142
146
|
### Session
|
|
@@ -169,6 +173,7 @@ distinction is the whole of the data boundary below.
|
|
|
169
173
|
| `--label TEXT` | what to call the artifact this run produces |
|
|
170
174
|
| `--input K=V` | a workflow input; repeatable |
|
|
171
175
|
| `--quiet` | only the result, no step narration |
|
|
176
|
+
| `--stream` | print the answer once the citation guard has passed |
|
|
172
177
|
| `--limit N` | how many rows to show (default 25) |
|
|
173
178
|
| `--budget N` | how many runs a night is worth (default 3) |
|
|
174
179
|
|
|
@@ -194,6 +199,8 @@ alphaengine commands
|
|
|
194
199
|
alphaengine run screen_universe --universe sp500
|
|
195
200
|
alphaengine run size_position --data returns.csv
|
|
196
201
|
alphaengine run validate_study --project research.momentum
|
|
202
|
+
alphaengine models
|
|
203
|
+
alphaengine trace
|
|
197
204
|
alphaengine logout
|
|
198
205
|
alphaengine version
|
|
199
206
|
```
|
|
@@ -68,6 +68,7 @@ factors = [
|
|
|
68
68
|
# Sharpe should not need an LLM dependency to get one.
|
|
69
69
|
agents = [
|
|
70
70
|
"anthropic>=0.40",
|
|
71
|
+
"openai>=1.0",
|
|
71
72
|
]
|
|
72
73
|
# Pulling data from a provider the customer already pays for.
|
|
73
74
|
connectors = [
|
|
@@ -178,5 +179,5 @@ ignore_missing_imports = true
|
|
|
178
179
|
# import is an error, so this is the difference between a build that passes on a
|
|
179
180
|
# machine that happens to have them and one that passes anywhere.
|
|
180
181
|
[[tool.mypy.overrides]]
|
|
181
|
-
module = ["scipy.*", "statsmodels.*"]
|
|
182
|
+
module = ["scipy.*", "statsmodels.*", "pyarrow.*", "httpx"]
|
|
182
183
|
ignore_missing_imports = true
|
|
@@ -44,4 +44,22 @@ is what a changed figure costs. The API may still move underneath it.
|
|
|
44
44
|
# `tonight`. The record already knew which workflow closes each open gap and
|
|
45
45
|
# nothing traversed those edges; these two read that derivation, so the terminal
|
|
46
46
|
# can now answer "what have I NOT tried" and "what will run while I sleep".
|
|
47
|
-
|
|
47
|
+
#
|
|
48
|
+
# 0.6.0 IS THE SAME RULE READ AS A FEATURE RELEASE. No computed value moved.
|
|
49
|
+
# The artefact grew a research OS around the maths that already shipped: any
|
|
50
|
+
# BYOK model (Anthropic, OpenAI-compatible, Gemini, Groq, OpenRouter, Azure,
|
|
51
|
+
# a private gateway), the workflow ops that were library-only (`backtest`,
|
|
52
|
+
# CPCV, factors, pairs, walk-forward, book overlap), allowlisted traces that
|
|
53
|
+
# stay on disk when the portal has no route, a session with history and slash
|
|
54
|
+
# verbs, and a screen that paints the parameter surface it is judging.
|
|
55
|
+
#
|
|
56
|
+
# A new published wheel is a new thing people `pip install`. While the leading
|
|
57
|
+
# digit is 0 the MINOR position carries that, the same way 0.5.0 carried a
|
|
58
|
+
# surface change that left every figure byte-identical.
|
|
59
|
+
#
|
|
60
|
+
# 0.6.1 IS THE SCREEN, NOT THE MATHS. No computed value moved. The session is a
|
|
61
|
+
# transcript now: a hue-cycling wait-star over a travelling plateau, cards for
|
|
62
|
+
# what a step found, a composer instead of a dashboard. Same rungs, same
|
|
63
|
+
# doors, same refusals. The published artefact is a new wheel because 0.6.0
|
|
64
|
+
# already left the building.
|
|
65
|
+
__version__ = "0.6.1"
|
|
@@ -55,7 +55,22 @@ __all__ = ["config_path", "load_stored", "save_key", "clear_stored", "apply_stor
|
|
|
55
55
|
#: `QUANTOS_API_KEY` is ours. The provider keys are here because a user who has
|
|
56
56
|
#: said "sign me in" means all of it, and leaving one of the three to be pasted
|
|
57
57
|
#: every session defeats the point.
|
|
58
|
-
MANAGED = (
|
|
58
|
+
MANAGED = (
|
|
59
|
+
"QUANTOS_API_KEY",
|
|
60
|
+
"ANTHROPIC_API_KEY",
|
|
61
|
+
"OPENAI_API_KEY",
|
|
62
|
+
"GEMINI_API_KEY",
|
|
63
|
+
"GOOGLE_API_KEY",
|
|
64
|
+
"GROQ_API_KEY",
|
|
65
|
+
"OPENROUTER_API_KEY",
|
|
66
|
+
"AZURE_OPENAI_API_KEY",
|
|
67
|
+
"AZURE_OPENAI_ENDPOINT",
|
|
68
|
+
"ALPHAENGINE_API_KEY",
|
|
69
|
+
"ALPHAENGINE_BASE_URL",
|
|
70
|
+
"ALPHAENGINE_MODEL",
|
|
71
|
+
"ALPHAENGINE_PROVIDER",
|
|
72
|
+
"OPENAI_BASE_URL",
|
|
73
|
+
)
|
|
59
74
|
|
|
60
75
|
|
|
61
76
|
def config_path() -> Path:
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""A multi-strategy book: named sleeves, figures, never an OMS.
|
|
2
|
+
|
|
3
|
+
WHAT THIS IS
|
|
4
|
+
The overlap/size/monitor questions, asked of a STACK of return series rather
|
|
5
|
+
than one candidate vs one book series. A desk that runs six sleeves needs to
|
|
6
|
+
know how a seventh sits on the whole stack, and whether any sleeve is
|
|
7
|
+
already outside its lines.
|
|
8
|
+
|
|
9
|
+
WHAT THIS IS NOT
|
|
10
|
+
Orders, shares, brokers, or target weights that become fills. `save_signals`
|
|
11
|
+
still files ticker/rank/score/weight. A book here is a research object.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .core.risk import compute_var_cvar
|
|
22
|
+
from .core.stress import overlap_stats
|
|
23
|
+
from .core.validation import min_track_record_length
|
|
24
|
+
|
|
25
|
+
__all__ = ["Book", "MAX_SLEEVES"]
|
|
26
|
+
|
|
27
|
+
#: A correlation matrix over 2000 names is a data export. Cap the book so the
|
|
28
|
+
#: figures that leave it stay figures.
|
|
29
|
+
MAX_SLEEVES = 16
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _as_returns(values: Any) -> list[float]:
|
|
33
|
+
if isinstance(values, dict) and "returns" in values:
|
|
34
|
+
values = values["returns"]
|
|
35
|
+
return [float(x) for x in list(values)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Book:
|
|
40
|
+
"""Named sleeves of return series, plus optional current weights."""
|
|
41
|
+
|
|
42
|
+
sleeves: dict[str, list[float]] = field(default_factory=dict)
|
|
43
|
+
weights: dict[str, float] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
def add(self, name: str, returns: Any, *, weight: float | None = None) -> None:
|
|
46
|
+
if len(self.sleeves) >= MAX_SLEEVES and name not in self.sleeves:
|
|
47
|
+
raise ValueError(f"a book holds at most {MAX_SLEEVES} sleeves; remove one first")
|
|
48
|
+
self.sleeves[str(name)] = _as_returns(returns)
|
|
49
|
+
if weight is not None:
|
|
50
|
+
self.weights[str(name)] = float(weight)
|
|
51
|
+
|
|
52
|
+
def drop(self, name: str) -> None:
|
|
53
|
+
self.sleeves.pop(name, None)
|
|
54
|
+
self.weights.pop(name, None)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def names(self) -> list[str]:
|
|
58
|
+
return list(self.sleeves)
|
|
59
|
+
|
|
60
|
+
def combined_returns(self) -> list[float]:
|
|
61
|
+
"""Equal-weight (or stated-weight) stack, aligned from the most recent end."""
|
|
62
|
+
if not self.sleeves:
|
|
63
|
+
return []
|
|
64
|
+
series = {k: np.asarray(v, dtype=float) for k, v in self.sleeves.items()}
|
|
65
|
+
n = min(int(a.size) for a in series.values())
|
|
66
|
+
if n <= 0:
|
|
67
|
+
return []
|
|
68
|
+
stacked = np.column_stack([a[-n:] for a in series.values()])
|
|
69
|
+
w = np.array([self.weights.get(k, 1.0) for k in series], dtype=float)
|
|
70
|
+
if float(w.sum()) == 0:
|
|
71
|
+
w = np.ones_like(w)
|
|
72
|
+
w = w / w.sum()
|
|
73
|
+
return [float(x) for x in (stacked @ w).tolist()]
|
|
74
|
+
|
|
75
|
+
def overlap_matrix(
|
|
76
|
+
self, candidate: Any | None = None, *, candidate_name: str = "candidate"
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Pairwise correlation/beta, bounded. Candidate optional."""
|
|
79
|
+
names = list(self.sleeves)
|
|
80
|
+
series = dict(self.sleeves)
|
|
81
|
+
if candidate is not None:
|
|
82
|
+
series[candidate_name] = _as_returns(candidate)
|
|
83
|
+
names = [candidate_name, *names]
|
|
84
|
+
names = names[:MAX_SLEEVES]
|
|
85
|
+
pairs: list[dict[str, Any]] = []
|
|
86
|
+
for i, a in enumerate(names):
|
|
87
|
+
for b in names[i + 1 :]:
|
|
88
|
+
stats = overlap_stats(series[a], series[b])
|
|
89
|
+
pairs.append(
|
|
90
|
+
{
|
|
91
|
+
"a": a,
|
|
92
|
+
"b": b,
|
|
93
|
+
"correlation": stats.get("correlation"),
|
|
94
|
+
"beta_to_book": stats.get("beta_to_book"),
|
|
95
|
+
"n_obs": stats.get("n_obs"),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return {
|
|
99
|
+
"n_sleeves": len(names),
|
|
100
|
+
"names": names,
|
|
101
|
+
"pairs": pairs,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def residual_weight(self, name: str, *, target: float = 1.0) -> dict[str, Any]:
|
|
105
|
+
"""How much of `target` is left after the sleeves already held.
|
|
106
|
+
|
|
107
|
+
Refuses rather than sizing small when the candidate record is shorter
|
|
108
|
+
than MinTRL — a small weight is still a claim.
|
|
109
|
+
"""
|
|
110
|
+
if name not in self.sleeves:
|
|
111
|
+
raise KeyError(name)
|
|
112
|
+
returns = self.sleeves[name]
|
|
113
|
+
mintrl = min_track_record_length(returns)
|
|
114
|
+
if mintrl.get("error") or not mintrl.get("sufficient"):
|
|
115
|
+
return {
|
|
116
|
+
"name": name,
|
|
117
|
+
"refused": True,
|
|
118
|
+
"reason": "record_too_short",
|
|
119
|
+
"min_track_record_length": mintrl,
|
|
120
|
+
}
|
|
121
|
+
held = sum(self.weights.get(k, 0.0) for k in self.sleeves if k != name)
|
|
122
|
+
residual = max(0.0, float(target) - held)
|
|
123
|
+
risk = compute_var_cvar(returns)
|
|
124
|
+
return {
|
|
125
|
+
"name": name,
|
|
126
|
+
"refused": False,
|
|
127
|
+
"held_weight": round(held, 6),
|
|
128
|
+
"residual_weight": round(residual, 6),
|
|
129
|
+
"cvar": risk.get("cvar"),
|
|
130
|
+
"min_track_record_length": mintrl,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def monitor(self) -> dict[str, Any]:
|
|
134
|
+
"""Per-sleeve status. Nothing checked never reads as all-clear."""
|
|
135
|
+
rows: list[dict[str, Any]] = []
|
|
136
|
+
for name, returns in self.sleeves.items():
|
|
137
|
+
if len(returns) < 2:
|
|
138
|
+
rows.append({"name": name, "status": "unchecked", "n_obs": len(returns)})
|
|
139
|
+
continue
|
|
140
|
+
risk = compute_var_cvar(returns)
|
|
141
|
+
mintrl = min_track_record_length(returns)
|
|
142
|
+
status = "ok" if mintrl.get("sufficient") else "undetermined"
|
|
143
|
+
rows.append(
|
|
144
|
+
{
|
|
145
|
+
"name": name,
|
|
146
|
+
"status": status,
|
|
147
|
+
"n_obs": len(returns),
|
|
148
|
+
"cvar": risk.get("cvar"),
|
|
149
|
+
"sufficient": mintrl.get("sufficient"),
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
if not rows or any(r["status"] == "unchecked" for r in rows):
|
|
153
|
+
overall = "unchecked"
|
|
154
|
+
elif any(r["status"] != "ok" for r in rows):
|
|
155
|
+
overall = "undetermined"
|
|
156
|
+
else:
|
|
157
|
+
overall = "ok"
|
|
158
|
+
return {"overall": overall, "sleeves": rows, "n_sleeves": len(rows)}
|