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

1"""Stop loss management for cc-liquid positions.""" 

2 

3from dataclasses import dataclass 

4from datetime import datetime, timedelta, timezone 

5from typing import Any 

6import logging 

7 

8 

9@dataclass 

10class StopLossOrder: 

11 """Represents a stop loss order on the exchange.""" 

12 

13 stop_id: str 

14 coin: str 

15 position_side: str # "SHORT" | "LONG" 

16 trigger_price: float 

17 entry_price: float 

18 size: float 

19 

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 

25 

26 # Metadata 

27 created_at: datetime | None = None 

28 triggered_at: datetime | None = None 

29 status: str = "active" # "active" | "triggered" | "cancelled" 

30 

31 

32class StopLossManager: 

33 """Manages stop loss orders for positions.""" 

34 

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__) 

40 

41 # Track stop orders by coin 

42 self._active_stops: dict[str, StopLossOrder] = {} 

43 

44 # Cooldown tracking (coin -> timestamp) 

45 self._stop_cooldowns: dict[str, datetime] = {} 

46 

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 

51 

52 # Currently only supporting shorts 

53 if side != "SHORT": 

54 return False 

55 

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 

62 

63 return True 

64 

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.""" 

69 

70 if not self.should_place_stop(coin, side): 

71 return None 

72 

73 cfg = self.config.execution.stop_loss 

74 

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) 

82 

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 ) 

91 

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 ) 

108 

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 ) 

114 

115 return stop 

116 

117 except Exception as e: 

118 self.logger.error(f"Failed to place stop loss for {coin}: {e}") 

119 return None 

120 

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.""" 

125 

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 

129 

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 ) 

139 

140 if result.get("status") != "ok": 

141 raise Exception(f"Stop order failed: {result}") 

142 

143 # Extract order ID 

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

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

146 

147 if not statuses or "resting" not in statuses[0]: 

148 raise Exception(f"No resting order in response: {result}") 

149 

150 order_id = statuses[0]["resting"]["oid"] 

151 return str(order_id) 

152 

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 

157 

158 all_mids = self.info.all_mids() 

159 

160 for coin, stop in list(self._active_stops.items()): 

161 if not stop.is_trailing or stop.status != "active": 

162 continue 

163 

164 current_price = float(all_mids.get(coin, 0)) 

165 if current_price == 0: 

166 continue 

167 

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 

173 

174 # Calculate new trigger (lowest + distance) 

175 new_trigger = stop.lowest_price * (1 + stop.trailing_distance) 

176 

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) 

182 

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 ) 

190 

191 stop.stop_id = new_stop_id 

192 stop.trigger_price = new_trigger 

193 

194 self.logger.info( 

195 f"Updated trailing stop for {coin}: ${stop.trigger_price:.2f} " 

196 f"(lowest=${stop.lowest_price:.2f})" 

197 ) 

198 

199 except Exception as e: 

200 self.logger.error(f"Failed to update trailing stop for {coin}: {e}") 

201 

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 

206 

207 stop = self._active_stops[coin] 

208 

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 

215 

216 except Exception as e: 

217 self.logger.error(f"Failed to cancel stop for {coin}: {e}") 

218 return False 

219 

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}") 

225 

226 def check_triggered_stops(self) -> list[StopLossOrder]: 

227 """Check which stops have triggered and update status.""" 

228 triggered = [] 

229 

230 for coin, stop in list(self._active_stops.items()): 

231 if stop.status != "active": 

232 continue 

233 

234 # Query order status 

235 try: 

236 order_status = self._get_order_status(stop.stop_id) 

237 

238 if order_status == "filled": 

239 stop.status = "triggered" 

240 stop.triggered_at = datetime.now(timezone.utc) 

241 triggered.append(stop) 

242 

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 

248 

249 # Remove from active 

250 del self._active_stops[coin] 

251 

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 ) 

257 

258 except Exception as e: 

259 self.logger.error(f"Error checking stop status for {coin}: {e}") 

260 

261 return triggered 

262 

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) 

268 

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" 

277 

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 

286 

287 # Default to cancelled if not found 

288 return "cancelled" 

289 

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) 

294 

295 for asset_pos in user_state.get("assetPositions", []): 

296 position = asset_pos.get("position", {}) 

297 if position.get("coin") == coin: 

298 return position 

299 

300 return {} 

301 

302 def get_active_stops(self) -> list[StopLossOrder]: 

303 """Get all active stop orders.""" 

304 return list(self._active_stops.values()) 

305