File size: 10,929 Bytes
da0ce7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
#!/usr/bin/env python3
"""Live exchange API throughput benchmark (public endpoints).



Expanded stress benchmark:

- ramp phases (low/med/high request pressure)

- optional concurrency

- per-phase latency + status + success RPM

- runtime tuning recommendation with buffer

"""
from __future__ import annotations

import argparse
import json
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Dict, List, Optional

from aureon.core.exchange_rate_limit_registry import OFFICIAL_EXCHANGE_RATE_LIMITS


@dataclass
class Probe:
    exchange: str
    name: str
    url: str


PROBES: List[Probe] = [
    Probe("binance", "ticker_price", "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT"),
    Probe("kraken", "ticker", "https://api.kraken.com/0/public/Ticker?pair=XBTUSD"),
    Probe("alpaca", "clock", "https://paper-api.alpaca.markets/v2/clock"),
    Probe("capital", "ping", "https://api-capital.backend-capital.com/api/v1/ping"),
]


def call(url: str, timeout_s: float) -> tuple[bool, int, float]:
    started = time.perf_counter()
    req = urllib.request.Request(url, headers={"User-Agent": "aureon-rate-benchmark/2.0"})
    try:
        with urllib.request.urlopen(req, timeout=timeout_s) as resp:
            _ = resp.read(2048)
            latency = (time.perf_counter() - started) * 1000.0
            return True, int(getattr(resp, "status", 200) or 200), latency
    except urllib.error.HTTPError as e:
        latency = (time.perf_counter() - started) * 1000.0
        return False, int(e.code or 0), latency
    except Exception:
        latency = (time.perf_counter() - started) * 1000.0
        return False, 0, latency


def _percentile(values: List[float], p: float) -> float:
    if not values:
        return 0.0
    v = sorted(values)
    idx = min(len(v) - 1, max(0, int(round((p / 100.0) * (len(v) - 1)))))
    return v[idx]


def run_phase(probe: Probe, phase_name: str, duration_s: int, qps: float, timeout_s: float, concurrency: int) -> Dict[str, object]:
    interval = 1.0 / max(0.1, qps)
    end = time.time() + duration_s
    latencies: List[float] = []
    ok = 0
    errors = 0
    statuses: Dict[str, int] = {}
    first_429: Optional[int] = None
    submitted = 0

    with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
        futures = []
        while time.time() < end:
            futures.append(pool.submit(call, probe.url, timeout_s))
            submitted += 1
            time.sleep(interval)

        completed = 0
        for fut in as_completed(futures):
            success, status, latency = fut.result()
            completed += 1
            latencies.append(latency)
            key = str(status)
            statuses[key] = statuses.get(key, 0) + 1
            if success:
                ok += 1
            else:
                errors += 1
                if status == 429 and first_429 is None:
                    first_429 = completed

    elapsed = max(0.001, float(duration_s))
    rpm_success = (ok / elapsed) * 60.0
    rpm_observed = (submitted / elapsed) * 60.0
    profile = OFFICIAL_EXCHANGE_RATE_LIMITS.get(probe.exchange)
    safe_cpm = float(profile.safe_governor_calls_per_min) if profile else 0.0
    pct_of_safe = (rpm_success / safe_cpm * 100.0) if safe_cpm > 0 else 0.0

    return {
        "phase": phase_name,
        "duration_sec": duration_s,
        "target_qps": qps,
        "concurrency": concurrency,
        "submitted": submitted,
        "ok": ok,
        "errors": errors,
        "status_counts": statuses,
        "first_429_at_response": first_429,
        "latency_ms": {
            "p50": round(_percentile(latencies, 50), 3),
            "p95": round(_percentile(latencies, 95), 3),
            "p99": round(_percentile(latencies, 99), 3),
            "max": round(max(latencies) if latencies else 0.0, 3),
        },
        "successful_requests_per_min": round(rpm_success, 2),
        "observed_requests_per_min": round(rpm_observed, 2),
        "safe_calls_per_min": round(safe_cpm, 2),
        "pct_of_safe_limit": round(pct_of_safe, 2),
        "phase_valid": ok > 0,
    }


def _phases_for_profile(profile: str) -> List[tuple[str, float]]:
    p = str(profile or "standard").strip().lower()
    if p == "aggressive":
        return [("low", 5.0), ("medium", 12.0), ("high", 20.0), ("burst", 30.0)]
    if p == "conservative":
        return [("low", 1.0), ("medium", 2.0), ("high", 4.0)]
    return [("low", 2.0), ("medium", 5.0), ("high", 10.0)]


def benchmark_probe(probe: Probe, phase_duration_s: int, timeout_s: float, concurrency: int, buffer_pct: float, stress_profile: str) -> Dict[str, object]:
    phases = _phases_for_profile(stress_profile)
    rows = [run_phase(probe, n, phase_duration_s, qps, timeout_s, concurrency) for n, qps in phases]

    valid_rows = [r for r in rows if r["phase_valid"]]
    max_success_rpm = max((float(r["successful_requests_per_min"]) for r in valid_rows), default=0.0)
    profile = OFFICIAL_EXCHANGE_RATE_LIMITS.get(probe.exchange)
    safe_cpm = float(profile.safe_governor_calls_per_min) if profile else 0.0
    buffer_factor = max(0.5, min(0.999, 1.0 - (buffer_pct / 100.0)))
    recommendation = min(max_success_rpm, safe_cpm) * buffer_factor if safe_cpm > 0 else max_success_rpm * buffer_factor

    return {
        "exchange": probe.exchange,
        "probe": probe.name,
        "url": probe.url,
        "phase_results": rows,
        "official_safe_calls_per_min": round(safe_cpm, 2),
        "max_successful_requests_per_min": round(max_success_rpm, 2),
        "buffer_pct": buffer_pct,
        "recommended_runtime_calls_per_min": round(recommendation, 2),
        "benchmark_valid": len(valid_rows) > 0,
        "stress_profile": stress_profile,
    }


def _env_var_for_exchange(exchange: str) -> str:
    mapping = {
        "binance": "UNIFIED_BINANCE_CALLS_PER_MIN",
        "kraken": "UNIFIED_KRAKEN_CALLS_PER_MIN",
        "alpaca": "UNIFIED_ALPACA_CALLS_PER_MIN",
        "capital": "UNIFIED_CAPITAL_CALLS_PER_MIN",
    }
    return mapping.get(exchange, f"UNIFIED_{exchange.upper()}_CALLS_PER_MIN")


def write_env_recommendations(results: List[Dict[str, object]], path: Path) -> None:
    lines = ["# Generated by benchmark_exchange_api_limits.py", "# Apply in runtime env before launch"]
    for row in results:
        ex = str(row.get("exchange") or "").lower()
        rec = float(row.get("recommended_runtime_calls_per_min") or 0.0)
        var = _env_var_for_exchange(ex)
        lines.append(f"{var}={int(max(1, round(rec)))}")
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def write_csv_round_runs(run_results: List[Dict[str, object]], path: Path) -> None:
    headers = [
        "round","exchange","phase","duration_sec","target_qps","concurrency","submitted","ok","errors",
        "successful_requests_per_min","observed_requests_per_min","safe_calls_per_min","pct_of_safe_limit","phase_valid"
    ]
    rows = [",".join(headers)]
    for run in run_results:
        rnd = run.get("round")
        for exrow in run.get("results", []):
            exchange = exrow.get("exchange")
            for ph in exrow.get("phase_results", []):
                vals = [
                    rnd, exchange, ph.get("phase"), ph.get("duration_sec"), ph.get("target_qps"), ph.get("concurrency"),
                    ph.get("submitted"), ph.get("ok"), ph.get("errors"), ph.get("successful_requests_per_min"),
                    ph.get("observed_requests_per_min"), ph.get("safe_calls_per_min"), ph.get("pct_of_safe_limit"), ph.get("phase_valid"),
                ]
                rows.append(",".join(str(v) for v in vals))
    path.write_text("\n".join(rows) + "\n", encoding="utf-8")


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--phase-duration-sec", type=int, default=10)
    ap.add_argument("--timeout-sec", type=float, default=4.0)
    ap.add_argument("--concurrency", type=int, default=4)
    ap.add_argument("--buffer-pct", type=float, default=1.0, help="Safety buffer percentage below measured/safe limit")
    ap.add_argument("--exchanges", default="all", help="Comma list: binance,kraken,alpaca,capital or all")
    ap.add_argument("--rounds", type=int, default=1, help="Repeat full benchmark N times")
    ap.add_argument("--stress-profile", default="standard", choices=["conservative","standard","aggressive"])
    ap.add_argument("--out", default="state/exchange_api_rate_benchmark.json")
    ap.add_argument("--env-out", default="state/exchange_api_rate_benchmark.env")
    ap.add_argument("--csv-out", default="state/exchange_api_rate_benchmark_rounds.csv")
    args = ap.parse_args()

    wanted = {x.strip().lower() for x in str(args.exchanges).split(",") if x.strip()} if str(args.exchanges).lower() != "all" else set()
    selected = [p for p in PROBES if not wanted or p.exchange in wanted]
    if not selected:
        raise SystemExit("No probes selected. Use --exchanges all or a valid comma list.")

    rounds = max(1, int(args.rounds))
    run_results = []
    for i in range(rounds):
        rows = [benchmark_probe(p, args.phase_duration_sec, args.timeout_sec, args.concurrency, args.buffer_pct, args.stress_profile) for p in selected]
        run_results.append({"round": i + 1, "results": rows})

    # flatten best recommendation per exchange across rounds
    best = {}
    for run in run_results:
        for row in run["results"]:
            ex = row["exchange"]
            cur = float(row.get("recommended_runtime_calls_per_min", 0.0) or 0.0)
            if ex not in best or cur > float(best[ex].get("recommended_runtime_calls_per_min", 0.0) or 0.0):
                best[ex] = row
    results = list(best.values())
    report = {
        "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
        "phase_duration_sec": args.phase_duration_sec,
        "concurrency": args.concurrency,
        "buffer_pct": args.buffer_pct,
        "rounds": rounds,
        "stress_profile": args.stress_profile,
        "selected_exchanges": [p.exchange for p in selected],
        "results": results,
        "round_runs": run_results,
    }
    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(report, indent=2), encoding="utf-8")
    write_env_recommendations(results, Path(args.env_out))
    write_csv_round_runs(run_results, Path(args.csv_out))
    print(json.dumps(report, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())