optiondesk 0.3.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 (48) hide show
  1. optiondesk/__init__.py +12 -0
  2. optiondesk/artifacts.py +197 -0
  3. optiondesk/cli/__init__.py +7 -0
  4. optiondesk/cli/__main__.py +122 -0
  5. optiondesk/cli/backtest.py +223 -0
  6. optiondesk/cli/chain.py +870 -0
  7. optiondesk/cli/compare.py +243 -0
  8. optiondesk/cli/expiries.py +129 -0
  9. optiondesk/cli/exposure.py +178 -0
  10. optiondesk/cli/forward.py +348 -0
  11. optiondesk/cli/greeks.py +209 -0
  12. optiondesk/cli/keys.py +221 -0
  13. optiondesk/cli/plots.py +180 -0
  14. optiondesk/cli/simulate.py +327 -0
  15. optiondesk/cli/strategy.py +633 -0
  16. optiondesk/config.py +109 -0
  17. optiondesk/contracts/__init__.py +35 -0
  18. optiondesk/contracts/backtest.schema.json +141 -0
  19. optiondesk/contracts/chain_snapshot.schema.json +307 -0
  20. optiondesk/contracts/exposure.schema.json +226 -0
  21. optiondesk/contracts/forward_ledger.schema.json +186 -0
  22. optiondesk/contracts/greeks_ladder.schema.json +276 -0
  23. optiondesk/contracts/simulation.schema.json +198 -0
  24. optiondesk/contracts/strategy_comparison.schema.json +298 -0
  25. optiondesk/contracts/strategy_plan.schema.json +481 -0
  26. optiondesk/contracts/validate.py +135 -0
  27. optiondesk/dashboard/__init__.py +0 -0
  28. optiondesk/dashboard/app.py +222 -0
  29. optiondesk/dashboard/charts.py +1117 -0
  30. optiondesk/dashboard/data.py +397 -0
  31. optiondesk/dashboard/flow.py +175 -0
  32. optiondesk/dashboard/maths.py +448 -0
  33. optiondesk/dashboard/page.py +1566 -0
  34. optiondesk/dashboard/static/echarts.min.js +45 -0
  35. optiondesk/dashboard/style.py +157 -0
  36. optiondesk/engine_bridge.py +131 -0
  37. optiondesk/mcp/__init__.py +0 -0
  38. optiondesk/mcp/server.py +847 -0
  39. optiondesk/plotting.py +373 -0
  40. optiondesk/providers/__init__.py +161 -0
  41. optiondesk/providers/alphavantage.py +187 -0
  42. optiondesk/providers/base.py +197 -0
  43. optiondesk/providers/yahoo.py +400 -0
  44. optiondesk-0.3.0.dist-info/METADATA +24 -0
  45. optiondesk-0.3.0.dist-info/RECORD +48 -0
  46. optiondesk-0.3.0.dist-info/WHEEL +4 -0
  47. optiondesk-0.3.0.dist-info/entry_points.txt +3 -0
  48. optiondesk-0.3.0.dist-info/licenses/LICENSE +76 -0
optiondesk/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Option desk shell.
2
+
3
+ Licensed under the PolyForm Noncommercial License 1.0.0; see LICENSE.
4
+ Data retrieval, schema-validated artifacts, agent runtime
5
+ adapters and a local dashboard. The analytics live in the engine
6
+ component, under the same licence; see LICENSES.md at the repository root.
7
+
8
+ Nothing in this package is investment advice. See DISCLAIMER.md.
9
+ """
10
+
11
+ __version__ = "0.3.0"
12
+ LICENSE = "PolyForm-Noncommercial-1.0.0"
@@ -0,0 +1,197 @@
1
+ """Artifact envelope and atomic writing.
2
+
3
+ Every artifact this project writes carries the same meta block, because a
4
+ number without provenance cannot be audited later. The block records what
5
+ produced it, when, from which provider, and whether the result was degraded.
6
+
7
+ Atomic write: serialise to a temporary file in the destination directory,
8
+ then os.replace. A reader can then never catch a half-written file, and a
9
+ failed run cannot leave a truncated artifact that looks complete.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ from optiondesk import __version__
18
+ from optiondesk.config import artifact_dir
19
+
20
+ # Carried inside every artifact. Short by necessity, and it points at the
21
+ # full terms rather than trying to restate them.
22
+ DISCLAIMER = (
23
+ "Research output, not investment advice, and not a recommendation or "
24
+ "solicitation. Option premiums here are theoretical model values, not "
25
+ "quotes and not achievable fills. Market data is third-party, may be "
26
+ "delayed or wrong, and its redistribution is governed by the provider's "
27
+ "terms. No warranty. See DISCLAIMER.md."
28
+ )
29
+
30
+ LICENSE_NOTE = (
31
+ "Produced by optiondesk under the PolyForm Noncommercial License 1.0.0. "
32
+ "Noncommercial use is permitted; commercial use requires a separate "
33
+ "written agreement with the copyright holder. See LICENSE and "
34
+ "LICENSES.md."
35
+ )
36
+
37
+
38
+ def utc_now():
39
+ """Current UTC time as an ISO-8601 string ending in Z.
40
+
41
+ Every artifact carries one, so timestamps written on different machines
42
+ still order correctly.
43
+ """
44
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
45
+
46
+
47
+ def envelope(schema, tool, provider_used, degraded=False,
48
+ degraded_reason=None, inputs=None, engine_version=None,
49
+ notes=None):
50
+ """Build the meta block shared by every artifact.
51
+
52
+ Two distinct fields, because collapsing them destroys the signal.
53
+
54
+ degraded means the run produced lower quality output than this pipeline
55
+ normally can: a provider fell back, a rate could not be fetched, the
56
+ analytics engine was absent. A consumer should hesitate before quoting
57
+ the numbers, and degraded_reason says why.
58
+
59
+ notes records ordinary observations that are not defects. A chain where
60
+ some far wing contracts carry no quotes and therefore no implied
61
+ volatility is a normal chain, not a broken run. Marking that degraded
62
+ would make almost every artifact degraded and the flag would stop
63
+ meaning anything.
64
+ """
65
+ return {
66
+ "schema": schema,
67
+ "generated_utc": utc_now(),
68
+ "tool": tool,
69
+ "shell_version": __version__,
70
+ "engine_version": engine_version,
71
+ "provider_used": provider_used,
72
+ "degraded": bool(degraded),
73
+ "degraded_reason": degraded_reason,
74
+ "notes": list(notes or []),
75
+ "inputs": inputs or {},
76
+ "disclaimer": DISCLAIMER,
77
+ "license": LICENSE_NOTE,
78
+ }
79
+
80
+
81
+ ARCHIVE_DIRNAME = "archive"
82
+
83
+
84
+ def _archive_stamp(path):
85
+ """The time the outgoing artifact was generated, for its archived name.
86
+
87
+ Its own `meta.generated_utc` is preferred over the file's mtime,
88
+ because the mtime is when the bytes landed and the envelope is when the
89
+ measurement was taken. They differ whenever a file is copied.
90
+ """
91
+ try:
92
+ with open(path, encoding="utf-8") as handle:
93
+ stamp = json.load(handle).get("meta", {}).get("generated_utc")
94
+ except (ValueError, OSError, AttributeError):
95
+ stamp = None
96
+ if not isinstance(stamp, str) or not stamp:
97
+ try:
98
+ stamp = datetime.fromtimestamp(
99
+ path.stat().st_mtime, timezone.utc).isoformat()
100
+ except OSError:
101
+ stamp = utc_now()
102
+ # Colons are legal on this filesystem and a nuisance on others.
103
+ return stamp.replace(":", "").replace("-", "").replace("+0000", "Z")
104
+
105
+
106
+ def archive_existing(path):
107
+ """Move an artifact about to be replaced into the archive.
108
+
109
+ WHY THE LIVE NAME DOES NOT CHANGE. Filenames are keyed by underlying
110
+ and expiry, so re-pulling the same chain replaced the previous one
111
+ outright and no measurement quoted from it could be produced again.
112
+ Timestamping the live file would fix that and break every consumer
113
+ that resolves the newest artifact by name: the dashboard, `expiries`,
114
+ the plan reuse in `compare`, and the graph's stage check. So the
115
+ timestamp goes on the outgoing copy instead, and the live name is
116
+ exactly what it was.
117
+
118
+ Identical content is not archived. Re-running a command that produces
119
+ the same bytes is not a new measurement, and archiving it would fill
120
+ the directory with copies of one answer.
121
+
122
+ Returns the archived path, or None if there was nothing to archive.
123
+ Never raises: losing the archive is worse than nothing, but losing the
124
+ write because the archive failed would be worse still.
125
+ """
126
+ if os.environ.get("OPTIONDESK_ARCHIVE", "1") == "0":
127
+ return None
128
+ try:
129
+ if not path.exists():
130
+ return None
131
+ archive_dir = path.parent / ARCHIVE_DIRNAME / utc_now()[:10]
132
+ archive_dir.mkdir(parents=True, exist_ok=True)
133
+ target = archive_dir / "{}_{}{}".format(
134
+ path.stem, _archive_stamp(path), path.suffix)
135
+ if target.exists():
136
+ return None
137
+ os.replace(path, target)
138
+ return target
139
+ except OSError:
140
+ return None
141
+
142
+
143
+ def write_json(payload, filename, directory=None):
144
+ """Write one artifact atomically. Returns the path written.
145
+
146
+ Raises ValueError if the payload contains a NaN or an infinity, which
147
+ is deliberate: those are not representable in strict JSON, and an
148
+ artifact that only Python can read is not an interchange format.
149
+
150
+ An artifact this replaces is moved into `archive/<date>/` first, under
151
+ a name carrying the time it was generated. Set OPTIONDESK_ARCHIVE=0 to
152
+ turn that off.
153
+ """
154
+ target_dir = Path(directory) if directory else artifact_dir()
155
+ target_dir.mkdir(parents=True, exist_ok=True)
156
+ path = target_dir / filename
157
+ tmp = path.with_suffix(path.suffix + ".tmp")
158
+ with open(tmp, "w", encoding="utf-8") as handle:
159
+ # allow_nan is off deliberately. The default writes NaN and
160
+ # Infinity as bare tokens, which Python reads back and a strict
161
+ # RFC 8259 parser rejects, so a non-finite number would leave this
162
+ # process looking healthy and break somewhere else. Failing here
163
+ # names the field instead.
164
+ json.dump(payload, handle, indent=1, sort_keys=False,
165
+ allow_nan=False)
166
+ handle.write("\n")
167
+ # After the temporary file is written, so a serialisation failure
168
+ # cannot archive the old artifact and then leave nothing in its place.
169
+ if path.exists() and path.read_bytes() != tmp.read_bytes():
170
+ archive_existing(path)
171
+ os.replace(tmp, path)
172
+ return path
173
+
174
+
175
+ def read_json(path):
176
+ """Read one artifact from disk.
177
+
178
+ Raises rather than returning an empty default, because a caller that
179
+ silently reads nothing goes on to report on an empty desk as though it were
180
+ a quiet one.
181
+ """
182
+ with open(path, "r", encoding="utf-8") as handle:
183
+ return json.load(handle)
184
+
185
+
186
+ def latest(pattern, directory=None):
187
+ """Most recently modified artifact matching a glob, or None.
188
+
189
+ Used by the dashboard and by tools that chain off the previous step
190
+ without being told the exact filename.
191
+ """
192
+ target_dir = Path(directory) if directory else artifact_dir()
193
+ if not target_dir.exists():
194
+ return None
195
+ matches = sorted(target_dir.glob(pattern),
196
+ key=lambda p: p.stat().st_mtime, reverse=True)
197
+ return matches[0] if matches else None
@@ -0,0 +1,7 @@
1
+ """Command line entry points.
2
+
3
+ Each command does one thing, writes one schema-validated artifact, and
4
+ prints a short JSON summary to stdout. The summary is what an agent reads;
5
+ the artifact is what the dashboard and later steps read. Both carry the same
6
+ provenance and the same disclaimer.
7
+ """
@@ -0,0 +1,122 @@
1
+ """optiondesk command dispatcher."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from optiondesk import __version__, engine_bridge
8
+ from optiondesk.artifacts import DISCLAIMER, artifact_dir
9
+ from optiondesk.cli import backtest as backtest_cmd
10
+ from optiondesk.cli import chain as chain_cmd
11
+ from optiondesk.cli import forward as forward_cmd
12
+ from optiondesk.cli import greeks as greeks_cmd
13
+ from optiondesk.cli import keys as keys_cmd
14
+ from optiondesk.cli import plots as plots_cmd
15
+ from optiondesk.cli import compare as compare_cmd
16
+ from optiondesk.cli import exposure as exposure_cmd
17
+ from optiondesk.cli import expiries as expiries_cmd
18
+ from optiondesk.cli import simulate as simulate_cmd
19
+ from optiondesk.cli import strategy as strategy_cmd
20
+ from optiondesk.config import configured_providers
21
+ from optiondesk.providers import describe_all
22
+
23
+
24
+ def _doctor(_args):
25
+ """Report what is installed, what is reachable, and what is missing."""
26
+ return {
27
+ "shell_version": __version__,
28
+ "artifact_dir": str(artifact_dir()),
29
+ "engine": engine_bridge.status(),
30
+ "providers": describe_all(),
31
+ "credentials": configured_providers(),
32
+ "disclaimer": DISCLAIMER,
33
+ }
34
+
35
+
36
+ def build_parser():
37
+ """Assemble the top level parser by asking each command module to register
38
+ its own arguments.
39
+ """
40
+ parser = argparse.ArgumentParser(
41
+ prog="optiondesk",
42
+ description="Option desk: chain retrieval, analytics, artifacts. "
43
+ "Research software, not investment advice. See "
44
+ "DISCLAIMER.md.")
45
+ parser.add_argument("--version", action="version",
46
+ version="optiondesk {}".format(__version__))
47
+ sub = parser.add_subparsers(dest="command", required=True)
48
+
49
+ chain_cmd.add_arguments(sub.add_parser(
50
+ "chain", help="retrieve an option chain snapshot"))
51
+ greeks_cmd.add_arguments(sub.add_parser(
52
+ "greeks", help="full Greek ladder from a snapshot"))
53
+ strategy_cmd.add_arguments(sub.add_parser(
54
+ "strategy", help="build a multi-leg strategy from a snapshot"))
55
+ exposure_cmd.add_arguments(sub.add_parser(
56
+ "exposure", help="dealer gamma, walls, max pain and smile geometry"))
57
+ plots_cmd.add_arguments(sub.add_parser(
58
+ "plots", help="write chat-ready market and Greek PNG plots"))
59
+ expiries_cmd.add_arguments(sub.add_parser(
60
+ "expiries", help="list available expiries and what is on disk"))
61
+ compare_cmd.add_arguments(sub.add_parser(
62
+ "compare", help="every structure side by side, ranked"))
63
+ simulate_cmd.add_arguments(sub.add_parser(
64
+ "simulate", help="GARCH-t posterior, forward paths and tail risk"))
65
+ backtest_cmd.add_arguments(sub.add_parser(
66
+ "backtest", help="run a structure across real history, modelled "
67
+ "premiums"))
68
+ forward_cmd.add_arguments(sub.add_parser(
69
+ "forward", help="paper ledger: open, mark, close, status"))
70
+ keys_cmd.add_arguments(sub.add_parser(
71
+ "keys", help="see, set or locate provider credentials"))
72
+ sub.add_parser("doctor",
73
+ help="report engine, provider and credential status")
74
+
75
+ dash = sub.add_parser("dashboard", help="serve the local dashboard")
76
+ dash.add_argument("--host", default="127.0.0.1")
77
+ dash.add_argument("--port", type=int, default=8787)
78
+ dash.add_argument("--out-dir", default=None,
79
+ help="artifact directory to read")
80
+ return parser
81
+
82
+
83
+ def _dashboard(args):
84
+ from optiondesk.dashboard.app import serve
85
+ serve(args.host, args.port, args.out_dir)
86
+ return {"served": "http://{}:{}".format(args.host, args.port)}
87
+
88
+
89
+ HANDLERS = {
90
+ "chain": chain_cmd.run,
91
+ "greeks": greeks_cmd.run,
92
+ "strategy": strategy_cmd.run,
93
+ "exposure": exposure_cmd.run,
94
+ "plots": plots_cmd.run,
95
+ "expiries": expiries_cmd.run,
96
+ "compare": compare_cmd.run,
97
+ "simulate": simulate_cmd.run,
98
+ "backtest": backtest_cmd.run,
99
+ "forward": forward_cmd.run,
100
+ "keys": keys_cmd.run,
101
+ "doctor": _doctor,
102
+ "dashboard": _dashboard,
103
+ }
104
+
105
+
106
+ def main(argv=None):
107
+ """Dispatch argv to the named subcommand and return its exit code."""
108
+ args = build_parser().parse_args(argv)
109
+ try:
110
+ result = HANDLERS[args.command](args)
111
+ except Exception as exc:
112
+ # One error shape for every failure, so an agent parsing stdout does
113
+ # not have to distinguish a traceback from a result.
114
+ print(json.dumps({"error": type(exc).__name__, "message": str(exc)},
115
+ indent=1))
116
+ return 1
117
+ print(json.dumps(result, indent=1, default=str))
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ sys.exit(main())
@@ -0,0 +1,223 @@
1
+ """optiondesk backtest: a structure entered repeatedly across real history.
2
+
3
+ Real underlying closes, modelled premiums. Read the honesty field in the
4
+ output before quoting any number from it, and read it again before showing
5
+ anyone else.
6
+
7
+ Alongside the performance statistics, this reports a permutation test and a
8
+ bootstrap interval, because a mean return without a statement of how easily
9
+ it could have arisen by chance is not a result. It also runs the same rule
10
+ against the underlying itself, so a structure that merely tracks the market
11
+ is visible as such rather than being credited with the market's move.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+
17
+ from optiondesk import engine_bridge
18
+ from optiondesk.artifacts import envelope, write_json
19
+ from optiondesk.contracts import BACKTEST, SCHEMA_FILES, validate
20
+ from optiondesk.providers import CAP_UNDERLYING_HISTORY, resolve
21
+
22
+
23
+ def add_arguments(parser):
24
+ """Register the window and cadence: holding days, entry interval, lookback,
25
+ history period, rate, dividend yield, size, provider and output directory.
26
+ """
27
+ parser.add_argument("symbol", help="underlying ticker")
28
+ parser.add_argument("strategy", help="structure to test, for example "
29
+ "iron_condor")
30
+ parser.add_argument("--holding-days", type=int, default=30,
31
+ dest="holding_days",
32
+ help="trading days from entry to expiry; the model "
33
+ "chain is priced over the same span")
34
+ parser.add_argument("--entry-every", type=int, default=5,
35
+ dest="entry_every",
36
+ help="trading days between entries")
37
+ parser.add_argument("--lookback", type=int, default=60,
38
+ help="window for the trailing volatility estimate")
39
+ parser.add_argument("--period", default="5y",
40
+ help="how much history to test, for example 5y")
41
+ parser.add_argument("--rate", type=float, default=0.04,
42
+ help="risk-free rate per 1.00 used for pricing")
43
+ parser.add_argument("--dividend-yield", type=float, default=0.0,
44
+ dest="dividend_yield")
45
+ parser.add_argument("--size", type=float, default=1.0,
46
+ help="contracts per leg")
47
+ parser.add_argument("--provider", default=None)
48
+ parser.add_argument("--out-dir", default=None)
49
+ return parser
50
+
51
+
52
+ def _benchmark(prices, dates, holding_days, entry_every, lookback,
53
+ engine_backtest):
54
+ """The same schedule, holding the underlying instead of the structure.
55
+
56
+ Without this, a structure that is simply long the market gets credit
57
+ for the market's drift and looks like a strategy.
58
+ """
59
+ returns = []
60
+ index = lookback
61
+ while index + holding_days < len(prices):
62
+ entry, exit_price = prices[index], prices[index + holding_days]
63
+ if entry > 0:
64
+ returns.append(exit_price / entry - 1.0)
65
+ index += entry_every
66
+ stats = engine_backtest.performance_stats(returns, holding_days)
67
+ if stats:
68
+ stats.pop("equity_curve", None)
69
+ return {"description": ("buy and hold the underlying over the same "
70
+ "windows, for comparison"),
71
+ "statistics": stats}
72
+
73
+
74
+ def run(args):
75
+ """Enter one structure repeatedly across real history and write a backtest
76
+ artifact with its honesty statement attached.
77
+ """
78
+ engine = engine_bridge.require()
79
+ strategies = engine_bridge.strategies()
80
+ engine_backtest = engine_bridge.backtest()
81
+
82
+ provider, choice = resolve(CAP_UNDERLYING_HISTORY, args.provider)
83
+ history = provider.underlying_history(args.symbol, period=args.period)
84
+
85
+ result = engine_backtest.run_backtest(
86
+ strategies, engine["bs_price"], history["closes"], history["dates"],
87
+ args.strategy, holding_days=args.holding_days,
88
+ entry_every=args.entry_every, lookback=args.lookback,
89
+ rate=args.rate, dividend_yield=args.dividend_yield, size=args.size)
90
+
91
+ returns = result["returns"]
92
+ statistics = engine_backtest.performance_stats(returns,
93
+ args.holding_days)
94
+ # Consecutive entries share days whenever the hold is longer than the
95
+ # spacing between entries, and every statistic below is computed on
96
+ # returns that therefore are not independent. The block is how many
97
+ # consecutive trades overlap: thirty day holds entered every five
98
+ # trading days give six, which is exactly where the measured
99
+ # autocorrelation collapses.
100
+ overlap_block = max(1, -(-int(args.holding_days) // max(1, int(
101
+ args.entry_every))))
102
+ significance = engine_backtest.permutation_p_value(
103
+ returns, block=overlap_block)
104
+ interval = engine_backtest.bootstrap_mean_interval(
105
+ returns, block=overlap_block)
106
+ benchmark = _benchmark(history["closes"], history["dates"],
107
+ args.holding_days, args.entry_every,
108
+ args.lookback, engine_backtest)
109
+
110
+ notes = []
111
+ # `returns` holds return on capital at risk, and a trade only appears in
112
+ # it when that capital is definable. A structure with an unbounded loss,
113
+ # such as a ratio spread, produces trades with no denominator, so the
114
+ # two counts diverge. Reporting the shorter one as the trade count said
115
+ # "only 0 trades" about a run that entered 52, which names the wrong
116
+ # problem and sends the reader looking for missing data.
117
+ entered = len(result["trades"])
118
+ measurable = len(returns)
119
+ if entered and not measurable:
120
+ notes.append(
121
+ "{} trades were entered and none has a definable capital at "
122
+ "risk, because this structure's maximum loss is unbounded. "
123
+ "Return on risk has no denominator, so no statistic here can be "
124
+ "computed. Every entry and exit is still in the trade list, and "
125
+ "profit in cash terms is still there to read.".format(entered))
126
+ elif measurable < entered:
127
+ notes.append(
128
+ "{} of {} trades have no definable capital at risk and are "
129
+ "excluded from every statistic below".format(
130
+ entered - measurable, entered))
131
+ if measurable and measurable < 30:
132
+ notes.append("only {} trades: too few for any statistic here to "
133
+ "carry weight".format(measurable))
134
+ if result["skipped"]:
135
+ notes.append("{} entries skipped, mostly where no viable structure "
136
+ "existed at that volatility".format(
137
+ len(result["skipped"])))
138
+ if significance and significance.get("block", 1) > 1:
139
+ notes.append(
140
+ "Entries overlap: each trade shares days with {} of its "
141
+ "neighbours, so the significance test flips signs a block at a "
142
+ "time and the interval resamples blocks. Treating these as {} "
143
+ "independent trades would understate the standard error by "
144
+ "roughly a factor of two.".format(
145
+ significance["block"] - 1, len(returns)))
146
+ if significance and significance["p_value"] > 0.05:
147
+ notes.append("the permutation test cannot distinguish this result "
148
+ "from a rule with no edge")
149
+
150
+ equity_curve = (statistics or {}).pop("equity_curve", None)
151
+
152
+ payload = {
153
+ "meta": envelope(
154
+ schema=BACKTEST,
155
+ tool="optiondesk backtest",
156
+ provider_used=provider.name,
157
+ degraded=bool(choice["degraded"]),
158
+ degraded_reason="; ".join(choice["skipped"]) or None,
159
+ inputs={"symbol": args.symbol, "strategy": args.strategy,
160
+ "holding_days": args.holding_days,
161
+ "entry_every": args.entry_every,
162
+ "period": args.period},
163
+ engine_version=engine["version"],
164
+ notes=notes,
165
+ ),
166
+ "underlying": args.symbol.upper(),
167
+ "strategy": args.strategy,
168
+ "premium_source": result["premium_source"],
169
+ "honesty": result["honesty"],
170
+ "settings": dict(result["settings"], equity_curve=equity_curve),
171
+ "statistics": statistics,
172
+ "significance": significance,
173
+ "interval": interval,
174
+ "benchmark": benchmark,
175
+ "trades": result["trades"],
176
+ "skipped": result["skipped"],
177
+ }
178
+ validate(payload, SCHEMA_FILES[BACKTEST])
179
+ filename = "backtest_{}_{}_{}d.json".format(
180
+ args.symbol.upper(), args.strategy, args.holding_days)
181
+ out = write_json(payload, filename, args.out_dir)
182
+
183
+ return {
184
+ "artifact": str(out),
185
+ "underlying": args.symbol.upper(),
186
+ "strategy": args.strategy,
187
+ "degraded": bool(choice["degraded"]),
188
+ "degraded_reason": "; ".join(choice["skipped"]) or None,
189
+ "trades": (statistics or {}).get("trades", 0),
190
+ "trades_entered": len(result["trades"]),
191
+ "win_rate": (statistics or {}).get("win_rate"),
192
+ "mean_return_on_risk": (statistics or {}).get("mean_return"),
193
+ "total_return_on_risk": (statistics or {}).get(
194
+ "total_return_on_risk"),
195
+ "max_drawdown_in_risk_units": (statistics or {}).get(
196
+ "max_drawdown_in_risk_units"),
197
+ "sharpe_per_trade": (statistics or {}).get("sharpe_per_trade"),
198
+ "p_value": (significance or {}).get("p_value"),
199
+ # How many consecutive trades share days. Published because a
200
+ # p-value computed at block 1 on overlapping windows is a different
201
+ # and more flattering number than one computed at the real block,
202
+ # and a reader comparing two runs has to be able to see which.
203
+ "overlap_block": (significance or {}).get("block"),
204
+ "mean_interval": ([interval["lower"], interval["upper"]]
205
+ if interval else None),
206
+ "interval_excludes_zero": (interval or {}).get("excludes_zero"),
207
+ "benchmark_mean": ((benchmark["statistics"] or {}).get("mean_return")
208
+ if benchmark["statistics"] else None),
209
+ "skipped_entries": len(result["skipped"]),
210
+ "notes": notes,
211
+ "honesty": result["honesty"],
212
+ }
213
+
214
+
215
+ def main(argv=None):
216
+ """Parse argv for this command alone and run it, so the command works when
217
+ invoked directly as well as through the dispatcher.
218
+ """
219
+ parser = add_arguments(argparse.ArgumentParser(
220
+ prog="optiondesk backtest", description=__doc__.splitlines()[0]))
221
+ args = parser.parse_args(argv)
222
+ print(json.dumps(run(args), indent=1, default=str))
223
+ return 0