222 lines
6.2 KiB
TypeScript
222 lines
6.2 KiB
TypeScript
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,
|
|
}));
|
|
}
|
|
}
|