Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 20 additions & 11 deletions keel/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)


Expand Down
54 changes: 51 additions & 3 deletions keel/commands/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"}
Expand Down Expand Up @@ -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)

Expand Down
13 changes: 13 additions & 0 deletions keel/strategy/promotion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
2 changes: 1 addition & 1 deletion keel/templates/config.live.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion keel/templates/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions packages/keel-core/keel_core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down
20 changes: 20 additions & 0 deletions tests/strategy/test_promotion.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
can_promote,
floor_for_class,
g4_pbo_gate,
next_status,
promotion_class_of,
should_demote,
transition,
Expand Down Expand Up @@ -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) ----------------------------------------


Expand Down
26 changes: 26 additions & 0 deletions tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) -----------------------------


Expand Down
108 changes: 108 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading