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,417 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict, deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from .models import (
|
|
8
|
+
ActivityRecord,
|
|
9
|
+
MarketReport,
|
|
10
|
+
PolymarketMarket,
|
|
11
|
+
TokenReport,
|
|
12
|
+
WarningItem,
|
|
13
|
+
)
|
|
14
|
+
from .models import TradeRecord
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class PnlDelta:
|
|
19
|
+
timestamp: int
|
|
20
|
+
market_slug: str
|
|
21
|
+
token_id: str
|
|
22
|
+
delta_pnl_usdc: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class _Lot:
|
|
27
|
+
qty: float
|
|
28
|
+
cost_per_qty: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class _TokenState:
|
|
33
|
+
token_id: str
|
|
34
|
+
outcome: str
|
|
35
|
+
lots: deque[_Lot]
|
|
36
|
+
realized_pnl_usdc: float = 0.0
|
|
37
|
+
taker_fee_usdc: float = 0.0
|
|
38
|
+
maker_reward_usdc: float = 0.0
|
|
39
|
+
buy_qty: float = 0.0
|
|
40
|
+
sell_qty: float = 0.0
|
|
41
|
+
split_qty: float = 0.0
|
|
42
|
+
redeem_qty: float = 0.0
|
|
43
|
+
trade_count: int = 0
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def position_qty(self) -> float:
|
|
47
|
+
return sum(lot.qty for lot in self.lots)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class _Event:
|
|
52
|
+
timestamp: int
|
|
53
|
+
tx: str
|
|
54
|
+
kind: str
|
|
55
|
+
token_id: str | None = None
|
|
56
|
+
side: str | None = None
|
|
57
|
+
size: float = 0.0
|
|
58
|
+
price: float = 0.0
|
|
59
|
+
usdc_size: float = 0.0
|
|
60
|
+
is_taker: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ProfitEngine:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
fee_rate_bps: float,
|
|
67
|
+
maker_reward_ratio: float,
|
|
68
|
+
missing_cost_warn_qty: float,
|
|
69
|
+
charge_taker_fee: bool = True,
|
|
70
|
+
):
|
|
71
|
+
self._fee_rate_bps = fee_rate_bps
|
|
72
|
+
self._maker_reward_ratio = maker_reward_ratio
|
|
73
|
+
self._missing_cost_warn_qty = missing_cost_warn_qty
|
|
74
|
+
self._charge_taker_fee = charge_taker_fee
|
|
75
|
+
|
|
76
|
+
def process_market(
|
|
77
|
+
self,
|
|
78
|
+
market: PolymarketMarket,
|
|
79
|
+
taker_trades: list[TradeRecord],
|
|
80
|
+
all_trades: list[TradeRecord],
|
|
81
|
+
split_activities: list[ActivityRecord],
|
|
82
|
+
redeem_activities: list[ActivityRecord],
|
|
83
|
+
) -> tuple[MarketReport, list[PnlDelta], list[WarningItem]]:
|
|
84
|
+
warnings: list[WarningItem] = []
|
|
85
|
+
token_states: dict[str, _TokenState] = {
|
|
86
|
+
market.up_token_id: _TokenState(token_id=market.up_token_id, outcome="Up", lots=deque()),
|
|
87
|
+
market.down_token_id: _TokenState(token_id=market.down_token_id, outcome="Down", lots=deque()),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
taker_keys = {_trade_key(t) for t in taker_trades}
|
|
91
|
+
events: list[_Event] = []
|
|
92
|
+
maker_reward_enabled = _is_maker_reward_enabled_for_market(market.slug)
|
|
93
|
+
has_maker_trade = False
|
|
94
|
+
|
|
95
|
+
for trade in all_trades:
|
|
96
|
+
is_taker = _trade_key(trade) in taker_keys
|
|
97
|
+
if not is_taker:
|
|
98
|
+
has_maker_trade = True
|
|
99
|
+
events.append(
|
|
100
|
+
_Event(
|
|
101
|
+
timestamp=trade.timestamp,
|
|
102
|
+
tx=trade.transaction_hash,
|
|
103
|
+
kind="TRADE",
|
|
104
|
+
token_id=trade.asset,
|
|
105
|
+
side=trade.side,
|
|
106
|
+
size=float(trade.size),
|
|
107
|
+
price=float(trade.price),
|
|
108
|
+
is_taker=is_taker,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
for split in split_activities:
|
|
113
|
+
events.append(
|
|
114
|
+
_Event(
|
|
115
|
+
timestamp=split.timestamp,
|
|
116
|
+
tx=split.transaction_hash,
|
|
117
|
+
kind="SPLIT",
|
|
118
|
+
size=float(split.size),
|
|
119
|
+
usdc_size=float(split.usdc_size),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
for redeem in redeem_activities:
|
|
124
|
+
winner_token = _resolve_winner_token(market)
|
|
125
|
+
if not winner_token:
|
|
126
|
+
warnings.append(
|
|
127
|
+
WarningItem(
|
|
128
|
+
timestamp=redeem.timestamp,
|
|
129
|
+
market_slug=market.slug,
|
|
130
|
+
code="REDEEM_SKIP_UNKNOWN_WINNER",
|
|
131
|
+
message="skip redeem because winner outcome cannot be uniquely inferred",
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
events.append(
|
|
137
|
+
_Event(
|
|
138
|
+
timestamp=redeem.timestamp,
|
|
139
|
+
tx=redeem.transaction_hash,
|
|
140
|
+
kind="REDEEM",
|
|
141
|
+
token_id=winner_token,
|
|
142
|
+
size=float(redeem.size),
|
|
143
|
+
usdc_size=float(redeem.usdc_size),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
events.sort(key=lambda e: (e.timestamp, e.tx, _event_priority(e.kind)))
|
|
148
|
+
|
|
149
|
+
pnl_deltas: list[PnlDelta] = []
|
|
150
|
+
for event in events:
|
|
151
|
+
if event.kind == "TRADE" and event.token_id in token_states:
|
|
152
|
+
token_state = token_states[event.token_id]
|
|
153
|
+
token_state.trade_count += 1
|
|
154
|
+
delta, new_warnings = self._apply_trade(
|
|
155
|
+
market_slug=market.slug,
|
|
156
|
+
token_state=token_state,
|
|
157
|
+
event=event,
|
|
158
|
+
maker_reward_enabled=maker_reward_enabled,
|
|
159
|
+
)
|
|
160
|
+
pnl_deltas.extend(delta)
|
|
161
|
+
warnings.extend(new_warnings)
|
|
162
|
+
elif event.kind == "SPLIT":
|
|
163
|
+
up_state = token_states[market.up_token_id]
|
|
164
|
+
down_state = token_states[market.down_token_id]
|
|
165
|
+
|
|
166
|
+
qty_each = event.size / 2.0
|
|
167
|
+
usdc_each = event.usdc_size / 2.0
|
|
168
|
+
if qty_each > 0:
|
|
169
|
+
up_state.lots.append(_Lot(qty=qty_each, cost_per_qty=usdc_each / qty_each))
|
|
170
|
+
down_state.lots.append(_Lot(qty=qty_each, cost_per_qty=usdc_each / qty_each))
|
|
171
|
+
up_state.split_qty += qty_each
|
|
172
|
+
down_state.split_qty += qty_each
|
|
173
|
+
elif event.kind == "REDEEM" and event.token_id in token_states:
|
|
174
|
+
token_state = token_states[event.token_id]
|
|
175
|
+
delta, new_warnings = self._close_position(
|
|
176
|
+
market_slug=market.slug,
|
|
177
|
+
token_state=token_state,
|
|
178
|
+
timestamp=event.timestamp,
|
|
179
|
+
quantity=event.size,
|
|
180
|
+
proceeds=event.usdc_size,
|
|
181
|
+
missing_cost_warn_code="REDEEM_OVERSELL_ZERO_COST",
|
|
182
|
+
)
|
|
183
|
+
token_state.redeem_qty += event.size
|
|
184
|
+
pnl_deltas.extend(delta)
|
|
185
|
+
warnings.extend(new_warnings)
|
|
186
|
+
|
|
187
|
+
if not maker_reward_enabled and has_maker_trade:
|
|
188
|
+
warnings.append(
|
|
189
|
+
WarningItem(
|
|
190
|
+
market_slug=market.slug,
|
|
191
|
+
code="MAKER_REWARD_DEFERRED_TODAY",
|
|
192
|
+
message=(
|
|
193
|
+
"maker reward for markets on/after current UTC day 00:00 is excluded "
|
|
194
|
+
"because Polymarket settles maker rewards once per day"
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
token_reports: list[TokenReport] = []
|
|
200
|
+
for token_state in token_states.values():
|
|
201
|
+
token_reports.append(
|
|
202
|
+
TokenReport(
|
|
203
|
+
token_id=token_state.token_id,
|
|
204
|
+
outcome=token_state.outcome,
|
|
205
|
+
realized_pnl_usdc=round(token_state.realized_pnl_usdc, 10),
|
|
206
|
+
taker_fee_usdc=round(token_state.taker_fee_usdc, 10),
|
|
207
|
+
maker_reward_usdc=round(token_state.maker_reward_usdc, 10),
|
|
208
|
+
buy_qty=round(token_state.buy_qty, 10),
|
|
209
|
+
sell_qty=round(token_state.sell_qty, 10),
|
|
210
|
+
split_qty=round(token_state.split_qty, 10),
|
|
211
|
+
redeem_qty=round(token_state.redeem_qty, 10),
|
|
212
|
+
ending_position_qty=round(token_state.position_qty, 10),
|
|
213
|
+
trade_count=token_state.trade_count,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
market_report = MarketReport(
|
|
218
|
+
market_slug=market.slug,
|
|
219
|
+
condition_id=market.condition_id,
|
|
220
|
+
up_token_id=market.up_token_id,
|
|
221
|
+
down_token_id=market.down_token_id,
|
|
222
|
+
realized_pnl_usdc=round(sum(t.realized_pnl_usdc for t in token_reports), 10),
|
|
223
|
+
taker_fee_usdc=round(sum(t.taker_fee_usdc for t in token_reports), 10),
|
|
224
|
+
maker_reward_usdc=round(sum(t.maker_reward_usdc for t in token_reports), 10),
|
|
225
|
+
ending_position_up=round(token_states[market.up_token_id].position_qty, 10),
|
|
226
|
+
ending_position_down=round(token_states[market.down_token_id].position_qty, 10),
|
|
227
|
+
tokens=sorted(token_reports, key=lambda x: x.token_id),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return market_report, pnl_deltas, warnings
|
|
231
|
+
|
|
232
|
+
def _apply_trade(
|
|
233
|
+
self,
|
|
234
|
+
market_slug: str,
|
|
235
|
+
token_state: _TokenState,
|
|
236
|
+
event: _Event,
|
|
237
|
+
maker_reward_enabled: bool,
|
|
238
|
+
) -> tuple[list[PnlDelta], list[WarningItem]]:
|
|
239
|
+
deltas: list[PnlDelta] = []
|
|
240
|
+
warnings: list[WarningItem] = []
|
|
241
|
+
|
|
242
|
+
adjusted_size, _, fee_usdc = _fee_adjust(event.size, event.price, self._fee_rate_bps)
|
|
243
|
+
|
|
244
|
+
if event.side == "BUY":
|
|
245
|
+
qty_add = event.size
|
|
246
|
+
if event.is_taker and self._charge_taker_fee:
|
|
247
|
+
qty_add = adjusted_size
|
|
248
|
+
total_cost = event.size * event.price
|
|
249
|
+
if qty_add > 0:
|
|
250
|
+
token_state.lots.append(_Lot(qty=qty_add, cost_per_qty=total_cost / qty_add))
|
|
251
|
+
token_state.buy_qty += qty_add
|
|
252
|
+
if event.is_taker and self._charge_taker_fee:
|
|
253
|
+
token_state.taker_fee_usdc += fee_usdc
|
|
254
|
+
elif event.side == "SELL":
|
|
255
|
+
token_state.sell_qty += event.size
|
|
256
|
+
proceeds = event.size * event.price
|
|
257
|
+
if event.is_taker and self._charge_taker_fee:
|
|
258
|
+
proceeds = adjusted_size * event.price
|
|
259
|
+
close_deltas, close_warnings = self._close_position(
|
|
260
|
+
market_slug=market_slug,
|
|
261
|
+
token_state=token_state,
|
|
262
|
+
timestamp=event.timestamp,
|
|
263
|
+
quantity=event.size,
|
|
264
|
+
proceeds=proceeds,
|
|
265
|
+
missing_cost_warn_code="SELL_OVERSELL_ZERO_COST",
|
|
266
|
+
)
|
|
267
|
+
deltas.extend(close_deltas)
|
|
268
|
+
warnings.extend(close_warnings)
|
|
269
|
+
if event.is_taker and self._charge_taker_fee:
|
|
270
|
+
token_state.taker_fee_usdc += fee_usdc
|
|
271
|
+
|
|
272
|
+
if not event.is_taker and maker_reward_enabled:
|
|
273
|
+
maker_reward = fee_usdc * self._maker_reward_ratio
|
|
274
|
+
token_state.realized_pnl_usdc += maker_reward
|
|
275
|
+
token_state.maker_reward_usdc += maker_reward
|
|
276
|
+
deltas.append(
|
|
277
|
+
PnlDelta(
|
|
278
|
+
timestamp=event.timestamp,
|
|
279
|
+
market_slug=market_slug,
|
|
280
|
+
token_id=token_state.token_id,
|
|
281
|
+
delta_pnl_usdc=maker_reward,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return deltas, warnings
|
|
286
|
+
|
|
287
|
+
def _close_position(
|
|
288
|
+
self,
|
|
289
|
+
market_slug: str,
|
|
290
|
+
token_state: _TokenState,
|
|
291
|
+
timestamp: int,
|
|
292
|
+
quantity: float,
|
|
293
|
+
proceeds: float,
|
|
294
|
+
missing_cost_warn_code: str,
|
|
295
|
+
) -> tuple[list[PnlDelta], list[WarningItem]]:
|
|
296
|
+
warnings: list[WarningItem] = []
|
|
297
|
+
quantity = max(0.0, quantity)
|
|
298
|
+
if quantity == 0:
|
|
299
|
+
return [], warnings
|
|
300
|
+
|
|
301
|
+
remaining = quantity
|
|
302
|
+
realized_cost = 0.0
|
|
303
|
+
while remaining > 1e-12 and token_state.lots:
|
|
304
|
+
lot = token_state.lots[0]
|
|
305
|
+
take = min(lot.qty, remaining)
|
|
306
|
+
realized_cost += take * lot.cost_per_qty
|
|
307
|
+
lot.qty -= take
|
|
308
|
+
remaining -= take
|
|
309
|
+
if lot.qty <= 1e-12:
|
|
310
|
+
token_state.lots.popleft()
|
|
311
|
+
|
|
312
|
+
if remaining > self._missing_cost_warn_qty:
|
|
313
|
+
warnings.append(
|
|
314
|
+
WarningItem(
|
|
315
|
+
timestamp=timestamp,
|
|
316
|
+
market_slug=market_slug,
|
|
317
|
+
token_id=token_state.token_id,
|
|
318
|
+
code=missing_cost_warn_code,
|
|
319
|
+
message=(
|
|
320
|
+
"position shortfall consumed at zero cost basis, "
|
|
321
|
+
f"missing_qty={remaining:.6f}"
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
realized = proceeds - realized_cost
|
|
327
|
+
token_state.realized_pnl_usdc += realized
|
|
328
|
+
|
|
329
|
+
return [
|
|
330
|
+
PnlDelta(
|
|
331
|
+
timestamp=timestamp,
|
|
332
|
+
market_slug=market_slug,
|
|
333
|
+
token_id=token_state.token_id,
|
|
334
|
+
delta_pnl_usdc=realized,
|
|
335
|
+
)
|
|
336
|
+
], warnings
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _fee_adjust(size: float, price: float, fee_rate_bps: float) -> tuple[float, float, float]:
|
|
341
|
+
fee_multiplier = fee_rate_bps / 1000 if fee_rate_bps else 0.0
|
|
342
|
+
fee = 0.25 * (price * (1 - price)) ** 2 * fee_multiplier
|
|
343
|
+
adjusted_size = (1 - fee) * size
|
|
344
|
+
fee_token = size - adjusted_size
|
|
345
|
+
fee_usdc = fee_token * price
|
|
346
|
+
return adjusted_size, fee_token, fee_usdc
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _event_priority(kind: str) -> int:
|
|
351
|
+
if kind == "SPLIT":
|
|
352
|
+
return 0
|
|
353
|
+
if kind == "TRADE":
|
|
354
|
+
return 1
|
|
355
|
+
if kind == "REDEEM":
|
|
356
|
+
return 2
|
|
357
|
+
return 9
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _trade_key(trade: TradeRecord) -> tuple[str, str, str, float, float, int]:
|
|
362
|
+
return (
|
|
363
|
+
trade.transaction_hash,
|
|
364
|
+
trade.asset,
|
|
365
|
+
trade.side,
|
|
366
|
+
float(trade.price),
|
|
367
|
+
float(trade.size),
|
|
368
|
+
int(trade.timestamp),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _resolve_winner_token(market: PolymarketMarket) -> str | None:
|
|
374
|
+
if len(market.outcome_prices) < 2:
|
|
375
|
+
return None
|
|
376
|
+
up_price = market.outcome_prices[0]
|
|
377
|
+
down_price = market.outcome_prices[1]
|
|
378
|
+
if up_price == 1 and down_price == 0:
|
|
379
|
+
return market.up_token_id
|
|
380
|
+
if up_price == 0 and down_price == 1:
|
|
381
|
+
return market.down_token_id
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _market_ts_from_slug(market_slug: str) -> int | None:
|
|
386
|
+
try:
|
|
387
|
+
return int(str(market_slug).rsplit("-", 1)[-1])
|
|
388
|
+
except Exception: # noqa: BLE001
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _utc_day_start_ts(now: datetime | None = None) -> int:
|
|
393
|
+
dt = now or datetime.now(timezone.utc)
|
|
394
|
+
start = datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc)
|
|
395
|
+
return int(start.timestamp())
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _is_maker_reward_enabled_for_market(market_slug: str) -> bool:
|
|
399
|
+
market_ts = _market_ts_from_slug(market_slug)
|
|
400
|
+
if market_ts is None:
|
|
401
|
+
return True
|
|
402
|
+
return market_ts < _utc_day_start_ts()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def build_curve(deltas: list[PnlDelta]) -> list[tuple[int, float, float]]:
|
|
407
|
+
by_ts: dict[int, float] = defaultdict(float)
|
|
408
|
+
for delta in deltas:
|
|
409
|
+
by_ts[delta.timestamp] += delta.delta_pnl_usdc
|
|
410
|
+
|
|
411
|
+
cumulative = 0.0
|
|
412
|
+
points: list[tuple[int, float, float]] = []
|
|
413
|
+
for ts in sorted(by_ts.keys()):
|
|
414
|
+
delta = by_ts[ts]
|
|
415
|
+
cumulative += delta
|
|
416
|
+
points.append((ts, delta, cumulative))
|
|
417
|
+
return points
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from .analyzer import AnalyzerHooks, PolymarketProfitAnalyzer
|
|
13
|
+
from .models import (
|
|
14
|
+
AnalysisReport,
|
|
15
|
+
AnalysisRequest,
|
|
16
|
+
RunCreated,
|
|
17
|
+
RunState,
|
|
18
|
+
RunStatus,
|
|
19
|
+
RunStopAck,
|
|
20
|
+
WarningItem,
|
|
21
|
+
utc_now,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RunContext:
|
|
27
|
+
state: RunState
|
|
28
|
+
stop_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
29
|
+
queue: asyncio.Queue[str] = field(default_factory=asyncio.Queue)
|
|
30
|
+
result: AnalysisReport | None = None
|
|
31
|
+
task: asyncio.Task | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunHooks(AnalyzerHooks):
|
|
35
|
+
def __init__(self, manager: "RunManager", run_id: str):
|
|
36
|
+
self._manager = manager
|
|
37
|
+
self._run_id = run_id
|
|
38
|
+
|
|
39
|
+
async def on_run_started(self, total_markets: int) -> None:
|
|
40
|
+
ctx = self._manager._runs[self._run_id]
|
|
41
|
+
ctx.state.progress_total = total_markets
|
|
42
|
+
await self._manager._emit(
|
|
43
|
+
self._run_id,
|
|
44
|
+
"run_started",
|
|
45
|
+
{"run_id": self._run_id, "progress_total": total_markets},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def on_progress(self, current: int, total: int, market_slug: str) -> None:
|
|
49
|
+
ctx = self._manager._runs[self._run_id]
|
|
50
|
+
ctx.state.progress_current = current
|
|
51
|
+
ctx.state.progress_total = total
|
|
52
|
+
ctx.state.message = market_slug
|
|
53
|
+
await self._manager._emit(
|
|
54
|
+
self._run_id,
|
|
55
|
+
"progress",
|
|
56
|
+
{
|
|
57
|
+
"current": current,
|
|
58
|
+
"total": total,
|
|
59
|
+
"market_slug": market_slug,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def on_warning(self, warning: WarningItem) -> None:
|
|
64
|
+
logger.warning(
|
|
65
|
+
"warning run_id={} code={} market={} token={} msg={}",
|
|
66
|
+
self._run_id,
|
|
67
|
+
warning.code,
|
|
68
|
+
warning.market_slug or "-",
|
|
69
|
+
warning.token_id or "-",
|
|
70
|
+
warning.message,
|
|
71
|
+
)
|
|
72
|
+
await self._manager._emit(self._run_id, "warning", warning.model_dump())
|
|
73
|
+
|
|
74
|
+
async def on_total_point(self, timestamp: int, delta: float, cumulative: float) -> None:
|
|
75
|
+
await self._manager._emit(
|
|
76
|
+
self._run_id,
|
|
77
|
+
"point_total",
|
|
78
|
+
{
|
|
79
|
+
"timestamp": timestamp,
|
|
80
|
+
"delta_realized_pnl_usdc": delta,
|
|
81
|
+
"cumulative_realized_pnl_usdc": cumulative,
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def on_market_point(
|
|
86
|
+
self, market_slug: str, timestamp: int, delta: float, cumulative: float
|
|
87
|
+
) -> None:
|
|
88
|
+
await self._manager._emit(
|
|
89
|
+
self._run_id,
|
|
90
|
+
"point_market",
|
|
91
|
+
{
|
|
92
|
+
"market_slug": market_slug,
|
|
93
|
+
"timestamp": timestamp,
|
|
94
|
+
"delta_realized_pnl_usdc": delta,
|
|
95
|
+
"cumulative_realized_pnl_usdc": cumulative,
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def on_total_point_no_fee(self, timestamp: int, delta: float, cumulative: float) -> None:
|
|
100
|
+
await self._manager._emit(
|
|
101
|
+
self._run_id,
|
|
102
|
+
"point_total_no_fee",
|
|
103
|
+
{
|
|
104
|
+
"timestamp": timestamp,
|
|
105
|
+
"delta_realized_pnl_usdc": delta,
|
|
106
|
+
"cumulative_realized_pnl_usdc": cumulative,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def on_market_point_no_fee(
|
|
111
|
+
self, market_slug: str, timestamp: int, delta: float, cumulative: float
|
|
112
|
+
) -> None:
|
|
113
|
+
await self._manager._emit(
|
|
114
|
+
self._run_id,
|
|
115
|
+
"point_market_no_fee",
|
|
116
|
+
{
|
|
117
|
+
"market_slug": market_slug,
|
|
118
|
+
"timestamp": timestamp,
|
|
119
|
+
"delta_realized_pnl_usdc": delta,
|
|
120
|
+
"cumulative_realized_pnl_usdc": cumulative,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RunManager:
|
|
126
|
+
def __init__(self):
|
|
127
|
+
self._lock = asyncio.Lock()
|
|
128
|
+
self._runs: dict[str, RunContext] = {}
|
|
129
|
+
self._active_run_id: str | None = None
|
|
130
|
+
self._analyzer = PolymarketProfitAnalyzer()
|
|
131
|
+
|
|
132
|
+
async def create_run(self, req: AnalysisRequest) -> RunCreated:
|
|
133
|
+
async with self._lock:
|
|
134
|
+
if self._active_run_id:
|
|
135
|
+
active = self._runs.get(self._active_run_id)
|
|
136
|
+
if active and active.state.status in {
|
|
137
|
+
RunStatus.PENDING,
|
|
138
|
+
RunStatus.RUNNING,
|
|
139
|
+
RunStatus.STOPPING,
|
|
140
|
+
}:
|
|
141
|
+
raise HTTPException(status_code=409, detail="another run is in progress")
|
|
142
|
+
|
|
143
|
+
run_id = uuid.uuid4().hex
|
|
144
|
+
state = RunState(run_id=run_id, status=RunStatus.PENDING)
|
|
145
|
+
ctx = RunContext(state=state)
|
|
146
|
+
self._runs[run_id] = ctx
|
|
147
|
+
self._active_run_id = run_id
|
|
148
|
+
|
|
149
|
+
ctx.task = asyncio.create_task(self._execute_run(run_id, req))
|
|
150
|
+
logger.info(
|
|
151
|
+
"create run run_id={} address={} range=[{}, {}] symbols={} intervals={}",
|
|
152
|
+
run_id,
|
|
153
|
+
req.address,
|
|
154
|
+
req.start_ts,
|
|
155
|
+
req.end_ts,
|
|
156
|
+
",".join(req.symbols),
|
|
157
|
+
",".join(str(v) for v in req.intervals),
|
|
158
|
+
)
|
|
159
|
+
return RunCreated(run_id=run_id, status=state.status)
|
|
160
|
+
|
|
161
|
+
async def stop_run(self, run_id: str) -> RunStopAck:
|
|
162
|
+
ctx = self._runs.get(run_id)
|
|
163
|
+
if not ctx:
|
|
164
|
+
raise HTTPException(status_code=404, detail="run not found")
|
|
165
|
+
|
|
166
|
+
if ctx.state.status in {RunStatus.COMPLETED, RunStatus.STOPPED, RunStatus.FAILED}:
|
|
167
|
+
return RunStopAck(run_id=run_id, status=ctx.state.status)
|
|
168
|
+
|
|
169
|
+
ctx.stop_event.set()
|
|
170
|
+
ctx.state.status = RunStatus.STOPPING
|
|
171
|
+
logger.warning("stop requested run_id={}", run_id)
|
|
172
|
+
await self._emit(run_id, "progress", {"message": "stopping requested"})
|
|
173
|
+
return RunStopAck(run_id=run_id, status=ctx.state.status)
|
|
174
|
+
|
|
175
|
+
async def get_result(self, run_id: str) -> AnalysisReport:
|
|
176
|
+
ctx = self._runs.get(run_id)
|
|
177
|
+
if not ctx:
|
|
178
|
+
raise HTTPException(status_code=404, detail="run not found")
|
|
179
|
+
|
|
180
|
+
if not ctx.result:
|
|
181
|
+
raise HTTPException(status_code=202, detail="run not finished")
|
|
182
|
+
return ctx.result
|
|
183
|
+
|
|
184
|
+
async def get_state(self, run_id: str) -> RunState:
|
|
185
|
+
ctx = self._runs.get(run_id)
|
|
186
|
+
if not ctx:
|
|
187
|
+
raise HTTPException(status_code=404, detail="run not found")
|
|
188
|
+
return ctx.state
|
|
189
|
+
|
|
190
|
+
async def stream(self, run_id: str):
|
|
191
|
+
ctx = self._runs.get(run_id)
|
|
192
|
+
if not ctx:
|
|
193
|
+
raise HTTPException(status_code=404, detail="run not found")
|
|
194
|
+
|
|
195
|
+
while True:
|
|
196
|
+
if ctx.task and ctx.task.done() and ctx.queue.empty():
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
payload = await asyncio.wait_for(ctx.queue.get(), timeout=10)
|
|
201
|
+
yield payload
|
|
202
|
+
except asyncio.TimeoutError:
|
|
203
|
+
yield ": keep-alive\n\n"
|
|
204
|
+
|
|
205
|
+
async def _execute_run(self, run_id: str, req: AnalysisRequest) -> None:
|
|
206
|
+
ctx = self._runs[run_id]
|
|
207
|
+
hooks = RunHooks(self, run_id)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
ctx.state.status = RunStatus.RUNNING
|
|
211
|
+
ctx.state.started_at = utc_now()
|
|
212
|
+
logger.info("run started run_id={}", run_id)
|
|
213
|
+
|
|
214
|
+
report = await self._analyzer.run(req=req, stop_event=ctx.stop_event, hooks=hooks)
|
|
215
|
+
|
|
216
|
+
output_dir = Path(req.output_dir)
|
|
217
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
suffix = f"{run_id}_{'partial' if report.is_partial else 'final'}"
|
|
219
|
+
|
|
220
|
+
json_path = self._analyzer.save_json(report, str(output_dir / f"pnl_summary_{suffix}.json"))
|
|
221
|
+
total_csv_path = self._analyzer.save_total_curve_csv(
|
|
222
|
+
report, str(output_dir / f"pnl_total_curve_{suffix}.csv")
|
|
223
|
+
)
|
|
224
|
+
market_csv_path = self._analyzer.save_market_curve_csv(
|
|
225
|
+
report, str(output_dir / f"pnl_market_curve_{suffix}.csv")
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
report.artifacts = {
|
|
229
|
+
"json": json_path,
|
|
230
|
+
"total_curve_csv": total_csv_path,
|
|
231
|
+
"market_curve_csv": market_csv_path,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
ctx.result = report
|
|
235
|
+
ctx.state.ended_at = utc_now()
|
|
236
|
+
|
|
237
|
+
if report.is_partial:
|
|
238
|
+
ctx.state.status = RunStatus.STOPPED
|
|
239
|
+
logger.warning("run stopped run_id={} (partial result saved)", run_id)
|
|
240
|
+
await self._emit(
|
|
241
|
+
run_id,
|
|
242
|
+
"stopped",
|
|
243
|
+
{
|
|
244
|
+
"run_id": run_id,
|
|
245
|
+
"status": ctx.state.status,
|
|
246
|
+
"artifacts": self._to_public_artifact_paths(report.artifacts),
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
ctx.state.status = RunStatus.COMPLETED
|
|
251
|
+
logger.info("run completed run_id={} markets_processed={}", run_id, report.summary.markets_processed)
|
|
252
|
+
await self._emit(
|
|
253
|
+
run_id,
|
|
254
|
+
"completed",
|
|
255
|
+
{
|
|
256
|
+
"run_id": run_id,
|
|
257
|
+
"status": ctx.state.status,
|
|
258
|
+
"artifacts": self._to_public_artifact_paths(report.artifacts),
|
|
259
|
+
"summary": report.summary.model_dump(),
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
except Exception as exc: # noqa: BLE001
|
|
263
|
+
ctx.state.status = RunStatus.FAILED
|
|
264
|
+
ctx.state.ended_at = utc_now()
|
|
265
|
+
logger.exception("run failed run_id={} error={}", run_id, exc)
|
|
266
|
+
await self._emit(run_id, "run_error", {"message": str(exc)})
|
|
267
|
+
finally:
|
|
268
|
+
async with self._lock:
|
|
269
|
+
if self._active_run_id == run_id:
|
|
270
|
+
self._active_run_id = None
|
|
271
|
+
|
|
272
|
+
async def _emit(self, run_id: str, event_name: str, data: dict) -> None:
|
|
273
|
+
ctx = self._runs.get(run_id)
|
|
274
|
+
if not ctx:
|
|
275
|
+
return
|
|
276
|
+
payload = self._format_sse(event_name, data)
|
|
277
|
+
await ctx.queue.put(payload)
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _format_sse(event_name: str, data: dict) -> str:
|
|
281
|
+
return f"event: {event_name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _to_public_artifact_paths(artifacts: dict[str, str]) -> dict[str, str]:
|
|
285
|
+
public: dict[str, str] = {}
|
|
286
|
+
for key, path in artifacts.items():
|
|
287
|
+
filename = Path(path).name
|
|
288
|
+
public[key] = f"/reports/{filename}"
|
|
289
|
+
return public
|