mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: add decision signal stock context (#1925)
Co-authored-by: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
@@ -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 }}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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" />}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
Reference in New Issue
Block a user