Skip to content

Commit 9b6eda6

Browse files
authored
Merge branch 'main' into feature/durable-job-queue-bullmq
2 parents b1ec46b + b1cd223 commit 9b6eda6

File tree

7 files changed

+1096
-288
lines changed

7 files changed

+1096
-288
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
# ============================================
@@ -89,6 +100,14 @@ MAX_TRADE_SIZE_PERCENTAGE=25
89100
# Minimum trade size (USD)
90101
MIN_TRADE_SIZE_USD=10
91102

103+
# Rebalance execution safety controls
104+
REBALANCE_MAX_TRADE_SLIPPAGE_BPS=100
105+
REBALANCE_MAX_TOTAL_SLIPPAGE_BPS=250
106+
REBALANCE_MAX_SPREAD_BPS=120
107+
REBALANCE_MIN_LIQUIDITY_COVERAGE=1.0
108+
REBALANCE_ALLOW_PARTIAL_FILL=true
109+
REBALANCE_ROLLBACK_ON_FAILURE=true
110+
92111
# ============================================
93112
# NOTIFICATION CONFIGURATION
94113
# ============================================

backend/src/api/routes.ts

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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 }

backend/src/monitoring/rebalancer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,4 @@ export class RebalancingService {
164164

165165
logger.info(`Market broadcast sent: ${event}`)
166166
}
167-
}
167+
}

backend/src/services/autoRebalancer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,4 @@ export class AutoRebalancerService {
141141
}
142142
}
143143
}
144-
}
144+
}

0 commit comments

Comments
 (0)