systemr-cli 1.0.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.
- neo/__init__.py +3 -0
- neo/__main__.py +5 -0
- neo/auth.py +192 -0
- neo/cli.py +205 -0
- neo/client.py +64 -0
- neo/commands/__init__.py +0 -0
- neo/commands/auth_commands.py +303 -0
- neo/commands/chat_commands.py +937 -0
- neo/commands/cron_commands.py +179 -0
- neo/commands/doctor_command.py +178 -0
- neo/commands/eval_commands.py +73 -0
- neo/commands/journal_commands.py +197 -0
- neo/commands/plan_commands.py +77 -0
- neo/commands/risk_commands.py +68 -0
- neo/commands/scan_commands.py +62 -0
- neo/commands/size_commands.py +60 -0
- neo/config.py +70 -0
- neo/confirmation.py +311 -0
- neo/credits.py +98 -0
- neo/cron.py +365 -0
- neo/display/__init__.py +0 -0
- neo/display/chat_renderer.py +127 -0
- neo/display/formatters.py +112 -0
- neo/display/tables.py +53 -0
- neo/display/theme.py +154 -0
- neo/hooks.py +170 -0
- neo/logging.py +56 -0
- neo/model_failover.py +193 -0
- neo/orchestrator.py +288 -0
- neo/profile.py +505 -0
- neo/store.py +405 -0
- neo/streaming.py +315 -0
- neo/types.py +109 -0
- systemr_cli-1.0.0.dist-info/METADATA +191 -0
- systemr_cli-1.0.0.dist-info/RECORD +37 -0
- systemr_cli-1.0.0.dist-info/WHEEL +4 -0
- systemr_cli-1.0.0.dist-info/entry_points.txt +3 -0
neo/store.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Local SQLite store for trade journal and analysis cache.
|
|
2
|
+
|
|
3
|
+
Financial values are stored as TEXT in SQLite (not REAL) to preserve
|
|
4
|
+
exact Decimal precision. The boundary conversion happens here — callers
|
|
5
|
+
always get Decimal values back from trade queries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Any, Generator, Union
|
|
15
|
+
|
|
16
|
+
from neo.config import DB_FILE, ensure_neo_home
|
|
17
|
+
from neo.types import to_decimal
|
|
18
|
+
|
|
19
|
+
# Accept both Decimal and float at the boundary (backward compat)
|
|
20
|
+
Numeric = Union[Decimal, float, int]
|
|
21
|
+
|
|
22
|
+
SCHEMA = """
|
|
23
|
+
CREATE TABLE IF NOT EXISTS trades (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
symbol TEXT NOT NULL,
|
|
26
|
+
direction TEXT NOT NULL CHECK(direction IN ('long', 'short')),
|
|
27
|
+
entry_price TEXT NOT NULL,
|
|
28
|
+
stop_price TEXT NOT NULL,
|
|
29
|
+
quantity INTEGER NOT NULL,
|
|
30
|
+
entry_date TEXT NOT NULL,
|
|
31
|
+
exit_price TEXT,
|
|
32
|
+
exit_date TEXT,
|
|
33
|
+
r_multiple TEXT,
|
|
34
|
+
notes TEXT,
|
|
35
|
+
tags TEXT,
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
updated_at TEXT NOT NULL
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS chat_sessions (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
title TEXT,
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
updated_at TEXT NOT NULL
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS chat_messages (
|
|
48
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
49
|
+
session_id INTEGER NOT NULL REFERENCES chat_sessions(id),
|
|
50
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
|
|
51
|
+
content TEXT NOT NULL,
|
|
52
|
+
created_at TEXT NOT NULL
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_trades_entry_date ON trades(entry_date);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id);
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Financial fields that are stored as TEXT and returned as Decimal
|
|
62
|
+
_DECIMAL_FIELDS = {"entry_price", "stop_price", "exit_price", "r_multiple"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _decimalize_trade(row: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
+
"""Convert TEXT financial fields back to Decimal on read.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
row: A trade dict from SQLite.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The same dict with financial fields as Decimal.
|
|
73
|
+
"""
|
|
74
|
+
for field in _DECIMAL_FIELDS:
|
|
75
|
+
val = row.get(field)
|
|
76
|
+
if val is not None:
|
|
77
|
+
row[field] = Decimal(str(val))
|
|
78
|
+
return row
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LocalStore:
|
|
82
|
+
"""SQLite-backed local data store.
|
|
83
|
+
|
|
84
|
+
Financial values (prices, R-multiples) are stored as TEXT to preserve
|
|
85
|
+
exact Decimal precision. They are converted to Decimal on read.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
ensure_neo_home()
|
|
90
|
+
self._db_path = str(DB_FILE)
|
|
91
|
+
self._init_db()
|
|
92
|
+
|
|
93
|
+
def _init_db(self) -> None:
|
|
94
|
+
"""Initialize database schema."""
|
|
95
|
+
with self._conn() as conn:
|
|
96
|
+
conn.executescript(SCHEMA)
|
|
97
|
+
|
|
98
|
+
@contextmanager
|
|
99
|
+
def _conn(self) -> Generator[sqlite3.Connection, None, None]:
|
|
100
|
+
"""Get a database connection with WAL mode and foreign keys."""
|
|
101
|
+
conn = sqlite3.connect(self._db_path)
|
|
102
|
+
conn.row_factory = sqlite3.Row
|
|
103
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
104
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
105
|
+
try:
|
|
106
|
+
yield conn
|
|
107
|
+
conn.commit()
|
|
108
|
+
finally:
|
|
109
|
+
conn.close()
|
|
110
|
+
|
|
111
|
+
def _now(self) -> str:
|
|
112
|
+
"""Return current UTC timestamp in ISO format."""
|
|
113
|
+
return datetime.now(timezone.utc).isoformat()
|
|
114
|
+
|
|
115
|
+
# --- Trades ---
|
|
116
|
+
|
|
117
|
+
def add_trade(
|
|
118
|
+
self,
|
|
119
|
+
symbol: str,
|
|
120
|
+
direction: str,
|
|
121
|
+
entry_price: Numeric,
|
|
122
|
+
stop_price: Numeric,
|
|
123
|
+
quantity: int,
|
|
124
|
+
entry_date: str | None = None,
|
|
125
|
+
notes: str | None = None,
|
|
126
|
+
tags: str | None = None,
|
|
127
|
+
) -> int:
|
|
128
|
+
"""Add a trade to the journal.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
symbol: Ticker symbol (auto-uppercased).
|
|
132
|
+
direction: "long" or "short".
|
|
133
|
+
entry_price: Entry price (Decimal or float).
|
|
134
|
+
stop_price: Stop loss price (Decimal or float).
|
|
135
|
+
quantity: Number of shares/contracts.
|
|
136
|
+
entry_date: Date string YYYY-MM-DD (default: today).
|
|
137
|
+
notes: Optional trade notes.
|
|
138
|
+
tags: Optional comma-separated tags.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The trade ID.
|
|
142
|
+
"""
|
|
143
|
+
now = self._now()
|
|
144
|
+
if entry_date is None:
|
|
145
|
+
entry_date = now[:10]
|
|
146
|
+
# Convert to string for TEXT storage (preserves Decimal precision)
|
|
147
|
+
entry_str = str(to_decimal(entry_price))
|
|
148
|
+
stop_str = str(to_decimal(stop_price))
|
|
149
|
+
with self._conn() as conn:
|
|
150
|
+
cursor = conn.execute(
|
|
151
|
+
"""INSERT INTO trades
|
|
152
|
+
(symbol, direction, entry_price, stop_price, quantity,
|
|
153
|
+
entry_date, notes, tags, created_at, updated_at)
|
|
154
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
155
|
+
(symbol.upper(), direction, entry_str, stop_str,
|
|
156
|
+
quantity, entry_date, notes, tags, now, now),
|
|
157
|
+
)
|
|
158
|
+
return cursor.lastrowid # type: ignore[return-value]
|
|
159
|
+
|
|
160
|
+
def list_trades(
|
|
161
|
+
self, limit: int = 50, symbol: str | None = None
|
|
162
|
+
) -> list[dict[str, Any]]:
|
|
163
|
+
"""List trades from the journal.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
limit: Maximum number of trades to return.
|
|
167
|
+
symbol: Optional filter by ticker symbol.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of trade dicts with Decimal financial values.
|
|
171
|
+
"""
|
|
172
|
+
with self._conn() as conn:
|
|
173
|
+
if symbol:
|
|
174
|
+
rows = conn.execute(
|
|
175
|
+
"SELECT * FROM trades WHERE symbol = ? "
|
|
176
|
+
"ORDER BY entry_date DESC LIMIT ?",
|
|
177
|
+
(symbol.upper(), limit),
|
|
178
|
+
).fetchall()
|
|
179
|
+
else:
|
|
180
|
+
rows = conn.execute(
|
|
181
|
+
"SELECT * FROM trades ORDER BY entry_date DESC LIMIT ?",
|
|
182
|
+
(limit,),
|
|
183
|
+
).fetchall()
|
|
184
|
+
return [_decimalize_trade(dict(r)) for r in rows]
|
|
185
|
+
|
|
186
|
+
def get_trade(self, trade_id: int) -> dict[str, Any] | None:
|
|
187
|
+
"""Get a single trade by ID.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
trade_id: The trade ID.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Trade dict with Decimal financial values, or None.
|
|
194
|
+
"""
|
|
195
|
+
with self._conn() as conn:
|
|
196
|
+
row = conn.execute(
|
|
197
|
+
"SELECT * FROM trades WHERE id = ?", (trade_id,)
|
|
198
|
+
).fetchone()
|
|
199
|
+
if row is None:
|
|
200
|
+
return None
|
|
201
|
+
return _decimalize_trade(dict(row))
|
|
202
|
+
|
|
203
|
+
def update_trade(self, trade_id: int, **fields: Any) -> bool:
|
|
204
|
+
"""Update fields on a trade.
|
|
205
|
+
|
|
206
|
+
Financial fields (exit_price, r_multiple, entry_price, stop_price)
|
|
207
|
+
are converted to string for TEXT storage.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
trade_id: The trade ID.
|
|
211
|
+
**fields: Fields to update (must be in the allowed set).
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
True if the trade was updated, False if not found or no changes.
|
|
215
|
+
"""
|
|
216
|
+
allowed = {
|
|
217
|
+
"exit_price", "exit_date", "r_multiple", "notes",
|
|
218
|
+
"tags", "quantity", "entry_price", "stop_price",
|
|
219
|
+
}
|
|
220
|
+
updates = {k: v for k, v in fields.items() if k in allowed and v is not None}
|
|
221
|
+
if not updates:
|
|
222
|
+
return False
|
|
223
|
+
# Convert financial fields to string for TEXT storage
|
|
224
|
+
for field in _DECIMAL_FIELDS:
|
|
225
|
+
if field in updates:
|
|
226
|
+
updates[field] = str(to_decimal(updates[field]))
|
|
227
|
+
updates["updated_at"] = self._now()
|
|
228
|
+
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
|
229
|
+
values = list(updates.values()) + [trade_id]
|
|
230
|
+
with self._conn() as conn:
|
|
231
|
+
cursor = conn.execute(
|
|
232
|
+
f"UPDATE trades SET {set_clause} WHERE id = ?", values
|
|
233
|
+
)
|
|
234
|
+
return cursor.rowcount > 0
|
|
235
|
+
|
|
236
|
+
def delete_trade(self, trade_id: int) -> bool:
|
|
237
|
+
"""Delete a trade from the journal.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
trade_id: The trade ID.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
True if the trade was deleted, False if not found.
|
|
244
|
+
"""
|
|
245
|
+
with self._conn() as conn:
|
|
246
|
+
cursor = conn.execute(
|
|
247
|
+
"DELETE FROM trades WHERE id = ?", (trade_id,)
|
|
248
|
+
)
|
|
249
|
+
return cursor.rowcount > 0
|
|
250
|
+
|
|
251
|
+
def export_trades(self) -> list[dict[str, Any]]:
|
|
252
|
+
"""Export all trades.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
List of all trade dicts with Decimal financial values.
|
|
256
|
+
"""
|
|
257
|
+
with self._conn() as conn:
|
|
258
|
+
rows = conn.execute(
|
|
259
|
+
"SELECT * FROM trades ORDER BY entry_date DESC"
|
|
260
|
+
).fetchall()
|
|
261
|
+
return [_decimalize_trade(dict(r)) for r in rows]
|
|
262
|
+
|
|
263
|
+
# --- Chat Sessions ---
|
|
264
|
+
|
|
265
|
+
def create_chat_session(self, title: str | None = None) -> int:
|
|
266
|
+
"""Create a new chat session.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
title: Optional session title.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The session ID.
|
|
273
|
+
"""
|
|
274
|
+
now = self._now()
|
|
275
|
+
with self._conn() as conn:
|
|
276
|
+
cursor = conn.execute(
|
|
277
|
+
"INSERT INTO chat_sessions (title, created_at, updated_at) "
|
|
278
|
+
"VALUES (?, ?, ?)",
|
|
279
|
+
(title, now, now),
|
|
280
|
+
)
|
|
281
|
+
return cursor.lastrowid # type: ignore[return-value]
|
|
282
|
+
|
|
283
|
+
def add_chat_message(
|
|
284
|
+
self, session_id: int, role: str, content: str
|
|
285
|
+
) -> int:
|
|
286
|
+
"""Add a message to a chat session.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
session_id: The session ID.
|
|
290
|
+
role: "user" or "assistant".
|
|
291
|
+
content: Message content.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The message ID.
|
|
295
|
+
"""
|
|
296
|
+
now = self._now()
|
|
297
|
+
with self._conn() as conn:
|
|
298
|
+
cursor = conn.execute(
|
|
299
|
+
"INSERT INTO chat_messages "
|
|
300
|
+
"(session_id, role, content, created_at) "
|
|
301
|
+
"VALUES (?, ?, ?, ?)",
|
|
302
|
+
(session_id, role, content, now),
|
|
303
|
+
)
|
|
304
|
+
conn.execute(
|
|
305
|
+
"UPDATE chat_sessions SET updated_at = ? WHERE id = ?",
|
|
306
|
+
(now, session_id),
|
|
307
|
+
)
|
|
308
|
+
return cursor.lastrowid # type: ignore[return-value]
|
|
309
|
+
|
|
310
|
+
def get_chat_history(
|
|
311
|
+
self, session_id: int
|
|
312
|
+
) -> list[dict[str, Any]]:
|
|
313
|
+
"""Get all messages in a chat session.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
session_id: The session ID.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
List of message dicts ordered by creation time.
|
|
320
|
+
"""
|
|
321
|
+
with self._conn() as conn:
|
|
322
|
+
rows = conn.execute(
|
|
323
|
+
"SELECT * FROM chat_messages WHERE session_id = ? "
|
|
324
|
+
"ORDER BY created_at",
|
|
325
|
+
(session_id,),
|
|
326
|
+
).fetchall()
|
|
327
|
+
return [dict(r) for r in rows]
|
|
328
|
+
|
|
329
|
+
def list_sessions(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
330
|
+
"""List recent chat sessions with message counts.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
limit: Maximum sessions to return.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
List of session dicts with id, title, created_at, message_count.
|
|
337
|
+
"""
|
|
338
|
+
with self._conn() as conn:
|
|
339
|
+
rows = conn.execute(
|
|
340
|
+
"SELECT s.id, s.title, s.created_at, s.updated_at, "
|
|
341
|
+
"COUNT(m.id) as message_count "
|
|
342
|
+
"FROM chat_sessions s "
|
|
343
|
+
"LEFT JOIN chat_messages m ON s.id = m.session_id "
|
|
344
|
+
"GROUP BY s.id "
|
|
345
|
+
"ORDER BY s.updated_at DESC "
|
|
346
|
+
"LIMIT ?",
|
|
347
|
+
(limit,),
|
|
348
|
+
).fetchall()
|
|
349
|
+
return [dict(r) for r in rows]
|
|
350
|
+
|
|
351
|
+
def prune_sessions(self, days: int = 30) -> int:
|
|
352
|
+
"""Delete sessions older than the specified number of days.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
days: Sessions older than this many days are deleted.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Number of sessions deleted.
|
|
359
|
+
"""
|
|
360
|
+
from datetime import timedelta
|
|
361
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
362
|
+
with self._conn() as conn:
|
|
363
|
+
# Get IDs to delete
|
|
364
|
+
rows = conn.execute(
|
|
365
|
+
"SELECT id FROM chat_sessions WHERE updated_at < ?",
|
|
366
|
+
(cutoff,),
|
|
367
|
+
).fetchall()
|
|
368
|
+
ids = [r["id"] for r in rows]
|
|
369
|
+
if ids:
|
|
370
|
+
placeholders = ",".join("?" * len(ids))
|
|
371
|
+
conn.execute(
|
|
372
|
+
f"DELETE FROM chat_messages WHERE session_id IN ({placeholders})",
|
|
373
|
+
ids,
|
|
374
|
+
)
|
|
375
|
+
conn.execute(
|
|
376
|
+
f"DELETE FROM chat_sessions WHERE id IN ({placeholders})",
|
|
377
|
+
ids,
|
|
378
|
+
)
|
|
379
|
+
return len(ids)
|
|
380
|
+
|
|
381
|
+
def export_session(self, session_id: int) -> dict[str, Any]:
|
|
382
|
+
"""Export a session and its messages as a JSON-serializable dict.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
session_id: The session ID.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Dict with session metadata and messages.
|
|
389
|
+
"""
|
|
390
|
+
with self._conn() as conn:
|
|
391
|
+
session = conn.execute(
|
|
392
|
+
"SELECT * FROM chat_sessions WHERE id = ?",
|
|
393
|
+
(session_id,),
|
|
394
|
+
).fetchone()
|
|
395
|
+
if not session:
|
|
396
|
+
return {}
|
|
397
|
+
messages = conn.execute(
|
|
398
|
+
"SELECT role, content, created_at FROM chat_messages "
|
|
399
|
+
"WHERE session_id = ? ORDER BY created_at",
|
|
400
|
+
(session_id,),
|
|
401
|
+
).fetchall()
|
|
402
|
+
return {
|
|
403
|
+
"session": dict(session),
|
|
404
|
+
"messages": [dict(m) for m in messages],
|
|
405
|
+
}
|