Coverage for src/finbot/execution/state.py: 91%
84 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
1import sqlite3
2from dataclasses import dataclass
3from datetime import UTC, datetime
4from pathlib import Path
6from finbot.execution.models import BrokerPosition, Order, OrderSide, OrderState, OrderStatus
8DEFAULT_EXECUTION_STATE_PATH = Path(".finbot/state/execution.sqlite3")
11class ExecutionStateError(RuntimeError):
12 """Base error for persistent execution state"""
15class DuplicateClientOrderError(ExecutionStateError):
16 """Raised when a client order ID has already been reserved"""
19class StoredOrderNotFoundError(ExecutionStateError):
20 """Raised when an expected persisted order doesn't exist."""
23@dataclass(frozen=True)
24class PersistedOrder:
25 order: Order
26 status: OrderStatus
27 broker_order_id: str | None
28 created_at: datetime
29 updated_at: datetime
32class ExecutionStateStore:
33 def __init__(self, path: str | Path = DEFAULT_EXECUTION_STATE_PATH):
34 self.path = Path(path)
35 self.path.parent.mkdir(parents=True, exist_ok=True)
36 self._initialize()
38 def _connect(self) -> sqlite3.Connection:
39 connection = sqlite3.connect(self.path)
40 connection.row_factory = sqlite3.Row
41 return connection
43 def _initialize(self) -> None:
44 with self._connect() as conn:
45 conn.execute(
46 """
47 create table if not exists orders
48 (
49 client_order_id text primary key,
50 broker_order_id text unique,
51 symbol text not null,
52 side text not null,
53 quantity real not null,
54 status text not null,
55 created_at text not null,
56 updated_at text not null
57 )
58 """,
59 )
60 conn.execute(
61 """
62 create table if not exists positions
63 (
64 symbol text primary key,
65 quantity real not null,
66 average_entry_price real not null,
67 updated_at text not null
68 )
69 """
70 )
72 def reserve(self, order: Order) -> PersistedOrder:
73 client_order_id = order.client_order_id
74 if client_order_id is None:
75 raise ValueError("Client order ID required for persistent execution state")
77 now = datetime.now(UTC).isoformat()
79 try:
80 with self._connect() as conn:
81 conn.execute(
82 """
83 insert into orders (client_order_id, broker_order_id, symbol, side, quantity,
84 status, created_at, updated_at)
85 values (?, null, ?, ?, ?, ?, ?, ?)
86 """,
87 (
88 client_order_id, order.symbol, order.side.value, order.quantity,
89 OrderStatus.PENDING.value, now, now,
90 ),
91 )
92 except sqlite3.IntegrityError as exc:
93 raise DuplicateClientOrderError(
94 f"Client order ID {client_order_id!r} has already been reserved.",
95 ) from exc
97 persisted = self.get_by_client_order_id(client_order_id)
98 assert persisted is not None
99 return persisted
101 def update(self, state: OrderState) -> PersistedOrder:
102 client_order_id = state.order.client_order_id
103 if client_order_id is None: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 raise ValueError("Client order ID required for persistent execution state")
106 now = datetime.now(UTC).isoformat()
108 try:
109 with self._connect() as conn:
110 cursor = conn.execute(
111 """
112 update orders
113 set broker_order_id = ?,
114 status = ?,
115 updated_at = ?
116 where client_order_id = ?
117 """,
118 (state.order_id, state.status.value, now, client_order_id),
119 )
120 except sqlite3.IntegrityError as exc:
121 raise ExecutionStateError(
122 f"Broker order ID {state.order_id!r} is already associated with another order",
123 ) from exc
125 if cursor.rowcount != 1: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true
126 raise StoredOrderNotFoundError(
127 f"Couldn't find order ID {client_order_id!r} in execution state",
128 )
130 persisted = self.get_by_client_order_id(client_order_id)
131 assert persisted is not None
132 return persisted
134 def get_by_client_order_id(self, client_order_id: str) -> PersistedOrder | None:
135 with self._connect() as conn:
136 row = conn.execute(
137 """
138 select *
139 from orders
140 where client_order_id = ?
141 """,
142 (client_order_id,),
143 ).fetchone()
145 return self._from_row(row)
147 def get_by_broker_order_id(self, broker_order_id: str) -> PersistedOrder | None:
148 with self._connect() as conn:
149 row = conn.execute(
150 """
151 select *
152 from orders
153 where broker_order_id = ?
154 """,
155 (broker_order_id,),
156 ).fetchone()
158 return self._from_row(row)
160 def list_reconcilable(self) -> list[PersistedOrder]:
161 statuses = (
162 OrderStatus.PENDING.value,
163 OrderStatus.OPEN.value,
164 OrderStatus.PARTIALLY_FILLED.value,
165 )
167 with self._connect() as conn:
168 rows = conn.execute(
169 """
170 select *
171 from orders
172 where status in (?, ?, ?)
173 order by created_at, client_order_id
174 """,
175 statuses,
176 ).fetchall()
178 return [persisted for row in rows if (persisted := self._from_row(row)) is not None]
180 @staticmethod
181 def _from_row(row: sqlite3.Row | None) -> PersistedOrder | None:
182 if row is None: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 return None
185 return PersistedOrder(
186 order=Order(
187 symbol=row["symbol"],
188 side=OrderSide(row["side"]),
189 quantity=float(row["quantity"]),
190 client_order_id=row["client_order_id"],
191 ),
192 status=OrderStatus(row["status"]),
193 broker_order_id=row["broker_order_id"],
194 created_at=datetime.fromisoformat(row["created_at"]),
195 updated_at=datetime.fromisoformat(row["updated_at"]),
196 )
198 def list_positions(self) -> list[BrokerPosition]:
199 with self._connect() as conn:
200 rows = conn.execute(
201 """
202 select symbol, quantity, average_entry_price
203 from positions
204 order by symbol
205 """,
206 ).fetchall()
208 return [
209 BrokerPosition(
210 symbol=row["symbol"],
211 quantity=float(row["quantity"]),
212 average_entry_price=float(row["average_entry_price"]),
213 )
214 for row in rows
215 ]
217 def replace_positions(self, positions: list[BrokerPosition]) -> None:
218 now = datetime.now(UTC).isoformat()
220 with self._connect() as conn:
221 conn.execute("delete from positions")
222 conn.executemany(
223 """
224 insert into positions (symbol, quantity, average_entry_price, updated_at)
225 values (?, ?, ?, ?)
226 """,
227 [
228 (position.symbol, position.quantity, position.average_entry_price, now)
229 for position in positions
230 ],
231 )