ENH - add portfolio performance forecast

Replace the decorative market sparkline with a holdings-weighted profit and loss chart. Anchor the solid history and dashed forecast to the same current-position point and localize the new labels.
This commit is contained in:
smx.pusha
2026-08-18 10:08:25 +02:00
parent 469b458551
commit bbca5590d4
5 changed files with 317 additions and 49 deletions
+6
View File
@@ -448,6 +448,12 @@ const cryptoFallbackLocales = {
},
portfolio: {
total: 'Total portfolio',
performance: 'Profit / loss',
return: 'Total return',
current: 'Current position',
history: 'Performance',
forecast: 'Forecast',
chartLabel: 'Profit and loss performance with dashed forecast',
cash: 'Available cash',
invested: 'Invested assets',
allocation: 'Portfolio allocation',
@@ -63,6 +63,16 @@ describe('VaultX crypto app contracts', () => {
expect(source).not.toContain(':aria-label="t(\'refresh\')"')
})
it('anchors portfolio performance history and forecast at the current position', () => {
expect(source).toContain('const portfolioProfitLoss = computed')
expect(source).toContain('class="portfolio-history"')
expect(source).toContain('class="portfolio-forecast"')
expect(source).toContain(':cx="portfolioChart.currentX"')
expect(source).toContain(':cy="portfolioChart.currentY"')
expect(source).toContain("t('portfolio.current')")
expect(source).toContain('stroke-dasharray: 7 7')
})
it('uses the compact Flare-style navigation and polished overlay transitions', () => {
expect(source).toContain('class="vault-navbar"')
expect(source).toContain('class="vault-header-logo"')
+299 -47
View File
@@ -80,13 +80,6 @@ const activities = computed(() =>
: ['deposit', 'withdrawal'].includes(item.type)),
),
)
const portfolioLine = computed(() => markets.value[0]?.sparkline ?? [])
const portfolioChange = computed(() =>
markets.value.length
? markets.value.reduce((sum, market) => sum + market.changePercent, 0) /
markets.value.length
: 0,
)
const investedValue = computed(() =>
Math.max(
0,
@@ -94,6 +87,109 @@ const investedValue = computed(() =>
Number(crypto.data?.cashBalance ?? 0),
),
)
const portfolioCostBasis = computed(() =>
holdings.value.reduce(
(sum, holding) =>
sum + Number(holding.quantity) * Number(holding.averagePrice),
0,
),
)
const portfolioProfitLoss = computed(
() => investedValue.value - portfolioCostBasis.value,
)
const portfolioProfitPercent = computed(() =>
portfolioCostBasis.value > 0
? (portfolioProfitLoss.value / portfolioCostBasis.value) * 100
: 0,
)
const portfolioChart = computed(() => {
const width = 320
const height = 126
const startX = 18
const currentX = 224
const forecastEndX = 306
const sampleCount = 10
const weightedMarkets = holdings.value
.map((holding) => ({
cost: Number(holding.quantity) * Number(holding.averagePrice),
market: markets.value.find((item) => item.id === holding.assetId),
}))
.filter((item): item is { cost: number; market: CryptoMarket } =>
Boolean(item.market?.sparkline.length),
)
const totalWeight =
weightedMarkets.reduce((sum, item) => sum + item.cost, 0) || 1
const reference = Array.from({ length: sampleCount }, (_, index) =>
weightedMarkets.reduce((sum, item) => {
const sourceIndex = Math.round(
(index / (sampleCount - 1)) * (item.market.sparkline.length - 1),
)
return (
sum + item.market.sparkline[sourceIndex] * (item.cost / totalWeight)
)
}, 0),
)
const currentProfit = portfolioProfitLoss.value
const referenceEnd = reference[reference.length - 1] ?? 0.5
const amplitude = Math.max(
portfolioCostBasis.value * 0.045,
Math.abs(currentProfit) * 0.38,
40,
)
const historyValues = reference.map(
(value) => currentProfit + (value - referenceEnd) * amplitude,
)
historyValues[historyValues.length - 1] = currentProfit
const recentMomentum =
currentProfit - (historyValues[historyValues.length - 3] ?? currentProfit)
const forecastValues = [
currentProfit,
currentProfit + recentMomentum * 0.42,
currentProfit + recentMomentum * 0.58 + amplitude * 0.035,
currentProfit + recentMomentum * 0.48 - amplitude * 0.018,
]
const extent = Math.max(
50,
...historyValues.map((value) => Math.abs(value)),
...forecastValues.map((value) => Math.abs(value)),
)
const scale = Math.ceil((extent * 1.18) / 50) * 50
const chartTop = 15
const chartBottom = height - 16
const chartRange = chartBottom - chartTop
const y = (value: number) =>
chartTop + ((scale - value) / (scale * 2)) * chartRange
const historyPoints = historyValues
.map(
(value, index) =>
`${
startX + index * ((currentX - startX) / (historyValues.length - 1))
},${y(value)}`,
)
.join(' ')
const forecastPoints = forecastValues
.map(
(value, index) =>
`${
currentX +
index * ((forecastEndX - currentX) / (forecastValues.length - 1))
},${y(value)}`,
)
.join(' ')
return {
currentX,
currentY: y(currentProfit),
forecastPoints,
height,
historyPoints,
labelY: Math.max(3, Math.min(height - 29, y(currentProfit) - 27)),
scale,
width,
zeroY: y(0),
}
})
const cashShare = computed(() => {
const total = Number(crypto.data?.portfolioValue ?? 0)
return total > 0
@@ -123,9 +219,18 @@ function money(value: string | number) {
style: 'currency',
}).format(Number(value) || 0)
}
function signedMoney(value: number) {
const formatted = money(Math.abs(value))
if (value > 0) return `+${formatted}`
if (value < 0) return `${formatted}`
return formatted
}
function privateMoney(value: string | number) {
return profile.value?.hideBalances ? '••••••' : money(value)
}
function privateSignedMoney(value: number) {
return profile.value?.hideBalances ? '••••••' : signedMoney(value)
}
function quantity(value: string) {
return new Intl.NumberFormat(locale.value, {
maximumFractionDigits: 6,
@@ -515,34 +620,105 @@ onMounted(() => void crypto.load())
<section class="portfolio-shell">
<div class="portfolio-glow" />
<div class="portfolio-topline">
<span><ShieldCheck :size="13" />{{ t('portfolio.total') }}</span>
<span class="live-pill"><i />{{ t('markets.live') }}</span>
<span
><ShieldCheck :size="13" />{{ t('portfolio.performance') }}</span
>
<span class="forecast-pill">{{ t('portfolio.forecast') }}</span>
</div>
<div class="portfolio-value">
<strong>{{
privateMoney(crypto.data?.portfolioValue ?? '0')
}}</strong>
<span :class="portfolioChange >= 0 ? 'up' : 'down'">
{{ portfolioChange >= 0 ? '↗' : '↘' }}
{{ Math.abs(portfolioChange).toFixed(2) }}%
<small>{{ t('marketDetail.today') }}</small>
<strong :class="portfolioProfitLoss >= 0 ? 'up' : 'down'">
{{ privateSignedMoney(portfolioProfitLoss) }}
</strong>
<span :class="portfolioProfitPercent >= 0 ? 'up' : 'down'">
{{ portfolioProfitPercent >= 0 ? '↗' : '↘' }}
{{ Math.abs(portfolioProfitPercent).toFixed(2) }}%
<small>{{ t('portfolio.return') }}</small>
</span>
</div>
<div class="portfolio-chart">
<svg viewBox="0 0 320 112" preserveAspectRatio="none">
<defs>
<linearGradient id="portfolio-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#65fbd2" stop-opacity=".32" />
<stop offset="1" stop-color="#65fbd2" stop-opacity="0" />
</linearGradient>
</defs>
<polygon
:points="`0,112 ${points(portfolioLine, 320, 106)} 320,112`"
fill="url(#portfolio-fill)"
<div
class="portfolio-chart"
role="img"
:aria-label="t('portfolio.chartLabel')"
>
<div class="portfolio-chart__legend">
<span><i />{{ t('portfolio.history') }}</span>
<span
><i class="is-forecast" />{{ t('portfolio.forecast') }}</span
>
</div>
<svg
:viewBox="`0 0 ${portfolioChart.width} ${portfolioChart.height}`"
aria-hidden="true"
>
<rect
class="portfolio-chart__forecast-zone"
:x="portfolioChart.currentX"
y="0"
:width="portfolioChart.width - portfolioChart.currentX"
:height="portfolioChart.height"
/>
<polyline :points="points(portfolioLine, 320, 106)" />
<line
class="portfolio-chart__grid"
x1="18"
y1="15"
x2="306"
y2="15"
/>
<line
class="portfolio-chart__zero"
x1="18"
:y1="portfolioChart.zeroY"
x2="306"
:y2="portfolioChart.zeroY"
/>
<line
class="portfolio-chart__grid"
x1="18"
:y1="portfolioChart.height - 16"
x2="306"
:y2="portfolioChart.height - 16"
/>
<line
class="portfolio-chart__boundary"
:x1="portfolioChart.currentX"
y1="4"
:x2="portfolioChart.currentX"
:y2="portfolioChart.height - 8"
/>
<polyline
class="portfolio-history"
:points="portfolioChart.historyPoints"
/>
<polyline
class="portfolio-forecast"
:points="portfolioChart.forecastPoints"
/>
<circle
class="portfolio-current-dot"
:cx="portfolioChart.currentX"
:cy="portfolioChart.currentY"
r="4.5"
/>
<g
class="portfolio-current-label"
:transform="`translate(${portfolioChart.currentX - 48} ${portfolioChart.labelY})`"
>
<rect width="96" height="20" rx="10" />
<text x="48" y="13" text-anchor="middle">
{{ t('portfolio.current') }}
</text>
</g>
<text class="portfolio-scale-label" x="18" y="10">
{{ privateSignedMoney(portfolioChart.scale) }}
</text>
<text
class="portfolio-scale-label"
x="18"
:y="portfolioChart.height - 3"
>
{{ privateSignedMoney(-portfolioChart.scale) }}
</text>
</svg>
<span class="chart-orb" />
</div>
<div class="portfolio-metrics">
<div>
@@ -1846,6 +2022,15 @@ onMounted(() => void crypto.load())
.portfolio-topline svg {
color: var(--vault-mint);
}
.forecast-pill {
display: inline-flex;
align-items: center;
padding: 5px 8px;
color: #b9c8ff;
background: rgba(112, 143, 255, 0.09);
border: 1px solid rgba(140, 169, 255, 0.18);
border-radius: var(--sky-radius-pill);
}
.live-pill,
.profile-status {
display: inline-flex;
@@ -1889,15 +2074,67 @@ onMounted(() => void crypto.load())
}
.portfolio-chart {
position: relative;
height: 112px;
margin: 2px -17px 0;
height: 148px;
margin: 8px -8px 0;
}
.portfolio-chart svg {
display: block;
width: 100%;
height: 100%;
height: 126px;
overflow: visible;
}
.portfolio-chart polyline,
.portfolio-chart__legend {
display: flex;
justify-content: flex-end;
gap: 12px;
height: 22px;
padding-right: 7px;
color: var(--muted);
font-size: 7px;
font-weight: 700;
}
.portfolio-chart__legend span {
display: flex;
align-items: center;
gap: 4px;
}
.portfolio-chart__legend i {
width: 13px;
height: 2px;
border-radius: 4px;
background: var(--vault-mint);
}
.portfolio-chart__legend i.is-forecast {
background: repeating-linear-gradient(
90deg,
#8ca9ff 0 4px,
transparent 4px 7px
);
}
.portfolio-chart__forecast-zone {
fill: rgba(112, 143, 255, 0.055);
}
.portfolio-chart__grid,
.portfolio-chart__zero,
.portfolio-chart__boundary {
vector-effect: non-scaling-stroke;
}
.portfolio-chart__grid {
stroke: rgba(255, 255, 255, 0.055);
stroke-width: 1;
}
.portfolio-chart__zero {
stroke: rgba(255, 255, 255, 0.2);
stroke-width: 1;
stroke-dasharray: 2 5;
}
.portfolio-chart__boundary {
stroke: rgba(140, 169, 255, 0.22);
stroke-width: 1;
stroke-dasharray: 3 5;
}
.portfolio-history,
.portfolio-forecast,
.featured-market polyline {
fill: none;
stroke: #dffff6;
@@ -1907,18 +2144,34 @@ onMounted(() => void crypto.load())
vector-effect: non-scaling-stroke;
filter: drop-shadow(0 0 8px rgba(101, 251, 210, 0.5));
}
.chart-orb {
position: absolute;
right: 11%;
top: 28%;
width: 9px;
height: 9px;
border: 2px solid #fff;
border-radius: 50%;
background: var(--vault-mint);
box-shadow:
0 0 0 5px rgba(101, 251, 210, 0.12),
0 0 17px var(--vault-mint);
.portfolio-forecast {
stroke: #8ca9ff;
stroke-dasharray: 7 7;
filter: drop-shadow(0 0 7px rgba(112, 143, 255, 0.4));
}
.portfolio-current-dot {
fill: var(--vault-mint);
stroke: #fff;
stroke-width: 2;
vector-effect: non-scaling-stroke;
filter: drop-shadow(0 0 6px var(--vault-mint));
}
.portfolio-current-label rect {
fill: #101923;
stroke: rgba(101, 251, 210, 0.45);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.portfolio-current-label text {
fill: #fff;
font-size: 7px;
font-weight: 800;
letter-spacing: 0.01em;
}
.portfolio-scale-label {
fill: rgba(255, 255, 255, 0.46);
font-size: 7px;
font-weight: 700;
}
.portfolio-metrics {
position: relative;
@@ -2654,8 +2907,7 @@ onMounted(() => void crypto.load())
border: 1px solid rgba(255, 255, 255, 0.07);
}
@media (prefers-reduced-motion: reduce) {
.portfolio-shell::after,
.chart-orb {
.portfolio-shell::after {
display: none;
}
.vault-view {
+1 -1
View File
@@ -1055,7 +1055,7 @@ Locales["de"] = {
eyebrow = "Charaktergebundene Börse", loginTitle = "Willkommen zurück", registerTitle = "Erstelle dein VaultX-Profil", body = "Handle fiktive digitale Assets mit dem Ingame-Geld deines Charakters.", login = "Einloggen", register = "Registrieren", handle = "VaultX-Benutzername", handlePlaceholder = "320 Buchstaben oder Zahlen", password = "Passwort", passwordPlaceholder = "872 Zeichen", showPassword = "Passwort anzeigen", hidePassword = "Passwort ausblenden", security = "Nutze ein In-Character-Passwort. Verwende niemals ein echtes Passwort erneut.", loginAction = "Portfolio öffnen", registerAction = "Sicheres Profil erstellen",
},
tabs = { portfolio = "Portfolio", markets = "Märkte", activity = "Aktivität", profile = "Profil" },
portfolio = { total = "Gesamtes Portfolio", cash = "Verfügbares Guthaben", invested = "Investierte Assets", allocation = "Portfolio-Verteilung", inMarket = "in Märkten", assets = "Assets", holdings = "Deine Assets", empty = "Noch keine Assets", emptyBody = "Öffne Märkte und fordere ein geschütztes Angebot an.", avg = "Ø", insightTitle = "Portfolio-Einblick", insightBody = "Deine Bestände werden mit den neuesten synthetischen Serverkursen bewertet." },
portfolio = { total = "Gesamtes Portfolio", performance = "Gewinn / Verlust", return = "Gesamtrendite", current = "Jetziger Stand", history = "Entwicklung", forecast = "Prognose", chartLabel = "Gewinn- und Verlustentwicklung mit gestrichelter Prognose", cash = "Verfügbares Guthaben", invested = "Investierte Assets", allocation = "Portfolio-Verteilung", inMarket = "in Märkten", assets = "Assets", holdings = "Deine Assets", empty = "Noch keine Assets", emptyBody = "Öffne Märkte und fordere ein geschütztes Angebot an.", avg = "Ø", insightTitle = "Portfolio-Einblick", insightBody = "Deine Bestände werden mit den neuesten synthetischen Serverkursen bewertet." },
quick = { trade = "Handeln", more = "Mehr" },
actions = { deposit = "Einzahlen", withdraw = "Auszahlen" },
markets = { title = "Fiktive Märkte", live = "Serverpreise", noticeTitle = "Ingame-Börse", notice = "Die Kurse sind synthetisch und haben keinen realen Wert. Jeder Handel wird gegen die begrenzte Liquidität der Börse geprüft.", eyebrow = "Marktpuls", movers = "Top-Beweger", today = "Heute" },
+1 -1
View File
@@ -1055,7 +1055,7 @@ Locales["en"] = {
eyebrow = "Character-bound exchange", loginTitle = "Welcome back", registerTitle = "Create your VaultX profile", body = "Trade fictional digital assets with your character's in-game funds.", login = "Log in", register = "Register", handle = "VaultX handle", handlePlaceholder = "320 letters or numbers", password = "Password", passwordPlaceholder = "872 characters", showPassword = "Show password", hidePassword = "Hide password", security = "Use an in-character password. Never reuse a real password.", loginAction = "Open portfolio", registerAction = "Create secure profile",
},
tabs = { portfolio = "Portfolio", markets = "Markets", activity = "Activity", profile = "Profile" },
portfolio = { total = "Total portfolio", cash = "Available cash", invested = "Invested assets", allocation = "Portfolio allocation", inMarket = "in markets", assets = "assets", holdings = "Your assets", empty = "No assets yet", emptyBody = "Open Markets to request a protected quote.", avg = "Avg.", insightTitle = "Portfolio insight", insightBody = "Your holdings are valued against the latest synthetic server prices." },
portfolio = { total = "Total portfolio", performance = "Profit / loss", return = "Total return", current = "Current position", history = "Performance", forecast = "Forecast", chartLabel = "Profit and loss performance with dashed forecast", cash = "Available cash", invested = "Invested assets", allocation = "Portfolio allocation", inMarket = "in markets", assets = "assets", holdings = "Your assets", empty = "No assets yet", emptyBody = "Open Markets to request a protected quote.", avg = "Avg.", insightTitle = "Portfolio insight", insightBody = "Your holdings are valued against the latest synthetic server prices." },
quick = { trade = "Trade", more = "More" },
actions = { deposit = "Deposit", withdraw = "Withdraw" },
markets = { title = "Fictional markets", live = "Server priced", noticeTitle = "In-game exchange", notice = "Prices are synthetic and have no real-world value. Every trade is checked against finite exchange liquidity.", eyebrow = "Market pulse", movers = "Top movers", today = "Today" },