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/__init__.py +11 -0
- cli/advisor.py +283 -0
- cli/api_client.py +421 -0
- cli/config.py +81 -0
- cli/display.py +395 -0
- cli/kite_manager.py +1042 -0
- cli/live_session.py +3841 -0
- cli/main.py +351 -0
- cli/nli.py +140 -0
- cli/recorder.py +321 -0
- cli/telegram_bot.py +1074 -0
- kitecli-0.1.0.dist-info/METADATA +259 -0
- kitecli-0.1.0.dist-info/RECORD +16 -0
- kitecli-0.1.0.dist-info/WHEEL +5 -0
- kitecli-0.1.0.dist-info/entry_points.txt +2 -0
- kitecli-0.1.0.dist-info/top_level.txt +1 -0
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()
|