From 8c92c0fc1a682aae69ae42cba5ae7627ae4f41ae Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 17:55:40 -0400 Subject: [PATCH 01/12] feat(config): add PaperConfig (starting_equity_usd, monthly_contribution_usd) Adds the paper-forward account model config block ahead of paper-mode fidelity wiring: a fallback equity seed (0 = no fallback, primary seed is live mark-to-market equity) plus an optional monthly contribution. No behavior wired yet -- config field, parser, templates, and golden fixtures only. --- config.yaml | 4 ++++ keel/templates/config.live.yaml | 4 ++++ keel/templates/config.yaml | 4 ++++ packages/keel-core/keel_core/config.py | 26 ++++++++++++++++++++++ tests/fixtures/config_golden_defaults.json | 4 ++++ tests/fixtures/config_golden_defaults.yaml | 4 ++++ tests/fixtures/config_golden_full.json | 4 ++++ tests/fixtures/config_golden_full.yaml | 4 ++++ tests/test_config.py | 25 +++++++++++++++++++++ 9 files changed, 79 insertions(+) diff --git a/config.yaml b/config.yaml index 7f7f8fb3..74a514ee 100644 --- a/config.yaml +++ b/config.yaml @@ -71,6 +71,10 @@ dca: budget_usd: 50 cadence_days: 7 +paper: + starting_equity_usd: 0 # fallback seed only; primary seed is live 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 # products you actually trade: everything here is `-USD` (see `_default_sim_products` / # `_history_product`), so this is USD. It is NOT used to decide which balance funds a given diff --git a/keel/templates/config.live.yaml b/keel/templates/config.live.yaml index d5e95214..751358f9 100644 --- a/keel/templates/config.live.yaml +++ b/keel/templates/config.live.yaml @@ -82,6 +82,10 @@ dca: budget_usd: 50 cadence_days: 7 +paper: + starting_equity_usd: 0 # fallback seed only; primary seed is live 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 # products you actually trade: everything here is `-USD` (see `_default_sim_products` / # `_history_product`), so this is USD. It is NOT used to decide which balance funds a given diff --git a/keel/templates/config.yaml b/keel/templates/config.yaml index 7f7f8fb3..74a514ee 100644 --- a/keel/templates/config.yaml +++ b/keel/templates/config.yaml @@ -71,6 +71,10 @@ dca: budget_usd: 50 cadence_days: 7 +paper: + starting_equity_usd: 0 # fallback seed only; primary seed is live 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 # products you actually trade: everything here is `-USD` (see `_default_sim_products` / # `_history_product`), so this is USD. It is NOT used to decide which balance funds a given diff --git a/packages/keel-core/keel_core/config.py b/packages/keel-core/keel_core/config.py index 506f27ba..ba491c24 100644 --- a/packages/keel-core/keel_core/config.py +++ b/packages/keel-core/keel_core/config.py @@ -129,6 +129,21 @@ class DcaConfig: cadence_days: int = 7 +@dataclass(frozen=True) +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. + `monthly_contribution_usd` models ongoing deposits during the paper-forward; 0 disables. + """ + + starting_equity_usd: Decimal = Decimal("0") + monthly_contribution_usd: Decimal = Decimal("0") + + _VALID_PACING_MODES = ("opportunistic", "even_daily") #: `paper` simulates and places nothing; `confirm` is live. Whether you are ASKED is a #: PROFILE choice (`keel autonomy on|off`), deliberately not a config mode -- config.yaml @@ -255,6 +270,7 @@ class Config: promotion: PromotionConfig = field(default_factory=PromotionConfig) money_mgmt: MoneyMgmtConfig = field(default_factory=MoneyMgmtConfig) dca: DcaConfig = field(default_factory=DcaConfig) + paper: PaperConfig = field(default_factory=PaperConfig) subscription: SubscriptionConfig = field(default_factory=SubscriptionConfig) tiers: tuple[TierConfig, ...] = field(default_factory=_default_tiers) fees: FeesConfig = field(default_factory=FeesConfig) @@ -491,6 +507,7 @@ def load_config(path: str | Path) -> Config: promotion_raw = raw.get("promotion") or {} money_mgmt_raw = raw.get("money_mgmt") or {} dca_raw = raw.get("dca") or {} + paper_raw = raw.get("paper") or {} subscription_raw = raw.get("subscription") or {} pacing = subscription_raw.get("pacing", "opportunistic") @@ -573,6 +590,14 @@ def load_config(path: str | Path) -> Config: budget_usd=_to_decimal(dca_raw.get("budget_usd", "0"), "dca.budget_usd"), cadence_days=int(dca_raw.get("cadence_days", 7)), ), + paper=PaperConfig( + starting_equity_usd=_non_negative_decimal( + paper_raw.get("starting_equity_usd", "0"), "paper.starting_equity_usd" + ), + monthly_contribution_usd=_non_negative_decimal( + paper_raw.get("monthly_contribution_usd", "0"), "paper.monthly_contribution_usd" + ), + ), subscription=SubscriptionConfig( assumed_free_volume_usd=_non_negative_decimal( subscription_raw.get("assumed_free_volume_usd", "500"), @@ -619,6 +644,7 @@ def load_secrets(env_path: str | Path = ".env") -> dict: "PromotionConfig", "MoneyMgmtConfig", "DcaConfig", + "PaperConfig", "SubscriptionConfig", "TierConfig", "LoggingConfig", diff --git a/tests/fixtures/config_golden_defaults.json b/tests/fixtures/config_golden_defaults.json index c56a0452..ac3223b0 100644 --- a/tests/fixtures/config_golden_defaults.json +++ b/tests/fixtures/config_golden_defaults.json @@ -39,6 +39,10 @@ "profit_trigger_pct": "0.1", "streak_cooloff_days": 0 }, + "paper": { + "monthly_contribution_usd": "0", + "starting_equity_usd": "0" + }, "promotion": { "min_expectancy": "0", "min_rr": "1.5", diff --git a/tests/fixtures/config_golden_defaults.yaml b/tests/fixtures/config_golden_defaults.yaml index 1a16db17..93a2c61e 100644 --- a/tests/fixtures/config_golden_defaults.yaml +++ b/tests/fixtures/config_golden_defaults.yaml @@ -13,3 +13,7 @@ caps: max_per_day_usd: 300 max_exposure_usd: 1000 max_per_asset_pct: 0.5 + +paper: + starting_equity_usd: 0 # fallback seed only; primary seed is live mark-to-market equity + monthly_contribution_usd: 0 # ongoing deposits during a paper-forward; 0 disables diff --git a/tests/fixtures/config_golden_full.json b/tests/fixtures/config_golden_full.json index 4be965cc..fe4ccf73 100644 --- a/tests/fixtures/config_golden_full.json +++ b/tests/fixtures/config_golden_full.json @@ -44,6 +44,10 @@ "profit_trigger_pct": "0.22", "streak_cooloff_days": 2 }, + "paper": { + "monthly_contribution_usd": "500", + "starting_equity_usd": "30000" + }, "promotion": { "min_expectancy": "0.21", "min_rr": "1.75", diff --git a/tests/fixtures/config_golden_full.yaml b/tests/fixtures/config_golden_full.yaml index b7fa5ebc..8e384c1a 100644 --- a/tests/fixtures/config_golden_full.yaml +++ b/tests/fixtures/config_golden_full.yaml @@ -54,6 +54,10 @@ dca: budget_usd: 75.5 cadence_days: 14 +paper: + starting_equity_usd: 30000 + monthly_contribution_usd: 500 + subscription: assumed_free_volume_usd: 1234.5 unsubscribed_allowance_usd: 25 diff --git a/tests/test_config.py b/tests/test_config.py index aaf2c2cc..c7d28e94 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -492,3 +492,28 @@ def test_an_unknown_mode_names_the_offending_key(tmp_path): with pytest.raises(ConfigError) as exc: load_config(str(p)) assert "wibble" in str(exc.value) + + +# -- paper: PaperConfig (paper-mode fidelity: real-equity denominator seed) ----------------- + + +def test_paper_config_parsed_from_yaml(write_config): + cfg_text = ( + VALID_CONFIG_YAML + + "\npaper:\n starting_equity_usd: 30000\n monthly_contribution_usd: 500\n" + ) + cfg = load_config(write_config(cfg_text)) + assert cfg.paper.starting_equity_usd == Decimal("30000") + assert cfg.paper.monthly_contribution_usd == Decimal("500") + + +def test_paper_config_defaults_when_absent(valid_config_path): + cfg = load_config(valid_config_path) # VALID_CONFIG_YAML has no paper: block + assert cfg.paper.starting_equity_usd == Decimal("0") + assert cfg.paper.monthly_contribution_usd == Decimal("0") + + +def test_paper_config_rejects_negative(write_config): + cfg_text = VALID_CONFIG_YAML + "\npaper:\n starting_equity_usd: -1\n" + with pytest.raises(ConfigError): + load_config(write_config(cfg_text)) From 11b2a99cb1a2b166cc928121d8eb97d07f34b67c Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 18:00:41 -0400 Subject: [PATCH 02/12] feat(paper): fills carry a caller-supplied qty (default 1) --- keel/strategy/paper.py | 29 +++++++++++++++++------------ tests/strategy/test_paper.py | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/keel/strategy/paper.py b/keel/strategy/paper.py index acecb39c..0eea1a61 100644 --- a/keel/strategy/paper.py +++ b/keel/strategy/paper.py @@ -48,6 +48,7 @@ class _OpenPaperPosition: entry_order_id: int entry_fill: Decimal entry_ts: int + qty: Decimal = _QTY mfe: Decimal = Decimal(0) mae: Decimal = Decimal(0) @@ -125,12 +126,15 @@ def _load_open_positions(self) -> None: entry_order_id=order_id, entry_fill=Decimal(payload["entry"]), entry_ts=setup.ts, + qty=Decimal(payload["qty"]), ) def has_open_position(self, product_id: str) -> bool: return product_id in self._open - def on_signal(self, signal: Signal, candle: Candle | None = None) -> int | None: + def on_signal( + self, signal: Signal, candle: Candle | None = None, qty: Decimal = _QTY + ) -> int | None: """Apply one `Signal`, writing a paper order if it results in a fill. ENTER: opens a paper position for `signal.product_id` and immediately writes @@ -144,7 +148,7 @@ def on_signal(self, signal: Signal, candle: Candle | None = None) -> int | None: Returns the written order's id, or `None` if nothing was written. """ if signal.action == Action.ENTER: - return self._enter(signal) + return self._enter(signal, qty) if signal.action == Action.EXIT: if candle is None: return None @@ -179,20 +183,20 @@ def on_candle(self, product_id: str, candle: Candle) -> int | None: return self._close(position, exit_price, candle.ts) - def _enter(self, signal: Signal) -> int | None: + def _enter(self, signal: Signal, qty: Decimal = _QTY) -> int | None: if signal.setup is None or signal.product_id in self._open: return None setup = signal.setup entry_fill = setup.entry * (Decimal(1) + self._slippage_pct) - fee = entry_fill * _QTY * self._fee_pct + fee = entry_fill * qty * self._fee_pct payload = { "role": "entry", "rule_name": signal.rule_name, "entry": str(entry_fill), "stop": str(setup.stop), "target": str(setup.target), - "qty": str(_QTY), + "qty": str(qty), "ts": setup.ts, } order_id = self._repo.insert_order( @@ -201,7 +205,7 @@ def _enter(self, signal: Signal) -> int | None: "product_id": signal.product_id, "side": Side.BUY.value, "order_type": "market", - "qty": _QTY, + "qty": qty, "limit_price": setup.entry, "status": "filled", "fee": fee, @@ -221,6 +225,7 @@ def _enter(self, signal: Signal) -> int | None: entry_order_id=order_id, entry_fill=entry_fill, entry_ts=setup.ts, + qty=qty, ) return order_id @@ -234,11 +239,11 @@ def _exit_on_signal(self, signal: Signal, candle: Candle) -> int | None: def _close(self, position: _OpenPaperPosition, exit_price: Decimal, exit_ts: int) -> int: exit_fill = exit_price * (Decimal(1) - self._slippage_pct) - entry_fee = position.entry_fill * _QTY * self._fee_pct - exit_fee = exit_fill * _QTY * self._fee_pct - pnl = (exit_fill - position.entry_fill) * _QTY - entry_fee - exit_fee + entry_fee = position.entry_fill * position.qty * self._fee_pct + exit_fee = exit_fill * position.qty * self._fee_pct + pnl = (exit_fill - position.entry_fill) * position.qty - entry_fee - exit_fee - risk = (position.entry_fill - position.setup.stop) * _QTY + risk = (position.entry_fill - position.setup.stop) * position.qty r_multiple = pnl / risk if risk != 0 else None if pnl > 0: @@ -254,7 +259,7 @@ def _close(self, position: _OpenPaperPosition, exit_price: Decimal, exit_ts: int "entry_order_id": position.entry_order_id, "entry": str(position.entry_fill), "exit": str(exit_fill), - "qty": str(_QTY), + "qty": str(position.qty), "pnl": str(pnl), "r_multiple": str(r_multiple) if r_multiple is not None else None, "mfe": str(position.mfe), @@ -269,7 +274,7 @@ def _close(self, position: _OpenPaperPosition, exit_price: Decimal, exit_ts: int "product_id": position.product_id, "side": Side.SELL.value, "order_type": "market", - "qty": _QTY, + "qty": position.qty, "limit_price": exit_price, "status": "filled", "fee": exit_fee, diff --git a/tests/strategy/test_paper.py b/tests/strategy/test_paper.py index 02a7977e..6f4277df 100644 --- a/tests/strategy/test_paper.py +++ b/tests/strategy/test_paper.py @@ -140,6 +140,26 @@ def test_no_touch_leaves_position_open_and_writes_no_exit_order( assert exit_order_id is None assert trader.has_open_position("BTC-USD") + def test_paper_entry_records_supplied_qty(self, repo: Repository) -> None: + trader = PaperTrader(repo) + signal = _enter_signal( + setup=Setup( + product_id="BTC-USD", + direction="long", + entry=Decimal("100"), + stop=Decimal("90"), + target=Decimal("130"), + context={}, + ts=1_000, + ) + ) + order_id = trader.on_signal(signal, qty=Decimal("3")) + + assert order_id is not None + order = repo.get_order(order_id) + assert order["qty"] == Decimal("3") + assert json.loads(order["raw_response"])["qty"] == "3" + class TestMfeMae: def test_mfe_and_mae_recorded_on_close(self, repo: Repository) -> None: From 94ae1a00a4b826ab56a058ace6768fc61831fda2 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 18:07:55 -0400 Subject: [PATCH 03/12] feat(paper): synthetic cash ledger, funding check, equity(), epoch cutoff Gives PaperTrader a persisted synthetic cash balance (paper_cash_usdc), a funding check in _enter that rejects a fill when cash is insufficient, an equity() method built on a new shared keel.execution.equity.mark_positions helper (cash + mark-to-market positions, cost-basis fallback on stale/missing prices), and an epoch cutoff (paper_ledger_start_ts) so rehydration ignores legacy pre-epoch orders written before the synthetic account existed. --- keel/execution/equity.py | 26 ++++++++ keel/strategy/paper.py | 71 +++++++++++++++++++++ tests/execution/test_paper_equity.py | 64 +++++++++++++++++++ tests/strategy/test_paper.py | 93 ++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 tests/execution/test_paper_equity.py diff --git a/keel/execution/equity.py b/keel/execution/equity.py index eea3b84e..bb08914c 100644 --- a/keel/execution/equity.py +++ b/keel/execution/equity.py @@ -29,6 +29,32 @@ UNEXPLAINED_JUMP_PCT = Decimal("0.25") +def mark_positions( + cash: Decimal, + positions: list[tuple[Decimal, Decimal]], + price_by_product: dict[str, Decimal], + product_ids: list[str], +) -> Decimal: + """Mark-to-market equity = cash + Σ qty·mark, with a cost-basis fallback. + + `positions[i]` is `(qty, cost_basis)` for `product_ids[i]`. A product with no fresh + price in `price_by_product` is valued at its `cost_basis` rather than dropped -- dropping + a held position understates equity and would trip a drawdown breaker on a data gap rather + than a loss (mirrors agent._mark_to_market_equity's fallback). + """ + total = cash + for (qty, cost_basis), product_id in zip(positions, product_ids): + if qty <= 0: + continue + mark = price_by_product.get(product_id) + if mark is None or mark <= 0: + mark = cost_basis + if mark <= 0: + continue + total += qty * mark + return total + + def record_external_flow(repo: Repository, *, amount: Decimal) -> None: """Rebase the high-water mark and the rolling weekly peak by an external cash flow. diff --git a/keel/strategy/paper.py b/keel/strategy/paper.py index 0eea1a61..43766e39 100644 --- a/keel/strategy/paper.py +++ b/keel/strategy/paper.py @@ -24,14 +24,19 @@ from __future__ import annotations import json +import logging from dataclasses import dataclass from decimal import Decimal +from keel_core.telemetry import log_event + from keel.data.repository import Repository from keel.strategy.rules.base import Action, Setup, Signal, Trade from keel.strategy.stats import BacktestResult, summarize from keel.types import Candle, Side +logger = logging.getLogger(__name__) + # Position sizing (money management) is out of scope here, same as backtest.py: # every paper trade uses a fixed 1-unit notional, sufficient for win-rate/ # expectancy/drawdown/R-multiple stats. @@ -75,7 +80,9 @@ def __init__( self._fee_pct = fee_pct self._slippage_pct = slippage_pct self._open: dict[str, _OpenPaperPosition] = {} + self._ledger_start_ts = self._repo.get_state("paper_ledger_start_ts") self._load_open_positions() + self._cash = self._repo.get_state("paper_cash_usdc") def _load_open_positions(self) -> None: """Rebuild open paper positions from the orders table. @@ -99,6 +106,13 @@ def _load_open_positions(self) -> None: payload = json.loads(order.get("raw_response") or "{}") except (TypeError, ValueError): continue + if self._ledger_start_ts is not None: + order_ts = payload.get("ts") or order.get("created_at") or 0 + if int(order_ts) < self._ledger_start_ts: + # Pre-epoch legacy order (predates this synthetic account's seed + # timestamp) -- never rehydrate it, so a legacy 1-unit paper + # position can't silently reappear in the synthetic ledger. + continue if payload.get("role") == "exit": entry_id = payload.get("entry_order_id") if entry_id is not None: @@ -132,6 +146,41 @@ def _load_open_positions(self) -> None: def has_open_position(self, product_id: str) -> bool: return product_id in self._open + def get_cash(self) -> Decimal | None: + return self._cash + + def seed_cash(self, amount: Decimal, now_ts: int) -> None: + """Set the synthetic cash balance, opting this repo into the funding check. + + Also stamps `paper_ledger_start_ts` the first time it's called (never overwritten + after) -- the epoch cutoff `_load_open_positions` uses to ignore legacy pre-epoch + orders written before the synthetic account existed. + """ + self._cash = amount + self._repo.set_state("paper_cash_usdc", amount) + if self._repo.get_state("paper_ledger_start_ts") is None: + self._ledger_start_ts = now_ts + self._repo.set_state("paper_ledger_start_ts", now_ts) + + def deposit(self, amount: Decimal) -> None: + if self._cash is None: + return + self._cash += amount + self._repo.set_state("paper_cash_usdc", self._cash) + + def equity(self, price_by_product: dict[str, Decimal]) -> Decimal | None: + """Mark-to-market equity: synthetic cash plus open paper positions. + + `None` iff cash is unseeded -- there is no synthetic account to mark yet. + """ + if self._cash is None: + return None + from keel.execution.equity import mark_positions + + product_ids = list(self._open.keys()) + positions = [(self._open[p].qty, self._open[p].entry_fill) for p in product_ids] + return mark_positions(self._cash, positions, price_by_product, product_ids) + def on_signal( self, signal: Signal, candle: Candle | None = None, qty: Decimal = _QTY ) -> int | None: @@ -188,6 +237,22 @@ def _enter(self, signal: Signal, qty: Decimal = _QTY) -> int | None: return None setup = signal.setup + + if self._cash is not None: + notional = setup.entry * qty + if self._cash < notional: + # Paper-path guard only -- a rejection here just means no synthetic + # fill; it never touches guards.py's live-order checks. + log_event( + logger, + logging.INFO, + "paper.funding_skip", + product_id=signal.product_id, + cash=str(self._cash), + notional=str(notional), + ) + return None + entry_fill = setup.entry * (Decimal(1) + self._slippage_pct) fee = entry_fill * qty * self._fee_pct payload = { @@ -227,6 +292,9 @@ def _enter(self, signal: Signal, qty: Decimal = _QTY) -> int | None: entry_ts=setup.ts, qty=qty, ) + if self._cash is not None: + self._cash -= entry_fill * qty + fee + self._repo.set_state("paper_cash_usdc", self._cash) return order_id def _exit_on_signal(self, signal: Signal, candle: Candle) -> int | None: @@ -288,6 +356,9 @@ def _close(self, position: _OpenPaperPosition, exit_price: Decimal, exit_ts: int } ) del self._open[position.product_id] + if self._cash is not None: + self._cash += exit_fill * position.qty - exit_fee + self._repo.set_state("paper_cash_usdc", self._cash) return order_id diff --git a/tests/execution/test_paper_equity.py b/tests/execution/test_paper_equity.py new file mode 100644 index 00000000..873a7d20 --- /dev/null +++ b/tests/execution/test_paper_equity.py @@ -0,0 +1,64 @@ +"""Tests for `keel.execution.equity.mark_positions`, the mark-to-market helper shared +between `PaperTrader.equity()` and the live agent's equity computation. +""" + +from __future__ import annotations + +from decimal import Decimal + +from keel.execution.equity import mark_positions + + +def test_mark_positions_uses_fresh_price(): + eq = mark_positions( + cash=Decimal("1000"), + positions=[(Decimal("2"), Decimal("100"))], # qty=2, cost basis 100 + price_by_product={"BTC-USD": Decimal("150")}, + product_ids=["BTC-USD"], + ) + assert eq == Decimal("1000") + Decimal("2") * Decimal("150") + + +def test_mark_positions_falls_back_to_cost_basis_when_price_missing(): + eq = mark_positions( + cash=Decimal("1000"), + positions=[(Decimal("2"), Decimal("100"))], + price_by_product={}, # no fresh price + product_ids=["BTC-USD"], + ) + assert eq == Decimal("1000") + Decimal("2") * Decimal("100") # cost-basis fallback + + +def test_mark_positions_falls_back_when_price_is_non_positive(): + eq = mark_positions( + cash=Decimal("1000"), + positions=[(Decimal("2"), Decimal("100"))], + price_by_product={"BTC-USD": Decimal("0")}, + product_ids=["BTC-USD"], + ) + assert eq == Decimal("1000") + Decimal("2") * Decimal("100") + + +def test_mark_positions_skips_non_positive_qty(): + eq = mark_positions( + cash=Decimal("1000"), + positions=[(Decimal("0"), Decimal("100"))], + price_by_product={"BTC-USD": Decimal("150")}, + product_ids=["BTC-USD"], + ) + assert eq == Decimal("1000") + + +def test_mark_positions_with_no_positions_returns_cash(): + eq = mark_positions(cash=Decimal("1000"), positions=[], price_by_product={}, product_ids=[]) + assert eq == Decimal("1000") + + +def test_mark_positions_sums_multiple_products(): + eq = mark_positions( + cash=Decimal("1000"), + positions=[(Decimal("2"), Decimal("100")), (Decimal("1"), Decimal("50"))], + price_by_product={"BTC-USD": Decimal("150")}, # ETH-USD missing -> cost-basis fallback + product_ids=["BTC-USD", "ETH-USD"], + ) + assert eq == Decimal("1000") + Decimal("2") * Decimal("150") + Decimal("1") * Decimal("50") diff --git a/tests/strategy/test_paper.py b/tests/strategy/test_paper.py index 6f4277df..9553bf9c 100644 --- a/tests/strategy/test_paper.py +++ b/tests/strategy/test_paper.py @@ -361,3 +361,96 @@ def test_a_rehydrated_position_still_exits_on_its_original_stop(repo): def test_rehydration_on_an_empty_repo_is_a_no_op(repo): assert PaperTrader(repo).has_open_position("BTC-USD") is False + + +# -- synthetic cash, funding check, equity, epoch cutoff ----------------------- + + +def test_paper_equity_seed_and_mark(repo): + trader = PaperTrader(repo) + assert trader.equity({"BTC-USD": Decimal("100")}) is None # unseeded + + trader.seed_cash(Decimal("30000"), now_ts=1_700_000_000) + assert trader.equity({}) == Decimal("30000") # all cash, no positions + + # open a position and re-mark + sig = _enter_signal(setup=_setup(entry="100", stop="90", target="130")) + trader.on_signal(sig, qty=Decimal("5")) + eq = trader.equity({"BTC-USD": Decimal("120")}) + # cash was debited by fill+fee; positions valued at 5*120 + assert eq < Decimal("30000") + Decimal("5") * Decimal("120") # fee/slippage drag + assert eq > Decimal("29000") + + +def test_paper_funding_check_rejects_when_cash_insufficient(repo): + trader = PaperTrader(repo) + trader.seed_cash(Decimal("50"), now_ts=1_700_000_000) + sig = _enter_signal(setup=_setup(entry="100", stop="90", target="130")) + # notional 5*100 = 500 >> 50 cash -> no fill + assert trader.on_signal(sig, qty=Decimal("5")) is None + assert trader.get_cash() == Decimal("50") # unchanged + assert not trader.has_open_position("BTC-USD") + assert repo.get_orders(mode="paper") == [] + + +def test_paper_funding_check_allows_when_cash_unseeded(repo): + """Unseeded (`None`) cash must not block fills -- the funding check only applies + once the operator has opted into a synthetic account via `seed_cash`. + """ + trader = PaperTrader(repo) + sig = _enter_signal(setup=_setup(entry="100", stop="90", target="130")) + assert trader.on_signal(sig, qty=Decimal("5")) is not None + + +def test_seed_cash_only_sets_ledger_start_ts_once(repo): + trader = PaperTrader(repo) + trader.seed_cash(Decimal("1000"), now_ts=1_700_000_000) + trader.seed_cash(Decimal("2000"), now_ts=1_800_000_000) + assert repo.get_state("paper_ledger_start_ts") == 1_700_000_000 + assert trader.get_cash() == Decimal("2000") + + +def test_deposit_adds_to_cash(repo): + trader = PaperTrader(repo) + trader.seed_cash(Decimal("1000"), now_ts=1_700_000_000) + trader.deposit(Decimal("500")) + assert trader.get_cash() == Decimal("1500") + assert repo.get_state("paper_cash_usdc") == Decimal("1500") + + +def test_deposit_is_a_noop_when_cash_unseeded(repo): + trader = PaperTrader(repo) + trader.deposit(Decimal("500")) + assert trader.get_cash() is None + + +def test_cash_debited_on_entry_and_credited_on_exit(repo): + trader = PaperTrader(repo) + trader.seed_cash(Decimal("1000"), now_ts=1_000) + trader.on_signal(_enter_signal(ts=1_000)) # qty defaults to 1, entry 100 + + entry_fill = Decimal("100") * (Decimal(1) + SLIPPAGE_PCT) + fee = entry_fill * FEE_PCT + expected_after_entry = Decimal("1000") - entry_fill - fee + assert trader.get_cash() == expected_after_entry + + trader.on_candle("BTC-USD", _candle(1_060, "115", "121", "114", "120")) # closes at target + exit_fill = Decimal("120") * (Decimal(1) - SLIPPAGE_PCT) + exit_fee = exit_fill * FEE_PCT + expected_after_exit = expected_after_entry + exit_fill - exit_fee + assert trader.get_cash() == expected_after_exit + + +def test_pre_epoch_orders_are_skipped_on_rehydration(repo): + """Legacy 1-unit orders written before `paper_ledger_start_ts` was seeded must + never rehydrate into the synthetic account. + """ + legacy = PaperTrader(repo) + legacy.on_signal(_enter_signal(ts=1_000)) + assert legacy.has_open_position("BTC-USD") + + # operator now seeds a synthetic ledger epoch AFTER the legacy order was written + legacy.seed_cash(Decimal("30000"), now_ts=2_000) + + resumed = PaperTrader(repo) + assert resumed.has_open_position("BTC-USD") is False From 26678e3399e9abdd98bf1c6ca62b3de7a308d1a7 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 18:18:38 -0400 Subject: [PATCH 04/12] fix(paper): correct-by-construction cash ledger, funding check on actual fill cost Fix 1: add costed:bool to _OpenPaperPosition so the "was this position debited?" decision is recorded at open time and read back (not re-evaluated) at close and in equity(). Without this, a position opened while cash was unseeded, then seeded before it closed, credited cash with no matching debit and inflated equity() by marking an uncosted position -- both manufactured equity from nothing. Rehydrated positions are always post-epoch (the ledger-start cutoff already excludes anything earlier), so they're marked costed=True. Fix 2: the funding check in _enter now gates on the actual debit (entry_fill*qty + fee) instead of the coarser intent notional (entry*qty), so cash can no longer go negative for a seed strictly between the two -- per spec Sec 4.2's stated purpose for the check. --- keel/strategy/paper.py | 47 ++++++++++++++++++++++++++--------- tests/strategy/test_paper.py | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/keel/strategy/paper.py b/keel/strategy/paper.py index 43766e39..98c73567 100644 --- a/keel/strategy/paper.py +++ b/keel/strategy/paper.py @@ -56,6 +56,17 @@ class _OpenPaperPosition: qty: Decimal = _QTY mfe: Decimal = Decimal(0) mae: Decimal = Decimal(0) + costed: bool = False + """Whether this position's entry was debited against synthetic cash. + + Cash can be seeded (`seed_cash`) at any time relative to a position's open, so the + "is cash seeded?" check at entry and at exit can disagree for the SAME position (opened + while unseeded, then seeded before it closes). Without this flag, `_close` would credit + an exit that was never debited, and `equity()` would mark a position on top of cash that + never paid for it -- both manufacture equity out of nothing. Recording, at open time, + whether a debit actually happened keeps the close-side credit and the equity mark + correct-by-construction regardless of seed timing. + """ def _touches(candle: Candle, price: Decimal) -> bool: @@ -141,6 +152,11 @@ def _load_open_positions(self) -> None: entry_fill=Decimal(payload["entry"]), entry_ts=setup.ts, qty=Decimal(payload["qty"]), + # A surviving (non-filtered) order is always post-epoch: the cutoff above + # already dropped anything predating `paper_ledger_start_ts`, and that + # timestamp is only ever set by `seed_cash` -- so every rehydrated position + # was opened while cash was seeded, and was costed at the time. + costed=True, ) def has_open_position(self, product_id: str) -> bool: @@ -169,15 +185,18 @@ def deposit(self, amount: Decimal) -> None: self._repo.set_state("paper_cash_usdc", self._cash) def equity(self, price_by_product: dict[str, Decimal]) -> Decimal | None: - """Mark-to-market equity: synthetic cash plus open paper positions. + """Mark-to-market equity: synthetic cash plus costed open paper positions. - `None` iff cash is unseeded -- there is no synthetic account to mark yet. + `None` iff cash is unseeded -- there is no synthetic account to mark yet. An open + position that was never debited against cash (`costed=False`, opened before cash was + seeded) is excluded -- marking it would inflate equity with a position nothing paid + for. """ if self._cash is None: return None from keel.execution.equity import mark_positions - product_ids = list(self._open.keys()) + product_ids = [p for p in self._open if self._open[p].costed] positions = [(self._open[p].qty, self._open[p].entry_fill) for p in product_ids] return mark_positions(self._cash, positions, price_by_product, product_ids) @@ -237,10 +256,17 @@ def _enter(self, signal: Signal, qty: Decimal = _QTY) -> int | None: return None setup = signal.setup + entry_fill = setup.entry * (Decimal(1) + self._slippage_pct) + fee = entry_fill * qty * self._fee_pct - if self._cash is not None: - notional = setup.entry * qty - if self._cash < notional: + costed = self._cash is not None + if costed: + # Gate on the ACTUAL debit (fill + slippage + fee), not the coarser intent + # notional (entry * qty) -- gating on notional alone would let cash go + # negative for any seed strictly between the two, defeating the check's + # purpose (spec Sec. 4.2: keep cash from going negative). + fill_cost = entry_fill * qty + fee + if self._cash < fill_cost: # Paper-path guard only -- a rejection here just means no synthetic # fill; it never touches guards.py's live-order checks. log_event( @@ -249,12 +275,10 @@ def _enter(self, signal: Signal, qty: Decimal = _QTY) -> int | None: "paper.funding_skip", product_id=signal.product_id, cash=str(self._cash), - notional=str(notional), + fill_cost=str(fill_cost), ) return None - entry_fill = setup.entry * (Decimal(1) + self._slippage_pct) - fee = entry_fill * qty * self._fee_pct payload = { "role": "entry", "rule_name": signal.rule_name, @@ -291,8 +315,9 @@ def _enter(self, signal: Signal, qty: Decimal = _QTY) -> int | None: entry_fill=entry_fill, entry_ts=setup.ts, qty=qty, + costed=costed, ) - if self._cash is not None: + if costed: self._cash -= entry_fill * qty + fee self._repo.set_state("paper_cash_usdc", self._cash) return order_id @@ -356,7 +381,7 @@ def _close(self, position: _OpenPaperPosition, exit_price: Decimal, exit_ts: int } ) del self._open[position.product_id] - if self._cash is not None: + if position.costed and self._cash is not None: self._cash += exit_fill * position.qty - exit_fee self._repo.set_state("paper_cash_usdc", self._cash) return order_id diff --git a/tests/strategy/test_paper.py b/tests/strategy/test_paper.py index 9553bf9c..d0b33bd5 100644 --- a/tests/strategy/test_paper.py +++ b/tests/strategy/test_paper.py @@ -454,3 +454,51 @@ def test_pre_epoch_orders_are_skipped_on_rehydration(repo): resumed = PaperTrader(repo) assert resumed.has_open_position("BTC-USD") is False + + +def test_position_opened_before_seeding_is_never_costed(repo): + """A position opened while cash is unseeded, later seeded mid-flight, must not + desync the ledger: no debit happened at open, so no credit may happen at close, + and it must never inflate `equity()` -- otherwise seeding after the fact would + silently manufacture free equity out of an uncosted position. + """ + trader = PaperTrader(repo) + trader.on_signal(_enter_signal(setup=_setup(entry="100", stop="90", target="130"), ts=1_000)) + assert trader.has_open_position("BTC-USD") + assert trader.get_cash() is None # unseeded -- no debit was possible + + trader.seed_cash(Decimal("30000"), now_ts=1_700_000_000) + + # equity must be exactly cash -- the uncosted open position contributes nothing + assert trader.equity({"BTC-USD": Decimal("120")}) == Decimal("30000") + + # close it via a stop touch + trader.on_candle("BTC-USD", _candle(2_000, "89", "91", "88", "90")) + assert not trader.has_open_position("BTC-USD") + + # no credit for an uncosted position -- cash stays exactly what was seeded + assert trader.get_cash() == Decimal("30000") + + +def test_funding_check_rejects_at_boundary_between_notional_and_actual_fill_cost(repo): + """The funding check must gate on the ACTUAL debit (fill + slippage + fee), not + the coarser intent notional (entry * qty) -- otherwise cash can go negative for + any seed strictly between the two, defeating the check's stated purpose. + """ + trader = PaperTrader(repo) + entry = Decimal("100") + qty = Decimal("5") + notional = entry * qty + entry_fill = entry * (Decimal(1) + SLIPPAGE_PCT) + fee = entry_fill * qty * FEE_PCT + fill_cost = entry_fill * qty + fee + assert notional < fill_cost # slippage+fee always push the real cost higher + + seed = (notional + fill_cost) / 2 # strictly between the two + trader.seed_cash(seed, now_ts=1_700_000_000) + + sig = _enter_signal(setup=_setup(entry="100", stop="90", target="130")) + assert trader.on_signal(sig, qty=qty) is None + assert trader.get_cash() == seed # unchanged + assert not trader.has_open_position("BTC-USD") + assert repo.get_orders(mode="paper") == [] From 480345a3903aab671708ed9236c6d44517e1c2be Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 18:23:45 -0400 Subject: [PATCH 05/12] feat(executor): optional equity_override on _build_intent (live path unchanged) --- keel/execution/executor.py | 19 ++++++++++++++++--- tests/execution/test_executor.py | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/keel/execution/executor.py b/keel/execution/executor.py index 2d51be2b..7e8cd3ce 100644 --- a/keel/execution/executor.py +++ b/keel/execution/executor.py @@ -301,9 +301,20 @@ def _fetch_available_quote(broker: Any, quote_currency: str | None) -> Decimal | def _build_intent( - signal: Signal, broker: Any, repo: Repository, config: Config, now_ts: int + signal: Signal, + broker: Any, + repo: Repository, + config: Config, + now_ts: int, + equity_override: Decimal | None = None, ) -> OrderIntent | None: - """Size `signal` into an `OrderIntent`, or `None` for an EXIT with nothing open to sell.""" + """Size `signal` into an `OrderIntent`, or `None` for an EXIT with nothing open to sell. + + `equity_override`, when given, replaces `config.caps.max_exposure_usd` as the equity input + to fixed-fractional sizing on the ENTER/non-DCA path -- used by the paper-trading enter path + (Task 6) to size off the paper account's real equity instead of the live exposure cap. `None` + (the default) preserves the live-path behavior exactly. + """ if signal.action == Action.ENTER: setup = signal.setup if setup is None: @@ -314,7 +325,9 @@ def _build_intent( qty = sizing.dca_size(config.dca.budget_usd, setup.entry) stop = None else: - equity = config.caps.max_exposure_usd + equity = ( + equity_override if equity_override is not None else config.caps.max_exposure_usd + ) qty = sizing.size(equity, config.risk_pct, setup.entry, setup.stop) stop = setup.stop diff --git a/tests/execution/test_executor.py b/tests/execution/test_executor.py index 59f3c6c6..529b7379 100644 --- a/tests/execution/test_executor.py +++ b/tests/execution/test_executor.py @@ -539,6 +539,28 @@ def test_monthly_allowance_updated_subscription_takes_effect_on_the_next_order(r assert second.placed is True +# -- _build_intent equity override (Task 4, sizing fix part 2) --------------------------------- + + +def test_build_intent_uses_equity_override(repo): + from keel.execution import executor + + signal = _enter_signal() + config = _config() + + default_intent = executor._build_intent(signal, None, repo, config, now_ts=NOW_TS) + override_intent = executor._build_intent( + signal, None, repo, config, now_ts=NOW_TS, equity_override=Decimal("30000") + ) + + # qty scales linearly with equity: override (30000) vs config.caps.max_exposure_usd + # (1000000 in _config()'s defaults). + assert override_intent.qty != default_intent.qty + assert override_intent.qty == default_intent.qty * ( + Decimal("30000") / config.caps.max_exposure_usd + ) + + # -- DCA sizing -------------------------------------------------------------------------------- From b0c25d19b0d16bffc77ca000d3f6774f3867a5f6 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 18:32:06 -0400 Subject: [PATCH 06/12] feat(agent): seed paper account, mode-flip clear, advance Rail 11 scalars in paper Wires the synthetic paper account into run_once's equity block so Rail 11's drawdown scalars advance during paper trading instead of being hard-set to None. Adds _seed_paper_account_if_needed: stamps/clears the shared equity_high_water_mark/drawdown_total_pct/drawdown_weekly_pct/equity_history keys on a paper<->live mode flip, then seeds paper_cash_usdc once from real mark-to-market equity, falling back to config.paper.starting_equity_usd. The symmetric live-side stamp/clear only fires right before a successful update_drawdown call, so an unreadable broker still leaves the previous cycle's scalars untouched (test_run_once_skips_the_drawdown_update_when_the_ quote_balance_is_unreadable is unaffected). --- keel/agent.py | 67 ++++++++++++++++++++++++++++++++++++++------- tests/test_agent.py | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/keel/agent.py b/keel/agent.py index 19e3d4e1..40488838 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -342,6 +342,41 @@ def _mark_to_market_equity( return total +def _seed_paper_account_if_needed( + repo: Repository, + broker: Any, + config: Config, + products: list[str], + price_by_product: dict[str, Decimal], + now_ts: int, + paper_trader: PaperTrader, +) -> None: + """Enforce the equity-state mode stamp and seed the synthetic paper account once. + + 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`. + """ + if repo.get_state("equity_state_mode") != "paper": + repo.set_state("equity_high_water_mark", None) + repo.set_state("drawdown_total_pct", Decimal("0")) + repo.set_state("drawdown_weekly_pct", Decimal("0")) + 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 + paper_trader.seed_cash(seed, now_ts) + + def _paper_resolve_bars( trader: PaperTrader, product_id: str, @@ -691,18 +726,21 @@ def run_once( if product_candles: latest_price_by_product[product_id] = product_candles[-1].close - # Paper never touches the broker. Mark-to-market needs the live quote balance, which a - # rehearsal has no claim on -- so rail 11's scalars simply do not advance in paper, the - # same "leave the previous cycle's values in place" behaviour as an unavailable broker. - # Stated explicitly rather than relying on the fetch failing and being logged as an - # error, which is what happened before: paper looked broker-free only by accident. - equity_now = ( - None - if paper_trader is not None - else _mark_to_market_equity( + # Paper now advances rail 11's scalars too: the synthetic account is seeded (once, from + # real mark-to-market equity or the config fallback) and marked to market every cycle + # exactly like a live account, via `_seed_paper_account_if_needed` + `PaperTrader.equity`. + # `equity_state_mode` records which account last drove the shared HWM/drawdown keys, so a + # paper<->live flip clears them first rather than letting one mode's scalars poison the + # other's (see `_seed_paper_account_if_needed`'s docstring). + if paper_trader is not None: + _seed_paper_account_if_needed( + repo, broker, config, products, latest_price_by_product, now_ts, paper_trader + ) + equity_now = paper_trader.equity(latest_price_by_product) + else: + equity_now = _mark_to_market_equity( repo, broker, products, latest_price_by_product, config.quote_currency ) - ) if equity_now is None: # Leave the previous cycle's scalars in place -- see `_mark_to_market_equity`. log_event( @@ -712,6 +750,15 @@ def run_once( paper=paper_trader is not None, ) else: + # The symmetric live-side mode stamp/clear -- only right before a REAL update, so an + # unreadable broker (equity_now is None, handled above) never gets to zero out the + # previous cycle's scalars on the strength of a stamp alone. + if paper_trader is None and repo.get_state("equity_state_mode") != "live": + repo.set_state("equity_high_water_mark", None) + repo.set_state("drawdown_total_pct", Decimal("0")) + repo.set_state("drawdown_weekly_pct", Decimal("0")) + repo.set_state("equity_history", []) + repo.set_state("equity_state_mode", "live") equity_mod.update_drawdown(repo, equity=equity_now, now_ts=now_ts) for product_id in products: diff --git a/tests/test_agent.py b/tests/test_agent.py index 75f1d792..2825da3b 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -28,6 +28,7 @@ DcaConfig, MarketDataConfig, MoneyMgmtConfig, + PaperConfig, ) from keel.data.db import connect, migrate from keel.data.repository import Repository @@ -1081,6 +1082,61 @@ def test_paper_mode_loads_PAPER_status_rules_not_live_ones(repo, monkeypatch): assert repo.get_orders(mode="paper") == [], "a LIVE rule must not trade in paper mode" +# -- paper equity: seed, mode-flip clear, per-cycle drawdown (P4 Task 5) -------------------- + + +class _NullBalanceBroker(FakeBroker): + """Serves candles fine, but has no readable account at all -- `_mark_to_market_equity` + (and therefore the paper seed's real-equity attempt) must return `None` here, forcing the + config fallback rather than a phantom balance.""" + + def get_accounts(self) -> list[dict[str, Any]]: + return [] + + +def test_paper_cycle_advances_drawdown_scalar(repo): + """A paper run_once with an already-seeded account and a losing mark (cash below the + existing HWM) writes a non-zero `drawdown_total_pct` -- Rail 11's scalars advancing in + paper, which is the whole point of this task.""" + repo.set_state("equity_state_mode", "paper") + repo.set_state("equity_high_water_mark", Decimal("10000")) + repo.set_state("paper_cash_usdc", Decimal("7000")) + repo.set_state("paper_ledger_start_ts", 0) + broker = FakeBroker() + + run_once(broker, repo, _paper_config(), now_ts=90_000) + + assert repo.get_state("equity_state_mode") == "paper" + assert repo.get_state("equity_high_water_mark") == Decimal("10000"), "HWM must not fall" + assert repo.get_state("drawdown_total_pct") == Decimal("0.3") + + +def test_mode_flip_clears_hwm(repo): + """A prior LIVE cycle's HWM/drawdown must not poison the first paper cycle after a flip -- + it is cleared and re-seeded from the paper account's own (real mark-to-market) equity.""" + repo.set_state("equity_state_mode", "live") + repo.set_state("equity_high_water_mark", Decimal("999999")) + repo.set_state("drawdown_total_pct", Decimal("0.9")) + broker = FakeBroker() + + run_once(broker, repo, _paper_config(), now_ts=90_000) + + assert repo.get_state("equity_state_mode") == "paper" + assert repo.get_state("equity_high_water_mark") != Decimal("999999") + assert repo.get_state("drawdown_total_pct") != Decimal("0.9") + + +def test_seed_falls_back_to_config_when_broker_read_none(repo): + """First paper run, broker has no readable balance at all: seed from + `config.paper.starting_equity_usd` instead of leaving the account dormant.""" + broker = _NullBalanceBroker() + 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") + + # -- interactive confirm: run_once threads confirm_fn to placement -------------- From d63c7e82e3b440231ac835ae7c96b7dd7d53667c Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 18:43:32 -0400 Subject: [PATCH 07/12] feat(agent): paper fills sized off synthetic account equity _paper_enter now takes paper_equity and passes it as _build_intent's equity_override, then fills the trader with intent.qty instead of a fixed 1 unit. run_once captures the paper branch's equity_now into a paper_equity local and skips paper entries for the cycle (logged) when it is None, rather than sizing off an unknown equity. --- keel/agent.py | 43 ++++++++++++++++-- tests/test_agent.py | 107 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/keel/agent.py b/keel/agent.py index 40488838..71419c8c 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -405,8 +405,10 @@ def _paper_enter( repo: Repository, config: Config, now_ts: int, + paper_equity: Decimal, ) -> executor.ExecutionResult: - """Run the offline-computable rails, then record a paper fill if they pass. + """Run the offline-computable rails, then record a paper fill sized off `paper_equity` if + they pass. Paper runs the rails DELIBERATELY (see `guards.check`'s `offline` docstring): the promotion gate is scored on this track record, so a rehearsal that skipped them would promote a @@ -416,6 +418,12 @@ def _paper_enter( `None` for a null broker without logging, and rail 13 -- the rail that would have consumed that balance -- is one of the two `offline=True` skips anyway, because paper has no live account to read a balance from. + + `paper_equity` sizes the intent (via `equity_override`) AND the fill: the synthetic account + equity is what a real paper account would risk `config.risk_pct` of, not the `$5k + max_exposure` proxy `_build_intent` falls back to absent an override -- that proxy only ever + existed to gate the guard check, and sizing the fill off it would score the track record on + trades no real paper balance could have produced. """ def _result(placed, order_id=None, vetoed_by=None, reason=""): return ExecutionResult( @@ -426,7 +434,9 @@ def _result(placed, order_id=None, vetoed_by=None, reason=""): reason=reason, ) - intent = executor._build_intent(signal, None, repo, config, now_ts) + intent = executor._build_intent( + signal, None, repo, config, now_ts, equity_override=paper_equity + ) if intent is None: return _result(False, reason="paper: nothing to size") @@ -434,7 +444,7 @@ def _result(placed, order_id=None, vetoed_by=None, reason=""): if not verdict.ok: return _result(False, vetoed_by=verdict.violations, reason="paper: vetoed by rails") - order_id = trader.on_signal(signal) + order_id = trader.on_signal(signal, qty=intent.qty) if order_id is None: return _result(False, reason="paper: no fill (position already open)") return _result( @@ -761,6 +771,13 @@ def run_once( repo.set_state("equity_state_mode", "live") equity_mod.update_drawdown(repo, equity=equity_now, now_ts=now_ts) + # `_paper_enter` sizes the fill off THIS cycle's synthetic equity -- reusing `equity_now` + # computed above rather than re-deriving it, so the entry and the drawdown scalars it just + # advanced always agree on what the account was worth this cycle. `None` when unseeded or + # unreadable (handled just above): sizing a fill off an unknown equity would be worse than + # not trading, so paper entries are skipped this cycle instead, below. + paper_equity = equity_now if paper_trader is not None else None + for product_id in products: if finest is not None and not market_feed.is_fresh( repo, product_id, finest, now_ts, max_age_sec @@ -809,7 +826,25 @@ def run_once( for signal in product_signals: enter_signals.append(signal) if paper_trader is not None: - result = _paper_enter(paper_trader, signal, repo, config, now_ts) + if paper_equity is None: + log_event( + logger, + logging.INFO, + "agent.paper_enter_skipped_no_equity", + product=product_id, + rule=signal.rule_name, + ) + result = ExecutionResult( + placed=False, + order_id=None, + vetoed_by=[], + preview=None, + reason="paper: skipped (synthetic account equity unavailable)", + ) + else: + result = _paper_enter( + paper_trader, signal, repo, config, now_ts, paper_equity + ) else: result = executor.execute( signal, broker, repo, config, mode, confirm_fn=confirm_fn, now_ts=now_ts diff --git a/tests/test_agent.py b/tests/test_agent.py index 2825da3b..8d8be82c 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -32,7 +32,7 @@ ) from keel.data.db import connect, migrate from keel.data.repository import Repository -from keel.strategy.rules.base import Rule, Setup +from keel.strategy.rules.base import Action, Rule, Setup, Signal from keel.strategy.rules.dca import Dca from keel.strategy.rules.pullback_continuation import PullbackContinuation from keel.types import Candle, Granularity, Side @@ -1032,7 +1032,11 @@ def test_paper_mode_records_a_fill_and_never_places_or_reads_account_state(repo, _seed_rule(repo, monkeypatch, _AlwaysEnterRule(PRODUCT)) broker = _MarketDataOnlyBroker(series={(PRODUCT, Granularity.ONE_DAY): [_candle(0, "100")]}) - result = run_once(broker, repo, _paper_config(), now_ts=90_000) + # Task 6: entries now size off the synthetic account equity, so the account needs a seed -- + # the real-balance read still gets attempted and swallowed exactly as before (see the + # caught `AssertionError` in the log), this only supplies the config fallback behind it. + cfg = _paper_config(paper=PaperConfig(starting_equity_usd=Decimal("100000"))) + result = run_once(broker, repo, cfg, now_ts=90_000) assert result.skipped is False orders = repo.get_orders(mode="paper") @@ -1049,7 +1053,10 @@ def test_paper_mode_still_enforces_the_offline_rails(repo, monkeypatch): _seed_rule(repo, monkeypatch, _AlwaysEnterRule(PRODUCT, stop_mult="0.9999")) broker = _MarketDataOnlyBroker(series={(PRODUCT, Granularity.ONE_DAY): [_candle(0, "100")]}) - result = run_once(broker, repo, _paper_config(), now_ts=90_000) + # Sizing needs a seeded synthetic account (Task 6) to reach the rails at all -- see the + # matching note on `test_paper_mode_records_a_fill_and_never_places_or_reads_account_state`. + cfg = _paper_config(paper=PaperConfig(starting_equity_usd=Decimal("100000"))) + result = run_once(broker, repo, cfg, now_ts=90_000) assert repo.get_orders(mode="paper") == [] assert any("vetoed by rails" in (r.reason or "") for r in result.enter_results) @@ -1137,6 +1144,100 @@ def test_seed_falls_back_to_config_when_broker_read_none(repo): assert repo.get_state("paper_cash_usdc") == Decimal("10000") +# -- paper fills sized off paper equity (P4 Task 6) ----------------------------- + + +def _paper_enter_signal( + product_id: str = PRODUCT, + entry: Decimal = Decimal("100"), + stop: Decimal = Decimal("90"), + target: Decimal = Decimal("130"), + ts: int = 1_000, +) -> Signal: + return Signal( + rule_name="fake_enter", + product_id=product_id, + action=Action.ENTER, + side=Side.BUY, + setup=Setup( + product_id=product_id, + direction="long", + entry=entry, + stop=stop, + target=target, + context={}, + ts=ts, + ), + cts_score=7, + entry_technique="signal_candle", + ts=ts, + ) + + +def test_paper_enter_sizes_off_paper_equity(repo): + """`_paper_enter` must size the fill off the SYNTHETIC ACCOUNT EQUITY it is handed, not the + `$5k max_exposure` proxy `_build_intent` falls back to and not the old fixed 1-unit fill.""" + from keel.execution import sizing + from keel.strategy.paper import PaperTrader + + trader = PaperTrader(repo) + trader.seed_cash(Decimal("30000"), now_ts=1_000) + repo.set_state("last_feed_ts", 90_000) + config = _paper_config() + sig = _paper_enter_signal(entry=Decimal("100"), stop=Decimal("90"), target=Decimal("130")) + + result = agent._paper_enter( + trader, sig, repo, config, now_ts=90_000, paper_equity=Decimal("30000") + ) + + assert result.placed + orders = repo.get_orders(mode="paper") + assert len(orders) == 1 + expected_qty = sizing.size(Decimal("30000"), config.risk_pct, Decimal("100"), Decimal("90")) + assert expected_qty == Decimal("30") + assert orders[0]["qty"] == expected_qty + assert orders[0]["qty"] != Decimal("1"), "must not fill the old fixed 1-unit qty" + + +def test_run_once_sizes_a_paper_entry_off_the_seeded_synthetic_equity(repo, monkeypatch): + """Loop-level: the `equity_now` Task 5 computes for the paper branch is what sizes the fill, + not a re-derived value and not the fixed 1-unit qty `_AlwaysEnterRule` used to produce.""" + from keel.execution import sizing + + _seed_rule(repo, monkeypatch, _AlwaysEnterRule(PRODUCT)) + repo.set_state("paper_cash_usdc", Decimal("30000")) + repo.set_state("paper_ledger_start_ts", 0) + repo.set_state("equity_state_mode", "paper") + # `_AlwaysEnterRule` sets entry = candle close, stop = 0.95 * close -> a 5% stop distance. + broker = _MarketDataOnlyBroker(series={(PRODUCT, Granularity.ONE_DAY): [_candle(0, "100")]}) + + result = run_once(broker, repo, _paper_config(), now_ts=90_000) + + orders = repo.get_orders(mode="paper") + assert len(orders) == 1 + expected_qty = sizing.size( + Decimal("30000"), _paper_config().risk_pct, Decimal("100"), Decimal("95") + ) + assert orders[0]["qty"] == expected_qty + assert orders[0]["qty"] != Decimal("1") + assert result.enter_results[0].placed + + +def test_run_once_skips_paper_entries_when_the_synthetic_account_is_unseeded(repo, monkeypatch): + """Sizing off an UNKNOWN equity is worse than not trading: an unseeded/unreadable paper + account must skip entries this cycle rather than fall back to a garbage size.""" + _seed_rule(repo, monkeypatch, _AlwaysEnterRule(PRODUCT)) + # No cash seeded, and the fallback is disabled (0) -- `equity_now` stays `None` all cycle. + cfg = _paper_config(paper=PaperConfig(starting_equity_usd=Decimal("0"))) + broker = _MarketDataOnlyBroker(series={(PRODUCT, Granularity.ONE_DAY): [_candle(0, "100")]}) + + result = run_once(broker, repo, cfg, now_ts=90_000) + + assert repo.get_orders(mode="paper") == [] + assert result.enter_results, "the signal still fires; it must just not be filled" + assert not any(r.placed for r in result.enter_results) + + # -- interactive confirm: run_once threads confirm_fn to placement -------------- From 9026fc4d37d85c0593f6ce0495170b98e41d2521 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 18:50:42 -0400 Subject: [PATCH 08/12] feat(agent): monthly paper contribution (once per calendar month, HWM-rebased) Adds an optional recurring deposit to the synthetic paper account: applied once per UTC calendar month (guards._utc_month_bounds), tracked via new state key paper_last_contribution_month, and rebased through equity.record_external_flow so the deposit is never misread as a drawdown recovery. Default is 0 (disabled). --- keel/agent.py | 12 +++++++++++ tests/test_agent.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/keel/agent.py b/keel/agent.py index 71419c8c..820b8766 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -746,6 +746,18 @@ def run_once( _seed_paper_account_if_needed( repo, broker, config, products, latest_price_by_product, now_ts, paper_trader ) + # Recurring deposit (P4 Task 7), applied once per UTC calendar month -- AFTER the + # seed (a first-ever cycle both seeds and contributes) and BEFORE this cycle's + # equity/`update_drawdown`, so the deposit lands in the equity this cycle computes + # rather than reading as next cycle's unexplained jump. `record_external_flow` + # rebases the HWM + weekly history so the deposit is never read as a recovery. + contribution = config.paper.monthly_contribution_usd + if contribution > 0 and paper_trader.get_cash() is not None: + month_start, _ = guards._utc_month_bounds(now_ts) + if repo.get_state("paper_last_contribution_month") != month_start: + paper_trader.deposit(contribution) + equity_mod.record_external_flow(repo, amount=contribution) + repo.set_state("paper_last_contribution_month", month_start) equity_now = paper_trader.equity(latest_price_by_product) else: equity_now = _mark_to_market_equity( diff --git a/tests/test_agent.py b/tests/test_agent.py index 8d8be82c..c68820e8 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1238,6 +1238,57 @@ def test_run_once_skips_paper_entries_when_the_synthetic_account_is_unseeded(rep assert not any(r.placed for r in result.enter_results) +# -- monthly contribution: calendar-month rollover (P4 Task 7) ------------------ + + +def test_paper_monthly_contribution_applied_once_per_month(repo): + """A configured `monthly_contribution_usd` deposits once per UTC calendar month -- applied + the cycle the month is first seen, not re-applied on a later cycle in the SAME month, and + applied again once the calendar rolls into the next month.""" + # JAN15/JAN20 share a UTC month_start; FEB03 is the next calendar month (see + # `guards._utc_month_bounds`). + JAN15 = 1_705_320_000 + JAN20 = 1_705_752_000 + FEB03 = 1_706_961_600 + + # `_NullBalanceBroker` (no readable account) forces the paper seed onto the config + # fallback, so `paper_cash_usdc` starts deterministic and non-`None`. + broker = _NullBalanceBroker() + cfg = _paper_config( + paper=PaperConfig( + starting_equity_usd=Decimal("10000"), + monthly_contribution_usd=Decimal("500"), + ) + ) + + # Cycle 1 (JAN15): first-ever cycle seeds the account AND applies month 1's contribution. + run_once(broker, repo, cfg, now_ts=JAN15) + cash_after_first = repo.get_state("paper_cash_usdc") + assert cash_after_first == Decimal("10000") + Decimal("500") + assert repo.get_state("paper_last_contribution_month") == 1_704_067_200 + + # Cycle 2 (JAN20): same calendar month -- no second contribution. + run_once(broker, repo, cfg, now_ts=JAN20) + assert repo.get_state("paper_cash_usdc") == cash_after_first + + # Cycle 3 (FEB03): calendar rolled over -- contribution applies again. + before = repo.get_state("paper_cash_usdc") + run_once(broker, repo, cfg, now_ts=FEB03) + assert repo.get_state("paper_cash_usdc") >= before + Decimal("500") - Decimal("1") + assert repo.get_state("paper_last_contribution_month") == 1_706_745_600 + + +def test_paper_monthly_contribution_disabled_by_default(repo): + """`monthly_contribution_usd` defaults to 0 -- no deposit, no state key written.""" + broker = _NullBalanceBroker() + cfg = _paper_config(paper=PaperConfig(starting_equity_usd=Decimal("10000"))) + + run_once(broker, repo, cfg, now_ts=1_705_320_000) + + assert repo.get_state("paper_cash_usdc") == Decimal("10000") + assert repo.get_state("paper_last_contribution_month") is None + + # -- interactive confirm: run_once threads confirm_fn to placement -------------- From d533ca182cbb715e8dafe937c1cb79dedf0e05fc Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 19:01:52 -0400 Subject: [PATCH 09/12] test(agent): Rail 11 total+weekly drawdown halt enforced in paper (e2e) Adds the P4 Task 8 acceptance tests: guards-level test_paper_drawdown_halt_vetoes_buys / test_paper_weekly_drawdown_halt_vetoes_buys (offline=True, equity_state_mode=paper) and a full-loop test that drives a real drawdown through run_once (seed cash, open a paper position, mark it far down) and proves the next paper entry attempt is vetoed by account_dd_breaker_total -- confirming Tasks 5-7's wiring end-to-end. --- tests/execution/test_guards.py | 39 ++++++++++++++ tests/test_agent.py | 97 ++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/tests/execution/test_guards.py b/tests/execution/test_guards.py index a1bc3c36..0cb1017a 100644 --- a/tests/execution/test_guards.py +++ b/tests/execution/test_guards.py @@ -432,6 +432,45 @@ def test_rail11_dca_exempt_from_drawdown_breaker(repo): assert result.violations == [] +# -- P4 Task 8: Rail 11 enforced in PAPER (offline=True) -- the headline acceptance test --------- + + +def test_paper_drawdown_halt_vetoes_buys(repo): + """A paper account drawn down past `max_total_dd_pct` gets BUYs vetoed by Rail 11. + + Same shape as `test_rail11_account_drawdown_breaker_total_rejects_new_entries` above, but + explicit about the two things that distinguish a PAPER check from a live one: `offline=True` + (paper never has a live broker balance to read) and `equity_state_mode="paper"` (the stamp + Task 5/6 write so the shared HWM/drawdown keys are known to belong to the synthetic account) + -- proving Rail 11 still fires on that path, not just on the live one. + """ + repo.set_state("equity_state_mode", "paper") + repo.set_state("drawdown_total_pct", Decimal("0.25")) # 25% > 20% ceiling + repo.set_state("kill_switch", False) + repo.set_state("last_feed_ts", NOW_TS) + intent = _intent() + + verdict = check(intent, repo, _config(), NOW_TS, offline=True) + + assert not verdict.ok + assert any("account_dd_breaker_total" in v for v in verdict.violations) + + +def test_paper_weekly_drawdown_halt_vetoes_buys(repo): + """The weekly twin of the test above: `drawdown_weekly_pct` past `max_weekly_dd_pct` vetoes + a paper BUY too, not just the total-drawdown scalar.""" + repo.set_state("equity_state_mode", "paper") + repo.set_state("drawdown_weekly_pct", Decimal("0.10")) # 10% > 8% ceiling + repo.set_state("kill_switch", False) + repo.set_state("last_feed_ts", NOW_TS) + intent = _intent() + + verdict = check(intent, repo, _config(), NOW_TS, offline=True) + + assert not verdict.ok + assert any("account_dd_breaker_weekly" in v for v in verdict.violations) + + # -- rail 12: stale-data / feed-health + kill-switch ----------------------------------------------- diff --git a/tests/test_agent.py b/tests/test_agent.py index c68820e8..0053001f 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1289,6 +1289,103 @@ def test_paper_monthly_contribution_disabled_by_default(repo): assert repo.get_state("paper_last_contribution_month") is None +# -- Rail 11 end-to-end in paper: a REAL drawdown driven through run_once (P4 Task 8) ----------- + + +def test_paper_full_loop_drawdown_halt_vetoes_subsequent_buys(repo, monkeypatch): + """The headline acceptance test: drive a genuine drawdown through `run_once` -- not inject + the scalar directly -- and prove Rail 11 vetoes the very next paper entry attempt. + + Cycle 1 opens a paper position and marks it at its entry price, seeding the high-water mark. + Cycle 2 feeds a catastrophic mark-down for the SAME product; `paper_trader.equity(...)` + craters and `update_drawdown` writes `drawdown_total_pct` far past the 20% ceiling -- both + via the REAL `run_once` loop, not injected. A fresh paper entry attempt against that + loop-produced state is then run through `agent._paper_enter` -- the exact function `run_once` + itself calls for every paper ENTER signal -- and must come back vetoed by + `account_dd_breaker_total`, proving the scalar Tasks 5/6 wired up is the one Rail 11 actually + reads, end-to-end. + + (Deliberately does NOT rely on `engine.evaluate` re-firing `_AlwaysEnterRule` in cycle 2 to + produce that attempt: with only two candles on record, `engine`'s own, unrelated + choppy-regime gate has too few swing pivots to ever call the window tradeable, and rejecting + on THAT gate would prove nothing about rail 11. `_paper_enter` is the real production + function `run_once` calls once a signal clears the engine, so driving it directly here still + exercises the genuine wiring under test.) + """ + from keel.strategy.paper import PaperTrader + + # A rule registered for PRODUCT is still needed so `run_once` includes it in `products` and + # therefore in `latest_price_by_product` -- without that, the loop would never learn day 1's + # crashed price and would mark the position at cost basis instead. + _seed_rule(repo, monkeypatch, _AlwaysEnterRule(PRODUCT), status="paper") + cfg = _paper_config(paper=PaperConfig(starting_equity_usd=Decimal("10000"))) + + # Seed the synthetic account and open a large paper position directly, BEFORE any cycle runs + # -- the brief's licensed shortcut: prove the SCALAR advances and Rail 11 acts on it through + # the real loop, without needing realistic sizing to get a position open in the first place. + trader = PaperTrader(repo) + trader.seed_cash(Decimal("10000"), now_ts=0) + repo.set_state("equity_state_mode", "paper") + entry_signal = _paper_enter_signal( + product_id=PRODUCT, entry=Decimal("100"), stop=Decimal("50"), target=Decimal("200"), ts=0 + ) + trader.on_signal(entry_signal, qty=Decimal("90")) # ~90% of the seeded $10k cash + + broker = _MarketDataOnlyBroker( + series={ + (PRODUCT, Granularity.ONE_DAY): [ + _candle(0, "100"), # day 0: the price the position was opened at + # day 1: a catastrophic mark-down. Realistic sizing/market moves would need a + # much bigger position to cross 20%; a large adverse move is the brief's other + # licensed shortcut for proving the scalar without modeling a realistic market. + _candle(86_400, "1"), + ] + } + ) + + # Cycle 1 (now_ts inside day 1 -> day 0's candle is the latest CLOSED): equity marks the + # position at its entry price, seeding the high-water mark at a real, non-zero equity. + run_once(broker, repo, cfg, now_ts=90_000) + hwm_before = repo.get_state("equity_high_water_mark") + assert hwm_before is not None and hwm_before > Decimal("9000") + assert repo.get_state("drawdown_total_pct") == Decimal("0") + + # Cycle 2 (now_ts inside day 2 -> day 1's candle is now the latest closed): the position + # marks down to $1/unit, crashing equity far below the high-water mark. + now_ts_2 = 86_400 + 90_000 + run_once(broker, repo, cfg, now_ts=now_ts_2) + + dd_total = repo.get_state("drawdown_total_pct") + assert dd_total is not None and dd_total > Decimal("0.20"), ( + f"expected the REAL loop to drive drawdown_total_pct past the 20% ceiling, got " + f"{dd_total} (hwm was {hwm_before})" + ) + + # A subsequent paper ENTER attempt, run through the real `_paper_enter` against the state the + # loop just produced, must come back vetoed by rail 11 -- not filled. + post_crash_trader = PaperTrader(repo) + post_crash_equity = post_crash_trader.equity({PRODUCT: Decimal("1")}) + next_signal = _paper_enter_signal( + product_id=PRODUCT, + entry=Decimal("1"), + stop=Decimal("0.5"), + target=Decimal("2"), + ts=now_ts_2 + 1, + ) + + entry_result = agent._paper_enter( + post_crash_trader, next_signal, repo, cfg, now_ts=now_ts_2, paper_equity=post_crash_equity + ) + + assert entry_result.placed is False + assert any("account_dd_breaker_total" in v for v in entry_result.vetoed_by), ( + entry_result.vetoed_by + ) + assert len(repo.get_orders(mode="paper", product_id=PRODUCT)) == 1, ( + "no new paper order should have been filled once the breaker tripped" + ) + + # -- interactive confirm: run_once threads confirm_fn to placement -------------- From ac3268886589eb63545eb37c91602aa90be17e09 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 19:10:43 -0400 Subject: [PATCH 10/12] feat(agent,cli): surface paper equity + drawdown in LoopResult output/logs Add optional paper_equity/drawdown_total_pct/drawdown_weekly_pct fields to LoopResult (None-defaulted, so existing constructions still compile), populate them from repo state right after update_drawdown in the paper branch of run_once, log them via agent.paper_equity, and print them in _print_loop_result -- the paper-forward observability for Rail 11's drawdown scalars (a dedicated `keel status` command is deferred). --- keel/agent.py | 28 ++++++++++++++++++++++++++++ keel/cli.py | 5 +++++ tests/test_agent.py | 17 +++++++++++++++++ tests/test_cli.py | 24 ++++++++++++++++++++++++ 4 files changed, 74 insertions(+) diff --git a/keel/agent.py b/keel/agent.py index 820b8766..5c29a4dc 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -620,6 +620,12 @@ class LoopResult: enter_signals: list[Signal] = field(default_factory=list) enter_results: list[ExecutionResult] = field(default_factory=list) exit_results: list[ExecutionResult] = field(default_factory=list) + # Paper-forward observability (P4 Task 9): the synthetic account's equity + Rail 11's + # drawdown scalars for THIS cycle. `None` in every non-paper cycle -- there is no synthetic + # account to report on -- so all existing `LoopResult(...)` constructions stay valid. + paper_equity: Decimal | None = None + drawdown_total_pct: Decimal | None = None + drawdown_weekly_pct: Decimal | None = None def _effective_mode(config: Config, repo: Repository, now_ts: int) -> str: @@ -763,6 +769,12 @@ def run_once( equity_now = _mark_to_market_equity( repo, broker, products, latest_price_by_product, config.quote_currency ) + # Task 9: paper-forward observability -- the synthetic equity + drawdown scalars this + # cycle advanced, surfaced on `LoopResult` (`_print_loop_result` + this log line) instead + # of only living in repo state. `None` unless this is a paper cycle that read equity. + result_paper_equity: Decimal | None = None + result_drawdown_total_pct: Decimal | None = None + result_drawdown_weekly_pct: Decimal | None = None if equity_now is None: # Leave the previous cycle's scalars in place -- see `_mark_to_market_equity`. log_event( @@ -783,6 +795,19 @@ def run_once( repo.set_state("equity_state_mode", "live") equity_mod.update_drawdown(repo, equity=equity_now, now_ts=now_ts) + if paper_trader is not None: + result_paper_equity = equity_now + result_drawdown_total_pct = repo.get_state("drawdown_total_pct") + result_drawdown_weekly_pct = repo.get_state("drawdown_weekly_pct") + log_event( + logger, + logging.INFO, + "agent.paper_equity", + equity=str(equity_now), + dd_total=str(result_drawdown_total_pct), + dd_weekly=str(result_drawdown_weekly_pct), + ) + # `_paper_enter` sizes the fill off THIS cycle's synthetic equity -- reusing `equity_now` # computed above rather than re-deriving it, so the entry and the drawdown scalars it just # advanced always agree on what the account was worth this cycle. `None` when unseeded or @@ -899,6 +924,9 @@ def run_once( enter_signals=enter_signals, enter_results=enter_results, exit_results=exit_results, + paper_equity=result_paper_equity, + drawdown_total_pct=result_drawdown_total_pct, + drawdown_weekly_pct=result_drawdown_weekly_pct, ) finally: unbind_cycle(cycle_token) diff --git a/keel/cli.py b/keel/cli.py index b6dda2b5..bc4732c2 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -1057,6 +1057,11 @@ def _print_loop_result(result: agent.LoopResult) -> None: f"products={result.products} stale={result.stale_products} " f"signals={len(result.enter_signals)} entered={entered} exited={exited}" ) + if result.paper_equity is not None: + click.echo( + f"paper equity ${result.paper_equity} | drawdown " + f"{result.drawdown_total_pct} total / {result.drawdown_weekly_pct} weekly" + ) @cli.command() diff --git a/tests/test_agent.py b/tests/test_agent.py index 0053001f..ac364a96 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1133,6 +1133,23 @@ def test_mode_flip_clears_hwm(repo): assert repo.get_state("drawdown_total_pct") != Decimal("0.9") +def test_loop_result_carries_paper_equity_and_drawdown(repo): + """Task 9: a paper cycle surfaces its synthetic equity + drawdown scalars on the returned + `LoopResult` -- the observability for a paper-forward (`_print_loop_result` + `log_event`), + not just a side effect buried in repo state.""" + repo.set_state("equity_state_mode", "paper") + repo.set_state("equity_high_water_mark", Decimal("10000")) + repo.set_state("paper_cash_usdc", Decimal("7000")) + repo.set_state("paper_ledger_start_ts", 0) + broker = FakeBroker() + + result = run_once(broker, repo, _paper_config(), now_ts=90_000) + + assert result.paper_equity == Decimal("7000") + assert result.drawdown_total_pct == Decimal("0.3") + assert result.drawdown_weekly_pct is not None + + def test_seed_falls_back_to_config_when_broker_read_none(repo): """First paper run, broker has no readable balance at all: seed from `config.paper.starting_equity_usd` instead of leaving the account dormant.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index fc981ee2..90e6dec6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -148,6 +148,30 @@ def test_agent_loop_bounded_by_max_cycles(tmp_path, valid_config_path, monkeypat assert result.output.count("skipped: kill_switch") == 3 +def test_agent_prints_paper_equity_and_drawdown_line(tmp_path, write_config, monkeypatch): + """Task 9: `_print_loop_result` surfaces the synthetic paper equity + Rail 11's drawdown + scalars -- the observability for a paper-forward, not just a side effect buried in state.""" + from tests.conftest import VALID_CONFIG_YAML + + monkeypatch.setattr(cli_module, "_build_broker", lambda config: FakeBroker()) + config_path = write_config(VALID_CONFIG_YAML + "\npaper:\n starting_equity_usd: 10000\n") + db_path = tmp_path / "test.db" + _repo_at(db_path).set_state("kill_switch", False) + runner = CliRunner() + + result = runner.invoke( + cli, + [ + "--db", str(db_path), + "--config", str(config_path), + "agent", + ], + ) + + assert result.exit_code == 0, result.output + assert "paper equity $10000" in result.output + assert "drawdown 0 total / 0 weekly" in result.output + From 1b7503c5b1381fdcba7479e17554bd92dc396565 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 19:13:44 -0400 Subject: [PATCH 11/12] docs(spec): note paper observability via LoopResult (keel status deferred) Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-07-23-paper-mode-fidelity-design.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/superpowers/specs/2026-07-23-paper-mode-fidelity-design.md b/docs/superpowers/specs/2026-07-23-paper-mode-fidelity-design.md index 6d78c523..bc0913bc 100644 --- a/docs/superpowers/specs/2026-07-23-paper-mode-fidelity-design.md +++ b/docs/superpowers/specs/2026-07-23-paper-mode-fidelity-design.md @@ -141,6 +141,12 @@ R-multiple, expectancy sign) are size-invariant, so the promotion gate is undist - Surface paper equity and current total/weekly drawdown in the agent's INFO logging and in `keel status`, so a paper-forward is observable. +> **Implementation note (2026-07-23):** `keel status` did not exist in the codebase. Observability +> was delivered instead via three optional `LoopResult` fields (`paper_equity`, +> `drawdown_total_pct`, `drawdown_weekly_pct`), printed by `cli._print_loop_result` and emitted as +> an `agent.paper_equity` INFO log event each paper cycle. A dedicated `keel status` command is +> deferred as a follow-up. + ## 5. Configuration - `paper_starting_equity_usd` (new): fallback seed when the one-time real-equity read fails (§4.1/D2). - Monthly contribution during paper-forward: applied via §4.1's `deposit`-rebase **once per calendar From cac5cb3f4d967f9e3cd1341451be84b221c0b05d Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 23 Jul 2026 19:27:06 -0400 Subject: [PATCH 12/12] fix(paper): id-based ledger epoch, loop-level dd-veto test, reason string - C1 (critical): _load_open_positions used paper_ledger_start_ts (wall-clock now_ts at seed) against an order's BAR timestamp, which always predates wall-clock time -- dropping any position opened during the seeding cycle on the next rehydration while its cash debit persisted. Switched the epoch cutoff to a new paper_ledger_start_order_id (max paper order id at first seed), stamped once by seed_cash and compared by id, not by clock. - I1: added a loop-level test proving the drawdown breaker vetoes a paper ENTER through the real run_once path, not only via a direct _paper_enter call. - Item 7: clarified _paper_enter's no-fill reason string to cover both the already-open and insufficient-synthetic-cash cases. - Item 5: documented (comment only) the known pre-live-arming asymmetry in the live-side mode stamp/clear; no behaviour change. Co-Authored-By: Claude Opus 4.8 --- keel/agent.py | 11 +++++++- keel/strategy/paper.py | 41 +++++++++++++++++++--------- tests/strategy/test_paper.py | 52 ++++++++++++++++++++++++++++++++++++ tests/test_agent.py | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 13 deletions(-) diff --git a/keel/agent.py b/keel/agent.py index 5c29a4dc..44fb0c1a 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -446,7 +446,9 @@ def _result(placed, order_id=None, vetoed_by=None, reason=""): order_id = trader.on_signal(signal, qty=intent.qty) if order_id is None: - return _result(False, reason="paper: no fill (position already open)") + return _result( + False, reason="paper: no fill (position open or insufficient synthetic cash)" + ) return _result( True, order_id=order_id, @@ -787,6 +789,13 @@ def run_once( # The symmetric live-side mode stamp/clear -- only right before a REAL update, so an # unreadable broker (equity_now is None, handled above) never gets to zero out the # previous cycle's scalars on the strength of a stamp alone. + # TODO(pre-live-arming): asymmetric with the paper-side clear above -- this guards on + # `!= "live"` (fires unless already live) rather than `== "paper"` (fires only on an + # actual paper->live flip). On a paper->live flip whose first live cycle reads an + # unreadable broker, `equity_now` is None, this whole branch is skipped, and stale + # paper drawdown scalars survive one extra cycle before self-healing on the next + # readable cycle. Fix before arming live execution: gate this clear on `== "paper"` + # and hoist it above the broker-equity read so it fires unconditionally on the flip. if paper_trader is None and repo.get_state("equity_state_mode") != "live": repo.set_state("equity_high_water_mark", None) repo.set_state("drawdown_total_pct", Decimal("0")) diff --git a/keel/strategy/paper.py b/keel/strategy/paper.py index 98c73567..b17667c9 100644 --- a/keel/strategy/paper.py +++ b/keel/strategy/paper.py @@ -92,6 +92,7 @@ def __init__( self._slippage_pct = slippage_pct self._open: dict[str, _OpenPaperPosition] = {} self._ledger_start_ts = self._repo.get_state("paper_ledger_start_ts") + self._ledger_start_order_id = self._repo.get_state("paper_ledger_start_order_id") self._load_open_positions() self._cash = self._repo.get_state("paper_cash_usdc") @@ -107,6 +108,14 @@ def _load_open_positions(self) -> None: Pairing is exact rather than heuristic: every exit payload carries the `entry_order_id` it closed, so an entry whose id never appears in an exit is open. + + The epoch cutoff below is ID-based, not timestamp-based: an order's `ts` comes + from BAR/candle time (always at or before wall-clock `now_ts`, since the latest + closed bar can't be in the future), while `paper_ledger_start_order_id` is + stamped once, at `seed_cash` time, off the max paper order id then on record. + Autoincrement ids cleanly separate legacy/pre-seed orders from genuinely new ones + regardless of bar time -- a ts-based cutoff would wrongly drop a position opened + off an earlier bar during the very seeding cycle. """ orders = self._repo.get_orders(mode="paper") closed_entry_ids: set[int] = set() @@ -117,12 +126,11 @@ def _load_open_positions(self) -> None: payload = json.loads(order.get("raw_response") or "{}") except (TypeError, ValueError): continue - if self._ledger_start_ts is not None: - order_ts = payload.get("ts") or order.get("created_at") or 0 - if int(order_ts) < self._ledger_start_ts: - # Pre-epoch legacy order (predates this synthetic account's seed - # timestamp) -- never rehydrate it, so a legacy 1-unit paper - # position can't silently reappear in the synthetic ledger. + if self._ledger_start_order_id is not None: + if int(order["id"]) <= self._ledger_start_order_id: + # Pre-epoch legacy order (written before this synthetic account's + # seed) -- never rehydrate it, so a legacy 1-unit paper position + # can't silently reappear in the synthetic ledger. continue if payload.get("role") == "exit": entry_id = payload.get("entry_order_id") @@ -153,9 +161,9 @@ def _load_open_positions(self) -> None: entry_ts=setup.ts, qty=Decimal(payload["qty"]), # A surviving (non-filtered) order is always post-epoch: the cutoff above - # already dropped anything predating `paper_ledger_start_ts`, and that - # timestamp is only ever set by `seed_cash` -- so every rehydrated position - # was opened while cash was seeded, and was costed at the time. + # already dropped anything with id <= `paper_ledger_start_order_id`, and + # that id is only ever stamped by `seed_cash` -- so every rehydrated + # position was opened while cash was seeded, and was costed at the time. costed=True, ) @@ -168,15 +176,24 @@ def get_cash(self) -> Decimal | None: def seed_cash(self, amount: Decimal, now_ts: int) -> None: """Set the synthetic cash balance, opting this repo into the funding check. - Also stamps `paper_ledger_start_ts` the first time it's called (never overwritten - after) -- the epoch cutoff `_load_open_positions` uses to ignore legacy pre-epoch - orders written before the synthetic account existed. + Also stamps `paper_ledger_start_order_id` the first time it's called (never + overwritten after) -- the ID-based epoch cutoff `_load_open_positions` uses to + ignore legacy pre-epoch orders written before the synthetic account existed. + ID-based rather than `now_ts`-based: `now_ts` is wall-clock time, but an order's + own `ts` comes from bar/candle time, which always predates wall-clock `now_ts` -- + a ts cutoff would wrongly drop a position opened off an earlier bar during the + very cycle that seeded the account. `paper_ledger_start_ts` is still stamped too + (harmless, kept for any external readers) but no longer drives the cutoff. """ self._cash = amount self._repo.set_state("paper_cash_usdc", amount) if self._repo.get_state("paper_ledger_start_ts") is None: self._ledger_start_ts = now_ts self._repo.set_state("paper_ledger_start_ts", now_ts) + if self._repo.get_state("paper_ledger_start_order_id") is None: + start_id = max((o["id"] for o in self._repo.get_orders(mode="paper")), default=0) + self._ledger_start_order_id = start_id + self._repo.set_state("paper_ledger_start_order_id", start_id) def deposit(self, amount: Decimal) -> None: if self._cash is None: diff --git a/tests/strategy/test_paper.py b/tests/strategy/test_paper.py index d0b33bd5..f442918c 100644 --- a/tests/strategy/test_paper.py +++ b/tests/strategy/test_paper.py @@ -456,6 +456,58 @@ def test_pre_epoch_orders_are_skipped_on_rehydration(repo): assert resumed.has_open_position("BTC-USD") is False +def test_first_cycle_position_survives_rehydration_despite_bar_ts_before_seed_wallclock(repo): + """The epoch cutoff must be ID-based, not wall-clock-ts-based. + + `seed_cash`'s `now_ts` is the WALL CLOCK at seed time; an order's `ts` comes from + candle/bar time, which always predates wall-clock `now_ts` (the latest CLOSED bar is + never in the future). A position opened off an earlier bar during the very seeding + cycle must still rehydrate on the NEXT cycle's fresh `PaperTrader` -- a ts-based + cutoff would wrongly treat it as pre-epoch and silently drop it (phantom drawdown, + orphaned position, double-exposure on re-entry) even though it was written AFTER the + seed, by order id. + """ + trader = PaperTrader(repo) + now_ts = 1_700_000_000 # realistic wall-clock seed time + trader.seed_cash(Decimal("30000"), now_ts=now_ts) + + # Opened the SAME cycle, off a bar whose ts is far EARLIER than the seed wall-clock. + bar_ts = 1_699_000_000 + assert bar_ts < now_ts + sig = _enter_signal(setup=_setup(entry="100", stop="90", target="130", ts=bar_ts), ts=bar_ts) + trader.on_signal(sig) + assert trader.has_open_position("BTC-USD") + cash_after_entry = trader.get_cash() + + # Next cycle: a FRESH PaperTrader, exactly like `agent.run_once` reconstructing it. + resumed = PaperTrader(repo) + + assert resumed.has_open_position("BTC-USD"), ( + "the position opened during the seeding cycle must survive rehydration" + ) + assert resumed.get_cash() == cash_after_entry + equity = resumed.equity({"BTC-USD": Decimal("120")}) + assert equity is not None + assert equity == cash_after_entry + Decimal("120") # qty=1 -- position value included + + +def test_pre_seed_order_still_excluded_by_id_based_epoch(repo): + """The legacy case the epoch exists for: an order written strictly BEFORE `seed_cash` + (its id is at or below the id recorded as the seed epoch) must still be excluded on + rehydration, even though the ID-based cutoff replaces the old ts-based one. + """ + legacy = PaperTrader(repo) + legacy.on_signal(_enter_signal(ts=1_000)) # unseeded -- written before any seed + assert legacy.has_open_position("BTC-USD") + + legacy.seed_cash(Decimal("30000"), now_ts=1_700_000_000) + start_id = repo.get_state("paper_ledger_start_order_id") + assert start_id is not None + + resumed = PaperTrader(repo) + assert resumed.has_open_position("BTC-USD") is False + + def test_position_opened_before_seeding_is_never_costed(repo): """A position opened while cash is unseeded, later seeded mid-flight, must not desync the ledger: no debit happened at open, so no credit may happen at close, diff --git a/tests/test_agent.py b/tests/test_agent.py index ac364a96..cda07134 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1403,6 +1403,56 @@ def test_paper_full_loop_drawdown_halt_vetoes_subsequent_buys(repo, monkeypatch) ) +def test_run_once_vetoes_a_paper_entry_through_the_real_loop_when_drawdown_breaker_is_tripped( + repo, monkeypatch +): + """Loop-level companion to the acceptance test above: that test drives the drawdown scalar + through a real `run_once` cycle, but asserts the veto via a DIRECT `agent._paper_enter(...)` + call -- so the within-cycle ordering (drawdown refreshed BEFORE the entry loop, by the SAME + `run_once` invocation that then evaluates the entry) is never executed end-to-end. + + This drives a genuine ENTER signal (`_AlwaysEnterRule`, same as the other loop-level paper + tests) through `run_once` itself, against a cycle where rail 11's scalar is already tripped, + and asserts the resulting `LoopResult.enter_results` shows the veto -- proving the breaker + fires through the REAL loop entry path, not just a direct `_paper_enter` call. + """ + from keel.strategy.paper import PaperTrader + + _seed_rule(repo, monkeypatch, _AlwaysEnterRule(PRODUCT), status="paper") + cfg = _paper_config(paper=PaperConfig(starting_equity_usd=Decimal("10000"))) + + # Already-seeded paper account (no open position), in "paper" equity-state mode. + trader = PaperTrader(repo) + trader.seed_cash(Decimal("10000"), now_ts=0) + repo.set_state("equity_state_mode", "paper") + + NOW = 90_000 + repo.set_state("kill_switch", False) + repo.set_state("last_feed_ts", NOW) + # Pre-set a high-water mark far above the seeded cash: with no open position, this + # cycle's equity is just the $10k cash, so `update_drawdown` (called by `run_once` + # itself, before the entry loop) recomputes `drawdown_total_pct` to 0.5 -- well past + # the 0.20 ceiling -- from THIS state, through the real loop, not injected directly. + repo.set_state("equity_high_water_mark", Decimal("20000")) + + broker = _MarketDataOnlyBroker(series={(PRODUCT, Granularity.ONE_DAY): [_candle(0, "100")]}) + + result = run_once(broker, repo, cfg, now_ts=NOW) + + dd_total = repo.get_state("drawdown_total_pct") + assert dd_total is not None and dd_total > Decimal("0.20"), ( + f"expected the real loop to compute drawdown past the ceiling, got {dd_total}" + ) + assert result.enter_signals, "the rule still fires a signal; it must just be vetoed" + assert any(not r.placed for r in result.enter_results) + assert any( + "account_dd_breaker_total" in v for r in result.enter_results for v in r.vetoed_by + ), [r.vetoed_by for r in result.enter_results] + assert repo.get_orders(mode="paper") == [], ( + "no paper BUY order should be written once the breaker tripped, through the real loop" + ) + + # -- interactive confirm: run_once threads confirm_fn to placement --------------