Skip to content

Commit 29ebb50

Browse files
Travisuncursoragent
andcommitted
feat: add stock trend tab with readable technical briefs
Introduce a trend研判 tab on stock detail with human-readable MA, volume, and risk strips, and only auto-refresh during market hours when the tab is open. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7b03e2e commit 29ebb50

11 files changed

Lines changed: 1035 additions & 3 deletions

File tree

client-ui/src/api/client.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export async function apiCall<T>(
6666
// ─── Typed convenience wrappers ───
6767
import type {
6868
StockDiagnosisData, InstitutionRatingData,
69-
ScreeningData, StrategySignalData, StrategyVerifyData,
69+
ScreeningData, StrategySignalData, StrategyVerifyData, TrendBriefData,
7070
PortfolioAnalysisData, IndustryMiningData, IndustryStatItem, IndustryStockItem, MarketReportData,
7171
SearchStocksData, BacktestResultData, LatestEvalData, ReportTextData,
7272
} from '../types/schemas'
@@ -95,6 +95,17 @@ export const research = {
9595
strategySignals: (code: string, signal?: AbortSignal) =>
9696
apiCall<StrategySignalData>('strategy_signal', { code }, { signal }, 30000),
9797

98+
trendBrief: (code: string, holdingCost?: number | null, signal?: AbortSignal) =>
99+
apiCall<TrendBriefData>(
100+
'trend_brief',
101+
{
102+
code,
103+
...(holdingCost != null && holdingCost > 0 ? { holding_cost: holdingCost } : {}),
104+
},
105+
{ signal },
106+
30000,
107+
),
108+
98109
strategyVerify: (code: string, checkpoints = 30, forwardDays = 5) =>
99110
apiCall<StrategyVerifyData>('strategy_verify', { code, checkpoints, forward_days: forwardDays }),
100111

client-ui/src/market/StockDetailTab.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ import {
2222
import OpptrixButton from '../components/opptrix/OpptrixButton'
2323
import TradingViewChart from './TradingViewChart'
2424
import StockDecisionCard, { type StockDiscussPayload } from './StockDecisionCard'
25+
import StockTrendTab from './StockTrendTab'
2526
import type { HoldingSnapshot } from './useFollowPortfolio'
2627
import { opptrixTokens } from '../theme/tokens'
2728
import { ghostInteractive } from '../theme/mixins'
2829

29-
type DetailTab = 'analysis' | 'chart' | 'basic' | 'company' | 'news' | 'f10'
30+
type DetailTab = 'analysis' | 'chart' | 'trend' | 'basic' | 'company' | 'news' | 'f10'
3031

3132
const CONTENT_PAD = '15px'
3233

@@ -753,6 +754,7 @@ export default function StockDetailTab({
753754
onTabSelect={(_, data) => setDetailTab(data.value as DetailTab)}
754755
>
755756
<Tab value="chart">走势</Tab>
757+
<Tab value="trend">趋势</Tab>
756758
<Tab value="analysis">分析</Tab>
757759
<Tab value="basic">概况</Tab>
758760
<Tab value="company">公司</Tab>
@@ -762,6 +764,18 @@ export default function StockDetailTab({
762764
</div>
763765

764766
<div className={s.tabBody}>
767+
<div className={mergeClasses(s.tabPanel, detailTab !== 'trend' && s.tabPanelHidden)}>
768+
<div className={mergeClasses(s.scrollPanel, 'opptrix-scroll')}>
769+
{detailTab === 'trend' && (
770+
<StockTrendTab
771+
code={detail.code}
772+
active={detailTab === 'trend'}
773+
holdingCost={holding?.costBasis}
774+
/>
775+
)}
776+
</div>
777+
</div>
778+
765779
<div className={mergeClasses(s.tabPanel, detailTab !== 'analysis' && s.tabPanelHidden)}>
766780
<div className={mergeClasses(s.scrollPanel, 'opptrix-scroll')}>
767781
{detailTab === 'analysis' && (
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { Spinner, Text, makeStyles, mergeClasses } from '@fluentui/react-components'
2+
import { ArrowClockwiseRegular } from '@fluentui/react-icons'
3+
import OpptrixButton from '../components/opptrix/OpptrixButton'
4+
import type { TrendStrip, TrendStripTone } from '../types/schemas'
5+
import { opptrixTokens } from '../theme/tokens'
6+
import { useStockTrendBrief } from './useStockTrendBrief'
7+
import { shouldPollTrendBrief } from './chartLiveRefresh'
8+
9+
const GROUP_LABELS: Record<TrendStrip['group'], string> = {
10+
trend: '趋势结构',
11+
volume: '量价行为',
12+
risk: '风险收益',
13+
holding: '持仓参考',
14+
aux: '辅助参考',
15+
}
16+
17+
const GROUP_ORDER: TrendStrip['group'][] = ['trend', 'volume', 'risk', 'holding', 'aux']
18+
19+
const useStyles = makeStyles({
20+
root: {
21+
display: 'flex',
22+
flexDirection: 'column',
23+
gap: '12px',
24+
padding: '10px 0 16px',
25+
},
26+
head: {
27+
display: 'flex',
28+
alignItems: 'center',
29+
justifyContent: 'space-between',
30+
gap: '8px',
31+
padding: '0 2px',
32+
},
33+
meta: {
34+
fontSize: '10px',
35+
color: opptrixTokens.textTertiary,
36+
lineHeight: 1.45,
37+
},
38+
section: {
39+
display: 'flex',
40+
flexDirection: 'column',
41+
gap: '6px',
42+
},
43+
sectionTitle: {
44+
fontSize: '10px',
45+
fontWeight: 650,
46+
color: opptrixTokens.textTertiary,
47+
letterSpacing: '0.06em',
48+
textTransform: 'uppercase',
49+
padding: '0 2px',
50+
},
51+
strip: {
52+
display: 'flex',
53+
flexDirection: 'column',
54+
gap: '4px',
55+
padding: '8px 10px',
56+
borderRadius: opptrixTokens.radiusMd,
57+
backgroundColor: opptrixTokens.canvas,
58+
border: `1px solid ${opptrixTokens.separator}`,
59+
},
60+
stripHead: {
61+
display: 'flex',
62+
alignItems: 'baseline',
63+
justifyContent: 'space-between',
64+
gap: '8px',
65+
},
66+
stripTitle: {
67+
fontSize: '11px',
68+
fontWeight: 600,
69+
color: opptrixTokens.textPrimary,
70+
flexShrink: 0,
71+
},
72+
stripStatus: {
73+
fontSize: '11px',
74+
fontWeight: 650,
75+
textAlign: 'right',
76+
lineHeight: 1.35,
77+
},
78+
stripDetail: {
79+
fontSize: '11px',
80+
lineHeight: 1.55,
81+
color: opptrixTokens.textSecondary,
82+
},
83+
toneBullish: { color: '#FF3B30' },
84+
toneBearish: { color: '#34C759' },
85+
toneNeutral: { color: opptrixTokens.textPrimary },
86+
toneCaution: { color: opptrixTokens.warning },
87+
toneMuted: { color: opptrixTokens.textTertiary },
88+
center: {
89+
display: 'flex',
90+
flexDirection: 'column',
91+
alignItems: 'center',
92+
justifyContent: 'center',
93+
gap: '8px',
94+
padding: '32px 16px',
95+
color: opptrixTokens.textTertiary,
96+
fontSize: '12px',
97+
textAlign: 'center',
98+
},
99+
disclaimer: {
100+
fontSize: '10px',
101+
lineHeight: 1.5,
102+
color: opptrixTokens.textTertiary,
103+
padding: '4px 2px 0',
104+
},
105+
})
106+
107+
function toneClass(s: ReturnType<typeof useStyles>, tone: TrendStripTone) {
108+
switch (tone) {
109+
case 'bullish': return s.toneBullish
110+
case 'bearish': return s.toneBearish
111+
case 'caution': return s.toneCaution
112+
case 'muted': return s.toneMuted
113+
default: return s.toneNeutral
114+
}
115+
}
116+
117+
function groupStrips(strips: TrendStrip[]) {
118+
const map = new Map<TrendStrip['group'], TrendStrip[]>()
119+
for (const strip of strips) {
120+
const list = map.get(strip.group) ?? []
121+
list.push(strip)
122+
map.set(strip.group, list)
123+
}
124+
return GROUP_ORDER
125+
.filter(g => map.has(g))
126+
.map(g => ({ group: g, label: GROUP_LABELS[g], items: map.get(g)! }))
127+
}
128+
129+
interface StockTrendTabProps {
130+
code: string
131+
active: boolean
132+
holdingCost?: number | null
133+
}
134+
135+
export default function StockTrendTab({ code, active, holdingCost }: StockTrendTabProps) {
136+
const s = useStyles()
137+
const { data, loading, error, updatedAt, refresh } = useStockTrendBrief(code, active, holdingCost)
138+
139+
if (loading && !data) {
140+
return (
141+
<div className={s.center}>
142+
<Spinner size="small" label="正在整理趋势研判…" />
143+
</div>
144+
)
145+
}
146+
147+
if (error && !data) {
148+
return (
149+
<div className={s.center}>
150+
<Text block>{error}</Text>
151+
<OpptrixButton size="small" variant="secondary" onClick={refresh}>
152+
重试
153+
</OpptrixButton>
154+
</div>
155+
)
156+
}
157+
158+
if (!data) {
159+
return <div className={s.center}>暂无趋势研判数据</div>
160+
}
161+
162+
const groups = groupStrips(data.strips)
163+
const livePolling = shouldPollTrendBrief()
164+
const updatedLabel = updatedAt
165+
? updatedAt.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
166+
: '—'
167+
168+
return (
169+
<div className={s.root}>
170+
<div className={s.head}>
171+
<Text className={s.meta} block>
172+
数据截至 {data.as_of}
173+
{livePolling ? (
174+
<>
175+
{' · '}
176+
盘中约每分钟自动刷新(更新于 {updatedLabel}
177+
</>
178+
) : (
179+
<>
180+
{' · '}
181+
非盘中数据不变,进入本页时加载
182+
</>
183+
)}
184+
</Text>
185+
<OpptrixButton
186+
variant="icon"
187+
icon={<ArrowClockwiseRegular fontSize={14} />}
188+
aria-label="立即刷新"
189+
onClick={refresh}
190+
/>
191+
</div>
192+
193+
{groups.map(section => (
194+
<div key={section.group} className={s.section}>
195+
<Text className={s.sectionTitle}>{section.label}</Text>
196+
{section.items.map(strip => (
197+
<div key={strip.id} className={s.strip}>
198+
<div className={s.stripHead}>
199+
<Text className={s.stripTitle}>{strip.title}</Text>
200+
<Text className={mergeClasses(s.stripStatus, toneClass(s, strip.tone))}>
201+
{strip.status}
202+
</Text>
203+
</div>
204+
<Text className={s.stripDetail} block>{strip.detail}</Text>
205+
</div>
206+
))}
207+
</div>
208+
))}
209+
210+
<Text className={s.disclaimer} block>
211+
以上基于历史行情与常用技术统计,帮助理解当前走势结构,不构成买卖建议。
212+
</Text>
213+
</div>
214+
)
215+
}

client-ui/src/market/chartLiveRefresh.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ export function shouldUseLiveIndustryQuotes(storedQuoteDate: string | null | und
6363

6464
export const INDUSTRY_STATS_POLL_MS = 5 * 60_000
6565
export const INDUSTRY_QUOTES_POLL_MS = 60_000
66+
export const TREND_BRIEF_POLL_MS = 60_000
67+
68+
/** 趋势研判仅在 A 股盘中轮询;非交易日、盘前盘后数据已固定,进入页面加载一次即可。 */
69+
export function shouldPollTrendBrief(now = cnMarketNow()): boolean {
70+
return isCnMarketOpen(now)
71+
}
6672

6773
export function shouldPollChartLive(
6874
period: ChartPeriod,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { research } from '../api/client'
3+
import type { TrendBriefData } from '../types/schemas'
4+
import { shouldPollTrendBrief, TREND_BRIEF_POLL_MS } from './chartLiveRefresh'
5+
6+
function isAbort(e: unknown): boolean {
7+
return e instanceof DOMException && e.name === 'AbortError'
8+
|| (e instanceof Error && e.name === 'AbortError')
9+
}
10+
11+
export function useStockTrendBrief(
12+
code: string | null,
13+
active: boolean,
14+
holdingCost?: number | null,
15+
) {
16+
const [data, setData] = useState<TrendBriefData | null>(null)
17+
const [loading, setLoading] = useState(false)
18+
const [error, setError] = useState('')
19+
const [updatedAt, setUpdatedAt] = useState<Date | null>(null)
20+
const dataRef = useRef<TrendBriefData | null>(null)
21+
22+
const load = useCallback(async (signal?: AbortSignal) => {
23+
if (!code) return
24+
setLoading(prev => (dataRef.current ? prev : true))
25+
setError('')
26+
try {
27+
const resp = await research.trendBrief(code, holdingCost, signal)
28+
if (!resp.success || !resp.data) {
29+
throw new Error(resp.message || '趋势研判加载失败')
30+
}
31+
dataRef.current = resp.data
32+
setData(resp.data)
33+
setUpdatedAt(new Date())
34+
} catch (e) {
35+
if (isAbort(e)) return
36+
setError(e instanceof Error ? e.message : '趋势研判加载失败')
37+
} finally {
38+
setLoading(false)
39+
}
40+
}, [code, holdingCost])
41+
42+
useEffect(() => {
43+
dataRef.current = null
44+
setData(null)
45+
setError('')
46+
setUpdatedAt(null)
47+
}, [code, holdingCost])
48+
49+
useEffect(() => {
50+
if (!active || !code) return undefined
51+
const ac = new AbortController()
52+
void load(ac.signal)
53+
54+
if (!shouldPollTrendBrief()) {
55+
return () => ac.abort()
56+
}
57+
58+
const timer = window.setInterval(() => {
59+
if (!shouldPollTrendBrief()) return
60+
void load()
61+
}, TREND_BRIEF_POLL_MS)
62+
63+
return () => {
64+
ac.abort()
65+
window.clearInterval(timer)
66+
}
67+
}, [active, code, load])
68+
69+
const refresh = useCallback(() => {
70+
void load()
71+
}, [load])
72+
73+
return { data, loading, error, updatedAt, refresh }
74+
}

client-ui/src/types/schemas.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,26 @@ export interface StrategySignalData {
182182
signals: SingleStrategySignal[]; timestamp?: string
183183
}
184184

185+
export type TrendStripTone = 'bullish' | 'bearish' | 'neutral' | 'caution' | 'muted'
186+
187+
export interface TrendStrip {
188+
id: string
189+
group: 'trend' | 'volume' | 'risk' | 'aux' | 'holding'
190+
title: string
191+
status: string
192+
detail: string
193+
tone: TrendStripTone
194+
}
195+
196+
export interface TrendBriefData {
197+
code: string
198+
name: string
199+
as_of: string
200+
data_days: number
201+
strips: TrendStrip[]
202+
timestamp?: string
203+
}
204+
185205
export interface StrategyPerformanceItem {
186206
name: string; overall_win_rate: number
187207
avg_return: number; sharpe: number | null

0 commit comments

Comments
 (0)