feat: add personal finance calendar

This commit is contained in:
zhulinsen
2026-08-27 12:05:22 +08:00
parent fb4735a105
commit 9281519ac7
5 changed files with 891 additions and 8 deletions

View File

@@ -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">

View File

@@ -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 () => {

View File

@@ -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-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->

View File

@@ -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 与使用边界 |

View 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 风险信号。
- 时间线:按日期排序展示交易、资金流、公司行为和组合提醒。
## 降级策略
- 日历三类流水分别请求;任一类失败时只显示已成功加载的数据,并在日历区显示加载失败提示。
- 日历加载失败不影响持仓快照、风险卡片、手工录入和原流水列表。
- 汇率过期、缺价/过期价、止损和回撤提醒直接来自现有快照与风险响应,不新增后端字段。
## 后续扩展
- 接入提醒持久化后,可把“已处理 / 稍后提醒”状态落库。
- 接入外部日历后,可把公司行为、资金计划和止损提醒同步到个人日历。
- 后端若新增股息预测、财报日、打新缴款或债券到期数据源,可继续复用同一时间线模型。