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
|
@@ -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)
|