diff --git a/src/entities/portfolio/index.ts b/src/entities/portfolio/index.ts
index 08d2148..cf9c198 100644
--- a/src/entities/portfolio/index.ts
+++ b/src/entities/portfolio/index.ts
@@ -2,6 +2,7 @@ export type { Portfolio } from "./api/usePortfolio";
export { usePortfolio } from "./api/usePortfolio";
export {
formatMoney,
+ formatShare,
formatSnapshotTime,
portfolioErrorCode,
} from "./model/display";
diff --git a/src/entities/portfolio/model/display.test.ts b/src/entities/portfolio/model/display.test.ts
index 504b2e5..198b8e7 100644
--- a/src/entities/portfolio/model/display.test.ts
+++ b/src/entities/portfolio/model/display.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { formatMoney } from "./display";
+import { formatMoney, formatShare } from "./display";
describe("portfolio display helpers", () => {
it("formats decimal strings without financial arithmetic", () => {
@@ -10,4 +10,27 @@ describe("portfolio display helpers", () => {
expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
expect(formatMoney(null)).toBe("—");
});
+
+ it("formats a share as a display-only percentage", () => {
+ expect(
+ formatShare(
+ { currency: "RUB", value: "150" },
+ { currency: "RUB", value: "1000" },
+ ),
+ ).toBe("15 %");
+ expect(
+ formatShare(
+ { currency: "RUB", value: "123.45" },
+ { currency: "RUB", value: "999.99" },
+ ),
+ ).toBe("12,3 %");
+ expect(formatShare(null, { currency: "RUB", value: "100" })).toBeNull();
+ expect(formatShare({ currency: "RUB", value: "100" }, null)).toBeNull();
+ expect(
+ formatShare(
+ { currency: "RUB", value: "100" },
+ { currency: "RUB", value: "0" },
+ ),
+ ).toBeNull();
+ });
});
diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts
index 21deffa..c73af7b 100644
--- a/src/entities/portfolio/model/display.ts
+++ b/src/entities/portfolio/model/display.ts
@@ -17,6 +17,21 @@ export function formatMoney(money: Money | null): string {
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
}
+export function formatShare(
+ numerator: Money | null,
+ denominator: Money | null,
+): string | null {
+ if (!numerator || !denominator) return null;
+ const total = Number(denominator.value);
+ if (!Number.isFinite(total) || total <= 0) return null;
+ const percent = (Number(numerator.value) / total) * 100;
+ if (!Number.isFinite(percent)) return null;
+ const rounded = new Intl.NumberFormat("ru-RU", {
+ maximumFractionDigits: 1,
+ }).format(percent);
+ return `${rounded} %`;
+}
+
export function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
diff --git a/src/pages/account/ui/AccountPage.module.css b/src/pages/account/ui/AccountPage.module.css
index 109a4dc..c8a92b9 100644
--- a/src/pages/account/ui/AccountPage.module.css
+++ b/src/pages/account/ui/AccountPage.module.css
@@ -3,7 +3,7 @@
}
.total,
-.row,
+.tile,
.cash {
border-color: var(--mantine-color-gray-3);
background: var(--mantine-color-gray-0);
@@ -14,7 +14,7 @@
border-top: 3px solid var(--mantine-color-blue-6);
}
-.row,
+.tile,
.cash {
padding: var(--mantine-spacing-md);
}
@@ -24,8 +24,14 @@
justify-content: space-between;
}
-@media (max-width: 36em) {
- .row :global(.mantine-Group-root) {
- align-items: flex-start;
- }
+.positive {
+ color: var(--mantine-color-green-6);
+}
+
+.negative {
+ color: var(--mantine-color-red-6);
+}
+
+.mono {
+ font-family: var(--mantine-font-family-monospace);
}
diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx
index 7beca37..8c445d4 100644
--- a/src/pages/account/ui/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage.test.tsx
@@ -36,6 +36,7 @@ const portfolio: Portfolio = {
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
+ isin: null,
},
{
figi: "FIGI-1",
@@ -43,8 +44,9 @@ const portfolio: Portfolio = {
type: "BOND",
quantity: "1",
currentValue: { currency: "RUB", value: "10" },
- averagePurchasePrice: null,
+ averagePurchasePrice: { currency: "RUB", value: "11" },
absoluteReturn: { currency: "RUB", value: "-1" },
+ isin: "RU000A0JX0J2",
},
],
cashBalances: [{ currency: "USD", value: "0.5" }],
@@ -80,7 +82,7 @@ describe("AccountPage states", () => {
expect(retry).toHaveBeenCalledOnce();
});
- it("renders a stale portfolio in upstream order with nullable values", () => {
+ it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage();
expect(
@@ -91,16 +93,20 @@ describe("AccountPage states", () => {
"T-Bank временно недоступен. Показан последний успешный снимок.",
),
).toBeInTheDocument();
- expect(screen.getByText("—")).toBeInTheDocument();
- expect(screen.getByText("Доход: —")).toBeInTheDocument();
- const positions = screen
- .getAllByText(/Вторая|Первая/)
- .map((element) => element.textContent);
- expect(positions).toEqual(["Вторая", "Первая"]);
expect(screen.getByRole("heading", { name: "Акции" })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Облигации" }),
).toBeInTheDocument();
+ const positions = screen
+ .getAllByText(/Вторая|Первая/)
+ .map((element) => element.textContent);
+ expect(positions).toEqual(["Вторая", "Первая"]);
+ expect(screen.getByText("10 ₽")).toBeInTheDocument();
+ expect(screen.getByText("Доход: -1 ₽")).toBeInTheDocument();
+ expect(screen.getByText("Средняя цена: 11 ₽")).toBeInTheDocument();
+ expect(screen.getByText("Доля: 100 %")).toBeInTheDocument();
+ expect(screen.getByText("RU000A0JX0J2")).toBeInTheDocument();
+ expect(screen.queryByText("—")).not.toBeInTheDocument();
});
it("uses the cached account title when available", () => {
diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx
index 922d0d3..1fd0366 100644
--- a/src/pages/account/ui/AccountPage.view.tsx
+++ b/src/pages/account/ui/AccountPage.view.tsx
@@ -19,7 +19,11 @@ import {
useBrokerageEvents,
} from "@/entities/brokerage-event";
import type { Portfolio } from "@/entities/portfolio";
-import { formatMoney, formatSnapshotTime } from "@/entities/portfolio";
+import {
+ formatMoney,
+ formatShare,
+ formatSnapshotTime,
+} from "@/entities/portfolio";
import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
@@ -124,6 +128,15 @@ export function AccountPageLoaded({
const other = portfolio.positions.filter(
(p) => p.type !== "STOCK" && p.type !== "BOND",
);
+ const denominators = new Map();
+ for (const position of portfolio.positions) {
+ if (!position.currentValue) continue;
+ const currency = position.currentValue.currency;
+ denominators.set(
+ currency,
+ (denominators.get(currency) ?? 0) + Number(position.currentValue.value),
+ );
+ }
return (
@@ -148,15 +161,24 @@ export function AccountPageLoaded({
))}
{stocks.length ? (
-
+
) : null}
{bonds.length ? (
-
+
) : null}
{other.length ? (
) : null}
@@ -269,40 +291,97 @@ function RecentEvent({ event }: { event: BrokerageEvent }) {
function PositionSection({
title,
positions,
+ denominators,
}: {
title: string;
positions: Portfolio["positions"];
+ denominators: Map;
}) {
- const { t } = useTranslation();
return (
- {positions.map((position) => (
-
-
-
- {position.displayName}
-
- {t("portfolio.quantity", { value: position.quantity })}
-
-
-
- {formatMoney(position.currentValue)}
-
- {t("portfolio.return", {
- value: formatMoney(position.absoluteReturn),
- })}
-
-
-
-
- ))}
+
+ {positions.map((position) => (
+
+ ))}
+
);
}
+function PositionCard({
+ position,
+ denominators,
+}: {
+ position: Portfolio["positions"][number];
+ denominators: Map;
+}) {
+ const { t } = useTranslation();
+ const share = position.currentValue?.currency
+ ? formatShare(position.currentValue, {
+ currency: position.currentValue.currency,
+ value: String(denominators.get(position.currentValue.currency) ?? 0),
+ })
+ : null;
+ const returnClass = position.absoluteReturn
+ ? Number(position.absoluteReturn.value) > 0
+ ? classes.positive
+ : Number(position.absoluteReturn.value) < 0
+ ? classes.negative
+ : undefined
+ : undefined;
+ return (
+
+
+
+
+ {position.displayName}
+
+ {position.isin ? (
+
+ {position.isin}
+
+ ) : null}
+
+ {t("portfolio.quantity", { value: position.quantity })}
+
+
+
+ {position.currentValue ? (
+
+ {formatMoney(position.currentValue)}
+
+ ) : null}
+ {position.absoluteReturn ? (
+
+ {t("portfolio.return", {
+ value: formatMoney(position.absoluteReturn),
+ })}
+
+ ) : null}
+
+
+ {position.averagePurchasePrice || share ? (
+
+ {position.averagePurchasePrice ? (
+
+ {t("portfolio.avgPrice", {
+ value: formatMoney(position.averagePurchasePrice),
+ })}
+
+ ) : null}
+ {share ? (
+
+ {t("portfolio.share", { value: share })}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
function AccountHeading({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index b990e65..2b2dd9d 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -252,6 +252,7 @@ export interface components {
currentValue: components["schemas"]["MoneyDto"] | null;
averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
absoluteReturn: components["schemas"]["MoneyDto"] | null;
+ isin: string | null;
};
PortfolioDto: {
/** Format: date-time */
diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json
index 4a62ace..254681a 100644
--- a/src/shared/api/spec/openapi.json
+++ b/src/shared/api/spec/openapi.json
@@ -1341,6 +1341,10 @@
"$ref": "#/components/schemas/MoneyDto"
}
]
+ },
+ "isin": {
+ "type": "string",
+ "nullable": true
}
},
"required": [
@@ -1350,7 +1354,8 @@
"quantity",
"currentValue",
"averagePurchasePrice",
- "absoluteReturn"
+ "absoluteReturn",
+ "isin"
]
},
"PortfolioDto": {
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index 5897db7..3a03113 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -69,6 +69,8 @@ export const ru = {
cashBalances: "Денежные остатки",
quantity: "Количество: {{value}}",
return: "Доход: {{value}}",
+ avgPrice: "Средняя цена: {{value}}",
+ share: "Доля: {{value}}",
},
ledger: {
title: "Журнал операций",