diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json
index de340ba..967e7eb 100644
--- a/scripts/lazy-route-bundle-baseline.json
+++ b/scripts/lazy-route-bundle-baseline.json
@@ -2,7 +2,7 @@
"src/app/routes/ProtectedLayout.tsx": 3137,
"src/pages/account-events/index.ts": 4468,
"src/pages/account-operations/index.ts": 13861,
- "src/pages/account/index.ts": 17018,
+ "src/pages/account/index.ts": 21868,
"src/pages/accounts/index.ts": 6174,
"src/pages/login/index.ts": 669,
"src/pages/not-found/index.ts": 850,
diff --git a/src/entities/portfolio/index.ts b/src/entities/portfolio/index.ts
index cf9c198..f425e0b 100644
--- a/src/entities/portfolio/index.ts
+++ b/src/entities/portfolio/index.ts
@@ -4,5 +4,6 @@ export {
formatMoney,
formatShare,
formatSnapshotTime,
+ instrumentLogoUrl,
portfolioErrorCode,
} from "./model/display";
diff --git a/src/entities/portfolio/model/display.test.ts b/src/entities/portfolio/model/display.test.ts
index 198b8e7..f1dd98b 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, formatShare } from "./display";
+import { formatMoney, formatShare, instrumentLogoUrl } from "./display";
describe("portfolio display helpers", () => {
it("formats decimal strings without financial arithmetic", () => {
@@ -33,4 +33,15 @@ describe("portfolio display helpers", () => {
),
).toBeNull();
});
+
+ it("builds the brand logo url from an upstream file name", () => {
+ expect(instrumentLogoUrl("sber3.png")).toBe(
+ "https://invest-brands.cdn-tinkoff.ru/sber3x160.png",
+ );
+ expect(instrumentLogoUrl(" RU000A0JPNM1.png ")).toBe(
+ "https://invest-brands.cdn-tinkoff.ru/RU000A0JPNM1x160.png",
+ );
+ expect(instrumentLogoUrl(null)).toBeNull();
+ expect(instrumentLogoUrl(" ")).toBeNull();
+ });
});
diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts
index c73af7b..71738c6 100644
--- a/src/entities/portfolio/model/display.ts
+++ b/src/entities/portfolio/model/display.ts
@@ -45,3 +45,14 @@ export function portfolioErrorCode(error: unknown): string | undefined {
? String(error.code)
: undefined;
}
+
+const logoSize = 160;
+
+export function instrumentLogoUrl(
+ logoName: string | null | undefined,
+): string | null {
+ const stem = logoName?.trim().replace(/\.png$/i, "");
+ return stem
+ ? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
+ : null;
+}
diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx
index 751c125..67c0122 100644
--- a/src/pages/account/ui/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage.test.tsx
@@ -56,6 +56,7 @@ const portfolio: Portfolio = {
averagePurchasePrice: null,
absoluteReturn: null,
isin: null,
+ logoName: "vtoraya.png",
},
{
figi: "FIGI-1",
@@ -66,6 +67,7 @@ const portfolio: Portfolio = {
averagePurchasePrice: { currency: "RUB", value: "11" },
absoluteReturn: { currency: "RUB", value: "-1" },
isin: "RU000A0JX0J2",
+ logoName: null,
},
],
cashBalances: [{ currency: "USD", value: "0.5" }],
@@ -126,6 +128,49 @@ describe("AccountPage states", () => {
expect(screen.getByText("Доля: 100 %")).toBeInTheDocument();
expect(screen.getByText("RU000A0JX0J2")).toBeInTheDocument();
expect(screen.queryByText("—")).not.toBeInTheDocument();
+ expect(screen.getByRole("img", { name: "Вторая" })).toHaveAttribute(
+ "src",
+ "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png",
+ );
+ });
+
+ it("renders stock cards with logo fallback and signed unlabeled return", () => {
+ renderPage(
+ ,
+ );
+ expect(screen.getByText("А")).toBeInTheDocument();
+ expect(screen.getByText("+3 ₽")).toBeInTheDocument();
+ expect(screen.getByText("0 ₽")).toBeInTheDocument();
+ expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
+ expect(screen.queryByRole("img", { name: "Абра" })).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 758280c..daa894f 100644
--- a/src/pages/account/ui/AccountPage.view.tsx
+++ b/src/pages/account/ui/AccountPage.view.tsx
@@ -1,5 +1,6 @@
import {
Alert,
+ Avatar,
Button,
Card,
Group,
@@ -10,6 +11,7 @@ import {
Text,
Title,
} from "@mantine/core";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useBrokerageOperations } from "@/entities/broker-operation";
@@ -24,6 +26,7 @@ import {
formatMoney,
formatShare,
formatSnapshotTime,
+ instrumentLogoUrl,
} from "@/entities/portfolio";
import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
@@ -188,6 +191,7 @@ export function AccountPageLoaded({
title={t("portfolio.stocks")}
positions={stocks}
denominators={denominators}
+ stock
/>
) : null}
{bonds.length ? (
@@ -349,19 +353,22 @@ function PositionSection({
title,
positions,
denominators,
+ stock = false,
}: {
title: string;
positions: Portfolio["positions"];
denominators: Map;
+ stock?: boolean;
}) {
return (
-
+
{positions.map((position) => (
))}
@@ -371,9 +378,11 @@ function PositionSection({
function PositionCard({
position,
denominators,
+ stock = false,
}: {
position: Portfolio["positions"][number];
denominators: Map;
+ stock?: boolean;
}) {
const { t } = useTranslation();
const share = position.currentValue?.currency
@@ -389,9 +398,24 @@ function PositionCard({
? classes.negative
: undefined
: undefined;
+ const returnText = position.absoluteReturn
+ ? stock
+ ? `${Number(position.absoluteReturn.value) > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
+ : t("portfolio.return", {
+ value: formatMoney(position.absoluteReturn),
+ })
+ : null;
+ const logoSrc = stock ? instrumentLogoUrl(position.logoName) : null;
return (
+ {stock ? (
+
+ ) : null}
{position.displayName}
@@ -411,11 +435,9 @@ function PositionCard({
{formatMoney(position.currentValue)}
) : null}
- {position.absoluteReturn ? (
+ {returnText ? (
- {t("portfolio.return", {
- value: formatMoney(position.absoluteReturn),
- })}
+ {returnText}
) : null}
@@ -439,6 +461,24 @@ function PositionCard({
);
}
+function PositionLogo({ src, label }: { src: string | null; label: string }) {
+ const [failed, setFailed] = useState(false);
+ if (src && !failed)
+ return (
+ setFailed(true)}
+ />
+ );
+ return (
+
+ {(label.trim()[0] ?? "?").toUpperCase()}
+
+ );
+}
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 2b2dd9d..0d01bf8 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -253,6 +253,7 @@ export interface components {
averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
absoluteReturn: components["schemas"]["MoneyDto"] | null;
isin: string | null;
+ logoName: string | null;
};
PortfolioDto: {
/** Format: date-time */
diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json
index 254681a..d8e3e6d 100644
--- a/src/shared/api/spec/openapi.json
+++ b/src/shared/api/spec/openapi.json
@@ -1345,6 +1345,10 @@
"isin": {
"type": "string",
"nullable": true
+ },
+ "logoName": {
+ "type": "string",
+ "nullable": true
}
},
"required": [
@@ -1355,7 +1359,8 @@
"currentValue",
"averagePurchasePrice",
"absoluteReturn",
- "isin"
+ "isin",
+ "logoName"
]
},
"PortfolioDto": {