120 lines
3.3 KiB
TypeScript
120 lines
3.3 KiB
TypeScript
|
|
// Risk Control Service
|
||
|
|
// Enforces hard caps and validates deal parameters
|
||
|
|
|
||
|
|
import { Decimal } from '@prisma/client/runtime/library';
|
||
|
|
import { logger } from '@/infrastructure/monitoring/logger';
|
||
|
|
import { DEFAULT_RISK_PARAMS } from './config';
|
||
|
|
import { RiskCheckResult, CapitalBuckets, DealExecutionRequest } from './types';
|
||
|
|
|
||
|
|
export class RiskControlService {
|
||
|
|
async validateDealRequest(
|
||
|
|
request: DealExecutionRequest,
|
||
|
|
currentNav: Decimal
|
||
|
|
): Promise<RiskCheckResult> {
|
||
|
|
const errors: string[] = [];
|
||
|
|
const maxLtv = new Decimal(request.maxLtv ?? DEFAULT_RISK_PARAMS.MAX_LTV);
|
||
|
|
const maxUsdtzExposure = currentNav.mul(
|
||
|
|
DEFAULT_RISK_PARAMS.MAX_USDTZ_EXPOSURE_PCT
|
||
|
|
);
|
||
|
|
|
||
|
|
if (maxLtv.gt(DEFAULT_RISK_PARAMS.MAX_LTV)) {
|
||
|
|
errors.push(
|
||
|
|
`Requested LTV ${maxLtv.toString()} exceeds maximum ${DEFAULT_RISK_PARAMS.MAX_LTV}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const totalEth = new Decimal(request.totalEthValue);
|
||
|
|
const workingLiquidity = totalEth.mul(0.30);
|
||
|
|
const estimatedBorrow = workingLiquidity.mul(maxLtv);
|
||
|
|
const estimatedUsdtzPurchase = estimatedBorrow.div(
|
||
|
|
new Decimal(1).minus(
|
||
|
|
new Decimal(request.usdtzDiscountRate ?? DEFAULT_RISK_PARAMS.DEFAULT_USDTZ_DISCOUNT)
|
||
|
|
)
|
||
|
|
);
|
||
|
|
|
||
|
|
if (estimatedUsdtzPurchase.gt(maxUsdtzExposure)) {
|
||
|
|
errors.push(
|
||
|
|
`Estimated USDTz exposure ${estimatedUsdtzPurchase.toString()} exceeds maximum ${maxUsdtzExposure.toString()}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
passed: errors.length === 0,
|
||
|
|
maxLtv: new Decimal(DEFAULT_RISK_PARAMS.MAX_LTV),
|
||
|
|
maxUsdtzExposure,
|
||
|
|
totalNav: currentNav,
|
||
|
|
errors,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async checkLtvCompliance(
|
||
|
|
collateralValue: Decimal,
|
||
|
|
borrowAmount: Decimal,
|
||
|
|
maxLtv: Decimal = new Decimal(DEFAULT_RISK_PARAMS.MAX_LTV)
|
||
|
|
): Promise<RiskCheckResult> {
|
||
|
|
const errors: string[] = [];
|
||
|
|
const ltv = borrowAmount.div(collateralValue);
|
||
|
|
|
||
|
|
if (ltv.gt(maxLtv)) {
|
||
|
|
errors.push(
|
||
|
|
`LTV ${ltv.mul(100).toFixed(2)}% exceeds maximum ${maxLtv.mul(100).toFixed(2)}%`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info('LTV Check', {
|
||
|
|
collateralValue: collateralValue.toString(),
|
||
|
|
borrowAmount: borrowAmount.toString(),
|
||
|
|
ltv: ltv.mul(100).toFixed(2) + '%',
|
||
|
|
maxLtv: maxLtv.mul(100).toFixed(2) + '%',
|
||
|
|
passed: errors.length === 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
passed: errors.length === 0,
|
||
|
|
ltv,
|
||
|
|
maxLtv,
|
||
|
|
errors,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async checkUsdtzExposure(
|
||
|
|
usdtzAmount: Decimal,
|
||
|
|
totalNav: Decimal
|
||
|
|
): Promise<RiskCheckResult> {
|
||
|
|
const errors: string[] = [];
|
||
|
|
const maxExposure = totalNav.mul(DEFAULT_RISK_PARAMS.MAX_USDTZ_EXPOSURE_PCT);
|
||
|
|
|
||
|
|
if (usdtzAmount.gt(maxExposure)) {
|
||
|
|
errors.push(
|
||
|
|
`USDTz exposure ${usdtzAmount.toString()} exceeds maximum ${maxExposure.toString()}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
passed: errors.length === 0,
|
||
|
|
usdtzExposure: usdtzAmount,
|
||
|
|
maxUsdtzExposure: maxExposure,
|
||
|
|
totalNav,
|
||
|
|
errors,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async validateNoRehypothecation(
|
||
|
|
usdtzAmount: Decimal,
|
||
|
|
collateralAssets: string[]
|
||
|
|
): Promise<RiskCheckResult> {
|
||
|
|
const errors: string[] = [];
|
||
|
|
|
||
|
|
if (collateralAssets.includes('USDTz')) {
|
||
|
|
errors.push('USDTz cannot be used as collateral (no rehypothecation)');
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
passed: errors.length === 0,
|
||
|
|
errors,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const riskControlService = new RiskControlService();
|