edumatcher 0.1.5__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.
- edumatcher/__init__.py +0 -0
- edumatcher/ai_trader/__init__.py +5 -0
- edumatcher/ai_trader/main.py +475 -0
- edumatcher/ai_trader/personality.py +91 -0
- edumatcher/ai_trader/swarm.py +202 -0
- edumatcher/audit/__init__.py +0 -0
- edumatcher/audit/main.py +126 -0
- edumatcher/board/__init__.py +0 -0
- edumatcher/board/main.py +261 -0
- edumatcher/clearing/__init__.py +0 -0
- edumatcher/clearing/main.py +262 -0
- edumatcher/commands/__init__.py +16 -0
- edumatcher/commands/cli.py +261 -0
- edumatcher/commands/client.py +450 -0
- edumatcher/commands/console.py +757 -0
- edumatcher/config.py +37 -0
- edumatcher/engine/__init__.py +0 -0
- edumatcher/engine/auction.py +185 -0
- edumatcher/engine/circuit_breaker.py +119 -0
- edumatcher/engine/collar.py +139 -0
- edumatcher/engine/config_loader.py +896 -0
- edumatcher/engine/drop_copy.py +194 -0
- edumatcher/engine/main.py +2898 -0
- edumatcher/engine/order_book.py +1134 -0
- edumatcher/engine/persistence.py +113 -0
- edumatcher/gateway/__init__.py +0 -0
- edumatcher/gateway/main.py +1221 -0
- edumatcher/messaging/__init__.py +0 -0
- edumatcher/messaging/bus.py +67 -0
- edumatcher/models/__init__.py +0 -0
- edumatcher/models/clock.py +35 -0
- edumatcher/models/combo.py +172 -0
- edumatcher/models/instrument.py +25 -0
- edumatcher/models/message.py +726 -0
- edumatcher/models/mm_obligation.py +47 -0
- edumatcher/models/order.py +240 -0
- edumatcher/models/participant.py +26 -0
- edumatcher/models/price.py +98 -0
- edumatcher/models/quote.py +110 -0
- edumatcher/models/session.py +51 -0
- edumatcher/models/trade.py +111 -0
- edumatcher/orders/__init__.py +0 -0
- edumatcher/orders/main.py +200 -0
- edumatcher/py.typed +0 -0
- edumatcher/scheduler/__init__.py +0 -0
- edumatcher/scheduler/main.py +194 -0
- edumatcher/stats/__init__.py +0 -0
- edumatcher/stats/main.py +473 -0
- edumatcher/ticker/__init__.py +0 -0
- edumatcher/ticker/main.py +335 -0
- edumatcher/viewer/__init__.py +0 -0
- edumatcher/viewer/main.py +160 -0
- edumatcher-0.1.5.dist-info/METADATA +146 -0
- edumatcher-0.1.5.dist-info/RECORD +56 -0
- edumatcher-0.1.5.dist-info/WHEEL +4 -0
- edumatcher-0.1.5.dist-info/entry_points.txt +16 -0
edumatcher/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Autonomous AI trader process.
|
|
2
|
+
|
|
3
|
+
Usage examples:
|
|
4
|
+
poetry run pm-ai-trader --id AI01 --profile aggressive
|
|
5
|
+
poetry run pm-ai-trader --id AI07 --profile cautious --symbols AAPL,MSFT
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from collections import deque
|
|
12
|
+
import random
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import zmq
|
|
20
|
+
|
|
21
|
+
from edumatcher.ai_trader.personality import available_profiles, get_profile
|
|
22
|
+
from edumatcher.config import ENGINE_PULL_ADDR, ENGINE_PUB_ADDR
|
|
23
|
+
from edumatcher.messaging.bus import make_pusher, make_subscriber
|
|
24
|
+
from edumatcher.models.message import (
|
|
25
|
+
decode,
|
|
26
|
+
make_gateway_connect_msg,
|
|
27
|
+
make_order_new_msg,
|
|
28
|
+
make_symbols_request_msg,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class MarketSnapshot:
|
|
34
|
+
best_bid: float | None = None
|
|
35
|
+
best_ask: float | None = None
|
|
36
|
+
last_price: float | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class BotMetrics:
|
|
41
|
+
submitted: int = 0
|
|
42
|
+
acknowledged: int = 0
|
|
43
|
+
rejected: int = 0
|
|
44
|
+
filled: int = 0
|
|
45
|
+
cancelled: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AITraderBot:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
gateway_id: str,
|
|
52
|
+
profile_name: str,
|
|
53
|
+
symbols: list[str],
|
|
54
|
+
seed: int,
|
|
55
|
+
run_id: str,
|
|
56
|
+
max_position: int,
|
|
57
|
+
max_rejects: int,
|
|
58
|
+
reject_window_sec: float,
|
|
59
|
+
reject_cooldown_sec: float,
|
|
60
|
+
stale_data_sec: float,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.gateway_id = gateway_id.upper()
|
|
63
|
+
self.profile = get_profile(profile_name)
|
|
64
|
+
self._symbols_filter = [sym.upper() for sym in symbols]
|
|
65
|
+
self._rng = random.Random(seed)
|
|
66
|
+
self._run_id = run_id
|
|
67
|
+
|
|
68
|
+
self._running = True
|
|
69
|
+
self._last_submit_ts = 0.0
|
|
70
|
+
self._market: dict[str, MarketSnapshot] = {}
|
|
71
|
+
self._known_symbols: list[str] = []
|
|
72
|
+
self._positions: dict[str, int] = {}
|
|
73
|
+
self._last_market_update: dict[str, float] = {}
|
|
74
|
+
self._reject_times: deque[float] = deque()
|
|
75
|
+
self._risk_pause_until = 0.0
|
|
76
|
+
self.metrics = BotMetrics()
|
|
77
|
+
|
|
78
|
+
self._max_position = max_position
|
|
79
|
+
self._max_rejects = max_rejects
|
|
80
|
+
self._reject_window_sec = reject_window_sec
|
|
81
|
+
self._reject_cooldown_sec = reject_cooldown_sec
|
|
82
|
+
self._stale_data_sec = stale_data_sec
|
|
83
|
+
|
|
84
|
+
self.push_sock = make_pusher(ENGINE_PULL_ADDR)
|
|
85
|
+
self.sub_sock = make_subscriber(
|
|
86
|
+
ENGINE_PUB_ADDR,
|
|
87
|
+
f"system.gateway_auth.{self.gateway_id}",
|
|
88
|
+
f"system.symbols.{self.gateway_id}",
|
|
89
|
+
f"order.ack.{self.gateway_id}",
|
|
90
|
+
f"order.fill.{self.gateway_id}",
|
|
91
|
+
f"order.cancelled.{self.gateway_id}",
|
|
92
|
+
f"order.expired.{self.gateway_id}",
|
|
93
|
+
"book.",
|
|
94
|
+
"trade.executed",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _log(self, text: str) -> None:
|
|
98
|
+
now = time.strftime("%H:%M:%S")
|
|
99
|
+
print(f"[AI:{self.gateway_id} {now}] {text}")
|
|
100
|
+
|
|
101
|
+
def _authenticate(self, timeout_sec: float = 3.0) -> bool:
|
|
102
|
+
time.sleep(0.1)
|
|
103
|
+
self.push_sock.send_multipart(make_gateway_connect_msg(self.gateway_id))
|
|
104
|
+
|
|
105
|
+
poller = zmq.Poller()
|
|
106
|
+
poller.register(self.sub_sock, zmq.POLLIN)
|
|
107
|
+
deadline = time.monotonic() + timeout_sec
|
|
108
|
+
|
|
109
|
+
while time.monotonic() < deadline:
|
|
110
|
+
remaining_ms = max(1, int((deadline - time.monotonic()) * 1000))
|
|
111
|
+
socks = dict(poller.poll(timeout=min(remaining_ms, 200)))
|
|
112
|
+
if self.sub_sock not in socks:
|
|
113
|
+
continue
|
|
114
|
+
topic, payload = decode(self.sub_sock.recv_multipart())
|
|
115
|
+
if topic == f"system.gateway_auth.{self.gateway_id}":
|
|
116
|
+
accepted = bool(payload.get("accepted", False))
|
|
117
|
+
if accepted:
|
|
118
|
+
self._log("authenticated")
|
|
119
|
+
else:
|
|
120
|
+
reason = str(payload.get("reason", "unknown reason"))
|
|
121
|
+
self._log(f"authentication rejected: {reason}")
|
|
122
|
+
return accepted
|
|
123
|
+
|
|
124
|
+
self._log("authentication timed out")
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def _request_symbols(self) -> None:
|
|
128
|
+
self.push_sock.send_multipart(make_symbols_request_msg(self.gateway_id))
|
|
129
|
+
|
|
130
|
+
def _on_book(self, symbol: str, payload: dict[str, Any]) -> None:
|
|
131
|
+
bids = payload.get("bids", [])
|
|
132
|
+
asks = payload.get("asks", [])
|
|
133
|
+
if symbol not in self._market:
|
|
134
|
+
self._market[symbol] = MarketSnapshot()
|
|
135
|
+
|
|
136
|
+
snap = self._market[symbol]
|
|
137
|
+
snap.last_price = _as_float(payload.get("last_price"))
|
|
138
|
+
snap.best_bid = _as_float(bids[0].get("price")) if bids else None
|
|
139
|
+
snap.best_ask = _as_float(asks[0].get("price")) if asks else None
|
|
140
|
+
self._last_market_update[symbol] = time.monotonic()
|
|
141
|
+
|
|
142
|
+
def _on_trade(self, payload: dict[str, Any]) -> None:
|
|
143
|
+
symbol = str(payload.get("symbol", "")).upper()
|
|
144
|
+
if not symbol:
|
|
145
|
+
return
|
|
146
|
+
if symbol not in self._market:
|
|
147
|
+
self._market[symbol] = MarketSnapshot()
|
|
148
|
+
self._market[symbol].last_price = _as_float(payload.get("price"))
|
|
149
|
+
self._last_market_update[symbol] = time.monotonic()
|
|
150
|
+
|
|
151
|
+
def _trim_reject_times(self, now: float) -> None:
|
|
152
|
+
threshold = now - self._reject_window_sec
|
|
153
|
+
while self._reject_times and self._reject_times[0] < threshold:
|
|
154
|
+
self._reject_times.popleft()
|
|
155
|
+
|
|
156
|
+
def _on_reject(self) -> None:
|
|
157
|
+
now = time.monotonic()
|
|
158
|
+
self._reject_times.append(now)
|
|
159
|
+
self._trim_reject_times(now)
|
|
160
|
+
if len(self._reject_times) >= self._max_rejects:
|
|
161
|
+
self._risk_pause_until = now + self._reject_cooldown_sec
|
|
162
|
+
self._reject_times.clear()
|
|
163
|
+
self._log(
|
|
164
|
+
"reject breaker tripped; pausing submissions "
|
|
165
|
+
f"for {self._reject_cooldown_sec:.1f}s"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _update_position_from_fill(self, payload: dict[str, Any]) -> None:
|
|
169
|
+
symbol = str(payload.get("symbol", "")).upper()
|
|
170
|
+
side = str(payload.get("side", "")).upper()
|
|
171
|
+
fill_qty_raw = payload.get("fill_qty")
|
|
172
|
+
if not symbol or side not in {"BUY", "SELL"}:
|
|
173
|
+
return
|
|
174
|
+
if fill_qty_raw is None:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
fill_qty = int(fill_qty_raw)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
return
|
|
180
|
+
if fill_qty <= 0:
|
|
181
|
+
return
|
|
182
|
+
pos = self._positions.get(symbol, 0)
|
|
183
|
+
if side == "BUY":
|
|
184
|
+
pos += fill_qty
|
|
185
|
+
else:
|
|
186
|
+
pos -= fill_qty
|
|
187
|
+
self._positions[symbol] = pos
|
|
188
|
+
|
|
189
|
+
def _handle_event(self, topic: str, payload: dict[str, Any]) -> None:
|
|
190
|
+
if topic.startswith("book."):
|
|
191
|
+
self._on_book(topic.split(".", 1)[1].upper(), payload)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
if topic == "trade.executed":
|
|
195
|
+
self._on_trade(payload)
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
if topic == f"system.symbols.{self.gateway_id}":
|
|
199
|
+
raw = payload.get("symbols", [])
|
|
200
|
+
all_syms = [str(sym).upper() for sym in raw if str(sym).strip()]
|
|
201
|
+
if self._symbols_filter:
|
|
202
|
+
allowed = set(self._symbols_filter)
|
|
203
|
+
self._known_symbols = [sym for sym in all_syms if sym in allowed]
|
|
204
|
+
else:
|
|
205
|
+
self._known_symbols = all_syms
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if topic == f"order.ack.{self.gateway_id}":
|
|
209
|
+
if payload.get("accepted", False):
|
|
210
|
+
self.metrics.acknowledged += 1
|
|
211
|
+
else:
|
|
212
|
+
self.metrics.rejected += 1
|
|
213
|
+
self._on_reject()
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
if topic == f"order.fill.{self.gateway_id}":
|
|
217
|
+
self.metrics.filled += 1
|
|
218
|
+
self._update_position_from_fill(payload)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
if topic in {
|
|
222
|
+
f"order.cancelled.{self.gateway_id}",
|
|
223
|
+
f"order.expired.{self.gateway_id}",
|
|
224
|
+
}:
|
|
225
|
+
self.metrics.cancelled += 1
|
|
226
|
+
|
|
227
|
+
def _active_symbols(self) -> list[str]:
|
|
228
|
+
if self._known_symbols:
|
|
229
|
+
return self._known_symbols
|
|
230
|
+
if self._symbols_filter:
|
|
231
|
+
return self._symbols_filter
|
|
232
|
+
return sorted(self._market)
|
|
233
|
+
|
|
234
|
+
def _pick_symbol(self) -> str | None:
|
|
235
|
+
universe = self._active_symbols()
|
|
236
|
+
if not universe:
|
|
237
|
+
return None
|
|
238
|
+
return self._rng.choice(universe)
|
|
239
|
+
|
|
240
|
+
def _make_order_payload(self, symbol: str) -> dict[str, Any] | None:
|
|
241
|
+
now = time.monotonic()
|
|
242
|
+
last_update = self._last_market_update.get(symbol)
|
|
243
|
+
if (
|
|
244
|
+
self._stale_data_sec > 0
|
|
245
|
+
and last_update is not None
|
|
246
|
+
and (now - last_update) > self._stale_data_sec
|
|
247
|
+
):
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
snap = self._market.get(symbol, MarketSnapshot())
|
|
251
|
+
pos = self._positions.get(symbol, 0)
|
|
252
|
+
if pos >= self._max_position:
|
|
253
|
+
side = "SELL"
|
|
254
|
+
elif pos <= -self._max_position:
|
|
255
|
+
side = "BUY"
|
|
256
|
+
else:
|
|
257
|
+
side = "BUY" if self._rng.random() < 0.5 else "SELL"
|
|
258
|
+
|
|
259
|
+
qty = self.profile.sample_qty(self._rng)
|
|
260
|
+
|
|
261
|
+
if side == "BUY":
|
|
262
|
+
allowed = self._max_position - pos
|
|
263
|
+
else:
|
|
264
|
+
allowed = self._max_position + pos
|
|
265
|
+
if allowed <= 0:
|
|
266
|
+
return None
|
|
267
|
+
qty = min(qty, allowed)
|
|
268
|
+
if qty <= 0:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
# Build a limit price around top-of-book and personality aggression.
|
|
272
|
+
# If no market data exists yet, avoid blind orders.
|
|
273
|
+
if side == "BUY":
|
|
274
|
+
ref = snap.best_bid if snap.best_bid is not None else snap.last_price
|
|
275
|
+
if ref is None:
|
|
276
|
+
return None
|
|
277
|
+
if (
|
|
278
|
+
snap.best_ask is not None
|
|
279
|
+
and self._rng.random() < self.profile.cross_probability
|
|
280
|
+
):
|
|
281
|
+
price = snap.best_ask
|
|
282
|
+
else:
|
|
283
|
+
price = max(
|
|
284
|
+
0.01,
|
|
285
|
+
ref - self.profile.passive_offset_ticks * self.profile.tick_size,
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
ref = snap.best_ask if snap.best_ask is not None else snap.last_price
|
|
289
|
+
if ref is None:
|
|
290
|
+
return None
|
|
291
|
+
if (
|
|
292
|
+
snap.best_bid is not None
|
|
293
|
+
and self._rng.random() < self.profile.cross_probability
|
|
294
|
+
):
|
|
295
|
+
price = snap.best_bid
|
|
296
|
+
else:
|
|
297
|
+
price = ref + self.profile.passive_offset_ticks * self.profile.tick_size
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
"symbol": symbol,
|
|
301
|
+
"side": side,
|
|
302
|
+
"order_type": "LIMIT",
|
|
303
|
+
"quantity": qty,
|
|
304
|
+
"price": round(price, 4),
|
|
305
|
+
"tif": "DAY",
|
|
306
|
+
"gateway_id": self.gateway_id,
|
|
307
|
+
"run_id": self._run_id,
|
|
308
|
+
"strategy": self.profile.name,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
def _maybe_submit_order(self) -> None:
|
|
312
|
+
now = time.monotonic()
|
|
313
|
+
self._trim_reject_times(now)
|
|
314
|
+
|
|
315
|
+
if now < self._risk_pause_until:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
interval = self.profile.decision_interval_ms / 1000.0
|
|
319
|
+
if now - self._last_submit_ts < interval:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
symbol = self._pick_symbol()
|
|
323
|
+
if symbol is None:
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
payload = self._make_order_payload(symbol)
|
|
327
|
+
if payload is None:
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
self.push_sock.send_multipart(make_order_new_msg(payload))
|
|
331
|
+
self.metrics.submitted += 1
|
|
332
|
+
self._last_submit_ts = now
|
|
333
|
+
|
|
334
|
+
def run(self, duration_sec: float) -> int:
|
|
335
|
+
if not self._authenticate():
|
|
336
|
+
return 1
|
|
337
|
+
|
|
338
|
+
self._request_symbols()
|
|
339
|
+
|
|
340
|
+
poller = zmq.Poller()
|
|
341
|
+
poller.register(self.sub_sock, zmq.POLLIN)
|
|
342
|
+
|
|
343
|
+
started = time.monotonic()
|
|
344
|
+
next_symbols_refresh = started + 2.0
|
|
345
|
+
while self._running:
|
|
346
|
+
if duration_sec > 0 and (time.monotonic() - started) >= duration_sec:
|
|
347
|
+
self._running = False
|
|
348
|
+
break
|
|
349
|
+
|
|
350
|
+
socks = dict(poller.poll(timeout=100))
|
|
351
|
+
if self.sub_sock in socks:
|
|
352
|
+
topic, payload = decode(self.sub_sock.recv_multipart())
|
|
353
|
+
self._handle_event(topic, payload)
|
|
354
|
+
|
|
355
|
+
if time.monotonic() >= next_symbols_refresh and not self._known_symbols:
|
|
356
|
+
self._request_symbols()
|
|
357
|
+
next_symbols_refresh = time.monotonic() + 2.0
|
|
358
|
+
|
|
359
|
+
self._maybe_submit_order()
|
|
360
|
+
|
|
361
|
+
self._log(
|
|
362
|
+
"stopped "
|
|
363
|
+
f"submitted={self.metrics.submitted} "
|
|
364
|
+
f"acked={self.metrics.acknowledged} "
|
|
365
|
+
f"rejected={self.metrics.rejected} "
|
|
366
|
+
f"fills={self.metrics.filled}"
|
|
367
|
+
)
|
|
368
|
+
self.push_sock.close()
|
|
369
|
+
self.sub_sock.close()
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _as_float(value: Any) -> float | None:
|
|
374
|
+
if value is None:
|
|
375
|
+
return None
|
|
376
|
+
try:
|
|
377
|
+
return float(value)
|
|
378
|
+
except (TypeError, ValueError):
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _parse_args() -> argparse.Namespace:
|
|
383
|
+
parser = argparse.ArgumentParser(description="EduMatcher autonomous AI trader")
|
|
384
|
+
parser.add_argument("--id", required=True, help="Gateway ID, e.g. AI01")
|
|
385
|
+
parser.add_argument(
|
|
386
|
+
"--profile",
|
|
387
|
+
default="cautious",
|
|
388
|
+
choices=available_profiles(),
|
|
389
|
+
help="Personality profile",
|
|
390
|
+
)
|
|
391
|
+
parser.add_argument(
|
|
392
|
+
"--symbols",
|
|
393
|
+
default="",
|
|
394
|
+
help="Optional comma-separated symbol allowlist, e.g. AAPL,MSFT",
|
|
395
|
+
)
|
|
396
|
+
parser.add_argument(
|
|
397
|
+
"--seed",
|
|
398
|
+
type=int,
|
|
399
|
+
default=1,
|
|
400
|
+
help="Random seed for deterministic behavior",
|
|
401
|
+
)
|
|
402
|
+
parser.add_argument(
|
|
403
|
+
"--duration",
|
|
404
|
+
type=float,
|
|
405
|
+
default=0.0,
|
|
406
|
+
help="Run duration in seconds; 0 means run until stopped",
|
|
407
|
+
)
|
|
408
|
+
parser.add_argument(
|
|
409
|
+
"--run-id",
|
|
410
|
+
default="",
|
|
411
|
+
help="Optional run identifier, autogenerated if omitted",
|
|
412
|
+
)
|
|
413
|
+
parser.add_argument(
|
|
414
|
+
"--max-position",
|
|
415
|
+
type=int,
|
|
416
|
+
default=1000,
|
|
417
|
+
help="Absolute position limit per symbol",
|
|
418
|
+
)
|
|
419
|
+
parser.add_argument(
|
|
420
|
+
"--max-rejects",
|
|
421
|
+
type=int,
|
|
422
|
+
default=25,
|
|
423
|
+
help="Reject count threshold for breaker",
|
|
424
|
+
)
|
|
425
|
+
parser.add_argument(
|
|
426
|
+
"--reject-window",
|
|
427
|
+
type=float,
|
|
428
|
+
default=10.0,
|
|
429
|
+
help="Rolling window in seconds for reject threshold",
|
|
430
|
+
)
|
|
431
|
+
parser.add_argument(
|
|
432
|
+
"--reject-cooldown",
|
|
433
|
+
type=float,
|
|
434
|
+
default=5.0,
|
|
435
|
+
help="Pause duration in seconds after reject breaker trips",
|
|
436
|
+
)
|
|
437
|
+
parser.add_argument(
|
|
438
|
+
"--stale-data",
|
|
439
|
+
type=float,
|
|
440
|
+
default=4.0,
|
|
441
|
+
help="Max age in seconds for market data before pausing submissions",
|
|
442
|
+
)
|
|
443
|
+
return parser.parse_args()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def main() -> None:
|
|
447
|
+
args = _parse_args()
|
|
448
|
+
symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
|
|
449
|
+
run_id = args.run_id or f"botrun-{uuid.uuid4().hex[:12]}"
|
|
450
|
+
|
|
451
|
+
try:
|
|
452
|
+
bot = AITraderBot(
|
|
453
|
+
gateway_id=str(args.id),
|
|
454
|
+
profile_name=str(args.profile),
|
|
455
|
+
symbols=symbols,
|
|
456
|
+
seed=int(args.seed),
|
|
457
|
+
run_id=run_id,
|
|
458
|
+
max_position=int(args.max_position),
|
|
459
|
+
max_rejects=int(args.max_rejects),
|
|
460
|
+
reject_window_sec=float(args.reject_window),
|
|
461
|
+
reject_cooldown_sec=float(args.reject_cooldown),
|
|
462
|
+
stale_data_sec=float(args.stale_data),
|
|
463
|
+
)
|
|
464
|
+
rc = bot.run(duration_sec=float(args.duration))
|
|
465
|
+
raise SystemExit(rc)
|
|
466
|
+
except KeyboardInterrupt:
|
|
467
|
+
print("\n[AI] interrupted")
|
|
468
|
+
raise SystemExit(0)
|
|
469
|
+
except Exception as exc:
|
|
470
|
+
print(f"[AI] FATAL: {exc}", file=sys.stderr)
|
|
471
|
+
raise SystemExit(1)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
if __name__ == "__main__":
|
|
475
|
+
main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class PersonalityProfile:
|
|
9
|
+
name: str
|
|
10
|
+
decision_interval_ms: int
|
|
11
|
+
order_size_min: int
|
|
12
|
+
order_size_max: int
|
|
13
|
+
cross_probability: float
|
|
14
|
+
passive_offset_ticks: int
|
|
15
|
+
tick_size: float
|
|
16
|
+
size_distribution: str
|
|
17
|
+
|
|
18
|
+
def sample_qty(self, rng: random.Random) -> int:
|
|
19
|
+
if self.order_size_min >= self.order_size_max:
|
|
20
|
+
return self.order_size_min
|
|
21
|
+
|
|
22
|
+
lo = self.order_size_min
|
|
23
|
+
hi = self.order_size_max
|
|
24
|
+
|
|
25
|
+
if self.size_distribution == "small-heavy":
|
|
26
|
+
span = hi - lo
|
|
27
|
+
qty = lo + int((rng.random() ** 2.0) * span)
|
|
28
|
+
return max(lo, min(hi, qty))
|
|
29
|
+
|
|
30
|
+
if self.size_distribution == "block-heavy":
|
|
31
|
+
span = hi - lo
|
|
32
|
+
qty = lo + int((1.0 - ((1.0 - rng.random()) ** 2.0)) * span)
|
|
33
|
+
return max(lo, min(hi, qty))
|
|
34
|
+
|
|
35
|
+
return rng.randint(lo, hi)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_PRESET_PROFILES: dict[str, PersonalityProfile] = {
|
|
39
|
+
"aggressive": PersonalityProfile(
|
|
40
|
+
name="aggressive",
|
|
41
|
+
decision_interval_ms=250,
|
|
42
|
+
order_size_min=20,
|
|
43
|
+
order_size_max=120,
|
|
44
|
+
cross_probability=0.35,
|
|
45
|
+
passive_offset_ticks=0,
|
|
46
|
+
tick_size=0.01,
|
|
47
|
+
size_distribution="balanced",
|
|
48
|
+
),
|
|
49
|
+
"cautious": PersonalityProfile(
|
|
50
|
+
name="cautious",
|
|
51
|
+
decision_interval_ms=900,
|
|
52
|
+
order_size_min=10,
|
|
53
|
+
order_size_max=60,
|
|
54
|
+
cross_probability=0.05,
|
|
55
|
+
passive_offset_ticks=2,
|
|
56
|
+
tick_size=0.01,
|
|
57
|
+
size_distribution="balanced",
|
|
58
|
+
),
|
|
59
|
+
"many-small": PersonalityProfile(
|
|
60
|
+
name="many-small",
|
|
61
|
+
decision_interval_ms=180,
|
|
62
|
+
order_size_min=1,
|
|
63
|
+
order_size_max=25,
|
|
64
|
+
cross_probability=0.18,
|
|
65
|
+
passive_offset_ticks=1,
|
|
66
|
+
tick_size=0.01,
|
|
67
|
+
size_distribution="small-heavy",
|
|
68
|
+
),
|
|
69
|
+
"few-large": PersonalityProfile(
|
|
70
|
+
name="few-large",
|
|
71
|
+
decision_interval_ms=1400,
|
|
72
|
+
order_size_min=150,
|
|
73
|
+
order_size_max=700,
|
|
74
|
+
cross_probability=0.12,
|
|
75
|
+
passive_offset_ticks=1,
|
|
76
|
+
tick_size=0.01,
|
|
77
|
+
size_distribution="block-heavy",
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_profile(name: str) -> PersonalityProfile:
|
|
83
|
+
key = name.strip().lower()
|
|
84
|
+
if key not in _PRESET_PROFILES:
|
|
85
|
+
allowed = ", ".join(sorted(_PRESET_PROFILES))
|
|
86
|
+
raise ValueError(f"Unknown profile '{name}'. Allowed: {allowed}")
|
|
87
|
+
return _PRESET_PROFILES[key]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def available_profiles() -> list[str]:
|
|
91
|
+
return sorted(_PRESET_PROFILES)
|