wayfinder-paths 0.1.7__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.

Files changed (149) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +399 -0
  2. wayfinder_paths/__init__.py +22 -0
  3. wayfinder_paths/abis/generic/erc20.json +383 -0
  4. wayfinder_paths/adapters/__init__.py +0 -0
  5. wayfinder_paths/adapters/balance_adapter/README.md +94 -0
  6. wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
  7. wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
  8. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  9. wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
  10. wayfinder_paths/adapters/brap_adapter/README.md +249 -0
  11. wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
  12. wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
  13. wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
  14. wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
  15. wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
  16. wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
  17. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
  18. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
  19. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
  20. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  21. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  22. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  23. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  24. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  28. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  29. wayfinder_paths/adapters/ledger_adapter/README.md +145 -0
  30. wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
  31. wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
  32. wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
  33. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
  34. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
  35. wayfinder_paths/adapters/pool_adapter/README.md +206 -0
  36. wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
  37. wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
  38. wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
  39. wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
  40. wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
  41. wayfinder_paths/adapters/token_adapter/README.md +101 -0
  42. wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
  43. wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
  44. wayfinder_paths/adapters/token_adapter/examples.json +26 -0
  45. wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
  46. wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
  47. wayfinder_paths/config.example.json +22 -0
  48. wayfinder_paths/conftest.py +31 -0
  49. wayfinder_paths/core/__init__.py +18 -0
  50. wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
  51. wayfinder_paths/core/adapters/__init__.py +5 -0
  52. wayfinder_paths/core/adapters/base.py +5 -0
  53. wayfinder_paths/core/adapters/models.py +46 -0
  54. wayfinder_paths/core/analytics/__init__.py +11 -0
  55. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  56. wayfinder_paths/core/analytics/stats.py +48 -0
  57. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  58. wayfinder_paths/core/clients/AuthClient.py +83 -0
  59. wayfinder_paths/core/clients/BRAPClient.py +109 -0
  60. wayfinder_paths/core/clients/ClientManager.py +210 -0
  61. wayfinder_paths/core/clients/HyperlendClient.py +192 -0
  62. wayfinder_paths/core/clients/LedgerClient.py +443 -0
  63. wayfinder_paths/core/clients/PoolClient.py +128 -0
  64. wayfinder_paths/core/clients/SimulationClient.py +192 -0
  65. wayfinder_paths/core/clients/TokenClient.py +89 -0
  66. wayfinder_paths/core/clients/TransactionClient.py +63 -0
  67. wayfinder_paths/core/clients/WalletClient.py +94 -0
  68. wayfinder_paths/core/clients/WayfinderClient.py +269 -0
  69. wayfinder_paths/core/clients/__init__.py +48 -0
  70. wayfinder_paths/core/clients/protocols.py +392 -0
  71. wayfinder_paths/core/clients/sdk_example.py +110 -0
  72. wayfinder_paths/core/config.py +458 -0
  73. wayfinder_paths/core/constants/__init__.py +26 -0
  74. wayfinder_paths/core/constants/base.py +42 -0
  75. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  76. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  77. wayfinder_paths/core/engine/StrategyJob.py +188 -0
  78. wayfinder_paths/core/engine/__init__.py +5 -0
  79. wayfinder_paths/core/engine/manifest.py +97 -0
  80. wayfinder_paths/core/services/__init__.py +0 -0
  81. wayfinder_paths/core/services/base.py +179 -0
  82. wayfinder_paths/core/services/local_evm_txn.py +430 -0
  83. wayfinder_paths/core/services/local_token_txn.py +231 -0
  84. wayfinder_paths/core/services/web3_service.py +45 -0
  85. wayfinder_paths/core/settings.py +61 -0
  86. wayfinder_paths/core/strategies/Strategy.py +280 -0
  87. wayfinder_paths/core/strategies/__init__.py +5 -0
  88. wayfinder_paths/core/strategies/base.py +7 -0
  89. wayfinder_paths/core/strategies/descriptors.py +81 -0
  90. wayfinder_paths/core/utils/__init__.py +1 -0
  91. wayfinder_paths/core/utils/evm_helpers.py +206 -0
  92. wayfinder_paths/core/utils/wallets.py +77 -0
  93. wayfinder_paths/core/wallets/README.md +91 -0
  94. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  95. wayfinder_paths/core/wallets/__init__.py +7 -0
  96. wayfinder_paths/policies/enso.py +17 -0
  97. wayfinder_paths/policies/erc20.py +34 -0
  98. wayfinder_paths/policies/evm.py +21 -0
  99. wayfinder_paths/policies/hyper_evm.py +19 -0
  100. wayfinder_paths/policies/hyperlend.py +12 -0
  101. wayfinder_paths/policies/hyperliquid.py +30 -0
  102. wayfinder_paths/policies/moonwell.py +54 -0
  103. wayfinder_paths/policies/prjx.py +30 -0
  104. wayfinder_paths/policies/util.py +27 -0
  105. wayfinder_paths/run_strategy.py +411 -0
  106. wayfinder_paths/scripts/__init__.py +0 -0
  107. wayfinder_paths/scripts/create_strategy.py +181 -0
  108. wayfinder_paths/scripts/make_wallets.py +169 -0
  109. wayfinder_paths/scripts/run_strategy.py +124 -0
  110. wayfinder_paths/scripts/validate_manifests.py +213 -0
  111. wayfinder_paths/strategies/__init__.py +0 -0
  112. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  113. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  114. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  115. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  116. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  117. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  118. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  119. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  120. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  121. wayfinder_paths/strategies/config.py +85 -0
  122. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
  123. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
  124. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  125. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
  126. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
  127. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
  128. wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
  129. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  130. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
  131. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
  132. wayfinder_paths/templates/adapter/README.md +105 -0
  133. wayfinder_paths/templates/adapter/adapter.py +26 -0
  134. wayfinder_paths/templates/adapter/examples.json +8 -0
  135. wayfinder_paths/templates/adapter/manifest.yaml +6 -0
  136. wayfinder_paths/templates/adapter/test_adapter.py +49 -0
  137. wayfinder_paths/templates/strategy/README.md +153 -0
  138. wayfinder_paths/templates/strategy/examples.json +11 -0
  139. wayfinder_paths/templates/strategy/manifest.yaml +8 -0
  140. wayfinder_paths/templates/strategy/strategy.py +57 -0
  141. wayfinder_paths/templates/strategy/test_strategy.py +197 -0
  142. wayfinder_paths/tests/__init__.py +0 -0
  143. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  144. wayfinder_paths/tests/test_test_coverage.py +212 -0
  145. wayfinder_paths/tests/test_utils.py +64 -0
  146. wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
  147. wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
  148. wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
  149. wayfinder_paths-0.1.7.dist-info/WHEEL +4 -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