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.
@@ -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