Skip to content

Commit b1cd223

Browse files
authored
Merge pull request #58 from anoncon/feat/issue-36-stellar-dex-rebalance
Implement real Stellar DEX rebalance execution with slippage controls
2 parents 211ac96 + b590ec1 commit b1cd223

File tree

7 files changed

+1159
-325
lines changed

7 files changed

+1159
-325
lines changed

backend/.env.example

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
2727
# Replace with your actual deployed contract address
2828
STELLAR_CONTRACT_ADDRESS=CDEMOCONTRACTADDRESS123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
2929

30+
# Secret used by backend to sign DEX rebalance transactions.
31+
# Use a testnet account secret only.
32+
STELLAR_REBALANCE_SECRET=
33+
34+
# Allow signer account mismatch with portfolio user address (not recommended)
35+
REBALANCE_ALLOW_SIGNER_MISMATCH=false
36+
37+
# Optional JSON map for non-native assets used in rebalancing
38+
# Example: {"USDC":"G...","BTC":"G...","ETH":"G..."}
39+
STELLAR_ASSET_ISSUERS={"USDC":"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5","BTC":"GAUTUYY2THLF7SGITDFMXJVYH3LHDSMGEAKSBU267M2K7A3W543CKUEF"}
40+
3041
# ============================================
3142
# PRICE FEED CONFIGURATION
3243
# ============================================
@@ -79,6 +90,14 @@ MAX_TRADE_SIZE_PERCENTAGE=25
7990
# Minimum trade size (USD)
8091
MIN_TRADE_SIZE_USD=10
8192

93+
# Rebalance execution safety controls
94+
REBALANCE_MAX_TRADE_SLIPPAGE_BPS=100
95+
REBALANCE_MAX_TOTAL_SLIPPAGE_BPS=250
96+
REBALANCE_MAX_SPREAD_BPS=120
97+
REBALANCE_MIN_LIQUIDITY_COVERAGE=1.0
98+
REBALANCE_ALLOW_PARTIAL_FILL=true
99+
REBALANCE_ROLLBACK_ON_FAILURE=true
100+
82101
# ============================================
83102
# NOTIFICATION CONFIGURATION
84103
# ============================================

backend/src/api/routes.ts

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,33 @@ const getPortfolioAllocationsAsRecord = (portfolio: any): Record<string, number>
4747
return portfolio.allocations as Record<string, number>
4848
}
4949

50+
const parseOptionalNumber = (value: unknown): number | undefined => {
51+
if (value === undefined || value === null || value === '') return undefined
52+
const parsed = Number(value)
53+
return Number.isFinite(parsed) ? parsed : undefined
54+
}
55+
56+
const parseOptionalBoolean = (value: unknown): boolean | undefined => {
57+
if (value === undefined || value === null || value === '') return undefined
58+
if (typeof value === 'boolean') return value
59+
if (typeof value === 'string') {
60+
const lower = value.toLowerCase()
61+
if (lower === 'true') return true
62+
if (lower === 'false') return false
63+
}
64+
return undefined
65+
}
66+
67+
const parseSlippageOverrides = (value: unknown): Record<string, number> | undefined => {
68+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
69+
const parsed: Record<string, number> = {}
70+
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
71+
const num = parseOptionalNumber(raw)
72+
if (num !== undefined) parsed[key] = num
73+
}
74+
return Object.keys(parsed).length > 0 ? parsed : undefined
75+
}
76+
5077
// ================================
5178
// HEALTH CHECK ROUTES
5279
// ================================
@@ -237,7 +264,18 @@ router.post('/portfolio/:id/rebalance', writeRateLimiter, async (req, res) => {
237264
})
238265
}
239266

240-
const result = await stellarService.executeRebalance(portfolioId)
267+
const executionOptions = {
268+
tradeSlippageBps: parseOptionalNumber(req.body?.tradeSlippageBps),
269+
maxSlippageBpsPerRebalance: parseOptionalNumber(req.body?.maxSlippageBpsPerRebalance),
270+
maxSpreadBps: parseOptionalNumber(req.body?.maxSpreadBps),
271+
minLiquidityCoverage: parseOptionalNumber(req.body?.minLiquidityCoverage),
272+
allowPartialFill: parseOptionalBoolean(req.body?.allowPartialFill),
273+
rollbackOnFailure: parseOptionalBoolean(req.body?.rollbackOnFailure),
274+
signerSecret: typeof req.body?.signerSecret === 'string' ? req.body.signerSecret : undefined,
275+
tradeSlippageOverrides: parseSlippageOverrides(req.body?.tradeSlippageOverrides)
276+
}
277+
278+
const result = await stellarService.executeRebalance(portfolioId, executionOptions)
241279

242280
await analyticsService.captureSnapshot(portfolioId, prices)
243281

@@ -246,40 +284,51 @@ router.post('/portfolio/:id/rebalance', writeRateLimiter, async (req, res) => {
246284
trigger: 'Manual Rebalance',
247285
trades: result.trades || 0,
248286
gasUsed: result.gasUsed || '0 XLM',
249-
status: 'completed',
287+
status: result.status === 'failed' ? 'failed' : 'completed',
250288
isAutomatic: false,
251-
riskAlerts: riskCheck.alerts
289+
riskAlerts: riskCheck.alerts,
290+
error: result.failureReasons?.join('; ')
252291
})
253292

254-
// Send notification for manual rebalance
255-
try {
256-
await notificationService.notify({
257-
userId: portfolio.userAddress,
258-
eventType: 'rebalance',
259-
title: 'Portfolio Rebalanced',
260-
message: `Your portfolio has been manually rebalanced. ${result.trades || 0} trades executed with ${result.gasUsed || '0 XLM'} gas used.`,
261-
data: {
293+
// Send notification for successful/partial manual rebalance
294+
if (result.status !== 'failed') {
295+
try {
296+
await notificationService.notify({
297+
userId: portfolio.userAddress,
298+
eventType: 'rebalance',
299+
title: result.status === 'partial' ? 'Portfolio Partially Rebalanced' : 'Portfolio Rebalanced',
300+
message: `Your portfolio has been manually rebalanced. ${result.trades || 0} trades executed with ${result.gasUsed || '0 XLM'} gas used.`,
301+
data: {
302+
portfolioId,
303+
trades: result.trades,
304+
gasUsed: result.gasUsed,
305+
trigger: 'manual',
306+
status: result.status
307+
},
308+
timestamp: new Date().toISOString()
309+
})
310+
} catch (notificationError) {
311+
logger.error('Failed to send rebalance notification', {
262312
portfolioId,
263-
trades: result.trades,
264-
gasUsed: result.gasUsed,
265-
trigger: 'manual'
266-
},
267-
timestamp: new Date().toISOString()
268-
})
269-
} catch (notificationError) {
270-
logger.error('Failed to send rebalance notification', {
271-
portfolioId,
272-
error: getErrorObject(notificationError)
273-
})
313+
error: getErrorObject(notificationError)
314+
})
315+
}
274316
}
275317

276318
logger.info('Rebalance executed successfully', { portfolioId, result })
277-
res.json({
319+
const responseStatus = result.status === 'failed' ? 409 : 200
320+
res.status(responseStatus).json({
278321
result,
279-
status: 'completed',
322+
status: result.status === 'failed' ? 'failed' : 'completed',
280323
mode: 'demo',
281-
message: 'Rebalance completed successfully',
282-
riskAlerts: riskCheck.alerts
324+
message: result.status === 'failed'
325+
? 'Rebalance execution failed safely'
326+
: result.status === 'partial'
327+
? 'Rebalance partially completed'
328+
: 'Rebalance completed successfully',
329+
riskAlerts: riskCheck.alerts,
330+
failureReasons: result.failureReasons || [],
331+
partialFills: result.partialFills || []
283332
})
284333
} catch (error) {
285334
logger.error('Rebalance failed', { error: getErrorObject(error), portfolioId: req.params.id })
@@ -1367,4 +1416,4 @@ router.get('/debug/auto-rebalancer-test', blockDebugInProduction, async (req, re
13671416
}
13681417
})
13691418

1370-
export { router as portfolioRouter }
1419+
export { router as portfolioRouter }

backend/src/monitoring/rebalancer.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,21 @@ export class RebalancingService {
8484
if (this.shouldAutoRebalance(portfolio, riskAlerts)) {
8585
logger.info(`Auto-executing rebalance for portfolio ${portfolioId}`)
8686
try {
87-
await this.stellarService.executeRebalance(portfolioId)
88-
89-
this.notifyClients(portfolioId, 'rebalance_completed', {
90-
message: 'Automatic rebalancing completed successfully',
91-
riskLevel: 'low'
92-
})
87+
const rebalanceResult = await this.stellarService.executeRebalance(portfolioId)
88+
if (rebalanceResult.status === 'failed') {
89+
this.notifyClients(portfolioId, 'rebalance_failed', {
90+
message: 'Automatic rebalancing failed safely',
91+
reasons: rebalanceResult.failureReasons || []
92+
})
93+
} else {
94+
this.notifyClients(portfolioId, 'rebalance_completed', {
95+
message: rebalanceResult.status === 'partial'
96+
? 'Automatic rebalancing partially completed'
97+
: 'Automatic rebalancing completed successfully',
98+
riskLevel: 'low',
99+
status: rebalanceResult.status
100+
})
101+
}
93102
} catch (rebalanceError) {
94103
logger.error(`Auto-rebalance failed for ${portfolioId}:`, rebalanceError)
95104

@@ -272,4 +281,4 @@ export class RebalancingService {
272281
riskManagement: { enabled: true, lastUpdate: new Date().toISOString() }
273282
}
274283
}
275-
}
284+
}

backend/src/services/autoRebalancer.ts

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -200,42 +200,59 @@ export class AutoRebalancerService {
200200
trigger: 'automatic_drift_detection'
201201
})
202202

203-
const rebalanceResult = await this.stellarService.executeRebalance(portfolioId)
204-
205-
// Record the auto-rebalance event
206-
await this.rebalanceHistoryService.recordRebalanceEvent({
207-
portfolioId,
208-
trigger: 'Automatic Rebalancing',
209-
trades: rebalanceResult.trades || 0,
210-
gasUsed: rebalanceResult.gasUsed || '0 XLM',
211-
status: 'completed',
212-
isAutomatic: true,
213-
riskAlerts: riskCheck.alerts || []
214-
})
215-
216-
// Send notification for successful auto-rebalance
217-
try {
218-
await notificationService.notify({
219-
userId: portfolio.userAddress,
220-
eventType: 'rebalance',
221-
title: 'Portfolio Rebalanced',
222-
message: `Your portfolio has been automatically rebalanced. ${rebalanceResult.trades || 0} trades executed with ${rebalanceResult.gasUsed || '0 XLM'} gas used.`,
223-
data: {
224-
portfolioId,
225-
trades: rebalanceResult.trades,
226-
gasUsed: rebalanceResult.gasUsed,
227-
trigger: 'automatic'
228-
},
229-
timestamp: new Date().toISOString()
230-
})
231-
} catch (notificationError) {
232-
logger.error('Failed to send rebalance notification', {
203+
const rebalanceResult = await this.stellarService.executeRebalance(portfolioId)
204+
const rebalanceSucceeded = rebalanceResult.status !== 'failed'
205+
206+
// Record the auto-rebalance event
207+
await this.rebalanceHistoryService.recordRebalanceEvent({
208+
portfolioId,
209+
trigger: 'Automatic Rebalancing',
210+
trades: rebalanceResult.trades || 0,
211+
gasUsed: rebalanceResult.gasUsed || '0 XLM',
212+
status: rebalanceSucceeded ? 'completed' : 'failed',
213+
isAutomatic: true,
214+
riskAlerts: riskCheck.alerts || [],
215+
error: rebalanceResult.failureReasons?.join('; ')
216+
})
217+
218+
if (!rebalanceSucceeded) {
219+
return {
220+
rebalanced: false,
221+
reason: rebalanceResult.failureReasons?.[0] || 'Execution failed'
222+
}
223+
}
224+
225+
// Send notification for successful auto-rebalance
226+
try {
227+
await notificationService.notify({
228+
userId: portfolio.userAddress,
229+
eventType: 'rebalance',
230+
title: rebalanceResult.status === 'partial'
231+
? 'Portfolio Partially Rebalanced'
232+
: 'Portfolio Rebalanced',
233+
message: `Your portfolio has been automatically rebalanced. ${rebalanceResult.trades || 0} trades executed with ${rebalanceResult.gasUsed || '0 XLM'} gas used.`,
234+
data: {
235+
portfolioId,
236+
trades: rebalanceResult.trades,
237+
gasUsed: rebalanceResult.gasUsed,
238+
trigger: 'automatic',
239+
status: rebalanceResult.status
240+
},
241+
timestamp: new Date().toISOString()
242+
})
243+
} catch (notificationError) {
244+
logger.error('Failed to send rebalance notification', {
233245
portfolioId,
234246
error: notificationError instanceof Error ? notificationError.message : String(notificationError)
235247
})
236248
}
237249

238-
return { rebalanced: true, reason: 'Successfully auto-rebalanced' }
250+
return {
251+
rebalanced: true,
252+
reason: rebalanceResult.status === 'partial'
253+
? 'Partially auto-rebalanced'
254+
: 'Successfully auto-rebalanced'
255+
}
239256

240257
} catch (error) {
241258
logger.error('[AUTO-REBALANCER] Error executing rebalance', {
@@ -367,4 +384,4 @@ export class AutoRebalancerService {
367384
}
368385
}
369386
}
370-
}
387+
}

0 commit comments

Comments
 (0)