Skip to content

Commit 5d7397f

Browse files
authored
Merge pull request #76 from Michaelkingsdev/normalize-portfolio
fix: enforce strict allocation types across risk management pipeline
2 parents fb02a03 + 8d78fc5 commit 5d7397f

File tree

10 files changed

+97
-65
lines changed

10 files changed

+97
-65
lines changed

backend/src/monitoring/rebalancer.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,15 @@ export class RebalancingService {
6969

7070
if (needsRebalance) {
7171
logger.info(`Portfolio ${portfolioId} needs rebalancing – enqueueing job`)
72-
const riskCheck = riskManagementService.shouldAllowRebalance(portfolio, prices)
72+
73+
// Use the stored portfolio (Record<string, number> allocations) for risk checks,
74+
// NOT the UI response from stellarService.getPortfolio() which has an array shape.
75+
const storedPortfolio = await portfolioStorage.getPortfolio(portfolioId)
76+
if (!storedPortfolio) {
77+
logger.warn(`Portfolio ${portfolioId} not found in storage during risk check`)
78+
return
79+
}
80+
const riskCheck = riskManagementService.shouldAllowRebalance(storedPortfolio, prices)
7381

7482
if (riskCheck.allowed) {
7583
// Enqueue a rebalance job rather than executing inline
@@ -99,7 +107,7 @@ export class RebalancingService {
99107
gasUsed: '0 XLM',
100108
status: 'failed',
101109
prices,
102-
portfolio,
110+
portfolio: storedPortfolio,
103111
})
104112
}
105113
}

backend/src/queue/workers/portfolioCheckWorker.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,20 @@ export async function processPortfolioCheckJob(
6969
continue
7070
}
7171

72-
// Check cooldown
73-
const p = await stellarService.getPortfolio(portfolio.id)
74-
const cooldownCheck = CircuitBreakers.checkCooldownPeriod(p.lastRebalance)
72+
// Use the stored portfolio directly for risk checks.
73+
// stellarService.getPortfolio() returns a UI response with allocations as an
74+
// array, which would corrupt weight calculations in shouldAllowRebalance.
75+
const storedPortfolio = portfolio // already the stored shape from getAllPortfolios()
76+
77+
// Check cooldown using last-rebalance timestamp from stored portfolio
78+
const cooldownCheck = CircuitBreakers.checkCooldownPeriod(storedPortfolio.lastRebalance)
7579
if (!cooldownCheck.safe) {
7680
skipped++
7781
continue
7882
}
7983

80-
// Risk management
81-
const riskCheck = riskManagementService.shouldAllowRebalance(p, prices)
84+
// Risk management — pass stored portfolio with Record<string, number> allocations
85+
const riskCheck = riskManagementService.shouldAllowRebalance(storedPortfolio, prices)
8286
if (!riskCheck.allowed) {
8387
logger.warn('[WORKER:portfolio-check] Rebalance blocked by risk management', {
8488
portfolioId: portfolio.id,
@@ -89,7 +93,7 @@ export async function processPortfolioCheckJob(
8993
}
9094

9195
// Concentration risk
92-
const concentrationCheck = CircuitBreakers.checkConcentrationRisk(p.allocations)
96+
const concentrationCheck = CircuitBreakers.checkConcentrationRisk(storedPortfolio.allocations)
9397
if (!concentrationCheck.safe) {
9498
skipped++
9599
continue

backend/src/services/circuitBreakers.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,22 +82,22 @@ export class CircuitBreakers {
8282
return { safe: true }
8383
}
8484

85-
static checkConcentrationRisk(allocations: any[]): { safe: boolean, reason?: string } {
85+
static checkConcentrationRisk(allocations: Record<string, number>): { safe: boolean, reason?: string } {
8686
const maxSingleAsset = 80 // 80% maximum for any single asset
8787
const minDiversification = 1 // Minimum number of assets
8888

89-
// Check maximum concentration
90-
for (const allocation of allocations) {
91-
if (allocation.current > maxSingleAsset) {
89+
// Check maximum concentration (values are target percentages, 0-100)
90+
for (const [asset, percentage] of Object.entries(allocations)) {
91+
if (percentage > maxSingleAsset) {
9292
return {
9393
safe: false,
94-
reason: `Concentration risk: ${allocation.asset} represents ${allocation.current.toFixed(1)}% of portfolio`
94+
reason: `Concentration risk: ${asset} represents ${percentage.toFixed(1)}% of portfolio`
9595
}
9696
}
9797
}
9898

9999
// Check minimum diversification
100-
const activeAssets = allocations.filter(a => a.current > 1).length
100+
const activeAssets = Object.values(allocations).filter(pct => pct > 1).length
101101
if (activeAssets < minDiversification) {
102102
return {
103103
safe: false,

backend/src/services/databaseService.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { RebalanceEvent } from './rebalanceHistory.js'
66
import { getFeatureFlags } from '../config/featureFlags.js'
77
import { ConflictError } from '../types/index.js'
88
import { logger } from '../utils/logger.js'
9+
import type { Portfolio } from '../types/index.js'
910

1011
// ─────────────────────────────────────────────
1112
// Exported type used by rebalanceHistory.ts
@@ -24,20 +25,6 @@ export interface RebalanceHistoryQueryOptions {
2425
// ─────────────────────────────────────────────
2526
// Types (mirrored from portfolioStorage.ts)
2627
// ─────────────────────────────────────────────
27-
28-
export interface Portfolio {
29-
id: string
30-
userAddress: string
31-
allocations: Record<string, number>
32-
threshold: number
33-
slippageTolerancePercent?: number
34-
balances: Record<string, number>
35-
totalValue: number
36-
createdAt: string
37-
lastRebalance: string
38-
version: number
39-
}
40-
4128
interface PortfolioRow {
4229
id: string
4330
user_address: string
@@ -247,7 +234,7 @@ function rowToPortfolio(row: PortfolioRow): Portfolio {
247234
userAddress: row.user_address,
248235
allocations: safeJsonParse(row.allocations, {}, `portfolio(${row.id}).allocations`),
249236
threshold: row.threshold,
250-
slippageTolerancePercent: row.slippage_tolerance_percent ?? 1,
237+
slippageTolerance: row.slippage_tolerance_percent ?? 1,
251238
balances: safeJsonParse(row.balances, {}, `portfolio(${row.id}).balances`),
252239
totalValue: row.total_value,
253240
createdAt: row.created_at,

backend/src/services/portfolioStorage.ts

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isDbConfigured } from '../db/client.js'
22
import * as portfolioDb from '../db/portfolioDb.js'
33
import { randomUUID } from 'node:crypto'
4+
import type { Portfolio } from '../types/index.js'
45

56
const SLIPPAGE_MIN = 0.5
67
const SLIPPAGE_MAX = 5
@@ -9,17 +10,7 @@ function clampSlippageTolerance(p: number): number {
910
return Math.max(SLIPPAGE_MIN, Math.min(SLIPPAGE_MAX, p))
1011
}
1112

12-
interface Portfolio {
13-
id: string
14-
userAddress: string
15-
allocations: Record<string, number>
16-
threshold: number
17-
slippageTolerance?: number
18-
balances: Record<string, number>
19-
totalValue: number
20-
createdAt: string
21-
lastRebalance: string
22-
}
13+
2314

2415
const useCache = process.env.USE_MEMORY_CACHE === 'true'
2516

@@ -56,7 +47,8 @@ class PortfolioStorage {
5647
balances: {},
5748
totalValue: 0,
5849
createdAt: new Date().toISOString(),
59-
lastRebalance: new Date().toISOString()
50+
lastRebalance: new Date().toISOString(),
51+
version: 1
6052
}
6153
if (isDbConfigured()) {
6254
await portfolioDb.dbCreatePortfolio(id, userAddress, allocations, threshold, {}, 0)
@@ -83,7 +75,8 @@ class PortfolioStorage {
8375
balances: currentBalances,
8476
totalValue,
8577
createdAt: new Date().toISOString(),
86-
lastRebalance: new Date().toISOString()
78+
lastRebalance: new Date().toISOString(),
79+
version: 1
8780
}
8881
if (isDbConfigured()) {
8982
await portfolioDb.dbCreatePortfolio(
@@ -159,10 +152,5 @@ class PortfolioStorage {
159152
this.portfolios.clear()
160153
}
161154
}
162-
/**
163-
* portfolioStorage.ts
164-
*
165-
* Backward-compatible re-export: all callers that import `portfolioStorage`
166-
* now transparently use the SQLite-backed DatabaseService singleton.
167-
*/
168-
export { databaseService as portfolioStorage, type Portfolio } from './databaseService.js'
155+
export { databaseService as portfolioStorage } from './databaseService.js'
156+
export type { Portfolio } from '../types/index.js'

backend/src/services/rebalanceHistory.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ export class RebalanceHistoryService {
6161
toAsset?: string
6262
amount?: number
6363
prices?: PricesMap
64-
portfolio?: any
64+
/** Stored portfolio record. Must have `allocations` as a `Record<string, number>`. */
65+
portfolio?: { allocations: Record<string, number> }
6566
eventSource?: RebalanceEvent['eventSource']
6667
onChainConfirmed?: boolean
6768
onChainEventType?: string

backend/src/services/rebalanceLock.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ export class RebalanceLockService {
6767

6868
if (this.useRedis && this.redis) {
6969
try {
70-
// SET resource_name my_random_value NX PX ttl
70+
// SET resource_name my_random_value PX ttl
7171
// NX = set if not exist
7272
// PX = expire time in milliseconds
73-
const result = await this.redis.set(lockKey, 'locked', 'PX', ttlMs, 'NX')
73+
7474
return result === 'OK'
7575
} catch (error) {
7676
logger.error(`[LOCK_SERVICE] Failed to acquire Redis lock for ${portfolioId}`, {

backend/src/services/riskManagements.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export class RiskManagementService {
107107
}
108108

109109
analyzePortfolioRisk(
110-
allocationsInput: Record<string, number> | Array<{ asset: string, target?: number, current?: number, percentage?: number }>,
110+
allocationsInput: Record<string, number>,
111111
_prices: PricesMap
112112
): RiskMetrics {
113113
const weights = this.normalizeAllocations(allocationsInput)
@@ -157,7 +157,7 @@ export class RiskManagementService {
157157
}
158158
}
159159

160-
shouldAllowRebalance(portfolio: any, prices: PricesMap): {
160+
shouldAllowRebalance(portfolio: { allocations: Record<string, number> }, prices: PricesMap): {
161161
allowed: boolean
162162
reason?: string
163163
reasonCode?: RiskDecisionReasonCode
@@ -385,18 +385,9 @@ export class RiskManagementService {
385385
}
386386

387387
private normalizeAllocations(
388-
allocationsInput: Record<string, number> | Array<{ asset: string, target?: number, current?: number, percentage?: number }>
388+
allocationsInput: Record<string, number>
389389
): Record<string, number> {
390-
let raw: Record<string, number> = {}
391-
392-
if (Array.isArray(allocationsInput)) {
393-
allocationsInput.forEach(item => {
394-
const value = item.current ?? item.target ?? item.percentage ?? 0
395-
raw[item.asset] = value
396-
})
397-
} else {
398-
raw = { ...allocationsInput }
399-
}
390+
let raw: Record<string, number> = { ...allocationsInput }
400391

401392
const cleaned = Object.entries(raw).reduce<Record<string, number>>((acc, [asset, value]) => {
402393
const numeric = Number(value)

backend/src/types/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ export interface HistoricalPrice {
2222
version: number
2323
}
2424

25+
26+
/**
27+
* Per-asset allocation item returned by the API / UI layer.
28+
* This is the array-element shape produced by StellarService.getPortfolio()
29+
* and MUST be converted via toStoredAllocations() before being passed to any
30+
* risk-management method.
31+
*/
32+
export interface UIAllocation {
33+
asset: string
34+
/** Target weight as a percentage (0-100) */
35+
target: number
36+
/** Current weight as a percentage (0-100) */
37+
current: number
38+
amount?: number
39+
balance?: number
40+
price?: number
41+
}
42+
2543
// Thrown when an update targets a stale portfolio version
2644
export class ConflictError extends Error {
2745
readonly currentVersion: number
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { UIAllocation } from '../types/index.js'
2+
3+
/**
4+
* allocation format (`Record<string, number>`).
5+
*/
6+
export function isStoredAllocations(v: unknown): v is Record<string, number> {
7+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
8+
return Object.values(v as object).every(x => typeof x === 'number')
9+
}
10+
11+
/**
12+
* @example
13+
* // From stored portfolio
14+
* toStoredAllocations({ XLM: 40, BTC: 30, ETH: 20, USDC: 10 })
15+
* // → { XLM: 40, BTC: 30, ETH: 20, USDC: 10 }
16+
*
17+
* @example
18+
* // From UI response
19+
* toStoredAllocations([{ asset: 'XLM', target: 40, current: 38 }])
20+
* // → { XLM: 40 }
21+
*/
22+
export function toStoredAllocations(
23+
input: Record<string, number> | UIAllocation[]
24+
): Record<string, number> {
25+
if (Array.isArray(input)) {
26+
const result: Record<string, number> = {}
27+
for (const item of input) {
28+
if (typeof item.asset === 'string') {
29+
result[item.asset] = item.target
30+
}
31+
}
32+
return result
33+
}
34+
return { ...input }
35+
}

0 commit comments

Comments
 (0)