analysis-poly 0.1.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.
- analysis_poly/__init__.py +3 -0
- analysis_poly/analyzer.py +587 -0
- analysis_poly/cli.py +28 -0
- analysis_poly/logging_config.py +29 -0
- analysis_poly/main.py +27 -0
- analysis_poly/market_cache.py +133 -0
- analysis_poly/market_result_cache.py +52 -0
- analysis_poly/models.py +183 -0
- analysis_poly/open_with_params.py +143 -0
- analysis_poly/polymarket_client.py +141 -0
- analysis_poly/profit_engine.py +417 -0
- analysis_poly/run_manager.py +289 -0
- analysis_poly/slugs.py +31 -0
- analysis_poly/static/dist/app.css +1 -0
- analysis_poly/static/dist/app.js +485 -0
- analysis_poly/templates/index.html +17 -0
- analysis_poly/web.py +74 -0
- analysis_poly-0.1.0.dist-info/METADATA +107 -0
- analysis_poly-0.1.0.dist-info/RECORD +22 -0
- analysis_poly-0.1.0.dist-info/WHEEL +5 -0
- analysis_poly-0.1.0.dist-info/entry_points.txt +3 -0
- analysis_poly-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import csv
|
|
5
|
+
import json
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from .market_cache import MarketMetadataCache
|
|
15
|
+
from .market_result_cache import AddressMarketResultCache
|
|
16
|
+
from .models import (
|
|
17
|
+
AnalysisReport,
|
|
18
|
+
AnalysisRequest,
|
|
19
|
+
CurvePoint,
|
|
20
|
+
MarketReport,
|
|
21
|
+
SummaryStats,
|
|
22
|
+
WarningItem,
|
|
23
|
+
)
|
|
24
|
+
from .polymarket_client import PolymarketApiClient
|
|
25
|
+
from .profit_engine import PnlDelta, ProfitEngine, build_curve
|
|
26
|
+
from .slugs import MarketSlugSpec, generate_market_slug_specs
|
|
27
|
+
|
|
28
|
+
MARKET_FETCH_CONCURRENCY_DEFAULT = 10
|
|
29
|
+
MARKET_TIMESTAMP_CHUNK_SIZE_DEFAULT = 20
|
|
30
|
+
MARKET_RESULT_CACHE_RECENT_WINDOW_SEC = 30 * 60
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class _MarketProcessResult:
|
|
35
|
+
market_slug: str
|
|
36
|
+
market_report: MarketReport
|
|
37
|
+
market_report_no_fee: MarketReport
|
|
38
|
+
deltas: list[PnlDelta]
|
|
39
|
+
deltas_no_fee: list[PnlDelta]
|
|
40
|
+
warnings: list[WarningItem]
|
|
41
|
+
cache_updated: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AnalyzerHooks(Protocol):
|
|
45
|
+
async def on_run_started(self, total_markets: int) -> None: ...
|
|
46
|
+
|
|
47
|
+
async def on_progress(self, current: int, total: int, market_slug: str) -> None: ...
|
|
48
|
+
|
|
49
|
+
async def on_warning(self, warning: WarningItem) -> None: ...
|
|
50
|
+
|
|
51
|
+
async def on_total_point(self, timestamp: int, delta: float, cumulative: float) -> None: ...
|
|
52
|
+
|
|
53
|
+
async def on_market_point(
|
|
54
|
+
self, market_slug: str, timestamp: int, delta: float, cumulative: float
|
|
55
|
+
) -> None: ...
|
|
56
|
+
|
|
57
|
+
async def on_total_point_no_fee(self, timestamp: int, delta: float, cumulative: float) -> None: ...
|
|
58
|
+
|
|
59
|
+
async def on_market_point_no_fee(
|
|
60
|
+
self, market_slug: str, timestamp: int, delta: float, cumulative: float
|
|
61
|
+
) -> None: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NullHooks:
|
|
65
|
+
async def on_run_started(self, total_markets: int) -> None:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
async def on_progress(self, current: int, total: int, market_slug: str) -> None:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
async def on_warning(self, warning: WarningItem) -> None:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
async def on_total_point(self, timestamp: int, delta: float, cumulative: float) -> None:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
async def on_market_point(
|
|
78
|
+
self, market_slug: str, timestamp: int, delta: float, cumulative: float
|
|
79
|
+
) -> None:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
async def on_total_point_no_fee(self, timestamp: int, delta: float, cumulative: float) -> None:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
async def on_market_point_no_fee(
|
|
86
|
+
self, market_slug: str, timestamp: int, delta: float, cumulative: float
|
|
87
|
+
) -> None:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PolymarketProfitAnalyzer:
|
|
92
|
+
def __init__(self):
|
|
93
|
+
self._market_cache = MarketMetadataCache()
|
|
94
|
+
self._market_result_cache = AddressMarketResultCache()
|
|
95
|
+
self._market_fetch_concurrency = MARKET_FETCH_CONCURRENCY_DEFAULT
|
|
96
|
+
self._timestamp_chunk_size = MARKET_TIMESTAMP_CHUNK_SIZE_DEFAULT
|
|
97
|
+
|
|
98
|
+
async def run(
|
|
99
|
+
self,
|
|
100
|
+
req: AnalysisRequest,
|
|
101
|
+
stop_event: asyncio.Event | None = None,
|
|
102
|
+
hooks: AnalyzerHooks | None = None,
|
|
103
|
+
) -> AnalysisReport:
|
|
104
|
+
stop_event = stop_event or asyncio.Event()
|
|
105
|
+
hooks = hooks or NullHooks()
|
|
106
|
+
|
|
107
|
+
client = PolymarketApiClient(timeout_sec=req.request_timeout_sec)
|
|
108
|
+
engine = ProfitEngine(
|
|
109
|
+
fee_rate_bps=req.fee_rate_bps,
|
|
110
|
+
maker_reward_ratio=req.maker_reward_ratio,
|
|
111
|
+
missing_cost_warn_qty=req.missing_cost_warn_qty,
|
|
112
|
+
)
|
|
113
|
+
engine_no_fee = ProfitEngine(
|
|
114
|
+
fee_rate_bps=req.fee_rate_bps,
|
|
115
|
+
maker_reward_ratio=req.maker_reward_ratio,
|
|
116
|
+
missing_cost_warn_qty=req.missing_cost_warn_qty,
|
|
117
|
+
charge_taker_fee=False,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
all_warnings: list[WarningItem] = []
|
|
121
|
+
market_reports: list[MarketReport] = []
|
|
122
|
+
total_deltas: list[PnlDelta] = []
|
|
123
|
+
total_deltas_no_fee: list[PnlDelta] = []
|
|
124
|
+
market_deltas: dict[str, list[PnlDelta]] = defaultdict(list)
|
|
125
|
+
market_deltas_no_fee: dict[str, list[PnlDelta]] = defaultdict(list)
|
|
126
|
+
|
|
127
|
+
total_by_ts: dict[int, float] = defaultdict(float)
|
|
128
|
+
market_by_ts: dict[str, dict[int, float]] = defaultdict(lambda: defaultdict(float))
|
|
129
|
+
total_by_ts_no_fee: dict[int, float] = defaultdict(float)
|
|
130
|
+
market_by_ts_no_fee: dict[str, dict[int, float]] = defaultdict(lambda: defaultdict(float))
|
|
131
|
+
address_market_cache = self._market_result_cache.load(req.address)
|
|
132
|
+
result_cache_dirty = False
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
specs = generate_market_slug_specs(req.symbols, req.intervals, req.start_ts, req.end_ts)
|
|
136
|
+
total_markets = len(specs)
|
|
137
|
+
spec_chunks = _chunk_specs_by_timestamp(specs, self._timestamp_chunk_size)
|
|
138
|
+
logger.info(
|
|
139
|
+
"analyzer prepared spec_count={} timestamp_chunks={} market_fetch_concurrency={} process_concurrency={}",
|
|
140
|
+
len(specs),
|
|
141
|
+
len(spec_chunks),
|
|
142
|
+
self._market_fetch_concurrency,
|
|
143
|
+
max(1, req.concurrency),
|
|
144
|
+
)
|
|
145
|
+
await hooks.on_run_started(total_markets)
|
|
146
|
+
|
|
147
|
+
process_concurrency = max(1, req.concurrency)
|
|
148
|
+
processed_count = 0
|
|
149
|
+
|
|
150
|
+
for spec_chunk in spec_chunks:
|
|
151
|
+
if stop_event.is_set():
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
chunk_slugs = [s.slug for s in spec_chunk]
|
|
155
|
+
chunk_fetch_results = await self._fetch_markets_with_status(
|
|
156
|
+
client,
|
|
157
|
+
chunk_slugs,
|
|
158
|
+
self._market_fetch_concurrency,
|
|
159
|
+
)
|
|
160
|
+
chunk_markets = [market for _, market in chunk_fetch_results if market is not None]
|
|
161
|
+
chunk_markets.sort(key=lambda m: _market_order_key(m.slug))
|
|
162
|
+
|
|
163
|
+
for batch_start in range(0, len(chunk_markets), process_concurrency):
|
|
164
|
+
if stop_event.is_set():
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
batch_markets = chunk_markets[batch_start : batch_start + process_concurrency]
|
|
168
|
+
batch_results = await asyncio.gather(
|
|
169
|
+
*(
|
|
170
|
+
self._process_single_market(
|
|
171
|
+
client=client,
|
|
172
|
+
engine=engine,
|
|
173
|
+
engine_no_fee=engine_no_fee,
|
|
174
|
+
address=req.address,
|
|
175
|
+
address_market_cache=address_market_cache,
|
|
176
|
+
req=req,
|
|
177
|
+
market=market,
|
|
178
|
+
)
|
|
179
|
+
for market in batch_markets
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
# Keep push order stable by market timestamp inside one concurrent batch.
|
|
183
|
+
batch_results.sort(key=lambda x: _market_order_key(x.market_slug))
|
|
184
|
+
|
|
185
|
+
for result in batch_results:
|
|
186
|
+
if stop_event.is_set():
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
processed_count += 1
|
|
190
|
+
has_trade_activity = _has_market_trade_activity(result.market_report)
|
|
191
|
+
if has_trade_activity:
|
|
192
|
+
market_reports.append(result.market_report)
|
|
193
|
+
total_deltas.extend(result.deltas)
|
|
194
|
+
total_deltas_no_fee.extend(result.deltas_no_fee)
|
|
195
|
+
if result.deltas:
|
|
196
|
+
market_deltas[result.market_slug].extend(result.deltas)
|
|
197
|
+
if result.deltas_no_fee:
|
|
198
|
+
market_deltas_no_fee[result.market_slug].extend(result.deltas_no_fee)
|
|
199
|
+
else:
|
|
200
|
+
logger.debug("skip market without trades in output slug={}", result.market_slug)
|
|
201
|
+
|
|
202
|
+
for warning in result.warnings:
|
|
203
|
+
all_warnings.append(warning)
|
|
204
|
+
await hooks.on_warning(warning)
|
|
205
|
+
if result.cache_updated:
|
|
206
|
+
result_cache_dirty = True
|
|
207
|
+
|
|
208
|
+
for delta in result.deltas:
|
|
209
|
+
total_by_ts[delta.timestamp] += delta.delta_pnl_usdc
|
|
210
|
+
market_by_ts[delta.market_slug][delta.timestamp] += delta.delta_pnl_usdc
|
|
211
|
+
|
|
212
|
+
total_cumulative = _cumulative_at(total_by_ts, delta.timestamp)
|
|
213
|
+
market_cumulative = _cumulative_at(market_by_ts[delta.market_slug], delta.timestamp)
|
|
214
|
+
|
|
215
|
+
await hooks.on_total_point(
|
|
216
|
+
delta.timestamp,
|
|
217
|
+
delta.delta_pnl_usdc,
|
|
218
|
+
total_cumulative,
|
|
219
|
+
)
|
|
220
|
+
await hooks.on_market_point(
|
|
221
|
+
delta.market_slug,
|
|
222
|
+
delta.timestamp,
|
|
223
|
+
delta.delta_pnl_usdc,
|
|
224
|
+
market_cumulative,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
for delta in result.deltas_no_fee:
|
|
228
|
+
total_by_ts_no_fee[delta.timestamp] += delta.delta_pnl_usdc
|
|
229
|
+
market_by_ts_no_fee[delta.market_slug][delta.timestamp] += delta.delta_pnl_usdc
|
|
230
|
+
|
|
231
|
+
total_cumulative_no_fee = _cumulative_at(total_by_ts_no_fee, delta.timestamp)
|
|
232
|
+
market_cumulative_no_fee = _cumulative_at(
|
|
233
|
+
market_by_ts_no_fee[delta.market_slug], delta.timestamp
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
await hooks.on_total_point_no_fee(
|
|
237
|
+
delta.timestamp,
|
|
238
|
+
delta.delta_pnl_usdc,
|
|
239
|
+
total_cumulative_no_fee,
|
|
240
|
+
)
|
|
241
|
+
await hooks.on_market_point_no_fee(
|
|
242
|
+
delta.market_slug,
|
|
243
|
+
delta.timestamp,
|
|
244
|
+
delta.delta_pnl_usdc,
|
|
245
|
+
market_cumulative_no_fee,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
await hooks.on_progress(processed_count, total_markets, result.market_slug)
|
|
249
|
+
|
|
250
|
+
missing_count = sum(1 for _, market in chunk_fetch_results if market is None)
|
|
251
|
+
if missing_count > 0:
|
|
252
|
+
processed_count += missing_count
|
|
253
|
+
await hooks.on_progress(processed_count, total_markets, chunk_slugs[-1])
|
|
254
|
+
|
|
255
|
+
total_curve = [
|
|
256
|
+
CurvePoint(
|
|
257
|
+
timestamp=ts,
|
|
258
|
+
delta_realized_pnl_usdc=round(delta, 10),
|
|
259
|
+
cumulative_realized_pnl_usdc=round(cum, 10),
|
|
260
|
+
)
|
|
261
|
+
for ts, delta, cum in build_curve(total_deltas)
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
market_curves: dict[str, list[CurvePoint]] = {}
|
|
265
|
+
for market_slug, deltas in market_deltas.items():
|
|
266
|
+
if not deltas:
|
|
267
|
+
continue
|
|
268
|
+
market_curves[market_slug] = [
|
|
269
|
+
CurvePoint(
|
|
270
|
+
timestamp=ts,
|
|
271
|
+
delta_realized_pnl_usdc=round(delta, 10),
|
|
272
|
+
cumulative_realized_pnl_usdc=round(cum, 10),
|
|
273
|
+
)
|
|
274
|
+
for ts, delta, cum in build_curve(deltas)
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
total_curve_no_fee = [
|
|
278
|
+
CurvePoint(
|
|
279
|
+
timestamp=ts,
|
|
280
|
+
delta_realized_pnl_usdc=round(delta, 10),
|
|
281
|
+
cumulative_realized_pnl_usdc=round(cum, 10),
|
|
282
|
+
)
|
|
283
|
+
for ts, delta, cum in build_curve(total_deltas_no_fee)
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
market_curves_no_fee: dict[str, list[CurvePoint]] = {}
|
|
287
|
+
for market_slug, deltas in market_deltas_no_fee.items():
|
|
288
|
+
if not deltas:
|
|
289
|
+
continue
|
|
290
|
+
market_curves_no_fee[market_slug] = [
|
|
291
|
+
CurvePoint(
|
|
292
|
+
timestamp=ts,
|
|
293
|
+
delta_realized_pnl_usdc=round(delta, 10),
|
|
294
|
+
cumulative_realized_pnl_usdc=round(cum, 10),
|
|
295
|
+
)
|
|
296
|
+
for ts, delta, cum in build_curve(deltas)
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
summary = SummaryStats(
|
|
300
|
+
total_realized_pnl_usdc=round(sum(m.realized_pnl_usdc for m in market_reports), 10),
|
|
301
|
+
total_taker_fee_usdc=round(sum(m.taker_fee_usdc for m in market_reports), 10),
|
|
302
|
+
total_maker_reward_usdc=round(sum(m.maker_reward_usdc for m in market_reports), 10),
|
|
303
|
+
markets_total=total_markets,
|
|
304
|
+
markets_processed=len(market_reports),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
report = AnalysisReport(
|
|
308
|
+
request=req,
|
|
309
|
+
summary=summary,
|
|
310
|
+
markets=sorted(market_reports, key=lambda x: x.market_slug),
|
|
311
|
+
total_curve=total_curve,
|
|
312
|
+
market_curves=market_curves,
|
|
313
|
+
total_curve_no_fee=total_curve_no_fee,
|
|
314
|
+
market_curves_no_fee=market_curves_no_fee,
|
|
315
|
+
warnings=all_warnings,
|
|
316
|
+
is_partial=stop_event.is_set() and len(market_reports) < total_markets,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return report
|
|
320
|
+
finally:
|
|
321
|
+
if result_cache_dirty:
|
|
322
|
+
try:
|
|
323
|
+
self._market_result_cache.save(req.address, address_market_cache)
|
|
324
|
+
except Exception as exc: # noqa: BLE001
|
|
325
|
+
logger.warning("market result cache save failed address={} error={}", req.address, exc)
|
|
326
|
+
await client.aclose()
|
|
327
|
+
|
|
328
|
+
async def _fetch_markets_with_status(
|
|
329
|
+
self,
|
|
330
|
+
client: PolymarketApiClient,
|
|
331
|
+
slugs: list[str],
|
|
332
|
+
concurrency: int,
|
|
333
|
+
) -> list[tuple[str, object | None]]:
|
|
334
|
+
sem = asyncio.Semaphore(max(1, concurrency))
|
|
335
|
+
|
|
336
|
+
async def fetch(slug: str):
|
|
337
|
+
async with sem:
|
|
338
|
+
market = await self._fetch_market_with_cache(client, slug)
|
|
339
|
+
return slug, market
|
|
340
|
+
|
|
341
|
+
return await asyncio.gather(*(fetch(s) for s in slugs))
|
|
342
|
+
|
|
343
|
+
async def _fetch_market_with_cache(
|
|
344
|
+
self,
|
|
345
|
+
client: PolymarketApiClient,
|
|
346
|
+
slug: str,
|
|
347
|
+
):
|
|
348
|
+
now_ts = int(datetime.now(timezone.utc).timestamp())
|
|
349
|
+
use_cache = self._market_cache.is_cache_eligible(slug, now_ts=now_ts)
|
|
350
|
+
|
|
351
|
+
if use_cache:
|
|
352
|
+
cached = self._market_cache.get(slug)
|
|
353
|
+
if cached is not None:
|
|
354
|
+
logger.debug("market cache hit slug={}", slug)
|
|
355
|
+
return cached
|
|
356
|
+
|
|
357
|
+
market = await client.get_market_by_slug(slug)
|
|
358
|
+
if market is None:
|
|
359
|
+
logger.warning("market not found slug={}", slug)
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
if market is not None and use_cache:
|
|
363
|
+
self._market_cache.set(slug, market)
|
|
364
|
+
logger.debug("market cache write slug={}", slug)
|
|
365
|
+
|
|
366
|
+
return market
|
|
367
|
+
|
|
368
|
+
async def _process_single_market(
|
|
369
|
+
self,
|
|
370
|
+
client: PolymarketApiClient,
|
|
371
|
+
engine: ProfitEngine,
|
|
372
|
+
engine_no_fee: ProfitEngine,
|
|
373
|
+
address: str,
|
|
374
|
+
address_market_cache: dict[str, dict],
|
|
375
|
+
req: AnalysisRequest,
|
|
376
|
+
market,
|
|
377
|
+
) -> _MarketProcessResult:
|
|
378
|
+
now_ts = int(datetime.now(timezone.utc).timestamp())
|
|
379
|
+
use_result_cache = _is_market_result_cache_eligible(
|
|
380
|
+
market.slug,
|
|
381
|
+
now_ts=now_ts,
|
|
382
|
+
recent_window_sec=MARKET_RESULT_CACHE_RECENT_WINDOW_SEC,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if use_result_cache:
|
|
386
|
+
cached_payload = address_market_cache.get(market.slug)
|
|
387
|
+
if cached_payload is not None:
|
|
388
|
+
cached_result = _result_from_cache_payload(market.slug, cached_payload)
|
|
389
|
+
if cached_result is not None:
|
|
390
|
+
logger.debug("market result cache hit address={} slug={}", address, market.slug)
|
|
391
|
+
return cached_result
|
|
392
|
+
logger.warning("market result cache invalid address={} slug={}", address, market.slug)
|
|
393
|
+
|
|
394
|
+
taker_trades, all_trades, split_acts, redeem_acts = await asyncio.gather(
|
|
395
|
+
client.get_trades(req.address, market.condition_id, True, req.page_limit),
|
|
396
|
+
client.get_trades(req.address, market.condition_id, False, req.page_limit),
|
|
397
|
+
client.get_activity(req.address, market.condition_id, "SPLIT", req.page_limit),
|
|
398
|
+
client.get_activity(req.address, market.condition_id, "REDEEM", req.page_limit),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
market_report, deltas, warnings = engine.process_market(
|
|
402
|
+
market=market,
|
|
403
|
+
taker_trades=taker_trades,
|
|
404
|
+
all_trades=all_trades,
|
|
405
|
+
split_activities=split_acts,
|
|
406
|
+
redeem_activities=redeem_acts,
|
|
407
|
+
)
|
|
408
|
+
market_report_no_fee, deltas_no_fee, _ = engine_no_fee.process_market(
|
|
409
|
+
market=market,
|
|
410
|
+
taker_trades=taker_trades,
|
|
411
|
+
all_trades=all_trades,
|
|
412
|
+
split_activities=split_acts,
|
|
413
|
+
redeem_activities=redeem_acts,
|
|
414
|
+
)
|
|
415
|
+
result = _MarketProcessResult(
|
|
416
|
+
market_slug=market.slug,
|
|
417
|
+
market_report=market_report,
|
|
418
|
+
market_report_no_fee=market_report_no_fee,
|
|
419
|
+
deltas=deltas,
|
|
420
|
+
deltas_no_fee=deltas_no_fee,
|
|
421
|
+
warnings=warnings,
|
|
422
|
+
)
|
|
423
|
+
if use_result_cache:
|
|
424
|
+
new_payload = _result_to_cache_payload(result)
|
|
425
|
+
if address_market_cache.get(market.slug) != new_payload:
|
|
426
|
+
address_market_cache[market.slug] = new_payload
|
|
427
|
+
result.cache_updated = True
|
|
428
|
+
return result
|
|
429
|
+
|
|
430
|
+
def save_json(self, report: AnalysisReport, path: str | None = None) -> str:
|
|
431
|
+
output_dir = Path(report.request.output_dir)
|
|
432
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
433
|
+
|
|
434
|
+
if not path:
|
|
435
|
+
suffix = "partial" if report.is_partial else "final"
|
|
436
|
+
path = str(output_dir / f"pnl_summary_{suffix}.json")
|
|
437
|
+
|
|
438
|
+
Path(path).write_text(report.model_dump_json(indent=2), encoding="utf-8")
|
|
439
|
+
return path
|
|
440
|
+
|
|
441
|
+
def save_total_curve_csv(self, report: AnalysisReport, path: str | None = None) -> str:
|
|
442
|
+
output_dir = Path(report.request.output_dir)
|
|
443
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
444
|
+
|
|
445
|
+
if not path:
|
|
446
|
+
suffix = "partial" if report.is_partial else "final"
|
|
447
|
+
path = str(output_dir / f"pnl_total_curve_{suffix}.csv")
|
|
448
|
+
|
|
449
|
+
with Path(path).open("w", newline="", encoding="utf-8") as fp:
|
|
450
|
+
writer = csv.writer(fp)
|
|
451
|
+
writer.writerow(["timestamp", "delta_realized_pnl_usdc", "cumulative_realized_pnl_usdc"])
|
|
452
|
+
for p in report.total_curve:
|
|
453
|
+
writer.writerow([p.timestamp, p.delta_realized_pnl_usdc, p.cumulative_realized_pnl_usdc])
|
|
454
|
+
return path
|
|
455
|
+
|
|
456
|
+
def save_market_curve_csv(self, report: AnalysisReport, path: str | None = None) -> str:
|
|
457
|
+
output_dir = Path(report.request.output_dir)
|
|
458
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
459
|
+
|
|
460
|
+
if not path:
|
|
461
|
+
suffix = "partial" if report.is_partial else "final"
|
|
462
|
+
path = str(output_dir / f"pnl_market_curve_{suffix}.csv")
|
|
463
|
+
|
|
464
|
+
with Path(path).open("w", newline="", encoding="utf-8") as fp:
|
|
465
|
+
writer = csv.writer(fp)
|
|
466
|
+
writer.writerow(
|
|
467
|
+
[
|
|
468
|
+
"market_slug",
|
|
469
|
+
"timestamp",
|
|
470
|
+
"delta_realized_pnl_usdc",
|
|
471
|
+
"cumulative_realized_pnl_usdc",
|
|
472
|
+
]
|
|
473
|
+
)
|
|
474
|
+
for market_slug, points in report.market_curves.items():
|
|
475
|
+
for p in points:
|
|
476
|
+
writer.writerow(
|
|
477
|
+
[
|
|
478
|
+
market_slug,
|
|
479
|
+
p.timestamp,
|
|
480
|
+
p.delta_realized_pnl_usdc,
|
|
481
|
+
p.cumulative_realized_pnl_usdc,
|
|
482
|
+
]
|
|
483
|
+
)
|
|
484
|
+
return path
|
|
485
|
+
|
|
486
|
+
def save_curve_csv(self, report: AnalysisReport, path: str | None = None) -> str:
|
|
487
|
+
return self.save_total_curve_csv(report, path)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _cumulative_at(by_ts: dict[int, float], ts: int) -> float:
|
|
492
|
+
cumulative = 0.0
|
|
493
|
+
for key in sorted(by_ts.keys()):
|
|
494
|
+
if key > ts:
|
|
495
|
+
break
|
|
496
|
+
cumulative += by_ts[key]
|
|
497
|
+
return cumulative
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _market_order_key(slug: str) -> tuple[int, str]:
|
|
501
|
+
try:
|
|
502
|
+
return int(str(slug).rsplit("-", 1)[-1]), slug
|
|
503
|
+
except Exception: # noqa: BLE001
|
|
504
|
+
return 10**18, slug
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _has_market_trade_activity(market_report: MarketReport) -> bool:
|
|
508
|
+
return any(token.trade_count > 0 for token in market_report.tokens)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _chunk_specs_by_timestamp(
|
|
512
|
+
specs: list[MarketSlugSpec], timestamps_per_chunk: int
|
|
513
|
+
) -> list[list[MarketSlugSpec]]:
|
|
514
|
+
if not specs:
|
|
515
|
+
return []
|
|
516
|
+
chunk_size = max(1, int(timestamps_per_chunk))
|
|
517
|
+
|
|
518
|
+
ts_to_specs: dict[int, list[MarketSlugSpec]] = defaultdict(list)
|
|
519
|
+
ts_order: list[int] = []
|
|
520
|
+
for spec in specs:
|
|
521
|
+
if spec.timestamp not in ts_to_specs:
|
|
522
|
+
ts_order.append(spec.timestamp)
|
|
523
|
+
ts_to_specs[spec.timestamp].append(spec)
|
|
524
|
+
|
|
525
|
+
chunks: list[list[MarketSlugSpec]] = []
|
|
526
|
+
for start in range(0, len(ts_order), chunk_size):
|
|
527
|
+
ts_slice = ts_order[start : start + chunk_size]
|
|
528
|
+
chunk_specs: list[MarketSlugSpec] = []
|
|
529
|
+
for ts in ts_slice:
|
|
530
|
+
chunk_specs.extend(ts_to_specs[ts])
|
|
531
|
+
chunks.append(chunk_specs)
|
|
532
|
+
return chunks
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _result_to_cache_payload(result: _MarketProcessResult) -> dict:
|
|
536
|
+
return {
|
|
537
|
+
"market_slug": result.market_slug,
|
|
538
|
+
"market_report": result.market_report.model_dump(),
|
|
539
|
+
"market_report_no_fee": result.market_report_no_fee.model_dump(),
|
|
540
|
+
"deltas": [_delta_to_dict(d) for d in result.deltas],
|
|
541
|
+
"deltas_no_fee": [_delta_to_dict(d) for d in result.deltas_no_fee],
|
|
542
|
+
"warnings": [w.model_dump() for w in result.warnings],
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _result_from_cache_payload(slug: str, payload: dict) -> _MarketProcessResult | None:
|
|
547
|
+
try:
|
|
548
|
+
market_report = MarketReport.model_validate(payload["market_report"])
|
|
549
|
+
market_report_no_fee = MarketReport.model_validate(payload["market_report_no_fee"])
|
|
550
|
+
deltas = [_delta_from_dict(d) for d in payload.get("deltas", [])]
|
|
551
|
+
deltas_no_fee = [_delta_from_dict(d) for d in payload.get("deltas_no_fee", [])]
|
|
552
|
+
warnings = [WarningItem.model_validate(w) for w in payload.get("warnings", [])]
|
|
553
|
+
return _MarketProcessResult(
|
|
554
|
+
market_slug=slug,
|
|
555
|
+
market_report=market_report,
|
|
556
|
+
market_report_no_fee=market_report_no_fee,
|
|
557
|
+
deltas=deltas,
|
|
558
|
+
deltas_no_fee=deltas_no_fee,
|
|
559
|
+
warnings=warnings,
|
|
560
|
+
)
|
|
561
|
+
except Exception: # noqa: BLE001
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _delta_to_dict(delta: PnlDelta) -> dict:
|
|
566
|
+
return {
|
|
567
|
+
"timestamp": int(delta.timestamp),
|
|
568
|
+
"market_slug": str(delta.market_slug),
|
|
569
|
+
"token_id": str(delta.token_id),
|
|
570
|
+
"delta_pnl_usdc": float(delta.delta_pnl_usdc),
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _delta_from_dict(payload: dict) -> PnlDelta:
|
|
575
|
+
return PnlDelta(
|
|
576
|
+
timestamp=int(payload["timestamp"]),
|
|
577
|
+
market_slug=str(payload["market_slug"]),
|
|
578
|
+
token_id=str(payload["token_id"]),
|
|
579
|
+
delta_pnl_usdc=float(payload["delta_pnl_usdc"]),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _is_market_result_cache_eligible(slug: str, now_ts: int, recent_window_sec: int) -> bool:
|
|
584
|
+
market_ts = _market_order_key(slug)[0]
|
|
585
|
+
if market_ts >= 10**18:
|
|
586
|
+
return False
|
|
587
|
+
return (now_ts - market_ts) > recent_window_sec
|
analysis_poly/cli.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
if __package__ in (None, ""):
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
10
|
+
from analysis_poly.main import run
|
|
11
|
+
else:
|
|
12
|
+
from .main import run
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(description="Run analysis-poly web server")
|
|
17
|
+
parser.add_argument("--host", default="0.0.0.0", help="Bind host for web server")
|
|
18
|
+
parser.add_argument("--port", type=int, default=8000, help="Bind port for web server")
|
|
19
|
+
return parser
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
args = _build_arg_parser().parse_args()
|
|
24
|
+
run(host=args.host, port=args.port)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
_CONFIGURED = False
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_logging(level: str = "INFO") -> None:
|
|
11
|
+
global _CONFIGURED
|
|
12
|
+
if _CONFIGURED:
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
logger.remove()
|
|
16
|
+
logger.add(
|
|
17
|
+
sys.stderr,
|
|
18
|
+
level=level,
|
|
19
|
+
colorize=True,
|
|
20
|
+
backtrace=False,
|
|
21
|
+
diagnose=False,
|
|
22
|
+
format=(
|
|
23
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
24
|
+
"<level>{level: <8}</level> | "
|
|
25
|
+
"<cyan>{name}:{function}:{line}</cyan> - "
|
|
26
|
+
"<level>{message}</level>"
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
_CONFIGURED = True
|
analysis_poly/main.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
if __package__ in (None, ""):
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
8
|
+
from analysis_poly.logging_config import configure_logging
|
|
9
|
+
from analysis_poly.web import app
|
|
10
|
+
else:
|
|
11
|
+
from .logging_config import configure_logging
|
|
12
|
+
from .web import app
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(host: str = "0.0.0.0", port: int = 8000) -> None:
|
|
16
|
+
import uvicorn
|
|
17
|
+
|
|
18
|
+
configure_logging()
|
|
19
|
+
uvicorn.run(app, host=host, port=port)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
run()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|