diff --git a/keel/commands/setup.py b/keel/commands/setup.py index 8913b52..c52fc82 100644 --- a/keel/commands/setup.py +++ b/keel/commands/setup.py @@ -209,7 +209,14 @@ def blocking(self) -> bool: title="At least one rule promoted to paper", kind=StepKind.JUDGEMENT, stage=Stage.PAPER, - why="Seeded rules are candidates and trade nothing. Which rule to run is your choice.", + why=( + "Seeded rules are candidates and trade nothing. Which rule to run is your choice -- " + "and on a fresh deployment the gate will very likely REFUSE it, naming too few " + "trades, a win rate under the floor, and an overfitting check that was never run. " + "That is the engine working, not a fault: a rule that has not earned paper status " + "does not get it, and this step can stay outstanding for a long time. Bypassing the " + "gate is `keel rules promote --force`, deliberately and on the record, at a terminal." + ), how="keel rules promote ", ), Step( @@ -296,7 +303,12 @@ def ready_for(self, stage: Stage) -> bool: `OFF_VENUE` steps can never be observed, so a deployment is never `ready_for(LIVE)` by this function's reckoning. That is the honest answer and it is deliberate: the last word on going live belongs to the operator who checked the venue dashboard, not to a function - that cannot see it.""" + that cannot see it. + + `ready_for(PAPER)` is a real answer, but not one a fresh install reaches quickly: + `rule_promoted` waits on a promotion gate that a newly-seeded rule will very likely fail, + by design. "Set up" and "has a rule worth running" are different states, and this reports + the second.""" wanted = (Stage.PAPER,) if stage is Stage.PAPER else (Stage.PAPER, Stage.LIVE) return all(state.done is True for state in self.states if state.step.stage in wanted) diff --git a/tests/commands/test_setup.py b/tests/commands/test_setup.py index 1fb3f62..9c85c4b 100644 --- a/tests/commands/test_setup.py +++ b/tests/commands/test_setup.py @@ -245,3 +245,28 @@ def test_the_rendered_checklist_names_every_step_and_the_next_action( for step in STEPS: assert step.title in text, step.key assert text.rstrip().splitlines()[-1].startswith("next: ") + + +def test_the_promotion_step_says_a_refusal_is_the_engine_working() -> None: + """Measured on a real deployment: a freshly-seeded turtle_breakout promoted from the browser + was refused with `n_trades 12 < 100`, `win_rate 0.5 < 0.55`, and the overfitting check never + run. That is correct, and it is also the single most likely thing a first-run user will see + on this step. + + Left unexplained it reads as a broken button on a checklist that has gone green everywhere + else. The step's own text has to say that a refusal is the gate doing its job, that this item + can stay outstanding for a long time, and where the deliberate bypass lives -- at a terminal, + on the record.""" + step = next(s for s in STEPS if s.key == "rule_promoted") + lowered = step.why.lower() + assert "refuse" in lowered + assert "not a fault" in lowered or "engine working" in lowered + assert "--force" in step.why + assert "terminal" in lowered + + +def test_ready_for_paper_does_not_claim_a_fresh_install_is_ready(fresh: tuple[Path, Path]) -> None: + """ "Set up" and "has a rule worth running" are different states, and a checklist that + conflated them would call a deployment ready on the strength of a rule the gate refused.""" + config_path, db_path = fresh + assert inspect(config_path, db_path).ready_for(Stage.PAPER) is False