pickle-strategy-sdk 0.5.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.
- pickle_strategy/__init__.py +161 -0
- pickle_strategy/cli.py +206 -0
- pickle_strategy/codecs.py +1022 -0
- pickle_strategy/examples/__init__.py +7 -0
- pickle_strategy/examples/_ladder/__init__.py +1 -0
- pickle_strategy/examples/_ladder/data.py +332 -0
- pickle_strategy/examples/_ladder/engine.py +459 -0
- pickle_strategy/examples/data/ladder/fred_observations.csv +184 -0
- pickle_strategy/examples/data/ladder/fred_source.json +64 -0
- pickle_strategy/examples/data/ladder/manifest.json +36 -0
- pickle_strategy/examples/data/ladder/reference_prices.csv +1241 -0
- pickle_strategy/examples/data/ladder/security_master.csv +11 -0
- pickle_strategy/examples/ladder_strategy.py +62 -0
- pickle_strategy/examples/null_strategy.py +18 -0
- pickle_strategy/examples/sell_on_friday_strategy.py +53 -0
- pickle_strategy/loading.py +62 -0
- pickle_strategy/models.py +280 -0
- pickle_strategy/service.py +341 -0
- pickle_strategy/signing.py +530 -0
- pickle_strategy_sdk-0.5.0.dist-info/METADATA +219 -0
- pickle_strategy_sdk-0.5.0.dist-info/RECORD +26 -0
- pickle_strategy_sdk-0.5.0.dist-info/WHEEL +5 -0
- pickle_strategy_sdk-0.5.0.dist-info/entry_points.txt +2 -0
- pickle_strategy_sdk-0.5.0.dist-info/licenses/LICENSE +202 -0
- pickle_strategy_sdk-0.5.0.dist-info/licenses/NOTICE +10 -0
- pickle_strategy_sdk-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Public contracts and hosting tools for broker-neutral Pickle strategies."""
|
|
2
|
+
|
|
3
|
+
from .codecs import (
|
|
4
|
+
StrategyContractError,
|
|
5
|
+
StrategyDecodeError,
|
|
6
|
+
broker_account_snapshot_from_dict,
|
|
7
|
+
broker_account_snapshot_to_dict,
|
|
8
|
+
dumps_execution_report,
|
|
9
|
+
dumps_ticket_request,
|
|
10
|
+
dumps_trade_ticket,
|
|
11
|
+
execution_report_from_dict,
|
|
12
|
+
execution_report_to_dict,
|
|
13
|
+
loads_execution_report,
|
|
14
|
+
loads_ticket_request,
|
|
15
|
+
loads_trade_ticket,
|
|
16
|
+
ticket_request_from_dict,
|
|
17
|
+
ticket_request_to_dict,
|
|
18
|
+
trade_ticket_for_request,
|
|
19
|
+
trade_ticket_from_dict,
|
|
20
|
+
trade_ticket_to_dict,
|
|
21
|
+
)
|
|
22
|
+
from .loading import StrategyTargetError, load_report_handler, load_strategy
|
|
23
|
+
from .models import (
|
|
24
|
+
SCHEMA_VERSION,
|
|
25
|
+
SUPPORTED_SCHEMA_VERSIONS,
|
|
26
|
+
BrokerAccountSnapshot,
|
|
27
|
+
CashBalance,
|
|
28
|
+
ComplianceRule,
|
|
29
|
+
Direction,
|
|
30
|
+
ExecutionReport,
|
|
31
|
+
Fee,
|
|
32
|
+
OrderExecution,
|
|
33
|
+
OrderReportStatus,
|
|
34
|
+
OrderType,
|
|
35
|
+
Position,
|
|
36
|
+
PricedPosition,
|
|
37
|
+
QuantityConformance,
|
|
38
|
+
ReportStatus,
|
|
39
|
+
SecurityType,
|
|
40
|
+
Strategy,
|
|
41
|
+
TicketRequest,
|
|
42
|
+
TimeInForce,
|
|
43
|
+
Trade,
|
|
44
|
+
TradeTicket,
|
|
45
|
+
new_ticket_id,
|
|
46
|
+
)
|
|
47
|
+
from .service import (
|
|
48
|
+
StrategyRejection,
|
|
49
|
+
create_app,
|
|
50
|
+
create_report_app,
|
|
51
|
+
is_report_handler,
|
|
52
|
+
is_strategy_service,
|
|
53
|
+
report_handler,
|
|
54
|
+
strategy_service,
|
|
55
|
+
)
|
|
56
|
+
from .signing import (
|
|
57
|
+
DEFAULT_REPLAY_WINDOW,
|
|
58
|
+
DELIVERY_ID_HEADER,
|
|
59
|
+
EVENT_ID_HEADER,
|
|
60
|
+
REQUEST_ID_HEADER,
|
|
61
|
+
SIGNATURE_HEADER,
|
|
62
|
+
SIGNATURE_VERSION,
|
|
63
|
+
TIMESTAMP_HEADER,
|
|
64
|
+
ExpiredSignature,
|
|
65
|
+
HmacVerifier,
|
|
66
|
+
InvalidSignature,
|
|
67
|
+
MalformedSignature,
|
|
68
|
+
MismatchedEventId,
|
|
69
|
+
MissingSignature,
|
|
70
|
+
ReplayCacheExhausted,
|
|
71
|
+
ReplayedSignature,
|
|
72
|
+
ReportVerifier,
|
|
73
|
+
SignatureError,
|
|
74
|
+
VerifiedReport,
|
|
75
|
+
VerifiedRequest,
|
|
76
|
+
format_timestamp,
|
|
77
|
+
report_signature_headers,
|
|
78
|
+
report_signing_payload,
|
|
79
|
+
sign,
|
|
80
|
+
sign_report,
|
|
81
|
+
signature_headers,
|
|
82
|
+
signing_payload,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
"broker_account_snapshot_from_dict",
|
|
87
|
+
"broker_account_snapshot_to_dict",
|
|
88
|
+
"BrokerAccountSnapshot",
|
|
89
|
+
"CashBalance",
|
|
90
|
+
"ComplianceRule",
|
|
91
|
+
"create_app",
|
|
92
|
+
"create_report_app",
|
|
93
|
+
"DEFAULT_REPLAY_WINDOW",
|
|
94
|
+
"DELIVERY_ID_HEADER",
|
|
95
|
+
"Direction",
|
|
96
|
+
"dumps_execution_report",
|
|
97
|
+
"dumps_ticket_request",
|
|
98
|
+
"dumps_trade_ticket",
|
|
99
|
+
"execution_report_from_dict",
|
|
100
|
+
"execution_report_to_dict",
|
|
101
|
+
"EVENT_ID_HEADER",
|
|
102
|
+
"ExecutionReport",
|
|
103
|
+
"ExpiredSignature",
|
|
104
|
+
"Fee",
|
|
105
|
+
"format_timestamp",
|
|
106
|
+
"HmacVerifier",
|
|
107
|
+
"InvalidSignature",
|
|
108
|
+
"is_report_handler",
|
|
109
|
+
"is_strategy_service",
|
|
110
|
+
"load_report_handler",
|
|
111
|
+
"load_strategy",
|
|
112
|
+
"loads_execution_report",
|
|
113
|
+
"loads_ticket_request",
|
|
114
|
+
"loads_trade_ticket",
|
|
115
|
+
"MalformedSignature",
|
|
116
|
+
"MismatchedEventId",
|
|
117
|
+
"MissingSignature",
|
|
118
|
+
"new_ticket_id",
|
|
119
|
+
"OrderExecution",
|
|
120
|
+
"OrderReportStatus",
|
|
121
|
+
"OrderType",
|
|
122
|
+
"Position",
|
|
123
|
+
"PricedPosition",
|
|
124
|
+
"QuantityConformance",
|
|
125
|
+
"ReplayCacheExhausted",
|
|
126
|
+
"ReplayedSignature",
|
|
127
|
+
"report_handler",
|
|
128
|
+
"report_signature_headers",
|
|
129
|
+
"report_signing_payload",
|
|
130
|
+
"ReportStatus",
|
|
131
|
+
"ReportVerifier",
|
|
132
|
+
"REQUEST_ID_HEADER",
|
|
133
|
+
"SCHEMA_VERSION",
|
|
134
|
+
"SUPPORTED_SCHEMA_VERSIONS",
|
|
135
|
+
"SecurityType",
|
|
136
|
+
"sign",
|
|
137
|
+
"sign_report",
|
|
138
|
+
"SIGNATURE_HEADER",
|
|
139
|
+
"signature_headers",
|
|
140
|
+
"SIGNATURE_VERSION",
|
|
141
|
+
"SignatureError",
|
|
142
|
+
"signing_payload",
|
|
143
|
+
"Strategy",
|
|
144
|
+
"strategy_service",
|
|
145
|
+
"StrategyContractError",
|
|
146
|
+
"StrategyDecodeError",
|
|
147
|
+
"StrategyRejection",
|
|
148
|
+
"StrategyTargetError",
|
|
149
|
+
"ticket_request_from_dict",
|
|
150
|
+
"ticket_request_to_dict",
|
|
151
|
+
"TicketRequest",
|
|
152
|
+
"TimeInForce",
|
|
153
|
+
"TIMESTAMP_HEADER",
|
|
154
|
+
"Trade",
|
|
155
|
+
"trade_ticket_from_dict",
|
|
156
|
+
"trade_ticket_for_request",
|
|
157
|
+
"trade_ticket_to_dict",
|
|
158
|
+
"TradeTicket",
|
|
159
|
+
"VerifiedReport",
|
|
160
|
+
"VerifiedRequest",
|
|
161
|
+
]
|
pickle_strategy/cli.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Command-line entry point for serving partner strategies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import uvicorn
|
|
13
|
+
|
|
14
|
+
from .loading import StrategyTargetError, load_report_handler, load_strategy
|
|
15
|
+
from .service import ReportFunction, StrategyFunction, create_app
|
|
16
|
+
from .signing import DEFAULT_REPLAY_WINDOW, HmacVerifier, ReportVerifier
|
|
17
|
+
|
|
18
|
+
# `serve` runs from a strategist's project directory, where the strategy module
|
|
19
|
+
# is usually a plain file rather than an installed distribution.
|
|
20
|
+
ServiceTargetError = StrategyTargetError
|
|
21
|
+
|
|
22
|
+
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_target(target: str) -> StrategyFunction:
|
|
26
|
+
"""Load a ``module:function`` target, allowing modules in the working directory."""
|
|
27
|
+
_allow_working_directory()
|
|
28
|
+
return load_strategy(target)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_report_target(target: str) -> ReportFunction:
|
|
32
|
+
"""Load a ``module:function`` report handler the same way."""
|
|
33
|
+
_allow_working_directory()
|
|
34
|
+
return load_report_handler(target)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _allow_working_directory() -> None:
|
|
38
|
+
working_directory = str(Path.cwd())
|
|
39
|
+
if working_directory not in sys.path:
|
|
40
|
+
sys.path.insert(0, working_directory)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
44
|
+
parser = argparse.ArgumentParser(prog="pickle-strategy")
|
|
45
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
46
|
+
|
|
47
|
+
serve = commands.add_parser("serve", help="serve a decorated strategy over JSON")
|
|
48
|
+
serve.add_argument("target", help="decorated strategy as module:function")
|
|
49
|
+
serve.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)")
|
|
50
|
+
serve.add_argument("--port", default=8000, type=_port, help="bind port (default: 8000)")
|
|
51
|
+
# The key is read from the environment rather than accepted as an argument
|
|
52
|
+
# so it never appears in a process listing or a shell history file.
|
|
53
|
+
serve.add_argument(
|
|
54
|
+
"--hmac-secret-env",
|
|
55
|
+
action="append",
|
|
56
|
+
default=None,
|
|
57
|
+
metavar="NAME",
|
|
58
|
+
help=(
|
|
59
|
+
"environment variable holding a shared HMAC key; repeat to accept "
|
|
60
|
+
"several keys during a rotation"
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
serve.add_argument(
|
|
64
|
+
"--replay-window-seconds",
|
|
65
|
+
type=_positive_seconds,
|
|
66
|
+
default=int(DEFAULT_REPLAY_WINDOW.total_seconds()),
|
|
67
|
+
help="how far a signed timestamp may deviate from now (default: 300)",
|
|
68
|
+
)
|
|
69
|
+
serve.add_argument(
|
|
70
|
+
"--allow-unauthenticated",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="serve unsigned requests; permitted only on a loopback address",
|
|
73
|
+
)
|
|
74
|
+
serve.add_argument(
|
|
75
|
+
"--report-handler",
|
|
76
|
+
default=None,
|
|
77
|
+
metavar="MODULE:FUNCTION",
|
|
78
|
+
help="decorated execution report handler to serve at POST /reports",
|
|
79
|
+
)
|
|
80
|
+
serve.add_argument(
|
|
81
|
+
"--report-secret-env",
|
|
82
|
+
action="append",
|
|
83
|
+
default=None,
|
|
84
|
+
metavar="NAME",
|
|
85
|
+
help=(
|
|
86
|
+
"environment variable holding the deployment's callback key, which "
|
|
87
|
+
"verifies reports; repeat to accept several keys during a rotation"
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
return parser
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
94
|
+
parser = build_parser()
|
|
95
|
+
args = parser.parse_args(argv)
|
|
96
|
+
if args.command == "serve":
|
|
97
|
+
try:
|
|
98
|
+
strategy = load_target(args.target)
|
|
99
|
+
handler = (
|
|
100
|
+
None if args.report_handler is None else load_report_target(args.report_handler)
|
|
101
|
+
)
|
|
102
|
+
except StrategyTargetError as exc:
|
|
103
|
+
parser.error(str(exc))
|
|
104
|
+
verifier = _build_verifier(parser, args)
|
|
105
|
+
application = create_app(
|
|
106
|
+
strategy,
|
|
107
|
+
verifier=verifier,
|
|
108
|
+
allow_unauthenticated=verifier is None,
|
|
109
|
+
report_handler=handler,
|
|
110
|
+
report_verifier=_build_report_verifier(parser, args),
|
|
111
|
+
# The flag is loopback-only, and covers both routes from here.
|
|
112
|
+
allow_unauthenticated_reports=handler is not None and args.allow_unauthenticated,
|
|
113
|
+
)
|
|
114
|
+
uvicorn.run(application, host=args.host, port=args.port)
|
|
115
|
+
return 0
|
|
116
|
+
parser.error(f"unknown command: {args.command}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_verifier(
|
|
120
|
+
parser: argparse.ArgumentParser, args: argparse.Namespace
|
|
121
|
+
) -> HmacVerifier | None:
|
|
122
|
+
secret_variables = args.hmac_secret_env or []
|
|
123
|
+
if args.allow_unauthenticated:
|
|
124
|
+
if secret_variables:
|
|
125
|
+
parser.error("--allow-unauthenticated cannot be combined with --hmac-secret-env")
|
|
126
|
+
if args.host not in LOOPBACK_HOSTS:
|
|
127
|
+
parser.error(
|
|
128
|
+
"--allow-unauthenticated is limited to loopback development; "
|
|
129
|
+
f"--host {args.host} would accept unsigned requests from the network"
|
|
130
|
+
)
|
|
131
|
+
return None
|
|
132
|
+
if not secret_variables:
|
|
133
|
+
parser.error(
|
|
134
|
+
"pass --hmac-secret-env NAME to verify Pickle's signature, or "
|
|
135
|
+
"--allow-unauthenticated for loopback development"
|
|
136
|
+
)
|
|
137
|
+
return HmacVerifier(
|
|
138
|
+
_secrets(parser, secret_variables),
|
|
139
|
+
replay_window=timedelta(seconds=args.replay_window_seconds),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _build_report_verifier(
|
|
144
|
+
parser: argparse.ArgumentParser, args: argparse.Namespace
|
|
145
|
+
) -> ReportVerifier | None:
|
|
146
|
+
secret_variables = args.report_secret_env or []
|
|
147
|
+
if args.report_handler is None:
|
|
148
|
+
if secret_variables:
|
|
149
|
+
parser.error("--report-secret-env needs --report-handler")
|
|
150
|
+
return None
|
|
151
|
+
if args.allow_unauthenticated:
|
|
152
|
+
if secret_variables:
|
|
153
|
+
parser.error("--allow-unauthenticated cannot be combined with --report-secret-env")
|
|
154
|
+
return None
|
|
155
|
+
if not secret_variables:
|
|
156
|
+
parser.error(
|
|
157
|
+
"pass --report-secret-env NAME with the deployment's callback key to "
|
|
158
|
+
"verify the reports Pickle sends"
|
|
159
|
+
)
|
|
160
|
+
callback_keys = _secrets(parser, secret_variables)
|
|
161
|
+
# Reports are signed with the callback key, which ADR 0016 keeps apart
|
|
162
|
+
# from the key that signs strategy requests. Compared by value, so two
|
|
163
|
+
# variables holding one key are caught too; neither value is printed.
|
|
164
|
+
if set(callback_keys) & set(_secrets(parser, args.hmac_secret_env or [])):
|
|
165
|
+
parser.error(
|
|
166
|
+
"--report-secret-env and --hmac-secret-env hold the same key; reports "
|
|
167
|
+
"are signed with the deployment's callback key, not its request key"
|
|
168
|
+
)
|
|
169
|
+
return ReportVerifier(
|
|
170
|
+
callback_keys,
|
|
171
|
+
replay_window=timedelta(seconds=args.replay_window_seconds),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _secrets(parser: argparse.ArgumentParser, variables: Sequence[str]) -> list[str]:
|
|
176
|
+
secrets = []
|
|
177
|
+
for name in variables:
|
|
178
|
+
value = os.environ.get(name)
|
|
179
|
+
if not value:
|
|
180
|
+
parser.error(f"environment variable {name} is unset or empty")
|
|
181
|
+
secrets.append(value)
|
|
182
|
+
return secrets
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _port(value: str) -> int:
|
|
186
|
+
try:
|
|
187
|
+
port = int(value)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
raise argparse.ArgumentTypeError("port must be an integer") from exc
|
|
190
|
+
if not 1 <= port <= 65535:
|
|
191
|
+
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
|
|
192
|
+
return port
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _positive_seconds(value: str) -> int:
|
|
196
|
+
try:
|
|
197
|
+
seconds = int(value)
|
|
198
|
+
except ValueError as exc:
|
|
199
|
+
raise argparse.ArgumentTypeError("seconds must be an integer") from exc
|
|
200
|
+
if seconds <= 0:
|
|
201
|
+
raise argparse.ArgumentTypeError("seconds must be positive")
|
|
202
|
+
return seconds
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
raise SystemExit(main())
|