analysis-poly 0.1.2__tar.gz → 0.1.4__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 (44) hide show
  1. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/PKG-INFO +1 -1
  2. analysis_poly-0.1.4/analysis_poly/activity_discovery.py +541 -0
  3. analysis_poly-0.1.4/analysis_poly/activity_page_cache.py +320 -0
  4. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/analyzer.py +111 -29
  5. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/models.py +1 -0
  6. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/polymarket_client.py +31 -1
  7. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/profit_engine.py +30 -5
  8. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/run_manager.py +64 -2
  9. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/static/dist/app.js +80 -80
  10. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/PKG-INFO +1 -1
  11. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/SOURCES.txt +2 -0
  12. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/pyproject.toml +1 -1
  13. analysis_poly-0.1.4/tests/test_activity_page_cache.py +555 -0
  14. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_discovery.py +204 -13
  15. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_profit_engine.py +154 -0
  16. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_run_manager.py +49 -0
  17. analysis_poly-0.1.2/analysis_poly/activity_discovery.py +0 -230
  18. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/README.md +0 -0
  19. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/__init__.py +0 -0
  20. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/cli.py +0 -0
  21. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/logging_config.py +0 -0
  22. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/main.py +0 -0
  23. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/market_cache.py +0 -0
  24. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/market_result_cache.py +0 -0
  25. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/open_with_params.py +0 -0
  26. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/slugs.py +0 -0
  27. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/static/dist/app.css +0 -0
  28. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/templates/index.html +0 -0
  29. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/web.py +0 -0
  30. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/dependency_links.txt +0 -0
  31. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/entry_points.txt +0 -0
  32. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/requires.txt +0 -0
  33. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/top_level.txt +0 -0
  34. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/setup.cfg +0 -0
  35. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_chunking.py +0 -0
  36. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_csv.py +0 -0
  37. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_filter_empty_market.py +0 -0
  38. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_market_cache.py +0 -0
  39. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_result_cache_compat.py +0 -0
  40. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_market_cache.py +0 -0
  41. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_market_result_cache.py +0 -0
  42. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_models_defaults.py +0 -0
  43. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_open_with_params.py +0 -0
  44. {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_slugs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: analysis-poly
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Polymarket real-profit web analyzer
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -0,0 +1,541 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from dataclasses import dataclass
6
+
7
+ from loguru import logger
8
+
9
+ from .models import WarningItem
10
+
11
+ DISCOVERY_ACTIVITY_TYPES = ("TRADE", "SPLIT", "REDEEM")
12
+ DISCOVERY_ACTIVITY_PAGE_LIMIT_MAX = 500
13
+ DISCOVERY_ACTIVITY_OFFSET_MAX = 10000
14
+ DISCOVERY_ACTIVITY_WINDOW_SEC = 2 * 60 * 60
15
+ DAY_WINDOW_SEC = 24 * 60 * 60
16
+ WEEK_WINDOW_SEC = 7 * DAY_WINDOW_SEC
17
+
18
+
19
+ @dataclass
20
+ class DiscoveredMarket:
21
+ slug: str
22
+ condition_id: str
23
+ first_activity_ts: int
24
+ last_activity_ts: int
25
+
26
+
27
+ async def collect_user_activity(
28
+ client,
29
+ address: str,
30
+ start_ts: int,
31
+ end_ts: int,
32
+ page_limit: int,
33
+ warnings: list[WarningItem],
34
+ activity_types: list[str] | tuple[str, ...] | None = None,
35
+ log_detail: bool = True,
36
+ allow_range_cache: bool = True,
37
+ ) -> list:
38
+ started = time.perf_counter()
39
+ normalized_activity_types = tuple(activity_types or DISCOVERY_ACTIVITY_TYPES)
40
+ cache = getattr(client, "_activity_page_cache", None)
41
+ now_ts = int(time.time())
42
+ use_range_cache = (
43
+ allow_range_cache
44
+ and cache is not None
45
+ and start_ts is not None
46
+ and end_ts is not None
47
+ and cache.is_cache_eligible(end_ts=end_ts, now_ts=now_ts)
48
+ )
49
+
50
+ if use_range_cache:
51
+ cached_records, missing_segments = cache.load_range(
52
+ user=address,
53
+ activity_types=normalized_activity_types,
54
+ start_ts=start_ts,
55
+ end_ts=end_ts,
56
+ sort_direction="ASC",
57
+ )
58
+ if not missing_segments:
59
+ deduped = dedupe_activity_records(cached_records)
60
+ if log_detail:
61
+ logger.info(
62
+ "activity range cache hit address={} types={} range=[{}, {}] cached_records={} elapsed_sec={:.3f}",
63
+ address,
64
+ ",".join(normalized_activity_types),
65
+ start_ts,
66
+ end_ts,
67
+ len(deduped),
68
+ time.perf_counter() - started,
69
+ )
70
+ return deduped
71
+
72
+ if cached_records and log_detail:
73
+ logger.info(
74
+ "activity range cache partial hit address={} types={} range=[{}, {}] cached_records={} missing_segments={} elapsed_sec={:.3f}",
75
+ address,
76
+ ",".join(normalized_activity_types),
77
+ start_ts,
78
+ end_ts,
79
+ len(cached_records),
80
+ len(missing_segments),
81
+ time.perf_counter() - started,
82
+ )
83
+
84
+ records = list(cached_records)
85
+ for missing_start, missing_end in missing_segments:
86
+ records.extend(
87
+ await collect_user_activity(
88
+ client=client,
89
+ address=address,
90
+ start_ts=missing_start,
91
+ end_ts=missing_end,
92
+ page_limit=page_limit,
93
+ warnings=warnings,
94
+ activity_types=normalized_activity_types,
95
+ log_detail=False,
96
+ allow_range_cache=False,
97
+ )
98
+ )
99
+ deduped = dedupe_activity_records(records)
100
+ cache.save_range(
101
+ user=address,
102
+ activity_types=normalized_activity_types,
103
+ start_ts=start_ts,
104
+ end_ts=end_ts,
105
+ sort_direction="ASC",
106
+ records=deduped,
107
+ )
108
+ if log_detail:
109
+ logger.info(
110
+ "activity collect done address={} types={} range=[{}, {}] cache_mode=range records={} deduped={} elapsed_sec={:.3f}",
111
+ address,
112
+ ",".join(normalized_activity_types),
113
+ start_ts,
114
+ end_ts,
115
+ len(records),
116
+ len(deduped),
117
+ time.perf_counter() - started,
118
+ )
119
+ return deduped
120
+
121
+ records: list = []
122
+ offset = 0
123
+ reached_offset_cap = False
124
+ request_count = 0
125
+
126
+ while True:
127
+ request_count += 1
128
+ page = await client.get_user_activity_page(
129
+ user=address,
130
+ activity_types=list(normalized_activity_types),
131
+ start_ts=start_ts,
132
+ end_ts=end_ts,
133
+ limit=page_limit,
134
+ offset=offset,
135
+ sort_direction="ASC",
136
+ )
137
+ if not page:
138
+ break
139
+ records.extend(page)
140
+ if len(page) < page_limit:
141
+ break
142
+ offset += len(page)
143
+ if offset > DISCOVERY_ACTIVITY_OFFSET_MAX:
144
+ reached_offset_cap = True
145
+ break
146
+
147
+ if not reached_offset_cap:
148
+ deduped = dedupe_activity_records(records)
149
+ if log_detail:
150
+ logger.info(
151
+ "activity collect done address={} types={} range=[{}, {}] requests={} records={} deduped={} elapsed_sec={:.3f}",
152
+ address,
153
+ ",".join(normalized_activity_types),
154
+ start_ts,
155
+ end_ts,
156
+ request_count,
157
+ len(records),
158
+ len(deduped),
159
+ time.perf_counter() - started,
160
+ )
161
+ if cache is not None and start_ts is not None and end_ts is not None and cache.is_cache_eligible(end_ts=end_ts, now_ts=now_ts):
162
+ cache.save_range(
163
+ user=address,
164
+ activity_types=normalized_activity_types,
165
+ start_ts=start_ts,
166
+ end_ts=end_ts,
167
+ sort_direction="ASC",
168
+ records=deduped,
169
+ )
170
+ return deduped
171
+
172
+ if end_ts - start_ts <= 1:
173
+ warnings.append(
174
+ WarningItem(
175
+ timestamp=start_ts,
176
+ code="DISCOVERY_WINDOW_TRUNCATED",
177
+ message="user activity window is too dense to split further; discovery may be incomplete",
178
+ )
179
+ )
180
+ deduped = dedupe_activity_records(records)
181
+ logger.warning(
182
+ "activity collect truncated address={} types={} range=[{}, {}] requests={} records={} deduped={} elapsed_sec={:.3f}",
183
+ address,
184
+ ",".join(normalized_activity_types),
185
+ start_ts,
186
+ end_ts,
187
+ request_count,
188
+ len(records),
189
+ len(deduped),
190
+ time.perf_counter() - started,
191
+ )
192
+ return deduped
193
+
194
+ split_ts = start_ts + ((end_ts - start_ts) // 2)
195
+ logger.info(
196
+ "activity collect split address={} types={} range=[{}, {}] requests={} records={} split_ts={} elapsed_sec={:.3f}",
197
+ address,
198
+ ",".join(normalized_activity_types),
199
+ start_ts,
200
+ end_ts,
201
+ request_count,
202
+ len(records),
203
+ split_ts,
204
+ time.perf_counter() - started,
205
+ )
206
+ left_records, right_records = await asyncio.gather(
207
+ collect_user_activity(
208
+ client=client,
209
+ address=address,
210
+ start_ts=start_ts,
211
+ end_ts=split_ts,
212
+ page_limit=page_limit,
213
+ warnings=warnings,
214
+ activity_types=activity_types,
215
+ log_detail=log_detail,
216
+ allow_range_cache=False,
217
+ ),
218
+ collect_user_activity(
219
+ client=client,
220
+ address=address,
221
+ start_ts=split_ts + 1,
222
+ end_ts=end_ts,
223
+ page_limit=page_limit,
224
+ warnings=warnings,
225
+ activity_types=activity_types,
226
+ log_detail=log_detail,
227
+ allow_range_cache=False,
228
+ ),
229
+ )
230
+ deduped = dedupe_activity_records([*left_records, *right_records])
231
+ if cache is not None and start_ts is not None and end_ts is not None and cache.is_cache_eligible(end_ts=end_ts, now_ts=now_ts):
232
+ cache.save_range(
233
+ user=address,
234
+ activity_types=normalized_activity_types,
235
+ start_ts=start_ts,
236
+ end_ts=end_ts,
237
+ sort_direction="ASC",
238
+ records=deduped,
239
+ )
240
+ return deduped
241
+
242
+
243
+ async def collect_user_activity_for_windows(
244
+ client,
245
+ address: str,
246
+ windows: list[tuple[int, int]],
247
+ page_limit: int,
248
+ warnings: list[WarningItem],
249
+ activity_types: list[str] | tuple[str, ...],
250
+ label: str,
251
+ ) -> list:
252
+ if not windows:
253
+ return []
254
+
255
+ started = time.perf_counter()
256
+ normalized_activity_types = tuple(activity_types or DISCOVERY_ACTIVITY_TYPES)
257
+ overall_start = min(window_start for window_start, _ in windows)
258
+ overall_end = max(window_end for _, window_end in windows)
259
+ cache = getattr(client, "_activity_page_cache", None)
260
+ now_ts = int(time.time())
261
+
262
+ records = []
263
+ missing_window_segments = list(windows)
264
+ if cache is not None:
265
+ cached_records, missing_segments = cache.load_range(
266
+ user=address,
267
+ activity_types=normalized_activity_types,
268
+ start_ts=overall_start,
269
+ end_ts=overall_end,
270
+ sort_direction="ASC",
271
+ )
272
+ records.extend(cached_records)
273
+ missing_window_segments = _intersect_segments_with_windows(missing_segments, windows)
274
+ logger.info(
275
+ "activity window group cache check address={} label={} types={} cached_records={} missing_segments={} missing_window_segments={} elapsed_sec={:.3f}",
276
+ address,
277
+ label,
278
+ ",".join(normalized_activity_types),
279
+ len(cached_records),
280
+ len(missing_segments),
281
+ len(missing_window_segments),
282
+ time.perf_counter() - started,
283
+ )
284
+
285
+ for window_start, window_end in missing_window_segments:
286
+ records.extend(
287
+ await collect_user_activity(
288
+ client=client,
289
+ address=address,
290
+ start_ts=window_start,
291
+ end_ts=window_end,
292
+ page_limit=page_limit,
293
+ warnings=warnings,
294
+ activity_types=normalized_activity_types,
295
+ log_detail=False,
296
+ allow_range_cache=False,
297
+ )
298
+ )
299
+
300
+ deduped = dedupe_activity_records(records)
301
+ if (
302
+ cache is not None
303
+ and cache.is_cache_eligible(end_ts=overall_end, now_ts=now_ts)
304
+ ):
305
+ cache.save_range(
306
+ user=address,
307
+ activity_types=normalized_activity_types,
308
+ start_ts=overall_start,
309
+ end_ts=overall_end,
310
+ sort_direction="ASC",
311
+ records=deduped,
312
+ )
313
+ logger.info(
314
+ "activity window group done address={} label={} types={} windows={} fetched_segments={} records={} deduped={} elapsed_sec={:.3f}",
315
+ address,
316
+ label,
317
+ ",".join(normalized_activity_types),
318
+ len(windows),
319
+ len(missing_window_segments),
320
+ len(records),
321
+ len(deduped),
322
+ time.perf_counter() - started,
323
+ )
324
+ return deduped
325
+
326
+
327
+ async def discover_user_markets_in_range(
328
+ client,
329
+ address: str,
330
+ start_ts: int,
331
+ end_ts: int,
332
+ page_limit: int,
333
+ warnings: list[WarningItem],
334
+ ) -> list[DiscoveredMarket]:
335
+ records = await collect_discovery_activity_by_policy(
336
+ client=client,
337
+ address=address,
338
+ start_ts=start_ts,
339
+ end_ts=end_ts,
340
+ page_limit=page_limit,
341
+ warnings=warnings,
342
+ )
343
+ return summarize_discovered_markets(records, warnings)
344
+
345
+
346
+ async def discover_user_markets_by_day(
347
+ client,
348
+ address: str,
349
+ start_ts: int,
350
+ end_ts: int,
351
+ page_limit: int,
352
+ warnings: list[WarningItem],
353
+ ) -> list[DiscoveredMarket]:
354
+ market_by_key: dict[str, DiscoveredMarket] = {}
355
+ records = await collect_discovery_activity_by_policy(
356
+ client=client,
357
+ address=address,
358
+ start_ts=start_ts,
359
+ end_ts=end_ts,
360
+ page_limit=page_limit,
361
+ warnings=warnings,
362
+ )
363
+ for discovered in summarize_discovered_markets(records, warnings):
364
+ market_key = discovered.condition_id or discovered.slug
365
+ existing = market_by_key.get(market_key)
366
+ if existing is None:
367
+ market_by_key[market_key] = discovered
368
+ continue
369
+ existing.first_activity_ts = min(existing.first_activity_ts, discovered.first_activity_ts)
370
+ existing.last_activity_ts = max(existing.last_activity_ts, discovered.last_activity_ts)
371
+ if not existing.slug and discovered.slug:
372
+ existing.slug = discovered.slug
373
+
374
+ return sorted(
375
+ market_by_key.values(),
376
+ key=lambda item: (item.first_activity_ts, item.last_activity_ts, item.slug),
377
+ )
378
+
379
+
380
+ async def collect_discovery_activity_by_policy(
381
+ client,
382
+ address: str,
383
+ start_ts: int,
384
+ end_ts: int,
385
+ page_limit: int,
386
+ warnings: list[WarningItem],
387
+ ) -> list:
388
+ capped_page_limit = min(max(1, page_limit), DISCOVERY_ACTIVITY_PAGE_LIMIT_MAX)
389
+ trade_records = await collect_user_activity_for_windows(
390
+ client=client,
391
+ address=address,
392
+ windows=iter_day_windows(start_ts, end_ts),
393
+ page_limit=capped_page_limit,
394
+ warnings=warnings,
395
+ activity_types=("TRADE",),
396
+ label="trade_2h",
397
+ )
398
+ split_redeem_records = await collect_user_activity_for_windows(
399
+ client=client,
400
+ address=address,
401
+ windows=iter_calendar_day_windows(start_ts, end_ts),
402
+ page_limit=capped_page_limit,
403
+ warnings=warnings,
404
+ activity_types=("SPLIT", "REDEEM"),
405
+ label="split_redeem_1d",
406
+ )
407
+ return dedupe_activity_records([*trade_records, *split_redeem_records])
408
+
409
+
410
+ def summarize_discovered_markets(records: list, warnings: list[WarningItem]) -> list[DiscoveredMarket]:
411
+ market_by_key: dict[str, DiscoveredMarket] = {}
412
+ warned_missing_slug: set[str] = set()
413
+
414
+ for record in records:
415
+ if _is_zero_value_redeem(record):
416
+ continue
417
+
418
+ slug = str(record.slug or "").strip()
419
+ condition_id = str(record.condition_id or "").strip()
420
+ if not slug:
421
+ dedupe_key = f"{condition_id}:{record.type}"
422
+ if dedupe_key not in warned_missing_slug:
423
+ warnings.append(
424
+ WarningItem(
425
+ timestamp=record.timestamp,
426
+ code="DISCOVERY_SKIP_MISSING_SLUG",
427
+ message="skip user activity record without market slug",
428
+ )
429
+ )
430
+ warned_missing_slug.add(dedupe_key)
431
+ continue
432
+
433
+ market_key = condition_id or slug
434
+ existing = market_by_key.get(market_key)
435
+ if existing is None:
436
+ market_by_key[market_key] = DiscoveredMarket(
437
+ slug=slug,
438
+ condition_id=condition_id,
439
+ first_activity_ts=record.timestamp,
440
+ last_activity_ts=record.timestamp,
441
+ )
442
+ continue
443
+
444
+ existing.first_activity_ts = min(existing.first_activity_ts, record.timestamp)
445
+ existing.last_activity_ts = max(existing.last_activity_ts, record.timestamp)
446
+ if not existing.slug and slug:
447
+ existing.slug = slug
448
+
449
+ return sorted(
450
+ market_by_key.values(),
451
+ key=lambda item: (item.first_activity_ts, item.last_activity_ts, item.slug),
452
+ )
453
+
454
+
455
+ def _is_zero_value_redeem(record) -> bool:
456
+ if str(getattr(record, "type", "")).upper() != "REDEEM":
457
+ return False
458
+ size = float(getattr(record, "size", 0) or 0)
459
+ usdc_size = float(getattr(record, "usdc_size", 0) or 0)
460
+ return size <= 0 and usdc_size <= 0
461
+
462
+
463
+ def dedupe_activity_records(records: list) -> list:
464
+ deduped: dict[str, object] = {}
465
+ for record in records:
466
+ deduped[activity_key(record)] = record
467
+ return sorted(
468
+ deduped.values(),
469
+ key=lambda item: (
470
+ int(getattr(item, "timestamp", 0)),
471
+ str(getattr(item, "transaction_hash", "")),
472
+ str(getattr(item, "type", "")),
473
+ ),
474
+ )
475
+
476
+
477
+ def _intersect_segments_with_windows(
478
+ segments: list[tuple[int, int]],
479
+ windows: list[tuple[int, int]],
480
+ ) -> list[tuple[int, int]]:
481
+ intersections: list[tuple[int, int]] = []
482
+ for seg_start, seg_end in segments:
483
+ if seg_start > seg_end:
484
+ continue
485
+ for window_start, window_end in windows:
486
+ if window_end < seg_start or window_start > seg_end:
487
+ continue
488
+ overlap_start = max(seg_start, window_start)
489
+ overlap_end = min(seg_end, window_end)
490
+ if overlap_start <= overlap_end:
491
+ intersections.append((overlap_start, overlap_end))
492
+ return intersections
493
+
494
+
495
+ def activity_key(record) -> str:
496
+ return "|".join(
497
+ [
498
+ str(getattr(record, "transaction_hash", "")),
499
+ str(getattr(record, "condition_id", "")),
500
+ str(getattr(record, "type", "")),
501
+ ]
502
+ )
503
+
504
+
505
+ def iter_day_windows(start_ts: int, end_ts: int) -> list[tuple[int, int]]:
506
+ return _iter_aligned_windows(start_ts, end_ts, DISCOVERY_ACTIVITY_WINDOW_SEC)
507
+
508
+
509
+ def iter_calendar_day_windows(start_ts: int, end_ts: int) -> list[tuple[int, int]]:
510
+ return _iter_aligned_windows(start_ts, end_ts, DAY_WINDOW_SEC)
511
+
512
+
513
+ def iter_week_windows(start_ts: int, end_ts: int) -> list[tuple[int, int]]:
514
+ return _iter_range_windows(start_ts, end_ts, WEEK_WINDOW_SEC)
515
+
516
+
517
+ def _iter_aligned_windows(start_ts: int, end_ts: int, window_sec: int) -> list[tuple[int, int]]:
518
+ if start_ts > end_ts:
519
+ return []
520
+
521
+ windows: list[tuple[int, int]] = []
522
+ current = start_ts
523
+ while current <= end_ts:
524
+ window_start = (current // window_sec) * window_sec
525
+ window_end = min(window_start + window_sec - 1, end_ts)
526
+ windows.append((current, window_end))
527
+ current = window_end + 1
528
+ return windows
529
+
530
+
531
+ def _iter_range_windows(start_ts: int, end_ts: int, window_sec: int) -> list[tuple[int, int]]:
532
+ if start_ts > end_ts:
533
+ return []
534
+
535
+ windows: list[tuple[int, int]] = []
536
+ current = start_ts
537
+ while current <= end_ts:
538
+ window_end = min(current + window_sec, end_ts)
539
+ windows.append((current, window_end))
540
+ current = window_end + 1
541
+ return windows