draftomen 0.3.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.
Files changed (59) hide show
  1. draftomen/__init__.py +17 -0
  2. draftomen/assets/draftomen.icns +0 -0
  3. draftomen/assets/draftomen.ico +0 -0
  4. draftomen/assets/draftomen_logo.png +0 -0
  5. draftomen/audit.py +606 -0
  6. draftomen/backtest.py +449 -0
  7. draftomen/benchmark.py +834 -0
  8. draftomen/carddb.py +1869 -0
  9. draftomen/cardimages.py +313 -0
  10. draftomen/cli.py +891 -0
  11. draftomen/config.py +133 -0
  12. draftomen/deckbuilder.py +2723 -0
  13. draftomen/events.py +772 -0
  14. draftomen/logfollow.py +451 -0
  15. draftomen/mock_session.py +745 -0
  16. draftomen/paths.py +120 -0
  17. draftomen/pickengine.py +1117 -0
  18. draftomen/pool.py +1259 -0
  19. draftomen/preferences.py +289 -0
  20. draftomen/qml/AboutDialog.qml +156 -0
  21. draftomen/qml/AppBar.qml +120 -0
  22. draftomen/qml/BacktestView.qml +316 -0
  23. draftomen/qml/BuildView.qml +1151 -0
  24. draftomen/qml/CardPreview.qml +434 -0
  25. draftomen/qml/DimensionalButton.qml +42 -0
  26. draftomen/qml/DimensionalComboBox.qml +182 -0
  27. draftomen/qml/DimensionalSurface.qml +118 -0
  28. draftomen/qml/DimensionalTabButton.qml +41 -0
  29. draftomen/qml/LiveDraftView.qml +468 -0
  30. draftomen/qml/Main.qml +175 -0
  31. draftomen/qml/NavigationRail.qml +226 -0
  32. draftomen/qml/PoolSummaryPanel.qml +322 -0
  33. draftomen/qml/PrivacyDialog.qml +96 -0
  34. draftomen/qml/RecentPickThumbnail.qml +83 -0
  35. draftomen/qml/RecentPicksGallery.qml +333 -0
  36. draftomen/qml/RecommendationRow.qml +404 -0
  37. draftomen/qml/SettingsSwitch.qml +141 -0
  38. draftomen/qml/SettingsView.qml +531 -0
  39. draftomen/qml/StateBanner.qml +189 -0
  40. draftomen/qml/StatusStrip.qml +84 -0
  41. draftomen/qml/Theme.qml +71 -0
  42. draftomen/qml/qmldir +23 -0
  43. draftomen/qt_adapter.py +884 -0
  44. draftomen/qt_gui.py +338 -0
  45. draftomen/qt_mock.py +63 -0
  46. draftomen/ranking.py +126 -0
  47. draftomen/replay.py +480 -0
  48. draftomen/session.py +3668 -0
  49. draftomen/setinfo.py +26 -0
  50. draftomen/seventeen.py +2766 -0
  51. draftomen/splash.py +617 -0
  52. draftomen/tui.py +4007 -0
  53. draftomen/watch.py +336 -0
  54. draftomen-0.3.0.dist-info/METADATA +156 -0
  55. draftomen-0.3.0.dist-info/RECORD +59 -0
  56. draftomen-0.3.0.dist-info/WHEEL +5 -0
  57. draftomen-0.3.0.dist-info/entry_points.txt +6 -0
  58. draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
  59. draftomen-0.3.0.dist-info/top_level.txt +1 -0
draftomen/backtest.py ADDED
@@ -0,0 +1,449 @@
1
+ """Post-draft recommendation backtesting.
2
+ Replay saved picks without mutating draft state.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from os import PathLike
9
+ from typing import TypeAlias
10
+
11
+ from draftomen.carddb import CardDatabase, CardInfo
12
+ from draftomen.events import EXPECTED_PICKS_PER_PACK
13
+ from draftomen.pickengine import PickEngine, ScoredCard
14
+ from draftomen.pool import DraftPick, DraftState, list_draft_states
15
+ from draftomen.ranking import DEFAULT_RANKING_MODE, rank_scored_cards, ranking_label
16
+ from draftomen.replay import format_card_info
17
+ from draftomen.seventeen import SeventeenLandsData
18
+
19
+ PathInput: TypeAlias = str | PathLike[str]
20
+
21
+
22
+ class BacktestError(RuntimeError):
23
+ """Raised when a persisted draft cannot be backtested.
24
+ CLI callers surface this as a concise diagnostic.
25
+ """
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class BacktestPickResult:
30
+ """Recommendation comparison for one saved draft pick.
31
+ Missing history keeps a skipped row instead of mutating state.
32
+ """
33
+
34
+ pack_number: int
35
+ pick_number: int
36
+ pool_size: int | None
37
+ offered_count: int | None
38
+ recommended: ScoredCard | None
39
+ actual: CardInfo | None
40
+ match: bool | None
41
+ skipped_reason: str | None
42
+ data_source: str | None
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class BacktestReport:
47
+ """Backtest rows plus the persisted draft metadata.
48
+ Derived counts keep formatting and tests simple.
49
+ """
50
+
51
+ state: DraftState
52
+ rows: tuple[BacktestPickResult, ...]
53
+ ranking_mode: str
54
+
55
+ @property
56
+ def compared_rows(self) -> tuple[BacktestPickResult, ...]:
57
+ """Return rows where recommendation and actual pick can be compared.
58
+ Rows skipped for missing history are excluded.
59
+ """
60
+
61
+ return tuple(row for row in self.rows if row.match is not None)
62
+
63
+ @property
64
+ def skipped_rows(self) -> tuple[BacktestPickResult, ...]:
65
+ """Return rows that could not produce a comparison.
66
+ The row itself carries the user-facing reason.
67
+ """
68
+
69
+ return tuple(row for row in self.rows if row.match is None)
70
+
71
+ @property
72
+ def match_count(self) -> int:
73
+ """Return how many comparable picks matched Draftomen's recommendation.
74
+ A skipped pick is not counted as a miss.
75
+ """
76
+
77
+ return sum(1 for row in self.rows if row.match is True)
78
+
79
+ @property
80
+ def data_sources(self) -> tuple[str, ...]:
81
+ """Return unique pack data-source summaries in first-seen order.
82
+ Missing-history rows do not contribute a source label.
83
+ """
84
+
85
+ sources: list[str] = []
86
+ for row in self.rows:
87
+ if row.data_source is None or row.data_source in sources:
88
+ continue
89
+
90
+ sources.append(row.data_source)
91
+
92
+ return tuple(sources)
93
+
94
+
95
+ def load_persisted_backtest_state(
96
+ *,
97
+ app_dir: PathInput | None = None,
98
+ account_id: str | None = None,
99
+ draft_id: str | None = None,
100
+ ) -> DraftState:
101
+ """Load the requested persisted draft state, defaulting to latest.
102
+ Account and draft filters disambiguate local multi-account state.
103
+ """
104
+
105
+ matches = _matching_states(
106
+ states=list_draft_states(app_dir=app_dir),
107
+ account_id=account_id,
108
+ draft_id=draft_id,
109
+ )
110
+ if not matches:
111
+ raise BacktestError(
112
+ _missing_persisted_draft_message(account_id=account_id, draft_id=draft_id)
113
+ )
114
+
115
+ if draft_id is not None and len(matches) > 1:
116
+ raise BacktestError(
117
+ f"Multiple persisted drafts use draft id {draft_id!r}; pass --account."
118
+ )
119
+
120
+ return _latest_state(states=matches)
121
+
122
+
123
+ def generate_backtest_report(
124
+ *,
125
+ state: DraftState,
126
+ card_database: CardDatabase,
127
+ ratings_data: SeventeenLandsData | None = None,
128
+ pick_engine: PickEngine | None = None,
129
+ ranking_mode: str = DEFAULT_RANKING_MODE,
130
+ splash_enabled: bool = True,
131
+ ) -> BacktestReport:
132
+ """Score each saved pick from the persisted pre-pick state.
133
+ The function is read-only and never writes draft state.
134
+ """
135
+
136
+ engine = (
137
+ pick_engine
138
+ if pick_engine is not None
139
+ else PickEngine(
140
+ ratings_data=ratings_data,
141
+ splash_enabled=splash_enabled,
142
+ )
143
+ )
144
+ rows = tuple(
145
+ _score_pick(
146
+ pick=pick,
147
+ card_database=card_database,
148
+ pick_engine=engine,
149
+ ranking_mode=ranking_mode,
150
+ )
151
+ for pick in state.picks
152
+ )
153
+ return BacktestReport(state=state, rows=rows, ranking_mode=ranking_mode)
154
+
155
+
156
+ def format_backtest_report(report: BacktestReport) -> str:
157
+ """Format a post-draft recommendation comparison report.
158
+ The output is stable plain text for CLI use and regression tests.
159
+ """
160
+
161
+ lines = _format_header(report=report)
162
+ lines.append("")
163
+ lines.extend(_format_rows(report=report))
164
+ lines.append("")
165
+ lines.extend(_format_summary(report=report))
166
+ return "\n".join(lines).rstrip() + "\n"
167
+
168
+
169
+ def _score_pick(
170
+ *,
171
+ pick: DraftPick,
172
+ card_database: CardDatabase,
173
+ pick_engine: PickEngine,
174
+ ranking_mode: str,
175
+ ) -> BacktestPickResult:
176
+ actual = (
177
+ card_database.lookup(grp_id=pick.chosen_grp_id)
178
+ if pick.chosen_grp_id is not None
179
+ else None
180
+ )
181
+ if pick.offered_grp_ids is None:
182
+ return _skipped_result(
183
+ pick=pick,
184
+ actual=actual,
185
+ reason="missing offered-card history",
186
+ )
187
+
188
+ if not pick.offered_grp_ids:
189
+ return _skipped_result(
190
+ pick=pick,
191
+ actual=actual,
192
+ reason="empty offered-card history",
193
+ offered_count=0,
194
+ )
195
+
196
+ if pick.pool_before_pick is None:
197
+ return _skipped_result(
198
+ pick=pick,
199
+ actual=actual,
200
+ reason="missing pool-before-pick snapshot",
201
+ offered_count=len(pick.offered_grp_ids),
202
+ )
203
+
204
+ scored_pack = pick_engine.score_pack(
205
+ offered_grp_ids=pick.offered_grp_ids,
206
+ card_database=card_database,
207
+ pool_grp_ids=pick.pool_before_pick,
208
+ pick_index=_draft_pick_index(pick=pick),
209
+ )
210
+ ranked_cards = rank_scored_cards(
211
+ cards=scored_pack.cards,
212
+ ranking_mode=ranking_mode,
213
+ )
214
+ recommended = ranked_cards[0] if ranked_cards else None
215
+ if recommended is None:
216
+ return _skipped_result(
217
+ pick=pick,
218
+ actual=actual,
219
+ reason="no recommended card",
220
+ pool_size=len(pick.pool_before_pick),
221
+ offered_count=len(pick.offered_grp_ids),
222
+ )
223
+
224
+ if pick.chosen_grp_id is None:
225
+ return BacktestPickResult(
226
+ pack_number=pick.pack_number,
227
+ pick_number=pick.pick_number,
228
+ pool_size=len(pick.pool_before_pick),
229
+ offered_count=len(pick.offered_grp_ids),
230
+ recommended=recommended,
231
+ actual=None,
232
+ match=None,
233
+ skipped_reason="missing actual selected card",
234
+ data_source=scored_pack.source_summary,
235
+ )
236
+
237
+ return BacktestPickResult(
238
+ pack_number=pick.pack_number,
239
+ pick_number=pick.pick_number,
240
+ pool_size=len(pick.pool_before_pick),
241
+ offered_count=len(pick.offered_grp_ids),
242
+ recommended=recommended,
243
+ actual=actual,
244
+ match=recommended.card.grp_id == pick.chosen_grp_id,
245
+ skipped_reason=None,
246
+ data_source=scored_pack.source_summary,
247
+ )
248
+
249
+
250
+ def _skipped_result(
251
+ *,
252
+ pick: DraftPick,
253
+ actual: CardInfo | None,
254
+ reason: str,
255
+ pool_size: int | None = None,
256
+ offered_count: int | None = None,
257
+ ) -> BacktestPickResult:
258
+ return BacktestPickResult(
259
+ pack_number=pick.pack_number,
260
+ pick_number=pick.pick_number,
261
+ pool_size=pool_size,
262
+ offered_count=offered_count,
263
+ recommended=None,
264
+ actual=actual,
265
+ match=None,
266
+ skipped_reason=reason,
267
+ data_source=None,
268
+ )
269
+
270
+
271
+ def _draft_pick_index(*, pick: DraftPick) -> int:
272
+ return (pick.pack_number * EXPECTED_PICKS_PER_PACK) + pick.pick_number + 1
273
+
274
+
275
+ def _format_header(*, report: BacktestReport) -> list[str]:
276
+ state = report.state
277
+ return [
278
+ "Draft Omen backtest",
279
+ f"Account: {_format_account(state=state)}",
280
+ f"Set: {state.set_code}",
281
+ f"Event: {state.event_name}",
282
+ f"Draft: {state.draft_id}",
283
+ f"Completed: {_yes_no(state.completed)}",
284
+ f"Ranking: {ranking_label(ranking_mode=report.ranking_mode)}",
285
+ (
286
+ "Picks: "
287
+ f"{state.chosen_pick_count} chosen, "
288
+ f"{len(report.compared_rows)} compared, "
289
+ f"{len(report.skipped_rows)} skipped"
290
+ ),
291
+ f"Data sources: {_format_data_sources(report=report)}",
292
+ ]
293
+
294
+
295
+ def _format_rows(*, report: BacktestReport) -> list[str]:
296
+ if not report.rows:
297
+ return ["No saved picks were found for this draft."]
298
+
299
+ recommended_values = tuple(_format_recommended(row=row) for row in report.rows)
300
+ actual_values = tuple(_format_actual(row=row) for row in report.rows)
301
+ recommended_width = max(len("Recommended"), *(len(value) for value in recommended_values))
302
+ actual_width = max(len("Actual"), *(len(value) for value in actual_values))
303
+ lines = [
304
+ "Pack Pick Pool 17L WR DO "
305
+ f"{'Recommended':<{recommended_width}} "
306
+ f"{'Actual':<{actual_width}} "
307
+ "Match"
308
+ ]
309
+ for row, recommended, actual in zip(
310
+ report.rows,
311
+ recommended_values,
312
+ actual_values,
313
+ strict=True,
314
+ ):
315
+ lines.append(
316
+ f"{row.pack_number + 1:>4} "
317
+ f"{row.pick_number + 1:>4} "
318
+ f"{_format_optional_int(row.pool_size):>4} "
319
+ f"{_format_win_rate(row=row):>6} "
320
+ f"{_format_score(row=row):>3} "
321
+ f"{recommended:<{recommended_width}} "
322
+ f"{actual:<{actual_width}} "
323
+ f"{_format_match(row=row)}"
324
+ )
325
+
326
+ return lines
327
+
328
+
329
+ def _format_summary(*, report: BacktestReport) -> list[str]:
330
+ compared_count = len(report.compared_rows)
331
+ if compared_count == 0:
332
+ lines = [
333
+ "Summary: no comparable picks; "
334
+ f"{len(report.skipped_rows)} skipped."
335
+ ]
336
+ else:
337
+ match_rate = report.match_count / compared_count
338
+ lines = [
339
+ "Summary: "
340
+ f"{report.match_count}/{compared_count} recommendations matched "
341
+ f"actual picks ({match_rate:.1%})."
342
+ ]
343
+
344
+ if report.skipped_rows:
345
+ lines.append(
346
+ "Skipped picks were not scored when saved offered-card or "
347
+ "pool-before-pick history was missing."
348
+ )
349
+
350
+ return lines
351
+
352
+
353
+ def _format_recommended(*, row: BacktestPickResult) -> str:
354
+ if row.recommended is not None:
355
+ return format_card_info(row.recommended.card)
356
+
357
+ reason = row.skipped_reason or "not scored"
358
+ return f"skipped: {reason}"
359
+
360
+
361
+ def _format_actual(*, row: BacktestPickResult) -> str:
362
+ if row.actual is not None:
363
+ return format_card_info(row.actual)
364
+
365
+ return row.skipped_reason or "missing actual selected card"
366
+
367
+
368
+ def _format_win_rate(*, row: BacktestPickResult) -> str:
369
+ if row.recommended is None or row.recommended.rating.gih_win_rate is None:
370
+ return "—"
371
+
372
+ return f"{row.recommended.rating.gih_win_rate:.1%}"
373
+
374
+
375
+ def _format_score(*, row: BacktestPickResult) -> str:
376
+ if row.recommended is None:
377
+ return "—"
378
+
379
+ return str(row.recommended.score)
380
+
381
+
382
+ def _format_match(*, row: BacktestPickResult) -> str:
383
+ if row.match is None:
384
+ return "skipped"
385
+
386
+ return _yes_no(row.match)
387
+
388
+
389
+ def _format_optional_int(value: int | None) -> str:
390
+ if value is None:
391
+ return "—"
392
+
393
+ return str(value)
394
+
395
+
396
+ def _format_account(*, state: DraftState) -> str:
397
+ if state.account_screen_name is None:
398
+ return state.account_id
399
+
400
+ return f"{state.account_screen_name} ({state.account_id})"
401
+
402
+
403
+ def _format_data_sources(*, report: BacktestReport) -> str:
404
+ if not report.data_sources:
405
+ return "none"
406
+
407
+ return "; ".join(report.data_sources)
408
+
409
+
410
+ def _yes_no(value: bool) -> str:
411
+ return "yes" if value else "no"
412
+
413
+
414
+ def _matching_states(
415
+ *,
416
+ states: tuple[DraftState, ...],
417
+ account_id: str | None,
418
+ draft_id: str | None,
419
+ ) -> tuple[DraftState, ...]:
420
+ return tuple(
421
+ state
422
+ for state in states
423
+ if (account_id is None or state.account_id == account_id)
424
+ and (draft_id is None or state.draft_id == draft_id)
425
+ )
426
+
427
+
428
+ def _latest_state(*, states: tuple[DraftState, ...]) -> DraftState:
429
+ return max(
430
+ states,
431
+ key=lambda state: (state.updated_at, state.account_id, state.draft_id),
432
+ )
433
+
434
+
435
+ def _missing_persisted_draft_message(
436
+ *,
437
+ account_id: str | None,
438
+ draft_id: str | None,
439
+ ) -> str:
440
+ if account_id is not None and draft_id is not None:
441
+ return f"No persisted draft found for account {account_id!r} and draft {draft_id!r}."
442
+
443
+ if account_id is not None:
444
+ return f"No persisted draft found for account {account_id!r}."
445
+
446
+ if draft_id is not None:
447
+ return f"No persisted draft found for draft {draft_id!r}."
448
+
449
+ return "No persisted drafts found. Replay/watch a draft first."