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,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