Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en-US/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ export const configuration = {
},
stocksPage: {
markets: { all: 'All', cn: 'Mainland China', hk: 'Hong Kong', us: 'US', hkShort: 'HK', usShort: 'US' },
quoteStatus: { closed: 'Market closed today; no daily change', pre_market: 'Market has not opened; no daily change', stale: 'Today’s quote has not updated', missing: 'Quote unavailable', unknown: 'Market status unknown', asOf: 'Quote date: {{date}}' },
marketStatus: { trading: 'Open', pre_market: 'Pre-market', break: 'Midday break', after_hours: 'Closed', closed: 'Closed', unknown: 'Unknown' },
messages: {
agent: 'Agent',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-CN/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ export const configuration = {
},
stocksPage: {
markets: { all: '全部', cn: 'A股', hk: '港股', us: '美股', hkShort: '港', usShort: '美' },
quoteStatus: { closed: '今日休市,暂无今日涨跌', pre_market: '尚未开盘,暂无今日涨跌', stale: '今日行情尚未更新', missing: '暂无行情', unknown: '市场状态未知', asOf: '行情日期:{{date}}' },
marketStatus: { trading: '交易中', pre_market: '盘前', break: '午间休市', after_hours: '已收盘', closed: '休市', unknown: '未知' },
messages: {
agent: 'Agent',
Expand Down
197 changes: 197 additions & 0 deletions frontend/src/lib/portfolio-quotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
export interface DisplayQuote {
current_price: number | null
change_pct: number | null
daily_move_status?: string
quote_date?: string | null
}

export interface QuoteResponse extends DisplayQuote {
symbol: string
market: string
}

export interface Position {
id: number
stock_id: number
sort_order?: number
symbol: string
name: string
market: string
cost_price: number
quantity: number
invested_amount: number | null
trading_style: string // short: 短线, swing: 波段, long: 长线
current_price: number | null
current_price_cny: number | null // 人民币价格(港股换算后)
change_pct: number | null
market_value: number | null
market_value_cny: number | null // 人民币市值
pnl: number | null
pnl_pct: number | null
daily_pnl: number | null
daily_pnl_pct: number | null
exchange_rate: number | null // 汇率(仅港股)
daily_move_status?: string
quote_date?: string | null
}

export interface AccountSummary {
id: number
name: string
available_funds: number
total_market_value: number
total_cost: number
total_pnl: number
total_pnl_pct: number
total_daily_pnl: number
total_assets: number
positions: Position[]
}

export interface PortfolioSummary {
accounts: AccountSummary[]
total: {
total_market_value: number
total_cost: number
total_pnl: number
total_pnl_pct: number
total_daily_pnl: number
available_funds: number
total_assets: number
}
exchange_rates?: {
HKD_CNY: number
USD_CNY?: number
}
quotes?: Record<string, { current_price: number | null; change_pct: number | null }>
}

export const toQuoteMap = (rows: QuoteResponse[]): Record<string, DisplayQuote> => {
const map: Record<string, DisplayQuote> = {}
for (const item of rows || []) {
map[`${item.market}:${item.symbol}`] = {
current_price: item.current_price ?? null,
change_pct: item.change_pct ?? null,
daily_move_status: item.daily_move_status,
quote_date: item.quote_date,
}
}
return map
}

const round2 = (value: number) => Math.round(value * 100) / 100

export const mergePortfolioQuotes = (
portfolio: PortfolioSummary | null,
quotes: Record<string, DisplayQuote>
): PortfolioSummary | null => {
if (!portfolio) return null

const hkdRate = portfolio.exchange_rates?.HKD_CNY ?? 0.92
const usdRate = portfolio.exchange_rates?.USD_CNY ?? 7.25

let grandMarketValue = 0
let grandCost = 0
let grandAvailable = 0
let grandDailyPnl = 0

const accounts = portfolio.accounts.map(account => {
let accMarketValue = 0
let accCost = 0
let accDailyPnl = 0

const positions = account.positions.map(pos => {
const quote = quotes[`${pos.market}:${pos.symbol}`]
const current_price = quote?.current_price ?? pos.current_price ?? null
const daily_move_status = quote ? quote.daily_move_status : pos.daily_move_status
const inactive = daily_move_status != null && daily_move_status !== 'current'
// An explicit null from the latest quote must not resurrect an older daily change.
const change_pct = inactive ? null : quote ? quote.change_pct : pos.change_pct ?? null
const rate = pos.market === 'HK' ? hkdRate : pos.market === 'US' ? usdRate : 1

const cost = pos.cost_price * pos.quantity * rate
accCost += cost

let market_value: number | null = null
let market_value_cny: number | null = null
let pnl: number | null = null
let pnl_pct: number | null = null
let daily_pnl: number | null = null
let daily_pnl_pct: number | null = null

if (current_price != null) {
market_value = current_price * pos.quantity
market_value_cny = market_value * rate
accMarketValue += market_value_cny
pnl = market_value_cny - cost
pnl_pct = cost > 0 ? (pnl / cost * 100) : 0
}

if (daily_move_status === 'closed' || daily_move_status === 'pre_market') {
daily_pnl = 0
} else if (current_price != null && change_pct != null && change_pct !== -100) {
const prev = current_price / (1 + change_pct / 100)
if (isFinite(prev) && prev > 0) {
daily_pnl = round2((current_price - prev) * pos.quantity * rate)
daily_pnl_pct = round2(change_pct)
accDailyPnl += daily_pnl
}
}

return {
...pos,
current_price,
current_price_cny: current_price != null ? current_price * rate : null,
change_pct,
market_value,
market_value_cny,
pnl,
pnl_pct,
daily_pnl,
daily_pnl_pct,
daily_move_status,
quote_date: quote ? quote.quote_date : pos.quote_date,
exchange_rate: pos.market === 'HK' || pos.market === 'US' ? rate : null,
}
})

const accPnl = accMarketValue - accCost
const accPnlPct = accCost > 0 ? (accPnl / accCost * 100) : 0
const accTotalAssets = accMarketValue + account.available_funds

grandMarketValue += accMarketValue
grandCost += accCost
grandAvailable += account.available_funds
grandDailyPnl += accDailyPnl

return {
...account,
total_market_value: round2(accMarketValue),
total_cost: round2(accCost),
total_pnl: round2(accPnl),
total_pnl_pct: round2(accPnlPct),
total_daily_pnl: round2(accDailyPnl),
total_assets: round2(accTotalAssets),
positions,
}
})

const grandPnl = grandMarketValue - grandCost
const grandPnlPct = grandCost > 0 ? (grandPnl / grandCost * 100) : 0
const grandTotalAssets = grandMarketValue + grandAvailable

return {
...portfolio,
accounts,
total: {
total_market_value: round2(grandMarketValue),
total_cost: round2(grandCost),
total_pnl: round2(grandPnl),
total_pnl_pct: round2(grandPnlPct),
total_daily_pnl: round2(grandDailyPnl),
available_funds: round2(grandAvailable),
total_assets: round2(grandTotalAssets),
},
}
}

Loading
Loading