Skip to content

Commit cae6467

Browse files
committed
docs: close out decision signal p7
1 parent dda5269 commit cae6467

21 files changed

Lines changed: 530 additions & 22 deletions

apps/dsa-web/src/components/decision-signals/DecisionSignalDisplay.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import {
1818
} from '../../utils/decisionAction';
1919
import { cn } from '../../utils/cn';
2020
import { parseDecisionSignalDate } from '../../utils/decisionSignalTime';
21+
import {
22+
getDecisionSignalHorizonLabel,
23+
getDecisionSignalMarketLabel,
24+
getDecisionSignalMarketPhaseLabel,
25+
getDecisionSignalPlanQualityLabel,
26+
} from '../../utils/decisionSignalLabels';
2127

2228
type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info' | 'history';
2329

@@ -120,11 +126,6 @@ function getActionVariant(item: DecisionSignalItem): BadgeVariant {
120126
return ACTION_VARIANTS[getDecisionActionTone(item.action, item.actionLabel, null)];
121127
}
122128

123-
function getMarketLabel(market: DecisionSignalItem['market'], t: (key: UiTextKey) => string): string {
124-
const key = `decisionSignals.market.${market}` as UiTextKey;
125-
return t(key);
126-
}
127-
128129
function getOutcomeLabel(value: DecisionSignalOutcomeValue | null | undefined, t: (key: UiTextKey) => string): string {
129130
if (!value) return '-';
130131
const key = `decisionSignals.outcome.${value}` as UiTextKey;
@@ -166,7 +167,7 @@ export const DecisionSignalCard: React.FC<DecisionSignalCardProps> = ({ item, on
166167
</h3>
167168
</div>
168169
<div className="text-right text-xs text-secondary-text">
169-
<div>{getMarketLabel(item.market, t)}</div>
170+
<div>{getDecisionSignalMarketLabel(item.market, t)}</div>
170171
<div className="mt-1">{formatDateTime(item.createdAt, language)}</div>
171172
</div>
172173
</div>
@@ -176,9 +177,9 @@ export const DecisionSignalCard: React.FC<DecisionSignalCardProps> = ({ item, on
176177
{item.watchConditions ? <p className="line-clamp-2">{item.watchConditions}</p> : null}
177178
</div>
178179
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-text">
179-
<span>{t('decisionSignals.horizon')}: {item.horizon || '-'}</span>
180-
<span>{t('decisionSignals.planQuality')}: {item.planQuality}</span>
181-
<span>{t('decisionSignals.marketPhase')}: {item.marketPhase || '-'}</span>
180+
<span>{t('decisionSignals.horizon')}: {getDecisionSignalHorizonLabel(item.horizon, t)}</span>
181+
<span>{t('decisionSignals.planQuality')}: {getDecisionSignalPlanQualityLabel(item.planQuality, t)}</span>
182+
<span>{t('decisionSignals.marketPhase')}: {getDecisionSignalMarketPhaseLabel(item.marketPhase, t)}</span>
182183
</div>
183184
</>
184185
);
@@ -257,17 +258,17 @@ export const DecisionSignalDetails: React.FC<DecisionSignalDetailsProps> = ({
257258
<Badge variant={STATUS_VARIANTS[item.status]} size="md">{t(STATUS_LABEL_KEYS[item.status])}</Badge>
258259
</div>
259260
<h3 className="mt-3 text-xl font-semibold text-foreground">{item.stockName || item.stockCode}</h3>
260-
<p className="mt-1 font-mono text-sm text-secondary-text">{item.stockCode} · {getMarketLabel(item.market, t)}</p>
261+
<p className="mt-1 font-mono text-sm text-secondary-text">{item.stockCode} · {getDecisionSignalMarketLabel(item.market, t)}</p>
261262
</div>
262263
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
263264
</div>
264265

265266
<div className="grid gap-3 sm:grid-cols-2">
266267
<DetailRow label={t('decisionSignals.score')} value={formatNumber(item.score)} />
267268
<DetailRow label={t('decisionSignals.confidence')} value={formatNumber(item.confidence)} />
268-
<DetailRow label={t('decisionSignals.horizon')} value={item.horizon || '-'} />
269-
<DetailRow label={t('decisionSignals.planQuality')} value={item.planQuality} />
270-
<DetailRow label={t('decisionSignals.marketPhase')} value={item.marketPhase || '-'} />
269+
<DetailRow label={t('decisionSignals.horizon')} value={getDecisionSignalHorizonLabel(item.horizon, t)} />
270+
<DetailRow label={t('decisionSignals.planQuality')} value={getDecisionSignalPlanQualityLabel(item.planQuality, t)} />
271+
<DetailRow label={t('decisionSignals.marketPhase')} value={getDecisionSignalMarketPhaseLabel(item.marketPhase, t)} />
271272
<DetailRow label={t('decisionSignals.sourceReport')} value={item.sourceReportId ? `#${item.sourceReportId}` : '-'} />
272273
<DetailRow label={t('decisionSignals.createdAt')} value={formatDateTime(item.createdAt, language)} />
273274
<DetailRow label={t('decisionSignals.expiresAt')} value={formatDateTime(item.expiresAt, language)} />
@@ -294,7 +295,7 @@ export const DecisionSignalDetails: React.FC<DecisionSignalDetailsProps> = ({
294295
<div key={outcome.id} className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
295296
<div className="flex flex-wrap items-center justify-between gap-2">
296297
<div className="flex flex-wrap items-center gap-2">
297-
<span className="text-sm font-medium text-foreground">{outcome.horizon}</span>
298+
<span className="text-sm font-medium text-foreground">{getDecisionSignalHorizonLabel(outcome.horizon, t)}</span>
298299
{outcome.outcome ? (
299300
<Badge variant={OUTCOME_VARIANTS[outcome.outcome]}>
300301
{getOutcomeLabel(outcome.outcome, t)}
@@ -395,7 +396,7 @@ export const PortfolioSignalSummary: React.FC<PortfolioSignalSummaryProps> = ({
395396
<div className="min-w-[11rem] max-w-[18rem] text-left">
396397
<div className="flex flex-wrap items-center justify-end gap-1.5">
397398
<Badge variant={getActionVariant(item)}>{actionLabel}</Badge>
398-
{item.horizon ? <span className="text-[11px] text-secondary-text">{item.horizon}</span> : null}
399+
{item.horizon ? <span className="text-[11px] text-secondary-text">{getDecisionSignalHorizonLabel(item.horizon, t)}</span> : null}
399400
</div>
400401
{item.riskSummary ? <p className="mt-1 line-clamp-2 text-[11px] text-warning">{item.riskSummary}</p> : null}
401402
{item.watchConditions ? <p className="mt-1 line-clamp-2 text-[11px] text-secondary-text">{item.watchConditions}</p> : null}

apps/dsa-web/src/components/decision-signals/__tests__/DecisionSignalDisplay.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
22
import { describe, expect, it, vi } from 'vitest';
33
import { UiLanguageProvider } from '../../../contexts/UiLanguageContext';
44
import type { DecisionSignalItem } from '../../../types/decisionSignals';
5-
import { DecisionSignalCard, DecisionSignalDetails } from '../DecisionSignalDisplay';
5+
import { DecisionSignalCard, DecisionSignalDetails, PortfolioSignalSummary } from '../DecisionSignalDisplay';
66

77
const signal: DecisionSignalItem = {
88
id: 7,
@@ -55,6 +55,12 @@ describe('DecisionSignalCard', () => {
5555
fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 AI 建议详情' }));
5656

5757
expect(onSelect).toHaveBeenCalledWith(signal);
58+
expect(screen.getByText('周期: 3 日')).toBeInTheDocument();
59+
expect(screen.getByText('计划质量: 完整')).toBeInTheDocument();
60+
expect(screen.getByText('阶段: 盘中')).toBeInTheDocument();
61+
expect(screen.queryByText('3d')).not.toBeInTheDocument();
62+
expect(screen.queryByText('complete')).not.toBeInTheDocument();
63+
expect(screen.queryByText('intraday')).not.toBeInTheDocument();
5864
});
5965

6066
it('renders non-interactive cards without a details button', () => {
@@ -77,6 +83,10 @@ describe('DecisionSignalDetails', () => {
7783
const entryRange = screen.getByText('入场区间').closest('div');
7884
expect(entryRange).not.toBeNull();
7985
expect(entryRange as HTMLElement).toHaveTextContent('1680');
86+
expect(screen.getByText('3 日')).toBeInTheDocument();
87+
expect(screen.getByText('完整')).toBeInTheDocument();
88+
expect(screen.getByText('盘中')).toBeInTheDocument();
89+
expect(screen.queryByText('3d')).not.toBeInTheDocument();
8090
});
8191

8292
it('renders opaque JSON fields without creating html nodes from their string values', () => {
@@ -146,9 +156,22 @@ describe('DecisionSignalDetails', () => {
146156
);
147157

148158
expect(screen.getByText('后验结果')).toBeInTheDocument();
159+
expect(screen.getAllByText('3 日').length).toBeGreaterThan(1);
149160
expect(screen.getByText('命中')).toBeInTheDocument();
150161
expect(screen.getByText('5%')).toBeInTheDocument();
151162
fireEvent.click(screen.getByRole('button', { name: '无用' }));
152163
expect(onFeedbackSubmit).toHaveBeenCalledWith('not_useful');
153164
});
165+
166+
it('renders portfolio signal horizon using the current UI language', () => {
167+
window.localStorage.setItem('dsa.uiLanguage', 'en');
168+
render(
169+
<UiLanguageProvider>
170+
<PortfolioSignalSummary item={{ ...signal, horizon: '10d', action: 'sell', actionLabel: null }} />
171+
</UiLanguageProvider>,
172+
);
173+
174+
expect(screen.getByText('10 days')).toBeInTheDocument();
175+
expect(screen.queryByText('10d')).not.toBeInTheDocument();
176+
});
154177
});

apps/dsa-web/src/components/report/__tests__/ReportDecisionSignals.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ describe('ReportDecisionSignals', () => {
7979

8080
expect(await screen.findByText('腾讯控股')).toBeInTheDocument();
8181
expect(screen.getByText('缺少成交量确认')).toBeInTheDocument();
82+
expect(screen.getByText('周期: 5 日')).toBeInTheDocument();
83+
expect(screen.getByText('计划质量: 部分')).toBeInTheDocument();
84+
expect(screen.getByText('阶段: 盘后')).toBeInTheDocument();
85+
expect(screen.queryByText('5d')).not.toBeInTheDocument();
86+
expect(screen.queryByText('postmarket')).not.toBeInTheDocument();
87+
expect(screen.queryByText('partial')).not.toBeInTheDocument();
8288
expect(decisionSignalsApi.list).toHaveBeenCalledWith({
8389
sourceReportId: 5,
8490
sourceType: 'analysis',

apps/dsa-web/src/i18n/uiText.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,13 @@ const zh = {
223223
'decisionSignals.feedbackTitle': '用户反馈',
224224
'decisionSignals.filter': '筛选',
225225
'decisionSignals.horizon': '周期',
226+
'decisionSignals.horizon.1d': '1 日',
227+
'decisionSignals.horizon.3d': '3 日',
228+
'decisionSignals.horizon.5d': '5 日',
229+
'decisionSignals.horizon.10d': '10 日',
230+
'decisionSignals.horizon.intraday': '盘中',
231+
'decisionSignals.horizon.long': '长期',
232+
'decisionSignals.horizon.swing': '波段',
226233
'decisionSignals.invalidate': '标记失效',
227234
'decisionSignals.invalidateConfirm': '确认将这条信号标记为失效吗?失效后不会再作为当前 active 建议展示。',
228235
'decisionSignals.invalidated': '已失效',
@@ -234,8 +241,17 @@ const zh = {
234241
'decisionSignals.market': '市场',
235242
'decisionSignals.market.cn': 'A 股',
236243
'decisionSignals.market.hk': '港股',
244+
'decisionSignals.market.jp': '日股',
245+
'decisionSignals.market.kr': '韩股',
237246
'decisionSignals.market.us': '美股',
238247
'decisionSignals.marketPhase': '阶段',
248+
'decisionSignals.marketPhase.closing_auction': '集合竞价',
249+
'decisionSignals.marketPhase.intraday': '盘中',
250+
'decisionSignals.marketPhase.lunch_break': '午间休市',
251+
'decisionSignals.marketPhase.non_trading': '非交易时段',
252+
'decisionSignals.marketPhase.postmarket': '盘后',
253+
'decisionSignals.marketPhase.premarket': '盘前',
254+
'decisionSignals.marketPhase.unknown': '未知阶段',
239255
'decisionSignals.metadata': '元数据',
240256
'decisionSignals.noLatestDescription': '该股票当前没有 active 信号,或信号已过期。',
241257
'decisionSignals.noLatestTitle': '暂无最新有效信号',
@@ -249,6 +265,10 @@ const zh = {
249265
'decisionSignals.outcomes': '后验结果',
250266
'decisionSignals.pageTitle': 'AI 建议 - DSA',
251267
'decisionSignals.planQuality': '计划质量',
268+
'decisionSignals.planQuality.complete': '完整',
269+
'decisionSignals.planQuality.minimal': '最小',
270+
'decisionSignals.planQuality.partial': '部分',
271+
'decisionSignals.planQuality.unknown': '未知',
252272
'decisionSignals.pricePlan': '价格计划',
253273
'decisionSignals.reason': '理由',
254274
'decisionSignals.refresh': '刷新',
@@ -261,6 +281,11 @@ const zh = {
261281
'decisionSignals.riskSummary': '风险',
262282
'decisionSignals.score': '评分',
263283
'decisionSignals.source': '来源',
284+
'decisionSignals.sourceType.agent': 'Agent',
285+
'decisionSignals.sourceType.alert': '告警',
286+
'decisionSignals.sourceType.analysis': '分析报告',
287+
'decisionSignals.sourceType.manual': '手动',
288+
'decisionSignals.sourceType.market_review': '大盘复盘',
264289
'decisionSignals.sourceReport': '来源报告',
265290
'decisionSignals.statsDescription': '基于当前后验引擎版本统计信号表现,默认排除已归档信号。',
266291
'decisionSignals.statsErrorTitle': '后验统计加载失败',
@@ -867,6 +892,13 @@ const en: Record<UiTextKey, string> = {
867892
'decisionSignals.feedbackTitle': 'User feedback',
868893
'decisionSignals.filter': 'Filter',
869894
'decisionSignals.horizon': 'Horizon',
895+
'decisionSignals.horizon.1d': '1 day',
896+
'decisionSignals.horizon.3d': '3 days',
897+
'decisionSignals.horizon.5d': '5 days',
898+
'decisionSignals.horizon.10d': '10 days',
899+
'decisionSignals.horizon.intraday': 'Intraday',
900+
'decisionSignals.horizon.long': 'Long term',
901+
'decisionSignals.horizon.swing': 'Swing',
870902
'decisionSignals.invalidate': 'Mark invalid',
871903
'decisionSignals.invalidateConfirm': 'Mark this signal invalid? Invalidated signals are no longer shown as current active recommendations.',
872904
'decisionSignals.invalidated': 'Invalidated',
@@ -878,8 +910,17 @@ const en: Record<UiTextKey, string> = {
878910
'decisionSignals.market': 'Market',
879911
'decisionSignals.market.cn': 'A-shares',
880912
'decisionSignals.market.hk': 'Hong Kong',
913+
'decisionSignals.market.jp': 'Japan',
914+
'decisionSignals.market.kr': 'Korea',
881915
'decisionSignals.market.us': 'US',
882916
'decisionSignals.marketPhase': 'Phase',
917+
'decisionSignals.marketPhase.closing_auction': 'Closing auction',
918+
'decisionSignals.marketPhase.intraday': 'Intraday',
919+
'decisionSignals.marketPhase.lunch_break': 'Lunch break',
920+
'decisionSignals.marketPhase.non_trading': 'Non-trading',
921+
'decisionSignals.marketPhase.postmarket': 'Post-market',
922+
'decisionSignals.marketPhase.premarket': 'Pre-market',
923+
'decisionSignals.marketPhase.unknown': 'Unknown phase',
883924
'decisionSignals.metadata': 'Metadata',
884925
'decisionSignals.noLatestDescription': 'This stock has no current active signal, or existing signals have expired.',
885926
'decisionSignals.noLatestTitle': 'No latest active signals',
@@ -893,6 +934,10 @@ const en: Record<UiTextKey, string> = {
893934
'decisionSignals.outcomes': 'Outcome results',
894935
'decisionSignals.pageTitle': 'AI signals - DSA',
895936
'decisionSignals.planQuality': 'Plan quality',
937+
'decisionSignals.planQuality.complete': 'Complete',
938+
'decisionSignals.planQuality.minimal': 'Minimal',
939+
'decisionSignals.planQuality.partial': 'Partial',
940+
'decisionSignals.planQuality.unknown': 'Unknown',
896941
'decisionSignals.pricePlan': 'Price plan',
897942
'decisionSignals.reason': 'Reason',
898943
'decisionSignals.refresh': 'Refresh',
@@ -905,6 +950,11 @@ const en: Record<UiTextKey, string> = {
905950
'decisionSignals.riskSummary': 'Risk',
906951
'decisionSignals.score': 'Score',
907952
'decisionSignals.source': 'Source',
953+
'decisionSignals.sourceType.agent': 'Agent',
954+
'decisionSignals.sourceType.alert': 'Alert',
955+
'decisionSignals.sourceType.analysis': 'Analysis report',
956+
'decisionSignals.sourceType.manual': 'Manual',
957+
'decisionSignals.sourceType.market_review': 'Market review',
908958
'decisionSignals.sourceReport': 'Source report',
909959
'decisionSignals.statsDescription': 'Signal performance from the current outcome engine version, excluding archived signals by default.',
910960
'decisionSignals.statsErrorTitle': 'Failed to load outcome stats',

apps/dsa-web/src/pages/DecisionSignalsPage.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ import type {
3434
} from '../types/decisionSignals';
3535
import { cn } from '../utils/cn';
3636
import { buildDecisionActionLabelMap } from '../utils/decisionAction';
37+
import {
38+
getDecisionSignalMarketLabel,
39+
getDecisionSignalMarketPhaseLabel,
40+
getDecisionSignalSourceTypeLabel,
41+
} from '../utils/decisionSignalLabels';
3742

3843
const PAGE_SIZE = 20;
3944

@@ -415,7 +420,7 @@ const DecisionSignalsPage: React.FC = () => {
415420
>
416421
<option value="">{t('decisionSignals.allMarkets')}</option>
417422
{MARKET_OPTIONS.map((market) => (
418-
<option key={market} value={market}>{t(`decisionSignals.market.${market}` as UiTextKey)}</option>
423+
<option key={market} value={market}>{getDecisionSignalMarketLabel(market, t)}</option>
419424
))}
420425
</select>
421426
<input
@@ -443,7 +448,9 @@ const DecisionSignalsPage: React.FC = () => {
443448
aria-label={t('decisionSignals.marketPhase')}
444449
>
445450
<option value="">{t('decisionSignals.allPhases')}</option>
446-
{PHASE_OPTIONS.map((phase) => <option key={phase} value={phase}>{phase}</option>)}
451+
{PHASE_OPTIONS.map((phase) => (
452+
<option key={phase} value={phase}>{getDecisionSignalMarketPhaseLabel(phase, t)}</option>
453+
))}
447454
</select>
448455
<select
449456
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm"
@@ -452,7 +459,9 @@ const DecisionSignalsPage: React.FC = () => {
452459
aria-label={t('decisionSignals.source')}
453460
>
454461
<option value="">{t('decisionSignals.allSources')}</option>
455-
{SOURCE_OPTIONS.map((source) => <option key={source} value={source}>{source}</option>)}
462+
{SOURCE_OPTIONS.map((source) => (
463+
<option key={source} value={source}>{getDecisionSignalSourceTypeLabel(source, t)}</option>
464+
))}
456465
</select>
457466
<select
458467
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm"

apps/dsa-web/src/pages/PortfolioPage.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type {
5151
} from '../types/portfolio';
5252
import { areStockCodesEquivalent, normalizeStockCode } from '../utils/stockCode';
5353
import { parseDecisionSignalDate } from '../utils/decisionSignalTime';
54+
import { buildDecisionActionLabelMap } from '../utils/decisionAction';
5455

5556
const PIE_COLORS = ['#00d4ff', '#00ff88', '#ffaa00', '#ff7a45', '#7f8cff', '#ff4466'];
5657
const DEFAULT_PAGE_SIZE = 20;
@@ -160,6 +161,7 @@ async function loadPortfolioSignalLookup(lookup: PortfolioSignalLookup): Promise
160161
const PortfolioPage: React.FC = () => {
161162
const { language, t } = useUiLanguage();
162163
const text = PORTFOLIO_TEXT[language];
164+
const decisionActionLabels = useMemo(() => buildDecisionActionLabelMap(t), [t]);
163165

164166
// Set page title
165167
useEffect(() => {
@@ -914,6 +916,11 @@ const PortfolioPage: React.FC = () => {
914916
};
915917

916918
const decisionSignalRiskPreviewItems = (risk?.decisionSignalRisk?.items ?? []).slice(0, 3);
919+
const formatDecisionSignalRiskAction = useCallback((signal: Partial<DecisionSignalItem>): string => (
920+
signal.actionLabel
921+
|| (signal.action ? decisionActionLabels[signal.action] : undefined)
922+
|| text.alert
923+
), [decisionActionLabels, text.alert]);
917924

918925
return (
919926
<div className="portfolio-page min-h-screen space-y-4 p-4 md:p-6">
@@ -1306,7 +1313,7 @@ const PortfolioPage: React.FC = () => {
13061313
<div className="space-y-1 pt-1">
13071314
{decisionSignalRiskPreviewItems.map((item) => (
13081315
<div key={`${item.accountId ?? 'all'}-${item.market}-${item.symbol}-${item.signal.id ?? item.signal.action}`} className="truncate text-foreground">
1309-
{item.symbol} · {item.signal.actionLabel || item.signal.action || text.alert}
1316+
{item.symbol} · {formatDecisionSignalRiskAction(item.signal)}
13101317
</div>
13111318
))}
13121319
</div>

0 commit comments

Comments
 (0)