Skip to content

feat(swaps): rank candidates on an interpolated venue cost curve - #187

Draft
haydenshively wants to merge 9 commits into
worktree-midnight-loan-as-collateralfrom
feat/venue-cost-curve
Draft

feat(swaps): rank candidates on an interpolated venue cost curve#187
haydenshively wants to merge 9 commits into
worktree-midnight-loan-as-collateralfrom
feat/venue-cost-curve

Conversation

@haydenshively

@haydenshively haydenshively commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Closes BOTS-91. Stacked on #184 (worktree-midnight-loan-as-collateral) — review that first; this PR's own diff is worktree-midnight-loan-as-collateral...feat/venue-cost-curve.

Why

From the 2026-08-28 15:00 UTC maturity (BetterStack source 2607569):

  • The probe ladder brackets nothing. PROBE_LADDER was 0.01,0.1,1,10,100 in whole collateral tokens — $800–$8M for cbBTC. Real seize sizes ran p50 0.0000379 / max 0.0504 cbBTC, so 832 of 926 (90%) fell below the bottom rung (median seize ≈ $3). Everything snapped to bucket 0, and the top rung reliably logged probe.venue_error.
  • Route cost was invisible to ranking. Median route cost for 0x cbBTC→USDC was 17.6 bps against the oracle; a loan-as-collateral slot pays zero. Post-maturity incentives are ~20 bps, so route cost is frequently the deciding term — and rankByUsdSurplus ordered on pure-oracle planSurplus.
  • Firm quotes were the dominant HTTP cost, at up to venues × 2 calls per candidate. Because the tick is a serial await chain, calls-per-candidate is a position's retry period — so this is a latency fix, not just a quota one.

createVenueSelector already probed every venue at every rung and cached it; select() threw the curve away.

What

  1. Fixed USD ladder — decades $0.01→$100k, converted per pair via an injected price source. Blue falls back to the whole-token ladder (no price source). PROBE_LADDER keeps its name.
  2. Rate-only cache — per-rung venue rate, no oracle, so the key stays (chainId, collateral, loan) and is shared across every market on that pair; cost in bps is derived per candidate at read time from its own oracle. Midnight's TTL drops to 45 s.
  3. Log-linear interpolation, clamp-never-extrapolate, clamped flag, and a fail-open rule: a cold, incomplete or clamped curve reverts to gross ordering with full venue fall-through, preserving the pre-curve guarantee that a bad probe costs at most a fall-through.
  4. Net-of-route-cost ranking above the cap. capCandidates truncated on gross surplus before ranking could reorder, so the true net winner could already be gone. Sizing now returns uncapped; the tick enriches, ranks, then caps — keeping the swap-free reservation.
  5. Async phase A.5 for route resolution and probe warming, between sizing and phase B. Phase A stays synchronous (its signature is the enforcement) and maintain was rejected as a home.
  6. Single-pass min-out derivation predicting the venue's own output from the curve, a venue fall-through cap when the curve is warm, firmCalls counted at the fetch seam, and curveCostBps/firmQuoteCostBps on select.ok to measure probe fidelity (currently unmeasured).

Plus two TIBs, a supersession block on TIB-2026-07-09 (its fixed ladder, its gross-ranking-only non-goal, and its "a mis-ranked venue costs at most a fall-through" assumption), and docs/INDEX.md entries.

Review notes

Post-review corrections worth a second look:

  • The min-out prediction is bounded in both directions. The overshoot factor is Q/Q̂, unbounded in the curve's pessimism — a curve only 0.5% pessimistic produced a floor 61.6 bps above break-even, against a ~20 bps incentive. clearsFloor is one-sided, so a symmetric postcondition now spends the second pass when the first-pass floor overshoots by more than the bound.
  • The fall-through cap distinguishes economic from transport outcomes. floor_unmet is also returned when the second-pass HTTP call fails, so capping on it lost liquidations a runner-up would have won. That path now reports quote_failed and keeps falling through.
  • Blue inherits the prediction, so a stale absolute level could set its min-out denominator on a 10-minute TTL. Bounded by a MAX_PREDICTION_AGE_MS in @repo/swaps rather than by shortening blue's TTL, which protects both bots.

Verification

pnpm test 210 files / 2811 tests, 0 failed · lint 0 warnings · knip clean · typecheck across swaps + both liquidators · fork suite 3/3. Every new assertion verified by break-one-assertion; the overshoot bound and the transport/economic split additionally proven by source mutation.

Note pnpm --filter @repo/contracts run build is required before bot typechecks (stale dist).

🤖 Generated with Claude Code

haydenshively and others added 9 commits August 31, 2026 00:20
Fix the select() return contract before the implementation lands so the
quoting and tick consumers can be written against a stable shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rungs become USD decades ($0.01–$100k) converted per-collateral against an
injected price source, the cache stores per-venue RATES (no oracle) so one
per-pair curve serves every market on that pair, and select() log-linearly
interpolates it — clamping and flagging `clamped` rather than extrapolating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
costBps stays floored at 0 so no scorer can treat a venue beating the oracle
as a bonus, but the signed value is what identifies a stale oracle -- the
signal the maturity's first-minute readings turned on -- so carry it as
costBpsRaw rather than discarding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The retry period is set by HTTP calls per candidate, not candidates, so the
tick needs the real number: venue fall-through and the floor re-derive each
multiply it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The firm quotes dominate the venue HTTP budget, and the tick's serial await
chain turns calls-per-candidate directly into retry period. Two multipliers
went away.

The min-out denominator is now predicted from the probe curve instead of the
oracle reference, so the first pass usually encodes a floor at or above
break-even and the second pass never runs. The prediction is biased down: an
aggregator encodes `quote · floor / denominator`, so a denominator under the
venue's real quote overshoots the floor (safe) while one over it undershoots,
where `clearsFloor` refuses the quote and the old second pass runs unchanged.
The postcondition is untouched — the pass count only ever sat beneath it.

A curve that ranked every enabled venue on unclamped rungs already names the
winner, so the venue walk stops there. Every other curve state (cold,
incomplete, clamped) fails open to the full fall-through, keeping the pre-curve
guarantee that a mis-ranked probe costs a fall-through and not a lost route.

`select.ok` now carries the curve's interpolated cost beside the same figure
off the quote the venue returned, which is the only way to tell whether the
probe (`/price`, no taker, no slippage) predicts the firm quote well enough to
tighten the cap further.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The curve ranks output, so a bad_route or floor_unmet on the winner says all
there is to say and the walk stops. A quote_failed does not: capping there
would lose a fill a runner-up could have made.

Size the prediction margin for the asymmetry too. Undersized only costs the
second pass; oversized tightens the encoded floor above break-even, and a
min-out shortfall already rejected 153 of 167 sends against a ~20 bps
incentive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sizing now returns every (slot, mode) candidate uncapped. The tick warms the
probe curve for the pairs its sized candidates actually sell through (a new
async phase A.5, deduplicated per pair), charges each candidate its interpolated
route cost, ranks on surplus minus that cost, and only then truncates to
MAX_PLAN_CANDIDATES_PER_POSITION - so the net winner is no longer discarded
before it is compared. A swap-free candidate costs zero by construction and is
still never truncated away.

Cold, clamped or unpriced curves fail open per position: gross-surplus ordering
and no new cutoff. Phase B keeps a bounded fall-through
(MAX_PRESELECTED_CANDIDATES_PER_POSITION) that reserves the best swap-free
candidate and is consumed only by candidates that actually spend work; drops are
counted as preselectSkipped and join the per-candidate identity. tick.end also
carries the tick's summed firmCalls (absent = unknown, not zero) and durationMs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both TIBs land here: they are one analysis behind two decisions and
cross-reference each other throughout, and Status: Proposed is how a decision
recorded ahead of its implementation is marked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Address the review of the curve-quoting work.

The min-out prediction was only bounded on one side. `clearsFloor` catches a
prediction that lands the encoded floor under break-even, but the overshoot
factor is `realQuote / prediction`, unbounded in the curve's pessimism: a curve
0.5% low encoded a floor 62 bps above break-even, three times the whole
post-maturity incentive, converting fundable fills into send-time reverts. The
postcondition is now symmetric — past twice the prediction margin the second
pass re-derives against the venue's real quote — and an overshooting first quote
is kept when that re-quote declines or fails, since it was already usable.

A failed re-quote was also reported as `floor_unmet`, which a trusted curve is
entitled to stop its venue walk on, so a transient 429 on the SECOND pass lost
a liquidation the runner-up could have filled. It now reports the transport
reason it is, and the walk continues.

The prediction consumes the curve's absolute level, which decays with cache age,
so it carries its own age ceiling independent of `PROBE_STALE_MS`. That protects
midnight against a stale cache and blue — whose 10-minute TTL was justified on
consuming ordering only, which the prediction work invalidated — without
multiplying blue's probe traffic.

Probe cost: a cold sweep was serial across venues at ~24 s per pair despite
per-venue token buckets, and it was awaited before any quote ran. Venues now
sweep concurrently (~8 s), the warm is started rather than awaited so it warms
for the next tick, and `refresh` dedupes an in-flight sweep so nothing
double-probes or waits behind one. Removing the candidate cap from sizing had
left the fan-out unbounded, so a second, looser gross cap bounds which
candidates get a route resolved at all; bad-debt write-offs, which phase B never
quotes, are no longer routed or warmed.

Also: count firm calls at the fetch seam so transport retries are not hidden,
serve `usdPriceOf` off the price snapshot instead of an unmemoized `decimals`
read that could leave a pair cold, clamp a degenerate interpolation bracket
instead of reporting confidence, and pin the two USD scales against each other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@haydenshively haydenshively changed the title feat/venue cost curve feat(swaps): rank candidates on an interpolated venue cost curve Aug 31, 2026
@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

BOTS-91

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant