alphaengine 0.9.1__tar.gz → 0.9.2__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 (93) hide show
  1. {alphaengine-0.9.1 → alphaengine-0.9.2}/PKG-INFO +4 -1
  2. {alphaengine-0.9.1 → alphaengine-0.9.2}/README.md +3 -0
  3. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/_version.py +5 -1
  4. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/cli.py +83 -2
  5. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/client/agent.py +13 -2
  6. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/client/session.py +19 -11
  7. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/commands.py +37 -1
  8. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/tui/app.py +60 -17
  9. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/tui/dispatch.py +86 -2
  10. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_client.py +25 -0
  11. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_tui.py +71 -1
  12. {alphaengine-0.9.1 → alphaengine-0.9.2}/.github/workflows/ci.yml +0 -0
  13. {alphaengine-0.9.1 → alphaengine-0.9.2}/.github/workflows/publish.yml +0 -0
  14. {alphaengine-0.9.1 → alphaengine-0.9.2}/.gitignore +0 -0
  15. {alphaengine-0.9.1 → alphaengine-0.9.2}/AGENTS.md +0 -0
  16. {alphaengine-0.9.1 → alphaengine-0.9.2}/LICENSE +0 -0
  17. {alphaengine-0.9.1 → alphaengine-0.9.2}/SECURITY.md +0 -0
  18. {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/assets/banner.png +0 -0
  19. {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/assets/data_boundary.png +0 -0
  20. {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/assets/session.png +0 -0
  21. {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/figure_contract.md +0 -0
  22. {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/workflows/evaluate_process.json +0 -0
  23. {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/workflows/research_week.json +0 -0
  24. {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/workflows/stress_study.json +0 -0
  25. {alphaengine-0.9.1 → alphaengine-0.9.2}/pyproject.toml +0 -0
  26. {alphaengine-0.9.1 → alphaengine-0.9.2}/scripts/gen_docs.py +0 -0
  27. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/__init__.py +0 -0
  28. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/__main__.py +0 -0
  29. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/agent/__init__.py +0 -0
  30. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/agent/answer.py +0 -0
  31. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/agent/driver.py +0 -0
  32. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/auth.py +0 -0
  33. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/book.py +0 -0
  34. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/charts.py +0 -0
  35. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/client/__init__.py +0 -0
  36. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/client/executor.py +0 -0
  37. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/complete.py +0 -0
  38. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/connectors/__init__.py +0 -0
  39. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/__init__.py +0 -0
  40. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/active.py +0 -0
  41. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/allocate.py +0 -0
  42. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/backtest.py +0 -0
  43. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/covariance.py +0 -0
  44. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/cross_section.py +0 -0
  45. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/factors.py +0 -0
  46. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/pairs.py +0 -0
  47. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/panel.py +0 -0
  48. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/performance.py +0 -0
  49. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/process.py +0 -0
  50. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/profile.py +0 -0
  51. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/risk.py +0 -0
  52. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/screen.py +0 -0
  53. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/series_shapes.py +0 -0
  54. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/signals.py +0 -0
  55. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/stress.py +0 -0
  56. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/technical.py +0 -0
  57. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/trace.py +0 -0
  58. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/validation.py +0 -0
  59. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/walkforward.py +0 -0
  60. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo.py +0 -0
  61. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo_book.py +0 -0
  62. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo_returns.py +0 -0
  63. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo_signal.py +0 -0
  64. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo_universe.py +0 -0
  65. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/events.py +0 -0
  66. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/loaders.py +0 -0
  67. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/model.py +0 -0
  68. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/py.typed +0 -0
  69. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/repl.py +0 -0
  70. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/study/__init__.py +0 -0
  71. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/study/report.py +0 -0
  72. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/study/schema.py +0 -0
  73. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/sweep/__init__.py +0 -0
  74. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/sweep/runner.py +0 -0
  75. {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/tui/__init__.py +0 -0
  76. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_agent.py +0 -0
  77. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_answer.py +0 -0
  78. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_book.py +0 -0
  79. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_cli.py +0 -0
  80. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_commands.py +0 -0
  81. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_events.py +0 -0
  82. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_executor_ops.py +0 -0
  83. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_goldens.py +0 -0
  84. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_loaders.py +0 -0
  85. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_math_audit.py +0 -0
  86. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_model.py +0 -0
  87. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_process.py +0 -0
  88. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_quant_maths.py +0 -0
  89. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_screen.py +0 -0
  90. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_signals.py +0 -0
  91. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_smoke.py +0 -0
  92. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_sweep.py +0 -0
  93. {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: alphaengine
3
- Version: 0.9.1
3
+ Version: 0.9.2
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
@@ -333,6 +333,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
333
333
  |---|---|---|
334
334
  | `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
335
335
  | `load <file \| module \| universe>` | a CSV, a project module, or a portal universe | session |
336
+ | `thesis [<name> \| clear]` | choose a portal thesis for the next run | session |
336
337
  | `universe <name>` | same as load: a universe registered in the portal | session |
337
338
  | `data <file>` | same as load: a local CSV or parquet | session |
338
339
  | `project <module>` | same as load: a module with data and backtest_fn | session |
@@ -363,6 +364,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
363
364
  | `--project MODULE` | a module exposing `data` and `backtest_fn` |
364
365
  | `--data FILE` | a local CSV: wide, long, or a single series |
365
366
  | `--universe NAME` | a universe registered in the portal, with its stored closes |
367
+ | `--thesis NAME` | a thesis on this account; the run is attached to it |
366
368
  | `--symbol TICKER` | one name out of a loaded universe; its closes become the return series |
367
369
  | `--label TEXT` | what to call the artifact this run produces |
368
370
  | `--input K=V` | a workflow input; repeatable |
@@ -398,6 +400,7 @@ alphaengine run size_position --data returns.csv
398
400
  alphaengine run validate_study --project research.momentum
399
401
  alphaengine models
400
402
  alphaengine trace
403
+ alphaengine run screen_universe --thesis momentum
401
404
  alphaengine logout
402
405
  alphaengine version
403
406
  ```
@@ -288,6 +288,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
288
288
  |---|---|---|
289
289
  | `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
290
290
  | `load <file \| module \| universe>` | a CSV, a project module, or a portal universe | session |
291
+ | `thesis [<name> \| clear]` | choose a portal thesis for the next run | session |
291
292
  | `universe <name>` | same as load: a universe registered in the portal | session |
292
293
  | `data <file>` | same as load: a local CSV or parquet | session |
293
294
  | `project <module>` | same as load: a module with data and backtest_fn | session |
@@ -318,6 +319,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
318
319
  | `--project MODULE` | a module exposing `data` and `backtest_fn` |
319
320
  | `--data FILE` | a local CSV: wide, long, or a single series |
320
321
  | `--universe NAME` | a universe registered in the portal, with its stored closes |
322
+ | `--thesis NAME` | a thesis on this account; the run is attached to it |
321
323
  | `--symbol TICKER` | one name out of a loaded universe; its closes become the return series |
322
324
  | `--label TEXT` | what to call the artifact this run produces |
323
325
  | `--input K=V` | a workflow input; repeatable |
@@ -353,6 +355,7 @@ alphaengine run size_position --data returns.csv
353
355
  alphaengine run validate_study --project research.momentum
354
356
  alphaengine models
355
357
  alphaengine trace
358
+ alphaengine run screen_universe --thesis momentum
356
359
  alphaengine logout
357
360
  alphaengine version
358
361
  ```
@@ -80,4 +80,8 @@ is what a changed figure costs. The API may still move underneath it.
80
80
  # 0.9.1 IS THE SESSION, NOT THE MATHS. No computed value moved. A signed-in
81
81
  # account loads its one stored universe from the portal. `enter model key`
82
82
  # takes whichever provider key is pasted. The left pane lists the commands.
83
- __version__ = "0.9.1"
83
+ #
84
+ # 0.9.2 IS THE SESSION, NOT THE MATHS. No computed value moved. A command
85
+ # shows that it is working, and a run writes each step as it happens. A thesis
86
+ # on the account can be pinned, and the run opened after that carries its id.
87
+ __version__ = "0.9.2"
@@ -1708,11 +1708,28 @@ def cmd_run(args: argparse.Namespace) -> int:
1708
1708
  # named one explicitly. Without this, a sweep received `grid={}`.
1709
1709
  if "grid" not in inputs and project_grid():
1710
1710
  inputs["grid"] = project_grid()
1711
+ thesis_id = None
1712
+ named_thesis = getattr(args, "thesis", None)
1713
+ if named_thesis:
1714
+ chosen, candidates = match_thesis(session.theses(), str(named_thesis))
1715
+ if chosen is None:
1716
+ if candidates:
1717
+ names = ", ".join(thesis_label(r) for r in candidates)
1718
+ say(yellow(f"Which thesis? {names}"))
1719
+ else:
1720
+ say(yellow(f"No thesis named {named_thesis!r} on this account."))
1721
+ return 2
1722
+ thesis_id = thesis_id_of(chosen)
1723
+ if not thesis_id:
1724
+ say(yellow(f"{thesis_label(chosen)} has no id, so a run cannot be attached to it."))
1725
+ return 2
1726
+ say(dim(f" thesis {thesis_label(chosen)}"))
1711
1727
  run = session.open(
1712
1728
  args.workflow,
1713
1729
  data=data,
1714
1730
  backtest_fn=backtest_fn,
1715
1731
  workspace_id=workspace_id,
1732
+ thesis_id=thesis_id,
1716
1733
  **inputs,
1717
1734
  )
1718
1735
  _drive(run, quiet=args.quiet)
@@ -2235,7 +2252,7 @@ def _extract_flags(line: str) -> tuple[str, list[str]]:
2235
2252
  i = 0
2236
2253
  while i < len(parts):
2237
2254
  tok = parts[i]
2238
- if tok in ("--data", "--universe", "--project", "--symbol") and i + 1 < len(parts):
2255
+ if tok in ("--data", "--universe", "--project", "--symbol", "--thesis") and i + 1 < len(parts):
2239
2256
  flags += [tok, parts[i + 1]]
2240
2257
  i += 2
2241
2258
  continue
@@ -2357,6 +2374,58 @@ def _stored_universes(session: Any, question: str = "") -> tuple[str | None, lis
2357
2374
  return (ready[0] if len(ready) == 1 else None), ready
2358
2375
 
2359
2376
 
2377
+ def thesis_label(row: dict[str, Any]) -> str:
2378
+ """The name a person types. The id is what the run carries."""
2379
+ return str(row.get("name") or row.get("title") or row.get("id") or "").strip()
2380
+
2381
+
2382
+ def thesis_text(row: dict[str, Any]) -> str:
2383
+ """The statement, under whichever key the portal sent it."""
2384
+ for key in ("statement", "summary", "body", "text"):
2385
+ value = row.get(key)
2386
+ if isinstance(value, str) and value.strip():
2387
+ return value.strip()
2388
+ return ""
2389
+
2390
+
2391
+ def thesis_id_of(row: dict[str, Any]) -> str:
2392
+ return str(row.get("id") or row.get("thesis_id") or "").strip()
2393
+
2394
+
2395
+ def match_thesis(rows: list[Any], query: str) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
2396
+ """One thesis, or the candidates when the name matches more than one.
2397
+
2398
+ Exact name or id wins. A unique substring is enough. Several matches are
2399
+ returned so the caller can name them instead of guessing.
2400
+ """
2401
+ usable = [r for r in rows if isinstance(r, dict)]
2402
+ q = query.strip().casefold()
2403
+ exact = [r for r in usable if thesis_label(r).casefold() == q or thesis_id_of(r).casefold() == q]
2404
+ if len(exact) == 1:
2405
+ return exact[0], []
2406
+ if len(exact) > 1:
2407
+ return None, exact
2408
+ partial = [r for r in usable if q and q in thesis_label(r).casefold()]
2409
+ if len(partial) == 1:
2410
+ return partial[0], []
2411
+ return None, partial
2412
+
2413
+
2414
+ def pop_flag(flags: list[str], name: str) -> tuple[str | None, list[str]]:
2415
+ """Take one ``--name value`` out of a flag list. The rest stay in order."""
2416
+ kept: list[str] = []
2417
+ value: str | None = None
2418
+ i = 0
2419
+ while i < len(flags):
2420
+ if flags[i] == name and i + 1 < len(flags):
2421
+ value = flags[i + 1]
2422
+ i += 2
2423
+ continue
2424
+ kept.append(flags[i])
2425
+ i += 1
2426
+ return value, kept
2427
+
2428
+
2360
2429
  def _split_run(rest: str) -> tuple[str, list[str]]:
2361
2430
  """`screen_universe --universe sp100` -> ("screen_universe", [...]).
2362
2431
 
@@ -3162,6 +3231,9 @@ def _ask(
3162
3231
  data: Any,
3163
3232
  backtest_fn: Any,
3164
3233
  workspace_id: str | None = None,
3234
+ thesis_id: str | None = None,
3235
+ thesis_name: str | None = None,
3236
+ thesis_statement: str | None = None,
3165
3237
  think: Callable[[str], str] | None = None,
3166
3238
  ) -> Any:
3167
3239
  """Natural language → a workflow chosen and driven by the user's own model.
@@ -3212,14 +3284,20 @@ def _ask(
3212
3284
  return None
3213
3285
 
3214
3286
  say(f" {_accent(STAR)} {dim(label)}")
3287
+ if thesis_name:
3288
+ say(dim(f" thesis {thesis_name}"))
3289
+ goal = request
3290
+ if thesis_statement:
3291
+ goal = f"{request}\n\nThesis:\n{thesis_statement}"
3215
3292
 
3216
3293
  # Choosing the workflow is the same shape as choosing a step: an index into
3217
3294
  # a list the server produced. Reusing `AgentDriver.pick` rather than writing
3218
3295
  # a second selector keeps one place where a model's answer is validated.
3219
3296
  driver = AgentDriver(
3220
3297
  think,
3221
- goal=request,
3298
+ goal=goal,
3222
3299
  on_thought=lambda why: say(f" {_accent(STAR)} {_c('2;3', why)}"),
3300
+ on_step=lambda line: say(f" {line}"),
3223
3301
  sink=sink,
3224
3302
  provider=provider or None,
3225
3303
  model=model_name or None,
@@ -3297,6 +3375,7 @@ def _ask(
3297
3375
  data=data,
3298
3376
  backtest_fn=backtest_fn,
3299
3377
  workspace_id=workspace_id,
3378
+ thesis_id=thesis_id,
3300
3379
  **({"grid": project_grid()} if project_grid() else {}),
3301
3380
  )
3302
3381
  sink.bind(run.run_id)
@@ -3520,6 +3599,7 @@ def build_parser() -> argparse.ArgumentParser:
3520
3599
  r = sub.add_parser("run", parents=[common], help="run one workflow to completion")
3521
3600
  r.add_argument("workflow")
3522
3601
  r.add_argument("--label", help="what to call the artifact")
3602
+ r.add_argument("--thesis", help="name or id of a thesis on this account; attached to the run")
3523
3603
  r.add_argument("--input", action="append", help="workflow input as key=value (repeatable)")
3524
3604
  r.add_argument("--quiet", action="store_true", help="only the result")
3525
3605
  r.add_argument("--stream", action="store_true", help="print the answer after the citation guard")
@@ -3532,6 +3612,7 @@ def build_parser() -> argparse.ArgumentParser:
3532
3612
  for verb, workflow in QUESTION_VERBS.items():
3533
3613
  q = sub.add_parser(verb, parents=[common], help=f"run {workflow}")
3534
3614
  q.add_argument("--label", help="what to call the artifact")
3615
+ q.add_argument("--thesis", help="name or id of a thesis on this account; attached to the run")
3535
3616
  q.add_argument("--input", action="append", help="workflow input as key=value (repeatable)")
3536
3617
  q.add_argument("--quiet", action="store_true", help="only the result")
3537
3618
  q.add_argument("--stream", action="store_true", help="print the answer after the citation guard")
@@ -47,6 +47,7 @@ from __future__ import annotations
47
47
 
48
48
  import json
49
49
  import re
50
+ import time
50
51
  from collections.abc import Callable
51
52
  from typing import Any, Protocol
52
53
 
@@ -115,6 +116,7 @@ class AgentDriver:
115
116
  *,
116
117
  goal: str,
117
118
  on_thought: Callable[[str], None] | None = None,
119
+ on_step: Callable[[str], None] | None = None,
118
120
  max_steps: int = 60,
119
121
  sink: Any | None = None,
120
122
  provider: str | None = None,
@@ -126,6 +128,9 @@ class AgentDriver:
126
128
  # A loop you cannot watch is a loop you cannot trust, and that applies
127
129
  # doubly when something non-deterministic is making the choices.
128
130
  self.on_thought = on_thought or (lambda _s: None)
131
+ # A second narrator for the step itself, so a watched run can show the
132
+ # op while it is still running and the duration once it returns.
133
+ self.on_step = on_step or (lambda _s: None)
129
134
  self.max_steps = max_steps
130
135
  self.history: list[str] = []
131
136
  self.sink = sink
@@ -215,21 +220,27 @@ class AgentDriver:
215
220
  break
216
221
 
217
222
  step = run.permitted[self.pick(run.permitted)]
223
+ op = str(step.get("op") or "?")
224
+ self.on_step(f"{n + 1} {op}")
218
225
  before = run.status
226
+ started = time.monotonic()
219
227
  run.step(step)
228
+ took = time.monotonic() - started
220
229
  n += 1
221
230
 
222
231
  still = any(p.get("step_id") == step.get("step_id") for p in run.permitted)
223
232
  if still and before == "open" and run.status == "open":
224
233
  key = str(step.get("step_id"))
225
234
  failures[key] = failures.get(key, 0) + 1
226
- self.history.append(f"{step.get('op')} could not be executed here")
235
+ self.history.append(f"{op} could not be executed here")
236
+ self.on_step(f"{n} {op} could not be executed {took:.1f}s")
227
237
  if failures[key] >= 2:
228
238
  run.status = "abandoned"
229
239
  run.stopped = {"reason": "step_failed", "op": step.get("op")}
230
240
  return run
231
241
  else:
232
- self.history.append(f"{step.get('op')} done")
242
+ self.history.append(f"{op} done")
243
+ self.on_step(f"{n} {op} {took:.1f}s")
233
244
 
234
245
  if run.status == "open" and n >= self.max_steps:
235
246
  raise AgentRefusal(
@@ -335,6 +335,7 @@ class Session:
335
335
  backtest_fn: Any = None,
336
336
  handlers: dict[str, Handler] | None = None,
337
337
  workspace_id: str | None = None,
338
+ thesis_id: str | None = None,
338
339
  **inputs: Any,
339
340
  ) -> Run:
340
341
  """Open a run. `data` and `backtest_fn` stay on this machine.
@@ -345,10 +346,15 @@ class Session:
345
346
  into the pod's book. Without it the run is complete, correct, and
346
347
  attached to nothing — which is why several finished mechanisms on the
347
348
  platform had never once received input.
349
+
350
+ `thesis_id` names a thesis the portal already holds. The statement
351
+ stays on this machine; the id is how the run is attached to it.
348
352
  """
349
353
  body: Figures = {"workflow": workflow, "version": version, "inputs": inputs}
350
354
  if workspace_id:
351
355
  body["workspace_id"] = workspace_id
356
+ if thesis_id:
357
+ body["thesis_id"] = thesis_id
352
358
  directive = self._post("/api/harness/runs", body)
353
359
  run = Run(
354
360
  session=self,
@@ -486,17 +492,19 @@ class Session:
486
492
  q = f"?budget={int(budget)}" if budget else ""
487
493
  return self._get(f"/api/harness/plan{q}")
488
494
 
489
- # ── THE THESIS AND SLEEVE METHODS ARE GONE (0.5.0) ─────────────────────
490
- #
491
- # `theses()`, `propose_sleeve()` and `sleeves()` backed `--thesis` and
492
- # `--sleeve`. Their routes — /api/me/theses, /api/me/proposals/from-os and
493
- # /api/me/sleeves — were part of the thesis object model, which came out with
494
- # the retired agent desk it was built around.
495
- #
496
- # A breaking change, and a deliberate one: they created a PROPOSAL from a
497
- # screen, which is the one thing this package ever made, and the loop that
498
- # object served now runs through assignments and the deliver pin instead.
499
- # See the 0.5.0 note in the README.
495
+ def theses(self) -> list[Figures]:
496
+ """Theses on this account. Names and statements. No market data.
497
+
498
+ ``GET /api/me/theses`` returns ``{"theses": [{"id", "name", "statement"}]}``.
499
+ ``summary`` is accepted as the statement when ``statement`` is absent.
500
+ The id is what a later ``open`` sends back as ``thesis_id``. This call
501
+ does not create a thesis and does not write a proposal.
502
+ """
503
+ return list(self._get("/api/me/theses").get("theses") or [])
504
+
505
+ # `propose_sleeve()` and `sleeves()` backed `--sleeve` and wrote a proposal
506
+ # to `/api/me/proposals/from-os` and `/api/me/sleeves`. Those left with the
507
+ # agent desk in 0.5.0. Selecting a thesis is the read above, not that writer.
500
508
 
501
509
  def post_event(self, run_id: str, event: dict[str, Any]) -> Figures:
502
510
  """Allowlisted model/run event. 404-tolerant at the sink, not here.
@@ -84,6 +84,7 @@ FLAGS: dict[str, Flag] = {
84
84
  "project": Flag("--project", "MODULE", "a module exposing `data` and `backtest_fn`"),
85
85
  "data": Flag("--data", "FILE", "a local CSV: wide, long, or a single series"),
86
86
  "universe": Flag("--universe", "NAME", "a universe registered in the portal, with its stored closes"),
87
+ "thesis": Flag("--thesis", "NAME", "a thesis on this account; the run is attached to it"),
87
88
  "symbol": Flag(
88
89
  "--symbol", "TICKER", "one name out of a loaded universe; its closes become the return series"
89
90
  ),
@@ -352,7 +353,19 @@ COMMANDS: tuple[Command, ...] = _question_commands() + (
352
353
  "alphaengine run size_position --data returns.csv",
353
354
  "alphaengine run validate_study --project research.momentum",
354
355
  ),
355
- flags=("project", "data", "universe", "symbol", "label", "input", "quiet", "stream", "url", "key"),
356
+ flags=(
357
+ "project",
358
+ "data",
359
+ "universe",
360
+ "symbol",
361
+ "thesis",
362
+ "label",
363
+ "input",
364
+ "quiet",
365
+ "stream",
366
+ "url",
367
+ "key",
368
+ ),
356
369
  ),
357
370
  Command(
358
371
  verb="<anything else>",
@@ -454,6 +467,29 @@ COMMANDS: tuple[Command, ...] = _question_commands() + (
454
467
  ),
455
468
  examples=("load prices.csv", "load research.momentum", "load sp500"),
456
469
  ),
470
+ Command(
471
+ verb="thesis",
472
+ group="data",
473
+ args="[<name> | clear]",
474
+ scope="repl",
475
+ purpose="choose a portal thesis for the next run",
476
+ body=(
477
+ "Lists the theses on this account, or pins one. A pinned thesis rides on "
478
+ "every following run, question, and workflow as thesis_id. The statement "
479
+ "stays on this machine and is given to your model as context. It is not "
480
+ "written into the figures.\n\n"
481
+ "`thesis clear` unpins it. One thesis on the account is pinned at sign-in. "
482
+ "Several are named and not guessed.\n\n"
483
+ "The shell spelling is `--thesis <name>` on `run`, `screen`, `diagnose`, "
484
+ "and the other question commands."
485
+ ),
486
+ examples=(
487
+ "thesis",
488
+ "thesis momentum",
489
+ "thesis clear",
490
+ "alphaengine run screen_universe --thesis momentum",
491
+ ),
492
+ ),
457
493
  Command(
458
494
  verb="universe",
459
495
  group="data",
@@ -5,13 +5,14 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import os
7
7
  import re
8
+ import time
8
9
  from typing import Any
9
10
 
10
11
  from textual.app import App, ComposeResult
11
12
  from textual.containers import Horizontal, Vertical
12
13
  from textual.widgets import DataTable, Input, ListItem, ListView, RichLog, Sparkline, Static
13
14
 
14
- from ..cli import narration_to, resolve_data
15
+ from ..cli import narration_to, resolve_data, thesis_id_of, thesis_label
15
16
  from .dispatch import Desk, submit
16
17
 
17
18
  _ANSI = re.compile(r"\x1b\[[0-9;]*m")
@@ -20,6 +21,7 @@ _COMMANDS = (
20
21
  "login",
21
22
  "enter model key",
22
23
  "load <name>",
24
+ "thesis",
23
25
  "screen",
24
26
  "diagnose",
25
27
  "signal",
@@ -37,6 +39,7 @@ _URL_AFTER = {
37
39
  "AZURE_OPENAI_API_KEY": "AZURE_OPENAI_ENDPOINT",
38
40
  "ALPHAENGINE_API_KEY": "ALPHAENGINE_BASE_URL",
39
41
  }
42
+ _SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
40
43
 
41
44
  _CSS = """
42
45
  Screen { background: #0b0d10; }
@@ -131,6 +134,8 @@ class QuantOSApp(App[None]):
131
134
  self._stored: set[str] = set()
132
135
  self._lines: list[dict[str, Any]] = []
133
136
  self.busy = False
137
+ self.activity = ""
138
+ self._started = 0.0
134
139
  self.rail_text = ""
135
140
  self.formula_text = ""
136
141
  self.transcript: list[str] = []
@@ -160,6 +165,7 @@ class QuantOSApp(App[None]):
160
165
  log.write("")
161
166
  log.write("help lists what you can type. quit leaves.")
162
167
  self.query_one(Input).focus()
168
+ self.set_interval(0.2, self._tick_working)
163
169
  self._sync_account()
164
170
 
165
171
  def _refresh_rail(self) -> None:
@@ -200,6 +206,9 @@ class QuantOSApp(App[None]):
200
206
  "loaded",
201
207
  f" {loaded}",
202
208
  "",
209
+ "thesis",
210
+ f" {_fit(thesis_label(self.desk.thesis) if self.desk.thesis else 'none')}",
211
+ "",
203
212
  "universes",
204
213
  ]
205
214
  if self._universes:
@@ -239,6 +248,11 @@ class QuantOSApp(App[None]):
239
248
  self._accept_url(text)
240
249
  return
241
250
  self.busy = True
251
+ self.activity = text
252
+ self._started = time.monotonic()
253
+ prompt = self.query_one(Input)
254
+ prompt.disabled = True
255
+ self._paint_working()
242
256
  self.query_one("#transcript", RichLog).write(f"> {text}")
243
257
 
244
258
  def go() -> None:
@@ -393,6 +407,17 @@ class QuantOSApp(App[None]):
393
407
  notes.append(
394
408
  "Stored on your account: " + ", ".join(ready) + ". Type load <name> to use one."
395
409
  )
410
+ if self.desk.keyed and self.desk.thesis is None and not self.desk.thesis_declined:
411
+ try:
412
+ theses = [r for r in self.desk.session.theses() if isinstance(r, dict) and thesis_id_of(r)]
413
+ except Exception: # noqa: BLE001 — offline or unsigned still boots
414
+ theses = []
415
+ if len(theses) == 1:
416
+ self.desk.thesis = theses[0]
417
+ notes.append(f"thesis {thesis_label(theses[0])}")
418
+ elif len(theses) > 1:
419
+ named = ", ".join(thesis_label(r) for r in theses[:8])
420
+ notes.append(f"Theses on your account: {named}. Type thesis <name> to use one.")
396
421
  log = self.query_one("#transcript", RichLog)
397
422
  for note in notes:
398
423
  plain = _plain(note)
@@ -401,46 +426,64 @@ class QuantOSApp(App[None]):
401
426
  log.write(plain)
402
427
  self._refresh_rail()
403
428
 
429
+ def _tick_working(self) -> None:
430
+ if self.busy:
431
+ self._paint_working()
432
+
433
+ def _paint_working(self) -> None:
434
+ elapsed = time.monotonic() - self._started
435
+ frame = _SPIN[int(elapsed * 12) % len(_SPIN)]
436
+ label = _fit(self.activity or "working", 42)
437
+ self.query_one("#status", Static).update(f"{frame} working · {label} · {elapsed:.1f}s")
438
+
439
+ def _live(self, chunk: str) -> None:
440
+ plain = _plain(chunk).strip()
441
+ if not plain:
442
+ return
443
+ self.activity = plain
444
+ self.transcript.append(plain)
445
+ self.query_one("#transcript", RichLog).write(plain)
446
+ if self.busy:
447
+ self._paint_working()
448
+
404
449
  def _run_line(self, text: str) -> None:
405
- chunks: list[str] = []
450
+ quitting = False
406
451
 
407
452
  def sink(chunk: str) -> None:
408
- chunks.append(chunk)
453
+ self.call_from_thread(self._live, chunk)
409
454
 
410
- quitting = False
411
455
  try:
412
456
  with narration_to(sink):
413
457
  quitting = submit(self.desk, text)
414
458
  except Exception as exc: # noqa: BLE001 — the session stays up
415
- chunks.append(str(exc))
416
- self.call_from_thread(self._finish, chunks, quitting)
459
+ self.call_from_thread(self._live, str(exc))
460
+ self.call_from_thread(self._finish, quitting)
417
461
 
418
- def _finish(self, chunks: list[str], quitting: bool) -> None:
419
- log = self.query_one("#transcript", RichLog)
420
- for chunk in chunks:
421
- plain = _plain(chunk)
422
- if plain.strip():
423
- self.transcript.append(plain)
424
- log.write(plain)
462
+ def _finish(self, quitting: bool) -> None:
463
+ prompt = self.query_one(Input)
464
+ prompt.disabled = False
425
465
  if self.desk.pending_secret:
426
- prompt = self.query_one(Input)
427
466
  prompt.password = True
428
467
  prompt.placeholder = "paste the key (hidden)"
429
468
  elif self.desk.pending_provider:
430
- prompt = self.query_one(Input)
431
469
  prompt.password = False
432
470
  prompt.placeholder = "anthropic, openai, gemini, groq, openrouter, azure, gateway"
433
471
  elif self.desk.pending_url:
434
- prompt = self.query_one(Input)
435
472
  prompt.password = False
436
473
  prompt.placeholder = f"paste {self.desk.pending_url}"
474
+ else:
475
+ prompt.password = False
476
+ prompt.placeholder = "Ask in plain English, or run <workflow>"
477
+ self.busy = False
478
+ self.activity = ""
437
479
  if self.desk.account_dirty:
438
480
  self.desk.account_dirty = False
439
481
  self._sync_account()
440
482
  else:
441
483
  self._refresh_rail()
442
484
  self._load_lines()
443
- self.busy = False
485
+ if not quitting:
486
+ prompt.focus()
444
487
  if quitting:
445
488
  self.exit()
446
489
 
@@ -27,11 +27,16 @@ from ..cli import (
27
27
  _split_run,
28
28
  dim,
29
29
  load_project,
30
+ match_thesis,
31
+ pop_flag,
30
32
  preflight,
31
33
  project_grid,
32
34
  red,
33
35
  resolve_data,
34
36
  say,
37
+ thesis_id_of,
38
+ thesis_label,
39
+ thesis_text,
35
40
  yellow,
36
41
  )
37
42
  from ..client import Offline, ServerError
@@ -57,6 +62,8 @@ class Desk:
57
62
  account_dirty: bool = False
58
63
  pinned_model: str | None = None
59
64
  book: Any = None
65
+ thesis: dict[str, Any] | None = None
66
+ thesis_declined: bool = False
60
67
 
61
68
 
62
69
  def submit(desk: Desk, line: str) -> bool:
@@ -66,6 +73,9 @@ def submit(desk: Desk, line: str) -> bool:
66
73
  return False
67
74
 
68
75
  line, inline_flags = _extract_flags(line)
76
+ thesis_query, inline_flags = pop_flag(inline_flags, "--thesis")
77
+ if thesis_query and not _pin_thesis(desk, thesis_query):
78
+ return False
69
79
  if inline_flags:
70
80
  try:
71
81
  desk.data, desk.backtest_fn = _apply_flags(
@@ -78,8 +88,8 @@ def submit(desk: Desk, line: str) -> bool:
78
88
  except Exception as exc: # noqa: BLE001 — a server refusal, said plainly
79
89
  say(red(str(exc)))
80
90
  return False
81
- if not line.strip():
82
- return False
91
+ if not line.strip():
92
+ return False
83
93
 
84
94
  if line.split()[:1] == ["alphaengine"]:
85
95
  line = line.partition(" ")[2].strip()
@@ -132,6 +142,9 @@ def submit(desk: Desk, line: str) -> bool:
132
142
  if verb == "book":
133
143
  _book(desk, rest)
134
144
  return False
145
+ if verb in ("thesis", "theses"):
146
+ _thesis(desk, rest)
147
+ return False
135
148
  if verb in ("commands", "?"):
136
149
  from ..cli import cmd_commands
137
150
 
@@ -164,6 +177,8 @@ def submit(desk: Desk, line: str) -> bool:
164
177
  _key(desk, rest)
165
178
  return False
166
179
  if verb == "status":
180
+ if desk.thesis:
181
+ say(dim(f"thesis {thesis_label(desk.thesis)}"))
167
182
  if desk.last is None:
168
183
  say(dim("no run yet"))
169
184
  else:
@@ -232,6 +247,8 @@ def _logout(desk: Desk) -> None:
232
247
  desk.pending_provider = False
233
248
  desk.held_key = None
234
249
  desk.pinned_model = None
250
+ desk.thesis = None
251
+ desk.thesis_declined = False
235
252
  if str(desk.loaded or "").startswith("universe:"):
236
253
  desk.data = None
237
254
  desk.backtest_fn = None
@@ -351,7 +368,66 @@ def _load_named(desk: Desk, verb: str, rest: str) -> None:
351
368
  say(red(str(exc)))
352
369
 
353
370
 
371
+ def _thesis_rows(desk: Desk) -> list[dict[str, Any]] | None:
372
+ try:
373
+ rows = desk.session.theses()
374
+ except Exception as exc: # noqa: BLE001 — offline or unsigned, said plainly
375
+ say(red(str(exc)))
376
+ return None
377
+ return [r for r in rows if isinstance(r, dict)]
378
+
379
+
380
+ def _pin_thesis(desk: Desk, query: str) -> bool:
381
+ """Pin one thesis. False leaves the previous pin alone."""
382
+ rows = _thesis_rows(desk)
383
+ if rows is None:
384
+ return False
385
+ chosen, candidates = match_thesis(rows, query)
386
+ if chosen is None:
387
+ if candidates:
388
+ say(yellow("Which thesis? " + ", ".join(thesis_label(r) for r in candidates)))
389
+ else:
390
+ say(yellow(f"No thesis named {query!r}."))
391
+ return False
392
+ if not thesis_id_of(chosen):
393
+ say(yellow(f"{thesis_label(chosen)} has no id, so a run cannot be attached to it."))
394
+ return False
395
+ desk.thesis = chosen
396
+ desk.thesis_declined = False
397
+ say(dim(f"thesis {thesis_label(chosen)}"))
398
+ text = thesis_text(chosen)
399
+ if text:
400
+ say(dim(" " + (text if len(text) <= 240 else text[:237] + "...")))
401
+ return True
402
+
403
+
404
+ def _thesis(desk: Desk, rest: str) -> None:
405
+ if rest.lower() in ("clear", "none", "off"):
406
+ desk.thesis = None
407
+ desk.thesis_declined = True
408
+ say(dim("thesis cleared"))
409
+ return
410
+ if rest:
411
+ _pin_thesis(desk, rest)
412
+ return
413
+ rows = _thesis_rows(desk)
414
+ if rows is None:
415
+ return
416
+ if not rows:
417
+ say(dim("No theses on this account."))
418
+ return
419
+ selected = thesis_id_of(desk.thesis) if desk.thesis else ""
420
+ for row in rows:
421
+ mark = " selected" if selected and thesis_id_of(row) == selected else ""
422
+ say(f" {thesis_label(row)}{mark}")
423
+ if desk.thesis is None and len(rows) > 1:
424
+ say(dim("Type thesis <name> to use one."))
425
+
426
+
354
427
  def _scripted(desk: Desk, name: str, flags: list[str]) -> None:
428
+ thesis_query, flags = pop_flag(flags, "--thesis")
429
+ if thesis_query and not _pin_thesis(desk, thesis_query):
430
+ return
355
431
  try:
356
432
  if flags:
357
433
  desk.data, desk.backtest_fn = _apply_flags(flags, desk.session, desk.data, desk.backtest_fn)
@@ -361,10 +437,14 @@ def _scripted(desk: Desk, name: str, flags: list[str]) -> None:
361
437
  say(yellow(_repl_gap(gap, name)))
362
438
  return
363
439
  grid = project_grid()
440
+ pinned = thesis_id_of(desk.thesis) if desk.thesis else ""
441
+ if desk.thesis:
442
+ say(dim(f" thesis {thesis_label(desk.thesis)}"))
364
443
  desk.last = desk.session.open(
365
444
  name,
366
445
  data=desk.data,
367
446
  backtest_fn=desk.backtest_fn,
447
+ thesis_id=pinned or None,
368
448
  **({"grid": grid} if grid else {}),
369
449
  )
370
450
  _drive(desk.last)
@@ -396,6 +476,7 @@ def _sentence(desk: Desk, line: str) -> None:
396
476
  except Exception as exc: # noqa: BLE001
397
477
  say(dim(f" ({named} is registered and could not be loaded: {exc})"))
398
478
 
479
+ pinned = desk.thesis
399
480
  run = _ask(
400
481
  desk.session,
401
482
  desk.url,
@@ -403,6 +484,9 @@ def _sentence(desk: Desk, line: str) -> None:
403
484
  data=desk.data,
404
485
  backtest_fn=desk.backtest_fn,
405
486
  think=desk.think,
487
+ thesis_id=thesis_id_of(pinned) or None if pinned else None,
488
+ thesis_name=thesis_label(pinned) if pinned else None,
489
+ thesis_statement=thesis_text(pinned) if pinned else None,
406
490
  )
407
491
  if run is not None:
408
492
  desk.last = run
@@ -822,3 +822,28 @@ def test_a_rejected_trace_does_not_fail_the_run():
822
822
  assert run.trace_rejected
823
823
  assert "404" in run.trace_rejected
824
824
  assert any(path.endswith("/steps") for path, _ in session.posts)
825
+
826
+
827
+ def test_open_attaches_a_thesis_by_id():
828
+ session = _TraceSession()
829
+ run = session.open("measure", data=[0.01, -0.01], thesis_id="th1")
830
+ assert run.run_id == "r1"
831
+ body = next(body for path, body in session.posts if path.endswith("/runs"))
832
+ assert body["thesis_id"] == "th1"
833
+ assert "statement" not in body
834
+
835
+
836
+ def test_a_thesis_name_matches_exactly_and_does_not_guess():
837
+ from alphaengine.cli import match_thesis, thesis_text
838
+
839
+ rows = [
840
+ {"id": "a", "name": "momentum book", "summary": "pays"},
841
+ {"id": "b", "name": "momentum sleeve"},
842
+ ]
843
+ chosen, extra = match_thesis(rows, "momentum book")
844
+ assert chosen is not None and chosen["id"] == "a"
845
+ assert thesis_text(chosen) == "pays"
846
+ assert extra == []
847
+ chosen, extra = match_thesis(rows, "momentum")
848
+ assert chosen is None
849
+ assert {row["id"] for row in extra} == {"a", "b"}
@@ -110,6 +110,73 @@ def test_a_scripted_run_opens_the_sharpe_line_at_full_length() -> None:
110
110
  assert "sqrt" in app.formula_text
111
111
  table = app.query_one("#series", DataTable)
112
112
  assert table.row_count == 40
113
+ assert any("compute.performance_report" in line for line in app.transcript)
114
+
115
+
116
+ def test_a_thesis_is_selected_and_sent_with_the_run() -> None:
117
+ class _Theses(_DeskSession):
118
+ def _get(self, path: str):
119
+ if path.endswith("/theses"):
120
+ return {
121
+ "theses": [
122
+ {"id": "th1", "name": "momentum", "statement": "Large-cap momentum still pays."},
123
+ {"id": "th2", "name": "quality", "statement": "Quality compounds."},
124
+ ]
125
+ }
126
+ return super()._get(path)
127
+
128
+ async def body() -> None:
129
+ app = QuantOSApp(session=_Theses(), url="fake://", data=_returns(), keyed=True, think=_think)
130
+ async with app.run_test(size=(140, 42)) as pilot:
131
+ await pilot.pause()
132
+ assert "momentum" in app.transcript[-1] or any("momentum" in line for line in app.transcript)
133
+ assert "none" in app.rail_text
134
+ field = app.query_one(Input)
135
+ field.focus()
136
+ field.value = "thesis momentum"
137
+ await pilot.press("enter")
138
+ await _wait(pilot, app)
139
+ assert app.desk.thesis is not None
140
+ assert app.desk.thesis["id"] == "th1"
141
+ assert "momentum" in app.rail_text
142
+
143
+ field.value = "run measure"
144
+ await pilot.press("enter")
145
+ await _wait(pilot, app)
146
+ opens = [body for path, body in app.desk.session.posts if path.endswith("/runs")]
147
+ assert opens and opens[-1].get("thesis_id") == "th1"
148
+
149
+ field.value = "which of my names are overbought"
150
+ await pilot.press("enter")
151
+ await _wait(pilot, app)
152
+ opens = [body for path, body in app.desk.session.posts if path.endswith("/runs")]
153
+ assert opens[-1].get("thesis_id") == "th1"
154
+ assert any("thesis" in line and "momentum" in line for line in app.transcript)
155
+
156
+ field.value = "thesis clear"
157
+ await pilot.press("enter")
158
+ await _wait(pilot, app)
159
+ assert app.desk.thesis is None
160
+
161
+ asyncio.run(body())
162
+
163
+
164
+ def test_one_thesis_is_pinned_at_sign_in() -> None:
165
+ class _One(_DeskSession):
166
+ def _get(self, path: str):
167
+ if path.endswith("/theses"):
168
+ return {"theses": [{"id": "th1", "name": "momentum", "summary": "It still pays."}]}
169
+ return super()._get(path)
170
+
171
+ async def body() -> None:
172
+ app = QuantOSApp(session=_One(), url="fake://", data=_returns(), keyed=True, think=_think)
173
+ async with app.run_test(size=(140, 42)) as pilot:
174
+ await pilot.pause()
175
+ assert app.desk.thesis is not None
176
+ assert app.desk.thesis["id"] == "th1"
177
+ assert "momentum" in app.rail_text
178
+
179
+ asyncio.run(body())
113
180
 
114
181
  asyncio.run(body())
115
182
 
@@ -214,6 +281,9 @@ def test_a_sentence_uses_the_fake_model_to_choose_a_workflow() -> None:
214
281
  field.value = "which of my names are overbought"
215
282
  await pilot.press("enter")
216
283
  await _wait(pilot, app)
217
- assert any("screen_universe" in line for line in app.transcript)
284
+ text = "\n".join(app.transcript)
285
+ assert "screen_universe" in text
286
+ assert "the question is a screen" in text
287
+ assert "compute.performance_report" in text
218
288
 
219
289
  asyncio.run(body())
File without changes
File without changes
File without changes
File without changes
File without changes