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 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]()