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
« prev ^ index » next coverage.py v7.10.3, created at 2025-10-13 20:16 +0000
1"""Core trading logic for cc-liquid.
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"""
8import logging
9from dataclasses import dataclass
10import math
11from datetime import timezone, datetime, timedelta, time
12from typing import Any
13import uuid
15import polars as pl
16from eth_account.signers.local import LocalAccount
17from hyperliquid.exchange import Exchange
18from hyperliquid.info import Info
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
27logging.basicConfig(level=logging.INFO)
30@dataclass
31class AccountInfo:
32 """Structured account information."""
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
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
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
53@dataclass
54class Position:
55 """Structured position information."""
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
69@dataclass
70class PortfolioInfo:
71 """Complete portfolio information."""
73 account: AccountInfo
74 positions: list[Position]
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")
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")
86 @property
87 def net_exposure(self) -> float:
88 """Calculate net exposure (long - short)."""
89 return self.total_long_value - self.total_short_value
91 @property
92 def total_exposure(self) -> float:
93 """Calculate total exposure (long + short)."""
94 return self.total_long_value + self.total_short_value
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)
102class CCLiquid:
103 """
104 Handles all interactions with the Hyperliquid exchange.
105 """
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()
116 # Validate config for trading operations
117 self.config.validate_for_trading()
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
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 )
141 def _get_account(self) -> LocalAccount:
142 """Creates an eth_account LocalAccount object from the private key."""
143 from eth_account import Account
145 return Account.from_key(self.config.HYPERLIQUID_PRIVATE_KEY)
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)
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
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"])
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 )
193 margin_summary = user_state.get("marginSummary", {}) if user_state else {}
194 all_mids = self.info.all_mids() if user_state else {}
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 )
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
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))
235 if size == 0:
236 continue
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)
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"
251 return_pct = (
252 (unrealized_pnl / (abs(size) * entry_px) * 100) if entry_px > 0 else 0
253 )
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 )
274 return PortfolioInfo(account=account_info, positions=positions)
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.
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
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)
304 def _round_size(self, coin: str, size: float) -> tuple[float, int] | None:
305 """Round size per coin's szDecimals.
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
314 def _round_price_perp(self, coin: str, px: float) -> float:
315 """Round price according to Hyperliquid perp rules (not used for market orders).
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)
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()
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 }
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 )
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 )
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 }
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}
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)
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"])
389 return result
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}
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()
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)
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
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
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()
431 # Check for triggers
432 triggered = self.stop_loss_manager.check_triggered_stops()
434 for stop in triggered:
435 self.callbacks.warn(
436 f"⚠️ Stop loss triggered: {stop.coin} @ ${stop.trigger_price:.2f}"
437 )
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()
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 }
453 self.callbacks.info("Closing all positions to return to cash...")
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 )
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 }
471 def _get_target_positions(self, predictions: pl.DataFrame) -> dict[str, float]:
472 """Calculate target notionals using configurable weighting scheme."""
474 latest_predictions = self._get_latest_predictions(predictions)
475 tradeable_predictions = self._filter_tradeable_predictions(latest_predictions)
477 if tradeable_predictions.height == 0:
478 return {}
480 id_col = self.config.data.asset_id_column
481 pred_col = self.config.data.prediction_column
483 sorted_preds = tradeable_predictions.sort(pred_col, descending=True)
485 num_long = self.config.portfolio.num_long
486 num_short = self.config.portfolio.num_short
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 )
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 )
503 account_value = self.get_account_value()
504 target_leverage = self.config.portfolio.target_leverage
505 total_positions = len(long_assets) + len(short_assets)
507 if total_positions == 0 or account_value <= 0 or target_leverage <= 0:
508 return {}
510 self.callbacks.info(
511 f"Target gross leverage: {target_leverage:.2f}x across {total_positions} positions"
512 )
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 )
525 target_positions = {
526 asset: weight * account_value for asset, weight in weights.items()
527 }
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 )
542 return target_positions
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 )
552 def _filter_tradeable_predictions(self, predictions: pl.DataFrame) -> pl.DataFrame:
553 """Filter predictions to Hyperliquid-listed assets."""
555 universe = self.info.meta()["universe"]
556 available_assets = {
557 p["name"] for p in universe if not p.get("isDelisted", False)
558 }
560 tradeable = predictions.filter(
561 pl.col(self.config.data.asset_id_column).is_in(available_assets)
562 )
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 )
579 return tradeable
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.
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()
597 all_assets = set(target_positions.keys()) | set(current_positions.keys())
599 for asset in all_assets:
600 target_value = target_positions.get(asset, 0)
602 # Get current position details
603 current_position = current_positions.get(asset, {})
604 current_size = float(current_position.get("szi", 0))
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
618 price = float(all_mids[asset])
620 # Calculate current value with proper sign
621 # szi is positive for long, negative for short
622 current_value = current_size * price
624 # Calculate the value delta we need to achieve
625 delta_value = target_value - current_value
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
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
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
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"
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 }
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)
720 return trades, skipped_trades
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.
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}
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)
737 return sorted(trades, key=sort_key)
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.
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
753 increase_is_buy = current_value > 0
754 force_id = f"force_close:{coin}"
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 }
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
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 }
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 )
814 return [step1, step2], None
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.
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
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 []
842 successful_trades = []
843 failed_trades = []
845 # Show progress during execution
846 self.callbacks.info(f"Executing {len(trades)} trades...")
848 for i, trade in enumerate(trades, 1):
849 # Notify callback of trade start
850 self.callbacks.on_trade_start(i, len(trades), trade)
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 )
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
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
876 statuses = response.get("data", {}).get("statuses", [])
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
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"]))
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
900 self.callbacks.on_trade_fill(trade, filled_data, slippage_pct)
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"
914 self.callbacks.on_trade_fail(trade, error_msg)
915 failed_trades.append(trade)
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)
922 # Notify callback of batch completion
923 self.callbacks.on_batch_complete(successful_trades, failed_trades)
925 return successful_trades
927 def load_state(self) -> datetime | None:
928 """Public wrapper to load last rebalance timestamp."""
929 return self._load_state()
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)
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.
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)
949 hour, minute = map(int, cfg.at_time.split(":"))
950 rebalance_time = time(hour=hour, minute=minute)
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
958 next_date = last_rebalance_date.date() + timedelta(days=cfg.every_n_days)
959 return datetime.combine(next_date, rebalance_time, tzinfo=timezone.utc)
961 def _load_state(self) -> datetime | None:
962 """Load the last rebalance date from persistent state."""
963 import json
964 import os
966 state_file = ".cc_liquid_state.json"
967 if not os.path.exists(state_file):
968 return None
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
979 def _save_state(self, last_rebalance_date: datetime):
980 """Save the last rebalance date to persistent state."""
981 import json
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)
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)
991 self.callbacks.info(
992 f"Starting TWAP execution of {len(trades)} trades "
993 f"(duration: {self.config.execution.twap.duration_minutes}min)..."
994 )
996 successful_orders = []
997 failed_orders = []
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 )
1016 # Persist order
1017 self.order_manager.create_order(order)
1018 self.callbacks.on_trade_start(i, len(trades), trade)
1020 try:
1021 # Validate min notional per child
1022 self._validate_twap_sizing(trade)
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 )
1033 if result.get("status") == "ok":
1034 # Extract TWAP ID from response
1035 twap_id = self._extract_twap_id(result)
1037 # Update order with TWAP ID
1038 order.status = "open"
1039 order.twap_id = twap_id
1040 order.raw_response = result
1042 # Calculate expected slices
1043 order.twap_slices_total = (
1044 self.config.execution.twap.duration_minutes * 2 # 30s intervals
1045 )
1047 self.order_manager.update_order(order)
1049 successful_orders.append({**trade, "order": order})
1050 self.callbacks.info(
1051 f"✓ TWAP submitted: {trade['coin']} (ID: {twap_id})"
1052 )
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)
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)
1069 self.callbacks.on_batch_complete(successful_orders, failed_orders)
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 )
1078 return {"successful_trades": successful_orders, "all_trades": trades}
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
1085 child_notional = (trade["sz"] * trade["price"]) / slices
1086 min_child = self.config.execution.twap.min_child_notional
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 )
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")
1105 asset_id = asset["assetId"]
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
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 }
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 )
1142 return self.exchange._post_action(twap_action, signature, timestamp)
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", {})
1150 # Try common response patterns
1151 twap_id = data.get("twapId") or data.get("twap_id") or data.get("orderId")
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")
1159 if not twap_id:
1160 raise ValueError(f"Could not extract TWAP ID from response: {result}")
1162 return str(twap_id)
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
1168 for order in self.order_manager.get_open_orders():
1169 if order.strategy != "twap_native" or not order.twap_id:
1170 continue
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 )
1179 if not fills:
1180 continue
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 )
1189 order.filled_size = total_filled
1190 order.remaining_size = order.size - total_filled
1191 order.twap_slices_filled = len(fills)
1193 # Calculate average fill price and slippage
1194 if total_filled > 0:
1195 order.avg_fill_price = weighted_px / total_filled
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
1204 # Update status
1205 fill_pct = total_filled / order.size if order.size > 0 else 0
1207 if fill_pct >= 0.99: # Allow 1% rounding tolerance
1208 order.status = "filled"
1209 elif total_filled > 0:
1210 order.status = "partially_filled"
1212 self.order_manager.update_order(order)
1214 except Exception as e:
1215 self.logger.warning(f"Failed to refresh TWAP {order.twap_id}: {e}")
1217 def _place_stops_for_shorts(self, executed_trades: list[dict]):
1218 """Place stop loss orders for executed short positions."""
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
1225 coin = trade["coin"]
1227 # Check if this is a short position
1228 position = self.get_positions().get(coin)
1229 if not position:
1230 continue
1232 szi = float(position.get("szi", 0))
1233 if szi >= 0: # Not a short
1234 continue
1236 # Get entry price and size
1237 entry_price = float(position.get("entryPx", 0))
1238 size = abs(szi)
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 )
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)
1256 self.callbacks.info(f"✓ Stop loss placed for {coin} at ${stop.trigger_price:.2f}")
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}")
1289 return predictions
1291 except Exception as e:
1292 self.logger.error(f"Error loading predictions: {e}")
1293 return None