Problem
An operator cannot reduce keel's measured exposure, and a sale made on the venue makes
keel's picture of the account wrong in the tightening direction.
_open_exposure_by_asset (keel/execution/guards.py:339) builds the per-asset figure that
feeds rails 4/5/6 from repo.get_orders(mode="live", ...) — keel's own orders table. Three
paths could reconcile that with reality, and none does:
- No manual close.
keel orders exposes only cancel and list. close_position
exists at keel/data/repository.py:1397 but has no CLI.
keel db import writes elsewhere. keel/data/csv_import.py imports Coinbase
transaction-history CSVs into the transactions table. Guard 6 never reads it.
reconcile_open_orders cannot discover. keel/execution/reconcile.py:89 iterates
_polled_rows(repo) and calls broker.get_order(native_id) per row. It refreshes orders
keel placed; it has no path to an order keel did not place.
Consequence
Sell a position on Coinbase and keel still counts the original BUY notional. Exposure stays
high, so rails 4/5/6 keep vetoing entries against inventory that is gone — and any exit the
engine later generates would try to sell what is no longer there.
The failure is quiet: nothing logs, doctor reports nothing, and the only symptom is
entries being refused for a reason that is no longer true.
Observed
On the live profile, guard 6 reports BTC exposure 101.81 (the two filled DCA buys at
50.00 + 51.81) against a 100.00 ceiling. Every BTC entry is vetoed. The obvious
operator response — sell a tranche — would not move the number at all.
Proposed
keel positions close <id> — records an operator-declared exit so the orders ledger
reflects it, confirmation-gated like the other dangerous verbs, written in a way guard 6
actually reads (an order row, not only a positions mutation).
- Balance reconciliation — compare venue-reported holdings against
_open_exposure_by_asset each cycle and surface a divergence. Given the module's stated
principle ("never let a row nobody can read make this figure SMALLER"), the safe default
is to report the skew loudly rather than silently trust the venue and shrink a cap.
- A
doctor check for the divergence, so it is visible before it blocks something.
Note on direction
The current behaviour fails closed, which is the right default — it refuses orders rather
than permitting them. This is not a request to make exposure shrink automatically. It is a
request for an auditable operator path and for the skew to be visible, since today it is
neither.
Found while diagnosing why the live loop had placed no order since 2026-08-21. Related: #797.
Problem
An operator cannot reduce keel's measured exposure, and a sale made on the venue makes
keel's picture of the account wrong in the tightening direction.
_open_exposure_by_asset(keel/execution/guards.py:339) builds the per-asset figure thatfeeds rails 4/5/6 from
repo.get_orders(mode="live", ...)— keel's own orders table. Threepaths could reconcile that with reality, and none does:
keel ordersexposes onlycancelandlist.close_positionexists at
keel/data/repository.py:1397but has no CLI.keel db importwrites elsewhere.keel/data/csv_import.pyimports Coinbasetransaction-history CSVs into the
transactionstable. Guard 6 never reads it.reconcile_open_orderscannot discover.keel/execution/reconcile.py:89iterates_polled_rows(repo)and callsbroker.get_order(native_id)per row. It refreshes orderskeel placed; it has no path to an order keel did not place.
Consequence
Sell a position on Coinbase and keel still counts the original BUY notional. Exposure stays
high, so rails 4/5/6 keep vetoing entries against inventory that is gone — and any exit the
engine later generates would try to sell what is no longer there.
The failure is quiet: nothing logs,
doctorreports nothing, and the only symptom isentries being refused for a reason that is no longer true.
Observed
On the live profile, guard 6 reports BTC exposure
101.81(the two filled DCA buys at50.00+51.81) against a100.00ceiling. Every BTC entry is vetoed. The obviousoperator response — sell a tranche — would not move the number at all.
Proposed
keel positions close <id>— records an operator-declared exit so the orders ledgerreflects it, confirmation-gated like the other dangerous verbs, written in a way guard 6
actually reads (an order row, not only a
positionsmutation)._open_exposure_by_asseteach cycle and surface a divergence. Given the module's statedprinciple ("never let a row nobody can read make this figure SMALLER"), the safe default
is to report the skew loudly rather than silently trust the venue and shrink a cap.
doctorcheck for the divergence, so it is visible before it blocks something.Note on direction
The current behaviour fails closed, which is the right default — it refuses orders rather
than permitting them. This is not a request to make exposure shrink automatically. It is a
request for an auditable operator path and for the skew to be visible, since today it is
neither.
Found while diagnosing why the live loop had placed no order since 2026-08-21. Related: #797.