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

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

2

3 from dataclasses import dataclass, field

4 from datetime import datetime, timezone

5 from typing import Any

6 import sqlite3

7 import json

8

9

10@dataclass

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

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

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