195 lines
5.2 KiB
Markdown
195 lines
5.2 KiB
Markdown
# Reconciliation EXIT Backfill Rollout Checklist
|
|
|
|
## Scope Guardrails
|
|
- No entry/exit/risk/signal logic changes.
|
|
- Data-only repair: insert compensating `EXIT` rows only.
|
|
- No deletes. Rollback uses status updates only.
|
|
- Backfill must be paused-mode gated and auditable by `batch_id`.
|
|
|
|
## Feature Flags (default safe state)
|
|
- `ENABLE_RECON_EXIT_BACKFILL=false`
|
|
- `RECON_EXIT_BACKFILL_DRY_RUN=true`
|
|
- `RECON_EXIT_BACKFILL_REQUIRE_PAUSE=true`
|
|
- `RECON_EXIT_BACKFILL_DUST_ABS_QTY=0.001`
|
|
- `RECON_EXIT_BACKFILL_DUST_REL_PCT=0.002`
|
|
- `RECON_EXIT_BACKFILL_LOOKBACK_HOURS=72`
|
|
- `EXCHANGE_STATE_MISMATCH_THROTTLE_MS=300000`
|
|
|
|
## 1. Detect Affected `trade_id` Values
|
|
```sql
|
|
WITH lifecycle AS (
|
|
SELECT
|
|
profile_id,
|
|
symbol,
|
|
trade_id,
|
|
SUM(CASE WHEN action = 'ENTRY' THEN COALESCE(quantity, qty, 0) ELSE 0 END) AS entry_qty,
|
|
SUM(CASE WHEN action = 'EXIT' THEN COALESCE(quantity, qty, 0) ELSE 0 END) AS exit_qty
|
|
FROM orders
|
|
WHERE profile_id = '<<PROFILE_ID>>'
|
|
AND status IN ('filled', 'partially_filled', 'partially-filled')
|
|
AND trade_id IS NOT NULL
|
|
GROUP BY profile_id, symbol, trade_id
|
|
)
|
|
SELECT
|
|
profile_id,
|
|
symbol,
|
|
trade_id,
|
|
entry_qty,
|
|
exit_qty,
|
|
(entry_qty - exit_qty) AS open_qty
|
|
FROM lifecycle
|
|
WHERE (entry_qty - exit_qty) > 0
|
|
ORDER BY symbol, trade_id;
|
|
```
|
|
|
|
## 2. Allow Criteria for Backfill `EXIT` Row
|
|
Backfill is allowed only when all are true:
|
|
- Profile is paused (`/internal/trading/status` returns `PAUSED`).
|
|
- Exchange is flat for symbol.
|
|
- No pending lifecycle blocker row exists for that `trade_id`.
|
|
- Close qty is supported by exchange fill evidence OR unresolved remainder is dust.
|
|
- Idempotency key resolves to unique deterministic `order_id`.
|
|
|
|
## 3. Idempotency Rules
|
|
- Deterministic `order_id`:
|
|
- `BFILL-<md5(profile_id:trade_id:exchange_order_id:filled_at)>`
|
|
- Insert path:
|
|
- `ON CONFLICT (order_id) DO NOTHING` (implemented via upsert + `ignoreDuplicates`).
|
|
- Re-runs are safe: existing backfill `order_id` rows are skipped.
|
|
- Exchange `client_order_id` (if present) is stored in `reconciliation_backfill_audit.exchange_client_order_id`.
|
|
|
|
## 4. Dust Threshold Handling
|
|
- Dust threshold per trade:
|
|
- `MAX(dust_abs_qty, open_qty * dust_rel_pct)`
|
|
- Default:
|
|
- `dust_abs_qty = 0.001`
|
|
- `dust_rel_pct = 0.2%`
|
|
- Auto-close allowed:
|
|
- evidence-covered qty always
|
|
- remainder only if `remainder <= threshold`
|
|
- No-Go:
|
|
- `remainder > threshold` with missing exchange fill evidence.
|
|
|
|
## 5. Dry-Run Execution and Verification
|
|
1. Set:
|
|
- `ENABLE_RECON_EXIT_BACKFILL=true`
|
|
- `RECON_EXIT_BACKFILL_DRY_RUN=true`
|
|
- `RECON_EXIT_BACKFILL_REQUIRE_PAUSE=true`
|
|
2. Pause trading from admin.
|
|
3. Let one reconciliation cycle run.
|
|
4. Verify audit-only effect:
|
|
```sql
|
|
SELECT
|
|
batch_id,
|
|
profile_id,
|
|
symbol,
|
|
trade_id,
|
|
decision,
|
|
reason,
|
|
filled_qty,
|
|
backfill_order_id,
|
|
created_at
|
|
FROM reconciliation_backfill_audit
|
|
WHERE profile_id = '<<PROFILE_ID>>'
|
|
ORDER BY created_at DESC
|
|
LIMIT 200;
|
|
```
|
|
5. Confirm `orders` table unchanged in dry-run:
|
|
```sql
|
|
SELECT COUNT(*) AS dry_run_backfill_rows
|
|
FROM orders
|
|
WHERE profile_id = '<<PROFILE_ID>>'
|
|
AND order_id LIKE 'BFILL-%';
|
|
```
|
|
|
|
## 6. Go / No-Go Before Apply
|
|
Go only if:
|
|
- Pause state confirmed.
|
|
- Dry-run shows expected `DRY_RUN` or `PENDING_APPLY` candidates.
|
|
- No unexpected `NO_GO` due to non-flat exchange or pending blockers.
|
|
- Candidate qty aligns with known exchange fills.
|
|
- Audit table writes succeed.
|
|
|
|
No-Go if any:
|
|
- Exchange not flat for target symbol.
|
|
- Large unmatched remainder (`missing_fill_evidence_for_large_remainder`).
|
|
- Audit table unavailable/write failure.
|
|
- Pending blocker rows for target `trade_id`.
|
|
|
|
## 7. Apply Step (still paused)
|
|
1. Set `RECON_EXIT_BACKFILL_DRY_RUN=false`.
|
|
2. Run one reconciliation cycle.
|
|
3. Validate inserted rows:
|
|
```sql
|
|
SELECT
|
|
order_id,
|
|
profile_id,
|
|
symbol,
|
|
trade_id,
|
|
action,
|
|
status,
|
|
qty,
|
|
price,
|
|
filled_at,
|
|
created_at
|
|
FROM orders
|
|
WHERE profile_id = '<<PROFILE_ID>>'
|
|
AND order_id LIKE 'BFILL-%'
|
|
ORDER BY created_at DESC;
|
|
```
|
|
|
|
## 8. Post-Fix Validation (ghost positions removed)
|
|
Open lifecycle should be zero/near-zero:
|
|
```sql
|
|
WITH lifecycle AS (
|
|
SELECT
|
|
profile_id,
|
|
symbol,
|
|
trade_id,
|
|
SUM(CASE WHEN action = 'ENTRY' THEN COALESCE(quantity, qty, 0) ELSE 0 END) AS entry_qty,
|
|
SUM(CASE WHEN action = 'EXIT' THEN COALESCE(quantity, qty, 0) ELSE 0 END) AS exit_qty
|
|
FROM orders
|
|
WHERE profile_id = '<<PROFILE_ID>>'
|
|
AND status IN ('filled', 'partially_filled', 'partially-filled')
|
|
AND trade_id IS NOT NULL
|
|
GROUP BY profile_id, symbol, trade_id
|
|
)
|
|
SELECT *
|
|
FROM lifecycle
|
|
WHERE (entry_qty - exit_qty) > 0.000001
|
|
ORDER BY symbol, trade_id;
|
|
```
|
|
|
|
Mismatch noise check (audit + events):
|
|
```sql
|
|
SELECT
|
|
decision,
|
|
COUNT(*) AS rows
|
|
FROM reconciliation_backfill_audit
|
|
WHERE profile_id = '<<PROFILE_ID>>'
|
|
GROUP BY decision
|
|
ORDER BY decision;
|
|
```
|
|
|
|
## 9. Rollback (Reversible, Non-Destructive)
|
|
Rollback is status-only, never delete:
|
|
```sql
|
|
UPDATE orders
|
|
SET
|
|
status = 'canceled',
|
|
updated_at = now()
|
|
WHERE order_id IN (
|
|
SELECT backfill_order_id
|
|
FROM reconciliation_backfill_audit
|
|
WHERE batch_id = '<<BATCH_ID>>'
|
|
AND decision IN ('APPLIED', 'SKIP_EXISTING')
|
|
AND backfill_order_id IS NOT NULL
|
|
);
|
|
```
|
|
Record rollback marker:
|
|
```sql
|
|
UPDATE reconciliation_backfill_audit
|
|
SET reverted_at = now()
|
|
WHERE batch_id = '<<BATCH_ID>>';
|
|
```
|