Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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