feat(analytics): add GET /analytics/market-history endpoint
Time-series endpoint returning monthly/weekly market data points for the analytics page. Queries MarketIndex aggregated by period with 6-hour Redis cache. Includes unit tests. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -13,6 +13,7 @@ import { GetDistrictStatsHandler } from './application/queries/get-district-stat
|
|||||||
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
|
import { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
|
||||||
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
||||||
|
import { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.handler';
|
||||||
import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler';
|
import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler';
|
||||||
import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler';
|
import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler';
|
||||||
import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler';
|
||||||
@@ -49,6 +50,7 @@ const CommandHandlers = [
|
|||||||
|
|
||||||
const QueryHandlers = [
|
const QueryHandlers = [
|
||||||
GetMarketReportHandler,
|
GetMarketReportHandler,
|
||||||
|
GetMarketHistoryHandler,
|
||||||
GetHeatmapHandler,
|
GetHeatmapHandler,
|
||||||
GetPriceTrendHandler,
|
GetPriceTrendHandler,
|
||||||
GetDistrictStatsHandler,
|
GetDistrictStatsHandler,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { type IMarketIndexRepository } from '../../../../domain/repositories/market-index.repository';
|
||||||
|
import { GetMarketHistoryHandler } from '../get-market-history.handler';
|
||||||
|
import { GetMarketHistoryQuery } from '../get-market-history.query';
|
||||||
|
|
||||||
|
describe('GetMarketHistoryHandler', () => {
|
||||||
|
let handler: GetMarketHistoryHandler;
|
||||||
|
let mockRepo: { getMarketHistory: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = { getMarketHistory: vi.fn() };
|
||||||
|
mockCache = {
|
||||||
|
getOrSet: vi.fn((_key: string, fn: () => Promise<unknown>) => fn()),
|
||||||
|
};
|
||||||
|
mockLogger = { error: vi.fn() };
|
||||||
|
|
||||||
|
handler = new GetMarketHistoryHandler(
|
||||||
|
mockRepo as unknown as IMarketIndexRepository,
|
||||||
|
mockCache as any,
|
||||||
|
mockLogger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return market history points for 12m monthly', async () => {
|
||||||
|
const points = [
|
||||||
|
{ date: '2025-05', avgPrice: 50000000, medianPrice: '45000000', listingsCount: 120, inquiriesCount: 0, daysOnMarket: 35 },
|
||||||
|
{ date: '2025-06', avgPrice: 51000000, medianPrice: '46000000', listingsCount: 130, inquiriesCount: 0, daysOnMarket: 33 },
|
||||||
|
];
|
||||||
|
mockRepo.getMarketHistory.mockResolvedValue(points);
|
||||||
|
|
||||||
|
const query = new GetMarketHistoryQuery('HCMC', '12m', 'monthly');
|
||||||
|
const result = await handler.execute(query);
|
||||||
|
|
||||||
|
expect(result.city).toBe('HCMC');
|
||||||
|
expect(result.points).toEqual(points);
|
||||||
|
expect(mockRepo.getMarketHistory).toHaveBeenCalledWith('HCMC', expect.any(Array));
|
||||||
|
// Should generate 12 monthly periods
|
||||||
|
const calledPeriods = mockRepo.getMarketHistory.mock.calls[0][1] as string[];
|
||||||
|
expect(calledPeriods).toHaveLength(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return market history for 6m period', async () => {
|
||||||
|
mockRepo.getMarketHistory.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const query = new GetMarketHistoryQuery('HCMC', '6m', 'monthly');
|
||||||
|
const result = await handler.execute(query);
|
||||||
|
|
||||||
|
expect(result.city).toBe('HCMC');
|
||||||
|
expect(result.points).toEqual([]);
|
||||||
|
const calledPeriods = mockRepo.getMarketHistory.mock.calls[0][1] as string[];
|
||||||
|
expect(calledPeriods).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use cache with 6h TTL', async () => {
|
||||||
|
mockRepo.getMarketHistory.mockResolvedValue([]);
|
||||||
|
const query = new GetMarketHistoryQuery('HCMC', '12m', 'monthly');
|
||||||
|
|
||||||
|
await handler.execute(query);
|
||||||
|
|
||||||
|
expect(mockCache.getOrSet).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('market_history'),
|
||||||
|
expect.any(Function),
|
||||||
|
21600,
|
||||||
|
'market_history',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw InternalServerErrorException on unexpected errors', async () => {
|
||||||
|
mockRepo.getMarketHistory.mockRejectedValue(new Error('DB connection lost'));
|
||||||
|
|
||||||
|
const query = new GetMarketHistoryQuery('HCMC', '12m', 'monthly');
|
||||||
|
|
||||||
|
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
|
||||||
|
expect(mockLogger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { DomainException, CacheService, CachePrefix, CacheTTL, LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
MARKET_INDEX_REPOSITORY,
|
||||||
|
type IMarketIndexRepository,
|
||||||
|
type MarketHistoryPoint,
|
||||||
|
} from '../../../domain/repositories/market-index.repository';
|
||||||
|
import { GetMarketHistoryQuery } from './get-market-history.query';
|
||||||
|
|
||||||
|
export interface MarketHistoryDto {
|
||||||
|
city: string;
|
||||||
|
points: MarketHistoryPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetMarketHistoryQuery)
|
||||||
|
export class GetMarketHistoryHandler implements IQueryHandler<GetMarketHistoryQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetMarketHistoryQuery): Promise<MarketHistoryDto> {
|
||||||
|
try {
|
||||||
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.MARKET_HISTORY,
|
||||||
|
query.city,
|
||||||
|
query.period,
|
||||||
|
query.granularity,
|
||||||
|
query.propertyType ?? 'all',
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.cache.getOrSet(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const periods = this.generatePeriods(query.period, query.granularity);
|
||||||
|
const points = await this.marketIndexRepo.getMarketHistory(query.city, periods);
|
||||||
|
return { city: query.city, points };
|
||||||
|
},
|
||||||
|
CacheTTL.MARKET_HISTORY,
|
||||||
|
'market_history',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to get market history: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'Không thể truy vấn lịch sử thị trường. Vui lòng thử lại sau.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate period strings based on the requested look-back and granularity.
|
||||||
|
* E.g. "12m" with "monthly" → ["2025-05", "2025-06", ..., "2026-04"]
|
||||||
|
*/
|
||||||
|
private generatePeriods(period: string, granularity: 'monthly' | 'weekly'): string[] {
|
||||||
|
const match = period.match(/^(\d+)m$/);
|
||||||
|
const months = match?.[1] ? parseInt(match[1], 10) : 12;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const periods: string[] = [];
|
||||||
|
|
||||||
|
if (granularity === 'monthly') {
|
||||||
|
for (let i = months - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
periods.push(`${yyyy}-${mm}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// weekly: generate ISO week strings for the past N months
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth() - months, now.getDate());
|
||||||
|
const cursor = new Date(startDate);
|
||||||
|
while (cursor <= now) {
|
||||||
|
const yyyy = cursor.getFullYear();
|
||||||
|
const week = this.getISOWeek(cursor);
|
||||||
|
periods.push(`${yyyy}-W${String(week).padStart(2, '0')}`);
|
||||||
|
cursor.setDate(cursor.getDate() + 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return periods;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getISOWeek(date: Date): number {
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export class GetMarketHistoryQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly city: string,
|
||||||
|
public readonly period: string,
|
||||||
|
public readonly granularity: 'monthly' | 'weekly',
|
||||||
|
public readonly propertyType?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ export interface DistrictStatsResult {
|
|||||||
yoyChange: number | null;
|
yoyChange: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MarketHistoryPoint {
|
||||||
|
date: string;
|
||||||
|
avgPrice: number;
|
||||||
|
medianPrice: string;
|
||||||
|
listingsCount: number;
|
||||||
|
inquiriesCount: number;
|
||||||
|
daysOnMarket: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IMarketIndexRepository {
|
export interface IMarketIndexRepository {
|
||||||
findById(id: string): Promise<MarketIndexEntity | null>;
|
findById(id: string): Promise<MarketIndexEntity | null>;
|
||||||
findByKey(district: string, city: string, propertyType: PropertyType, period: string): Promise<MarketIndexEntity | null>;
|
findByKey(district: string, city: string, propertyType: PropertyType, period: string): Promise<MarketIndexEntity | null>;
|
||||||
@@ -54,4 +63,5 @@ export interface IMarketIndexRepository {
|
|||||||
getHeatmap(city: string, period: string): Promise<HeatmapDataPoint[]>;
|
getHeatmap(city: string, period: string): Promise<HeatmapDataPoint[]>;
|
||||||
getPriceTrend(district: string, city: string, propertyType: PropertyType, periods: string[]): Promise<PriceTrendPoint[]>;
|
getPriceTrend(district: string, city: string, propertyType: PropertyType, periods: string[]): Promise<PriceTrendPoint[]>;
|
||||||
getDistrictStats(city: string, period: string): Promise<DistrictStatsResult[]>;
|
getDistrictStats(city: string, period: string): Promise<DistrictStatsResult[]>;
|
||||||
|
getMarketHistory(city: string, periods: string[]): Promise<MarketHistoryPoint[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type HeatmapDataPoint,
|
type HeatmapDataPoint,
|
||||||
type PriceTrendPoint,
|
type PriceTrendPoint,
|
||||||
type DistrictStatsResult,
|
type DistrictStatsResult,
|
||||||
|
type MarketHistoryPoint,
|
||||||
} from '../../domain/repositories/market-index.repository';
|
} from '../../domain/repositories/market-index.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -173,6 +174,53 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMarketHistory(city: string, periods: string[]): Promise<MarketHistoryPoint[]> {
|
||||||
|
const records = await this.prisma.marketIndex.findMany({
|
||||||
|
where: {
|
||||||
|
city: { equals: city, mode: 'insensitive' },
|
||||||
|
period: { in: periods },
|
||||||
|
},
|
||||||
|
orderBy: { period: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggregate across all districts/property types per period
|
||||||
|
const periodMap = new Map<string, {
|
||||||
|
totalAvgPrice: number;
|
||||||
|
totalMedian: bigint;
|
||||||
|
totalListings: number;
|
||||||
|
totalDaysOnMarket: number;
|
||||||
|
count: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const r of records) {
|
||||||
|
const existing = periodMap.get(r.period);
|
||||||
|
if (existing) {
|
||||||
|
existing.totalAvgPrice += r.avgPriceM2;
|
||||||
|
existing.totalMedian += r.medianPrice;
|
||||||
|
existing.totalListings += r.totalListings;
|
||||||
|
existing.totalDaysOnMarket += r.daysOnMarket;
|
||||||
|
existing.count++;
|
||||||
|
} else {
|
||||||
|
periodMap.set(r.period, {
|
||||||
|
totalAvgPrice: r.avgPriceM2,
|
||||||
|
totalMedian: r.medianPrice,
|
||||||
|
totalListings: r.totalListings,
|
||||||
|
totalDaysOnMarket: r.daysOnMarket,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(periodMap.entries()).map(([period, data]) => ({
|
||||||
|
date: period,
|
||||||
|
avgPrice: Math.round(data.totalAvgPrice / data.count),
|
||||||
|
medianPrice: (data.totalMedian / BigInt(data.count)).toString(),
|
||||||
|
listingsCount: data.totalListings,
|
||||||
|
inquiriesCount: 0, // inquiries not tracked in MarketIndex
|
||||||
|
daysOnMarket: Math.round(data.totalDaysOnMarket / data.count),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private toDomain(raw: PrismaMarketIndex): MarketIndexEntity {
|
private toDomain(raw: PrismaMarketIndex): MarketIndexEntity {
|
||||||
const props: MarketIndexProps = {
|
const props: MarketIndexProps = {
|
||||||
district: raw.district,
|
district: raw.district,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
import { GetProjectAiAdviceQuery } from '../../application/queries/get-project-ai-advice/get-project-ai-advice.query';
|
import { GetProjectAiAdviceQuery } from '../../application/queries/get-project-ai-advice/get-project-ai-advice.query';
|
||||||
import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
||||||
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
||||||
|
import { type MarketHistoryDto } from '../../application/queries/get-market-history/get-market-history.handler';
|
||||||
|
import { GetMarketHistoryQuery } from '../../application/queries/get-market-history/get-market-history.query';
|
||||||
import { type MarketSnapshotDto } from '../../application/queries/get-market-snapshot/get-market-snapshot.handler';
|
import { type MarketSnapshotDto } from '../../application/queries/get-market-snapshot/get-market-snapshot.handler';
|
||||||
import { GetMarketSnapshotQuery } from '../../application/queries/get-market-snapshot/get-market-snapshot.query';
|
import { GetMarketSnapshotQuery } from '../../application/queries/get-market-snapshot/get-market-snapshot.query';
|
||||||
import { type PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler';
|
import { type PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler';
|
||||||
@@ -52,6 +54,7 @@ import { BatchValuationDto } from '../dto/batch-valuation.dto';
|
|||||||
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
||||||
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
|
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
|
||||||
import { GetMarketReportDto } from '../dto/get-market-report.dto';
|
import { GetMarketReportDto } from '../dto/get-market-report.dto';
|
||||||
|
import { GetMarketHistoryDto } from '../dto/get-market-history.dto';
|
||||||
import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto';
|
import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto';
|
||||||
import { GetPriceMoversDto } from '../dto/get-price-movers.dto';
|
import { GetPriceMoversDto } from '../dto/get-price-movers.dto';
|
||||||
import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto';
|
import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto';
|
||||||
@@ -82,6 +85,23 @@ export class AnalyticsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
|
@Get('market-history')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Lịch sử thị trường BĐS theo chuỗi thời gian',
|
||||||
|
description:
|
||||||
|
'Trả về time-series dữ liệu thị trường (giá trung bình, giá trung vị, số tin đăng, thời gian rao) cho trang analytics. Cache 6 giờ.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'Market history time-series retrieved' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
|
async getMarketHistory(@Query() dto: GetMarketHistoryDto): Promise<MarketHistoryDto> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetMarketHistoryQuery(dto.city, dto.period, dto.granularity, dto.propertyType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
@RequireQuota('analytics_queries')
|
@RequireQuota('analytics_queries')
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { PropertyType } from '@prisma/client';
|
||||||
|
import { IsEnum, IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetMarketHistoryDto {
|
||||||
|
@ApiProperty({ description: 'City name', example: 'HCMC' })
|
||||||
|
@IsString()
|
||||||
|
city!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Look-back period (e.g. 12m, 6m, 24m)',
|
||||||
|
example: '12m',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
period!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Time granularity',
|
||||||
|
enum: ['monthly', 'weekly'],
|
||||||
|
default: 'monthly',
|
||||||
|
})
|
||||||
|
@IsIn(['monthly', 'weekly'])
|
||||||
|
granularity!: 'monthly' | 'weekly';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: PropertyType, description: 'Property type filter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(PropertyType)
|
||||||
|
propertyType?: PropertyType;
|
||||||
|
}
|
||||||
@@ -39,6 +39,10 @@ export const CacheTTL = {
|
|||||||
TRENDING_AREAS: 1800, // 30 min
|
TRENDING_AREAS: 1800, // 30 min
|
||||||
/** Price movers — 30 min TTL, aggregation over two time windows */
|
/** Price movers — 30 min TTL, aggregation over two time windows */
|
||||||
PRICE_MOVERS: 1800, // 30 min
|
PRICE_MOVERS: 1800, // 30 min
|
||||||
|
/** Market history — 6 hour TTL, time-series data recomputed nightly */
|
||||||
|
MARKET_HISTORY: 21600, // 6 hours
|
||||||
|
/** AVM valuation estimate per listing — long TTL, model outputs are stable within a day */
|
||||||
|
VALUATION_LISTING: 86400, // 24 h
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export enum CachePrefix {
|
export enum CachePrefix {
|
||||||
@@ -58,6 +62,7 @@ export enum CachePrefix {
|
|||||||
MARKET_SNAPSHOT = 'cache:analytics:market_snapshot',
|
MARKET_SNAPSHOT = 'cache:analytics:market_snapshot',
|
||||||
TRENDING_AREAS = 'cache:analytics:trending_areas',
|
TRENDING_AREAS = 'cache:analytics:trending_areas',
|
||||||
PRICE_MOVERS = 'cache:analytics:price_movers',
|
PRICE_MOVERS = 'cache:analytics:price_movers',
|
||||||
|
MARKET_HISTORY = 'cache:analytics:market_history',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
Reference in New Issue
Block a user