Coverage for src/cc_liquid/trader.py: 17%

554 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-10-13 20:16 +0000

1"""Core trading logic for cc-liquid. 

2 

3This module contains the core trading logic. Using this software may result in 

4COMPLETE LOSS of funds. CrowdCent makes NO WARRANTIES and assumes NO LIABILITY. 

5Users must comply with all Hyperliquid terms of service. 

6""" 

7 

8import logging 

9from dataclasses import dataclass 

10import math 

11from datetime import timezone, datetime, timedelta, time 

12from typing import Any 

13import uuid 

14 

15import polars as pl 

16from eth_account.signers.local import LocalAccount 

17from hyperliquid.exchange import Exchange 

18from hyperliquid.info import Info 

19 

20from .callbacks import CCLiquidCallbacks, NoOpCallbacks 

21from .config import Config 

22from .data_loader import DataLoader 

23from .portfolio import weights_from_ranks 

24from .order import Order, OrderManager, OrderHistory 

25from .stop_loss import StopLossManager, StopLossOrder 

26 

27logging.basicConfig(level=logging.INFO) 

28 

29 

30@dataclass 

31class AccountInfo: 

32 """Structured account information.""" 

33 

34 # Account metrics 

35 account_value: float 

36 total_position_value: float 

37 margin_used: float 

38 free_collateral: float 

39 cash_balance: float 

40 withdrawable: float 

41 current_leverage: float 

42 

43 # Cross margin info (optional) 

44 cross_leverage: float | None = None 

45 cross_margin_used: float | None = None 

46 cross_maintenance_margin: float | None = None 

47 

48 # Raw data for advanced users 

49 raw_margin_summary: dict[str, Any] | None = None 

50 raw_cross_margin_summary: dict[str, Any] | None = None 

51 

52 

53@dataclass 

54class Position: 

55 """Structured position information.""" 

56 

57 coin: str 

58 side: str # "LONG" or "SHORT" 

59 size: float 

60 entry_price: float 

61 mark_price: float 

62 value: float 

63 unrealized_pnl: float 

64 return_pct: float 

65 liquidation_price: float | None = None 

66 margin_used: float | None = None 

67 

68 

69@dataclass 

70class PortfolioInfo: 

71 """Complete portfolio information.""" 

72 

73 account: AccountInfo 

74 positions: list[Position] 

75 

76 @property 

77 def total_long_value(self) -> float: 

78 """Calculate total long position value.""" 

79 return sum(p.value for p in self.positions if p.side == "LONG") 

80 

81 @property 

82 def total_short_value(self) -> float: 

83 """Calculate total short position value.""" 

84 return sum(p.value for p in self.positions if p.side == "SHORT") 

85 

86 @property 

87 def net_exposure(self) -> float: 

88 """Calculate net exposure (long - short).""" 

89 return self.total_long_value - self.total_short_value 

90 

91 @property 

92 def total_exposure(self) -> float: 

93 """Calculate total exposure (long + short).""" 

94 return self.total_long_value + self.total_short_value 

95 

96 @property 

97 def total_unrealized_pnl(self) -> float: 

98 """Calculate total unrealized PnL.""" 

99 return sum(p.unrealized_pnl for p in self.positions) 

100 

101 

102class CCLiquid: 

103 """ 

104 Handles all interactions with the Hyperliquid exchange. 

105 """ 

106 

107 def __init__( 

108 self, 

109 config: Config, 

110 callbacks: CCLiquidCallbacks | None = None, 

111 skip_ws: bool = True, 

112 ): 

113 self.config = config 

114 self.callbacks = callbacks or NoOpCallbacks() 

115 

116 # Validate config for trading operations 

117 self.config.validate_for_trading() 

118 

119 self.account: LocalAccount = self._get_account() 

120 self.exchange = Exchange( 

121 self.account, 

122 self.config.base_url, 

123 vault_address=(self.config.HYPERLIQUID_VAULT_ADDRESS or None), 

124 account_address=self.config.HYPERLIQUID_ADDRESS, 

125 ) 

126 self.info = Info(self.config.base_url, skip_ws=skip_ws) 

127 self.logger = logging.getLogger(__name__) 

128 # Lazy-loaded map of coin -> szDecimals from Info.meta()["universe"]. 

129 # Perps only: Hyperliquid perps use max 6 decimals for price rules. 

130 self._coin_to_sz_decimals: dict[str, int] | None = None 

131 

132 # Order and stop loss management 

133 self.order_manager = OrderManager() 

134 self.stop_loss_manager = StopLossManager( 

135 config=self.config, 

136 exchange=self.exchange, 

137 info=self.info, 

138 logger=self.logger 

139 ) 

140 

141 def _get_account(self) -> LocalAccount: 

142 """Creates an eth_account LocalAccount object from the private key.""" 

143 from eth_account import Account 

144 

145 return Account.from_key(self.config.HYPERLIQUID_PRIVATE_KEY) 

146 

147 def get_user_state(self) -> dict[str, Any]: 

148 """Retrieves the current state of the user's account.""" 

149 # Always query Info using the portfolio owner: vault (if set) or master address. 

150 # Never use the agent/signer address for Info, as it has no balances. 

151 owner = self.config.HYPERLIQUID_VAULT_ADDRESS or self.config.HYPERLIQUID_ADDRESS 

152 if not owner: 

153 raise ValueError( 

154 "Missing portfolio owner. Set HYPERLIQUID_VAULT_ADDRESS or HYPERLIQUID_ADDRESS." 

155 ) 

156 return self.info.user_state(owner) 

157 

158 def get_positions(self) -> dict[str, Any]: 

159 """Retrieves the user's open positions as a dict.""" 

160 user_state = self.get_user_state() 

161 positions = {} 

162 for position_data in user_state.get("assetPositions", []): 

163 position = position_data.get("position", {}) 

164 if float(position.get("szi", 0)) != 0: 

165 positions[position["coin"]] = position 

166 return positions 

167 

168 def get_account_value(self) -> float: 

169 """Retrieves the total account value in USD.""" 

170 user_state = self.get_user_state() 

171 return float(user_state["marginSummary"]["accountValue"]) 

172 

173 def get_portfolio_info(self) -> PortfolioInfo: 

174 """Get complete portfolio information as structured data.""" 

175 try: 

176 user_state = self.get_user_state() 

177 except Exception as e: 

178 self.logger.warning(f"Could not get user state: {e}") 

179 # Return empty portfolio if we can't connect 

180 return PortfolioInfo( 

181 account=AccountInfo( 

182 account_value=0, 

183 total_position_value=0, 

184 margin_used=0, 

185 free_collateral=0, 

186 cash_balance=0, 

187 withdrawable=0, 

188 current_leverage=0, 

189 ), 

190 positions=[], 

191 ) 

192 

193 margin_summary = user_state.get("marginSummary", {}) if user_state else {} 

194 all_mids = self.info.all_mids() if user_state else {} 

195 

196 # Build account info 

197 account_info = AccountInfo( 

198 account_value=float(margin_summary.get("accountValue", 0)), 

199 total_position_value=float(margin_summary.get("totalNtlPos", 0)), 

200 margin_used=float(margin_summary.get("totalMarginUsed", 0)), 

201 free_collateral=float(margin_summary.get("accountValue", 0)) 

202 - float(margin_summary.get("totalMarginUsed", 0)), 

203 cash_balance=float(margin_summary.get("totalRawUsd", 0)), 

204 withdrawable=float(user_state.get("withdrawable", 0)), 

205 current_leverage=float(margin_summary.get("totalNtlPos", 0)) 

206 / float(margin_summary.get("accountValue", 1)) 

207 if float(margin_summary.get("accountValue", 0)) > 0 

208 else 0, 

209 raw_margin_summary=margin_summary, 

210 ) 

211 

212 # Add cross margin info if available 

213 cross_margin = user_state.get("crossMarginSummary") 

214 if cross_margin: 

215 account_info.cross_leverage = ( 

216 float(cross_margin.get("accountValue", 0)) 

217 / float(margin_summary.get("accountValue", 1)) 

218 if float(margin_summary.get("accountValue", 0)) > 0 

219 else 0 

220 ) 

221 account_info.cross_margin_used = float( 

222 cross_margin.get("totalMarginUsed", 0) 

223 ) 

224 account_info.cross_maintenance_margin = float( 

225 cross_margin.get("totalMaintenanceMargin", 0) 

226 ) 

227 account_info.raw_cross_margin_summary = cross_margin 

228 

229 # Build positions list 

230 positions = [] 

231 for position_data in user_state.get("assetPositions", []): 

232 pos = position_data.get("position", {}) 

233 size = float(pos.get("szi", 0)) 

234 

235 if size == 0: 

236 continue 

237 

238 coin = pos["coin"] 

239 entry_px = float(pos.get("entryPx", 0)) 

240 mark_px = float(all_mids.get(coin, entry_px)) 

241 position_value = abs(size * mark_px) 

242 

243 # Calculate unrealized PnL 

244 if size > 0: 

245 unrealized_pnl = (mark_px - entry_px) * size 

246 side = "LONG" 

247 else: 

248 unrealized_pnl = (entry_px - mark_px) * abs(size) 

249 side = "SHORT" 

250 

251 return_pct = ( 

252 (unrealized_pnl / (abs(size) * entry_px) * 100) if entry_px > 0 else 0 

253 ) 

254 

255 positions.append( 

256 Position( 

257 coin=coin, 

258 side=side, 

259 size=abs(size), 

260 entry_price=entry_px, 

261 mark_price=mark_px, 

262 value=position_value, 

263 unrealized_pnl=unrealized_pnl, 

264 return_pct=return_pct, 

265 liquidation_price=float(pos["liquidationPx"]) 

266 if "liquidationPx" in pos and pos["liquidationPx"] is not None 

267 else None, 

268 margin_used=float(pos["marginUsed"]) 

269 if "marginUsed" in pos and pos["marginUsed"] is not None 

270 else None, 

271 ) 

272 ) 

273 

274 return PortfolioInfo(account=account_info, positions=positions) 

275 

276 # --- Rounding helpers (Perps only) --- 

277 def _load_sz_decimals_map(self, force_refresh: bool = False) -> dict[str, int]: 

278 """Load and cache coin -> szDecimals from exchange meta. 

279 

280 Per Hyperliquid rounding guidance for orders, sizes must be rounded to a 

281 coin-specific number of decimals (szDecimals). We cache from `info.meta()` 

282 and refresh on demand. 

283 """ 

284 if self._coin_to_sz_decimals is None or force_refresh: 

285 try: 

286 universe = self.info.meta().get("universe", []) 

287 self._coin_to_sz_decimals = { 

288 asset.get("name"): int(asset.get("szDecimals", 2)) 

289 for asset in universe 

290 if asset.get("name") and not asset.get("isDelisted", False) 

291 } 

292 except Exception as e: 

293 self.logger.warning(f"Failed to load szDecimals map: {e}") 

294 self._coin_to_sz_decimals = {} 

295 return self._coin_to_sz_decimals 

296 

297 def _get_sz_decimals(self, coin: str) -> int | None: 

298 """Return szDecimals for the given coin, refreshing meta once if needed.""" 

299 sz_map = self._load_sz_decimals_map() 

300 if coin not in sz_map: 

301 sz_map = self._load_sz_decimals_map(force_refresh=True) 

302 return sz_map.get(coin) 

303 

304 def _round_size(self, coin: str, size: float) -> tuple[float, int] | None: 

305 """Round size per coin's szDecimals. 

306 

307 Returns (rounded_size, sz_decimals) or None if szDecimals are unknown. 

308 """ 

309 sz_decimals = self._get_sz_decimals(coin) 

310 if sz_decimals is None: 

311 return None 

312 return round(size, sz_decimals), sz_decimals 

313 

314 def _round_price_perp(self, coin: str, px: float) -> float: 

315 """Round price according to Hyperliquid perp rules (not used for market orders). 

316 

317 Rules (per Hyperliquid): 

318 - If px > 100_000: round to integer. 

319 - Else: round to 5 significant figures and at most (6 - szDecimals) decimals. 

320 Reference: Hyperliquid SDK example rounding: see rounding.py 

321 """ 

322 if px > 100_000: 

323 return round(px) 

324 sz_decimals = self._get_sz_decimals(coin) 

325 # If unknown, still limit to 5 significant figures as a safe default. 

326 if sz_decimals is None: 

327 return float(f"{px:.5g}") 

328 max_decimals = 6 # perps 

329 return round(float(f"{px:.5g}"), max_decimals - sz_decimals) 

330 

331 def plan_rebalance(self, predictions: pl.DataFrame | None = None) -> dict: 

332 """Compute a rebalancing plan without executing orders.""" 

333 # Load predictions if not provided 

334 if predictions is None: 

335 self.callbacks.info("Loading predictions...") 

336 predictions = self._load_predictions() 

337 

338 if predictions is None or predictions.is_empty(): 

339 self.callbacks.error("No predictions available, cannot rebalance") 

340 return { 

341 "target_positions": {}, 

342 "trades": [], 

343 "skipped_trades": [], 

344 "account_value": 0.0, 

345 "leverage": self.config.portfolio.target_leverage, 

346 } 

347 

348 # Display prediction info 

349 unique_assets = predictions[self.config.data.asset_id_column].n_unique() 

350 latest_data = predictions[self.config.data.date_column].max() 

351 self.callbacks.info( 

352 f"Loaded predictions for {unique_assets} assets (latest: {latest_data})" 

353 ) 

354 

355 # Asset Selection, Position Calculation, and Trade Generation 

356 target_positions = self._get_target_positions(predictions) 

357 current_positions = self.get_positions() 

358 trades, skipped_trades = self._calculate_trades( 

359 target_positions, current_positions 

360 ) 

361 

362 # Build plan (including skipped trades) 

363 account_value = self.get_account_value() 

364 leverage = self.config.portfolio.target_leverage 

365 return { 

366 "target_positions": target_positions, 

367 "trades": trades, 

368 "skipped_trades": skipped_trades, 

369 "account_value": account_value, 

370 "leverage": leverage, 

371 } 

372 

373 def execute_plan(self, plan: dict) -> dict: 

374 """Execute a precomputed plan, routing by strategy.""" 

375 trades: list[dict] = plan.get("trades", []) 

376 if not trades: 

377 return {"successful_trades": [], "all_trades": trades} 

378 

379 # Route based on execution strategy 

380 if self.config.execution.strategy == "twap_native": 

381 result = self._execute_twap_native(trades) 

382 else: 

383 result = self._execute_market(trades) 

384 

385 # Place stop losses for new short positions if enabled 

386 if self.config.execution.stop_loss.enabled: 

387 self._place_stops_for_shorts(result["successful_trades"]) 

388 

389 return result 

390 

391 def _execute_market(self, trades: list[dict]) -> dict: 

392 """Execute trades using market orders (current behavior).""" 

393 trades = self._sort_trades_for_leverage_reduction(trades) 

394 self.callbacks.info(f"Starting market order execution of {len(trades)} trades...") 

395 successful_trades = self._execute_trades(trades) 

396 return {"successful_trades": successful_trades, "all_trades": trades} 

397 

398 def get_open_orders(self) -> list[Order]: 

399 """Get all open orders (refreshes TWAP statuses from exchange).""" 

400 self._refresh_twap_statuses() 

401 return self.order_manager.get_open_orders() 

402 

403 def get_order_history(self, days: int = 30) -> OrderHistory: 

404 """Get historical orders with performance metrics.""" 

405 return self.order_manager.get_order_history(days=days) 

406 

407 def cancel_order(self, order_id: str) -> bool: 

408 """Cancel an open order.""" 

409 order = self.order_manager.get_order(order_id) 

410 if not order or order.status not in ("open", "partially_filled"): 

411 return False 

412 

413 try: 

414 if order.strategy == "twap_native" and order.twap_id: 

415 # Cancel TWAP via exchange API 

416 result = self.exchange.cancel(order.twap_id) 

417 if result.get("status") == "ok": 

418 order.status = "cancelled" 

419 self.order_manager.update_order(order) 

420 return True 

421 return False 

422 except Exception as e: 

423 self.logger.error(f"Failed to cancel order {order_id}: {e}") 

424 return False 

425 

426 def monitor_stops(self): 

427 """Check for triggered stops and update trailing stops.""" 

428 # Update trailing stops 

429 self.stop_loss_manager.update_trailing_stops() 

430 

431 # Check for triggers 

432 triggered = self.stop_loss_manager.check_triggered_stops() 

433 

434 for stop in triggered: 

435 self.callbacks.warn( 

436 f"⚠️ Stop loss triggered: {stop.coin} @ ${stop.trigger_price:.2f}" 

437 ) 

438 

439 def plan_close_all_positions(self, *, force: bool = False) -> dict: 

440 """Plan to close all open positions (return to cash) without executing orders.""" 

441 current_positions = self.get_positions() 

442 

443 if not current_positions: 

444 self.callbacks.info("No open positions to close.") 

445 return { 

446 "target_positions": {}, 

447 "trades": [], 

448 "skipped_trades": [], 

449 "account_value": self.get_account_value(), 

450 "leverage": self.config.portfolio.target_leverage, 

451 } 

452 

453 self.callbacks.info("Closing all positions to return to cash...") 

454 

455 # Create target positions of 0 for all current positions 

456 target_positions = {coin: 0 for coin in current_positions.keys()} 

457 trades, skipped_trades = self._calculate_trades( 

458 target_positions, current_positions, force=force 

459 ) 

460 

461 account_value = self.get_account_value() 

462 leverage = self.config.portfolio.target_leverage 

463 return { 

464 "target_positions": target_positions, 

465 "trades": trades, 

466 "skipped_trades": skipped_trades, 

467 "account_value": account_value, 

468 "leverage": leverage, 

469 } 

470 

471 def _get_target_positions(self, predictions: pl.DataFrame) -> dict[str, float]: 

472 """Calculate target notionals using configurable weighting scheme.""" 

473 

474 latest_predictions = self._get_latest_predictions(predictions) 

475 tradeable_predictions = self._filter_tradeable_predictions(latest_predictions) 

476 

477 if tradeable_predictions.height == 0: 

478 return {} 

479 

480 id_col = self.config.data.asset_id_column 

481 pred_col = self.config.data.prediction_column 

482 

483 sorted_preds = tradeable_predictions.sort(pred_col, descending=True) 

484 

485 num_long = self.config.portfolio.num_long 

486 num_short = self.config.portfolio.num_short 

487 

488 if sorted_preds.height < num_long + num_short: 

489 self.callbacks.warn( 

490 f"Limited tradeable assets: {sorted_preds.height} available; " 

491 f"requested {num_long} longs and {num_short} shorts" 

492 ) 

493 

494 long_assets = sorted_preds.head(num_long)[id_col].to_list() 

495 short_assets = ( 

496 sorted_preds.sort(pred_col, descending=False) 

497 .head(num_short)[id_col] 

498 .to_list() 

499 if num_short > 0 

500 else [] 

501 ) 

502 

503 account_value = self.get_account_value() 

504 target_leverage = self.config.portfolio.target_leverage 

505 total_positions = len(long_assets) + len(short_assets) 

506 

507 if total_positions == 0 or account_value <= 0 or target_leverage <= 0: 

508 return {} 

509 

510 self.callbacks.info( 

511 f"Target gross leverage: {target_leverage:.2f}x across {total_positions} positions" 

512 ) 

513 

514 weights = weights_from_ranks( 

515 latest_preds=tradeable_predictions.select([id_col, pred_col]), 

516 id_col=id_col, 

517 pred_col=pred_col, 

518 long_assets=long_assets, 

519 short_assets=short_assets, 

520 target_gross=target_leverage, 

521 scheme=self.config.portfolio.weighting_scheme, 

522 power=self.config.portfolio.rank_power, 

523 ) 

524 

525 target_positions = { 

526 asset: weight * account_value for asset, weight in weights.items() 

527 } 

528 

529 # Warn if resulting notionals fall below exchange minimums 

530 min_notional = self.config.execution.min_trade_value 

531 undersized = [ 

532 asset 

533 for asset, weight in target_positions.items() 

534 if abs(weight) < min_notional 

535 ] 

536 if undersized: 

537 self.callbacks.warn( 

538 "Some target positions fall below minimum notional: " 

539 + ", ".join(sorted(undersized)) 

540 ) 

541 

542 return target_positions 

543 

544 def _get_latest_predictions(self, predictions: pl.DataFrame) -> pl.DataFrame: 

545 """Filters for the latest predictions for each asset by date.""" 

546 return ( 

547 predictions.sort(self.config.data.date_column, descending=True) 

548 .group_by(self.config.data.asset_id_column) 

549 .first() 

550 ) 

551 

552 def _filter_tradeable_predictions(self, predictions: pl.DataFrame) -> pl.DataFrame: 

553 """Filter predictions to Hyperliquid-listed assets.""" 

554 

555 universe = self.info.meta()["universe"] 

556 available_assets = { 

557 p["name"] for p in universe if not p.get("isDelisted", False) 

558 } 

559 

560 tradeable = predictions.filter( 

561 pl.col(self.config.data.asset_id_column).is_in(available_assets) 

562 ) 

563 

564 if tradeable.height == 0: 

565 self.logger.warning("No predictions match Hyperliquid tradeable assets!") 

566 self.callbacks.error( 

567 "Error: No predictions match Hyperliquid tradeable assets!" 

568 ) 

569 self.callbacks.info( 

570 f"Available on Hyperliquid: {sorted(list(available_assets)[:10])}{'...' if len(available_assets) > 10 else ''}" 

571 ) 

572 prediction_assets = ( 

573 predictions[self.config.data.asset_id_column].unique().to_list() 

574 ) 

575 self.callbacks.info( 

576 f"In predictions: {sorted(prediction_assets[:10])}{'...' if len(prediction_assets) > 10 else ''}" 

577 ) 

578 

579 return tradeable 

580 

581 def _calculate_trades( 

582 self, 

583 target_positions: dict[str, float], 

584 current_positions: dict[str, Any], 

585 *, 

586 force: bool = False, 

587 ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: 

588 """Calculates the trades required to reach the target portfolio using market orders. 

589 

590 Returns: 

591 (executable_trades, skipped_trades) - trades that can be executed and those below minimum 

592 """ 

593 trades = [] 

594 skipped_trades = [] # Track trades we can't execute 

595 all_mids = self.info.all_mids() 

596 

597 all_assets = set(target_positions.keys()) | set(current_positions.keys()) 

598 

599 for asset in all_assets: 

600 target_value = target_positions.get(asset, 0) 

601 

602 # Get current position details 

603 current_position = current_positions.get(asset, {}) 

604 current_size = float(current_position.get("szi", 0)) 

605 

606 # Ensure we have a mid price; otherwise skip 

607 if asset not in all_mids: 

608 skipped_trades.append( 

609 { 

610 "coin": asset, 

611 "target_value": target_value, 

612 "skipped": True, 

613 "skip_reason": "No mid price available", 

614 } 

615 ) 

616 continue 

617 

618 price = float(all_mids[asset]) 

619 

620 # Calculate current value with proper sign 

621 # szi is positive for long, negative for short 

622 current_value = current_size * price 

623 

624 # Calculate the value delta we need to achieve 

625 delta_value = target_value - current_value 

626 

627 # Determine trade direction 

628 # If delta_value > 0, we need to buy (increase position or reduce short) 

629 # If delta_value < 0, we need to sell (decrease position or increase short) 

630 is_buy = delta_value > 0 

631 size = abs(delta_value) / price 

632 

633 # Round the size using szDecimals from meta (perps only) 

634 coin = asset 

635 rounded = self._round_size(coin, size) 

636 if rounded is None: 

637 skipped_trades.append( 

638 { 

639 "coin": asset, 

640 "target_value": target_value, 

641 "skipped": True, 

642 "skip_reason": "Unknown szDecimals (meta)", 

643 } 

644 ) 

645 continue 

646 size, sz_decimals = rounded 

647 

648 # If rounding collapses to zero, skip 

649 if size == 0: 

650 skipped_trades.append( 

651 { 

652 "coin": asset, 

653 "target_value": target_value, 

654 "skipped": True, 

655 "skip_reason": f"Rounded size is 0 at {sz_decimals} dp", 

656 } 

657 ) 

658 continue 

659 

660 # Check if trade is below minimum value threshold 

661 min_trade_value = self.config.execution.min_trade_value 

662 # Classify the trade type for clearer downstream handling 

663 # Types: open, close, reduce, increase, flip 

664 trade_type: str 

665 if current_value == 0: 

666 trade_type = "open" if target_value != 0 else "increase" 

667 elif target_value == 0: 

668 trade_type = "close" 

669 else: 

670 same_sign = (current_value > 0 and target_value > 0) or ( 

671 current_value < 0 and target_value < 0 

672 ) 

673 if same_sign: 

674 trade_type = ( 

675 "reduce" 

676 if abs(target_value) < abs(current_value) 

677 else "increase" 

678 ) 

679 else: 

680 trade_type = "flip" 

681 

682 trade_data = { 

683 "coin": asset, 

684 "is_buy": is_buy, 

685 "sz": size, 

686 "price": price, 

687 "current_value": current_value, 

688 "target_value": target_value, 

689 "delta_value": delta_value, 

690 "type": trade_type, 

691 } 

692 

693 # Re-evaluate min notional AFTER rounding size 

694 if abs(size * price) < min_trade_value: 

695 # Below minimum. If not forcing or not a pure close-to-zero scenario, skip. 

696 if not force or target_value != 0: 

697 trade_data["skipped"] = True 

698 trade_data["skip_reason"] = f"Below minimum ${min_trade_value}" 

699 skipped_trades.append(trade_data) 

700 else: 

701 forced, reason = self._compose_force_close_trades( 

702 asset, price, current_value, min_trade_value 

703 ) 

704 if forced is None: 

705 skipped_trades.append( 

706 { 

707 "coin": asset, 

708 "target_value": target_value, 

709 "skipped": True, 

710 "skip_reason": reason 

711 or "Force close composition failed", 

712 } 

713 ) 

714 else: 

715 trades.extend(forced) 

716 else: 

717 # Add to executable trades 

718 trades.append(trade_data) 

719 

720 return trades, skipped_trades 

721 

722 def _sort_trades_for_leverage_reduction( 

723 self, trades: list[dict[str, Any]] 

724 ) -> list[dict[str, Any]]: 

725 """Return trades ordered to reduce leverage first using explicit trade types. 

726 

727 Priority: close (0), reduce/flip (1), increase (2), open (3). Stable ordering within groups. 

728 """ 

729 priority = {"close": 0, "reduce": 1, "flip": 1, "increase": 2, "open": 3} 

730 

731 def sort_key(t: dict[str, Any]): 

732 # Forced close chains must execute in sequence: increase (0) then close (1) 

733 if t.get("force"): 

734 return (0, t.get("force_id", ""), t.get("force_seq", 0)) 

735 return (1, priority.get(t.get("type", "increase"), 2), 0) 

736 

737 return sorted(trades, key=sort_key) 

738 

739 def _compose_force_close_trades( 

740 self, coin: str, price: float, current_value: float, min_trade_value: float 

741 ) -> tuple[list[dict[str, Any]] | None, str | None]: 

742 """Compose the two-step forced close for sub-minimum closes. 

743 i.e. if we have a position of less than $10, we want to close it to $0, we need to increase the position 

744 to at least $10, then close it to $0. 

745 

746 Returns (trades, None) on success or (None, reason) on failure. 

747 """ 

748 rounded_up = self._round_size_up_to_min_notional(coin, min_trade_value, price) 

749 if rounded_up is None: 

750 return None, "Unknown szDecimals (meta)" 

751 min_increase_sz, _ = rounded_up 

752 

753 increase_is_buy = current_value > 0 

754 force_id = f"force_close:{coin}" 

755 

756 step1 = { 

757 "coin": coin, 

758 "is_buy": increase_is_buy, 

759 "sz": min_increase_sz, 

760 "price": price, 

761 "current_value": current_value, 

762 "target_value": current_value 

763 + ( 

764 min_increase_sz * price 

765 if current_value >= 0 

766 else -min_increase_sz * price 

767 ), 

768 "delta_value": min_increase_sz * price 

769 if increase_is_buy 

770 else -(min_increase_sz * price), 

771 "type": "increase", 

772 "force": True, 

773 "force_id": force_id, 

774 "force_seq": 0, 

775 } 

776 

777 total_notional_to_close = abs(current_value) + (min_increase_sz * price) 

778 close_is_buy = not increase_is_buy 

779 close_sz_rounded = self._round_size(coin, total_notional_to_close / price) 

780 if close_sz_rounded is None: 

781 return None, "Unknown szDecimals (meta)" 

782 close_sz, _ = close_sz_rounded 

783 

784 step2 = { 

785 "coin": coin, 

786 "is_buy": close_is_buy, 

787 "sz": close_sz, 

788 "price": price, 

789 "current_value": current_value 

790 + ( 

791 min_increase_sz * price 

792 if increase_is_buy 

793 else -(min_increase_sz * price) 

794 ), 

795 "target_value": 0, 

796 "delta_value": total_notional_to_close 

797 if close_is_buy 

798 else -total_notional_to_close, 

799 "type": "close", 

800 "force": True, 

801 "force_id": force_id, 

802 "force_seq": 1, 

803 } 

804 

805 # Ensure both meet minimum notional 

806 if (step1["sz"] * price) < min_trade_value or ( 

807 step2["sz"] * price 

808 ) < min_trade_value: 

809 return ( 

810 None, 

811 f"Below minimum even after force composition (${min_trade_value})", 

812 ) 

813 

814 return [step1, step2], None 

815 

816 def _round_size_up_to_min_notional( 

817 self, coin: str, target_notional: float, price: float 

818 ) -> tuple[float, int] | None: 

819 """Return (size, decimals) such that size*price >= target_notional after rounding to szDecimals. 

820 

821 Rounds up to the nearest step defined by szDecimals to satisfy the notional constraint. 

822 """ 

823 sz_decimals = self._get_sz_decimals(coin) 

824 if sz_decimals is None: 

825 return None 

826 raw_size = target_notional / price if price > 0 else 0 

827 if raw_size <= 0: 

828 return (0.0, sz_decimals) 

829 step = 10 ** (-sz_decimals) 

830 # Avoid floating imprecision by working in integer steps 

831 steps_needed = math.ceil(raw_size / step) 

832 rounded_up_size = steps_needed * step 

833 # Round to the allowed decimals to avoid long floats 

834 rounded_up_size = round(rounded_up_size, sz_decimals) 

835 return rounded_up_size, sz_decimals 

836 

837 def _execute_trades(self, trades: list[dict[str, Any]]) -> list[dict[str, Any]]: 

838 """Executes a list of trades using the SDK's market_open for robustness.""" 

839 if not trades: 

840 return [] 

841 

842 successful_trades = [] 

843 failed_trades = [] 

844 

845 # Show progress during execution 

846 self.callbacks.info(f"Executing {len(trades)} trades...") 

847 

848 for i, trade in enumerate(trades, 1): 

849 # Notify callback of trade start 

850 self.callbacks.on_trade_start(i, len(trades), trade) 

851 

852 try: 

853 self.logger.debug(f"Executing trade: {trade}") 

854 # Pass vault address if trading on behalf of a vault/subaccount. 

855 result = self.exchange.market_open( 

856 name=trade["coin"], 

857 is_buy=trade["is_buy"], 

858 sz=trade["sz"], # Already a float 

859 slippage=self.config.execution.slippage_tolerance, 

860 ) 

861 

862 # Handle error responses 

863 if result.get("status") == "err": 

864 error_msg = result.get("response", "Unknown error") 

865 self.callbacks.on_trade_fail(trade, error_msg) 

866 failed_trades.append(trade) 

867 continue 

868 

869 # Handle success responses 

870 response = result.get("response", {}) 

871 if not isinstance(response, dict): 

872 self.callbacks.on_trade_fail(trade, f"Unexpected response format: {response}") 

873 failed_trades.append(trade) 

874 continue 

875 

876 statuses = response.get("data", {}).get("statuses", []) 

877 

878 # Check for filled orders 

879 filled_data = None 

880 for status in statuses: 

881 if "filled" in status: 

882 filled_data = status["filled"] 

883 break 

884 

885 if filled_data: 

886 # Extract fill details 

887 float(filled_data.get("totalSz", trade["sz"])) 

888 avg_px = float(filled_data.get("avgPx", trade["price"])) 

889 

890 # Calculate slippage 

891 if trade["is_buy"]: 

892 slippage_pct = ( 

893 (avg_px - trade["price"]) / trade["price"] 

894 ) * 100 

895 else: 

896 slippage_pct = ( 

897 (trade["price"] - avg_px) / trade["price"] 

898 ) * 100 

899 

900 self.callbacks.on_trade_fill(trade, filled_data, slippage_pct) 

901 

902 successful_trades.append( 

903 { 

904 **trade, 

905 "fill_data": filled_data, 

906 "slippage_pct": slippage_pct, 

907 } 

908 ) 

909 else: 

910 # Handle errors or unfilled orders 

911 errors = [s.get("error") for s in statuses if "error" in s] 

912 error_msg = errors[0] if errors else "Order not filled" 

913 

914 self.callbacks.on_trade_fail(trade, error_msg) 

915 failed_trades.append(trade) 

916 

917 except Exception as e: 

918 self.callbacks.on_trade_fail(trade, str(e)) 

919 self.logger.error(f"Error executing trade for {trade['coin']}: {e}") 

920 failed_trades.append(trade) 

921 

922 # Notify callback of batch completion 

923 self.callbacks.on_batch_complete(successful_trades, failed_trades) 

924 

925 return successful_trades 

926 

927 def load_state(self) -> datetime | None: 

928 """Public wrapper to load last rebalance timestamp.""" 

929 return self._load_state() 

930 

931 def save_state(self, last_rebalance_date: datetime) -> None: 

932 """Public wrapper to persist last rebalance timestamp.""" 

933 self._save_state(last_rebalance_date) 

934 

935 def compute_next_rebalance_time( 

936 self, last_rebalance_date: datetime | None, now: datetime | None = None 

937 ) -> datetime: 

938 """Compute the next scheduled rebalance timestamp in UTC. 

939 

940 Rules: 

941 - If this is the first run (no last date): schedule for today at configured time; if 

942 already past that time, return "now" to indicate it is due immediately. 

943 - Otherwise: schedule exactly every_n_days after the last rebalance date, at the 

944 configured time. 

945 """ 

946 cfg = self.config.portfolio.rebalancing 

947 now_utc = now or datetime.now(timezone.utc) 

948 

949 hour, minute = map(int, cfg.at_time.split(":")) 

950 rebalance_time = time(hour=hour, minute=minute) 

951 

952 if last_rebalance_date is None: 

953 today_at = datetime.combine( 

954 now_utc.date(), rebalance_time, tzinfo=timezone.utc 

955 ) 

956 return today_at if now_utc < today_at else now_utc 

957 

958 next_date = last_rebalance_date.date() + timedelta(days=cfg.every_n_days) 

959 return datetime.combine(next_date, rebalance_time, tzinfo=timezone.utc) 

960 

961 def _load_state(self) -> datetime | None: 

962 """Load the last rebalance date from persistent state.""" 

963 import json 

964 import os 

965 

966 state_file = ".cc_liquid_state.json" 

967 if not os.path.exists(state_file): 

968 return None 

969 

970 try: 

971 with open(state_file) as f: 

972 state = json.load(f) 

973 last_date = datetime.fromisoformat(state.get("last_rebalance_date")) 

974 return last_date 

975 except Exception as e: 

976 self.logger.warning(f"Could not load state file: {e}") 

977 return None 

978 

979 def _save_state(self, last_rebalance_date: datetime): 

980 """Save the last rebalance date to persistent state.""" 

981 import json 

982 

983 state_file = ".cc_liquid_state.json" 

984 with open(state_file, "w") as f: 

985 json.dump({"last_rebalance_date": last_rebalance_date.isoformat()}, f) 

986 

987 def _execute_twap_native(self, trades: list[dict]) -> dict: 

988 """Execute trades using Hyperliquid native TWAP orders.""" 

989 trades = self._sort_trades_for_leverage_reduction(trades) 

990 

991 self.callbacks.info( 

992 f"Starting TWAP execution of {len(trades)} trades " 

993 f"(duration: {self.config.execution.twap.duration_minutes}min)..." 

994 ) 

995 

996 successful_orders = [] 

997 failed_orders = [] 

998 

999 for i, trade in enumerate(trades, 1): 

1000 # Create order record 

1001 order_id = f"twap_{trade['coin']}_{uuid.uuid4().hex[:8]}" 

1002 order = Order( 

1003 order_id=order_id, 

1004 timestamp=datetime.now(timezone.utc), 

1005 coin=trade["coin"], 

1006 side="BUY" if trade["is_buy"] else "SELL", 

1007 strategy="twap_native", 

1008 size=trade["sz"], 

1009 target_value=trade["target_value"], 

1010 status="pending", 

1011 remaining_size=trade["sz"], 

1012 expected_price=trade["price"], 

1013 twap_duration_minutes=self.config.execution.twap.duration_minutes 

1014 ) 

1015 

1016 # Persist order 

1017 self.order_manager.create_order(order) 

1018 self.callbacks.on_trade_start(i, len(trades), trade) 

1019 

1020 try: 

1021 # Validate min notional per child 

1022 self._validate_twap_sizing(trade) 

1023 

1024 # Submit TWAP to exchange 

1025 result = self._submit_twap_order( 

1026 coin=trade["coin"], 

1027 is_buy=trade["is_buy"], 

1028 size=trade["sz"], 

1029 duration_minutes=self.config.execution.twap.duration_minutes, 

1030 randomize=self.config.execution.twap.randomize 

1031 ) 

1032 

1033 if result.get("status") == "ok": 

1034 # Extract TWAP ID from response 

1035 twap_id = self._extract_twap_id(result) 

1036 

1037 # Update order with TWAP ID 

1038 order.status = "open" 

1039 order.twap_id = twap_id 

1040 order.raw_response = result 

1041 

1042 # Calculate expected slices 

1043 order.twap_slices_total = ( 

1044 self.config.execution.twap.duration_minutes * 2 # 30s intervals 

1045 ) 

1046 

1047 self.order_manager.update_order(order) 

1048 

1049 successful_orders.append({**trade, "order": order}) 

1050 self.callbacks.info( 

1051 f"✓ TWAP submitted: {trade['coin']} (ID: {twap_id})" 

1052 ) 

1053 

1054 else: 

1055 error_msg = result.get("response", "Unknown error") 

1056 order.status = "failed" 

1057 order.error_message = error_msg 

1058 self.order_manager.update_order(order) 

1059 self.callbacks.on_trade_fail(trade, error_msg) 

1060 failed_orders.append(trade) 

1061 

1062 except Exception as e: 

1063 order.status = "failed" 

1064 order.error_message = str(e) 

1065 self.order_manager.update_order(order) 

1066 self.callbacks.on_trade_fail(trade, str(e)) 

1067 failed_orders.append(trade) 

1068 

1069 self.callbacks.on_batch_complete(successful_orders, failed_orders) 

1070 

1071 # Show monitoring instructions 

1072 if successful_orders: 

1073 self.callbacks.info( 

1074 f"\n{len(successful_orders)} TWAP orders are now executing. " 

1075 f"Monitor progress with: cc-liquid orders list" 

1076 ) 

1077 

1078 return {"successful_trades": successful_orders, "all_trades": trades} 

1079 

1080 def _validate_twap_sizing(self, trade: dict): 

1081 """Validate that TWAP child orders will meet minimum notional.""" 

1082 duration_min = self.config.execution.twap.duration_minutes 

1083 slices = duration_min * 2 # 30s intervals 

1084 

1085 child_notional = (trade["sz"] * trade["price"]) / slices 

1086 min_child = self.config.execution.twap.min_child_notional 

1087 

1088 if child_notional < min_child: 

1089 raise ValueError( 

1090 f"TWAP child order size too small: ${child_notional:.2f} < ${min_child:.2f}. " 

1091 f"Reduce duration or increase position size." 

1092 ) 

1093 

1094 def _submit_twap_order( 

1095 self, coin: str, is_buy: bool, size: float, 

1096 duration_minutes: int, randomize: bool 

1097 ) -> dict: 

1098 """Submit a TWAP order to Hyperliquid.""" 

1099 # Get asset ID from meta 

1100 universe = self.info.meta()["universe"] 

1101 asset = next((a for a in universe if a["name"] == coin), None) 

1102 if not asset: 

1103 raise ValueError(f"Asset {coin} not found in universe") 

1104 

1105 asset_id = asset["assetId"] 

1106 

1107 # Check if SDK has twap_order method, otherwise use _post_action 

1108 if hasattr(self.exchange, 'twap_order'): 

1109 # Preferred: Use SDK's TWAP method if available 

1110 return self.exchange.twap_order( 

1111 coin=coin, 

1112 is_buy=is_buy, 

1113 sz=size, 

1114 duration_minutes=duration_minutes, 

1115 randomize=randomize 

1116 ) 

1117 else: 

1118 # Fallback: Direct API call using signing utils 

1119 from hyperliquid.utils.signing import get_timestamp_ms, sign_l1_action, float_to_wire 

1120 

1121 twap_action = { 

1122 "type": "twapOrder", 

1123 "twap": { 

1124 "a": asset_id, 

1125 "b": is_buy, 

1126 "s": float_to_wire(size), 

1127 "r": False, # not reduce-only 

1128 "m": duration_minutes, 

1129 "t": randomize 

1130 } 

1131 } 

1132 

1133 timestamp = get_timestamp_ms() 

1134 signature = sign_l1_action( 

1135 self.account, 

1136 twap_action, 

1137 self.config.HYPERLIQUID_VAULT_ADDRESS, 

1138 timestamp, 

1139 False 

1140 ) 

1141 

1142 return self.exchange._post_action(twap_action, signature, timestamp) 

1143 

1144 def _extract_twap_id(self, result: dict) -> str: 

1145 """Extract TWAP ID from exchange response.""" 

1146 # Response structure: {"status": "ok", "response": {"data": {"twapId": "..."}}} 

1147 response = result.get("response", {}) 

1148 data = response.get("data", {}) 

1149 

1150 # Try common response patterns 

1151 twap_id = data.get("twapId") or data.get("twap_id") or data.get("orderId") 

1152 

1153 if not twap_id: 

1154 # Fallback: extract from statuses array 

1155 statuses = data.get("statuses", []) 

1156 if statuses: 

1157 twap_id = statuses[0].get("twapId") 

1158 

1159 if not twap_id: 

1160 raise ValueError(f"Could not extract TWAP ID from response: {result}") 

1161 

1162 return str(twap_id) 

1163 

1164 def _refresh_twap_statuses(self): 

1165 """Poll Hyperliquid for TWAP execution updates.""" 

1166 owner = self.config.HYPERLIQUID_VAULT_ADDRESS or self.config.HYPERLIQUID_ADDRESS 

1167 

1168 for order in self.order_manager.get_open_orders(): 

1169 if order.strategy != "twap_native" or not order.twap_id: 

1170 continue 

1171 

1172 try: 

1173 # Query TWAP slice fills from Info API 

1174 fills = self.info.user_twap_slice_fills( 

1175 user=owner, 

1176 twap_id=order.twap_id 

1177 ) 

1178 

1179 if not fills: 

1180 continue 

1181 

1182 # Aggregate fill data 

1183 total_filled = sum(float(f.get("sz", 0)) for f in fills) 

1184 weighted_px = sum( 

1185 float(f.get("px", 0)) * float(f.get("sz", 0)) 

1186 for f in fills 

1187 ) 

1188 

1189 order.filled_size = total_filled 

1190 order.remaining_size = order.size - total_filled 

1191 order.twap_slices_filled = len(fills) 

1192 

1193 # Calculate average fill price and slippage 

1194 if total_filled > 0: 

1195 order.avg_fill_price = weighted_px / total_filled 

1196 

1197 if order.expected_price: 

1198 if order.side == "BUY": 

1199 slippage = (order.avg_fill_price - order.expected_price) / order.expected_price 

1200 else: 

1201 slippage = (order.expected_price - order.avg_fill_price) / order.expected_price 

1202 order.slippage_bps = slippage * 10000 

1203 

1204 # Update status 

1205 fill_pct = total_filled / order.size if order.size > 0 else 0 

1206 

1207 if fill_pct >= 0.99: # Allow 1% rounding tolerance 

1208 order.status = "filled" 

1209 elif total_filled > 0: 

1210 order.status = "partially_filled" 

1211 

1212 self.order_manager.update_order(order) 

1213 

1214 except Exception as e: 

1215 self.logger.warning(f"Failed to refresh TWAP {order.twap_id}: {e}") 

1216 

1217 def _place_stops_for_shorts(self, executed_trades: list[dict]): 

1218 """Place stop loss orders for executed short positions.""" 

1219 

1220 for trade in executed_trades: 

1221 # Only place stops for new shorts or increased shorts 

1222 if trade.get("type") not in ("open", "increase", "flip"): 

1223 continue 

1224 

1225 coin = trade["coin"] 

1226 

1227 # Check if this is a short position 

1228 position = self.get_positions().get(coin) 

1229 if not position: 

1230 continue 

1231 

1232 szi = float(position.get("szi", 0)) 

1233 if szi >= 0: # Not a short 

1234 continue 

1235 

1236 # Get entry price and size 

1237 entry_price = float(position.get("entryPx", 0)) 

1238 size = abs(szi) 

1239 

1240 # Place stop 

1241 stop = self.stop_loss_manager.place_stop_loss( 

1242 coin=coin, 

1243 side="SHORT", 

1244 entry_price=entry_price, 

1245 size=size 

1246 ) 

1247 

1248 if stop: 

1249 # Link stop to order tracking 

1250 if "order" in trade: 

1251 order = trade["order"] 

1252 order.stop_loss_order_id = stop.stop_id 

1253 order.stop_loss_trigger_price = stop.trigger_price 

1254 self.order_manager.update_order(order) 

1255 

1256 self.callbacks.info(f"✓ Stop loss placed for {coin} at ${stop.trigger_price:.2f}") 

1257 

1258 def _load_predictions(self) -> pl.DataFrame | None: 

1259 """Load predictions based on configured data source.""" 

1260 try: 

1261 if self.config.data.source == "local": 

1262 # Use local file 

1263 predictions = DataLoader.from_file( 

1264 self.config.data.path, 

1265 date_col=self.config.data.date_column, 

1266 id_col=self.config.data.asset_id_column, 

1267 pred_col=self.config.data.prediction_column, 

1268 ) 

1269 elif self.config.data.source == "crowdcent": 

1270 # Download and use CrowdCent meta model 

1271 predictions = DataLoader.from_crowdcent_api( 

1272 api_key=self.config.CROWDCENT_API_KEY, 

1273 download_path=self.config.data.path, 

1274 date_col=self.config.data.date_column, 

1275 id_col=self.config.data.asset_id_column, 

1276 pred_col=self.config.data.prediction_column, 

1277 ) 

1278 elif self.config.data.source == "numerai": 

1279 # Download and use Numerai meta model 

1280 predictions = DataLoader.from_numerai_api( 

1281 download_path=self.config.data.path, 

1282 date_col=self.config.data.date_column, 

1283 id_col=self.config.data.asset_id_column, 

1284 pred_col=self.config.data.prediction_column, 

1285 ) 

1286 else: 

1287 raise ValueError(f"Unknown data source: {self.config.data.source}") 

1288 

1289 return predictions 

1290 

1291 except Exception as e: 

1292 self.logger.error(f"Error loading predictions: {e}") 

1293 return None