diff --git a/keel/cli.py b/keel/cli.py index bc4732c2..5181b90b 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -9,8 +9,10 @@ `resume-entries` (clear an armed rail-16 consecutive-loss halt), `record-flow` (declare a deposit/withdrawal so rail 11 does not read it as P&L) and `reset-hwm` (reset rail 11's high-water mark), -`subscription attest|set|show` (the per-venue, user-attested allowance rail 14 reads live), and a -Phase-4 `insights` stub. +`subscription attest|set|show` (the per-venue, user-attested allowance rail 14 reads live), +`status` (`commands.status`: the read-only, no-broker operator dashboard the paper-mode-fidelity +spec deferred -- mode/kill-switch/autonomy/Rail 11/positions/rules/data freshness, plus `--json`), +and a Phase-4 `insights` stub. **Dangerous commands ask a human; nothing needs a stored secret.** The former scrypt passphrase gate is gone (see `2026-07-21-security-simplification-design.md`). Five commands re-permit trading @@ -78,6 +80,7 @@ from keel.commands.autonomy import autonomy_group from keel.commands.db import db_group from keel.commands.rules import rules_group, rules_seed +from keel.commands.status import status_cmd from keel.commands.subscription import subscription_group from keel.commands.trials import trials_group from keel.commands.withdrawals import withdrawals_group @@ -1629,6 +1632,13 @@ def simulate( cli.add_command(subscription_group) +# -- status (interim operator-observability dashboard, no broker call) -------------------------- + +# The paper-mode-fidelity spec deferred a dedicated `keel status` command as a follow-up; it is +# defined in `keel.commands.status` and registered here. +cli.add_command(status_cmd) + + # -- kill / resume ------------------------------------------------------------------------------ diff --git a/keel/commands/__init__.py b/keel/commands/__init__.py index 5b25b978..89edad3d 100644 --- a/keel/commands/__init__.py +++ b/keel/commands/__init__.py @@ -6,7 +6,8 @@ `cli.add_command(...)`. The broker-free command groups live here as standalone modules -- `db`, `trials`, `withdrawals`, -`autonomy`, `rules`, `subscription` -- so the large CLI file stays a thin wiring layer. They draw +`autonomy`, `rules`, `subscription`, `status` -- so the large CLI file stays a thin wiring layer. +They draw the shared seams they need (`_open_repo`, `_load_cfg`, the confirmation gate, the disclaimer decorator) from `_common`, and the shared product-id derivation from `_products`; neither of those helper modules imports `keel.cli`, so there is no cycle. The `assets` group stays in `keel/cli.py` diff --git a/keel/commands/status.py b/keel/commands/status.py new file mode 100644 index 00000000..ea96cc34 --- /dev/null +++ b/keel/commands/status.py @@ -0,0 +1,378 @@ +"""`keel status` -- the interim, read-only operator dashboard for a paper-forward. + +The paper-mode-fidelity spec explicitly deferred a dedicated status command as a follow-up +("A dedicated `keel status` command is deferred as a follow-up"); this is that follow-up. Its +job is narrow: let an operator running a paper-forward see the agent's state at a glance, +**purely from the local DB and config** -- mode, kill-switch, autonomy, Rail 11 drawdown/equity +state, open positions, rule counts, and per-product data freshness. It NEVER calls the broker; +that is the whole point (`monitor`/`agent` are the commands that touch the network). + +Two layers, matching the rest of `keel/commands/*`: + +- `gather_status` is a PURE function of `(Repository, Config, now_ts)` -> `StatusReport`. It does + no I/O beyond the repo/config it is handed and takes no click dependency, so it is directly + unit-testable without a CLI runner or a broker. +- `status_cmd` (registered in `keel/cli.py` as `keel status`) opens the repo/config via the + standard `_open_repo`/`_load_cfg` seams, calls `gather_status`, and renders it -- either as + `click.echo` lines (default) or as JSON (`--json`), which exists mainly as a stable, + machine-readable shape for the eventual TUI to consume without re-deriving this logic. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass +from decimal import Decimal +from typing import Any + +import click + +from keel.commands._common import DISCLAIMER, _load_cfg, _open_repo +from keel.commands._products import _default_sim_products +from keel.config import Config +from keel.data.repository import Repository +from keel.types import Granularity + +# -- the pure report shape --------------------------------------------------------------------- + + +@dataclass(frozen=True) +class AutonomyStatus: + """Mirrors `keel autonomy show`'s own reading, so the two commands never disagree.""" + + live: bool + autonomous: bool + autonomous_until: int | None + updated_ts: int | None + profile_readable: bool + + +@dataclass(frozen=True) +class OpenPositionStatus: + id: int + product_id: str + rule_name: str + qty: Decimal + entry_price: Decimal + opened_at: int + has_bracket: bool + + +@dataclass(frozen=True) +class RuleSummary: + id: int + kind: str + status: str + product_id: str | None + params: dict[str, Any] + + +@dataclass(frozen=True) +class ProductFreshness: + product_id: str + granularity: str | None + last_ts: int | None + age_sec: int | None + + +@dataclass(frozen=True) +class SubscriptionStatusRow: + venue: str + tier_name: str + pacing: str + stored_status: str + effective_status: str + effective_cap: Decimal | None + + +@dataclass(frozen=True) +class StatusReport: + now_ts: int + mode: str + kill_switch_engaged: bool + autonomy: AutonomyStatus + equity_state_mode: str | None + high_water_mark: Decimal | None + drawdown_total_pct: Decimal | None + drawdown_weekly_pct: Decimal | None + max_total_dd_pct: Decimal + max_weekly_dd_pct: Decimal + rail11_status: str + paper_cash_usdc: Decimal | None + open_positions: list[OpenPositionStatus] + rule_counts: dict[str, int] + live_rules: list[RuleSummary] + data_freshness: list[ProductFreshness] + subscriptions: list[SubscriptionStatusRow] + + +# -- gather (pure) ------------------------------------------------------------------------------ + + +def _rail11_status( + dd_total: Decimal | None, + dd_weekly: Decimal | None, + max_total: Decimal, + max_weekly: Decimal, +) -> str: + """"HALTED" if either drawdown is at/over its ceiling (matches `execution.guards` rail 11's + own `>=` comparison), "unknown" if either scalar was never written, else "ok". + + Guarding on `None` matters here in a way it does not in `guards.py`: the guard reads + `get_state(..., default=Decimal("0"))` because it must make a PASS/VETO decision every + cycle and "no data yet" has to fail safe as "no drawdown". This is a DISPLAY, not a veto -- + reporting an unwritten value as a confident "ok" would be a lie (there may be a real + breach the agent just hasn't computed yet), so it is surfaced as "unknown" instead. + """ + if dd_total is None or dd_weekly is None: + return "unknown" + if dd_total >= max_total or dd_weekly >= max_weekly: + return "HALTED" + return "ok" + + +def _finest_granularity(granularities: list[Granularity]) -> Granularity | None: + """The shortest-timeframe granularity configured, by `Granularity`'s own declaration order + (`ONE_MINUTE` ... `ONE_DAY`, finest first) -- freshness is most informative measured against + whichever series updates most often.""" + if not granularities: + return None + order = {g: i for i, g in enumerate(Granularity)} + return min(granularities, key=lambda g: order[g]) + + +def _autonomy_status(repo: Repository, now_ts: int) -> AutonomyStatus: + profile = repo.get_profile() + return AutonomyStatus( + live=profile.is_autonomous(now_ts), + autonomous=profile.autonomous, + autonomous_until=profile.autonomous_until, + updated_ts=profile.updated_ts, + profile_readable=repo.profile_readable(), + ) + + +def _open_position_status(row: dict[str, Any]) -> OpenPositionStatus: + return OpenPositionStatus( + id=row["id"], + product_id=row["product_id"], + rule_name=row["rule_name"], + qty=row["qty"], + entry_price=row["entry_fill"], + opened_at=row["opened_at"], + has_bracket=row["bracket_order_id"] is not None, + ) + + +def _rule_summary(row: dict[str, Any]) -> RuleSummary: + params = row.get("params") or {} + return RuleSummary( + id=row["id"], + kind=row["kind"], + status=row["status"], + product_id=params.get("product_id"), + params=params, + ) + + +def _data_freshness(repo: Repository, config: Config, now_ts: int) -> list[ProductFreshness]: + granularity = _finest_granularity(list(config.market_data.granularities)) + rows = [] + for product_id in _default_sim_products(config): + if granularity is None: + rows.append(ProductFreshness(product_id, None, None, None)) + continue + candles = repo.get_candles(product_id, granularity) + if not candles: + rows.append(ProductFreshness(product_id, granularity.value, None, None)) + continue + last_ts = candles[-1].ts + rows.append( + ProductFreshness(product_id, granularity.value, last_ts, max(now_ts - last_ts, 0)) + ) + return rows + + +def _subscription_rows( + repo: Repository, config: Config, now_ts: int +) -> list[SubscriptionStatusRow]: + unsubscribed = config.subscription.unsubscribed_allowance_usd + return [ + SubscriptionStatusRow( + venue=record.venue, + tier_name=record.tier_name, + pacing=record.pacing, + stored_status=record.status.value, + effective_status=record.effective_status(now_ts).value, + effective_cap=record.allowance_usd(now_ts, unsubscribed), + ) + for record in repo.list_broker_subscriptions() + ] + + +def gather_status(repo: Repository, config: Config, now_ts: int) -> StatusReport: + """Assemble the full status report from `repo`/`config` alone -- no broker, no network. + + Pure aside from the read-only `repo` calls it makes: given the same DB contents and config it + always returns the same report, which is what makes it directly unit-testable and safe to + call from both the human-readable and `--json` renderers without divergence. + """ + dd_total = repo.get_state("drawdown_total_pct") + dd_weekly = repo.get_state("drawdown_weekly_pct") + max_total = config.money_mgmt.max_total_dd_pct + max_weekly = config.money_mgmt.max_weekly_dd_pct + + rules = repo.get_rules() + rule_counts: dict[str, int] = {} + for row in rules: + rule_counts[row["status"]] = rule_counts.get(row["status"], 0) + 1 + live_rules = [_rule_summary(row) for row in rules if row["status"] == "live"] + + return StatusReport( + now_ts=now_ts, + mode=config.auto_trade.mode, + kill_switch_engaged=bool(repo.get_state("kill_switch", default=True)), + autonomy=_autonomy_status(repo, now_ts), + equity_state_mode=repo.get_state("equity_state_mode"), + high_water_mark=repo.get_state("equity_high_water_mark"), + drawdown_total_pct=dd_total, + drawdown_weekly_pct=dd_weekly, + max_total_dd_pct=max_total, + max_weekly_dd_pct=max_weekly, + rail11_status=_rail11_status(dd_total, dd_weekly, max_total, max_weekly), + paper_cash_usdc=( + repo.get_state("paper_cash_usdc") if config.auto_trade.mode == "paper" else None + ), + open_positions=[_open_position_status(row) for row in repo.get_open_positions()], + rule_counts=rule_counts, + live_rules=live_rules, + data_freshness=_data_freshness(repo, config, now_ts), + subscriptions=_subscription_rows(repo, config, now_ts), + ) + + +# -- render (human-readable) -------------------------------------------------------------------- + + +def _human_age(age_sec: int) -> str: + if age_sec < 60: + return f"{age_sec}s ago" + minutes = age_sec // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = age_sec // 3600 + if hours < 24: + return f"{hours}h ago" + days = age_sec // 86400 + return f"{days}d ago" + + +def render_human(report: StatusReport) -> list[str]: + """The `keel status` (default, non-`--json`) rendering, as a list of lines -- kept as a pure + function of the report so it is testable without a CliRunner.""" + lines: list[str] = [] + lines.append(f"mode: {report.mode}") + lines.append( + "kill_switch: ENGAGED (halted)" if report.kill_switch_engaged else "kill_switch: clear" + ) + a = report.autonomy + if not a.profile_readable: + lines.append( + " WARNING: profile row unreadable -- reporting autonomy as OFF (safe reading)." + ) + autonomy_line = "autonomy: ON -- orders placed WITHOUT asking" if a.live else "autonomy: off" + lines.append(autonomy_line) + if a.autonomous and not a.live: + lines.append(f" (was ON but LAPSED at {a.autonomous_until})") + elif a.live and a.autonomous_until is not None: + lines.append(f" lapses at {a.autonomous_until}") + + lines.append("") + lines.append(f"equity_state_mode: {report.equity_state_mode or 'unknown'}") + hwm = report.high_water_mark if report.high_water_mark is not None else "unknown" + lines.append(f"high_water_mark: {hwm}") + dd_total = report.drawdown_total_pct if report.drawdown_total_pct is not None else "unknown" + dd_weekly = report.drawdown_weekly_pct if report.drawdown_weekly_pct is not None else "unknown" + lines.append( + f"drawdown: total={dd_total} (ceiling {report.max_total_dd_pct}) " + f"weekly={dd_weekly} (ceiling {report.max_weekly_dd_pct})" + ) + lines.append(f"rail11 (drawdown breaker): {report.rail11_status}") + if report.mode == "paper": + lines.append(f"paper_cash_usdc: {report.paper_cash_usdc}") + + lines.append("") + if not report.open_positions: + lines.append("open positions: no open positions") + else: + lines.append(f"open positions ({len(report.open_positions)}):") + for pos in report.open_positions: + bracket = "bracketed" if pos.has_bracket else "NO bracket" + lines.append( + f" [{pos.id}] {pos.product_id} qty={pos.qty} entry={pos.entry_price} " + f"opened_at={pos.opened_at} rule={pos.rule_name} ({bracket})" + ) + + lines.append("") + counts = " ".join(f"{status}={count}" for status, count in sorted(report.rule_counts.items())) + lines.append(f"rules: {counts or 'none'}") + for rule in report.live_rules: + lines.append( + f" live [{rule.id}] {rule.kind} product={rule.product_id} params={rule.params}" + ) + + lines.append("") + lines.append("data freshness:") + for f in report.data_freshness: + if f.last_ts is None: + lines.append(f" {f.product_id}: no data") + else: + lines.append(f" {f.product_id} ({f.granularity}): {_human_age(f.age_sec or 0)}") + + if report.subscriptions: + lines.append("") + lines.append("subscriptions:") + for s in report.subscriptions: + cap = "unlimited" if s.effective_cap is None else str(s.effective_cap) + lines.append( + f" {s.venue}: tier={s.tier_name} status={s.effective_status} cap={cap}" + ) + + return lines + + +def _report_to_jsonable(report: StatusReport) -> dict[str, Any]: + return asdict(report) + + +# -- the command ---------------------------------------------------------------------------- + + +@click.command("status") +@click.option("--json", "as_json", is_flag=True, default=False, help="Emit machine-readable JSON.") +@click.pass_context +def status_cmd(ctx: click.Context, as_json: bool) -> None: + """Operator-observability snapshot of the agent's state -- read-only, no broker call. + + Shows mode, the kill-switch, autonomy, Rail 11 drawdown/equity state, open positions, rule + counts, and per-product data freshness, all read straight from the local DB and config. This + is the interim of the deferred `keel status` TUI: same underlying report (`--json` is its + forward-compatible shape), just rendered to the terminal. + + `--json` deliberately skips the disclaimer footer every other command prints + (`with_disclaimer`): it exists for scripting/the future TUI, and a trailing line of prose + after the JSON would break every consumer that does `json.loads(output)`. + """ + repo = _open_repo(ctx) + config = _load_cfg(ctx) + report = gather_status(repo, config, now_ts=int(time.time())) + + if as_json: + click.echo(json.dumps(_report_to_jsonable(report), indent=2, default=str)) + return + + for line in render_human(report): + click.echo(line) + click.echo("") + click.echo(DISCLAIMER) diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/commands/test_status.py b/tests/commands/test_status.py new file mode 100644 index 00000000..41dc223d --- /dev/null +++ b/tests/commands/test_status.py @@ -0,0 +1,361 @@ +"""Tests for `keel status` -- the read-only, no-broker operator dashboard. + +Two layers, matching `keel/commands/status.py`'s split: + +- `gather_status` is a PURE function (`Repository` + `Config` + `now_ts` -> `StatusReport` + dataclass), driven directly here for every logic branch (Rail 11 halted/ok/unknown, + kill-switch rendering, positions, rule counts, freshness). No click, no CliRunner needed for + these. +- The `keel status` command itself (human-readable + `--json`) gets one thin `CliRunner` pass, + since the rendering/wiring is what's left untested by the pure-function tests. + +Fixtures mirror `tests/test_agent.py::repo` (in-memory `Repository`, `set_state` seeding) and +`tests/conftest.py::valid_config_path` (a real `config.yaml` on disk for the CLI test). +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from typing import Any + +import pytest +from click.testing import CliRunner + +from keel.cli import cli +from keel.commands.status import gather_status +from keel.config import ( + AutoTradeConfig, + Caps, + Config, + DcaConfig, + MarketDataConfig, + MoneyMgmtConfig, +) +from keel.data.db import connect, migrate +from keel.data.repository import Repository +from keel.types import Candle, Granularity + +NOW_TS = 1_800_000_000 + + +@pytest.fixture +def repo() -> Repository: + conn = connect(":memory:") + migrate(conn) + r = Repository(conn) + r.set_state("kill_switch", False) + return r + + +def _config(**overrides: Any) -> Config: + base: dict[str, Any] = dict( + allowlist=["BTC", "ETH"], + target_weights={}, + risk_pct=Decimal("0.01"), + caps=Caps( + max_per_order_usd=Decimal("100000"), + max_per_day_usd=Decimal("300000"), + max_exposure_usd=Decimal("1000000"), + max_per_asset_pct=Decimal("1"), + ), + market_data=MarketDataConfig( + granularities=[Granularity.ONE_DAY, Granularity.ONE_HOUR], history_days=365 + ), + auto_trade=AutoTradeConfig(mode="paper", interval_sec=900), + money_mgmt=MoneyMgmtConfig( + max_total_dd_pct=Decimal("0.20"), max_weekly_dd_pct=Decimal("0.08") + ), + dca=DcaConfig(budget_usd=Decimal("50"), cadence_days=7), + ) + base.update(overrides) + return Config(**base) + + +# -- mode / kill-switch / autonomy ----------------------------------------------------------- + + +def test_paper_mode_surfaces_equity_and_ok_drawdown(repo: Repository) -> None: + repo.set_state("equity_state_mode", "paper") + repo.set_state("paper_cash_usdc", Decimal("955.25")) + repo.set_state("drawdown_total_pct", Decimal("0.05")) + repo.set_state("drawdown_weekly_pct", Decimal("0.01")) + + report = gather_status(repo, _config(), now_ts=NOW_TS) + + assert report.mode == "paper" + assert report.equity_state_mode == "paper" + assert report.paper_cash_usdc == Decimal("955.25") + assert report.drawdown_total_pct == Decimal("0.05") + assert report.drawdown_weekly_pct == Decimal("0.01") + assert report.rail11_status == "ok" + + +def test_confirm_mode_reports_no_paper_cash(repo: Repository) -> None: + report = gather_status(repo, _config(auto_trade=AutoTradeConfig(mode="confirm")), now_ts=NOW_TS) + assert report.mode == "confirm" + assert report.paper_cash_usdc is None + + +def test_rail11_halted_on_total_drawdown_breach(repo: Repository) -> None: + repo.set_state("drawdown_total_pct", Decimal("0.20")) # == ceiling: >= trips it + repo.set_state("drawdown_weekly_pct", Decimal("0.00")) + + report = gather_status(repo, _config(), now_ts=NOW_TS) + + assert report.rail11_status == "HALTED" + + +def test_rail11_halted_on_weekly_drawdown_breach(repo: Repository) -> None: + repo.set_state("drawdown_total_pct", Decimal("0.00")) + repo.set_state("drawdown_weekly_pct", Decimal("0.09")) # > 0.08 ceiling + + report = gather_status(repo, _config(), now_ts=NOW_TS) + + assert report.rail11_status == "HALTED" + + +def test_rail11_unknown_when_state_never_written(repo: Repository) -> None: + """A fresh DB has never written `drawdown_total_pct`/`drawdown_weekly_pct` -- unknown must + not be misread as safe ("ok") nor as an alarm ("HALTED").""" + report = gather_status(repo, _config(), now_ts=NOW_TS) + assert report.drawdown_total_pct is None + assert report.drawdown_weekly_pct is None + assert report.rail11_status == "unknown" + + +def test_kill_switch_defaults_engaged(repo: Repository) -> None: + """`get_state("kill_switch", default=True)` fails closed -- an UNSET key must read engaged, + not clear. This repo fixture explicitly clears it (`set_state("kill_switch", False)`), so + exercise the true default via a second, untouched connection.""" + conn = connect(":memory:") + migrate(conn) + fresh = Repository(conn) + + report = gather_status(fresh, _config(), now_ts=NOW_TS) + + assert report.kill_switch_engaged is True + + +def test_kill_switch_clear_when_resumed(repo: Repository) -> None: + repo.set_state("kill_switch", False) + report = gather_status(repo, _config(), now_ts=NOW_TS) + assert report.kill_switch_engaged is False + + +def test_autonomy_reflects_profile(repo: Repository) -> None: + repo.set_autonomous(True, now_ts=NOW_TS - 10) + report = gather_status(repo, _config(), now_ts=NOW_TS) + assert report.autonomy.live is True + assert report.autonomy.autonomous is True + + +def test_autonomy_off_by_default(repo: Repository) -> None: + report = gather_status(repo, _config(), now_ts=NOW_TS) + assert report.autonomy.live is False + + +# -- open positions --------------------------------------------------------------------------- + + +def test_no_open_positions_is_empty_list(repo: Repository) -> None: + report = gather_status(repo, _config(), now_ts=NOW_TS) + assert report.open_positions == [] + + +def _insert_bracket_order(repo: Repository, product_id: str, ts: int) -> int: + """`positions.bracket_order_id` is a real FK into `orders`; seed one to attach.""" + return repo.insert_order( + dict( + mode="live", + product_id=product_id, + side="SELL", + order_type="limit", + qty=Decimal("0.01"), + limit_price=Decimal("70000"), + status="pending", + fee=Decimal("0"), + expected_fill=None, + actual_fill=None, + raw_response=None, + confirmation="autonomous", + rule_id=None, + created_at=ts, + updated_at=ts, + ) + ) + + +def test_open_position_appears_in_report(repo: Repository) -> None: + bracket_id = _insert_bracket_order(repo, "BTC-USD", NOW_TS - 3600) + repo.open_position( + product_id="BTC-USD", + rule_name="pullback_continuation", + opened_at=NOW_TS - 3600, + qty=Decimal("0.01"), + entry_fill=Decimal("65000"), + entry_fee=Decimal("1.5"), + bracket_order_id=bracket_id, + ) + + report = gather_status(repo, _config(), now_ts=NOW_TS) + + assert len(report.open_positions) == 1 + pos = report.open_positions[0] + assert pos.product_id == "BTC-USD" + assert pos.qty == Decimal("0.01") + assert pos.entry_price == Decimal("65000") + assert pos.opened_at == NOW_TS - 3600 + assert pos.has_bracket is True + + +def test_open_position_without_bracket_reports_false(repo: Repository) -> None: + repo.open_position( + product_id="ETH-USD", + rule_name="dca", + opened_at=NOW_TS, + qty=Decimal("1"), + entry_fill=Decimal("3000"), + entry_fee=Decimal("2"), + bracket_order_id=None, + ) + + report = gather_status(repo, _config(), now_ts=NOW_TS) + + assert report.open_positions[0].has_bracket is False + + +# -- rules --------------------------------------------------------------------------------- + + +def test_rule_counts_grouped_by_status(repo: Repository) -> None: + repo.insert_rule("pullback_continuation", {"product_id": "BTC-USD"}, status="candidate") + repo.insert_rule("dca", {"product_id": "ETH-USD"}, status="candidate") + repo.insert_rule("turtle_breakout", {"product_id": "BTC-USD"}, status="live") + repo.insert_rule("mean_reversion", {"product_id": "SOL-USD"}, status="disabled") + + report = gather_status(repo, _config(), now_ts=NOW_TS) + + assert report.rule_counts == {"candidate": 2, "live": 1, "disabled": 1} + + +def test_live_rules_are_listed_with_kind_and_product(repo: Repository) -> None: + repo.insert_rule("turtle_breakout", {"product_id": "BTC-USD", "lookback": 20}, status="live") + repo.insert_rule("dca", {"product_id": "ETH-USD"}, status="candidate") + + report = gather_status(repo, _config(), now_ts=NOW_TS) + + assert len(report.live_rules) == 1 + rule = report.live_rules[0] + assert rule.kind == "turtle_breakout" + assert rule.product_id == "BTC-USD" + assert rule.params["lookback"] == 20 + + +def test_no_rules_gives_empty_counts_and_list(repo: Repository) -> None: + report = gather_status(repo, _config(), now_ts=NOW_TS) + assert report.rule_counts == {} + assert report.live_rules == [] + + +# -- data freshness --------------------------------------------------------------------------- + + +def _candle(ts: int, price: str = "100") -> Candle: + p = Decimal(price) + return Candle(ts=ts, open=p, high=p, low=p, close=p, volume=Decimal("1")) + + +def test_data_freshness_uses_finest_granularity_and_computes_age(repo: Repository) -> None: + # Two candles on the finest configured granularity (ONE_HOUR); a coarser ONE_DAY series + # (seeded with a far staler ts) proves freshness does NOT read that one instead. + repo.upsert_candles( + "BTC-USD", + Granularity.ONE_HOUR, + [_candle(NOW_TS - 7200), _candle(NOW_TS - 3600)], + ) + repo.upsert_candles("BTC-USD", Granularity.ONE_DAY, [_candle(NOW_TS - 999_999)]) + + report = gather_status(repo, _config(), now_ts=NOW_TS) + + btc = next(f for f in report.data_freshness if f.product_id == "BTC-USD") + assert btc.last_ts == NOW_TS - 3600 + assert btc.age_sec == 3600 + eth = next(f for f in report.data_freshness if f.product_id == "ETH-USD") + assert eth.last_ts is None + assert eth.age_sec is None + + +# -- subscriptions (rail 14, optional section) ------------------------------------------------ + + +def test_subscriptions_empty_when_none_attested(repo: Repository) -> None: + report = gather_status(repo, _config(), now_ts=NOW_TS) + assert report.subscriptions == [] + + +def test_subscriptions_surfaced_when_attested(repo: Repository) -> None: + from keel_core.subscription import BrokerSubscription, SubscriptionStatus + + repo.upsert_broker_subscription( + BrokerSubscription( + venue="coinbase", + tier_name="Preferred", + free_volume_usd=Decimal("10000"), + pacing="opportunistic", + subscription_usd_month=Decimal("29.99"), + status=SubscriptionStatus.ACTIVE, + attested_at=NOW_TS - 1000, + attest_due_ts=NOW_TS + 1_000_000, + ) + ) + + report = gather_status(repo, _config(), now_ts=NOW_TS) + + assert len(report.subscriptions) == 1 + row = report.subscriptions[0] + assert row.venue == "coinbase" + assert row.effective_status == "active" + + +# -- the `keel status` command -------------------------------------------------------------- + + +def _repo_at(db_path) -> Repository: + conn = connect(str(db_path)) + migrate(conn) + return Repository(conn) + + +def test_status_command_runs_read_only_and_prints_key_facts(tmp_path, valid_config_path) -> None: + db_path = tmp_path / "keel.db" + _repo_at(db_path).set_state("kill_switch", False) + + result = CliRunner().invoke( + cli, ["--db", str(db_path), "--config", str(valid_config_path), "status"] + ) + + assert result.exit_code == 0, result.output + assert "mode: paper" in result.output + assert "kill_switch: clear" in result.output.lower() or "clear" in result.output.lower() + assert "no open positions" in result.output.lower() + + +def test_status_command_json_flag_emits_parseable_json(tmp_path, valid_config_path) -> None: + db_path = tmp_path / "keel.db" + seeded = _repo_at(db_path) + seeded.set_state("kill_switch", False) + seeded.set_state("drawdown_total_pct", Decimal("0.01")) + + result = CliRunner().invoke( + cli, ["--db", str(db_path), "--config", str(valid_config_path), "status", "--json"] + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["mode"] == "paper" + assert payload["kill_switch_engaged"] is False + assert payload["rail11_status"] in {"ok", "HALTED", "unknown"} + assert "open_positions" in payload + assert "rule_counts" in payload + assert "data_freshness" in payload