feat: default locale Russian, geo determines language for other countries
- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
15
services/finance-svc/Dockerfile
Normal file
15
services/finance-svc/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3003
|
||||
CMD ["node", "dist/index.js"]
|
||||
21
services/finance-svc/package.json
Normal file
21
services/finance-svc/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "finance-svc",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^4.28.1",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"ioredis": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
419
services/finance-svc/src/index.ts
Normal file
419
services/finance-svc/src/index.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* finance-svc — Market data, heatmap, news по тикерам
|
||||
* docs/architecture: 01-perplexity-analogue-design.md §2.2.C
|
||||
* Redis: finance:summary, finance:heatmap, finance:news:{ticker}
|
||||
*/
|
||||
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3003', 10);
|
||||
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
|
||||
const FMP_API_KEY = process.env.FMP_API_KEY ?? '';
|
||||
|
||||
async function fetchWithRetry(url: string, opts: RequestInit = {}, maxAttempts = 3): Promise<Response> {
|
||||
let lastErr: Error | null = null;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const res = await fetch(url, { ...opts, signal: AbortSignal.timeout(10000) });
|
||||
if (res.ok || res.status < 500) return res;
|
||||
lastErr = new Error(`FMP HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
lastErr = err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
if (i < maxAttempts - 1) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// @ts-expect-error — ioredis + NodeNext ESM constructability
|
||||
const redis = new Redis(REDIS_URL);
|
||||
|
||||
const STUB_SUMMARY = {
|
||||
indices: [
|
||||
{ symbol: 'SPX', name: 'S&P 500', price: 0, change: 0 },
|
||||
{ symbol: 'NDX', name: 'NASDAQ', price: 0, change: 0 },
|
||||
{ symbol: 'VIX', name: 'VIX', price: 0, change: 0 },
|
||||
],
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
|
||||
const STUB_HEATMAP = { sectors: [], updated_at: Date.now() };
|
||||
const STUB_NEWS = { items: [], updated_at: Date.now() };
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
const corsOrigin = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: true;
|
||||
await app.register(cors, { origin: corsOrigin });
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
|
||||
app.get('/metrics', async (_req, reply) => {
|
||||
reply.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
return reply.send(
|
||||
'# HELP gooseek_up Service is up (1) or down (0)\n' +
|
||||
'# TYPE gooseek_up gauge\n' +
|
||||
'gooseek_up 1\n'
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/ready', async () => {
|
||||
try {
|
||||
await redis.ping();
|
||||
return { status: 'ready' };
|
||||
} catch {
|
||||
return { status: 'degraded' };
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/v1/finance/summary', async (req, reply) => {
|
||||
const cached = await redis.get('finance:summary');
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const res = await fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/quote/^GSPC,^IXIC,^VIX?apikey=${FMP_API_KEY}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const payload = {
|
||||
indices: (Array.isArray(data) ? data : []).map((q: { symbol?: string; name?: string; price?: number; changesPercentage?: number }) => ({
|
||||
symbol: q.symbol,
|
||||
name: q.name,
|
||||
price: q.price ?? 0,
|
||||
change: q.changesPercentage ?? 0,
|
||||
})),
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
await redis.setex('finance:summary', 5 * 60, JSON.stringify(payload));
|
||||
return payload;
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
return reply.send(STUB_SUMMARY);
|
||||
});
|
||||
|
||||
app.get('/api/v1/finance/heatmap', async (req, reply) => {
|
||||
const cached = await redis.get('finance:heatmap');
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const res = await fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/sector-performance?apikey=${FMP_API_KEY}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const sectors = (Array.isArray(data) ? data : []).map(
|
||||
(s: { sector?: string; changesPercentage?: number }) => ({
|
||||
name: s.sector ?? '',
|
||||
change: Number(s.changesPercentage) ?? 0,
|
||||
})
|
||||
);
|
||||
const payload = { sectors, updated_at: Date.now() };
|
||||
await redis.setex('finance:heatmap', 5 * 60, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
return reply.send(STUB_HEATMAP);
|
||||
});
|
||||
|
||||
const STUB_MOVERS = { items: [], updated_at: Date.now() };
|
||||
app.get('/api/v1/finance/gainers', async (req, reply) => {
|
||||
const cached = await redis.get('finance:gainers');
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const res = await fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/stock_market/gainers?apikey=${FMP_API_KEY}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const items = (Array.isArray(data) ? data : []).slice(0, 15);
|
||||
const payload = { items, updated_at: Date.now() };
|
||||
await redis.setex('finance:gainers', 2 * 60, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
return reply.send(STUB_MOVERS);
|
||||
});
|
||||
app.get('/api/v1/finance/losers', async (req, reply) => {
|
||||
const cached = await redis.get('finance:losers');
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const res = await fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/stock_market/losers?apikey=${FMP_API_KEY}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const items = (Array.isArray(data) ? data : []).slice(0, 15);
|
||||
const payload = { items, updated_at: Date.now() };
|
||||
await redis.setex('finance:losers', 2 * 60, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
return reply.send(STUB_MOVERS);
|
||||
});
|
||||
app.get('/api/v1/finance/crypto', async (req, reply) => {
|
||||
const cached = await redis.get('finance:crypto');
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const symbols = 'BTCUSD,ETHUSD,BNBUSD,SOLUSD,XRPUSD,ADAUSD,DOGEUSD,AVAXUSD,MATICUSD,LINKUSD';
|
||||
const res = await fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/quote/${symbols}?apikey=${FMP_API_KEY}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const items = (Array.isArray(data) ? data : []).slice(0, 20);
|
||||
const payload = { items, updated_at: Date.now() };
|
||||
await redis.setex('finance:crypto', 2 * 60, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
return reply.send(STUB_MOVERS);
|
||||
});
|
||||
|
||||
app.get<{ Params: { ticker: string } }>('/api/v1/finance/news/:ticker', async (req, reply) => {
|
||||
const { ticker } = req.params;
|
||||
const key = `finance:news:${ticker.toUpperCase()}`;
|
||||
const cached = await redis.get(key);
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
return reply.send(STUB_NEWS);
|
||||
});
|
||||
|
||||
const STUB_QUOTE = { symbol: '', name: '', price: 0, change: 0, updated_at: Date.now() };
|
||||
app.get<{ Params: { ticker: string } }>('/api/v1/finance/quote/:ticker', async (req, reply) => {
|
||||
const ticker = req.params.ticker.toUpperCase();
|
||||
const key = `finance:quote:${ticker}`;
|
||||
const cached = await redis.get(key);
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const res = await fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/quote/${ticker}?apikey=${FMP_API_KEY}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const q = Array.isArray(data) && data[0] ? data[0] : null;
|
||||
const payload = q
|
||||
? {
|
||||
symbol: q.symbol ?? ticker,
|
||||
name: q.name ?? ticker,
|
||||
price: Number(q.price) ?? 0,
|
||||
change: Number(q.changesPercentage) ?? 0,
|
||||
high: Number(q.dayHigh) ?? 0,
|
||||
low: Number(q.dayLow) ?? 0,
|
||||
updated_at: Date.now(),
|
||||
}
|
||||
: { ...STUB_QUOTE, symbol: ticker };
|
||||
await redis.setex(key, 2 * 60, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
return reply.send({ ...STUB_QUOTE, symbol: ticker });
|
||||
});
|
||||
|
||||
const STUB_ANALYST = { ratings: [], symbol: '', updated_at: Date.now() };
|
||||
app.get<{ Params: { ticker: string } }>('/api/v1/finance/analyst-ratings/:ticker', async (req, reply) => {
|
||||
const ticker = req.params.ticker.toUpperCase();
|
||||
const key = `finance:analyst:${ticker}`;
|
||||
const cached = await redis.get(key);
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const res = await fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/analyst-stock-recommendations/${ticker}?apikey=${FMP_API_KEY}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const payload = { ratings: Array.isArray(data) ? data : [], symbol: ticker, updated_at: Date.now() };
|
||||
await redis.setex(key, 24 * 60 * 60, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
return reply.send(STUB_ANALYST);
|
||||
});
|
||||
|
||||
const STUB_SEC = { filings: [], symbol: '', updated_at: Date.now() };
|
||||
app.get<{ Params: { ticker: string } }>('/api/v1/finance/sec-filings/:ticker', async (req, reply) => {
|
||||
const ticker = req.params.ticker.toUpperCase();
|
||||
const key = `finance:sec:${ticker}`;
|
||||
const cached = await redis.get(key);
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const res = await fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/sec_filings/${ticker}?apikey=${FMP_API_KEY}&limit=20`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const payload = { filings: Array.isArray(data) ? data : [], symbol: ticker, updated_at: Date.now() };
|
||||
await redis.setex(key, 60 * 60, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
return reply.send(STUB_SEC);
|
||||
});
|
||||
|
||||
const STUB_ETF = { holdings: [], symbol: '', updated_at: Date.now() };
|
||||
app.get<{ Params: { symbol: string } }>('/api/v1/finance/etf-holdings/:symbol', async (req, reply) => {
|
||||
const symbol = req.params.symbol.toUpperCase();
|
||||
const key = `finance:etf:${symbol}`;
|
||||
const cached = await redis.get(key);
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const res = await fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/etf-holder/${symbol}?apikey=${FMP_API_KEY}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const payload = { holdings: Array.isArray(data) ? data : [], symbol, updated_at: Date.now() };
|
||||
await redis.setex(key, 60 * 60, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
return reply.send(STUB_ETF);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/finance/predictions/:id', async (req, reply) => {
|
||||
const { id } = req.params;
|
||||
return reply.send({
|
||||
id,
|
||||
question: 'Prediction market',
|
||||
outcomes: [],
|
||||
volume: 0,
|
||||
endDate: null,
|
||||
updated_at: Date.now(),
|
||||
_stub: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Heatmap hover: LLM-синтез причины движения цены
|
||||
* docs/architecture: 01-perplexity-analogue-design.md §2.2.C
|
||||
*/
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? '';
|
||||
|
||||
async function synthesizePriceContext(ticker: string, quote: { change: number }, newsHeadlines: string[]): Promise<string | null> {
|
||||
if (!OPENAI_API_KEY) return null;
|
||||
try {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Summarize in 1-2 short sentences why this stock/sector may have moved. Be concise.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Ticker: ${ticker}, today's change: ${quote.change}%. Recent headlines: ${newsHeadlines.slice(0, 5).join('; ') || 'none'}.`,
|
||||
},
|
||||
],
|
||||
max_tokens: 100,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const text = data.choices?.[0]?.message?.content?.trim();
|
||||
return text || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
app.get<{ Params: { ticker: string } }>('/api/v1/finance/price-context/:ticker', async (req, reply) => {
|
||||
const ticker = req.params.ticker.toUpperCase();
|
||||
const key = `finance:pricecontext:${ticker}`;
|
||||
const cached = await redis.get(key);
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
|
||||
let quote = { change: 0 };
|
||||
const newsHeadlines: string[] = [];
|
||||
|
||||
if (FMP_API_KEY) {
|
||||
try {
|
||||
const [quoteRes, newsRes] = await Promise.all([
|
||||
fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/quote/${ticker}?apikey=${FMP_API_KEY}`
|
||||
),
|
||||
fetchWithRetry(
|
||||
`https://financialmodelingprep.com/api/v3/stock_news?tickers=${ticker}&limit=5&apikey=${FMP_API_KEY}`
|
||||
),
|
||||
]);
|
||||
if (quoteRes.ok) {
|
||||
const qData = await quoteRes.json();
|
||||
const q = Array.isArray(qData) && qData[0] ? qData[0] : null;
|
||||
quote = { change: Number(q?.changesPercentage) ?? 0 };
|
||||
}
|
||||
if (newsRes.ok) {
|
||||
const nData = await newsRes.json();
|
||||
const items = Array.isArray(nData) ? nData : [];
|
||||
newsHeadlines.push(...items.slice(0, 5).map((n: { title?: string }) => String(n.title ?? '')));
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(err);
|
||||
}
|
||||
}
|
||||
|
||||
let summary: string | null = null;
|
||||
if (OPENAI_API_KEY && (quote.change !== 0 || newsHeadlines.length > 0)) {
|
||||
summary = await synthesizePriceContext(ticker, quote, newsHeadlines);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
ticker,
|
||||
quote: { change: quote.change },
|
||||
news: newsHeadlines.filter(Boolean),
|
||||
summary,
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
await redis.setex(key, 5 * 60, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: PORT, host: '0.0.0.0' });
|
||||
console.log(`finance-svc listening on :${PORT}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
13
services/finance-svc/tsconfig.json
Normal file
13
services/finance-svc/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user