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.
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/PKG-INFO +1 -1
- analysis_poly-0.1.4/analysis_poly/activity_discovery.py +541 -0
- analysis_poly-0.1.4/analysis_poly/activity_page_cache.py +320 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/analyzer.py +111 -29
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/models.py +1 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/polymarket_client.py +31 -1
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/profit_engine.py +30 -5
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/run_manager.py +64 -2
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/static/dist/app.js +80 -80
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/PKG-INFO +1 -1
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/SOURCES.txt +2 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/pyproject.toml +1 -1
- analysis_poly-0.1.4/tests/test_activity_page_cache.py +555 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_discovery.py +204 -13
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_profit_engine.py +154 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_run_manager.py +49 -0
- analysis_poly-0.1.2/analysis_poly/activity_discovery.py +0 -230
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/README.md +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/__init__.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/cli.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/logging_config.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/main.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/market_cache.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/market_result_cache.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/open_with_params.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/slugs.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/static/dist/app.css +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/templates/index.html +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly/web.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/dependency_links.txt +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/entry_points.txt +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/requires.txt +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/analysis_poly.egg-info/top_level.txt +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/setup.cfg +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_chunking.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_csv.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_filter_empty_market.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_market_cache.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_analyzer_result_cache_compat.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_market_cache.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_market_result_cache.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_models_defaults.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_open_with_params.py +0 -0
- {analysis_poly-0.1.2 → analysis_poly-0.1.4}/tests/test_slugs.py +0 -0
|
@@ -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
|