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
99 changes: 92 additions & 7 deletions deploy/live-rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"rules": [
{
"kind": "turtle_breakout",
"status": "live",
"status": "paper",
Comment thread
eaitbrahim marked this conversation as resolved.
"params": {
"entry_lookback": 40,
"exit_lookback": 20,
Expand All @@ -22,7 +22,7 @@
},
{
"kind": "turtle_breakout",
"status": "live",
"status": "paper",
"params": {
"entry_lookback": 40,
"exit_lookback": 20,
Expand All @@ -41,7 +41,7 @@
},
{
"kind": "turtle_breakout",
"status": "live",
"status": "paper",
"params": {
"entry_lookback": 40,
"exit_lookback": 20,
Expand All @@ -60,7 +60,7 @@
},
{
"kind": "turtle_breakout",
"status": "live",
"status": "paper",
"params": {
"entry_lookback": 40,
"exit_lookback": 20,
Expand All @@ -79,7 +79,7 @@
},
{
"kind": "turtle_breakout",
"status": "live",
"status": "paper",
"params": {
"entry_lookback": 40,
"exit_lookback": 20,
Expand All @@ -100,9 +100,94 @@
"kind": "dca",
"status": "live",
"params": {
"product_id": "BTC-USD",
"budget_usd": "40",
"cadence_days": 7,
"dip_bonus_pct": "0",
"lookback_days": 90,
"product_id": "BTC-USD"
}
},
{
"kind": "turtle_breakout",
"status": "paper",
"params": {
"entry_lookback": 40,
"exit_lookback": 20,
"adx_period": 14,
"adx_threshold": 25.0,
"atr_period": 20,
"atr_stop_mult": "2",
"use_macd_confirm": false,
"s1_filter": false,
"min_volume_filter": false,
"volume_ma_period": 20,
"volume_mult": 1.2,
"target_rr": "6",
"product_id": "DOGE-USD"
}
},
{
"kind": "dca",
"status": "live",
"params": {
"product_id": "ETH-USD",
"cadence_days": 7,
"budget_usd": "50",
"budget_usd": "25",
"dip_bonus_pct": "0",
"lookback_days": 90
}
},
{
"kind": "dca",
"status": "live",
"params": {
"product_id": "PAXG-USD",
"cadence_days": 14,
"budget_usd": "25",
"dip_bonus_pct": "0",
"lookback_days": 90
}
},
{
"kind": "dca",
"status": "live",
"params": {
"product_id": "ADA-USD",
"cadence_days": 14,
"budget_usd": "15",
"dip_bonus_pct": "0",
"lookback_days": 90
}
},
{
"kind": "dca",
"status": "live",
"params": {
"product_id": "XLM-USD",
"cadence_days": 14,
"budget_usd": "15",
"dip_bonus_pct": "0",
"lookback_days": 90
}
},
{
"kind": "dca",
"status": "live",
"params": {
"product_id": "DOGE-USD",
"cadence_days": 14,
"budget_usd": "15",
"dip_bonus_pct": "0",
"lookback_days": 90
}
},
{
"kind": "dca",
"status": "live",
"params": {
"product_id": "FET-USD",
"cadence_days": 14,
"budget_usd": "15",
"dip_bonus_pct": "0",
"lookback_days": 90
}
Expand Down
5 changes: 3 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ setup carries), with `config.dca.budget_usd` only as the fallback — so a resee
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`
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.
in `tests/test_rule_manifest.py` covers the gap by also asserting every committed DCA rule's
*status* is `live` and that no committed rule is `candidate`, 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
5 changes: 5 additions & 0 deletions docs/go-live-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ decision on the record rather than an oversight nobody re-examined.
live-seeded rules in place afterwards."* They were left in place. **Reviewed 2026-08-08; kept
deliberately.**

> **Superseded 2026-09-27.** The live rule export of that date (`deploy/live-rules.json`, #855)
> shows every `turtle_breakout` rule in `keel-live.db` — rules 1–5 and the DOGE turtle, rule 7 —
> at `status = paper`. The only `live` rules are the seven DCA rules (6, 8–13). This exception is
> no longer in force; the section below is kept as the record of why it existed.

**Why they are kept.** `min_trades` is 100 *per rule*, and this strategy cannot reach it.
Backtesting each rule over ~5.02 years of daily bars on 2026-08-08:

Expand Down
2 changes: 1 addition & 1 deletion docs/operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ and has already produced one. Establish which account a number came from before
| `equity_state_mode` | `paper` | `live` | `paper` | `paper` |
| launchd job | `com.keel.paperforward` | `com.keel.live` | `com.keel.paper-hourly` | `com.keel.paper-equities` |
| cadence | daily (day-stamp) | daily, UTC (UTC day-stamp) | **hourly**, UTC (UTC hour-stamp) | daily, in the US session (UTC day-stamp) |
| rules traded | daily turtle, `paper` | daily turtle + DCA, `live` | **hourly** turtle, `paper` | daily turtle on equities, `paper` |
| rules traded | daily turtle, `paper` | DCA `live` (7 rules); daily turtle `paper` (since 2026-09-27) | **hourly** turtle, `paper` | daily turtle on equities, `paper` |

### Installing and verifying a profile's launchd job

Expand Down
15 changes: 6 additions & 9 deletions scripts/rule_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,12 @@
using each rule kind's CONSTRUCTOR DEFAULTS. Any parameter an operator tuned by hand is silently
replaced by the default: a DCA rule deliberately set to `budget_usd: 25` comes back as the
built-in `50`, at `candidate`, on a box that otherwise looks correctly provisioned. Nothing
errors. (That is a worked example rather than a description of today's deployment -- the live DCA
rule is now `50` on purpose, matching the constructor default. Since #840 the live executor spends
the RULE's own `budget_usd` (the `size_usd` its setup carries), not `config.dca.budget_usd` --
that config value is only the FALLBACK for a setup whose `size_usd` is absent -- so the manifest
and the config are free to diverge by design and this module does not require them to agree.
Because the rule's value now matches the default, the VALUE alone can no longer prove the rule
wasn't reseeded; `tests/test_rule_manifest.py`'s
`test_committed_manifest_is_valid` also asserts every committed rule's `status` is `live`, since a
`keel init` reseed always lands at `candidate` no matter what the params say.) This module makes
errors. (That is a worked example: since #840 the live executor spends the RULE's own
`budget_usd` (the `size_usd` its setup carries) -- `config.dca.budget_usd` is only the FALLBACK
for a setup whose `size_usd` is absent -- so the manifest and the config are free to diverge by
design and this module does not require them to agree. The live DCA rules are tuned off the
constructor defaults, so a reseed shows up as a VALUE change as well as a `candidate` status;
`tests/test_rule_manifest.py`'s `test_committed_manifest_is_valid` asserts both.) This module makes
Comment thread
eaitbrahim marked this conversation as resolved.
that state an artifact you can diff in a PR instead of a fact that lives only on one laptop.

**`export`** writes the manifest. It is the source of truth's snapshot, not the source of truth:
Expand Down
95 changes: 33 additions & 62 deletions tests/test_rule_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,77 +142,48 @@ def test_committed_manifest_is_valid(tmp_path: Path) -> None:
rebuilt = _rules(db)
assert len(rebuilt) == len(json.loads(committed.read_text())["rules"])
dca = [r for r in rebuilt if r["kind"] == "dca"]
assert len(dca) == 1, "the live DCA rule is missing from the manifest"

# This used to pin the rule's budget at "25" with the rationale "must not revert to the
# default 50", then (still before #840) switched to an AGREEMENT assertion that the
# manifest's budget must equal `config.dca.budget_usd`. Both versions were reasoning about a
# world where the live executor sized DCA from `config.dca.budget_usd`
# (`execution/executor.py::_build_intent`) and ignored the RULE's `budget_usd` entirely --
# so the rule row could say 25, the config could say 50, and 50 is what moved live while the
# account simulator, which read the rule's value, modeled a position size live never took.
# AGREEMENT closed that gap by requiring the manifest and the config to match.
#
# #840 reverses which value is real: the live executor now spends the RULE's own `budget_usd`
# (the `size_usd` its setup carries -- see `_dca_budget`), and `config.dca.budget_usd` is only
# the FALLBACK for a setup whose `size_usd` is absent. The rule row is now the number that
# actually moves, live and in the sim alike, and it is MEANT to be free to diverge from the
# config -- letting an operator tune one rule's budget away from the shared config value is
# exactly what #840 made possible. Requiring the manifest to agree with the config would break
# the moment that divergence is actually used, so this test no longer asserts that agreement.
# Right now `deploy/live-rules.json` defines a single DCA rule, at $50 -- matching `Dca`'s
# constructor default -- but that coincidence is what assertion (2) below pins down
# explicitly, not a fact this test takes for granted or asserts on its own.
#
# What is still true, and still worth guarding: `deploy/live-rules.json`'s DCA params are,
# right now, EXACTLY `Dca.__init__`'s constructor defaults (see `keel/strategy/rules/dca.py`).
# That means the VALUE alone cannot prove this rule wasn't reseeded by a fresh `keel init`
# rather than deliberately provisioned, since the operator's intended value and `keel init`'s
# default are, today, the same number. Two assertions below make up for that: (1) status,
# which is the only thing that still can, and (2) a pinned check on the coincidence itself,
# so that if the operator ever moves the budget off the default, the value check regains its
# own power and (2) is what tells them so.
assert dca, "the live DCA rules are missing from the manifest"

# History: this used to pin the single DCA rule's budget at "25", then an AGREEMENT with
# `config.dca.budget_usd`, then (after #840 made the live executor spend the RULE's own
# `budget_usd` -- see `_dca_budget`) a pinned COINCIDENCE that the one DCA rule's params equal
# `Dca`'s constructor defaults. #855 regenerated the manifest from the live DB: seven DCA rules
# (BTC $40/7d, ETH $25/7d, PAXG $25/14d, ADA/XLM/DOGE/FET $15/14d), none at the default $50,
# and the turtles at `paper`. So the coincidence no longer holds, and the assertions below say
# what IS true now.
#
# SCOPE: this asserts the COMMITTED FILE, not a deployment's database, so it catches a
# reseeded box's state being COMMITTED -- not the reseed itself. `rule_manifest.py apply`
# is what reports that drift against a live DB.

# (1) NOT SEED-SHAPED -- status is the only discriminator standing between "the operator's
# 50" and "keel init's 50" now that the manifest's budget is not checked against anything
# else. `keel init` always seeds fresh rules at `candidate` (`docs/RELEASING.md`), and
# nothing in this test path promotes them, so a reseeded box's manifest would show
# `candidate` even though its budget_usd matches the constructor default byte-for-byte. A
# correctly-provisioned deployment has every rule at `live`.
assert dca[0]["status"] == "live", (
"the live DCA rule is not status=live -- if this is a candidate, keel init likely "
"reseeded it from Dca's constructor defaults rather than preserving an operator's tuned "
"value, and nothing else here can catch that on its own (see comment)"
# (1) NOT SEED-SHAPED BY STATUS -- `keel init` always seeds fresh rules at `candidate`
# (`docs/RELEASING.md`), and nothing in this test path promotes them. A deliberately
# provisioned deployment has every DCA rule at `live` and no rule at `candidate`.
assert [r["status"] for r in dca] == ["live"] * len(dca), (
"a DCA rule in the committed manifest is not status=live -- if it is a candidate, keel "
"init likely reseeded it from Dca's constructor defaults rather than preserving an "
"operator's tuned value"
)
assert all(r["status"] != "candidate" for r in rebuilt), (
"a rule in the committed live manifest is status=candidate -- that shape matches a fresh "
"`keel init` reseed from constructor defaults, not a deliberately-provisioned deployment"
)

# (2) THE COINCIDENCE IS PINNED, NOT ASSUMED -- assertion (1) only carries the weight it does
# because the operator's intended DCA params happen, today, to equal Dca's constructor
# defaults. Pin that equality explicitly instead of taking it on faith. If it ever stops
# being true -- e.g. the operator deliberately moves the budget off 50 -- THIS assertion is
# what fails, and failing here is good news dressed as a test failure: it means the
# manifest's value would now visibly differ from a reseed's, so the VALUE alone regains the
# power to catch a `keel init` revert, and the status check in (1) is no longer the last
# line of defense. Whoever hits this failure should update this comment, not just delete the
# assertion.
manifest_params = dca[0]["params"]
default_params = Dca(product_id=manifest_params["product_id"]).describe()["params"]
for key, expected in default_params.items():
if key == "product_id":
continue # trivially equal -- it's the constructor arg we just passed in
assert Decimal(str(manifest_params[key])) == Decimal(str(expected)), (
f"deploy/live-rules.json's DCA {key} ({manifest_params[key]!r}) no longer matches "
f"Dca's constructor default ({expected!r}). Assertion (1) above still holds, but the "
"reasoning behind it -- that status is the ONLY thing distinguishing a deliberate "
"value from a reseeded default -- no longer applies to this parameter: a reseed "
"would now produce a visibly different value here, and THIS comparison catches that "
"on its own, without needing assertion (1)'s status check as the last line of "
"defense."
# (2) NOT SEED-SHAPED BY VALUE -- every live DCA rule's params differ from `Dca`'s constructor
# defaults on at least one key, so a reseed would show up as a VALUE change in this file, not
# only as a status change. If an operator ever deliberately sets a rule back to the defaults,
# this fails: status in (1) is then the only discriminator left for that rule. Update this
# comment when you relax it, do not just delete the assertion.
for rule in dca:
params = rule["params"]
default_params = Dca(product_id=params["product_id"]).describe()["params"]
differing = sorted(
key
for key, expected in default_params.items()
if key != "product_id" and Decimal(str(params[key])) != Decimal(str(expected))
)
assert differing, (
f"deploy/live-rules.json's {params['product_id']} DCA rule equals Dca's constructor "
"defaults on every param, so only its status distinguishes it from a `keel init` "
"reseed (see comment)"
)
Loading