openbacktesting 0.2.0__py3-none-any.whl

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 (350) hide show
  1. openbacktesting/__init__.py +85 -0
  2. openbacktesting/aggregation.py +8 -0
  3. openbacktesting/broker/__init__.py +54 -0
  4. openbacktesting/broker/commission.py +57 -0
  5. openbacktesting/broker/core.py +1122 -0
  6. openbacktesting/broker/execution.py +62 -0
  7. openbacktesting/broker/instrument.py +106 -0
  8. openbacktesting/broker/ledger.py +60 -0
  9. openbacktesting/broker/models.py +236 -0
  10. openbacktesting/broker/observations.py +99 -0
  11. openbacktesting/broker/requests.py +192 -0
  12. openbacktesting/broker/sizing.py +76 -0
  13. openbacktesting/colors.py +175 -0
  14. openbacktesting/data/__init__.py +20 -0
  15. openbacktesting/data/aggregation.py +39 -0
  16. openbacktesting/data/context.py +30 -0
  17. openbacktesting/data/core.py +218 -0
  18. openbacktesting/data/generators.py +54 -0
  19. openbacktesting/data/strategy_data.py +103 -0
  20. openbacktesting/data/timeframe.py +69 -0
  21. openbacktesting/engine.py +34 -0
  22. openbacktesting/helpers.py +77 -0
  23. openbacktesting/indicator_api.py +10 -0
  24. openbacktesting/indicator_methods.py +123 -0
  25. openbacktesting/indicator_types.py +19 -0
  26. openbacktesting/indicators/__init__.py +7 -0
  27. openbacktesting/indicators/api.py +97 -0
  28. openbacktesting/indicators/calculus.py +75 -0
  29. openbacktesting/indicators/candles/__init__.py +0 -0
  30. openbacktesting/indicators/candles/cdl_doji.py +17 -0
  31. openbacktesting/indicators/candles/cdl_engulfing.py +14 -0
  32. openbacktesting/indicators/candles/cdl_eveningstar.py +15 -0
  33. openbacktesting/indicators/candles/cdl_hammer.py +14 -0
  34. openbacktesting/indicators/candles/cdl_harami.py +14 -0
  35. openbacktesting/indicators/candles/cdl_inside.py +14 -0
  36. openbacktesting/indicators/candles/cdl_marubozu.py +14 -0
  37. openbacktesting/indicators/candles/cdl_morningstar.py +15 -0
  38. openbacktesting/indicators/candles/cdl_shootingstar.py +14 -0
  39. openbacktesting/indicators/candles/cdl_z.py +28 -0
  40. openbacktesting/indicators/candles/ha.py +19 -0
  41. openbacktesting/indicators/catalog.json +8348 -0
  42. openbacktesting/indicators/common.py +94 -0
  43. openbacktesting/indicators/cycles/__init__.py +0 -0
  44. openbacktesting/indicators/cycles/dsp.py +10 -0
  45. openbacktesting/indicators/cycles/ebsw.py +10 -0
  46. openbacktesting/indicators/cycles/ht_dcperiod.py +10 -0
  47. openbacktesting/indicators/cycles/ht_dcphase.py +10 -0
  48. openbacktesting/indicators/cycles/ht_phasor.py +17 -0
  49. openbacktesting/indicators/cycles/ht_sine.py +17 -0
  50. openbacktesting/indicators/cycles/ht_trendmode.py +10 -0
  51. openbacktesting/indicators/cycles/msw.py +19 -0
  52. openbacktesting/indicators/methods.py +5762 -0
  53. openbacktesting/indicators/momentum/__init__.py +0 -0
  54. openbacktesting/indicators/momentum/ac.py +9 -0
  55. openbacktesting/indicators/momentum/ao.py +10 -0
  56. openbacktesting/indicators/momentum/apo.py +10 -0
  57. openbacktesting/indicators/momentum/bias.py +10 -0
  58. openbacktesting/indicators/momentum/bop.py +10 -0
  59. openbacktesting/indicators/momentum/brar.py +26 -0
  60. openbacktesting/indicators/momentum/cci.py +10 -0
  61. openbacktesting/indicators/momentum/cfo.py +10 -0
  62. openbacktesting/indicators/momentum/cg.py +10 -0
  63. openbacktesting/indicators/momentum/cmo.py +10 -0
  64. openbacktesting/indicators/momentum/coppock.py +10 -0
  65. openbacktesting/indicators/momentum/crsi.py +28 -0
  66. openbacktesting/indicators/momentum/cti.py +10 -0
  67. openbacktesting/indicators/momentum/dm.py +17 -0
  68. openbacktesting/indicators/momentum/er.py +10 -0
  69. openbacktesting/indicators/momentum/eri.py +17 -0
  70. openbacktesting/indicators/momentum/fisher.py +17 -0
  71. openbacktesting/indicators/momentum/fosc.py +10 -0
  72. openbacktesting/indicators/momentum/gator.py +13 -0
  73. openbacktesting/indicators/momentum/ibs.py +7 -0
  74. openbacktesting/indicators/momentum/inertia.py +22 -0
  75. openbacktesting/indicators/momentum/kdj.py +18 -0
  76. openbacktesting/indicators/momentum/kst.py +30 -0
  77. openbacktesting/indicators/momentum/lrsi.py +10 -0
  78. openbacktesting/indicators/momentum/macd.py +27 -0
  79. openbacktesting/indicators/momentum/macdext.py +27 -0
  80. openbacktesting/indicators/momentum/macdfix.py +18 -0
  81. openbacktesting/indicators/momentum/mom.py +10 -0
  82. openbacktesting/indicators/momentum/pgo.py +10 -0
  83. openbacktesting/indicators/momentum/pmo.py +13 -0
  84. openbacktesting/indicators/momentum/po.py +10 -0
  85. openbacktesting/indicators/momentum/ppo.py +20 -0
  86. openbacktesting/indicators/momentum/psl.py +12 -0
  87. openbacktesting/indicators/momentum/pvo.py +20 -0
  88. openbacktesting/indicators/momentum/qqe.py +31 -0
  89. openbacktesting/indicators/momentum/rci.py +20 -0
  90. openbacktesting/indicators/momentum/rmi.py +7 -0
  91. openbacktesting/indicators/momentum/roc.py +10 -0
  92. openbacktesting/indicators/momentum/rocp.py +10 -0
  93. openbacktesting/indicators/momentum/rocr.py +10 -0
  94. openbacktesting/indicators/momentum/rocr100.py +10 -0
  95. openbacktesting/indicators/momentum/rsi.py +10 -0
  96. openbacktesting/indicators/momentum/rsx.py +10 -0
  97. openbacktesting/indicators/momentum/rvgi.py +26 -0
  98. openbacktesting/indicators/momentum/slope.py +17 -0
  99. openbacktesting/indicators/momentum/smc_sweep.py +18 -0
  100. openbacktesting/indicators/momentum/smi.py +21 -0
  101. openbacktesting/indicators/momentum/smi_stochastic.py +15 -0
  102. openbacktesting/indicators/momentum/squeeze.py +47 -0
  103. openbacktesting/indicators/momentum/squeeze_pro.py +53 -0
  104. openbacktesting/indicators/momentum/stc.py +20 -0
  105. openbacktesting/indicators/momentum/stoch.py +19 -0
  106. openbacktesting/indicators/momentum/stochf.py +19 -0
  107. openbacktesting/indicators/momentum/stochrsi.py +19 -0
  108. openbacktesting/indicators/momentum/td_seq.py +17 -0
  109. openbacktesting/indicators/momentum/trix.py +19 -0
  110. openbacktesting/indicators/momentum/trixh.py +20 -0
  111. openbacktesting/indicators/momentum/tsi.py +26 -0
  112. openbacktesting/indicators/momentum/uo.py +22 -0
  113. openbacktesting/indicators/momentum/vwmacd.py +20 -0
  114. openbacktesting/indicators/momentum/wavetrend.py +15 -0
  115. openbacktesting/indicators/momentum/willr.py +10 -0
  116. openbacktesting/indicators/overlap/__init__.py +0 -0
  117. openbacktesting/indicators/overlap/alligator.py +13 -0
  118. openbacktesting/indicators/overlap/alma.py +33 -0
  119. openbacktesting/indicators/overlap/anchored_vwap.py +50 -0
  120. openbacktesting/indicators/overlap/anchored_vwap_bands.py +13 -0
  121. openbacktesting/indicators/overlap/avgprice.py +10 -0
  122. openbacktesting/indicators/overlap/dema.py +10 -0
  123. openbacktesting/indicators/overlap/ema.py +12 -0
  124. openbacktesting/indicators/overlap/frama.py +29 -0
  125. openbacktesting/indicators/overlap/fwma.py +10 -0
  126. openbacktesting/indicators/overlap/hilo.py +26 -0
  127. openbacktesting/indicators/overlap/hl2.py +10 -0
  128. openbacktesting/indicators/overlap/hlc3.py +10 -0
  129. openbacktesting/indicators/overlap/hma.py +10 -0
  130. openbacktesting/indicators/overlap/ht_trendline.py +10 -0
  131. openbacktesting/indicators/overlap/hwma.py +10 -0
  132. openbacktesting/indicators/overlap/ichimoku.py +44 -0
  133. openbacktesting/indicators/overlap/jma.py +10 -0
  134. openbacktesting/indicators/overlap/kama.py +10 -0
  135. openbacktesting/indicators/overlap/linreg.py +15 -0
  136. openbacktesting/indicators/overlap/linregangle.py +10 -0
  137. openbacktesting/indicators/overlap/linregintercept.py +10 -0
  138. openbacktesting/indicators/overlap/linregslope.py +10 -0
  139. openbacktesting/indicators/overlap/ma.py +10 -0
  140. openbacktesting/indicators/overlap/ma_envelopes.py +16 -0
  141. openbacktesting/indicators/overlap/mama.py +17 -0
  142. openbacktesting/indicators/overlap/mavp.py +17 -0
  143. openbacktesting/indicators/overlap/mcgd.py +10 -0
  144. openbacktesting/indicators/overlap/medprice.py +10 -0
  145. openbacktesting/indicators/overlap/midpoint.py +10 -0
  146. openbacktesting/indicators/overlap/midprice.py +10 -0
  147. openbacktesting/indicators/overlap/mmar.py +21 -0
  148. openbacktesting/indicators/overlap/ohlc4.py +10 -0
  149. openbacktesting/indicators/overlap/pwma.py +10 -0
  150. openbacktesting/indicators/overlap/rainbow.py +25 -0
  151. openbacktesting/indicators/overlap/rma.py +10 -0
  152. openbacktesting/indicators/overlap/sinwma.py +10 -0
  153. openbacktesting/indicators/overlap/sma.py +14 -0
  154. openbacktesting/indicators/overlap/ssf.py +10 -0
  155. openbacktesting/indicators/overlap/supertrend.py +21 -0
  156. openbacktesting/indicators/overlap/swma.py +10 -0
  157. openbacktesting/indicators/overlap/t3.py +10 -0
  158. openbacktesting/indicators/overlap/tema.py +10 -0
  159. openbacktesting/indicators/overlap/trima.py +10 -0
  160. openbacktesting/indicators/overlap/tsf.py +10 -0
  161. openbacktesting/indicators/overlap/typprice.py +10 -0
  162. openbacktesting/indicators/overlap/vidya.py +10 -0
  163. openbacktesting/indicators/overlap/vwap.py +10 -0
  164. openbacktesting/indicators/overlap/vwma.py +10 -0
  165. openbacktesting/indicators/overlap/wcp.py +10 -0
  166. openbacktesting/indicators/overlap/wma.py +10 -0
  167. openbacktesting/indicators/overlap/zlma.py +10 -0
  168. openbacktesting/indicators/performance/__init__.py +0 -0
  169. openbacktesting/indicators/performance/drawdown.py +18 -0
  170. openbacktesting/indicators/performance/log_return.py +10 -0
  171. openbacktesting/indicators/performance/percent_return.py +10 -0
  172. openbacktesting/indicators/predicates.py +88 -0
  173. openbacktesting/indicators/registry.py +571 -0
  174. openbacktesting/indicators/runtime.py +290 -0
  175. openbacktesting/indicators/statistics/__init__.py +0 -0
  176. openbacktesting/indicators/statistics/beta.py +10 -0
  177. openbacktesting/indicators/statistics/correl.py +10 -0
  178. openbacktesting/indicators/statistics/entropy.py +10 -0
  179. openbacktesting/indicators/statistics/kurtosis.py +10 -0
  180. openbacktesting/indicators/statistics/mad.py +10 -0
  181. openbacktesting/indicators/statistics/md.py +10 -0
  182. openbacktesting/indicators/statistics/median.py +10 -0
  183. openbacktesting/indicators/statistics/mode.py +48 -0
  184. openbacktesting/indicators/statistics/quantile.py +10 -0
  185. openbacktesting/indicators/statistics/skew.py +10 -0
  186. openbacktesting/indicators/statistics/stderr.py +10 -0
  187. openbacktesting/indicators/statistics/stdev.py +10 -0
  188. openbacktesting/indicators/statistics/variance.py +10 -0
  189. openbacktesting/indicators/statistics/zscore.py +10 -0
  190. openbacktesting/indicators/trend/__init__.py +0 -0
  191. openbacktesting/indicators/trend/adx.py +28 -0
  192. openbacktesting/indicators/trend/adxr.py +28 -0
  193. openbacktesting/indicators/trend/amat.py +19 -0
  194. openbacktesting/indicators/trend/aroon.py +18 -0
  195. openbacktesting/indicators/trend/atr_stop.py +33 -0
  196. openbacktesting/indicators/trend/chop.py +20 -0
  197. openbacktesting/indicators/trend/cksp.py +17 -0
  198. openbacktesting/indicators/trend/cpr.py +57 -0
  199. openbacktesting/indicators/trend/decay.py +10 -0
  200. openbacktesting/indicators/trend/decreasing.py +18 -0
  201. openbacktesting/indicators/trend/dpo.py +12 -0
  202. openbacktesting/indicators/trend/dx.py +19 -0
  203. openbacktesting/indicators/trend/edecay.py +10 -0
  204. openbacktesting/indicators/trend/increasing.py +18 -0
  205. openbacktesting/indicators/trend/long_run.py +10 -0
  206. openbacktesting/indicators/trend/minus_dm.py +10 -0
  207. openbacktesting/indicators/trend/pivot_points.py +89 -0
  208. openbacktesting/indicators/trend/pivothigh.py +23 -0
  209. openbacktesting/indicators/trend/pivotlow.py +23 -0
  210. openbacktesting/indicators/trend/plus_dm.py +10 -0
  211. openbacktesting/indicators/trend/pmax.py +18 -0
  212. openbacktesting/indicators/trend/psar.py +19 -0
  213. openbacktesting/indicators/trend/qstick.py +10 -0
  214. openbacktesting/indicators/trend/sarext.py +35 -0
  215. openbacktesting/indicators/trend/short_run.py +10 -0
  216. openbacktesting/indicators/trend/tsignals.py +26 -0
  217. openbacktesting/indicators/trend/ttm_trend.py +16 -0
  218. openbacktesting/indicators/trend/vhf.py +10 -0
  219. openbacktesting/indicators/trend/volatility_stop.py +33 -0
  220. openbacktesting/indicators/trend/vortex.py +17 -0
  221. openbacktesting/indicators/trend/xsignals.py +29 -0
  222. openbacktesting/indicators/types.py +344 -0
  223. openbacktesting/indicators/utility/__init__.py +0 -0
  224. openbacktesting/indicators/utility/barssince.py +21 -0
  225. openbacktesting/indicators/utility/change.py +7 -0
  226. openbacktesting/indicators/utility/cross.py +12 -0
  227. openbacktesting/indicators/utility/crossover.py +11 -0
  228. openbacktesting/indicators/utility/crossunder.py +11 -0
  229. openbacktesting/indicators/utility/cum.py +7 -0
  230. openbacktesting/indicators/utility/falling.py +10 -0
  231. openbacktesting/indicators/utility/highest.py +7 -0
  232. openbacktesting/indicators/utility/highestbars.py +29 -0
  233. openbacktesting/indicators/utility/lowest.py +7 -0
  234. openbacktesting/indicators/utility/lowestbars.py +29 -0
  235. openbacktesting/indicators/utility/percentile_linear_interpolation.py +12 -0
  236. openbacktesting/indicators/utility/percentile_nearest_rank.py +16 -0
  237. openbacktesting/indicators/utility/percentrank.py +7 -0
  238. openbacktesting/indicators/utility/range.py +8 -0
  239. openbacktesting/indicators/utility/rising.py +10 -0
  240. openbacktesting/indicators/utility/rolling_count.py +12 -0
  241. openbacktesting/indicators/utility/rolling_sum.py +7 -0
  242. openbacktesting/indicators/utility/running_max.py +7 -0
  243. openbacktesting/indicators/utility/running_min.py +7 -0
  244. openbacktesting/indicators/utility/shift.py +7 -0
  245. openbacktesting/indicators/utility/valuewhen.py +25 -0
  246. openbacktesting/indicators/volatility/__init__.py +0 -0
  247. openbacktesting/indicators/volatility/aberration.py +21 -0
  248. openbacktesting/indicators/volatility/accbands.py +20 -0
  249. openbacktesting/indicators/volatility/adr.py +7 -0
  250. openbacktesting/indicators/volatility/atr.py +12 -0
  251. openbacktesting/indicators/volatility/avolume.py +10 -0
  252. openbacktesting/indicators/volatility/bbands.py +20 -0
  253. openbacktesting/indicators/volatility/ce.py +25 -0
  254. openbacktesting/indicators/volatility/cvi.py +10 -0
  255. openbacktesting/indicators/volatility/donchian.py +20 -0
  256. openbacktesting/indicators/volatility/hvol.py +10 -0
  257. openbacktesting/indicators/volatility/hwc.py +27 -0
  258. openbacktesting/indicators/volatility/kc.py +27 -0
  259. openbacktesting/indicators/volatility/massi.py +10 -0
  260. openbacktesting/indicators/volatility/natr.py +19 -0
  261. openbacktesting/indicators/volatility/pdist.py +10 -0
  262. openbacktesting/indicators/volatility/rvi.py +21 -0
  263. openbacktesting/indicators/volatility/starc.py +11 -0
  264. openbacktesting/indicators/volatility/thermo.py +28 -0
  265. openbacktesting/indicators/volatility/true_range.py +31 -0
  266. openbacktesting/indicators/volatility/ui.py +10 -0
  267. openbacktesting/indicators/volume/__init__.py +0 -0
  268. openbacktesting/indicators/volume/ad.py +10 -0
  269. openbacktesting/indicators/volume/adosc.py +19 -0
  270. openbacktesting/indicators/volume/aobv.py +31 -0
  271. openbacktesting/indicators/volume/cmf.py +12 -0
  272. openbacktesting/indicators/volume/efi.py +12 -0
  273. openbacktesting/indicators/volume/emv.py +10 -0
  274. openbacktesting/indicators/volume/eom.py +19 -0
  275. openbacktesting/indicators/volume/iii.py +9 -0
  276. openbacktesting/indicators/volume/kvo.py +28 -0
  277. openbacktesting/indicators/volume/marketfi.py +10 -0
  278. openbacktesting/indicators/volume/mfi.py +12 -0
  279. openbacktesting/indicators/volume/nvi.py +26 -0
  280. openbacktesting/indicators/volume/obv.py +10 -0
  281. openbacktesting/indicators/volume/pvi.py +26 -0
  282. openbacktesting/indicators/volume/pvol.py +10 -0
  283. openbacktesting/indicators/volume/pvr.py +10 -0
  284. openbacktesting/indicators/volume/pvt.py +10 -0
  285. openbacktesting/indicators/volume/rvol.py +8 -0
  286. openbacktesting/indicators/volume/vfi.py +18 -0
  287. openbacktesting/indicators/volume/vosc.py +10 -0
  288. openbacktesting/indicators/volume/wad.py +10 -0
  289. openbacktesting/indicators/volume/wvad.py +9 -0
  290. openbacktesting/inputs.py +51 -0
  291. openbacktesting/lib.py +31 -0
  292. openbacktesting/logs.py +129 -0
  293. openbacktesting/optimization.py +8 -0
  294. openbacktesting/optimizer/__init__.py +25 -0
  295. openbacktesting/optimizer/_semantics.py +155 -0
  296. openbacktesting/optimizer/identity.py +45 -0
  297. openbacktesting/optimizer/models.py +92 -0
  298. openbacktesting/optimizer/plan.py +129 -0
  299. openbacktesting/optimizer/portfolio-result.schema.json +4984 -0
  300. openbacktesting/optimizer/query.py +257 -0
  301. openbacktesting/optimizer/runner.py +98 -0
  302. openbacktesting/optimizer/storage.py +261 -0
  303. openbacktesting/optimizer/validation.py +64 -0
  304. openbacktesting/parallel.py +9 -0
  305. openbacktesting/plotting/__init__.py +23 -0
  306. openbacktesting/plotting/collector.py +509 -0
  307. openbacktesting/plotting/encoding.py +102 -0
  308. openbacktesting/plotting/types.py +92 -0
  309. openbacktesting/portfolio/__init__.py +15 -0
  310. openbacktesting/portfolio/analysis.py +245 -0
  311. openbacktesting/portfolio/builder.py +381 -0
  312. openbacktesting/portfolio/catalog.json +1404 -0
  313. openbacktesting/portfolio/helpers.py +92 -0
  314. openbacktesting/portfolio/metrics.py +186 -0
  315. openbacktesting/portfolio/models.py +63 -0
  316. openbacktesting/portfolio/periods.py +164 -0
  317. openbacktesting/portfolio/rates.py +14 -0
  318. openbacktesting/portfolio/snapshot.py +111 -0
  319. openbacktesting/portfolio/stats.py +272 -0
  320. openbacktesting/portfolio/trades.py +136 -0
  321. openbacktesting/portfolio/trading.py +215 -0
  322. openbacktesting/py.typed +0 -0
  323. openbacktesting/runtime.py +330 -0
  324. openbacktesting/simulation/__init__.py +17 -0
  325. openbacktesting/simulation/collection.py +13 -0
  326. openbacktesting/simulation/config.py +241 -0
  327. openbacktesting/simulation/core.py +528 -0
  328. openbacktesting/simulation/optimization.py +326 -0
  329. openbacktesting/simulation/parallel.py +145 -0
  330. openbacktesting/simulation/variants.py +107 -0
  331. openbacktesting/strategy/__init__.py +19 -0
  332. openbacktesting/strategy/bar_state.py +14 -0
  333. openbacktesting/strategy/base.py +810 -0
  334. openbacktesting/strategy/declaration.py +90 -0
  335. openbacktesting/strategy/helpers.py +198 -0
  336. openbacktesting/strategy/inputs.py +460 -0
  337. openbacktesting/strategy/sizing.py +80 -0
  338. openbacktesting/strategy/templates.py +160 -0
  339. openbacktesting/strategy_data.py +10 -0
  340. openbacktesting/test/BTCUSD.csv +157 -0
  341. openbacktesting/test/EURUSD.csv +5001 -0
  342. openbacktesting/test/GOOG.csv +2149 -0
  343. openbacktesting/test/__init__.py +29 -0
  344. openbacktesting/test/__main__.py +11 -0
  345. openbacktesting/test/_test.py +1312 -0
  346. openbacktesting/util.py +31 -0
  347. openbacktesting-0.2.0.dist-info/METADATA +151 -0
  348. openbacktesting-0.2.0.dist-info/RECORD +350 -0
  349. openbacktesting-0.2.0.dist-info/WHEEL +4 -0
  350. openbacktesting-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,92 @@
1
+ """Existing implementation moved from lib: compute_stats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import cast
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from openbacktesting.broker.models import finite
11
+ from openbacktesting.portfolio.rates import validate_risk_free_rate
12
+ from openbacktesting.portfolio.snapshot import refresh_snapshot
13
+ from openbacktesting.portfolio.stats import compute_stats as _compute_stats
14
+
15
+
16
+ def compute_stats(
17
+ *,
18
+ stats: pd.Series,
19
+ data: pd.DataFrame,
20
+ trades: pd.DataFrame = None,
21
+ risk_free_rate: float = cast(float, ...),
22
+ ) -> pd.Series:
23
+ """Recompute performance statistics from existing results, data, and an optional trade subset.
24
+
25
+
26
+ (Re-)compute strategy performance metrics.
27
+
28
+ `stats` is the statistics series as returned by `openbacktesting.engine.Backtest.run()`.
29
+ `data` is OHLC data as passed to the `openbacktesting.engine.Backtest`
30
+ the `stats` were obtained in.
31
+ `trades` can be a dataframe subset of `stats._trades` (e.g. only long trades).
32
+ risk_free_rate is an annualized fraction used for Sharpe, Sortino, and Alpha; omission inherits the latest statistics value,
33
+ missing records fall back to zero, explicit zero overrides, and None is invalid. Recalculation does not rewrite the original run snapshot or actual fees.
34
+ Initial capital inherits statistics_initial_equity; historical records lacking it fall back to the first equity value.
35
+ Initial capital must be positive and finite; approximate equity for trade subsets starts from the same principal.
36
+
37
+ >>> stats = Backtest(GOOG, MyStrategy).run()
38
+ >>> only_long_trades = stats._trades[stats._trades.Size > 0]
39
+ >>> long_stats = compute_stats(stats=stats, trades=only_long_trades,
40
+ ... data=GOOG, risk_free_rate=.02)
41
+ """
42
+ if risk_free_rate is Ellipsis:
43
+ risk_free_rate = stats.attrs.get("statistics_risk_free_rate", 0.0)
44
+ risk_free_rate = validate_risk_free_rate(risk_free_rate)
45
+ equity = stats._equity_curve.Equity
46
+ initial_equity = finite(
47
+ stats.attrs.get("statistics_initial_equity", equity.iloc[0]),
48
+ "initial_equity",
49
+ positive=True,
50
+ )
51
+ exposure = None
52
+ full_account = trades is None
53
+ if full_account:
54
+ trades = stats._trades
55
+ if "_positions" in stats:
56
+ positions = stats["_positions"]
57
+ exposure = np.zeros(len(data), dtype=bool)
58
+ bars = positions[positions.phase == "bar"]
59
+ exposure[bars.bar_index] = bars.gross_qty > 0
60
+ else:
61
+ # XXX: Is this buggy?
62
+ equity = equity.copy()
63
+ equity[:] = initial_equity
64
+ for t in trades.itertuples(index=False):
65
+ equity.iloc[t.EntryBar :] += t.PnL
66
+ result = _compute_stats(
67
+ trades=trades,
68
+ equity=equity.values,
69
+ ohlc_data=data,
70
+ risk_free_rate=risk_free_rate,
71
+ strategy_instance=stats.get("_strategy"),
72
+ exposure=exposure,
73
+ initial_equity=initial_equity,
74
+ )
75
+ if full_account:
76
+ preserved = {
77
+ key: value
78
+ for key, value in stats.items()
79
+ if key.startswith("_")
80
+ and key not in ("_strategy", "_trades", "_equity_curve")
81
+ or key in ("Commissions [$]", "# Closed Lots")
82
+ }
83
+ result = type(result)(dict(result) | preserved, dtype=object)
84
+ result.attrs.update(stats.attrs)
85
+ result.attrs["statistics_risk_free_rate"] = risk_free_rate
86
+ result.attrs["statistics_initial_equity"] = initial_equity
87
+ if full_account:
88
+ refresh_snapshot(result)
89
+ else:
90
+ result.attrs.pop("portfolio_snapshot", None)
91
+ result.attrs["portfolio_diagnostic"] = "portfolio_unavailable_for_legacy_subset"
92
+ return result
@@ -0,0 +1,186 @@
1
+ """Versioned monetary, trade, and path metrics; legacy statistics are retained independently."""
2
+
3
+ import math
4
+ from itertools import groupby
5
+
6
+ import numpy as np
7
+
8
+ from openbacktesting.portfolio.models import metric, ratio
9
+
10
+
11
+ def mean(values):
12
+ return sum(values) / len(values) if values else None
13
+
14
+
15
+ def trade_metrics(trades, opened):
16
+ pnl = [t["pnlNet"] for t in trades]
17
+ wins = [t for t in trades if t["pnlNet"] > 0]
18
+ losses = [t for t in trades if t["pnlNet"] < 0]
19
+ gp, gl = sum(t["pnlNet"] for t in wins), -sum(t["pnlNet"] for t in losses)
20
+ result = {}
21
+
22
+ def put(name, value, unit="ratio", **kwargs):
23
+ result[name] = metric(value, unit, sample=len(trades), **kwargs)
24
+
25
+ for name, value in (
26
+ ("closedFragmentCount", len(trades)),
27
+ ("openLotCount", len(opened)),
28
+ ("closedLotCount", len({t["lotId"] for t in trades} - {t["lotId"] for t in opened})),
29
+ ("openedLotCount", len({t["lotId"] for t in trades + opened})),
30
+ ("winningCount", len(wins)),
31
+ ("losingCount", len(losses)),
32
+ ("evenCount", len(trades) - len(wins) - len(losses)),
33
+ ):
34
+ put(name, value, "count")
35
+ for name, value in (
36
+ ("closedNetPnl", sum(pnl)),
37
+ ("grossProfit", gp),
38
+ ("grossLoss", gl),
39
+ ("expectedPayoff", mean(pnl)),
40
+ ("averageProfit", mean([t["pnlNet"] for t in wins])),
41
+ ("averageLoss", mean([-t["pnlNet"] for t in losses])),
42
+ ("closedFees", sum(t["feeTotal"] for t in trades)),
43
+ ):
44
+ put(name, value, "money", reason="no_closed_trades")
45
+ put(
46
+ "profitFactorMoney",
47
+ ratio(gp, gl),
48
+ reason="unbounded" if gp and not gl else "zero_denominator",
49
+ )
50
+ put("winRate", ratio(len(wins), len(trades)), reason="no_closed_trades")
51
+ put("meanTradeReturn", mean([t["returnFraction"] for t in trades]), reason="no_closed_trades")
52
+ for label, group in (("Trade", trades), ("Winning", wins), ("Losing", losses)):
53
+ put(
54
+ "average" + label + "Bars",
55
+ mean([t["barsHeld"] for t in group]),
56
+ "bars",
57
+ reason="no_samples",
58
+ )
59
+ for label, group, sign in (("Profit", wins, 1), ("Loss", losses, -1)):
60
+ put(
61
+ "average" + label + "Return",
62
+ mean([sign * t["returnFraction"] for t in group]),
63
+ reason="no_samples",
64
+ )
65
+ for suffix, key, unit in (("", "pnlNet", "money"), ("Return", "returnFraction", "ratio")):
66
+ item = max(group, key=lambda t: sign * t[key]) if group else None
67
+ put(
68
+ "largest" + label + suffix,
69
+ sign * item[key] if item else None,
70
+ unit,
71
+ reference=item["fragmentId"] if item else None,
72
+ reason="no_samples",
73
+ )
74
+ largest = max([sign * t["pnlNet"] for t in group], default=0.0)
75
+ put(
76
+ "largest" + label + "Share",
77
+ ratio(largest, gp if sign > 0 else gl),
78
+ reason="zero_denominator",
79
+ )
80
+ ap, al = mean([t["pnlNet"] for t in wins]), mean([-t["pnlNet"] for t in losses])
81
+ put("averageProfitLossRatio", ratio(ap, al) if ap is not None else None, reason="no_samples")
82
+ durations = [int(t["durationNs"]) for t in trades if t["durationNs"] is not None]
83
+ put(
84
+ "maxTradeDurationNs",
85
+ str(max(durations)) if durations else None,
86
+ "nanoseconds",
87
+ reason="no_duration",
88
+ )
89
+ put(
90
+ "averageTradeDurationNs",
91
+ str(sum(durations) // len(durations)) if durations else None,
92
+ "nanoseconds",
93
+ reason="no_duration",
94
+ )
95
+ return result
96
+
97
+
98
+ def path_metrics(rows, initial, *, prefix="", approximate=False):
99
+ high = low = initial
100
+ max_dd = max_ru = dd_fraction = ru_fraction = 0.0
101
+ dd_ref = ru_ref = ddf_ref = ruf_ref = None
102
+ invalid_runup_base = False
103
+
104
+ # Treat same-bar highs and lows with unknown ordering as a group; compare only with earlier peaks/troughs without inventing within-group order.
105
+ def group_key(row):
106
+ return row["barIndex"] if not row.get("ordered", True) else row["observationId"]
107
+
108
+ for _, group in groupby(rows, group_key):
109
+ points = list(group)
110
+ bottom = min(points, key=lambda r: r["equity"])
111
+ top = max(points, key=lambda r: r["equity"])
112
+ dd, ru = max(0.0, high - bottom["equity"]), max(0.0, top["equity"] - low)
113
+ if dd > max_dd:
114
+ max_dd, dd_ref = dd, bottom["observationId"]
115
+ if ru > max_ru:
116
+ max_ru, ru_ref = ru, top["observationId"]
117
+ if high > 0 and dd / high > dd_fraction:
118
+ dd_fraction, ddf_ref = dd / high, bottom["observationId"]
119
+ if ru > 0 and low <= 0:
120
+ invalid_runup_base = True
121
+ if low > 0 and ru / low > ru_fraction:
122
+ ru_fraction, ruf_ref = ru / low, top["observationId"]
123
+ high, low = max(high, top["equity"]), min(low, bottom["equity"])
124
+ result = {}
125
+ for key, value, unit, ref in (
126
+ ("maxDrawdown", max_dd, "money", dd_ref),
127
+ ("maxRunup", max_ru, "money", ru_ref),
128
+ ("maxDrawdownFraction", dd_fraction, "ratio", ddf_ref),
129
+ ("maxRunupFraction", None if invalid_runup_base else ru_fraction, "ratio", ruf_ref),
130
+ ("maxDrawdownInitialFraction", max_dd / initial, "ratio", dd_ref),
131
+ ("maxRunupInitialFraction", max_ru / initial, "ratio", ru_ref),
132
+ ):
133
+ result[prefix + key] = metric(
134
+ value,
135
+ unit,
136
+ reference=ref,
137
+ method="sampled_bounds_v1" if approximate else "close_path_v1",
138
+ status="undefined" if value is None else ("approximate" if approximate else "ok"),
139
+ reason="nonpositive_runup_base"
140
+ if value is None
141
+ else ("unknown_intrabar_order" if approximate else None),
142
+ )
143
+ return result
144
+
145
+
146
+ def monthly_risk(periods, rf):
147
+ returns = [p["equityReturn"] for p in periods]
148
+ if len(returns) < 2 or any(r is None for r in returns):
149
+ return {
150
+ k: metric(
151
+ None,
152
+ method="monthly_compounded_rf_v1",
153
+ status="insufficient_data",
154
+ reason="invalid_or_insufficient_periods",
155
+ sample=len(returns),
156
+ )
157
+ for k in ("sharpe", "sortino")
158
+ }
159
+ excess = np.asarray(returns) - ((1 + rf) ** (1 / 12) - 1)
160
+ denominators = (float(np.std(returns)), float(np.sqrt(np.mean(np.minimum(excess, 0) ** 2))))
161
+ return {
162
+ k: metric(
163
+ ratio(float(np.mean(excess)) * math.sqrt(12), d),
164
+ method="monthly_compounded_rf_v1",
165
+ reason="zero_denominator",
166
+ sample=len(returns),
167
+ )
168
+ for k, d in zip(("sharpe", "sortino"), denominators)
169
+ }
170
+
171
+
172
+ def correlation(x, y):
173
+ pairs = [(a, b) for a, b in zip(x, y) if a is not None and b is not None]
174
+ if len(pairs) < 2:
175
+ return metric(
176
+ None,
177
+ status="insufficient_data",
178
+ reason="insufficient_pairs",
179
+ sample=len(pairs),
180
+ method="paired_period_returns_v1",
181
+ )
182
+ a, b = np.array(pairs).T
183
+ value = float(np.corrcoef(a, b)[0, 1]) if np.std(a) and np.std(b) else None
184
+ return metric(
185
+ value, reason="zero_variance", sample=len(pairs), method="paired_period_returns_v1"
186
+ )
@@ -0,0 +1,63 @@
1
+ """Strict plain-value encoding, metrics, and immutable snapshots without runtime objects."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+
9
+ def time_ref(value):
10
+ if isinstance(value, pd.Timestamp):
11
+ return {
12
+ "kind": "aware" if value.tzinfo else "naive",
13
+ "value": str(value.as_unit("ns").value),
14
+ }
15
+ return {"kind": "ordinal", "value": str(value)}
16
+
17
+
18
+ def plain(value):
19
+ if value is None or value is pd.NA or value is pd.NaT:
20
+ return None
21
+ if isinstance(value, dict):
22
+ return {str(k): plain(v) for k, v in value.items()}
23
+ if isinstance(value, (list, tuple)):
24
+ return [plain(v) for v in value]
25
+ if isinstance(value, pd.Timestamp):
26
+ return time_ref(value)
27
+ if isinstance(value, pd.Timedelta):
28
+ return str(value.value)
29
+ if isinstance(value, np.generic):
30
+ return plain(value.item())
31
+ if isinstance(value, float) and not math.isfinite(value):
32
+ return None
33
+ if isinstance(value, int) and not isinstance(value, bool) and abs(value) > 2**53 - 1:
34
+ return str(value)
35
+ if isinstance(value, (str, bool, int, float)):
36
+ return value
37
+ raise TypeError(f"Portfolio does not support plain-value encoding: {type(value).__name__}")
38
+
39
+
40
+ def metric(
41
+ value,
42
+ unit="ratio",
43
+ *,
44
+ method="openbacktesting_portfolio_v1",
45
+ reason=None,
46
+ status=None,
47
+ sample=None,
48
+ reference=None,
49
+ ):
50
+ value = plain(value)
51
+ return {
52
+ "value": value,
53
+ "unit": unit,
54
+ "methodId": method,
55
+ "status": status or ("ok" if value is not None else "undefined"),
56
+ "reason": (reason or "undefined_value") if value is None else (reason if status else None),
57
+ "sampleCount": sample,
58
+ "reference": reference,
59
+ }
60
+
61
+
62
+ def ratio(numerator, denominator):
63
+ return numerator / denominator if denominator else None
@@ -0,0 +1,164 @@
1
+ """Chart-calendar buckets, benchmarks, and period correlations."""
2
+
3
+ from decimal import ROUND_FLOOR, Decimal
4
+
5
+ import pandas as pd
6
+
7
+ from openbacktesting.portfolio.metrics import correlation
8
+ from openbacktesting.portfolio.models import time_ref
9
+
10
+
11
+ def benchmarks(data, rows, fills, allocations, instrument, initial):
12
+ close_prices = data.Close.to_numpy()
13
+ first = next((a for a in allocations if a["kind"] == "open"), None)
14
+ by_id = {f["fill_id"]: f for f in fills}
15
+ configs = [("input_start_close", 0, float(data.Close.iloc[0]), initial)]
16
+ if first:
17
+ fill = by_id[first["fill_id"]]
18
+ before = next(
19
+ r
20
+ for r in rows
21
+ if r["phase"].startswith("before_")
22
+ and r["fillSequence"] < fill["sequence"]
23
+ and r["barIndex"] == fill["bar_index"]
24
+ )
25
+ configs.append(("first_entry_price", fill["bar_index"], fill["price"], before["equity"]))
26
+ result = {}
27
+ for name, start, price, base in configs:
28
+ step = Decimal(str(instrument["quantity_step"]))
29
+ qty = float(
30
+ (Decimal(str(initial)) / Decimal(str(price)) / step).to_integral_value(
31
+ rounding=ROUND_FLOOR
32
+ )
33
+ * step
34
+ )
35
+ if qty < instrument["min_quantity"] or qty * price < instrument["min_notional"]:
36
+ qty = 0.0
37
+ cash = initial - qty * price
38
+ points = []
39
+ for r in rows:
40
+ if r["phase"] not in ("bar", "terminal") or r["barIndex"] < start:
41
+ continue
42
+ equity = cash + qty * float(close_prices[r["barIndex"]])
43
+ strategy = initial + r["equity"] - base
44
+ points.append(
45
+ {
46
+ "observationId": r["observationId"],
47
+ "barIndex": r["barIndex"],
48
+ "equity": equity,
49
+ "pnlMoney": equity - initial,
50
+ "returnFraction": (equity - initial) / initial,
51
+ "strategyReturn": (strategy - initial) / initial,
52
+ "outperformance": (strategy - equity) / initial,
53
+ "outperformanceMoney": strategy - equity,
54
+ }
55
+ )
56
+ result[name] = {
57
+ "status": "ok",
58
+ "quantity": qty,
59
+ "cash": cash,
60
+ "entryPrice": price,
61
+ "startBar": start,
62
+ "costPolicy": "none",
63
+ "points": points,
64
+ "summary": {
65
+ key: {
66
+ "minimum": min(p[key] for p in points),
67
+ "maximum": max(p[key] for p in points),
68
+ "current": points[-1][key],
69
+ }
70
+ for key in (
71
+ "returnFraction",
72
+ "strategyReturn",
73
+ "outperformance",
74
+ "outperformanceMoney",
75
+ )
76
+ },
77
+ }
78
+ if not first:
79
+ result["first_entry_price"] = {
80
+ "status": "not_applicable",
81
+ "reason": "no_entry",
82
+ "points": [],
83
+ }
84
+ return result
85
+
86
+
87
+ def periodical(rows, trades, fills, benchmark, initial, index):
88
+ if not isinstance(index, pd.DatetimeIndex):
89
+ return {
90
+ "status": "not_applicable",
91
+ "reason": "ordinal_index",
92
+ "buckets": {},
93
+ "correlations": {},
94
+ }
95
+ result, correlations = {}, {}
96
+ start, end = index[0], index[max(r["barIndex"] for r in rows)]
97
+ timezone = index.tz
98
+ naive = index.tz_localize(None) if timezone else index
99
+ for label, freq in (
100
+ ("daily", "D"),
101
+ ("weekly", "W-SUN"),
102
+ ("monthly", "M"),
103
+ ("quarterly", "Q"),
104
+ ("yearly", "Y"),
105
+ ):
106
+ keys = list(naive.to_period(freq))
107
+ covered = pd.period_range(keys[0], keys[max(r["barIndex"] for r in rows)], freq=freq)
108
+ groups = {key: [] for key in covered}
109
+ closed = {key: [] for key in covered}
110
+ paid = {key: 0.0 for key in covered}
111
+ for row in rows:
112
+ groups[keys[row["barIndex"]]].append(row)
113
+ for t in trades:
114
+ closed[keys[t["exit"]["barIndex"]]].append(t)
115
+ for f in fills:
116
+ paid[keys[f["bar_index"]]] += f["fee"]
117
+ previous, previous_bench = initial, initial
118
+ bench_by_bar = {p["barIndex"]: p["equity"] for p in benchmark["points"]}
119
+ buckets = []
120
+ for key, group in groups.items():
121
+ last = group[-1]["equity"] if group else previous
122
+ # Carry forward only unobserved buckets within the calendar range; never extend the execution range.
123
+ bvalue = bench_by_bar[group[-1]["barIndex"]] if group else previous_bench
124
+ left, right = key.start_time, (key + 1).start_time
125
+ if timezone:
126
+ left = left.tz_localize(timezone, ambiguous=True, nonexistent="shift_forward")
127
+ right = right.tz_localize(timezone, ambiguous=True, nonexistent="shift_forward")
128
+ ts = closed[key]
129
+ values = [r["equity"] for r in group]
130
+ buckets.append(
131
+ {
132
+ "start": time_ref(left),
133
+ "endExclusive": time_ref(right),
134
+ "partial": left < start or right > end,
135
+ "carriedForward": not group,
136
+ "count": len(group),
137
+ "firstBar": group[0]["barIndex"] if group else None,
138
+ "lastBar": group[-1]["barIndex"] if group else None,
139
+ "closedNetPnl": sum(t["pnlNet"] for t in ts),
140
+ "grossProfit": sum(max(0, t["pnlNet"]) for t in ts),
141
+ "grossLoss": sum(max(0, -t["pnlNet"]) for t in ts),
142
+ "closedFees": sum(t["feeTotal"] for t in ts),
143
+ "fees": paid[key],
144
+ "equityReturn": last / previous - 1 if previous > 0 else None,
145
+ "benchmarkReturn": bvalue / previous_bench - 1 if previous_bench > 0 else None,
146
+ "favorableExcursion": max([0.0] + [v - previous for v in values]),
147
+ "adverseExcursion": max([0.0] + [previous - v for v in values]),
148
+ "coverage": "partial"
149
+ if any(r["coverage"] == "partial" for r in group)
150
+ else "complete_under_bar_model",
151
+ }
152
+ )
153
+ previous, previous_bench = last, bvalue
154
+ for b in buckets:
155
+ b["outperformance"] = (
156
+ b["equityReturn"] - b["benchmarkReturn"]
157
+ if b["equityReturn"] is not None and b["benchmarkReturn"] is not None
158
+ else None
159
+ )
160
+ result[label] = buckets
161
+ correlations[label] = correlation(
162
+ [b["equityReturn"] for b in buckets], [b["benchmarkReturn"] for b in buckets]
163
+ )
164
+ return {"status": "ok", "reason": None, "buckets": result, "correlations": correlations}
@@ -0,0 +1,14 @@
1
+ """Pure numeric validation of statistical rates without runtime or account dependencies."""
2
+
3
+ from math import isfinite
4
+ from numbers import Real
5
+
6
+
7
+ def validate_risk_free_rate(value: float) -> float:
8
+ """Accept annualized fractions; reject booleans, nonfinite values, and out-of-range values."""
9
+ if isinstance(value, bool) or not isinstance(value, Real):
10
+ raise TypeError("risk_free_rate must be a finite number")
11
+ value = float(value)
12
+ if not isfinite(value) or not -1 < value < 1:
13
+ raise ValueError("risk_free_rate must be in (-1, 1)")
14
+ return value
@@ -0,0 +1,111 @@
1
+ """Freeze complete facts at runtime; derive reports on reads without executing user code."""
2
+
3
+ import json
4
+
5
+ import pandas as pd
6
+
7
+ from openbacktesting.portfolio.builder import build_report, legacy
8
+ from openbacktesting.portfolio.models import plain, time_ref
9
+ from openbacktesting.portfolio.trades import project_trades
10
+
11
+
12
+ class PortfolioSnapshot(str):
13
+ """Immutable versioned fact text; read complete reports through to_dict.
14
+
15
+ Snapshots hold no strategy, pandas objects, or mutable inputs. Derived analyses read only frozen facts;
16
+ the text itself is an internal storage format; cross-language consumers use export_result reports.
17
+ """
18
+
19
+ __slots__ = ()
20
+
21
+ def __new__(cls, payload: str):
22
+ return super().__new__(cls, payload)
23
+
24
+ def to_dict(self) -> dict:
25
+ """Compute and return an independent strict JSON report without modifying the snapshot or executing strategies."""
26
+ return build_report(json.loads(self))
27
+
28
+
29
+ def freeze(payload):
30
+ return PortfolioSnapshot(
31
+ json.dumps(
32
+ payload, default=plain, ensure_ascii=False, allow_nan=False, separators=(",", ":")
33
+ )
34
+ )
35
+
36
+
37
+ def capture_snapshot(stats, data, observations, reason, context=None):
38
+ facts, closed, opened = project_trades(stats, observations)
39
+ columns = list(observations.rows[0])
40
+ keys = (
41
+ "statistics_initial_equity",
42
+ "statistics_risk_free_rate",
43
+ "instrument",
44
+ "execution_policy_version",
45
+ "trading_schema_version",
46
+ "engine_version",
47
+ "commission",
48
+ "spread",
49
+ "slippage",
50
+ "margin",
51
+ "end_policy",
52
+ "execution_timing",
53
+ "hedging",
54
+ "allow_short",
55
+ "max_gross_exposure",
56
+ "diagnostics",
57
+ )
58
+ attrs = {k: plain(stats.attrs[k]) for k in keys}
59
+ attrs.update({k: plain(stats.attrs.get(k)) for k in ("input_snapshot", "backtest_snapshot")})
60
+ return freeze(
61
+ {
62
+ "format": "openbacktesting_portfolio_facts_v1",
63
+ "facts": facts,
64
+ "closed": closed,
65
+ "opened": opened,
66
+ "columns": columns,
67
+ "rows": [list(r.values()) for r in observations.rows],
68
+ "index": data.index.as_unit("ns").asi8.astype(str).tolist()
69
+ if isinstance(data.index, pd.DatetimeIndex)
70
+ else data.index.tolist(),
71
+ "close": data.Close.tolist(),
72
+ "high": data.High.tolist(),
73
+ "low": data.Low.tolist(),
74
+ "timeKind": time_ref(data.index[0])["kind"],
75
+ "timezone": str(data.index.tz)
76
+ if isinstance(data.index, pd.DatetimeIndex) and data.index.tz
77
+ else None,
78
+ "reason": reason,
79
+ "context": plain(context),
80
+ "attrs": attrs,
81
+ "legacy": legacy(stats),
82
+ }
83
+ )
84
+
85
+
86
+ def refresh_snapshot(result):
87
+ snapshot = result.attrs.get("portfolio_snapshot")
88
+ if snapshot is None:
89
+ return
90
+ source = json.loads(snapshot)
91
+ source["attrs"]["statistics_risk_free_rate"] = result.attrs["statistics_risk_free_rate"]
92
+ source["attrs"]["backtest_snapshot"] = plain(result.attrs.get("backtest_snapshot"))
93
+ source["legacy"] = legacy(result)
94
+ result.attrs["portfolio_snapshot"] = freeze(source)
95
+
96
+
97
+ def get_snapshot(result: pd.Series) -> PortfolioSnapshot:
98
+ """Read the frozen fact snapshot; explicitly reject old results or trade subsets lacking complete facts.
99
+
100
+ Raises:
101
+ ValueError: The result did not collect Portfolio data.
102
+ """
103
+ snapshot = result.attrs.get("portfolio_snapshot")
104
+ if not isinstance(snapshot, PortfolioSnapshot):
105
+ raise ValueError("portfolio_snapshot_missing") # noqa: TRY004 — missing snapshot rather than incorrect argument type
106
+ return snapshot
107
+
108
+
109
+ def export_result(result: pd.Series) -> dict:
110
+ """Return an independent plain-value copy of the complete report without file, network, or plotting side effects."""
111
+ return get_snapshot(result).to_dict()