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.
Files changed (56) hide show
  1. edumatcher/__init__.py +0 -0
  2. edumatcher/ai_trader/__init__.py +5 -0
  3. edumatcher/ai_trader/main.py +475 -0
  4. edumatcher/ai_trader/personality.py +91 -0
  5. edumatcher/ai_trader/swarm.py +202 -0
  6. edumatcher/audit/__init__.py +0 -0
  7. edumatcher/audit/main.py +126 -0
  8. edumatcher/board/__init__.py +0 -0
  9. edumatcher/board/main.py +261 -0
  10. edumatcher/clearing/__init__.py +0 -0
  11. edumatcher/clearing/main.py +262 -0
  12. edumatcher/commands/__init__.py +16 -0
  13. edumatcher/commands/cli.py +261 -0
  14. edumatcher/commands/client.py +450 -0
  15. edumatcher/commands/console.py +757 -0
  16. edumatcher/config.py +37 -0
  17. edumatcher/engine/__init__.py +0 -0
  18. edumatcher/engine/auction.py +185 -0
  19. edumatcher/engine/circuit_breaker.py +119 -0
  20. edumatcher/engine/collar.py +139 -0
  21. edumatcher/engine/config_loader.py +896 -0
  22. edumatcher/engine/drop_copy.py +194 -0
  23. edumatcher/engine/main.py +2898 -0
  24. edumatcher/engine/order_book.py +1134 -0
  25. edumatcher/engine/persistence.py +113 -0
  26. edumatcher/gateway/__init__.py +0 -0
  27. edumatcher/gateway/main.py +1221 -0
  28. edumatcher/messaging/__init__.py +0 -0
  29. edumatcher/messaging/bus.py +67 -0
  30. edumatcher/models/__init__.py +0 -0
  31. edumatcher/models/clock.py +35 -0
  32. edumatcher/models/combo.py +172 -0
  33. edumatcher/models/instrument.py +25 -0
  34. edumatcher/models/message.py +726 -0
  35. edumatcher/models/mm_obligation.py +47 -0
  36. edumatcher/models/order.py +240 -0
  37. edumatcher/models/participant.py +26 -0
  38. edumatcher/models/price.py +98 -0
  39. edumatcher/models/quote.py +110 -0
  40. edumatcher/models/session.py +51 -0
  41. edumatcher/models/trade.py +111 -0
  42. edumatcher/orders/__init__.py +0 -0
  43. edumatcher/orders/main.py +200 -0
  44. edumatcher/py.typed +0 -0
  45. edumatcher/scheduler/__init__.py +0 -0
  46. edumatcher/scheduler/main.py +194 -0
  47. edumatcher/stats/__init__.py +0 -0
  48. edumatcher/stats/main.py +473 -0
  49. edumatcher/ticker/__init__.py +0 -0
  50. edumatcher/ticker/main.py +335 -0
  51. edumatcher/viewer/__init__.py +0 -0
  52. edumatcher/viewer/main.py +160 -0
  53. edumatcher-0.1.5.dist-info/METADATA +146 -0
  54. edumatcher-0.1.5.dist-info/RECORD +56 -0
  55. edumatcher-0.1.5.dist-info/WHEEL +4 -0
  56. edumatcher-0.1.5.dist-info/entry_points.txt +16 -0
edumatcher/__init__.py ADDED
File without changes
@@ -0,0 +1,5 @@
1
+ """AI trader process package."""
2
+
3
+ from edumatcher.ai_trader.personality import PersonalityProfile, get_profile
4
+
5
+ __all__ = ["PersonalityProfile", "get_profile"]
@@ -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)