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:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View 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"]

View 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"
}
}

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

View 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"]
}