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.
- {alphaengine-0.9.1 → alphaengine-0.9.2}/PKG-INFO +4 -1
- {alphaengine-0.9.1 → alphaengine-0.9.2}/README.md +3 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/_version.py +5 -1
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/cli.py +83 -2
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/client/agent.py +13 -2
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/client/session.py +19 -11
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/commands.py +37 -1
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/tui/app.py +60 -17
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/tui/dispatch.py +86 -2
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_client.py +25 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_tui.py +71 -1
- {alphaengine-0.9.1 → alphaengine-0.9.2}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/.gitignore +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/AGENTS.md +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/LICENSE +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/SECURITY.md +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/assets/banner.png +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/assets/data_boundary.png +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/assets/session.png +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/figure_contract.md +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/workflows/evaluate_process.json +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/workflows/research_week.json +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/docs/workflows/stress_study.json +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/pyproject.toml +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/scripts/gen_docs.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/__init__.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/__main__.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/agent/answer.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/auth.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/book.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/charts.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/client/__init__.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/client/executor.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/complete.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/connectors/__init__.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/__init__.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/active.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/allocate.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/backtest.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/covariance.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/cross_section.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/factors.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/pairs.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/panel.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/performance.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/process.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/profile.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/risk.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/screen.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/series_shapes.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/signals.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/stress.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/trace.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/validation.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/core/walkforward.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo_book.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo_returns.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo_signal.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/demo_universe.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/events.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/loaders.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/model.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/repl.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/study/report.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/study/schema.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/sweep/runner.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/src/alphaengine/tui/__init__.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_agent.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_answer.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_book.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_cli.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_commands.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_events.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_executor_ops.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_goldens.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_loaders.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_math_audit.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_model.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_process.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_quant_maths.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_screen.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_signals.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_smoke.py +0 -0
- {alphaengine-0.9.1 → alphaengine-0.9.2}/tests/test_sweep.py +0 -0
- {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.
|
|
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
|
-
|
|
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=
|
|
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"{
|
|
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"{
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
#
|
|
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=(
|
|
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
|
-
|
|
450
|
+
quitting = False
|
|
406
451
|
|
|
407
452
|
def sink(chunk: str) -> None:
|
|
408
|
-
|
|
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
|
-
|
|
416
|
-
self.call_from_thread(self._finish,
|
|
459
|
+
self.call_from_thread(self._live, str(exc))
|
|
460
|
+
self.call_from_thread(self._finish, quitting)
|
|
417
461
|
|
|
418
|
-
def _finish(self,
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|