fix: handle quote fallbacks safely
This commit is contained in:
1 parent
58a4663aaf
commit
62ac3c387d
4 files changed
+21
-9
No files matched your search
@@ -4,6 +4,9 @@ import { getMoexQuote } from './moex';
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/securities/RU0000000002.json')) return new Response('{}', { status: 404 });
|
||||
if (url.includes('/securities.json') && url.includes('RU0000000002')) return new Response(JSON.stringify({ securities: { columns: ['secid', 'isin'], data: [['TEST2', 'RU0000000002']] } }), { status: 200 });
|
||||
if (url.includes('/securities/TEST2.json') && url.includes('iss.only=boards')) return new Response(JSON.stringify({ boards: { columns: ['engine', 'market', 'boardid', 'secid'], data: [['stock', 'shares', 'TQBR', 'TEST2']] } }), { status: 200 });
|
||||
if (url.includes('/securities/RU0000000001.json')) return new Response(JSON.stringify({ boards: { columns: ['engine', 'market', 'boardid', 'secid'], data: [['stock', 'shares', 'TQBR', 'TEST']] } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ marketdata: { columns: ['LAST', 'LCURRENTPRICE', 'WAPRICE', 'UPDATETIME'], data: [[123.45, null, null, '2026-08-28 12:00:00']] } }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
@@ -11,6 +14,7 @@ globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
async function run(): Promise<void> {
|
||||
const quote = await getMoexQuote('RU0000000001');
|
||||
assert.deepEqual(quote, { price: 123.45, at: '2026-08-28 12:00:00' });
|
||||
assert.deepEqual(await getMoexQuote('RU0000000002'), { price: 123.45, at: '2026-08-28 12:00:00' });
|
||||
assert.equal(await getMoexQuote('bad-isin'), null);
|
||||
globalThis.fetch = originalFetch;
|
||||
console.log('MOEX quote: OK');
|
||||
|
||||
@@ -24,7 +24,10 @@ function numberValue(value: unknown): number | null {
|
||||
export async function getMoexQuote(isin: string): Promise<MarketQuote | null> {
|
||||
if (!/^RU[A-Z0-9]{10}$/.test(isin)) return null;
|
||||
try {
|
||||
let info = await getJson(`${BASE_URL}/securities/${encodeURIComponent(isin)}.json?iss.meta=off&iss.only=boards&boards.columns=engine,market,boardid,secid`);
|
||||
let info: Record<string, IssBlock> = {};
|
||||
try {
|
||||
info = await getJson(`${BASE_URL}/securities/${encodeURIComponent(isin)}.json?iss.meta=off&iss.only=boards&boards.columns=engine,market,boardid,secid`);
|
||||
} catch { /* search by ISIN below */ }
|
||||
let boards = rows(info.boards);
|
||||
if (boards.length === 0) {
|
||||
const search = await getJson(`${BASE_URL}/securities.json?q=${encodeURIComponent(isin)}&iss.meta=off&iss.only=securities&securities.columns=secid,isin`);
|
||||
|
||||
@@ -44,6 +44,11 @@ export function classifyPortfolioCash(amount: number, description: string): Port
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function sumKnownPortfolioValuations(values: Array<number | null>): number | null {
|
||||
const known = values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
return known.length > 0 ? known.reduce((sum, value) => sum + value, 0) : null;
|
||||
}
|
||||
|
||||
function decimalValue(value: unknown, field: string, required = false): string | null {
|
||||
if (value == null || value === '') {
|
||||
if (required) throw new Error(`${field} is required`);
|
||||
@@ -193,16 +198,15 @@ export async function getPortfolioOverview(): Promise<PortfolioOverviewResponse>
|
||||
const overview = toPortfolioOverview(rows);
|
||||
const quoteCache = new Map<string, Awaited<ReturnType<typeof getMoexQuote>>>();
|
||||
for (const account of overview.accounts) {
|
||||
let currentValuation = 0;
|
||||
let hasValue = false;
|
||||
const currentValuations: Array<number | null> = [];
|
||||
let hasMarket = false;
|
||||
let hasFallback = false;
|
||||
let valuationAsOf: string | null = null;
|
||||
for (const position of account.positions) {
|
||||
if (!position.isin) { hasFallback = true; if (position.valuation !== null) currentValuation += Number(position.valuation); hasValue = position.valuation !== null; continue; }
|
||||
if (!position.isin) { hasFallback = true; currentValuations.push(position.valuation === null ? null : Number(position.valuation)); continue; }
|
||||
if (!quoteCache.has(position.isin)) quoteCache.set(position.isin, await getMoexQuote(position.isin));
|
||||
const quote = quoteCache.get(position.isin);
|
||||
if (!quote) { hasFallback = true; if (position.valuation !== null) currentValuation += Number(position.valuation); hasValue = position.valuation !== null; continue; }
|
||||
if (!quote) { hasFallback = true; currentValuations.push(position.valuation === null ? null : Number(position.valuation)); continue; }
|
||||
const reportPrice = position.price === null ? null : Number(position.price);
|
||||
const reportValuation = position.valuation === null ? null : Number(position.valuation);
|
||||
const currentValuationForPosition = reportPrice && reportValuation !== null
|
||||
@@ -213,11 +217,11 @@ export async function getPortfolioOverview(): Promise<PortfolioOverviewResponse>
|
||||
position.currentValuation = String(currentValuationForPosition);
|
||||
position.quoteAt = quote.at;
|
||||
hasMarket = true;
|
||||
currentValuation += currentValuationForPosition;
|
||||
hasValue = true;
|
||||
currentValuations.push(currentValuationForPosition);
|
||||
if (quote.at && (!valuationAsOf || quote.at > valuationAsOf)) valuationAsOf = quote.at;
|
||||
}
|
||||
if (hasValue) {
|
||||
const currentValuation = sumKnownPortfolioValuations(currentValuations);
|
||||
if (currentValuation !== null) {
|
||||
account.currentValuation = String(currentValuation);
|
||||
account.valuationAsOf = valuationAsOf;
|
||||
account.valuationSource = hasMarket ? (hasFallback ? 'mixed' : 'market') : 'report';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { calculatePortfolioTradeResults, classifyPortfolioCash, toPortfolioHistory, toPortfolioOverview } from './portfolio';
|
||||
import { calculatePortfolioTradeResults, classifyPortfolioCash, sumKnownPortfolioValuations, toPortfolioHistory, toPortfolioOverview } from './portfolio';
|
||||
|
||||
const result = toPortfolioOverview([
|
||||
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Облигация', isin: 'RU0000000001', quantity: '1', price: '100', valuation: '100' },
|
||||
@@ -29,4 +29,5 @@ console.log('portfolio performance: OK');
|
||||
|
||||
assert.equal(classifyPortfolioCash(-12500, 'Перечисление денежных средств со счёта'), 'withdrawal');
|
||||
assert.equal(classifyPortfolioCash(-12500, 'Перевод денежных средств под нерассчитанные сделки'), 'other');
|
||||
assert.equal(sumKnownPortfolioValuations([100, null, 25]), 125);
|
||||
console.log('portfolio cash classification: OK');
|
||||
Reference in new issue
Block a user