alphaengine 1.1.1__tar.gz → 1.1.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 (143) hide show
  1. {alphaengine-1.1.1 → alphaengine-1.1.2}/CHANGELOG.md +11 -0
  2. {alphaengine-1.1.1 → alphaengine-1.1.2}/PKG-INFO +1 -1
  3. {alphaengine-1.1.1 → alphaengine-1.1.2}/SECURITY.md +4 -5
  4. {alphaengine-1.1.1 → alphaengine-1.1.2}/pyproject.toml +1 -1
  5. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/cli.py +7 -42
  6. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/allocate.py +8 -1
  7. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/backtest.py +2 -2
  8. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/pairs.py +3 -1
  9. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/performance.py +13 -8
  10. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/process.py +8 -1
  11. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/risk.py +8 -10
  12. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/technical.py +3 -1
  13. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/validation.py +29 -11
  14. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/watch.py +3 -2
  15. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_cli.py +16 -11
  16. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_math_audit.py +81 -0
  17. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_project.py +20 -0
  18. {alphaengine-1.1.1 → alphaengine-1.1.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  19. {alphaengine-1.1.1 → alphaengine-1.1.2}/.github/ISSUE_TEMPLATE/data_shape.md +0 -0
  20. {alphaengine-1.1.1 → alphaengine-1.1.2}/.github/ISSUE_TEMPLATE/method_request.md +0 -0
  21. {alphaengine-1.1.1 → alphaengine-1.1.2}/.github/dependabot.yml +0 -0
  22. {alphaengine-1.1.1 → alphaengine-1.1.2}/.github/workflows/ci.yml +0 -0
  23. {alphaengine-1.1.1 → alphaengine-1.1.2}/.github/workflows/codeql.yml +0 -0
  24. {alphaengine-1.1.1 → alphaengine-1.1.2}/.github/workflows/publish.yml +0 -0
  25. {alphaengine-1.1.1 → alphaengine-1.1.2}/.gitignore +0 -0
  26. {alphaengine-1.1.1 → alphaengine-1.1.2}/AGENTS.md +0 -0
  27. {alphaengine-1.1.1 → alphaengine-1.1.2}/CODE_OF_CONDUCT.md +0 -0
  28. {alphaengine-1.1.1 → alphaengine-1.1.2}/CONTRIBUTING.md +0 -0
  29. {alphaengine-1.1.1 → alphaengine-1.1.2}/LICENSE +0 -0
  30. {alphaengine-1.1.1 → alphaengine-1.1.2}/README.md +0 -0
  31. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/assets/banner.png +0 -0
  32. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/assets/data_boundary.png +0 -0
  33. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/assets/session.png +0 -0
  34. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/figure_contract.md +0 -0
  35. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/free-vs-firm.md +0 -0
  36. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/plugins.md +0 -0
  37. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/project-format.md +0 -0
  38. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/result-format.md +0 -0
  39. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/workflows/evaluate_process.json +0 -0
  40. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/workflows/research_week.json +0 -0
  41. {alphaengine-1.1.1 → alphaengine-1.1.2}/docs/workflows/stress_study.json +0 -0
  42. {alphaengine-1.1.1 → alphaengine-1.1.2}/examples/process_stress_monitor.yaml +0 -0
  43. {alphaengine-1.1.1 → alphaengine-1.1.2}/examples/screen_validate_size.yaml +0 -0
  44. {alphaengine-1.1.1 → alphaengine-1.1.2}/scripts/gen_docs.py +0 -0
  45. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/__init__.py +0 -0
  46. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/__main__.py +0 -0
  47. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/_version.py +0 -0
  48. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/agent/__init__.py +0 -0
  49. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/agent/answer.py +0 -0
  50. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/agent/bakeoff.py +0 -0
  51. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/agent/brief.py +0 -0
  52. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/agent/chart.py +0 -0
  53. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/agent/driver.py +0 -0
  54. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/agent/sim.py +0 -0
  55. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/auth.py +0 -0
  56. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/book.py +0 -0
  57. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/charts.py +0 -0
  58. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/client/__init__.py +0 -0
  59. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/client/agent.py +0 -0
  60. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/client/executor.py +0 -0
  61. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/client/session.py +0 -0
  62. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/commands.py +0 -0
  63. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/complete.py +0 -0
  64. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/connectors/__init__.py +0 -0
  65. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/context.py +0 -0
  66. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/contracts/__init__.py +0 -0
  67. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/contracts/hash_golden.json +0 -0
  68. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/__init__.py +0 -0
  69. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/active.py +0 -0
  70. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/bakeoff.py +0 -0
  71. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/covariance.py +0 -0
  72. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/cross_section.py +0 -0
  73. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/factors.py +0 -0
  74. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/market.py +0 -0
  75. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/options.py +0 -0
  76. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/panel.py +0 -0
  77. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/portfolio.py +0 -0
  78. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/profile.py +0 -0
  79. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/rates.py +0 -0
  80. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/regime.py +0 -0
  81. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/relations.py +0 -0
  82. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/screen.py +0 -0
  83. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/series_shapes.py +0 -0
  84. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/signals.py +0 -0
  85. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/stress.py +0 -0
  86. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/trace.py +0 -0
  87. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/core/walkforward.py +0 -0
  88. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/datasets.py +0 -0
  89. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/demo.py +0 -0
  90. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/demo_book.py +0 -0
  91. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/demo_returns.py +0 -0
  92. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/demo_signal.py +0 -0
  93. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/demo_universe.py +0 -0
  94. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/envelope.py +0 -0
  95. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/events.py +0 -0
  96. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/ideas.py +0 -0
  97. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/io.py +0 -0
  98. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/ledger.py +0 -0
  99. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/loaders.py +0 -0
  100. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/memo.py +0 -0
  101. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/model.py +0 -0
  102. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/pipeline.py +0 -0
  103. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/plugins.py +0 -0
  104. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/project.py +0 -0
  105. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/py.typed +0 -0
  106. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/repl.py +0 -0
  107. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/study/__init__.py +0 -0
  108. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/study/report.py +0 -0
  109. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/study/schema.py +0 -0
  110. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/sweep/__init__.py +0 -0
  111. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/sweep/runner.py +0 -0
  112. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/sync.py +0 -0
  113. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/tui/__init__.py +0 -0
  114. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/tui/app.py +0 -0
  115. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/tui/dispatch.py +0 -0
  116. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/tui/mark.py +0 -0
  117. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/tui/stage.py +0 -0
  118. {alphaengine-1.1.1 → alphaengine-1.1.2}/src/alphaengine/tui/typeface.py +0 -0
  119. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_agent.py +0 -0
  120. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_answer.py +0 -0
  121. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_bakeoff.py +0 -0
  122. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_book.py +0 -0
  123. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_client.py +0 -0
  124. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_commands.py +0 -0
  125. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_context.py +0 -0
  126. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_coverage_paths.py +0 -0
  127. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_events.py +0 -0
  128. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_executor_ops.py +0 -0
  129. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_goldens.py +0 -0
  130. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_io.py +0 -0
  131. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_loaders.py +0 -0
  132. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_model.py +0 -0
  133. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_portfolio.py +0 -0
  134. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_process.py +0 -0
  135. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_quant_maths.py +0 -0
  136. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_relations.py +0 -0
  137. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_screen.py +0 -0
  138. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_signals.py +0 -0
  139. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_sim.py +0 -0
  140. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_smoke.py +0 -0
  141. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_sweep.py +0 -0
  142. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_trace.py +0 -0
  143. {alphaengine-1.1.1 → alphaengine-1.1.2}/tests/test_tui.py +0 -0
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to Semantic Versioning (with computed golden values
7
7
  treated as part of the public contract).
8
8
 
9
+ ## [1.1.2] - 2026-10-24
10
+
11
+ ### Fixed
12
+ - **Array-like inputs across validation and metrics (`core/validation.py`, `core/performance.py`, `core/risk.py`):** Replaced truthiness checks (`returns or []`, `if equity_curve:`, `if benchmark_returns:`, `portfolio_returns or []`) with `np.asarray` conversion so `deflated_sharpe`, `min_track_record_length`, `combinatorial_oos_stability` / `cpcv_score`, `pbo_cscv`, `performance_report`, and `compute_var_cvar` accept Python `list`, NumPy `ndarray`, and pandas `Series` / `DataFrame` without raising `ValueError: The truth value of an array/Series is ambiguous`.
13
+ - **Complete `_GATED_COMMANDS` and unified 401/403 refusal (`cli.py`):** Added `workflows`, `runs`, `gaps`, and `tonight` to `_GATED_COMMANDS` so every gated command goes through `require_firm_key()`, and replaced the separate `"Not authenticated"` refusal block with `FIRM_KEY_ERROR_MESSAGE`.
14
+ - **Live-monitor trial accounting (`watch.py`):** `ae monitor <run> --live <file>` (`monitor_live`) now records `n_trials=0` (`already_counted_in_ledger=True`) so out-of-sample live drift checks never inflate the project's cumulative trial count `N`.
15
+ - **Clean SLSQP bounds clipping (`core/process.py`, `core/allocate.py`):** Suppressed the benign SciPy SLSQP bounds-clipping `RuntimeWarning` during GARCH(1,1) and risk-parity optimization.
16
+
17
+ ### Changed
18
+ - **Security policy (`SECURITY.md`):** Replaced personal email contact with [GitHub Private Vulnerability Reporting / Security Advisories](https://github.com/quantOSC/alphaengine/security/advisories/new).
19
+
9
20
  ## [1.1.1] - 2026-10-24
10
21
 
11
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: alphaengine
3
- Version: 1.1.1
3
+ Version: 1.1.2
4
4
  Summary: Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts.
5
5
  Project-URL: Homepage, https://qosai.app
6
6
  Project-URL: Documentation, https://github.com/quantOSC/alphaengine#readme
@@ -2,9 +2,8 @@
2
2
 
3
3
  ## Reporting a vulnerability
4
4
 
5
- Report privately through [GitHub Private Vulnerability Reporting / Security Advisories](https://github.com/quantOSC/alphaengine/security/advisories/new),
6
- or email **wfe818@gmail.com**. Please don't open a public issue for a
7
- suspected vulnerability.
5
+ Report privately through [GitHub Private Vulnerability Reporting / Security Advisories](https://github.com/quantOSC/alphaengine/security/advisories/new).
6
+ Please don't open a public issue for a suspected vulnerability.
8
7
 
9
8
  We'll acknowledge within **3 working days** and give an assessment within **10**.
10
9
  If we accept the report we'll agree a disclosure timeline with you and credit
@@ -13,8 +12,8 @@ you in the advisory unless you'd rather we didn't.
13
12
  ## What's in scope
14
13
 
15
14
  This repository: the library, its build, and its release pipeline. The hosted
16
- platform is a separate system, report issues there to the same address and say
17
- so.
15
+ platform is a separate system; report issues there through the same private
16
+ vulnerability reporting channel and say so.
18
17
 
19
18
  ## How this package is built and shipped
20
19
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "alphaengine"
7
- version = "1.1.1"
7
+ version = "1.1.2"
8
8
  description = "Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -3673,21 +3673,9 @@ def boot(url: str, *, project: str | None, data: Any, keyed: bool, session: Any
3673
3673
 
3674
3674
 
3675
3675
  def refused(exc: Any) -> None:
3676
- """Report a server refusal, and make an AUTH refusal actionable.
3676
+ """Report a server refusal; 401/403 emit the canonical firm-key message."""
3677
+ from .auth import FIRM_KEY_ERROR_MESSAGE
3677
3678
 
3678
- ── WHY THIS IS NOT JUST A RED LINE ────────────────────────────────────────
3679
-
3680
- "The server refused: Authentication required" is true, useless, and actively
3681
- misleading to somebody who IS a paying user — they are signed into the
3682
- portal in a browser two windows away, so the natural reading is that
3683
- something is broken. It is not: a browser session is not a CLI credential
3684
- and never will be, because this process has no cookie jar and should not
3685
- acquire one.
3686
-
3687
- So a 401 says what is actually wrong, where the key comes from, and offers
3688
- to take it right here. Every other status keeps the plain message, because
3689
- the fix for a 404 or a 422 is not a credential.
3690
- """
3691
3679
  status = getattr(exc, "status", None)
3692
3680
  detail = getattr(exc, "detail", str(exc))
3693
3681
 
@@ -3695,34 +3683,7 @@ def refused(exc: Any) -> None:
3695
3683
  say(red(f"The server refused: {detail}"))
3696
3684
  return
3697
3685
 
3698
- say("")
3699
- say(
3700
- yellow(" Not authenticated.")
3701
- + dim(
3702
- " This command requires a portal-issued ae_live_ key; "
3703
- "your browser login does not reach this process."
3704
- )
3705
- )
3706
- say(dim(" A browser session is a cookie; this needs a key. They are different"))
3707
- say(dim(" credentials on purpose."))
3708
- say("")
3709
- say(
3710
- " " + dim("Need one:") + " a portal-issued ae_live_ key (request access at https://qosai.app/access)"
3711
- )
3712
- say(" " + dim("Get one:") + f" the portal {ARROW} Data {ARROW} API keys {ARROW} New key")
3713
- say(" " + dim("Then:") + " " + bold("login") + dim(f" (or export {_ENV_KEY}=ae_live_...)"))
3714
- say(
3715
- " "
3716
- + dim("Offline:")
3717
- + " "
3718
- + bold("alphaengine demo")
3719
- + dim(", ")
3720
- + bold("alphaengine process")
3721
- + dim(", and the Python API (")
3722
- + bold("import alphaengine")
3723
- + dim(") work without an account.")
3724
- )
3725
- say("")
3686
+ say(red(FIRM_KEY_ERROR_MESSAGE))
3726
3687
 
3727
3688
 
3728
3689
  def offer_key(which: str = "quantos") -> bool:
@@ -5603,6 +5564,10 @@ _GATED_COMMANDS: frozenset[str] = frozenset(
5603
5564
  {
5604
5565
  "run",
5605
5566
  *QUESTION_VERBS.keys(),
5567
+ "workflows",
5568
+ "runs",
5569
+ "gaps",
5570
+ "tonight",
5606
5571
  "init",
5607
5572
  "log",
5608
5573
  "show",
@@ -13,6 +13,7 @@ test, not a hope.
13
13
  from __future__ import annotations
14
14
 
15
15
  import math
16
+ import warnings
16
17
  from typing import Any
17
18
 
18
19
  import numpy as np
@@ -135,7 +136,13 @@ def risk_parity_weights(cov: np.ndarray, *, names: list[str] | None = None) -> d
135
136
 
136
137
  bounds = [(0.0, 1.0)] * n
137
138
  cons = {"type": "eq", "fun": lambda w: float(np.sum(w) - 1.0)}
138
- result = minimize(objective, w0, method="SLSQP", bounds=bounds, constraints=cons, tol=1e-12)
139
+ with warnings.catch_warnings():
140
+ warnings.filterwarnings(
141
+ "ignore",
142
+ message="Values in x were outside bounds during a minimize step",
143
+ category=RuntimeWarning,
144
+ )
145
+ result = minimize(objective, w0, method="SLSQP", bounds=bounds, constraints=cons, tol=1e-12)
139
146
  raw = np.clip(result.x if result.success else w0, 0.0, None)
140
147
  raw = raw / raw.sum()
141
148
  weights = _rounded_weights(raw, labels)
@@ -399,9 +399,9 @@ def _ar1_effective_n(returns: list) -> tuple:
399
399
  t-stat; this discounts it. Conservative convention: capped at n, so NEGATIVE
400
400
  autocorrelation is not credited (never claims more significance than the raw
401
401
  sample). Returns (n_eff, r1)."""
402
- import numpy as np
402
+ from .validation import _as_1d_float
403
403
 
404
- arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
404
+ arr = _as_1d_float(returns)
405
405
  n = arr.size
406
406
  if n < 3:
407
407
  return (float(n), 0.0)
@@ -113,7 +113,9 @@ def _series_to_list(s) -> list[float]:
113
113
  """A series is either a list of closes or a {date: close} dict (sorted by date)."""
114
114
  if isinstance(s, dict):
115
115
  return [float(s[k]) for k in sorted(s.keys())]
116
- return [float(x) for x in (s or []) if x is not None]
116
+ if s is None:
117
+ return []
118
+ return [float(x) for x in np.asarray(s).ravel() if x is not None]
117
119
 
118
120
 
119
121
  def _align_pair(a, b) -> tuple[list[float], list[float]]:
@@ -15,11 +15,15 @@ both decimal and percent; the sign convention is documented per field.
15
15
  from __future__ import annotations
16
16
 
17
17
  import math
18
+ from collections.abc import Sequence
19
+ from typing import Any
18
20
 
19
21
  import numpy as np
22
+ from numpy.typing import ArrayLike
20
23
 
21
24
  from .rates import cash_rate, rate_fields
22
25
  from .trace import record
26
+ from .validation import _as_1d_float
23
27
 
24
28
  _PPY = 252 # trading periods per year
25
29
 
@@ -45,20 +49,20 @@ def wealth_from_returns(returns: np.ndarray) -> np.ndarray:
45
49
 
46
50
 
47
51
  def performance_report(
48
- returns: list[float],
52
+ returns: ArrayLike | Sequence[float],
49
53
  *,
50
- equity_curve: list[float] | None = None,
51
- benchmark_returns: list[float] | None = None,
54
+ equity_curve: ArrayLike | Sequence[float] | None = None,
55
+ benchmark_returns: ArrayLike | Sequence[float] | None = None,
52
56
  risk_free_rate: float | None = None,
53
57
  periods_per_year: int = _PPY,
54
- ) -> dict:
58
+ ) -> dict[str, Any]:
55
59
  """Risk-adjusted performance metrics for a daily return series (decimals).
56
60
 
57
61
  `risk_free_rate` is ANNUAL. Omitted, it is the pinned H.15 3-month
58
62
  Treasury (see ``core.rates``). The engine does not fetch a rate. Loss
59
63
  metrics are positive magnitudes.
60
64
  """
61
- arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
65
+ arr = _as_1d_float(returns)
62
66
  n = arr.size
63
67
  if n < 30:
64
68
  return {"error": "need >= 30 observations", "n_obs": int(n)}
@@ -83,7 +87,8 @@ def performance_report(
83
87
  # Drawdown reads a marked account when one was supplied. Otherwise the
84
88
  # path is built from unit capital. Total return always comes from the
85
89
  # return product: a dollar curve's last mark minus one is not a return.
86
- eq = np.asarray(equity_curve, dtype=float) if equity_curve else wealth_from_returns(arr)
90
+ eq_arr = _as_1d_float(equity_curve)
91
+ eq = eq_arr if eq_arr.size > 0 else wealth_from_returns(arr)
87
92
  peak = np.maximum.accumulate(eq)
88
93
  drawdown = eq / np.maximum(peak, 1e-18) - 1.0
89
94
  max_dd = float(drawdown.min()) if drawdown.size else 0.0
@@ -115,8 +120,8 @@ def performance_report(
115
120
  "alpha_annualization": "arithmetic",
116
121
  }
117
122
 
118
- if benchmark_returns:
119
- b = np.asarray([float(x) for x in benchmark_returns], dtype=float)
123
+ b = _as_1d_float(benchmark_returns)
124
+ if b.size > 0:
120
125
  m = min(len(b), n)
121
126
  if m >= 30:
122
127
  a2, b2 = arr[-m:] - rf_per, b[-m:] - rf_per
@@ -17,6 +17,7 @@ TRIAL COUNT HONESTY. A Monte Carlo stress records `n_trials = n_paths` with
17
17
  from __future__ import annotations
18
18
 
19
19
  import math
20
+ import warnings
20
21
  from typing import Any
21
22
 
22
23
  import numpy as np
@@ -330,7 +331,13 @@ def garch_calibrate(values: Any) -> dict[str, Any]:
330
331
  x0 = np.array([var0 * 0.05, 0.05, 0.90], dtype=float)
331
332
  bounds = [(1e-18, None), (0.0, 0.999), (0.0, 0.999)]
332
333
  cons = {"type": "ineq", "fun": lambda p: 0.998 - p[1] - p[2]}
333
- result = minimize(nll, x0, method="SLSQP", bounds=bounds, constraints=cons, tol=1e-12)
334
+ with warnings.catch_warnings():
335
+ warnings.filterwarnings(
336
+ "ignore",
337
+ message="Values in x were outside bounds during a minimize step",
338
+ category=RuntimeWarning,
339
+ )
340
+ result = minimize(nll, x0, method="SLSQP", bounds=bounds, constraints=cons, tol=1e-12)
334
341
  omega, alpha, beta = result.x if result.success else x0
335
342
  omega, alpha, beta = float(omega), float(alpha), float(beta)
336
343
  persist = alpha + beta
@@ -17,11 +17,15 @@ Pure numpy/scipy. Deterministic given inputs on the pinned stack.
17
17
  from __future__ import annotations
18
18
 
19
19
  import math
20
+ from collections.abc import Sequence
21
+ from typing import Any
20
22
 
21
23
  import numpy as np
24
+ from numpy.typing import ArrayLike
22
25
  from scipy import stats
23
26
 
24
27
  from .trace import record
28
+ from .validation import _as_1d_float
25
29
 
26
30
 
27
31
  def _clean(val):
@@ -48,26 +52,20 @@ def _quantile_loss(point: float, mean: float, horizon: int) -> float:
48
52
 
49
53
 
50
54
  def compute_var_cvar(
51
- portfolio_returns: list[float],
55
+ portfolio_returns: ArrayLike | Sequence[float],
52
56
  *,
53
57
  confidence: float = 0.95,
54
58
  horizon_days: int = 1,
55
59
  portfolio_value: float = 100_000.0,
56
60
  bootstrap_samples: int = 1000,
57
- ) -> dict:
61
+ ) -> dict[str, Any]:
58
62
  """Portfolio VaR (parametric + Cornish-Fisher + historical) and CVaR.
59
63
 
60
64
  `portfolio_returns`: historical daily portfolio returns as decimals. VaR is
61
65
  reported as a positive loss fraction (and dollars at `portfolio_value`).
62
66
  """
63
- arr = np.array(
64
- [
65
- r
66
- for r in (portfolio_returns or [])
67
- if r is not None and not (isinstance(r, float) and np.isnan(r))
68
- ],
69
- dtype=float,
70
- )
67
+ raw = _as_1d_float(portfolio_returns)
68
+ arr = raw[~np.isnan(raw)]
71
69
  n_obs = arr.size
72
70
  if n_obs < 20:
73
71
  return {"error": "need >= 20 observations", "n_obs": int(n_obs)}
@@ -54,7 +54,9 @@ def _extract(series: Any) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | N
54
54
  lows = np.asarray([float(r["low"]) for r in rows], dtype=float)
55
55
  return closes, highs, lows
56
56
  return closes, None, None
57
- closes = np.asarray([float(x) for x in (series or []) if x is not None], dtype=float)
57
+ from .validation import _as_1d_float
58
+
59
+ closes = _as_1d_float(series)
58
60
  return closes, None, None
59
61
 
60
62
 
@@ -17,8 +17,11 @@ from __future__ import annotations
17
17
  import itertools
18
18
  import math
19
19
  import warnings
20
+ from collections.abc import Sequence
21
+ from typing import Any
20
22
 
21
23
  import numpy as np
24
+ from numpy.typing import ArrayLike
22
25
  from scipy import stats
23
26
 
24
27
  from .rates import cash_rate, rate_fields
@@ -27,6 +30,19 @@ from .trace import record
27
30
  _EULER = 0.5772156649015329
28
31
 
29
32
 
33
+ def _as_1d_float(values: ArrayLike | Sequence[float] | None) -> np.ndarray:
34
+ """Convert any 1-D array-like (list, ndarray, pandas Series) to a 1-D float ndarray, dropping None."""
35
+ if values is None:
36
+ return np.empty(0, dtype=float)
37
+ raw = np.asarray(values)
38
+ if raw.ndim == 0:
39
+ item = raw.item()
40
+ return np.empty(0, dtype=float) if item is None else np.asarray([float(item)], dtype=float)
41
+ if raw.dtype.kind in ("f", "i", "u"):
42
+ return raw.astype(float, copy=False).ravel()
43
+ return np.asarray([float(r) for r in raw.ravel() if r is not None], dtype=float)
44
+
45
+
30
46
  # ── Sharpe statistics ───────────────────────────────────────────────────
31
47
 
32
48
 
@@ -78,13 +94,13 @@ def expected_max_sharpe(n_trials: int, trials_sharpe_std: float) -> float:
78
94
 
79
95
 
80
96
  def deflated_sharpe(
81
- returns: list[float],
97
+ returns: ArrayLike | Sequence[float],
82
98
  *,
83
99
  n_trials: int,
84
100
  trials_sharpe_std: float | None = None,
85
101
  risk_free_rate: float | None = None,
86
102
  periods_per_year: int = 252,
87
- ) -> dict:
103
+ ) -> dict[str, Any]:
88
104
  """Deflated Sharpe Ratio for a return stream.
89
105
 
90
106
  DSR = PSR(SR0), where SR0 = expected max Sharpe across `n_trials`. A DSR
@@ -93,7 +109,7 @@ def deflated_sharpe(
93
109
  caller's hypothesis ledger; when absent we estimate it from the result's
94
110
  own sampling error (a conservative lower bound on selection variance).
95
111
  """
96
- arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
112
+ arr = _as_1d_float(returns)
97
113
  n = arr.size
98
114
  if n < 8:
99
115
  return {"error": "need >= 8 observations", "n_obs": int(n)}
@@ -170,13 +186,13 @@ def deflated_sharpe(
170
186
 
171
187
 
172
188
  def min_track_record_length(
173
- returns: list[float],
189
+ returns: ArrayLike | Sequence[float],
174
190
  *,
175
191
  sr_benchmark: float = 0.0,
176
192
  confidence: float = 0.95,
177
193
  risk_free_rate: float | None = None,
178
194
  periods_per_year: int = 252,
179
- ) -> dict:
195
+ ) -> dict[str, Any]:
180
196
  """Minimum Track Record Length (Bailey & López de Prado, 2012): the number of
181
197
  observations needed for the observed Sharpe to be statistically greater than
182
198
  `sr_benchmark` at `confidence`. Answers "is this backtest long enough to trust,
@@ -185,7 +201,7 @@ def min_track_record_length(
185
201
  MinTRL = 1 + [1 - γ3·SR + ((γ4-1)/4)·SR²] · (Z_α / (SR - SR*))² (per-period).
186
202
  Returns the required count + whether the actual sample already clears it.
187
203
  """
188
- arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
204
+ arr = _as_1d_float(returns)
189
205
  n = arr.size
190
206
  if n < 8:
191
207
  return {"error": "need >= 8 observations", "n_obs": int(n)}
@@ -257,6 +273,8 @@ def pbo_cscv(pnl_matrix, n_splits: int = 10, max_combos: int = 2000) -> dict:
257
273
  f"n_splits must be an even integer >= 2 (got {n_splits!r}); "
258
274
  "CSCV partitions blocks into equal in-sample and out-of-sample halves."
259
275
  )
276
+ if pnl_matrix is None:
277
+ return {"error": "need (T>=2S observations, N>=2 configs)", "shape": [0]}
260
278
  M = np.asarray(pnl_matrix, dtype=float)
261
279
  if M.ndim != 2 or M.shape[1] < 2 or M.shape[0] < n_splits * 2:
262
280
  return {"error": "need (T>=2S observations, N>=2 configs)", "shape": list(M.shape)}
@@ -313,7 +331,7 @@ def pbo_cscv(pnl_matrix, n_splits: int = 10, max_combos: int = 2000) -> dict:
313
331
 
314
332
 
315
333
  def combinatorial_oos_stability(
316
- returns: list[float],
334
+ returns: ArrayLike | Sequence[float],
317
335
  *,
318
336
  n_groups: int = 8,
319
337
  n_test_groups: int = 2,
@@ -323,7 +341,7 @@ def combinatorial_oos_stability(
323
341
  max_paths: int = 2000,
324
342
  risk_free_rate: float | None = None,
325
343
  periods_per_year: int = 252,
326
- ) -> dict:
344
+ ) -> dict[str, Any]:
327
345
  """Combinatorial out-of-sample stability over a single pre-computed return stream.
328
346
 
329
347
  WHAT THIS MEASURES (AND HOW IT DIFFERS FROM FULL CPCV):
@@ -342,7 +360,7 @@ def combinatorial_oos_stability(
342
360
  deflated Sharpe (haircut by `n_trials`) across all held-out sub-sample
343
361
  combinations.
344
362
  """
345
- arr = np.asarray([float(r) for r in (returns or []) if r is not None], dtype=float)
363
+ arr = _as_1d_float(returns)
346
364
  n = arr.size
347
365
  if n < 8:
348
366
  return {"error": "need >= 8 observations", "n_obs": int(n)}
@@ -465,7 +483,7 @@ def combinatorial_oos_stability(
465
483
 
466
484
 
467
485
  def cpcv_score(
468
- returns: list[float],
486
+ returns: ArrayLike | Sequence[float],
469
487
  *,
470
488
  n_groups: int = 8,
471
489
  n_test_groups: int = 2,
@@ -475,7 +493,7 @@ def cpcv_score(
475
493
  max_paths: int = 2000,
476
494
  risk_free_rate: float | None = None,
477
495
  periods_per_year: int = 252,
478
- ) -> dict:
496
+ ) -> dict[str, Any]:
479
497
  """Deprecated alias for :func:`combinatorial_oos_stability`.
480
498
 
481
499
  Measures combinatorial out-of-sample sub-sample stability on a single return
@@ -248,12 +248,13 @@ def monitor_live_drift(
248
248
  command="monitor_live",
249
249
  params={"reference_run_id": run_rec.get("run_id"), "live_file": str(lp.name)},
250
250
  dataset_hashes=hash_dataset_any(live_list),
251
- n_trials=1,
251
+ n_trials=0,
252
252
  n_trials_source="monitor_live",
253
253
  figures=figures,
254
254
  verdict=status,
255
255
  idea_id=run_rec.get("idea_id"),
256
256
  family=str(run_rec.get("family") or "monitor"),
257
+ already_counted_in_ledger=True,
257
258
  )
258
259
  return build_envelope(
259
260
  command="monitor_live",
@@ -265,7 +266,7 @@ def monitor_live_drift(
265
266
  family=str(rec["family"]),
266
267
  params_hash=str(rec["params_hash"]),
267
268
  dataset_hashes=rec["dataset_hashes"],
268
- n_trials=1,
269
+ n_trials=int(rec["cumulative_trials"]),
269
270
  n_trials_source="monitor_live",
270
271
  cumulative_trials=int(rec["cumulative_trials"]),
271
272
  project_trials=int(rec["project_trials"]),
@@ -238,7 +238,16 @@ def test_the_glyphs_are_the_real_ones_when_the_stream_can_take_them():
238
238
  def test_offline_names_the_host_and_the_half_that_still_works():
239
239
  """A workflow server being absent is not an error state for this package."""
240
240
  out = subprocess.run(
241
- [sys.executable, "-m", "alphaengine.cli", "workflows", "--url", "http://127.0.0.1:9"],
241
+ [
242
+ sys.executable,
243
+ "-m",
244
+ "alphaengine.cli",
245
+ "workflows",
246
+ "--url",
247
+ "http://127.0.0.1:9",
248
+ "--key",
249
+ "ae_live_test",
250
+ ],
242
251
  capture_output=True,
243
252
  text=True,
244
253
  )
@@ -578,24 +587,20 @@ class _Refusal(RuntimeError):
578
587
 
579
588
 
580
589
  def test_a_401_explains_that_a_browser_login_is_not_a_cli_credential(capsys, monkeypatch):
581
- """THE REPORTED BUG. A paying user, signed into the portal, read
582
- "Authentication required" as the product being broken. It is not: this
583
- process has no cookie jar and should never acquire one."""
590
+ from alphaengine.auth import FIRM_KEY_ERROR_MESSAGE
591
+
584
592
  monkeypatch.setattr(cli, "_tty", lambda: True)
585
593
  cli.refused(_Refusal(401, "Authentication required"))
586
594
  out = capsys.readouterr().out
587
- assert "browser login does not reach this process" in out
588
- assert "ae_live_" in out
589
- assert "https://qosai.app/access" in out
590
- assert "Data" in out and "API keys" in out, "it must say where the key comes from"
591
- assert "Then:" in out and "QUANTOS_API_KEY" in out, "and how to enter one"
592
- assert "alphaengine demo" in out and "alphaengine process" in out and "import alphaengine" in out
595
+ assert FIRM_KEY_ERROR_MESSAGE in out
593
596
 
594
597
 
595
598
  def test_a_403_is_treated_the_same_way(capsys, monkeypatch):
599
+ from alphaengine.auth import FIRM_KEY_ERROR_MESSAGE
600
+
596
601
  monkeypatch.setattr(cli, "_tty", lambda: True)
597
602
  cli.refused(_Refusal(403, "Forbidden"))
598
- assert "Not authenticated" in capsys.readouterr().out
603
+ assert FIRM_KEY_ERROR_MESSAGE in capsys.readouterr().out
599
604
 
600
605
 
601
606
  def test_a_non_auth_refusal_keeps_the_plain_message(capsys, monkeypatch):
@@ -349,3 +349,84 @@ def test_the_pair_screen_keeps_only_the_fdr_survivors() -> None:
349
349
  # One pair is the raw test.
350
350
  assert _benjamini_hochberg([0.04], 0.05) == [True]
351
351
  assert _benjamini_hochberg([0.06], 0.05) == [False]
352
+
353
+
354
+ def test_array_like_list_ndarray_and_series_across_validation_and_metrics() -> None:
355
+ """deflated_sharpe, min_track_record_length, combinatorial_oos_stability,
356
+ cpcv_score, pbo_cscv, performance_report, and compute_var_cvar accept
357
+ Python lists, numpy ndarrays, and pandas Series (whose __bool__ raises
358
+ ValueError) and return identical figures.
359
+ """
360
+ from alphaengine.core import min_track_record_length
361
+
362
+ class _FakeSeries:
363
+ """Mimics pandas.Series: array-like via __array__, raises ValueError on bool()."""
364
+
365
+ def __init__(self, data: list[float]) -> None:
366
+ self._arr = np.asarray(data, dtype=float)
367
+
368
+ def __bool__(self) -> bool:
369
+ raise ValueError("The truth value of a Series is ambiguous.")
370
+
371
+ def __len__(self) -> int:
372
+ return int(self._arr.size)
373
+
374
+ def __iter__(self):
375
+ return iter(self._arr)
376
+
377
+ def __array__(self, dtype=None):
378
+ return np.asarray(self._arr, dtype=dtype)
379
+
380
+ try:
381
+ import pandas as pd
382
+
383
+ series_cls = pd.Series
384
+ df_cls = pd.DataFrame
385
+ except ImportError:
386
+ series_cls = _FakeSeries
387
+ df_cls = None
388
+
389
+ rng = np.random.default_rng(17)
390
+ as_list = rng.normal(0.0008, 0.012, 120).tolist()
391
+ as_ndarray = np.asarray(as_list, dtype=float)
392
+ as_series = series_cls(as_list)
393
+ as_fake_series = _FakeSeries(as_list)
394
+
395
+ bench_list = rng.normal(0.0004, 0.01, 120).tolist()
396
+ bench_ndarray = np.asarray(bench_list, dtype=float)
397
+ bench_series = series_cls(bench_list)
398
+
399
+ # 1. deflated_sharpe & min_track_record_length
400
+ dsr_list = deflated_sharpe(as_list, n_trials=9)
401
+ mart_list = min_track_record_length(as_list)
402
+ for candidate in (as_ndarray, as_series, as_fake_series):
403
+ assert deflated_sharpe(candidate, n_trials=9) == dsr_list
404
+ assert min_track_record_length(candidate) == mart_list
405
+
406
+ # 2. combinatorial_oos_stability & cpcv_score
407
+ stab_list = combinatorial_oos_stability(as_list, n_groups=6, n_test_groups=2, n_trials=9)
408
+ for candidate in (as_ndarray, as_series, as_fake_series):
409
+ assert combinatorial_oos_stability(candidate, n_groups=6, n_test_groups=2, n_trials=9) == stab_list
410
+ with pytest.deprecated_call(match="combinatorial_oos_stability"):
411
+ assert cpcv_score(candidate, n_groups=6, n_test_groups=2, n_trials=9) == stab_list
412
+
413
+ # 3. pbo_cscv (2-D list, 2-D ndarray, list of Series, and DataFrame if pandas present)
414
+ mat_list = rng.normal(0.0005, 0.01, size=(80, 6)).tolist()
415
+ mat_ndarray = np.asarray(mat_list, dtype=float)
416
+ mat_series_rows = [series_cls(row) for row in mat_list]
417
+ pbo_expected = pbo_cscv(mat_list, n_splits=8)
418
+ assert pbo_cscv(mat_ndarray, n_splits=8) == pbo_expected
419
+ assert pbo_cscv(mat_series_rows, n_splits=8) == pbo_expected
420
+ if df_cls is not None:
421
+ assert pbo_cscv(df_cls(mat_list), n_splits=8) == pbo_expected
422
+
423
+ # 4. performance_report & compute_var_cvar
424
+ perf_list = performance_report(as_list, benchmark_returns=bench_list)
425
+ risk_list = compute_var_cvar(as_list)
426
+ for candidate, bench in (
427
+ (as_ndarray, bench_ndarray),
428
+ (as_series, bench_series),
429
+ (as_fake_series, _FakeSeries(bench_list)),
430
+ ):
431
+ assert performance_report(candidate, benchmark_returns=bench) == perf_list
432
+ assert compute_var_cvar(candidate) == risk_list
@@ -935,6 +935,8 @@ def test_watch_pipeline_reruns_on_dataset_change_and_monitor_live_detects_drift(
935
935
  assert cli.main(["validate", "--data", str(bt_csv), "--input", "n_trials=3", "--json"]) == 0
936
936
  bt_env = json.loads(capsys.readouterr().out)
937
937
  bt_run_id = bt_env["run_id"]
938
+ bt_cum_n = bt_env["cumulative_trials"]
939
+ bt_proj_n = bt_env["project_trials"]
938
940
 
939
941
  # Calm live returns within the expected range -> `within_expected_range`
940
942
  calm_live = tmp_path / "live_calm.csv"
@@ -946,6 +948,15 @@ def test_watch_pipeline_reruns_on_dataset_change_and_monitor_live_detects_drift(
946
948
  calm_env = json.loads(capsys.readouterr().out)
947
949
  assert calm_env["verdict"] == "within_expected_range"
948
950
  assert calm_env["figures"]["drift_flags"] == []
951
+ assert calm_env["figures"]["run_n_trials"] == 0
952
+ assert calm_env["cumulative_trials"] == bt_cum_n
953
+ assert calm_env["project_trials"] == bt_proj_n
954
+
955
+ # Re-running live monitor on same or updated live file never inflates cumulative N
956
+ assert cli.main(["monitor", bt_run_id, "--live", str(calm_live), "--json"]) == 0
957
+ calm_env_2 = json.loads(capsys.readouterr().out)
958
+ assert calm_env_2["cumulative_trials"] == bt_cum_n
959
+ assert calm_env_2["project_trials"] == bt_proj_n
949
960
 
950
961
  # Severe drawdown live returns outside the backtest's bootstrapped interval -> `drift_flagged`
951
962
  crash_live = tmp_path / "live_crash.csv"
@@ -958,6 +969,11 @@ def test_watch_pipeline_reruns_on_dataset_change_and_monitor_live_detects_drift(
958
969
  assert "drift_flagged" in crash_out
959
970
  assert "Research tool, not investment advice." in crash_out
960
971
 
972
+ from alphaengine.ledger import cumulative_trials_for
973
+
974
+ _, final_proj_n = cumulative_trials_for(proj)
975
+ assert final_proj_n == bt_proj_n
976
+
961
977
 
962
978
  def test_require_firm_key_choke_point_keyring_and_no_key_leak(
963
979
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
@@ -997,6 +1013,10 @@ def test_require_firm_key_choke_point_keyring_and_no_key_leak(
997
1013
  ["overlap"],
998
1014
  ["size"],
999
1015
  ["monitor"],
1016
+ ["workflows"],
1017
+ ["runs"],
1018
+ ["gaps"],
1019
+ ["tonight"],
1000
1020
  ["run", "validate_study", "--url", "http://127.0.0.1:9"],
1001
1021
  ["init"],
1002
1022
  ["log"],
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes