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.
@@ -0,0 +1,179 @@
1
+ """Cron commands — manage scheduled tasks from the CLI.
2
+
3
+ Usage:
4
+ systemr cron list — show all scheduled jobs
5
+ systemr cron add — create a new job
6
+ systemr cron remove <id> — delete a job
7
+ systemr cron run <id> — force-run a job now
8
+ systemr cron runs <id> — show run history
9
+
10
+ No bare print() — all output via Rich console. Logging via structlog.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import click
16
+ import structlog
17
+
18
+ from neo.cron import (
19
+ CronJob,
20
+ Schedule,
21
+ ScheduleKind,
22
+ add_job,
23
+ get_job,
24
+ get_runs,
25
+ load_jobs,
26
+ remove_job,
27
+ )
28
+ from neo.display.theme import (
29
+ AMBER,
30
+ DIM,
31
+ GRAY,
32
+ GREEN,
33
+ RED,
34
+ WHITE,
35
+ ICON_ERROR,
36
+ ICON_SUCCESS,
37
+ console,
38
+ print_error,
39
+ print_success,
40
+ )
41
+
42
+ logger = structlog.get_logger(module="cron_commands")
43
+
44
+
45
+ @click.group()
46
+ def cron() -> None:
47
+ """Manage scheduled tasks."""
48
+ pass
49
+
50
+
51
+ @cron.command("list")
52
+ def cron_list() -> None:
53
+ """List all scheduled jobs."""
54
+ jobs = load_jobs()
55
+ if not jobs:
56
+ console.print(f" [{DIM}]No scheduled tasks.[/]")
57
+ console.print(f" [{DIM}]Create one: systemr cron add --name 'Morning scan' --message 'Run morning briefing'[/]")
58
+ return
59
+
60
+ console.print()
61
+ console.print(f" [{GRAY}]Scheduled tasks ({len(jobs)})[/]")
62
+ console.print()
63
+ for j in jobs:
64
+ status_icon = f"[{GREEN}]{ICON_SUCCESS}[/]" if j.enabled else f"[{DIM}]○[/]"
65
+ kind = j.schedule.kind.value
66
+ detail = ""
67
+ if j.schedule.kind == ScheduleKind.AT and j.schedule.at:
68
+ detail = j.schedule.at[:19]
69
+ elif j.schedule.kind == ScheduleKind.EVERY and j.schedule.every_seconds:
70
+ mins = j.schedule.every_seconds / 60
71
+ detail = f"every {mins:.0f}m"
72
+ elif j.schedule.kind == ScheduleKind.CRON and j.schedule.expr:
73
+ detail = j.schedule.expr
74
+ if j.schedule.tz:
75
+ detail += f" ({j.schedule.tz})"
76
+
77
+ console.print(
78
+ f" {status_icon} [{WHITE}]{j.name:<24}[/] "
79
+ f"[{DIM}]{detail:<30}[/] "
80
+ f"[{DIM}]runs: {j.run_count}[/] "
81
+ f"[{DIM}]{j.id}[/]"
82
+ )
83
+ console.print()
84
+
85
+
86
+ @cron.command()
87
+ @click.option("--name", required=True, help="Job name")
88
+ @click.option("--message", required=True, help="Prompt to send when job fires")
89
+ @click.option("--cron-expr", "expr", default=None, help="Cron expression (e.g., '0 7 * * 1-5')")
90
+ @click.option("--every", "every_seconds", type=float, default=None, help="Interval in seconds")
91
+ @click.option("--at", "at_time", default=None, help="ISO 8601 timestamp for one-shot")
92
+ @click.option("--tz", default=None, help="IANA timezone (e.g., 'America/New_York')")
93
+ @click.option("--model", default=None, help="Model override for this job")
94
+ def add(
95
+ name: str,
96
+ message: str,
97
+ expr: str | None,
98
+ every_seconds: float | None,
99
+ at_time: str | None,
100
+ tz: str | None,
101
+ model: str | None,
102
+ ) -> None:
103
+ """Create a new scheduled job."""
104
+ if expr:
105
+ schedule = Schedule(kind=ScheduleKind.CRON, expr=expr, tz=tz)
106
+ desc = f"cron: {expr}"
107
+ elif every_seconds:
108
+ schedule = Schedule(kind=ScheduleKind.EVERY, every_seconds=every_seconds)
109
+ desc = f"every {every_seconds / 60:.0f}m"
110
+ elif at_time:
111
+ schedule = Schedule(kind=ScheduleKind.AT, at=at_time)
112
+ desc = f"at: {at_time[:19]}"
113
+ else:
114
+ # Default: every 1 hour
115
+ schedule = Schedule(kind=ScheduleKind.EVERY, every_seconds=3600)
116
+ desc = "every 60m (default)"
117
+
118
+ delete_after = schedule.kind == ScheduleKind.AT
119
+ job = add_job(name, message, schedule, model=model, delete_after_run=delete_after)
120
+ print_success(f"Created: {job.name} — {desc} ({job.id})")
121
+
122
+
123
+ @cron.command()
124
+ @click.argument("job_id")
125
+ def remove(job_id: str) -> None:
126
+ """Remove a scheduled job by ID."""
127
+ if remove_job(job_id):
128
+ print_success(f"Removed: {job_id}")
129
+ else:
130
+ print_error(f"Job not found: {job_id}")
131
+
132
+
133
+ @cron.command()
134
+ @click.argument("job_id")
135
+ def run(job_id: str) -> None:
136
+ """Force-run a job now (sends its message to the backend)."""
137
+ job = get_job(job_id)
138
+ if not job:
139
+ print_error(f"Job not found: {job_id}")
140
+ return
141
+ console.print(f" [{GRAY}]Running:[/] [{WHITE}]{job.name}[/]")
142
+ console.print(f" [{DIM}]Message: {job.message[:80]}[/]")
143
+ console.print(f" [{AMBER}]Note: Manual run queued. Use `systemr chat` to see results.[/]")
144
+ logger.info("cron_manual_run", job_id=job_id, name=job.name)
145
+
146
+
147
+ @cron.command()
148
+ @click.argument("job_id")
149
+ @click.option("--limit", default=20, help="Max records to show")
150
+ def runs(job_id: str, limit: int) -> None:
151
+ """Show run history for a job."""
152
+ job = get_job(job_id)
153
+ if not job:
154
+ print_error(f"Job not found: {job_id}")
155
+ return
156
+
157
+ records = get_runs(job_id, limit=limit)
158
+ if not records:
159
+ console.print(f" [{DIM}]No runs recorded for {job.name}[/]")
160
+ return
161
+
162
+ console.print()
163
+ console.print(f" [{GRAY}]Run history: {job.name} (last {len(records)})[/]")
164
+ console.print()
165
+ for r in records:
166
+ status = r.get("status", "unknown")
167
+ if status == "ok":
168
+ icon = f"[{GREEN}]{ICON_SUCCESS}[/]"
169
+ else:
170
+ icon = f"[{RED}]{ICON_ERROR}[/]"
171
+ run_at = r.get("run_at", "")[:19]
172
+ duration = r.get("duration_seconds", 0)
173
+ msg = r.get("message", "")
174
+ console.print(
175
+ f" {icon} [{WHITE}]{run_at}[/] "
176
+ f"[{DIM}]{duration:.1f}s[/] "
177
+ f"[{DIM}]{msg[:60]}[/]" if msg else f" {icon} [{WHITE}]{run_at}[/] [{DIM}]{duration:.1f}s[/]"
178
+ )
179
+ console.print()
@@ -0,0 +1,178 @@
1
+ """Doctor command — health check and diagnostic tool.
2
+
3
+ Checks: API connectivity, auth validity, profile completeness,
4
+ rules loaded, local DB integrity, credit balance, Python version.
5
+ Exit codes: 0=healthy, 1=errors, 2=warnings.
6
+
7
+ Inspired by OpenClaw's `openclaw doctor` + `openclaw status --deep`.
8
+ No bare print() — all output via Rich console. Logging via structlog.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sqlite3
14
+ import sys
15
+
16
+ import click
17
+ import structlog
18
+
19
+ from neo.auth import AuthManager
20
+ from neo.config import (
21
+ DB_FILE,
22
+ PROFILE_FILE,
23
+ RULES_FILE,
24
+ MEMORY_DIR,
25
+ SYSTEMR_HOME,
26
+ get_api_url,
27
+ )
28
+ from neo.display.theme import (
29
+ AMBER,
30
+ DIM,
31
+ GREEN,
32
+ GRAY,
33
+ RED,
34
+ WHITE,
35
+ ICON_ERROR,
36
+ ICON_SUCCESS,
37
+ ICON_WARN,
38
+ console,
39
+ print_banner,
40
+ print_separator,
41
+ )
42
+
43
+ logger = structlog.get_logger(module="doctor")
44
+
45
+
46
+ @click.command()
47
+ def doctor() -> None:
48
+ """Run health checks on System R CLI."""
49
+ print_banner()
50
+ print_separator()
51
+ console.print(f" [{GRAY}]Running diagnostics...[/]\n")
52
+
53
+ checks: list[tuple[str, str, str]] = [] # (name, status, detail)
54
+
55
+ # 1. Python version
56
+ v = sys.version_info
57
+ if v >= (3, 11):
58
+ checks.append(("Python version", "ok", f"{v.major}.{v.minor}.{v.micro}"))
59
+ else:
60
+ checks.append(("Python version", "error", f"{v.major}.{v.minor} (need >=3.11)"))
61
+
62
+ # 2. SYSTEMR_HOME exists
63
+ if SYSTEMR_HOME.exists():
64
+ checks.append(("~/.systemr/ directory", "ok", str(SYSTEMR_HOME)))
65
+ else:
66
+ checks.append(("~/.systemr/ directory", "warn", "not created yet — run `systemr setup`"))
67
+
68
+ # 3. API connectivity
69
+ api_url = get_api_url()
70
+ try:
71
+ import httpx
72
+ resp = httpx.get(f"{api_url}/health", timeout=5.0)
73
+ if resp.status_code == 200:
74
+ checks.append(("API connectivity", "ok", api_url))
75
+ else:
76
+ checks.append(("API connectivity", "error", f"{api_url} returned {resp.status_code}"))
77
+ except Exception as exc:
78
+ checks.append(("API connectivity", "error", f"{api_url} — {exc}"))
79
+
80
+ # 4. Auth token
81
+ auth = AuthManager()
82
+ token = auth.get_token()
83
+ if token is not None:
84
+ checks.append(("Authentication", "ok", f"logged in as {token.email}"))
85
+ else:
86
+ checks.append(("Authentication", "error", "not authenticated — run `systemr login`"))
87
+
88
+ # 5. Profile
89
+ if PROFILE_FILE.exists():
90
+ content = PROFILE_FILE.read_text()
91
+ has_name = "Name:" in content and "(none)" not in content
92
+ has_risk = "Max risk per trade:" in content
93
+ if has_name and has_risk:
94
+ checks.append(("Trader profile", "ok", "PROFILE.md complete"))
95
+ else:
96
+ checks.append(("Trader profile", "warn", "PROFILE.md incomplete — run `systemr setup`"))
97
+ else:
98
+ checks.append(("Trader profile", "warn", "no PROFILE.md — run `systemr setup`"))
99
+
100
+ # 6. Rules
101
+ if RULES_FILE.exists():
102
+ content = RULES_FILE.read_text()
103
+ if "(none set)" not in content:
104
+ checks.append(("Trading rules", "ok", "RULES.md has rules defined"))
105
+ else:
106
+ checks.append(("Trading rules", "warn", "RULES.md has no rules — add via `systemr setup`"))
107
+ else:
108
+ checks.append(("Trading rules", "warn", "no RULES.md — run `systemr setup`"))
109
+
110
+ # 7. Local DB integrity
111
+ if DB_FILE.exists():
112
+ try:
113
+ conn = sqlite3.connect(str(DB_FILE))
114
+ result = conn.execute("PRAGMA integrity_check").fetchone()
115
+ conn.close()
116
+ if result and result[0] == "ok":
117
+ checks.append(("Journal database", "ok", "integrity check passed"))
118
+ else:
119
+ checks.append(("Journal database", "error", f"integrity check: {result}"))
120
+ except Exception as exc:
121
+ checks.append(("Journal database", "error", f"cannot open: {exc}"))
122
+ else:
123
+ checks.append(("Journal database", "warn", "no journal.db — will be created on first use"))
124
+
125
+ # 8. Memory directory
126
+ if MEMORY_DIR.exists():
127
+ file_count = len(list(MEMORY_DIR.glob("*.md")))
128
+ checks.append(("Memory files", "ok", f"{file_count} files in ~/.systemr/memory/"))
129
+ else:
130
+ checks.append(("Memory files", "warn", "memory directory not created yet"))
131
+
132
+ # 9. Credit balance (only if authenticated)
133
+ if token is not None:
134
+ try:
135
+ import httpx
136
+ resp = httpx.get(
137
+ f"{api_url}/v1/billing/balance",
138
+ headers={"Authorization": f"Bearer {token.access_token}"},
139
+ timeout=5.0,
140
+ )
141
+ if resp.status_code == 200:
142
+ data = resp.json()
143
+ balance = data.get("balance", data.get("credits", "unknown"))
144
+ checks.append(("Credit balance", "ok", f"{balance} credits"))
145
+ else:
146
+ checks.append(("Credit balance", "warn", f"could not fetch (HTTP {resp.status_code})"))
147
+ except Exception:
148
+ checks.append(("Credit balance", "warn", "could not fetch balance"))
149
+
150
+ # Display results
151
+ errors = 0
152
+ warnings = 0
153
+ for name, status, detail in checks:
154
+ if status == "ok":
155
+ icon = f"[{GREEN}]{ICON_SUCCESS}[/]"
156
+ elif status == "warn":
157
+ icon = f"[{AMBER}]{ICON_WARN}[/]"
158
+ warnings += 1
159
+ else:
160
+ icon = f"[{RED}]{ICON_ERROR}[/]"
161
+ errors += 1
162
+ console.print(f" {icon} [{WHITE}]{name:<20}[/] [{DIM}]{detail}[/]")
163
+
164
+ console.print()
165
+ if errors > 0:
166
+ console.print(f" [{RED}]{errors} error(s)[/] [{DIM}]found. Fix before using System R.[/]")
167
+ elif warnings > 0:
168
+ console.print(f" [{AMBER}]{warnings} warning(s)[/] [{DIM}]— System R will work but some features may be limited.[/]")
169
+ else:
170
+ console.print(f" [{GREEN}]All checks passed.[/] [{DIM}]System R is ready.[/]")
171
+ console.print()
172
+
173
+ logger.info("doctor_complete", errors=errors, warnings=warnings)
174
+
175
+ if errors > 0:
176
+ raise SystemExit(1)
177
+ if warnings > 0:
178
+ raise SystemExit(2)
@@ -0,0 +1,73 @@
1
+ """Trade evaluation command — neo eval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import click
8
+
9
+ from neo.auth import AuthManager
10
+ from neo.client import AuthRequired, NeoClient
11
+ from neo.display.formatters import fmt_r
12
+ from neo.display.tables import kv_table
13
+ from neo.display.theme import console, print_error, GREEN, GREEN_DIM
14
+
15
+
16
+ @click.command()
17
+ @click.argument("r_multiples", nargs=-1, type=float, required=True)
18
+ @click.option("--tier", type=click.Choice(["basic", "full", "pro"]), default="basic", help="Evaluation tier")
19
+ def eval(r_multiples: tuple[float, ...], tier: str) -> None:
20
+ """Evaluate a series of R-multiples.
21
+
22
+ Example: neo eval 1.5 -1.0 2.3 0.8 -1.0
23
+ """
24
+ auth = AuthManager()
25
+ client = NeoClient(auth)
26
+
27
+ payload = {
28
+ "r_multiples": list(r_multiples),
29
+ }
30
+
31
+ try:
32
+ with console.status(f"[{GREEN_DIM}]Processing...[/]"):
33
+ result = asyncio.run(client.post(f"/v1/eval/{tier}", json=payload))
34
+ except AuthRequired:
35
+ print_error("Not authenticated. Run `neo login` first.")
36
+ raise SystemExit(1)
37
+ except Exception as e:
38
+ print_error(f"Evaluation failed: {e}")
39
+ raise SystemExit(1)
40
+
41
+ # Display results
42
+ total_r = sum(r_multiples)
43
+ win_count = sum(1 for r in r_multiples if r > 0)
44
+ loss_count = sum(1 for r in r_multiples if r <= 0)
45
+ win_rate = win_count / len(r_multiples) * 100 if r_multiples else 0
46
+
47
+ display_data = {
48
+ "Trades": str(len(r_multiples)),
49
+ "Wins": str(win_count),
50
+ "Losses": str(loss_count),
51
+ "Win Rate": f"{win_rate:.1f}%",
52
+ "Total R": fmt_r(total_r),
53
+ "Avg R": fmt_r(total_r / len(r_multiples)) if r_multiples else "0.00R",
54
+ }
55
+
56
+ # Add API-provided metrics
57
+ if "expectancy" in result:
58
+ display_data["Expectancy"] = fmt_r(result["expectancy"])
59
+ if "g_score" in result:
60
+ score = result["g_score"]
61
+ style = f"bold {GREEN}" if score >= 50 else "bold red"
62
+ display_data["G-Score"] = f"[{style}]{score:.1f}[/]"
63
+ if "edge_ratio" in result:
64
+ display_data["Edge Ratio"] = f"{result['edge_ratio']:.2f}"
65
+
66
+ kv_table("TRADE EVALUATION", display_data)
67
+
68
+ # R-multiple sequence
69
+ r_display = " ".join(
70
+ f"[bold green]{fmt_r(r)}[/]" if r > 0 else f"[bold red]{fmt_r(r)}[/]"
71
+ for r in r_multiples
72
+ )
73
+ console.print(f"\n Sequence: {r_display}")
@@ -0,0 +1,197 @@
1
+ """Journal commands — local trade journal (SQLite)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+
9
+ import click
10
+
11
+ from neo.display.formatters import direction_style, fmt_price, fmt_qty, fmt_r
12
+ from neo.display.tables import data_table, kv_table
13
+ from neo.display.theme import console, print_error, print_success
14
+ from neo.store import LocalStore
15
+
16
+
17
+ @click.group()
18
+ def journal() -> None:
19
+ """Local trade journal — stored on your machine."""
20
+ pass
21
+
22
+
23
+ @journal.command()
24
+ @click.option("--symbol", required=True, help="Ticker symbol")
25
+ @click.option("--entry", required=True, type=float, help="Entry price")
26
+ @click.option("--stop", required=True, type=float, help="Stop loss price")
27
+ @click.option("--direction", type=click.Choice(["long", "short"]), default="long")
28
+ @click.option("--qty", required=True, type=int, help="Number of shares")
29
+ @click.option("--date", default=None, help="Entry date (YYYY-MM-DD, default today)")
30
+ @click.option("--notes", default=None, help="Trade notes")
31
+ @click.option("--tags", default=None, help="Comma-separated tags")
32
+ def add(symbol: str, entry: float, stop: float, direction: str, qty: int, date: str | None, notes: str | None, tags: str | None) -> None:
33
+ """Add a trade to the journal."""
34
+ store = LocalStore()
35
+ trade_id = store.add_trade(
36
+ symbol=symbol,
37
+ direction=direction,
38
+ entry_price=entry,
39
+ stop_price=stop,
40
+ quantity=qty,
41
+ entry_date=date,
42
+ notes=notes,
43
+ tags=tags,
44
+ )
45
+ print_success(f"Trade #{trade_id} added — {symbol.upper()} {direction.upper()} {qty} @ {fmt_price(entry)}")
46
+
47
+
48
+ @journal.command("list")
49
+ @click.option("--symbol", default=None, help="Filter by symbol")
50
+ @click.option("--limit", default=20, type=int, help="Max trades to show")
51
+ def list_trades(symbol: str | None, limit: int) -> None:
52
+ """List recent trades."""
53
+ store = LocalStore()
54
+ trades = store.list_trades(limit=limit, symbol=symbol)
55
+ if not trades:
56
+ console.print("[neo.dim]No trades found.[/]")
57
+ return
58
+
59
+ rows = []
60
+ for t in trades:
61
+ r_str = fmt_r(t["r_multiple"]) if t["r_multiple"] is not None else "—"
62
+ dir_style = direction_style(t["direction"])
63
+ rows.append([
64
+ str(t["id"]),
65
+ t["entry_date"][:10],
66
+ t["symbol"],
67
+ f"[{dir_style}]{t['direction'].upper()}[/]",
68
+ fmt_price(t["entry_price"]),
69
+ fmt_price(t["stop_price"]),
70
+ fmt_qty(t["quantity"]),
71
+ r_str,
72
+ ])
73
+
74
+ data_table(
75
+ "TRADE JOURNAL",
76
+ [
77
+ ("#", "dim"),
78
+ ("Date", ""),
79
+ ("Symbol", "bold"),
80
+ ("Dir", ""),
81
+ ("Entry", ""),
82
+ ("Stop", ""),
83
+ ("Qty", ""),
84
+ ("R", ""),
85
+ ],
86
+ rows,
87
+ )
88
+
89
+
90
+ @journal.command()
91
+ @click.argument("trade_id", type=int)
92
+ def show(trade_id: int) -> None:
93
+ """Show details of a specific trade."""
94
+ store = LocalStore()
95
+ t = store.get_trade(trade_id)
96
+ if not t:
97
+ print_error(f"Trade #{trade_id} not found.")
98
+ raise SystemExit(1)
99
+
100
+ data = {
101
+ "Symbol": t["symbol"],
102
+ "Direction": t["direction"].upper(),
103
+ "Entry": fmt_price(t["entry_price"]),
104
+ "Stop": fmt_price(t["stop_price"]),
105
+ "Quantity": fmt_qty(t["quantity"]),
106
+ "Entry Date": t["entry_date"],
107
+ }
108
+ if t["exit_price"] is not None:
109
+ data["Exit"] = fmt_price(t["exit_price"])
110
+ if t["exit_date"]:
111
+ data["Exit Date"] = t["exit_date"]
112
+ if t["r_multiple"] is not None:
113
+ data["R-Multiple"] = fmt_r(t["r_multiple"])
114
+ if t["notes"]:
115
+ data["Notes"] = t["notes"]
116
+ if t["tags"]:
117
+ data["Tags"] = t["tags"]
118
+
119
+ kv_table(f"TRADE #{trade_id}", data)
120
+
121
+
122
+ @journal.command()
123
+ @click.argument("trade_id", type=int)
124
+ @click.option("--exit-price", type=float, help="Exit price")
125
+ @click.option("--exit-date", type=str, help="Exit date (YYYY-MM-DD)")
126
+ @click.option("--r-multiple", type=float, help="R-multiple result")
127
+ @click.option("--notes", type=str, help="Update notes")
128
+ @click.option("--tags", type=str, help="Update tags")
129
+ def edit(trade_id: int, exit_price: float | None, exit_date: str | None, r_multiple: float | None, notes: str | None, tags: str | None) -> None:
130
+ """Edit a trade in the journal."""
131
+ store = LocalStore()
132
+ updated = store.update_trade(
133
+ trade_id,
134
+ exit_price=exit_price,
135
+ exit_date=exit_date,
136
+ r_multiple=r_multiple,
137
+ notes=notes,
138
+ tags=tags,
139
+ )
140
+ if updated:
141
+ print_success(f"Trade #{trade_id} updated.")
142
+ # Auto-save lesson for significant losses (R < -1)
143
+ if r_multiple is not None and r_multiple < -1.0:
144
+ trade = store.get_trade(trade_id)
145
+ if trade:
146
+ from neo.profile import auto_save_trade_lesson
147
+ auto_save_trade_lesson(
148
+ symbol=str(trade["symbol"]),
149
+ r_multiple=str(r_multiple),
150
+ direction=str(trade["direction"]),
151
+ entry=str(trade["entry_price"]),
152
+ exit_price=str(exit_price or trade.get("exit_price", 0)),
153
+ notes=notes or str(trade.get("notes", "")),
154
+ )
155
+ print_success("Lesson auto-saved to memory.")
156
+ else:
157
+ print_error(f"Trade #{trade_id} not found or no changes.")
158
+
159
+
160
+ @journal.command()
161
+ @click.argument("trade_id", type=int)
162
+ @click.confirmation_option(prompt="Are you sure you want to delete this trade?")
163
+ def delete(trade_id: int) -> None:
164
+ """Delete a trade from the journal."""
165
+ store = LocalStore()
166
+ if store.delete_trade(trade_id):
167
+ print_success(f"Trade #{trade_id} deleted.")
168
+ else:
169
+ print_error(f"Trade #{trade_id} not found.")
170
+
171
+
172
+ @journal.command()
173
+ @click.option("--format", "fmt", type=click.Choice(["csv", "json"]), default="csv", help="Export format")
174
+ @click.option("--output", "-o", type=click.Path(), default=None, help="Output file (default: stdout)")
175
+ def export(fmt: str, output: str | None) -> None:
176
+ """Export all trades."""
177
+ store = LocalStore()
178
+ trades = store.export_trades()
179
+ if not trades:
180
+ console.print("[neo.dim]No trades to export.[/]")
181
+ return
182
+
183
+ if fmt == "json":
184
+ text = json.dumps(trades, indent=2)
185
+ else:
186
+ buf = io.StringIO()
187
+ writer = csv.DictWriter(buf, fieldnames=trades[0].keys())
188
+ writer.writeheader()
189
+ writer.writerows(trades)
190
+ text = buf.getvalue()
191
+
192
+ if output:
193
+ with open(output, "w") as f:
194
+ f.write(text)
195
+ print_success(f"Exported {len(trades)} trades to {output}")
196
+ else:
197
+ console.print(text)