From 7e400a5290754dffcc90fd979d3068329d8c6730 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Fri, 24 Jul 2026 00:09:14 -0400 Subject: [PATCH] feat(rules,agent): funded paper-forward enablement - rules promote --force: deliberate un-gated candidate->paper (or paper->live) step, for starting a paper-forward whose backtest can't reach the min_trades floor (analogous to `seed --status live`). - paper seed: config.paper.starting_equity_usd > 0 now funds the paper account at that amount (an explicitly-funded rehearsal), taking precedence over the real mark-to-market seed; 0 keeps the real-equity default. Co-Authored-By: Claude Opus 4.8 --- config.yaml | 2 +- keel/agent.py | 31 ++++--- keel/commands/rules.py | 54 ++++++++++++- keel/strategy/promotion.py | 13 +++ keel/templates/config.live.yaml | 2 +- keel/templates/config.yaml | 2 +- packages/keel-core/keel_core/config.py | 11 ++- tests/strategy/test_promotion.py | 20 +++++ tests/test_agent.py | 26 ++++++ tests/test_cli.py | 108 +++++++++++++++++++++++++ 10 files changed, 248 insertions(+), 21 deletions(-) diff --git a/config.yaml b/config.yaml index 74a514ee..713facc1 100644 --- a/config.yaml +++ b/config.yaml @@ -72,7 +72,7 @@ dca: cadence_days: 7 paper: - starting_equity_usd: 0 # fallback seed only; primary seed is live mark-to-market equity + starting_equity_usd: 0 # >0 = funded paper-forward override (seeds AT this amount); 0 = seed from real mark-to-market equity monthly_contribution_usd: 0 # ongoing deposits during a paper-forward; 0 disables # The settlement currency this deployment TRADES IN. It must match the quote leg of the diff --git a/keel/agent.py b/keel/agent.py index 6bd1dd38..c88120dc 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -355,8 +355,16 @@ def _seed_paper_account_if_needed( On a paper->live or live->paper flip, clear the shared HWM/history/drawdown scalars (same keys `keel reset-hwm` clears) before this cycle's update_drawdown, so a synthetic - HWM never poisons live equity (or vice versa). Seed `paper_cash_usdc` on first paper run - from real broker mark-to-market equity, falling back to `config.paper.starting_equity_usd`. + HWM never poisons live equity (or vice versa). + + `paper_cash_usdc`'s seed amount on first paper run, by `config.paper.starting_equity_usd`: + - `starting_equity_usd > 0`: seed at THAT amount -- a deliberately-funded paper-forward + rehearsal. Real broker mark-to-market equity is NOT read for the seed in this case; the + funded amount is the source of truth, so a funded paper-forward can seed with no broker + equity read at all. + - `starting_equity_usd == 0` (the default): seed from real broker mark-to-market equity; if + that read fails, log `agent.paper_seed_unavailable` and leave the account unseeded this + cycle (no bogus 0 denominator). """ if repo.get_state("equity_state_mode") != "paper": repo.set_state("equity_high_water_mark", None) @@ -365,15 +373,16 @@ def _seed_paper_account_if_needed( repo.set_state("equity_history", []) repo.set_state("equity_state_mode", "paper") if paper_trader.get_cash() is None: - seed = _mark_to_market_equity( - repo, broker, products, price_by_product, config.quote_currency - ) - if seed is None: - fallback = config.paper.starting_equity_usd - seed = fallback if fallback > 0 else None - if seed is None: - log_event(logger, logging.WARNING, "agent.paper_seed_unavailable") - return + funding = config.paper.starting_equity_usd + if funding > 0: + seed = funding + else: + seed = _mark_to_market_equity( + repo, broker, products, price_by_product, config.quote_currency + ) + if seed is None: + log_event(logger, logging.WARNING, "agent.paper_seed_unavailable") + return paper_trader.seed_cash(seed, now_ts) diff --git a/keel/commands/rules.py b/keel/commands/rules.py index da895853..999a559e 100644 --- a/keel/commands/rules.py +++ b/keel/commands/rules.py @@ -8,11 +8,13 @@ from __future__ import annotations +import logging import time from decimal import Decimal from typing import Any import click +from keel_core.telemetry import log_event from keel import agent from keel.commands._common import _load_cfg, _open_repo, with_disclaimer @@ -22,6 +24,8 @@ from keel.strategy import promotion as promotion_mod from keel.types import Granularity +logger = logging.getLogger(__name__) + # `rules demote` steps a rule back one lifecycle stage; `disabled` is terminal (see # `strategy.promotion`'s own `_PROMOTE_NEXT` docstring) and so is not a demote target. _DEMOTE_PREV: dict[str, str] = {"live": "paper", "paper": "candidate"} @@ -107,13 +111,57 @@ def rules_backtest(ctx: click.Context, rule_id: int, granularity: str | None) -> @click.option( "--granularity", default=None, help="Override the candle granularity for the backtest." ) +@click.option( + "--force", + is_flag=True, + default=False, + help="Skip the backtest/promotion gate and advance the rule one lifecycle step directly " + "(candidate->paper, or paper->live). For a deliberate, un-gated paper-forward start when " + "a rule's backtest can never reach the min_trades floor -- analogous to `rules seed " + "--status live`'s gate bypass.", +) @click.pass_context @with_disclaimer -def rules_promote(ctx: click.Context, rule_id: int, granularity: str | None) -> None: - """Re-run a rule's backtest and advance its lifecycle status if it clears the floor.""" +def rules_promote(ctx: click.Context, rule_id: int, granularity: str | None, force: bool) -> None: + """Re-run a rule's backtest and advance its lifecycle status if it clears the floor. + + With `--force`, SKIPS the backtest/gate entirely and advances the rule one lifecycle step + directly. This exists for a low-frequency trend-follower (or any rule) whose backtest can + NEVER produce `min_trades` (default 100) trades -- without a bypass such a rule could never + reach `paper` status, yet the whole point of a paper-forward is to accrue the out-of-sample + trades the backtest can't. Use deliberately and audit the (loud) warning this prints. + """ repo = _open_repo(ctx) - config = _load_cfg(ctx) row = _require_rule_row(ctx, repo, rule_id) + + if force: + target = promotion_mod.next_status(row["status"]) + if target is None: + click.echo( + f"rule {rule_id} ({row['kind']}): already at {row['status']!r}; " + "nothing to promote" + ) + return + repo.update_rule_status(rule_id, target) + click.echo( + f"⚠️ FORCE-PROMOTING rule {rule_id} ({row['kind']}): {row['status']} -> {target}, " + "BYPASSING the backtest/promotion gate. This is for a deliberate, un-gated " + "paper-forward start (e.g. a low-frequency trend-follower whose backtest can never " + "reach the min_trades floor). Confirm this is intentional and monitor accordingly." + ) + log_event( + logger, + logging.WARNING, + "rules.promote_forced", + rule_id=rule_id, + kind=row["kind"], + from_status=row["status"], + to_status=target, + ) + click.echo(f"rule {rule_id} ({row['kind']}): status -> {target}") + return + + config = _load_cfg(ctx) rule = agent._build_rule(row) stats = _run_backtest(ctx, repo, rule, granularity) diff --git a/keel/strategy/promotion.py b/keel/strategy/promotion.py index 2dde7a09..a37595f7 100644 --- a/keel/strategy/promotion.py +++ b/keel/strategy/promotion.py @@ -36,6 +36,19 @@ _PROMOTE_NEXT: dict[str, str] = {"candidate": "paper", "paper": "live"} +def next_status(status: str) -> str | None: + """The lifecycle status one un-gated step ahead of `status` (`candidate`->`paper`, + `paper`->`live`), or `None` when `status` has no next step (`live`, `disabled`, or any + unrecognized status). + + Public wrapper around `_PROMOTE_NEXT`, for a caller that wants to advance a rule's status + WITHOUT going through `transition`'s backtest/`can_promote` gate -- e.g. `rules promote + --force`, a deliberate, auditable bypass for starting a paper-forward whose backtest can + never reach the promotion floor (see that command's docstring). + """ + return _PROMOTE_NEXT.get(status) + + @dataclass class PromotionConfig: """Performance floors a rule's stats must clear to promote (spec §11/§4.5).""" diff --git a/keel/templates/config.live.yaml b/keel/templates/config.live.yaml index 751358f9..9b7526e3 100644 --- a/keel/templates/config.live.yaml +++ b/keel/templates/config.live.yaml @@ -83,7 +83,7 @@ dca: cadence_days: 7 paper: - starting_equity_usd: 0 # fallback seed only; primary seed is live mark-to-market equity + starting_equity_usd: 0 # >0 = funded paper-forward override (seeds AT this amount); 0 = seed from real mark-to-market equity monthly_contribution_usd: 0 # ongoing deposits during a paper-forward; 0 disables # The settlement currency this deployment TRADES IN. It must match the quote leg of the diff --git a/keel/templates/config.yaml b/keel/templates/config.yaml index 74a514ee..713facc1 100644 --- a/keel/templates/config.yaml +++ b/keel/templates/config.yaml @@ -72,7 +72,7 @@ dca: cadence_days: 7 paper: - starting_equity_usd: 0 # fallback seed only; primary seed is live mark-to-market equity + starting_equity_usd: 0 # >0 = funded paper-forward override (seeds AT this amount); 0 = seed from real mark-to-market equity monthly_contribution_usd: 0 # ongoing deposits during a paper-forward; 0 disables # The settlement currency this deployment TRADES IN. It must match the quote leg of the diff --git a/packages/keel-core/keel_core/config.py b/packages/keel-core/keel_core/config.py index ba491c24..89c2d68d 100644 --- a/packages/keel-core/keel_core/config.py +++ b/packages/keel-core/keel_core/config.py @@ -133,10 +133,13 @@ class DcaConfig: class PaperConfig: """Paper-forward account model (spec: paper-mode fidelity). - `starting_equity_usd` is only a FALLBACK seed used when the one-time real-equity - read at paper-start fails; the primary seed is live mark-to-market equity. A value of - 0 means "no fallback" -- if the broker read also fails, paper drawdown tracking stays - dormant that run (logged loudly) rather than seeding a bogus 0 denominator. + `starting_equity_usd` sets the one-time seed for `paper_cash_usdc` at paper-start: + - `> 0`: a deliberate FUNDING OVERRIDE -- seed at exactly this amount (a funded + paper-forward rehearsal), taking precedence over real broker mark-to-market equity, which + is not read for the seed in this case. + - `0` (the default): seed from real broker mark-to-market equity instead; if that read + fails, paper drawdown tracking stays dormant that run (logged loudly) rather than seeding + a bogus 0 denominator. `monthly_contribution_usd` models ongoing deposits during the paper-forward; 0 disables. """ diff --git a/tests/strategy/test_promotion.py b/tests/strategy/test_promotion.py index 8246387e..70102969 100644 --- a/tests/strategy/test_promotion.py +++ b/tests/strategy/test_promotion.py @@ -23,6 +23,7 @@ can_promote, floor_for_class, g4_pbo_gate, + next_status, promotion_class_of, should_demote, transition, @@ -329,6 +330,25 @@ def test_transition_unknown_rule_raises(repo: Repository) -> None: transition(repo, "no-such-rule", _stats(), cfg) +# -- next_status: public un-gated-step helper (funded paper-forward, `rules promote --force`) -- + + +def test_next_status_candidate_to_paper() -> None: + assert next_status("candidate") == "paper" + + +def test_next_status_paper_to_live() -> None: + assert next_status("paper") == "live" + + +def test_next_status_live_has_no_next_step() -> None: + assert next_status("live") is None + + +def test_next_status_disabled_has_no_next_step() -> None: + assert next_status("disabled") is None + + # -- G4: PBO conjunction gate (spec §7) ---------------------------------------- diff --git a/tests/test_agent.py b/tests/test_agent.py index 634110b7..e960d7ae 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1187,6 +1187,32 @@ def test_seed_falls_back_to_config_when_broker_read_none(repo): assert repo.get_state("paper_cash_usdc") == Decimal("10000") +def test_seed_prefers_configured_funding_over_real_equity_when_broker_IS_readable(repo): + """A funded paper-forward: `config.paper.starting_equity_usd > 0` is a DELIBERATE funding + override and must win even when the broker's real mark-to-market equity is readable (and + very different) -- the whole point is to rehearse at a specific funded amount, not at + whatever the real account happens to hold. `FakeBroker`'s real balance ($1,000,000) would + produce a wildly different seed if the real-equity read were still used.""" + broker = FakeBroker() + cfg = _paper_config(paper=PaperConfig(starting_equity_usd=Decimal("10000"))) + + run_once(broker, repo, cfg, now_ts=90_000) + + assert repo.get_state("paper_cash_usdc") == Decimal("10000") + + +def test_seed_uses_real_equity_when_starting_equity_usd_is_zero(repo, monkeypatch): + """`starting_equity_usd == 0` (the default) keeps the existing behavior: seed from real + broker mark-to-market equity, not the (disabled) funding override.""" + broker = FakeBroker() + cfg = _paper_config(paper=PaperConfig(starting_equity_usd=Decimal("0"))) + monkeypatch.setattr(agent, "_mark_to_market_equity", lambda *a, **k: Decimal("42000")) + + run_once(broker, repo, cfg, now_ts=90_000) + + assert repo.get_state("paper_cash_usdc") == Decimal("42000") + + # -- paper fills sized off paper equity (P4 Task 6) ----------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index 90e6dec6..0b00832b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -332,6 +332,114 @@ def test_rules_promote_stays_when_floor_not_cleared(tmp_path, valid_config_path) assert "status -> candidate" in result.output +# -- rules promote --force (funded paper-forward: un-gated lifecycle step) ------------------ + + +def test_rules_promote_force_advances_candidate_to_paper_without_a_passing_backtest(tmp_path): + """A low-frequency trend-follower's backtest can never clear the `min_trades=100` floor, + yet the whole point of a paper-forward is to accrue the out-of-sample trades the backtest + can't -- `--force` advances the lifecycle step directly, no backtest/gate involved. (0 + candles here stands in for "backtest that could never reach the floor".)""" + db_path = tmp_path / "test.db" + repo = _repo_at(db_path) + rule_id = repo.insert_rule("pullback_continuation", {"product_id": "BTC-USD"}) + runner = CliRunner() + + result = runner.invoke( + cli, ["--db", str(db_path), "rules", "promote", str(rule_id), "--force"] + ) + + assert result.exit_code == 0, result.output + assert "status -> paper" in result.output + row = {r["id"]: r for r in repo.get_rules()}[rule_id] + assert row["status"] == "paper" + + +def test_rules_promote_force_advances_paper_to_live(tmp_path): + db_path = tmp_path / "test.db" + repo = _repo_at(db_path) + rule_id = repo.insert_rule("dca", {"product_id": "BTC-USD"}, status="paper") + runner = CliRunner() + + result = runner.invoke( + cli, ["--db", str(db_path), "rules", "promote", str(rule_id), "--force"] + ) + + assert result.exit_code == 0, result.output + assert "status -> live" in result.output + row = {r["id"]: r for r in repo.get_rules()}[rule_id] + assert row["status"] == "live" + + +def test_rules_promote_force_on_live_rule_is_a_noop(tmp_path): + db_path = tmp_path / "test.db" + repo = _repo_at(db_path) + rule_id = repo.insert_rule("dca", {"product_id": "BTC-USD"}, status="live") + runner = CliRunner() + + result = runner.invoke( + cli, ["--db", str(db_path), "rules", "promote", str(rule_id), "--force"] + ) + + assert result.exit_code == 0, result.output + assert "nothing to promote" in result.output.lower() + row = {r["id"]: r for r in repo.get_rules()}[rule_id] + assert row["status"] == "live" + + +def test_rules_promote_force_on_disabled_rule_is_a_noop(tmp_path): + db_path = tmp_path / "test.db" + repo = _repo_at(db_path) + rule_id = repo.insert_rule("dca", {"product_id": "BTC-USD"}, status="disabled") + runner = CliRunner() + + result = runner.invoke( + cli, ["--db", str(db_path), "rules", "promote", str(rule_id), "--force"] + ) + + assert result.exit_code == 0, result.output + assert "nothing to promote" in result.output.lower() + row = {r["id"]: r for r in repo.get_rules()}[rule_id] + assert row["status"] == "disabled" + + +def test_rules_promote_force_prints_a_loud_bypass_warning(tmp_path): + db_path = tmp_path / "test.db" + repo = _repo_at(db_path) + rule_id = repo.insert_rule("pullback_continuation", {"product_id": "BTC-USD"}) + runner = CliRunner() + + result = runner.invoke( + cli, ["--db", str(db_path), "rules", "promote", str(rule_id), "--force"] + ) + + assert result.exit_code == 0, result.output + assert "bypass" in result.output.lower() + + +def test_rules_promote_without_force_still_gates_on_the_backtest(tmp_path, valid_config_path): + """Unchanged non-force behavior: no candles -> the backtest can't clear the floor -> the + rule stays `candidate`, exactly as `test_rules_promote_stays_when_floor_not_cleared` covers.""" + db_path = tmp_path / "test.db" + repo = _repo_at(db_path) + rule_id = repo.insert_rule("pullback_continuation", {"product_id": "BTC-USD"}) + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "--db", str(db_path), + "--config", str(valid_config_path), + "rules", "promote", str(rule_id), + ], + ) + + assert result.exit_code == 0, result.output + assert "status -> candidate" in result.output + row = {r["id"]: r for r in repo.get_rules()}[rule_id] + assert row["status"] == "candidate" + + def test_rules_demote_steps_back_one_stage(tmp_path): db_path = tmp_path / "test.db" repo = _repo_at(db_path)