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.
- optiondesk/__init__.py +12 -0
- optiondesk/artifacts.py +197 -0
- optiondesk/cli/__init__.py +7 -0
- optiondesk/cli/__main__.py +122 -0
- optiondesk/cli/backtest.py +223 -0
- optiondesk/cli/chain.py +870 -0
- optiondesk/cli/compare.py +243 -0
- optiondesk/cli/expiries.py +129 -0
- optiondesk/cli/exposure.py +178 -0
- optiondesk/cli/forward.py +348 -0
- optiondesk/cli/greeks.py +209 -0
- optiondesk/cli/keys.py +221 -0
- optiondesk/cli/plots.py +180 -0
- optiondesk/cli/simulate.py +327 -0
- optiondesk/cli/strategy.py +633 -0
- optiondesk/config.py +109 -0
- optiondesk/contracts/__init__.py +35 -0
- optiondesk/contracts/backtest.schema.json +141 -0
- optiondesk/contracts/chain_snapshot.schema.json +307 -0
- optiondesk/contracts/exposure.schema.json +226 -0
- optiondesk/contracts/forward_ledger.schema.json +186 -0
- optiondesk/contracts/greeks_ladder.schema.json +276 -0
- optiondesk/contracts/simulation.schema.json +198 -0
- optiondesk/contracts/strategy_comparison.schema.json +298 -0
- optiondesk/contracts/strategy_plan.schema.json +481 -0
- optiondesk/contracts/validate.py +135 -0
- optiondesk/dashboard/__init__.py +0 -0
- optiondesk/dashboard/app.py +222 -0
- optiondesk/dashboard/charts.py +1117 -0
- optiondesk/dashboard/data.py +397 -0
- optiondesk/dashboard/flow.py +175 -0
- optiondesk/dashboard/maths.py +448 -0
- optiondesk/dashboard/page.py +1566 -0
- optiondesk/dashboard/static/echarts.min.js +45 -0
- optiondesk/dashboard/style.py +157 -0
- optiondesk/engine_bridge.py +131 -0
- optiondesk/mcp/__init__.py +0 -0
- optiondesk/mcp/server.py +847 -0
- optiondesk/plotting.py +373 -0
- optiondesk/providers/__init__.py +161 -0
- optiondesk/providers/alphavantage.py +187 -0
- optiondesk/providers/base.py +197 -0
- optiondesk/providers/yahoo.py +400 -0
- optiondesk-0.3.0.dist-info/METADATA +24 -0
- optiondesk-0.3.0.dist-info/RECORD +48 -0
- optiondesk-0.3.0.dist-info/WHEEL +4 -0
- optiondesk-0.3.0.dist-info/entry_points.txt +3 -0
- 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"
|
optiondesk/artifacts.py
ADDED
|
@@ -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
|