@@ -30,17 +30,22 @@ type PortfolioHistoryRow = {
total_valuation : string | number | null ;
} ;
type PerformanceCashRow = { id : number | string ; account_id : number | string ; alias : string | null ; bank : string ; account_number : string ; account_type : string | null ; operation_at : string ; amount_signed : number | string ; description : string } ;
export type PerformanceCashRow = { id : number | string ; account_id : number | string ; alias : string | null ; bank : string ; account_number : string ; account_type : string | null ; operation_at : string ; amount_signed : number | string ; description : string } ;
type PerformanceTradeRow = { account_id : number | string ; side : string ; quantity : string | number ; settlement_amount : string | number | null ; settlement_commission : string | number | null ; trade_commission : string | number | null ; isin : string | null ; instrument : string } ;
type PerformancePositionRow = { account_id : number | string ; quantity : string | number ; valuation : string | number | null ; isin : string | null ; instrument : string } ;
export type PortfolioCashKind = 'income' | 'contribution' | 'withdrawal' | 'other' ;
export function extractBrokerWithdrawalAccount ( description : string ) : string | null {
return description . toLowerCase ( ) . replace ( /ё/g , 'е ' ) . match ( /вывод денежных средств с брокерского счета\s+([0-9]+)/ ) ? . [ 1 ] ? ? null ;
}
export function classifyPortfolioCash ( amount : number , description : string , matchedExternalTransfer = false ) : PortfolioCashKind {
const text = description . toLowerCase ( ) . replace ( /ё/g , 'е ' ) ;
if ( amount > 0 && /дивиденд|купон|процент/ . test ( text ) ) return 'income' ;
if ( amount > 0 && ( matchedExternalTransfer || /пополнение/ . test ( text ) ) && ! text . includes ( 'под нерассчитанные сделки' ) ) return 'contribution' ;
if ( amount < 0 && /вывод|перевод денежных средств|перечисление денежных средств|возврат денежных средств/ . test ( text ) && ( ! text . includes ( 'под нерассчитанные сделки' ) || matchedExternalTransfer ) ) return 'withdrawal' ;
if ( amount < 0 && matchedExternalTransfer ) return 'withdrawal' ;
if ( amount < 0 && /вывод|перевод денежных средств|перечисление денежных средств|возврат денежных средств/ . test ( text ) && ! text . includes ( 'под нерассчитанные сделки' ) ) return 'withdrawal' ;
return 'other' ;
}
@@ -49,6 +54,31 @@ export function sumKnownPortfolioValuations(values: Array<number | null>): numbe
return known . length > 0 ? known . reduce ( ( sum , value ) = > sum + value , 0 ) : null ;
}
export function matchExternalCashTransfers ( rows : PerformanceCashRow [ ] ) : Set < number | string > {
const broker = rows . filter ( ( row ) = > [ 'brokerage' , 'iis' ] . includes ( row . account_type ? ? '' ) ) ;
const external = rows . filter ( ( row ) = > ! [ 'brokerage' , 'iis' ] . includes ( row . account_type ? ? '' ) ) ;
const matchedBroker = new Set < number | string > ( ) ;
const matchedExternal = new Set < number | string > ( ) ;
for ( const target of external ) {
const targetAmount = Math . round ( Number ( target . amount_signed ) ) ;
if ( ! targetAmount || matchedExternal . has ( target . id ) ) continue ;
const candidates = broker . filter ( ( row ) = > ! matchedBroker . has ( row . id ) && Math . sign ( Number ( row . amount_signed ) ) === - Math . sign ( targetAmount ) && Math . abs ( Math . round ( Number ( row . amount_signed ) ) ) <= Math . abs ( targetAmount ) && Math . abs ( Date . parse ( row . operation_at ) - Date . parse ( target . operation_at ) ) <= 3 * 24 * 60 * 60 * 1000 ) ;
const exact = candidates . find ( ( row ) = > Math . round ( Number ( row . amount_signed ) ) === - targetAmount ) ;
let group : PerformanceCashRow [ ] = exact ? [ exact ] : [ ] ;
if ( group . length === 0 ) {
for ( let index = 0 ; index < candidates . length && group . length === 0 ; index += 1 ) {
for ( const second of candidates . slice ( index + 1 ) ) {
if ( Math . round ( Number ( candidates [ index ] . amount_signed ) ) + Math . round ( Number ( second . amount_signed ) ) === - targetAmount ) { group = [ candidates [ index ] , second ] ; break ; }
}
}
}
if ( group . length === 0 ) continue ;
matchedExternal . add ( target . id ) ;
group . forEach ( ( row ) = > matchedBroker . add ( row . id ) ) ;
}
return matchedBroker ;
}
function decimalValue ( value : unknown , field : string , required = false ) : string | null {
if ( value == null || value === '' ) {
if ( required ) throw new Error ( ` ${ field } is required ` ) ;
@@ -335,24 +365,27 @@ export async function getPortfolioPerformance(): Promise<PortfolioPerformanceRes
] ) ;
const tradeMap = calculatePortfolioTradeResults ( tradeResult . rows , positionResult . rows ) ;
const accounts = new Map < number , PortfolioPerformanceResponse [ 'accounts' ] [ number ] > ( ) ;
const matchedExternalTransfers = new Set < number | string > ( ) ;
const externalCash = cashResult . rows . filter ( ( row ) = > ! [ 'brokerage' , 'iis' ] . includes ( row . account_type ? ? '' ) ) ;
const hasExternalTransfer = ( row : PerformanceCashRow ) : boolean = > {
const target = - Number ( row . amount_signed ) ;
const at = Date . parse ( row . operation_at ) ;
const match = externalCash . find ( ( candidate ) = > ! matchedExternalTransfers . has ( candidate . id ) && Number ( candidate . amount_signed ) === target && Math . abs ( Date . parse ( candidate . operation_at ) - at ) <= 3 * 24 * 60 * 60 * 1000 && /перевод|перечисление|счет|сч[её]т/ . test ( candidate . description . toLowerCase ( ) ) ) ;
if ( ! match ) return false ;
matchedExternalTransfers . add ( match . id ) ;
return true ;
} ;
for ( const row of cashResult . rows . filter ( ( item ) = > [ 'brokerage' , 'iis' ] . includes ( item . account_type ? ? '' ) ) ) {
const brokerRows = cashResult . rows . filter ( ( item ) = > [ 'brokerage' , 'iis' ] . includes ( item . account_type ? ? '' ) ) ;
const matchedBrokerTransfers = matchExternalCashTransfers ( cashResult . rows ) ;
const accountByReference = new Map ( b rokerRows . map ( ( row ) = > [ Number ( row . account_id ) , row . account_number . replace ( /\D/g , '' ) ] ) ) ;
for ( const row of brokerRows ) {
const accountId = Number ( row . account_id ) ;
const account = accounts . get ( accountId ) ? ? { accountId , accountName : row.alias || ` ${ row . bank } · ${ maskAccountNumber ( row . account_number ) } ` , contributions : 0 , withdrawals : 0 , income : 0 , fees : 0 , realizedResult : 0 , unrealizedResult : null } ;
const amount = Number ( row . amount_signed ) ;
const kind = classifyPortfolioCash ( amount , row . description , hasExternalTransfer ( row ) ) ;
const kind = classifyPortfolioCash ( amount , row . description , amount > 0 && matchedBrokerTransfers . has ( row . id )) ;
if ( kind === 'income' ) account . income += amount ;
else if ( kind === 'contribution' ) account . contributions += amount ;
else if ( kind === 'withdrawal' ) account . withdrawals += - am ount;
accounts . set ( accountId , acc ount) ;
}
for ( const row of cashResult . rows . filter ( ( item ) = > ! [ 'brokerage' , 'iis' ] . includes ( item . account_type ? ? '' ) && Number ( item . amount_signed ) > 0 ) ) {
const reference = extractBrokerWithdrawalAccount ( row . description ) ;
if ( ! reference ) continue ;
const accountEntry = [ . . . accountByReference . entries ( ) ] . find ( ( [ , accountNumber ] ) = > accountNumber . endsWith ( reference ) || reference . endsWith ( accountNumber ) ) ;
if ( ! accountEntry ) continue ;
const accountId = accountEntry [ 0 ] ;
const source = brokerRows . find ( ( item ) = > Number ( item . account_id ) === accountId ) ;
const account = accounts . get ( accountId ) ? ? { accountId , accountName : source?.alias || ` ${ source ? . bank ? ? 'Брокерский счёт' } · ${ maskAccountNumber ( source ? . account_number ? ? reference ) } ` , contributions : 0 , withdrawals : 0 , income : 0 , fees : 0 , realizedResult : 0 , unrealizedResult : null } ;
account . withdrawals += Number ( row . amount_signed ) ;
accounts . set ( accountId , account ) ;
}
for ( const [ accountId , values ] of tradeMap ) {