feat(scrypt): hard-cap LIMIT execution price (Phase 2 / followup to #3736)#3738
Draft
TaprootFreak wants to merge 4 commits into
Draft
feat(scrypt): hard-cap LIMIT execution price (Phase 2 / followup to #3736)#3738TaprootFreak wants to merge 4 commits into
TaprootFreak wants to merge 4 commits into
Conversation
Scrypt embeds its commission into the quoted price ("price you see is
what you get"), so the spread between Scrypt's executable price and our
pricing reference is the only pre-trade signal that the implicit cost
is too high.
The default maxPriceDeviation of 5% in ScryptAdapter.getAndCheckTradePrice
was too loose: on 2026-05-21 a 570'000 EUR BTC/EUR buy on Scrypt cleared
at a 0.65% spread vs. our pricing reference, costing ~2'870 EUR more
than Kraken would have. The check passed silently because 0.65% << 5%.
Changes:
- Add Config.scrypt.maxPriceDeviation = 0.003 (0.3%)
- ScryptAdapter.getAndCheckTradePrice defaults to that cap
- On overshoot: send ErrorMonitoring mail (isLiqMail) before throwing
OrderFailedException, debounced 1h per asset pair to avoid spam
Phase 1 (#3736) added a pre-trade reference deviation check. That fires only at order-placement time; if Scrypt's quote drifts during fill, or the periodic edit loop re-prices a resting order, the 0.3% cap is no longer enforced. This change makes the cap hard. The LIMIT price submitted to Scrypt is clamped to a Reference × (1 ± maxPriceDeviation) bound for the lifetime of the order: - ScryptService.sell(from, to, amount, priceCap?) — initial order placement uses min(orderbookPrice, cap) for BUY, max(orderbookPrice, cap) for SELL. - ScryptService.checkTrade(..., priceCap?) — the edit loop applies the same clamp before issuing an OrderCancelReplaceRequest, so resting orders never get re-priced past the cap. - ScryptService.applyPriceCap() — single source of truth for the clamp, static for ease of unit testing. In the adapter: - getAndCheckTradePrice now returns { scryptPrice, priceCap, side } - check{Sell,Buy}Completion recompute priceCap on each tick from the current reference, so the protection adapts to market moves without needing extra DB columns. - computePriceCap helper handles the "to per from" vs "quote per base" conversion (only differs for side=BUY). Trade-off: an order can now legitimately sit unfilled if the venue is priced beyond the cap for an extended period. That is the intended behaviour — better a stalled order than silent overpayment.
7 tasks
…ap validation Self-review caught three correctness bugs in the price-cap implementation: 1. **toPriceCap inversion direction was reversed.** pricingService returns Price.price = source/target (= from-per-to), verified via Price.convert(amount) = amount/price. Scrypt LIMIT orders are quote-per-base. The two coincide for side=BUY (from=quote, to=base) and require inversion for side=SELL (from=base, to=quote). The original code inverted on BUY, producing a microscopic cap that would have rejected every Scrypt BUY trade once the cap was hit (or filled catastrophically against any standing book). Symmetry test added. 2. **Notification debounce was a permanent-mute.** `suppressRecurring: true` combined with `debounce: 1h` made Notification.isSuppressed return true forever for the same correlationId+context (the two conditions are OR'd in notification.entity.ts:43-47). Drop suppressRecurring so the 1h debounce works as documented in the PR. 3. **applyPriceCap accepted invalid caps.** Only `priceCap == null` short-circuited; 0, negative, NaN, ±Infinity would pass through and produce nonsensical LIMIT prices. Add defensive Number.isFinite + positivity check. Plus toPriceCap promoted to static-public to make it directly unit-testable. Tests added: applyPriceCap (BUY/SELL × cap variants, including all invalid forms) and toPriceCap (BUY/SELL with realistic refs, plus a symmetry sanity check that locks the inversion direction in).
This was referenced May 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
#3736 added a pre-trade reference-deviation check at 0.3 %. That check only fires at the moment we send
NewOrderSingle. Two things can still get past it:ScryptService.checkTradere-prices resting orders to whatever Scrypt's current best ask/bid is — completely unaware of our 0.3 % rule. A resting order placed inside the cap can be edited back out of it on the next tick.Reading the Scrypt AsyncAPI spec (#3737) confirmed there's no separate fee field to inspect — Scrypt embeds its commission in the quote (
Fee: "0"on every Trade in the spec examples,AvgPxdoc-string: "Does not include fees"). The only way to bound implicit cost is the LIMITPriceitself.What
Make the cap hard by clamping every LIMIT price we send to
reference × (1 ± maxPriceDeviation), for both placement and edits.ScryptServicesell(from, to, amount, priceCap?)— initial placement usesmin(orderbookPrice, cap)for BUY,max(orderbookPrice, cap)for SELL.checkTrade(clOrdId, from, to, orderCreated?, priceCap?)— edit loop applies the same clamp before anyOrderCancelReplaceRequest. Resting orders cannot be re-priced past the cap.applyPriceCap()— single static source of truth for the clamp; defensively returns the orderbook price fornull/undefined/NaN/±Infinity/≤0caps.ScryptAdaptergetAndCheckTradePricereturns{ scryptPrice, priceCap, side }. Pre-trade check + cap computation share onepricingService.getPricecall.computePriceCapis the per-tick helper used by completion checks, recomputing the cap from a fresh reference so the protection adapts to market moves without needing extra DB columns.toPriceCap(static, public for testability) does thefrom-per-to → quote-per-baseconversion.pricingService.getPrice(from, to).priceissource/target(verified viaPrice.convert(amount) = amount/priceinprice.ts). That coincides with quote-per-base forside=BUY(from=quote, to=base) and requires inversion forside=SELL.sell,buy,sellIfDeficit) and both completion checks (checkSellCompletion,checkBuyCompletion) wired through.Tests
Added
__tests__/scrypt.service.spec.tsand__tests__/scrypt.adapter.spec.ts(18 tests total) covering:applyPriceCap×{BUY, SELL}×{below cap, above cap, equal, undefined, null, 0, negative, NaN, ±Infinity}toPriceCaprealistic BUY (ref 66'500 EUR/BTC → cap 66'699.5) and SELL (ref 1.5e-5 BTC/EUR → cap 66'466.67)BUY-cap / SELL-cap = (1+δ)/(1−δ))Self-review bugs found and fixed in this PR
A self-review pass before requesting human review caught three correctness bugs in the original implementation (all in the second commit of this PR):
toPriceCapinverted on the wrong side. Initial code inverted for BUY (where the reference is already quote-per-base) and not for SELL (which actually needs inversion). The symmetry test now locks this in.suppressRecurring: truecombined withdebouncecausesNotification.isSuppressedto return true forever (the conditions are OR'd innotification.entity.ts:43-47). DroppedsuppressRecurringso the 1h debounce works as documented.applyPriceCapaccepted invalid caps. OnlypriceCap == nullshort-circuited;0/negative/NaN/±Infinitywould have produced bogus LIMIT prices. AddedNumber.isFinite+ positivity check.Behavioural change
An order can now legitimately sit unfilled if the venue is priced beyond the cap for an extended period. That is the intended behaviour — better a stalled order than silent overpayment like 2026-05-21. After the existing 1 h-no-found timeout (
ScryptService.checkTrade), the LM pipeline will fail and the alert fires.Effect on the 2026-05-21 incident
Replay with cap = 0.3 %, reference ≈ 66'500 EUR/BTC → cap ≈ 66'700 EUR/BTC. The 13 fills at 66'479–66'484 would have filled normally (they were under the cap; our internal spread vs
pricingServiceis what was high, not Scrypt's quote itself). The real win is in the bad case: if Scrypt's quote drifted up to 67'200 mid-fill, our LIMIT at 66'700 would simply stop filling instead of being edited up bycheckTrade.Out of scope (separate follow-up)
A spec-vs-code audit during this work surfaced unrelated drift between
scrypt.dto.tsand the vendor spec (wrongScryptTransactionStatusenum values, missingOrdStatus.Replaced, missingTransactTimeonOrderCancelRequest/OrderCancelReplaceRequest, unhandledExecType.CancelRejected/ReplaceRejected). These are not regressions introduced by this PR and are not addressed here. Tracked separately.Test plan
OrderCancelReplaceRequestis sent at cap price (or not at all if cap = current order price)Supersedes
This PR contains all changes from #3736 (Phase 1: pre-trade deviation check + alert) plus the LIMIT-cap and the bug-fix commit. #3736 can be closed once this is merged.