feat: display Korean stock names in portfolio views
All checks were successful
Deploy to Production / deploy (push) Successful in 1m35s

The portfolio API was returning only ticker symbols (e.g., "095570")
without stock names. The Stock table already has Korean names
(e.g., "AJ네트웍스") from data collection.

Backend: Add name field to HoldingWithValue schema, fetch stock names
via RebalanceService.get_stock_names() in the portfolio detail endpoint.

Frontend: Show Korean stock name as primary label with ticker as
subtitle in portfolio detail, donut charts, and target vs actual
comparison. Dashboard donut chart also shows names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-13 23:22:48 +09:00
parent 51fb812d57
commit 0cd1e931b0
4 changed files with 25 additions and 10 deletions

View File

@ -354,10 +354,11 @@ async def get_portfolio_detail(
"""Get portfolio with calculated values.""" """Get portfolio with calculated values."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id) portfolio = _get_portfolio(db, portfolio_id, current_user.id)
# Get current prices # Get current prices and stock names
tickers = [h.ticker for h in portfolio.holdings] tickers = [h.ticker for h in portfolio.holdings]
service = RebalanceService(db) service = RebalanceService(db)
prices = service.get_current_prices(tickers) prices = service.get_current_prices(tickers)
names = service.get_stock_names(tickers)
# Calculate holding values # Calculate holding values
holdings_with_value = [] holdings_with_value = []
@ -376,6 +377,7 @@ async def get_portfolio_detail(
holdings_with_value.append(HoldingWithValue( holdings_with_value.append(HoldingWithValue(
ticker=holding.ticker, ticker=holding.ticker,
name=names.get(holding.ticker),
quantity=holding.quantity, quantity=holding.quantity,
avg_price=Decimal(str(holding.avg_price)), avg_price=Decimal(str(holding.avg_price)),
current_price=current_price, current_price=current_price,

View File

@ -47,6 +47,7 @@ class HoldingResponse(HoldingBase):
class HoldingWithValue(HoldingResponse): class HoldingWithValue(HoldingResponse):
"""Holding with calculated values.""" """Holding with calculated values."""
name: str | None = None
current_price: FloatDecimal | None = None current_price: FloatDecimal | None = None
value: FloatDecimal | None = None value: FloatDecimal | None = None
current_ratio: FloatDecimal | None = None current_ratio: FloatDecimal | None = None

View File

@ -11,6 +11,7 @@ import { api } from '@/lib/api';
interface HoldingWithValue { interface HoldingWithValue {
ticker: string; ticker: string;
name: string | null;
current_ratio: number | null; current_ratio: number | null;
value: number | null; value: number | null;
profit_loss_ratio: number | null; profit_loss_ratio: number | null;
@ -93,22 +94,27 @@ export default function DashboardPage() {
// Aggregate holdings for donut chart // Aggregate holdings for donut chart
const allHoldings = portfolios.flatMap((p) => p.holdings); const allHoldings = portfolios.flatMap((p) => p.holdings);
const holdingsByTicker: Record<string, number> = {}; const holdingsByTicker: Record<string, { value: number; name: string }> = {};
for (const h of allHoldings) { for (const h of allHoldings) {
if (h.value && h.value > 0) { if (h.value && h.value > 0) {
holdingsByTicker[h.ticker] = (holdingsByTicker[h.ticker] ?? 0) + h.value; const existing = holdingsByTicker[h.ticker];
holdingsByTicker[h.ticker] = {
value: (existing?.value ?? 0) + h.value,
name: h.name || existing?.name || h.ticker,
};
} }
} }
const donutData = Object.entries(holdingsByTicker) const donutData = Object.entries(holdingsByTicker)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1].value - a[1].value)
.slice(0, 6) .slice(0, 6)
.map(([ticker, value], i) => ({ .map(([, { value, name }], i) => ({
name: ticker, name,
value: totalValue > 0 ? (value / totalValue) * 100 : 0, value: totalValue > 0 ? (value / totalValue) * 100 : 0,
color: CHART_COLORS[i % CHART_COLORS.length], color: CHART_COLORS[i % CHART_COLORS.length],
})); }));
// Add "기타" if there are more holdings // Add "기타" if there are more holdings
const totalHoldingsCount = Object.keys(holdingsByTicker).length;
const topValue = donutData.reduce((s, d) => s + d.value, 0); const topValue = donutData.reduce((s, d) => s + d.value, 0);
if (topValue < 100 && topValue > 0) { if (topValue < 100 && topValue > 0) {
donutData.push({ name: '기타', value: 100 - topValue, color: '#6b7280' }); donutData.push({ name: '기타', value: 100 - topValue, color: '#6b7280' });
@ -172,7 +178,7 @@ export default function DashboardPage() {
<RefreshCw className="h-4 w-4 text-muted-foreground" /> <RefreshCw className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{Object.keys(holdingsByTicker).length}</div> <div className="text-2xl font-bold">{totalHoldingsCount}</div>
<p className="text-xs text-muted-foreground"> </p> <p className="text-xs text-muted-foreground"> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -15,6 +15,7 @@ import type { AreaData, Time } from 'lightweight-charts';
interface HoldingWithValue { interface HoldingWithValue {
ticker: string; ticker: string;
name: string | null;
quantity: number; quantity: number;
avg_price: number; avg_price: number;
current_price: number | null; current_price: number | null;
@ -163,7 +164,7 @@ export default function PortfolioDetailPage() {
return portfolio.holdings return portfolio.holdings
.filter((h) => h.current_ratio !== null && h.current_ratio > 0) .filter((h) => h.current_ratio !== null && h.current_ratio > 0)
.map((h, index) => ({ .map((h, index) => ({
name: h.ticker, name: h.name || h.ticker,
value: h.current_ratio ?? 0, value: h.current_ratio ?? 0,
color: CHART_COLORS[index % CHART_COLORS.length], color: CHART_COLORS[index % CHART_COLORS.length],
})); }));
@ -339,7 +340,12 @@ export default function PortfolioDetailPage() {
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{portfolio.holdings.map((holding, index) => ( {portfolio.holdings.map((holding, index) => (
<tr key={holding.ticker}> <tr key={holding.ticker}>
<td className="px-4 py-3 text-sm font-medium">{holding.ticker}</td> <td className="px-4 py-3">
<div className="font-medium text-sm">{holding.name || holding.ticker}</div>
{holding.name && (
<div className="text-xs text-muted-foreground">{holding.ticker}</div>
)}
</td>
<td className="px-4 py-3 text-sm text-right"> <td className="px-4 py-3 text-sm text-right">
{holding.quantity.toLocaleString()} {holding.quantity.toLocaleString()}
</td> </td>
@ -513,7 +519,7 @@ export default function PortfolioDetailPage() {
return ( return (
<div key={target.ticker} className="space-y-2"> <div key={target.ticker} className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="font-medium">{target.ticker}</span> <span className="font-medium">{holding?.name || target.ticker}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{actualRatio.toFixed(1)}% / {target.target_ratio.toFixed(1)}% {actualRatio.toFixed(1)}% / {target.target_ratio.toFixed(1)}%
<span <span