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

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

2

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

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

5 Users must comply with all Hyperliquid terms of service.

6"""

7

8 import logging

9 from dataclasses import dataclass

10 import math

11 from datetime import timezone, datetime, timedelta, time

12 from typing import Any

13 import uuid

14

15 import polars as pl

16 from eth_account.signers.local import LocalAccount

17 from hyperliquid.exchange import Exchange

18 from hyperliquid.info import Info

19

20 from .callbacks import CCLiquidCallbacks, NoOpCallbacks

21 from .config import Config

22 from .data_loader import DataLoader

23 from .portfolio import weights_from_ranks

24 from .order import Order, OrderManager, OrderHistory

25 from .stop_loss import StopLossManager, StopLossOrder

26

27 logging.basicConfig(level=logging.INFO)

28

29

30@dataclass

31 class 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

54 class 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

70 class 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

102 class 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