Skip to content

Filled entry left untracked: a bracket-preview Decimal parse error aborts open_position, stranding funded inventory #799

Description

@eaitbrahim

What happened

On the live deployment, 2026-08-25 13:20 UTC, cycle e917fb3d21544dc2:

13:20:19 INFO  engine.setup_detected  turtle_breakout PAXG-USD cts_score=6 entry=4673.23 stop=4521.76
13:20:19 INFO  executor.order_previewed  PAXG-USD BUY notional=61.70661377875054234072303553
13:20:20 INFO  executor.order_placed     PAXG-USD BUY order_id=4 status=filled
13:20:21 ERROR executor.preview_failed   PAXG-USD SELL
  Traceback (most recent call last):
    File ".../keel/execution/executor.py", line 625, in _run_order
      preview = broker.preview_order(intent.product_id, intent.side, order_configuration)
    File ".../keel/data/cb_client.py", line 255, in preview_order
      result[key] = Decimal(value)
  decimal.InvalidOperation: [<class 'decimal.ConversionSyntax'>]

The entry filled. The protective SELL preview then threw, and the exception aborted the
flow before agent.py:380's repo.open_position(...) ran.

Resulting state, still true today

orders:     id=4  rule_id=3  PAXG-USD  BUY  filled  qty=0.0132  actual_fill=4673.23  fee=0.73
positions:  (no PAXG row -- only the two BTC/dca tranches)

Consequences, all live right now:

  • No exit can ever fire. The 20-day channel exit and the 2xATR stop both key off a position
    row. There isn't one, so a planned swing trade became permanent unmanaged spot.
  • $61.71 of cap is consumed forever. _open_exposure_by_asset (guards.py:339) reads
    orders, not positions, so guard 6 counts the BUY notional. It is the exact gap between the
    reported total exposure $163.51 and BTC's $101.81.
  • keel positions cannot see it, so no operator action can address it either (see Reconcile: out-of-band venue executions leave phantom exposure; no keel positions close #798).
  • initial_stop was computed and discarded -- 4521.76 is in the log and nowhere else.

Note the silence: agent.tranche_not_recorded (the WARNING that exists for "entry filled but
could not be recorded") never fired, because that guard only covers a missing actual_fill/qty.
This path throws earlier and logs only a preview failure, which reads like a harmless
pre-trade hiccup rather than a stranded position.

Root cause, and why it is NOT fixed

The venue returned a value that is not a valid Decimal -- an empty string is the likely shape --
for a field the client converted unguarded.

The legacy client has since been fixed. In the deployed 0.15.0,
keel/data/cb_client.py reads:

est_base_size=Decimal(_field(response, "base_size", "0") or "0"),

The or "0" is what handles ""; the bare "0" default only covers a MISSING key.

But _build_broker was migrated to the registry-resolved adapter by #524, and that adapter never
got the guard
. Deployed 0.15.0, keel_broker_coinbase/adapter.py:332-334:

est_base_size=Decimal(_field(response, "base_size", "0")),
est_quote_size=Decimal(_field(response, "quote_size", "0")),
est_fee=Decimal(_field(response, "commission_total", "0")),

Same in the repo at packages/keel-broker-coinbase/keel_broker_coinbase/adapter.py. The fix
landed on the path that was being retired and not on the path that replaced it, so the August
incident would recur today.

Proposed

  1. Guard the conversions in the adapter, matching the legacy client (or "0"), and add a test
    feeding "" for each of base_size/quote_size/commission_total. A cross-implementation
    test asserting the two parsers agree on a degenerate response would stop the next divergence.
  2. A filled entry must never be lost to a later failure. Record the position first, or wrap
    the bracket leg so its failure downgrades to "position open, UNBRACKETED" -- a state the
    codebase already models (bracket: NO bracket renders on the two BTC tranches) -- rather than
    unwinding the bookkeeping for an entry that really did fill.
  3. Escalate the log line. A preview_failed on a protective leg after order_placed is a
    stranded-position event, not a preview hiccup. It needs its own event and a doctor check for
    filled BUY orders with no corresponding position row.
  4. Reconcile order id=4 -- decide whether to write the missing position (recovering the exit
    lifecycle and the recorded initial_stop of 4521.76) or to treat the holding as closed.

Severity

Silent, and it strands real money in an unmanaged state while tightening every cap against
inventory nothing can act on. Found while diagnosing why the live loop had placed no order since
2026-08-21. Related: #797, #798.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingrailsUn-overridable safety rail / guard (Compliance & rails)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions