Initial commit

This commit is contained in:
Boki 2026-02-05 13:52:07 -05:00
commit 84d38c5173
46 changed files with 6819 additions and 0 deletions

View file

@ -0,0 +1,222 @@
import { eq, desc } from 'drizzle-orm';
import { getDatabase, items, priceHistory } from '@poe2data/database';
export interface TrendMover {
id: number;
name: string;
category: string;
iconUrl: string | null;
currentValue: number;
change7d: number;
volume: number;
chaosValue: number;
}
export interface CategorySummary {
category: string;
itemCount: number;
topItem: string;
topValue: number;
avgChange7d: number;
}
export interface LeagueStartPriority {
name: string;
category: string;
currentValue: number;
change7d: number;
volume: number;
priority: 'high' | 'medium' | 'low';
reason: string;
}
export class TrendAnalysisService {
private db = getDatabase();
/**
* Get all items with current prices
*/
async getAllItems(category?: string): Promise<TrendMover[]> {
// Get all items
const allItems = await this.db.select().from(items).all();
// Get latest price for each item - using Drizzle query builder
// We'll get all prices and pick the latest per item in JS
const allPrices = await this.db
.select()
.from(priceHistory)
.orderBy(desc(priceHistory.recordedAt))
.all();
// Group by item and get the latest
const priceMap = new Map<number, typeof allPrices[0]>();
for (const price of allPrices) {
if (!priceMap.has(price.itemId)) {
priceMap.set(price.itemId, price);
}
}
let result = allItems.map(item => {
const price = priceMap.get(item.id);
return {
id: item.id,
name: item.name,
category: item.category,
iconUrl: item.iconUrl,
currentValue: price?.divineValue || 0,
change7d: price?.change7d || 0,
volume: price?.volume || 0,
chaosValue: (price?.divineValue || 0) * (price?.chaosRate || 42),
};
});
if (category) {
result = result.filter(i => i.category === category);
}
return result.sort((a, b) => b.currentValue - a.currentValue);
}
/**
* Get top movers (gainers and losers)
*/
async getTopMovers(limit: number = 20): Promise<{ gainers: TrendMover[]; losers: TrendMover[] }> {
const allItems = await this.getAllItems();
// Filter items with valid change data
const movers = allItems.filter(m => m.change7d !== 0 && m.currentValue > 0);
// Sort for gainers (highest positive change)
const gainers = [...movers]
.filter(m => m.change7d > 0)
.sort((a, b) => b.change7d - a.change7d)
.slice(0, limit);
// Sort for losers (most negative change)
const losers = [...movers]
.filter(m => m.change7d < 0)
.sort((a, b) => a.change7d - b.change7d)
.slice(0, limit);
return { gainers, losers };
}
/**
* Get category summaries
*/
async getCategorySummaries(): Promise<CategorySummary[]> {
const allItems = await this.getAllItems();
const byCategory = new Map<string, TrendMover[]>();
for (const item of allItems) {
const list = byCategory.get(item.category) || [];
list.push(item);
byCategory.set(item.category, list);
}
const summaries: CategorySummary[] = [];
for (const [category, categoryItems] of byCategory) {
const sorted = categoryItems.sort((a, b) => b.currentValue - a.currentValue);
const validChanges = categoryItems.filter(i => i.change7d !== 0);
const avgChange = validChanges.length > 0
? validChanges.reduce((sum, i) => sum + i.change7d, 0) / validChanges.length
: 0;
summaries.push({
category,
itemCount: categoryItems.length,
topItem: sorted[0]?.name || 'N/A',
topValue: sorted[0]?.currentValue || 0,
avgChange7d: avgChange,
});
}
return summaries.sort((a, b) => b.topValue - a.topValue);
}
/**
* Get league start priorities - items to farm early
*/
async getLeagueStartPriorities(): Promise<LeagueStartPriority[]> {
const allItems = await this.getAllItems();
const priorities: LeagueStartPriority[] = [];
for (const item of allItems) {
let priority: 'high' | 'medium' | 'low' = 'low';
let reason = '';
// High priority: High value + rising + good volume
if (item.currentValue > 1 && item.change7d > 20 && item.volume > 100) {
priority = 'high';
reason = 'High value, rising price, good demand';
}
// High priority: Very high value items
else if (item.currentValue > 50) {
priority = 'high';
reason = 'Very high value item';
}
// Medium priority: Rising prices
else if (item.change7d > 50) {
priority = 'medium';
reason = 'Rapidly rising price';
}
// Medium priority: Decent value with volume
else if (item.currentValue > 0.5 && item.volume > 500) {
priority = 'medium';
reason = 'Good value with high liquidity';
}
// Skip low value items
else if (item.currentValue < 0.01) {
continue;
}
// Low priority: everything else valuable
else if (item.currentValue > 0.1) {
priority = 'low';
reason = 'Moderate value';
} else {
continue;
}
priorities.push({
name: item.name,
category: item.category,
currentValue: item.currentValue,
change7d: item.change7d,
volume: item.volume,
priority,
reason,
});
}
// Sort by priority then value
const priorityOrder = { high: 0, medium: 1, low: 2 };
return priorities.sort((a, b) => {
const pDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
if (pDiff !== 0) return pDiff;
return b.currentValue - a.currentValue;
});
}
/**
* Get price history for an item
*/
async getItemHistory(itemId: number): Promise<{ timestamp: number; value: number; volume: number }[]> {
const history = await this.db
.select({
recordedAt: priceHistory.recordedAt,
divineValue: priceHistory.divineValue,
volume: priceHistory.volume,
})
.from(priceHistory)
.where(eq(priceHistory.itemId, itemId))
.orderBy(priceHistory.recordedAt)
.all();
return history.map(h => ({
timestamp: h.recordedAt?.getTime() || 0,
value: h.divineValue || 0,
volume: h.volume || 0,
}));
}
}