Initial commit
This commit is contained in:
commit
84d38c5173
46 changed files with 6819 additions and 0 deletions
222
packages/api/src/services/trend-analysis.ts
Normal file
222
packages/api/src/services/trend-analysis.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue