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

22
packages/api/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "@poe2data/api",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"@poe2data/shared": "workspace:*",
"@poe2data/database": "workspace:*",
"fastify": "^5.0.0",
"@fastify/cors": "^10.0.0",
"drizzle-orm": "^0.38.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

61
packages/api/src/index.ts Normal file
View file

@ -0,0 +1,61 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { itemRoutes } from './routes/items.js';
import { getDatabase, closeDatabase } from '@poe2data/database';
const PORT = parseInt(process.env.API_PORT || '3001', 10);
const HOST = process.env.API_HOST || '0.0.0.0';
async function main() {
const fastify = Fastify({
logger: true,
});
// Register CORS
await fastify.register(cors, {
origin: true,
});
// Initialize database
getDatabase();
// Register routes
await fastify.register(itemRoutes, { prefix: '/api/v1' });
// Health check
fastify.get('/health', async () => ({ status: 'ok' }));
// Root endpoint
fastify.get('/', async () => ({
name: 'POE2 Data API',
version: '1.0.0',
endpoints: [
'GET /api/v1/items',
'GET /api/v1/items/:id/history',
'GET /api/v1/trends/movers',
'GET /api/v1/categories',
'GET /api/v1/league-start/priorities',
],
}));
// Graceful shutdown
const shutdown = async () => {
console.log('Shutting down...');
await closeDatabase();
await fastify.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
try {
await fastify.listen({ port: PORT, host: HOST });
console.log(`API server running at http://localhost:${PORT}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
}
main();

View file

@ -0,0 +1,46 @@
import { FastifyInstance } from 'fastify';
import { TrendAnalysisService } from '../services/trend-analysis.js';
export async function itemRoutes(fastify: FastifyInstance) {
const trendService = new TrendAnalysisService();
// Get all items (optionally filtered by category)
fastify.get('/items', async (request, reply) => {
const { category } = request.query as { category?: string };
const items = await trendService.getAllItems(category);
return { items, count: items.length };
});
// Get item price history
fastify.get('/items/:id/history', async (request, reply) => {
const { id } = request.params as { id: string };
const history = await trendService.getItemHistory(parseInt(id, 10));
return { history };
});
// Get top movers
fastify.get('/trends/movers', async (request, reply) => {
const { limit } = request.query as { limit?: string };
const movers = await trendService.getTopMovers(parseInt(limit || '20', 10));
return movers;
});
// Get category summaries
fastify.get('/categories', async (request, reply) => {
const summaries = await trendService.getCategorySummaries();
return { categories: summaries };
});
// Get league start priorities
fastify.get('/league-start/priorities', async (request, reply) => {
const priorities = await trendService.getLeagueStartPriorities();
return {
priorities,
summary: {
high: priorities.filter(p => p.priority === 'high').length,
medium: priorities.filter(p => p.priority === 'medium').length,
low: priorities.filter(p => p.priority === 'low').length,
},
};
});
}

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,
}));
}
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}