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.
- openbacktesting/__init__.py +85 -0
- openbacktesting/aggregation.py +8 -0
- openbacktesting/broker/__init__.py +54 -0
- openbacktesting/broker/commission.py +57 -0
- openbacktesting/broker/core.py +1122 -0
- openbacktesting/broker/execution.py +62 -0
- openbacktesting/broker/instrument.py +106 -0
- openbacktesting/broker/ledger.py +60 -0
- openbacktesting/broker/models.py +236 -0
- openbacktesting/broker/observations.py +99 -0
- openbacktesting/broker/requests.py +192 -0
- openbacktesting/broker/sizing.py +76 -0
- openbacktesting/colors.py +175 -0
- openbacktesting/data/__init__.py +20 -0
- openbacktesting/data/aggregation.py +39 -0
- openbacktesting/data/context.py +30 -0
- openbacktesting/data/core.py +218 -0
- openbacktesting/data/generators.py +54 -0
- openbacktesting/data/strategy_data.py +103 -0
- openbacktesting/data/timeframe.py +69 -0
- openbacktesting/engine.py +34 -0
- openbacktesting/helpers.py +77 -0
- openbacktesting/indicator_api.py +10 -0
- openbacktesting/indicator_methods.py +123 -0
- openbacktesting/indicator_types.py +19 -0
- openbacktesting/indicators/__init__.py +7 -0
- openbacktesting/indicators/api.py +97 -0
- openbacktesting/indicators/calculus.py +75 -0
- openbacktesting/indicators/candles/__init__.py +0 -0
- openbacktesting/indicators/candles/cdl_doji.py +17 -0
- openbacktesting/indicators/candles/cdl_engulfing.py +14 -0
- openbacktesting/indicators/candles/cdl_eveningstar.py +15 -0
- openbacktesting/indicators/candles/cdl_hammer.py +14 -0
- openbacktesting/indicators/candles/cdl_harami.py +14 -0
- openbacktesting/indicators/candles/cdl_inside.py +14 -0
- openbacktesting/indicators/candles/cdl_marubozu.py +14 -0
- openbacktesting/indicators/candles/cdl_morningstar.py +15 -0
- openbacktesting/indicators/candles/cdl_shootingstar.py +14 -0
- openbacktesting/indicators/candles/cdl_z.py +28 -0
- openbacktesting/indicators/candles/ha.py +19 -0
- openbacktesting/indicators/catalog.json +8348 -0
- openbacktesting/indicators/common.py +94 -0
- openbacktesting/indicators/cycles/__init__.py +0 -0
- openbacktesting/indicators/cycles/dsp.py +10 -0
- openbacktesting/indicators/cycles/ebsw.py +10 -0
- openbacktesting/indicators/cycles/ht_dcperiod.py +10 -0
- openbacktesting/indicators/cycles/ht_dcphase.py +10 -0
- openbacktesting/indicators/cycles/ht_phasor.py +17 -0
- openbacktesting/indicators/cycles/ht_sine.py +17 -0
- openbacktesting/indicators/cycles/ht_trendmode.py +10 -0
- openbacktesting/indicators/cycles/msw.py +19 -0
- openbacktesting/indicators/methods.py +5762 -0
- openbacktesting/indicators/momentum/__init__.py +0 -0
- openbacktesting/indicators/momentum/ac.py +9 -0
- openbacktesting/indicators/momentum/ao.py +10 -0
- openbacktesting/indicators/momentum/apo.py +10 -0
- openbacktesting/indicators/momentum/bias.py +10 -0
- openbacktesting/indicators/momentum/bop.py +10 -0
- openbacktesting/indicators/momentum/brar.py +26 -0
- openbacktesting/indicators/momentum/cci.py +10 -0
- openbacktesting/indicators/momentum/cfo.py +10 -0
- openbacktesting/indicators/momentum/cg.py +10 -0
- openbacktesting/indicators/momentum/cmo.py +10 -0
- openbacktesting/indicators/momentum/coppock.py +10 -0
- openbacktesting/indicators/momentum/crsi.py +28 -0
- openbacktesting/indicators/momentum/cti.py +10 -0
- openbacktesting/indicators/momentum/dm.py +17 -0
- openbacktesting/indicators/momentum/er.py +10 -0
- openbacktesting/indicators/momentum/eri.py +17 -0
- openbacktesting/indicators/momentum/fisher.py +17 -0
- openbacktesting/indicators/momentum/fosc.py +10 -0
- openbacktesting/indicators/momentum/gator.py +13 -0
- openbacktesting/indicators/momentum/ibs.py +7 -0
- openbacktesting/indicators/momentum/inertia.py +22 -0
- openbacktesting/indicators/momentum/kdj.py +18 -0
- openbacktesting/indicators/momentum/kst.py +30 -0
- openbacktesting/indicators/momentum/lrsi.py +10 -0
- openbacktesting/indicators/momentum/macd.py +27 -0
- openbacktesting/indicators/momentum/macdext.py +27 -0
- openbacktesting/indicators/momentum/macdfix.py +18 -0
- openbacktesting/indicators/momentum/mom.py +10 -0
- openbacktesting/indicators/momentum/pgo.py +10 -0
- openbacktesting/indicators/momentum/pmo.py +13 -0
- openbacktesting/indicators/momentum/po.py +10 -0
- openbacktesting/indicators/momentum/ppo.py +20 -0
- openbacktesting/indicators/momentum/psl.py +12 -0
- openbacktesting/indicators/momentum/pvo.py +20 -0
- openbacktesting/indicators/momentum/qqe.py +31 -0
- openbacktesting/indicators/momentum/rci.py +20 -0
- openbacktesting/indicators/momentum/rmi.py +7 -0
- openbacktesting/indicators/momentum/roc.py +10 -0
- openbacktesting/indicators/momentum/rocp.py +10 -0
- openbacktesting/indicators/momentum/rocr.py +10 -0
- openbacktesting/indicators/momentum/rocr100.py +10 -0
- openbacktesting/indicators/momentum/rsi.py +10 -0
- openbacktesting/indicators/momentum/rsx.py +10 -0
- openbacktesting/indicators/momentum/rvgi.py +26 -0
- openbacktesting/indicators/momentum/slope.py +17 -0
- openbacktesting/indicators/momentum/smc_sweep.py +18 -0
- openbacktesting/indicators/momentum/smi.py +21 -0
- openbacktesting/indicators/momentum/smi_stochastic.py +15 -0
- openbacktesting/indicators/momentum/squeeze.py +47 -0
- openbacktesting/indicators/momentum/squeeze_pro.py +53 -0
- openbacktesting/indicators/momentum/stc.py +20 -0
- openbacktesting/indicators/momentum/stoch.py +19 -0
- openbacktesting/indicators/momentum/stochf.py +19 -0
- openbacktesting/indicators/momentum/stochrsi.py +19 -0
- openbacktesting/indicators/momentum/td_seq.py +17 -0
- openbacktesting/indicators/momentum/trix.py +19 -0
- openbacktesting/indicators/momentum/trixh.py +20 -0
- openbacktesting/indicators/momentum/tsi.py +26 -0
- openbacktesting/indicators/momentum/uo.py +22 -0
- openbacktesting/indicators/momentum/vwmacd.py +20 -0
- openbacktesting/indicators/momentum/wavetrend.py +15 -0
- openbacktesting/indicators/momentum/willr.py +10 -0
- openbacktesting/indicators/overlap/__init__.py +0 -0
- openbacktesting/indicators/overlap/alligator.py +13 -0
- openbacktesting/indicators/overlap/alma.py +33 -0
- openbacktesting/indicators/overlap/anchored_vwap.py +50 -0
- openbacktesting/indicators/overlap/anchored_vwap_bands.py +13 -0
- openbacktesting/indicators/overlap/avgprice.py +10 -0
- openbacktesting/indicators/overlap/dema.py +10 -0
- openbacktesting/indicators/overlap/ema.py +12 -0
- openbacktesting/indicators/overlap/frama.py +29 -0
- openbacktesting/indicators/overlap/fwma.py +10 -0
- openbacktesting/indicators/overlap/hilo.py +26 -0
- openbacktesting/indicators/overlap/hl2.py +10 -0
- openbacktesting/indicators/overlap/hlc3.py +10 -0
- openbacktesting/indicators/overlap/hma.py +10 -0
- openbacktesting/indicators/overlap/ht_trendline.py +10 -0
- openbacktesting/indicators/overlap/hwma.py +10 -0
- openbacktesting/indicators/overlap/ichimoku.py +44 -0
- openbacktesting/indicators/overlap/jma.py +10 -0
- openbacktesting/indicators/overlap/kama.py +10 -0
- openbacktesting/indicators/overlap/linreg.py +15 -0
- openbacktesting/indicators/overlap/linregangle.py +10 -0
- openbacktesting/indicators/overlap/linregintercept.py +10 -0
- openbacktesting/indicators/overlap/linregslope.py +10 -0
- openbacktesting/indicators/overlap/ma.py +10 -0
- openbacktesting/indicators/overlap/ma_envelopes.py +16 -0
- openbacktesting/indicators/overlap/mama.py +17 -0
- openbacktesting/indicators/overlap/mavp.py +17 -0
- openbacktesting/indicators/overlap/mcgd.py +10 -0
- openbacktesting/indicators/overlap/medprice.py +10 -0
- openbacktesting/indicators/overlap/midpoint.py +10 -0
- openbacktesting/indicators/overlap/midprice.py +10 -0
- openbacktesting/indicators/overlap/mmar.py +21 -0
- openbacktesting/indicators/overlap/ohlc4.py +10 -0
- openbacktesting/indicators/overlap/pwma.py +10 -0
- openbacktesting/indicators/overlap/rainbow.py +25 -0
- openbacktesting/indicators/overlap/rma.py +10 -0
- openbacktesting/indicators/overlap/sinwma.py +10 -0
- openbacktesting/indicators/overlap/sma.py +14 -0
- openbacktesting/indicators/overlap/ssf.py +10 -0
- openbacktesting/indicators/overlap/supertrend.py +21 -0
- openbacktesting/indicators/overlap/swma.py +10 -0
- openbacktesting/indicators/overlap/t3.py +10 -0
- openbacktesting/indicators/overlap/tema.py +10 -0
- openbacktesting/indicators/overlap/trima.py +10 -0
- openbacktesting/indicators/overlap/tsf.py +10 -0
- openbacktesting/indicators/overlap/typprice.py +10 -0
- openbacktesting/indicators/overlap/vidya.py +10 -0
- openbacktesting/indicators/overlap/vwap.py +10 -0
- openbacktesting/indicators/overlap/vwma.py +10 -0
- openbacktesting/indicators/overlap/wcp.py +10 -0
- openbacktesting/indicators/overlap/wma.py +10 -0
- openbacktesting/indicators/overlap/zlma.py +10 -0
- openbacktesting/indicators/performance/__init__.py +0 -0
- openbacktesting/indicators/performance/drawdown.py +18 -0
- openbacktesting/indicators/performance/log_return.py +10 -0
- openbacktesting/indicators/performance/percent_return.py +10 -0
- openbacktesting/indicators/predicates.py +88 -0
- openbacktesting/indicators/registry.py +571 -0
- openbacktesting/indicators/runtime.py +290 -0
- openbacktesting/indicators/statistics/__init__.py +0 -0
- openbacktesting/indicators/statistics/beta.py +10 -0
- openbacktesting/indicators/statistics/correl.py +10 -0
- openbacktesting/indicators/statistics/entropy.py +10 -0
- openbacktesting/indicators/statistics/kurtosis.py +10 -0
- openbacktesting/indicators/statistics/mad.py +10 -0
- openbacktesting/indicators/statistics/md.py +10 -0
- openbacktesting/indicators/statistics/median.py +10 -0
- openbacktesting/indicators/statistics/mode.py +48 -0
- openbacktesting/indicators/statistics/quantile.py +10 -0
- openbacktesting/indicators/statistics/skew.py +10 -0
- openbacktesting/indicators/statistics/stderr.py +10 -0
- openbacktesting/indicators/statistics/stdev.py +10 -0
- openbacktesting/indicators/statistics/variance.py +10 -0
- openbacktesting/indicators/statistics/zscore.py +10 -0
- openbacktesting/indicators/trend/__init__.py +0 -0
- openbacktesting/indicators/trend/adx.py +28 -0
- openbacktesting/indicators/trend/adxr.py +28 -0
- openbacktesting/indicators/trend/amat.py +19 -0
- openbacktesting/indicators/trend/aroon.py +18 -0
- openbacktesting/indicators/trend/atr_stop.py +33 -0
- openbacktesting/indicators/trend/chop.py +20 -0
- openbacktesting/indicators/trend/cksp.py +17 -0
- openbacktesting/indicators/trend/cpr.py +57 -0
- openbacktesting/indicators/trend/decay.py +10 -0
- openbacktesting/indicators/trend/decreasing.py +18 -0
- openbacktesting/indicators/trend/dpo.py +12 -0
- openbacktesting/indicators/trend/dx.py +19 -0
- openbacktesting/indicators/trend/edecay.py +10 -0
- openbacktesting/indicators/trend/increasing.py +18 -0
- openbacktesting/indicators/trend/long_run.py +10 -0
- openbacktesting/indicators/trend/minus_dm.py +10 -0
- openbacktesting/indicators/trend/pivot_points.py +89 -0
- openbacktesting/indicators/trend/pivothigh.py +23 -0
- openbacktesting/indicators/trend/pivotlow.py +23 -0
- openbacktesting/indicators/trend/plus_dm.py +10 -0
- openbacktesting/indicators/trend/pmax.py +18 -0
- openbacktesting/indicators/trend/psar.py +19 -0
- openbacktesting/indicators/trend/qstick.py +10 -0
- openbacktesting/indicators/trend/sarext.py +35 -0
- openbacktesting/indicators/trend/short_run.py +10 -0
- openbacktesting/indicators/trend/tsignals.py +26 -0
- openbacktesting/indicators/trend/ttm_trend.py +16 -0
- openbacktesting/indicators/trend/vhf.py +10 -0
- openbacktesting/indicators/trend/volatility_stop.py +33 -0
- openbacktesting/indicators/trend/vortex.py +17 -0
- openbacktesting/indicators/trend/xsignals.py +29 -0
- openbacktesting/indicators/types.py +344 -0
- openbacktesting/indicators/utility/__init__.py +0 -0
- openbacktesting/indicators/utility/barssince.py +21 -0
- openbacktesting/indicators/utility/change.py +7 -0
- openbacktesting/indicators/utility/cross.py +12 -0
- openbacktesting/indicators/utility/crossover.py +11 -0
- openbacktesting/indicators/utility/crossunder.py +11 -0
- openbacktesting/indicators/utility/cum.py +7 -0
- openbacktesting/indicators/utility/falling.py +10 -0
- openbacktesting/indicators/utility/highest.py +7 -0
- openbacktesting/indicators/utility/highestbars.py +29 -0
- openbacktesting/indicators/utility/lowest.py +7 -0
- openbacktesting/indicators/utility/lowestbars.py +29 -0
- openbacktesting/indicators/utility/percentile_linear_interpolation.py +12 -0
- openbacktesting/indicators/utility/percentile_nearest_rank.py +16 -0
- openbacktesting/indicators/utility/percentrank.py +7 -0
- openbacktesting/indicators/utility/range.py +8 -0
- openbacktesting/indicators/utility/rising.py +10 -0
- openbacktesting/indicators/utility/rolling_count.py +12 -0
- openbacktesting/indicators/utility/rolling_sum.py +7 -0
- openbacktesting/indicators/utility/running_max.py +7 -0
- openbacktesting/indicators/utility/running_min.py +7 -0
- openbacktesting/indicators/utility/shift.py +7 -0
- openbacktesting/indicators/utility/valuewhen.py +25 -0
- openbacktesting/indicators/volatility/__init__.py +0 -0
- openbacktesting/indicators/volatility/aberration.py +21 -0
- openbacktesting/indicators/volatility/accbands.py +20 -0
- openbacktesting/indicators/volatility/adr.py +7 -0
- openbacktesting/indicators/volatility/atr.py +12 -0
- openbacktesting/indicators/volatility/avolume.py +10 -0
- openbacktesting/indicators/volatility/bbands.py +20 -0
- openbacktesting/indicators/volatility/ce.py +25 -0
- openbacktesting/indicators/volatility/cvi.py +10 -0
- openbacktesting/indicators/volatility/donchian.py +20 -0
- openbacktesting/indicators/volatility/hvol.py +10 -0
- openbacktesting/indicators/volatility/hwc.py +27 -0
- openbacktesting/indicators/volatility/kc.py +27 -0
- openbacktesting/indicators/volatility/massi.py +10 -0
- openbacktesting/indicators/volatility/natr.py +19 -0
- openbacktesting/indicators/volatility/pdist.py +10 -0
- openbacktesting/indicators/volatility/rvi.py +21 -0
- openbacktesting/indicators/volatility/starc.py +11 -0
- openbacktesting/indicators/volatility/thermo.py +28 -0
- openbacktesting/indicators/volatility/true_range.py +31 -0
- openbacktesting/indicators/volatility/ui.py +10 -0
- openbacktesting/indicators/volume/__init__.py +0 -0
- openbacktesting/indicators/volume/ad.py +10 -0
- openbacktesting/indicators/volume/adosc.py +19 -0
- openbacktesting/indicators/volume/aobv.py +31 -0
- openbacktesting/indicators/volume/cmf.py +12 -0
- openbacktesting/indicators/volume/efi.py +12 -0
- openbacktesting/indicators/volume/emv.py +10 -0
- openbacktesting/indicators/volume/eom.py +19 -0
- openbacktesting/indicators/volume/iii.py +9 -0
- openbacktesting/indicators/volume/kvo.py +28 -0
- openbacktesting/indicators/volume/marketfi.py +10 -0
- openbacktesting/indicators/volume/mfi.py +12 -0
- openbacktesting/indicators/volume/nvi.py +26 -0
- openbacktesting/indicators/volume/obv.py +10 -0
- openbacktesting/indicators/volume/pvi.py +26 -0
- openbacktesting/indicators/volume/pvol.py +10 -0
- openbacktesting/indicators/volume/pvr.py +10 -0
- openbacktesting/indicators/volume/pvt.py +10 -0
- openbacktesting/indicators/volume/rvol.py +8 -0
- openbacktesting/indicators/volume/vfi.py +18 -0
- openbacktesting/indicators/volume/vosc.py +10 -0
- openbacktesting/indicators/volume/wad.py +10 -0
- openbacktesting/indicators/volume/wvad.py +9 -0
- openbacktesting/inputs.py +51 -0
- openbacktesting/lib.py +31 -0
- openbacktesting/logs.py +129 -0
- openbacktesting/optimization.py +8 -0
- openbacktesting/optimizer/__init__.py +25 -0
- openbacktesting/optimizer/_semantics.py +155 -0
- openbacktesting/optimizer/identity.py +45 -0
- openbacktesting/optimizer/models.py +92 -0
- openbacktesting/optimizer/plan.py +129 -0
- openbacktesting/optimizer/portfolio-result.schema.json +4984 -0
- openbacktesting/optimizer/query.py +257 -0
- openbacktesting/optimizer/runner.py +98 -0
- openbacktesting/optimizer/storage.py +261 -0
- openbacktesting/optimizer/validation.py +64 -0
- openbacktesting/parallel.py +9 -0
- openbacktesting/plotting/__init__.py +23 -0
- openbacktesting/plotting/collector.py +509 -0
- openbacktesting/plotting/encoding.py +102 -0
- openbacktesting/plotting/types.py +92 -0
- openbacktesting/portfolio/__init__.py +15 -0
- openbacktesting/portfolio/analysis.py +245 -0
- openbacktesting/portfolio/builder.py +381 -0
- openbacktesting/portfolio/catalog.json +1404 -0
- openbacktesting/portfolio/helpers.py +92 -0
- openbacktesting/portfolio/metrics.py +186 -0
- openbacktesting/portfolio/models.py +63 -0
- openbacktesting/portfolio/periods.py +164 -0
- openbacktesting/portfolio/rates.py +14 -0
- openbacktesting/portfolio/snapshot.py +111 -0
- openbacktesting/portfolio/stats.py +272 -0
- openbacktesting/portfolio/trades.py +136 -0
- openbacktesting/portfolio/trading.py +215 -0
- openbacktesting/py.typed +0 -0
- openbacktesting/runtime.py +330 -0
- openbacktesting/simulation/__init__.py +17 -0
- openbacktesting/simulation/collection.py +13 -0
- openbacktesting/simulation/config.py +241 -0
- openbacktesting/simulation/core.py +528 -0
- openbacktesting/simulation/optimization.py +326 -0
- openbacktesting/simulation/parallel.py +145 -0
- openbacktesting/simulation/variants.py +107 -0
- openbacktesting/strategy/__init__.py +19 -0
- openbacktesting/strategy/bar_state.py +14 -0
- openbacktesting/strategy/base.py +810 -0
- openbacktesting/strategy/declaration.py +90 -0
- openbacktesting/strategy/helpers.py +198 -0
- openbacktesting/strategy/inputs.py +460 -0
- openbacktesting/strategy/sizing.py +80 -0
- openbacktesting/strategy/templates.py +160 -0
- openbacktesting/strategy_data.py +10 -0
- openbacktesting/test/BTCUSD.csv +157 -0
- openbacktesting/test/EURUSD.csv +5001 -0
- openbacktesting/test/GOOG.csv +2149 -0
- openbacktesting/test/__init__.py +29 -0
- openbacktesting/test/__main__.py +11 -0
- openbacktesting/test/_test.py +1312 -0
- openbacktesting/util.py +31 -0
- openbacktesting-0.2.0.dist-info/METADATA +151 -0
- openbacktesting-0.2.0.dist-info/RECORD +350 -0
- openbacktesting-0.2.0.dist-info/WHEEL +4 -0
- openbacktesting-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Query experiments from on-disk scalar projections; do not import strategies or recompute statistics."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from openbacktesting.optimizer.models import digest, encode
|
|
10
|
+
from openbacktesting.optimizer.storage import (
|
|
11
|
+
check_version,
|
|
12
|
+
connect,
|
|
13
|
+
counts,
|
|
14
|
+
load_report,
|
|
15
|
+
local_path,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _compare(left, right):
|
|
20
|
+
a, b = json.loads(left), json.loads(right)
|
|
21
|
+
if a[0] == "number":
|
|
22
|
+
a, b = Decimal(str(a[1])), Decimal(str(b[1]))
|
|
23
|
+
else:
|
|
24
|
+
a, b = a[1], b[1]
|
|
25
|
+
return (a > b) - (a < b)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _key(value, kind):
|
|
29
|
+
return encode([kind, json.loads(value)]) if value is not None else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _status(alias):
|
|
33
|
+
return (
|
|
34
|
+
f"CASE WHEN {alias}.trial IS NULL THEN 'missing' "
|
|
35
|
+
f"ELSE COALESCE({alias}.status, CASE WHEN {alias}.kind='null' THEN 'null' ELSE 'ok' END) END"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _direction(path):
|
|
40
|
+
return (
|
|
41
|
+
"asc"
|
|
42
|
+
if "drawdown" in path.casefold() and not path.startswith("extensions.legacy.")
|
|
43
|
+
else "desc"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _limit(value):
|
|
48
|
+
if type(value) is not int or not 1 <= value <= 1000:
|
|
49
|
+
raise ValueError("optimizer: limit must be in 1..1000")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ExperimentResult:
|
|
54
|
+
"""Reopenable read-only result handle; each access uses an independent connection without live strategy references."""
|
|
55
|
+
|
|
56
|
+
path: str
|
|
57
|
+
experiment_id: str
|
|
58
|
+
|
|
59
|
+
@contextmanager
|
|
60
|
+
def _connection(self):
|
|
61
|
+
conn = connect(self.path, readonly=True)
|
|
62
|
+
try:
|
|
63
|
+
check_version(conn)
|
|
64
|
+
if conn.execute("SELECT id FROM experiment").fetchone()[0] != self.experiment_id:
|
|
65
|
+
raise ValueError("optimizer: experiment ID does not match")
|
|
66
|
+
conn.create_collation("exact", _compare)
|
|
67
|
+
conn.create_function("scalar_key", 2, _key, deterministic=True)
|
|
68
|
+
conn.execute("PRAGMA temp_store=FILE")
|
|
69
|
+
yield conn
|
|
70
|
+
finally:
|
|
71
|
+
conn.close()
|
|
72
|
+
|
|
73
|
+
def info(self) -> dict:
|
|
74
|
+
"""Return experiment status, category counts, and an immutable plan copy."""
|
|
75
|
+
with self._connection() as conn:
|
|
76
|
+
return counts(conn) | {
|
|
77
|
+
"plan": json.loads(conn.execute("SELECT plan FROM experiment").fetchone()[0])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def fields(self) -> list[dict]:
|
|
81
|
+
"""Return scalar paths, types, units, statuses, methods, and suggested sort directions."""
|
|
82
|
+
with self._connection() as conn:
|
|
83
|
+
return [
|
|
84
|
+
dict(row) | {"suggestedDirection": _direction(row["path"])}
|
|
85
|
+
for row in conn.execute(
|
|
86
|
+
"SELECT DISTINCT path,kind,unit,status,method FROM scalars ORDER BY path"
|
|
87
|
+
)
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
def load_portfolio(self, trial_id: str) -> dict:
|
|
91
|
+
"""Validate and load one complete report; explicitly reject failures and corruption."""
|
|
92
|
+
with self._connection() as conn:
|
|
93
|
+
return load_report(conn, trial_id)
|
|
94
|
+
|
|
95
|
+
def trials(self, *, limit: int = 100, offset: int = 0) -> list[dict]:
|
|
96
|
+
"""Browse live statuses of all visited trials by page, without complete Portfolios."""
|
|
97
|
+
_limit(limit)
|
|
98
|
+
if type(offset) is not int or offset < 0:
|
|
99
|
+
raise ValueError("optimizer: offset must be a nonnegative integer")
|
|
100
|
+
with self._connection() as conn:
|
|
101
|
+
return [
|
|
102
|
+
dict(row)
|
|
103
|
+
for row in conn.execute(
|
|
104
|
+
"SELECT t.id AS trial_id,t.ordinal,t.request,t.state,t.attempt,a.error FROM trials t JOIN attempts a ON a.trial=t.id AND a.number=t.attempt ORDER BY t.ordinal LIMIT ? OFFSET ?",
|
|
105
|
+
(limit, offset),
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def query(
|
|
110
|
+
self, *, order_by=(), filters=(), fields=(), limit: int = 100, cursor: str | None = None
|
|
111
|
+
) -> dict:
|
|
112
|
+
"""Query scalar values and parameters for successful trials; cursors freeze the commit range, and new queries refresh it.
|
|
113
|
+
|
|
114
|
+
order_by is a path or (path, asc/desc); filters are (path, operator, value).
|
|
115
|
+
Supported operators: eq/ne/lt/le/gt/ge/status; escape dots and backslashes in keys with a backslash.
|
|
116
|
+
"""
|
|
117
|
+
_limit(limit)
|
|
118
|
+
ordering = [
|
|
119
|
+
(item, _direction(item)) if isinstance(item, str) else tuple(item) for item in order_by
|
|
120
|
+
]
|
|
121
|
+
if any(len(item) != 2 or item[1] not in ("asc", "desc") for item in ordering):
|
|
122
|
+
raise ValueError("optimizer: invalid sort direction")
|
|
123
|
+
operations = {"eq": "=", "ne": "!=", "lt": "<", "le": "<=", "gt": ">", "ge": ">="}
|
|
124
|
+
if any(len(item) != 3 or item[1] not in (*operations, "status") for item in filters):
|
|
125
|
+
raise ValueError("optimizer: invalid filter condition")
|
|
126
|
+
paths = list(
|
|
127
|
+
dict.fromkeys([p for p, _ in ordering] + [f[0] for f in filters] + list(fields))
|
|
128
|
+
)
|
|
129
|
+
query_hash = digest([ordering, filters, fields, limit])
|
|
130
|
+
with self._connection() as conn:
|
|
131
|
+
maximum = conn.execute("SELECT sequence FROM experiment").fetchone()[0]
|
|
132
|
+
token = (
|
|
133
|
+
json.loads(cursor)
|
|
134
|
+
if cursor is not None
|
|
135
|
+
else {
|
|
136
|
+
"experiment": self.experiment_id,
|
|
137
|
+
"query": query_hash,
|
|
138
|
+
"watermark": maximum,
|
|
139
|
+
"offset": 0,
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
if (
|
|
143
|
+
not isinstance(token, dict)
|
|
144
|
+
or token.get("experiment") != self.experiment_id
|
|
145
|
+
or token.get("query") != query_hash
|
|
146
|
+
or type(token.get("watermark")) is not int
|
|
147
|
+
or not 0 <= token["watermark"] <= maximum
|
|
148
|
+
or type(token.get("offset")) is not int
|
|
149
|
+
or token["offset"] < 0
|
|
150
|
+
):
|
|
151
|
+
raise ValueError("optimizer: cursor does not match the query")
|
|
152
|
+
joins, selected, params, metadata = [], [], [], {}
|
|
153
|
+
for i, path in enumerate(paths):
|
|
154
|
+
rows = conn.execute(
|
|
155
|
+
"SELECT DISTINCT kind,unit,method FROM scalars s JOIN trials t ON s.trial=t.id WHERE s.path=? AND t.sequence<=?",
|
|
156
|
+
(path, token["watermark"]),
|
|
157
|
+
).fetchall()
|
|
158
|
+
if not rows:
|
|
159
|
+
raise ValueError(f"optimizer: unknown or nonscalar field {path}")
|
|
160
|
+
signatures = {
|
|
161
|
+
(r["kind"], r["unit"], r["method"]) for r in rows if r["kind"] != "null"
|
|
162
|
+
}
|
|
163
|
+
if len(signatures) > 1 or len({(r["unit"], r["method"]) for r in rows}) > 1:
|
|
164
|
+
raise ValueError(f"optimizer: inconsistent field types or statistical definitions {path}")
|
|
165
|
+
metadata[path] = next(iter(signatures), ("null", rows[0]["unit"], rows[0]["method"]))
|
|
166
|
+
joins.append(f"LEFT JOIN scalars s{i} ON s{i}.trial=t.id AND s{i}.path=?")
|
|
167
|
+
params.append(path)
|
|
168
|
+
selected += [f"s{i}.value AS v{i}", f"{_status(f's{i}')} AS z{i}"]
|
|
169
|
+
where = ["t.state='succeeded'", "t.sequence<=?"]
|
|
170
|
+
params.append(token["watermark"])
|
|
171
|
+
for path, op, value in filters:
|
|
172
|
+
alias = f"s{paths.index(path)}"
|
|
173
|
+
if op == "status":
|
|
174
|
+
where.append(f"{_status(alias)}=?")
|
|
175
|
+
params.append(value)
|
|
176
|
+
else:
|
|
177
|
+
kind = metadata[path][0]
|
|
178
|
+
valid = (
|
|
179
|
+
kind == "number"
|
|
180
|
+
and type(value) in (int, float, str)
|
|
181
|
+
or kind == "string"
|
|
182
|
+
and type(value) is str
|
|
183
|
+
or kind == "boolean"
|
|
184
|
+
and type(value) is bool
|
|
185
|
+
)
|
|
186
|
+
if not valid:
|
|
187
|
+
raise ValueError("optimizer: filter value type does not match")
|
|
188
|
+
if kind == "number":
|
|
189
|
+
number = Decimal(str(value))
|
|
190
|
+
if not number.is_finite():
|
|
191
|
+
raise ValueError("optimizer: nonfinite filter value")
|
|
192
|
+
where.append(
|
|
193
|
+
f"scalar_key({alias}.value,{alias}.kind) COLLATE exact {operations[op]} ?"
|
|
194
|
+
)
|
|
195
|
+
params.append(encode([kind, value]))
|
|
196
|
+
order = []
|
|
197
|
+
for path, direction in ordering:
|
|
198
|
+
alias = f"s{paths.index(path)}"
|
|
199
|
+
order += [
|
|
200
|
+
f"({alias}.value IS NULL) ASC",
|
|
201
|
+
f"scalar_key({alias}.value,{alias}.kind) COLLATE exact {direction.upper()}",
|
|
202
|
+
]
|
|
203
|
+
order.append("t.ordinal ASC")
|
|
204
|
+
columns = "t.id,t.ordinal,t.request" + ("," + ",".join(selected) if selected else "")
|
|
205
|
+
sql = f"SELECT {columns} FROM trials t {' '.join(joins)} WHERE {' AND '.join(where)} ORDER BY {','.join(order)} LIMIT ? OFFSET ?"
|
|
206
|
+
rows = conn.execute(sql, (*params, limit + 1, token["offset"])).fetchall()
|
|
207
|
+
result = [
|
|
208
|
+
{
|
|
209
|
+
"trial_id": row["id"],
|
|
210
|
+
"ordinal": row["ordinal"],
|
|
211
|
+
"inputs": json.loads(row["request"])["values"],
|
|
212
|
+
"values": {
|
|
213
|
+
p: {
|
|
214
|
+
"value": json.loads(row[f"v{i}"]) if row[f"v{i}"] is not None else None,
|
|
215
|
+
"status": row[f"z{i}"],
|
|
216
|
+
"unit": metadata[p][1],
|
|
217
|
+
"methodId": metadata[p][2],
|
|
218
|
+
}
|
|
219
|
+
for i, p in enumerate(paths)
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
for row in rows[:limit]
|
|
223
|
+
]
|
|
224
|
+
following = (
|
|
225
|
+
encode(token | {"offset": token["offset"] + limit}) if len(rows) > limit else None
|
|
226
|
+
)
|
|
227
|
+
return {"rows": result, "cursor": following, "watermark": token["watermark"]}
|
|
228
|
+
|
|
229
|
+
def backup(self, destination) -> str:
|
|
230
|
+
"""Create a consistent backup in a new local file; reject overwriting existing files."""
|
|
231
|
+
target = local_path(destination)
|
|
232
|
+
with target.open("xb"):
|
|
233
|
+
pass
|
|
234
|
+
try:
|
|
235
|
+
with self._connection() as source:
|
|
236
|
+
dest = sqlite3.connect(target)
|
|
237
|
+
try:
|
|
238
|
+
source.backup(dest)
|
|
239
|
+
finally:
|
|
240
|
+
dest.close()
|
|
241
|
+
except BaseException:
|
|
242
|
+
target.unlink()
|
|
243
|
+
raise
|
|
244
|
+
return str(target)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def open_result(path, experiment_id: str | None = None) -> ExperimentResult:
|
|
248
|
+
"""Open an experiment result database read-only, without strategy or market-data dependencies."""
|
|
249
|
+
conn = connect(path, readonly=True)
|
|
250
|
+
try:
|
|
251
|
+
check_version(conn)
|
|
252
|
+
current = conn.execute("SELECT id FROM experiment").fetchone()[0]
|
|
253
|
+
if experiment_id is not None and current != experiment_id:
|
|
254
|
+
raise ValueError("optimizer: experiment ID does not match")
|
|
255
|
+
return ExperimentResult(str(local_path(path)), current)
|
|
256
|
+
finally:
|
|
257
|
+
conn.close()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Execute and commit trials sequentially; file side effects occur only during explicit execution."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
from openbacktesting.optimizer.models import OptimizationError, _ExportError
|
|
6
|
+
from openbacktesting.optimizer.plan import combinations
|
|
7
|
+
from openbacktesting.optimizer.query import open_result
|
|
8
|
+
from openbacktesting.optimizer.storage import Store, counts, owner
|
|
9
|
+
from openbacktesting.optimizer.validation import validate_report
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def execute(
|
|
13
|
+
plan_value,
|
|
14
|
+
executor,
|
|
15
|
+
path,
|
|
16
|
+
*,
|
|
17
|
+
experiment_id=None,
|
|
18
|
+
constraint=None,
|
|
19
|
+
cancel=None,
|
|
20
|
+
on_progress=None,
|
|
21
|
+
):
|
|
22
|
+
"""Execute or resume a plan; executor receives title-based requests and returns complete Portfolio dictionaries.
|
|
23
|
+
|
|
24
|
+
Constraints receive complete wire values; cancel is a no-argument bool callable, and on_progress only consumes notifications.
|
|
25
|
+
Only an explicit experiment_id indicates resumption; failed trials are not retried automatically.
|
|
26
|
+
"""
|
|
27
|
+
if (constraint is None) != (plan_value.snapshot()["constraintId"] is None):
|
|
28
|
+
raise ValueError("optimizer: constraint does not match the plan digest")
|
|
29
|
+
committed = 0
|
|
30
|
+
store = None
|
|
31
|
+
try:
|
|
32
|
+
with owner(path):
|
|
33
|
+
store = Store(path, plan_value, experiment_id=experiment_id)
|
|
34
|
+
experiment_id = store.id
|
|
35
|
+
try:
|
|
36
|
+
committed = counts(store.conn)["committed"]
|
|
37
|
+
for ordinal, request in combinations(plan_value):
|
|
38
|
+
if cancel is not None and cancel():
|
|
39
|
+
store.finish("cancelled")
|
|
40
|
+
break
|
|
41
|
+
attempt = store.start(ordinal, request)
|
|
42
|
+
if attempt is None:
|
|
43
|
+
continue
|
|
44
|
+
trial_id, number = attempt
|
|
45
|
+
if constraint is not None:
|
|
46
|
+
try:
|
|
47
|
+
accepted = constraint(dict(request["values"]))
|
|
48
|
+
if type(accepted) is not bool:
|
|
49
|
+
raise TypeError("optimizer: constraint must return bool")
|
|
50
|
+
except Exception as error:
|
|
51
|
+
store.state(
|
|
52
|
+
trial_id,
|
|
53
|
+
number,
|
|
54
|
+
"failed",
|
|
55
|
+
f"constraint: {type(error).__name__}: {str(error)[:2000]}",
|
|
56
|
+
)
|
|
57
|
+
raise
|
|
58
|
+
if not accepted:
|
|
59
|
+
store.state(trial_id, number, "skipped_constraint", "constraint_false")
|
|
60
|
+
continue
|
|
61
|
+
phase = "execution"
|
|
62
|
+
try:
|
|
63
|
+
report = executor(request)
|
|
64
|
+
phase = "export"
|
|
65
|
+
validate_report(report)
|
|
66
|
+
except Exception as error: # noqa: BLE001 — isolate arbitrary user strategy/export exceptions and record failures
|
|
67
|
+
store.state(
|
|
68
|
+
trial_id,
|
|
69
|
+
number,
|
|
70
|
+
"failed",
|
|
71
|
+
f"{'export' if isinstance(error, _ExportError) else phase}: {type(error).__name__}: {str(error)[:2000]}",
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
store.state(trial_id, number, "persisting")
|
|
75
|
+
store.commit(trial_id, number, report)
|
|
76
|
+
del report
|
|
77
|
+
committed += 1
|
|
78
|
+
if on_progress is not None:
|
|
79
|
+
on_progress(
|
|
80
|
+
counts(store.conn)
|
|
81
|
+
| {"event": "trial_committed", "trial_id": trial_id}
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
store.finish(
|
|
85
|
+
"completed_with_errors" if counts(store.conn)["failed"] else "completed"
|
|
86
|
+
)
|
|
87
|
+
except BaseException as failure:
|
|
88
|
+
try:
|
|
89
|
+
store.finish("failed")
|
|
90
|
+
except sqlite3.Error as status_error:
|
|
91
|
+
failure.add_note(f"Failed to persist experiment failure status: {status_error}")
|
|
92
|
+
raise
|
|
93
|
+
finally:
|
|
94
|
+
store.close()
|
|
95
|
+
except Exception as error:
|
|
96
|
+
message = str(error) + " ".join(getattr(error, "__notes__", ()))
|
|
97
|
+
raise OptimizationError(message, path, experiment_id, committed) from error
|
|
98
|
+
return open_result(path, experiment_id)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Local single-experiment SQLite result database; one report corresponds to one atomic commit."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sqlite3
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from openbacktesting.optimizer.models import encode
|
|
13
|
+
from openbacktesting.optimizer.validation import validate_report
|
|
14
|
+
|
|
15
|
+
APPLICATION_ID = 1128546128
|
|
16
|
+
VERSION = 1
|
|
17
|
+
DDL = """
|
|
18
|
+
CREATE TABLE experiment(id TEXT PRIMARY KEY, plan TEXT NOT NULL, status TEXT NOT NULL,
|
|
19
|
+
sequence INTEGER NOT NULL DEFAULT 0);
|
|
20
|
+
CREATE TABLE trials(id TEXT PRIMARY KEY, ordinal INTEGER UNIQUE NOT NULL,
|
|
21
|
+
request TEXT NOT NULL UNIQUE, state TEXT NOT NULL, attempt INTEGER NOT NULL,
|
|
22
|
+
sequence INTEGER UNIQUE, report TEXT, hash TEXT);
|
|
23
|
+
CREATE TABLE attempts(trial TEXT NOT NULL REFERENCES trials(id), number INTEGER NOT NULL,
|
|
24
|
+
state TEXT NOT NULL, started TEXT NOT NULL, ended TEXT, error TEXT,
|
|
25
|
+
PRIMARY KEY(trial,number));
|
|
26
|
+
CREATE TABLE scalars(trial TEXT NOT NULL REFERENCES trials(id), path TEXT NOT NULL,
|
|
27
|
+
value TEXT, kind TEXT NOT NULL, unit TEXT, status TEXT, method TEXT,
|
|
28
|
+
PRIMARY KEY(trial,path));
|
|
29
|
+
CREATE INDEX scalar_path ON scalars(path);
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def local_path(path):
|
|
34
|
+
raw = str(path)
|
|
35
|
+
if raw.startswith(("file:", "//", "\\\\")) or raw == ":memory:":
|
|
36
|
+
raise ValueError("optimizer: a local file path is required")
|
|
37
|
+
return Path(path).expanduser().resolve()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def connect(path, *, readonly=False):
|
|
41
|
+
path = local_path(path)
|
|
42
|
+
conn = sqlite3.connect(
|
|
43
|
+
path.as_uri() + ("?mode=ro" if readonly else "?mode=rwc"), uri=True, timeout=1
|
|
44
|
+
)
|
|
45
|
+
conn.row_factory = sqlite3.Row
|
|
46
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
47
|
+
if readonly:
|
|
48
|
+
conn.execute("PRAGMA query_only=ON")
|
|
49
|
+
return conn
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_version(conn):
|
|
53
|
+
if (
|
|
54
|
+
conn.execute("PRAGMA application_id").fetchone()[0] != APPLICATION_ID
|
|
55
|
+
or conn.execute("PRAGMA user_version").fetchone()[0] != VERSION
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("optimizer: unsupported result database version")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextmanager
|
|
61
|
+
def owner(path):
|
|
62
|
+
"""Separate lock database holding an operating-system file lock without blocking result database readers."""
|
|
63
|
+
path = local_path(path)
|
|
64
|
+
lock = sqlite3.connect(str(path) + ".owner", timeout=0)
|
|
65
|
+
try:
|
|
66
|
+
lock.execute("BEGIN EXCLUSIVE")
|
|
67
|
+
yield path
|
|
68
|
+
finally:
|
|
69
|
+
lock.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def now():
|
|
73
|
+
return datetime.now(UTC).isoformat()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def flatten(value, path="", metadata=None):
|
|
77
|
+
"""Scalar projection; escape dots/backslashes in keys with a backslash, and do not traverse arrays."""
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
if {"value", "unit", "status", "methodId"} <= value.keys():
|
|
80
|
+
metadata = value
|
|
81
|
+
elif value.get("kind") in ("aware", "naive") and "value" in value:
|
|
82
|
+
metadata = {"unit": "ns"}
|
|
83
|
+
for key, child in value.items():
|
|
84
|
+
part = key.replace("\\", "\\\\").replace(".", "\\.")
|
|
85
|
+
yield from flatten(
|
|
86
|
+
child, f"{path}.{part}" if path else part, metadata if key == "value" else None
|
|
87
|
+
)
|
|
88
|
+
elif not isinstance(value, list):
|
|
89
|
+
meta = metadata or {}
|
|
90
|
+
if path.casefold().endswith(("durationns", ".ns")):
|
|
91
|
+
meta = meta | {"unit": "ns"}
|
|
92
|
+
kind = {str: "string", int: "number", float: "number", bool: "boolean", type(None): "null"}[
|
|
93
|
+
type(value)
|
|
94
|
+
]
|
|
95
|
+
if (
|
|
96
|
+
isinstance(value, str)
|
|
97
|
+
and re.fullmatch(r"-?\d+", value)
|
|
98
|
+
and (metadata is not None or meta.get("unit") == "ns")
|
|
99
|
+
):
|
|
100
|
+
kind = "number"
|
|
101
|
+
yield (
|
|
102
|
+
path,
|
|
103
|
+
None if value is None else encode(value),
|
|
104
|
+
kind,
|
|
105
|
+
meta.get("unit"),
|
|
106
|
+
meta.get("status"),
|
|
107
|
+
meta.get("methodId"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Store:
|
|
112
|
+
def __init__(self, path, plan_value, *, experiment_id=None):
|
|
113
|
+
self.path = local_path(path)
|
|
114
|
+
self.conn = connect(path)
|
|
115
|
+
try:
|
|
116
|
+
if experiment_id is None:
|
|
117
|
+
if self.conn.execute(
|
|
118
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
119
|
+
).fetchone():
|
|
120
|
+
raise FileExistsError("optimizer: database already contains an experiment; resume explicitly or use a new path")
|
|
121
|
+
self.conn.executescript(DDL)
|
|
122
|
+
self.conn.execute(f"PRAGMA application_id={APPLICATION_ID}")
|
|
123
|
+
self.conn.execute(f"PRAGMA user_version={VERSION}")
|
|
124
|
+
self.id = uuid4().hex
|
|
125
|
+
self.conn.execute(
|
|
126
|
+
"INSERT INTO experiment(id,plan,status) VALUES(?,?,'running')",
|
|
127
|
+
(self.id, plan_value._json),
|
|
128
|
+
)
|
|
129
|
+
self.conn.commit()
|
|
130
|
+
else:
|
|
131
|
+
check_version(self.conn)
|
|
132
|
+
row = self.conn.execute(
|
|
133
|
+
"SELECT * FROM experiment WHERE id=?", (experiment_id,)
|
|
134
|
+
).fetchone()
|
|
135
|
+
if row is None or row["plan"] != plan_value._json:
|
|
136
|
+
raise ValueError("optimizer: experiment identity or plan does not match")
|
|
137
|
+
self.id = experiment_id
|
|
138
|
+
mode = self.conn.execute("PRAGMA journal_mode=WAL").fetchone()[0]
|
|
139
|
+
self.conn.execute("PRAGMA synchronous=FULL")
|
|
140
|
+
if mode != "wal" or self.conn.execute("PRAGMA synchronous").fetchone()[0] != 2:
|
|
141
|
+
raise RuntimeError("optimizer: unable to enable local durability policy")
|
|
142
|
+
with self.conn:
|
|
143
|
+
self.conn.execute(
|
|
144
|
+
"UPDATE attempts SET state='interrupted',ended=? WHERE state IN ('running','persisting')",
|
|
145
|
+
(now(),),
|
|
146
|
+
)
|
|
147
|
+
self.conn.execute(
|
|
148
|
+
"UPDATE trials SET state='interrupted' WHERE state IN ('running','persisting')"
|
|
149
|
+
)
|
|
150
|
+
self.conn.execute("UPDATE experiment SET status='running'")
|
|
151
|
+
except BaseException:
|
|
152
|
+
self.conn.close()
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
def close(self):
|
|
156
|
+
self.conn.close()
|
|
157
|
+
|
|
158
|
+
def start(self, ordinal, request):
|
|
159
|
+
trial_id = f"{self.id}:{ordinal}"
|
|
160
|
+
row = self.conn.execute("SELECT * FROM trials WHERE id=?", (trial_id,)).fetchone()
|
|
161
|
+
if row and row["state"] in ("succeeded", "failed", "skipped_constraint"):
|
|
162
|
+
return None
|
|
163
|
+
attempt = row["attempt"] + 1 if row else 1
|
|
164
|
+
with self.conn:
|
|
165
|
+
self.conn.execute(
|
|
166
|
+
"INSERT INTO trials(id,ordinal,request,state,attempt) VALUES(?,?,?,'running',?) ON CONFLICT(id) DO UPDATE SET state='running',attempt=excluded.attempt",
|
|
167
|
+
(trial_id, ordinal, encode(request), attempt),
|
|
168
|
+
)
|
|
169
|
+
self.conn.execute(
|
|
170
|
+
"INSERT INTO attempts(trial,number,state,started) VALUES(?,?,'running',?)",
|
|
171
|
+
(trial_id, attempt, now()),
|
|
172
|
+
)
|
|
173
|
+
return trial_id, attempt
|
|
174
|
+
|
|
175
|
+
def state(self, trial_id, attempt, state, error=None):
|
|
176
|
+
with self.conn:
|
|
177
|
+
count = self.conn.execute(
|
|
178
|
+
"UPDATE trials SET state=? WHERE id=? AND attempt=? AND state IN ('running','persisting')",
|
|
179
|
+
(state, trial_id, attempt),
|
|
180
|
+
).rowcount
|
|
181
|
+
if count != 1:
|
|
182
|
+
raise ValueError("optimizer: stale trial attempt")
|
|
183
|
+
self.conn.execute(
|
|
184
|
+
"UPDATE attempts SET state=?,ended=?,error=? WHERE trial=? AND number=?",
|
|
185
|
+
(state, None if state == "persisting" else now(), error, trial_id, attempt),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def commit(self, trial_id, attempt, report):
|
|
189
|
+
validate_report(report)
|
|
190
|
+
payload = encode(report)
|
|
191
|
+
checksum = hashlib.sha256(payload.encode()).hexdigest()
|
|
192
|
+
row = self.conn.execute("SELECT * FROM trials WHERE id=?", (trial_id,)).fetchone()
|
|
193
|
+
if row is None or row["attempt"] != attempt:
|
|
194
|
+
raise ValueError("optimizer: stale trial attempt")
|
|
195
|
+
if row["state"] == "succeeded":
|
|
196
|
+
if row["hash"] != checksum:
|
|
197
|
+
raise ValueError("optimizer: committed report content conflicts")
|
|
198
|
+
return
|
|
199
|
+
if row["state"] != "persisting":
|
|
200
|
+
raise ValueError("optimizer: trial has not produced a complete report")
|
|
201
|
+
request = json.loads(row["request"])
|
|
202
|
+
inputs = report["context"]["inputs"]
|
|
203
|
+
if inputs is None or any(inputs.get(k) != v for k, v in request.items()):
|
|
204
|
+
raise ValueError("optimizer: Portfolio parameters do not match the trial")
|
|
205
|
+
execution = json.loads(self.conn.execute("SELECT plan FROM experiment").fetchone()[0])[
|
|
206
|
+
"execution"
|
|
207
|
+
]
|
|
208
|
+
expected = execution.get("backtest")
|
|
209
|
+
if expected is not None:
|
|
210
|
+
actual = report["context"]["backtest"]
|
|
211
|
+
if actual is None or any(actual.get(k) != v for k, v in expected.items()):
|
|
212
|
+
raise ValueError("optimizer: Portfolio configuration does not match the experiment")
|
|
213
|
+
with self.conn:
|
|
214
|
+
self.conn.execute("UPDATE experiment SET sequence=sequence+1")
|
|
215
|
+
sequence = self.conn.execute("SELECT sequence FROM experiment").fetchone()[0]
|
|
216
|
+
self.conn.execute(
|
|
217
|
+
"UPDATE trials SET state='succeeded',sequence=?,report=?,hash=? WHERE id=?",
|
|
218
|
+
(sequence, payload, checksum, trial_id),
|
|
219
|
+
)
|
|
220
|
+
self.conn.executemany(
|
|
221
|
+
"INSERT INTO scalars VALUES(?,?,?,?,?,?,?)",
|
|
222
|
+
((trial_id, *item) for item in flatten(report)),
|
|
223
|
+
)
|
|
224
|
+
self.conn.execute(
|
|
225
|
+
"UPDATE attempts SET state='succeeded',ended=? WHERE trial=? AND number=?",
|
|
226
|
+
(now(), trial_id, attempt),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def finish(self, state):
|
|
230
|
+
with self.conn:
|
|
231
|
+
self.conn.execute("UPDATE experiment SET status=?", (state,))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def load_report(conn, trial_id):
|
|
235
|
+
row = conn.execute("SELECT report,hash,state FROM trials WHERE id=?", (trial_id,)).fetchone()
|
|
236
|
+
if row is None or row["state"] != "succeeded":
|
|
237
|
+
raise ValueError("optimizer: this trial has no committed Portfolio")
|
|
238
|
+
if hashlib.sha256(row["report"].encode()).hexdigest() != row["hash"]:
|
|
239
|
+
raise ValueError("optimizer: Portfolio content verification failed")
|
|
240
|
+
return validate_report(json.loads(row["report"]))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def counts(conn):
|
|
244
|
+
experiment = conn.execute("SELECT * FROM experiment").fetchone()
|
|
245
|
+
states = dict(conn.execute("SELECT state,COUNT(*) FROM trials GROUP BY state").fetchall())
|
|
246
|
+
total = json.loads(experiment["plan"])["total"]
|
|
247
|
+
committed = states.get("succeeded", 0)
|
|
248
|
+
skipped = states.get("skipped_constraint", 0)
|
|
249
|
+
failed = states.get("failed", 0)
|
|
250
|
+
return {
|
|
251
|
+
"experiment_id": experiment["id"],
|
|
252
|
+
"status": experiment["status"],
|
|
253
|
+
"total": total,
|
|
254
|
+
"checked": sum(states.values()),
|
|
255
|
+
"committed": committed,
|
|
256
|
+
"skipped": skipped,
|
|
257
|
+
"failed": failed,
|
|
258
|
+
"running": states.get("running", 0),
|
|
259
|
+
"computed": committed + states.get("persisting", 0),
|
|
260
|
+
"remaining": total - committed - skipped - failed,
|
|
261
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Validate Portfolio against the single generated schema; the standalone kernel does not depend on the protocol package."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from openbacktesting.optimizer._semantics import check_portfolio
|
|
8
|
+
from openbacktesting.optimizer.models import encode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@lru_cache(maxsize=1)
|
|
12
|
+
def _schema():
|
|
13
|
+
return json.loads(Path(__file__).with_name("portfolio-result.schema.json").read_text())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _valid(value, schema):
|
|
17
|
+
if "$ref" in schema:
|
|
18
|
+
return _valid(value, _schema()["$defs"][schema["$ref"].rsplit("/", 1)[1]])
|
|
19
|
+
for key in ("anyOf", "oneOf"):
|
|
20
|
+
if key in schema:
|
|
21
|
+
count = sum(_valid(value, child) for child in schema[key])
|
|
22
|
+
if not (count == 1 if key == "oneOf" else count > 0):
|
|
23
|
+
return False
|
|
24
|
+
if "const" in schema and value != schema["const"]:
|
|
25
|
+
return False
|
|
26
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
27
|
+
return False
|
|
28
|
+
kind = {
|
|
29
|
+
dict: "object",
|
|
30
|
+
list: "array",
|
|
31
|
+
str: "string",
|
|
32
|
+
bool: "boolean",
|
|
33
|
+
int: "integer",
|
|
34
|
+
float: "number",
|
|
35
|
+
type(None): "null",
|
|
36
|
+
}[type(value)]
|
|
37
|
+
types = schema.get("type", [kind])
|
|
38
|
+
types = [types] if isinstance(types, str) else types
|
|
39
|
+
if kind not in types and not (kind == "integer" and "number" in types):
|
|
40
|
+
return False
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
props = schema.get("properties", {})
|
|
43
|
+
extra = schema.get("additionalProperties", {})
|
|
44
|
+
if not set(schema.get("required", ())).issubset(value):
|
|
45
|
+
return False
|
|
46
|
+
for key, item in value.items():
|
|
47
|
+
if key not in props and extra is False:
|
|
48
|
+
return False
|
|
49
|
+
if not _valid(item, props.get(key, extra)):
|
|
50
|
+
return False
|
|
51
|
+
if isinstance(value, list):
|
|
52
|
+
if not schema.get("minItems", 0) <= len(value) <= schema.get("maxItems", len(value)):
|
|
53
|
+
return False
|
|
54
|
+
if not all(_valid(item, schema.get("items", {})) for item in value):
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_report(report):
|
|
60
|
+
encode(report)
|
|
61
|
+
if not _valid(report, _schema()):
|
|
62
|
+
raise ValueError("optimizer: invalid Portfolio structure or version")
|
|
63
|
+
check_portfolio(report)
|
|
64
|
+
return report
|