feat: display Korean stock names in portfolio views
All checks were successful
Deploy to Production / deploy (push) Successful in 1m35s
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:
parent
51fb812d57
commit
0cd1e931b0
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user