diff --git a/backend/src/migrations/1795000000000-CreateProductApySnapshots.ts b/backend/src/migrations/1795000000000-CreateProductApySnapshots.ts new file mode 100644 index 000000000..d1fed2593 --- /dev/null +++ b/backend/src/migrations/1795000000000-CreateProductApySnapshots.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateProductApySnapshots1795000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'product_apy_snapshots', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + isGenerated: true, + generationStrategy: 'uuid', + }, + { name: 'productId', type: 'uuid' }, + { + name: 'apy', + type: 'decimal', + precision: 5, + scale: 2, + }, + { + name: 'tvlAmount', + type: 'decimal', + precision: 14, + scale: 2, + default: 0, + }, + { + name: 'activeSubscribers', + type: 'int', + default: 0, + }, + { name: 'snapshotDate', type: 'date' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'product_apy_snapshots', + new TableIndex({ + name: 'IDX_apy_snapshots_product_date', + columnNames: ['productId', 'snapshotDate'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex( + 'product_apy_snapshots', + 'IDX_apy_snapshots_product_date', + ); + await queryRunner.dropTable('product_apy_snapshots'); + } +} diff --git a/backend/src/modules/savings/dto/product-metrics.dto.ts b/backend/src/modules/savings/dto/product-metrics.dto.ts new file mode 100644 index 000000000..f7f2a573a --- /dev/null +++ b/backend/src/modules/savings/dto/product-metrics.dto.ts @@ -0,0 +1,124 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +export enum MetricsGranularity { + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', +} + +export class ProductMetricsQueryDto { + @ApiPropertyOptional({ + enum: MetricsGranularity, + default: MetricsGranularity.DAILY, + description: 'Granularity of historical chart data', + }) + @IsEnum(MetricsGranularity) + @IsOptional() + granularity?: MetricsGranularity = MetricsGranularity.DAILY; +} + +export class ApyDataPointDto { + @ApiProperty({ example: '2026-03-01', description: 'Date of the data point' }) + date: string; + + @ApiProperty({ example: 4.5, description: 'APY at this point (%)' }) + apy: number; +} + +export class TvlDataPointDto { + @ApiProperty({ example: '2026-03-01', description: 'Date of the data point' }) + date: string; + + @ApiProperty({ example: 125000, description: 'TVL at this point' }) + tvl: number; +} + +export class RiskMetricsDto { + @ApiProperty({ + example: 1.42, + description: 'Sharpe ratio (risk-adjusted return)', + }) + sharpeRatio: number; + + @ApiProperty({ + example: 0.85, + description: 'Standard deviation of APY over the period (%)', + }) + apyVolatility: number; + + @ApiProperty({ example: 4.5, description: 'Maximum APY observed (%)' }) + maxApy: number; + + @ApiProperty({ example: 3.8, description: 'Minimum APY observed (%)' }) + minApy: number; + + @ApiProperty({ example: 4.2, description: 'Average APY over the period (%)' }) + avgApy: number; +} + +export class SimilarProductDto { + @ApiProperty({ description: 'Product UUID' }) + id: string; + + @ApiProperty({ description: 'Product name' }) + name: string; + + @ApiProperty({ description: 'Current APY (%)' }) + apy: number; + + @ApiProperty({ description: 'Current TVL' }) + tvl: number; + + @ApiProperty({ description: 'Risk level' }) + riskLevel: string; +} + +export class ProductMetricsDto { + @ApiProperty({ description: 'Product UUID' }) + productId: string; + + @ApiProperty({ description: 'Product name' }) + productName: string; + + @ApiProperty({ description: 'Current APY (%)' }) + currentApy: number; + + @ApiProperty({ description: 'Current TVL' }) + currentTvl: number; + + @ApiProperty({ description: 'Total active subscribers' }) + totalSubscribers: number; + + @ApiProperty({ + description: 'User retention rate (active / total ever subscribed, 0-100)', + example: 78.5, + }) + retentionRate: number; + + @ApiProperty({ + type: [ApyDataPointDto], + description: 'Historical APY chart data', + }) + apyHistory: ApyDataPointDto[]; + + @ApiProperty({ + type: [TvlDataPointDto], + description: 'TVL growth over time', + }) + tvlHistory: TvlDataPointDto[]; + + @ApiProperty({ type: RiskMetricsDto, description: 'Risk-adjusted metrics' }) + riskMetrics: RiskMetricsDto; + + @ApiProperty({ + type: [SimilarProductDto], + description: 'Similar products for comparison', + }) + similarProducts: SimilarProductDto[]; + + @ApiPropertyOptional({ + description: 'ISO timestamp when this response was cached', + }) + cachedAt?: string; +} diff --git a/backend/src/modules/savings/entities/product-apy-snapshot.entity.ts b/backend/src/modules/savings/entities/product-apy-snapshot.entity.ts new file mode 100644 index 000000000..f330d35fb --- /dev/null +++ b/backend/src/modules/savings/entities/product-apy-snapshot.entity.ts @@ -0,0 +1,42 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { SavingsProduct } from './savings-product.entity'; + +@Entity('product_apy_snapshots') +@Index('IDX_apy_snapshots_product_date', ['productId', 'snapshotDate']) +export class ProductApySnapshot { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + productId: string; + + /** APY at the time of snapshot (%) */ + @Column('decimal', { precision: 5, scale: 2 }) + apy: number; + + /** Total Value Locked at snapshot time */ + @Column('decimal', { precision: 14, scale: 2, default: 0 }) + tvlAmount: number; + + /** Number of active subscribers at snapshot time */ + @Column('int', { default: 0 }) + activeSubscribers: number; + + @Column({ type: 'date' }) + snapshotDate: Date; + + @CreateDateColumn() + createdAt: Date; + + @ManyToOne(() => SavingsProduct, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'productId' }) + product: SavingsProduct; +} diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index 2c43f0be2..455c6dfd0 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -34,6 +34,11 @@ import { CreateGoalDto } from './dto/create-goal.dto'; import { UpdateGoalDto } from './dto/update-goal.dto'; import { SavingsProductDto } from './dto/savings-product.dto'; import { ProductDetailsDto } from './dto/product-details.dto'; +import { + MetricsGranularity, + ProductMetricsDto, + ProductMetricsQueryDto, +} from './dto/product-metrics.dto'; import { RecommendationResponseDto } from './dto/recommendation-response.dto'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -129,6 +134,39 @@ export class SavingsController { }; } + @Get('products/:id/metrics') + @UseInterceptors(CacheInterceptor) + @CacheTTL(3600000) + @ApiOperation({ + summary: 'Get performance metrics for a savings product', + description: + 'Returns historical APY, TVL trends, user retention, risk-adjusted returns (Sharpe ratio), and similar product comparisons. Cached for 1 hour.', + }) + @ApiParam({ + name: 'id', + type: 'string', + format: 'uuid', + description: 'Product UUID', + }) + @ApiQuery({ + name: 'granularity', + required: false, + enum: MetricsGranularity, + description: 'Chart data granularity (daily, weekly, monthly)', + }) + @ApiResponse({ + status: 200, + description: 'Product performance metrics', + type: ProductMetricsDto, + }) + @ApiResponse({ status: 404, description: 'Product not found' }) + async getProductMetrics( + @Param('id') id: string, + @Query() query: ProductMetricsQueryDto, + ): Promise { + return this.savingsService.getProductMetrics(id, query.granularity); + } + @Post('subscribe') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.CREATED) diff --git a/backend/src/modules/savings/savings.module.ts b/backend/src/modules/savings/savings.module.ts index b3df722a1..0cce90276 100644 --- a/backend/src/modules/savings/savings.module.ts +++ b/backend/src/modules/savings/savings.module.ts @@ -8,6 +8,7 @@ import { RecommendationService } from './services/recommendation.service'; import { SavingsProduct } from './entities/savings-product.entity'; import { UserSubscription } from './entities/user-subscription.entity'; import { SavingsGoal } from './entities/savings-goal.entity'; +import { ProductApySnapshot } from './entities/product-apy-snapshot.entity'; import { WithdrawalRequest } from './entities/withdrawal-request.entity'; import { Transaction } from '../transactions/entities/transaction.entity'; import { User } from '../user/entities/user.entity'; @@ -26,6 +27,7 @@ import { ExperimentsService } from './experiments.service'; SavingsProduct, UserSubscription, SavingsGoal, + ProductApySnapshot, WithdrawalRequest, Transaction, User, diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index d8f18f67b..6ae1ba015 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -19,6 +19,7 @@ import { SubscriptionStatus, } from './entities/user-subscription.entity'; import { SavingsGoal, SavingsGoalStatus } from './entities/savings-goal.entity'; +import { ProductApySnapshot } from './entities/product-apy-snapshot.entity'; import { WithdrawalRequest, WithdrawalStatus, @@ -32,6 +33,10 @@ import { CreateProductDto } from './dto/create-product.dto'; import { UpdateProductDto } from './dto/update-product.dto'; import { SavingsProductDto } from './dto/savings-product.dto'; import { GoalProgressDto } from './dto/goal-progress.dto'; +import { + MetricsGranularity, + ProductMetricsDto, +} from './dto/product-metrics.dto'; import { User } from '../user/entities/user.entity'; import { SavingsService as BlockchainSavingsService } from '../blockchain/savings.service'; import { PredictiveEvaluatorService } from './services/predictive-evaluator.service'; @@ -66,6 +71,7 @@ export interface ProductCapacitySnapshot { const STROOPS_PER_XLM = 10_000_000; const POOLS_CACHE_KEY = 'pools_all'; +const METRICS_CACHE_TTL = 3600000; // 1 hour in ms @Injectable() export class SavingsService { @@ -80,6 +86,8 @@ export class SavingsService { private readonly goalRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(ProductApySnapshot) + private readonly snapshotRepository: Repository, @InjectRepository(SavingsProductVersionAudit) private readonly productVersionAuditRepository: Repository, @InjectRepository(WithdrawalRequest) @@ -541,6 +549,215 @@ export class SavingsService { ); } + async getProductMetrics( + id: string, + granularity: MetricsGranularity = MetricsGranularity.DAILY, + ): Promise { + const cacheKey = `product_metrics:${id}:${granularity}`; + const cached = await this.cacheManager.get(cacheKey); + if (cached) { + return cached; + } + + const product = await this.productRepository.findOne({ + where: { id }, + relations: ['subscriptions'], + }); + if (!product) { + throw new NotFoundException(`Savings product ${id} not found`); + } + + const now = new Date(); + const lookbackDays = + granularity === MetricsGranularity.MONTHLY + ? 365 + : granularity === MetricsGranularity.WEEKLY + ? 90 + : 30; + + const since = new Date(now); + since.setDate(since.getDate() - lookbackDays); + + // Fetch historical snapshots + const snapshots = await this.snapshotRepository + .createQueryBuilder('s') + .where('s.productId = :id', { id }) + .andWhere('s.snapshotDate >= :since', { since }) + .orderBy('s.snapshotDate', 'ASC') + .getMany(); + + // Build chart data — fall back to synthetic single-point if no snapshots yet + const apyHistory = this.buildApyHistory(snapshots, product, granularity); + const tvlHistory = this.buildTvlHistory(snapshots, product, granularity); + + // Subscription stats + const allSubs = product.subscriptions ?? []; + const activeSubs = allSubs.filter( + (s) => s.status === SubscriptionStatus.ACTIVE, + ); + const totalSubscribers = activeSubs.length; + const retentionRate = + allSubs.length > 0 + ? Math.round((activeSubs.length / allSubs.length) * 1000) / 10 + : 0; + + // Current TVL from active subscriptions + const currentTvl = activeSubs.reduce( + (sum, s) => sum + Number(s.amount), + 0, + ); + + // Risk metrics from APY history + const apyValues = + apyHistory.length > 0 + ? apyHistory.map((p) => p.apy) + : [Number(product.interestRate)]; + const riskMetrics = this.calculateRiskMetrics(apyValues); + + // Similar products: same riskLevel, different id, active + const similar = await this.productRepository.find({ + where: { riskLevel: product.riskLevel, isActive: true }, + relations: ['subscriptions'], + }); + const similarProducts = similar + .filter((p) => p.id !== id) + .slice(0, 5) + .map((p) => { + const subs = (p.subscriptions ?? []).filter( + (s) => s.status === SubscriptionStatus.ACTIVE, + ); + return { + id: p.id, + name: p.name, + apy: Number(p.interestRate), + tvl: subs.reduce((sum, s) => sum + Number(s.amount), 0), + riskLevel: p.riskLevel, + }; + }); + + const metrics: ProductMetricsDto = { + productId: id, + productName: product.name, + currentApy: Number(product.interestRate), + currentTvl, + totalSubscribers, + retentionRate, + apyHistory, + tvlHistory, + riskMetrics, + similarProducts, + cachedAt: now.toISOString(), + }; + + await this.cacheManager.set(cacheKey, metrics, METRICS_CACHE_TTL); + return metrics; + } + + private buildApyHistory( + snapshots: ProductApySnapshot[], + product: SavingsProduct, + granularity: MetricsGranularity, + ) { + if (snapshots.length === 0) { + // No historical data yet — return current rate as single point + return [ + { + date: new Date().toISOString().split('T')[0], + apy: Number(product.interestRate), + }, + ]; + } + + const grouped = this.groupSnapshotsByGranularity(snapshots, granularity); + return grouped.map(({ date, items }) => ({ + date, + apy: + Math.round( + (items.reduce((s, i) => s + Number(i.apy), 0) / items.length) * 100, + ) / 100, + })); + } + + private buildTvlHistory( + snapshots: ProductApySnapshot[], + product: SavingsProduct, + granularity: MetricsGranularity, + ) { + if (snapshots.length === 0) { + return [ + { + date: new Date().toISOString().split('T')[0], + tvl: Number(product.tvlAmount), + }, + ]; + } + + const grouped = this.groupSnapshotsByGranularity(snapshots, granularity); + return grouped.map(({ date, items }) => ({ + date, + tvl: + Math.round( + (items.reduce((s, i) => s + Number(i.tvlAmount), 0) / + items.length) * + 100, + ) / 100, + })); + } + + private groupSnapshotsByGranularity( + snapshots: ProductApySnapshot[], + granularity: MetricsGranularity, + ): { date: string; items: ProductApySnapshot[] }[] { + const buckets = new Map(); + + for (const snap of snapshots) { + const d = new Date(snap.snapshotDate); + let key: string; + + if (granularity === MetricsGranularity.MONTHLY) { + key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + } else if (granularity === MetricsGranularity.WEEKLY) { + // ISO week start (Monday) + const day = d.getDay() || 7; + const monday = new Date(d); + monday.setDate(d.getDate() - day + 1); + key = monday.toISOString().split('T')[0]; + } else { + key = d.toISOString().split('T')[0]; + } + + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key)!.push(snap); + } + + return Array.from(buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, items]) => ({ date, items })); + } + + private calculateRiskMetrics(apyValues: number[]) { + const n = apyValues.length; + const avg = apyValues.reduce((s, v) => s + v, 0) / n; + const variance = + n > 1 + ? apyValues.reduce((s, v) => s + Math.pow(v - avg, 2), 0) / (n - 1) + : 0; + const stdDev = Math.sqrt(variance); + + // Sharpe ratio: (avg return - risk-free rate) / stdDev + // Using 0% risk-free rate as a conservative baseline + const sharpeRatio = + stdDev > 0 ? Math.round((avg / stdDev) * 100) / 100 : 0; + + return { + sharpeRatio, + apyVolatility: Math.round(stdDev * 100) / 100, + maxApy: Math.max(...apyValues), + minApy: Math.min(...apyValues), + avgApy: Math.round(avg * 100) / 100, + }; + } + async invalidatePoolsCache(): Promise { await this.cacheManager.del(POOLS_CACHE_KEY); this.logger.log(