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.
Files changed (71) hide show
  1. alphaengine-0.6.1/AGENTS.md +55 -0
  2. {alphaengine-0.5.0 → alphaengine-0.6.1}/PKG-INFO +12 -4
  3. {alphaengine-0.5.0 → alphaengine-0.6.1}/README.md +9 -2
  4. {alphaengine-0.5.0 → alphaengine-0.6.1}/pyproject.toml +2 -1
  5. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/_version.py +19 -1
  6. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/auth.py +16 -1
  7. alphaengine-0.6.1/src/alphaengine/book.py +158 -0
  8. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/cli.py +462 -107
  9. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/client/agent.py +41 -6
  10. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/client/executor.py +201 -1
  11. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/client/session.py +8 -0
  12. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/commands.py +63 -9
  13. alphaengine-0.6.1/src/alphaengine/complete.py +71 -0
  14. alphaengine-0.6.1/src/alphaengine/connectors/__init__.py +75 -0
  15. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/technical.py +6 -4
  16. alphaengine-0.6.1/src/alphaengine/core/walkforward.py +139 -0
  17. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo.py +12 -0
  18. alphaengine-0.6.1/src/alphaengine/events.py +195 -0
  19. alphaengine-0.6.1/src/alphaengine/model.py +500 -0
  20. alphaengine-0.6.1/src/alphaengine/repl.py +75 -0
  21. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/sweep/runner.py +37 -18
  22. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_agent.py +8 -0
  23. alphaengine-0.6.1/tests/test_book.py +40 -0
  24. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_cli.py +27 -0
  25. alphaengine-0.6.1/tests/test_events.py +57 -0
  26. alphaengine-0.6.1/tests/test_executor_ops.py +62 -0
  27. alphaengine-0.6.1/tests/test_model.py +96 -0
  28. alphaengine-0.5.0/src/alphaengine/model.py +0 -248
  29. {alphaengine-0.5.0 → alphaengine-0.6.1}/.github/workflows/ci.yml +0 -0
  30. {alphaengine-0.5.0 → alphaengine-0.6.1}/.github/workflows/publish.yml +0 -0
  31. {alphaengine-0.5.0 → alphaengine-0.6.1}/.gitignore +0 -0
  32. {alphaengine-0.5.0 → alphaengine-0.6.1}/LICENSE +0 -0
  33. {alphaengine-0.5.0 → alphaengine-0.6.1}/SECURITY.md +0 -0
  34. {alphaengine-0.5.0 → alphaengine-0.6.1}/scripts/gen_docs.py +0 -0
  35. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/__init__.py +0 -0
  36. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/__main__.py +0 -0
  37. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/agent/__init__.py +0 -0
  38. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/agent/answer.py +0 -0
  39. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/agent/driver.py +0 -0
  40. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/client/__init__.py +0 -0
  41. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/__init__.py +0 -0
  42. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/backtest.py +0 -0
  43. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/factors.py +0 -0
  44. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/pairs.py +0 -0
  45. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/performance.py +0 -0
  46. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/profile.py +0 -0
  47. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/risk.py +0 -0
  48. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/screen.py +0 -0
  49. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/series_shapes.py +0 -0
  50. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/signals.py +0 -0
  51. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/stress.py +0 -0
  52. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/core/validation.py +0 -0
  53. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo_book.py +0 -0
  54. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo_returns.py +0 -0
  55. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo_signal.py +0 -0
  56. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/demo_universe.py +0 -0
  57. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/loaders.py +0 -0
  58. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/py.typed +0 -0
  59. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/study/__init__.py +0 -0
  60. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/study/report.py +0 -0
  61. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/study/schema.py +0 -0
  62. {alphaengine-0.5.0 → alphaengine-0.6.1}/src/alphaengine/sweep/__init__.py +0 -0
  63. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_answer.py +0 -0
  64. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_client.py +0 -0
  65. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_commands.py +0 -0
  66. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_goldens.py +0 -0
  67. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_loaders.py +0 -0
  68. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_screen.py +0 -0
  69. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_signals.py +0 -0
  70. {alphaengine-0.5.0 → alphaengine-0.6.1}/tests/test_smoke.py +0 -0
  71. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: alphaengine
3
- Version: 0.5.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.csv>` | load a local CSV without leaving the session | session |
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.csv>` | load a local CSV without leaving the session | session |
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
- __version__ = "0.5.0"
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 = ("QUANTOS_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY")
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)}