alphaengine 0.4.0__tar.gz → 0.5.0__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 (59) hide show
  1. {alphaengine-0.4.0 → alphaengine-0.5.0}/PKG-INFO +7 -3
  2. {alphaengine-0.4.0 → alphaengine-0.5.0}/README.md +6 -2
  3. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/_version.py +16 -1
  4. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/cli.py +137 -190
  5. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/client/session.py +30 -57
  6. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/commands.py +38 -2
  7. {alphaengine-0.4.0 → alphaengine-0.5.0}/.github/workflows/ci.yml +0 -0
  8. {alphaengine-0.4.0 → alphaengine-0.5.0}/.github/workflows/publish.yml +0 -0
  9. {alphaengine-0.4.0 → alphaengine-0.5.0}/.gitignore +0 -0
  10. {alphaengine-0.4.0 → alphaengine-0.5.0}/LICENSE +0 -0
  11. {alphaengine-0.4.0 → alphaengine-0.5.0}/SECURITY.md +0 -0
  12. {alphaengine-0.4.0 → alphaengine-0.5.0}/pyproject.toml +0 -0
  13. {alphaengine-0.4.0 → alphaengine-0.5.0}/scripts/gen_docs.py +0 -0
  14. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/__init__.py +0 -0
  15. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/__main__.py +0 -0
  16. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/agent/__init__.py +0 -0
  17. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/agent/answer.py +0 -0
  18. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/agent/driver.py +0 -0
  19. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/auth.py +0 -0
  20. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/client/__init__.py +0 -0
  21. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/client/agent.py +0 -0
  22. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/client/executor.py +0 -0
  23. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/__init__.py +0 -0
  24. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/backtest.py +0 -0
  25. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/factors.py +0 -0
  26. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/pairs.py +0 -0
  27. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/performance.py +0 -0
  28. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/profile.py +0 -0
  29. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/risk.py +0 -0
  30. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/screen.py +0 -0
  31. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/series_shapes.py +0 -0
  32. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/signals.py +0 -0
  33. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/stress.py +0 -0
  34. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/technical.py +0 -0
  35. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/core/validation.py +0 -0
  36. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/demo.py +0 -0
  37. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/demo_book.py +0 -0
  38. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/demo_returns.py +0 -0
  39. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/demo_signal.py +0 -0
  40. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/demo_universe.py +0 -0
  41. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/loaders.py +0 -0
  42. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/model.py +0 -0
  43. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/py.typed +0 -0
  44. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/study/__init__.py +0 -0
  45. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/study/report.py +0 -0
  46. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/study/schema.py +0 -0
  47. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/sweep/__init__.py +0 -0
  48. {alphaengine-0.4.0 → alphaengine-0.5.0}/src/alphaengine/sweep/runner.py +0 -0
  49. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_agent.py +0 -0
  50. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_answer.py +0 -0
  51. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_cli.py +0 -0
  52. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_client.py +0 -0
  53. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_commands.py +0 -0
  54. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_goldens.py +0 -0
  55. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_loaders.py +0 -0
  56. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_screen.py +0 -0
  57. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_signals.py +0 -0
  58. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_smoke.py +0 -0
  59. {alphaengine-0.4.0 → alphaengine-0.5.0}/tests/test_sweep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alphaengine
3
- Version: 0.4.0
3
+ Version: 0.5.0
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
@@ -150,6 +150,8 @@ distinction is the whole of the data boundary below.
150
150
  |---|---|---|
151
151
  | `demo` | run the built-in example offline, with no account and no data | shell + session |
152
152
  | `runs [--limit N]` | your own week: what ran, what it decided, what it filed | shell + session |
153
+ | `gaps` | what your record says is UNANSWERED, and what closes each one | shell + session |
154
+ | `tonight [--budget N]` | what would run unattended tonight, without running any of it | shell + session |
153
155
  | `workflows` | what the server offers, what each needs, and which reproduce | shell + session |
154
156
  | `key [quantos \| anthropic \| openai]` | enter a credential now, or see which rungs are unlocked | session |
155
157
  | `commands [verb]` | this directory, or one command in full | shell + session |
@@ -205,12 +207,11 @@ distinction is the whole of the data boundary below.
205
207
  | `--data FILE` | a local CSV: wide, long, or a single series |
206
208
  | `--universe NAME` | a universe registered in the portal, with its stored closes |
207
209
  | `--symbol TICKER` | one name out of a loaded universe; its closes become the return series |
208
- | `--sleeve NAME` | the sleeve this run belongs to, and the budget it is bound by |
209
- | `--thesis ID` | a draft thesis to propose this run's shortlist as a sleeve for |
210
210
  | `--label TEXT` | what to call the artifact this run produces |
211
211
  | `--input K=V` | a workflow input; repeatable |
212
212
  | `--quiet` | only the result, no step narration |
213
213
  | `--limit N` | how many rows to show (default 25) |
214
+ | `--budget N` | how many runs a night is worth (default 3) |
214
215
 
215
216
  ### Examples
216
217
 
@@ -226,6 +227,9 @@ alphaengine monitor
226
227
  alphaengine demo
227
228
  alphaengine runs
228
229
  alphaengine runs --limit 50
230
+ alphaengine gaps
231
+ alphaengine tonight
232
+ alphaengine tonight --budget 5
229
233
  alphaengine workflows
230
234
  alphaengine commands
231
235
  alphaengine run screen_universe --universe sp500
@@ -109,6 +109,8 @@ distinction is the whole of the data boundary below.
109
109
  |---|---|---|
110
110
  | `demo` | run the built-in example offline, with no account and no data | shell + session |
111
111
  | `runs [--limit N]` | your own week: what ran, what it decided, what it filed | shell + session |
112
+ | `gaps` | what your record says is UNANSWERED, and what closes each one | shell + session |
113
+ | `tonight [--budget N]` | what would run unattended tonight, without running any of it | shell + session |
112
114
  | `workflows` | what the server offers, what each needs, and which reproduce | shell + session |
113
115
  | `key [quantos \| anthropic \| openai]` | enter a credential now, or see which rungs are unlocked | session |
114
116
  | `commands [verb]` | this directory, or one command in full | shell + session |
@@ -164,12 +166,11 @@ distinction is the whole of the data boundary below.
164
166
  | `--data FILE` | a local CSV: wide, long, or a single series |
165
167
  | `--universe NAME` | a universe registered in the portal, with its stored closes |
166
168
  | `--symbol TICKER` | one name out of a loaded universe; its closes become the return series |
167
- | `--sleeve NAME` | the sleeve this run belongs to, and the budget it is bound by |
168
- | `--thesis ID` | a draft thesis to propose this run's shortlist as a sleeve for |
169
169
  | `--label TEXT` | what to call the artifact this run produces |
170
170
  | `--input K=V` | a workflow input; repeatable |
171
171
  | `--quiet` | only the result, no step narration |
172
172
  | `--limit N` | how many rows to show (default 25) |
173
+ | `--budget N` | how many runs a night is worth (default 3) |
173
174
 
174
175
  ### Examples
175
176
 
@@ -185,6 +186,9 @@ alphaengine monitor
185
186
  alphaengine demo
186
187
  alphaengine runs
187
188
  alphaengine runs --limit 50
189
+ alphaengine gaps
190
+ alphaengine tonight
191
+ alphaengine tonight --budget 5
188
192
  alphaengine workflows
189
193
  alphaengine commands
190
194
  alphaengine run screen_universe --universe sp500
@@ -29,4 +29,19 @@ is what a changed figure costs. The API may still move underneath it.
29
29
  #
30
30
  # Not one of those is a bug fix, and every one changes what a saved study
31
31
  # reproduces. That costs the MINOR position while the leading digit is 0.
32
- __version__ = "0.4.0"
32
+ #
33
+ # 0.5.0 IS THE RULE READ THE OTHER WAY: no computed value moved, and the SURFACE
34
+ # broke. `--sleeve` and `--thesis` are removed along with `Session.sleeves()`,
35
+ # `.theses()` and `.propose_sleeve()`. Their three routes were part of the thesis
36
+ # object model, which came out of the platform with the agent desk it was built
37
+ # around, so the flags could only have failed at the END of a run — after the
38
+ # work, naming a server error rather than a missing feature.
39
+ #
40
+ # A removed flag is a breaking change even though every figure is byte-identical,
41
+ # and while the leading digit is 0 the MINOR position carries that too.
42
+ #
43
+ # Added in the same release, and the reason the removal is not a loss: `gaps` and
44
+ # `tonight`. The record already knew which workflow closes each open gap and
45
+ # nothing traversed those edges; these two read that derivation, so the terminal
46
+ # can now answer "what have I NOT tried" and "what will run while I sleep".
47
+ __version__ = "0.5.0"
@@ -227,6 +227,11 @@ QUESTION_VERBS: dict[str, str] = {
227
227
  "size": "size_position",
228
228
  "monitor": "monitor_sleeve",
229
229
  }
230
+ #: The same table read backwards, so a gap naming a WORKFLOW can print the verb
231
+ #: a person actually types. Derived rather than written out twice, because two
232
+ #: hand-maintained copies of this map is precisely the drift `commands.py`
233
+ #: exists to prevent.
234
+ QUESTION_FOR_WORKFLOW: dict[str, str] = {w: v for v, w in QUESTION_VERBS.items()}
230
235
  #: The held-back-rows marker. Probed like the rest: cp1252 renders a literal
231
236
  #: ellipsis as mojibake in exactly the place a user reads their result.
232
237
  ELLIPSIS = "…" if _UNICODE_OK else "..."
@@ -577,6 +582,105 @@ def cmd_workflows(args: argparse.Namespace) -> int:
577
582
  return 0
578
583
 
579
584
 
585
+ def cmd_gaps(args: argparse.Namespace) -> int:
586
+ """WHAT THE RECORD SAYS IS UNANSWERED, and what closes each one.
587
+
588
+ `runs` shows what you already tried. This shows what you have NOT — the
589
+ other half of the same question, and the one you want at 9am rather than at
590
+ the end of a task.
591
+
592
+ Every line names its remedy, because a gap that cannot name one is an
593
+ observation rather than a next step. Both the gap and the remedy are
594
+ DERIVED from the record: a model asked to find gaps can decline to find
595
+ them, and can invent them.
596
+ """
597
+ from .client import Offline, ServerError
598
+
599
+ session, url = _session(args.url, args.key)
600
+ try:
601
+ out = session.gaps()
602
+ except Offline:
603
+ _explain_offline(url)
604
+ return 2
605
+ except ServerError as exc:
606
+ refused(exc)
607
+ if is_auth_error(exc) and offer_key():
608
+ say(dim(" Key set. Run that again."))
609
+ return 2
610
+
611
+ rows = list(out.get("queued") or []) + list(out.get("skipped") or [])
612
+ if not rows:
613
+ say("")
614
+ reason = str(out.get("reason") or "")
615
+ say(dim(f" {reason}") if reason else dim(" Nothing open. The record answers what it was asked."))
616
+ return 0
617
+
618
+ say("")
619
+ say(f" {bold(str(len(rows)))} open on this account")
620
+ for r in rows:
621
+ workflow = str(r.get("workflow") or "")
622
+ # THE SENTENCE IS THE FINDING. Printed whole rather than clipped to a
623
+ # label, because "nobody diagnosed this data before the run read it" is
624
+ # what tells somebody what to do and "data_never_diagnosed" is not.
625
+ say(f" {dim(DOT)} {str(r.get('because') or r.get('code') or '').strip()}")
626
+ blocked = r.get("reason")
627
+ if blocked:
628
+ say(f" {yellow('blocked')} {dim(str(blocked))}")
629
+ else:
630
+ say(f" {dim('->')} alphaengine {QUESTION_FOR_WORKFLOW.get(workflow, workflow)}")
631
+ say("")
632
+ return 0
633
+
634
+
635
+ def cmd_tonight(args: argparse.Namespace) -> int:
636
+ """WHAT WOULD RUN TONIGHT, without running any of it.
637
+
638
+ The same derivation the scheduler uses, so this is what WILL happen rather
639
+ than a description of it.
640
+
641
+ Everything the budget cut is printed too. A plan that shows only what it
642
+ kept reads as "this is everything that mattered", which is the one thing a
643
+ plan must never imply.
644
+ """
645
+ from .client import Offline, ServerError
646
+
647
+ session, url = _session(args.url, args.key)
648
+ try:
649
+ out = session.plan(getattr(args, "budget", 0) or 0)
650
+ except Offline:
651
+ _explain_offline(url)
652
+ return 2
653
+ except ServerError as exc:
654
+ refused(exc)
655
+ if is_auth_error(exc) and offer_key():
656
+ say(dim(" Key set. Run that again."))
657
+ return 2
658
+
659
+ queued = list(out.get("queued") or [])
660
+ skipped = list(out.get("skipped") or [])
661
+ if not queued and not skipped:
662
+ say("")
663
+ reason = str(out.get("reason") or "")
664
+ say(dim(f" {reason}") if reason else dim(" Nothing queued. The record has no open gap to close."))
665
+ return 0
666
+
667
+ say("")
668
+ if queued:
669
+ say(f" {bold('queued')} {dim(f'{len(queued)} of tonight')}")
670
+ for q in queued:
671
+ inputs = q.get("inputs") or {}
672
+ on = str(inputs.get("universe") or inputs.get("symbol") or "")
673
+ say(f" {green(DOT)} {str(q.get('workflow') or '').ljust(18)}{dim(on)}")
674
+ say(f" {dim(str(q.get('because') or ''))}")
675
+ if skipped:
676
+ say("")
677
+ say(f" {dim('skipped')}")
678
+ for s in skipped:
679
+ say(f" {dim(DOT)} {str(s.get('workflow') or '').ljust(18)}{dim(str(s.get('reason') or ''))}")
680
+ say("")
681
+ return 0
682
+
683
+
580
684
  def cmd_runs(args: argparse.Namespace) -> int:
581
685
  """YOUR OWN WEEK, from where the work happens.
582
686
 
@@ -818,24 +922,6 @@ def resolve_data(args: argparse.Namespace, session: Any = None) -> tuple[Any, An
818
922
 
819
923
  universe = getattr(args, "universe", None)
820
924
 
821
- # ── A SLEEVE BRINGS ITS OWN DATA ───────────────────────────────────────
822
- #
823
- # `--sleeve macro` is enough; `--sleeve macro --universe "sp100 universe"`
824
- # was the shape before this, and saying which book and which data as two
825
- # separate arguments on every command is what kept them separate concepts.
826
- # A sleeve IS a book plus what it runs on.
827
- #
828
- # AN EXPLICIT `--universe` STILL WINS, and silently is not an option — the
829
- # line below says which universe was used and why, because a run that
830
- # quietly substituted the sleeve's default for the one somebody typed is
831
- # the kind of helpfulness nobody can debug.
832
- if universe is None and getattr(args, "sleeve", None):
833
- sleeve = _sleeve_row(session, args.sleeve) or {}
834
- from_sleeve = sleeve.get("universe_id")
835
- if from_sleeve:
836
- universe = str(from_sleeve)
837
- say(dim(f" sleeve {sleeve.get('name')} {DOT} runs on its registered universe"))
838
-
839
925
  if universe:
840
926
  data = _narrow_to_universe(session, universe, data)
841
927
 
@@ -967,167 +1053,24 @@ def _narrow_to_universe(session: Any, wanted: str, data: Any) -> Any:
967
1053
  return kept
968
1054
 
969
1055
 
970
- def _sleeve_row(session: Any, wanted: str | None) -> dict[str, Any] | None:
971
- """A sleeve name or id -> the workspace id a run is opened against.
972
-
973
- Resolved by NAME because that is what a person types. A pod calls it
974
- "Systematic Macro", not a uuid, and requiring the uuid is how a correct
975
- mechanism ends up never receiving input — which is what happened here: no
976
- caller ever sent `workspace_id`, so the risk budget resolved to nothing and
977
- every sleeve's P&L reported zero.
978
-
979
- A miss NAMES what is available rather than failing bare, and an account with
980
- no pod is told that plainly instead of being shown an empty list.
981
- """
982
- if not wanted or session is None:
983
- return None
984
- from .loaders import DataShapeError
985
-
986
- try:
987
- rows = session.sleeves()
988
- except Exception as exc: # noqa: BLE001 - offline or refused, said plainly
989
- raise DataShapeError(f"--sleeve needs the portal, and it could not be reached: {exc}") from exc
990
-
991
- if not rows:
992
- raise DataShapeError(
993
- "your account is not in a pod, so it has no sleeves. Drop --sleeve "
994
- "and the run works exactly the same; a sleeve only adds the budget "
995
- "it is bound by and the book it rolls into."
996
- )
997
- # THE KEY IS `id`, NOT `workspace_id`. The server's sleeve serializer returns
998
- # `{id, pod_id, name, stage, benchmark, initial_capital, pm_user_id, ...}` —
999
- # the workspace IS the sleeve, so its primary key is `id`, and the name
1000
- # `workspace_id` only appears on the routes that TAKE one. Reading the
1001
- # parameter name off the route and assuming it names the response field is
1002
- # the same mistake that made `--universe` read a top-level `symbols` key the
1003
- # server has never sent. Pinned by a contract test in the platform repo that
1004
- # imports this function and the real serializer together.
1005
- match = next(
1006
- (s for s in rows if wanted in (str(s.get("id")), str(s.get("name")))),
1007
- None,
1008
- )
1009
- if match is None:
1010
- known = ", ".join(str(s.get("name")) for s in rows) or "none"
1011
- raise DataShapeError(f"no sleeve called {wanted!r}. Yours: {known}")
1012
- return dict(match)
1013
-
1014
-
1015
- def _resolve_sleeve(session: Any, wanted: str | None) -> str | None:
1016
- """The workspace id a run is opened against, or None."""
1017
- row = _sleeve_row(session, wanted)
1018
- return str(row.get("id")) if row else None
1019
-
1020
-
1021
- def _offer_sleeve(session: Any, run: Any, wanted: str | None) -> None:
1022
- """Resolve `--thesis` and propose, or say why it cannot.
1023
-
1024
- BY TITLE AS WELL AS BY ID, because a person types a name. The same reasoning
1025
- as `--sleeve` and `--universe`: requiring a uuid is how a correct mechanism
1026
- ends up never receiving input.
1027
- """
1028
- if not wanted or session is None:
1029
- return
1030
- try:
1031
- rows = session.theses()
1032
- except Exception as exc: # noqa: BLE001 - offline or refused, said plainly
1033
- say(dim(f" Could not read your theses: {exc}"))
1034
- return
1035
-
1036
- match = next((t for t in rows if wanted in (str(t.get("id")), str(t.get("title")))), None)
1037
- if match is None:
1038
- known = ", ".join(str(t.get("title")) for t in rows[:6]) or "none"
1039
- say(yellow(f" No thesis called {wanted!r}. Yours: {known}"))
1040
- return
1041
- if match.get("workspace_id"):
1042
- say(yellow(f" {match.get('title')!r} already belongs to a sleeve."))
1043
- say(dim(" A thesis expresses one claim in one book. Write a new draft."))
1044
- return
1045
-
1046
- _propose_sleeve(session, run, match)
1047
-
1048
-
1049
- def _propose_sleeve(session: Any, run: Any, thesis: dict[str, Any]) -> None:
1050
- """Offer the closed screen as a sleeve for the thesis it was run against.
1051
-
1052
- ── THE VERB, FROM THE QUANT'S DESK ────────────────────────────────────────
1053
-
1054
- The screen ran here, against data that never left this machine. What crosses
1055
- is the shortlist and the argument for it — as a PROPOSAL, which creates no
1056
- sleeve and no position. A person decides in the portal, because deciding is
1057
- the human act the whole object exists to require.
1058
-
1059
- That split is the product: the quant runs the research where the data is,
1060
- the PM decides where the authority is, and something durable travels between
1061
- them instead of a screenshot.
1062
-
1063
- A FAILURE HERE NEVER FAILS THE RUN. The figures are already on screen and
1064
- the run is sealed server-side; losing the proposal costs a hand-off, and
1065
- taking the run down with it would trade the work for the paperwork.
1066
- """
1067
- figures = (run.artifact or {}).get("figures") or {}
1068
- flat: dict[str, Any] = {}
1069
- for value in figures.values():
1070
- if isinstance(value, dict):
1071
- flat.update(value)
1072
- rows = flat.get("rows") or []
1073
- if not rows:
1074
- return
1075
-
1076
- ranked = [
1077
- {
1078
- "ticker": str(r.get("symbol") or r.get("ticker") or ""),
1079
- "rank": i + 1,
1080
- "score": r.get("score"),
1081
- "target_weight": round(1.0 / min(len(rows), 25), 6),
1082
- }
1083
- for i, r in enumerate(rows[:25])
1084
- ]
1085
- caveats: list[str] = []
1086
- universe, evaluated = flat.get("universe_size"), flat.get("n_evaluated")
1087
- if isinstance(universe, int) and isinstance(evaluated, int) and evaluated < universe:
1088
- caveats.append(
1089
- f"{evaluated} of {universe} names could be measured; the rest had too "
1090
- "little history and are absent from this ranking, which flatters it."
1091
- )
1092
- if flat.get("truncated") or len(rows) > len(ranked):
1093
- caveats.append(
1094
- f"{len(rows)} names cleared the screen and the {len(ranked)} highest are "
1095
- "proposed. The cut is a position-count decision, not a quality one."
1096
- )
1097
-
1098
- title = str(thesis.get("title") or "this thesis")
1099
- try:
1100
- filed = session.propose_sleeve(
1101
- thesis_id=str(thesis.get("id")),
1102
- rows=ranked,
1103
- rationale=(
1104
- f"{len(ranked)} names express {title!r}, ranked by "
1105
- f"{flat.get('rank_by') or 'score'}. Equal weight across the shortlist."
1106
- ),
1107
- caveats=caveats,
1108
- payload={
1109
- "rank_by": flat.get("rank_by"),
1110
- "n_ranked": len(rows),
1111
- "universe_size": universe,
1112
- "n_evaluated": evaluated,
1113
- },
1114
- run_id=getattr(run, "run_id", None),
1115
- )
1116
- except Exception as exc: # noqa: BLE001 - named, never fatal
1117
- say(dim(f" The sleeve could not be proposed: {exc}"))
1118
- return
1119
-
1120
- say("")
1121
- say(f" {green('Proposed.')} {len(ranked)} names for {bold(title)}")
1122
- # THE PROPOSAL SURFACE WAS RETIRED IN PHASE 8 and this line went on naming
1123
- # it, so the one instruction printed here pointed at a redirect. Accepting
1124
- # work happens on the RUN now, which is where its figures, its caveats and
1125
- # its review pass already are.
1126
- say(
1127
- dim(" Nothing exists yet. Accept or decline it on the run: ")
1128
- + f"{portal_url()}/portal/runs/{getattr(run, 'run_id', '')}"
1129
- )
1130
- say(dim(f" proposal {filed.get('id')}"))
1056
+ # ── `--sleeve` AND `--thesis` ARE GONE (0.5.0) ─────────────────────────────
1057
+ #
1058
+ # Four helpers lived here: `_sleeve_row`, `_resolve_sleeve`, `_offer_sleeve` and
1059
+ # `_propose_sleeve`. They resolved a sleeve by name to open a run against, and
1060
+ # turned a closed screen into a PROPOSAL — the one thing this package ever
1061
+ # created.
1062
+ #
1063
+ # Their three routes (`/api/me/sleeves`, `/api/me/theses`,
1064
+ # `/api/me/proposals/from-os`) belonged to the thesis object model, which came
1065
+ # out with the retired agent desk it was built around. A CLI flag whose endpoint
1066
+ # does not exist is worse than an absent one: it fails at the END of a run, after
1067
+ # the work, naming a server error rather than a missing feature.
1068
+ #
1069
+ # THE SPLIT THEY EXISTED FOR IS UNCHANGED and now runs through a smaller object.
1070
+ # The quant still runs the research where the data is and the PM still decides
1071
+ # where the authority is; what travels between them is the run itself, handed
1072
+ # over with an addressee (`delivered_to`) and decided on the run page where its
1073
+ # figures, its caveats and its review pass already are.
1131
1074
 
1132
1075
 
1133
1076
  def _publish_signals(session: Any, run: Any, *, workspace_id: str | None, label: str | None) -> None:
@@ -1324,7 +1267,7 @@ def cmd_run(args: argparse.Namespace) -> int:
1324
1267
  session, url = _session(args.url, args.key)
1325
1268
  try:
1326
1269
  data, backtest_fn = resolve_data(args, session)
1327
- workspace_id = _resolve_sleeve(session, getattr(args, "sleeve", None))
1270
+ workspace_id = None
1328
1271
  except (ProjectError, ValueError) as exc:
1329
1272
  say(red(str(exc)))
1330
1273
  return 2
@@ -1397,7 +1340,6 @@ def cmd_run(args: argparse.Namespace) -> int:
1397
1340
  code = _report(run)
1398
1341
  if run.status == "closed":
1399
1342
  _publish_signals(session, run, workspace_id=workspace_id, label=args.label)
1400
- _offer_sleeve(session, run, getattr(args, "thesis", None))
1401
1343
  return code
1402
1344
 
1403
1345
 
@@ -3017,7 +2959,7 @@ def cmd_repl(args: argparse.Namespace) -> int:
3017
2959
  session, url = _session(args.url, args.key)
3018
2960
  try:
3019
2961
  data, backtest_fn = resolve_data(args, session)
3020
- workspace_id = _resolve_sleeve(session, getattr(args, "sleeve", None))
2962
+ workspace_id = None
3021
2963
  except (ProjectError, ValueError) as exc:
3022
2964
  say(red(str(exc)))
3023
2965
  return 2
@@ -3114,6 +3056,14 @@ def cmd_repl(args: argparse.Namespace) -> int:
3114
3056
  # leave to find out.
3115
3057
  cmd_runs(argparse.Namespace(url=args.url, key=args.key, limit=25))
3116
3058
  continue
3059
+ if verb == "gaps":
3060
+ # AND THE OTHER HALF: what has NOT been tried. Same reason it is in
3061
+ # the session — the moment you want it is mid-flight.
3062
+ cmd_gaps(argparse.Namespace(url=args.url, key=args.key))
3063
+ continue
3064
+ if verb == "tonight":
3065
+ cmd_tonight(argparse.Namespace(url=args.url, key=args.key, budget=0))
3066
+ continue
3117
3067
  if verb == "status":
3118
3068
  say(dim("no run yet") if last is None else f"{last.run_id} {last.status}")
3119
3069
  continue
@@ -3310,14 +3260,6 @@ def build_parser() -> argparse.ArgumentParser:
3310
3260
  "--symbol",
3311
3261
  help="one name out of a loaded universe; its closes become the return series sizing runs on",
3312
3262
  )
3313
- common.add_argument(
3314
- "--sleeve",
3315
- help="name or id of a sleeve this run belongs to; binds it to that sleeve's risk budget",
3316
- )
3317
- common.add_argument(
3318
- "--thesis",
3319
- help="a draft thesis to propose this run's shortlist as a sleeve for",
3320
- )
3321
3263
 
3322
3264
  p = argparse.ArgumentParser(
3323
3265
  prog="alphaengine",
@@ -3330,6 +3272,9 @@ def build_parser() -> argparse.ArgumentParser:
3330
3272
  sub.add_parser("demo", parents=[common], help="run the built-in example, offline")
3331
3273
  rl = sub.add_parser("runs", parents=[common], help="your own week: what ran, and what it decided")
3332
3274
  rl.add_argument("--limit", type=int, default=25, help="how many to show (default 25)")
3275
+ sub.add_parser("gaps", parents=[common], help="what your record says is unanswered")
3276
+ tn = sub.add_parser("tonight", parents=[common], help="what would run unattended, without running it")
3277
+ tn.add_argument("--budget", type=int, default=0, help="how many runs a night is worth")
3333
3278
  sub.add_parser("logout", parents=[common], help="remove stored credentials")
3334
3279
  d = sub.add_parser("commands", parents=[common], help="every command, grouped")
3335
3280
  d.add_argument("verb", nargs="?", help="expand one command in full")
@@ -3398,6 +3343,8 @@ def main(argv: list[str] | None = None) -> int:
3398
3343
  "version": cmd_version,
3399
3344
  "workflows": cmd_workflows,
3400
3345
  "runs": cmd_runs,
3346
+ "gaps": cmd_gaps,
3347
+ "tonight": cmd_tonight,
3401
3348
  "demo": cmd_demo,
3402
3349
  "logout": cmd_logout,
3403
3350
  "commands": cmd_commands,
@@ -422,68 +422,41 @@ class Session:
422
422
  body["source_run_id"] = source_run_id
423
423
  return self._post("/api/me/signals", body)
424
424
 
425
- def theses(self) -> list[Figures]:
426
- """The claims on your account, including drafts with no sleeve yet.
425
+ def gaps(self) -> Figures:
426
+ """WHAT YOUR RECORD SAYS IS UNANSWERED, across the whole account.
427
427
 
428
- A draft is a thesis somebody wrote and has not acted on. It is the input
429
- to the only verb in this package that creates anything, so the CLI has
430
- to be able to see one.
431
- """
432
- return list(self._get("/api/me/theses").get("theses") or [])
433
-
434
- def propose_sleeve(
435
- self,
436
- *,
437
- thesis_id: str,
438
- rows: list[Figures],
439
- rationale: str,
440
- caveats: list[str] | None = None,
441
- payload: Figures | None = None,
442
- run_id: str | None = None,
443
- book_id: str | None = None,
444
- ) -> Figures:
445
- """File a proposed sleeve for a thesis. THE ONLY THING THIS MAKES.
428
+ A run's own `/gaps` answers this for one run — the view a quant has at
429
+ the moment of finishing something. This is the same derivation read
430
+ across everything, which is the view somebody has at the START of a day.
446
431
 
447
- ── WHAT THIS IS AND IS NOT ────────────────────────────────────────────
432
+ Every gap names the workflow that closes it, and both are DERIVED: a gap
433
+ a model was invited to find is one it can decline to find, and worse, one
434
+ it can invent.
435
+ """
436
+ return self._get("/api/harness/gaps")
448
437
 
449
- It creates a PROPOSAL, never a sleeve and never a position. The screen
450
- ran here, on this machine, against data that never moved; what crosses
451
- is the shortlist and the argument for it. Somebody then reads it and
452
- decides — in the portal, because deciding is a human act and a
453
- long-lived key pressing accept would be the machine approving its own
454
- work through one extra hop.
438
+ def plan(self, budget: int = 0) -> Figures:
439
+ """WHAT WOULD RUN TONIGHT, without running any of it.
455
440
 
456
- THE CAVEATS ARE NOT OPTIONAL. They travel with the proposal because the
457
- handoff is exactly where they get dropped, and a shortlist read without
458
- what the run could not establish is the failure every control in this
459
- product exists to prevent.
460
- """
461
- body: Figures = {
462
- "thesis_id": thesis_id,
463
- "rows": rows,
464
- "rationale": rationale,
465
- "caveats": list(caveats or []),
466
- }
467
- if payload:
468
- body["payload"] = payload
469
- if run_id:
470
- body["source_run_id"] = run_id
471
- if book_id:
472
- body["book_id"] = book_id
473
- return self._post("/api/me/proposals/from-os", body)
474
-
475
- def sleeves(self) -> list[Figures]:
476
- """The sleeves you can run against, if your account is in a pod.
477
-
478
- A sleeve is a workspace with a pod, a stage and a PM. Naming one on a
479
- run is what lets the platform resolve the risk budget that binds it and
480
- roll the result into the pod's book — without it every run is orphaned
481
- from the object model and every sleeve reports zero.
482
-
483
- An account with no pod has no sleeves, and that is a working product
484
- rather than an error: a solo quant is a first-class user here.
441
+ The same derivation the scheduler uses, so this shows what will happen
442
+ rather than a description of it. `skipped` carries what the budget cut
443
+ AND what is blocked on an input only you can state — a plan that
444
+ silently truncates reads as "this is everything that mattered".
485
445
  """
486
- return list(self._get("/api/me/sleeves").get("sleeves") or [])
446
+ q = f"?budget={int(budget)}" if budget else ""
447
+ return self._get(f"/api/harness/plan{q}")
448
+
449
+ # ── THE THESIS AND SLEEVE METHODS ARE GONE (0.5.0) ─────────────────────
450
+ #
451
+ # `theses()`, `propose_sleeve()` and `sleeves()` backed `--thesis` and
452
+ # `--sleeve`. Their routes — /api/me/theses, /api/me/proposals/from-os and
453
+ # /api/me/sleeves — were part of the thesis object model, which came out with
454
+ # the retired agent desk it was built around.
455
+ #
456
+ # A breaking change, and a deliberate one: they created a PROPOSAL from a
457
+ # screen, which is the one thing this package ever made, and the loop that
458
+ # object served now runs through assignments and the deliver pin instead.
459
+ # See the 0.5.0 note in the README.
487
460
 
488
461
  # ── transport ──────────────────────────────────────────────────────────
489
462
  def _request(self, method: str, path: str, body: Figures | None = None) -> Figures:
@@ -87,12 +87,11 @@ FLAGS: dict[str, Flag] = {
87
87
  "symbol": Flag(
88
88
  "--symbol", "TICKER", "one name out of a loaded universe; its closes become the return series"
89
89
  ),
90
- "sleeve": Flag("--sleeve", "NAME", "the sleeve this run belongs to, and the budget it is bound by"),
91
- "thesis": Flag("--thesis", "ID", "a draft thesis to propose this run's shortlist as a sleeve for"),
92
90
  "label": Flag("--label", "TEXT", "what to call the artifact this run produces"),
93
91
  "input": Flag("--input", "K=V", "a workflow input; repeatable"),
94
92
  "quiet": Flag("--quiet", "", "only the result, no step narration"),
95
93
  "limit": Flag("--limit", "N", "how many rows to show (default 25)"),
94
+ "budget": Flag("--budget", "N", "how many runs a night is worth (default 3)"),
96
95
  }
97
96
 
98
97
 
@@ -213,6 +212,43 @@ COMMANDS: tuple[Command, ...] = _question_commands() + (
213
212
  ),
214
213
  examples=("alphaengine runs", "alphaengine runs --limit 50"),
215
214
  ),
215
+ Command(
216
+ verb="gaps",
217
+ group="start",
218
+ args="",
219
+ scope="both",
220
+ purpose="what your record says is UNANSWERED, and what closes each one",
221
+ body=(
222
+ "`runs` shows what you already tried. This shows what you have not: "
223
+ "the other half of the same question, and the one you want at 9am "
224
+ "rather than at the end of a task.\n\n"
225
+ "EVERY LINE NAMES ITS REMEDY. A gap that cannot name one is an "
226
+ "observation rather than a next step, so the ones with no workflow "
227
+ "behind them are not printed as work.\n\n"
228
+ "Derived from the record, never asked of a model: a model invited to "
229
+ "find gaps can decline to find them, and can invent them. What you "
230
+ "see is a query over runs that actually happened."
231
+ ),
232
+ examples=("alphaengine gaps",),
233
+ ),
234
+ Command(
235
+ verb="tonight",
236
+ group="start",
237
+ args="[--budget N]",
238
+ scope="both",
239
+ purpose="what would run unattended tonight, without running any of it",
240
+ body=(
241
+ "The same derivation the scheduler uses, so this is what WILL happen "
242
+ "rather than a description of it.\n\n"
243
+ "EVERYTHING SKIPPED IS PRINTED. A plan showing only what it kept "
244
+ "reads as 'this is everything that mattered', which is the one thing "
245
+ "a plan must never imply. Two reasons appear: past tonight's budget, "
246
+ "and blocked on an input only you can state. A monitor's tolerances "
247
+ "are not something the planner may invent."
248
+ ),
249
+ examples=("alphaengine tonight", "alphaengine tonight --budget 5"),
250
+ flags=("budget",),
251
+ ),
216
252
  Command(
217
253
  verb="workflows",
218
254
  group="start",
File without changes
File without changes
File without changes
File without changes