|
| 1 | +"""CLI handlers for ``vibe-trading hypothesis {list,show,invalidate}``. |
| 2 | +
|
| 3 | +All logic lives here; ``agent/cli.py`` only wires this in via :func:`add_subparser` |
| 4 | +and :func:`dispatch`. Handlers print to stdout (Rich when available, plain |
| 5 | +``print`` fallback) and return an int exit code. Errors are reported as a |
| 6 | +one-line stderr message; tracebacks are suppressed unless ``--verbose`` is set |
| 7 | +on the namespace. |
| 8 | +
|
| 9 | +Storage path resolution defers to :func:`default_hypotheses_path`, so callers |
| 10 | +(and tests) can override via the ``VIBE_TRADING_HYPOTHESES_PATH`` env var or by |
| 11 | +passing ``--path``. |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import argparse |
| 17 | +import json |
| 18 | +import sys |
| 19 | +import traceback |
| 20 | +from pathlib import Path |
| 21 | +from typing import Any, Callable |
| 22 | + |
| 23 | +try: |
| 24 | + from rich.console import Console |
| 25 | + from rich.panel import Panel |
| 26 | + from rich.table import Table |
| 27 | + |
| 28 | + _console: Console | None = Console() |
| 29 | +except Exception: # pragma: no cover — rich is a project dep, fallback only |
| 30 | + _console = None |
| 31 | + Table = None # type: ignore[assignment] |
| 32 | + Panel = None # type: ignore[assignment] |
| 33 | + |
| 34 | +from src.hypotheses.registry import ( |
| 35 | + HYPOTHESIS_STATUSES, |
| 36 | + Hypothesis, |
| 37 | + HypothesisRegistry, |
| 38 | +) |
| 39 | + |
| 40 | + |
| 41 | +_STATUS_STYLES = { |
| 42 | + "exploring": "cyan", |
| 43 | + "testing": "yellow", |
| 44 | + "validated": "green", |
| 45 | + "rejected": "red", |
| 46 | + "monitoring": "magenta", |
| 47 | +} |
| 48 | + |
| 49 | + |
| 50 | +def _print(msg: str) -> None: |
| 51 | + if _console is not None: |
| 52 | + _console.print(msg) |
| 53 | + else: |
| 54 | + print(msg) |
| 55 | + |
| 56 | + |
| 57 | +def _err(msg: str) -> None: |
| 58 | + print(msg, file=sys.stderr) |
| 59 | + |
| 60 | + |
| 61 | +def _registry(args: argparse.Namespace) -> HypothesisRegistry: |
| 62 | + override = getattr(args, "path", None) |
| 63 | + if override: |
| 64 | + return HypothesisRegistry(path=Path(override).expanduser()) |
| 65 | + return HypothesisRegistry() |
| 66 | + |
| 67 | + |
| 68 | +def _hypothesis_payload(hyp: Hypothesis) -> dict[str, Any]: |
| 69 | + return hyp.to_dict() |
| 70 | + |
| 71 | + |
| 72 | +def _emit_table(rows: list[Hypothesis]) -> None: |
| 73 | + if Table is None: |
| 74 | + for hyp in rows: |
| 75 | + print( |
| 76 | + f"{hyp.hypothesis_id}\t{hyp.status}\t{hyp.title}\t{hyp.updated_at}" |
| 77 | + ) |
| 78 | + return |
| 79 | + table = Table(title=f"Hypotheses ({len(rows)})", show_lines=False) |
| 80 | + table.add_column("ID", style="bold", overflow="fold") |
| 81 | + table.add_column("Status") |
| 82 | + table.add_column("Title", overflow="fold") |
| 83 | + table.add_column("Universe", overflow="fold") |
| 84 | + table.add_column("Run cards", justify="right") |
| 85 | + table.add_column("Updated", overflow="fold") |
| 86 | + for hyp in rows: |
| 87 | + status_style = _STATUS_STYLES.get(hyp.status, "white") |
| 88 | + table.add_row( |
| 89 | + hyp.hypothesis_id, |
| 90 | + f"[{status_style}]{hyp.status}[/{status_style}]", |
| 91 | + hyp.title, |
| 92 | + hyp.universe or "-", |
| 93 | + str(len(hyp.run_cards)), |
| 94 | + hyp.updated_at, |
| 95 | + ) |
| 96 | + # Use a wide non-TTY console so piped/captured output keeps each row on |
| 97 | + # one line; interactive TTYs continue to honor the live terminal width |
| 98 | + # via the module-level _console. |
| 99 | + if sys.stdout.isatty() and _console is not None: |
| 100 | + _console.print(table) |
| 101 | + else: |
| 102 | + Console(width=200, force_terminal=False).print(table) |
| 103 | + |
| 104 | + |
| 105 | +def _emit_detail(hyp: Hypothesis) -> None: |
| 106 | + if _console is None or Panel is None: |
| 107 | + print(json.dumps(hyp.to_dict(), ensure_ascii=False, indent=2)) |
| 108 | + return |
| 109 | + status_style = _STATUS_STYLES.get(hyp.status, "white") |
| 110 | + body_lines = [ |
| 111 | + f"[bold]ID:[/bold] {hyp.hypothesis_id}", |
| 112 | + f"[bold]Status:[/bold] [{status_style}]{hyp.status}[/{status_style}]", |
| 113 | + f"[bold]Universe:[/bold] {hyp.universe or '-'}", |
| 114 | + f"[bold]Data sources:[/bold] {', '.join(hyp.data_sources) or '-'}", |
| 115 | + f"[bold]Skills:[/bold] {', '.join(hyp.skills) or '-'}", |
| 116 | + f"[bold]Created:[/bold] {hyp.created_at}", |
| 117 | + f"[bold]Updated:[/bold] {hyp.updated_at}", |
| 118 | + "", |
| 119 | + "[bold]Thesis[/bold]", |
| 120 | + hyp.thesis or "-", |
| 121 | + ] |
| 122 | + if hyp.signal_definition: |
| 123 | + body_lines.extend(["", "[bold]Signal[/bold]", hyp.signal_definition]) |
| 124 | + if hyp.invalidation_notes: |
| 125 | + body_lines.extend( |
| 126 | + ["", "[bold red]Invalidation notes[/bold red]", hyp.invalidation_notes] |
| 127 | + ) |
| 128 | + if hyp.run_cards: |
| 129 | + body_lines.append("") |
| 130 | + body_lines.append(f"[bold]Linked run cards ({len(hyp.run_cards)})[/bold]") |
| 131 | + for idx, link in enumerate(hyp.run_cards, 1): |
| 132 | + run_card_path = link.get("run_card_path") or "-" |
| 133 | + run_dir = link.get("backtest_run_dir") or "-" |
| 134 | + note = link.get("notes") or "" |
| 135 | + linked_at = link.get("linked_at") or "-" |
| 136 | + body_lines.append( |
| 137 | + f" {idx}. run_card={run_card_path} run_dir={run_dir} linked={linked_at}" |
| 138 | + ) |
| 139 | + if note: |
| 140 | + body_lines.append(f" note: {note}") |
| 141 | + _console.print(Panel("\n".join(body_lines), title=hyp.title, expand=False)) |
| 142 | + |
| 143 | + |
| 144 | +def _cmd_list(args: argparse.Namespace) -> int: |
| 145 | + registry = _registry(args) |
| 146 | + status_filter: str | None = getattr(args, "status", None) |
| 147 | + limit: int = max(0, int(getattr(args, "limit", 50) or 0)) |
| 148 | + rows = registry.list() |
| 149 | + if status_filter: |
| 150 | + rows = [hyp for hyp in rows if hyp.status == status_filter] |
| 151 | + rows.sort(key=lambda h: h.updated_at, reverse=True) |
| 152 | + if limit: |
| 153 | + rows = rows[:limit] |
| 154 | + |
| 155 | + if getattr(args, "json", False): |
| 156 | + print( |
| 157 | + json.dumps( |
| 158 | + [_hypothesis_payload(hyp) for hyp in rows], |
| 159 | + ensure_ascii=False, |
| 160 | + indent=2, |
| 161 | + ) |
| 162 | + ) |
| 163 | + return 0 |
| 164 | + |
| 165 | + if not rows: |
| 166 | + suffix = f" status={status_filter}" if status_filter else "" |
| 167 | + _print(f"No hypotheses found{suffix}.") |
| 168 | + return 0 |
| 169 | + _emit_table(rows) |
| 170 | + return 0 |
| 171 | + |
| 172 | + |
| 173 | +def _cmd_show(args: argparse.Namespace) -> int: |
| 174 | + registry = _registry(args) |
| 175 | + hypothesis_id = args.hypothesis_id |
| 176 | + for hyp in registry.list(): |
| 177 | + if hyp.hypothesis_id == hypothesis_id: |
| 178 | + if getattr(args, "json", False): |
| 179 | + print(json.dumps(hyp.to_dict(), ensure_ascii=False, indent=2)) |
| 180 | + else: |
| 181 | + _emit_detail(hyp) |
| 182 | + return 0 |
| 183 | + _err(f"hypothesis not found: {hypothesis_id}") |
| 184 | + return 1 |
| 185 | + |
| 186 | + |
| 187 | +def _cmd_invalidate(args: argparse.Namespace) -> int: |
| 188 | + registry = _registry(args) |
| 189 | + note = (getattr(args, "note", "") or "").strip() |
| 190 | + try: |
| 191 | + hyp = registry.update( |
| 192 | + args.hypothesis_id, |
| 193 | + status="rejected", |
| 194 | + invalidation_notes=note if note else None, |
| 195 | + ) |
| 196 | + except KeyError: |
| 197 | + _err(f"hypothesis not found: {args.hypothesis_id}") |
| 198 | + return 1 |
| 199 | + except ValueError as exc: |
| 200 | + _err(f"invalid update: {exc}") |
| 201 | + return 2 |
| 202 | + |
| 203 | + if getattr(args, "json", False): |
| 204 | + print(json.dumps(hyp.to_dict(), ensure_ascii=False, indent=2)) |
| 205 | + else: |
| 206 | + _print( |
| 207 | + f"[red]rejected[/red] {hyp.hypothesis_id} — {hyp.title}" |
| 208 | + + (f"\n note: {note}" if note else "") |
| 209 | + ) |
| 210 | + return 0 |
| 211 | + |
| 212 | + |
| 213 | +_DISPATCH: dict[str, Callable[[argparse.Namespace], int]] = { |
| 214 | + "list": _cmd_list, |
| 215 | + "show": _cmd_show, |
| 216 | + "invalidate": _cmd_invalidate, |
| 217 | +} |
| 218 | + |
| 219 | + |
| 220 | +_HYP_PARSER: argparse.ArgumentParser | None = None |
| 221 | + |
| 222 | + |
| 223 | +def add_subparser(subparsers: Any) -> argparse.ArgumentParser: |
| 224 | + """Register ``hypothesis`` and its three sub-sub-commands on the parent |
| 225 | + subparsers. |
| 226 | +
|
| 227 | + Args: |
| 228 | + subparsers: The object returned by ``ArgumentParser.add_subparsers(...)``. |
| 229 | +
|
| 230 | + Returns: |
| 231 | + The ``hypothesis`` parser (mostly for test introspection). |
| 232 | + """ |
| 233 | + global _HYP_PARSER |
| 234 | + |
| 235 | + hyp_parser = subparsers.add_parser( |
| 236 | + "hypothesis", |
| 237 | + help="Hypothesis Registry: list / show / invalidate", |
| 238 | + ) |
| 239 | + hyp_parser.add_argument( |
| 240 | + "--verbose", action="store_true", help="Show full traceback on errors" |
| 241 | + ) |
| 242 | + hyp_parser.add_argument( |
| 243 | + "--path", |
| 244 | + default=None, |
| 245 | + help=( |
| 246 | + "Override registry JSON path (also respects " |
| 247 | + "VIBE_TRADING_HYPOTHESES_PATH env var)" |
| 248 | + ), |
| 249 | + ) |
| 250 | + hyp_sub = hyp_parser.add_subparsers(dest="hypothesis_command") |
| 251 | + |
| 252 | + p_list = hyp_sub.add_parser("list", help="List hypotheses") |
| 253 | + p_list.add_argument( |
| 254 | + "--status", |
| 255 | + choices=HYPOTHESIS_STATUSES, |
| 256 | + default=None, |
| 257 | + help="Filter by lifecycle status", |
| 258 | + ) |
| 259 | + p_list.add_argument( |
| 260 | + "--limit", |
| 261 | + type=int, |
| 262 | + default=50, |
| 263 | + help="Maximum rows to print (default: 50; pass 0 for no cap)", |
| 264 | + ) |
| 265 | + p_list.add_argument( |
| 266 | + "--json", |
| 267 | + action="store_true", |
| 268 | + help="Emit JSON array instead of a table", |
| 269 | + ) |
| 270 | + |
| 271 | + p_show = hyp_sub.add_parser("show", help="Show hypothesis detail") |
| 272 | + p_show.add_argument("hypothesis_id", help="Hypothesis id, e.g. hyp_abcd1234ef56") |
| 273 | + p_show.add_argument( |
| 274 | + "--json", |
| 275 | + action="store_true", |
| 276 | + help="Emit JSON object instead of a panel", |
| 277 | + ) |
| 278 | + |
| 279 | + p_invalidate = hyp_sub.add_parser( |
| 280 | + "invalidate", help="Mark a hypothesis as rejected with optional notes" |
| 281 | + ) |
| 282 | + p_invalidate.add_argument( |
| 283 | + "hypothesis_id", help="Hypothesis id to invalidate" |
| 284 | + ) |
| 285 | + p_invalidate.add_argument( |
| 286 | + "--note", |
| 287 | + default="", |
| 288 | + help="Invalidation note recorded on the hypothesis", |
| 289 | + ) |
| 290 | + p_invalidate.add_argument( |
| 291 | + "--json", |
| 292 | + action="store_true", |
| 293 | + help="Emit JSON object of the updated hypothesis", |
| 294 | + ) |
| 295 | + |
| 296 | + _HYP_PARSER = hyp_parser |
| 297 | + return hyp_parser |
| 298 | + |
| 299 | + |
| 300 | +def dispatch(args: argparse.Namespace) -> int: |
| 301 | + """Dispatch ``hypothesis <sub>`` to the matching handler. |
| 302 | +
|
| 303 | + Returns the exit code; ``cli.py`` propagates it via ``_coerce_exit_code``. |
| 304 | + """ |
| 305 | + sub = getattr(args, "hypothesis_command", None) |
| 306 | + if sub is None: |
| 307 | + if _HYP_PARSER is not None: |
| 308 | + _HYP_PARSER.print_help() |
| 309 | + else: |
| 310 | + _err("hypothesis requires a subcommand. Try: vibe-trading hypothesis list") |
| 311 | + return 1 |
| 312 | + handler = _DISPATCH.get(sub) |
| 313 | + if handler is None: |
| 314 | + _err(f"unknown hypothesis subcommand: {sub}") |
| 315 | + return 1 |
| 316 | + try: |
| 317 | + return int(handler(args)) |
| 318 | + except Exception as exc: # noqa: BLE001 — surface as one-line stderr |
| 319 | + if getattr(args, "verbose", False): |
| 320 | + traceback.print_exc() |
| 321 | + else: |
| 322 | + _err(f"hypothesis {sub} failed: {exc}") |
| 323 | + return 1 |
0 commit comments