Skip to content

feat(scrypt): hard-cap LIMIT execution price (Phase 2 / followup to #3736)#3738

Draft
TaprootFreak wants to merge 4 commits into
developfrom
feat/scrypt-limit-cap-followup
Draft

feat(scrypt): hard-cap LIMIT execution price (Phase 2 / followup to #3736)#3738
TaprootFreak wants to merge 4 commits into
developfrom
feat/scrypt-limit-cap-followup

Conversation

@TaprootFreak
Copy link
Copy Markdown
Collaborator

@TaprootFreak TaprootFreak commented May 21, 2026

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:

  1. Market moves between check and fill. Scrypt acks the order ~ms later, by which point the quoted ask can already be above our cap.
  2. The periodic edit loop in ScryptService.checkTrade re-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, AvgPx doc-string: "Does not include fees"). The only way to bound implicit cost is the LIMIT Price itself.

What

Make the cap hard by clamping every LIMIT price we send to reference × (1 ± maxPriceDeviation), for both placement and edits.

ScryptService

  • sell(from, to, amount, priceCap?) — initial placement uses min(orderbookPrice, cap) for BUY, max(orderbookPrice, cap) for SELL.
  • checkTrade(clOrdId, from, to, orderCreated?, priceCap?) — edit loop applies the same clamp before any OrderCancelReplaceRequest. Resting orders cannot be re-priced past the cap.
  • applyPriceCap() — single static source of truth for the clamp; defensively returns the orderbook price for null/undefined/NaN/±Infinity/≤0 caps.

ScryptAdapter

  • getAndCheckTradePrice returns { scryptPrice, priceCap, side }. Pre-trade check + cap computation share one pricingService.getPrice call.
  • computePriceCap is 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 the from-per-to → quote-per-base conversion. pricingService.getPrice(from, to).price is source/target (verified via Price.convert(amount) = amount/price in price.ts). That coincides with quote-per-base for side=BUY (from=quote, to=base) and requires inversion for side=SELL.
  • All three placement paths (sell, buy, sellIfDeficit) and both completion checks (checkSellCompletion, checkBuyCompletion) wired through.

Tests

Added __tests__/scrypt.service.spec.ts and __tests__/scrypt.adapter.spec.ts (18 tests total) covering:

  • applyPriceCap × {BUY, SELL} × {below cap, above cap, equal, undefined, null, 0, negative, NaN, ±Infinity}
  • toPriceCap realistic BUY (ref 66'500 EUR/BTC → cap 66'699.5) and SELL (ref 1.5e-5 BTC/EUR → cap 66'466.67)
  • A symmetry sanity test that locks the inversion direction in (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):

  1. toPriceCap inverted 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.
  2. Notification was permanently suppressed. suppressRecurring: true combined with debounce causes Notification.isSuppressed to return true forever (the conditions are OR'd in notification.entity.ts:43-47). Dropped suppressRecurring so the 1h debounce works as documented.
  3. applyPriceCap accepted invalid caps. Only priceCap == null short-circuited; 0/negative/NaN/±Infinity would have produced bogus LIMIT prices. Added Number.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 pricingService is 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 by checkTrade.

Out of scope (separate follow-up)

A spec-vs-code audit during this work surfaced unrelated drift between scrypt.dto.ts and the vendor spec (wrong ScryptTransactionStatus enum values, missing OrdStatus.Replaced, missing TransactTime on OrderCancelRequest/OrderCancelReplaceRequest, unhandled ExecType.CancelRejected/ReplaceRejected). These are not regressions introduced by this PR and are not addressed here. Tracked separately.

Test plan

  • Unit tests added + passing (18/18 locally)
  • lint, format, build green locally
  • CI green
  • DEV smoke test: force a Scrypt quote outside cap, confirm order is placed at cap price and does not fill until quote recovers
  • DEV: trigger edit-loop with quote outside cap, confirm OrderCancelReplaceRequest is sent at cap price (or not at all if cap = current order price)
  • Verify completion check still aggregates output correctly when order eventually fills

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.

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.
@TaprootFreak TaprootFreak changed the base branch from feat/scrypt-tighten-price-deviation-check to develop May 21, 2026 16:40
…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).
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