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
8 changes: 8 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ money_mgmt:
streak_cooloff_days: 0

dca:
# FALLBACK only (#840). Each DCA buy -- live, paper and the account sim -- spends the RULE's
# own amount (`size_usd`, computed from the rule's `budget_usd`; see `keel rules list`), and
# all three paths share one predicate (`executor._dca_budget`) for when this value applies.
# It sizes a buy ONLY when a setup's `size_usd` is ABSENT (the key missing, or `None`); the
# executor logs `executor.dca_sized` with `source=config` when it falls back. A setup that
# DOES carry a `size_usd`, but an unusable one -- zero, negative, non-finite, or not a number
# -- is never sized from this value: that buy is SKIPPED instead, because a rule meant to buy
# less must never spend more.
budget_usd: 50
cadence_days: 7

Expand Down
12 changes: 7 additions & 5 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,14 @@ A **fresh deployment** does not. `keel init` seeds every rule at `candidate` fro
**constructor defaults**, so any parameter an operator tuned by hand silently reverts — a DCA rule
deliberately set to `budget_usd: 25` comes back as the built-in `50`, unpromoted, on a box that
otherwise looks correctly provisioned. Nothing errors. (A worked example, not a description of
today's deployment: the live DCA rule is now `50`, deliberately matching both the constructor
default and `config.dca.budget_usd`, which is the value the live executor actually spends. That
coincidence means the value alone can no longer prove the rule wasn't reseeded — `keel init`'s
default and the operator's intended value are now the same number. `test_committed_manifest_is_valid`
today's deployment.) This matters for money, not just
bookkeeping: since #840 the live executor spends each **rule's** `budget_usd` (the `size_usd` its
setup carries), with `config.dca.budget_usd` only as the fallback — so a reseeded $15 rule that
comes back at `50` really does spend $50 a buy. A rule whose intended value happens to equal the
constructor default cannot prove by its value alone that it wasn't reseeded, since `keel init`'s
default and the operator's intended value are then the same number. `test_committed_manifest_is_valid`
Comment thread
eaitbrahim marked this conversation as resolved.
in `tests/test_rule_manifest.py` covers the gap by also asserting every committed rule's *status*
is `live`, since `keel init` always seeds at `candidate` regardless of what the params say.)
is `live`, since `keel init` always seeds at `candidate` regardless of what the params say.

`deploy/live-rules.json` is the committed record of the live deployment's rule set, so that state
is a diff in a PR rather than a fact stored on one laptop:
Expand Down
4 changes: 2 additions & 2 deletions docs/operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ This is not a trading decision the system can veto; it is an account setting onl
> read** is reduced by pending purification — `mark_to_market − build_report(transactions).
> total_owed_usd` (`keel/execution/equity.py::sizing_equity`). Today that is the paper account's
> balance-derived seed (`paper.starting_equity_usd == 0`), which then sizes every paper fill; the live
> path sizes off `caps.max_exposure_usd` and DCA off `dca.budget_usd`, both operator constants immune
> by construction. Note the boundary: the **drawdown/HWM rail-11 equity is deliberately NOT purified** —
> path sizes off `caps.max_exposure_usd` and DCA off each rule's own `budget_usd` (with
> `dca.budget_usd` as the fallback, #840), all operator constants immune by construction. Note the boundary: the **drawdown/HWM rail-11 equity is deliberately NOT purified** —
> it measures what the account actually holds, and a breaker must trip on real value, not on a
> post-obligation fiction. Discharging the owed amount (`keel purification` to see it) is still your
> act; keep the imported transaction ledger current so the subtraction sees what actually accrued.
Expand Down
31 changes: 28 additions & 3 deletions keel/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,13 @@ def _paper_enter(
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.

A DCA setup whose `size_usd` is PRESENT but not usable makes `_build_intent` raise
`DcaSizeInvalid` (orchestrator ruling 2026-09-27, mirroring `executor.execute`'s own catch):
caught here too, before any paper fill, and reported as a not-placed result rather than
promoted on a trade the rule never asked for. Paper scores the promotion gate on its
recorded fills, so silently resizing to `config.dca.budget_usd` here would let a broken rule
promote on evidence live would have refused to produce.
"""

def _result(placed, order_id=None, vetoed_by=None, reason=""):
Expand All @@ -800,9 +807,27 @@ def _result(placed, order_id=None, vetoed_by=None, reason=""):
reason=reason,
)

intent = executor._build_intent(
signal, None, repo, config, now_ts, equity_override=paper_equity
)
try:
intent = executor._build_intent(
signal, None, repo, config, now_ts, equity_override=paper_equity
)
except executor.DcaSizeInvalid as exc:
log_event(
logger,
logging.WARNING,
"agent.paper_dca_size_invalid",
product=signal.product_id,
rule=signal.rule_name,
rule_id=signal.rule_id,
size_usd=repr(exc.size_usd),
)
return _result(
False,
reason=(
f"paper: dca: rule computed an invalid size_usd={exc.size_usd!r}; buy skipped, "
"not sized from config"
),
)
if intent is None:
return _result(False, reason="paper: nothing to size")

Expand Down
7 changes: 4 additions & 3 deletions keel/execution/equity.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ def sizing_equity(mark_to_market: Decimal, pending_purification: Decimal) -> Dec
inflate the equity the sizing formula reads from" -- riba compounding into position size,
a correctness bug independent of the fiqh point (KB §65.9: non-compliant income is given
away, never recognised as trading capital). Every path that derives sizing equity from a
LIVE balance read must go through this helper; config-constant sizing inputs
(`caps.max_exposure_usd`, `dca.budget_usd`, a funded `paper.starting_equity_usd`) are
immune by construction and pass through unchanged.
LIVE balance read must go through this helper; constant sizing inputs
(`caps.max_exposure_usd`, a DCA rule's own `budget_usd` -- or `dca.budget_usd`, its
fallback (#840) -- and a funded `paper.starting_equity_usd`) are immune by construction and
pass through unchanged.

Floored at zero: pending purification can exceed the mark-to-market read (a reward-heavy
ledger against a mostly-withdrawn account), and a negative equity base would size a
Expand Down
135 changes: 117 additions & 18 deletions keel/execution/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@
**Sizing.** ENTER signals size via `sizing.size` (fixed-fractional risk, off the setup's
entry/stop) for risk-defined rules, or `sizing.dca_size` (budget/price, no stop) for the DCA
order class (`setup.context["order_class"] == "dca"` or `["no_stop"]`, matching
`strategy/engine.py`'s own class test). `execution/guards.py` documents the same design choice
this module reuses: `config.caps.max_exposure_usd` stands in for account equity in
fixed-fractional sizing, since neither module has a separate equity oracle -- it is the funded
trading-capital ceiling (§2.8) `max_per_asset_pct` is already a fraction of.
`strategy/engine.py`'s own class test). A DCA budget is the RULE's `setup.context["size_usd"]`,
with `config.dca.budget_usd` only as the fallback for a setup carrying no `size_usd` at all
(`_dca_budget`, #840). A `size_usd` that is present but not usable does not fall back to the
config -- it skips the buy instead (`DcaSizeInvalid`, orchestrator ruling 2026-09-27), since a
rule meant to buy less must never spend more. `execution/guards.py`
documents the same design choice this module reuses: `config.caps.max_exposure_usd` stands in
for account equity in fixed-fractional sizing, since neither module has a separate equity oracle
-- it is the funded trading-capital ceiling (§2.8) `max_per_asset_pct` is already a fraction of.

**EXIT signals** carry no `setup` (`strategy/rules/base.Signal` docstring: `setup` is `None` for
EXIT/NONE) -- the position being closed is reconstructed from the orders audit log
Expand Down Expand Up @@ -194,11 +198,38 @@ def execute(
means the order is not placed (fails closed, never silently proceeds). `mode="autonomous"`
places without a prompt but is *not* exempt from `guards.check` -- rails run before every
order in every mode, un-overridable, per the main spec §14.

A DCA setup whose `size_usd` is PRESENT but not usable raises `DcaSizeInvalid` out of
`_build_intent`/`_dca_budget`; caught here, before any broker call, and reported as a
not-placed `ExecutionResult` rather than allowed to propagate -- `None` already means "EXIT
with nothing open" for this function's return, so a distinct exception is the signal that
does not collide with that meaning (orchestrator ruling 2026-09-27).
"""
if now_ts is None:
now_ts = int(time.time())

intent = _build_intent(signal, broker, repo, config, now_ts)
try:
intent = _build_intent(signal, broker, repo, config, now_ts)
except DcaSizeInvalid as exc:
log_event(
logger,
logging.WARNING,
"executor.dca_size_invalid",
product=signal.product_id,
rule=signal.rule_name,
rule_id=signal.rule_id,
size_usd=repr(exc.size_usd),
)
return ExecutionResult(
placed=False,
order_id=None,
vetoed_by=[],
preview=None,
reason=(
f"dca: rule computed an invalid size_usd={exc.size_usd!r}; buy skipped, "
"not sized from config"
),
)
if intent is None:
return ExecutionResult(
placed=False,
Expand Down Expand Up @@ -335,6 +366,53 @@ def _is_dca_setup(context: dict[str, Any]) -> bool:
return bool(context.get("no_stop")) or context.get("order_class") == "dca"


class DcaSizeInvalid(Exception):
"""Raised by `_dca_budget` when `setup.context["size_usd"]` is PRESENT but not a usable
amount (orchestrator ruling 2026-09-27, follow-up to #840).

A `size_usd` that is merely ABSENT (the key missing, or explicitly `None`) means the rule
left no opinion, and `config.dca.budget_usd` fills in exactly as it always has. But a rule
that computed a size_usd of 0, negative, non-finite, or otherwise garbage HAD an opinion: it
meant to buy less, or not at all. Falling back to the config in that case would spend MORE
than the rule asked for, and a rule meant to buy less must never spend more. So this is not
a fallback case -- it is a refusal, caught by `execute()` (live) and `agent._paper_enter`
(paper), both of which skip the buy entirely rather than resize it. Carries the raw
offending value so the caller can log/report it without re-deriving it.
"""

def __init__(self, size_usd: Any) -> None:
self.size_usd = size_usd
super().__init__(f"invalid size_usd={size_usd!r}")


def _dca_budget(context: dict[str, Any], fallback: Decimal) -> tuple[Decimal, str]:
"""`(USD to spend, where it came from)` for a DCA setup -- `"rule"` or `"config"` (#840).

The RULE's amount wins: `context["size_usd"]`, which `Dca.detect` computes as
`budget_usd x (1 + dip bonus)`. `fallback` (the config's `dca.budget_usd`) is used ONLY when
that key is ABSENT -- missing, or explicitly `None`. A size_usd that is PRESENT but not a
positive, finite number raises `DcaSizeInvalid` instead of falling back (see that class's
docstring for why): "positive" alone is not enough, since `Decimal('Infinity') > 0`, and an
infinite `budget_usd` becomes an infinite `size_usd` with nothing raising
(`commands/rules.py`'s non-finite-param check says why). A `bool` is an `int` to Python and
is refused too -- `True` is not an amount of dollars.

The fallback ITSELF is not checked here: a zero or negative config budget still reaches
`sizing.dca_size` exactly as it always did, and the rails see whatever notional it yields.
That is unchanged from before this ruling -- only a PRESENT, invalid `size_usd` newly skips
instead of falling back.
"""
size_usd = context.get("size_usd")
if size_usd is None:
return fallback, "config"
if isinstance(size_usd, bool) or not isinstance(size_usd, Decimal | int | float):
raise DcaSizeInvalid(size_usd)
amount = Decimal(str(size_usd)) if isinstance(size_usd, float) else Decimal(size_usd)
Comment thread
eaitbrahim marked this conversation as resolved.
if not amount.is_finite() or amount <= 0:
raise DcaSizeInvalid(size_usd)
return amount, "rule"


#: How long a withdrawal-capability attestation stays fresh (§65.4). Deliberately short: the
#: attestation is about the account's CURRENT state, and a freeze can appear at any time, so a
#: stale attestation is no better than none. 7 days.
Expand Down Expand Up @@ -733,17 +811,36 @@ def _build_intent(

is_dca = _is_dca_setup(setup.context)
if is_dca:
# CAREFUL: the live path sizes DCA from the CONFIG's `dca.budget_usd`, and ignores
# the RULE's own `budget_usd` / the `size_usd` the rule computed from it (which is
# sitting right there in `setup.context`). That is deliberate -- the config is the
# operator-facing dial and a rule row is not reviewed on every deploy -- but it means
# the two can disagree silently, and a rule row saying 25 while the config says 50
# spends 50. It surprised us once; do not assume the rule's number is what moves.
# The account simulator (`sim/portfolio_sim.py`) prefers `context["size_usd"]` and
# only falls back to this config value, so a divergence also makes the sim and the
# live path model different position sizes. Keep rule, config and
# `deploy/live-rules.json` in agreement.
qty = sizing.dca_size(config.dca.budget_usd, setup.entry)
# CAREFUL: DCA is sized from the RULE's amount -- `setup.context["size_usd"]`, which
# `Dca.detect` computes from the rule's own `budget_usd` -- and the config's
# `dca.budget_usd` is only the FALLBACK for a setup whose `size_usd` is ABSENT
# (#840). This reverses the earlier design, where live always spent the config value
# on the grounds that "the config is the operator-facing dial and a rule row is not
# reviewed on every deploy". Paper sizes through this same function, and the account
# sim (`sim/portfolio_sim.py`) shares `_dca_budget` too, so all three agree. Which
# source sized the order is logged (`executor.dca_sized`), because a fallback means a
# setup arrived without the rule's number and deserves a look. The rails are
# unchanged: `notional` below is computed from this qty, so rail 14 and every other
# guard see the smaller, real order.
#
# A `size_usd` that is PRESENT but not usable (0, negative, non-finite, a bool, or
# not a number) does NOT fall back to the config either (orchestrator ruling
# 2026-09-27): `_dca_budget` raises `DcaSizeInvalid`, which propagates out of this
# function and is caught by `execute()`, which skips the buy entirely -- no order,
# no broker call. A rule meant to buy less must never spend more, and falling back
# to a config value the rule never referenced would do exactly that.
budget, source = _dca_budget(setup.context, config.dca.budget_usd)
log_event(
logger,
logging.INFO if source == "rule" else logging.WARNING,
"executor.dca_sized",
product=signal.product_id,
rule=signal.rule_name,
rule_id=signal.rule_id,
source=source,
budget_usd=str(budget),
)
qty = sizing.dca_size(budget, setup.entry)
stop = None
else:
equity = (
Expand Down Expand Up @@ -1876,8 +1973,10 @@ def _order_spec(intent: OrderIntent) -> OrderSpec:
finer than the product's increment, which is how the first live `turtle_breakout` entry was
rejected (order 3, 2026-08-22, `quote_size: "23.00803473938010547532738517"`).

DCA never tripped this only because its `budget_usd` is a round constant: `"50.000...0"` is
26 decimal places too, and the venue accepts it because the VALUE is exactly 50.
DCA never tripped this only because its budget was a round constant: `"50.000...0"` is
26 decimal places too, and the venue accepts it because the VALUE is exactly 50. Since #840
the budget is the rule's `size_usd`, which a non-zero dip bonus makes non-round; the BUY
quantization below covers it like any other entry.

**SELL is quantized too (#516), but its UNKNOWN case is the opposite of BUY's, deliberately.**

Expand Down
12 changes: 10 additions & 2 deletions keel/sim/portfolio_sim.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
from keel.analysis import regime
from keel.config import Config
from keel.execution import sizing
from keel.execution.executor import DcaSizeInvalid, _dca_budget
from keel.execution.guards import _asset, _utc_day_bounds, _utc_month_bounds
from keel.sim.account import OpenIntent, OpenPosition, SimAccount
from keel.strategy import engine, indicators_cts
Expand Down Expand Up @@ -699,9 +700,16 @@ def _process_dca_signals(
decided.add(key)

try:
budget = setup.context.get("size_usd") or config.dca.budget_usd
# Shares `executor._dca_budget` with the live and paper paths rather than
# re-deriving the same predicate here (orchestrator ruling 2026-09-27): `size_usd`
# ABSENT (missing or `None`) falls back to `config.dca.budget_usd`; a `size_usd`
# that is PRESENT but not usable (0, negative, non-finite, a bool, non-numeric)
# raises `DcaSizeInvalid` instead, and this cycle's decision is SKIPPED (`continue`)
# rather than sized from a config value the rule never referenced -- exactly like
# the live/paper skip, so all three paths agree on what "usable" means.
budget, _source = _dca_budget(setup.context, config.dca.budget_usd)
qty = sizing.dca_size(budget, setup.entry)
except ValueError:
except DcaSizeInvalid, ValueError:
continue

notional = sizing.spend(qty, setup.entry)
Expand Down
10 changes: 10 additions & 0 deletions keel/strategy/rules/dca.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ def __init__(
raise ValueError("budget_usd must be positive")
if lookback_days <= 0:
raise ValueError("lookback_days must be positive")
if dip_bonus_pct < 0:
# 0 (the default) stays allowed -- it disables dip-scaling entirely, per
# `PARAM_DOCS` above. A NEGATIVE value would shrink the budget as price drops, the
# inverse of what a "dip bonus" means, and would make `Dca.detect` emit a
# `size_usd` smaller than `budget_usd` on every drawdown -- which the executor
# (`execution.executor._dca_budget`) treats as an INVALID size_usd once it goes
# non-positive, skipping the buy entirely rather than resizing it (orchestrator
# ruling 2026-09-27). Refusing it here, at construction, is cheaper than discovering
# it as a silently-skipped live buy.
raise ValueError("dip_bonus_pct must not be negative")

self.name = name
self.product_id = product_id
Expand Down
Loading
Loading