Coverage for src/cc_liquid/order.py: 91%

123 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-10-13 20:16 +0000

1"""Order tracking and lifecycle management for cc-liquid.""" 

2 

3from dataclasses import dataclass, field 

4from datetime import datetime, timezone 

5from typing import Any 

6import sqlite3 

7import json 

8 

9 

10@dataclass 

11class Order: 

12 """Unified order representation across strategies.""" 

13 

14 # Identity 

15 order_id: str 

16 timestamp: datetime 

17 coin: str 

18 side: str # "BUY" | "SELL" 

19 strategy: str # "market" | "twap_native" 

20 

21 # Sizing 

22 size: float 

23 target_value: float 

24 

25 # Status tracking 

26 status: str # "pending" | "open" | "partially_filled" | "filled" | "cancelled" | "failed" 

27 filled_size: float = 0.0 

28 remaining_size: float = 0.0 

29 

30 # Execution details 

31 avg_fill_price: float | None = None 

32 expected_price: float | None = None 

33 slippage_bps: float | None = None 

34 total_fees: float = 0.0 

35 

36 # TWAP specific 

37 twap_id: str | None = None 

38 twap_duration_minutes: int | None = None 

39 twap_slices_filled: int = 0 

40 twap_slices_total: int | None = None 

41 

42 # Stop loss tracking 

43 stop_loss_order_id: str | None = None 

44 stop_loss_trigger_price: float | None = None 

45 

46 # Raw data 

47 raw_response: dict | None = None 

48 error_message: str | None = None 

49 updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) 

50 

51 

52@dataclass 

53class OrderHistory: 

54 """Historical order performance analytics.""" 

55 orders: list[Order] = field(default_factory=list) 

56 

57 @property 

58 def total_slippage_bps(self) -> float: 

59 """Average slippage across completed orders.""" 

60 completed = [o for o in self.orders if o.status == "filled" and o.slippage_bps] 

61 return sum(o.slippage_bps for o in completed) / len(completed) if completed else 0 

62 

63 @property 

64 def total_fees(self) -> float: 

65 """Sum of all trading fees.""" 

66 return sum(o.total_fees for o in self.orders) 

67 

68 @property 

69 def fill_rate(self) -> float: 

70 """Percentage of orders that filled successfully.""" 

71 total = len([o for o in self.orders if o.status != "pending"]) 

72 filled = len([o for o in self.orders if o.status == "filled"]) 

73 return filled / total if total > 0 else 0 

74 

75 @property 

76 def avg_fill_time_minutes(self) -> float: 

77 """Average time from submission to fill (TWAP orders).""" 

78 twap_orders = [ 

79 o for o in self.orders 

80 if o.strategy == "twap_native" and o.status == "filled" and o.twap_duration_minutes 

81 ] 

82 return sum(o.twap_duration_minutes for o in twap_orders) / len(twap_orders) if twap_orders else 0 

83 

84 

85class OrderManager: 

86 """Persistent order lifecycle management with SQLite backend.""" 

87 

88 def __init__(self, db_path: str = ".cc_liquid_orders.db"): 

89 self.db_path = db_path 

90 # For :memory: databases, keep persistent connection 

91 self._persistent_conn = None 

92 if db_path == ":memory:": 

93 self._persistent_conn = sqlite3.connect(db_path) 

94 self._init_db() 

95 self._open_orders: dict[str, Order] = {} 

96 self._load_open_orders() 

97 

98 def _get_conn(self): 

99 """Get a database connection (persistent for :memory:, new for file-based).""" 

100 if self._persistent_conn: 

101 return self._persistent_conn 

102 return sqlite3.connect(self.db_path) 

103 

104 def _close_conn(self, conn): 

105 """Close connection if not persistent.""" 

106 if not self._persistent_conn: 

107 conn.close() 

108 

109 def _init_db(self): 

110 """Create SQLite schema for order tracking.""" 

111 conn = self._get_conn() 

112 conn.execute(""" 

113 CREATE TABLE IF NOT EXISTS orders ( 

114 order_id TEXT PRIMARY KEY, 

115 timestamp TEXT NOT NULL, 

116 coin TEXT NOT NULL, 

117 side TEXT NOT NULL, 

118 strategy TEXT NOT NULL, 

119 size REAL NOT NULL, 

120 target_value REAL NOT NULL, 

121 status TEXT NOT NULL, 

122 filled_size REAL DEFAULT 0, 

123 remaining_size REAL DEFAULT 0, 

124 avg_fill_price REAL, 

125 expected_price REAL, 

126 slippage_bps REAL, 

127 total_fees REAL DEFAULT 0, 

128 twap_id TEXT, 

129 twap_duration_minutes INTEGER, 

130 twap_slices_filled INTEGER DEFAULT 0, 

131 twap_slices_total INTEGER, 

132 stop_loss_order_id TEXT, 

133 stop_loss_trigger_price REAL, 

134 error_message TEXT, 

135 raw_response TEXT, 

136 updated_at TEXT NOT NULL 

137 ) 

138 """) 

139 conn.execute("CREATE INDEX IF NOT EXISTS idx_status ON orders(status)") 

140 conn.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON orders(timestamp)") 

141 conn.execute("CREATE INDEX IF NOT EXISTS idx_coin ON orders(coin)") 

142 conn.commit() 

143 self._close_conn(conn) 

144 

145 def create_order(self, order: Order) -> Order: 

146 """Create and persist a new order.""" 

147 self._open_orders[order.order_id] = order 

148 self._save_order(order) 

149 return order 

150 

151 def update_order(self, order: Order): 

152 """Update order status and persist changes.""" 

153 order.updated_at = datetime.now(timezone.utc) 

154 self._open_orders[order.order_id] = order 

155 self._save_order(order) 

156 

157 # Move to history if completed 

158 if order.status in ("filled", "cancelled", "failed"): 

159 self._open_orders.pop(order.order_id, None) 

160 

161 def get_open_orders(self) -> list[Order]: 

162 """Get all currently open orders.""" 

163 return list(self._open_orders.values()) 

164 

165 def get_order(self, order_id: str) -> Order | None: 

166 """Get a specific order by ID.""" 

167 if order_id in self._open_orders: 

168 return self._open_orders[order_id] 

169 

170 # Check DB for historical order 

171 conn = self._get_conn() 

172 cursor = conn.execute( 

173 "SELECT * FROM orders WHERE order_id = ?", 

174 (order_id,) 

175 ) 

176 row = cursor.fetchone() 

177 self._close_conn(conn) 

178 

179 return self._row_to_order(row) if row else None 

180 

181 def get_orders_by_coin(self, coin: str, open_only: bool = True) -> list[Order]: 

182 """Get all orders for a specific coin.""" 

183 if open_only: 

184 return [o for o in self._open_orders.values() if o.coin == coin] 

185 

186 conn = self._get_conn() 

187 cursor = conn.execute( 

188 "SELECT * FROM orders WHERE coin = ? ORDER BY timestamp DESC", 

189 (coin,) 

190 ) 

191 orders = [self._row_to_order(row) for row in cursor.fetchall()] 

192 self._close_conn(conn) 

193 return orders 

194 

195 def get_order_history(self, days: int = 30) -> OrderHistory: 

196 """Get order history for the past N days.""" 

197 from datetime import timedelta 

198 

199 cutoff = datetime.now(timezone.utc) - timedelta(days=days) 

200 conn = self._get_conn() 

201 cursor = conn.execute( 

202 "SELECT * FROM orders WHERE timestamp >= ? ORDER BY timestamp DESC", 

203 (cutoff.isoformat(),) 

204 ) 

205 

206 orders = [self._row_to_order(row) for row in cursor.fetchall()] 

207 self._close_conn(conn) 

208 return OrderHistory(orders=orders) 

209 

210 def _save_order(self, order: Order): 

211 """Persist order to SQLite.""" 

212 conn = self._get_conn() 

213 conn.execute(""" 

214 INSERT OR REPLACE INTO orders VALUES ( 

215 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? 

216 ) 

217 """, ( 

218 order.order_id, 

219 order.timestamp.isoformat(), 

220 order.coin, 

221 order.side, 

222 order.strategy, 

223 order.size, 

224 order.target_value, 

225 order.status, 

226 order.filled_size, 

227 order.remaining_size, 

228 order.avg_fill_price, 

229 order.expected_price, 

230 order.slippage_bps, 

231 order.total_fees, 

232 order.twap_id, 

233 order.twap_duration_minutes, 

234 order.twap_slices_filled, 

235 order.twap_slices_total, 

236 order.stop_loss_order_id, 

237 order.stop_loss_trigger_price, 

238 order.error_message, 

239 json.dumps(order.raw_response) if order.raw_response else None, 

240 order.updated_at.isoformat() 

241 )) 

242 conn.commit() 

243 self._close_conn(conn) 

244 

245 def _load_open_orders(self): 

246 """Load open orders from DB on startup.""" 

247 conn = self._get_conn() 

248 cursor = conn.execute( 

249 "SELECT * FROM orders WHERE status IN ('pending', 'open', 'partially_filled')" 

250 ) 

251 

252 for row in cursor.fetchall(): 

253 order = self._row_to_order(row) 

254 self._open_orders[order.order_id] = order 

255 

256 self._close_conn(conn) 

257 

258 def _row_to_order(self, row) -> Order: 

259 """Convert SQLite row to Order object.""" 

260 return Order( 

261 order_id=row[0], 

262 timestamp=datetime.fromisoformat(row[1]), 

263 coin=row[2], 

264 side=row[3], 

265 strategy=row[4], 

266 size=row[5], 

267 target_value=row[6], 

268 status=row[7], 

269 filled_size=row[8] or 0.0, 

270 remaining_size=row[9] or 0.0, 

271 avg_fill_price=row[10], 

272 expected_price=row[11], 

273 slippage_bps=row[12], 

274 total_fees=row[13] or 0.0, 

275 twap_id=row[14], 

276 twap_duration_minutes=row[15], 

277 twap_slices_filled=row[16] or 0, 

278 twap_slices_total=row[17], 

279 stop_loss_order_id=row[18], 

280 stop_loss_trigger_price=row[19], 

281 error_message=row[20], 

282 raw_response=json.loads(row[21]) if row[21] else None, 

283 updated_at=datetime.fromisoformat(row[22]) 

284 ) 

285