Files
goodgo-platform/docs/explorations/ANALYTICS_ARCHITECTURE.md
Ho Ngoc Hai 08b96f9c2d docs: consolidate exploration & audit reports under docs/ (TEC-3094)
- Move 8 stray .md (+5 .txt) from ~/Desktop into docs/explorations/from-desktop/
- Reorganize 27 .md/.txt at workspace root:
  - audit reports -> docs/audits/
  - exploration reports -> docs/explorations/
  - design system -> docs/design-system/
- Keep only README/CHANGELOG/CONTRIBUTING/CLAUDE at repo root
- Refresh docs/README.md as canonical index with links to all groups
- Note: pre-existing docs/audits/AUDIT_INDEX.md and AUDIT_SUMMARY.md were
  overwritten by the newer root-level versions during the move

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 16:29:24 +07:00

789 lines
25 KiB
Markdown

# GoodGo Analytics Module - Architecture Exploration Summary
## 1. MODULE STRUCTURE (DDD Layers)
### File Organization
```
apps/api/src/modules/analytics/
├── analytics.module.ts # NestJS module (CQRS setup, DI)
├── index.ts # Exports
├── application/ # Application layer
│ ├── commands/ # Write operations
│ │ ├── generate-report/
│ │ ├── track-event/
│ │ └── update-market-index/
│ ├── queries/ # Read operations
│ │ ├── batch-valuation/
│ │ ├── get-district-stats/
│ │ ├── get-heatmap/
│ │ ├── get-listing-ai-advice/
│ │ ├── get-market-report/
│ │ ├── get-nearby-pois/
│ │ ├── get-neighborhood-score/
│ │ ├── get-price-trend/
│ │ ├── get-project-ai-advice/
│ │ ├── get-valuation/
│ │ ├── industrial-valuation/
│ │ ├── predict-valuation/
│ │ ├── valuation-comparison/
│ │ ├── valuation-explanation/
│ │ ├── valuation-history/
│ │ ├── _shared/ # Shared utilities
│ │ │ └── ai-json-client.ts
│ ├── event-handlers/
│ │ └── listing-created-moderation.handler.ts
│ └── __tests__/
├── domain/ # Domain layer
│ ├── repositories/
│ │ ├── market-index.repository.ts
│ │ ├── valuation.repository.ts
│ ├── services/
│ │ ├── avm-service.ts
│ │ └── neighborhood-score.service.ts
│ ├── entities/
│ └── aggregates/
├── infrastructure/ # Infrastructure layer
│ ├── repositories/
│ │ ├── prisma-market-index.repository.ts
│ │ └── prisma-valuation.repository.ts
│ ├── services/
│ │ ├── ai-service.client.ts
│ │ ├── http-avm.service.ts
│ │ ├── market-index-cron.service.ts
│ │ ├── neighborhood-score.service.ts
│ │ └── prisma-avm.service.ts
│ └── __tests__/
└── presentation/ # Presentation layer
├── controllers/
│ ├── analytics.controller.ts
│ ├── avm.controller.ts
│ └── index.ts
├── dto/ # Request/Response DTOs
│ ├── avm-compare-query.dto.ts
│ ├── avm-explain-query.dto.ts
│ ├── batch-valuation.dto.ts
│ ├── get-district-stats.dto.ts
│ ├── get-heatmap.dto.ts
│ ├── get-market-report.dto.ts
│ ├── get-nearby-pois.dto.ts
│ ├── get-price-trend.dto.ts
│ ├── get-valuation.dto.ts
│ ├── industrial-valuation.dto.ts
│ ├── predict-valuation.dto.ts
│ ├── valuation-comparison.dto.ts
│ ├── valuation-history.dto.ts
│ └── index.ts
├── __tests__/
└── index.ts
```
### Architecture Pattern: **DDD + CQRS**
- **Commands**: State mutations (GenerateReportHandler, TrackEventHandler, UpdateMarketIndexHandler)
- **Queries**: Read operations with cache-aside pattern
- **Event Handlers**: Listen to domain events (listing-created-moderation)
- **Repositories**: Abstract data access (Market Index, Valuation)
- **Services**: Business logic (AVM, Neighborhood Scoring)
---
## 2. CONTROLLER & ENDPOINT STRUCTURE
### Analytics Controller (`analytics.controller.ts`)
Routes: `GET/POST /analytics/*` and public endpoints
**Key Endpoints:**
```
GET /analytics/market-report → GetMarketReportQuery
GET /analytics/price-trend → GetPriceTrendQuery
GET /analytics/heatmap → GetHeatmapQuery
GET /analytics/district-stats → GetDistrictStatsQuery
GET /analytics/valuation → GetValuationQuery (by propertyId or coords)
POST /analytics/valuation → PredictValuationQuery (manual input form)
POST /analytics/valuation/batch → BatchValuationQuery (1-50 properties)
GET /analytics/valuation/history/:id → ValuationHistoryQuery
POST /analytics/valuation/compare → ValuationComparisonQuery (2-5 properties)
GET /analytics/neighborhoods/:district/score → GetNeighborhoodScoreQuery (no auth)
GET /analytics/pois/nearby → GetNearbyPOIsQuery (no auth, public)
POST /analytics/listings/:id/ai-advice → GetListingAiAdviceQuery (Claude)
POST /analytics/projects/:id/ai-advice → GetProjectAiAdviceQuery (Claude)
```
### AVM Controller (`avm.controller.ts`)
Routes: `GET/POST /avm/*`
**Key Endpoints:**
```
POST /avm/batch → BatchValuationQuery
GET /avm/history/:propertyId → ValuationHistoryQuery
GET /avm/compare → ValuationComparisonQuery (query string: ids)
GET /avm/explain → ValuationExplanationQuery (valuationId)
POST /avm/industrial → IndustrialValuationQuery
```
### Guard & Decorator Stack
```
@ApiBearerAuth('JWT') // Swagger doc
@UseGuards(
EndpointRateLimitGuard, // Redis sliding-window rate limit
JwtAuthGuard, // Verify JWT token
QuotaGuard // Check user subscription quota
)
@RequireQuota('analytics_queries') // Decorator: specify quota resource
@EndpointRateLimit({
limit: 10,
windowSeconds: 60,
keyStrategy: 'user' | 'ip' // Rate limit by authenticated user or IP
})
```
---
## 3. QUERY/HANDLER PATTERN (CQRS Implementation)
### Query Definition (Example: `GetPriceTrendQuery`)
```typescript
export class GetPriceTrendQuery {
constructor(
public readonly district: string,
public readonly city: string,
public readonly propertyType: PropertyType, // From @prisma/client
public readonly periods: string[],
) {}
}
```
### Handler Pattern (Example: `GetPriceTrendHandler`)
```typescript
@QueryHandler(GetPriceTrendQuery)
export class GetPriceTrendHandler implements IQueryHandler<GetPriceTrendQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY)
private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService, // Cache-aside pattern
private readonly logger: LoggerService,
) {}
async execute(query: GetPriceTrendQuery): Promise<PriceTrendDto> {
try {
const cacheKey = CacheService.buildKey(
CachePrefix.MARKET_TREND,
query.district,
query.city,
query.propertyType,
query.periods?.join(','),
);
return this.cache.getOrSet(
cacheKey,
async () => {
const trend = await this.marketIndexRepo.getPriceTrend(...);
return { district, city, propertyType, trend };
},
CacheTTL.MARKET_DATA, // 30 minutes
'price_trend', // Metric label
);
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(...);
throw new InternalServerErrorException('...');
}
}
}
```
**Key Pattern:**
1. Inject repository (abstraction) and cache service
2. Build cache key with CacheService.buildKey()
3. Use cache.getOrSet() for cache-aside pattern
4. Specify TTL from CacheTTL constant and metric name
5. Catch DomainException separately, rethrow or wrap
---
## 4. REDIS CACHING PATTERNS
### Cache Configuration (`CacheService`)
**Cache TTLs:**
```typescript
CacheTTL = {
LISTING_DETAIL: 300, // 5 min
SEARCH_RESULTS: 120, // 2 min
DISTRICT_STATS: 300, // 5 min
MARKET_REPORT: 900, // 15 min
HEATMAP: 300, // 5 min
MARKET_DATA: 1800, // 30 min (price trends)
USER_PROFILE: 600, // 10 min
USER_QUOTA: 60, // 1 min (frequently invalidated)
PLAN_LIST: 3600, // 1 hour
REFERENCE_DATA: 86400, // 24 hours (static districts/wards)
}
```
**Cache Key Prefixes:**
```typescript
enum CachePrefix {
LISTING = 'cache:listing',
SEARCH = 'cache:search',
GEO_SEARCH = 'cache:geo_search',
MARKET_REPORT = 'cache:market:report',
MARKET_TREND = 'cache:market:trend', // For price trends
MARKET_HEATMAP = 'cache:market:heatmap',
MARKET_DISTRICT = 'cache:market:district',
USER_PROFILE = 'cache:user:profile',
USER_QUOTA = 'cache:user:quota',
VALUATION = 'cache:valuation',
PLAN_LIST = 'cache:plan:list',
REFERENCE = 'cache:reference',
AGENT_LISTINGS = 'cache:agent:listings',
}
```
**Cache-Aside Implementation:**
```typescript
async getOrSet<T>(
key: string,
loader: () => Promise<T>,
ttlSeconds: number,
resource: string, // Metric label
): Promise<T> {
// 1. Fast-path: if Redis unavailable, call loader directly (graceful degradation)
if (!this.redis.isAvailable()) {
// Metric: cache_degradation_total[resource]++
return await loader();
}
// 2. Try to get from cache
// 3. If miss: call loader, store in Redis, return
// 4. Metrics: cache_hit_total or cache_miss_total[resource]++
}
```
**Metrics Tracked:**
```
cache_hit_total[resource] // Counter: hits by resource
cache_miss_total[resource] // Counter: misses by resource
cache_degradation_total[resource] // Counter: fallbacks when Redis down
```
### Rate Limiting with Redis (Sliding Window)
**Lua Script in `EndpointRateLimitGuard`:**
```lua
-- Sliding-window algorithm using Redis sorted set
-- KEYS[1] = rate limit key (e.g., "rate:user:123:POST:/analytics/valuation")
-- ARGV[1] = now (ms timestamp)
-- ARGV[2] = windowMs (duration)
-- ARGV[3] = limit (max requests)
-- ARGV[4] = requestId (unique)
-- ARGV[5] = windowSec (TTL)
-- Remove entries older than window
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
local current = redis.call('ZCARD', key)
if current < limit then
redis.call('ZADD', key, now, requestId) -- Add current request
redis.call('EXPIRE', key, windowSec + 1) -- Set expiry
return {current + 1, 0}
else
-- Compute Retry-After header
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
return {current, retryAfterMs}
end
```
**Rate Limit Headers Returned:**
```
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 5
X-RateLimit-Reset: 1640000000
Retry-After: 12
```
---
## 5. PRISMA SCHEMA - PROPERTY & LISTING MODELS
### Property Model
```prisma
model Property {
id String @id @default(cuid())
addressNormalized String?
propertyType PropertyType // APARTMENT, HOUSE, LAND, COMMERCIAL, etc.
status PropertyStatus // ACTIVE, SOLD, RENTED, REMOVED
areaM2 Float?
usableAreaM2 Float?
bedrooms Int?
bathrooms Int?
floors Int?
floor Int?
totalFloors Int?
direction Direction?
yearBuilt Int?
legalStatus String?
amenities Json?
nearbyPOIs Json?
metroDistanceM Float?
projectName String?
projectDevelopmentId String?
projectDevelopment ProjectDevelopment? @relation(...)
furnishing Furnishing?
propertyCondition PropertyCondition?
balconyDirection Direction?
maintenanceFeeVND BigInt?
parkingSlots Int?
viewType String[] @default([])
petFriendly Boolean?
suitableFor String[] @default([])
whyThisLocation String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings Listing[]
valuations Valuation[]
media PropertyMedia[]
// Indexes for analytics queries
@@index([propertyType])
@@index([district, city])
@@index([location], type: Gist) // PostGIS spatial index
@@index([district, propertyType])
@@index([district, city, propertyType])
}
```
### Listing Model (with Analytics Fields)
```prisma
model Listing {
id String @id @default(cuid())
propertyId String
property Property @relation(...)
agentId String?
agent Agent? @relation(...)
sellerId String
seller User @relation(...)
transactionType TransactionType // BUY_SELL, RENT
status ListingStatus @default(DRAFT)
priceVND BigInt // CHECK: > 0
pricePerM2 Float? // Derived for analytics
rentPriceMonthly BigInt?
commissionPct Float? @default(2.0)
// AI Valuation fields
aiPriceEstimate BigInt? // AVM estimate
aiConfidence Float?
moderationScore Float?
moderationNotes String?
// Analytics tracking
viewCount Int @default(0)
saveCount Int @default(0)
inquiryCount Int @default(0)
// Lifecycle
featuredUntil DateTime?
expiresAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
inquiries Inquiry[]
orders Order[]
priceHistories PriceHistory[]
savedByUsers SavedListing[]
@@index([status])
@@index([transactionType])
@@index([priceVND])
@@index([sellerId])
@@index([propertyId])
@@index([publishedAt])
@@index([createdAt])
@@index([status, createdAt(sort: Desc)]) // For market analytics
@@index([status, transactionType, priceVND]) // For price analysis
}
```
### Price History Model
```prisma
model PriceHistory {
id String @id @default(cuid())
listingId String
listing Listing @relation(...)
oldPrice BigInt // CHECK: > 0
newPrice BigInt // CHECK: > 0
source String @default("manual_update")
changedAt DateTime @default(now())
@@index([listingId, changedAt(sort: Desc)]) // For trend queries
}
```
### Market Index Model (Pre-calculated Analytics)
```prisma
model MarketIndex {
id String @id @default(cuid())
district String
city String
propertyType PropertyType
period String // "2024-Q1" or "2024-04" format
medianPrice BigInt
avgPriceM2 Float
totalListings Int
daysOnMarket Int
inventoryLevel Int
absorptionRate Float?
yoyChange Float? // Year-over-year % change
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([district, city, propertyType, period])
@@index([city, period])
@@index([period, propertyType])
}
```
### Valuation Model
```prisma
model Valuation {
id String @id @default(cuid())
propertyId String
property Property @relation(...)
estimatedPrice BigInt
confidence Float // 0-1 confidence score
drivers Json? // Key price drivers
comparables Json? // Similar properties used
explanation String?
model String // "v1" or "v2"
createdAt DateTime @default(now())
@@index([propertyId, createdAt(sort: Desc)])
@@index([createdAt])
}
```
---
## 6. SHARED GUARDS & DECORATORS
### File Locations
```
apps/api/src/modules/shared/
├── infrastructure/
│ ├── decorators/
│ │ ├── endpoint-rate-limit.decorator.ts ← Rate limit config
│ │ ├── user-rate-limit.decorator.ts
│ │ └── cacheable.decorator.ts
│ ├── guards/
│ │ ├── endpoint-rate-limit.guard.ts ← Sliding-window enforcement
│ │ ├── user-rate-limit.guard.ts
│ │ └── feature-listing-throttler.guard.ts
│ ├── middleware/
│ │ ├── correlation-id.middleware.ts ← Trace ID injection
│ │ ├── csrf.middleware.ts
│ │ ├── request-logging.middleware.ts ← Audit logging
│ │ └── sanitize-input.middleware.ts
│ ├── cache.service.ts ← Cache-aside + Redis
│ ├── redis.service.ts ← Redis connection pool
│ ├── logger.service.ts ← Structured logging
│ ├── prisma.service.ts
│ ├── field-encryption.service.ts
│ └── filters/
│ └── global-exception.filter.ts ← Error response standardization
├── domain/
│ ├── domain-exception.ts ← Base error class
│ ├── error-codes.ts
│ ├── base-entity.ts
│ ├── domain-event.ts
│ ├── aggregate-root.ts
│ └── value-object.ts
└── utils/
└── ...
```
### Key Decorators & Guards
**@EndpointRateLimit Decorator:**
```typescript
interface EndpointRateLimitOptions {
limit: number; // Max requests
windowSeconds?: number; // Default 60
keyStrategy?: 'ip' | 'user'; // Default 'ip'
adminBypass?: boolean; // Admins skip limit (default true)
}
// Usage:
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard)
async method() {}
```
**Response Standardization (via GlobalExceptionFilter):**
```typescript
interface ErrorResponseBody {
statusCode: number;
errorCode: ErrorCode; // Enum: NOT_FOUND, VALIDATION_FAILED, etc.
message: string;
details?: Record<string, unknown>;
correlationId?: string; // From CorrelationIdMiddleware
timestamp: string;
}
```
### Exception Hierarchy
```typescript
class DomainException extends HttpException {
constructor(
errorCode: ErrorCode,
message: string,
statusCode?: HttpStatus,
details?: Record
)
}
// Specialized exceptions:
class NotFoundException extends DomainException
class ValidationException extends DomainException
class ConflictException extends DomainException
class UnauthorizedException extends DomainException
class ForbiddenException extends DomainException
```
---
## 7. DTO PATTERNS IN ANALYTICS
### Request DTOs (Query Parameters)
```typescript
// GET /analytics/market-report?city=...&period=...&propertyType=...
export class GetMarketReportDto {
@IsString() city: string;
@IsString() period: string;
@IsEnum(PropertyType) propertyType?: PropertyType;
}
// POST /analytics/valuation with body
export class PredictValuationDto {
@IsEnum(PropertyType) propertyType: PropertyType;
@IsNumber() area: number;
@IsString() district: string;
@IsString() city: string;
@IsNumber() latitude?: number;
@IsNumber() longitude?: number;
@IsNumber() bedrooms?: number;
@IsNumber() bathrooms?: number;
@IsBoolean() hasElevator?: boolean;
@IsBoolean() hasParking?: boolean;
@IsBoolean() hasPool?: boolean;
@IsNumber() distanceToHospitalKm?: number;
@IsNumber() distanceToParkKm?: number;
@IsNumber() distanceToMallKm?: number;
@IsString() floodZoneRisk?: string;
}
```
### Response DTOs (Handler Return Types)
```typescript
// From handler
export interface PriceTrendDto {
district: string;
city: string;
propertyType: string;
trend: PriceTrendPoint[];
}
export interface PriceTrendPoint {
period: string;
medianPrice: string; // Stringified BigInt
avgPriceM2: number;
totalListings: number;
}
// From repository
export interface MarketReportResult {
district: string;
city: string;
propertyType: PropertyType;
period: string;
medianPrice: string; // Stringified for JSON safety
avgPriceM2: number;
totalListings: number;
daysOnMarket: number;
inventoryLevel: number;
absorptionRate: number | null;
yoyChange: number | null;
}
```
---
## 8. DEPENDENCY INJECTION & MODULE EXPORTS
### Analytics Module Registration
```typescript
@Module({
imports: [CqrsModule, ListingsModule, AdminModule, ProjectsModule],
controllers: [AnalyticsController, AvmController],
providers: [
{ provide: AI_SERVICE_CLIENT, useClass: AiServiceClient },
{ provide: MARKET_INDEX_REPOSITORY, useClass: PrismaMarketIndexRepository },
{ provide: VALUATION_REPOSITORY, useClass: PrismaValuationRepository },
PrismaAVMService,
{ provide: AVM_SERVICE, useClass: HttpAVMService }, // HTTP → Python AI
PrismaNeighborhoodScoreService,
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: HttpNeighborhoodScoreService },
MarketIndexCronService,
...CommandHandlers,
...QueryHandlers,
...EventHandlers,
],
exports: [
MARKET_INDEX_REPOSITORY,
VALUATION_REPOSITORY,
AVM_SERVICE,
AI_SERVICE_CLIENT,
],
})
export class AnalyticsModule {}
```
### Shared Module (Global)
```typescript
@Global()
@Module({
imports: [ConfigModule.forRoot(...), EventEmitterModule.forRoot(), PrometheusModule.register(...)],
providers: [
LoggerService,
PrismaService,
RedisService, // Redis connection pool
CacheService, // Cache-aside pattern + metrics
EventBusService,
FieldEncryptionService,
// Prometheus metrics for cache
makeCounterProvider({ name: CACHE_HIT_TOTAL, labelNames: ['resource'] }),
makeCounterProvider({ name: CACHE_MISS_TOTAL, labelNames: ['resource'] }),
makeCounterProvider({ name: CACHE_DEGRADATION_TOTAL, labelNames: ['resource', 'operation'] }),
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
],
exports: [PrismaService, RedisService, CacheService, LoggerService, ...],
})
export class SharedModule implements NestModule {
// Middleware registration:
configure(consumer: MiddlewareConsumer) {
consumer
.apply(CorrelationIdMiddleware, SanitizeInputMiddleware, RequestLoggingMiddleware)
.forRoutes('*');
consumer
.apply(CsrfMiddleware)
.exclude([{ path: 'auth/login', method: RequestMethod.POST }, ...])
.forRoutes('*');
}
}
```
---
## 9. KEY PATTERNS & CONVENTIONS
### Cache Key Building
```typescript
CacheService.buildKey(CachePrefix.MARKET_TREND, district, city, propertyType, periods)
// → "cache:market:trend:{district}:{city}:{propertyType}:{periods}"
```
### Error Handling Pattern
```typescript
async execute(query: Query): Promise<Result> {
try {
const cacheKey = CacheService.buildKey(...);
return this.cache.getOrSet(cacheKey, async () => {
// Business logic
}, CacheTTL.MARKET_DATA, 'metric_label');
} catch (error) {
if (error instanceof DomainException) throw error; // Re-throw domain errors
this.logger.error(`Failed to ...`, error.stack, this.constructor.name);
throw new InternalServerErrorException('User-facing message');
}
}
```
### Repository Pattern (Dependency Inversion)
```typescript
// domain/repositories/market-index.repository.ts
export const MARKET_INDEX_REPOSITORY = Symbol('MARKET_INDEX_REPOSITORY');
export interface IMarketIndexRepository { ... }
// infrastructure/repositories/prisma-market-index.repository.ts
@Injectable()
export class PrismaMarketIndexRepository implements IMarketIndexRepository { ... }
// In handlers:
@Inject(MARKET_INDEX_REPOSITORY) private readonly repo: IMarketIndexRepository
```
### Quota & Rate Limit Stack
```
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard) // Order matters!
@RequireQuota('analytics_queries') // Quota resource name
```
### Query Handler Naming
- Query class: `GetPriceTrendQuery`
- Handler: `GetPriceTrendHandler` (decorated with `@QueryHandler(GetPriceTrendQuery)`)
- DTO: `GetPriceTrendDto` (response type)
- Query class file: `get-price-trend.query.ts`
- Handler file: `get-price-trend.handler.ts`
---
## 10. QUICK REFERENCE: PATHS & KEYS
**Module Root:**
```
apps/api/src/modules/analytics/
```
**Controllers:**
```
analytics.controller.ts → /analytics/*
avm.controller.ts → /avm/*
```
**Cache Prefixes (for market analytics):**
```
cache:market:report → Market report data
cache:market:trend → Price trends
cache:market:heatmap → Heatmap data
cache:market:district → District statistics
cache:valuation → Property valuations
```
**Shared Module Exports:**
```
PrismaService → Database
RedisService → Redis connection
CacheService → Cache-aside + metrics
LoggerService → Structured logging
EventBusService → Event emission
```
**Key Decorators:**
```
@EndpointRateLimit({...}) → Per-endpoint sliding-window rate limit
@RequireQuota('...') → Subscription quota check
@UseGuards(...) → Auth, rate limit, quota guards
```
**TTLs:**
```
MARKET_DATA: 1800 → 30 minutes (price trends, historical)
MARKET_REPORT: 900 → 15 minutes (report summaries)
HEATMAP: 300 → 5 minutes (heatmap tiles)
DISTRICT_STATS: 300 → 5 minutes (statistics)
```