Skip to content

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

Merged
haydenshively merged 9 commits into
worktree-midnight-loan-as-collateralfrom
feat/venue-cost-curve
Sep 1, 2026
Merged

feat(swaps): rank candidates on an interpolated venue cost curve#187
haydenshively merged 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 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

@haydenshively haydenshively self-assigned this Sep 1, 2026
@haydenshively
haydenshively marked this pull request as ready for review September 1, 2026 03:50
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T04:06:48.894967Z 3d9e79e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

haydenshively and others added 9 commits August 31, 2026 22:52
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 added a commit that referenced this pull request Sep 1, 2026
A regression sweep over #184/#187/#188/#189 found four behaviors the stack
changed without meaning to. None are new functionality; each restores what
the bots did on main.

Detection-only blue could broadcast. The no-venues gate moved to AFTER unwrap
resolution and the swap-free branch lost its `steps.length > 0` guard, so a
venue-less deployment returned `kind: 'swap'` — which the tick takes straight
to simulate+submit. docker-compose defaults Robinhood (4663) to
ALLOW_DETECTION_ONLY with a funded key, documented as skipping every routed
liquidation. `swapFreeWithoutVenues` (default false) restores the immediate
refusal; midnight opts in, because its loan-as-collateral slots need no route
and ALLOW_BAD_DEBT_ONLY is a supported posture there.

That same reorder downgraded a transient unwrapper RPC failure from
`no_config` (skip) to `failed` (arms backoff), pushing a deliberately unarmed
deployment into a suppression state machine it never entered. Refusing before
the unwrap chain fixes both at once, and spends no reads doing it.

A send REJECTION now arms backoff even when a sibling execution-reverted.
Both sets are keyed by position, so `backoffExempt` was cancelling the backoff
a broken nonce/funds/RPC send earned; the position then re-sent every block
while the send machinery was still broken.

Phase A.5 no longer resolves routes for suppressed positions. It ran ahead of
the cooldown/backoff gates, so a backed-off position spent an uncached read
per candidate per tick — breaking backoff's contract that it bounds API and
RPC usage under a backlog, and putting that latency in front of the first
send of a maturity burst.

Each of the 8 new tests was verified to fail against the pre-fix source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread packages/swaps/src/quoting.ts
Comment thread bots/midnight-liquidation/src/runner/tick.ts
Comment thread bots/midnight-liquidation/src/quotes.ts
Comment thread bots/midnight-liquidation/src/runner/tick.ts
@haydenshively
haydenshively marked this pull request as draft September 1, 2026 03:54
@haydenshively
haydenshively marked this pull request as ready for review September 1, 2026 03:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d9e79ef77

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bots/midnight-liquidation/src/runner/tick.ts
Comment thread bots/midnight-liquidation/src/runner/tick.ts
Comment thread packages/swaps/src/venue-selector.ts
haydenshively added a commit that referenced this pull request Sep 1, 2026
Triaged all 20 Codex/Devin findings across #184, #187, #188, #189 and #192.

Fixed:
- the venue-less swap-free exception admitted unwrap-only plans, so an
  ALLOW_BAD_DEBT_ONLY deployment could broadcast an asset-moving liquidation
- candidate ranking trusted stale and incomplete curves that quoting refuses;
  both now share one exported `curveIsTrusted`
- a Pendle PT resolved its unwrap chain twice per tick, both hosted API calls;
  new `previewTokenOut` seam answers phase A.5 from cache
- phase A.5 resolved routes serially, in discovery order
- a sibling's `no_route` suppressed a position whose other candidate was
  `floor_unmet`, which is meant to retry every block as the LIF ramps
- the wall-clock cooldown verdict could flip mid-tick, leaving a candidate
  quoted but unpriced
- revert-streak state never expired, so a reused label reported false crossings
- `tx.submit_failed` carried no candidate discriminator
- docs: the swap-free "iff" guarantee, `selectorConstant`, two README rows

Declined, with reasoning for the threads: narrowing the backoff exemption to
post-maturity plans (a regression — the sets are per-position while the mode is
per-candidate), `stopAfterWinner`, and the sub-1e-18 rate truncation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@haydenshively
haydenshively merged commit 7d07f26 into main Sep 1, 2026
15 checks passed
@haydenshively
haydenshively deleted the feat/venue-cost-curve branch September 1, 2026 16:18
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.

2 participants