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,133 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from .models import PolymarketMarket
8
+
9
+
10
+ class MarketMetadataCache:
11
+ def __init__(self, cache_dir: str | Path = ".cache/market_by_slug", recent_window_sec: int = 30 * 60):
12
+ self._cache_dir = Path(cache_dir)
13
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
14
+ self._recent_window_sec = recent_window_sec
15
+ self._symbol_payload_cache: dict[str, dict] = {}
16
+ self._market_obj_cache: dict[str, PolymarketMarket] = {}
17
+
18
+ def is_cache_eligible(self, slug: str, now_ts: int) -> bool:
19
+ market_ts = _market_ts_from_slug(slug)
20
+ if market_ts is None:
21
+ return False
22
+ return (now_ts - market_ts) > self._recent_window_sec
23
+
24
+ def get(self, slug: str) -> PolymarketMarket | None:
25
+ cached_market = self._market_obj_cache.get(slug)
26
+ if cached_market is not None:
27
+ return cached_market
28
+
29
+ symbol = _symbol_from_slug(slug)
30
+ if symbol is None:
31
+ return None
32
+
33
+ payload = self._load_symbol_payload(symbol)
34
+ markets = payload.get("markets", {})
35
+ market_payload = markets.get(slug)
36
+ if market_payload is not None:
37
+ try:
38
+ market = PolymarketMarket.model_validate(market_payload)
39
+ self._market_obj_cache[slug] = market
40
+ return market
41
+ except Exception: # noqa: BLE001
42
+ return None
43
+
44
+ # Backward compatibility for legacy per-slug cache files.
45
+ legacy_path = self._legacy_path_for_slug(slug)
46
+ if not legacy_path.exists():
47
+ return None
48
+ try:
49
+ market = PolymarketMarket.model_validate_json(legacy_path.read_text(encoding="utf-8"))
50
+ self._market_obj_cache[slug] = market
51
+ self.set(slug, market)
52
+ return market
53
+ except Exception: # noqa: BLE001
54
+ return None
55
+
56
+ def set(self, slug: str, market: PolymarketMarket) -> None:
57
+ symbol = _symbol_from_slug(slug)
58
+ if symbol is None:
59
+ return
60
+
61
+ payload = self._load_symbol_payload(symbol)
62
+ market_dump = market.model_dump()
63
+ payload["symbol"] = symbol
64
+ markets = payload["markets"]
65
+ if markets.get(slug) == market_dump:
66
+ self._market_obj_cache[slug] = market
67
+ return
68
+
69
+ markets[slug] = market_dump
70
+ self._market_obj_cache[slug] = market
71
+ self._save_symbol_payload(symbol, payload)
72
+
73
+ def _load_symbol_payload(self, symbol: str) -> dict:
74
+ cached = self._symbol_payload_cache.get(symbol)
75
+ if cached is not None:
76
+ return cached
77
+
78
+ path = self._path_for_symbol(symbol)
79
+ payload: dict = {"symbol": symbol, "markets": {}}
80
+ if path.exists():
81
+ try:
82
+ loaded = json.loads(path.read_text(encoding="utf-8"))
83
+ if isinstance(loaded, dict):
84
+ payload = loaded
85
+ except Exception: # noqa: BLE001
86
+ payload = {"symbol": symbol, "markets": {}}
87
+
88
+ if not isinstance(payload.get("markets"), dict):
89
+ payload["markets"] = {}
90
+ payload["symbol"] = symbol
91
+ self._symbol_payload_cache[symbol] = payload
92
+ return payload
93
+
94
+ def _save_symbol_payload(self, symbol: str, payload: dict) -> None:
95
+ path = self._path_for_symbol(symbol)
96
+ tmp_path = path.with_suffix(".tmp")
97
+ try:
98
+ tmp_path.write_text(
99
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
100
+ encoding="utf-8",
101
+ )
102
+ tmp_path.replace(path)
103
+ except Exception: # noqa: BLE001
104
+ try:
105
+ if tmp_path.exists():
106
+ tmp_path.unlink()
107
+ except Exception: # noqa: BLE001
108
+ pass
109
+ return
110
+
111
+ def _path_for_symbol(self, symbol: str) -> Path:
112
+ safe_symbol = re.sub(r"[^a-zA-Z0-9_.-]", "_", symbol.lower())
113
+ return self._cache_dir / f"{safe_symbol}.json"
114
+
115
+ def _legacy_path_for_slug(self, slug: str) -> Path:
116
+ safe_slug = re.sub(r"[^a-zA-Z0-9_.-]", "_", slug)
117
+ return self._cache_dir / f"{safe_slug}.json"
118
+
119
+
120
+
121
+ def _market_ts_from_slug(slug: str) -> int | None:
122
+ try:
123
+ return int(str(slug).rsplit("-", 1)[-1])
124
+ except Exception: # noqa: BLE001
125
+ return None
126
+
127
+
128
+ def _symbol_from_slug(slug: str) -> str | None:
129
+ parts = str(slug).split("-")
130
+ if not parts:
131
+ return None
132
+ symbol = parts[0].strip().lower()
133
+ return symbol or None
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from pathlib import Path
7
+
8
+
9
+ class AddressMarketResultCache:
10
+ def __init__(self, cache_dir: str | Path = ".cache/address_market_results"):
11
+ self._cache_dir = Path(cache_dir)
12
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
13
+
14
+ def load(self, address: str) -> dict[str, dict]:
15
+ path = self._path_for_address(address)
16
+ if not path.exists():
17
+ return {}
18
+
19
+ try:
20
+ payload = json.loads(path.read_text(encoding="utf-8"))
21
+ markets = payload.get("markets")
22
+ if isinstance(markets, dict):
23
+ return markets
24
+ except Exception: # noqa: BLE001
25
+ return {}
26
+ return {}
27
+
28
+ def save(self, address: str, markets: dict[str, dict]) -> None:
29
+ path = self._path_for_address(address)
30
+ payload = {
31
+ "address": address.lower(),
32
+ "updated_at": int(time.time()),
33
+ "markets": markets,
34
+ }
35
+
36
+ tmp_path = path.with_suffix(".tmp")
37
+ try:
38
+ tmp_path.write_text(
39
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
40
+ encoding="utf-8",
41
+ )
42
+ tmp_path.replace(path)
43
+ except Exception: # noqa: BLE001
44
+ try:
45
+ if tmp_path.exists():
46
+ tmp_path.unlink()
47
+ except Exception: # noqa: BLE001
48
+ pass
49
+
50
+ def _path_for_address(self, address: str) -> Path:
51
+ safe_addr = re.sub(r"[^a-zA-Z0-9_.-]", "_", address.lower().strip())
52
+ return self._cache_dir / f"{safe_addr}.json"
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from enum import Enum
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field, field_validator, model_validator
8
+
9
+
10
+ class RunStatus(str, Enum):
11
+ PENDING = "PENDING"
12
+ RUNNING = "RUNNING"
13
+ STOPPING = "STOPPING"
14
+ COMPLETED = "COMPLETED"
15
+ STOPPED = "STOPPED"
16
+ FAILED = "FAILED"
17
+
18
+
19
+ class AnalysisRequest(BaseModel):
20
+ address: str
21
+ start_ts: int
22
+ end_ts: int
23
+ symbols: list[Literal["btc", "eth", "sol", "xrp"]]
24
+ intervals: list[int]
25
+ fee_rate_bps: float = 1000
26
+ maker_reward_ratio: float = 0.2
27
+ missing_cost_warn_qty: float = 0.5
28
+ page_limit: int = 1000
29
+ concurrency: int = 5
30
+ request_timeout_sec: float = 20
31
+ output_dir: str = "reports"
32
+
33
+ @field_validator("address")
34
+ @classmethod
35
+ def validate_address(cls, value: str) -> str:
36
+ lowered = value.lower().strip()
37
+ if not lowered.startswith("0x"):
38
+ raise ValueError("address must start with 0x")
39
+ return lowered
40
+
41
+ @field_validator("symbols")
42
+ @classmethod
43
+ def validate_symbols(cls, value: list[str]) -> list[str]:
44
+ if not value:
45
+ raise ValueError("symbols cannot be empty")
46
+ uniq = sorted(set(value))
47
+ return uniq
48
+
49
+ @field_validator("intervals")
50
+ @classmethod
51
+ def validate_intervals(cls, value: list[int]) -> list[int]:
52
+ if not value:
53
+ raise ValueError("intervals cannot be empty")
54
+ if any(v <= 0 for v in value):
55
+ raise ValueError("intervals must be > 0")
56
+ return sorted(set(value))
57
+
58
+ @model_validator(mode="after")
59
+ def validate_time_range(self) -> "AnalysisRequest":
60
+ if self.start_ts >= self.end_ts:
61
+ raise ValueError("start_ts must be smaller than end_ts")
62
+ return self
63
+
64
+
65
+ class RunCreated(BaseModel):
66
+ run_id: str
67
+ status: RunStatus
68
+
69
+
70
+ class RunStopAck(BaseModel):
71
+ run_id: str
72
+ status: RunStatus
73
+
74
+
75
+ class RunState(BaseModel):
76
+ run_id: str
77
+ status: RunStatus
78
+ started_at: datetime | None = None
79
+ ended_at: datetime | None = None
80
+ progress_current: int = 0
81
+ progress_total: int = 0
82
+ message: str = ""
83
+
84
+
85
+ class WarningItem(BaseModel):
86
+ timestamp: int | None = None
87
+ market_slug: str | None = None
88
+ token_id: str | None = None
89
+ code: str
90
+ message: str
91
+
92
+
93
+ class CurvePoint(BaseModel):
94
+ timestamp: int
95
+ delta_realized_pnl_usdc: float
96
+ cumulative_realized_pnl_usdc: float
97
+
98
+
99
+ class TokenReport(BaseModel):
100
+ token_id: str
101
+ outcome: Literal["Up", "Down"]
102
+ realized_pnl_usdc: float = 0
103
+ taker_fee_usdc: float = 0
104
+ maker_reward_usdc: float = 0
105
+ buy_qty: float = 0
106
+ sell_qty: float = 0
107
+ split_qty: float = 0
108
+ redeem_qty: float = 0
109
+ ending_position_qty: float = 0
110
+ trade_count: int = 0
111
+
112
+
113
+ class MarketReport(BaseModel):
114
+ market_slug: str
115
+ condition_id: str
116
+ up_token_id: str
117
+ down_token_id: str
118
+ realized_pnl_usdc: float = 0
119
+ taker_fee_usdc: float = 0
120
+ maker_reward_usdc: float = 0
121
+ ending_position_up: float = 0
122
+ ending_position_down: float = 0
123
+ tokens: list[TokenReport] = Field(default_factory=list)
124
+
125
+
126
+ class SummaryStats(BaseModel):
127
+ total_realized_pnl_usdc: float = 0
128
+ total_taker_fee_usdc: float = 0
129
+ total_maker_reward_usdc: float = 0
130
+ markets_total: int = 0
131
+ markets_processed: int = 0
132
+
133
+
134
+ class AnalysisReport(BaseModel):
135
+ request: AnalysisRequest
136
+ summary: SummaryStats
137
+ markets: list[MarketReport]
138
+ total_curve: list[CurvePoint]
139
+ market_curves: dict[str, list[CurvePoint]]
140
+ total_curve_no_fee: list[CurvePoint] = Field(default_factory=list)
141
+ market_curves_no_fee: dict[str, list[CurvePoint]] = Field(default_factory=dict)
142
+ warnings: list[WarningItem]
143
+ is_partial: bool = False
144
+ artifacts: dict[str, str] = Field(default_factory=dict)
145
+
146
+
147
+ class PolymarketMarket(BaseModel):
148
+ slug: str
149
+ condition_id: str
150
+ up_token_id: str
151
+ down_token_id: str
152
+ outcomes: list[str]
153
+ outcome_prices: list[float]
154
+
155
+
156
+ class TradeRecord(BaseModel):
157
+ transaction_hash: str = Field(alias="transactionHash")
158
+ timestamp: int
159
+ side: Literal["BUY", "SELL"]
160
+ asset: str
161
+ condition_id: str = Field(alias="conditionId")
162
+ size: float
163
+ price: float
164
+ outcome: str | None = None
165
+
166
+
167
+ class ActivityRecord(BaseModel):
168
+ transaction_hash: str = Field(alias="transactionHash")
169
+ timestamp: int
170
+ type: str
171
+ condition_id: str = Field(alias="conditionId")
172
+ slug: str = ""
173
+ size: float = 0
174
+ usdc_size: float = Field(default=0, alias="usdcSize")
175
+
176
+
177
+ class StreamEvent(BaseModel):
178
+ event: str
179
+ data: dict
180
+
181
+
182
+ def utc_now() -> datetime:
183
+ return datetime.now(timezone.utc)
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import threading
5
+ import time
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ import webbrowser
10
+ from datetime import datetime
11
+
12
+ if __package__ in (None, ""):
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
17
+ from analysis_poly.logging_config import configure_logging
18
+ from analysis_poly.web import app
19
+ else:
20
+ from .logging_config import configure_logging
21
+ from .web import app
22
+
23
+
24
+ def _to_datetime_text(unix_ts: int) -> str:
25
+ return datetime.fromtimestamp(unix_ts).strftime("%Y-%m-%d %H:%M")
26
+
27
+
28
+ def _browser_host(host: str) -> str:
29
+ if host == "0.0.0.0":
30
+ return "localhost"
31
+ return host
32
+
33
+
34
+ def _build_bootstrap_query(args: argparse.Namespace) -> dict[str, str]:
35
+ params: dict[str, str] = {}
36
+
37
+ mapping = {
38
+ "address": "address",
39
+ "symbols": "symbols",
40
+ "intervals": "intervals",
41
+ "start_time": "start_time",
42
+ "end_time": "end_time",
43
+ "fee_rate_bps": "fee_rate_bps",
44
+ "missing_cost_warn_qty": "missing_cost_warn_qty",
45
+ "maker_reward_ratio": "maker_reward_ratio",
46
+ "concurrency": "concurrency",
47
+ "page_limit": "page_limit",
48
+ }
49
+ for arg_key, query_key in mapping.items():
50
+ value = getattr(args, arg_key)
51
+ if value is None:
52
+ continue
53
+ params[query_key] = str(value)
54
+
55
+ if args.start_ts is not None and "start_time" not in params:
56
+ params["start_time"] = _to_datetime_text(args.start_ts)
57
+ if args.end_ts is not None and "end_time" not in params:
58
+ params["end_time"] = _to_datetime_text(args.end_ts)
59
+
60
+ has_analysis_fields = any(
61
+ key in params
62
+ for key in {
63
+ "address",
64
+ "symbols",
65
+ "intervals",
66
+ "start_time",
67
+ "end_time",
68
+ "fee_rate_bps",
69
+ "missing_cost_warn_qty",
70
+ "maker_reward_ratio",
71
+ "concurrency",
72
+ "page_limit",
73
+ }
74
+ )
75
+ if args.auto_start or has_analysis_fields:
76
+ params["auto_start"] = "1"
77
+
78
+ return params
79
+
80
+
81
+ def _build_browser_url(args: argparse.Namespace) -> str:
82
+ host = _browser_host(args.host)
83
+ base = f"http://{host}:{args.port}/"
84
+ params = _build_bootstrap_query(args)
85
+ if not params:
86
+ return base
87
+ return f"{base}?{urllib.parse.urlencode(params)}"
88
+
89
+
90
+ def _open_browser_when_ready(url: str, probe_url: str, timeout_sec: float) -> None:
91
+ deadline = time.monotonic() + timeout_sec
92
+ while time.monotonic() < deadline:
93
+ try:
94
+ with urllib.request.urlopen(probe_url, timeout=0.8):
95
+ webbrowser.open(url)
96
+ return
97
+ except (urllib.error.URLError, TimeoutError):
98
+ time.sleep(0.25)
99
+ webbrowser.open(url)
100
+
101
+
102
+ def _build_arg_parser() -> argparse.ArgumentParser:
103
+ parser = argparse.ArgumentParser(description="Start web server and open browser with bootstrap params")
104
+ parser.add_argument("--host", default="0.0.0.0", help="Bind host for web server")
105
+ parser.add_argument("--port", type=int, default=8000, help="Bind port for web server")
106
+ parser.add_argument("--browser-timeout-sec", type=float, default=20.0, help="Wait timeout for browser auto-open")
107
+
108
+ parser.add_argument("--address")
109
+ parser.add_argument("--symbols", help='Comma-separated, e.g. "btc,eth,sol"')
110
+ parser.add_argument("--intervals", help='Comma-separated, e.g. "5,15"')
111
+ parser.add_argument("--start-time", help='Local datetime, format "YYYY-MM-DD HH:MM"')
112
+ parser.add_argument("--end-time", help='Local datetime, format "YYYY-MM-DD HH:MM"')
113
+ parser.add_argument("--start-ts", type=int, help="Unix timestamp (seconds), used when --start-time is missing")
114
+ parser.add_argument("--end-ts", type=int, help="Unix timestamp (seconds), used when --end-time is missing")
115
+ parser.add_argument("--fee-rate-bps", type=float)
116
+ parser.add_argument("--missing-cost-warn-qty", type=float)
117
+ parser.add_argument("--maker-reward-ratio", type=float)
118
+ parser.add_argument("--concurrency", type=int)
119
+ parser.add_argument("--page-limit", type=int)
120
+ parser.add_argument("--auto-start", action="store_true", help="Force auto start analysis in frontend")
121
+ return parser
122
+
123
+
124
+ def main() -> None:
125
+ import uvicorn
126
+
127
+ args = _build_arg_parser().parse_args()
128
+ configure_logging()
129
+
130
+ browser_host = _browser_host(args.host)
131
+ probe_url = f"http://{browser_host}:{args.port}/"
132
+ url = _build_browser_url(args)
133
+ threading.Thread(
134
+ target=_open_browser_when_ready,
135
+ args=(url, probe_url, max(1.0, args.browser_timeout_sec)),
136
+ daemon=True,
137
+ ).start()
138
+
139
+ uvicorn.run(app, host=args.host, port=args.port)
140
+
141
+
142
+ if __name__ == "__main__":
143
+ main()
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from .models import ActivityRecord, PolymarketMarket, TradeRecord
9
+
10
+
11
+ class PolymarketApiClient:
12
+ def __init__(self, timeout_sec: float = 20, retries: int = 5):
13
+ self._timeout_sec = timeout_sec
14
+ self._retries = retries
15
+ self._gamma_base = "https://gamma-api.polymarket.com"
16
+ self._data_base = "https://data-api.polymarket.com"
17
+ self._client = httpx.AsyncClient(timeout=timeout_sec)
18
+
19
+ async def aclose(self) -> None:
20
+ await self._client.aclose()
21
+
22
+ async def _request_json(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
23
+ last_exc: Exception | None = None
24
+ for attempt in range(self._retries):
25
+ try:
26
+ response = await self._client.request(method, url, params=params)
27
+ if response.status_code == 404:
28
+ return None
29
+ response.raise_for_status()
30
+ return response.json()
31
+ except httpx.HTTPStatusError as exc:
32
+ last_exc = exc
33
+ if 400 <= exc.response.status_code < 500:
34
+ raise
35
+ except Exception as exc: # noqa: BLE001
36
+ last_exc = exc
37
+
38
+ if attempt + 1 < self._retries:
39
+ await asyncio.sleep(0.4 * (2**attempt))
40
+
41
+ if last_exc:
42
+ raise last_exc
43
+ raise RuntimeError("request failed without exception")
44
+
45
+ async def get_market_by_slug(self, slug: str) -> PolymarketMarket | None:
46
+ data = await self._request_json("GET", f"{self._gamma_base}/markets/slug/{slug}")
47
+ if not data:
48
+ return None
49
+
50
+ outcomes_raw = data.get("outcomes", "[]")
51
+ outcome_prices_raw = data.get("outcomePrices", "[]")
52
+ tokens_raw = data.get("clobTokenIds", "[]")
53
+
54
+ outcomes = _parse_json_field(outcomes_raw, fallback=[])
55
+ outcome_prices = [float(x) for x in _parse_json_field(outcome_prices_raw, fallback=[])]
56
+ token_ids = [str(x) for x in _parse_json_field(tokens_raw, fallback=[])]
57
+
58
+ if len(token_ids) < 2:
59
+ return None
60
+
61
+ return PolymarketMarket(
62
+ slug=data["slug"],
63
+ condition_id=data["conditionId"],
64
+ up_token_id=token_ids[0],
65
+ down_token_id=token_ids[1],
66
+ outcomes=outcomes,
67
+ outcome_prices=outcome_prices,
68
+ )
69
+
70
+ async def get_trades(
71
+ self,
72
+ user: str,
73
+ market: str,
74
+ taker_only: bool,
75
+ limit: int = 1000,
76
+ ) -> list[TradeRecord]:
77
+ records: list[TradeRecord] = []
78
+ offset = 0
79
+ while True:
80
+ params = {
81
+ "user": user,
82
+ "market": market,
83
+ "takerOnly": str(taker_only).lower(),
84
+ "limit": limit,
85
+ "offset": offset,
86
+ }
87
+ data = await self._request_json("GET", f"{self._data_base}/trades", params=params)
88
+ if not data:
89
+ break
90
+ page = [TradeRecord.model_validate(item) for item in data]
91
+ records.extend(page)
92
+ if len(page) < limit:
93
+ break
94
+ offset += len(page)
95
+ return records
96
+
97
+ async def get_activity(
98
+ self,
99
+ user: str,
100
+ market: str,
101
+ activity_type: str,
102
+ limit: int = 1000,
103
+ ) -> list[ActivityRecord]:
104
+ records: list[ActivityRecord] = []
105
+ offset = 0
106
+ while True:
107
+ params = {
108
+ "user": user,
109
+ "market": market,
110
+ "type": activity_type,
111
+ "sortBy": "TIMESTAMP",
112
+ "sortDirection": "ASC",
113
+ "limit": limit,
114
+ "offset": offset,
115
+ }
116
+ data = await self._request_json("GET", f"{self._data_base}/activity", params=params)
117
+ if not data:
118
+ break
119
+ page = [ActivityRecord.model_validate(item) for item in data]
120
+ records.extend(page)
121
+ if len(page) < limit:
122
+ break
123
+ offset += len(page)
124
+ return records
125
+
126
+
127
+
128
+ def _parse_json_field(raw: Any, fallback: list[Any]) -> list[Any]:
129
+ if isinstance(raw, list):
130
+ return raw
131
+ if isinstance(raw, str):
132
+ import json
133
+
134
+ try:
135
+ parsed = json.loads(raw)
136
+ if isinstance(parsed, list):
137
+ return parsed
138
+ return fallback
139
+ except Exception: # noqa: BLE001
140
+ return fallback
141
+ return fallback