dojozero-client 0.2.2__tar.gz → 0.4.0__tar.gz

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 (19) hide show
  1. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/PKG-INFO +3 -3
  2. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/README.md +1 -1
  3. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/pyproject.toml +2 -2
  4. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/__init__.py +11 -1
  5. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/_cli.py +246 -47
  6. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/_client.py +182 -10
  7. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/_daemon.py +314 -70
  8. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/_exceptions.py +22 -0
  9. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/_transport.py +8 -0
  10. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/tests/test_client.py +3 -3
  11. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/tests/test_daemon.py +216 -73
  12. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/.gitignore +0 -0
  13. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/_config.py +0 -0
  14. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/_credentials.py +0 -0
  15. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/_rpc.py +0 -0
  16. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/src/dojozero_client/_strategy.py +0 -0
  17. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/tests/test_config.py +0 -0
  18. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/tests/test_credentials.py +0 -0
  19. {dojozero_client-0.2.2 → dojozero_client-0.4.0}/tests/test_rpc.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dojozero-client
3
- Version: 0.2.2
4
- Summary: Python SDK for external agents participating in DojoZero trials. Works with OpenClaw and CoPaw.
3
+ Version: 0.4.0
4
+ Summary: Python SDK for external agents participating in DojoZero trials. Works with OpenClaw and QwenPaw.
5
5
  Project-URL: Homepage, https://github.com/agentscope-ai/DojoZero
6
6
  Project-URL: Repository, https://github.com/agentscope-ai/DojoZero
7
7
  Project-URL: Issues, https://github.com/agentscope-ai/DojoZero/issues
@@ -149,7 +149,7 @@ asyncio.run(main())
149
149
 
150
150
  ## Documentation
151
151
 
152
- For the full API reference, daemon mode, multiple agent profiles, and Agent Skill integration (OpenClaw / CoPaw / AgentScope), see the [full documentation](https://github.com/agentscope-ai/DojoZero/tree/main/docs).
152
+ For the full API reference, daemon mode, multiple agent profiles, and Agent Skill integration (OpenClaw / QwenPaw / AgentScope), see the [full documentation](https://github.com/agentscope-ai/DojoZero/tree/main/docs).
153
153
 
154
154
  ## License
155
155
 
@@ -127,7 +127,7 @@ asyncio.run(main())
127
127
 
128
128
  ## Documentation
129
129
 
130
- For the full API reference, daemon mode, multiple agent profiles, and Agent Skill integration (OpenClaw / CoPaw / AgentScope), see the [full documentation](https://github.com/agentscope-ai/DojoZero/tree/main/docs).
130
+ For the full API reference, daemon mode, multiple agent profiles, and Agent Skill integration (OpenClaw / QwenPaw / AgentScope), see the [full documentation](https://github.com/agentscope-ai/DojoZero/tree/main/docs).
131
131
 
132
132
  ## License
133
133
 
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dojozero-client"
7
- version = "0.2.2"
8
- description = "Python SDK for external agents participating in DojoZero trials. Works with OpenClaw and CoPaw."
7
+ version = "0.4.0"
8
+ description = "Python SDK for external agents participating in DojoZero trials. Works with OpenClaw and QwenPaw."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = {text = "MIT"}
@@ -22,11 +22,14 @@ from dojozero_client._client import (
22
22
  AgentResult,
23
23
  Balance,
24
24
  BetResult,
25
+ ContestRules,
25
26
  DojoClient,
26
27
  EventEnvelope,
28
+ EventInfo,
27
29
  GatewayInfo,
28
30
  Holding,
29
31
  Odds,
32
+ PredictionResult,
30
33
  TrialConnection,
31
34
  TrialEndedEvent,
32
35
  TrialMetadata,
@@ -52,6 +55,8 @@ from dojozero_client._exceptions import (
52
55
  DojoClientError,
53
56
  InsufficientBalanceError,
54
57
  NotRegisteredError,
58
+ PredictionClosedError,
59
+ PredictionRejectedError,
55
60
  RateLimitedError,
56
61
  RegistrationError,
57
62
  StaleReferenceError,
@@ -60,7 +65,7 @@ from dojozero_client._exceptions import (
60
65
  )
61
66
  from dojozero_client._transport import GatewayTransport, SSEEvent
62
67
 
63
- __version__ = "0.1.0"
68
+ __version__ = "0.4.0"
64
69
 
65
70
  __all__ = [
66
71
  # Main client
@@ -70,10 +75,13 @@ __all__ = [
70
75
  "AgentResult",
71
76
  "Balance",
72
77
  "BetResult",
78
+ "ContestRules",
73
79
  "EventEnvelope",
80
+ "EventInfo",
74
81
  "GatewayInfo",
75
82
  "Holding",
76
83
  "Odds",
84
+ "PredictionResult",
77
85
  "TrialEndedEvent",
78
86
  "TrialMetadata",
79
87
  "TrialResults",
@@ -100,6 +108,8 @@ __all__ = [
100
108
  "StaleReferenceError",
101
109
  "InsufficientBalanceError",
102
110
  "BettingClosedError",
111
+ "PredictionRejectedError",
112
+ "PredictionClosedError",
103
113
  "RateLimitedError",
104
114
  "StreamDisconnectedError",
105
115
  "TrialEndedError",
@@ -163,11 +163,10 @@ def cmd_start(args: argparse.Namespace) -> int:
163
163
  )
164
164
 
165
165
  async def _run_foreground() -> None:
166
- daemon = UnifiedDaemon()
166
+ daemon = UnifiedDaemon(profile=profile)
167
167
  # Start daemon, then auto-join the trial
168
168
  daemon._stop_event = asyncio.Event()
169
- daemon._api_key = load_api_key(profile=profile)
170
- if not daemon._api_key:
169
+ if not daemon._get_api_key():
171
170
  raise RuntimeError("No API key configured")
172
171
  daemon._write_pid()
173
172
  daemon._setup_signals()
@@ -229,10 +228,12 @@ def cmd_status(args: argparse.Namespace) -> int:
229
228
  try:
230
229
  state = client.call_sync("status", trial_id=trial_id)
231
230
  _print_status(state, daemon_running=True)
232
- # Show bet history from disk
233
231
  tid = state.get("trial_id", trial_id or "")
234
232
  if tid:
235
- _print_bet_history(_trial_state_dir(tid))
233
+ if state.get("contest_kind") == "window_pool_prediction":
234
+ _print_prediction_history(_trial_state_dir(tid))
235
+ else:
236
+ _print_bet_history(_trial_state_dir(tid))
236
237
  return 0
237
238
  except RPCError as e:
238
239
  if e.code == "NO_TRIALS":
@@ -263,8 +264,12 @@ def _print_status(state: dict[str, Any], daemon_running: bool) -> None:
263
264
  away_team = state.get("away_team", "")
264
265
  home_tri = state.get("home_team_tricode", "")
265
266
  away_tri = state.get("away_team_tricode", "")
267
+ contest_kind = state.get("contest_kind", "classic_betting")
268
+ is_prediction = contest_kind == "window_pool_prediction"
266
269
 
267
270
  print(f"Trial: {state.get('trial_id', 'unknown')}")
271
+ mode_label = "prediction" if is_prediction else "classic betting"
272
+ print(f"Mode: {mode_label}")
268
273
  if home_team and away_team:
269
274
  sport = state.get("sport_type", "")
270
275
  game_time = state.get("game_time", "")
@@ -283,11 +288,12 @@ def _print_status(state: dict[str, Any], daemon_running: bool) -> None:
283
288
  else:
284
289
  print(f"Status: {status_label} (daemon not running)")
285
290
 
286
- # Use tricodes for compact score/odds, fall back to full name or "Home"/"Away"
287
291
  home_label = home_tri or home_team or "Home"
288
292
  away_label = away_tri or away_team or "Away"
289
293
 
290
- if game_state:
294
+ def _print_game_state_score() -> None:
295
+ if not game_state:
296
+ return
291
297
  home_score = game_state.get("home_score", "?")
292
298
  away_score = game_state.get("away_score", "?")
293
299
  period = game_state.get("period", game_state.get("quarter", "?"))
@@ -296,24 +302,51 @@ def _print_status(state: dict[str, Any], daemon_running: bool) -> None:
296
302
  f"Score: {home_label} {home_score} - {away_label} {away_score} (Q{period} {clock})"
297
303
  )
298
304
 
299
- if odds:
300
- home_prob = odds.get("home_probability", 0)
301
- away_prob = odds.get("away_probability", 0)
302
- print(f"Moneyline: {home_label} {home_prob:.1%}, {away_label} {away_prob:.1%}")
303
- for s in odds.get("spreads", []):
304
- spread_val = s.get("spread", 0)
305
- h_p = s.get("home_probability", 0)
306
- a_p = s.get("away_probability", 0)
305
+ if is_prediction:
306
+ # Show event info for prediction mode
307
+ event_info = state.get("event_info", {})
308
+ if event_info:
309
+ window = event_info.get("current_window", "?")
310
+ ratio = event_info.get("elapsed_ratio", 0)
311
+ window_labels = {0: "Pre-game", 1: "Q1", 2: "Q2", 3: "Q3", 4: "Q4"}
312
+ wlabel = window_labels.get(window, f"W{window}")
313
+ print(f"Window: {wlabel} (elapsed {ratio:.0%})")
314
+ h_score = event_info.get("home_score")
315
+ a_score = event_info.get("away_score")
316
+ if h_score is not None and a_score is not None:
317
+ period = event_info.get("period", "?")
318
+ clock = event_info.get("game_clock", "")
319
+ time_str = f"Q{period} {clock}" if clock else f"Q{period}"
320
+ print(
321
+ f"Score: {home_label} {h_score} - {away_label} {a_score} ({time_str})"
322
+ )
323
+ else:
324
+ _print_game_state_score()
325
+ else:
326
+ # Classic betting mode
327
+ _print_game_state_score()
328
+
329
+ if odds:
330
+ home_prob = odds.get("home_probability", 0)
331
+ away_prob = odds.get("away_probability", 0)
307
332
  print(
308
- f"Spread {spread_val:+g}: {home_label} {h_p:.1%}, {away_label} {a_p:.1%}"
333
+ f"Moneyline: {home_label} {home_prob:.1%}, {away_label} {away_prob:.1%}"
309
334
  )
310
- for t in odds.get("totals", []):
311
- total_val = t.get("total", 0)
312
- over_p = t.get("over_probability", 0)
313
- under_p = t.get("under_probability", 0)
314
- print(f"Total {total_val}: over {over_p:.1%}, under {under_p:.1%}")
335
+ for s in odds.get("spreads", []):
336
+ spread_val = s.get("spread", 0)
337
+ h_p = s.get("home_probability", 0)
338
+ a_p = s.get("away_probability", 0)
339
+ print(
340
+ f"Spread {spread_val:+g}: {home_label} {h_p:.1%}, {away_label} {a_p:.1%}"
341
+ )
342
+ for t in odds.get("totals", []):
343
+ total_val = t.get("total", 0)
344
+ over_p = t.get("over_probability", 0)
345
+ under_p = t.get("under_probability", 0)
346
+ print(f"Total {total_val}: over {over_p:.1%}, under {under_p:.1%}")
347
+
348
+ print(f"Balance: ${state.get('balance', 0):.2f}")
315
349
 
316
- print(f"Balance: ${state.get('balance', 0):.2f}")
317
350
  print(f"Last Update: {state.get('last_updated', 'never')}")
318
351
 
319
352
 
@@ -323,7 +356,7 @@ def _print_bet_history(state_dir: Path) -> None:
323
356
  if not bets_file.exists():
324
357
  return
325
358
 
326
- lines = bets_file.read_text().strip().split("\n")
359
+ lines = bets_file.read_text().splitlines()
327
360
  bets = []
328
361
  for line in lines:
329
362
  if line:
@@ -344,6 +377,36 @@ def _print_bet_history(state_dir: Path) -> None:
344
377
  print(f" ${amt:.0f} on {selection} ({market}){prob_str} - {ts}")
345
378
 
346
379
 
380
+ def _print_prediction_history(state_dir: Path) -> None:
381
+ """Print prediction history from predictions.jsonl."""
382
+ preds_file = state_dir / "predictions.jsonl"
383
+ if not preds_file.exists():
384
+ return
385
+
386
+ lines = preds_file.read_text().splitlines()
387
+ preds = []
388
+ for line in lines:
389
+ if line:
390
+ try:
391
+ preds.append(json.loads(line))
392
+ except json.JSONDecodeError:
393
+ continue
394
+ if preds:
395
+ print(f"\nPredictions ({len(preds)}):")
396
+ for p in preds:
397
+ sel = p.get("selection", "?")
398
+ window = p.get("window", "?")
399
+ correct = p.get("is_correct")
400
+ score = p.get("score")
401
+ ts = p.get("submit_time", "")[:16]
402
+ status_str = ""
403
+ if correct is not None:
404
+ status_str = f" {'correct' if correct else 'incorrect'}"
405
+ if score is not None:
406
+ status_str += f" (score={score:.1f})"
407
+ print(f" W{window} {sel}{status_str} - {ts}")
408
+
409
+
347
410
  def cmd_logs(args: argparse.Namespace) -> int:
348
411
  """Show daemon logs."""
349
412
  state_dir = _get_state_dir(args)
@@ -361,7 +424,7 @@ def cmd_logs(args: argparse.Namespace) -> int:
361
424
  pass
362
425
  else:
363
426
  # Show last 50 lines
364
- lines = log_file.read_text().strip().split("\n")
427
+ lines = log_file.read_text().splitlines()
365
428
  for line in lines[-50:]:
366
429
  print(line)
367
430
 
@@ -405,6 +468,95 @@ def cmd_bet(args: argparse.Namespace) -> int:
405
468
  return 1
406
469
 
407
470
 
471
+ def cmd_predict(args: argparse.Namespace) -> int:
472
+ """Submit a prediction via daemon RPC."""
473
+ trial_id = getattr(args, "trial_id", None)
474
+
475
+ if not is_daemon_running():
476
+ print("Daemon not running. Use 'start <trial-id>' first.", file=sys.stderr)
477
+ return 1
478
+
479
+ client = RPCClient(SOCKET_PATH)
480
+ try:
481
+ result = client.call_sync(
482
+ "predict",
483
+ trial_id=trial_id,
484
+ selection=args.selection,
485
+ )
486
+ window = result.get("window", "?")
487
+ print(f"Prediction submitted: {args.selection} (window {window})")
488
+ print(f"Prediction ID: {result.get('prediction_id')}")
489
+ return 0
490
+ except RPCError as e:
491
+ print(f"Error: {e.message}", file=sys.stderr)
492
+ return 1
493
+
494
+
495
+ def _format_prediction_row(p: dict[str, Any]) -> str:
496
+ """Render a single prediction-history row.
497
+
498
+ Used by both the live-RPC and on-disk fallback paths so the two stay in
499
+ sync. Scores come back as Decimal-strings from the server and as floats
500
+ or strings from predictions.jsonl, so we accept either.
501
+ """
502
+ pid = p.get("prediction_id", "?")[:8]
503
+ sel = p.get("selection", "?")
504
+ window = p.get("window", "?")
505
+ correct = p.get("is_correct")
506
+ score = p.get("score")
507
+ status_str = ""
508
+ if correct is not None:
509
+ status_str = f" {'✓' if correct else '✗'}"
510
+ if score is not None:
511
+ try:
512
+ status_str += f" score={float(score):.1f}"
513
+ except (TypeError, ValueError):
514
+ status_str += f" score={score}"
515
+ return f" [{pid}] W{window} {sel}{status_str}"
516
+
517
+
518
+ def cmd_predictions(args: argparse.Namespace) -> int:
519
+ """Show prediction history."""
520
+ trial_id = getattr(args, "trial_id", None)
521
+
522
+ # Try RPC first for live data
523
+ if is_daemon_running():
524
+ client = RPCClient(SOCKET_PATH)
525
+ try:
526
+ result = client.call_sync("predictions", trial_id=trial_id)
527
+ preds = result.get("predictions", [])
528
+ if not preds:
529
+ print("No predictions")
530
+ return 0
531
+
532
+ print(f"Predictions ({len(preds)}):")
533
+ for p in preds:
534
+ print(_format_prediction_row(p))
535
+ return 0
536
+ except RPCError as e:
537
+ print(f"Error: {e.message}", file=sys.stderr)
538
+ return 1
539
+
540
+ # Fall back to predictions.jsonl on disk
541
+ state_dir = _get_state_dir(args)
542
+ preds_file = state_dir / "predictions.jsonl"
543
+ if not preds_file.exists():
544
+ print("No predictions")
545
+ return 0
546
+
547
+ lines = preds_file.read_text().splitlines()
548
+ count = args.count if hasattr(args, "count") and args.count else 20
549
+ for line in lines[-count:]:
550
+ if not line:
551
+ continue
552
+ try:
553
+ p = json.loads(line)
554
+ except json.JSONDecodeError:
555
+ continue
556
+ print(_format_prediction_row(p))
557
+ return 0
558
+
559
+
408
560
  def _format_event_summary(payload: dict[str, Any], home: str, away: str) -> str:
409
561
  """Format an event payload as a human-readable summary line."""
410
562
  event_type = payload.get("event_type", "")
@@ -477,7 +629,7 @@ def cmd_events(args: argparse.Namespace) -> int:
477
629
  for t in raw_types.split(",")
478
630
  }
479
631
 
480
- lines = events_file.read_text().strip().split("\n")
632
+ lines = events_file.read_text().splitlines()
481
633
  count = args.count if hasattr(args, "count") and args.count else 20
482
634
 
483
635
  # First pass: discover team tricodes from odds/result events
@@ -549,7 +701,7 @@ def cmd_bets(args: argparse.Namespace) -> int:
549
701
  print("No bets")
550
702
  return 0
551
703
 
552
- lines = bets_file.read_text().strip().split("\n")
704
+ lines = bets_file.read_text().splitlines()
553
705
  count = args.count if hasattr(args, "count") and args.count else 20
554
706
 
555
707
  for line in lines[-count:]:
@@ -587,8 +739,12 @@ def cmd_list(_: argparse.Namespace) -> int:
587
739
  print(f"Connected trials ({len(trials)}):")
588
740
  for trial_id, info in trials.items():
589
741
  status = "connected" if info.get("connected") else "disconnected"
590
- balance = info.get("balance", 0)
591
- print(f" {trial_id}: {status}, balance=${balance:.2f}")
742
+ kind = info.get("contest_kind", "classic_betting")
743
+ if kind == "window_pool_prediction":
744
+ print(f" {trial_id}: {status} (prediction mode)")
745
+ else:
746
+ balance = info.get("balance", 0)
747
+ print(f" {trial_id}: {status}, balance=${balance:.2f}")
592
748
  return 0
593
749
  except RPCError as e:
594
750
  print(f"Error: {e.message}", file=sys.stderr)
@@ -627,6 +783,7 @@ def cmd_leaderboard(args: argparse.Namespace) -> int:
627
783
 
628
784
  data = resp.json()
629
785
  board = data.get("leaderboard", [])
786
+ mode = data.get("mode", "classic_betting")
630
787
 
631
788
  if not board:
632
789
  print("No agents registered")
@@ -645,27 +802,49 @@ def cmd_leaderboard(args: argparse.Namespace) -> int:
645
802
  f"{data.get('internal_agents', 0)} internal)"
646
803
  )
647
804
  print()
648
- print(
649
- f" {'#':<4} {'Agent':<24} {'Balance':>10} {'P/L':>10} {'Bets':>6} {'Win%':>6} {'ROI':>7}"
650
- )
651
- print(
652
- f" {'─' * 4} {'─' * 24} {'─' * 10} {'─' * 10} {'─' * 6} {'─' * 6} {'─' * 7}"
653
- )
654
805
 
655
- for i, entry in enumerate(board, 1):
656
- agent = entry["agent_id"]
657
- if len(agent) > 23:
658
- agent = agent[:20] + "..."
659
- tag = " *" if entry.get("is_external") else ""
660
- balance = float(entry.get("balance", 0))
661
- pnl = float(entry.get("net_profit", 0))
662
- bets = entry.get("total_bets", 0)
663
- wr = entry.get("win_rate", 0)
664
- roi = entry.get("roi", 0)
806
+ if mode == "prediction":
665
807
  print(
666
- f" {i:<4} {agent + tag:<24} ${balance:>9.2f} "
667
- f"{'+' if pnl >= 0 else ''}{pnl:>9.2f} {bets:>6} {wr:>5.0%} {roi:>6.0%}"
808
+ f" {'#':<4} {'Agent':<24} {'Score':>10} {'Preds':>6} "
809
+ f"{'Correct':>8} {'Accuracy':>9}"
668
810
  )
811
+ print(f" {'─' * 4} {'─' * 24} {'─' * 10} {'─' * 6} {'─' * 8} {'─' * 9}")
812
+ for i, entry in enumerate(board, 1):
813
+ agent = entry["agent_id"]
814
+ if len(agent) > 23:
815
+ agent = agent[:20] + "..."
816
+ tag = " *" if entry.get("is_external") else ""
817
+ score = float(entry.get("total_score", 0))
818
+ total = entry.get("total_predictions", 0)
819
+ correct = entry.get("correct_predictions", 0)
820
+ accuracy = entry.get("accuracy", 0)
821
+ print(
822
+ f" {i:<4} {agent + tag:<24} {score:>10.1f} "
823
+ f"{total:>6} {correct:>8} {accuracy:>8.0%}"
824
+ )
825
+ else:
826
+ print(
827
+ f" {'#':<4} {'Agent':<24} {'Balance':>10} {'P/L':>10} "
828
+ f"{'Bets':>6} {'Win%':>6} {'ROI':>7}"
829
+ )
830
+ print(
831
+ f" {'─' * 4} {'─' * 24} {'─' * 10} {'─' * 10} "
832
+ f"{'─' * 6} {'─' * 6} {'─' * 7}"
833
+ )
834
+ for i, entry in enumerate(board, 1):
835
+ agent = entry["agent_id"]
836
+ if len(agent) > 23:
837
+ agent = agent[:20] + "..."
838
+ tag = " *" if entry.get("is_external") else ""
839
+ balance = float(entry.get("balance", 0))
840
+ pnl = float(entry.get("net_profit", 0))
841
+ bets = entry.get("total_bets", 0)
842
+ wr = entry.get("win_rate", 0)
843
+ roi = entry.get("roi", 0)
844
+ print(
845
+ f" {i:<4} {agent + tag:<24} ${balance:>9.2f} "
846
+ f"{'+' if pnl >= 0 else ''}{pnl:>9.2f} {bets:>6} {wr:>5.0%} {roi:>6.0%}"
847
+ )
669
848
 
670
849
  print()
671
850
  print(" * = external agent")
@@ -851,7 +1030,7 @@ def cmd_daemon_start(args: argparse.Namespace) -> int:
851
1030
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
852
1031
  )
853
1032
 
854
- daemon = UnifiedDaemon()
1033
+ daemon = UnifiedDaemon(profile=profile)
855
1034
  try:
856
1035
  asyncio.run(daemon.start())
857
1036
  except KeyboardInterrupt:
@@ -1120,6 +1299,26 @@ def create_parser() -> argparse.ArgumentParser:
1120
1299
  )
1121
1300
  p_bet.set_defaults(func=cmd_bet)
1122
1301
 
1302
+ # predict (prediction mode)
1303
+ p_predict = subparsers.add_parser("predict", help="Submit a prediction")
1304
+ p_predict.add_argument(
1305
+ "trial_id", nargs="?", help="Trial ID (optional if only one running)"
1306
+ )
1307
+ p_predict.add_argument(
1308
+ "selection",
1309
+ choices=["home_win", "away_win", "even"],
1310
+ help="Prediction selection",
1311
+ )
1312
+ p_predict.set_defaults(func=cmd_predict)
1313
+
1314
+ # predictions (prediction mode)
1315
+ p_preds = subparsers.add_parser("predictions", help="Show prediction history")
1316
+ p_preds.add_argument(
1317
+ "trial_id", nargs="?", help="Trial ID (optional if only one running)"
1318
+ )
1319
+ p_preds.add_argument("-n", "--count", type=int, default=20, help="Number to show")
1320
+ p_preds.set_defaults(func=cmd_predictions)
1321
+
1123
1322
  # events
1124
1323
  p_events = subparsers.add_parser("events", help="Show event log")
1125
1324
  p_events.add_argument(