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
4 changes: 4 additions & 0 deletions api/v1/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@
PortfolioImportBrokerItem,
PortfolioImportBrokerListResponse,
PortfolioFxRefreshResponse,
PortfolioDecisionSignalRiskBlock,
PortfolioDecisionSignalRiskItem,
PortfolioRiskResponse,
)
from api.v1.schemas.alerts import (
Expand Down Expand Up @@ -204,6 +206,8 @@
"PortfolioImportBrokerItem",
"PortfolioImportBrokerListResponse",
"PortfolioFxRefreshResponse",
"PortfolioDecisionSignalRiskBlock",
"PortfolioDecisionSignalRiskItem",
"PortfolioRiskResponse",
# alerts
"AlertDeleteResponse",
Expand Down
1 change: 1 addition & 0 deletions api/v1/schemas/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class AlertTriggerItem(BaseModel):
"evaluator_snapshot / legacy_text / null"
),
)
decision_signal_summary: Optional[Dict[str, Any]] = None


class AlertTriggerListResponse(BaseModel):
Expand Down
15 changes: 15 additions & 0 deletions api/v1/schemas/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,20 @@ class PortfolioFxRefreshResponse(BaseModel):
error_count: int


class PortfolioDecisionSignalRiskItem(BaseModel):
account_id: Optional[int] = None
symbol: str
market: str
signal: Dict[str, Any] = Field(default_factory=dict)


class PortfolioDecisionSignalRiskBlock(BaseModel):
available: bool = True
total: int = 0
actions: Dict[str, int] = Field(default_factory=dict)
items: List[PortfolioDecisionSignalRiskItem] = Field(default_factory=list)


class PortfolioRiskResponse(BaseModel):
as_of: str
account_id: Optional[int] = None
Expand All @@ -273,3 +287,4 @@ class PortfolioRiskResponse(BaseModel):
sector_concentration: Dict[str, Any] = Field(default_factory=dict)
drawdown: Dict[str, Any] = Field(default_factory=dict)
stop_loss: Dict[str, Any] = Field(default_factory=dict)
decision_signal_risk: PortfolioDecisionSignalRiskBlock = Field(default_factory=PortfolioDecisionSignalRiskBlock)
4 changes: 2 additions & 2 deletions apps/dsa-web/src/locales/featureText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ export const ALERT_MARKET_REGION_OPTIONS: Record<UiLanguage, Array<Option<Market
export const ALERT_MARKET_LIGHT_STATUS_OPTIONS: Record<UiLanguage, Array<Option<MarketLightStatus>>> = { zh: [{ value: 'red', label: '红灯' }, { value: 'yellow', label: '黄灯' }], en: [{ value: 'red', label: 'Red' }, { value: 'yellow', label: 'Yellow' }] };

export const PORTFOLIO_TEXT = {
zh: { documentTitle: '持仓分析 - DSA', title: '持仓管理', description: '组合快照、手工录入、CSV 导入与风险分析(支持全组合 / 单账户切换)', accountView: '账户视图', allAccounts: '全部账户', costMethod: '成本口径', fifo: '先进先出(FIFO)', avg: '均价成本(AVG)', collapseCreate: '收起新建', createAccount: '新建账户', deleteAccount: '删除账户', deletingAccount: '删除中...', deleteAccountTitle: '删除持仓账户', deleteAccountConfirm: '确认删除', deleteAccountMessage: '确认删除账户 {name}(#{id})吗?删除后该账户会从默认列表、快照、风险和录入入口隐藏;历史流水不会物理删除。', refreshing: '刷新中...', refreshData: '刷新数据', noAccounts: '还没有可用账户,请先创建账户后再录入交易或导入 CSV。', riskDegraded: '风险模块降级', operationHint: '操作提示', analysisTask: '分析任务', totalEquity: '总权益', totalMarketValue: '总市值', totalCash: '总现金', fxStatus: '汇率状态', refreshFx: '刷新汇率', stale: '过期', latest: '最新', fxRefreshResult: '汇率刷新结果', positionsTitle: '持仓明细', countItems: '共 {count} 项', noPositionsTitle: '当前无持仓数据', noPositionsDescription: '录入交易或导入 CSV 后,这里会展示按账户汇总的持仓明细。', account: '账户', code: '代码', quantity: '数量', avgCost: '均价', lastPrice: '现价', marketValue: '市值', unrealizedPnl: '未实现盈亏', returnPct: '收益率', action: '操作', submitting: '提交中', analyze: '分析', sectorConcentration: '行业集中度分布', positionConcentrationFallback: '行业数据暂不可用,当前展示个股集中度', noConcentrationTitle: '暂无集中度数据', noConcentrationDescription: '风险模块完成计算后,这里会展示行业或个股维度的集中度分布。', displayScope: '展示口径', sectorDimension: '行业维度', positionDimensionFallback: '个股维度(降级显示)', sectorAlert: '板块集中度告警', topWeight: 'Top1 权重', yes: '是', no: '否', writeBlocked: '当前处于“全部账户”视图。为避免误写,请先选择一个具体账户后再进行手工录入或 CSV 提交。', drawdownMonitor: '回撤监控', maxDrawdown: '最大回撤', currentDrawdown: '当前回撤', alert: '告警', stopLossWarning: '止损接近预警', triggeredCount: '触发数', nearCount: '接近数', scope: '口径', accountCount: '账户数', currency: '计价币种', costMethodShort: '成本法' },
en: { documentTitle: 'Portfolio Analysis - DSA', title: 'Portfolio management', description: 'Portfolio snapshots, manual entries, CSV import, and risk analysis with full-portfolio or single-account views', accountView: 'Account view', allAccounts: 'All accounts', costMethod: 'Cost method', fifo: 'FIFO', avg: 'Average cost', collapseCreate: 'Collapse', createAccount: 'New account', deleteAccount: 'Delete account', deletingAccount: 'Deleting...', deleteAccountTitle: 'Delete portfolio account', deleteAccountConfirm: 'Delete account', deleteAccountMessage: 'Delete account {name} (#{id})? It will be hidden from default lists, snapshots, risk views, and entry forms; historical ledger rows are not physically deleted.', refreshing: 'Refreshing...', refreshData: 'Refresh data', noAccounts: 'No accounts are available. Create an account before entering trades or importing CSV files.', riskDegraded: 'Risk module degraded', operationHint: 'Operation hint', analysisTask: 'Analysis task', totalEquity: 'Total equity', totalMarketValue: 'Total market value', totalCash: 'Total cash', fxStatus: 'FX status', refreshFx: 'Refresh FX', stale: 'Stale', latest: 'Current', fxRefreshResult: 'FX refresh result', positionsTitle: 'Positions', countItems: '{count} items', noPositionsTitle: 'No positions', noPositionsDescription: 'After you enter trades or import CSV data, account-level positions appear here.', account: 'Account', code: 'Code', quantity: 'Quantity', avgCost: 'Avg cost', lastPrice: 'Last price', marketValue: 'Market value', unrealizedPnl: 'Unrealized P/L', returnPct: 'Return', action: 'Action', submitting: 'Submitting', analyze: 'Analyze', sectorConcentration: 'Sector concentration', positionConcentrationFallback: 'Sector data unavailable; showing position concentration', noConcentrationTitle: 'No concentration data', noConcentrationDescription: 'Sector or position concentration appears after the risk module finishes.', displayScope: 'Display scope', sectorDimension: 'Sector', positionDimensionFallback: 'Position fallback', sectorAlert: 'Sector concentration alert', topWeight: 'Top1 weight', yes: 'Yes', no: 'No', writeBlocked: 'You are viewing all accounts. Select a specific account before manual entry or CSV submission to avoid writing to the wrong scope.', drawdownMonitor: 'Drawdown monitor', maxDrawdown: 'Max drawdown', currentDrawdown: 'Current drawdown', alert: 'Alert', stopLossWarning: 'Stop-loss proximity warning', triggeredCount: 'Triggered', nearCount: 'Near', scope: 'Scope', accountCount: 'Accounts', currency: 'Quote currency', costMethodShort: 'Cost method' },
zh: { documentTitle: '持仓分析 - DSA', title: '持仓管理', description: '组合快照、手工录入、CSV 导入与风险分析(支持全组合 / 单账户切换)', accountView: '账户视图', allAccounts: '全部账户', costMethod: '成本口径', fifo: '先进先出(FIFO)', avg: '均价成本(AVG)', collapseCreate: '收起新建', createAccount: '新建账户', deleteAccount: '删除账户', deletingAccount: '删除中...', deleteAccountTitle: '删除持仓账户', deleteAccountConfirm: '确认删除', deleteAccountMessage: '确认删除账户 {name}(#{id})吗?删除后该账户会从默认列表、快照、风险和录入入口隐藏;历史流水不会物理删除。', refreshing: '刷新中...', refreshData: '刷新数据', noAccounts: '还没有可用账户,请先创建账户后再录入交易或导入 CSV。', riskDegraded: '风险模块降级', operationHint: '操作提示', analysisTask: '分析任务', totalEquity: '总权益', totalMarketValue: '总市值', totalCash: '总现金', fxStatus: '汇率状态', refreshFx: '刷新汇率', stale: '过期', latest: '最新', fxRefreshResult: '汇率刷新结果', positionsTitle: '持仓明细', countItems: '共 {count} 项', noPositionsTitle: '当前无持仓数据', noPositionsDescription: '录入交易或导入 CSV 后,这里会展示按账户汇总的持仓明细。', account: '账户', code: '代码', quantity: '数量', avgCost: '均价', lastPrice: '现价', marketValue: '市值', unrealizedPnl: '未实现盈亏', returnPct: '收益率', action: '操作', submitting: '提交中', analyze: '分析', sectorConcentration: '行业集中度分布', positionConcentrationFallback: '行业数据暂不可用,当前展示个股集中度', noConcentrationTitle: '暂无集中度数据', noConcentrationDescription: '风险模块完成计算后,这里会展示行业或个股维度的集中度分布。', displayScope: '展示口径', sectorDimension: '行业维度', positionDimensionFallback: '个股维度(降级显示)', sectorAlert: '板块集中度告警', topWeight: 'Top1 权重', yes: '是', no: '否', writeBlocked: '当前处于“全部账户”视图。为避免误写,请先选择一个具体账户后再进行手工录入或 CSV 提交。', drawdownMonitor: '回撤监控', maxDrawdown: '最大回撤', currentDrawdown: '当前回撤', alert: '告警', stopLossWarning: '止损接近预警', triggeredCount: '触发数', nearCount: '接近数', scope: '口径', accountCount: '账户数', currency: '计价币种', costMethodShort: '成本法', aiRiskSignals: 'AI 风险信号', aiRiskUnavailable: '信号风险暂不可用', aiRiskTotal: '风险信号', sellSignals: '卖出', reduceSignals: '减仓', alertSignals: '预警', noAiRiskSignals: '暂无防御型信号' },
en: { documentTitle: 'Portfolio Analysis - DSA', title: 'Portfolio management', description: 'Portfolio snapshots, manual entries, CSV import, and risk analysis with full-portfolio or single-account views', accountView: 'Account view', allAccounts: 'All accounts', costMethod: 'Cost method', fifo: 'FIFO', avg: 'Average cost', collapseCreate: 'Collapse', createAccount: 'New account', deleteAccount: 'Delete account', deletingAccount: 'Deleting...', deleteAccountTitle: 'Delete portfolio account', deleteAccountConfirm: 'Delete account', deleteAccountMessage: 'Delete account {name} (#{id})? It will be hidden from default lists, snapshots, risk views, and entry forms; historical ledger rows are not physically deleted.', refreshing: 'Refreshing...', refreshData: 'Refresh data', noAccounts: 'No accounts are available. Create an account before entering trades or importing CSV files.', riskDegraded: 'Risk module degraded', operationHint: 'Operation hint', analysisTask: 'Analysis task', totalEquity: 'Total equity', totalMarketValue: 'Total market value', totalCash: 'Total cash', fxStatus: 'FX status', refreshFx: 'Refresh FX', stale: 'Stale', latest: 'Current', fxRefreshResult: 'FX refresh result', positionsTitle: 'Positions', countItems: '{count} items', noPositionsTitle: 'No positions', noPositionsDescription: 'After you enter trades or import CSV data, account-level positions appear here.', account: 'Account', code: 'Code', quantity: 'Quantity', avgCost: 'Avg cost', lastPrice: 'Last price', marketValue: 'Market value', unrealizedPnl: 'Unrealized P/L', returnPct: 'Return', action: 'Action', submitting: 'Submitting', analyze: 'Analyze', sectorConcentration: 'Sector concentration', positionConcentrationFallback: 'Sector data unavailable; showing position concentration', noConcentrationTitle: 'No concentration data', noConcentrationDescription: 'Sector or position concentration appears after the risk module finishes.', displayScope: 'Display scope', sectorDimension: 'Sector', positionDimensionFallback: 'Position fallback', sectorAlert: 'Sector concentration alert', topWeight: 'Top1 weight', yes: 'Yes', no: 'No', writeBlocked: 'You are viewing all accounts. Select a specific account before manual entry or CSV submission to avoid writing to the wrong scope.', drawdownMonitor: 'Drawdown monitor', maxDrawdown: 'Max drawdown', currentDrawdown: 'Current drawdown', alert: 'Alert', stopLossWarning: 'Stop-loss proximity warning', triggeredCount: 'Triggered', nearCount: 'Near', scope: 'Scope', accountCount: 'Accounts', currency: 'Quote currency', costMethodShort: 'Cost method', aiRiskSignals: 'AI risk signals', aiRiskUnavailable: 'Signal risk unavailable', aiRiskTotal: 'Risk signals', sellSignals: 'Sell', reduceSignals: 'Reduce', alertSignals: 'Alert', noAiRiskSignals: 'No defensive signals' },
} as const;
export const PORTFOLIO_SIDE_LABELS: Record<UiLanguage, Record<PortfolioSide, string>> = { zh: { buy: '买入', sell: '卖出' }, en: { buy: 'Buy', sell: 'Sell' } };
export const PORTFOLIO_CASH_DIRECTION_LABELS: Record<UiLanguage, Record<PortfolioCashDirection, string>> = { zh: { in: '流入', out: '流出' }, en: { in: 'Inflow', out: 'Outflow' } };
Expand Down
30 changes: 29 additions & 1 deletion apps/dsa-web/src/pages/PortfolioPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,8 @@ const PortfolioPage: React.FC = () => {
}
};

const decisionSignalRiskPreviewItems = (risk?.decisionSignalRisk?.items ?? []).slice(0, 3);

return (
<div className="portfolio-page min-h-screen space-y-4 p-4 md:p-6">
<section className="space-y-3">
Expand Down Expand Up @@ -1261,7 +1263,7 @@ const PortfolioPage: React.FC = () => {
/>
) : null}

<section className="grid grid-cols-1 md:grid-cols-3 gap-3">
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
<Card padding="md">
<h3 className="text-sm font-semibold text-foreground mb-2">{text.drawdownMonitor}</h3>
<div className="text-xs text-secondary space-y-1">
Expand All @@ -1286,6 +1288,32 @@ const PortfolioPage: React.FC = () => {
<div>{text.costMethodShort}: {(snapshot?.costMethod || costMethod).toUpperCase()}</div>
</div>
</Card>
<Card padding="md">
<h3 className="text-sm font-semibold text-foreground mb-2">{text.aiRiskSignals}</h3>
<div className="text-xs text-secondary space-y-1">
{risk?.decisionSignalRisk?.available === false ? (
<div className="text-warning">{text.aiRiskUnavailable}</div>
) : (
<>
<div>{text.aiRiskTotal}: {risk?.decisionSignalRisk?.total ?? 0}</div>
<div>
{text.sellSignals}: {risk?.decisionSignalRisk?.actions?.sell ?? 0} · {text.reduceSignals}: {risk?.decisionSignalRisk?.actions?.reduce ?? 0} · {text.alertSignals}: {risk?.decisionSignalRisk?.actions?.alert ?? 0}
</div>
{decisionSignalRiskPreviewItems.length > 0 ? (
<div className="space-y-1 pt-1">
{decisionSignalRiskPreviewItems.map((item) => (
<div key={`${item.accountId ?? 'all'}-${item.market}-${item.symbol}-${item.signal.id ?? item.signal.action}`} className="truncate text-foreground">
{item.symbol} · {item.signal.actionLabel || item.signal.action || text.alert}
</div>
))}
</div>
) : (
<div>{text.noAiRiskSignals}</div>
)}
</>
)}
</div>
</Card>
</section>

<section className="grid grid-cols-1 xl:grid-cols-3 gap-3">
Expand Down
62 changes: 61 additions & 1 deletion apps/dsa-web/src/pages/__tests__/PortfolioPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ function makePosition(overrides: Record<string, unknown> = {}) {
};
}

function makeRisk() {
function makeRisk(overrides: Record<string, unknown> = {}) {
return {
asOf: '2026-03-19',
accountId: null,
Expand Down Expand Up @@ -216,6 +216,13 @@ function makeRisk() {
nearCount: 0,
items: [],
},
decisionSignalRisk: {
available: true,
total: 0,
actions: { sell: 0, reduce: 0, alert: 0 },
items: [],
},
...overrides,
};
}

Expand Down Expand Up @@ -354,9 +361,62 @@ describe('PortfolioPage FX refresh', () => {
expect(screen.getByText(/Current drawdown:/)).toBeInTheDocument();
expect(screen.getByText('Stop-loss proximity warning')).toBeInTheDocument();
expect(screen.getByText('Scope')).toBeInTheDocument();
expect(screen.getByText('AI risk signals')).toBeInTheDocument();
expect(screen.getByText('No defensive signals')).toBeInTheDocument();
expect(screen.queryByText('回撤监控')).not.toBeInTheDocument();
});

it('renders portfolio decision signal risk summary', async () => {
getRisk.mockResolvedValueOnce(makeRisk({
decisionSignalRisk: {
available: true,
total: 2,
actions: { sell: 1, reduce: 0, alert: 1 },
items: [
{
accountId: 1,
symbol: '600519',
market: 'cn',
signal: makeDecisionSignal({ id: 201, action: 'sell', actionLabel: '卖出' }),
},
{
accountId: 1,
symbol: '300750',
market: 'cn',
signal: makeDecisionSignal({ id: 202, stockCode: '300750', action: 'alert', actionLabel: '预警' }),
},
],
},
}));

render(<PortfolioPage />);

await waitForInitialLoad();

expect(screen.getByText('AI 风险信号')).toBeInTheDocument();
expect(screen.getByText(/风险信号: 2/)).toBeInTheDocument();
expect(screen.getByText(/卖出: 1 · 减仓: 0 · 预警: 1/)).toBeInTheDocument();
expect(screen.getByText('600519 · 卖出')).toBeInTheDocument();
expect(screen.getByText('300750 · 预警')).toBeInTheDocument();
});

it('renders portfolio decision signal risk fail-open state', async () => {
getRisk.mockResolvedValueOnce(makeRisk({
decisionSignalRisk: {
available: false,
total: 0,
actions: { sell: 0, reduce: 0, alert: 0 },
items: [],
},
}));

render(<PortfolioPage />);

await waitForInitialLoad();

expect(screen.getByText('信号风险暂不可用')).toBeInTheDocument();
});

it('refreshes FX for a single selected account and only reloads snapshot/risk', async () => {
getSnapshot
.mockResolvedValueOnce(makeSnapshot({ fxStale: true }))
Expand Down
2 changes: 2 additions & 0 deletions apps/dsa-web/src/types/alerts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AnalysisContextPackOverview, MarketPhaseSummary } from './analysis';
import type { DecisionSignalItem } from './decisionSignals';

export type AlertType =
| 'price_cross'
Expand Down Expand Up @@ -122,6 +123,7 @@ export interface AlertTriggerItem {
marketPhaseSummary?: MarketPhaseSummary | null;
analysisContextPackOverview?: AnalysisContextPackOverview | null;
analysisVisibilitySource?: string | null;
decisionSignalSummary?: Partial<DecisionSignalItem> | null;
}

export interface AlertTriggerListResponse {
Expand Down
22 changes: 22 additions & 0 deletions apps/dsa-web/src/types/portfolio.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { DecisionSignalItem } from './decisionSignals';

export type PortfolioCostMethod = 'fifo' | 'avg';
export type PortfolioSide = 'buy' | 'sell';
export type PortfolioCashDirection = 'in' | 'out';
Expand Down Expand Up @@ -121,6 +123,25 @@ export interface PortfolioStopLossItem {
isTriggered: boolean;
}

export interface PortfolioDecisionSignalRiskItem {
accountId?: number | null;
symbol: string;
market: string;
signal: Partial<DecisionSignalItem>;
}

export interface PortfolioDecisionSignalRiskBlock {
available: boolean;
total: number;
actions: {
sell?: number;
reduce?: number;
alert?: number;
[key: string]: number | undefined;
};
items: PortfolioDecisionSignalRiskItem[];
}

export interface PortfolioRiskResponse {
asOf: string;
accountId?: number | null;
Expand Down Expand Up @@ -148,6 +169,7 @@ export interface PortfolioRiskResponse {
nearCount: number;
items: PortfolioStopLossItem[];
};
decisionSignalRisk?: PortfolioDecisionSignalRiskBlock;
}

export interface PortfolioTradeCreateRequest {
Expand Down
Loading
Loading