refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,46 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type {
TbankCandle,
TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api';
export function timestamp(value?: TbankTimestamp): string | null {
const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0
? new Date(seconds * 1000).toISOString()
: null;
}
export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10);
}
export function previousClose(
candles: TbankCandle[],
priceAt: string,
): TbankCandle | null {
return (
candles
.filter((candle) => {
const time = timestamp(candle.time);
return (
candle.isComplete &&
candle.close &&
time &&
time.slice(0, 10) < moscowDay(priceAt)
);
})
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
null
);
}
export async function optional<T>(
operation: () => Promise<T>,
name: string,
unavailable: string[],
): Promise<T | null> {
try {
return await operation();
} catch (error) {
if (!(error instanceof ServiceUnavailableException)) throw error;
unavailable.push(name);
return null;
}
}
@@ -0,0 +1,109 @@
import {
add,
decimalFromString,
divide,
formatDecimal,
multiply,
} from '../../../../common/decimal';
import { MoneyDto } from '../../../../common/money.dto';
import {
PortfolioDto,
PortfolioPositionDto,
} from '../../portfolio/portfolio.api';
import { StockPositionDto } from '../stocks/presentation/stock.dto';
export function instrumentPosition(
portfolio: PortfolioDto,
uid: string,
type: 'STOCK' | 'BOND',
): StockPositionDto {
const positions = portfolio.positions.filter(
(p) => p.type === type && p.instrumentUid === uid,
);
const zero = decimalFromString('0');
const quantity = positions
.map((p) => decimalFromString(p.quantity))
.reduce(add, zero);
const costs = positions.map(cost);
const totalCost = sumComplete(costs);
const currentValue = sumComplete(positions.map((p) => p.currentValue));
const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn));
const positive =
positions.length > 0 &&
positions.every((p) => decimalFromString(p.quantity).value > 0n);
return {
quantity: formatDecimal(quantity),
currentValue,
absoluteReturn,
averagePurchasePrice:
positive && totalCost
? {
currency: totalCost.currency,
value: formatDecimal(
divide(decimalFromString(totalCost.value), quantity, 9),
),
}
: null,
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
accounts: positions.map((p) => ({
...p,
returnPercent:
decimalFromString(p.quantity).value > 0n
? percent(p.absoluteReturn, cost(p))
: null,
})),
isStale: portfolio.isStale,
observedAt: portfolio.lastSuccessfulAt,
};
}
function cost(position: PortfolioPositionDto): MoneyDto | null {
const price = position.averagePurchasePrice;
return price
? {
currency: price.currency,
value: formatDecimal(
multiply(
decimalFromString(price.value),
decimalFromString(position.quantity),
),
),
}
: null;
}
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency;
if (
!currency ||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
)
return null;
return {
currency,
value: formatDecimal(
values.reduce(
(sum, value) => add(sum, decimalFromString(value.value)),
decimalFromString('0'),
),
),
};
}
function percent(
result: MoneyDto | null,
base: MoneyDto | null,
): string | null {
if (
!base ||
!result ||
base.currency !== result.currency ||
decimalFromString(base.value).value <= 0n
)
return null;
return formatDecimal(
divide(
multiply(decimalFromString(result.value), decimalFromString('100')),
decimalFromString(base.value),
6,
),
);
}