@@ -24,6 +24,24 @@ pub const MAX_LIQUIDITY_SENTINEL: &str = "max";
2424/// keep `ttl_secs ≥ 2 × repost_lead_secs` to get the full lead.
2525pub const DEFAULT_REPOST_LEAD_SECS : u64 = 60 ;
2626
27+ /// Seconds the indexer (and stitch's own slot-reuse credit) subtract from an
28+ /// order's deadline before serving it as live. Matches
29+ /// `FILLER_ORDER_DEADLINE_MARGIN_SECS` / `REUSABLE_DEADLINE_MARGIN_SECS`.
30+ /// `ttl_secs` must be strictly greater than this, or posted orders are accepted
31+ /// but immediately excluded from the live book and never fillable.
32+ pub const LIVE_ORDER_DEADLINE_MARGIN_SECS : u64 = 30 ;
33+
34+ /// Default cap on how far a TWAP-centered quote may post through the
35+ /// instantaneous feed, in bps (see `quote::SpotDeviationGuard`). Wide enough
36+ /// to keep selling into ordinary transient spikes (the strategy's win), tight
37+ /// enough that a persistent trend can't pick the lagging side off by more
38+ /// than this per fill while the average converges.
39+ pub const DEFAULT_TWAP_MAX_DEVIATION_BPS : u32 = 50 ;
40+
41+ /// Exclusive upper bound on `twap_max_deviation_bps`. At 10_000 bps the ask
42+ /// floor collapses to ≤ 0 and the guard silently disables itself.
43+ pub const MAX_TWAP_MAX_DEVIATION_BPS : u32 = 10_000 ;
44+
2745#[ derive( Debug , Clone , Deserialize ) ]
2846pub struct Config {
2947 pub chain_id : u64 ,
@@ -163,9 +181,32 @@ pub struct PoolConfig {
163181 /// Capped at half the TTL. Defaults to `DEFAULT_REPOST_LEAD_SECS`.
164182 #[ serde( default ) ]
165183 pub repost_lead_secs : Option < u64 > ,
166- /// Re-sign a side when its price moves more than this since its last order.
184+ /// Re-sign a side only when its price moves more than this since its last
185+ /// order (plus the TTL-driven age repost either way). 0 — the default —
186+ /// re-quotes every tick, keeping the book pinned to the current price:
187+ /// posting is off-chain and free, and a deadband lets quotes drift stale
188+ /// on slow moves. Set it above 0 only to cut signing/RPC churn (e.g. a
189+ /// rate-limited MPC signer).
190+ #[ serde( default ) ]
167191 pub refresh_threshold_bps : u32 ,
168192
193+ // ----- TWAP quoting. Center the spread on a short rolling time-weighted
194+ // average of the feed instead of the instantaneous value, so the book
195+ // stops chasing every tick: transient spikes get sold into above the
196+ // reverting mean instead of picking off a chased quote. See
197+ // [`crate::twap`]. -----
198+ /// Rolling TWAP window in seconds (~60-300 is sensible). Omit to quote
199+ /// off the instantaneous feed (the historical behavior). Longer filters
200+ /// more noise but lags real moves more.
201+ #[ serde( default ) ]
202+ pub twap_window_secs : Option < u64 > ,
203+ /// With TWAP on: never post a side more than this many bps through the
204+ /// instantaneous feed (ask below spot / bid above spot), bounding what a
205+ /// persistent trend can extract from the lagging center. Defaults to
206+ /// `DEFAULT_TWAP_MAX_DEVIATION_BPS`.
207+ #[ serde( default ) ]
208+ pub twap_max_deviation_bps : Option < u32 > ,
209+
169210 // ----- Taker leg (user limit orders). Users rest signed limit orders in
170211 // the same book the bot quotes into; when one's price reaches the bot's
171212 // own bid/ask it can be filled on-chain via `reactor.executeBatch`. The
@@ -290,6 +331,15 @@ impl PoolConfig {
290331 self . limit_taker_enabled . unwrap_or ( false )
291332 && ( self . buy_spread ( ) . is_some ( ) || self . sell_spread ( ) . is_some ( ) )
292333 }
334+ /// The TWAP window when TWAP quoting is on for this pool.
335+ pub fn twap_window ( & self ) -> Option < u64 > {
336+ self . twap_window_secs . filter ( |w| * w > 0 )
337+ }
338+ /// The spot-deviation cap for TWAP-centered quotes, with the default.
339+ pub fn twap_deviation_bps ( & self ) -> u32 {
340+ self . twap_max_deviation_bps
341+ . unwrap_or ( DEFAULT_TWAP_MAX_DEVIATION_BPS )
342+ }
293343 /// The pool's inventory-lean rollout mode. Live wins over shadow.
294344 pub fn lean_mode ( & self ) -> LeanMode {
295345 if self . lean_enabled . unwrap_or ( false ) {
@@ -320,6 +370,14 @@ impl Config {
320370
321371 fn validate ( & self ) -> anyhow:: Result < ( ) > {
322372 for ( idx, pool) in self . pools . iter ( ) . enumerate ( ) {
373+ anyhow:: ensure!(
374+ pool. ttl_secs > LIVE_ORDER_DEADLINE_MARGIN_SECS ,
375+ "pools[{idx}].ttl_secs ({}) must be greater than the live-order deadline \
376+ margin ({LIVE_ORDER_DEADLINE_MARGIN_SECS}s) — the indexer and stitch's own \
377+ slot reuse only serve orders whose deadline is later than chain time plus \
378+ that margin, so a shorter TTL posts orders that never appear as fillable depth",
379+ pool. ttl_secs
380+ ) ;
323381 if let Some ( min_slice) = pool. buy_min_slice_debt . as_deref ( ) {
324382 parse_min_slice_debt ( min_slice, & format ! ( "pools[{idx}].buy_min_slice_debt" ) ) ?;
325383 }
@@ -338,6 +396,30 @@ impl Config {
338396 "pools[{idx}].sell_max_orders {max_orders} exceeds supported limit {MAX_SUPPORTED_LADDER_ORDERS}"
339397 ) ;
340398 }
399+ if let Some ( window) = pool. twap_window_secs {
400+ anyhow:: ensure!(
401+ window > 0 ,
402+ "pools[{idx}].twap_window_secs must be positive; omit it to quote off the \
403+ instantaneous feed"
404+ ) ;
405+ }
406+ if let Some ( dev) = pool. twap_max_deviation_bps {
407+ anyhow:: ensure!(
408+ pool. twap_window_secs. is_some( ) ,
409+ "pools[{idx}].twap_max_deviation_bps only applies with twap_window_secs set"
410+ ) ;
411+ anyhow:: ensure!(
412+ dev > 0 ,
413+ "pools[{idx}].twap_max_deviation_bps must be positive — 0 would pin every \
414+ quote to the instantaneous feed and disable the TWAP center entirely"
415+ ) ;
416+ anyhow:: ensure!(
417+ dev < MAX_TWAP_MAX_DEVIATION_BPS ,
418+ "pools[{idx}].twap_max_deviation_bps ({dev}) must be < {MAX_TWAP_MAX_DEVIATION_BPS} \
419+ — at 10000 bps the ask floor collapses to ≤ 0 and the spot-deviation guard \
420+ silently disables itself"
421+ ) ;
422+ }
341423 if pool. lean_mode ( ) != LeanMode :: Off {
342424 let floor = pool. lean_floor_bps . ok_or_else ( || {
343425 anyhow:: anyhow!(
@@ -413,7 +495,7 @@ mod tests {
413495 sell_total_liquidity_collateral = "30000000000000000000000"
414496 sell_min_slice_debt = "10000000"
415497 sell_max_orders = 40
416- ttl_secs = 30
498+ ttl_secs = 60
417499 refresh_threshold_bps = 10
418500 "# ;
419501 let cfg = Config :: from_toml ( toml) . expect ( "config parses" ) ;
@@ -493,7 +575,7 @@ mod tests {
493575 sell_total_liquidity_collateral = "30000000000000000000000"
494576 sell_min_slice_debt = "10000000"
495577 sell_max_orders = 40
496- ttl_secs = 30
578+ ttl_secs = 60
497579 refresh_threshold_bps = 10
498580 "# ;
499581 let err = Config :: from_toml ( toml) . expect_err ( "oversized buy cap is rejected" ) ;
@@ -533,6 +615,80 @@ mod tests {
533615 refresh_threshold_bps = 10
534616 "# ;
535617
618+ #[ test]
619+ fn refresh_threshold_defaults_to_requoting_every_tick ( ) {
620+ let toml = LEAN_POOL_BASE . replace ( "refresh_threshold_bps = 10" , "" ) ;
621+ let cfg = Config :: from_toml ( & toml) . expect ( "threshold is optional" ) ;
622+ assert_eq ! ( cfg. pools[ 0 ] . refresh_threshold_bps, 0 ) ;
623+ }
624+
625+ #[ test]
626+ fn twap_defaults_to_off_with_a_default_deviation_cap ( ) {
627+ let cfg = Config :: from_toml ( LEAN_POOL_BASE ) . unwrap ( ) ;
628+ assert_eq ! ( cfg. pools[ 0 ] . twap_window( ) , None ) ;
629+ assert_eq ! (
630+ cfg. pools[ 0 ] . twap_deviation_bps( ) ,
631+ DEFAULT_TWAP_MAX_DEVIATION_BPS
632+ ) ;
633+ }
634+
635+ #[ test]
636+ fn twap_window_and_deviation_parse_together ( ) {
637+ let toml =
638+ format ! ( "{LEAN_POOL_BASE}\n twap_window_secs = 180\n twap_max_deviation_bps = 80\n " ) ;
639+ let cfg = Config :: from_toml ( & toml) . unwrap ( ) ;
640+ assert_eq ! ( cfg. pools[ 0 ] . twap_window( ) , Some ( 180 ) ) ;
641+ assert_eq ! ( cfg. pools[ 0 ] . twap_deviation_bps( ) , 80 ) ;
642+ }
643+
644+ #[ test]
645+ fn a_zero_twap_window_is_rejected ( ) {
646+ let toml = format ! ( "{LEAN_POOL_BASE}\n twap_window_secs = 0\n " ) ;
647+ let err = Config :: from_toml ( & toml) . expect_err ( "zero window is rejected" ) ;
648+ assert ! ( err. to_string( ) . contains( "twap_window_secs" ) ) ;
649+ }
650+
651+ #[ test]
652+ fn a_twap_deviation_without_a_window_is_rejected ( ) {
653+ let toml = format ! ( "{LEAN_POOL_BASE}\n twap_max_deviation_bps = 50\n " ) ;
654+ let err = Config :: from_toml ( & toml) . expect_err ( "deviation needs the window" ) ;
655+ assert ! ( err. to_string( ) . contains( "twap_window_secs" ) ) ;
656+ }
657+
658+ #[ test]
659+ fn a_zero_twap_deviation_is_rejected ( ) {
660+ let toml =
661+ format ! ( "{LEAN_POOL_BASE}\n twap_window_secs = 180\n twap_max_deviation_bps = 0\n " ) ;
662+ let err = Config :: from_toml ( & toml) . expect_err ( "zero deviation is rejected" ) ;
663+ assert ! ( err. to_string( ) . contains( "twap_max_deviation_bps" ) ) ;
664+ }
665+
666+ #[ test]
667+ fn a_twap_deviation_at_or_above_10000_bps_is_rejected ( ) {
668+ // 10000 bps collapses ask_floor to ≤ 0 and silently disables the guard.
669+ let toml =
670+ format ! ( "{LEAN_POOL_BASE}\n twap_window_secs = 60\n twap_max_deviation_bps = 10000\n " ) ;
671+ let err = Config :: from_toml ( & toml) . expect_err ( "10000 bps deviation is rejected" ) ;
672+ assert ! ( err. to_string( ) . contains( "twap_max_deviation_bps" ) ) ;
673+ }
674+
675+ #[ test]
676+ fn a_ttl_at_or_below_the_live_deadline_margin_is_rejected ( ) {
677+ // The indexer only serves orders with deadline > chain_time + 30s.
678+ // ttl == 30 posts orders that are immediately invisible; ttl == 15
679+ // (the short-ETH temptation) is the same footgun.
680+ for ttl in [ 0_u64 , 15 , LIVE_ORDER_DEADLINE_MARGIN_SECS ] {
681+ let toml = LEAN_POOL_BASE . replace ( "ttl_secs = 120" , & format ! ( "ttl_secs = {ttl}" ) ) ;
682+ let err =
683+ Config :: from_toml ( & toml) . expect_err ( & format ! ( "ttl_secs = {ttl} must be rejected" ) ) ;
684+ let msg = err. to_string ( ) ;
685+ assert ! (
686+ msg. contains( "ttl_secs" ) && msg. contains( "deadline margin" ) ,
687+ "unexpected error for ttl={ttl}: {msg}"
688+ ) ;
689+ }
690+ }
691+
536692 #[ test]
537693 fn lean_defaults_to_off_and_live_wins_over_shadow ( ) {
538694 let cfg = Config :: from_toml ( LEAN_POOL_BASE ) . unwrap ( ) ;
@@ -598,7 +754,7 @@ mod tests {
598754 debt_decimals = 6
599755 buy_offset_bps = 150
600756 buy_order_size_debt = "1000000000"
601- ttl_secs = 30
757+ ttl_secs = 60
602758 refresh_threshold_bps = 10
603759 "# ;
604760 let cfg = Config :: from_toml ( toml) . expect ( "buy-only config parses" ) ;
0 commit comments