qjtrader 0.1.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.
- qjtrader/__init__.py +60 -0
- qjtrader/_cli.py +201 -0
- qjtrader/_stream.py +128 -0
- qjtrader/_version.py +1 -0
- qjtrader/auth.py +63 -0
- qjtrader/autotools.py +80 -0
- qjtrader/backtest.py +210 -0
- qjtrader/client.py +164 -0
- qjtrader/errors.py +18 -0
- qjtrader/market_data.py +31 -0
- qjtrader/orders.py +89 -0
- qjtrader/py.typed +0 -0
- qjtrader/rest.py +80 -0
- qjtrader/run.py +227 -0
- qjtrader/runner.py +76 -0
- qjtrader/strategy.py +102 -0
- qjtrader-0.1.0.dist-info/METADATA +184 -0
- qjtrader-0.1.0.dist-info/RECORD +21 -0
- qjtrader-0.1.0.dist-info/WHEEL +4 -0
- qjtrader-0.1.0.dist-info/entry_points.txt +2 -0
- qjtrader-0.1.0.dist-info/licenses/LICENSE +201 -0
qjtrader/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""qjtrader — the official Python client for the QJ Trader AI Trading APIs.
|
|
2
|
+
|
|
3
|
+
Stream real-time Canadian market data and send orders over one authenticated
|
|
4
|
+
connection. Get a free sandbox key (no approval) at https://console.qjtrader.ai
|
|
5
|
+
and set ``QJ_CLIENT_ID`` / ``QJ_CLIENT_SECRET``:
|
|
6
|
+
|
|
7
|
+
import qjtrader
|
|
8
|
+
client = qjtrader.Client()
|
|
9
|
+
with client.orders() as oe:
|
|
10
|
+
print(oe.order_and_wait(sym="MX:CRAU26", side="buy", qty=1,
|
|
11
|
+
price=97.00, account="SIM", tif="ioc"))
|
|
12
|
+
|
|
13
|
+
Docs: https://docs.qjtrader.ai/docs/ai
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .autotools import REGISTRY as AUTO_TOOLS, Scalper, make_auto_tool
|
|
19
|
+
from .backtest import BacktestReport, run_backtest, synthetic_bars
|
|
20
|
+
from .client import (
|
|
21
|
+
Client,
|
|
22
|
+
MARKET_DATA_SCOPE,
|
|
23
|
+
ORDERS_SCOPE,
|
|
24
|
+
)
|
|
25
|
+
from .errors import AuthError, ConnectionClosed, QJError, TokenError
|
|
26
|
+
from .market_data import MarketData
|
|
27
|
+
from .orders import Orders
|
|
28
|
+
from .rest import RestClient
|
|
29
|
+
from .run import LiveContext, Supervisor, load_strategy, run_strategy_live
|
|
30
|
+
from .runner import RunRegistry
|
|
31
|
+
from .strategy import Context, PositionBook, Strategy
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Client",
|
|
35
|
+
"MarketData",
|
|
36
|
+
"Orders",
|
|
37
|
+
"RestClient",
|
|
38
|
+
"QJError",
|
|
39
|
+
"TokenError",
|
|
40
|
+
"AuthError",
|
|
41
|
+
"ConnectionClosed",
|
|
42
|
+
"MARKET_DATA_SCOPE",
|
|
43
|
+
"ORDERS_SCOPE",
|
|
44
|
+
# strategy contract + run venues (§10)
|
|
45
|
+
"Strategy",
|
|
46
|
+
"Context",
|
|
47
|
+
"PositionBook",
|
|
48
|
+
"run_backtest",
|
|
49
|
+
"synthetic_bars",
|
|
50
|
+
"BacktestReport",
|
|
51
|
+
"Supervisor",
|
|
52
|
+
"LiveContext",
|
|
53
|
+
"load_strategy",
|
|
54
|
+
"run_strategy_live",
|
|
55
|
+
"RunRegistry",
|
|
56
|
+
"Scalper",
|
|
57
|
+
"make_auto_tool",
|
|
58
|
+
"AUTO_TOOLS",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
qjtrader/_cli.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Command-line interface: ``qjtrader <command>``.
|
|
2
|
+
|
|
3
|
+
Credentials come from QJ_CLIENT_ID / QJ_CLIENT_SECRET (or --client-id/--secret).
|
|
4
|
+
|
|
5
|
+
export QJ_CLIENT_ID=... QJ_CLIENT_SECRET=...
|
|
6
|
+
qjtrader subscribe CA:RY MX:CRAU26 --watch 30
|
|
7
|
+
qjtrader order --sym MX:CRAU26 --side buy --qty 1 --price 97.00 --account SIM --tif ioc
|
|
8
|
+
qjtrader status
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .client import Client
|
|
18
|
+
from .errors import ConnectionClosed, QJError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _client(a: argparse.Namespace) -> Client:
|
|
22
|
+
return Client(
|
|
23
|
+
client_id=a.client_id, client_secret=a.client_secret,
|
|
24
|
+
token_url=a.token_url, data_host=a.data_host, orders_host=a.orders_host,
|
|
25
|
+
ca_file=a.ca, verify=not a.insecure,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _common(p: argparse.ArgumentParser) -> None:
|
|
30
|
+
p.add_argument("--client-id")
|
|
31
|
+
p.add_argument("--client-secret")
|
|
32
|
+
p.add_argument("--token-url")
|
|
33
|
+
p.add_argument("--data-host")
|
|
34
|
+
p.add_argument("--orders-host")
|
|
35
|
+
p.add_argument("--ca", help="CA/cert file to pin (pilot order endpoint)")
|
|
36
|
+
p.add_argument("--insecure", action="store_true", help="skip TLS verification (dev only)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
p = argparse.ArgumentParser(prog="qjtrader", description=__doc__,
|
|
41
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
42
|
+
p.add_argument("--version", action="version", version=f"qjtrader {__version__}")
|
|
43
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
44
|
+
|
|
45
|
+
ps = sub.add_parser("subscribe", help="stream market data for symbols")
|
|
46
|
+
ps.add_argument("symbols", nargs="+", help="e.g. CA:RY CA:RY.PT MX:CRAU26")
|
|
47
|
+
ps.add_argument("--depth", type=int)
|
|
48
|
+
ps.add_argument("--watch", type=float, default=60.0)
|
|
49
|
+
_common(ps)
|
|
50
|
+
|
|
51
|
+
po = sub.add_parser("order", help="submit a limit order")
|
|
52
|
+
po.add_argument("--sym", required=True)
|
|
53
|
+
po.add_argument("--side", required=True, choices=["buy", "sell"])
|
|
54
|
+
po.add_argument("--qty", type=int, required=True)
|
|
55
|
+
po.add_argument("--price", type=float, required=True)
|
|
56
|
+
po.add_argument("--tif", default="day", choices=["day", "ioc", "fok"])
|
|
57
|
+
po.add_argument("--account", default="")
|
|
58
|
+
po.add_argument("--iceberg", type=int, default=0)
|
|
59
|
+
po.add_argument("--cid")
|
|
60
|
+
po.add_argument("--watch", type=float, default=15.0)
|
|
61
|
+
_common(po)
|
|
62
|
+
|
|
63
|
+
pc = sub.add_parser("cancel", help="cancel an order by its cid")
|
|
64
|
+
pc.add_argument("--orig", required=True)
|
|
65
|
+
pc.add_argument("--watch", type=float, default=10.0)
|
|
66
|
+
_common(pc)
|
|
67
|
+
|
|
68
|
+
pst = sub.add_parser("status", help="show open orders + session state")
|
|
69
|
+
_common(pst)
|
|
70
|
+
pca = sub.add_parser("cancel-all", help="cancel every open order")
|
|
71
|
+
_common(pca)
|
|
72
|
+
|
|
73
|
+
pb = sub.add_parser("backtest", help="run a strategy file over synthetic/offline bars")
|
|
74
|
+
pb.add_argument("strategy", help="path to a .py file with a Strategy subclass")
|
|
75
|
+
pb.add_argument("--symbol", required=True, help="e.g. MX:CRAU26")
|
|
76
|
+
pb.add_argument("--bars", type=int, default=390, help="number of synthetic bars")
|
|
77
|
+
pb.add_argument("--interval", type=int, default=60, help="bar seconds")
|
|
78
|
+
pb.add_argument("--seed", type=int, help="synthetic seed (reproducible day)")
|
|
79
|
+
pb.add_argument("--param", action="append", default=[],
|
|
80
|
+
help="strategy param key=value (repeatable)")
|
|
81
|
+
|
|
82
|
+
pr = sub.add_parser("run", help="run a strategy against a live/paper credential")
|
|
83
|
+
pr.add_argument("strategy", help="path to a .py file with a Strategy subclass")
|
|
84
|
+
pr.add_argument("--symbols", nargs="+", required=True)
|
|
85
|
+
pr.add_argument("--tag", default="strat", help="strategy tag on every order")
|
|
86
|
+
pr.add_argument("--account", default="")
|
|
87
|
+
pr.add_argument("--param", action="append", default=[])
|
|
88
|
+
_common(pr)
|
|
89
|
+
|
|
90
|
+
a = p.parse_args(argv)
|
|
91
|
+
if a.cmd == "backtest":
|
|
92
|
+
return _cmd_backtest(a)
|
|
93
|
+
if a.cmd == "run":
|
|
94
|
+
return _cmd_run(a)
|
|
95
|
+
try:
|
|
96
|
+
client = _client(a)
|
|
97
|
+
if a.cmd == "subscribe":
|
|
98
|
+
with client.market_data() as md:
|
|
99
|
+
print(f"# authenticated as {md.user}; subscribing {a.symbols}", file=sys.stderr)
|
|
100
|
+
md.subscribe(a.symbols, depth=a.depth)
|
|
101
|
+
try:
|
|
102
|
+
for msg in md.messages(timeout=a.watch):
|
|
103
|
+
print(json.dumps(msg))
|
|
104
|
+
except ConnectionClosed:
|
|
105
|
+
print("# server closed the connection", file=sys.stderr)
|
|
106
|
+
elif a.cmd == "order":
|
|
107
|
+
with client.orders() as oe:
|
|
108
|
+
print(f"# authenticated as {oe.user}", file=sys.stderr)
|
|
109
|
+
cid = oe.order(sym=a.sym, side=a.side, qty=a.qty, price=a.price,
|
|
110
|
+
tif=a.tif, account=a.account, iceberg=a.iceberg, cid=a.cid)
|
|
111
|
+
print(f"# submitted cid={cid}", file=sys.stderr)
|
|
112
|
+
_drain(oe, a.watch)
|
|
113
|
+
elif a.cmd == "cancel":
|
|
114
|
+
with client.orders() as oe:
|
|
115
|
+
oe.cancel(a.orig)
|
|
116
|
+
_drain(oe, a.watch)
|
|
117
|
+
elif a.cmd == "cancel-all":
|
|
118
|
+
with client.orders() as oe:
|
|
119
|
+
oe.cancel_all()
|
|
120
|
+
_drain(oe, 5.0)
|
|
121
|
+
elif a.cmd == "status":
|
|
122
|
+
with client.orders() as oe:
|
|
123
|
+
print(json.dumps(oe.status(), indent=2))
|
|
124
|
+
except QJError as e:
|
|
125
|
+
print(f"error: {e}", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_params(items: list[str]) -> dict:
|
|
131
|
+
out: dict = {}
|
|
132
|
+
for it in items:
|
|
133
|
+
k, _, v = it.partition("=")
|
|
134
|
+
try:
|
|
135
|
+
out[k] = json.loads(v) # numbers/bools/JSON pass through
|
|
136
|
+
except ValueError:
|
|
137
|
+
out[k] = v # plain string
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _load_strategy_or_autotool(spec: str):
|
|
142
|
+
"""A .py file path, or the name of a built-in auto-tool (e.g. 'scalper')."""
|
|
143
|
+
from .autotools import REGISTRY, make_auto_tool
|
|
144
|
+
from .run import load_strategy
|
|
145
|
+
if spec in REGISTRY:
|
|
146
|
+
return make_auto_tool(spec)
|
|
147
|
+
return load_strategy(spec)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cmd_backtest(a: argparse.Namespace) -> int:
|
|
151
|
+
from .backtest import run_backtest, synthetic_bars
|
|
152
|
+
try:
|
|
153
|
+
strat = _load_strategy_or_autotool(a.strategy)
|
|
154
|
+
except QJError as e:
|
|
155
|
+
print(f"error: {e}", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
bars = synthetic_bars(a.symbol, a.bars, interval_s=a.interval, seed=a.seed)
|
|
158
|
+
params = _parse_params(a.param)
|
|
159
|
+
params.setdefault("symbol", a.symbol) # so auto-tools scope to the tested symbol
|
|
160
|
+
report = run_backtest(strat, bars, params=params)
|
|
161
|
+
report.pop("equity_curve", None) # keep stdout compact; PnL summary remains
|
|
162
|
+
print(json.dumps(report, indent=2, default=str))
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _cmd_run(a: argparse.Namespace) -> int:
|
|
167
|
+
import signal
|
|
168
|
+
import threading
|
|
169
|
+
|
|
170
|
+
from .run import run_strategy_live
|
|
171
|
+
stop = threading.Event()
|
|
172
|
+
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
|
173
|
+
try:
|
|
174
|
+
client = _client(a)
|
|
175
|
+
print(f"# running {a.strategy} on {a.symbols} (tag={a.tag}); Ctrl-C to stop",
|
|
176
|
+
file=sys.stderr)
|
|
177
|
+
run_strategy_live(client, a.strategy, symbols=a.symbols,
|
|
178
|
+
params=_parse_params(a.param), account=a.account,
|
|
179
|
+
strategy_tag=a.tag, stop=stop)
|
|
180
|
+
except QJError as e:
|
|
181
|
+
print(f"error: {e}", file=sys.stderr)
|
|
182
|
+
return 1
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _drain(oe: object, watch: float) -> None:
|
|
187
|
+
try:
|
|
188
|
+
for msg in oe.updates(timeout=watch): # type: ignore[attr-defined]
|
|
189
|
+
print(json.dumps(msg))
|
|
190
|
+
if msg.get("type") == "order_update" and msg.get("status") in (
|
|
191
|
+
"filled", "canceled", "rejected", "replaced",
|
|
192
|
+
):
|
|
193
|
+
return
|
|
194
|
+
if msg.get("type") == "exec" and msg.get("status") == "filled":
|
|
195
|
+
return
|
|
196
|
+
except ConnectionClosed:
|
|
197
|
+
print("# connection closed", file=sys.stderr)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
raise SystemExit(main())
|
qjtrader/_stream.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Shared NDJSON-over-TLS stream: connect, authenticate, read messages.
|
|
2
|
+
|
|
3
|
+
Both APIs speak the same wire contract — one JSON object per line, UTF-8,
|
|
4
|
+
newline-terminated, over TLS; the first line the client sends is
|
|
5
|
+
``{"action": "auth", "token": <jwt>}`` and the server replies
|
|
6
|
+
``{"type": "auth_success", ...}``. This class carries that for both.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import select
|
|
12
|
+
import socket
|
|
13
|
+
import ssl
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Iterator
|
|
16
|
+
|
|
17
|
+
from .auth import TokenSource
|
|
18
|
+
from .errors import AuthError, ConnectionClosed
|
|
19
|
+
|
|
20
|
+
_CLOSED = object()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Stream:
|
|
24
|
+
"""A single TLS connection carrying newline-delimited JSON."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, token_source: TokenSource, host: str, port: int, *,
|
|
27
|
+
ca_file: str | None = None, verify: bool = True,
|
|
28
|
+
timeout: float = 15.0) -> None:
|
|
29
|
+
self._ts = token_source
|
|
30
|
+
self._host = host
|
|
31
|
+
self._port = port
|
|
32
|
+
self._ca_file = ca_file
|
|
33
|
+
self._verify = verify
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
self._sock: ssl.SSLSocket | None = None
|
|
36
|
+
self._buf = bytearray()
|
|
37
|
+
#: The authenticated principal (client_id), set after connect().
|
|
38
|
+
self.user: str | None = None
|
|
39
|
+
|
|
40
|
+
# -- lifecycle ---------------------------------------------------------
|
|
41
|
+
def connect(self) -> "_Stream":
|
|
42
|
+
"""Open the TLS connection and complete the auth handshake."""
|
|
43
|
+
if self._verify:
|
|
44
|
+
ctx = ssl.create_default_context(cafile=self._ca_file)
|
|
45
|
+
else: # pilot/dev only — never in production
|
|
46
|
+
ctx = ssl.create_default_context()
|
|
47
|
+
ctx.check_hostname = False
|
|
48
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
49
|
+
raw = socket.create_connection((self._host, self._port), timeout=self._timeout)
|
|
50
|
+
self._sock = ctx.wrap_socket(raw, server_hostname=self._host)
|
|
51
|
+
try:
|
|
52
|
+
self._authenticate()
|
|
53
|
+
except BaseException:
|
|
54
|
+
self.close()
|
|
55
|
+
raise
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def _authenticate(self) -> dict[str, Any]:
|
|
59
|
+
self.send({"action": "auth", "token": self._ts.token()})
|
|
60
|
+
line = self._read_line(time.monotonic() + self._timeout)
|
|
61
|
+
if line is None or line is _CLOSED:
|
|
62
|
+
raise AuthError("no auth response (timeout or connection closed)")
|
|
63
|
+
ack = json.loads(line)
|
|
64
|
+
if ack.get("type") != "auth_success":
|
|
65
|
+
raise AuthError(f"auth rejected: {ack.get('message') or ack}")
|
|
66
|
+
self.user = ack.get("user")
|
|
67
|
+
return ack
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
if self._sock is not None:
|
|
71
|
+
try:
|
|
72
|
+
self._sock.close()
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
self._sock = None
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> "_Stream":
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *exc: object) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
# -- io ----------------------------------------------------------------
|
|
84
|
+
def send(self, obj: dict[str, Any]) -> None:
|
|
85
|
+
"""Send one JSON message."""
|
|
86
|
+
assert self._sock is not None, "not connected"
|
|
87
|
+
self._sock.sendall(json.dumps(obj).encode() + b"\n")
|
|
88
|
+
|
|
89
|
+
def _read_line(self, deadline: float) -> bytes | None | object:
|
|
90
|
+
"""One line, or None on this-slice timeout, or _CLOSED on close."""
|
|
91
|
+
while True:
|
|
92
|
+
nl = self._buf.find(b"\n")
|
|
93
|
+
if nl >= 0:
|
|
94
|
+
line = bytes(self._buf[:nl])
|
|
95
|
+
del self._buf[: nl + 1]
|
|
96
|
+
return line
|
|
97
|
+
remaining = deadline - time.monotonic()
|
|
98
|
+
if remaining <= 0:
|
|
99
|
+
return None
|
|
100
|
+
r, _, _ = select.select([self._sock], [], [], min(remaining, 1.0))
|
|
101
|
+
if not r:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
chunk = self._sock.recv(1 << 16) # type: ignore[union-attr]
|
|
105
|
+
except ssl.SSLWantReadError:
|
|
106
|
+
continue
|
|
107
|
+
if not chunk:
|
|
108
|
+
return _CLOSED
|
|
109
|
+
self._buf.extend(chunk)
|
|
110
|
+
|
|
111
|
+
def messages(self, timeout: float | None = None,
|
|
112
|
+
include_heartbeats: bool = False) -> Iterator[dict[str, Any]]:
|
|
113
|
+
"""Yield decoded messages until `timeout` seconds elapse (or forever if
|
|
114
|
+
None). Raises :class:`ConnectionClosed` if the server closes the stream."""
|
|
115
|
+
deadline = time.monotonic() + timeout if timeout is not None else float("inf")
|
|
116
|
+
while time.monotonic() < deadline:
|
|
117
|
+
line = self._read_line(deadline)
|
|
118
|
+
if line is None:
|
|
119
|
+
continue
|
|
120
|
+
if line is _CLOSED:
|
|
121
|
+
raise ConnectionClosed("server closed the connection")
|
|
122
|
+
try:
|
|
123
|
+
msg = json.loads(line)
|
|
124
|
+
except ValueError:
|
|
125
|
+
continue
|
|
126
|
+
if msg.get("type") == "heartbeat" and not include_heartbeats:
|
|
127
|
+
continue
|
|
128
|
+
yield msg
|
qjtrader/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
qjtrader/auth.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""OAuth2 client-credentials token source.
|
|
2
|
+
|
|
3
|
+
A credential mints its own short-lived JWT (the console never hands out tokens).
|
|
4
|
+
`TokenSource` fetches one on demand and caches it until shortly before expiry, so
|
|
5
|
+
callers can just ask for `.token()` every time.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
from .errors import TokenError
|
|
17
|
+
|
|
18
|
+
_REFRESH_SKEW = 60.0 # refresh this many seconds before expiry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TokenSource:
|
|
22
|
+
"""Mints and caches an access token for one (credential, scope)."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, token_url: str, client_id: str, client_secret: str,
|
|
25
|
+
scope: str) -> None:
|
|
26
|
+
self._url = token_url
|
|
27
|
+
self._cid = client_id
|
|
28
|
+
self._secret = client_secret
|
|
29
|
+
self._scope = scope
|
|
30
|
+
self._token: str | None = None
|
|
31
|
+
self._expires_at = 0.0
|
|
32
|
+
|
|
33
|
+
def token(self) -> str:
|
|
34
|
+
"""A valid access token, refreshed automatically before it expires."""
|
|
35
|
+
if self._token and time.time() < self._expires_at - _REFRESH_SKEW:
|
|
36
|
+
return self._token
|
|
37
|
+
|
|
38
|
+
body = urllib.parse.urlencode(
|
|
39
|
+
{"grant_type": "client_credentials", "scope": self._scope}
|
|
40
|
+
).encode()
|
|
41
|
+
basic = base64.b64encode(f"{self._cid}:{self._secret}".encode()).decode()
|
|
42
|
+
req = urllib.request.Request(
|
|
43
|
+
self._url,
|
|
44
|
+
data=body,
|
|
45
|
+
headers={
|
|
46
|
+
"Authorization": f"Basic {basic}",
|
|
47
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
52
|
+
data = json.loads(resp.read())
|
|
53
|
+
except urllib.error.HTTPError as e:
|
|
54
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
55
|
+
raise TokenError(f"token request failed (HTTP {e.code}): {detail}") from None
|
|
56
|
+
except urllib.error.URLError as e:
|
|
57
|
+
raise TokenError(f"token request failed: {e.reason}") from None
|
|
58
|
+
except (ValueError, KeyError):
|
|
59
|
+
raise TokenError("token endpoint returned an unexpected response") from None
|
|
60
|
+
|
|
61
|
+
self._token = data["access_token"]
|
|
62
|
+
self._expires_at = time.time() + float(data.get("expires_in", 3600))
|
|
63
|
+
return self._token
|
qjtrader/autotools.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Auto-tools — hardened, parameterized strategies (plan §10.2).
|
|
2
|
+
|
|
3
|
+
The WinForms AutoTool family (Legger, Scalper, RangerF) is 15 years of evidence
|
|
4
|
+
that most traders **parameterize a hardened automation through a form** and only
|
|
5
|
+
sometimes write code. Auto-tools are that: shipped `Strategy` subclasses configured
|
|
6
|
+
by params, no user code — which also makes them trivially hostable (no arbitrary
|
|
7
|
+
code to sandbox), quietly solving the hosted-runner problem for most users.
|
|
8
|
+
|
|
9
|
+
The LLM bridges the spectrum (§10.2): its output can be a *parameter set* for one
|
|
10
|
+
of these ("this idea is a scalper with edge=0.05, target=0.10") or new code when
|
|
11
|
+
no template fits. Every auto-tool runs unmodified in backtest / paper / live.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .strategy import Strategy
|
|
16
|
+
|
|
17
|
+
# name -> Strategy subclass (used by the CLI/MCP to run an auto-tool by name)
|
|
18
|
+
REGISTRY: dict[str, type] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _register(name: str):
|
|
22
|
+
def deco(cls):
|
|
23
|
+
REGISTRY[name] = cls
|
|
24
|
+
return cls
|
|
25
|
+
return deco
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@_register("scalper")
|
|
29
|
+
class Scalper(Strategy):
|
|
30
|
+
"""Rest a bid below the market; on a fill, rest an exit above cost for a small
|
|
31
|
+
profit; scale in up to ``max_position``. Params (all optional):
|
|
32
|
+
|
|
33
|
+
symbol restrict to one symbol (else trades every symbol it sees)
|
|
34
|
+
size lots per entry (default 1)
|
|
35
|
+
edge rest the entry this far below the close (default 0.05)
|
|
36
|
+
target take profit this far above the fill/close (default 0.05)
|
|
37
|
+
max_position max net long lots (default 3)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def on_start(self, ctx):
|
|
41
|
+
self.symbol = ctx.param("symbol")
|
|
42
|
+
self.size = int(ctx.param("size", 1))
|
|
43
|
+
self.edge = float(ctx.param("edge", 0.05))
|
|
44
|
+
self.target = float(ctx.param("target", 0.05))
|
|
45
|
+
self.max_position = int(ctx.param("max_position", 3))
|
|
46
|
+
self.entry_cid: str | None = None
|
|
47
|
+
self.exit_cid: str | None = None
|
|
48
|
+
|
|
49
|
+
def _mine(self, sym: str) -> bool:
|
|
50
|
+
return self.symbol is None or sym == self.symbol
|
|
51
|
+
|
|
52
|
+
def on_bar(self, ctx, bar):
|
|
53
|
+
sym = bar["symbol"]
|
|
54
|
+
close = bar.get("close")
|
|
55
|
+
if not self._mine(sym) or close is None:
|
|
56
|
+
return
|
|
57
|
+
pos = ctx.position(sym)
|
|
58
|
+
# entry: rest a bid below the market while we have room to add
|
|
59
|
+
if pos < self.max_position and not self.entry_cid:
|
|
60
|
+
self.entry_cid = ctx.buy(sym, self.size, round(close - self.edge, 4))
|
|
61
|
+
# exit: while long, keep a fresh take-profit sell for the whole position
|
|
62
|
+
if pos > 0:
|
|
63
|
+
if self.exit_cid:
|
|
64
|
+
ctx.cancel(self.exit_cid)
|
|
65
|
+
self.exit_cid = ctx.sell(sym, pos, round(close + self.target, 4))
|
|
66
|
+
elif self.exit_cid:
|
|
67
|
+
ctx.cancel(self.exit_cid)
|
|
68
|
+
self.exit_cid = None
|
|
69
|
+
|
|
70
|
+
def on_fill(self, ctx, fill):
|
|
71
|
+
cid = fill.get("cid")
|
|
72
|
+
if cid == self.entry_cid:
|
|
73
|
+
self.entry_cid = None # free the entry slot to scale in again
|
|
74
|
+
if cid == self.exit_cid:
|
|
75
|
+
self.exit_cid = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def make_auto_tool(name: str) -> Strategy:
|
|
79
|
+
"""Instantiate a registered auto-tool by name (raises KeyError if unknown)."""
|
|
80
|
+
return REGISTRY[name]()
|