Coverage for src/cc_liquid/stop_loss.py: 55%

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

2

3 from dataclasses import dataclass

4 from datetime import datetime, timedelta, timezone

5 from typing import Any

6 import logging

7

8

9@dataclass

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

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