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/profile.py ADDED
@@ -0,0 +1,505 @@
1
+ """Trader profile, rules, and memory management.
2
+
3
+ Implements the Claude Code memory pattern for trading:
4
+ - PROFILE.md: trader identity, risk params, broker (auto-loaded every session)
5
+ - RULES.md: hard/soft trading rules (auto-enforced by risk engine)
6
+ - memory/MEMORY.md: index + individual memory files (lessons, notes)
7
+
8
+ All financial values in profiles use string representation to avoid
9
+ float contamination — they are parsed to Decimal by consumers.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import date, datetime
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import structlog
19
+
20
+ from neo.config import (
21
+ PROFILE_FILE,
22
+ RULES_FILE,
23
+ MEMORY_DIR,
24
+ MEMORY_INDEX,
25
+ ensure_systemr_home,
26
+ )
27
+
28
+ logger = structlog.get_logger(module="profile")
29
+
30
+ # ── Templates ───────────────────────────────────────────────────────
31
+
32
+ DEFAULT_PROFILE = """\
33
+ # Trader Profile
34
+
35
+ ## Identity
36
+ - Name: {name}
37
+ - Style: {style}
38
+ - Timeframe: {timeframe}
39
+ - Experience: {experience}
40
+
41
+ ## Risk Parameters
42
+ - Max risk per trade: {risk_pct}%
43
+ - Max daily loss: {daily_loss_pct}%
44
+ - Max open positions: {max_positions}
45
+ - Max sector exposure: 30%
46
+
47
+ ## Broker
48
+ - Primary: {broker}
49
+
50
+ ## Preferences
51
+ - Default model: (auto — best available)
52
+ - Research mode: on
53
+ - Thinking display: visible
54
+ """
55
+
56
+ DEFAULT_RULES = """\
57
+ # Trading Rules
58
+
59
+ ## Hard Rules (NEVER override — System R will refuse violations)
60
+ {hard_rules}
61
+
62
+ ## Soft Rules (warn but allow override)
63
+ {soft_rules}
64
+ """
65
+
66
+ DEFAULT_MEMORY_INDEX = """\
67
+ # Trading Memory
68
+
69
+ <!-- System R auto-manages this index. Each entry links to a memory file. -->
70
+ """
71
+
72
+
73
+ # ── Read operations ─────────────────────────────────────────────────
74
+
75
+ def profile_exists() -> bool:
76
+ """Check if a trader profile has been created.
77
+
78
+ Returns:
79
+ True if PROFILE.md exists.
80
+ """
81
+ return PROFILE_FILE.exists()
82
+
83
+
84
+ def load_profile() -> str:
85
+ """Load the trader profile as a string for LLM context injection.
86
+
87
+ Returns:
88
+ PROFILE.md content, or empty string if not found.
89
+ """
90
+ if not PROFILE_FILE.exists():
91
+ return ""
92
+ return PROFILE_FILE.read_text()
93
+
94
+
95
+ def load_rules() -> str:
96
+ """Load trading rules as a string for LLM context injection.
97
+
98
+ Returns:
99
+ RULES.md content, or empty string if not found.
100
+ """
101
+ if not RULES_FILE.exists():
102
+ return ""
103
+ return RULES_FILE.read_text()
104
+
105
+
106
+ def load_memory_index() -> str:
107
+ """Load the memory index for LLM context injection.
108
+
109
+ Returns:
110
+ MEMORY.md content, or empty string if not found.
111
+ """
112
+ if not MEMORY_INDEX.exists():
113
+ return ""
114
+ return MEMORY_INDEX.read_text()
115
+
116
+
117
+ # ── Write operations ────────────────────────────────────────────────
118
+
119
+ def save_profile(
120
+ name: str,
121
+ style: str,
122
+ timeframe: str,
123
+ experience: str,
124
+ risk_pct: str,
125
+ daily_loss_pct: str,
126
+ max_positions: int,
127
+ broker: str,
128
+ ) -> Path:
129
+ """Create the trader profile from onboarding answers.
130
+
131
+ Risk values are stored as strings to avoid float contamination.
132
+ Consumers parse them to Decimal when needed.
133
+
134
+ Args:
135
+ name: Trader's name.
136
+ style: Trading style description.
137
+ timeframe: Typical holding period.
138
+ experience: Skill level.
139
+ risk_pct: Max risk per trade as string (e.g., "2").
140
+ daily_loss_pct: Max daily loss as string (e.g., "5").
141
+ max_positions: Max concurrent positions.
142
+ broker: Primary broker name.
143
+
144
+ Returns:
145
+ Path to the created PROFILE.md.
146
+ """
147
+ ensure_systemr_home()
148
+ content = DEFAULT_PROFILE.format(
149
+ name=name,
150
+ style=style,
151
+ timeframe=timeframe,
152
+ experience=experience,
153
+ risk_pct=risk_pct,
154
+ daily_loss_pct=daily_loss_pct,
155
+ max_positions=max_positions,
156
+ broker=broker,
157
+ )
158
+ PROFILE_FILE.write_text(content)
159
+ logger.info("profile_saved", path=str(PROFILE_FILE))
160
+ return PROFILE_FILE
161
+
162
+
163
+ def save_rules(hard_rules: list[str], soft_rules: list[str]) -> Path:
164
+ """Create the trading rules file.
165
+
166
+ Args:
167
+ hard_rules: List of hard rules (never overridable).
168
+ soft_rules: List of soft rules (warn but allow override).
169
+
170
+ Returns:
171
+ Path to the created RULES.md.
172
+ """
173
+ ensure_systemr_home()
174
+ hard = "\n".join(f"- {r}" for r in hard_rules) if hard_rules else "- (none set)"
175
+ soft = "\n".join(f"- {r}" for r in soft_rules) if soft_rules else "- (none set)"
176
+ content = DEFAULT_RULES.format(hard_rules=hard, soft_rules=soft)
177
+ RULES_FILE.write_text(content)
178
+ logger.info("rules_saved", path=str(RULES_FILE), hard=len(hard_rules), soft=len(soft_rules))
179
+ return RULES_FILE
180
+
181
+
182
+ def init_memory() -> Path:
183
+ """Initialize the memory directory and index file.
184
+
185
+ Returns:
186
+ Path to the MEMORY.md index file.
187
+ """
188
+ ensure_systemr_home()
189
+ if not MEMORY_INDEX.exists():
190
+ MEMORY_INDEX.write_text(DEFAULT_MEMORY_INDEX)
191
+ logger.info("memory_initialized", path=str(MEMORY_INDEX))
192
+ return MEMORY_INDEX
193
+
194
+
195
+ def save_memory(filename: str, content: str, description: str) -> Path:
196
+ """Save a memory file and update the index.
197
+
198
+ Args:
199
+ filename: Name of the memory file (e.g., "tsla_notes.md").
200
+ content: Full markdown content of the memory.
201
+ description: One-line description for the index entry.
202
+
203
+ Returns:
204
+ Path to the saved memory file.
205
+ """
206
+ ensure_systemr_home()
207
+ filepath = MEMORY_DIR / filename
208
+ filepath.write_text(content)
209
+
210
+ # Append to index if not already there
211
+ index_text = MEMORY_INDEX.read_text() if MEMORY_INDEX.exists() else DEFAULT_MEMORY_INDEX
212
+ if filename not in index_text:
213
+ entry = f"- [{description}]({filename})\n"
214
+ index_text += entry
215
+ MEMORY_INDEX.write_text(index_text)
216
+
217
+ logger.info("memory_saved", filename=filename, description=description[:50])
218
+ return filepath
219
+
220
+
221
+ def init_all() -> None:
222
+ """Initialize the full ~/.systemr/ directory structure.
223
+
224
+ Creates SYSTEMR_HOME, memory/, sessions/, and MEMORY.md index.
225
+ """
226
+ ensure_systemr_home()
227
+ init_memory()
228
+
229
+
230
+ # ── Auto-memory triggers ────────────────────────────────────────────
231
+
232
+ # ── Standing orders ────────────────────────────────────────────────
233
+
234
+ STANDING_ORDER_TEMPLATE = """\
235
+ ### {name}
236
+ - **Scope**: {scope}
237
+ - **Trigger**: {trigger}
238
+ - **Approval**: {approval}
239
+ - **Escalation**: {escalation}
240
+ - **Action**: {action}
241
+ """
242
+
243
+
244
+ def load_standing_orders() -> str:
245
+ """Load standing orders from RULES.md.
246
+
247
+ Standing orders are sections in RULES.md under '## Standing Orders'.
248
+ They define programs the agent can execute autonomously within boundaries.
249
+
250
+ Returns:
251
+ Standing orders text, or empty string if none defined.
252
+ """
253
+ rules = load_rules()
254
+ if not rules:
255
+ return ""
256
+
257
+ # Extract standing orders section
258
+ marker = "## Standing Orders"
259
+ if marker not in rules:
260
+ return ""
261
+
262
+ idx = rules.index(marker)
263
+ return rules[idx:]
264
+
265
+
266
+ def save_standing_order(
267
+ name: str,
268
+ scope: str,
269
+ trigger: str,
270
+ approval: str,
271
+ escalation: str,
272
+ action: str,
273
+ ) -> None:
274
+ """Append a standing order to RULES.md.
275
+
276
+ Args:
277
+ name: Order name (e.g., "Morning Scan").
278
+ scope: What the agent is authorized to do.
279
+ trigger: When it fires (schedule, event, condition).
280
+ approval: What requires human sign-off.
281
+ escalation: When to ask for help.
282
+ action: What to execute.
283
+ """
284
+ ensure_systemr_home()
285
+ if not RULES_FILE.exists():
286
+ RULES_FILE.write_text("# Trading Rules\n\n## Standing Orders\n")
287
+
288
+ content = RULES_FILE.read_text()
289
+ if "## Standing Orders" not in content:
290
+ content += "\n\n## Standing Orders\n"
291
+
292
+ order = STANDING_ORDER_TEMPLATE.format(
293
+ name=name, scope=scope, trigger=trigger,
294
+ approval=approval, escalation=escalation, action=action,
295
+ )
296
+ content += "\n" + order
297
+ RULES_FILE.write_text(content)
298
+ logger.info("standing_order_saved", name=name, trigger=trigger)
299
+
300
+
301
+ # ── Daily trading log ──────────────────────────────────────────────
302
+
303
+ def _daily_log_path(for_date: Optional[date] = None) -> Path:
304
+ """Return path for a daily trading log file.
305
+
306
+ Args:
307
+ for_date: Date for the log. Defaults to today.
308
+
309
+ Returns:
310
+ Path to ~/.systemr/memory/YYYY-MM-DD.md
311
+ """
312
+ d = for_date or date.today()
313
+ return MEMORY_DIR / f"{d.isoformat()}.md"
314
+
315
+
316
+ def append_daily_log(entry: str, category: str = "note") -> Path:
317
+ """Append an entry to today's daily trading log.
318
+
319
+ Creates the file if it doesn't exist. Each entry is timestamped
320
+ and categorized for easy scanning.
321
+
322
+ Args:
323
+ entry: The text to log.
324
+ category: Entry category (trade, violation, lesson, note, flush).
325
+
326
+ Returns:
327
+ Path to the daily log file.
328
+ """
329
+ ensure_systemr_home()
330
+ filepath = _daily_log_path()
331
+ now = datetime.now()
332
+
333
+ if not filepath.exists():
334
+ header = f"# Trading Log — {date.today().isoformat()}\n\n"
335
+ filepath.write_text(header)
336
+
337
+ line = f"- **{now.strftime('%H:%M')}** [{category}] {entry}\n"
338
+ with open(filepath, "a") as f:
339
+ f.write(line)
340
+
341
+ logger.info("daily_log_appended", category=category, entry=entry[:50])
342
+ return filepath
343
+
344
+
345
+ def load_daily_context() -> str:
346
+ """Load today's and yesterday's daily logs for LLM context injection.
347
+
348
+ Matches the OpenClaw pattern: load current + previous day to give
349
+ the model awareness of recent trading activity.
350
+
351
+ Returns:
352
+ Combined log content, or empty string if no logs exist.
353
+ """
354
+ from datetime import timedelta
355
+ parts = []
356
+
357
+ today = date.today()
358
+ yesterday = today - timedelta(days=1)
359
+
360
+ for d in (yesterday, today):
361
+ filepath = _daily_log_path(d)
362
+ if filepath.exists():
363
+ parts.append(filepath.read_text())
364
+
365
+ return "\n".join(parts)
366
+
367
+
368
+ def search_memory(query: str, max_results: int = 10) -> list[dict[str, str]]:
369
+ """Search across all memory files for matching text.
370
+
371
+ Simple case-insensitive text search over ~/.systemr/memory/*.md.
372
+ Returns matching lines with file context.
373
+
374
+ Args:
375
+ query: Search text.
376
+ max_results: Maximum results to return.
377
+
378
+ Returns:
379
+ List of dicts with 'file', 'line', 'content' keys.
380
+ """
381
+ if not MEMORY_DIR.exists():
382
+ return []
383
+
384
+ query_lower = query.lower()
385
+ results: list[dict[str, str]] = []
386
+
387
+ for filepath in sorted(MEMORY_DIR.glob("*.md"), reverse=True):
388
+ try:
389
+ text = filepath.read_text()
390
+ for i, line in enumerate(text.splitlines(), 1):
391
+ if query_lower in line.lower():
392
+ results.append({
393
+ "file": filepath.name,
394
+ "line": str(i),
395
+ "content": line.strip(),
396
+ })
397
+ if len(results) >= max_results:
398
+ return results
399
+ except Exception:
400
+ continue
401
+
402
+ return results
403
+
404
+
405
+ # ── Auto-memory triggers ────────────────────────────────────────────
406
+
407
+ def auto_save_trade_lesson(
408
+ symbol: str,
409
+ r_multiple: str,
410
+ direction: str,
411
+ entry: str,
412
+ exit_price: str,
413
+ notes: str = "",
414
+ ) -> Path | None:
415
+ """Auto-save a memory after a significant losing trade.
416
+
417
+ Only saves for losses where R < -1.0 to avoid memory bloat.
418
+ All financial values accepted as strings (Decimal-safe).
419
+
420
+ Args:
421
+ symbol: Ticker symbol.
422
+ r_multiple: R-multiple as string (e.g., "-2.30").
423
+ direction: "long" or "short".
424
+ entry: Entry price as string.
425
+ exit_price: Exit price as string.
426
+ notes: Optional notes about the trade.
427
+
428
+ Returns:
429
+ Path to saved memory file, or None if R >= -1.0.
430
+ """
431
+ from decimal import Decimal
432
+ r_val = Decimal(str(r_multiple))
433
+ if r_val >= Decimal("-1"):
434
+ return None
435
+
436
+ today = date.today().isoformat()
437
+ filename = f"lesson_{symbol.lower()}_{today}.md"
438
+ content = (
439
+ f"# Trade Lesson — {symbol.upper()} ({today})\n\n"
440
+ f"- Direction: {direction}\n"
441
+ f"- Entry: ${entry}\n"
442
+ f"- Exit: ${exit_price}\n"
443
+ f"- R-Multiple: {r_multiple}R\n"
444
+ f"\n## What happened\n{notes or '(no notes)'}\n"
445
+ f"\n## Lesson\nReview this trade. What could have been done differently?\n"
446
+ )
447
+ logger.info("auto_save_trade_lesson", symbol=symbol, r_multiple=r_multiple)
448
+ append_daily_log(
449
+ f"{symbol} {direction} {r_multiple}R — entry ${entry} exit ${exit_price}",
450
+ category="lesson",
451
+ )
452
+ return save_memory(filename, content, f"{symbol} loss {r_multiple}R — {today}")
453
+
454
+
455
+ def auto_save_rule_violation(rule: str, action_attempted: str) -> Path:
456
+ """Auto-save when System R blocks a rule violation.
457
+
458
+ Args:
459
+ rule: The rule that was violated.
460
+ action_attempted: Description of what was blocked.
461
+
462
+ Returns:
463
+ Path to the saved memory file.
464
+ """
465
+ today = date.today().isoformat()
466
+ filename = f"blocked_{today}.md"
467
+
468
+ filepath = MEMORY_DIR / filename
469
+ if filepath.exists():
470
+ existing = filepath.read_text()
471
+ existing += f"\n- **{action_attempted}** blocked by: {rule}\n"
472
+ filepath.write_text(existing)
473
+ else:
474
+ content = (
475
+ f"# Rule Violations Blocked — {today}\n\n"
476
+ f"- **{action_attempted}** blocked by: {rule}\n"
477
+ )
478
+ save_memory(filename, content, f"Blocked violations — {today}")
479
+
480
+ logger.info("auto_save_rule_violation", rule=rule, action=action_attempted)
481
+ append_daily_log(f"BLOCKED: {action_attempted} — {rule}", category="violation")
482
+ return filepath
483
+
484
+
485
+ def auto_save_explicit(user_text: str, context: str = "") -> Path:
486
+ """Save when trader says 'remember this' — explicit memory request.
487
+
488
+ Args:
489
+ user_text: What the trader wants remembered.
490
+ context: Optional conversation context.
491
+
492
+ Returns:
493
+ Path to the saved memory file.
494
+ """
495
+ now = datetime.now()
496
+ filename = f"note_{now.strftime('%Y%m%d_%H%M%S')}.md"
497
+ content = (
498
+ f"# Note — {now.strftime('%Y-%m-%d %H:%M')}\n\n"
499
+ f"{user_text}\n"
500
+ )
501
+ if context:
502
+ content += f"\n## Context\n{context}\n"
503
+
504
+ logger.info("auto_save_explicit", text=user_text[:50])
505
+ return save_memory(filename, content, user_text[:60])