Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions backend/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { AnalyticsService } from './analytics.service';
import { DashboardKpisDto } from './dto/dashboard-kpis.dto';
import { MarketAnalyticsDto } from './dto/market-analytics.dto';
import { MarketHistoryResponseDto } from './dto/market-history.dto';
import { UserTrendsDto } from './dto/user-trends.dto';
import { CategoryAnalyticsResponseDto } from './dto/category-analytics.dto';

@ApiTags('Analytics')
@Controller('analytics')
Expand Down Expand Up @@ -63,4 +65,33 @@ export class AnalyticsController {
): Promise<MarketHistoryResponseDto> {
return this.analyticsService.getMarketHistory(id);
}

@Get('users/:address/trends')
@Public()
@ApiOperation({ summary: 'Get user performance trends over time' })
@ApiResponse({
status: 200,
description:
'User trends including accuracy, volume, profit/loss, and category performance',
type: UserTrendsDto,
})
@ApiResponse({ status: 404, description: 'User not found' })
async getUserTrends(
@Param('address') address: string,
): Promise<UserTrendsDto> {
return this.analyticsService.getUserTrends(address);
}

@Get('categories')
@Public()
@ApiOperation({ summary: 'Get category analytics and statistics' })
@ApiResponse({
status: 200,
description:
'Category analytics including market counts, volume, participants, and trending status',
type: CategoryAnalyticsResponseDto,
})
async getCategoryAnalytics(): Promise<CategoryAnalyticsResponseDto> {
return this.analyticsService.getCategoryAnalytics();
}
}
219 changes: 219 additions & 0 deletions backend/src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import {
OutcomeDistributionDto,
} from './dto/market-analytics.dto';
import { MarketHistoryResponseDto } from './dto/market-history.dto';
import {
UserTrendsDto,
TrendDataPointDto,
CategoryPerformanceDto,
} from './dto/user-trends.dto';
import {
CategoryStatsDto,
CategoryAnalyticsResponseDto,
} from './dto/category-analytics.dto';

/** Tier thresholds: Bronze < 200, Silver < 500, Gold < 1000, Platinum ≥ 1000 */
export function predictorTierFromReputation(reputationScore: number): string {
Expand Down Expand Up @@ -255,4 +264,214 @@ export class AnalyticsService {

await this.marketHistoryRepository.save(snapshot);
}

/**
* Get user performance trends over time
*/
async getUserTrends(address: string): Promise<UserTrendsDto> {
const user = await this.usersRepository.findOne({
where: { stellar_address: address },
});

if (!user) {
throw new NotFoundException(`User with address ${address} not found`);
}

const predictions = await this.predictionsRepository.find({
where: { user: { id: user.id } },
relations: ['market'],
order: { submitted_at: 'ASC' },
});

const accuracyTrend = this.computeAccuracyTrend(predictions);
const volumeTrend = this.computeVolumeTrend(predictions);
const profitLossTrend = this.computeProfitLossTrend(predictions);
const categoryPerformance = this.computeCategoryPerformance(predictions);

const bestCategory = categoryPerformance.reduce((best, current) =>
current.accuracy_rate > (best?.accuracy_rate ?? 0) ? current : best,
);

const worstCategory = categoryPerformance.reduce((worst, current) =>
current.accuracy_rate < (worst?.accuracy_rate ?? 100) ? current : worst,
);

return {
address,
accuracy_trend: accuracyTrend,
prediction_volume_trend: volumeTrend,
profit_loss_trend: profitLossTrend,
category_performance: categoryPerformance,
best_category: bestCategory || null,
worst_category: worstCategory || null,
};
}

private computeAccuracyTrend(predictions: Prediction[]): TrendDataPointDto[] {
const trend: TrendDataPointDto[] = [];
let correct = 0;
let total = 0;

predictions.forEach((p) => {
if (p.market?.is_resolved) {
total++;
if (p.market.resolved_outcome === p.chosen_outcome) {
correct++;
}
trend.push({
timestamp: p.submitted_at,
value: total > 0 ? Math.round((correct / total) * 10000) / 100 : 0,
});
}
});

return trend;
}

private computeVolumeTrend(predictions: Prediction[]): TrendDataPointDto[] {
const trend: TrendDataPointDto[] = [];
let count = 0;

predictions.forEach((p) => {
count++;
trend.push({
timestamp: p.submitted_at,
value: count,
});
});

return trend;
}

private computeProfitLossTrend(
predictions: Prediction[],
): TrendDataPointDto[] {
const trend: TrendDataPointDto[] = [];
let cumulativePnL = 0n;

predictions.forEach((p) => {
if (p.market?.is_resolved) {
const stake = BigInt(p.stake_amount_stroops || 0);
const payout = BigInt(p.payout_amount_stroops || 0);
cumulativePnL += payout - stake;

trend.push({
timestamp: p.submitted_at,
value: Number(cumulativePnL),
});
}
});

return trend;
}

private computeCategoryPerformance(
predictions: Prediction[],
): CategoryPerformanceDto[] {
const categoryMap = new Map<
string,
{ correct: number; total: number; pnl: bigint }
>();

predictions.forEach((p) => {
const category = p.market?.category || 'Unknown';
const current = categoryMap.get(category) || {
correct: 0,
total: 0,
pnl: 0n,
};

if (p.market?.is_resolved) {
current.total++;
if (p.market.resolved_outcome === p.chosen_outcome) {
current.correct++;
}
const stake = BigInt(p.stake_amount_stroops || 0);
const payout = BigInt(p.payout_amount_stroops || 0);
current.pnl += payout - stake;
}

categoryMap.set(category, current);
});

return Array.from(categoryMap.entries()).map(([category, stats]) => ({
category,
accuracy_rate:
stats.total > 0
? Math.round((stats.correct / stats.total) * 10000) / 100
: 0,
prediction_count: stats.total,
profit_loss_stroops: stats.pnl.toString(),
}));
}

/**
* Get category analytics with trending calculation
*/
async getCategoryAnalytics(): Promise<CategoryAnalyticsResponseDto> {
const markets = await this.marketsRepository.find();

const categoryMap = new Map<
string,
{
total: number;
active: number;
volume: bigint;
participants: number[];
}
>();

markets.forEach((market) => {
const category = market.category || 'Unknown';
const current = categoryMap.get(category) || {
total: 0,
active: 0,
volume: 0n,
participants: [],
};

current.total++;
if (!market.is_resolved && !market.is_cancelled) {
current.active++;
}
current.volume += BigInt(market.total_pool_stroops || 0);
current.participants.push(market.participant_count);

categoryMap.set(category, current);
});

const categories: CategoryStatsDto[] = Array.from(
categoryMap.entries(),
).map(([name, stats]) => {
const avgParticipants =
stats.participants.length > 0
? Math.round(
stats.participants.reduce((a, b) => a + b, 0) /
stats.participants.length,
)
: 0;

const trending = this.isCategoryTrending(stats.active, stats.total);

return {
name,
total_markets: stats.total,
active_markets: stats.active,
total_volume_stroops: stats.volume.toString(),
avg_participants: avgParticipants,
trending,
};
});

return {
categories: categories.sort((a, b) => b.total_markets - a.total_markets),
generated_at: new Date(),
};
}

private isCategoryTrending(active: number, total: number): boolean {
if (total === 0) return false;
const activeRatio = active / total;
return activeRatio > 0.5;
}
}
13 changes: 13 additions & 0 deletions backend/src/analytics/dto/category-analytics.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class CategoryStatsDto {
name: string;
total_markets: number;
active_markets: number;
total_volume_stroops: string;
avg_participants: number;
trending: boolean;
}

export class CategoryAnalyticsResponseDto {
categories: CategoryStatsDto[];
generated_at: Date;
}
21 changes: 21 additions & 0 deletions backend/src/analytics/dto/user-trends.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export class TrendDataPointDto {
timestamp: Date;
value: number;
}

export class CategoryPerformanceDto {
category: string;
accuracy_rate: number;
prediction_count: number;
profit_loss_stroops: string;
}

export class UserTrendsDto {
address: string;
accuracy_trend: TrendDataPointDto[];
prediction_volume_trend: TrendDataPointDto[];
profit_loss_trend: TrendDataPointDto[];
category_performance: CategoryPerformanceDto[];
best_category: CategoryPerformanceDto | null;
worst_category: CategoryPerformanceDto | null;
}
21 changes: 21 additions & 0 deletions backend/src/migrations/1775300000000-CreateUserPreferencesTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateUserPreferencesTable1775300000000 implements MigrationInterface {
name = 'CreateUserPreferencesTable1775300000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "user_preferences" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" uuid NOT NULL, "email_notifications" boolean NOT NULL DEFAULT true, "market_resolution_notifications" boolean NOT NULL DEFAULT true, "competition_notifications" boolean NOT NULL DEFAULT true, "leaderboard_notifications" boolean NOT NULL DEFAULT true, "marketing_emails" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "REL_user_preferences_user_id" UNIQUE ("user_id"), CONSTRAINT "PK_user_preferences_id" PRIMARY KEY ("id"), CONSTRAINT "FK_user_preferences_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_user_preferences_user_id" ON "user_preferences" ("user_id")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_user_preferences_user_id"`,
);
await queryRunner.query(`DROP TABLE "user_preferences"`);
}
}
27 changes: 27 additions & 0 deletions backend/src/migrations/1775310000000-CreateUserFollowsTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateUserFollowsTable1775310000000 implements MigrationInterface {
name = 'CreateUserFollowsTable1775310000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "user_follows" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "follower_id" uuid NOT NULL, "following_id" uuid NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_user_follows_follower_following" UNIQUE ("follower_id", "following_id"), CONSTRAINT "PK_user_follows_id" PRIMARY KEY ("id"), CONSTRAINT "FK_user_follows_follower_id" FOREIGN KEY ("follower_id") REFERENCES "users"("id") ON DELETE CASCADE, CONSTRAINT "FK_user_follows_following_id" FOREIGN KEY ("following_id") REFERENCES "users"("id") ON DELETE CASCADE)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_user_follows_follower_id" ON "user_follows" ("follower_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_user_follows_following_id" ON "user_follows" ("following_id")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_user_follows_following_id"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_user_follows_follower_id"`,
);
await queryRunner.query(`DROP TABLE "user_follows"`);
}
}
Loading
Loading