wayfinder-paths 0.1.6__py3-none-any.whl → 0.1.8__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.
Potentially problematic release.
This version of wayfinder-paths might be problematic. Click here for more details.
- wayfinder_paths/adapters/balance_adapter/README.md +0 -10
- wayfinder_paths/adapters/balance_adapter/adapter.py +0 -20
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -30
- wayfinder_paths/adapters/brap_adapter/adapter.py +3 -2
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +9 -13
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +14 -7
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
- wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +7 -6
- wayfinder_paths/adapters/pool_adapter/README.md +3 -28
- wayfinder_paths/adapters/pool_adapter/adapter.py +0 -72
- wayfinder_paths/adapters/pool_adapter/examples.json +0 -43
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +4 -54
- wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -14
- wayfinder_paths/core/adapters/models.py +9 -4
- wayfinder_paths/core/analytics/__init__.py +11 -0
- wayfinder_paths/core/analytics/bootstrap.py +57 -0
- wayfinder_paths/core/analytics/stats.py +48 -0
- wayfinder_paths/core/analytics/test_analytics.py +170 -0
- wayfinder_paths/core/clients/BRAPClient.py +1 -0
- wayfinder_paths/core/clients/LedgerClient.py +2 -7
- wayfinder_paths/core/clients/PoolClient.py +0 -16
- wayfinder_paths/core/clients/WalletClient.py +0 -27
- wayfinder_paths/core/clients/protocols.py +104 -18
- wayfinder_paths/scripts/make_wallets.py +9 -0
- wayfinder_paths/scripts/run_strategy.py +124 -0
- wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
- wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
- wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
- wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +1 -9
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +36 -5
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +367 -278
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +204 -7
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/METADATA +32 -3
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/RECORD +50 -27
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Basis trading snapshot/backtest helpers.
|
|
3
|
+
|
|
4
|
+
Kept as a mixin so the main strategy file stays readable without changing behavior.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from decimal import ROUND_DOWN, Decimal
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BasisSnapshotMixin:
|
|
20
|
+
def _build_safe_entry(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
horizon: int,
|
|
24
|
+
deposit_usdc: float,
|
|
25
|
+
leverage: int,
|
|
26
|
+
entry_mmr: float,
|
|
27
|
+
margin_table_id: int | None,
|
|
28
|
+
max_coin_leverage: int,
|
|
29
|
+
fee_eps: float,
|
|
30
|
+
mark_price: float,
|
|
31
|
+
spot_asset_id: int,
|
|
32
|
+
perp_asset_id: int,
|
|
33
|
+
depth_checks: dict[str, dict[str, Any]],
|
|
34
|
+
hourly_funding: list[float],
|
|
35
|
+
closes: list[float],
|
|
36
|
+
highs: list[float],
|
|
37
|
+
net_apy: float,
|
|
38
|
+
gross_apy: float,
|
|
39
|
+
entry_cost_usd: float,
|
|
40
|
+
exit_cost_usd: float,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Build the `safe[horizon]` entry matching Django output shape."""
|
|
43
|
+
L = max(1, int(leverage))
|
|
44
|
+
|
|
45
|
+
depth_checks = depth_checks or {}
|
|
46
|
+
depth_checks = {
|
|
47
|
+
key: (value.copy() if isinstance(value, dict) else value)
|
|
48
|
+
for key, value in depth_checks.items()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if mark_price <= 0 or deposit_usdc <= 0:
|
|
52
|
+
return {
|
|
53
|
+
"safe_leverage": 0,
|
|
54
|
+
"buffer_fraction_required": 0.0,
|
|
55
|
+
"per_leg_notional": 0.0,
|
|
56
|
+
"spot_usdc": 0.0,
|
|
57
|
+
"perp_margin_usdc": 0.0,
|
|
58
|
+
"spot_amount": 0.0,
|
|
59
|
+
"perp_amount": 0.0,
|
|
60
|
+
"m_notional_multiple": 0.0,
|
|
61
|
+
"expected_apy_before_fees": float(gross_apy),
|
|
62
|
+
"note": "Insufficient inputs for sizing",
|
|
63
|
+
"depth_check": depth_checks,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
unit_step = self._common_unit_step(spot_asset_id, perp_asset_id)
|
|
67
|
+
mark = Decimal(str(mark_price))
|
|
68
|
+
N_max = Decimal(str(deposit_usdc)) * Decimal(L) / (Decimal(L) + Decimal(1))
|
|
69
|
+
units = (N_max / mark / unit_step).to_integral_value(
|
|
70
|
+
rounding=ROUND_DOWN
|
|
71
|
+
) * unit_step
|
|
72
|
+
|
|
73
|
+
if units <= 0:
|
|
74
|
+
return {
|
|
75
|
+
"safe_leverage": 0,
|
|
76
|
+
"buffer_fraction_required": 0.0,
|
|
77
|
+
"per_leg_notional": 0.0,
|
|
78
|
+
"spot_usdc": 0.0,
|
|
79
|
+
"perp_margin_usdc": 0.0,
|
|
80
|
+
"spot_amount": 0.0,
|
|
81
|
+
"perp_amount": 0.0,
|
|
82
|
+
"m_notional_multiple": 0.0,
|
|
83
|
+
"expected_apy_before_fees": float(gross_apy),
|
|
84
|
+
"note": "Deposit too small for venue lot size",
|
|
85
|
+
"depth_check": depth_checks,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
spot_amount_units = self.round_size_for_hypecore_asset(
|
|
89
|
+
spot_asset_id, float(units), ensure_min_step=True
|
|
90
|
+
)
|
|
91
|
+
perp_amount_units = self.round_size_for_hypecore_asset(
|
|
92
|
+
perp_asset_id, float(units), ensure_min_step=True
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
Nq = Decimal(str(spot_amount_units)) * mark
|
|
96
|
+
spot_usdc = float(Nq)
|
|
97
|
+
perp_margin_usdc = float(Nq / Decimal(L)) if L > 0 else 0.0
|
|
98
|
+
m_notional = float(Decimal(L) / (Decimal(L) + Decimal(1))) if L > 0 else 0.0
|
|
99
|
+
|
|
100
|
+
base_notional = float(Nq)
|
|
101
|
+
coin_max_lev = max(1, int(max_coin_leverage) if max_coin_leverage else L)
|
|
102
|
+
fallback_buffer = float(
|
|
103
|
+
(
|
|
104
|
+
entry_mmr
|
|
105
|
+
if entry_mmr > 0
|
|
106
|
+
else self.maintenance_rate_from_max_leverage(coin_max_lev)
|
|
107
|
+
)
|
|
108
|
+
+ fee_eps
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
window = 24 * max(1, int(horizon))
|
|
112
|
+
if (
|
|
113
|
+
len(hourly_funding) <= window
|
|
114
|
+
or len(closes) <= window
|
|
115
|
+
or len(highs) <= window
|
|
116
|
+
):
|
|
117
|
+
B_star = fallback_buffer
|
|
118
|
+
else:
|
|
119
|
+
B_star = float(
|
|
120
|
+
self._buffer_requirement_tiered(
|
|
121
|
+
closes=closes,
|
|
122
|
+
highs=highs,
|
|
123
|
+
hourly_funding=hourly_funding,
|
|
124
|
+
window=window,
|
|
125
|
+
margin_table_id=margin_table_id,
|
|
126
|
+
base_notional=base_notional,
|
|
127
|
+
fallback_max_leverage=coin_max_lev,
|
|
128
|
+
fee_eps=fee_eps,
|
|
129
|
+
require_full_window=True,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
note = (
|
|
134
|
+
f"Net APY {net_apy:.2%}, gross {gross_apy:.2%}, costs(entry {entry_cost_usd:.2f},"
|
|
135
|
+
f" exit {exit_cost_usd:.2f})"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"safe_leverage": int(L),
|
|
140
|
+
"buffer_fraction_required": float(B_star),
|
|
141
|
+
"per_leg_notional": float(Nq),
|
|
142
|
+
"spot_usdc": float(spot_usdc),
|
|
143
|
+
"perp_margin_usdc": float(perp_margin_usdc),
|
|
144
|
+
"spot_amount": float(spot_amount_units),
|
|
145
|
+
"perp_amount": float(perp_amount_units),
|
|
146
|
+
"m_notional_multiple": float(m_notional),
|
|
147
|
+
"expected_apy_before_fees": float(gross_apy),
|
|
148
|
+
"note": note,
|
|
149
|
+
"depth_check": depth_checks,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async def find_best_trade_with_backtest(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
deposit_usdc: float,
|
|
156
|
+
stop_frac: float = 0.75,
|
|
157
|
+
lookback_days: int = 180,
|
|
158
|
+
oi_floor: float = 50.0,
|
|
159
|
+
day_vlm_floor: float = 1e5,
|
|
160
|
+
max_leverage: int = 3,
|
|
161
|
+
fee_eps: float = 0.003,
|
|
162
|
+
fee_model: dict[str, float] | None = None,
|
|
163
|
+
depth_params: dict[str, Any] | None = None,
|
|
164
|
+
perp_slippage_bps: float = 1.0,
|
|
165
|
+
cooloff_hours: int = 0,
|
|
166
|
+
horizons_days: list[int] | None = None,
|
|
167
|
+
bootstrap_sims: int | None = None,
|
|
168
|
+
bootstrap_block_hours: int | None = None,
|
|
169
|
+
bootstrap_seed: int | None = None,
|
|
170
|
+
) -> dict[str, Any] | None:
|
|
171
|
+
"""Return the top-ranked candidate with solver diagnostics for downstream use."""
|
|
172
|
+
|
|
173
|
+
bootstrap_sims = int(
|
|
174
|
+
self.DEFAULT_BOOTSTRAP_SIMS if bootstrap_sims is None else bootstrap_sims
|
|
175
|
+
)
|
|
176
|
+
bootstrap_block_hours = int(
|
|
177
|
+
self.DEFAULT_BOOTSTRAP_BLOCK_HOURS
|
|
178
|
+
if bootstrap_block_hours is None
|
|
179
|
+
else bootstrap_block_hours
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
ranked = await self.solve_candidates_max_net_apy_with_stop(
|
|
183
|
+
deposit_usdc=deposit_usdc,
|
|
184
|
+
stop_frac=stop_frac,
|
|
185
|
+
lookback_days=lookback_days,
|
|
186
|
+
oi_floor=oi_floor,
|
|
187
|
+
day_vlm_floor=day_vlm_floor,
|
|
188
|
+
max_leverage=max_leverage,
|
|
189
|
+
fee_eps=fee_eps,
|
|
190
|
+
fee_model=fee_model,
|
|
191
|
+
depth_params=depth_params,
|
|
192
|
+
perp_slippage_bps=perp_slippage_bps,
|
|
193
|
+
cooloff_hours=cooloff_hours,
|
|
194
|
+
bootstrap_sims=bootstrap_sims,
|
|
195
|
+
bootstrap_block_hours=bootstrap_block_hours,
|
|
196
|
+
bootstrap_seed=bootstrap_seed,
|
|
197
|
+
)
|
|
198
|
+
if not ranked:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
best = dict(ranked[0])
|
|
202
|
+
horizons = horizons_days or [1, 7]
|
|
203
|
+
|
|
204
|
+
if best.get("margin_table_id"):
|
|
205
|
+
await self._get_margin_table_tiers(int(best["margin_table_id"]))
|
|
206
|
+
|
|
207
|
+
ms_now = int(time.time() * 1000)
|
|
208
|
+
start_ms = ms_now - int(int(lookback_days) * 24 * 3600 * 1000)
|
|
209
|
+
|
|
210
|
+
(funding_ok, funding_data), (candles_ok, candle_data) = await asyncio.gather(
|
|
211
|
+
self._fetch_funding_history_chunked(best["coin"], start_ms, ms_now),
|
|
212
|
+
self._fetch_candles_chunked(best["coin"], "1h", start_ms, ms_now),
|
|
213
|
+
)
|
|
214
|
+
hourly_funding = (
|
|
215
|
+
[float(x.get("fundingRate", 0.0)) for x in funding_data]
|
|
216
|
+
if funding_ok
|
|
217
|
+
else []
|
|
218
|
+
)
|
|
219
|
+
closes = (
|
|
220
|
+
[float(c.get("c", 0)) for c in candle_data if c.get("c")]
|
|
221
|
+
if candles_ok
|
|
222
|
+
else []
|
|
223
|
+
)
|
|
224
|
+
highs = (
|
|
225
|
+
[float(c.get("h", 0)) for c in candle_data if c.get("h")]
|
|
226
|
+
if candles_ok
|
|
227
|
+
else []
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
safe_map: dict[str, dict[str, Any]] = {}
|
|
231
|
+
depth_checks = best.get("depth_checks") or {}
|
|
232
|
+
|
|
233
|
+
for horizon in horizons:
|
|
234
|
+
safe_entry = self._build_safe_entry(
|
|
235
|
+
horizon=horizon,
|
|
236
|
+
deposit_usdc=deposit_usdc,
|
|
237
|
+
leverage=int(best.get("best_L", 0) or 0),
|
|
238
|
+
entry_mmr=float(best.get("mmr", 0.0) or 0.0),
|
|
239
|
+
margin_table_id=best.get("margin_table_id"),
|
|
240
|
+
max_coin_leverage=int(best.get("max_coin_leverage", 0) or 0),
|
|
241
|
+
fee_eps=fee_eps,
|
|
242
|
+
mark_price=float(best.get("mark_price", 0.0) or 0.0),
|
|
243
|
+
spot_asset_id=int(best.get("spot_asset_id", 0) or 0),
|
|
244
|
+
perp_asset_id=int(best.get("perp_asset_id", 0) or 0),
|
|
245
|
+
depth_checks=depth_checks,
|
|
246
|
+
hourly_funding=hourly_funding,
|
|
247
|
+
closes=closes,
|
|
248
|
+
highs=highs,
|
|
249
|
+
net_apy=float(best.get("net_apy", 0.0) or 0.0),
|
|
250
|
+
gross_apy=float(best.get("gross_funding_apy", 0.0) or 0.0),
|
|
251
|
+
entry_cost_usd=float(best.get("entry_cost_usd", 0.0) or 0.0),
|
|
252
|
+
exit_cost_usd=float(best.get("exit_cost_usd", 0.0) or 0.0),
|
|
253
|
+
)
|
|
254
|
+
safe_map[str(horizon)] = safe_entry
|
|
255
|
+
|
|
256
|
+
best["safe"] = safe_map
|
|
257
|
+
best["deposit_usdc"] = float(deposit_usdc)
|
|
258
|
+
best["stop_frac"] = float(stop_frac)
|
|
259
|
+
best["lookback_days"] = int(lookback_days)
|
|
260
|
+
best["oi_floor"] = float(oi_floor)
|
|
261
|
+
best["day_vlm_floor"] = float(day_vlm_floor)
|
|
262
|
+
best["max_leverage_limit"] = int(max_leverage)
|
|
263
|
+
best["fee_eps"] = float(fee_eps)
|
|
264
|
+
best["perp_slippage_bps"] = float(perp_slippage_bps)
|
|
265
|
+
best["cooloff_hours"] = int(cooloff_hours)
|
|
266
|
+
best["horizons_days"] = list(horizons)
|
|
267
|
+
|
|
268
|
+
best["backtest"] = {
|
|
269
|
+
key: best[key]
|
|
270
|
+
for key in (
|
|
271
|
+
"coin",
|
|
272
|
+
"spot_pair",
|
|
273
|
+
"spot_asset_id",
|
|
274
|
+
"perp_asset_id",
|
|
275
|
+
"best_L",
|
|
276
|
+
"net_apy",
|
|
277
|
+
"gross_funding_apy",
|
|
278
|
+
"entry_cost_usd",
|
|
279
|
+
"exit_cost_usd",
|
|
280
|
+
"cycles",
|
|
281
|
+
"hit_rate_per_day",
|
|
282
|
+
"avg_hold_hours",
|
|
283
|
+
"time_in_market_frac",
|
|
284
|
+
"stop_frac",
|
|
285
|
+
"mmr",
|
|
286
|
+
"margin_table_id",
|
|
287
|
+
"max_coin_leverage",
|
|
288
|
+
"cost_breakdown",
|
|
289
|
+
"depth_checks",
|
|
290
|
+
)
|
|
291
|
+
if key in best
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return best
|
|
295
|
+
|
|
296
|
+
def _hour_bucket_start(self, ts: datetime | None = None) -> datetime:
|
|
297
|
+
now = ts or datetime.now(UTC)
|
|
298
|
+
return now.replace(minute=0, second=0, microsecond=0, tzinfo=UTC)
|
|
299
|
+
|
|
300
|
+
async def build_batch_snapshot(
|
|
301
|
+
self,
|
|
302
|
+
*,
|
|
303
|
+
score_deposit_usdc: float = 1000.0,
|
|
304
|
+
stop_frac: float | None = None,
|
|
305
|
+
lookback_days: int | None = None,
|
|
306
|
+
oi_floor: float | None = None,
|
|
307
|
+
day_vlm_floor: float | None = None,
|
|
308
|
+
max_leverage: int | None = None,
|
|
309
|
+
fee_eps: float | None = None,
|
|
310
|
+
fee_model: dict[str, float] | None = None,
|
|
311
|
+
depth_params: dict[str, Any] | None = None,
|
|
312
|
+
perp_slippage_bps: float = 1.0,
|
|
313
|
+
cooloff_hours: int = 0,
|
|
314
|
+
coin_whitelist: list[str] | None = None,
|
|
315
|
+
bootstrap_sims: int | None = None,
|
|
316
|
+
bootstrap_block_hours: int | None = None,
|
|
317
|
+
bootstrap_seed: int | None = None,
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
"""
|
|
320
|
+
Build an hourly, shareable snapshot of:
|
|
321
|
+
- performance table (net APY + churn stats)
|
|
322
|
+
- liquidity capacity per pair (max order/deposit supported by depth checks)
|
|
323
|
+
|
|
324
|
+
Intended for batch jobs: compute once, distribute to workers, and have workers
|
|
325
|
+
filter by user deposit size without re-hitting Hyperliquid APIs.
|
|
326
|
+
"""
|
|
327
|
+
stop_frac = float(
|
|
328
|
+
stop_frac if stop_frac is not None else self.LIQUIDATION_REBALANCE_THRESHOLD
|
|
329
|
+
)
|
|
330
|
+
lookback_days = int(
|
|
331
|
+
lookback_days if lookback_days is not None else self.DEFAULT_LOOKBACK_DAYS
|
|
332
|
+
)
|
|
333
|
+
oi_floor = float(oi_floor if oi_floor is not None else self.DEFAULT_OI_FLOOR)
|
|
334
|
+
day_vlm_floor = float(
|
|
335
|
+
day_vlm_floor if day_vlm_floor is not None else self.DEFAULT_DAY_VLM_FLOOR
|
|
336
|
+
)
|
|
337
|
+
max_leverage = int(
|
|
338
|
+
max_leverage if max_leverage is not None else self.DEFAULT_MAX_LEVERAGE
|
|
339
|
+
)
|
|
340
|
+
fee_eps = float(fee_eps if fee_eps is not None else self.DEFAULT_FEE_EPS)
|
|
341
|
+
bootstrap_sims = int(
|
|
342
|
+
bootstrap_sims
|
|
343
|
+
if bootstrap_sims is not None
|
|
344
|
+
else (self._cfg_get("bootstrap_sims", self.DEFAULT_BOOTSTRAP_SIMS) or 0)
|
|
345
|
+
)
|
|
346
|
+
bootstrap_block_hours = int(
|
|
347
|
+
bootstrap_block_hours
|
|
348
|
+
if bootstrap_block_hours is not None
|
|
349
|
+
else (
|
|
350
|
+
self._cfg_get(
|
|
351
|
+
"bootstrap_block_hours", self.DEFAULT_BOOTSTRAP_BLOCK_HOURS
|
|
352
|
+
)
|
|
353
|
+
or 0
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
if bootstrap_seed is None:
|
|
357
|
+
cfg_seed = self._cfg_get("bootstrap_seed")
|
|
358
|
+
bootstrap_seed = int(cfg_seed) if cfg_seed is not None else None
|
|
359
|
+
else:
|
|
360
|
+
bootstrap_seed = int(bootstrap_seed)
|
|
361
|
+
|
|
362
|
+
whitelist = (
|
|
363
|
+
{coin.upper() for coin in coin_whitelist} if coin_whitelist else None
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
(
|
|
367
|
+
success,
|
|
368
|
+
perps_ctx_pack,
|
|
369
|
+
) = await self.hyperliquid_adapter.get_meta_and_asset_ctxs()
|
|
370
|
+
if not success:
|
|
371
|
+
raise ValueError(f"Failed to fetch perp metadata: {perps_ctx_pack}")
|
|
372
|
+
|
|
373
|
+
perps_meta_list = perps_ctx_pack[0]["universe"]
|
|
374
|
+
perps_ctxs = perps_ctx_pack[1]
|
|
375
|
+
|
|
376
|
+
coin_to_ctx: dict[str, Any] = {}
|
|
377
|
+
coin_to_maxlev: dict[str, int] = {}
|
|
378
|
+
coin_to_margin_table: dict[str, int | None] = {}
|
|
379
|
+
coins: list[str] = []
|
|
380
|
+
for meta, ctx in zip(perps_meta_list, perps_ctxs, strict=False):
|
|
381
|
+
coin = meta["name"]
|
|
382
|
+
coin_to_ctx[coin] = ctx
|
|
383
|
+
coin_to_maxlev[coin] = int(meta.get("maxLeverage", 10))
|
|
384
|
+
coin_to_margin_table[coin] = meta.get("marginTableId")
|
|
385
|
+
coins.append(coin)
|
|
386
|
+
perps_set = set(coins)
|
|
387
|
+
|
|
388
|
+
perp_coin_to_asset_id = {
|
|
389
|
+
k: v for k, v in self.hyperliquid_adapter.coin_to_asset.items() if v < 10000
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
|
|
393
|
+
if not success:
|
|
394
|
+
raise ValueError(f"Failed to fetch spot metadata: {spot_meta}")
|
|
395
|
+
|
|
396
|
+
tokens = spot_meta.get("tokens", [])
|
|
397
|
+
spot_pairs = spot_meta.get("universe", [])
|
|
398
|
+
idx_to_token = {t["index"]: t["name"] for t in tokens}
|
|
399
|
+
|
|
400
|
+
candidates = self._find_basis_candidates(spot_pairs, idx_to_token, perps_set)
|
|
401
|
+
|
|
402
|
+
ms_now = int(time.time() * 1000)
|
|
403
|
+
start_ms = ms_now - int(lookback_days * 24 * 3600 * 1000)
|
|
404
|
+
|
|
405
|
+
snapshot_candidates: list[dict[str, Any]] = []
|
|
406
|
+
|
|
407
|
+
for spot_sym, coin, spot_asset_id in candidates:
|
|
408
|
+
if whitelist is not None and coin.upper() not in whitelist:
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
ctx = coin_to_ctx.get(coin, {})
|
|
412
|
+
oi_base = float(ctx.get("openInterest") or 0.0)
|
|
413
|
+
mark_px = float(ctx.get("markPx") or 0.0)
|
|
414
|
+
day_ntl_usd = float(ctx.get("dayNtlVlm") or 0.0)
|
|
415
|
+
if mark_px <= 0:
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
perp_asset_id = perp_coin_to_asset_id.get(coin)
|
|
419
|
+
if perp_asset_id is None:
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
oi_usd = oi_base * mark_px
|
|
423
|
+
if oi_usd < oi_floor or day_ntl_usd < day_vlm_floor:
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
raw_max_lev = coin_to_maxlev.get(coin, max_leverage)
|
|
427
|
+
coin_max_lev = int(raw_max_lev) if raw_max_lev else max_leverage
|
|
428
|
+
max_available_lev = max(1, min(max_leverage, coin_max_lev))
|
|
429
|
+
margin_table_id = coin_to_margin_table.get(coin)
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
spot_book = await self._l2_book_spot(
|
|
433
|
+
spot_asset_id, fallback_mid=mark_px, spot_symbol=spot_sym
|
|
434
|
+
)
|
|
435
|
+
except Exception as exc: # noqa: BLE001
|
|
436
|
+
self.logger.warning(f"Skipping {spot_sym}: L2 fetch error: {exc}")
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
cap = await self.max_spot_order_usd_for_book(
|
|
440
|
+
spot_asset_id=spot_asset_id,
|
|
441
|
+
spot_symbol=spot_sym,
|
|
442
|
+
book=spot_book,
|
|
443
|
+
day_ntl_usd=day_ntl_usd,
|
|
444
|
+
params=depth_params,
|
|
445
|
+
)
|
|
446
|
+
max_order_usd = float(cap.get("max_order_usd") or 0.0)
|
|
447
|
+
if max_order_usd <= 0.0:
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
if margin_table_id:
|
|
451
|
+
await self._get_margin_table_tiers(int(margin_table_id))
|
|
452
|
+
|
|
453
|
+
(
|
|
454
|
+
(funding_ok, funding_data),
|
|
455
|
+
(candles_ok, candle_data),
|
|
456
|
+
) = await asyncio.gather(
|
|
457
|
+
self._fetch_funding_history_chunked(coin, start_ms, ms_now),
|
|
458
|
+
self._fetch_candles_chunked(coin, "1h", start_ms, ms_now),
|
|
459
|
+
)
|
|
460
|
+
if not funding_ok or not candles_ok:
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
hourly_funding = [float(x.get("fundingRate", 0.0)) for x in funding_data]
|
|
464
|
+
closes = [float(c.get("c", 0)) for c in candle_data if c.get("c")]
|
|
465
|
+
highs = [float(c.get("h", 0)) for c in candle_data if c.get("h")]
|
|
466
|
+
|
|
467
|
+
n_ok = min(len(hourly_funding), len(closes), len(highs))
|
|
468
|
+
if n_ok < (lookback_days * 24 - 48):
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
options: list[dict[str, Any]] = []
|
|
472
|
+
|
|
473
|
+
for L in range(1, max_available_lev + 1):
|
|
474
|
+
deposit_min = self._min_deposit_needed(
|
|
475
|
+
mark_price=mark_px,
|
|
476
|
+
leverage=L,
|
|
477
|
+
spot_asset_id=spot_asset_id,
|
|
478
|
+
perp_asset_id=perp_asset_id,
|
|
479
|
+
)
|
|
480
|
+
deposit_max = max_order_usd * (float(L) + 1.0) / float(L)
|
|
481
|
+
if deposit_max < deposit_min:
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
# Use a small safety factor when scoring at the top of capacity to
|
|
485
|
+
# avoid borderline pass/fail due to book rounding / float noise.
|
|
486
|
+
deposit_max_safe = deposit_max * 0.98
|
|
487
|
+
score_dep = min(float(score_deposit_usdc), float(deposit_max_safe))
|
|
488
|
+
if score_dep < deposit_min:
|
|
489
|
+
score_dep = float(deposit_min)
|
|
490
|
+
|
|
491
|
+
order_usd = score_dep * (float(L) / (float(L) + 1.0))
|
|
492
|
+
|
|
493
|
+
entry_mmr = self.maintenance_fraction_for_notional(
|
|
494
|
+
margin_table_id,
|
|
495
|
+
order_usd,
|
|
496
|
+
max_available_lev,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
(
|
|
500
|
+
entry_cost,
|
|
501
|
+
exit_cost,
|
|
502
|
+
cost_breakdown,
|
|
503
|
+
depth_checks,
|
|
504
|
+
) = await self._estimate_cycle_costs(
|
|
505
|
+
N_leg_usd=order_usd,
|
|
506
|
+
spot_asset_id=spot_asset_id,
|
|
507
|
+
spot_book=spot_book,
|
|
508
|
+
fee_model=fee_model,
|
|
509
|
+
depth_params=depth_params,
|
|
510
|
+
perp_slippage_bps=perp_slippage_bps,
|
|
511
|
+
day_ntl_usd=day_ntl_usd,
|
|
512
|
+
spot_symbol=spot_sym,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Ensure scoring uses a passing depth state. If it doesn't, shrink.
|
|
516
|
+
if not (
|
|
517
|
+
bool((depth_checks.get("buy") or {}).get("pass"))
|
|
518
|
+
and bool((depth_checks.get("sell") or {}).get("pass"))
|
|
519
|
+
):
|
|
520
|
+
# Fall back to a smaller, clearly safe order size.
|
|
521
|
+
order_usd = min(order_usd, max_order_usd * 0.5)
|
|
522
|
+
score_dep = order_usd * (float(L) + 1.0) / float(L)
|
|
523
|
+
(
|
|
524
|
+
entry_cost,
|
|
525
|
+
exit_cost,
|
|
526
|
+
cost_breakdown,
|
|
527
|
+
depth_checks,
|
|
528
|
+
) = await self._estimate_cycle_costs(
|
|
529
|
+
N_leg_usd=order_usd,
|
|
530
|
+
spot_asset_id=spot_asset_id,
|
|
531
|
+
spot_book=spot_book,
|
|
532
|
+
fee_model=fee_model,
|
|
533
|
+
depth_params=depth_params,
|
|
534
|
+
perp_slippage_bps=perp_slippage_bps,
|
|
535
|
+
day_ntl_usd=day_ntl_usd,
|
|
536
|
+
spot_symbol=spot_sym,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
sim = self._simulate_barrier_backtest(
|
|
540
|
+
funding=hourly_funding,
|
|
541
|
+
closes=closes,
|
|
542
|
+
highs=highs,
|
|
543
|
+
leverage=L,
|
|
544
|
+
stop_frac=stop_frac,
|
|
545
|
+
fee_eps=fee_eps,
|
|
546
|
+
N_leg_usd=order_usd,
|
|
547
|
+
entry_cost_usd=entry_cost,
|
|
548
|
+
exit_cost_usd=exit_cost,
|
|
549
|
+
margin_table_id=margin_table_id,
|
|
550
|
+
fallback_max_leverage=max_available_lev,
|
|
551
|
+
cooloff_hours=cooloff_hours,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
hours = max(1.0, float(sim["hours"]))
|
|
555
|
+
years = hours / (24.0 * 365.0)
|
|
556
|
+
net_apy = (float(sim["net_pnl_usd"]) / max(1e-9, score_dep)) / years
|
|
557
|
+
gross_apy = (
|
|
558
|
+
float(sim["gross_funding_usd"]) / max(1e-9, score_dep)
|
|
559
|
+
) / years
|
|
560
|
+
hit_rate_per_day = (
|
|
561
|
+
float(sim["cycles"]) / (hours / 24.0) if hours > 0 else 0.0
|
|
562
|
+
)
|
|
563
|
+
avg_hold_hours = (
|
|
564
|
+
float(sim["hours_in_market"]) / max(1.0, float(sim["cycles"]))
|
|
565
|
+
if float(sim["cycles"]) > 0
|
|
566
|
+
else hours
|
|
567
|
+
)
|
|
568
|
+
time_in_market = float(sim["hours_in_market"]) / hours
|
|
569
|
+
|
|
570
|
+
bootstrap_stats = self._bootstrap_churn_metrics(
|
|
571
|
+
funding=hourly_funding,
|
|
572
|
+
closes=closes,
|
|
573
|
+
highs=highs,
|
|
574
|
+
leverage=L,
|
|
575
|
+
stop_frac=stop_frac,
|
|
576
|
+
fee_eps=fee_eps,
|
|
577
|
+
N_leg_usd=order_usd,
|
|
578
|
+
entry_cost_usd=entry_cost,
|
|
579
|
+
exit_cost_usd=exit_cost,
|
|
580
|
+
margin_table_id=margin_table_id,
|
|
581
|
+
fallback_max_leverage=max_available_lev,
|
|
582
|
+
cooloff_hours=cooloff_hours,
|
|
583
|
+
deposit_usdc=score_dep,
|
|
584
|
+
sims=bootstrap_sims,
|
|
585
|
+
block_hours=bootstrap_block_hours,
|
|
586
|
+
seed=None
|
|
587
|
+
if bootstrap_seed is None
|
|
588
|
+
else hash((bootstrap_seed, coin, L)),
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
opt: dict[str, Any] = {
|
|
592
|
+
"leverage": int(L),
|
|
593
|
+
"deposit_used_usdc": float(score_dep),
|
|
594
|
+
"deposit_min_usdc": float(deposit_min),
|
|
595
|
+
"deposit_max_usdc": float(deposit_max),
|
|
596
|
+
"order_usd": float(order_usd),
|
|
597
|
+
"net_apy": float(net_apy),
|
|
598
|
+
"gross_funding_apy": float(gross_apy),
|
|
599
|
+
"entry_cost_usd": float(entry_cost),
|
|
600
|
+
"exit_cost_usd": float(exit_cost),
|
|
601
|
+
"cycles": float(sim["cycles"]),
|
|
602
|
+
"hit_rate_per_day": float(hit_rate_per_day),
|
|
603
|
+
"avg_hold_hours": float(avg_hold_hours),
|
|
604
|
+
"time_in_market_frac": float(time_in_market),
|
|
605
|
+
"stop_frac": float(stop_frac),
|
|
606
|
+
"mmr": float(entry_mmr),
|
|
607
|
+
"margin_table_id": margin_table_id,
|
|
608
|
+
"max_coin_leverage": int(max_available_lev),
|
|
609
|
+
"cost_breakdown": cost_breakdown,
|
|
610
|
+
"depth_checks": depth_checks,
|
|
611
|
+
"perp_asset_id": int(perp_asset_id),
|
|
612
|
+
"spot_asset_id": int(spot_asset_id),
|
|
613
|
+
"mark_price": float(mark_px),
|
|
614
|
+
}
|
|
615
|
+
if bootstrap_stats is not None:
|
|
616
|
+
opt["bootstrap_metrics"] = bootstrap_stats
|
|
617
|
+
|
|
618
|
+
options.append(opt)
|
|
619
|
+
|
|
620
|
+
if not options:
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
best_opt = max(
|
|
624
|
+
options, key=lambda o: float(o.get("net_apy", float("-inf")))
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
snapshot_candidates.append(
|
|
628
|
+
{
|
|
629
|
+
"coin": coin,
|
|
630
|
+
"spot_pair": spot_sym,
|
|
631
|
+
"spot_asset_id": int(spot_asset_id),
|
|
632
|
+
"perp_asset_id": int(perp_asset_id),
|
|
633
|
+
"mark_price": float(mark_px),
|
|
634
|
+
"open_interest_usd": float(oi_usd),
|
|
635
|
+
"day_notional_usd": float(day_ntl_usd),
|
|
636
|
+
"margin_table_id": margin_table_id,
|
|
637
|
+
"max_coin_leverage": int(max_available_lev),
|
|
638
|
+
"liquidity": {
|
|
639
|
+
"max_order_usd": float(max_order_usd),
|
|
640
|
+
"upper_bound_usd": float(cap.get("upper_bound_usd") or 0.0),
|
|
641
|
+
"checks_at_capacity": cap.get("checks"),
|
|
642
|
+
"depth_params": depth_params or None,
|
|
643
|
+
},
|
|
644
|
+
"options": options,
|
|
645
|
+
"best": best_opt,
|
|
646
|
+
}
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
snapshot_candidates.sort(
|
|
650
|
+
key=lambda c: float((c.get("best") or {}).get("net_apy", float("-inf"))),
|
|
651
|
+
reverse=True,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
bucket = self._hour_bucket_start()
|
|
655
|
+
return {
|
|
656
|
+
"kind": "basis_trading_batch_snapshot",
|
|
657
|
+
"generated_at": int(time.time() * 1000),
|
|
658
|
+
"hour_bucket_utc": bucket.isoformat(),
|
|
659
|
+
"params": {
|
|
660
|
+
"score_deposit_usdc": float(score_deposit_usdc),
|
|
661
|
+
"stop_frac": float(stop_frac),
|
|
662
|
+
"lookback_days": int(lookback_days),
|
|
663
|
+
"oi_floor": float(oi_floor),
|
|
664
|
+
"day_vlm_floor": float(day_vlm_floor),
|
|
665
|
+
"max_leverage": int(max_leverage),
|
|
666
|
+
"fee_eps": float(fee_eps),
|
|
667
|
+
"fee_model": fee_model or None,
|
|
668
|
+
"depth_params": depth_params or None,
|
|
669
|
+
"perp_slippage_bps": float(perp_slippage_bps),
|
|
670
|
+
"cooloff_hours": int(cooloff_hours),
|
|
671
|
+
"bootstrap_sims": int(bootstrap_sims),
|
|
672
|
+
"bootstrap_block_hours": int(bootstrap_block_hours),
|
|
673
|
+
"bootstrap_seed": bootstrap_seed,
|
|
674
|
+
"coin_whitelist": sorted(whitelist) if whitelist is not None else None,
|
|
675
|
+
},
|
|
676
|
+
"candidates": snapshot_candidates,
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
def opportunities_from_snapshot(
|
|
680
|
+
self,
|
|
681
|
+
*,
|
|
682
|
+
snapshot: dict[str, Any],
|
|
683
|
+
deposit_usdc: float,
|
|
684
|
+
) -> list[dict[str, Any]]:
|
|
685
|
+
"""Filter a batch snapshot for a specific deposit size."""
|
|
686
|
+
if deposit_usdc <= 0:
|
|
687
|
+
return []
|
|
688
|
+
|
|
689
|
+
candidates = snapshot.get("candidates", [])
|
|
690
|
+
if not isinstance(candidates, list):
|
|
691
|
+
return []
|
|
692
|
+
|
|
693
|
+
opportunities: list[dict[str, Any]] = []
|
|
694
|
+
for candidate in candidates:
|
|
695
|
+
if not isinstance(candidate, dict):
|
|
696
|
+
continue
|
|
697
|
+
options = candidate.get("options", [])
|
|
698
|
+
if not isinstance(options, list):
|
|
699
|
+
continue
|
|
700
|
+
|
|
701
|
+
feasible: list[dict[str, Any]] = []
|
|
702
|
+
for opt in options:
|
|
703
|
+
if not isinstance(opt, dict):
|
|
704
|
+
continue
|
|
705
|
+
dep_min = float(opt.get("deposit_min_usdc", 0.0) or 0.0)
|
|
706
|
+
dep_max = float(opt.get("deposit_max_usdc", 0.0) or 0.0)
|
|
707
|
+
if dep_min <= float(deposit_usdc) <= dep_max:
|
|
708
|
+
feasible.append(opt)
|
|
709
|
+
|
|
710
|
+
if not feasible:
|
|
711
|
+
continue
|
|
712
|
+
|
|
713
|
+
best_opt = max(
|
|
714
|
+
feasible, key=lambda o: float(o.get("net_apy", float("-inf")))
|
|
715
|
+
)
|
|
716
|
+
L = int(best_opt.get("leverage", 1) or 1)
|
|
717
|
+
order_usd = float(deposit_usdc) * (float(L) / (float(L) + 1.0))
|
|
718
|
+
out = {
|
|
719
|
+
"coin": candidate.get("coin"),
|
|
720
|
+
"spot_pair": candidate.get("spot_pair"),
|
|
721
|
+
"spot_asset_id": candidate.get("spot_asset_id"),
|
|
722
|
+
"perp_asset_id": candidate.get("perp_asset_id"),
|
|
723
|
+
"mark_price": candidate.get("mark_price"),
|
|
724
|
+
"open_interest_usd": candidate.get("open_interest_usd"),
|
|
725
|
+
"day_notional_usd": candidate.get("day_notional_usd"),
|
|
726
|
+
"liquidity": candidate.get("liquidity"),
|
|
727
|
+
"selection": dict(best_opt),
|
|
728
|
+
"deposit_usdc": float(deposit_usdc),
|
|
729
|
+
"order_usd": float(order_usd),
|
|
730
|
+
}
|
|
731
|
+
opportunities.append(out)
|
|
732
|
+
|
|
733
|
+
opportunities.sort(
|
|
734
|
+
key=lambda o: float(
|
|
735
|
+
(o.get("selection") or {}).get("net_apy", float("-inf"))
|
|
736
|
+
),
|
|
737
|
+
reverse=True,
|
|
738
|
+
)
|
|
739
|
+
return opportunities
|
|
740
|
+
|
|
741
|
+
async def score_opportunity_from_snapshot(
|
|
742
|
+
self,
|
|
743
|
+
*,
|
|
744
|
+
opportunity: dict[str, Any],
|
|
745
|
+
deposit_usdc: float,
|
|
746
|
+
horizons_days: list[int] | None = None,
|
|
747
|
+
stop_frac: float = 0.75,
|
|
748
|
+
lookback_days: int = 45,
|
|
749
|
+
fee_eps: float = 0.003,
|
|
750
|
+
fee_model: dict[str, float] | None = None,
|
|
751
|
+
depth_params: dict[str, Any] | None = None,
|
|
752
|
+
perp_slippage_bps: float = 1.0,
|
|
753
|
+
cooloff_hours: int = 0,
|
|
754
|
+
bootstrap_sims: int = 0,
|
|
755
|
+
bootstrap_block_hours: int = 48,
|
|
756
|
+
bootstrap_seed: int | None = None,
|
|
757
|
+
) -> dict[str, Any] | None:
|
|
758
|
+
"""
|
|
759
|
+
Given a snapshot-selected opportunity, recompute a full single-coin backtest
|
|
760
|
+
+ safe sizing for the user's exact deposit size using fresh market data.
|
|
761
|
+
|
|
762
|
+
This avoids running the full cross-asset scan on every worker.
|
|
763
|
+
"""
|
|
764
|
+
if deposit_usdc <= 0:
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
coin = opportunity.get("coin")
|
|
768
|
+
spot_pair = opportunity.get("spot_pair")
|
|
769
|
+
spot_asset_id = opportunity.get("spot_asset_id")
|
|
770
|
+
perp_asset_id = opportunity.get("perp_asset_id")
|
|
771
|
+
if not isinstance(coin, str) or not coin:
|
|
772
|
+
return None
|
|
773
|
+
if not isinstance(spot_pair, str) or not spot_pair:
|
|
774
|
+
spot_pair = coin
|
|
775
|
+
if not isinstance(spot_asset_id, int) or not isinstance(perp_asset_id, int):
|
|
776
|
+
return None
|
|
777
|
+
|
|
778
|
+
selection = opportunity.get("selection") or {}
|
|
779
|
+
if not isinstance(selection, dict):
|
|
780
|
+
selection = {}
|
|
781
|
+
L = int(selection.get("leverage") or selection.get("best_L") or 1)
|
|
782
|
+
L = max(1, L)
|
|
783
|
+
|
|
784
|
+
day_ntl_usd = float(opportunity.get("day_notional_usd", 0.0) or 0.0)
|
|
785
|
+
mark_price = float(opportunity.get("mark_price", 0.0) or 0.0)
|
|
786
|
+
margin_table_id = opportunity.get("margin_table_id")
|
|
787
|
+
margin_table_id = int(margin_table_id) if margin_table_id is not None else None
|
|
788
|
+
max_coin_leverage = int(opportunity.get("max_coin_leverage", L) or L)
|
|
789
|
+
|
|
790
|
+
bootstrap_seed_val: int | None
|
|
791
|
+
if bootstrap_seed is None:
|
|
792
|
+
bootstrap_seed_val = None
|
|
793
|
+
else:
|
|
794
|
+
bootstrap_seed_val = int(bootstrap_seed)
|
|
795
|
+
|
|
796
|
+
# Refresh book for precise sizing + costs
|
|
797
|
+
spot_book = await self._l2_book_spot(
|
|
798
|
+
spot_asset_id, fallback_mid=mark_price or None, spot_symbol=spot_pair
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
order_usd = float(deposit_usdc) * (float(L) / (float(L) + 1.0))
|
|
802
|
+
|
|
803
|
+
(
|
|
804
|
+
entry_cost,
|
|
805
|
+
exit_cost,
|
|
806
|
+
cost_breakdown,
|
|
807
|
+
depth_checks,
|
|
808
|
+
) = await self._estimate_cycle_costs(
|
|
809
|
+
N_leg_usd=order_usd,
|
|
810
|
+
spot_asset_id=spot_asset_id,
|
|
811
|
+
spot_book=spot_book,
|
|
812
|
+
fee_model=fee_model,
|
|
813
|
+
depth_params=depth_params,
|
|
814
|
+
perp_slippage_bps=perp_slippage_bps,
|
|
815
|
+
day_ntl_usd=day_ntl_usd if day_ntl_usd > 0 else None,
|
|
816
|
+
spot_symbol=spot_pair,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
if not (
|
|
820
|
+
bool((depth_checks.get("buy") or {}).get("pass"))
|
|
821
|
+
and bool((depth_checks.get("sell") or {}).get("pass"))
|
|
822
|
+
):
|
|
823
|
+
return None
|
|
824
|
+
|
|
825
|
+
if margin_table_id:
|
|
826
|
+
await self._get_margin_table_tiers(int(margin_table_id))
|
|
827
|
+
|
|
828
|
+
ms_now = int(time.time() * 1000)
|
|
829
|
+
start_ms = ms_now - int(int(lookback_days) * 24 * 3600 * 1000)
|
|
830
|
+
|
|
831
|
+
(funding_ok, funding_data), (candles_ok, candle_data) = await asyncio.gather(
|
|
832
|
+
self._fetch_funding_history_chunked(coin, start_ms, ms_now),
|
|
833
|
+
self._fetch_candles_chunked(coin, "1h", start_ms, ms_now),
|
|
834
|
+
)
|
|
835
|
+
if not funding_ok or not candles_ok:
|
|
836
|
+
return None
|
|
837
|
+
|
|
838
|
+
hourly_funding = [float(x.get("fundingRate", 0.0)) for x in funding_data]
|
|
839
|
+
closes = [float(c.get("c", 0)) for c in candle_data if c.get("c")]
|
|
840
|
+
highs = [float(c.get("h", 0)) for c in candle_data if c.get("h")]
|
|
841
|
+
|
|
842
|
+
n_ok = min(len(hourly_funding), len(closes), len(highs))
|
|
843
|
+
if n_ok < (int(lookback_days) * 24 - 48):
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
entry_mmr = self.maintenance_fraction_for_notional(
|
|
847
|
+
margin_table_id,
|
|
848
|
+
order_usd,
|
|
849
|
+
max_coin_leverage,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
sim = self._simulate_barrier_backtest(
|
|
853
|
+
funding=hourly_funding,
|
|
854
|
+
closes=closes,
|
|
855
|
+
highs=highs,
|
|
856
|
+
leverage=L,
|
|
857
|
+
stop_frac=stop_frac,
|
|
858
|
+
fee_eps=fee_eps,
|
|
859
|
+
N_leg_usd=order_usd,
|
|
860
|
+
entry_cost_usd=entry_cost,
|
|
861
|
+
exit_cost_usd=exit_cost,
|
|
862
|
+
margin_table_id=margin_table_id,
|
|
863
|
+
fallback_max_leverage=max_coin_leverage,
|
|
864
|
+
cooloff_hours=cooloff_hours,
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
hours = max(1.0, float(sim["hours"]))
|
|
868
|
+
years = hours / (24.0 * 365.0)
|
|
869
|
+
net_apy = (float(sim["net_pnl_usd"]) / max(1e-9, float(deposit_usdc))) / years
|
|
870
|
+
gross_apy = (
|
|
871
|
+
float(sim["gross_funding_usd"]) / max(1e-9, float(deposit_usdc))
|
|
872
|
+
) / years
|
|
873
|
+
hit_rate_per_day = float(sim["cycles"]) / (hours / 24.0) if hours > 0 else 0.0
|
|
874
|
+
avg_hold_hours = (
|
|
875
|
+
float(sim["hours_in_market"]) / max(1.0, float(sim["cycles"]))
|
|
876
|
+
if float(sim["cycles"]) > 0
|
|
877
|
+
else hours
|
|
878
|
+
)
|
|
879
|
+
time_in_market = float(sim["hours_in_market"]) / hours
|
|
880
|
+
|
|
881
|
+
bootstrap_stats = self._bootstrap_churn_metrics(
|
|
882
|
+
funding=hourly_funding,
|
|
883
|
+
closes=closes,
|
|
884
|
+
highs=highs,
|
|
885
|
+
leverage=L,
|
|
886
|
+
stop_frac=stop_frac,
|
|
887
|
+
fee_eps=fee_eps,
|
|
888
|
+
N_leg_usd=order_usd,
|
|
889
|
+
entry_cost_usd=entry_cost,
|
|
890
|
+
exit_cost_usd=exit_cost,
|
|
891
|
+
margin_table_id=margin_table_id,
|
|
892
|
+
fallback_max_leverage=max_coin_leverage,
|
|
893
|
+
cooloff_hours=cooloff_hours,
|
|
894
|
+
deposit_usdc=float(deposit_usdc),
|
|
895
|
+
sims=int(bootstrap_sims),
|
|
896
|
+
block_hours=int(bootstrap_block_hours),
|
|
897
|
+
seed=None
|
|
898
|
+
if bootstrap_seed_val is None
|
|
899
|
+
else hash((bootstrap_seed_val, coin, L, int(deposit_usdc))),
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
horizons = horizons_days or [1, 7]
|
|
903
|
+
safe_map: dict[str, dict[str, Any]] = {}
|
|
904
|
+
for horizon in horizons:
|
|
905
|
+
safe_map[str(horizon)] = self._build_safe_entry(
|
|
906
|
+
horizon=int(horizon),
|
|
907
|
+
deposit_usdc=float(deposit_usdc),
|
|
908
|
+
leverage=L,
|
|
909
|
+
entry_mmr=float(entry_mmr),
|
|
910
|
+
margin_table_id=margin_table_id,
|
|
911
|
+
max_coin_leverage=max_coin_leverage,
|
|
912
|
+
fee_eps=float(fee_eps),
|
|
913
|
+
mark_price=float(mark_price),
|
|
914
|
+
spot_asset_id=int(spot_asset_id),
|
|
915
|
+
perp_asset_id=int(perp_asset_id),
|
|
916
|
+
depth_checks=depth_checks,
|
|
917
|
+
hourly_funding=hourly_funding,
|
|
918
|
+
closes=closes,
|
|
919
|
+
highs=highs,
|
|
920
|
+
net_apy=float(net_apy),
|
|
921
|
+
gross_apy=float(gross_apy),
|
|
922
|
+
entry_cost_usd=float(entry_cost),
|
|
923
|
+
exit_cost_usd=float(exit_cost),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
out: dict[str, Any] = {
|
|
927
|
+
"coin": coin,
|
|
928
|
+
"spot_pair": spot_pair,
|
|
929
|
+
"spot_asset_id": int(spot_asset_id),
|
|
930
|
+
"perp_asset_id": int(perp_asset_id),
|
|
931
|
+
"best_L": int(L),
|
|
932
|
+
"net_apy": float(net_apy),
|
|
933
|
+
"gross_funding_apy": float(gross_apy),
|
|
934
|
+
"entry_cost_usd": float(entry_cost),
|
|
935
|
+
"exit_cost_usd": float(exit_cost),
|
|
936
|
+
"cycles": float(sim["cycles"]),
|
|
937
|
+
"hit_rate_per_day": float(hit_rate_per_day),
|
|
938
|
+
"avg_hold_hours": float(avg_hold_hours),
|
|
939
|
+
"time_in_market_frac": float(time_in_market),
|
|
940
|
+
"stop_frac": float(stop_frac),
|
|
941
|
+
"mmr": float(entry_mmr),
|
|
942
|
+
"margin_table_id": margin_table_id,
|
|
943
|
+
"max_coin_leverage": int(max_coin_leverage),
|
|
944
|
+
"cost_breakdown": cost_breakdown,
|
|
945
|
+
"depth_checks": depth_checks,
|
|
946
|
+
"mark_price": float(mark_price),
|
|
947
|
+
"safe": safe_map,
|
|
948
|
+
"deposit_usdc": float(deposit_usdc),
|
|
949
|
+
"lookback_days": int(lookback_days),
|
|
950
|
+
"fee_eps": float(fee_eps),
|
|
951
|
+
"perp_slippage_bps": float(perp_slippage_bps),
|
|
952
|
+
"cooloff_hours": int(cooloff_hours),
|
|
953
|
+
"horizons_days": list(horizons),
|
|
954
|
+
}
|
|
955
|
+
if bootstrap_stats is not None:
|
|
956
|
+
out["bootstrap_metrics"] = bootstrap_stats
|
|
957
|
+
|
|
958
|
+
out["backtest"] = {
|
|
959
|
+
key: out[key]
|
|
960
|
+
for key in (
|
|
961
|
+
"coin",
|
|
962
|
+
"spot_pair",
|
|
963
|
+
"spot_asset_id",
|
|
964
|
+
"perp_asset_id",
|
|
965
|
+
"best_L",
|
|
966
|
+
"net_apy",
|
|
967
|
+
"gross_funding_apy",
|
|
968
|
+
"entry_cost_usd",
|
|
969
|
+
"exit_cost_usd",
|
|
970
|
+
"cycles",
|
|
971
|
+
"hit_rate_per_day",
|
|
972
|
+
"avg_hold_hours",
|
|
973
|
+
"time_in_market_frac",
|
|
974
|
+
"stop_frac",
|
|
975
|
+
"mmr",
|
|
976
|
+
"margin_table_id",
|
|
977
|
+
"max_coin_leverage",
|
|
978
|
+
"cost_breakdown",
|
|
979
|
+
"depth_checks",
|
|
980
|
+
)
|
|
981
|
+
if key in out
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
return out
|
|
985
|
+
|
|
986
|
+
def load_snapshot_from_path(self, snapshot_path: str) -> dict[str, Any]:
|
|
987
|
+
p = Path(snapshot_path)
|
|
988
|
+
raw = p.read_text()
|
|
989
|
+
data = json.loads(raw)
|
|
990
|
+
if not isinstance(data, dict):
|
|
991
|
+
raise ValueError("Snapshot file must contain a JSON object")
|
|
992
|
+
return data
|
|
993
|
+
|
|
994
|
+
def _snapshot_from_config(self) -> dict[str, Any] | None:
|
|
995
|
+
val = (
|
|
996
|
+
self._cfg_get("basis_snapshot")
|
|
997
|
+
or self._cfg_get("precomputed_basis_snapshot")
|
|
998
|
+
or self._cfg_get("precomputed_snapshot")
|
|
999
|
+
)
|
|
1000
|
+
return val if isinstance(val, dict) else None
|
|
1001
|
+
|
|
1002
|
+
def _snapshot_path_from_config(self) -> str | None:
|
|
1003
|
+
val = (
|
|
1004
|
+
self._cfg_get("basis_snapshot_path")
|
|
1005
|
+
or self._cfg_get("precomputed_snapshot_path")
|
|
1006
|
+
or self._cfg_get("precomputed_basis_snapshot_path")
|
|
1007
|
+
)
|
|
1008
|
+
if isinstance(val, str) and val.strip():
|
|
1009
|
+
return val.strip()
|
|
1010
|
+
env = os.getenv("BASIS_SNAPSHOT_PATH")
|
|
1011
|
+
return env.strip() if isinstance(env, str) and env.strip() else None
|