A sell order can end while part of the position remains. If an exit for 10 units fills 2.500 units, then another 0.250 units during cancellation, the next order cannot reuse the original size. It also cannot rely on the earlier 7.500-unit remainder.
This edition builds an offline reconciliation report for that situation. A fill is an executed piece of an order. Cancellation stops the outstanding piece; it does not reverse pieces already executed. Our completed artifact checks the recorded fills, order state and available balance before calculating a possible replacement quantity.
Reader problem and inspected sources
A new repository change makes this question timely. Pull request 644 merged on October 14, 2026 at 03:18 UTC. It adds post-only limit sells with an expiry for selected non-urgent Coinbase exits. We inspected app/server.py, app/exchange.py and tests/test_server_entry_idempotency.py at d61be46cbacce2e8206ffc90a9ae1d21536fe04c. The trading branch was main at d25732d26f1be544b3a86deaaab46668f8655921 when checked.
The inspected path records an exit identity, waits for uncertain submissions and handles cancelled or expired orders through a later retry cycle. Railway showed a successful server deployment of the maker-exit commit. A query for maker events from October 14, 03:18 through 10:05 UTC returned no matching entries. Deployment is verified; a maker fill, fee saving or profitable exit is not.
A separate demand signal is Freqtrade issue 7444, opened September 19, 2022 and closed September 24. The reporter described a cancelled, partially filled sell leaving the bot's tracked amount larger than the exchange balance. This is a historical report about another system. It establishes a concrete failure pattern, not a current defect in our code.
Prerequisites and research question
Use Python 3.12, an empty directory and about 35 minutes. Everything below uses invented records and the standard library. There are no credentials or network calls.
The question is: After a partially filled exit ends, what evidence is required to calculate a replacement quantity without counting fills twice or reusing the original order size?
Before running, hand-calculate 10.000 - 2.000 - 0.500 - 0.250 = 7.250. Predict no replacement while cancellation is pending, while fill pages are incomplete, or while the fill ledger disagrees with the order's cumulative quantity.
1. Preserve order state and fill quantity separately
Coinbase's Get Order reference exposes order status, pending_cancel and filled_size. Its Cancel Orders reference describes results of initiated cancellation requests. Our inference is that a cancellation response alone is insufficient for replacement planning.
The fixture uses normalized states. PENDING_CANCEL is our educational state, not a copied Coinbase response. A real adapter must map the venue's status and cancellation fields explicitly.
An ended order may still have partial fills. The lab therefore compares the deduplicated fill total with the venue's cumulative filled quantity. It requires complete collection before producing a candidate size.
2. Count each fill once, then round the remainder down
Coinbase's List Fills reference documents trade identity, order identity, size and a pagination cursor. A real export must scope records to the intended order and process the pagination contract. One page is not automatically complete.
The example has one order and uses synthetic trade identities f1, f2 and f3. A repeated identity with the same quantity is ignored. A repeated identity with a different quantity is rejected. Real identity keys must follow the venue's documented scope.
Quantities enter as strings and use Python Decimal. This preserves the decimal arithmetic of our inputs. Rounding divides by the quantity increment, takes the whole-number portion and multiplies back. It works for steps such as 0.005 as well as 0.001.
3. Run the complete synthetic example
Save this as partial_exit_lab.py, then run python3 -I partial_exit_lab.py. Python's isolation flag excludes user site packages and the current directory from its import path.
from decimal import Decimal, InvalidOperation, ROUND_DOWN
def quantity(text):
if not isinstance(text, str):
raise ValueError('BAD_QUANTITY')
try:
value = Decimal(text)
except InvalidOperation:
raise ValueError('BAD_QUANTITY') from None
if not value.is_finite() or value < 0:
raise ValueError('BAD_QUANTITY')
return value
def plan(original, snapshot, fills, available, step='0.001'):
total, balance, tick = map(quantity, (original, available, step))
if total <= 0 or tick <= 0:
raise ValueError('BAD_QUANTITY')
status = snapshot['status']
statuses = {'OPEN', 'PENDING_CANCEL', 'CANCELLED', 'EXPIRED',
'FILLED', 'REJECTED'}
if status not in statuses or type(snapshot['fills_complete']) is not bool:
raise ValueError('BAD_SNAPSHOT')
venue_filled = quantity(snapshot['filled_qty'])
unique, duplicates = {}, 0
for fill in fills:
ident = fill['trade_id']
amount = quantity(fill['qty'])
if not isinstance(ident, str) or not ident or amount <= 0:
raise ValueError('BAD_FILL')
if ident in unique:
if unique[ident] != amount:
raise ValueError('FILL_ID_COLLISION')
duplicates += 1
else:
unique[ident] = amount
filled = sum(unique.values(), Decimal('0'))
if filled > total or venue_filled > total:
raise ValueError('OVERFILLED')
remaining = total - filled
result = dict(state='WAIT_ORDER', filled=filled, remaining=remaining,
replacement=Decimal('0'), duplicates=duplicates)
if status in {'OPEN', 'PENDING_CANCEL'}:
return result
if not snapshot['fills_complete'] or filled != venue_filled:
result['state'] = 'WAIT_FILLS'
return result
if (status == 'FILLED' and remaining != 0 or
status == 'REJECTED' and filled != 0):
raise ValueError('CONTRADICTORY_STATUS')
if remaining == 0:
result['state'] = 'CLOSED'
elif balance < remaining:
result['state'] = 'WAIT_BALANCE'
else:
size = (remaining / tick).to_integral_value(rounding=ROUND_DOWN) * tick
result['state'] = 'REPLACEMENT_CANDIDATE' if size else 'DUST'
result['replacement'] = size
return result
def snapshot(status, filled, complete=True):
return dict(status=status, filled_qty=filled, fills_complete=complete)
early = [dict(trade_id='f1', qty='2.000'), dict(trade_id='f2', qty='0.500')]
late = early + [dict(trade_id='f3', qty='0.250')]
cases = [
('open', snapshot('OPEN', '2.500'), early, '7.500'),
('cancel_ack', snapshot('PENDING_CANCEL', '2.500'), early, '7.500'),
('late_fill_missing', snapshot('CANCELLED', '2.750'), early, '7.250'),
('pages_incomplete', snapshot('CANCELLED', '2.750', False), late, '7.250'),
('reconciled', snapshot('CANCELLED', '2.750'), late + [late[0]], '7.250'),
('balance_mismatch', snapshot('CANCELLED', '2.750'), late, '7.000'),
('all_filled', snapshot('FILLED', '10.000'),
[dict(trade_id='f4', qty='10.000')], '0.000'),
('rejected_empty', snapshot('REJECTED', '0.000'), [], '10.000'),
('dust', snapshot('EXPIRED', '9.9995'),
[dict(trade_id='f5', qty='9.9995')], '0.0005'),
]
expected = [
('WAIT_ORDER', '0', '7.500', 0),
('WAIT_ORDER', '0', '7.500', 0),
('WAIT_FILLS', '0', '7.500', 0),
('WAIT_FILLS', '0', '7.250', 0),
('REPLACEMENT_CANDIDATE', '7.250', '7.250', 1),
('WAIT_BALANCE', '0', '7.250', 0),
('CLOSED', '0', '0.000', 0),
('REPLACEMENT_CANDIDATE', '10.000', '10.000', 0),
('DUST', '0.000', '0.0005', 0),
]
checks = 0
for (name, view, fills, balance), (state, size, remaining, duplicates) in zip(cases, expected):
result = plan('10.000', view, fills, balance)
assert (result['state'], result['replacement'], result['remaining'],
result['duplicates']) == (state, Decimal(size), Decimal(remaining), duplicates)
assert result['filled'] + result['remaining'] == Decimal('10.000')
assert result['replacement'] <= min(result['remaining'], Decimal(balance))
checks += 3
print(f"{name}: state={result['state']} filled={result['filled']:.4f} "
f"remaining={result['remaining']:.4f} replacement={result['replacement']:.3f} "
f"duplicates={result['duplicates']}")
bad = {
'collision': (snapshot('CANCELLED', '2.500'), early +
[dict(trade_id='f1', qty='3.000')], 'FILL_ID_COLLISION'),
'overfill': (snapshot('CANCELLED', '11.000'),
[dict(trade_id='f6', qty='11.000')], 'OVERFILLED'),
'nan': (snapshot('CANCELLED', 'NaN'), [], 'BAD_QUANTITY'),
'negative': (snapshot('CANCELLED', '-1'), [], 'BAD_QUANTITY'),
'unknown_status': (snapshot('MAYBE', '0'), [], 'BAD_SNAPSHOT'),
'filled_mismatch': (snapshot('FILLED', '2.500'), early, 'CONTRADICTORY_STATUS'),
}
for name, (view, fills, reason) in bad.items():
try:
plan('10.000', view, fills, '10.000')
except ValueError as error:
assert str(error) == reason
checks += 1
print(f'{name}: rejected={error}')
else:
raise AssertionError('INVALID_CASE_ACCEPTED')
print(f'checks={checks} passed; external_calls=0 orders=0')
Expected complete stdout:
open: state=WAIT_ORDER filled=2.5000 remaining=7.5000 replacement=0.000 duplicates=0
cancel_ack: state=WAIT_ORDER filled=2.5000 remaining=7.5000 replacement=0.000 duplicates=0
late_fill_missing: state=WAIT_FILLS filled=2.5000 remaining=7.5000 replacement=0.000 duplicates=0
pages_incomplete: state=WAIT_FILLS filled=2.7500 remaining=7.2500 replacement=0.000 duplicates=0
reconciled: state=REPLACEMENT_CANDIDATE filled=2.7500 remaining=7.2500 replacement=7.250 duplicates=1
balance_mismatch: state=WAIT_BALANCE filled=2.7500 remaining=7.2500 replacement=0.000 duplicates=0
all_filled: state=CLOSED filled=10.0000 remaining=0.0000 replacement=0.000 duplicates=0
rejected_empty: state=REPLACEMENT_CANDIDATE filled=0.0000 remaining=10.0000 replacement=10.000 duplicates=0
dust: state=DUST filled=9.9995 remaining=0.0005 replacement=0.000 duplicates=0
collision: rejected=FILL_ID_COLLISION
overfill: rejected=OVERFILLED
nan: rejected=BAD_QUANTITY
negative: rejected=BAD_QUANTITY
unknown_status: rejected=BAD_SNAPSHOT
filled_mismatch: rejected=CONTRADICTORY_STATUS
checks=33 passed; external_calls=0 orders=0
4. Explain the nine valid histories
open and cancel_ack produce no replacement. The old order can still execute. In late_fill_missing, the order reports 2.750 filled while the ledger contains only 2.500, so the report waits. In pages_incomplete, matching totals are still insufficient because collection is incomplete.
reconciled counts the repeated first fill once and calculates 7.250 remaining. balance_mismatch waits because available units are lower than that remainder. We deliberately require an investigation instead of silently changing the tracked position to match a balance.
all_filled closes with no replacement. rejected_empty leaves the whole original quantity as a candidate. dust preserves the 0.0005-unit remainder but rounds replacement to zero at a 0.001 step. Nothing disappears from the ledger.
Six invalid histories test conflicting fill identities, excess fills, non-finite and negative quantities, an unknown state and a contradictory fully filled status. All 33 checks pass. A separate Fraction-based arithmetic review and reordered-input checks passed before publication.
5. Integrate the investigation as a read-only report
Export one order, its cumulative fill quantity, every associated fill, collection completeness and the relevant available balance. Freeze that evidence set. Recount independently before investigating execution decisions.
If the report waits for fills, inspect pagination and delayed observations. If it waits for balance, inspect outstanding holds, other orders and inventory movements. If an identity collides, inspect the producer's identity contract. Preserve the original export.
REPLACEMENT_CANDIDATE is a calculation, not order authorization. A live executor still needs current permissions, available inventory, venue minimums and protection against competing orders. Edition 7 covers the separate problem of an unknown external effect.
Existing guidance, troubleshooting and limits
Our October 14 search sampled Coinbase's order, cancellation and fill references, Freqtrade's historical issue and its documented cancellation configuration. Those sources explain interfaces and established failure patterns. This article adds a complete quantity-conservation lab with delayed fills, duplicate records, pagination uncertainty, balance disagreement and dust. The bounded search does not establish an uncovered market or search volume.
If output differs, check that the three fill quantities are strings, the duplicate retains its identity and the expected block includes every line. Never replace missing fill evidence with zero. Never treat an empty page as a complete history without checking the API contract.
The example assumes the order snapshot and fills can be reconciled into one frozen evidence set. It does not authenticate receipts, synchronize live observations, recover durable state, calculate fees or model concurrent inventory changes. A consistent total does not by itself prove a complete live history. The manual fallback is to retain the blocked replacement and inspect read-only records.
Completion check and next experiment
Match complete stdout, verify that filled plus remaining always equals 10.000, and explain why the duplicate and late fill lead to 7.250 rather than 7.500. Explain why a cancellation acknowledgement cannot establish that remainder.
The next experiment will give two synthetic exit planners the same remaining inventory, then test whether a durable reservation prevents both from claiming it. That is the next boundary after a correct quantity calculation.