@@ -48,6 +48,33 @@ const getPortfolioAllocationsAsRecord = (portfolio: any): Record<string, number>
4848 return portfolio . allocations as Record < string , number >
4949}
5050
51+ const parseOptionalNumber = ( value : unknown ) : number | undefined => {
52+ if ( value === undefined || value === null || value === '' ) return undefined
53+ const parsed = Number ( value )
54+ return Number . isFinite ( parsed ) ? parsed : undefined
55+ }
56+
57+ const parseOptionalBoolean = ( value : unknown ) : boolean | undefined => {
58+ if ( value === undefined || value === null || value === '' ) return undefined
59+ if ( typeof value === 'boolean' ) return value
60+ if ( typeof value === 'string' ) {
61+ const lower = value . toLowerCase ( )
62+ if ( lower === 'true' ) return true
63+ if ( lower === 'false' ) return false
64+ }
65+ return undefined
66+ }
67+
68+ const parseSlippageOverrides = ( value : unknown ) : Record < string , number > | undefined => {
69+ if ( ! value || typeof value !== 'object' || Array . isArray ( value ) ) return undefined
70+ const parsed : Record < string , number > = { }
71+ for ( const [ key , raw ] of Object . entries ( value as Record < string , unknown > ) ) {
72+ const num = parseOptionalNumber ( raw )
73+ if ( num !== undefined ) parsed [ key ] = num
74+ }
75+ return Object . keys ( parsed ) . length > 0 ? parsed : undefined
76+ }
77+
5178// ================================
5279// HEALTH CHECK ROUTES
5380// ================================
@@ -238,7 +265,18 @@ router.post('/portfolio/:id/rebalance', writeRateLimiter, async (req, res) => {
238265 } )
239266 }
240267
241- const result = await stellarService . executeRebalance ( portfolioId )
268+ const executionOptions = {
269+ tradeSlippageBps : parseOptionalNumber ( req . body ?. tradeSlippageBps ) ,
270+ maxSlippageBpsPerRebalance : parseOptionalNumber ( req . body ?. maxSlippageBpsPerRebalance ) ,
271+ maxSpreadBps : parseOptionalNumber ( req . body ?. maxSpreadBps ) ,
272+ minLiquidityCoverage : parseOptionalNumber ( req . body ?. minLiquidityCoverage ) ,
273+ allowPartialFill : parseOptionalBoolean ( req . body ?. allowPartialFill ) ,
274+ rollbackOnFailure : parseOptionalBoolean ( req . body ?. rollbackOnFailure ) ,
275+ signerSecret : typeof req . body ?. signerSecret === 'string' ? req . body . signerSecret : undefined ,
276+ tradeSlippageOverrides : parseSlippageOverrides ( req . body ?. tradeSlippageOverrides )
277+ }
278+
279+ const result = await stellarService . executeRebalance ( portfolioId , executionOptions )
242280
243281 await analyticsService . captureSnapshot ( portfolioId , prices )
244282
@@ -247,40 +285,51 @@ router.post('/portfolio/:id/rebalance', writeRateLimiter, async (req, res) => {
247285 trigger : 'Manual Rebalance' ,
248286 trades : result . trades || 0 ,
249287 gasUsed : result . gasUsed || '0 XLM' ,
250- status : 'completed' ,
288+ status : result . status === 'failed' ? 'failed' : 'completed' ,
251289 isAutomatic : false ,
252- riskAlerts : riskCheck . alerts
290+ riskAlerts : riskCheck . alerts ,
291+ error : result . failureReasons ?. join ( '; ' )
253292 } )
254293
255- // Send notification for manual rebalance
256- try {
257- await notificationService . notify ( {
258- userId : portfolio . userAddress ,
259- eventType : 'rebalance' ,
260- title : 'Portfolio Rebalanced' ,
261- message : `Your portfolio has been manually rebalanced. ${ result . trades || 0 } trades executed with ${ result . gasUsed || '0 XLM' } gas used.` ,
262- data : {
294+ // Send notification for successful/partial manual rebalance
295+ if ( result . status !== 'failed' ) {
296+ try {
297+ await notificationService . notify ( {
298+ userId : portfolio . userAddress ,
299+ eventType : 'rebalance' ,
300+ title : result . status === 'partial' ? 'Portfolio Partially Rebalanced' : 'Portfolio Rebalanced' ,
301+ message : `Your portfolio has been manually rebalanced. ${ result . trades || 0 } trades executed with ${ result . gasUsed || '0 XLM' } gas used.` ,
302+ data : {
303+ portfolioId,
304+ trades : result . trades ,
305+ gasUsed : result . gasUsed ,
306+ trigger : 'manual' ,
307+ status : result . status
308+ } ,
309+ timestamp : new Date ( ) . toISOString ( )
310+ } )
311+ } catch ( notificationError ) {
312+ logger . error ( 'Failed to send rebalance notification' , {
263313 portfolioId,
264- trades : result . trades ,
265- gasUsed : result . gasUsed ,
266- trigger : 'manual'
267- } ,
268- timestamp : new Date ( ) . toISOString ( )
269- } )
270- } catch ( notificationError ) {
271- logger . error ( 'Failed to send rebalance notification' , {
272- portfolioId,
273- error : getErrorObject ( notificationError )
274- } )
314+ error : getErrorObject ( notificationError )
315+ } )
316+ }
275317 }
276318
277319 logger . info ( 'Rebalance executed successfully' , { portfolioId, result } )
278- res . json ( {
320+ const responseStatus = result . status === 'failed' ? 409 : 200
321+ res . status ( responseStatus ) . json ( {
279322 result,
280- status : 'completed' ,
323+ status : result . status === 'failed' ? 'failed' : 'completed' ,
281324 mode : 'demo' ,
282- message : 'Rebalance completed successfully' ,
283- riskAlerts : riskCheck . alerts
325+ message : result . status === 'failed'
326+ ? 'Rebalance execution failed safely'
327+ : result . status === 'partial'
328+ ? 'Rebalance partially completed'
329+ : 'Rebalance completed successfully' ,
330+ riskAlerts : riskCheck . alerts ,
331+ failureReasons : result . failureReasons || [ ] ,
332+ partialFills : result . partialFills || [ ]
284333 } )
285334 } catch ( error ) {
286335 logger . error ( 'Rebalance failed' , { error : getErrorObject ( error ) , portfolioId : req . params . id } )
@@ -1396,4 +1445,4 @@ router.get('/queue/health', async (req, res) => {
13961445 }
13971446} )
13981447
1399- export { router as portfolioRouter }
1448+ export { router as portfolioRouter }
0 commit comments