kitecli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/recorder.py ADDED
@@ -0,0 +1,321 @@
1
+ """
2
+ SQLite-backed data recording subsystem for KiteCLI.
3
+
4
+ Records order updates, position snapshots, and user commands asynchronously
5
+ on a background thread to prevent TUI rendering blocks.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import queue
11
+ import sqlite3
12
+ import threading
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger("kitecli.recorder")
18
+
19
+
20
+ class DataRecorder:
21
+ """Manages asynchronous batch persistence of trading logs to SQLite."""
22
+
23
+ def __init__(self, db_path: Path | None = None) -> None:
24
+ if db_path is None:
25
+ self.db_path = Path.home() / ".kcli" / "data.db"
26
+ else:
27
+ self.db_path = Path(db_path)
28
+
29
+ self._queue: queue.Queue = queue.Queue()
30
+ self._thread: threading.Thread | None = None
31
+ self._running = False
32
+
33
+ def start(self) -> None:
34
+ """Start the background recorder thread."""
35
+ if self._running:
36
+ return
37
+
38
+ self._running = True
39
+ self._thread = threading.Thread(
40
+ target=self._worker_loop,
41
+ name="KCLIDataRecorder",
42
+ daemon=True,
43
+ )
44
+ self._thread.start()
45
+ logger.info("DataRecorder thread started.")
46
+
47
+ def stop(self) -> None:
48
+ """Gracefully stop the background recorder thread and flush pending items."""
49
+ if not self._running:
50
+ return
51
+
52
+ self._running = False
53
+ # Send sentinel to unblock worker
54
+ self._queue.put(None)
55
+ if self._thread:
56
+ self._thread.join(timeout=5.0)
57
+ self._thread = None
58
+ logger.info("DataRecorder thread stopped.")
59
+
60
+ def enqueue_order(self, order_data: dict[str, Any], index_values: dict[str, Any], user_id: str) -> None:
61
+ """Enqueue an order update for logging."""
62
+ self._queue.put({
63
+ "type": "order",
64
+ "timestamp": datetime.now().isoformat(),
65
+ "user_id": user_id,
66
+ "order_data": order_data,
67
+ "index_values": index_values,
68
+ })
69
+
70
+ def enqueue_positions(self, positions: list[dict[str, Any]], index_values: dict[str, Any], user_id: str) -> None:
71
+ """Enqueue a list of positions for logging."""
72
+ self._queue.put({
73
+ "type": "positions",
74
+ "timestamp": datetime.now().isoformat(),
75
+ "user_id": user_id,
76
+ "positions": positions,
77
+ "index_values": index_values,
78
+ })
79
+
80
+ def enqueue_command(
81
+ self,
82
+ command_text: str,
83
+ parsed_action: str,
84
+ status: str,
85
+ result_message: str | None,
86
+ index_values: dict[str, Any],
87
+ user_id: str | None = None,
88
+ ) -> None:
89
+ """Enqueue a user command execution log."""
90
+ self._queue.put({
91
+ "type": "command",
92
+ "timestamp": datetime.now().isoformat(),
93
+ "user_id": user_id,
94
+ "command_text": command_text,
95
+ "parsed_action": parsed_action,
96
+ "status": status,
97
+ "result_message": result_message,
98
+ "index_values": index_values,
99
+ })
100
+
101
+ def _init_db(self, conn: sqlite3.Connection) -> None:
102
+ """Initialise database tables and enable WAL mode."""
103
+ conn.execute("PRAGMA journal_mode=WAL;")
104
+ conn.execute("PRAGMA synchronous=NORMAL;")
105
+
106
+ # Create orders table
107
+ conn.execute("""
108
+ CREATE TABLE IF NOT EXISTS orders (
109
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
110
+ timestamp TEXT NOT NULL,
111
+ user_id TEXT NOT NULL,
112
+ order_id TEXT NOT NULL,
113
+ parent_order_id TEXT,
114
+ tradingsymbol TEXT NOT NULL,
115
+ exchange TEXT NOT NULL,
116
+ transaction_type TEXT NOT NULL,
117
+ quantity INTEGER NOT NULL,
118
+ order_type TEXT NOT NULL,
119
+ price REAL,
120
+ trigger_price REAL,
121
+ average_price REAL,
122
+ status TEXT NOT NULL,
123
+ status_message TEXT,
124
+ nifty REAL,
125
+ sensex REAL,
126
+ vix REAL
127
+ );
128
+ """)
129
+
130
+ # Create position snapshots table
131
+ conn.execute("""
132
+ CREATE TABLE IF NOT EXISTS position_snapshots (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ timestamp TEXT NOT NULL,
135
+ user_id TEXT NOT NULL,
136
+ tradingsymbol TEXT NOT NULL,
137
+ exchange TEXT NOT NULL,
138
+ quantity INTEGER NOT NULL,
139
+ average_price REAL NOT NULL,
140
+ last_price REAL NOT NULL,
141
+ pnl REAL NOT NULL,
142
+ realised REAL NOT NULL,
143
+ unrealised REAL NOT NULL,
144
+ nifty REAL,
145
+ sensex REAL,
146
+ vix REAL
147
+ );
148
+ """)
149
+
150
+ # Create user commands table
151
+ conn.execute("""
152
+ CREATE TABLE IF NOT EXISTS user_commands (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ timestamp TEXT NOT NULL,
155
+ user_id TEXT,
156
+ command_text TEXT NOT NULL,
157
+ parsed_action TEXT NOT NULL,
158
+ status TEXT NOT NULL,
159
+ result_message TEXT,
160
+ nifty REAL,
161
+ sensex REAL,
162
+ vix REAL
163
+ );
164
+ """)
165
+ conn.commit()
166
+
167
+ def _worker_loop(self) -> None:
168
+ """Background thread worker loop that processes and batch-saves queued logs."""
169
+ try:
170
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
171
+ conn = sqlite3.connect(str(self.db_path))
172
+ self._init_db(conn)
173
+ except Exception as exc:
174
+ logger.error("Failed to initialize database: %s", exc, exc_info=True)
175
+ return
176
+
177
+ try:
178
+ while True:
179
+ # Blocks until an item is available
180
+ item = self._queue.get()
181
+ if item is None:
182
+ # Sentinel received, exit thread
183
+ self._queue.task_done()
184
+ break
185
+
186
+ # Gather any other immediately available items to write in a single batch
187
+ batch = [item]
188
+ while not self._queue.empty():
189
+ try:
190
+ next_item = self._queue.get_nowait()
191
+ if next_item is None:
192
+ # Sentinel found, we should stop after this batch
193
+ self._queue.task_done()
194
+ self._running = False
195
+ break
196
+ batch.append(next_item)
197
+ except queue.Empty:
198
+ break
199
+
200
+ # Process the gathered batch within a single transaction
201
+ try:
202
+ self._write_batch(conn, batch)
203
+ except Exception as exc:
204
+ logger.error("Error writing batch to DB: %s", exc, exc_info=True)
205
+
206
+ # Mark all processed items as done
207
+ for _ in range(len(batch)):
208
+ self._queue.task_done()
209
+
210
+ if not self._running:
211
+ break
212
+
213
+ finally:
214
+ try:
215
+ # Final flush of any items that arrived right during shutdown
216
+ final_batch = []
217
+ while not self._queue.empty():
218
+ try:
219
+ next_item = self._queue.get_nowait()
220
+ if next_item is not None:
221
+ final_batch.append(next_item)
222
+ self._queue.task_done()
223
+ except queue.Empty:
224
+ break
225
+ if final_batch:
226
+ self._write_batch(conn, final_batch)
227
+ except Exception as exc:
228
+ logger.error("Error during final database flush: %s", exc, exc_info=True)
229
+ finally:
230
+ conn.close()
231
+
232
+ def _write_batch(self, conn: sqlite3.Connection, batch: list[dict[str, Any]]) -> None:
233
+ """Write a batch of items to the database in a transaction."""
234
+ cursor = conn.cursor()
235
+ for item in batch:
236
+ item_type = item.get("type")
237
+ ts = item.get("timestamp")
238
+ user_id = item.get("user_id")
239
+ indices = item.get("index_values", {})
240
+ nifty = indices.get("nifty")
241
+ sensex = indices.get("sensex")
242
+ vix = indices.get("vix")
243
+
244
+ if item_type == "order":
245
+ o = item.get("order_data", {})
246
+ cursor.execute(
247
+ """
248
+ INSERT INTO orders (
249
+ timestamp, user_id, order_id, parent_order_id, tradingsymbol, exchange,
250
+ transaction_type, quantity, order_type, price, trigger_price,
251
+ average_price, status, status_message, nifty, sensex, vix
252
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
253
+ """,
254
+ (
255
+ ts,
256
+ user_id,
257
+ str(o.get("order_id", "")),
258
+ o.get("parent_order_id"),
259
+ o.get("tradingsymbol", ""),
260
+ o.get("exchange", ""),
261
+ o.get("transaction_type", ""),
262
+ int(o.get("quantity", 0)),
263
+ o.get("order_type", ""),
264
+ o.get("price"),
265
+ o.get("trigger_price"),
266
+ o.get("average_price"),
267
+ o.get("status", ""),
268
+ o.get("status_message"),
269
+ nifty,
270
+ sensex,
271
+ vix,
272
+ ),
273
+ )
274
+
275
+ elif item_type == "positions":
276
+ positions_list = item.get("positions", [])
277
+ for p in positions_list:
278
+ cursor.execute(
279
+ """
280
+ INSERT INTO position_snapshots (
281
+ timestamp, user_id, tradingsymbol, exchange, quantity,
282
+ average_price, last_price, pnl, realised, unrealised, nifty, sensex, vix
283
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
284
+ """,
285
+ (
286
+ ts,
287
+ user_id,
288
+ p.get("tradingsymbol", ""),
289
+ p.get("exchange", ""),
290
+ int(p.get("quantity", 0)),
291
+ float(p.get("average_price", 0.0)),
292
+ float(p.get("last_price", 0.0)),
293
+ float(p.get("pnl", 0.0)),
294
+ float(p.get("realised", 0.0)),
295
+ float(p.get("unrealised", 0.0)),
296
+ nifty,
297
+ sensex,
298
+ vix,
299
+ ),
300
+ )
301
+
302
+ elif item_type == "command":
303
+ cursor.execute(
304
+ """
305
+ INSERT INTO user_commands (
306
+ timestamp, user_id, command_text, parsed_action, status, result_message, nifty, sensex, vix
307
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
308
+ """,
309
+ (
310
+ ts,
311
+ user_id,
312
+ item.get("command_text", ""),
313
+ item.get("parsed_action", ""),
314
+ item.get("status", ""),
315
+ item.get("result_message"),
316
+ nifty,
317
+ sensex,
318
+ vix,
319
+ ),
320
+ )
321
+ conn.commit()