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
4 changes: 4 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 146 additions & 15 deletions keel/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -370,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
Expand All @@ -381,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(
Expand All @@ -391,17 +434,21 @@ 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")

verdict = guards.check(intent, repo, config, now_ts, offline=True)
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(
False, reason="paper: no fill (position open or insufficient synthetic cash)"
)
return _result(
True,
order_id=order_id,
Expand Down Expand Up @@ -575,6 +622,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:
Expand Down Expand Up @@ -691,18 +744,39 @@ 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
)
# 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(
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(
Expand All @@ -712,8 +786,44 @@ 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.
# 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"))
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)

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
# 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
Expand Down Expand Up @@ -762,7 +872,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
Expand Down Expand Up @@ -805,6 +933,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)
Expand Down
5 changes: 5 additions & 0 deletions keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
26 changes: 26 additions & 0 deletions keel/execution/equity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 16 additions & 3 deletions keel/execution/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
Loading
Loading