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
« 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."""
3from dataclasses import dataclass, field
4from datetime import datetime, timezone
5from typing import Any
6import sqlite3
7import json
10@dataclass
11class Order:
12 """Unified order representation across strategies."""
14 # Identity
15 order_id: str
16 timestamp: datetime
17 coin: str
18 side: str # "BUY" | "SELL"
19 strategy: str # "market" | "twap_native"
21 # Sizing
22 size: float
23 target_value: float
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
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
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
42 # Stop loss tracking
43 stop_loss_order_id: str | None = None
44 stop_loss_trigger_price: float | None = None
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))
52@dataclass
53class OrderHistory:
54 """Historical order performance analytics."""
55 orders: list[Order] = field(default_factory=list)
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
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)
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
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
85class OrderManager:
86 """Persistent order lifecycle management with SQLite backend."""
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()
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)
104 def _close_conn(self, conn):
105 """Close connection if not persistent."""
106 if not self._persistent_conn:
107 conn.close()
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)
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
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)
157 # Move to history if completed
158 if order.status in ("filled", "cancelled", "failed"):
159 self._open_orders.pop(order.order_id, None)
161 def get_open_orders(self) -> list[Order]:
162 """Get all currently open orders."""
163 return list(self._open_orders.values())
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]
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)
179 return self._row_to_order(row) if row else None
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]
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
195 def get_order_history(self, days: int = 30) -> OrderHistory:
196 """Get order history for the past N days."""
197 from datetime import timedelta
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 )
206 orders = [self._row_to_order(row) for row in cursor.fetchall()]
207 self._close_conn(conn)
208 return OrderHistory(orders=orders)
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)
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 )
252 for row in cursor.fetchall():
253 order = self._row_to_order(row)
254 self._open_orders[order.order_id] = order
256 self._close_conn(conn)
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 )