Initial commit
This commit is contained in:
commit
84d38c5173
46 changed files with 6819 additions and 0 deletions
22
packages/api/package.json
Normal file
22
packages/api/package.json
Normal 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
61
packages/api/src/index.ts
Normal 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();
|
||||
46
packages/api/src/routes/items.ts
Normal file
46
packages/api/src/routes/items.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
8
packages/api/tsconfig.json
Normal file
8
packages/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue