Coverage for src/cc_liquid/stop_loss.py: 55%
152 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"""Stop loss management for cc-liquid positions."""
3from dataclasses import dataclass
4from datetime import datetime, timedelta, timezone
5from typing import Any
6import logging
9@dataclass
10class StopLossOrder:
11 """Represents a stop loss order on the exchange."""
13 stop_id: str
14 coin: str
15 position_side: str # "SHORT" | "LONG"
16 trigger_price: float
17 entry_price: float
18 size: float
20 # Trailing stop specific
21 is_trailing: bool = False
22 trailing_distance: float | None = None
23 highest_price: float | None = None # For trailing shorts
24 lowest_price: float | None = None # For trailing longs
26 # Metadata
27 created_at: datetime | None = None
28 triggered_at: datetime | None = None
29 status: str = "active" # "active" | "triggered" | "cancelled"
32class StopLossManager:
33 """Manages stop loss orders for positions."""
35 def __init__(self, config, exchange, info, logger=None):
36 self.config = config
37 self.exchange = exchange
38 self.info = info
39 self.logger = logger or logging.getLogger(__name__)
41 # Track stop orders by coin
42 self._active_stops: dict[str, StopLossOrder] = {}
44 # Cooldown tracking (coin -> timestamp)
45 self._stop_cooldowns: dict[str, datetime] = {}
47 def should_place_stop(self, coin: str, side: str) -> bool:
48 """Check if we should place a stop loss for this position."""
49 if not self.config.execution.stop_loss.enabled:
50 return False
52 # Currently only supporting shorts
53 if side != "SHORT":
54 return False
56 # Check cooldown
57 if coin in self._stop_cooldowns:
58 cooldown_until = self._stop_cooldowns[coin]
59 if datetime.now(timezone.utc) < cooldown_until:
60 self.logger.info(f"Skipping stop for {coin} (in cooldown until {cooldown_until})")
61 return False
63 return True
65 def place_stop_loss(
66 self, coin: str, side: str, entry_price: float, size: float
67 ) -> StopLossOrder | None:
68 """Place a stop loss order for a position."""
70 if not self.should_place_stop(coin, side):
71 return None
73 cfg = self.config.execution.stop_loss
75 # Calculate trigger price
76 if side == "SHORT":
77 # Short: stop if price rises X% above entry
78 trigger_price = entry_price * (1 + cfg.short_stop_pct)
79 else:
80 # Long: stop if price falls X% below entry (future)
81 trigger_price = entry_price * (1 - cfg.short_stop_pct)
83 try:
84 # Place stop order on exchange
85 stop_id = self._submit_stop_order(
86 coin=coin,
87 trigger_price=trigger_price,
88 size=size,
89 reduce_only=True
90 )
92 # Create stop object
93 stop = StopLossOrder(
94 stop_id=stop_id,
95 coin=coin,
96 position_side=side,
97 trigger_price=trigger_price,
98 entry_price=entry_price,
99 size=size,
100 is_trailing=cfg.use_trailing_stop,
101 trailing_distance=cfg.short_stop_pct if cfg.use_trailing_stop else None,
102 # For shorts, track lowest price; for longs, track highest
103 lowest_price=entry_price if cfg.use_trailing_stop and side == "SHORT" else None,
104 highest_price=entry_price if cfg.use_trailing_stop and side == "LONG" else None,
105 created_at=datetime.now(timezone.utc),
106 status="active"
107 )
109 self._active_stops[coin] = stop
110 self.logger.info(
111 f"Placed stop loss for {coin} {side}: "
112 f"trigger=${trigger_price:.2f} (entry=${entry_price:.2f}, size={size})"
113 )
115 return stop
117 except Exception as e:
118 self.logger.error(f"Failed to place stop loss for {coin}: {e}")
119 return None
121 def _submit_stop_order(
122 self, coin: str, trigger_price: float, size: float, reduce_only: bool = True
123 ) -> str:
124 """Submit stop order to Hyperliquid exchange."""
126 # Get current position to determine if stop is buy or sell
127 position = self._get_position(coin)
128 is_buy = float(position.get("szi", 0)) < 0 # If short (negative szi), stop is a buy
130 # Submit stop order using SDK
131 result = self.exchange.order(
132 coin=coin,
133 is_buy=is_buy,
134 sz=size,
135 limit_px=trigger_price,
136 order_type={"trigger": {"triggerPx": trigger_price, "isMarket": True, "tpsl": "sl"}},
137 reduce_only=reduce_only
138 )
140 if result.get("status") != "ok":
141 raise Exception(f"Stop order failed: {result}")
143 # Extract order ID
144 response = result.get("response", {})
145 statuses = response.get("data", {}).get("statuses", [])
147 if not statuses or "resting" not in statuses[0]:
148 raise Exception(f"No resting order in response: {result}")
150 order_id = statuses[0]["resting"]["oid"]
151 return str(order_id)
153 def update_trailing_stops(self):
154 """Update trailing stop levels based on current prices."""
155 if not self.config.execution.stop_loss.use_trailing_stop:
156 return
158 all_mids = self.info.all_mids()
160 for coin, stop in list(self._active_stops.items()):
161 if not stop.is_trailing or stop.status != "active":
162 continue
164 current_price = float(all_mids.get(coin, 0))
165 if current_price == 0:
166 continue
168 # For shorts, trail downward as price falls (lock in profit)
169 if stop.position_side == "SHORT":
170 # Update lowest price seen
171 if stop.lowest_price is None or current_price < stop.lowest_price:
172 stop.lowest_price = current_price
174 # Calculate new trigger (lowest + distance)
175 new_trigger = stop.lowest_price * (1 + stop.trailing_distance)
177 # Only update if new trigger is lower (tighter stop)
178 if new_trigger < stop.trigger_price:
179 try:
180 # Cancel old stop
181 self._cancel_stop_order(stop.stop_id)
183 # Place new stop at tighter level
184 new_stop_id = self._submit_stop_order(
185 coin=coin,
186 trigger_price=new_trigger,
187 size=stop.size,
188 reduce_only=True
189 )
191 stop.stop_id = new_stop_id
192 stop.trigger_price = new_trigger
194 self.logger.info(
195 f"Updated trailing stop for {coin}: ${stop.trigger_price:.2f} "
196 f"(lowest=${stop.lowest_price:.2f})"
197 )
199 except Exception as e:
200 self.logger.error(f"Failed to update trailing stop for {coin}: {e}")
202 def cancel_stop(self, coin: str) -> bool:
203 """Cancel stop loss for a coin."""
204 if coin not in self._active_stops:
205 return False
207 stop = self._active_stops[coin]
209 try:
210 self._cancel_stop_order(stop.stop_id)
211 stop.status = "cancelled"
212 del self._active_stops[coin]
213 self.logger.info(f"Cancelled stop loss for {coin}")
214 return True
216 except Exception as e:
217 self.logger.error(f"Failed to cancel stop for {coin}: {e}")
218 return False
220 def _cancel_stop_order(self, order_id: str):
221 """Cancel a stop order on the exchange."""
222 result = self.exchange.cancel(order_id)
223 if result.get("status") != "ok":
224 raise Exception(f"Cancel failed: {result}")
226 def check_triggered_stops(self) -> list[StopLossOrder]:
227 """Check which stops have triggered and update status."""
228 triggered = []
230 for coin, stop in list(self._active_stops.items()):
231 if stop.status != "active":
232 continue
234 # Query order status
235 try:
236 order_status = self._get_order_status(stop.stop_id)
238 if order_status == "filled":
239 stop.status = "triggered"
240 stop.triggered_at = datetime.now(timezone.utc)
241 triggered.append(stop)
243 # Set cooldown
244 cooldown_duration = timedelta(
245 minutes=self.config.execution.stop_loss.cooldown_minutes
246 )
247 self._stop_cooldowns[coin] = datetime.now(timezone.utc) + cooldown_duration
249 # Remove from active
250 del self._active_stops[coin]
252 self.logger.warning(
253 f"STOP LOSS TRIGGERED: {coin} at ${stop.trigger_price:.2f} "
254 f"(entry=${stop.entry_price:.2f}, "
255 f"loss={((stop.trigger_price - stop.entry_price) / stop.entry_price * 100):.1f}%)"
256 )
258 except Exception as e:
259 self.logger.error(f"Error checking stop status for {coin}: {e}")
261 return triggered
263 def _get_order_status(self, order_id: str) -> str:
264 """Get order status from exchange (open/filled/cancelled)."""
265 # Query user state for open orders
266 owner = self.config.HYPERLIQUID_VAULT_ADDRESS or self.config.HYPERLIQUID_ADDRESS
267 user_state = self.info.user_state(owner)
269 # Check if order is still open
270 open_orders = user_state.get("assetPositions", [])
271 for asset_pos in open_orders:
272 orders = asset_pos.get("position", {}).get("openOrders", [])
273 if orders:
274 for order in orders:
275 if str(order.get("oid")) == str(order_id):
276 return "open"
278 # If not in open orders, check user fills to see if filled
279 try:
280 fills = self.info.user_fills(owner)
281 for fill in fills:
282 if str(fill.get("oid")) == str(order_id):
283 return "filled"
284 except Exception:
285 pass
287 # Default to cancelled if not found
288 return "cancelled"
290 def _get_position(self, coin: str) -> dict[str, Any]:
291 """Get current position for a coin."""
292 owner = self.config.HYPERLIQUID_VAULT_ADDRESS or self.config.HYPERLIQUID_ADDRESS
293 user_state = self.info.user_state(owner)
295 for asset_pos in user_state.get("assetPositions", []):
296 position = asset_pos.get("position", {})
297 if position.get("coin") == coin:
298 return position
300 return {}
302 def get_active_stops(self) -> list[StopLossOrder]:
303 """Get all active stop orders."""
304 return list(self._active_stops.values())