@@ -136,6 +136,15 @@ pub struct SettingsBody {
136136 pub sell_sizing : SizingBody ,
137137 pub ttl_secs : u64 ,
138138 pub tick_interval_secs : u64 ,
139+ /// Rolling TWAP window in seconds. Empty = quote off the instantaneous feed.
140+ pub twap_window_secs : String ,
141+ /// Spot-deviation guard in bps. Empty = bot default when TWAP is on.
142+ pub twap_max_deviation_bps : String ,
143+ pub lean_enabled : bool ,
144+ pub lean_shadow : bool ,
145+ pub lean_floor_bps : String ,
146+ pub lean_base_bps : String ,
147+ pub lean_wide_bps : String ,
139148 /// Whether saving will be accepted. False for a bot whose config the panel can
140149 /// see but not write.
141150 pub editable : bool ,
@@ -156,6 +165,13 @@ impl SettingsBody {
156165 sell_sizing : SizingBody :: from ( & v. sell_sizing ) ,
157166 ttl_secs : v. ttl_secs ,
158167 tick_interval_secs : v. tick_interval_secs ,
168+ twap_window_secs : v. twap_window_secs . clone ( ) ,
169+ twap_max_deviation_bps : v. twap_max_deviation_bps . clone ( ) ,
170+ lean_enabled : v. lean_enabled ,
171+ lean_shadow : v. lean_shadow ,
172+ lean_floor_bps : v. lean_floor_bps . clone ( ) ,
173+ lean_base_bps : v. lean_base_bps . clone ( ) ,
174+ lean_wide_bps : v. lean_wide_bps . clone ( ) ,
159175 editable,
160176 }
161177 }
@@ -261,6 +277,20 @@ pub struct SettingsUpdate {
261277 pub ttl_secs : Option < u64 > ,
262278 #[ serde( default ) ]
263279 pub tick_interval_secs : Option < u64 > ,
280+ #[ serde( default ) ]
281+ pub twap_window_secs : Option < String > ,
282+ #[ serde( default ) ]
283+ pub twap_max_deviation_bps : Option < String > ,
284+ #[ serde( default ) ]
285+ pub lean_enabled : Option < bool > ,
286+ #[ serde( default ) ]
287+ pub lean_shadow : Option < bool > ,
288+ #[ serde( default ) ]
289+ pub lean_floor_bps : Option < String > ,
290+ #[ serde( default ) ]
291+ pub lean_base_bps : Option < String > ,
292+ #[ serde( default ) ]
293+ pub lean_wide_bps : Option < String > ,
264294}
265295
266296impl SettingsUpdate {
@@ -297,6 +327,38 @@ impl SettingsUpdate {
297327 if let Some ( v) = self . tick_interval_secs {
298328 patch. tick_interval_secs = Some ( v) ;
299329 }
330+ // A partial UI patch must leave untouched experimental fields alone —
331+ // otherwise a spread-only save would rewrite them from a stale form.
332+ // So start from a "don't touch" patch for those and only fold in what
333+ // the request named.
334+ patch. twap_window_secs = None ;
335+ patch. twap_max_deviation_bps = None ;
336+ patch. lean_enabled = None ;
337+ patch. lean_shadow = None ;
338+ patch. lean_floor_bps = None ;
339+ patch. lean_base_bps = None ;
340+ patch. lean_wide_bps = None ;
341+ if let Some ( v) = & self . twap_window_secs {
342+ patch. twap_window_secs = Some ( v. trim ( ) . to_string ( ) ) ;
343+ }
344+ if let Some ( v) = & self . twap_max_deviation_bps {
345+ patch. twap_max_deviation_bps = Some ( v. trim ( ) . to_string ( ) ) ;
346+ }
347+ if let Some ( v) = self . lean_enabled {
348+ patch. lean_enabled = Some ( v) ;
349+ }
350+ if let Some ( v) = self . lean_shadow {
351+ patch. lean_shadow = Some ( v) ;
352+ }
353+ if let Some ( v) = & self . lean_floor_bps {
354+ patch. lean_floor_bps = Some ( v. trim ( ) . to_string ( ) ) ;
355+ }
356+ if let Some ( v) = & self . lean_base_bps {
357+ patch. lean_base_bps = Some ( v. trim ( ) . to_string ( ) ) ;
358+ }
359+ if let Some ( v) = & self . lean_wide_bps {
360+ patch. lean_wide_bps = Some ( v. trim ( ) . to_string ( ) ) ;
361+ }
300362 Ok ( patch)
301363 }
302364}
@@ -322,8 +384,10 @@ pub async fn update(
322384 let current = setup:: read_settings_at ( & current_toml, pool) . map_err ( ApiError :: bad_request) ?;
323385 let patch = body. onto ( & current) ?;
324386 // `apply_settings` re-validates through the real loader, so an invalid value
325- // fails here and nothing is written.
326- let edited = setup:: apply_settings ( & current_toml, & patch) . map_err ( ApiError :: bad_request) ?;
387+ // fails here and nothing is written. Use the full anyhow chain — the outer
388+ // "edited config is not valid" alone doesn't name the field that failed.
389+ let edited = setup:: apply_settings ( & current_toml, & patch)
390+ . map_err ( |e| ApiError :: bad_request ( format ! ( "{e:#}" ) ) ) ?;
327391
328392 save_and_restart ( & state, & bot, & path, & edited, pool) . await
329393}
@@ -930,6 +994,40 @@ mod tests {
930994 assert_eq ! ( v[ "settings" ] [ "sell" ] , before[ "sell" ] ) ;
931995 assert_eq ! ( v[ "settings" ] [ "ttlSecs" ] , before[ "ttlSecs" ] ) ;
932996 assert_eq ! ( v[ "settings" ] [ "buySizing" ] , before[ "buySizing" ] ) ;
997+ assert_eq ! ( v[ "settings" ] [ "twapWindowSecs" ] , before[ "twapWindowSecs" ] ) ;
998+ }
999+
1000+ #[ tokio:: test]
1001+ async fn experimental_twap_and_lean_round_trip_through_settings ( ) {
1002+ let h = harness ( "settings-experimental" ) ;
1003+ seed ( & h, "bot-a" ) ;
1004+ let ( status, body) = h
1005+ . patch_json (
1006+ "/api/bots/bot-a/settings" ,
1007+ json ! ( {
1008+ "twapWindowSecs" : "60" ,
1009+ "twapMaxDeviationBps" : "50" ,
1010+ "leanShadow" : true ,
1011+ "leanFloorBps" : "3.0" ,
1012+ } ) ,
1013+ )
1014+ . await ;
1015+ assert_eq ! ( status, StatusCode :: OK , "first patch: {body}" ) ;
1016+ let v = Harness :: parse ( & body) ;
1017+ assert_eq ! ( v[ "settings" ] [ "twapWindowSecs" ] , "60" ) ;
1018+ assert_eq ! ( v[ "settings" ] [ "twapMaxDeviationBps" ] , "50" ) ;
1019+ assert_eq ! ( v[ "settings" ] [ "leanShadow" ] , true ) ;
1020+ assert_eq ! ( v[ "settings" ] [ "leanEnabled" ] , false ) ;
1021+
1022+ // Lean on without a floor is refused; nothing written for that field set.
1023+ let ( status, body) = h
1024+ . patch_json (
1025+ "/api/bots/bot-a/settings" ,
1026+ json ! ( { "leanEnabled" : true , "leanFloorBps" : "" } ) ,
1027+ )
1028+ . await ;
1029+ assert_eq ! ( status, StatusCode :: BAD_REQUEST , "{body}" ) ;
1030+ assert ! ( body. contains( "lean_floor" ) , "{body}" ) ;
9331031 }
9341032
9351033 #[ tokio:: test]
0 commit comments