Skip to content

Commit d073063

Browse files
authored
Merge pull request #152 from CodeGateSoftware/fix/orders-rule-id
fix(orders): populate orders.rule_id from originating rule (Phase-2 debt)
2 parents 905f864 + 58de8f8 commit d073063

11 files changed

Lines changed: 381 additions & 21 deletions

File tree

keel/agent.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,13 @@ def _build_rule(row: dict[str, Any]) -> Rule:
140140
if "signal_patterns" in kwargs:
141141
kwargs["signal_patterns"] = tuple(kwargs["signal_patterns"])
142142

143-
return rule_cls(**kwargs)
143+
rule = rule_cls(**kwargs)
144+
# Thread the real `rules.id` onto the instance (additive `Rule.rule_id`, default `None`) so
145+
# it can flow onto emitted `Signal`s and, from there, into `orders.rule_id`/`signals.rule_id`
146+
# for audit -- `.get` rather than `row["id"]` so a hand-built row with no "id" (some tests)
147+
# still builds a rule, just with `rule_id` left at its `None` default.
148+
rule.rule_id = row.get("id")
149+
return rule
144150

145151

146152
# -- freshness -----------------------------------------------------------------------------
@@ -532,6 +538,7 @@ def _handle_exits(
532538
cts_score=0,
533539
entry_technique="market",
534540
ts=now_ts,
541+
rule_id=owning_rule.rule_id,
535542
)
536543
result = executor.execute(
537544
exit_signal, broker, repo, config, mode, confirm_fn=confirm_fn, now_ts=now_ts

keel/execution/executor.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ def _build_intent(
346346
notional=sizing.spend(qty, setup.entry),
347347
is_dca=is_dca,
348348
rule_kind=signal.rule_name,
349+
rule_id=signal.rule_id,
349350
available_quote=available_quote,
350351
withdrawals_enabled=withdrawals,
351352
)
@@ -365,6 +366,7 @@ def _build_intent(
365366
notional=sizing.spend(qty, entry),
366367
is_dca=False,
367368
rule_kind=signal.rule_name,
369+
rule_id=signal.rule_id,
368370
)
369371

370372

@@ -590,7 +592,7 @@ def _order_row(intent: OrderIntent, mode: str, now_ts: int) -> dict[str, Any]:
590592
# called `'autonomous'`. Nothing reads this column back -- it is an audit trail only --
591593
# so the rows are deliberately left as-written rather than rewritten by a migration.
592594
confirmation=mode,
593-
rule_id=None,
595+
rule_id=intent.rule_id,
594596
created_at=now_ts,
595597
updated_at=now_ts,
596598
)

keel/execution/guards.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ class OrderIntent:
119119
notional: Decimal
120120
is_dca: bool
121121
rule_kind: str
122+
# The originating `rules.id` DB row (`signal.rule_id`, threaded from `agent._build_rule`),
123+
# carried through purely so `executor._order_row` can write it to `orders.rule_id` -- no rail
124+
# reads this, and it plays no part in any guard decision. `None` (the default) for an intent
125+
# built from a hand-constructed `Rule`/`Signal` (most tests) or a caller that doesn't thread
126+
# one (`place_bracket`/`scale_out`/`_roll_stop`, which only have a `rule_name` string).
127+
rule_id: int | None = None
122128
# Rail 13 (USDC-funding): the live available quote-currency (default USDC) balance, fetched
123129
# by the caller (the executor) from the broker -- guards has no broker access of its own.
124130
# `None` means "unknown/unavailable" and fails the BUY closed, same as a missing quote

keel/strategy/engine.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def evaluate(
136136
cts_score=cts_result.total,
137137
entry_technique=technique,
138138
ts=setup.ts,
139+
rule_id=rule.rule_id,
139140
)
140141
signals.append(signal)
141142
log_event(
@@ -321,10 +322,11 @@ def _persist_signal(repo: Repository, signal: Signal, cts_result: indicators_cts
321322
"""Write an emitted `Signal` to the `signals` table via `Repository.insert_signal`
322323
(schema: `data/db.py`; P3 Task 1 added the typed method).
323324
324-
`rule_id` is left `NULL` (no DB-backed `rules` row lookup is wired here -- Task 9's
325-
promotion lifecycle owns that linkage); `indicators` carries the full CTS breakdown +
326-
setup for audit/explainability; `fired=1` marks this as an actionable, gate-cleared
327-
signal -- rejected candidates are never persisted at all.
325+
`rule_id` carries `signal.rule_id` -- the originating `rules.id`, threaded from
326+
`agent._build_rule` through the `Rule` that emitted this `Signal` (`None` for a signal from a
327+
hand-constructed rule, e.g. most tests); `indicators` carries the full CTS breakdown + setup
328+
for audit/explainability; `fired=1` marks this as an actionable, gate-cleared signal --
329+
rejected candidates are never persisted at all.
328330
"""
329331
setup = signal.setup
330332
payload = {
@@ -347,7 +349,7 @@ def _persist_signal(repo: Repository, signal: Signal, cts_result: indicators_cts
347349
}
348350
repo.insert_signal(
349351
{
350-
"rule_id": None,
352+
"rule_id": signal.rule_id,
351353
"product_id": signal.product_id,
352354
"ts": signal.ts,
353355
"indicators": json.dumps(payload, default=str),

keel/strategy/paper.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,18 @@
99
`orders(mode='paper')` via `Repository`. No live order placement ever happens here
1010
(Phase 3 `execution/*` owns that).
1111
12-
**Schema note:** `orders.rule_id` is an `INTEGER` foreign key into the `rules` table
13-
(populated by promotion/demotion, task 9); paper fills from a rule that hasn't been
14-
persisted there yet would violate that FK, so paper orders leave `rule_id` NULL and
15-
instead carry `rule_name` plus this module's own reconstruction fields (`role`,
16-
`entry_order_id`, pnl/mfe/mae/outcome/etc.) JSON-encoded in `orders.raw_response` --
17-
the column live trading reserves for a broker's raw response, unused for paper
18-
fills. `track_record()` reads that back out (via `Repository.get_orders(mode='paper')`,
19-
P3 Task 1) to reconstruct `Trade`s and aggregates them via the shared
20-
`strategy.stats.summarize` helper into the same `BacktestResult` shape `backtest.py`
21-
produces, so paper and historical stats are directly comparable.
12+
**Schema note:** `orders.rule_id` is an `INTEGER` foreign key into the `rules` table. A paper
13+
`Signal` carries `rule_id` only when it was emitted for a rule reconstructed from a persisted
14+
`rules` row (`agent._build_rule`) -- which is always true for a real paper cycle (paper mode
15+
loads its rules the same way live mode does, from `repo.get_rules("paper")`), so the FK is
16+
satisfied by construction; a hand-built `Signal` with no `rule_id` (most tests) still writes
17+
`NULL`, exactly as before. Either way, `rule_name` plus this module's own reconstruction fields
18+
(`role`, `entry_order_id`, pnl/mfe/mae/outcome/etc.) are still JSON-encoded in
19+
`orders.raw_response` -- the column live trading reserves for a broker's raw response, unused for
20+
paper fills -- since `rule_id` alone doesn't carry that bookkeeping. `track_record()` reads that
21+
back out (via `Repository.get_orders(mode='paper')`, P3 Task 1) to reconstruct `Trade`s and
22+
aggregates them via the shared `strategy.stats.summarize` helper into the same `BacktestResult`
23+
shape `backtest.py` produces, so paper and historical stats are directly comparable.
2224
"""
2325

2426
from __future__ import annotations
@@ -67,6 +69,10 @@ class _OpenPaperPosition:
6769
whether a debit actually happened keeps the close-side credit and the equity mark
6870
correct-by-construction regardless of seed timing.
6971
"""
72+
#: The entry order's `rule_id` (its DB row's real value, `None` if it had none) -- carried
73+
#: so the paired exit order writes the SAME `rule_id`, mirroring how `rule_name` above is
74+
#: taken from the position rather than re-read off the exit signal.
75+
rule_id: int | None = None
7076

7177

7278
def _touches(candle: Candle, price: Decimal) -> bool:
@@ -165,6 +171,10 @@ def _load_open_positions(self) -> None:
165171
# that id is only ever stamped by `seed_cash` -- so every rehydrated
166172
# position was opened while cash was seeded, and was costed at the time.
167173
costed=True,
174+
# The entry order's own `rule_id` column -- a real DB value, not something the
175+
# JSON payload needs to carry -- so a restart-rehydrated position still closes
176+
# with the same `rule_id` a same-process exit would have written.
177+
rule_id=order.get("rule_id"),
168178
)
169179

170180
def has_open_position(self, product_id: str) -> bool:
@@ -319,7 +329,7 @@ def _enter(self, signal: Signal, qty: Decimal = _QTY) -> int | None:
319329
"actual_fill": entry_fill,
320330
"raw_response": json.dumps(payload),
321331
"confirmation": "paper",
322-
"rule_id": None,
332+
"rule_id": signal.rule_id,
323333
"created_at": signal.ts,
324334
"updated_at": signal.ts,
325335
}
@@ -333,6 +343,7 @@ def _enter(self, signal: Signal, qty: Decimal = _QTY) -> int | None:
333343
entry_ts=setup.ts,
334344
qty=qty,
335345
costed=costed,
346+
rule_id=signal.rule_id,
336347
)
337348
if costed:
338349
self._cash -= entry_fill * qty + fee
@@ -392,7 +403,7 @@ def _close(self, position: _OpenPaperPosition, exit_price: Decimal, exit_ts: int
392403
"actual_fill": exit_fill,
393404
"raw_response": json.dumps(payload),
394405
"confirmation": "paper",
395-
"rule_id": None,
406+
"rule_id": position.rule_id,
396407
"created_at": exit_ts,
397408
"updated_at": exit_ts,
398409
}

keel/strategy/rules/base.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ class Signal:
6565
cts_score: int
6666
entry_technique: str
6767
ts: int
68+
#: The originating `rules.id` DB row, when the emitting `Rule` was reconstructed via
69+
#: `agent._build_rule` (which threads `Rule.rule_id` through). `None` for a hand-constructed
70+
#: `Rule`/`Signal` (most tests) -- purely additive metadata, never read by any gate/guard.
71+
rule_id: int | None = None
6872

6973

7074
@dataclass
@@ -92,11 +96,18 @@ class Rule(ABC):
9296
`promotion_class` selects the rule's promotion floor (`strategy.promotion.floor_for_class`):
9397
the default `"default"` uses the canonical 100/0.55 floor; trend-followers override it to
9498
`"trend_follow"` for a low-win/high-R:R floor (KB §25.5).
99+
100+
`rule_id` is the originating `rules.id` DB row, set by `agent._build_rule` on a rule loaded
101+
from `repo.get_rules()`. It defaults to `None` -- same pattern as `promotion_class` -- so
102+
every hand-constructed `Rule` in tests (or anywhere else) is unaffected; it exists purely so
103+
the id can be threaded onto emitted `Signal`s and, from there, into `orders.rule_id` for
104+
audit -- it plays no part in `detect`/`exit_signal`/any guard or gate.
95105
"""
96106

97107
name: str
98108
params: dict
99109
promotion_class: str = "default"
110+
rule_id: int | None = None
100111

101112
@abstractmethod
102113
def detect(self, candles_by_tf: dict[Granularity, list[Candle]]) -> Setup | None:

tests/data/test_repository.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,26 @@ def test_insert_order_ids_increment(repo):
179179
assert id2 > id1
180180

181181

182+
def test_insert_order_round_trips_a_non_none_rule_id(repo):
183+
"""`orders.rule_id` is a real FK into `rules` -- once the row exists, an order naming it
184+
must round-trip that id exactly, not just `None` (the `_order()` default)."""
185+
rule_id = repo.insert_rule("pullback_continuation", {"lookback": 20})
186+
187+
order_id = repo.insert_order(_order(rule_id=rule_id))
188+
189+
stored = repo.get_order(order_id)
190+
assert stored["rule_id"] == rule_id
191+
192+
193+
def test_get_orders_also_round_trips_rule_id(repo):
194+
rule_id = repo.insert_rule("dca", {})
195+
repo.insert_order(_order(rule_id=rule_id))
196+
repo.insert_order(_order(rule_id=None))
197+
198+
rows = repo.get_orders()
199+
assert {r["rule_id"] for r in rows} == {rule_id, None}
200+
201+
182202
def test_update_order_updates_fields_and_round_trips_decimal(repo):
183203
order_id = repo.insert_order(_order())
184204

@@ -348,6 +368,15 @@ def test_insert_signal_returns_id_and_round_trips(repo):
348368
assert row["fired"] == 1
349369

350370

371+
def test_insert_signal_round_trips_a_non_none_rule_id(repo):
372+
rule_id = repo.insert_rule("rsi_meanrev", {})
373+
374+
signal_id = repo.insert_signal(_signal(rule_id=rule_id))
375+
376+
row = repo._conn.execute("SELECT * FROM signals WHERE id = ?", (signal_id,)).fetchone()
377+
assert row["rule_id"] == rule_id
378+
379+
351380
def test_insert_signal_ids_increment(repo):
352381
id1 = repo.insert_signal(_signal())
353382
id2 = repo.insert_signal(_signal())

tests/execution/test_executor.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,98 @@ def test_confirm_mode_without_confirm_fn_defaults_to_not_placed(repo):
349349
assert len(broker.place_calls) == 0
350350

351351

352+
# -- rule_id metadata (Phase-2 debt: orders.rule_id was always written NULL) ----------------------
353+
354+
355+
def test_placed_order_carries_the_signals_rule_id(repo):
356+
"""The fix under test: `orders.rule_id` used to be hardcoded `None` in `_order_row`
357+
regardless of the signal. It must now carry `signal.rule_id` end to end through
358+
`_build_intent`'s `OrderIntent.rule_id`.
359+
"""
360+
rule_id = repo.insert_rule("pullback_continuation", {}, status="live")
361+
broker = FakeBroker()
362+
signal = _enter_signal(rule_id=rule_id)
363+
364+
result = execute(signal, broker, repo, _config(), mode="autonomous", now_ts=NOW_TS)
365+
366+
assert result.placed is True
367+
order = repo.get_order(result.order_id)
368+
assert order["rule_id"] == rule_id
369+
370+
371+
def test_a_signal_with_no_rule_id_still_writes_none(repo):
372+
"""Backward-compat: a signal from a hand-constructed rule (no `rule_id` supplied, the
373+
default) still writes `NULL`, exactly as before this fix."""
374+
broker = FakeBroker()
375+
signal = _enter_signal() # no rule_id override -> defaults to None
376+
377+
result = execute(signal, broker, repo, _config(), mode="autonomous", now_ts=NOW_TS)
378+
379+
assert result.placed is True
380+
order = repo.get_order(result.order_id)
381+
assert order["rule_id"] is None
382+
383+
384+
def test_rule_id_is_purely_additive_metadata_placement_and_guards_are_unchanged(repo):
385+
"""The metadata-only guarantee: two otherwise-identical signals, differing only in
386+
`rule_id`, must produce byte-for-byte identical guard/placement outcomes -- same veto
387+
decisions, same `placed`, same broker calls/order-configuration, same sized qty/notional.
388+
The ONLY difference in the resulting order rows is the `rule_id` column.
389+
"""
390+
rule_id = repo.insert_rule("pullback_continuation", {}, status="live")
391+
broker_a = FakeBroker()
392+
broker_b = FakeBroker()
393+
signal_no_id = _enter_signal(rule_id=None)
394+
signal_with_id = _enter_signal(rule_id=rule_id)
395+
396+
result_a = execute(signal_no_id, broker_a, repo, _config(), mode="autonomous", now_ts=NOW_TS)
397+
result_b = execute(
398+
signal_with_id, broker_b, repo, _config(), mode="autonomous", now_ts=NOW_TS + 1
399+
)
400+
401+
assert result_a.placed == result_b.placed is True
402+
assert result_a.vetoed_by == result_b.vetoed_by == []
403+
assert len(broker_a.preview_calls) == len(broker_b.preview_calls)
404+
assert len(broker_a.place_calls) == len(broker_b.place_calls)
405+
assert (
406+
broker_a.place_calls[0]["order_configuration"]
407+
== broker_b.place_calls[0]["order_configuration"]
408+
)
409+
410+
order_a = repo.get_order(result_a.order_id)
411+
order_b = repo.get_order(result_b.order_id)
412+
# Every field EXCEPT rule_id/created_at/updated_at/id must match -- proving rule_id is the
413+
# only thing that changed.
414+
for field in (
415+
"mode",
416+
"product_id",
417+
"side",
418+
"order_type",
419+
"qty",
420+
"status",
421+
"confirmation",
422+
):
423+
assert order_a[field] == order_b[field], f"{field} differs -- not metadata-only"
424+
assert order_a["rule_id"] is None
425+
assert order_b["rule_id"] == rule_id
426+
427+
428+
def test_rail_violating_signal_with_a_rule_id_is_still_vetoed_the_same_way(repo):
429+
"""`rule_id` must not influence guard decisions -- a vetoed intent stays vetoed."""
430+
rule_id = repo.insert_rule("pullback_continuation", {}, status="live")
431+
broker = NoNetworkBroker()
432+
signal = _enter_signal(
433+
product_id="DOGE-USD", setup=_setup(product_id="DOGE-USD"), rule_id=rule_id
434+
)
435+
436+
result = execute(signal, broker, repo, _config(), mode="autonomous", now_ts=NOW_TS)
437+
438+
assert result.placed is False
439+
assert result.order_id is None
440+
assert any(v.startswith("halal_allowlist") for v in result.vetoed_by)
441+
assert repo.get_orders() == []
442+
443+
352444
# -- rail-violating signal -> vetoed, never previews/places --------------------------------------
353445

354446

tests/strategy/test_base.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,36 @@ def test_constructs_with_expected_fields(self) -> None:
8888
assert signal.entry_technique == "signal_candle"
8989
assert signal.ts == 1_700_000_100
9090

91+
def test_rule_id_defaults_to_none(self) -> None:
92+
"""A hand-constructed `Signal` (most tests, and any caller that predates this field)
93+
must still build with no `rule_id` kwarg -- additive, backward-compatible default."""
94+
setup = _setup()
95+
signal = Signal(
96+
rule_name="pullback_continuation",
97+
product_id="BTC-USD",
98+
action=Action.ENTER,
99+
side=Side.BUY,
100+
setup=setup,
101+
cts_score=7,
102+
entry_technique="signal_candle",
103+
ts=1_700_000_100,
104+
)
105+
assert signal.rule_id is None
106+
107+
def test_rule_id_round_trips_when_supplied(self) -> None:
108+
signal = Signal(
109+
rule_name="pullback_continuation",
110+
product_id="BTC-USD",
111+
action=Action.ENTER,
112+
side=Side.BUY,
113+
setup=None,
114+
cts_score=7,
115+
entry_technique="signal_candle",
116+
ts=0,
117+
rule_id=42,
118+
)
119+
assert signal.rule_id == 42
120+
91121
def test_setup_may_be_none_for_none_action(self) -> None:
92122
signal = Signal(
93123
rule_name="rsi_meanrev",
@@ -187,3 +217,17 @@ def test_concrete_subclass_satisfies_the_abc(self) -> None:
187217
assert rule.describe() == {"name": "trivial", "params": {}}
188218
assert rule.name == "trivial"
189219
assert rule.params == {}
220+
221+
def test_rule_id_defaults_to_none(self) -> None:
222+
"""A hand-constructed `Rule` (most tests, and every rule not loaded via
223+
`agent._build_rule`) must still build with no `rule_id` -- additive, backward-compatible
224+
default, same pattern as `promotion_class`."""
225+
rule = _TrivialRule()
226+
assert rule.rule_id is None
227+
228+
def test_rule_id_is_settable_after_construction(self) -> None:
229+
"""`agent._build_rule` sets this as a plain attribute post-construction (not a
230+
constructor kwarg) -- `Rule` is a plain mutable object, not frozen."""
231+
rule = _TrivialRule()
232+
rule.rule_id = 7
233+
assert rule.rule_id == 7

0 commit comments

Comments
 (0)