mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: add personal finance calendar
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BellRing, CalendarDays, Clock3 } from 'lucide-react';
|
||||
import { Pie, PieChart, ResponsiveContainer, Tooltip, Legend, Cell } from 'recharts';
|
||||
import { decisionSignalsApi } from '../api/decisionSignals';
|
||||
import { portfolioApi } from '../api/portfolio';
|
||||
import type { ParsedApiError } from '../api/error';
|
||||
import { getParsedApiError } from '../api/error';
|
||||
import { ApiErrorAlert, Card, Badge, ConfirmDialog, EmptyState, InlineAlert } from '../components/common';
|
||||
import { ApiErrorAlert, Card, Badge, ConfirmDialog, EmptyState, InlineAlert, StatusDot } from '../components/common';
|
||||
import { PortfolioSignalSummary } from '../components/decision-signals/DecisionSignalDisplay';
|
||||
import { useUiLanguage } from '../contexts/UiLanguageContext';
|
||||
import { formatUiText } from '../i18n/uiText';
|
||||
@@ -55,6 +56,9 @@ import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../utils/de
|
||||
|
||||
const PIE_COLORS = ['#00d4ff', '#00ff88', '#ffaa00', '#ff7a45', '#7f8cff', '#ff4466'];
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const FINANCE_CALENDAR_PAGE_SIZE = 100;
|
||||
const FINANCE_CALENDAR_LOOKBACK_DAYS = 14;
|
||||
const FINANCE_CALENDAR_LOOKAHEAD_DAYS = 45;
|
||||
const PORTFOLIO_SIGNAL_LOOKUP_CONCURRENCY = 6;
|
||||
const FALLBACK_BROKERS: PortfolioImportBrokerItem[] = [
|
||||
{ broker: 'huatai', aliases: [], displayName: '华泰' },
|
||||
@@ -82,6 +86,173 @@ type PortfolioSignalLookupResult = {
|
||||
|
||||
type PortfolioPageLanguage = 'zh' | 'en';
|
||||
|
||||
type FinanceCalendarText = {
|
||||
title: string;
|
||||
window: string;
|
||||
refresh: string;
|
||||
refreshing: string;
|
||||
reminders: string;
|
||||
timeline: string;
|
||||
needsAction: string;
|
||||
today: string;
|
||||
next7Days: string;
|
||||
completed: string;
|
||||
later: string;
|
||||
noEventsTitle: string;
|
||||
noEventsDescription: string;
|
||||
warningTitle: string;
|
||||
trade: string;
|
||||
cash: string;
|
||||
corporate: string;
|
||||
valuation: string;
|
||||
risk: string;
|
||||
buy: string;
|
||||
sell: string;
|
||||
cashIn: string;
|
||||
cashOut: string;
|
||||
cashDividend: string;
|
||||
splitAdjustment: string;
|
||||
fxStaleTitle: string;
|
||||
fxStaleDetail: string;
|
||||
priceQualityTitle: string;
|
||||
priceQualityDetail: string;
|
||||
stopLossTitle: string;
|
||||
stopLossDetail: string;
|
||||
drawdownTitle: string;
|
||||
drawdownDetail: string;
|
||||
aiRiskTitle: string;
|
||||
aiRiskDetail: string;
|
||||
account: string;
|
||||
quantity: string;
|
||||
price: string;
|
||||
amount: string;
|
||||
dividend: string;
|
||||
splitRatio: string;
|
||||
missingPrice: string;
|
||||
stalePrice: string;
|
||||
};
|
||||
|
||||
type FinanceCalendarTone = 'success' | 'warning' | 'danger' | 'info' | 'neutral';
|
||||
type FinanceCalendarKind = 'trade' | 'cash' | 'corporate' | 'valuation' | 'risk';
|
||||
type FinanceCalendarStatus = 'completed' | 'today' | 'next7' | 'later';
|
||||
|
||||
type FinanceCalendarItem = {
|
||||
id: string;
|
||||
date: string;
|
||||
kind: FinanceCalendarKind;
|
||||
tone: FinanceCalendarTone;
|
||||
title: string;
|
||||
detail: string;
|
||||
status: FinanceCalendarStatus;
|
||||
needsAction: boolean;
|
||||
};
|
||||
|
||||
type FinanceCalendarStats = {
|
||||
needsAction: number;
|
||||
today: number;
|
||||
next7Days: number;
|
||||
completed: number;
|
||||
};
|
||||
|
||||
type FinanceCalendarSourceEvents = {
|
||||
trades: PortfolioTradeListItem[];
|
||||
cash: PortfolioCashLedgerListItem[];
|
||||
corporate: PortfolioCorporateActionListItem[];
|
||||
};
|
||||
|
||||
const FINANCE_CALENDAR_TEXT: Record<PortfolioPageLanguage, FinanceCalendarText> = {
|
||||
zh: {
|
||||
title: '个人财务日历',
|
||||
window: '近 14 天 / 未来 45 天',
|
||||
refresh: '刷新日历',
|
||||
refreshing: '刷新中...',
|
||||
reminders: '提醒',
|
||||
timeline: '事件时间线',
|
||||
needsAction: '需处理',
|
||||
today: '今日',
|
||||
next7Days: '7 日内',
|
||||
completed: '已记录',
|
||||
later: '后续',
|
||||
noEventsTitle: '暂无日历事件',
|
||||
noEventsDescription: '当前范围没有交易、资金、公司行为或组合提醒。',
|
||||
warningTitle: '日历加载失败',
|
||||
trade: '交易',
|
||||
cash: '资金',
|
||||
corporate: '公司行为',
|
||||
valuation: '估值',
|
||||
risk: '风控',
|
||||
buy: '买入',
|
||||
sell: '卖出',
|
||||
cashIn: '资金流入',
|
||||
cashOut: '资金流出',
|
||||
cashDividend: '现金分红',
|
||||
splitAdjustment: '拆并股调整',
|
||||
fxStaleTitle: '汇率过期',
|
||||
fxStaleDetail: '当前账户范围仍有汇率使用 stale/fallback 口径。',
|
||||
priceQualityTitle: '价格数据待补齐',
|
||||
priceQualityDetail: '缺价 {missing} 项,过期价 {stale} 项。',
|
||||
stopLossTitle: '止损提醒',
|
||||
stopLossDetail: '已触发 {triggered} 项,接近 {near} 项。',
|
||||
drawdownTitle: '回撤提醒',
|
||||
drawdownDetail: '最大回撤 {max},当前回撤 {current}。',
|
||||
aiRiskTitle: 'AI 风险信号',
|
||||
aiRiskDetail: '当前组合有 {total} 条防御型信号。',
|
||||
account: '账户',
|
||||
quantity: '数量',
|
||||
price: '价格',
|
||||
amount: '金额',
|
||||
dividend: '每股分红',
|
||||
splitRatio: '拆并股比例',
|
||||
missingPrice: '缺价',
|
||||
stalePrice: '过期价',
|
||||
},
|
||||
en: {
|
||||
title: 'Personal finance calendar',
|
||||
window: 'Last 14 days / next 45 days',
|
||||
refresh: 'Refresh calendar',
|
||||
refreshing: 'Refreshing...',
|
||||
reminders: 'Reminders',
|
||||
timeline: 'Event timeline',
|
||||
needsAction: 'Needs action',
|
||||
today: 'Today',
|
||||
next7Days: 'Next 7 days',
|
||||
completed: 'Recorded',
|
||||
later: 'Later',
|
||||
noEventsTitle: 'No calendar events',
|
||||
noEventsDescription: 'No trades, cash flows, corporate actions, or portfolio reminders in this scope.',
|
||||
warningTitle: 'Calendar load failed',
|
||||
trade: 'Trade',
|
||||
cash: 'Cash',
|
||||
corporate: 'Corporate action',
|
||||
valuation: 'Valuation',
|
||||
risk: 'Risk',
|
||||
buy: 'Buy',
|
||||
sell: 'Sell',
|
||||
cashIn: 'Cash inflow',
|
||||
cashOut: 'Cash outflow',
|
||||
cashDividend: 'Cash dividend',
|
||||
splitAdjustment: 'Split adjustment',
|
||||
fxStaleTitle: 'Stale FX',
|
||||
fxStaleDetail: 'This account scope still uses stale/fallback FX rates.',
|
||||
priceQualityTitle: 'Price data gap',
|
||||
priceQualityDetail: '{missing} missing prices, {stale} stale prices.',
|
||||
stopLossTitle: 'Stop-loss reminder',
|
||||
stopLossDetail: '{triggered} triggered, {near} near.',
|
||||
drawdownTitle: 'Drawdown reminder',
|
||||
drawdownDetail: 'Max drawdown {max}, current drawdown {current}.',
|
||||
aiRiskTitle: 'AI risk signals',
|
||||
aiRiskDetail: '{total} defensive signals in this portfolio.',
|
||||
account: 'Account',
|
||||
quantity: 'Quantity',
|
||||
price: 'Price',
|
||||
amount: 'Amount',
|
||||
dividend: 'Dividend/share',
|
||||
splitRatio: 'Split ratio',
|
||||
missingPrice: 'Missing',
|
||||
stalePrice: 'Stale',
|
||||
},
|
||||
};
|
||||
|
||||
const PORTFOLIO_LIMITATION_LABELS: Record<string, Record<PortfolioPageLanguage, string>> = {
|
||||
realtime_quote_best_effort: {
|
||||
zh: '实时行情为尽力获取',
|
||||
@@ -133,6 +304,441 @@ function formatPortfolioLimitation(limitation: string, language: PortfolioPageLa
|
||||
return PORTFOLIO_LIMITATION_LABELS[limitation]?.[language] ?? limitation;
|
||||
}
|
||||
|
||||
function parseIsoDay(value: string | null | undefined): Date | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || ''));
|
||||
if (!match) return null;
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function toIsoDay(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function addIsoDays(value: string, days: number): string {
|
||||
const date = parseIsoDay(value) ?? parseIsoDay(getTodayIso()) ?? new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
return toIsoDay(date);
|
||||
}
|
||||
|
||||
function diffIsoDays(date: string, referenceDate: string): number {
|
||||
const target = parseIsoDay(date);
|
||||
const reference = parseIsoDay(referenceDate);
|
||||
if (!target || !reference) return 0;
|
||||
const millisecondsPerDay = 24 * 60 * 60 * 1000;
|
||||
return Math.round((target.getTime() - reference.getTime()) / millisecondsPerDay);
|
||||
}
|
||||
|
||||
function buildFinanceCalendarWindow(referenceDate: string): { dateFrom: string; dateTo: string } {
|
||||
return {
|
||||
dateFrom: addIsoDays(referenceDate, -FINANCE_CALENDAR_LOOKBACK_DAYS),
|
||||
dateTo: addIsoDays(referenceDate, FINANCE_CALENDAR_LOOKAHEAD_DAYS),
|
||||
};
|
||||
}
|
||||
|
||||
function getFinanceCalendarStatus(date: string, referenceDate: string): FinanceCalendarStatus {
|
||||
const dayDelta = diffIsoDays(date, referenceDate);
|
||||
if (dayDelta < 0) return 'completed';
|
||||
if (dayDelta === 0) return 'today';
|
||||
if (dayDelta <= 7) return 'next7';
|
||||
return 'later';
|
||||
}
|
||||
|
||||
function getFinanceCalendarStatusLabel(status: FinanceCalendarStatus, text: FinanceCalendarText): string {
|
||||
if (status === 'today') return text.today;
|
||||
if (status === 'next7') return text.next7Days;
|
||||
if (status === 'completed') return text.completed;
|
||||
return text.later;
|
||||
}
|
||||
|
||||
function getFinanceCalendarStatusVariant(status: FinanceCalendarStatus): 'default' | 'success' | 'warning' | 'info' {
|
||||
if (status === 'today') return 'warning';
|
||||
if (status === 'next7') return 'info';
|
||||
if (status === 'completed') return 'success';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function getFinanceCalendarKindLabel(kind: FinanceCalendarKind, text: FinanceCalendarText): string {
|
||||
if (kind === 'trade') return text.trade;
|
||||
if (kind === 'cash') return text.cash;
|
||||
if (kind === 'corporate') return text.corporate;
|
||||
if (kind === 'valuation') return text.valuation;
|
||||
return text.risk;
|
||||
}
|
||||
|
||||
function getFinanceCalendarToneVariant(tone: FinanceCalendarTone): 'default' | 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (tone === 'neutral') return 'default';
|
||||
return tone;
|
||||
}
|
||||
|
||||
function getFinanceCalendarSeverity(tone: FinanceCalendarTone): number {
|
||||
if (tone === 'danger') return 0;
|
||||
if (tone === 'warning') return 1;
|
||||
if (tone === 'info') return 2;
|
||||
if (tone === 'success') return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function getFinanceCalendarAccountLabel(accountId: number, accountNameById: ReadonlyMap<number, string>, text: FinanceCalendarText): string {
|
||||
return `${text.account}: ${accountNameById.get(accountId) || `#${accountId}`}`;
|
||||
}
|
||||
|
||||
function formatFinanceCalendarSide(side: PortfolioSide, text: FinanceCalendarText): string {
|
||||
return side === 'buy' ? text.buy : text.sell;
|
||||
}
|
||||
|
||||
function formatFinanceCalendarCashDirection(direction: PortfolioCashDirection, text: FinanceCalendarText): string {
|
||||
return direction === 'in' ? text.cashIn : text.cashOut;
|
||||
}
|
||||
|
||||
function formatFinanceCalendarCorporateAction(actionType: PortfolioCorporateActionType, text: FinanceCalendarText): string {
|
||||
return actionType === 'cash_dividend' ? text.cashDividend : text.splitAdjustment;
|
||||
}
|
||||
|
||||
function compareFinanceCalendarItems(left: FinanceCalendarItem, right: FinanceCalendarItem): number {
|
||||
const leftTime = parseIsoDay(left.date)?.getTime() ?? 0;
|
||||
const rightTime = parseIsoDay(right.date)?.getTime() ?? 0;
|
||||
if (leftTime !== rightTime) return leftTime - rightTime;
|
||||
const severityDelta = getFinanceCalendarSeverity(left.tone) - getFinanceCalendarSeverity(right.tone);
|
||||
if (severityDelta !== 0) return severityDelta;
|
||||
return left.title.localeCompare(right.title);
|
||||
}
|
||||
|
||||
function buildFinanceCalendarLedgerItems(
|
||||
sourceEvents: FinanceCalendarSourceEvents,
|
||||
referenceDate: string,
|
||||
accountNameById: ReadonlyMap<number, string>,
|
||||
text: FinanceCalendarText,
|
||||
): FinanceCalendarItem[] {
|
||||
const items: FinanceCalendarItem[] = [];
|
||||
|
||||
for (const item of sourceEvents.trades) {
|
||||
const sideLabel = formatFinanceCalendarSide(item.side, text);
|
||||
const status = getFinanceCalendarStatus(item.tradeDate, referenceDate);
|
||||
items.push({
|
||||
id: `trade-${item.id}`,
|
||||
date: item.tradeDate,
|
||||
kind: 'trade',
|
||||
tone: item.side === 'buy' ? 'info' : 'warning',
|
||||
title: `${sideLabel} ${item.symbol}`,
|
||||
detail: [
|
||||
getFinanceCalendarAccountLabel(item.accountId, accountNameById, text),
|
||||
`${text.quantity}: ${item.quantity}`,
|
||||
`${text.price}: ${item.price}`,
|
||||
item.currency,
|
||||
].join(' · '),
|
||||
status,
|
||||
needsAction: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of sourceEvents.cash) {
|
||||
const status = getFinanceCalendarStatus(item.eventDate, referenceDate);
|
||||
items.push({
|
||||
id: `cash-${item.id}`,
|
||||
date: item.eventDate,
|
||||
kind: 'cash',
|
||||
tone: item.direction === 'out' ? 'warning' : 'success',
|
||||
title: formatFinanceCalendarCashDirection(item.direction, text),
|
||||
detail: [
|
||||
getFinanceCalendarAccountLabel(item.accountId, accountNameById, text),
|
||||
`${text.amount}: ${formatMoney(item.amount, item.currency)}`,
|
||||
].join(' · '),
|
||||
status,
|
||||
needsAction: item.direction === 'out' && status !== 'completed',
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of sourceEvents.corporate) {
|
||||
const status = getFinanceCalendarStatus(item.effectiveDate, referenceDate);
|
||||
const actionLabel = formatFinanceCalendarCorporateAction(item.actionType, text);
|
||||
const actionDetail = item.actionType === 'cash_dividend'
|
||||
? `${text.dividend}: ${item.cashDividendPerShare ?? '--'} ${item.currency}`
|
||||
: `${text.splitRatio}: ${item.splitRatio ?? '--'}`;
|
||||
items.push({
|
||||
id: `corporate-${item.id}`,
|
||||
date: item.effectiveDate,
|
||||
kind: 'corporate',
|
||||
tone: status === 'completed' ? 'success' : 'info',
|
||||
title: `${actionLabel} ${item.symbol}`,
|
||||
detail: [
|
||||
getFinanceCalendarAccountLabel(item.accountId, accountNameById, text),
|
||||
actionDetail,
|
||||
].join(' · '),
|
||||
status,
|
||||
needsAction: status !== 'completed',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildFinanceCalendarReminderItems(
|
||||
snapshot: PortfolioSnapshotResponse | null,
|
||||
risk: PortfolioRiskResponse | null,
|
||||
positions: FlatPosition[],
|
||||
referenceDate: string,
|
||||
text: FinanceCalendarText,
|
||||
): FinanceCalendarItem[] {
|
||||
const items: FinanceCalendarItem[] = [];
|
||||
const status = getFinanceCalendarStatus(referenceDate, referenceDate);
|
||||
|
||||
if (snapshot?.fxStale) {
|
||||
items.push({
|
||||
id: 'valuation-fx-stale',
|
||||
date: referenceDate,
|
||||
kind: 'valuation',
|
||||
tone: 'warning',
|
||||
title: text.fxStaleTitle,
|
||||
detail: text.fxStaleDetail,
|
||||
status,
|
||||
needsAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
const missingPriceCount = positions.filter((item) => !hasPositionPrice(item)).length;
|
||||
const stalePriceCount = positions.filter((item) => hasPositionPrice(item) && item.priceStale).length;
|
||||
if (missingPriceCount > 0 || stalePriceCount > 0) {
|
||||
items.push({
|
||||
id: 'valuation-price-quality',
|
||||
date: referenceDate,
|
||||
kind: 'valuation',
|
||||
tone: missingPriceCount > 0 ? 'danger' : 'warning',
|
||||
title: text.priceQualityTitle,
|
||||
detail: formatUiText(text.priceQualityDetail, {
|
||||
missing: missingPriceCount,
|
||||
stale: stalePriceCount,
|
||||
}),
|
||||
status,
|
||||
needsAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
if ((risk?.stopLoss?.triggeredCount ?? 0) > 0 || (risk?.stopLoss?.nearCount ?? 0) > 0) {
|
||||
items.push({
|
||||
id: 'risk-stop-loss',
|
||||
date: referenceDate,
|
||||
kind: 'risk',
|
||||
tone: (risk?.stopLoss?.triggeredCount ?? 0) > 0 ? 'danger' : 'warning',
|
||||
title: text.stopLossTitle,
|
||||
detail: formatUiText(text.stopLossDetail, {
|
||||
triggered: risk?.stopLoss?.triggeredCount ?? 0,
|
||||
near: risk?.stopLoss?.nearCount ?? 0,
|
||||
}),
|
||||
status,
|
||||
needsAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (risk?.drawdown?.alert) {
|
||||
items.push({
|
||||
id: 'risk-drawdown',
|
||||
date: referenceDate,
|
||||
kind: 'risk',
|
||||
tone: 'warning',
|
||||
title: text.drawdownTitle,
|
||||
detail: formatUiText(text.drawdownDetail, {
|
||||
max: formatPct(risk.drawdown.maxDrawdownPct),
|
||||
current: formatPct(risk.drawdown.currentDrawdownPct),
|
||||
}),
|
||||
status,
|
||||
needsAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
if ((risk?.decisionSignalRisk?.total ?? 0) > 0) {
|
||||
items.push({
|
||||
id: 'risk-ai-signals',
|
||||
date: referenceDate,
|
||||
kind: 'risk',
|
||||
tone: 'warning',
|
||||
title: text.aiRiskTitle,
|
||||
detail: formatUiText(text.aiRiskDetail, { total: risk?.decisionSignalRisk?.total ?? 0 }),
|
||||
status,
|
||||
needsAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildFinanceCalendarItems(
|
||||
sourceEvents: FinanceCalendarSourceEvents,
|
||||
snapshot: PortfolioSnapshotResponse | null,
|
||||
risk: PortfolioRiskResponse | null,
|
||||
positions: FlatPosition[],
|
||||
accountNameById: ReadonlyMap<number, string>,
|
||||
text: FinanceCalendarText,
|
||||
): FinanceCalendarItem[] {
|
||||
const referenceDate = snapshot?.asOf || getTodayIso();
|
||||
return [
|
||||
...buildFinanceCalendarReminderItems(snapshot, risk, positions, referenceDate, text),
|
||||
...buildFinanceCalendarLedgerItems(sourceEvents, referenceDate, accountNameById, text),
|
||||
].sort(compareFinanceCalendarItems);
|
||||
}
|
||||
|
||||
function buildFinanceCalendarStats(items: FinanceCalendarItem[]): FinanceCalendarStats {
|
||||
return items.reduce<FinanceCalendarStats>(
|
||||
(stats, item) => ({
|
||||
needsAction: stats.needsAction + (item.needsAction ? 1 : 0),
|
||||
today: stats.today + (item.status === 'today' ? 1 : 0),
|
||||
next7Days: stats.next7Days + (item.status === 'next7' ? 1 : 0),
|
||||
completed: stats.completed + (item.status === 'completed' ? 1 : 0),
|
||||
}),
|
||||
{ needsAction: 0, today: 0, next7Days: 0, completed: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function FinanceCalendarStat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2">
|
||||
<div className="text-[11px] text-secondary">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FinanceCalendarItemRow({
|
||||
item,
|
||||
text,
|
||||
}: {
|
||||
item: FinanceCalendarItem;
|
||||
text: FinanceCalendarText;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[6rem_minmax(0,1fr)] gap-3 border-b border-white/5 py-3 last:border-0">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-xs text-foreground">{item.date}</div>
|
||||
<div className="mt-1">
|
||||
<Badge variant={getFinanceCalendarStatusVariant(item.status)} className="whitespace-nowrap">
|
||||
{getFinanceCalendarStatusLabel(item.status, text)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusDot tone={item.tone} />
|
||||
<span className="text-sm font-medium text-foreground">{item.title}</span>
|
||||
<Badge variant={getFinanceCalendarToneVariant(item.tone)}>
|
||||
{getFinanceCalendarKindLabel(item.kind, text)}
|
||||
</Badge>
|
||||
{item.needsAction ? <Badge variant="danger">{text.needsAction}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-secondary">{item.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortfolioFinanceCalendar({
|
||||
items,
|
||||
stats,
|
||||
text,
|
||||
loading,
|
||||
warning,
|
||||
onRefresh,
|
||||
}: {
|
||||
items: FinanceCalendarItem[];
|
||||
stats: FinanceCalendarStats;
|
||||
text: FinanceCalendarText;
|
||||
loading: boolean;
|
||||
warning: string | null;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const reminderItems = items.filter((item) => item.needsAction).slice(0, 4);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Card padding="md">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 text-cyan" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-foreground">{text.title}</h2>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-secondary">
|
||||
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{text.window}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary text-sm"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? text.refreshing : text.refresh}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{warning ? (
|
||||
<InlineAlert
|
||||
variant="warning"
|
||||
title={text.warningTitle}
|
||||
message={warning}
|
||||
className="mt-3 rounded-xl px-3 py-2 text-xs shadow-none"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<FinanceCalendarStat label={text.needsAction} value={stats.needsAction} />
|
||||
<FinanceCalendarStat label={text.today} value={stats.today} />
|
||||
<FinanceCalendarStat label={text.next7Days} value={stats.next7Days} />
|
||||
<FinanceCalendarStat label={text.completed} value={stats.completed} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.02] p-3">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<BellRing className="h-4 w-4 text-warning" aria-hidden="true" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{text.reminders}</h3>
|
||||
</div>
|
||||
{reminderItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{reminderItems.map((item) => (
|
||||
<div key={`reminder-${item.id}`} className="rounded-lg border border-white/10 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot tone={item.tone} pulse={item.tone === 'danger'} />
|
||||
<span className="text-sm font-medium text-foreground">{item.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-secondary">{item.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={text.noEventsTitle}
|
||||
description={text.noEventsDescription}
|
||||
className="border-none bg-transparent px-3 py-6 shadow-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.02] p-3">
|
||||
<h3 className="mb-1 text-sm font-semibold text-foreground">{text.timeline}</h3>
|
||||
<div className="max-h-80 overflow-auto">
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<FinanceCalendarItemRow key={item.id} item={item} text={text} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
title={text.noEventsTitle}
|
||||
description={text.noEventsDescription}
|
||||
className="border-none bg-transparent px-3 py-8 shadow-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const DECISION_SIGNAL_MARKETS = new Set<DecisionSignalMarket>(['cn', 'hk', 'us', 'jp', 'kr', 'tw']);
|
||||
type PortfolioAccountMarket = 'cn' | 'hk' | 'us' | 'jp' | 'kr' | 'tw';
|
||||
|
||||
@@ -182,6 +788,7 @@ async function loadPortfolioSignalLookup(lookup: PortfolioSignalLookup): Promise
|
||||
const PortfolioPage: React.FC = () => {
|
||||
const { language, t } = useUiLanguage();
|
||||
const text = PORTFOLIO_TEXT[language];
|
||||
const financeCalendarText = FINANCE_CALENDAR_TEXT[language];
|
||||
const decisionActionLabels = useMemo(() => buildDecisionActionLabelMap(t), [t]);
|
||||
|
||||
// Set page title
|
||||
@@ -241,6 +848,15 @@ const PortfolioPage: React.FC = () => {
|
||||
const [tradeEvents, setTradeEvents] = useState<PortfolioTradeListItem[]>([]);
|
||||
const [cashEvents, setCashEvents] = useState<PortfolioCashLedgerListItem[]>([]);
|
||||
const [corporateEvents, setCorporateEvents] = useState<PortfolioCorporateActionListItem[]>([]);
|
||||
const [financeCalendarEvents, setFinanceCalendarEvents] = useState<FinanceCalendarSourceEvents>({
|
||||
trades: [],
|
||||
cash: [],
|
||||
corporate: [],
|
||||
});
|
||||
const [financeCalendarLoading, setFinanceCalendarLoading] = useState(false);
|
||||
const [financeCalendarWarning, setFinanceCalendarWarning] = useState<string | null>(null);
|
||||
const [financeCalendarRefreshKey, setFinanceCalendarRefreshKey] = useState(0);
|
||||
const financeCalendarRequestRef = useRef(0);
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [pendingAccountDelete, setPendingAccountDelete] = useState<PendingAccountDelete | null>(null);
|
||||
@@ -287,6 +903,7 @@ const PortfolioPage: React.FC = () => {
|
||||
: eventType === 'cash'
|
||||
? cashEvents.length
|
||||
: corporateEvents.length;
|
||||
const financeCalendarReferenceDate = snapshot?.asOf || null;
|
||||
|
||||
const isActiveRefreshContext = (requestedViewKey: string, requestedRequestId: number) => {
|
||||
return (
|
||||
@@ -429,6 +1046,73 @@ const PortfolioPage: React.FC = () => {
|
||||
await loadEventsPage(eventPage);
|
||||
}, [eventPage, loadEventsPage]);
|
||||
|
||||
const loadFinanceCalendar = useCallback(async () => {
|
||||
if (!financeCalendarReferenceDate) {
|
||||
setFinanceCalendarEvents({ trades: [], cash: [], corporate: [] });
|
||||
setFinanceCalendarWarning(null);
|
||||
setFinanceCalendarLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = financeCalendarRequestRef.current + 1;
|
||||
financeCalendarRequestRef.current = requestId;
|
||||
const referenceDate = financeCalendarReferenceDate;
|
||||
const calendarWindow = buildFinanceCalendarWindow(referenceDate);
|
||||
const query = {
|
||||
accountId: queryAccountId,
|
||||
dateFrom: calendarWindow.dateFrom,
|
||||
dateTo: calendarWindow.dateTo,
|
||||
page: 1,
|
||||
pageSize: FINANCE_CALENDAR_PAGE_SIZE,
|
||||
};
|
||||
|
||||
setFinanceCalendarLoading(true);
|
||||
setFinanceCalendarWarning(null);
|
||||
|
||||
try {
|
||||
const [tradeResult, cashResult, corporateResult] = await Promise.allSettled([
|
||||
portfolioApi.listTrades(query),
|
||||
portfolioApi.listCashLedger(query),
|
||||
portfolioApi.listCorporateActions(query),
|
||||
] as const);
|
||||
if (financeCalendarRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const failures: string[] = [];
|
||||
const nextEvents: FinanceCalendarSourceEvents = {
|
||||
trades: [],
|
||||
cash: [],
|
||||
corporate: [],
|
||||
};
|
||||
|
||||
if (tradeResult.status === 'fulfilled') {
|
||||
nextEvents.trades = tradeResult.value.items || [];
|
||||
} else {
|
||||
failures.push(getParsedApiError(tradeResult.reason).message);
|
||||
}
|
||||
|
||||
if (cashResult.status === 'fulfilled') {
|
||||
nextEvents.cash = cashResult.value.items || [];
|
||||
} else {
|
||||
failures.push(getParsedApiError(cashResult.reason).message);
|
||||
}
|
||||
|
||||
if (corporateResult.status === 'fulfilled') {
|
||||
nextEvents.corporate = corporateResult.value.items || [];
|
||||
} else {
|
||||
failures.push(getParsedApiError(corporateResult.reason).message);
|
||||
}
|
||||
|
||||
setFinanceCalendarEvents(nextEvents);
|
||||
setFinanceCalendarWarning(failures[0] || null);
|
||||
} finally {
|
||||
if (financeCalendarRequestRef.current === requestId) {
|
||||
setFinanceCalendarLoading(false);
|
||||
}
|
||||
}
|
||||
}, [financeCalendarReferenceDate, queryAccountId]);
|
||||
|
||||
const refreshPortfolioData = useCallback(async (page = eventPage) => {
|
||||
await Promise.all([loadSnapshotAndRisk(), loadEventsPage(page)]);
|
||||
}, [eventPage, loadEventsPage, loadSnapshotAndRisk]);
|
||||
@@ -446,6 +1130,16 @@ const PortfolioPage: React.FC = () => {
|
||||
void loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccounts || !financeCalendarReferenceDate) {
|
||||
setFinanceCalendarEvents({ trades: [], cash: [], corporate: [] });
|
||||
setFinanceCalendarWarning(null);
|
||||
setFinanceCalendarLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadFinanceCalendar();
|
||||
}, [financeCalendarReferenceDate, financeCalendarRefreshKey, hasAccounts, loadFinanceCalendar]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshContextRef.current = {
|
||||
viewKey: refreshViewKey,
|
||||
@@ -481,6 +1175,31 @@ const PortfolioPage: React.FC = () => {
|
||||
return rows;
|
||||
}, [snapshot]);
|
||||
|
||||
const financeCalendarAccountNameById = useMemo(() => (
|
||||
new Map(accounts.map((account) => [account.id, account.name]))
|
||||
), [accounts]);
|
||||
|
||||
const financeCalendarItems = useMemo(() => buildFinanceCalendarItems(
|
||||
financeCalendarEvents,
|
||||
snapshot,
|
||||
risk,
|
||||
positionRows,
|
||||
financeCalendarAccountNameById,
|
||||
financeCalendarText,
|
||||
), [
|
||||
financeCalendarAccountNameById,
|
||||
financeCalendarEvents,
|
||||
financeCalendarText,
|
||||
positionRows,
|
||||
risk,
|
||||
snapshot,
|
||||
]);
|
||||
|
||||
const financeCalendarStats = useMemo(
|
||||
() => buildFinanceCalendarStats(financeCalendarItems),
|
||||
[financeCalendarItems],
|
||||
);
|
||||
|
||||
const snapshotMatchesAccountScope = useMemo(() => {
|
||||
if (!snapshot) return false;
|
||||
const snapshotAccountIds = new Set((snapshot.accounts || []).map((account) => account.accountId));
|
||||
@@ -639,6 +1358,7 @@ const PortfolioPage: React.FC = () => {
|
||||
note: tradeForm.note || undefined,
|
||||
});
|
||||
await refreshPortfolioData();
|
||||
setFinanceCalendarRefreshKey((current) => current + 1);
|
||||
setTradeForm((prev) => ({ ...prev, symbol: '', tradeUid: '', note: '' }));
|
||||
} catch (err) {
|
||||
setError(getParsedApiError(err));
|
||||
@@ -662,6 +1382,7 @@ const PortfolioPage: React.FC = () => {
|
||||
note: cashForm.note || undefined,
|
||||
});
|
||||
await refreshPortfolioData();
|
||||
setFinanceCalendarRefreshKey((current) => current + 1);
|
||||
setCashForm((prev) => ({ ...prev, note: '' }));
|
||||
} catch (err) {
|
||||
setError(getParsedApiError(err));
|
||||
@@ -686,6 +1407,7 @@ const PortfolioPage: React.FC = () => {
|
||||
note: corpForm.note || undefined,
|
||||
});
|
||||
await refreshPortfolioData();
|
||||
setFinanceCalendarRefreshKey((current) => current + 1);
|
||||
setCorpForm((prev) => ({ ...prev, symbol: '', note: '' }));
|
||||
} catch (err) {
|
||||
setError(getParsedApiError(err));
|
||||
@@ -790,6 +1512,7 @@ const PortfolioPage: React.FC = () => {
|
||||
setEventPage(nextPage);
|
||||
}
|
||||
await refreshPortfolioData(nextPage);
|
||||
setFinanceCalendarRefreshKey((current) => current + 1);
|
||||
} catch (err) {
|
||||
setError(getParsedApiError(err));
|
||||
} finally {
|
||||
@@ -838,6 +1561,7 @@ const PortfolioPage: React.FC = () => {
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([loadAccounts(), loadSnapshotAndRisk(), loadEvents(), loadBrokers()]);
|
||||
setPortfolioSignalsRefreshKey((current) => current + 1);
|
||||
setFinanceCalendarRefreshKey((current) => current + 1);
|
||||
};
|
||||
|
||||
const reloadSnapshotAndRiskForScope = useCallback(async (
|
||||
@@ -1177,6 +1901,15 @@ const PortfolioPage: React.FC = () => {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<PortfolioFinanceCalendar
|
||||
items={financeCalendarItems}
|
||||
stats={financeCalendarStats}
|
||||
text={financeCalendarText}
|
||||
loading={financeCalendarLoading}
|
||||
warning={financeCalendarWarning}
|
||||
onRefresh={() => setFinanceCalendarRefreshKey((current) => current + 1)}
|
||||
/>
|
||||
|
||||
<section className="grid grid-cols-1 xl:grid-cols-3 gap-3">
|
||||
<Card className="xl:col-span-2" padding="md">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -281,7 +281,7 @@ async function waitForInitialLoad() {
|
||||
await waitFor(() => expect(getAccounts).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(getRisk).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(listTrades).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(listTrades.mock.calls.length).toBeGreaterThanOrEqual(1));
|
||||
}
|
||||
|
||||
describe('PortfolioPage FX refresh', () => {
|
||||
@@ -363,6 +363,119 @@ describe('PortfolioPage FX refresh', () => {
|
||||
expect(screen.getByRole('button', { name: '刷新汇率' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a personal finance calendar with ledger events and portfolio reminders', async () => {
|
||||
getSnapshot.mockResolvedValueOnce(makeSnapshot({
|
||||
fxStale: true,
|
||||
positions: [
|
||||
makePosition({ symbol: 'AAPL', market: 'us', currency: 'USD', priceStale: true }),
|
||||
makePosition({
|
||||
symbol: 'MSFT',
|
||||
market: 'us',
|
||||
currency: 'USD',
|
||||
lastPrice: 0,
|
||||
marketValueBase: 0,
|
||||
unrealizedPnlBase: 0,
|
||||
unrealizedPnlPct: null,
|
||||
priceSource: 'missing',
|
||||
priceStale: true,
|
||||
priceAvailable: false,
|
||||
}),
|
||||
],
|
||||
}));
|
||||
getRisk.mockResolvedValueOnce(makeRisk({
|
||||
drawdown: {
|
||||
seriesPoints: 10,
|
||||
maxDrawdownPct: 18.5,
|
||||
currentDrawdownPct: 12.5,
|
||||
alert: true,
|
||||
fxStale: false,
|
||||
},
|
||||
stopLoss: {
|
||||
nearAlert: true,
|
||||
triggeredCount: 1,
|
||||
nearCount: 2,
|
||||
items: [],
|
||||
},
|
||||
decisionSignalRisk: {
|
||||
available: true,
|
||||
total: 2,
|
||||
actions: { sell: 1, reduce: 1, alert: 0 },
|
||||
items: [],
|
||||
},
|
||||
}));
|
||||
listTrades.mockResolvedValue({
|
||||
items: [{
|
||||
id: 11,
|
||||
accountId: 1,
|
||||
tradeDate: '2026-03-19',
|
||||
side: 'buy',
|
||||
symbol: 'AAPL',
|
||||
market: 'us',
|
||||
currency: 'USD',
|
||||
quantity: 3,
|
||||
price: 180,
|
||||
fee: 0,
|
||||
tax: 0,
|
||||
}],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
listCashLedger.mockResolvedValue({
|
||||
items: [{
|
||||
id: 21,
|
||||
accountId: 1,
|
||||
eventDate: '2026-03-20',
|
||||
direction: 'out',
|
||||
amount: 300,
|
||||
currency: 'CNY',
|
||||
}],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
listCorporateActions.mockResolvedValue({
|
||||
items: [{
|
||||
id: 31,
|
||||
accountId: 1,
|
||||
effectiveDate: '2026-03-22',
|
||||
actionType: 'cash_dividend',
|
||||
symbol: '600519',
|
||||
market: 'cn',
|
||||
currency: 'CNY',
|
||||
cashDividendPerShare: 12,
|
||||
splitRatio: null,
|
||||
}],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
render(<PortfolioPage />);
|
||||
|
||||
await waitForInitialLoad();
|
||||
|
||||
const calendar = await screen.findByText('个人财务日历');
|
||||
const calendarSection = calendar.closest('section');
|
||||
expect(calendarSection).not.toBeNull();
|
||||
const calendarScope = within(calendarSection as HTMLElement);
|
||||
|
||||
expect(calendarScope.getByText('提醒')).toBeInTheDocument();
|
||||
expect(calendarScope.getByText('事件时间线')).toBeInTheDocument();
|
||||
expect(calendarScope.getByText('买入 AAPL')).toBeInTheDocument();
|
||||
expect(calendarScope.getByText('资金流出')).toBeInTheDocument();
|
||||
expect(calendarScope.getByText('现金分红 600519')).toBeInTheDocument();
|
||||
expect(calendarScope.getAllByText('汇率过期').length).toBeGreaterThan(0);
|
||||
expect(calendarScope.getAllByText('价格数据待补齐').length).toBeGreaterThan(0);
|
||||
expect(calendarScope.getAllByText('止损提醒').length).toBeGreaterThan(0);
|
||||
expect(calendarScope.getAllByText('回撤提醒').length).toBeGreaterThan(0);
|
||||
expect(calendarScope.getAllByText('AI 风险信号').length).toBeGreaterThan(0);
|
||||
expect(calendarScope.getAllByText('缺价 1 项,过期价 1 项。').length).toBeGreaterThan(0);
|
||||
expect(calendarScope.getAllByText('已触发 1 项,接近 2 项。').length).toBeGreaterThan(0);
|
||||
expect(listCashLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, pageSize: 100 }));
|
||||
expect(listCorporateActions).toHaveBeenCalledWith(expect.objectContaining({ page: 1, pageSize: 100 }));
|
||||
});
|
||||
|
||||
it('shows aggregate partial valuation limitations near summary totals', async () => {
|
||||
getSnapshot.mockResolvedValueOnce(makeSnapshot({
|
||||
dataQuality: 'partial',
|
||||
@@ -421,7 +534,7 @@ describe('PortfolioPage FX refresh', () => {
|
||||
|
||||
await waitForInitialLoad();
|
||||
|
||||
expect(screen.getByText('AI 风险信号')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('AI 风险信号').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/风险信号: 2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/卖出: 1 · 减仓: 0 · 预警: 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText('600519 · 卖出')).toBeInTheDocument();
|
||||
@@ -451,7 +564,7 @@ describe('PortfolioPage FX refresh', () => {
|
||||
|
||||
await waitForInitialLoad();
|
||||
|
||||
expect(screen.getByText('AI risk signals')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('AI risk signals').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('600519 · Sell')).toBeInTheDocument();
|
||||
expect(screen.queryByText('600519 · 卖出')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('600519 · sell')).not.toBeInTheDocument();
|
||||
@@ -494,6 +607,8 @@ describe('PortfolioPage FX refresh', () => {
|
||||
const snapshotCallsBeforeRefresh = getSnapshot.mock.calls.length;
|
||||
const riskCallsBeforeRefresh = getRisk.mock.calls.length;
|
||||
const tradeCallsBeforeRefresh = listTrades.mock.calls.length;
|
||||
const cashCallsBeforeRefresh = listCashLedger.mock.calls.length;
|
||||
const corporateCallsBeforeRefresh = listCorporateActions.mock.calls.length;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新汇率' }));
|
||||
|
||||
@@ -502,8 +617,8 @@ describe('PortfolioPage FX refresh', () => {
|
||||
await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(snapshotCallsBeforeRefresh + 1));
|
||||
await waitFor(() => expect(getRisk).toHaveBeenCalledTimes(riskCallsBeforeRefresh + 1));
|
||||
expect(listTrades).toHaveBeenCalledTimes(tradeCallsBeforeRefresh);
|
||||
expect(listCashLedger).not.toHaveBeenCalled();
|
||||
expect(listCorporateActions).not.toHaveBeenCalled();
|
||||
expect(listCashLedger).toHaveBeenCalledTimes(cashCallsBeforeRefresh);
|
||||
expect(listCorporateActions).toHaveBeenCalledTimes(corporateCallsBeforeRefresh);
|
||||
expect(screen.getByText('最新')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -928,6 +1043,8 @@ describe('PortfolioPage FX refresh', () => {
|
||||
const snapshotCallsBeforeRefresh = getSnapshot.mock.calls.length;
|
||||
const riskCallsBeforeRefresh = getRisk.mock.calls.length;
|
||||
const tradeCallsBeforeRefresh = listTrades.mock.calls.length;
|
||||
const cashCallsBeforeRefresh = listCashLedger.mock.calls.length;
|
||||
const corporateCallsBeforeRefresh = listCorporateActions.mock.calls.length;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新汇率' }));
|
||||
|
||||
@@ -935,8 +1052,8 @@ describe('PortfolioPage FX refresh', () => {
|
||||
await waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(snapshotCallsBeforeRefresh + 1));
|
||||
await waitFor(() => expect(getRisk).toHaveBeenCalledTimes(riskCallsBeforeRefresh + 1));
|
||||
expect(listTrades).toHaveBeenCalledTimes(tradeCallsBeforeRefresh);
|
||||
expect(listCashLedger).not.toHaveBeenCalled();
|
||||
expect(listCorporateActions).not.toHaveBeenCalled();
|
||||
expect(listCashLedger).toHaveBeenCalledTimes(cashCallsBeforeRefresh);
|
||||
expect(listCorporateActions).toHaveBeenCalledTimes(corporateCallsBeforeRefresh);
|
||||
});
|
||||
|
||||
it('restores the button state and shows the existing error alert when FX refresh fails', async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
- [改进] PR CI 增加文档路径检测:仅修改普通文档、非治理 Markdown 或 LICENSE 时跳过后端测试分片、Docker、Web 与桌面打包,保留轻量治理和门禁汇总;契约文档、静态 API 规格与测试 fixture 仍执行后端回归。
|
||||
- [修复] Linux/Docker 分享图补齐 Noto CJK 字体与中韩文字体栈,避免 PNG 只显示数字和英文、中文或韩文内容消失。
|
||||
- [改进] Web 持仓页新增个人财务日历,聚合交易流水、资金流水、公司行为、汇率过期、缺价/过期价、止损、回撤和 AI 风险信号提醒。
|
||||
- [新功能] Web Chat 意图识别层新增分词模块:`web_intent_tokenizer` 六步管道(多股票全名实体扫描 → 标点/空白切分 → 代码形提取 → 市场关键词 → 无歧义关键词 → 残存 gap 多策略 DFS 匹配)把用户消息切分为携带语义标签的 Token 序列;配套 `web_intent_types` 数据字典(Token 结构、Market 枚举、21 个语义 tag、clean/extend 双词池与正则机器)。核心原则"宁可不做,不可做错":Step 1~5 只做精确匹配,Step 6 要求整段 TAG 全覆盖(交叉验证)才产出,未覆盖片段保持空 tag 交下游 LLM 兜底;代码形 token 辨认为 `stock_code`(附 code/name/market 三元组)/ `wrong_{market}_code` / `unknown_{market}_code` 三态,token 层代码拼写统一 canonical 归一(a=6 位裸数字、hk=HK+5 位、us=大写 ticker)。意图枚举与意图识别结果随后续 `web_intent_resolver` PR 引入。新增 183 个分词单元测试。
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
| [Bot 平台配置](bot/) | 飞书、钉钉、Discord 等 Bot 配置截图和补充说明 |
|
||||
| [实时告警中心](alerts.md) | EventMonitor 基线、Web 规则管理、通知结果、冷却状态和 Phase 边界 |
|
||||
| [DecisionSignal 决策信号专题](decision-signals.md) | AI 建议池字段语义、API、Web 展示、告警/通知/组合风险联动、后验评估、脱敏、迁移与回滚 |
|
||||
| [个人财务日历](personal-finance-calendar.md) | Web 持仓页的交易、资金、公司行为、估值质量和组合风险提醒聚合 |
|
||||
| [资讯 / 情报源](intelligence-sources.md) | RSS/Atom 合规资讯源配置、测试、拉取、去重、存储、查询与安全边界 |
|
||||
| [分析上下文包契约、运行态消费与可见性](analysis-context-pack.md) | AnalysisContextPack 首版范围、字段质量状态、P1/P2 内部契约、P3 Prompt 摘要消费、P4 历史/API/Web 低敏可见性、P5 数据质量评分、P6 迁移回滚与源码锚点;完整指南补充 #1386 阶段感知分析、迁移与回滚入口 |
|
||||
| [图片识别 Prompt](image-extract-prompt.md) | 图片识别股票信息的 Prompt 与使用边界 |
|
||||
|
||||
31
docs/personal-finance-calendar.md
Normal file
31
docs/personal-finance-calendar.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 个人财务日历
|
||||
|
||||
个人财务日历位于 Web 持仓页,用于把持仓相关的流水和提醒合并到一个时间线中,降低用户在交易、资金、公司行为、估值质量和风险模块之间来回切换的成本。
|
||||
|
||||
## 数据来源
|
||||
|
||||
- 交易流水:`GET /api/v1/portfolio/trades`
|
||||
- 资金流水:`GET /api/v1/portfolio/cash-ledger`
|
||||
- 公司行为:`GET /api/v1/portfolio/corporate-actions`
|
||||
- 组合快照:`GET /api/v1/portfolio/snapshot`
|
||||
- 组合风险:`GET /api/v1/portfolio/risk`
|
||||
|
||||
日历默认使用当前账户视图的口径;在“全部账户”视图下聚合全部账户,在单账户视图下仅展示该账户范围。流水窗口为快照日期前 14 天到后 45 天,页面最多读取每类 100 条。
|
||||
|
||||
## 展示内容
|
||||
|
||||
- 摘要指标:需处理、今日、7 日内、已记录。
|
||||
- 提醒区:展示需要优先处理的汇率过期、缺价/过期价、止损、回撤和 AI 风险信号。
|
||||
- 时间线:按日期排序展示交易、资金流、公司行为和组合提醒。
|
||||
|
||||
## 降级策略
|
||||
|
||||
- 日历三类流水分别请求;任一类失败时只显示已成功加载的数据,并在日历区显示加载失败提示。
|
||||
- 日历加载失败不影响持仓快照、风险卡片、手工录入和原流水列表。
|
||||
- 汇率过期、缺价/过期价、止损和回撤提醒直接来自现有快照与风险响应,不新增后端字段。
|
||||
|
||||
## 后续扩展
|
||||
|
||||
- 接入提醒持久化后,可把“已处理 / 稍后提醒”状态落库。
|
||||
- 接入外部日历后,可把公司行为、资金计划和止损提醒同步到个人日历。
|
||||
- 后端若新增股息预测、财报日、打新缴款或债券到期数据源,可继续复用同一时间线模型。
|
||||
Reference in New Issue
Block a user