Skip to content

Commit 95cce8d

Browse files
Travisuncursoragent
andcommitted
feat: policies-driven discover strategies, market regime, and G=B+M scoring
Land AlphaGBM-inspired discover presets (G=B+M core pool, fear rebound, Buffett lens upgrade), G=B+M scorecard with B/M breakdown on decision cards, and enriched market_regime from local breadth and Marks-cycle proxies. Document macro data backlog and switch README logo to opptrix-logo.png. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 29ebb50 commit 95cce8d

34 files changed

Lines changed: 981 additions & 43 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<p align="center">
2-
<img src="icons/logo@256.png" alt="Opptrix" width="128" height="128" />
2+
<img src="icons/opptrix-logo.png" alt="Opptrix" width="128" height="128" />
33
</p>
44

55
# Opptrix — 你的A股投研助手

apps/server/src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export interface AppConfig {
3535

3636
const DEFAULTS: AppConfig = {
3737
providers: [],
38-
default_scorecard: process.env.DEFAULT_SCORECARD ?? '综合评估',
38+
default_scorecard: process.env.DEFAULT_SCORECARD ?? 'G=B+M',
3939
default_top_n: Number(process.env.DEFAULT_TOP_N ?? 20),
4040
}
4141

client-ui/src/api/client.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ import type {
7272
} from '../types/schemas'
7373

7474
export const research = {
75-
diagnose: (code: string) =>
76-
apiCall<StockDiagnosisData>('stock_diagnosis', { code }),
75+
diagnose: (code: string, scorecard?: string) =>
76+
apiCall<StockDiagnosisData>('stock_diagnosis', { code, ...(scorecard ? { scorecard } : {}) }),
7777

7878
institutionRating: (code: string, groups?: string[], signal?: AbortSignal) =>
7979
apiCall<InstitutionRatingData>('institution_rating', { code, groups }, { signal }, 20000),
@@ -121,6 +121,9 @@ export const research = {
121121
tradeDate ? { trade_date: tradeDate } : {},
122122
),
123123

124+
marketRegime: () =>
125+
apiCall<import('../types/schemas').MarketRegimeData>('market_regime'),
126+
124127
industryStocks: (industry: string, limit = 120) =>
125128
apiCall<{ trade_date: string; quote_date: string | null; industry: string; items: IndustryStockItem[] }>(
126129
'market_industry_stocks',
@@ -170,8 +173,13 @@ export const research = {
170173
backtest: (codes: string[], scorecard = '综合评估', periods = 5) =>
171174
apiCall<BacktestResultData>('backtest', { codes, scorecard, periods }),
172175

173-
latestEval: (code: string, signal?: AbortSignal) =>
174-
apiCall<LatestEvalData>('latest_evaluation', { code }, { signal }, 30000),
176+
latestEval: (code: string, signal?: AbortSignal, scorecard?: string, force = false) =>
177+
apiCall<LatestEvalData>(
178+
'latest_evaluation',
179+
{ code, ...(scorecard ? { scorecard } : {}), ...(force ? { force: true } : {}) },
180+
{ signal },
181+
90000,
182+
),
175183

176184
strategyReport: (code: string) =>
177185
apiCall<ReportTextData>('strategy_report', { code }),

client-ui/src/market/DiscoverTab.tsx

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type MouseEvent } from 'react'
22
import { ProgressBar, Spinner, Tab, TabList, Text, makeStyles, mergeClasses } from '@fluentui/react-components'
33
import { AddRegular, ArrowRightRegular, DeleteRegular } from '@fluentui/react-icons'
44
import { listDiscoverStrategies, getHealth } from '../api/client'
5-
import type { DiscoverJobSnapshot, DiscoverStrategyOption, DiscoverStrategyPublic } from '../types/schemas'
5+
import type { DiscoverJobSnapshot, DiscoverStrategyOption, DiscoverStrategyPublic, MarketRegimeData } from '../types/schemas'
66
import type { WatchlistItem } from '../types/market'
77
import OpptrixButton from '../components/opptrix/OpptrixButton'
88
import DiscoverStrategyPicker from './DiscoverStrategyPicker'
@@ -25,6 +25,7 @@ const CATEGORY_LABEL: Record<DiscoverStrategyPublic['category'], string> = {
2525
quality: '质量',
2626
momentum: '动量',
2727
balanced: '均衡',
28+
contrarian: '逆向',
2829
}
2930

3031
const useStyles = makeStyles({
@@ -103,6 +104,26 @@ const useStyles = makeStyles({
103104
color: opptrixTokens.textSecondary,
104105
lineHeight: 1.45,
105106
},
107+
regimeBanner: {
108+
flexShrink: 0,
109+
padding: `6px ${CONTENT_PAD}`,
110+
borderBottom: `1px solid ${opptrixTokens.separator}`,
111+
fontSize: '10px',
112+
lineHeight: 1.45,
113+
color: opptrixTokens.textSecondary,
114+
backgroundColor: opptrixTokens.accentSoft,
115+
},
116+
regimeHeadline: {
117+
fontWeight: 650,
118+
color: opptrixTokens.textPrimary,
119+
marginBottom: '2px',
120+
},
121+
regimeIndicators: {
122+
fontSize: '9px',
123+
color: opptrixTokens.textTertiary,
124+
lineHeight: 1.4,
125+
marginBottom: '3px',
126+
},
106127
summary: {
107128
flexShrink: 0,
108129
padding: `8px ${CONTENT_PAD}`,
@@ -363,6 +384,7 @@ export default function DiscoverTab({ session, watchlistCodes, onSelect, onAdd }
363384
const [panelTab, setPanelTab] = useState<DiscoverPanelTab>('results')
364385
const [dbReady, setDbReady] = useState<boolean | null>(null)
365386
const [llmReady, setLlmReady] = useState<boolean | null>(null)
387+
const [marketRegime, setMarketRegime] = useState<MarketRegimeData | null>(null)
366388

367389
const { strategies: customStrategies } = useCustomDiscoverStrategies()
368390

@@ -409,6 +431,10 @@ export default function DiscoverTab({ session, watchlistCodes, onSelect, onAdd }
409431
if (cancelled) return
410432
setLlmReady(Boolean(h.llm_configured))
411433
}).catch(() => setLlmReady(false))
434+
void research.marketRegime().then(resp => {
435+
if (cancelled || !resp.success || !resp.data) return
436+
setMarketRegime(resp.data)
437+
}).catch(() => {})
412438
return () => { cancelled = true }
413439
}, [])
414440

@@ -427,6 +453,35 @@ export default function DiscoverTab({ session, watchlistCodes, onSelect, onAdd }
427453
const barValue = running && progressPct <= 0 ? 0.03 : Math.min(1, progressPct / 100)
428454
const historyJobs = history.filter(h => h.status !== 'running' || h.id === job?.id)
429455

456+
const regimeIndicators = useMemo(() => {
457+
const ind = marketRegime?.indicators
458+
if (!ind) return null
459+
const parts: string[] = []
460+
if (ind.marks_cycle) parts.push(`周期 ${ind.marks_cycle}`)
461+
if (ind.valuation_anchor) parts.push(`估值 ${ind.valuation_anchor}`)
462+
if (ind.sentiment_score != null) parts.push(`情绪 ${ind.sentiment_score}`)
463+
if (ind.advance_pct != null) parts.push(`上涨 ${ind.advance_pct.toFixed(0)}%`)
464+
if (ind.index_pe != null) parts.push(`沪深300 PE ${ind.index_pe.toFixed(1)}`)
465+
if (ind.northbound_net_yi != null) {
466+
const sign = ind.northbound_net_yi >= 0 ? '+' : ''
467+
parts.push(`北向 ${sign}${ind.northbound_net_yi.toFixed(1)}亿`)
468+
}
469+
return parts.length ? parts.join(' · ') : null
470+
}, [marketRegime])
471+
472+
const regimeHint = useMemo(() => {
473+
if (!marketRegime) return null
474+
if (!selectedId) return marketRegime.detail
475+
const suggested = marketRegime.suggested_strategy_ids
476+
if (!suggested.length) return marketRegime.detail
477+
if (suggested.includes(selectedId)) {
478+
return `当前市况与所选策略较契合。${marketRegime.detail}`
479+
}
480+
const first = builtinList.find(st => st.id === suggested[0])
481+
if (!first) return marketRegime.detail
482+
return `${marketRegime.detail} 可优先考虑「${first.name}」。`
483+
}, [marketRegime, selectedId, builtinList])
484+
430485
const handleLoadHistory = (histJob: DiscoverJobSnapshot) => {
431486
loadHistoryJob(histJob)
432487
setPanelTab('results')
@@ -472,6 +527,19 @@ export default function DiscoverTab({ session, watchlistCodes, onSelect, onAdd }
472527
{llmReady === false ? ' · 需配置大模型' : ''}
473528
</Text>
474529
</div>
530+
{marketRegime && (
531+
<div className={s.regimeBanner}>
532+
<Text className={s.regimeHeadline} block>
533+
{marketRegime.headline}
534+
</Text>
535+
{regimeIndicators && (
536+
<Text className={s.regimeIndicators} block>
537+
{regimeIndicators}
538+
</Text>
539+
)}
540+
{regimeHint && <Text block>{regimeHint}</Text>}
541+
</div>
542+
)}
475543
</div>
476544

477545
<div className={s.tabBar}>

client-ui/src/market/StockDecisionCard.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,11 @@ export default function StockDecisionCard({
502502

503503
<Section title="核心结论">
504504
<div className={s.metricGrid3}>
505-
<Metric label="综合评分" value={vm.scoreSummary} tone={scoreTone} />
505+
<Metric
506+
label={vm.gbmLabel ? 'G=B+M 评分' : '综合评分'}
507+
value={vm.gbmLabel ? `${vm.scoreSummary}${vm.gbmLabel})` : vm.scoreSummary}
508+
tone={scoreTone}
509+
/>
506510
<Metric label="多空倾向" value={vm.strategySummary ?? '—'} tone={strategyTone} />
507511
<Metric label="研报观点" value={vm.institutionLabel ?? '—'} tone={institutionTone} />
508512
<Metric label="估值高低" value={vm.valuationLabel ?? '—'} tone={valuationToneVal} />
@@ -511,10 +515,11 @@ export default function StockDecisionCard({
511515
</div>
512516
<div className={s.guideBox}>
513517
<Text className={s.guideText}>
514-
<span className={s.guideHighlight}>综合评分:</span>
518+
<span className={s.guideHighlight}>{vm.gbmLabel ? 'G=B+M 评分:' : '综合评分:'}</span>
515519
<span className={mergeClasses(s.guideValue, toneClass(s, scoreTone))}>
516520
{vm.scoreSummary}
517521
</span>
522+
{vm.gbmLabel ? ` · ${vm.gbmLabel}` : null}
518523
{' — '}
519524
{vm.scoreExplanation ?? SCORE_GRADE_LEGEND}
520525
</Text>

client-ui/src/market/decisionCardLogic.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface DecisionCardViewModel {
4747
grade: string | null
4848
scoreSummary: string
4949
scoreExplanation: string | null
50+
scorecardLabel: string | null
51+
gbmLabel: string | null
5052
strategySummary: string | null
5153
institutionLabel: string | null
5254
valuationLabel: string | null
@@ -123,6 +125,13 @@ export function buildDecisionCardViewModel(input: {
123125
}): DecisionCardViewModel {
124126
const factors = input.evalData?.factors ?? {}
125127
const totalScore = input.evalData?.total_score ?? null
128+
const scorecard = input.evalData?.scorecard ?? null
129+
const gbm = input.evalData?.gbm ?? null
130+
131+
let gbmLabel: string | null = null
132+
if (gbm) {
133+
gbmLabel = `B ${gbm.b_score.toFixed(1)} · M ${gbm.m_score.toFixed(1)}`
134+
}
126135

127136
const valuationLabel = formatValuationDisplay({
128137
factors,
@@ -164,6 +173,8 @@ export function buildDecisionCardViewModel(input: {
164173
grade: scoreGrade(totalScore),
165174
scoreSummary: formatScoreSummary(totalScore),
166175
scoreExplanation: formatScoreExplanation(totalScore),
176+
scorecardLabel: scorecard,
177+
gbmLabel,
167178
strategySummary: formatStrategyDisplay(input.strategy),
168179
institutionLabel,
169180
valuationLabel,
@@ -193,6 +204,8 @@ export function buildStockResearchContext(input: {
193204
'## 摘要',
194205
`- 现价:${vm.priceLabel}`,
195206
vm.grade ? `- 综合评分:${vm.scoreSummary}` : '- 综合评分:待评估',
207+
vm.scorecardLabel ? `- 评分模板:${vm.scorecardLabel}` : null,
208+
vm.gbmLabel ? `- G=B+M:${vm.gbmLabel}` : null,
196209
vm.scoreExplanation ? `- 评分说明:${vm.scoreExplanation}` : null,
197210
vm.strategySummary ? `- 策略倾向:${vm.strategySummary}` : null,
198211
vm.institutionLabel ? `- 机构共识:${vm.institutionLabel}` : null,

client-ui/src/market/useStockAnalysis.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect, useRef, useState } from 'react'
2-
import { research } from '../api/client'
2+
import { getConfig, research } from '../api/client'
33
import type { InstitutionRatingData, LatestEvalData, StrategySignalData } from '../types/schemas'
44
import type { ChipDistributionPoint } from '../types/market'
55
import type { WatchlistRadarItem } from '../types/schemas'
@@ -24,7 +24,7 @@ function stepPercent(steps: AnalysisStep[]): number {
2424

2525
function freshSteps(): AnalysisStep[] {
2626
return [
27-
{ id: 'eval', label: '综合评分', status: 'pending', message: null },
27+
{ id: 'eval', label: 'G=B+M 评分', status: 'pending', message: null },
2828
{ id: 'strategy', label: '多空倾向', status: 'pending', message: null },
2929
{ id: 'institution', label: '研报观点', status: 'pending', message: null },
3030
{ id: 'cyq', label: '筹码分布', status: 'pending', message: null },
@@ -37,7 +37,7 @@ function isAbort(e: unknown): boolean {
3737
}
3838

3939
const STEP_HINTS: Record<string, string> = {
40-
eval: '正在计算多维度评分…',
40+
eval: '正在计算基本面与动量评分…',
4141
strategy: '正在汇总多种策略看法,约需 10–30 秒',
4242
institution: '正在汇总券商研报观点,这一步通常最久',
4343
cyq: '正在获取筹码与成本区…',
@@ -120,9 +120,19 @@ export function useStockAnalysis(code: string | null) {
120120

121121
try {
122122
await runStep('eval', STEP_HINTS.eval, async () => {
123-
const resp = await research.latestEval(code, controller.signal)
123+
const cfg = await getConfig().catch(() => null)
124+
const scorecard = cfg?.default_scorecard || 'G=B+M'
125+
const resp = await research.latestEval(code, controller.signal, scorecard, force)
124126
if (resp.success && resp.data) payload.evalData = resp.data
125-
return { ok: resp.success, message: resp.success ? '已完成' : (resp.message ?? '未能完成') }
127+
const label = resp.data?.scorecard ?? scorecard
128+
return {
129+
ok: resp.success,
130+
message: resp.success
131+
? (resp.data?.gbm
132+
? `${label} · B ${resp.data.gbm.b_score} / M ${resp.data.gbm.m_score}`
133+
: label)
134+
: (resp.message ?? '未能完成'),
135+
}
126136
})
127137

128138
await runStep('strategy', STEP_HINTS.strategy, async () => {

client-ui/src/pages/Diagnosis.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from '@fluentui/react-components'
66
import { ArrowSyncRegular } from '@fluentui/react-icons'
77
import MetricTile from '../components/MetricTile'
8-
import { research } from '../api/client'
8+
import { getConfig, research } from '../api/client'
99
import type { StockDiagnosisData, InstitutionRatingData, StrategySignalData } from '../types/schemas'
1010

1111
const useStyles = makeStyles({
@@ -65,8 +65,10 @@ export default function Diagnosis({ globalStock, setGlobalStock }: Props) {
6565
if (!code.trim()) return
6666
setLoading(true)
6767
try {
68+
const cfg = await getConfig().catch(() => null)
69+
const scorecard = cfg?.default_scorecard || 'G=B+M'
6870
const [d, r, sg] = await Promise.all([
69-
research.diagnose(code.trim()),
71+
research.diagnose(code.trim(), scorecard),
7072
research.institutionRating(code.trim()),
7173
research.strategySignals(code.trim()).catch(() => null),
7274
])

client-ui/src/pages/SettingsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ function SettingsPageView({
351351
<SettingsTextField
352352
value={scorecard}
353353
onChange={setScorecard}
354-
placeholder="综合评估"
354+
placeholder="G=B+M"
355355
/>
356356
)}
357357
last

client-ui/src/pages/settings/DiscoverStrategiesSettingsSection.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const CATEGORY_LABEL: Record<DiscoverStrategyPublic['category'], string> = {
2828
quality: '质量',
2929
momentum: '动量',
3030
balanced: '均衡',
31+
contrarian: '逆向',
3132
}
3233

3334
type ListItem =

0 commit comments

Comments
 (0)