|
|
|
@@ -4,34 +4,48 @@ import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@fa
|
|
|
|
|
|
|
|
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
|
|
|
|
|
|
|
|
function numberValue(value: unknown, field: string, required = false): number | null {
|
|
|
|
function decimalValue(value: unknown, field: string, required = false): string | null {
|
|
|
|
if (value == null || value === '') {
|
|
|
|
if (value == null || value === '') {
|
|
|
|
if (required) throw new Error(`${field} is required`);
|
|
|
|
if (required) throw new Error(`${field} is required`);
|
|
|
|
return null;
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const result = typeof value === 'number' ? value : Number(String(value).replace(/\s/g, '').replace(',', '.'));
|
|
|
|
const result = String(value).replace(/\s/g, '').replace(',', '.');
|
|
|
|
if (!Number.isFinite(result)) throw new Error(`${field} must be numeric`);
|
|
|
|
if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(result)) throw new Error(`${field} must be numeric`);
|
|
|
|
return result;
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function validate(data: PortfolioFile): void {
|
|
|
|
export function deriveTradeSourceId(trade: PortfolioTrade): string {
|
|
|
|
if (data.schemaVersion !== 'broker-portfolio-1.0' || !data.bank || !data.accountNumber) throw new Error('Invalid portfolio file');
|
|
|
|
if (trade.sourceId?.trim()) return trade.sourceId.trim();
|
|
|
|
if (!data.reportPeriod?.from || !data.reportPeriod?.to || Number.isNaN(Date.parse(data.reportPeriod.from)) || Number.isNaN(Date.parse(data.reportPeriod.to))) throw new Error('Invalid report period');
|
|
|
|
return [trade.isin || trade.instrument, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), decimalValue(trade.settlementAmount, 'settlementAmount') || ''].join('|');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function validatePortfolio(body: unknown): asserts body is PortfolioFile {
|
|
|
|
|
|
|
|
if (!body || typeof body !== 'object') throw new Error('Portfolio file must be an object');
|
|
|
|
|
|
|
|
const data = body as PortfolioFile;
|
|
|
|
|
|
|
|
if (data.schemaVersion !== 'broker-portfolio-1.0' || typeof data.bank !== 'string' || !data.bank.trim() || typeof data.accountNumber !== 'string' || !data.accountNumber.trim()) throw new Error('Invalid portfolio file');
|
|
|
|
|
|
|
|
if (!data.reportPeriod?.from || !data.reportPeriod?.to || Number.isNaN(Date.parse(data.reportPeriod.from)) || Number.isNaN(Date.parse(data.reportPeriod.to)) || data.reportPeriod.from > data.reportPeriod.to) throw new Error('Invalid report period');
|
|
|
|
|
|
|
|
if (data.reportedAt !== null && (typeof data.reportedAt !== 'string' || Number.isNaN(Date.parse(data.reportedAt)))) throw new Error('Invalid reportedAt');
|
|
|
|
if (!Array.isArray(data.positions) || !Array.isArray(data.trades)) throw new Error('positions and trades must be arrays');
|
|
|
|
if (!Array.isArray(data.positions) || !Array.isArray(data.trades)) throw new Error('positions and trades must be arrays');
|
|
|
|
const sourceIds = new Set<string>();
|
|
|
|
const sourceIds = new Set<string>();
|
|
|
|
for (const trade of data.trades) {
|
|
|
|
for (const trade of data.trades) {
|
|
|
|
if (!trade.sourceId || !trade.instrument || !trade.concludedAt || !trade.side) throw new Error('Invalid trade');
|
|
|
|
if (!trade || typeof trade.instrument !== 'string' || !trade.instrument.trim() || typeof trade.concludedAt !== 'string' || Number.isNaN(Date.parse(trade.concludedAt)) || typeof trade.side !== 'string' || !trade.side.trim()) throw new Error('Invalid trade');
|
|
|
|
if (sourceIds.has(trade.sourceId)) throw new Error(`Duplicate sourceId: ${trade.sourceId}`);
|
|
|
|
const sourceId = deriveTradeSourceId(trade);
|
|
|
|
sourceIds.add(trade.sourceId);
|
|
|
|
if (sourceIds.has(sourceId)) throw new Error(`Duplicate sourceId: ${sourceId}`);
|
|
|
|
|
|
|
|
sourceIds.add(sourceId);
|
|
|
|
if (trade.operationId && !UUID_RE.test(trade.operationId)) throw new Error(`Invalid operationId: ${trade.sourceId}`);
|
|
|
|
if (trade.operationId && !UUID_RE.test(trade.operationId)) throw new Error(`Invalid operationId: ${trade.sourceId}`);
|
|
|
|
numberValue(trade.quantity, 'quantity', true);
|
|
|
|
decimalValue(trade.quantity, 'quantity', true);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const position of data.positions) {
|
|
|
|
|
|
|
|
if (!position || typeof position.instrument !== 'string' || !position.instrument.trim()) throw new Error('Invalid position');
|
|
|
|
|
|
|
|
decimalValue(position.quantity, 'position.quantity', true);
|
|
|
|
|
|
|
|
decimalValue(position.price, 'position.price');
|
|
|
|
|
|
|
|
decimalValue(position.valuation, 'position.valuation');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (const position of data.positions) numberValue(position.quantity, 'position.quantity', true);
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function importPortfolio(body: unknown): Promise<ImportPortfolioResponse> {
|
|
|
|
export async function importPortfolio(body: unknown): Promise<ImportPortfolioResponse> {
|
|
|
|
const data = body as PortfolioFile;
|
|
|
|
validatePortfolio(body);
|
|
|
|
validate(data);
|
|
|
|
const data = body;
|
|
|
|
const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
|
|
|
const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
|
|
|
const client = await pool.connect();
|
|
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
@@ -61,17 +75,18 @@ export async function importPortfolio(body: unknown): Promise<ImportPortfolioRes
|
|
|
|
await client.query(
|
|
|
|
await client.query(
|
|
|
|
`INSERT INTO portfolio_positions (report_id, instrument, isin, quantity, price, valuation)
|
|
|
|
`INSERT INTO portfolio_positions (report_id, instrument, isin, quantity, price, valuation)
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
|
|
[reportId, position.instrument, position.isin ?? null, numberValue(position.quantity, 'position.quantity', true), numberValue(position.price, 'position.price'), numberValue(position.valuation, 'position.valuation')],
|
|
|
|
[reportId, position.instrument, position.isin ?? null, decimalValue(position.quantity, 'position.quantity', true), decimalValue(position.price, 'position.price'), decimalValue(position.valuation, 'position.valuation')],
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
let importedTrades = 0;
|
|
|
|
let importedTrades = 0;
|
|
|
|
for (const trade of data.trades) {
|
|
|
|
for (const trade of data.trades) {
|
|
|
|
|
|
|
|
const sourceId = deriveTradeSourceId(trade);
|
|
|
|
const result = await client.query(
|
|
|
|
const result = await client.query(
|
|
|
|
`INSERT INTO portfolio_trades
|
|
|
|
`INSERT INTO portfolio_trades
|
|
|
|
(account_id, source_id, operation_id, instrument, isin, concluded_at, side, quantity, price_currency, price, settlement_currency, settlement_amount, nkd, settlement_commission, trade_commission, order_id, trade_id, venue, comment)
|
|
|
|
(account_id, source_id, operation_id, instrument, isin, concluded_at, side, quantity, price_currency, price, settlement_currency, settlement_amount, nkd, settlement_commission, trade_commission, order_id, trade_id, venue, comment)
|
|
|
|
VALUES ($1, $2, COALESCE($3::uuid, gen_random_uuid()), $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
|
|
|
VALUES ($1, $2, COALESCE($3::uuid, gen_random_uuid()), $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
|
|
|
ON CONFLICT (account_id, source_id) DO NOTHING`,
|
|
|
|
ON CONFLICT (account_id, source_id) DO NOTHING`,
|
|
|
|
[accountId, trade.sourceId, trade.operationId ?? null, trade.instrument, trade.isin ?? null, trade.concludedAt, trade.side, numberValue(trade.quantity, 'quantity', true), trade.priceCurrency ?? null, numberValue(trade.price, 'price'), trade.settlementCurrency ?? null, numberValue(trade.settlementAmount, 'settlementAmount'), numberValue(trade.nkd, 'nkd'), numberValue(trade.settlementCommission, 'settlementCommission'), numberValue(trade.tradeCommission, 'tradeCommission'), trade.orderId ?? null, trade.tradeId ?? null, trade.venue ?? null, trade.comment ?? null],
|
|
|
|
[accountId, sourceId, trade.operationId ?? null, trade.instrument, trade.isin ?? null, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), trade.priceCurrency ?? null, decimalValue(trade.price, 'price'), trade.settlementCurrency ?? null, decimalValue(trade.settlementAmount, 'settlementAmount'), decimalValue(trade.nkd, 'nkd'), decimalValue(trade.settlementCommission, 'settlementCommission'), decimalValue(trade.tradeCommission, 'tradeCommission'), trade.orderId ?? null, trade.tradeId ?? null, trade.venue ?? null, trade.comment ?? null],
|
|
|
|
);
|
|
|
|
);
|
|
|
|
importedTrades += result.rowCount ?? 0;
|
|
|
|
importedTrades += result.rowCount ?? 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|