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,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"
|
analysis_poly/models.py
ADDED
|
@@ -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
|