diff --git a/deploy/live-rules.json b/deploy/live-rules.json index d39dfb9d..a3ff91a8 100644 --- a/deploy/live-rules.json +++ b/deploy/live-rules.json @@ -3,7 +3,7 @@ "rules": [ { "kind": "turtle_breakout", - "status": "live", + "status": "paper", "params": { "entry_lookback": 40, "exit_lookback": 20, @@ -22,7 +22,7 @@ }, { "kind": "turtle_breakout", - "status": "live", + "status": "paper", "params": { "entry_lookback": 40, "exit_lookback": 20, @@ -41,7 +41,7 @@ }, { "kind": "turtle_breakout", - "status": "live", + "status": "paper", "params": { "entry_lookback": 40, "exit_lookback": 20, @@ -60,7 +60,7 @@ }, { "kind": "turtle_breakout", - "status": "live", + "status": "paper", "params": { "entry_lookback": 40, "exit_lookback": 20, @@ -79,7 +79,7 @@ }, { "kind": "turtle_breakout", - "status": "live", + "status": "paper", "params": { "entry_lookback": 40, "exit_lookback": 20, @@ -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 } diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c54f74f2..540c26c2 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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: diff --git a/docs/go-live-runbook.md b/docs/go-live-runbook.md index 5be78691..a96120ac 100644 --- a/docs/go-live-runbook.md +++ b/docs/go-live-runbook.md @@ -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: diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index dad78f1f..1523192f 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -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 diff --git a/scripts/rule_manifest.py b/scripts/rule_manifest.py index 932c2f54..280d6c68 100644 --- a/scripts/rule_manifest.py +++ b/scripts/rule_manifest.py @@ -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 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: diff --git a/tests/test_rule_manifest.py b/tests/test_rule_manifest.py index 5a9468b9..1e81143d 100644 --- a/tests/test_rule_manifest.py +++ b/tests/test_rule_manifest.py @@ -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)" )