Compare commits
3
Commits
3ce67a59a5
...
c809102d73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c809102d73 | ||
|
|
187af391d0 | ||
|
|
7242240b21 |
@@ -13,6 +13,9 @@
|
||||
!public/**
|
||||
!docker/
|
||||
!docker/**
|
||||
!scripts/
|
||||
!scripts/minimatch-compat/
|
||||
!scripts/minimatch-compat/**
|
||||
!packages/
|
||||
!packages/tokens/
|
||||
!packages/tokens/package.json
|
||||
|
||||
@@ -5,6 +5,7 @@ COPY package.json package-lock.json ./
|
||||
COPY packages/tokens/package.json ./packages/tokens/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/icons/package.json ./packages/icons/
|
||||
COPY scripts/minimatch-compat ./scripts/minimatch-compat
|
||||
RUN npm ci
|
||||
|
||||
FROM dependencies AS development
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"ignoreDependencies": ["conventional-changelog-conventionalcommits"],
|
||||
"ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"],
|
||||
"workspaces": {
|
||||
".": {
|
||||
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
|
||||
|
||||
Generated
+40
-136
@@ -80,6 +80,7 @@
|
||||
"jsdom": "30.1.3",
|
||||
"knip": "6.43.0",
|
||||
"lint-staged": "17.6.0",
|
||||
"minimatch": "file:scripts/minimatch-compat",
|
||||
"msw": "2.15.0",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"playwright": "1.64.1",
|
||||
@@ -88,7 +89,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-pwa": "2.0.0",
|
||||
"vitest": "5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2511,14 +2512,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "9.0.0",
|
||||
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
||||
"node_modules/@isaacs/nonrecursive-glob-match": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
|
||||
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript": {
|
||||
"version": "0.7.0",
|
||||
@@ -3720,33 +3719,6 @@
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.1.7",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
@@ -6448,6 +6420,7 @@
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -6495,6 +6468,7 @@
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -9097,33 +9071,6 @@
|
||||
"minimatch": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.1.7",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
@@ -9194,22 +9141,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/formatly": {
|
||||
"version": "0.11.4",
|
||||
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.11.4.tgz",
|
||||
@@ -9488,14 +9419,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz",
|
||||
"integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minimatch": "^11.0.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
"path-scurry": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -10670,21 +10602,6 @@
|
||||
"node": "^18.17 || >=20.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "4.2.3",
|
||||
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/jake": {
|
||||
"version": "10.9.4",
|
||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||
@@ -11473,12 +11390,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"resolved": "scripts/minimatch-compat",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/minimatch-11": {
|
||||
"name": "minimatch",
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.2.tgz",
|
||||
"integrity": "sha512-pWTgz5CN3tjqyZNSK2tePlSmu9IwM41q8yn8Usdqqwe9TIHh+9dEpLLjF4uApsfPEAZY71qtSiA2B2APEr+1Yw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
"@isaacs/nonrecursive-glob-match": "^1.0.2",
|
||||
"brace-expansion": "^5.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -12212,12 +12136,6 @@
|
||||
"quickjs-wasi": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/package-manager-detector": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz",
|
||||
@@ -12314,8 +12232,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz",
|
||||
"integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -15929,8 +15848,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-pwa": {
|
||||
"version": "1.3.0",
|
||||
"integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-2.0.0.tgz",
|
||||
"integrity": "sha512-6G9dsPKT9QKYpeTuTLJM28zZL3HsXUTUM/QUZBEgQk2jbfrPJDq2i8gTewAKqi1c9Mt6MiidrL4SFI+HthiQMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15941,13 +15861,13 @@
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vite-pwa/assets-generator": "^1.0.0",
|
||||
"@vite-pwa/assets-generator": "^1.0.0 || ^2.0.0",
|
||||
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1"
|
||||
@@ -17642,30 +17562,6 @@
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/glob": {
|
||||
"version": "11.1.0",
|
||||
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.3.1",
|
||||
"jackspeak": "^4.1.1",
|
||||
"minimatch": "^10.1.1",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
@@ -18076,6 +17972,14 @@
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
}
|
||||
},
|
||||
"scripts/minimatch-compat": {
|
||||
"name": "minimatch",
|
||||
"version": "11.0.2-compat.0",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"minimatch-11": "npm:minimatch@11.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -106,6 +106,7 @@
|
||||
"jsdom": "30.1.3",
|
||||
"knip": "6.43.0",
|
||||
"lint-staged": "17.6.0",
|
||||
"minimatch": "file:scripts/minimatch-compat",
|
||||
"msw": "2.15.0",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"playwright": "1.64.1",
|
||||
@@ -114,7 +115,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-pwa": "2.0.0",
|
||||
"vitest": "5.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -124,7 +125,9 @@
|
||||
},
|
||||
"@vitest/browser": "5.0.5",
|
||||
"@vitest/ui": "5.0.5",
|
||||
"@vitest/mocker": "5.0.5"
|
||||
"@vitest/mocker": "5.0.5",
|
||||
"minimatch": "$minimatch",
|
||||
"glob": "14.0.3"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// ponytail: CommonJS twin of index.js (import-x ships lib/index.cjs too).
|
||||
const minimatch = require("minimatch-11");
|
||||
|
||||
module.exports = {
|
||||
...minimatch,
|
||||
makeRe: (pattern, options = {}) => ({
|
||||
test: path => minimatch.minimatch(path, pattern, options),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
// ponytail: shim for eslint-plugin-import-x@4.17.1, which still imports the
|
||||
// makeRe export removed in minimatch 11. Its only caller (no-internal-modules)
|
||||
// uses the result via .test(), so a matcher object is enough. Delete this
|
||||
// package and the override once import-x supports minimatch 11.
|
||||
import { minimatch } from "minimatch-11";
|
||||
|
||||
export * from "minimatch-11";
|
||||
|
||||
export const makeRe = (pattern, options = {}) => ({
|
||||
test: path => minimatch(path, pattern, options),
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "minimatch",
|
||||
"version": "11.0.2-compat.0",
|
||||
"private": true,
|
||||
"description": "minimatch 11 plus the makeRe export eslint-plugin-import-x 4.17.1 still imports. Remove when import-x supports minimatch 11.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"import": "./index.js",
|
||||
"require": "./index.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimatch-11": "npm:minimatch@11.0.2"
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,10 @@ const position: Portfolio["positions"][number]
|
||||
displayName: "Сбербанк",
|
||||
figi: "SBER",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
quantity: "2",
|
||||
sector: null,
|
||||
ticker: "SBER",
|
||||
type: "STOCK",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { toConcentration } from "./concentration";
|
||||
import { portfolio, position } from "./positions.fixtures";
|
||||
|
||||
function snapshot(positions: Portfolio["positions"]): Portfolio {
|
||||
return { ...portfolio, positions };
|
||||
}
|
||||
|
||||
function stock(
|
||||
name: string,
|
||||
value: null | string,
|
||||
currency = "RUB",
|
||||
overrides: Partial<Portfolio["positions"][number]> = {},
|
||||
): Portfolio["positions"][number] {
|
||||
return {
|
||||
...position,
|
||||
currentValue: value === null ? null : { currency, value },
|
||||
displayName: name,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const labels = {
|
||||
missingIssuer: "Эмитент не указан",
|
||||
missingSector: "Сектор не указан",
|
||||
other: "Прочее",
|
||||
sector: (code: string) => code,
|
||||
};
|
||||
|
||||
describe("account concentration", () => {
|
||||
it("uses exact positive values and excludes cash, negatives and nulls", () => {
|
||||
const model = toConcentration(snapshot([
|
||||
stock("A", "1"), stock("B", "15"),
|
||||
stock("C", "0"), stock("D", "-50"), stock("E", null),
|
||||
stock("F", null, "USD"), stock("G", "-3", "USD"),
|
||||
]), "stocks", "RUB", labels);
|
||||
|
||||
expect(model.currencies).toEqual(["RUB", "USD"]);
|
||||
expect(model.total).toBe("16");
|
||||
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
|
||||
expect(model.unaccounted).toBe(3);
|
||||
const negative = snapshot([stock("A", "-3", "USD")]);
|
||||
expect(toConcentration(negative, "stocks", "USD", labels))
|
||||
.toMatchObject({ empty: true, unaccounted: 1 });
|
||||
});
|
||||
|
||||
it("ranks seven positions with exact other and top-five totals", () => {
|
||||
const model = toConcentration(snapshot([
|
||||
stock("A", "400"), stock("B", "250"), stock("C", "150"),
|
||||
stock("D", "100"), stock("E", "50"), stock("F", "30"),
|
||||
stock("G", "20"),
|
||||
]), "stocks", "RUB", labels);
|
||||
|
||||
expect(model.items.map(item => item.amount)).toEqual([
|
||||
"400", "250", "150", "100", "50",
|
||||
]);
|
||||
expect(model.other).toMatchObject({
|
||||
amount: "50", count: 2, share: "5 %",
|
||||
});
|
||||
expect(model.top5Share).toBe("95 %");
|
||||
expect(model.other?.entries.map(item => item.share)).toEqual([
|
||||
"3 %", "2 %",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps sixth row, tiny share and stable alphabetical ties", () => {
|
||||
const model = toConcentration(snapshot([
|
||||
stock("Сбер", "100"), stock("X5", "100"),
|
||||
stock("VK", "100"), stock("D", "100"),
|
||||
stock("E", "100"), stock("F", "100"),
|
||||
]), "stocks", "RUB", labels);
|
||||
|
||||
expect(model.items).toHaveLength(6);
|
||||
expect(model.other).toBeNull();
|
||||
expect(model.items.slice(0, 2).map(item => item.name)).toEqual([
|
||||
"D", "E",
|
||||
]);
|
||||
|
||||
const tiny = snapshot([
|
||||
stock("Large", "100000000"), stock("Small", "1"),
|
||||
]);
|
||||
|
||||
expect(toConcentration(tiny, "stocks", "RUB", labels).items[1]?.share)
|
||||
.toBe("< 0,1 %");
|
||||
});
|
||||
|
||||
it("groups sectors and issuers by codes, not labels", () => {
|
||||
const positions = [
|
||||
stock("A", "300", "RUB", {
|
||||
issuer: { id: "one", name: "Сбербанк" }, sector: "energy",
|
||||
}),
|
||||
stock("B", "200", "RUB", {
|
||||
issuer: { id: "one", name: "Сбер" }, sector: "energy",
|
||||
type: "BOND",
|
||||
}),
|
||||
stock("C", "100", "RUB", {
|
||||
issuer: { id: "two", name: "Сбер" }, sector: "financial",
|
||||
type: "FUND",
|
||||
}),
|
||||
stock("D", "50", "RUB", { issuer: null, sector: null }),
|
||||
];
|
||||
|
||||
expect(toConcentration(snapshot(positions), "sectors", "RUB", labels)
|
||||
.items.map(item => item.amount)).toEqual(["500", "100", "50"]);
|
||||
expect(toConcentration(snapshot(positions), "issuers", "RUB", labels)
|
||||
.items.map(item => [item.name, item.amount])).toEqual([
|
||||
["Сбербанк", "500"], ["", "50"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders equal sums by displayed name", () => {
|
||||
const issuers = snapshot([
|
||||
stock("A", "300", "RUB", {
|
||||
issuer: { id: "g", name: "Газпром" }, sector: "energy",
|
||||
}),
|
||||
stock("B", "300", "RUB", {
|
||||
issuer: { id: "s", name: "Сбер" }, sector: "energy",
|
||||
}),
|
||||
stock("C", "200", "RUB", { issuer: null, sector: "energy" }),
|
||||
stock("D", "100", "RUB", { issuer: null, sector: "energy" }),
|
||||
]);
|
||||
|
||||
expect(toConcentration(issuers, "issuers", "RUB", labels)
|
||||
.items.map(item => [item.label, item.amount])).toEqual([
|
||||
["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"],
|
||||
]);
|
||||
|
||||
const sectors = snapshot([
|
||||
stock("A", "100", "RUB", { sector: "it" }),
|
||||
stock("B", "100", "RUB", { sector: "energy" }),
|
||||
]);
|
||||
|
||||
const sectorLabels = {
|
||||
...labels,
|
||||
sector: (code: string) => code === "it"
|
||||
? "Информационные технологии"
|
||||
: "Энергетика",
|
||||
};
|
||||
|
||||
expect(toConcentration(sectors, "sectors", "RUB", sectorLabels)
|
||||
.items.map(item => item.label))
|
||||
.toEqual(["Информационные технологии", "Энергетика"]);
|
||||
});
|
||||
|
||||
it("chooses issuer name from the largest position, then alphabetically", () => {
|
||||
const data = snapshot([
|
||||
stock("A", "100", "RUB", {
|
||||
issuer: { id: "brand", name: "Ястреб" },
|
||||
}),
|
||||
stock("B", "300", "RUB", {
|
||||
issuer: { id: "brand", name: "Альфа" },
|
||||
}),
|
||||
stock("C", "400", "RUB", {
|
||||
issuer: { id: "other", name: "Бета" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(toConcentration(data, "issuers", "RUB", labels).items
|
||||
.map(item => [item.name, item.label, item.amount])).toEqual([
|
||||
["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses ISIN before ticker for bonds", () => {
|
||||
const data = snapshot([stock("Облигация", "100", "RUB", {
|
||||
isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND",
|
||||
})]);
|
||||
|
||||
expect(toConcentration(data, "bonds", "RUB", labels)
|
||||
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import {
|
||||
type Decimal,
|
||||
decimalFromString,
|
||||
decimalToString,
|
||||
instrumentLogoUrl,
|
||||
sumDecimals,
|
||||
} from "@/shared/lib";
|
||||
|
||||
type Concentration = {
|
||||
currencies: string[];
|
||||
empty: boolean;
|
||||
items: ConcentrationItem[];
|
||||
other: ConcentrationOther | null;
|
||||
top5Share: null | string;
|
||||
total: null | string;
|
||||
unaccounted: number;
|
||||
};
|
||||
|
||||
type ConcentrationItem = {
|
||||
amount: string;
|
||||
chartValue: number;
|
||||
key: string;
|
||||
label: string;
|
||||
logo: ConcentrationLogo | null;
|
||||
name: string;
|
||||
share: string;
|
||||
unknown: boolean;
|
||||
};
|
||||
|
||||
type ConcentrationLabels = {
|
||||
missingIssuer: string;
|
||||
missingSector: string;
|
||||
other: string;
|
||||
sector: (code: string) => string;
|
||||
};
|
||||
|
||||
type ConcentrationLogo = {
|
||||
src: null | string;
|
||||
subtitle: null | string;
|
||||
};
|
||||
|
||||
type ConcentrationOther = ConcentrationItem & {
|
||||
count: number;
|
||||
entries: ConcentrationItem[];
|
||||
};
|
||||
|
||||
type Group = {
|
||||
best?: { name: string; value: Decimal };
|
||||
first: number;
|
||||
key: string;
|
||||
label: string;
|
||||
logo: ConcentrationLogo | null;
|
||||
name: string;
|
||||
unknown: boolean;
|
||||
values: Decimal[];
|
||||
};
|
||||
|
||||
type Position = Portfolio["positions"][number];
|
||||
|
||||
type Slice = "bonds"
|
||||
| "issuers"
|
||||
| "sectors"
|
||||
| "stocks";
|
||||
|
||||
const eligibleTypes = new Set(["BOND", "FUND", "STOCK"]);
|
||||
const RADIX = 10n;
|
||||
const CHART_PRECISION = 1_000_000n;
|
||||
const SHARE_PRECISION = 1000n;
|
||||
const HALF = 2n;
|
||||
const TOP = 5;
|
||||
const FIRST_OTHER = 7;
|
||||
|
||||
const compareNames = (a: string, b: string) =>
|
||||
a.toLowerCase().localeCompare(b.toLowerCase(), "en", {
|
||||
numeric: false,
|
||||
});
|
||||
|
||||
function addPosition(
|
||||
groups: Map<string, Group>,
|
||||
position: Position,
|
||||
index: number,
|
||||
slice: Slice,
|
||||
labels: ConcentrationLabels,
|
||||
selected: null | string,
|
||||
): number {
|
||||
if (!isIncluded(slice, position.type)) return 0;
|
||||
if (!position.currentValue) return 1;
|
||||
if (position.currentValue.currency !== selected) return 0;
|
||||
const value = decimalFromString(position.currentValue.value);
|
||||
if (value.value < 0n) return 1;
|
||||
if (value.value === 0n) return 0;
|
||||
const identity = groupIdentity(position, slice, index, labels);
|
||||
|
||||
const group = groups.get(identity.key) ?? {
|
||||
...identity, first: index, values: [],
|
||||
};
|
||||
|
||||
group.values.push(value);
|
||||
|
||||
if (slice === "issuers" && !identity.unknown) {
|
||||
const best = group.best;
|
||||
|
||||
if (!best || compareDecimal(value, best.value) > 0
|
||||
|| (compareDecimal(value, best.value) === 0
|
||||
&& compareNames(identity.name, best.name) < 0)) {
|
||||
group.best = { name: identity.name, value };
|
||||
group.name = identity.name;
|
||||
group.label = identity.label;
|
||||
}
|
||||
}
|
||||
|
||||
groups.set(identity.key, group);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function chartValue(part: Decimal, total: Decimal): number {
|
||||
const scale = Math.max(part.scale, total.scale);
|
||||
const numerator = part.value * RADIX ** BigInt(scale - part.scale);
|
||||
const denominator = total.value * RADIX ** BigInt(scale - total.scale);
|
||||
|
||||
return Number(numerator * CHART_PRECISION / denominator)
|
||||
/ Number(CHART_PRECISION);
|
||||
}
|
||||
|
||||
function compareDecimal(a: Decimal, b: Decimal): number {
|
||||
const scale = Math.max(a.scale, b.scale);
|
||||
const left = a.value * RADIX ** BigInt(scale - a.scale);
|
||||
const right = b.value * RADIX ** BigInt(scale - b.scale);
|
||||
|
||||
if (left > right) return 1;
|
||||
|
||||
return left < right ? -1 : 0;
|
||||
}
|
||||
|
||||
function concentrationCurrencies(portfolio: Portfolio): string[] {
|
||||
return [...new Set(portfolio.positions
|
||||
.filter(position => eligibleTypes.has(position.type))
|
||||
.filter(position => position.currentValue
|
||||
&& decimalFromString(position.currentValue.value).value !== 0n)
|
||||
.map(position => position.currentValue?.currency)
|
||||
.filter((value): value is string => !!value))]
|
||||
.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function groupIdentity(
|
||||
position: Position,
|
||||
slice: Slice,
|
||||
index: number,
|
||||
labels: ConcentrationLabels,
|
||||
) {
|
||||
if (slice === "sectors") {
|
||||
const sector = position.sector;
|
||||
const isUnknown = !sector;
|
||||
|
||||
return {
|
||||
key: sector ?? "",
|
||||
label: isUnknown
|
||||
? labels.missingSector
|
||||
: labels.sector(sector),
|
||||
logo: null,
|
||||
name: sector ?? "",
|
||||
unknown: isUnknown,
|
||||
};
|
||||
}
|
||||
|
||||
if (slice === "issuers") {
|
||||
const isUnknown = !position.issuer;
|
||||
|
||||
return {
|
||||
key: position.issuer?.id ?? "",
|
||||
label: position.issuer ? position.issuer.name : labels.missingIssuer,
|
||||
logo: null,
|
||||
name: position.issuer?.name ?? "",
|
||||
unknown: isUnknown,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: String(index),
|
||||
label: position.displayName,
|
||||
logo: {
|
||||
src: instrumentLogoUrl(position.logoName),
|
||||
subtitle: slice === "bonds"
|
||||
? position.isin ?? position.ticker ?? position.figi
|
||||
: position.ticker ?? position.isin ?? position.figi,
|
||||
},
|
||||
name: position.displayName,
|
||||
unknown: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isIncluded(slice: Slice, type: string): boolean {
|
||||
if (slice === "stocks") return type === "STOCK";
|
||||
if (slice === "bonds") return type === "BOND";
|
||||
|
||||
return slice === "issuers" ? type === "STOCK" || type === "BOND" : eligibleTypes.has(type);
|
||||
}
|
||||
|
||||
function share(part: Decimal, total: Decimal): string {
|
||||
const scale = Math.max(part.scale, total.scale);
|
||||
const numerator = part.value * RADIX ** BigInt(scale - part.scale);
|
||||
const denominator = total.value * RADIX ** BigInt(scale - total.scale);
|
||||
if (numerator * SHARE_PRECISION < denominator) return "< 0,1 %";
|
||||
|
||||
const tenths = (numerator * SHARE_PRECISION * HALF + denominator)
|
||||
/ (HALF * denominator);
|
||||
|
||||
return tenths % RADIX === 0n ? `${tenths / RADIX} %` : `${tenths / RADIX},${tenths % RADIX} %`;
|
||||
}
|
||||
|
||||
function toConcentration(
|
||||
portfolio: Portfolio,
|
||||
slice: Slice,
|
||||
currency: null | string,
|
||||
labels: ConcentrationLabels,
|
||||
): Concentration {
|
||||
const currencies = concentrationCurrencies(portfolio);
|
||||
|
||||
const selected = currency && currencies.includes(currency)
|
||||
? currency
|
||||
: currencies[0] ?? null;
|
||||
|
||||
const groups = new Map<string, Group>();
|
||||
let unaccounted = 0;
|
||||
|
||||
for (const [index, position] of portfolio.positions.entries()) {
|
||||
unaccounted += addPosition(
|
||||
groups, position, index, slice, labels, selected,
|
||||
);
|
||||
}
|
||||
|
||||
const ranked = Array.from(groups.values(), group => ({
|
||||
...group, sum: sumDecimals(group.values),
|
||||
})).toSorted((a, b) => compareDecimal(b.sum, a.sum)
|
||||
|| compareNames(a.label, b.label) || a.first - b.first);
|
||||
|
||||
const total = ranked.length > 0
|
||||
? sumDecimals(ranked.map(item => item.sum))
|
||||
: null;
|
||||
|
||||
const entries = total
|
||||
? ranked.map(group => ({
|
||||
amount: decimalToString(group.sum),
|
||||
chartValue: chartValue(group.sum, total),
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
logo: group.logo,
|
||||
name: group.name,
|
||||
share: share(group.sum, total),
|
||||
unknown: group.unknown,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const rest = entries.length >= FIRST_OTHER ? entries.slice(TOP) : [];
|
||||
|
||||
const otherSum = rest.length > 0
|
||||
? sumDecimals(ranked.slice(TOP).map(item => item.sum))
|
||||
: null;
|
||||
|
||||
const other = otherSum && total
|
||||
? {
|
||||
amount: decimalToString(otherSum),
|
||||
chartValue: chartValue(otherSum, total),
|
||||
count: rest.length,
|
||||
entries: rest,
|
||||
key: "__other",
|
||||
label: labels.other,
|
||||
logo: null,
|
||||
name: "", share: share(otherSum, total), unknown: false,
|
||||
}
|
||||
: null;
|
||||
|
||||
const top5 = ranked.length > TOP
|
||||
? sumDecimals(ranked.slice(0, TOP).map(item => item.sum))
|
||||
: null;
|
||||
|
||||
return {
|
||||
currencies,
|
||||
empty: !total,
|
||||
items: other ? entries.slice(0, TOP) : entries,
|
||||
other,
|
||||
top5Share: top5 && total ? share(top5, total) : null,
|
||||
total: total && decimalToString(total),
|
||||
unaccounted,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
concentrationCurrencies,
|
||||
toConcentration,
|
||||
};
|
||||
export type {
|
||||
Concentration,
|
||||
ConcentrationItem,
|
||||
ConcentrationLabels,
|
||||
Slice,
|
||||
};
|
||||
@@ -13,8 +13,10 @@ const portfolio
|
||||
displayName: "Акция",
|
||||
figi: "FIGI-STOCK",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: "akciya.png",
|
||||
quantity: "2",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
},
|
||||
@@ -25,8 +27,10 @@ const portfolio
|
||||
displayName: "Облигация",
|
||||
figi: "FIGI-BOND",
|
||||
isin: "RU000A0JX0J2",
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
@@ -37,8 +41,10 @@ const portfolio
|
||||
displayName: "Фонд",
|
||||
figi: "FIGI-FUND",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: "fond.png",
|
||||
quantity: "3",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "FUND",
|
||||
},
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { getI18n } from "react-i18next";
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
@@ -25,6 +27,7 @@ import type {
|
||||
AccountPageRecentOperations,
|
||||
} from "../../model/previews";
|
||||
|
||||
import { toConcentration } from "../../model/concentration";
|
||||
import {
|
||||
toAccountPagePositionSections,
|
||||
toUnrealizedReturns,
|
||||
@@ -35,6 +38,19 @@ import { AccountPageError } from "./components/AccountPageError.view";
|
||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
||||
|
||||
function AccountSwitcher() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate("/accounts/account-2")}
|
||||
type="button"
|
||||
>
|
||||
Другой счёт
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function configureTestAuth() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test-token",
|
||||
@@ -50,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
|
||||
<AccountSwitcher />
|
||||
|
||||
<Routes>
|
||||
<Route element={<AccountPage />} path="/accounts/:accountId" />
|
||||
</Routes>
|
||||
@@ -84,8 +102,10 @@ const portfolio
|
||||
displayName: "Вторая",
|
||||
figi: "FIGI-2",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: "vtoraya.png",
|
||||
quantity: "2",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
},
|
||||
@@ -96,8 +116,10 @@ const portfolio
|
||||
displayName: "Первая",
|
||||
figi: "FIGI-1",
|
||||
isin: "RU000A0JX0J2",
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
@@ -116,8 +138,10 @@ function position(
|
||||
displayName: "Инструмент",
|
||||
figi: null,
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
...overrides,
|
||||
@@ -134,6 +158,19 @@ const emptyRecentEvents: AccountPageRecentEvents = {
|
||||
to: "/accounts/account-1/events",
|
||||
};
|
||||
|
||||
function concentrationLabels() {
|
||||
const i18n = getI18n();
|
||||
|
||||
return {
|
||||
missingIssuer: i18n.t("portfolio.concentration.missingIssuer"),
|
||||
missingSector: i18n.t("portfolio.concentration.missingSector"),
|
||||
other: i18n.t("portfolio.concentration.other"),
|
||||
sector: (code: string) => i18n.t(`stock.sectors.${code}`, {
|
||||
defaultValue: code,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyProps() {
|
||||
return {
|
||||
recentEvents: emptyRecentEvents,
|
||||
@@ -141,17 +178,357 @@ function emptyProps() {
|
||||
};
|
||||
}
|
||||
|
||||
const LoadedHarness = (
|
||||
props: Omit<Partial<Parameters<typeof AccountPageLoaded>[0]>, "onTabChange">,
|
||||
) => {
|
||||
const [tab, setTab] = useState(props.tab ?? "positions");
|
||||
|
||||
return (
|
||||
<AccountPageLoaded
|
||||
{...loadedProps()}
|
||||
{...props}
|
||||
onTabChange={value => setTab(value ?? "positions")}
|
||||
tab={tab}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
||||
return {
|
||||
concentration: null,
|
||||
currency: null,
|
||||
onCurrencyChange: vi.fn(),
|
||||
onSliceChange: vi.fn(),
|
||||
portfolio: nextPortfolio,
|
||||
positionSections: toAccountPagePositionSections(nextPortfolio),
|
||||
recentEvents: emptyRecentEvents,
|
||||
recentOperations: emptyRecentOperations,
|
||||
slice: "stocks" as const,
|
||||
unrealizedReturns: toUnrealizedReturns(nextPortfolio.positions),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AccountPage states", () => {
|
||||
it("shows concentration only for a loaded individual account", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(resource({
|
||||
...portfolio,
|
||||
positions: [position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Акция",
|
||||
})],
|
||||
})),
|
||||
),
|
||||
);
|
||||
const client = createTestQueryClient();
|
||||
renderAccountRoute(client);
|
||||
const tab = await screen.findByRole("tab", { name: "Концентрация" });
|
||||
expect(tab).toHaveAttribute("aria-selected", "false");
|
||||
fireEvent.click(tab);
|
||||
expect(screen.getAllByText("100 ₽")).toHaveLength(2);
|
||||
expect(screen.getByRole("img", { name: "Распределение позиций" }))
|
||||
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
|
||||
});
|
||||
|
||||
it("expands and resets other when switching slice", () => {
|
||||
const positions = [400, 250, 150, 100, 50, 30, 20].map(
|
||||
(value, index) => position({
|
||||
currentValue: { currency: "RUB", value: String(value) },
|
||||
displayName: `Акция ${index + 1}`,
|
||||
}),
|
||||
);
|
||||
|
||||
const data = { ...portfolio, positions };
|
||||
|
||||
const model = toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
);
|
||||
|
||||
const props = loadedProps(data);
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...props}
|
||||
accountId="a"
|
||||
concentration={model}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
const other = screen.getByRole("button", { name: /Прочее/ });
|
||||
expect(other).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(other);
|
||||
expect(other).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("Акция 7")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
||||
expect(props.onSliceChange).toHaveBeenCalledWith("sectors");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, "2 элемента"],
|
||||
[5, "5 элементов"],
|
||||
[21, "21 элемент"],
|
||||
])("pluralizes the other count %d as %s", (count, expected) => {
|
||||
const values = [400, 250, 150, 100, 50];
|
||||
|
||||
const rest = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => 20 + index,
|
||||
);
|
||||
|
||||
const positions = [...values, ...rest].map(
|
||||
(value, index) => position({
|
||||
currentValue: { currency: "RUB", value: String(value) },
|
||||
displayName: `Акция ${index + 1}`,
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps({ ...portfolio, positions })}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
{ ...portfolio, positions },
|
||||
"stocks",
|
||||
"RUB",
|
||||
concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
const other = screen.getByRole("button", { name: /Прочее/ });
|
||||
expect(other).toHaveTextContent(expected);
|
||||
});
|
||||
|
||||
it("shows ticker subtitles and stocks rows with logos, issuers without", () => {
|
||||
const positions = [
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "300" },
|
||||
displayName: "Сбербанк",
|
||||
ticker: "SBER",
|
||||
}),
|
||||
];
|
||||
|
||||
const data = { ...portfolio, positions };
|
||||
|
||||
const view = renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("SBER")).toBeInTheDocument();
|
||||
|
||||
view.unmount();
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "issuers", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
slice="issuers"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("SBER")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the currency select when only one currency is available", () => {
|
||||
const data = { ...portfolio, positions: [position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Акция",
|
||||
})] };
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("combobox", { name: "Валюта концентрации" }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows sector labels including government and unknown codes", () => {
|
||||
const positions = [
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "300" },
|
||||
displayName: "А",
|
||||
sector: "government",
|
||||
}),
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Б",
|
||||
sector: "unknown_code",
|
||||
}),
|
||||
];
|
||||
|
||||
const data = { ...portfolio, positions };
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "sectors", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
slice="sectors"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Государственный сектор")).toBeInTheDocument();
|
||||
expect(screen.getByText("unknown_code")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("describes ranked segments with amounts and shares", () => {
|
||||
const data = { ...portfolio, positions: [
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "300" },
|
||||
displayName: "Первая акция",
|
||||
}),
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Вторая акция",
|
||||
}),
|
||||
...[50, 40, 30, 20, 10].map((value, index) => position({
|
||||
currentValue: { currency: "RUB", value: String(value) },
|
||||
displayName: `Остаток ${index + 1}`,
|
||||
})),
|
||||
] };
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chart = screen.getByRole("img", { name: "Распределение позиций" });
|
||||
|
||||
expect(chart).toHaveAccessibleDescription(
|
||||
/Первая акция: 300 ₽, 54,5 %; Вторая акция: 100 ₽, 18,2 %; Остаток 1: 50 ₽, 9,1 %; Остаток 2: 40 ₽, 7,3 %; Остаток 3: 30 ₽, 5,5 %; Прочее: 30 ₽, 5,5 %/,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not duplicate the partial warning on the concentration tab", () => {
|
||||
const data = { ...portfolio, positions: [position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
displayName: "Акция",
|
||||
})] };
|
||||
|
||||
renderPage(
|
||||
<LoadedHarness
|
||||
{...loadedProps(data)}
|
||||
accountId="a"
|
||||
concentration={toConcentration(
|
||||
data, "stocks", "RUB", concentrationLabels(),
|
||||
)}
|
||||
currency="RUB"
|
||||
partial
|
||||
tab="concentration"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole("status"))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps slice and currency while switching page tabs and resets on account change", async () => {
|
||||
const multiCurrency = { ...portfolio, positions:
|
||||
["RUB", "USD"].flatMap(currency =>
|
||||
[400, 250, 150, 100, 50, 30, 20].map((value, index) =>
|
||||
position({
|
||||
currentValue: { currency, value: String(value) },
|
||||
displayName: `${currency} акция ${index + 1}`,
|
||||
sector: "energy", type: "STOCK",
|
||||
}),
|
||||
),
|
||||
) };
|
||||
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(resource(multiCurrency)),
|
||||
),
|
||||
);
|
||||
const client = createTestQueryClient();
|
||||
renderAccountRoute(client);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
|
||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||
.toHaveAttribute("aria-expanded", "true");
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Валюта концентрации" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "USD" }));
|
||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||
.toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Акции" }));
|
||||
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||
.toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
||||
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
||||
.toHaveValue("USD");
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Позиции" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Концентрация" }));
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Секторы" }))
|
||||
.toBeChecked();
|
||||
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
||||
.toHaveValue("USD");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Другой счёт" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
|
||||
expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked();
|
||||
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
||||
.toHaveValue("RUB");
|
||||
});
|
||||
|
||||
it("falls back to the first currency when the selected one disappears", () => {
|
||||
const data = { ...portfolio, positions: [
|
||||
position({
|
||||
currentValue: { currency: "USD", value: "200" },
|
||||
}),
|
||||
position({
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
}),
|
||||
] };
|
||||
|
||||
const model = toConcentration(
|
||||
data, "stocks", "EUR", concentrationLabels(),
|
||||
);
|
||||
|
||||
expect(model.currencies).toEqual(["RUB", "USD"]);
|
||||
expect(model.total).toBe("100");
|
||||
expect(model.items.map(item => item.amount)).toEqual(["100"]);
|
||||
});
|
||||
it("shows the loaded portfolio source sync status", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
@@ -306,7 +683,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
cashBalances: [
|
||||
@@ -490,7 +867,7 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
||||
@@ -528,7 +905,7 @@ describe("AccountPage states", () => {
|
||||
"renders %s logos, fallbacks and signed returns including zero",
|
||||
(type) => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
@@ -571,7 +948,7 @@ describe("AccountPage states", () => {
|
||||
"groups %s supporting metrics into quiet single lines",
|
||||
(type) => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [position({
|
||||
@@ -593,7 +970,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
@@ -618,7 +995,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [position({
|
||||
@@ -636,7 +1013,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("uses the cached account title when available", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
|
||||
account={{
|
||||
@@ -655,7 +1032,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("keeps the portfolio visible when recent operations fail", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
recentOperations={{
|
||||
state: "error",
|
||||
@@ -674,7 +1051,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("uses the ledger row for a recent outgoing trade operation", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
|
||||
recentOperations={{
|
||||
@@ -724,7 +1101,7 @@ describe("AccountPage states", () => {
|
||||
};
|
||||
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
|
||||
recentEvents={{
|
||||
@@ -793,7 +1170,7 @@ describe("AccountPage states", () => {
|
||||
"keeps the upcoming %s state and calendar action",
|
||||
(state) => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
recentEvents={{ state, to: "/accounts/account-1/events" }}
|
||||
/>,
|
||||
@@ -830,7 +1207,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("keeps the portfolio visible when upcoming events fail", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
recentEvents={{
|
||||
state: "error",
|
||||
@@ -995,7 +1372,7 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
it("opens positions by default and switches between local tabs", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Позиции" }))
|
||||
.toHaveAttribute("aria-selected", "true");
|
||||
@@ -1012,7 +1389,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("links operation, event and income previews to their detail pages", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps()}
|
||||
incomeSummary={{
|
||||
state: "empty",
|
||||
@@ -1033,7 +1410,7 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
it("filters positions by the kinds present in the portfolio", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||
|
||||
expect(
|
||||
screen.getAllByRole<HTMLInputElement>("radio").map(radio => radio.value),
|
||||
@@ -1047,7 +1424,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("hides the position filter when only one kind exists", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [portfolio.positions[0]],
|
||||
@@ -1060,7 +1437,7 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
it("hides unrealized returns for a partial portfolio", () => {
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} partial />);
|
||||
renderPage(<LoadedHarness {...loadedProps()} partial />);
|
||||
|
||||
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
|
||||
.not.toBeInTheDocument();
|
||||
@@ -1068,7 +1445,7 @@ describe("AccountPage states", () => {
|
||||
|
||||
it("omits an unrealized currency total when any return is incomplete", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
<LoadedHarness
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
|
||||
@@ -25,13 +25,20 @@ const AccountPage: FC = () => {
|
||||
<AccountPageLoaded
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
concentration={viewModel.concentration}
|
||||
currency={viewModel.currency}
|
||||
incomeSummary={viewModel.incomeSummary}
|
||||
onCurrencyChange={viewModel.onCurrencyChange}
|
||||
onSliceChange={viewModel.onSliceChange}
|
||||
onTabChange={viewModel.onTabChange}
|
||||
partial={viewModel.partial}
|
||||
portfolio={viewModel.portfolio}
|
||||
positionSections={viewModel.positionSections}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
slice={viewModel.slice}
|
||||
source={viewModel.source}
|
||||
tab={viewModel.tab}
|
||||
unrealizedReturns={viewModel.unrealizedReturns}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
@@ -17,6 +19,13 @@ import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import { useInitialAvailability } from "@/shared/lib";
|
||||
|
||||
import {
|
||||
type Concentration,
|
||||
concentrationCurrencies,
|
||||
type ConcentrationLabels,
|
||||
type Slice,
|
||||
toConcentration,
|
||||
} from "../../model/concentration";
|
||||
import {
|
||||
type AccountPagePositionSection,
|
||||
toAccountPagePositionSections,
|
||||
@@ -45,10 +54,17 @@ type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
};
|
||||
|
||||
type AccountPageLoadedViewModel = AccountPageContent & {
|
||||
concentration: Concentration | null;
|
||||
currency: null | string;
|
||||
onCurrencyChange: (currency: null | string) => void;
|
||||
onSliceChange: (slice: Slice) => void;
|
||||
onTabChange: (tab: null | string) => void;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
slice: Slice;
|
||||
source?: InvestmentResourceMeta;
|
||||
state: "loaded";
|
||||
tab: string;
|
||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
||||
};
|
||||
|
||||
@@ -66,6 +82,24 @@ function isPortfolioAccessFailure(code?: string) {
|
||||
|
||||
function useAccountPageViewModel(): AccountPageViewModel {
|
||||
const { accountId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selection, setSelection] = useState<{
|
||||
accountId?: string;
|
||||
currency: null | string;
|
||||
slice: Slice;
|
||||
tab: string;
|
||||
}>({ accountId, currency: null, slice: "stocks", tab: "positions" });
|
||||
|
||||
const current = selection.accountId === accountId
|
||||
? selection
|
||||
: {
|
||||
accountId, currency: null, slice: "stocks" as const, tab: "positions",
|
||||
};
|
||||
|
||||
const updateSelection = (change: Partial<typeof selection>) =>
|
||||
setSelection({ ...current, ...change });
|
||||
|
||||
const portfolio = usePortfolio(accountId);
|
||||
|
||||
const operations = useBrokerageOperations(accountId, {
|
||||
@@ -122,18 +156,42 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
};
|
||||
|
||||
const isPartial = portfolio.meta?.completeness === "incomplete";
|
||||
const currencies = concentrationCurrencies(portfolio.data);
|
||||
|
||||
const currency = current.currency && currencies.includes(current.currency)
|
||||
? current.currency
|
||||
: currencies[0] ?? null;
|
||||
|
||||
const labels: ConcentrationLabels = {
|
||||
missingIssuer: t("portfolio.concentration.missingIssuer"),
|
||||
missingSector: t("portfolio.concentration.missingSector"),
|
||||
other: t("portfolio.concentration.other"),
|
||||
sector: code => t(`stock.sectors.${code}`, { defaultValue: code }),
|
||||
};
|
||||
|
||||
const concentration = accountId !== "all"
|
||||
&& current.tab === "concentration"
|
||||
? toConcentration(portfolio.data, current.slice, currency, labels)
|
||||
: null;
|
||||
|
||||
return {
|
||||
account,
|
||||
accountId,
|
||||
concentration,
|
||||
currency,
|
||||
incomeSummary,
|
||||
onCurrencyChange: value => updateSelection({ currency: value }),
|
||||
onSliceChange: slice => updateSelection({ slice }),
|
||||
onTabChange: tab => updateSelection({ tab: tab ?? "positions" }),
|
||||
partial: isPartial,
|
||||
portfolio: portfolio.data,
|
||||
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||
recentEvents,
|
||||
recentOperations,
|
||||
slice: current.slice,
|
||||
source: portfolio.meta,
|
||||
state: "loaded" as const,
|
||||
tab: current.tab,
|
||||
unrealizedReturns: toUnrealizedReturns(portfolio.data.positions),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { formatMoney } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
||||
|
||||
import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
|
||||
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
|
||||
import classes from "../AccountPage.module.css";
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { Concentration } from "./Concentration/Concentration.view";
|
||||
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PositionSection } from "./PositionSection.view";
|
||||
@@ -35,19 +37,29 @@ import { RecentOperations } from "./RecentOperations.view";
|
||||
type AccountPageLoadedProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
concentration: ConcentrationModel | null;
|
||||
currency: null | string;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
onCurrencyChange: (currency: null | string) => void;
|
||||
onSliceChange: (slice: Slice) => void;
|
||||
onTabChange: (tab: null | string) => void;
|
||||
partial?: boolean;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
slice: Slice;
|
||||
source?: InvestmentResourceMeta;
|
||||
tab: string;
|
||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
||||
};
|
||||
|
||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<string>();
|
||||
const tab = props.tab;
|
||||
|
||||
const onTabChange = props.onTabChange;
|
||||
|
||||
const activeSection = props.positionSections.find(
|
||||
section => section.kind === filter,
|
||||
@@ -75,7 +87,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
/>
|
||||
|
||||
{props.partial
|
||||
? <Alert role="status">{t("investment.partial")}</Alert>
|
||||
? tab !== "concentration" && (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
)
|
||||
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
|
||||
|
||||
{!props.partial && props.unrealizedReturns.length > 0 && (
|
||||
@@ -107,14 +121,21 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
|
||||
<Tabs
|
||||
classNames={{ list: classes.tabsList }}
|
||||
defaultValue="positions"
|
||||
keepMounted={false}
|
||||
onChange={onTabChange}
|
||||
value={tab}
|
||||
>
|
||||
<Tabs.List aria-label={t("portfolio.sections")} mb="md">
|
||||
<Tabs.Tab value="positions">
|
||||
{t("portfolio.tabs.positions")}
|
||||
</Tabs.Tab>
|
||||
|
||||
{props.accountId !== "all" && (
|
||||
<Tabs.Tab value="concentration">
|
||||
{t("portfolio.concentration.title")}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
|
||||
<Tabs.Tab value="cash">{t("portfolio.tabs.cash")}</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab value="operations">
|
||||
@@ -143,6 +164,22 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{props.accountId !== "all" && (
|
||||
<Tabs.Panel value="concentration">
|
||||
{props.concentration && (
|
||||
<Concentration
|
||||
currency={props.currency}
|
||||
key={`${props.slice}-${props.currency}`}
|
||||
model={props.concentration}
|
||||
onCurrencyChange={props.onCurrencyChange}
|
||||
onSliceChange={props.onSliceChange}
|
||||
partial={!!props.partial}
|
||||
slice={props.slice}
|
||||
/>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
|
||||
<Tabs.Panel value="cash">
|
||||
<CurrencyCards amounts={props.portfolio.cashBalances} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr);
|
||||
gap: var(--mantine-spacing-xl);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart {
|
||||
position: relative;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.pattern {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
inset: 32%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: clamp(12px, 1.3vw, 17px);
|
||||
overflow-wrap: anywhere;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
min-height: 58px;
|
||||
border-bottom: 1px solid var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
.rank {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.identity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.bar,
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
background: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.other {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: var(--mantine-color-gray-0);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.other:focus-visible {
|
||||
outline: 2px solid var(--mantine-color-blue-6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.slices { max-width: 100%; overflow-x: auto; }
|
||||
.other { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { DonutChart } from "@mantine/charts";
|
||||
import {
|
||||
Alert, Box, Group, Paper, SegmentedControl, Select, Stack,
|
||||
Text, Title,
|
||||
} from "@mantine/core";
|
||||
import { type FC, useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type {
|
||||
ConcentrationItem,
|
||||
Concentration as ConcentrationModel,
|
||||
Slice,
|
||||
} from "../../../../model/concentration";
|
||||
|
||||
import { PositionLogo } from "../PositionLogo.view";
|
||||
import classes from "./Concentration.module.css";
|
||||
|
||||
type Props = {
|
||||
currency: null | string;
|
||||
model: ConcentrationModel;
|
||||
onCurrencyChange: (currency: null | string) => void;
|
||||
onSliceChange: (slice: Slice) => void;
|
||||
partial: boolean;
|
||||
slice: Slice;
|
||||
};
|
||||
|
||||
const sliceLabels = {
|
||||
bonds: "portfolio.bonds",
|
||||
issuers: "portfolio.concentration.issuers",
|
||||
sectors: "portfolio.concentration.sectors",
|
||||
stocks: "portfolio.stocks",
|
||||
} as const;
|
||||
|
||||
type RowProps = {
|
||||
color: string;
|
||||
currency: string;
|
||||
index: number;
|
||||
item: ConcentrationItem;
|
||||
logo?: ConcentrationItem["logo"];
|
||||
name: string;
|
||||
};
|
||||
|
||||
const colors = [
|
||||
"#2563eb", "#18a999", "#f59e0b", "#8b5cf6", "#ef6c5b",
|
||||
"#64748b",
|
||||
];
|
||||
|
||||
const PERCENT = 100;
|
||||
const OTHER_START = 6;
|
||||
|
||||
function amountFor(item: ConcentrationItem, currency: string): string {
|
||||
return formatMoney({ currency, value: item.amount });
|
||||
}
|
||||
|
||||
const ConcentrationRow: FC<RowProps> = props => (
|
||||
<li className={classes.row}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classes.rank}
|
||||
style={{ backgroundColor: props.color }}
|
||||
>
|
||||
{props.index}
|
||||
</span>
|
||||
|
||||
{props.logo && <PositionLogo label={props.name} src={props.logo.src} />}
|
||||
|
||||
<span className={classes.identity}>
|
||||
<span>{props.name}</span>
|
||||
|
||||
{props.logo?.subtitle && (
|
||||
<Text c="dimmed" size="xs">{props.logo.subtitle}</Text>
|
||||
)}
|
||||
|
||||
<span aria-hidden="true" className={classes.bar}>
|
||||
<span style={{
|
||||
backgroundColor: props.color,
|
||||
width: `${props.item.chartValue * PERCENT}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className={classes.value}>
|
||||
<strong>{amountFor(props.item, props.currency)}</strong>
|
||||
|
||||
<Text c="dimmed" size="sm">{props.item.share}</Text>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
type TooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: { name?: number | string }[];
|
||||
rows: { amount: string; label: string; share: string }[];
|
||||
};
|
||||
|
||||
function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) {
|
||||
const index = Number(series.name);
|
||||
const item = rows.at(index);
|
||||
|
||||
return {
|
||||
stroke: "white",
|
||||
strokeWidth: 2,
|
||||
...((item?.unknown || (index === rows.length - 1
|
||||
&& item?.key === "__other"))
|
||||
&& { strokeDasharray: "3 2" }),
|
||||
};
|
||||
}
|
||||
|
||||
function rankLabel({ index }: { index: number; percent?: number }) {
|
||||
return String(index + 1);
|
||||
}
|
||||
|
||||
const ChartTooltip: FC<TooltipProps> = (props) => {
|
||||
const row = props.rows[Number(props.payload?.[0]?.name)];
|
||||
if (!row || !props.active) return;
|
||||
|
||||
return (
|
||||
<Paper p="sm" radius="md" shadow="sm">
|
||||
<Text fw={600} size="sm">{row.label}</Text>
|
||||
|
||||
<Text size="sm">{row.amount} · {row.share}</Text>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const Concentration: FC<Props> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const otherId = useId();
|
||||
const chartId = useId();
|
||||
const currency = props.currency ?? "";
|
||||
|
||||
const rows = props.model.other
|
||||
? [...props.model.items, props.model.other]
|
||||
: props.model.items;
|
||||
|
||||
const chartData = rows.map((item, index) => ({
|
||||
color: colors[index % colors.length] ?? colors[0] ?? "blue",
|
||||
name: String(index),
|
||||
value: item.chartValue,
|
||||
}));
|
||||
|
||||
const chartText = [
|
||||
props.model.top5Share && t("portfolio.concentration.top5", {
|
||||
share: props.model.top5Share,
|
||||
}),
|
||||
props.model.total && formatMoney({
|
||||
currency, value: props.model.total,
|
||||
}),
|
||||
...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`),
|
||||
].filter(Boolean).join("; ");
|
||||
|
||||
const slices = [
|
||||
{ label: t("portfolio.stocks"), value: "stocks" },
|
||||
{ label: t("portfolio.bonds"), value: "bonds" },
|
||||
{ label: t("portfolio.concentration.sectors"), value: "sectors" },
|
||||
{ label: t("portfolio.concentration.issuers"), value: "issuers" },
|
||||
];
|
||||
|
||||
// eslint-disable-next-line frontend/readability -- Mantine callback closes over page action.
|
||||
const selectSlice = (value: string) => props.onSliceChange(value as Slice);
|
||||
// eslint-disable-next-line frontend/readability -- Disclosure state belongs to this view.
|
||||
const toggleOther = () => setExpanded(value => !value);
|
||||
|
||||
// eslint-disable-next-line frontend/readability -- Chart callback closes over visible rows.
|
||||
const cellProps = (series: { name: string }) => chartCellProps(series, rows);
|
||||
const pieProps = { isAnimationActive: false, label: rankLabel };
|
||||
|
||||
const tooltipRows = rows.map(item => ({
|
||||
amount: amountFor(item, currency),
|
||||
label: item.label,
|
||||
share: item.share,
|
||||
}));
|
||||
|
||||
const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> };
|
||||
|
||||
const totalText = props.model.total
|
||||
? formatMoney({ currency, value: props.model.total })
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group align="end" justify="space-between">
|
||||
<SegmentedControl
|
||||
aria-label={t("portfolio.concentration.slice")}
|
||||
className={classes.slices}
|
||||
data={slices}
|
||||
onChange={selectSlice}
|
||||
value={props.slice}
|
||||
/>
|
||||
|
||||
{props.model.currencies.length > 1 && (
|
||||
<Select
|
||||
allowDeselect={false}
|
||||
aria-label={t("portfolio.concentration.currency")}
|
||||
data={props.model.currencies}
|
||||
onChange={props.onCurrencyChange}
|
||||
value={props.currency}
|
||||
w={130}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{props.partial && (
|
||||
<Alert role="status">{t("portfolio.concentration.incomplete")}</Alert>
|
||||
)}
|
||||
|
||||
{props.model.empty
|
||||
? (
|
||||
<Stack gap="xs">
|
||||
<Text fw={600}>{t(sliceLabels[props.slice])}
|
||||
{props.currency && ` · ${props.currency}`}
|
||||
</Text>
|
||||
|
||||
<Text>{t("portfolio.concentration.empty")}</Text>
|
||||
</Stack>
|
||||
)
|
||||
: (
|
||||
<Paper className={classes.layout} p="lg" radius="md" withBorder>
|
||||
<Box className={classes.chart}>
|
||||
<DonutChart
|
||||
aria-describedby={chartId}
|
||||
aria-label={t("portfolio.concentration.chart")}
|
||||
cellProps={cellProps}
|
||||
data={chartData}
|
||||
pieProps={pieProps}
|
||||
role="img"
|
||||
size={260}
|
||||
strokeWidth={24}
|
||||
tooltipDataSource="segment"
|
||||
tooltipProps={tooltipProps}
|
||||
/>
|
||||
|
||||
<div aria-hidden="true" className={classes.center}>
|
||||
{props.model.top5Share && (
|
||||
<Text fw={700} size="sm">{t("portfolio.concentration.top5", {
|
||||
share: props.model.top5Share,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text fw={700}>{totalText}</Text>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
className={classes.srOnly}
|
||||
id={chartId}
|
||||
>
|
||||
{chartText}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={3} size="md">{t("portfolio.concentration.title")}</Title>
|
||||
|
||||
<ol className={classes.list}>
|
||||
{props.model.items.map((item, index) => (
|
||||
<ConcentrationRow
|
||||
color={colors[index % colors.length] ?? "blue"}
|
||||
currency={currency}
|
||||
index={index + 1}
|
||||
item={item}
|
||||
key={item.key}
|
||||
logo={item.logo}
|
||||
name={item.label}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{props.model.other && (
|
||||
<>
|
||||
<button
|
||||
aria-controls={otherId}
|
||||
aria-expanded={expanded}
|
||||
className={classes.other}
|
||||
onClick={toggleOther}
|
||||
type="button"
|
||||
>
|
||||
<span>{t("portfolio.concentration.other")} · {t(
|
||||
"portfolio.concentration.otherCount",
|
||||
{ count: props.model.other.count },
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span>{amountFor(props.model.other, currency)} · {
|
||||
props.model.other.share
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ol
|
||||
className={classes.list}
|
||||
hidden={!expanded}
|
||||
id={otherId}
|
||||
start={OTHER_START}
|
||||
>
|
||||
{props.model.other.entries.map((item, index) => (
|
||||
<ConcentrationRow
|
||||
color={colors[(index + OTHER_START) % colors.length]
|
||||
?? "blue"}
|
||||
currency={currency}
|
||||
index={index + OTHER_START}
|
||||
item={item}
|
||||
key={item.key}
|
||||
logo={item.logo}
|
||||
name={item.label}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{props.model.unaccounted > 0 && (
|
||||
<Text c="dimmed" size="sm">{t(
|
||||
"portfolio.concentration.unaccounted",
|
||||
{ count: props.model.unaccounted },
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { Concentration };
|
||||
@@ -12,6 +12,7 @@ const syntheticPosition
|
||||
displayName: "Учебные технологии",
|
||||
figi: null,
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
logoSrc: null,
|
||||
quantity: "100",
|
||||
@@ -19,6 +20,7 @@ const syntheticPosition
|
||||
returnPercentText: "+25,00 %",
|
||||
returnText: "+5 000 ₽",
|
||||
returnTone: "positive",
|
||||
sector: null,
|
||||
shareText: "20%",
|
||||
ticker: "DEMO",
|
||||
type: "STOCK",
|
||||
|
||||
@@ -37,12 +37,14 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
|
||||
displayName: `Инструмент ${index}`,
|
||||
figi: null,
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
logoSrc: null,
|
||||
quantity: "10",
|
||||
returnPercent: null,
|
||||
returnPercentText: null,
|
||||
returnText: null,
|
||||
sector: null,
|
||||
shareText: null,
|
||||
ticker: null,
|
||||
type: kind === "stocks" ? "STOCK" : otherType,
|
||||
|
||||
Vendored
+8
@@ -1056,6 +1056,10 @@ export interface components {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
PortfolioIssuerDto: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
PortfolioPositionDto: {
|
||||
instrumentUid?: string | null;
|
||||
account?: components["schemas"]["AccountReferenceDto"];
|
||||
@@ -1070,6 +1074,8 @@ export interface components {
|
||||
isin: string | null;
|
||||
ticker: string | null;
|
||||
logoName: string | null;
|
||||
sector: string | null;
|
||||
issuer: components["schemas"]["PortfolioIssuerDto"] | null;
|
||||
};
|
||||
PortfolioDto: {
|
||||
/** Format: date-time */
|
||||
@@ -1383,6 +1389,8 @@ export interface components {
|
||||
isin: string | null;
|
||||
ticker: string | null;
|
||||
logoName: string | null;
|
||||
sector: string | null;
|
||||
issuer: components["schemas"]["PortfolioIssuerDto"] | null;
|
||||
returnPercent: string | null;
|
||||
};
|
||||
StockPositionDto: {
|
||||
|
||||
@@ -6423,6 +6423,21 @@
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"PortfolioIssuerDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"PortfolioPositionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6495,6 +6510,19 @@
|
||||
"logoName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sector": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"issuer": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PortfolioIssuerDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -6507,7 +6535,9 @@
|
||||
"absoluteReturn",
|
||||
"isin",
|
||||
"ticker",
|
||||
"logoName"
|
||||
"logoName",
|
||||
"sector",
|
||||
"issuer"
|
||||
]
|
||||
},
|
||||
"PortfolioDto": {
|
||||
@@ -7910,6 +7940,19 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sector": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"issuer": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PortfolioIssuerDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"returnPercent": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
@@ -7926,6 +7969,8 @@
|
||||
"isin",
|
||||
"ticker",
|
||||
"logoName",
|
||||
"sector",
|
||||
"issuer",
|
||||
"returnPercent"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -509,6 +509,26 @@ const ru = {
|
||||
bonds: "Облигации",
|
||||
byAccount: "Позиции по счетам",
|
||||
cashBalances: "Денежные остатки",
|
||||
concentration: {
|
||||
chart: "Распределение позиций",
|
||||
currency: "Валюта концентрации",
|
||||
empty: "В этом разрезе нет позиций с положительной оценкой.",
|
||||
incomplete: "Данные неполные",
|
||||
issuers: "Эмитенты",
|
||||
missingIssuer: "Эмитент не указан",
|
||||
missingSector: "Сектор не указан",
|
||||
other: "Прочее",
|
||||
otherCount: "{{count}} элементов",
|
||||
otherCount_few: "{{count}} элемента",
|
||||
otherCount_many: "{{count}} элементов",
|
||||
otherCount_one: "{{count}} элемент",
|
||||
otherCount_other: "{{count}} элемента",
|
||||
sectors: "Секторы",
|
||||
slice: "Разрез концентрации",
|
||||
title: "Концентрация",
|
||||
top5: "Топ-5 · {{share}}",
|
||||
unaccounted: "Не учтено позиций: {{count}}",
|
||||
},
|
||||
connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.",
|
||||
connectionErrorTitle: "Проверьте подключение T-Bank",
|
||||
currencies: {
|
||||
@@ -760,6 +780,7 @@ const ru = {
|
||||
consumer: "Потребительский сектор",
|
||||
energy: "Энергетика",
|
||||
financial: "Финансы",
|
||||
government: "Государственный сектор",
|
||||
health_care: "Здравоохранение",
|
||||
industrials: "Промышленность",
|
||||
it: "Информационные технологии",
|
||||
|
||||
Reference in New Issue
Block a user