feat: add decision signal stock context (#1925)

Co-authored-by: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
Alfred
2026-07-05 19:22:23 +08:00
committed by GitHub
parent 01addc6318
commit 36f03c06e2
7 changed files with 953 additions and 124 deletions

View File

@@ -13,6 +13,7 @@ import { useStockIndex } from '../../hooks/useStockIndex';
import { useAutocomplete } from '../../hooks/useAutocomplete'; import { useAutocomplete } from '../../hooks/useAutocomplete';
import { SuggestionsList } from './SuggestionsList'; import { SuggestionsList } from './SuggestionsList';
import { cn } from '../../utils/cn'; import { cn } from '../../utils/cn';
import type { Market } from '../../types/stockIndex';
const AUTOCOMPLETE_INPUT_CLASS = const AUTOCOMPLETE_INPUT_CLASS =
'input-surface input-focus-glow h-11 w-full rounded-xl border bg-transparent px-4 text-sm transition-all focus:outline-none disabled:cursor-not-allowed disabled:opacity-60'; 'input-surface input-focus-glow h-11 w-full rounded-xl border bg-transparent px-4 text-sm transition-all focus:outline-none disabled:cursor-not-allowed disabled:opacity-60';
@@ -22,12 +23,19 @@ export interface StockAutocompleteProps {
value: string; value: string;
/** Value change callback */ /** Value change callback */
onChange: (value: string) => void; onChange: (value: string) => void;
/** Submit callback (code, name, source) */ /** Submit callback (code, name, source, metadata) */
onSubmit: (code: string, name?: string, source?: 'manual' | 'autocomplete') => void; onSubmit: (
code: string,
name?: string,
source?: 'manual' | 'autocomplete',
metadata?: { market?: Market; displayCode?: string },
) => void;
/** Whether disabled */ /** Whether disabled */
disabled?: boolean; disabled?: boolean;
/** Placeholder text */ /** Placeholder text */
placeholder?: string; placeholder?: string;
/** Accessible label */
ariaLabel?: string;
/** Additional CSS class name */ /** Additional CSS class name */
className?: string; className?: string;
} }
@@ -38,6 +46,7 @@ function FallbackInput({
onSubmit, onSubmit,
disabled = false, disabled = false,
placeholder = '输入股票代码或名称', placeholder = '输入股票代码或名称',
ariaLabel,
className, className,
}: StockAutocompleteProps) { }: StockAutocompleteProps) {
return ( return (
@@ -47,10 +56,12 @@ function FallbackInput({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !disabled && value) { if (e.key === 'Enter' && !disabled && value) {
e.preventDefault();
onSubmit(value); onSubmit(value);
} }
}} }}
placeholder={placeholder} placeholder={placeholder}
aria-label={ariaLabel}
disabled={disabled} disabled={disabled}
className={cn(AUTOCOMPLETE_INPUT_CLASS, className)} className={cn(AUTOCOMPLETE_INPUT_CLASS, className)}
data-autocomplete-mode="fallback" data-autocomplete-mode="fallback"
@@ -97,6 +108,7 @@ function StockAutocompleteInner({
onSubmit, onSubmit,
disabled = false, disabled = false,
placeholder = '输入股票代码或名称', placeholder = '输入股票代码或名称',
ariaLabel,
className, className,
}: StockAutocompleteProps) { }: StockAutocompleteProps) {
const { index, loading, fallback } = useStockIndex(); const { index, loading, fallback } = useStockIndex();
@@ -194,7 +206,10 @@ function StockAutocompleteInner({
const selected = suggestions[highlightedIndex]; const selected = suggestions[highlightedIndex];
onChange(selected.displayCode); onChange(selected.displayCode);
closeSuggestions(); closeSuggestions();
onSubmit(selected.canonicalCode, selected.nameZh, 'autocomplete'); onSubmit(selected.canonicalCode, selected.nameZh, 'autocomplete', {
market: selected.market,
displayCode: selected.displayCode,
});
} else { } else {
// Submit directly // Submit directly
onSubmit(value); onSubmit(value);
@@ -230,6 +245,7 @@ function StockAutocompleteInner({
onSubmit={onSubmit} onSubmit={onSubmit}
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
ariaLabel={ariaLabel}
className={className} className={className}
/> />
); );
@@ -252,6 +268,7 @@ function StockAutocompleteInner({
}} }}
onBlur={handleBlur} onBlur={handleBlur}
placeholder={placeholder} placeholder={placeholder}
aria-label={ariaLabel}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
AUTOCOMPLETE_INPUT_CLASS, AUTOCOMPLETE_INPUT_CLASS,
@@ -283,7 +300,10 @@ function StockAutocompleteInner({
// Close dropdown list // Close dropdown list
closeSuggestions(); closeSuggestions();
// Submit analysis // Submit analysis
onSubmit(s.canonicalCode, s.nameZh, 'autocomplete'); onSubmit(s.canonicalCode, s.nameZh, 'autocomplete', {
market: s.market,
displayCode: s.displayCode,
});
}} }}
onMouseEnter={(index) => setHighlightedIndex(index)} onMouseEnter={(index) => setHighlightedIndex(index)}
style={{ position: 'fixed', ...dropdownStyle }} style={{ position: 'fixed', ...dropdownStyle }}

View File

@@ -4,6 +4,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import type React from 'react';
import { StockAutocomplete } from '../StockAutocomplete'; import { StockAutocomplete } from '../StockAutocomplete';
import type { StockIndexItem, StockSuggestion } from '../../../types/stockIndex'; import type { StockIndexItem, StockSuggestion } from '../../../types/stockIndex';
@@ -238,6 +239,19 @@ describe('StockAutocomplete', () => {
expect(input).toHaveAttribute('role', 'combobox'); expect(input).toHaveAttribute('role', 'combobox');
}); });
it('applies an accessible label to the autocomplete input', () => {
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
ariaLabel="当前股票"
/>
);
expect(screen.getByLabelText('当前股票')).toBeInTheDocument();
});
describe('fallback mode', () => { describe('fallback mode', () => {
it('renders a plain input when index loading fallback is active', () => { it('renders a plain input when index loading fallback is active', () => {
stockIndexHookImpl = () => ({ stockIndexHookImpl = () => ({
@@ -323,6 +337,73 @@ describe('StockAutocomplete', () => {
expect(mockOnSubmit).toHaveBeenCalledWith('600519'); expect(mockOnSubmit).toHaveBeenCalledWith('600519');
}); });
it('applies an accessible label to the fallback input', () => {
autocompleteHookImpl = () => ({
query: '',
setQuery: vi.fn(),
suggestions: [],
isOpen: false,
highlightedIndex: -1,
setHighlightedIndex: vi.fn(),
highlightPrevious: vi.fn(),
highlightNext: vi.fn(),
handleSelect: vi.fn(),
close: vi.fn(),
reset: vi.fn(),
isComposing: false,
setIsComposing: vi.fn(),
runtimeFallback: true,
error: new Error('Search crashed'),
});
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
ariaLabel="当前股票"
/>
);
expect(screen.getByLabelText('当前股票')).toHaveAttribute('data-autocomplete-mode', 'fallback');
});
it('prevents duplicate form submission when fallback input receives Enter', () => {
const formSubmit = vi.fn((event: React.FormEvent) => event.preventDefault());
autocompleteHookImpl = () => ({
query: '',
setQuery: vi.fn(),
suggestions: [],
isOpen: false,
highlightedIndex: -1,
setHighlightedIndex: vi.fn(),
highlightPrevious: vi.fn(),
highlightNext: vi.fn(),
handleSelect: vi.fn(),
close: vi.fn(),
reset: vi.fn(),
isComposing: false,
setIsComposing: vi.fn(),
runtimeFallback: true,
error: new Error('Search crashed'),
});
render(
<form onSubmit={formSubmit}>
<StockAutocomplete
value="600519"
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
</form>
);
fireEvent.keyDown(screen.getByDisplayValue('600519'), { key: 'Enter' });
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
expect(formSubmit).not.toHaveBeenCalled();
});
}); });
describe('IME support', () => { describe('IME support', () => {
@@ -410,7 +491,10 @@ describe('StockAutocomplete', () => {
fireEvent.keyDown(input, { key: 'Enter' }); fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnChange).toHaveBeenCalledWith('600519'); expect(mockOnChange).toHaveBeenCalledWith('600519');
expect(mockOnSubmit).toHaveBeenCalledWith('600519.SH', '贵州茅台', 'autocomplete'); expect(mockOnSubmit).toHaveBeenCalledWith('600519.SH', '贵州茅台', 'autocomplete', {
market: 'CN',
displayCode: '600519',
});
}); });
it('submits the highlighted HK suggestion using the canonical .HK code', () => { it('submits the highlighted HK suggestion using the canonical .HK code', () => {
@@ -444,7 +528,10 @@ describe('StockAutocomplete', () => {
fireEvent.keyDown(input, { key: 'Enter' }); fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnChange).toHaveBeenCalledWith('00700'); expect(mockOnChange).toHaveBeenCalledWith('00700');
expect(mockOnSubmit).toHaveBeenCalledWith('00700.HK', '腾讯控股', 'autocomplete'); expect(mockOnSubmit).toHaveBeenCalledWith('00700.HK', '腾讯控股', 'autocomplete', {
market: 'HK',
displayCode: '00700',
});
}); });
it('submits the highlighted BSE suggestion using the canonical .BJ code', () => { it('submits the highlighted BSE suggestion using the canonical .BJ code', () => {
@@ -478,7 +565,10 @@ describe('StockAutocomplete', () => {
fireEvent.keyDown(input, { key: 'Enter' }); fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnChange).toHaveBeenCalledWith('920493'); expect(mockOnChange).toHaveBeenCalledWith('920493');
expect(mockOnSubmit).toHaveBeenCalledWith('920493.BJ', '示例北交所股票', 'autocomplete'); expect(mockOnSubmit).toHaveBeenCalledWith('920493.BJ', '示例北交所股票', 'autocomplete', {
market: 'BSE',
displayCode: '920493',
});
}); });
}); });

View File

@@ -236,7 +236,7 @@ const zh = {
'decisionSignals.invalidateConfirm': '确认将这条信号标记为失效吗?失效后不会再作为当前 active 建议展示。', 'decisionSignals.invalidateConfirm': '确认将这条信号标记为失效吗?失效后不会再作为当前 active 建议展示。',
'decisionSignals.invalidated': '已失效', 'decisionSignals.invalidated': '已失效',
'decisionSignals.latestButton': '查询最新', 'decisionSignals.latestButton': '查询最新',
'decisionSignals.latestDescription': '输入股票代码,直接读取该股票最新 active 信号。', 'decisionSignals.latestDescription': '读取当前查看股票最新 active 信号。',
'decisionSignals.latestInput': '最新股票代码', 'decisionSignals.latestInput': '最新股票代码',
'decisionSignals.latestPlaceholder': '例如 600519、HK00700、AAPL', 'decisionSignals.latestPlaceholder': '例如 600519、HK00700、AAPL',
'decisionSignals.latestTitle': '按股票查询最新信号', 'decisionSignals.latestTitle': '按股票查询最新信号',
@@ -258,6 +258,8 @@ const zh = {
'decisionSignals.metadata': '元数据', 'decisionSignals.metadata': '元数据',
'decisionSignals.noLatestDescription': '该股票当前没有 active 信号,或信号已过期。', 'decisionSignals.noLatestDescription': '该股票当前没有 active 信号,或信号已过期。',
'decisionSignals.noLatestTitle': '暂无最新有效信号', 'decisionSignals.noLatestTitle': '暂无最新有效信号',
'decisionSignals.noReviewedStatsDescription': '当前已有 AI 建议时,也可能还没有形成可统计的后验复盘结果。',
'decisionSignals.noReviewedStatsTitle': '暂无已复盘样本',
'decisionSignals.noOutcomes': '暂无后验结果', 'decisionSignals.noOutcomes': '暂无后验结果',
'decisionSignals.noStatsDescription': '触发一次信号后验计算后,这里会显示命中、未命中和无法评估统计。', 'decisionSignals.noStatsDescription': '触发一次信号后验计算后,这里会显示命中、未命中和无法评估统计。',
'decisionSignals.noStatsTitle': '暂无后验统计', 'decisionSignals.noStatsTitle': '暂无后验统计',
@@ -302,10 +304,24 @@ const zh = {
'decisionSignals.sourceReportId': '来源报告 ID', 'decisionSignals.sourceReportId': '来源报告 ID',
'decisionSignals.statsDescription': '基于当前后验引擎版本统计信号表现,默认排除已归档信号。', 'decisionSignals.statsDescription': '基于当前后验引擎版本统计信号表现,默认排除已归档信号。',
'decisionSignals.statsErrorTitle': '后验统计加载失败', 'decisionSignals.statsErrorTitle': '后验统计加载失败',
'decisionSignals.statsGlobalScope': '当前统计为全局已复盘 outcome 口径,不等于当前可见信号数量,也不随当前股票过滤。',
'decisionSignals.statsHitRate': '命中率', 'decisionSignals.statsHitRate': '命中率',
'decisionSignals.statsTitle': '信号表现统计', 'decisionSignals.statsTitle': '信号表现统计',
'decisionSignals.statsTotal': '评估数', 'decisionSignals.statsTotal': '评估数',
'decisionSignals.status': '状态', 'decisionSignals.status': '状态',
'decisionSignals.stockContextApply': '查看股票',
'decisionSignals.stockContextClear': '清空当前股票',
'decisionSignals.stockContextCurrent': '当前查看:{stock}',
'decisionSignals.stockContextDescription': '选择一次股票后,最新信号和时间线会共享这个上下文。',
'decisionSignals.stockContextEmpty': '尚未选择当前股票。',
'decisionSignals.stockContextGuideDescription': '先在页面顶部选择当前股票,再查看最新信号和时间线。',
'decisionSignals.stockContextGuideTitle': '选择股票查看 AI 建议',
'decisionSignals.stockContextInput': '当前股票',
'decisionSignals.stockContextNoCandidates': '暂无可用候选,可直接输入股票代码或名称。',
'decisionSignals.stockContextPlaceholder': '输入股票代码或名称,如 600519、贵州茅台、AAPL',
'decisionSignals.stockContextPopular': '热门候选',
'decisionSignals.stockContextRecent': '最近分析',
'decisionSignals.stockContextTitle': '当前股票',
'decisionSignals.stockCode': '股票代码', 'decisionSignals.stockCode': '股票代码',
'decisionSignals.stopLoss': '止损', 'decisionSignals.stopLoss': '止损',
'decisionSignals.targetPrice': '目标价', 'decisionSignals.targetPrice': '目标价',
@@ -319,8 +335,8 @@ const zh = {
'decisionSignals.timelineFamilyBullish': '偏多', 'decisionSignals.timelineFamilyBullish': '偏多',
'decisionSignals.timelineFamilyDefensive': '防御', 'decisionSignals.timelineFamilyDefensive': '防御',
'decisionSignals.timelineFamilyNeutral': '中性', 'decisionSignals.timelineFamilyNeutral': '中性',
'decisionSignals.timelineGuideDescription': '输入股票代码后再查询;不会在空股票代码时拉取全局信号。', 'decisionSignals.timelineGuideDescription': '调整时间范围、状态或市场后,点击查询时间线应用筛选。',
'decisionSignals.timelineGuideTitle': '输入股票代码查看时间线', 'decisionSignals.timelineGuideTitle': '查询当前股票时间线',
'decisionSignals.timelineMarket': '时间线市场', 'decisionSignals.timelineMarket': '时间线市场',
'decisionSignals.timelineRange': '时间范围', 'decisionSignals.timelineRange': '时间范围',
'decisionSignals.timelineRange.30d': '30 天', 'decisionSignals.timelineRange.30d': '30 天',
@@ -1017,7 +1033,7 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.invalidateConfirm': 'Mark this signal invalid? Invalidated signals are no longer shown as current active recommendations.', 'decisionSignals.invalidateConfirm': 'Mark this signal invalid? Invalidated signals are no longer shown as current active recommendations.',
'decisionSignals.invalidated': 'Invalidated', 'decisionSignals.invalidated': 'Invalidated',
'decisionSignals.latestButton': 'Query latest', 'decisionSignals.latestButton': 'Query latest',
'decisionSignals.latestDescription': 'Enter a stock code to read the latest active signals for that stock.', 'decisionSignals.latestDescription': 'Read the latest active signals for the current stock context.',
'decisionSignals.latestInput': 'Latest stock code', 'decisionSignals.latestInput': 'Latest stock code',
'decisionSignals.latestPlaceholder': 'e.g. 600519, HK00700, AAPL', 'decisionSignals.latestPlaceholder': 'e.g. 600519, HK00700, AAPL',
'decisionSignals.latestTitle': 'Latest signals by stock', 'decisionSignals.latestTitle': 'Latest signals by stock',
@@ -1039,6 +1055,8 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.metadata': 'Metadata', 'decisionSignals.metadata': 'Metadata',
'decisionSignals.noLatestDescription': 'This stock has no current active signal, or existing signals have expired.', 'decisionSignals.noLatestDescription': 'This stock has no current active signal, or existing signals have expired.',
'decisionSignals.noLatestTitle': 'No latest active signals', 'decisionSignals.noLatestTitle': 'No latest active signals',
'decisionSignals.noReviewedStatsDescription': 'AI signals may already exist, but no statistically reviewable outcome has been produced yet.',
'decisionSignals.noReviewedStatsTitle': 'No reviewed samples yet',
'decisionSignals.noOutcomes': 'No outcome results yet', 'decisionSignals.noOutcomes': 'No outcome results yet',
'decisionSignals.noStatsDescription': 'After signal outcome evaluation runs, hit, miss, neutral, and unable counts appear here.', 'decisionSignals.noStatsDescription': 'After signal outcome evaluation runs, hit, miss, neutral, and unable counts appear here.',
'decisionSignals.noStatsTitle': 'No outcome stats yet', 'decisionSignals.noStatsTitle': 'No outcome stats yet',
@@ -1083,10 +1101,24 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.sourceReportId': 'Source report ID', 'decisionSignals.sourceReportId': 'Source report ID',
'decisionSignals.statsDescription': 'Signal performance from the current outcome engine version, excluding archived signals by default.', 'decisionSignals.statsDescription': 'Signal performance from the current outcome engine version, excluding archived signals by default.',
'decisionSignals.statsErrorTitle': 'Failed to load outcome stats', 'decisionSignals.statsErrorTitle': 'Failed to load outcome stats',
'decisionSignals.statsGlobalScope': 'These are global reviewed outcome stats. They are not the current visible signal count and do not follow the current stock filter.',
'decisionSignals.statsHitRate': 'Hit rate', 'decisionSignals.statsHitRate': 'Hit rate',
'decisionSignals.statsTitle': 'Signal performance', 'decisionSignals.statsTitle': 'Signal performance',
'decisionSignals.statsTotal': 'Evaluations', 'decisionSignals.statsTotal': 'Evaluations',
'decisionSignals.status': 'Status', 'decisionSignals.status': 'Status',
'decisionSignals.stockContextApply': 'View stock',
'decisionSignals.stockContextClear': 'Clear current stock',
'decisionSignals.stockContextCurrent': 'Current stock: {stock}',
'decisionSignals.stockContextDescription': 'Choose one stock once; latest signals and the timeline will share that context.',
'decisionSignals.stockContextEmpty': 'No current stock selected.',
'decisionSignals.stockContextGuideDescription': 'Choose the current stock at the top of the page before viewing latest signals and the timeline.',
'decisionSignals.stockContextGuideTitle': 'Choose a stock to view AI signals',
'decisionSignals.stockContextInput': 'Current stock',
'decisionSignals.stockContextNoCandidates': 'No candidates available. You can enter a stock code or name directly.',
'decisionSignals.stockContextPlaceholder': 'Enter a stock code or name, e.g. 600519, Kweichow Moutai, AAPL',
'decisionSignals.stockContextPopular': 'Popular candidates',
'decisionSignals.stockContextRecent': 'Recent analyses',
'decisionSignals.stockContextTitle': 'Current stock',
'decisionSignals.stockCode': 'Stock code', 'decisionSignals.stockCode': 'Stock code',
'decisionSignals.stopLoss': 'Stop loss', 'decisionSignals.stopLoss': 'Stop loss',
'decisionSignals.targetPrice': 'Target price', 'decisionSignals.targetPrice': 'Target price',
@@ -1100,8 +1132,8 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.timelineFamilyBullish': 'Bullish', 'decisionSignals.timelineFamilyBullish': 'Bullish',
'decisionSignals.timelineFamilyDefensive': 'Defensive', 'decisionSignals.timelineFamilyDefensive': 'Defensive',
'decisionSignals.timelineFamilyNeutral': 'Neutral', 'decisionSignals.timelineFamilyNeutral': 'Neutral',
'decisionSignals.timelineGuideDescription': 'Enter a stock code before searching; empty stock code never loads global signals.', 'decisionSignals.timelineGuideDescription': 'Adjust range, status, or market, then search the timeline to apply the filters.',
'decisionSignals.timelineGuideTitle': 'Enter a stock code to view the timeline', 'decisionSignals.timelineGuideTitle': 'Search the current stock timeline',
'decisionSignals.timelineMarket': 'Timeline market', 'decisionSignals.timelineMarket': 'Timeline market',
'decisionSignals.timelineRange': 'Range', 'decisionSignals.timelineRange': 'Range',
'decisionSignals.timelineRange.30d': '30 days', 'decisionSignals.timelineRange.30d': '30 days',

View File

@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Activity, BarChart3, RefreshCw, Search, ShieldCheck } from 'lucide-react'; import { Activity, BarChart3, RefreshCw, Search, ShieldCheck } from 'lucide-react';
import { decisionSignalsApi } from '../api/decisionSignals'; import { decisionSignalsApi } from '../api/decisionSignals';
import { getParsedApiError, type ParsedApiError } from '../api/error'; import { getParsedApiError, type ParsedApiError } from '../api/error';
import { historyApi } from '../api/history';
import { import {
ApiErrorAlert, ApiErrorAlert,
AppPage, AppPage,
@@ -19,9 +20,11 @@ import {
DecisionSignalDetails, DecisionSignalDetails,
} from '../components/decision-signals/DecisionSignalDisplay'; } from '../components/decision-signals/DecisionSignalDisplay';
import { DecisionSignalTimeline } from '../components/decision-signals/DecisionSignalTimeline'; import { DecisionSignalTimeline } from '../components/decision-signals/DecisionSignalTimeline';
import { StockAutocomplete } from '../components/StockAutocomplete';
import { useUiLanguage } from '../contexts/UiLanguageContext'; import { useUiLanguage } from '../contexts/UiLanguageContext';
import { useStockIndex } from '../hooks/useStockIndex';
import type { UiTextKey } from '../i18n/uiText'; import type { UiTextKey } from '../i18n/uiText';
import type { DecisionAction, MarketPhaseValue } from '../types/analysis'; import type { DecisionAction, MarketPhaseValue, StockBarItem } from '../types/analysis';
import type { import type {
DecisionSignalItem, DecisionSignalItem,
DecisionSignalFeedbackItem, DecisionSignalFeedbackItem,
@@ -35,6 +38,7 @@ import type {
DecisionSignalStatus, DecisionSignalStatus,
DecisionProfile, DecisionProfile,
} from '../types/decisionSignals'; } from '../types/decisionSignals';
import type { Market, StockIndexItem } from '../types/stockIndex';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { buildDecisionActionLabelMap } from '../utils/decisionAction'; import { buildDecisionActionLabelMap } from '../utils/decisionAction';
import { import {
@@ -45,6 +49,7 @@ import {
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const TIMELINE_PAGE_SIZE = 100; const TIMELINE_PAGE_SIZE = 100;
const STOCK_CANDIDATE_LIMIT = 8;
const DAY_MS = 86400_000; const DAY_MS = 86400_000;
type ListFilters = { type ListFilters = {
@@ -62,11 +67,32 @@ type TimelineStatusFilter = 'all' | 'active';
type TimelineFilters = { type TimelineFilters = {
market: '' | DecisionSignalMarket; market: '' | DecisionSignalMarket;
stockCode: string;
range: TimelineRange; range: TimelineRange;
status: TimelineStatusFilter; status: TimelineStatusFilter;
}; };
type TimelineMarketSource = 'context' | 'user' | null;
type TimelineFilterUpdate = {
filters: TimelineFilters;
marketSource: TimelineMarketSource;
};
type AppliedTimelineContext = TimelineFilters & {
stockCode: string;
};
type StockContext = {
code: string;
displayCode?: string;
name?: string;
market?: DecisionSignalMarket;
};
type StockCandidate = StockContext & {
source: 'history' | 'popular';
};
type PendingStatusChange = { type PendingStatusChange = {
item: DecisionSignalItem; item: DecisionSignalItem;
status: Extract<DecisionSignalStatus, 'closed' | 'invalidated' | 'archived'>; status: Extract<DecisionSignalStatus, 'closed' | 'invalidated' | 'archived'>;
@@ -123,7 +149,6 @@ const DEFAULT_LIST_FILTERS: ListFilters = {
const DEFAULT_TIMELINE_FILTERS: TimelineFilters = { const DEFAULT_TIMELINE_FILTERS: TimelineFilters = {
market: '', market: '',
stockCode: '',
range: '90d', range: '90d',
status: 'all', status: 'all',
}; };
@@ -192,13 +217,71 @@ function refreshTimelineSelection(
return refreshed ? { source: 'timeline', item: refreshed } : null; return refreshed ? { source: 'timeline', item: refreshed } : null;
} }
function toTimelineParams(filters: TimelineFilters): DecisionSignalListParams { function normalizeDecisionSignalMarket(value: unknown): DecisionSignalMarket | undefined {
const market = String(value ?? '').trim().toUpperCase();
if (!market || market === 'INDEX' || market === 'ETF' || market === 'UNKNOWN') return undefined;
if (market === 'CN' || market === 'BSE') return 'cn';
if (market === 'HK') return 'hk';
if (market === 'US') return 'us';
if (market === 'JP') return 'jp';
if (market === 'KR') return 'kr';
if (market === 'TW') return 'tw';
if (MARKET_OPTIONS.includes(market.toLowerCase() as DecisionSignalMarket)) {
return market.toLowerCase() as DecisionSignalMarket;
}
return undefined;
}
function getCandidateKey(candidate: Pick<StockCandidate, 'code' | 'market'>): string {
const code = candidate.code.trim().toUpperCase();
return candidate.market ? `${candidate.market}:${code}` : code;
}
function toHistoryCandidate(item: StockBarItem): StockCandidate | null {
const code = String(item.stockCode || '').trim();
if (!code || code.toUpperCase() === 'MARKET') return null;
return {
code,
displayCode: code,
name: item.stockName || undefined,
market: normalizeDecisionSignalMarket(item.marketPhaseSummary?.market),
source: 'history',
};
}
function toPopularCandidates(index: StockIndexItem[], limit = STOCK_CANDIDATE_LIMIT): StockCandidate[] {
const candidates: StockCandidate[] = [];
const seen = new Set<string>();
const sorted = [...index]
.filter((item) => item.active && item.assetType === 'stock')
.sort((left, right) => (right.popularity ?? 0) - (left.popularity ?? 0));
for (const item of sorted) {
const market = normalizeDecisionSignalMarket(item.market);
const candidate: StockCandidate = {
code: item.canonicalCode,
displayCode: item.displayCode,
name: item.nameZh,
market,
source: 'popular',
};
const key = getCandidateKey(candidate);
if (seen.has(key)) continue;
seen.add(key);
candidates.push(candidate);
if (candidates.length >= limit) break;
}
return candidates;
}
function toTimelineParams(filters: TimelineFilters, stockCode: string): DecisionSignalListParams {
const days = TIMELINE_RANGE_DAYS[filters.range]; const days = TIMELINE_RANGE_DAYS[filters.range];
const createdTo = new Date(); const createdTo = new Date();
const createdFrom = new Date(createdTo.getTime() - days * DAY_MS); const createdFrom = new Date(createdTo.getTime() - days * DAY_MS);
return { return {
market: filters.market || undefined, market: filters.market || undefined,
stockCode: filters.stockCode.trim(), stockCode,
createdFrom: createdFrom.toISOString(), createdFrom: createdFrom.toISOString(),
createdTo: createdTo.toISOString(), createdTo: createdTo.toISOString(),
status: filters.status === 'active' ? 'active' : undefined, status: filters.status === 'active' ? 'active' : undefined,
@@ -207,6 +290,46 @@ function toTimelineParams(filters: TimelineFilters): DecisionSignalListParams {
}; };
} }
function isSameStockContext(
previousContext: StockContext | null,
nextContext: StockContext,
): boolean {
return previousContext?.code.trim().toUpperCase() === nextContext.code.trim().toUpperCase()
&& previousContext?.market === nextContext.market;
}
function buildNextTimelineFilters(
currentFilters: TimelineFilters,
previousContext: StockContext | null,
nextContext: StockContext,
marketSource: TimelineMarketSource,
): TimelineFilterUpdate {
if (isSameStockContext(previousContext, nextContext)) {
return { filters: currentFilters, marketSource };
}
if (nextContext.market) {
return {
filters: { ...currentFilters, market: nextContext.market },
marketSource: 'context',
};
}
if (marketSource === 'context') {
return {
filters: { ...currentFilters, market: '' },
marketSource: null,
};
}
return { filters: currentFilters, marketSource };
}
function draftMatchesStockContext(draft: string, context: StockContext | null): context is StockContext {
if (!context) return false;
const normalizedDraft = draft.trim().toUpperCase();
if (!normalizedDraft) return false;
return normalizedDraft === context.code.trim().toUpperCase()
|| normalizedDraft === String(context.displayCode ?? '').trim().toUpperCase();
}
function formatStatNumber(value: number | null | undefined): string { function formatStatNumber(value: number | null | undefined): string {
if (value === null || value === undefined || Number.isNaN(value)) return '-'; if (value === null || value === undefined || Number.isNaN(value)) return '-';
return Number(value).toFixed(2).replace(/\.?0+$/, ''); return Number(value).toFixed(2).replace(/\.?0+$/, '');
@@ -220,6 +343,7 @@ function formatStatPercent(value: number | null | undefined): string {
const DecisionSignalsPage: React.FC = () => { const DecisionSignalsPage: React.FC = () => {
const { t } = useUiLanguage(); const { t } = useUiLanguage();
const actionLabels = useMemo(() => buildDecisionActionLabelMap(t), [t]); const actionLabels = useMemo(() => buildDecisionActionLabelMap(t), [t]);
const { index: stockIndex } = useStockIndex();
const [filters, setFilters] = useState<ListFilters>(() => getInitialFilters()); const [filters, setFilters] = useState<ListFilters>(() => getInitialFilters());
const [appliedFilters, setAppliedFilters] = useState<ListFilters>(() => getInitialFilters()); const [appliedFilters, setAppliedFilters] = useState<ListFilters>(() => getInitialFilters());
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -233,13 +357,16 @@ const DecisionSignalsPage: React.FC = () => {
const [outcomeStats, setOutcomeStats] = useState<DecisionSignalOutcomeStatsResponse | null>(null); const [outcomeStats, setOutcomeStats] = useState<DecisionSignalOutcomeStatsResponse | null>(null);
const [statsLoading, setStatsLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(true);
const [statsError, setStatsError] = useState<ParsedApiError | null>(null); const [statsError, setStatsError] = useState<ParsedApiError | null>(null);
const [latestStockCode, setLatestStockCode] = useState(''); const [stockDraft, setStockDraft] = useState('');
const [activeStockContext, setActiveStockContext] = useState<StockContext | null>(null);
const [historyCandidates, setHistoryCandidates] = useState<StockCandidate[]>([]);
const [historyCandidatesLoaded, setHistoryCandidatesLoaded] = useState(false);
const [latestItems, setLatestItems] = useState<DecisionSignalItem[]>([]); const [latestItems, setLatestItems] = useState<DecisionSignalItem[]>([]);
const [latestSearched, setLatestSearched] = useState(false); const [latestSearched, setLatestSearched] = useState(false);
const [latestLoading, setLatestLoading] = useState(false); const [latestLoading, setLatestLoading] = useState(false);
const [latestError, setLatestError] = useState<ParsedApiError | null>(null); const [latestError, setLatestError] = useState<ParsedApiError | null>(null);
const [timelineFilters, setTimelineFilters] = useState<TimelineFilters>(DEFAULT_TIMELINE_FILTERS); const [timelineFilters, setTimelineFilters] = useState<TimelineFilters>(DEFAULT_TIMELINE_FILTERS);
const [appliedTimelineFilters, setAppliedTimelineFilters] = useState<TimelineFilters>(DEFAULT_TIMELINE_FILTERS); const [appliedTimelineContext, setAppliedTimelineContext] = useState<AppliedTimelineContext | null>(null);
const [timelineItems, setTimelineItems] = useState<DecisionSignalItem[]>([]); const [timelineItems, setTimelineItems] = useState<DecisionSignalItem[]>([]);
const [timelineSearched, setTimelineSearched] = useState(false); const [timelineSearched, setTimelineSearched] = useState(false);
const [timelineLoading, setTimelineLoading] = useState(false); const [timelineLoading, setTimelineLoading] = useState(false);
@@ -264,11 +391,52 @@ const DecisionSignalsPage: React.FC = () => {
const reassessRequestIdRef = useRef(0); const reassessRequestIdRef = useRef(0);
const selectedSignalIdRef = useRef<number | null>(null); const selectedSignalIdRef = useRef<number | null>(null);
const statusUpdateInFlightRef = useRef(false); const statusUpdateInFlightRef = useRef(false);
const timelineMarketSourceRef = useRef<TimelineMarketSource>(null);
const popularCandidates = useMemo(
() => toPopularCandidates(stockIndex, STOCK_CANDIDATE_LIMIT),
[stockIndex],
);
const stockCandidates = historyCandidates.length > 0 ? historyCandidates : popularCandidates;
const stockCandidateMode: 'history' | 'popular' | 'empty' = historyCandidates.length > 0
? 'history'
: stockCandidates.length > 0
? 'popular'
: 'empty';
useEffect(() => { useEffect(() => {
document.title = t('decisionSignals.pageTitle'); document.title = t('decisionSignals.pageTitle');
}, [t]); }, [t]);
useEffect(() => {
let mounted = true;
void historyApi.getStockBarList({ limit: STOCK_CANDIDATE_LIMIT })
.then((response) => {
if (!mounted) return;
const nextCandidates: StockCandidate[] = [];
const seen = new Set<string>();
for (const item of response.items) {
const candidate = toHistoryCandidate(item);
if (!candidate) continue;
const key = getCandidateKey(candidate);
if (seen.has(key)) continue;
seen.add(key);
nextCandidates.push(candidate);
if (nextCandidates.length >= STOCK_CANDIDATE_LIMIT) break;
}
setHistoryCandidates(nextCandidates);
})
.catch(() => {
if (mounted) setHistoryCandidates([]);
})
.finally(() => {
if (mounted) setHistoryCandidatesLoaded(true);
});
return () => {
mounted = false;
};
}, []);
const loadSignalsForPage = useCallback(async (nextPage: number) => { const loadSignalsForPage = useCallback(async (nextPage: number) => {
const requestId = requestIdRef.current + 1; const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId; requestIdRef.current = requestId;
@@ -449,18 +617,28 @@ const DecisionSignalsPage: React.FC = () => {
setPage(1); setPage(1);
}; };
const handleLatestSearch = async (event: React.FormEvent) => { const resetLatestView = useCallback(() => {
event.preventDefault(); latestRequestIdRef.current += 1;
const stockCode = latestStockCode.trim(); setLatestItems([]);
setLatestSearched(false);
setLatestLoading(false);
setLatestError(null);
setSelected((current) => (current?.source === 'latest' ? null : current));
}, []);
const loadLatestForContext = useCallback(async (context: StockContext) => {
const stockCode = context.code.trim();
if (!stockCode) return; if (!stockCode) return;
const requestId = latestRequestIdRef.current + 1; const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId; latestRequestIdRef.current = requestId;
setLatestLoading(true); setLatestLoading(true);
setLatestError(null); setLatestError(null);
setLatestSearched(true); setLatestSearched(true);
setLatestItems([]);
setSelected((current) => (current?.source === 'latest' ? null : current));
try { try {
const response = await decisionSignalsApi.getLatest(stockCode, { const response = await decisionSignalsApi.getLatest(stockCode, {
market: appliedFilters.market || undefined, market: context.market,
limit: 5, limit: 5,
}); });
if (latestRequestIdRef.current !== requestId) return; if (latestRequestIdRef.current !== requestId) return;
@@ -476,7 +654,7 @@ const DecisionSignalsPage: React.FC = () => {
setLatestLoading(false); setLatestLoading(false);
} }
} }
}; }, []);
const resetTimelineView = useCallback(() => { const resetTimelineView = useCallback(() => {
timelineRequestIdRef.current += 1; timelineRequestIdRef.current += 1;
@@ -485,26 +663,33 @@ const DecisionSignalsPage: React.FC = () => {
setTimelineLoading(false); setTimelineLoading(false);
setTimelineError(null); setTimelineError(null);
setTimelineTruncated(false); setTimelineTruncated(false);
setAppliedTimelineContext(null);
setSelected((current) => (current?.source === 'timeline' ? null : current)); setSelected((current) => (current?.source === 'timeline' ? null : current));
}, []); }, []);
const handleTimelineSearch = async (event: React.FormEvent) => { const loadTimelineForContext = useCallback(async (
event.preventDefault(); context: StockContext,
const stockCode = timelineFilters.stockCode.trim(); filtersSnapshot: TimelineFilters,
) => {
const stockCode = context.code.trim();
if (!stockCode) return; if (!stockCode) return;
const requestId = timelineRequestIdRef.current + 1; const requestId = timelineRequestIdRef.current + 1;
timelineRequestIdRef.current = requestId; timelineRequestIdRef.current = requestId;
setTimelineLoading(true); setTimelineLoading(true);
setTimelineError(null); setTimelineError(null);
setTimelineSearched(true); setTimelineSearched(true);
const nextAppliedFilters = { setTimelineItems([]);
...timelineFilters, setTimelineTruncated(false);
setAppliedTimelineContext(null);
setSelected((current) => (current?.source === 'timeline' ? null : current));
const nextAppliedContext: AppliedTimelineContext = {
...filtersSnapshot,
stockCode, stockCode,
}; };
try { try {
const response = await decisionSignalsApi.list(toTimelineParams(nextAppliedFilters)); const response = await decisionSignalsApi.list(toTimelineParams(filtersSnapshot, stockCode));
if (timelineRequestIdRef.current !== requestId) return; if (timelineRequestIdRef.current !== requestId) return;
setAppliedTimelineFilters(nextAppliedFilters); setAppliedTimelineContext(nextAppliedContext);
setTimelineItems(response.items); setTimelineItems(response.items);
setTimelineTruncated(response.total > response.items.length); setTimelineTruncated(response.total > response.items.length);
setSelected((current) => refreshTimelineSelection(current, response.items)); setSelected((current) => refreshTimelineSelection(current, response.items));
@@ -519,7 +704,65 @@ const DecisionSignalsPage: React.FC = () => {
setTimelineLoading(false); setTimelineLoading(false);
} }
} }
}; }, []);
const applyStockContext = useCallback((nextContext: StockContext) => {
const nextTimeline = buildNextTimelineFilters(
timelineFilters,
activeStockContext,
nextContext,
timelineMarketSourceRef.current,
);
timelineMarketSourceRef.current = nextTimeline.marketSource;
setActiveStockContext(nextContext);
setStockDraft(nextContext.displayCode ?? nextContext.code);
setTimelineFilters(nextTimeline.filters);
void loadLatestForContext(nextContext);
void loadTimelineForContext(nextContext, nextTimeline.filters);
}, [activeStockContext, loadLatestForContext, loadTimelineForContext, timelineFilters]);
const handleStockSubmit = useCallback((
code: string,
name?: string,
_source?: 'manual' | 'autocomplete',
metadata?: { market?: Market; displayCode?: string },
) => {
const trimmedCode = code.trim();
if (!trimmedCode) return;
applyStockContext({
code: trimmedCode,
displayCode: metadata?.displayCode,
name,
market: normalizeDecisionSignalMarket(metadata?.market),
});
}, [applyStockContext]);
const handleCandidateSelect = useCallback((candidate: StockCandidate) => {
applyStockContext(candidate);
}, [applyStockContext]);
const handleStockFormSubmit = useCallback((code: string) => {
if (draftMatchesStockContext(code, activeStockContext)) {
applyStockContext(activeStockContext);
return;
}
handleStockSubmit(code);
}, [activeStockContext, applyStockContext, handleStockSubmit]);
const handleClearStockContext = useCallback(() => {
setStockDraft('');
setActiveStockContext(null);
timelineMarketSourceRef.current = null;
setTimelineFilters((current) => ({ ...current, market: '' }));
resetLatestView();
resetTimelineView();
}, [resetLatestView, resetTimelineView]);
const handleTimelineSearch = useCallback((event: React.FormEvent) => {
event.preventDefault();
if (!activeStockContext) return;
void loadTimelineForContext(activeStockContext, timelineFilters);
}, [activeStockContext, loadTimelineForContext, timelineFilters]);
const handleStatusUpdate = async () => { const handleStatusUpdate = async () => {
if (!pendingStatus || statusUpdateInFlightRef.current) return; if (!pendingStatus || statusUpdateInFlightRef.current) return;
@@ -536,7 +779,7 @@ const DecisionSignalsPage: React.FC = () => {
})); }));
setTimelineItems((current) => current.flatMap((item) => { setTimelineItems((current) => current.flatMap((item) => {
if (item.id !== updated.id) return [item]; if (item.id !== updated.id) return [item];
return appliedTimelineFilters.status === 'active' && updated.status !== 'active' ? [] : [updated]; return appliedTimelineContext?.status === 'active' && updated.status !== 'active' ? [] : [updated];
})); }));
setSelected((current) => { setSelected((current) => {
if (!current || current.item.id !== updated.id) return current; if (!current || current.item.id !== updated.id) return current;
@@ -544,7 +787,7 @@ const DecisionSignalsPage: React.FC = () => {
return updated.status === 'active' ? { source: 'latest', item: updated } : null; return updated.status === 'active' ? { source: 'latest', item: updated } : null;
} }
if (current.source === 'timeline') { if (current.source === 'timeline') {
return appliedTimelineFilters.status === 'active' && updated.status !== 'active' return appliedTimelineContext?.status === 'active' && updated.status !== 'active'
? null ? null
: { source: 'timeline', item: updated }; : { source: 'timeline', item: updated };
} }
@@ -713,6 +956,13 @@ const DecisionSignalsPage: React.FC = () => {
); );
}; };
const activeStockLabel = activeStockContext
? [
activeStockContext.displayCode ?? activeStockContext.code,
activeStockContext.name,
activeStockContext.market,
].filter(Boolean).join(' / ')
: null;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return ( return (
@@ -738,6 +988,76 @@ const DecisionSignalsPage: React.FC = () => {
)} )}
/> />
<Card title={t('decisionSignals.stockContextTitle')} subtitle={t('decisionSignals.stockContextDescription')} padding="md">
<form
className="flex flex-col gap-3 md:flex-row"
onSubmit={(event) => {
event.preventDefault();
handleStockFormSubmit(stockDraft);
}}
>
<div className="min-w-0 flex-1">
<StockAutocomplete
value={stockDraft}
onChange={setStockDraft}
onSubmit={handleStockSubmit}
placeholder={t('decisionSignals.stockContextPlaceholder')}
ariaLabel={t('decisionSignals.stockContextInput')}
/>
</div>
<button
type="submit"
className="btn-primary inline-flex h-11 items-center justify-center gap-2"
disabled={!stockDraft.trim()}
>
<Search className="h-4 w-4" />
{t('decisionSignals.stockContextApply')}
</button>
<button
type="button"
className="btn-secondary inline-flex h-11 items-center justify-center gap-2"
onClick={handleClearStockContext}
disabled={!activeStockContext && !stockDraft}
>
{t('decisionSignals.stockContextClear')}
</button>
</form>
{activeStockLabel ? (
<p className="mt-3 text-sm text-secondary-text">
{t('decisionSignals.stockContextCurrent', { stock: activeStockLabel })}
</p>
) : (
<p className="mt-3 text-sm text-secondary-text">{t('decisionSignals.stockContextEmpty')}</p>
)}
{historyCandidatesLoaded && stockCandidates.length > 0 ? (
<div className="mt-4">
<p className="text-xs font-medium uppercase text-muted-text">
{stockCandidateMode === 'history'
? t('decisionSignals.stockContextRecent')
: t('decisionSignals.stockContextPopular')}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{stockCandidates.map((candidate) => (
<button
key={`${candidate.source}:${getCandidateKey(candidate)}`}
type="button"
className="rounded-full border border-border/70 bg-elevated/40 px-3 py-1.5 text-sm text-foreground transition-colors hover:border-primary/60 hover:text-primary"
onClick={() => handleCandidateSelect(candidate)}
>
<span className="font-mono">{candidate.displayCode ?? candidate.code}</span>
{candidate.name ? <span className="ml-1 text-secondary-text">{candidate.name}</span> : null}
{candidate.market ? <span className="ml-1 text-muted-text">/ {candidate.market}</span> : null}
</button>
))}
</div>
</div>
) : historyCandidatesLoaded ? (
<p className="mt-4 text-sm text-secondary-text">{t('decisionSignals.stockContextNoCandidates')}</p>
) : null}
</Card>
<Card padding="md"> <Card padding="md">
<form className="grid gap-3 md:grid-cols-3 xl:grid-cols-7" onSubmit={handleApplyFilters}> <form className="grid gap-3 md:grid-cols-3 xl:grid-cols-7" onSubmit={handleApplyFilters}>
<select <select
@@ -825,6 +1145,7 @@ const DecisionSignalsPage: React.FC = () => {
) : null} ) : null}
<Card title={t('decisionSignals.statsTitle')} subtitle={t('decisionSignals.statsDescription')} padding="md"> <Card title={t('decisionSignals.statsTitle')} subtitle={t('decisionSignals.statsDescription')} padding="md">
<p className="mb-3 text-sm text-secondary-text">{t('decisionSignals.statsGlobalScope')}</p>
{statsError ? ( {statsError ? (
<ApiErrorAlert <ApiErrorAlert
error={{ ...statsError, title: t('decisionSignals.statsErrorTitle') }} error={{ ...statsError, title: t('decisionSignals.statsErrorTitle') }}
@@ -833,7 +1154,7 @@ const DecisionSignalsPage: React.FC = () => {
/> />
) : statsLoading ? ( ) : statsLoading ? (
<p className="text-sm text-secondary-text">{t('common.loading')}...</p> <p className="text-sm text-secondary-text">{t('common.loading')}...</p>
) : outcomeStats ? ( ) : outcomeStats && outcomeStats.total > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3"> <div className="rounded-xl border border-border/60 bg-elevated/40 px-3 py-3">
<p className="text-xs text-secondary-text">{t('decisionSignals.statsTotal')}</p> <p className="text-xs text-secondary-text">{t('decisionSignals.statsTotal')}</p>
@@ -859,27 +1180,22 @@ const DecisionSignalsPage: React.FC = () => {
) : ( ) : (
<EmptyState <EmptyState
className="border-none bg-transparent py-6 shadow-none" className="border-none bg-transparent py-6 shadow-none"
title={t('decisionSignals.noStatsTitle')} title={t('decisionSignals.noReviewedStatsTitle')}
description={t('decisionSignals.noStatsDescription')} description={t('decisionSignals.noReviewedStatsDescription')}
icon={<BarChart3 className="h-6 w-6" />} icon={<BarChart3 className="h-6 w-6" />}
/> />
)} )}
</Card> </Card>
<Card title={t('decisionSignals.latestTitle')} subtitle={t('decisionSignals.latestDescription')} padding="md"> <Card title={t('decisionSignals.latestTitle')} subtitle={t('decisionSignals.latestDescription')} padding="md">
<form className="flex flex-col gap-3 md:flex-row" onSubmit={handleLatestSearch}> {!activeStockContext ? (
<input <EmptyState
className="input-surface input-focus-glow h-11 flex-1 rounded-xl border bg-transparent px-3 text-sm" className="border-none bg-transparent py-6 shadow-none"
value={latestStockCode} title={t('decisionSignals.stockContextGuideTitle')}
onChange={(event) => setLatestStockCode(event.target.value)} description={t('decisionSignals.stockContextGuideDescription')}
placeholder={t('decisionSignals.latestPlaceholder')} icon={<Activity className="h-6 w-6" />}
aria-label={t('decisionSignals.latestInput')}
/> />
<button type="submit" className="btn-secondary inline-flex h-11 items-center justify-center gap-2" disabled={latestLoading || !latestStockCode.trim()}> ) : null}
<Search className="h-4 w-4" />
{t('decisionSignals.latestButton')}
</button>
</form>
{latestError ? <ApiErrorAlert className="mt-3" error={latestError} /> : null} {latestError ? <ApiErrorAlert className="mt-3" error={latestError} /> : null}
{latestSearched && !latestLoading && !latestError && latestItems.length === 0 ? ( {latestSearched && !latestLoading && !latestError && latestItems.length === 0 ? (
<EmptyState <EmptyState
@@ -889,6 +1205,7 @@ const DecisionSignalsPage: React.FC = () => {
icon={<Activity className="h-6 w-6" />} icon={<Activity className="h-6 w-6" />}
/> />
) : null} ) : null}
{latestLoading ? <p className="mt-3 text-sm text-secondary-text">{t('common.loading')}...</p> : null}
{latestItems.length > 0 ? ( {latestItems.length > 0 ? (
<div className="mt-4 grid gap-3 lg:grid-cols-2"> <div className="mt-4 grid gap-3 lg:grid-cols-2">
{latestItems.map((item) => ( {latestItems.map((item) => (
@@ -904,11 +1221,15 @@ const DecisionSignalsPage: React.FC = () => {
</Card> </Card>
<Card title={t('decisionSignals.timelineTitle')} subtitle={t('decisionSignals.timelineDescription')} padding="md"> <Card title={t('decisionSignals.timelineTitle')} subtitle={t('decisionSignals.timelineDescription')} padding="md">
<form className="grid gap-3 md:grid-cols-5" onSubmit={handleTimelineSearch}> <form className="grid gap-3 md:grid-cols-4" onSubmit={handleTimelineSearch}>
<select <select
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm" className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm"
value={timelineFilters.market} value={timelineFilters.market}
onChange={(event) => setTimelineFilters((current) => ({ ...current, market: event.target.value as TimelineFilters['market'] }))} onChange={(event) => {
const market = event.target.value as TimelineFilters['market'];
timelineMarketSourceRef.current = market ? 'user' : null;
setTimelineFilters((current) => ({ ...current, market }));
}}
aria-label={t('decisionSignals.timelineMarket')} aria-label={t('decisionSignals.timelineMarket')}
> >
<option value="">{t('decisionSignals.allMarkets')}</option> <option value="">{t('decisionSignals.allMarkets')}</option>
@@ -916,19 +1237,6 @@ const DecisionSignalsPage: React.FC = () => {
<option key={market} value={market}>{getDecisionSignalMarketLabel(market, t)}</option> <option key={market} value={market}>{getDecisionSignalMarketLabel(market, t)}</option>
))} ))}
</select> </select>
<input
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm md:col-span-2"
value={timelineFilters.stockCode}
onChange={(event) => {
const stockCode = event.target.value;
setTimelineFilters((current) => ({ ...current, stockCode }));
if (!stockCode.trim()) {
resetTimelineView();
}
}}
placeholder={t('decisionSignals.timelineStockPlaceholder')}
aria-label={t('decisionSignals.timelineStockCode')}
/>
<select <select
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm" className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm"
value={timelineFilters.range} value={timelineFilters.range}
@@ -950,8 +1258,8 @@ const DecisionSignalsPage: React.FC = () => {
</select> </select>
<button <button
type="submit" type="submit"
className="btn-secondary inline-flex h-11 items-center justify-center gap-2 md:col-start-5" className="btn-secondary inline-flex h-11 items-center justify-center gap-2"
disabled={timelineLoading || !timelineFilters.stockCode.trim()} disabled={timelineLoading || !activeStockContext?.code}
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
{t('decisionSignals.timelineSearch')} {t('decisionSignals.timelineSearch')}
@@ -961,8 +1269,8 @@ const DecisionSignalsPage: React.FC = () => {
{!timelineSearched ? ( {!timelineSearched ? (
<EmptyState <EmptyState
className="border-none bg-transparent py-6 shadow-none" className="border-none bg-transparent py-6 shadow-none"
title={t('decisionSignals.timelineGuideTitle')} title={activeStockContext ? t('decisionSignals.timelineGuideTitle') : t('decisionSignals.stockContextGuideTitle')}
description={t('decisionSignals.timelineGuideDescription')} description={activeStockContext ? t('decisionSignals.timelineGuideDescription') : t('decisionSignals.stockContextGuideDescription')}
icon={<Activity className="h-6 w-6" />} icon={<Activity className="h-6 w-6" />}
/> />
) : ( ) : (

View File

@@ -2,7 +2,9 @@ import type React from 'react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { decisionSignalsApi } from '../../api/decisionSignals'; import { decisionSignalsApi } from '../../api/decisionSignals';
import { historyApi } from '../../api/history';
import { UiLanguageProvider } from '../../contexts/UiLanguageContext'; import { UiLanguageProvider } from '../../contexts/UiLanguageContext';
import type { StockBarResponse } from '../../types/analysis';
import type { import type {
DecisionSignalFeedbackItem, DecisionSignalFeedbackItem,
DecisionSignalItem, DecisionSignalItem,
@@ -11,8 +13,17 @@ import type {
DecisionSignalOutcomeStatsResponse, DecisionSignalOutcomeStatsResponse,
DecisionSignalReassessResponse, DecisionSignalReassessResponse,
} from '../../types/decisionSignals'; } from '../../types/decisionSignals';
import type { StockIndexItem } from '../../types/stockIndex';
import DecisionSignalsPage from '../DecisionSignalsPage'; import DecisionSignalsPage from '../DecisionSignalsPage';
let stockIndexState: {
index: StockIndexItem[];
loading: boolean;
error: Error | null;
fallback: boolean;
loaded: boolean;
};
vi.mock('../../api/decisionSignals', () => ({ vi.mock('../../api/decisionSignals', () => ({
decisionSignalsApi: { decisionSignalsApi: {
list: vi.fn(), list: vi.fn(),
@@ -26,6 +37,16 @@ vi.mock('../../api/decisionSignals', () => ({
}, },
})); }));
vi.mock('../../api/history', () => ({
historyApi: {
getStockBarList: vi.fn(),
},
}));
vi.mock('../../hooks/useStockIndex', () => ({
useStockIndex: () => stockIndexState,
}));
vi.mock('recharts', () => ({ vi.mock('recharts', () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ScatterChart: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, ScatterChart: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -91,6 +112,51 @@ const signal: DecisionSignalItem = {
metadata: { source: 'test' }, metadata: { source: 'test' },
}; };
const stockIndexItems: StockIndexItem[] = [
{
canonicalCode: '600519.SH',
displayCode: '600519',
nameZh: '贵州茅台',
pinyinFull: 'guizhoumaotai',
pinyinAbbr: 'gzmt',
aliases: ['茅台'],
market: 'CN',
assetType: 'stock',
active: true,
popularity: 100,
},
{
canonicalCode: 'AAPL',
displayCode: 'AAPL',
nameZh: 'Apple',
market: 'US',
assetType: 'stock',
active: true,
popularity: 90,
},
{
canonicalCode: '00700.HK',
displayCode: '00700',
nameZh: '腾讯控股',
market: 'HK',
assetType: 'stock',
active: true,
popularity: 80,
},
];
const stockBarResponse: StockBarResponse = {
total: 1,
items: [
{
id: 1,
stockCode: '600519',
analysisCount: 2,
marketPhaseSummary: { market: 'CN', phase: 'unknown', warnings: [] },
},
],
};
function makeSignal(overrides: Partial<DecisionSignalItem> = {}): DecisionSignalItem { function makeSignal(overrides: Partial<DecisionSignalItem> = {}): DecisionSignalItem {
return { return {
...signal, ...signal,
@@ -211,11 +277,25 @@ function deferredPromise<T>() {
return { promise, resolve }; return { promise, resolve };
} }
function submitCurrentStock(value: string) {
const input = screen.getByLabelText('当前股票');
fireEvent.change(input, { target: { value } });
fireEvent.click(screen.getByRole('button', { name: '查看股票' }));
}
beforeEach(() => { beforeEach(() => {
window.history.pushState({}, '', '/'); window.history.pushState({}, '', '/');
window.localStorage.clear(); window.localStorage.clear();
window.localStorage.setItem('dsa.uiLanguage', 'zh'); window.localStorage.setItem('dsa.uiLanguage', 'zh');
vi.clearAllMocks(); vi.clearAllMocks();
stockIndexState = {
index: stockIndexItems,
loading: false,
error: null,
fallback: false,
loaded: true,
};
vi.mocked(historyApi.getStockBarList).mockResolvedValue(stockBarResponse);
vi.mocked(decisionSignalsApi.list).mockResolvedValue(listResponse()); vi.mocked(decisionSignalsApi.list).mockResolvedValue(listResponse());
vi.mocked(decisionSignalsApi.getLatest).mockResolvedValue(listResponse([signal])); vi.mocked(decisionSignalsApi.getLatest).mockResolvedValue(listResponse([signal]));
vi.mocked(decisionSignalsApi.getOutcomeStats).mockResolvedValue(outcomeStats); vi.mocked(decisionSignalsApi.getOutcomeStats).mockResolvedValue(outcomeStats);
@@ -249,6 +329,27 @@ describe('DecisionSignalsPage', () => {
expect(screen.getByText('贵州茅台').closest('button')).toBeNull(); expect(screen.getByText('贵州茅台').closest('button')).toBeNull();
expect(screen.getByText('放量下跌风险')).toBeInTheDocument(); expect(screen.getByText('放量下跌风险')).toBeInTheDocument();
expect(screen.getByText(formattedCreatedAt)).toBeInTheDocument(); expect(screen.getByText(formattedCreatedAt)).toBeInTheDocument();
expect(screen.getByText('当前统计为全局已复盘 outcome 口径,不等于当前可见信号数量,也不随当前股票过滤。')).toBeInTheDocument();
});
it('shows a zero-sample outcome stats state instead of misleading zero metrics', async () => {
vi.mocked(decisionSignalsApi.getOutcomeStats).mockResolvedValueOnce({
...outcomeStats,
total: 0,
completed: 0,
unable: 0,
hit: 0,
miss: 0,
neutral: 0,
hitRatePct: null,
avgStockReturnPct: null,
});
renderPage();
expect(await screen.findByText('暂无已复盘样本')).toBeInTheDocument();
expect(screen.getByText('当前统计为全局已复盘 outcome 口径,不等于当前可见信号数量,也不随当前股票过滤。')).toBeInTheDocument();
expect(screen.queryByText('0%')).not.toBeInTheDocument();
}); });
it('uses a source report id query parameter as an exact analysis lookup on load', async () => { it('uses a source report id query parameter as an exact analysis lookup on load', async () => {
@@ -457,10 +558,7 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
fireEvent.change(screen.getByLabelText('最新股票代码'), { submitCurrentStock('600519');
target: { value: '600519' },
});
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
await waitFor(() => { await waitFor(() => {
expect(decisionSignalsApi.getLatest).toHaveBeenCalledWith('600519', { expect(decisionSignalsApi.getLatest).toHaveBeenCalledWith('600519', {
@@ -470,7 +568,170 @@ describe('DecisionSignalsPage', () => {
}); });
}); });
it('uses the applied market filter for latest lookup instead of draft filter state', async () => { it('submits the main stock context once and keeps the applied context separate from the draft', async () => {
renderPage();
await screen.findByText('贵州茅台');
vi.mocked(decisionSignalsApi.getLatest).mockClear();
vi.mocked(decisionSignalsApi.list).mockClear();
submitCurrentStock('AAPL');
await waitFor(() => {
expect(decisionSignalsApi.getLatest).toHaveBeenCalledTimes(1);
expect(decisionSignalsApi.list).toHaveBeenCalledTimes(1);
});
expect(screen.getByText('当前查看AAPL')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('当前股票'), { target: { value: 'MSFT' } });
expect(screen.getByText('当前查看AAPL')).toBeInTheDocument();
expect(decisionSignalsApi.getLatest).toHaveBeenCalledTimes(1);
expect(decisionSignalsApi.list).toHaveBeenCalledTimes(1);
});
it('uses autocomplete metadata for the active context instead of the old draft value', async () => {
renderPage();
await screen.findByText('贵州茅台');
fireEvent.change(screen.getByLabelText('当前股票'), { target: { value: '6005' } });
const listbox = await screen.findByRole('listbox');
fireEvent.click(within(listbox).getByRole('option', { name: /贵州茅台.*600519/ }));
await waitFor(() => {
expect(decisionSignalsApi.getLatest).toHaveBeenCalledWith('600519.SH', {
market: 'cn',
limit: 5,
});
});
expect(screen.getByText('当前查看600519 / 贵州茅台 / cn')).toBeInTheDocument();
expect(screen.getByLabelText('当前股票')).toHaveValue('600519');
});
it('shows recent history candidates and passes normalized market when a candidate is selected', async () => {
renderPage();
expect(await screen.findByText('最近分析')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /600519/ }));
await waitFor(() => {
expect(decisionSignalsApi.getLatest).toHaveBeenCalledWith('600519', {
market: 'cn',
limit: 5,
});
});
expect(screen.getByText('当前查看600519 / cn')).toBeInTheDocument();
});
it('preserves the applied stock context metadata when the unchanged draft is submitted again', async () => {
renderPage();
expect(await screen.findByText('最近分析')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /600519/ }));
await waitFor(() => {
expect(decisionSignalsApi.getLatest).toHaveBeenLastCalledWith('600519', {
market: 'cn',
limit: 5,
});
});
expect(screen.getByLabelText('当前股票')).toHaveValue('600519');
fireEvent.click(screen.getByRole('button', { name: '查看股票' }));
await waitFor(() => {
expect(decisionSignalsApi.getLatest).toHaveBeenLastCalledWith('600519', {
market: 'cn',
limit: 5,
});
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({
stockCode: '600519',
market: 'cn',
}));
});
});
it('does not pass market for a history candidate when market cannot be inferred', async () => {
vi.mocked(historyApi.getStockBarList).mockResolvedValueOnce({
total: 1,
items: [
{
id: 1,
stockCode: '600519',
analysisCount: 1,
marketPhaseSummary: null,
},
],
});
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /^600519$/ }));
await waitFor(() => {
expect(decisionSignalsApi.getLatest).toHaveBeenCalledWith('600519', {
market: undefined,
limit: 5,
});
});
});
it('falls back to popular stock index candidates when history is empty or fails', async () => {
vi.mocked(historyApi.getStockBarList).mockResolvedValueOnce({ total: 0, items: [] });
const { unmount } = renderPage();
expect(await screen.findByText('热门候选')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /AAPL.*Apple.*us/ })).toBeInTheDocument();
expect(decisionSignalsApi.getLatest).not.toHaveBeenCalled();
unmount();
vi.clearAllMocks();
vi.mocked(historyApi.getStockBarList).mockRejectedValueOnce(new Error('history down'));
vi.mocked(decisionSignalsApi.list).mockResolvedValue(listResponse());
vi.mocked(decisionSignalsApi.getLatest).mockResolvedValue(listResponse([signal]));
vi.mocked(decisionSignalsApi.getOutcomeStats).mockResolvedValue(outcomeStats);
renderPage();
expect(await screen.findByText('热门候选')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /AAPL.*Apple.*us/ })).toBeInTheDocument();
});
it('renders no candidate fallback without crashing when history and stock index are unavailable', async () => {
stockIndexState = {
index: [],
loading: false,
error: new Error('index down'),
fallback: true,
loaded: false,
};
vi.mocked(historyApi.getStockBarList).mockRejectedValueOnce(new Error('history down'));
renderPage();
expect(await screen.findByText('暂无可用候选,可直接输入股票代码或名称。')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'AI 建议' })).toBeInTheDocument();
});
it('deduplicates history candidates with market-aware keys and falls back to stock code without market', async () => {
vi.mocked(historyApi.getStockBarList).mockResolvedValueOnce({
total: 4,
items: [
{ id: 1, stockCode: '600519', analysisCount: 1, marketPhaseSummary: { market: 'CN', phase: 'unknown', warnings: [] } },
{ id: 2, stockCode: '600519', analysisCount: 1, marketPhaseSummary: { market: 'HK', phase: 'unknown', warnings: [] } },
{ id: 3, stockCode: 'AAPL', analysisCount: 1, marketPhaseSummary: null },
{ id: 4, stockCode: 'AAPL', analysisCount: 1, marketPhaseSummary: null },
],
});
renderPage();
expect(await screen.findByText('最近分析')).toBeInTheDocument();
const candidateButtons = screen.getAllByRole('button').filter((button) => (
button.textContent?.includes('600519') || button.textContent?.includes('AAPL')
));
expect(candidateButtons.filter((button) => button.textContent?.includes('600519'))).toHaveLength(2);
expect(candidateButtons.filter((button) => button.textContent?.includes('AAPL'))).toHaveLength(1);
});
it('does not use the advanced list market filter for latest lookup', async () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
@@ -484,14 +745,11 @@ describe('DecisionSignalsPage', () => {
}); });
fireEvent.change(marketSelect, { target: { value: 'hk' } }); fireEvent.change(marketSelect, { target: { value: 'hk' } });
fireEvent.change(screen.getByLabelText('最新股票代码'), { submitCurrentStock('600519');
target: { value: '600519' },
});
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
await waitFor(() => { await waitFor(() => {
expect(decisionSignalsApi.getLatest).toHaveBeenCalledWith('600519', { expect(decisionSignalsApi.getLatest).toHaveBeenCalledWith('600519', {
market: 'cn', market: undefined,
limit: 5, limit: 5,
}); });
}); });
@@ -513,16 +771,9 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
const latestInput = screen.getByLabelText('最新股票代码'); submitCurrentStock('600519');
fireEvent.change(latestInput, {
target: { value: '600519' },
});
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
fireEvent.change(latestInput, { submitCurrentStock('AAPL');
target: { value: 'AAPL' },
});
fireEvent.submit(latestInput.closest('form') as HTMLFormElement);
expect(await screen.findByText('第二次查询结果')).toBeInTheDocument(); expect(await screen.findByText('第二次查询结果')).toBeInTheDocument();
@@ -542,24 +793,21 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
fireEvent.change(screen.getByLabelText('最新股票代码'), { submitCurrentStock('600519');
target: { value: '600519' },
});
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
expect(await screen.findByText('暂无最新有效信号')).toBeInTheDocument(); expect(await screen.findByText('暂无最新有效信号')).toBeInTheDocument();
vi.mocked(decisionSignalsApi.getLatest).mockRejectedValueOnce(new Error('latest down')); vi.mocked(decisionSignalsApi.getLatest).mockRejectedValueOnce(new Error('latest down'));
fireEvent.click(screen.getByRole('button', { name: '查询最新' })); submitCurrentStock('600519');
expect(await screen.findByRole('alert')).toHaveTextContent('latest down'); expect(await screen.findByRole('alert')).toHaveTextContent('latest down');
}); });
it('does not request the timeline before a non-empty stock search', async () => { it('does not request the timeline before a current stock is selected', async () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
expect(screen.getByText('输入股票代码查看时间线')).toBeInTheDocument(); expect(screen.getAllByText('选择股票查看 AI 建议').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: '查询时间线' })).toBeDisabled(); expect(screen.getByRole('button', { name: '查询时间线' })).toBeDisabled();
expect(decisionSignalsApi.list).toHaveBeenCalledTimes(1); expect(decisionSignalsApi.list).toHaveBeenCalledTimes(1);
expect(within(screen.getByLabelText('时间线状态')).queryByRole('option', { name: '已关闭' })).not.toBeInTheDocument(); expect(within(screen.getByLabelText('时间线状态')).queryByRole('option', { name: '已关闭' })).not.toBeInTheDocument();
@@ -570,13 +818,15 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
submitCurrentStock('600519');
await waitFor(() => expect(decisionSignalsApi.list).toHaveBeenCalledTimes(2));
fireEvent.change(screen.getByLabelText('时间线市场'), { target: { value: 'cn' } }); fireEvent.change(screen.getByLabelText('时间线市场'), { target: { value: 'cn' } });
fireEvent.change(screen.getByLabelText('时间线股票代码'), { target: { value: ' 600519 ' } });
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30d' } }); fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30d' } });
fireEvent.click(screen.getByRole('button', { name: '查询时间线' })); fireEvent.click(screen.getByRole('button', { name: '查询时间线' }));
await waitFor(() => { await waitFor(() => {
expect(decisionSignalsApi.list).toHaveBeenCalledTimes(2); expect(decisionSignalsApi.list).toHaveBeenCalledTimes(3);
}); });
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({
market: 'cn', market: 'cn',
@@ -590,6 +840,126 @@ describe('DecisionSignalsPage', () => {
expect(params.createdTo).toEqual(expect.any(String)); expect(params.createdTo).toEqual(expect.any(String));
}); });
it('initializes timeline market from a new stock context once and preserves later user overrides', async () => {
renderPage();
await screen.findByText('贵州茅台');
const getHistoryCandidateButton = () => screen.getAllByRole('button').find((button) => (
button.textContent?.includes('600519') && button.textContent.includes('/ cn')
));
fireEvent.click(await waitFor(() => {
const button = getHistoryCandidateButton();
expect(button).toBeTruthy();
return button as HTMLButtonElement;
}));
await waitFor(() => {
expect(screen.getByLabelText('时间线市场')).toHaveValue('cn');
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({
stockCode: '600519',
market: 'cn',
}));
});
fireEvent.change(screen.getByLabelText('时间线市场'), { target: { value: 'hk' } });
const sameCandidateButton = getHistoryCandidateButton();
expect(sameCandidateButton).toBeTruthy();
fireEvent.click(sameCandidateButton as HTMLButtonElement);
await waitFor(() => {
expect(screen.getByLabelText('时间线市场')).toHaveValue('hk');
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({
stockCode: '600519',
market: 'hk',
}));
});
});
it('clears timeline market from a previous stock context before a later manual stock submit without metadata', async () => {
renderPage();
await screen.findByText('贵州茅台');
const historyCandidateButton = await waitFor(() => {
const button = screen.getAllByRole('button').find((candidateButton) => (
candidateButton.textContent?.includes('600519') && candidateButton.textContent.includes('/ cn')
));
expect(button).toBeTruthy();
return button as HTMLButtonElement;
});
fireEvent.click(historyCandidateButton);
await waitFor(() => {
expect(screen.getByLabelText('时间线市场')).toHaveValue('cn');
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({
stockCode: '600519',
market: 'cn',
}));
});
fireEvent.click(screen.getByRole('button', { name: '清空当前股票' }));
await waitFor(() => {
expect(screen.getByLabelText('时间线市场')).toHaveValue('');
});
submitCurrentStock('AAPL');
await waitFor(() => {
expect(decisionSignalsApi.getLatest).toHaveBeenLastCalledWith('AAPL', {
market: undefined,
limit: 5,
});
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({
stockCode: 'AAPL',
market: undefined,
}));
});
});
it('preserves a user-selected timeline market when a later manual stock submit has no metadata', async () => {
renderPage();
await screen.findByText('贵州茅台');
fireEvent.change(screen.getByLabelText('时间线市场'), { target: { value: 'us' } });
submitCurrentStock('AAPL');
await waitFor(() => {
expect(screen.getByLabelText('时间线市场')).toHaveValue('us');
expect(decisionSignalsApi.getLatest).toHaveBeenLastCalledWith('AAPL', {
market: undefined,
limit: 5,
});
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({
stockCode: 'AAPL',
market: 'us',
}));
});
});
it('applies timeline draft filters only after the query button is clicked', async () => {
renderPage();
await screen.findByText('贵州茅台');
submitCurrentStock('AAPL');
await waitFor(() => expect(decisionSignalsApi.list).toHaveBeenCalledTimes(2));
fireEvent.change(screen.getByLabelText('时间线市场'), { target: { value: 'us' } });
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30d' } });
fireEvent.change(screen.getByLabelText('时间线状态'), { target: { value: 'active' } });
expect(decisionSignalsApi.list).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole('button', { name: '查询时间线' }));
await waitFor(() => {
expect(decisionSignalsApi.list).toHaveBeenCalledTimes(3);
});
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({
stockCode: 'AAPL',
market: 'us',
status: 'active',
}));
});
it('passes active timeline status, shows truncation, and opens details from a point', async () => { it('passes active timeline status, shows truncation, and opens details from a point', async () => {
const timelineSignal = makeSignal({ const timelineSignal = makeSignal({
id: 8, id: 8,
@@ -605,9 +975,8 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
fireEvent.change(screen.getByLabelText('时间线股票代码'), { target: { value: 'AAPL' } });
fireEvent.change(screen.getByLabelText('时间线状态'), { target: { value: 'active' } }); fireEvent.change(screen.getByLabelText('时间线状态'), { target: { value: 'active' } });
fireEvent.click(screen.getByRole('button', { name: '查询时间线' })); submitCurrentStock('AAPL');
await waitFor(() => { await waitFor(() => {
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ expect(decisionSignalsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({
@@ -637,21 +1006,35 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
const timelineStockInput = screen.getByLabelText('时间线股票代码'); submitCurrentStock('AAPL');
fireEvent.change(timelineStockInput, { target: { value: 'AAPL' } });
fireEvent.click(screen.getByRole('button', { name: '查询时间线' }));
fireEvent.click(await screen.findByTestId('timeline-click-8')); fireEvent.click(await screen.findByTestId('timeline-click-8'));
expect(within(await screen.findByRole('dialog')).getByText('Timeline stale risk')).toBeInTheDocument(); expect(within(await screen.findByRole('dialog')).getByText('Timeline stale risk')).toBeInTheDocument();
fireEvent.change(timelineStockInput, { target: { value: '' } }); fireEvent.click(screen.getByRole('button', { name: '清空当前股票' }));
expect(screen.getByText('输入股票代码查看时间线')).toBeInTheDocument(); expect(screen.getAllByText('选择股票查看 AI 建议').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: '查询时间线' })).toBeDisabled(); expect(screen.getByRole('button', { name: '查询时间线' })).toBeDisabled();
expect(screen.queryByTestId('timeline-click-8')).not.toBeInTheDocument(); expect(screen.queryByTestId('timeline-click-8')).not.toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(decisionSignalsApi.list).toHaveBeenCalledTimes(2); expect(decisionSignalsApi.list).toHaveBeenCalledTimes(2);
}); });
it('clears current stock derived state without closing a list-sourced drawer', async () => {
renderPage();
fireEvent.click(await screen.findByRole('button', { name: '查看 贵州茅台 AI 建议详情' }));
expect(within(await screen.findByRole('dialog')).getByText('趋势保持')).toBeInTheDocument();
submitCurrentStock('AAPL');
expect(await screen.findByText('当前查看AAPL')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '清空当前股票' }));
expect(screen.getByLabelText('当前股票')).toHaveValue('');
expect(screen.getAllByText('选择股票查看 AI 建议').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: '查询时间线' })).toBeDisabled();
expect(within(screen.getByRole('dialog')).getByText('趋势保持')).toBeInTheDocument();
});
it('closes a timeline-sourced drawer when an active timeline status update removes it', async () => { it('closes a timeline-sourced drawer when an active timeline status update removes it', async () => {
const timelineSignal = makeSignal({ const timelineSignal = makeSignal({
id: 8, id: 8,
@@ -668,9 +1051,8 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
fireEvent.change(screen.getByLabelText('时间线股票代码'), { target: { value: 'AAPL' } });
fireEvent.change(screen.getByLabelText('时间线状态'), { target: { value: 'active' } }); fireEvent.change(screen.getByLabelText('时间线状态'), { target: { value: 'active' } });
fireEvent.click(screen.getByRole('button', { name: '查询时间线' })); submitCurrentStock('AAPL');
fireEvent.click(await screen.findByTestId('timeline-click-8')); fireEvent.click(await screen.findByTestId('timeline-click-8'));
const dialog = await screen.findByRole('dialog'); const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: '标记失效' })); fireEvent.click(within(dialog).getByRole('button', { name: '标记失效' }));
@@ -699,8 +1081,7 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
fireEvent.change(screen.getByLabelText('时间线股票代码'), { target: { value: 'AAPL' } }); submitCurrentStock('AAPL');
fireEvent.click(screen.getByRole('button', { name: '查询时间线' }));
fireEvent.change(screen.getByLabelText('时间线状态'), { target: { value: 'active' } }); fireEvent.change(screen.getByLabelText('时间线状态'), { target: { value: 'active' } });
fireEvent.click(await screen.findByTestId('timeline-click-8')); fireEvent.click(await screen.findByTestId('timeline-click-8'));
const dialog = await screen.findByRole('dialog'); const dialog = await screen.findByRole('dialog');
@@ -860,8 +1241,7 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
fireEvent.change(screen.getByLabelText('最新股票代码'), { target: { value: 'AAPL' } }); submitCurrentStock('AAPL');
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
fireEvent.click(await screen.findByRole('button', { name: '查看 Apple AI 建议详情' })); fireEvent.click(await screen.findByRole('button', { name: '查看 Apple AI 建议详情' }));
const dialog = await screen.findByRole('dialog'); const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Latest risk')).toBeInTheDocument(); expect(within(dialog).getByText('Latest risk')).toBeInTheDocument();
@@ -895,14 +1275,11 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
const latestInput = screen.getByLabelText('最新股票代码'); submitCurrentStock('AAPL');
fireEvent.change(latestInput, { target: { value: 'AAPL' } });
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
fireEvent.click(await screen.findByRole('button', { name: '查看 Apple AI 建议详情' })); fireEvent.click(await screen.findByRole('button', { name: '查看 Apple AI 建议详情' }));
expect(within(await screen.findByRole('dialog')).getByText('Latest A risk')).toBeInTheDocument(); expect(within(await screen.findByRole('dialog')).getByText('Latest A risk')).toBeInTheDocument();
fireEvent.change(latestInput, { target: { value: 'MSFT' } }); submitCurrentStock('MSFT');
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
expect(await screen.findByText('Latest B risk')).toBeInTheDocument(); expect(await screen.findByText('Latest B risk')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
@@ -922,14 +1299,11 @@ describe('DecisionSignalsPage', () => {
renderPage(); renderPage();
await screen.findByText('贵州茅台'); await screen.findByText('贵州茅台');
const latestInput = screen.getByLabelText('最新股票代码'); submitCurrentStock('AAPL');
fireEvent.change(latestInput, { target: { value: 'AAPL' } });
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
fireEvent.click(await screen.findByRole('button', { name: '查看 Apple AI 建议详情' })); fireEvent.click(await screen.findByRole('button', { name: '查看 Apple AI 建议详情' }));
expect(within(await screen.findByRole('dialog')).getByText('Latest risk before failure')).toBeInTheDocument(); expect(within(await screen.findByRole('dialog')).getByText('Latest risk before failure')).toBeInTheDocument();
fireEvent.change(latestInput, { target: { value: 'MSFT' } }); submitCurrentStock('MSFT');
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
expect(await screen.findByRole('alert')).toHaveTextContent('latest failed'); expect(await screen.findByRole('alert')).toHaveTextContent('latest failed');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
@@ -949,8 +1323,7 @@ describe('DecisionSignalsPage', () => {
fireEvent.click(await screen.findByRole('button', { name: '查看 贵州茅台 AI 建议详情' })); fireEvent.click(await screen.findByRole('button', { name: '查看 贵州茅台 AI 建议详情' }));
expect(within(await screen.findByRole('dialog')).getByText('趋势保持')).toBeInTheDocument(); expect(within(await screen.findByRole('dialog')).getByText('趋势保持')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('最新股票代码'), { target: { value: 'AAPL' } }); submitCurrentStock('AAPL');
fireEvent.click(screen.getByRole('button', { name: '查询最新' }));
expect(await screen.findByText('Latest lookup risk')).toBeInTheDocument(); expect(await screen.findByText('Latest lookup risk')).toBeInTheDocument();
expect(within(screen.getByRole('dialog')).getByText('趋势保持')).toBeInTheDocument(); expect(within(screen.getByRole('dialog')).getByText('趋势保持')).toBeInTheDocument();

View File

@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 修复桌面端 `WEBUI_HOST=*` / `WEBUI_HOST=[::]` 会被原样传给端口探测和后端启动导致无法监听的问题,启动前分别规范化为 `0.0.0.0` / `::` - [修复] 修复桌面端 `WEBUI_HOST=*` / `WEBUI_HOST=[::]` 会被原样传给端口探测和后端启动导致无法监听的问题,启动前分别规范化为 `0.0.0.0` / `::`
- [改进] `STOCK_LIST` 自选股解析支持中文逗号、顿号、分号、空格和换行等常见粘贴分隔符运行时、定时热刷新、CLI `--stocks`、Web 设置保存和自选 API 统一识别,并在写回时规范为英文逗号。 - [改进] `STOCK_LIST` 自选股解析支持中文逗号、顿号、分号、空格和换行等常见粘贴分隔符运行时、定时热刷新、CLI `--stocks`、Web 设置保存和自选 API 统一识别,并在写回时规范为英文逗号。
- [改进] 新增 `NEWS_INTEL_AUTO_FETCH_ENABLED` 单开关开启后个股分析、Agent 分析和大盘复盘会 fail-open 自动初始化并刷新 RSS/Atom/NewsNow 本地资讯池。 - [改进] 新增 `NEWS_INTEL_AUTO_FETCH_ENABLED` 单开关开启后个股分析、Agent 分析和大盘复盘会 fail-open 自动初始化并刷新 RSS/Atom/NewsNow 本地资讯池。
- [改进] Web AI 建议页新增主股票上下文,复用最近分析和股票索引候选,并改进表现统计零样本说明。
- [改进] 补充本次设置页布局收敛:移动端分类导航改为横向滚动列表并保证设置内容首屏可见,桌面端保留分类说明并收紧字段布局层级与间距,提升首屏效率与可配置信息密度。 - [改进] 补充本次设置页布局收敛:移动端分类导航改为横向滚动列表并保证设置内容首屏可见,桌面端保留分类说明并收紧字段布局层级与间距,提升首屏效率与可配置信息密度。
- [文档] 在 README 快速开始中补充行情数据源配置说明TUSHARE_TOKEN / Longbridge明确未配置时仍可走 AkShare、Baostock、YFinance 等免费兜底源日志中相关提示不影响运行。同步更新docs下的中英双份 README - [文档] 在 README 快速开始中补充行情数据源配置说明TUSHARE_TOKEN / Longbridge明确未配置时仍可走 AkShare、Baostock、YFinance 等免费兜底源日志中相关提示不影响运行。同步更新docs下的中英双份 README

View File

@@ -107,10 +107,15 @@ Web 展示必须把这些 wire value 映射为当前 UI 语言的用户可读标
Web 入口位于 `/decision-signals` Web 入口位于 `/decision-signals`
- 默认查询 `status=active` - 默认查询 `status=active`
- 支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态筛选 - 页面顶部提供页面级“当前股票”主路径,独立于高级列表筛选。用户提交主股票、选择自动补全候选或点击候选 chip 后latest active 与时间线共用同一个已应用股票上下文;只修改输入草稿不会触发 latest 或时间线查询
- 新增单支股票信号时间线,复用现有 `GET /api/v1/decision-signals` list API不新增 timeline endpoint。时间线必须输入非空 `stockCode` 后才会查询;空 `stockCode` 只显示引导态,不拉取 market-only 或 global timeline - 当前股票候选优先展示最近分析过的股票;如果没有历史候选,或历史候选加载失败,则降级展示股票索引中 active 且 popularity 较高的热门股票。候选只作为手动点击入口,页面加载时不会自动提交查询;历史和股票索引都不可用时仅显示无候选降级文案
- 当前股票上下文会显示已应用的代码、名称和可推导市场,并提供清空入口。清空会让 latest 与时间线回到引导态,不影响高级列表筛选或列表来源详情抽屉。
- 支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态进行高级列表筛选;这些筛选不等同于当前股票上下文,也不会污染 latest active 查询。
- 单支股票信号时间线复用现有 `GET /api/v1/decision-signals` list API不新增 timeline endpoint。时间线必须先应用非空当前股票后才会查询没有当前股票时只显示引导态不拉取 market-only 或 global timeline。
- 时间线只支持 `30d``90d``180d` 三个时间范围,默认 `90d`;每次最多请求 100 条。若返回 `total > items.length`Web 会显示“仅展示最近 100 条信号,请缩小时间范围”,避免静默展示不完整轨迹。 - 时间线只支持 `30d``90d``180d` 三个时间范围,默认 `90d`;每次最多请求 100 条。若返回 `total > items.length`Web 会显示“仅展示最近 100 条信号,请缩小时间范围”,避免静默展示不完整轨迹。
- 时间线筛选保留独立的 market、range、status 表单和查询按钮。选择新当前股票时,如果能推导市场,只在这一次初始化时间线 market用户之后可以手动改 market查询以按钮提交时的表单快照为准。
- 时间线 status filter 只支持 `all``active``all` 不传 `status``active``status=active`。P1 不提供 terminal status filter也不做前端 terminal 过滤。 - 时间线 status filter 只支持 `all``active``all` 不传 `status``active``status=active`。P1 不提供 terminal status filter也不做前端 terminal 过滤。
- 信号表现统计保持全局已复盘 outcome 口径,不等于当前可见信号数量,也不随当前股票或高级列表筛选变化;当已复盘样本数为 0 时Web 显示零样本空状态而不是一组 `0/-` 指标。
- P1 不提供 profile filter`decision_profile` 仍只存在于 metadata 中,不能可靠 server-side 过滤。历史缺失或非法 profile 的信号在 Web 中显示为 `unknown`,不会误标为 `balanced` - P1 不提供 profile filter`decision_profile` 仍只存在于 metadata 中,不能可靠 server-side 过滤。历史缺失或非法 profile 的信号在 Web 中显示为 `unknown`,不会误标为 `balanced`
- market filter 在 API / 服务层与 Web 前端均已支持 `cn/hk/us/jp/kr/tw``jp/kr/tw` 的前端本地化标签均已补齐,`tw` 信号可经 API 正常写入、按 `market=tw` 查询,并可在 Web DecisionSignal 页面通过市场筛选项选择台股tw告警大盘红绿灯市场支持 `cn/hk/us/jp/kr` - market filter 在 API / 服务层与 Web 前端均已支持 `cn/hk/us/jp/kr/tw``jp/kr/tw` 的前端本地化标签均已补齐,`tw` 信号可经 API 正常写入、按 `market=tw` 查询,并可在 Web DecisionSignal 页面通过市场筛选项选择台股tw告警大盘红绿灯市场支持 `cn/hk/us/jp/kr`
- 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。 - 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。