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,77 @@
|
|
|
1
|
+
"""Plan command — neo plan (trade plan generation)."""
|
|
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_price, fmt_pct, fmt_risk, fmt_qty
|
|
12
|
+
from neo.display.tables import kv_table
|
|
13
|
+
from neo.display.theme import console, print_error, GREEN
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@click.option("--ticker", required=True, help="Ticker symbol")
|
|
18
|
+
@click.option("--equity", required=True, type=float, help="Account equity ($)")
|
|
19
|
+
@click.option("--risk-pct", type=float, default=1.0, help="Risk per trade (%, default 1.0)")
|
|
20
|
+
@click.option("--direction", type=click.Choice(["long", "short"]), default=None, help="Force direction (auto-detect if omitted)")
|
|
21
|
+
def plan(ticker: str, equity: float, risk_pct: float, direction: str | None) -> None:
|
|
22
|
+
"""Generate a complete trade plan for a ticker."""
|
|
23
|
+
auth = AuthManager()
|
|
24
|
+
client = NeoClient(auth)
|
|
25
|
+
|
|
26
|
+
payload: dict = {
|
|
27
|
+
"ticker": ticker.upper(),
|
|
28
|
+
"equity": equity,
|
|
29
|
+
"risk_percent": risk_pct,
|
|
30
|
+
}
|
|
31
|
+
if direction:
|
|
32
|
+
payload["direction"] = direction
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
with console.status(f"[neo.green]Building plan for {ticker.upper()}...[/]"):
|
|
36
|
+
result = asyncio.run(client.post("/api/plans", json=payload))
|
|
37
|
+
except AuthRequired:
|
|
38
|
+
print_error("Not authenticated. Run `neo login` first.")
|
|
39
|
+
raise SystemExit(1)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
print_error(f"Plan generation failed: {e}")
|
|
42
|
+
raise SystemExit(1)
|
|
43
|
+
|
|
44
|
+
# Display the plan
|
|
45
|
+
data: dict[str, str] = {"Ticker": ticker.upper()}
|
|
46
|
+
|
|
47
|
+
dir_val = result.get("direction", direction or "—")
|
|
48
|
+
dir_style = f"bold {GREEN}" if dir_val == "long" else "bold red"
|
|
49
|
+
data["Direction"] = f"[{dir_style}]{dir_val.upper()}[/]"
|
|
50
|
+
|
|
51
|
+
if "entry" in result:
|
|
52
|
+
data["Entry"] = fmt_price(result["entry"])
|
|
53
|
+
if "stop" in result:
|
|
54
|
+
data["Stop"] = fmt_price(result["stop"])
|
|
55
|
+
if "target" in result:
|
|
56
|
+
data["Target"] = fmt_price(result["target"])
|
|
57
|
+
if "risk_per_share" in result:
|
|
58
|
+
data["Risk/Share"] = fmt_price(result["risk_per_share"])
|
|
59
|
+
if "risk_amount" in result:
|
|
60
|
+
data["Risk Amount"] = fmt_risk(result["risk_amount"])
|
|
61
|
+
if "shares" in result:
|
|
62
|
+
data["Shares"] = fmt_qty(result["shares"])
|
|
63
|
+
if "position_value" in result:
|
|
64
|
+
data["Position Value"] = fmt_price(result["position_value"])
|
|
65
|
+
if "reward_risk" in result:
|
|
66
|
+
data["Reward:Risk"] = f"{result['reward_risk']:.2f}"
|
|
67
|
+
if "confidence" in result:
|
|
68
|
+
data["Confidence"] = fmt_pct(result["confidence"])
|
|
69
|
+
|
|
70
|
+
kv_table(f"TRADE PLAN — {ticker.upper()}", data)
|
|
71
|
+
|
|
72
|
+
# Show rationale if provided
|
|
73
|
+
if "rationale" in result:
|
|
74
|
+
console.print(f"\n[bold]Rationale:[/] {result['rationale']}")
|
|
75
|
+
if "notes" in result:
|
|
76
|
+
for note in result["notes"]:
|
|
77
|
+
console.print(f" [neo.dim]•[/] {note}")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Risk check command — neo risk."""
|
|
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_price, fmt_pct, fmt_risk
|
|
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.option("--symbol", required=True, help="Ticker symbol")
|
|
18
|
+
@click.option("--entry", required=True, type=float, help="Entry price")
|
|
19
|
+
@click.option("--stop", required=True, type=float, help="Stop loss price")
|
|
20
|
+
@click.option("--qty", required=True, type=int, help="Number of shares")
|
|
21
|
+
@click.option("--equity", required=True, type=float, help="Account equity ($)")
|
|
22
|
+
def risk(symbol: str, entry: float, stop: float, qty: int, equity: float) -> None:
|
|
23
|
+
"""Check risk metrics for a trade."""
|
|
24
|
+
auth = AuthManager()
|
|
25
|
+
client = NeoClient(auth)
|
|
26
|
+
|
|
27
|
+
payload = {
|
|
28
|
+
"symbol": symbol.upper(),
|
|
29
|
+
"entry_price": entry,
|
|
30
|
+
"stop_price": stop,
|
|
31
|
+
"quantity": qty,
|
|
32
|
+
"equity": equity,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with console.status(f"[{GREEN_DIM}]Analyzing risk...[/]"):
|
|
37
|
+
result = asyncio.run(client.post("/v1/risk/check", json=payload))
|
|
38
|
+
except AuthRequired:
|
|
39
|
+
print_error("Not authenticated. Run `neo login` first.")
|
|
40
|
+
raise SystemExit(1)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
print_error(f"Risk check failed: {e}")
|
|
43
|
+
raise SystemExit(1)
|
|
44
|
+
|
|
45
|
+
risk_per_share = abs(entry - stop)
|
|
46
|
+
total_risk = risk_per_share * qty
|
|
47
|
+
risk_pct = (total_risk / equity * 100) if equity else 0
|
|
48
|
+
position_value = entry * qty
|
|
49
|
+
|
|
50
|
+
status = result.get("status", "PASS" if risk_pct <= 2.0 else "FAIL")
|
|
51
|
+
status_style = f"bold {GREEN}" if status == "PASS" else "bold red"
|
|
52
|
+
|
|
53
|
+
kv_table(f"RISK CHECK — {symbol.upper()}", {
|
|
54
|
+
"Status": f"[{status_style}]{status}[/]",
|
|
55
|
+
"Entry": fmt_price(entry),
|
|
56
|
+
"Stop": fmt_price(stop),
|
|
57
|
+
"Quantity": str(qty),
|
|
58
|
+
"Risk/Share": fmt_price(risk_per_share),
|
|
59
|
+
"Total Risk": fmt_risk(total_risk),
|
|
60
|
+
"Risk % of Equity": fmt_pct(risk_pct),
|
|
61
|
+
"Position Value": fmt_price(position_value),
|
|
62
|
+
"Position % of Equity": fmt_pct(position_value / equity * 100 if equity else 0),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
# Show any warnings from the API
|
|
66
|
+
warnings = result.get("warnings", [])
|
|
67
|
+
for w in warnings:
|
|
68
|
+
console.print(f" [neo.warn]![/] {w}")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Scan command — neo scan SYMBOL."""
|
|
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_price, fmt_pct
|
|
12
|
+
from neo.display.tables import kv_table
|
|
13
|
+
from neo.display.theme import console, print_error
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@click.argument("symbol")
|
|
18
|
+
@click.option("--scanner", default="default", help="Scanner ID to use")
|
|
19
|
+
def scan(symbol: str, scanner: str) -> None:
|
|
20
|
+
"""Scan a symbol for trading signals."""
|
|
21
|
+
auth = AuthManager()
|
|
22
|
+
client = NeoClient(auth)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
with console.status(f"[neo.green]Scanning {symbol.upper()}...[/]"):
|
|
26
|
+
result = asyncio.run(
|
|
27
|
+
client.post(
|
|
28
|
+
f"/api/scanners/{scanner}/scan",
|
|
29
|
+
json={"symbol": symbol.upper()},
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
except AuthRequired:
|
|
33
|
+
print_error("Not authenticated. Run `neo login` first.")
|
|
34
|
+
raise SystemExit(1)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print_error(f"Scan failed: {e}")
|
|
37
|
+
raise SystemExit(1)
|
|
38
|
+
|
|
39
|
+
# Build display from response
|
|
40
|
+
data: dict[str, str] = {"Symbol": symbol.upper()}
|
|
41
|
+
|
|
42
|
+
if "price" in result:
|
|
43
|
+
data["Price"] = fmt_price(result["price"])
|
|
44
|
+
if "change_pct" in result:
|
|
45
|
+
pct = result["change_pct"]
|
|
46
|
+
style = "bold green" if pct >= 0 else "bold red"
|
|
47
|
+
data["Change"] = f"[{style}]{fmt_pct(pct)}[/]"
|
|
48
|
+
if "trend" in result:
|
|
49
|
+
data["Trend"] = result["trend"]
|
|
50
|
+
if "signals" in result:
|
|
51
|
+
for sig in result["signals"]:
|
|
52
|
+
name = sig.get("name", "Signal")
|
|
53
|
+
value = sig.get("value", sig.get("direction", "—"))
|
|
54
|
+
data[name] = str(value)
|
|
55
|
+
if "support" in result:
|
|
56
|
+
data["Support"] = fmt_price(result["support"])
|
|
57
|
+
if "resistance" in result:
|
|
58
|
+
data["Resistance"] = fmt_price(result["resistance"])
|
|
59
|
+
if "score" in result:
|
|
60
|
+
data["Score"] = str(result["score"])
|
|
61
|
+
|
|
62
|
+
kv_table(f"SCAN — {symbol.upper()}", data)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Position sizing command — neo size."""
|
|
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_price, fmt_pct, fmt_qty, fmt_risk
|
|
12
|
+
from neo.display.tables import kv_table
|
|
13
|
+
from neo.display.theme import GREEN_DIM, console, print_error
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@click.option("--equity", required=True, type=float, help="Account equity ($)")
|
|
18
|
+
@click.option("--entry", required=True, type=float, help="Entry price")
|
|
19
|
+
@click.option("--stop", required=True, type=float, help="Stop loss price")
|
|
20
|
+
@click.option("--direction", type=click.Choice(["long", "short"]), default="long", help="Trade direction")
|
|
21
|
+
@click.option("--risk-pct", type=float, default=1.0, help="Risk per trade (%, default 1.0)")
|
|
22
|
+
def size(equity: float, entry: float, stop: float, direction: str, risk_pct: float) -> None:
|
|
23
|
+
"""Calculate position size for a trade."""
|
|
24
|
+
auth = AuthManager()
|
|
25
|
+
client = NeoClient(auth)
|
|
26
|
+
|
|
27
|
+
payload = {
|
|
28
|
+
"equity": equity,
|
|
29
|
+
"entry_price": entry,
|
|
30
|
+
"stop_price": stop,
|
|
31
|
+
"direction": direction,
|
|
32
|
+
"risk_percent": risk_pct,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with console.status(f"[{GREEN_DIM}]Computing...[/]"):
|
|
37
|
+
result = asyncio.run(client.post("/v1/sizing/calculate", json=payload))
|
|
38
|
+
except AuthRequired:
|
|
39
|
+
print_error("Not authenticated. Run `neo login` first.")
|
|
40
|
+
raise SystemExit(1)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
print_error(f"Sizing failed: {e}")
|
|
43
|
+
raise SystemExit(1)
|
|
44
|
+
|
|
45
|
+
risk_per_share = abs(entry - stop)
|
|
46
|
+
risk_amount = result.get("risk_amount", equity * risk_pct / 100)
|
|
47
|
+
shares = result.get("shares", int(risk_amount / risk_per_share) if risk_per_share else 0)
|
|
48
|
+
position_value = result.get("position_value", shares * entry)
|
|
49
|
+
|
|
50
|
+
kv_table("POSITION SIZE", {
|
|
51
|
+
"Direction": direction.upper(),
|
|
52
|
+
"Entry": fmt_price(entry),
|
|
53
|
+
"Stop": fmt_price(stop),
|
|
54
|
+
"Risk/Share": fmt_price(risk_per_share),
|
|
55
|
+
"Risk Amount": fmt_risk(risk_amount),
|
|
56
|
+
"Risk %": fmt_pct(risk_pct),
|
|
57
|
+
"Shares": fmt_qty(shares),
|
|
58
|
+
"Position Value": fmt_price(position_value),
|
|
59
|
+
"% of Equity": fmt_pct(position_value / equity * 100 if equity else 0),
|
|
60
|
+
})
|
neo/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""System R CLI configuration — paths, settings, defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Base directory for all System R local data
|
|
9
|
+
SYSTEMR_HOME = Path(os.environ.get("SYSTEMR_HOME", Path.home() / ".systemr"))
|
|
10
|
+
|
|
11
|
+
# File paths
|
|
12
|
+
AUTH_FILE = SYSTEMR_HOME / "auth.json"
|
|
13
|
+
DB_FILE = SYSTEMR_HOME / "journal.db"
|
|
14
|
+
CONFIG_FILE = SYSTEMR_HOME / "config.json"
|
|
15
|
+
PROFILE_FILE = SYSTEMR_HOME / "PROFILE.md"
|
|
16
|
+
RULES_FILE = SYSTEMR_HOME / "RULES.md"
|
|
17
|
+
MEMORY_DIR = SYSTEMR_HOME / "memory"
|
|
18
|
+
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
|
|
19
|
+
SESSIONS_DIR = SYSTEMR_HOME / "sessions"
|
|
20
|
+
|
|
21
|
+
# API base URLs
|
|
22
|
+
DEFAULT_API_URL = "https://agents.systemr.ai"
|
|
23
|
+
STAGING_API_URL = "https://app-staging.systemr.ai"
|
|
24
|
+
|
|
25
|
+
# Backward compat aliases (additive-only rule — old names still work)
|
|
26
|
+
NEO_HOME = SYSTEMR_HOME
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_api_url() -> str:
|
|
30
|
+
"""Return the API base URL, respecting SYSTEMR_API_URL env override.
|
|
31
|
+
|
|
32
|
+
Validates HTTPS for non-localhost URLs to prevent credential leaks
|
|
33
|
+
over plaintext connections.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The API base URL string.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If the URL uses HTTP for a non-localhost host.
|
|
40
|
+
"""
|
|
41
|
+
# Check new name first, fall back to old name for backward compat
|
|
42
|
+
url = os.environ.get(
|
|
43
|
+
"SYSTEMR_API_URL",
|
|
44
|
+
os.environ.get("NEO_API_URL", DEFAULT_API_URL),
|
|
45
|
+
)
|
|
46
|
+
if (
|
|
47
|
+
not url.startswith("https://")
|
|
48
|
+
and "localhost" not in url
|
|
49
|
+
and "127.0.0.1" not in url
|
|
50
|
+
):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"SYSTEMR_API_URL must use HTTPS (got: {url}). "
|
|
53
|
+
"Only localhost URLs are allowed over HTTP."
|
|
54
|
+
)
|
|
55
|
+
return url
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def ensure_systemr_home() -> None:
|
|
59
|
+
"""Create ~/.systemr/ with secure permissions if it doesn't exist.
|
|
60
|
+
|
|
61
|
+
Creates the base directory plus memory/ and sessions/ subdirectories.
|
|
62
|
+
All directories use mode 0o700 (owner read/write/execute only).
|
|
63
|
+
"""
|
|
64
|
+
SYSTEMR_HOME.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
65
|
+
MEMORY_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
66
|
+
SESSIONS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Backward compat alias
|
|
70
|
+
ensure_neo_home = ensure_systemr_home
|
neo/confirmation.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Trade confirmation flow — mandatory safety layer.
|
|
2
|
+
|
|
3
|
+
Permission levels (from DESIGN.md §9):
|
|
4
|
+
AUTO — read-only, calculations: no confirmation needed
|
|
5
|
+
CONFIRM — place/cancel/modify order: show details, wait for y/n
|
|
6
|
+
DOUBLE_CONFIRM — kill switch: show warning, require typing KILL
|
|
7
|
+
|
|
8
|
+
SAFETY: Unknown actions default to CONFIRM, not AUTO. Any new tool
|
|
9
|
+
added to the backend requires explicit approval until classified.
|
|
10
|
+
|
|
11
|
+
All financial values in confirmation functions use Decimal.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
import structlog
|
|
22
|
+
|
|
23
|
+
from neo.display.theme import (
|
|
24
|
+
GREEN,
|
|
25
|
+
RED,
|
|
26
|
+
GRAY,
|
|
27
|
+
console,
|
|
28
|
+
)
|
|
29
|
+
from neo.types import Price, RiskAmount, Percentage, Quantity
|
|
30
|
+
|
|
31
|
+
logger = structlog.get_logger(module="confirmation")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PermissionLevel(Enum):
|
|
35
|
+
"""Permission level for tool actions."""
|
|
36
|
+
|
|
37
|
+
AUTO = "auto"
|
|
38
|
+
CONFIRM = "confirm"
|
|
39
|
+
DOUBLE_CONFIRM = "double_confirm"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Actions mapped to permission levels.
|
|
43
|
+
# SAFETY: default is CONFIRM for unknown actions (see get_permission_level).
|
|
44
|
+
ACTION_PERMISSIONS: dict[str, PermissionLevel] = {
|
|
45
|
+
# Auto — no confirmation needed (read-only and calculations)
|
|
46
|
+
"get_quote": PermissionLevel.AUTO,
|
|
47
|
+
"get_account_balance": PermissionLevel.AUTO,
|
|
48
|
+
"calculate_position_size": PermissionLevel.AUTO,
|
|
49
|
+
"check_trade_risk": PermissionLevel.AUTO,
|
|
50
|
+
"evaluate_performance": PermissionLevel.AUTO,
|
|
51
|
+
"get_pricing": PermissionLevel.AUTO,
|
|
52
|
+
"get_positions": PermissionLevel.AUTO,
|
|
53
|
+
"get_portfolio": PermissionLevel.AUTO,
|
|
54
|
+
"get_order_status": PermissionLevel.AUTO,
|
|
55
|
+
"search_memory": PermissionLevel.AUTO,
|
|
56
|
+
"get_trading_biases": PermissionLevel.AUTO,
|
|
57
|
+
"record_trade_outcome": PermissionLevel.AUTO,
|
|
58
|
+
"store_memory": PermissionLevel.AUTO,
|
|
59
|
+
# Confirm — show details, wait for y/n (actions touching money)
|
|
60
|
+
"place_order": PermissionLevel.CONFIRM,
|
|
61
|
+
"cancel_order": PermissionLevel.CONFIRM,
|
|
62
|
+
"modify_order": PermissionLevel.CONFIRM,
|
|
63
|
+
"close_position": PermissionLevel.CONFIRM,
|
|
64
|
+
"set_stop_loss": PermissionLevel.CONFIRM,
|
|
65
|
+
"modify_stop": PermissionLevel.CONFIRM,
|
|
66
|
+
"set_take_profit": PermissionLevel.CONFIRM,
|
|
67
|
+
"connect_broker": PermissionLevel.CONFIRM,
|
|
68
|
+
"disconnect_broker": PermissionLevel.CONFIRM,
|
|
69
|
+
# Double confirm — type to confirm (destructive/irreversible)
|
|
70
|
+
"kill_switch": PermissionLevel.DOUBLE_CONFIRM,
|
|
71
|
+
"close_all_positions": PermissionLevel.DOUBLE_CONFIRM,
|
|
72
|
+
"enable_margin": PermissionLevel.DOUBLE_CONFIRM,
|
|
73
|
+
"withdraw_funds": PermissionLevel.DOUBLE_CONFIRM,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Permission profiles ────────────────────────────────────────────
|
|
78
|
+
# Named profiles configure safety tiers for different user levels.
|
|
79
|
+
# Inspired by OpenClaw's tool profiles (messaging/minimal/automation).
|
|
80
|
+
|
|
81
|
+
# Profile overrides: maps action names to elevated/demoted permission levels.
|
|
82
|
+
# Actions not in overrides use the default ACTION_PERMISSIONS mapping.
|
|
83
|
+
PERMISSION_PROFILES: dict[str, dict[str, PermissionLevel]] = {
|
|
84
|
+
"paper": {
|
|
85
|
+
# Maximum safety — ALL trade actions require DOUBLE_CONFIRM
|
|
86
|
+
"place_order": PermissionLevel.DOUBLE_CONFIRM,
|
|
87
|
+
"cancel_order": PermissionLevel.DOUBLE_CONFIRM,
|
|
88
|
+
"modify_order": PermissionLevel.DOUBLE_CONFIRM,
|
|
89
|
+
"close_position": PermissionLevel.DOUBLE_CONFIRM,
|
|
90
|
+
"set_stop_loss": PermissionLevel.DOUBLE_CONFIRM,
|
|
91
|
+
"modify_stop": PermissionLevel.DOUBLE_CONFIRM,
|
|
92
|
+
"set_take_profit": PermissionLevel.DOUBLE_CONFIRM,
|
|
93
|
+
"connect_broker": PermissionLevel.DOUBLE_CONFIRM,
|
|
94
|
+
},
|
|
95
|
+
"standard": {}, # Empty — uses ACTION_PERMISSIONS as-is
|
|
96
|
+
"experienced": {
|
|
97
|
+
# Relaxed — stop/target adjustments auto-approved
|
|
98
|
+
"set_stop_loss": PermissionLevel.AUTO,
|
|
99
|
+
"modify_stop": PermissionLevel.AUTO,
|
|
100
|
+
"set_take_profit": PermissionLevel.AUTO,
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Active profile — set via /permissions command or config.json
|
|
105
|
+
_active_profile: str = "standard"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_active_profile() -> str:
|
|
109
|
+
"""Return the name of the active permission profile.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Profile name string.
|
|
113
|
+
"""
|
|
114
|
+
return _active_profile
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def set_active_profile(profile: str) -> bool:
|
|
118
|
+
"""Set the active permission profile and persist to config.json.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
profile: Profile name (paper, standard, experienced).
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if profile was set, False if unknown.
|
|
125
|
+
"""
|
|
126
|
+
global _active_profile
|
|
127
|
+
if profile not in PERMISSION_PROFILES:
|
|
128
|
+
return False
|
|
129
|
+
_active_profile = profile
|
|
130
|
+
_save_profile_to_config(profile)
|
|
131
|
+
logger.info("permission_profile_changed", profile=profile)
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_profile_from_config() -> None:
|
|
136
|
+
"""Load the permission profile from config.json on startup.
|
|
137
|
+
|
|
138
|
+
Reads the 'permission_profile' key from ~/.systemr/config.json.
|
|
139
|
+
Falls back to 'standard' if not set or invalid.
|
|
140
|
+
"""
|
|
141
|
+
global _active_profile
|
|
142
|
+
import json
|
|
143
|
+
from neo.config import CONFIG_FILE
|
|
144
|
+
|
|
145
|
+
if not CONFIG_FILE.exists():
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
config = json.loads(CONFIG_FILE.read_text())
|
|
150
|
+
profile = config.get("permission_profile", "standard")
|
|
151
|
+
if profile in PERMISSION_PROFILES:
|
|
152
|
+
_active_profile = profile
|
|
153
|
+
logger.info("permission_profile_loaded", profile=profile)
|
|
154
|
+
except (json.JSONDecodeError, TypeError):
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _save_profile_to_config(profile: str) -> None:
|
|
159
|
+
"""Persist the active permission profile to config.json.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
profile: Profile name to save.
|
|
163
|
+
"""
|
|
164
|
+
import json
|
|
165
|
+
from neo.config import CONFIG_FILE, ensure_systemr_home
|
|
166
|
+
|
|
167
|
+
ensure_systemr_home()
|
|
168
|
+
config: dict = {}
|
|
169
|
+
if CONFIG_FILE.exists():
|
|
170
|
+
try:
|
|
171
|
+
config = json.loads(CONFIG_FILE.read_text())
|
|
172
|
+
except (json.JSONDecodeError, TypeError):
|
|
173
|
+
config = {}
|
|
174
|
+
|
|
175
|
+
config["permission_profile"] = profile
|
|
176
|
+
CONFIG_FILE.write_text(json.dumps(config, indent=2))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_permission_level(action: str) -> PermissionLevel:
|
|
180
|
+
"""Get the permission level for a given action under active profile.
|
|
181
|
+
|
|
182
|
+
Resolution order:
|
|
183
|
+
1. Active profile overrides (if action is in profile)
|
|
184
|
+
2. Default ACTION_PERMISSIONS mapping
|
|
185
|
+
3. CONFIRM (safety default for unknown actions)
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
action: Tool/action name from the backend.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The permission level for this action.
|
|
192
|
+
"""
|
|
193
|
+
# Check active profile overrides first
|
|
194
|
+
profile_overrides = PERMISSION_PROFILES.get(_active_profile, {})
|
|
195
|
+
if action in profile_overrides:
|
|
196
|
+
return profile_overrides[action]
|
|
197
|
+
return ACTION_PERMISSIONS.get(action, PermissionLevel.CONFIRM)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def confirm_trade(
|
|
201
|
+
symbol: str,
|
|
202
|
+
direction: str,
|
|
203
|
+
entry: Price,
|
|
204
|
+
stop: Price,
|
|
205
|
+
shares: Quantity,
|
|
206
|
+
risk_amount: RiskAmount,
|
|
207
|
+
risk_pct: Percentage,
|
|
208
|
+
target: Price | None = None,
|
|
209
|
+
rr_ratio: Decimal | None = None,
|
|
210
|
+
position_value: Price | None = None,
|
|
211
|
+
) -> bool:
|
|
212
|
+
"""Show trade card and ask for confirmation.
|
|
213
|
+
|
|
214
|
+
All financial values are Decimal. Returns True if the user approves.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
symbol: Ticker symbol.
|
|
218
|
+
direction: "long" or "short".
|
|
219
|
+
entry: Entry price.
|
|
220
|
+
stop: Stop loss price.
|
|
221
|
+
shares: Number of shares.
|
|
222
|
+
risk_amount: Total risk in dollars.
|
|
223
|
+
risk_pct: Risk as percentage of account.
|
|
224
|
+
target: Optional target price.
|
|
225
|
+
rr_ratio: Optional reward-to-risk ratio.
|
|
226
|
+
position_value: Optional total position value.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if user typed 'y', False otherwise.
|
|
230
|
+
"""
|
|
231
|
+
from neo.display.formatters import fmt_price, fmt_qty, fmt_risk, fmt_pct
|
|
232
|
+
|
|
233
|
+
dir_color = GREEN if direction.lower() == "long" else RED
|
|
234
|
+
console.print()
|
|
235
|
+
console.print(f" ┌─ [{GREEN}]TRADE — {symbol.upper()}[/] {'─' * 30}┐")
|
|
236
|
+
console.print(f" │ Direction [{dir_color}]{direction.upper()}[/]")
|
|
237
|
+
console.print(f" │ Entry {fmt_price(entry)}")
|
|
238
|
+
console.print(f" │ Stop {fmt_price(stop)}")
|
|
239
|
+
if target is not None:
|
|
240
|
+
console.print(f" │ Target {fmt_price(target)}")
|
|
241
|
+
console.print(f" │ Shares {fmt_qty(shares)}")
|
|
242
|
+
console.print(f" │ Risk {fmt_risk(risk_amount)} ({fmt_pct(risk_pct)})")
|
|
243
|
+
if rr_ratio is not None:
|
|
244
|
+
console.print(f" │ R:R 1 : {float(rr_ratio):.1f}")
|
|
245
|
+
if position_value is not None:
|
|
246
|
+
console.print(f" │ Position {fmt_price(position_value)}")
|
|
247
|
+
console.print(f" └{'─' * 45}┘")
|
|
248
|
+
console.print()
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
response = click.prompt(
|
|
252
|
+
click.style(" Place this trade? [y/n]", fg="white"),
|
|
253
|
+
type=click.Choice(["y", "n"], case_sensitive=False),
|
|
254
|
+
show_choices=False,
|
|
255
|
+
)
|
|
256
|
+
approved = response.lower() == "y"
|
|
257
|
+
logger.info(
|
|
258
|
+
"trade_confirmation",
|
|
259
|
+
symbol=symbol, direction=direction, approved=approved,
|
|
260
|
+
risk=str(risk_amount), shares=shares,
|
|
261
|
+
)
|
|
262
|
+
return approved
|
|
263
|
+
except (click.Abort, EOFError):
|
|
264
|
+
logger.info("trade_confirmation", symbol=symbol, approved=False, reason="aborted")
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def confirm_kill_switch(
|
|
269
|
+
positions: list[dict[str, Any]] | None = None,
|
|
270
|
+
) -> bool:
|
|
271
|
+
"""Double confirmation for kill switch — requires typing KILL.
|
|
272
|
+
|
|
273
|
+
This is the most dangerous action. It closes ALL open positions.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
positions: Optional list of position dicts to display.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
True only if user types exactly "KILL".
|
|
280
|
+
"""
|
|
281
|
+
console.print()
|
|
282
|
+
console.print(f" [{RED}]{'━' * 48}[/]")
|
|
283
|
+
console.print(f" [{RED}]KILL SWITCH — This will close ALL open positions.[/]")
|
|
284
|
+
console.print(f" [{RED}]{'━' * 48}[/]")
|
|
285
|
+
|
|
286
|
+
if positions:
|
|
287
|
+
console.print()
|
|
288
|
+
for p in positions:
|
|
289
|
+
symbol = p.get("symbol", "?")
|
|
290
|
+
qty = p.get("quantity", 0)
|
|
291
|
+
pnl = p.get("pnl", 0)
|
|
292
|
+
pnl_color = GREEN if float(pnl) >= 0 else RED
|
|
293
|
+
console.print(
|
|
294
|
+
f" [{GRAY}]{symbol} {qty} shares "
|
|
295
|
+
f"[{pnl_color}]{'+' if float(pnl) >= 0 else ''}{float(pnl):.2f}[/][/]"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
console.print()
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
response = click.prompt(
|
|
302
|
+
click.style(" Type KILL to confirm", fg="red", bold=True),
|
|
303
|
+
default="",
|
|
304
|
+
show_default=False,
|
|
305
|
+
)
|
|
306
|
+
approved = response.strip() == "KILL"
|
|
307
|
+
logger.info("kill_switch_confirmation", approved=approved)
|
|
308
|
+
return approved
|
|
309
|
+
except (click.Abort, EOFError):
|
|
310
|
+
logger.info("kill_switch_confirmation", approved=False, reason="aborted")
|
|
311
|
+
return False
|