Skip to content

Commit 5020900

Browse files
Travisuncursoragent
andcommitted
feat: add market data sync, Tushare driver, and market desk research UX.
Integrate local market-data sync with settings UI and Tushare priority sourcing, and expand the right panel with discover screening, watchlist radar, and decision cards. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8fac3ad commit 5020900

53 files changed

Lines changed: 6858 additions & 70 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/server/src/index.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const PORT = Number(process.env.STOCK_RESEARCH_PORT ?? 8711)
1313
const HOST = process.env.STOCK_RESEARCH_HOST ?? '127.0.0.1'
1414

1515
const hub = new ResearchHub()
16+
hub.initMarketDataAutoResume()
1617
let cfg = loadConfig()
1718

1819
function syncAgentProviders() {
@@ -51,6 +52,51 @@ app.post<{ Body: { feature: string; params?: Record<string, unknown> } }>(
5152
},
5253
)
5354

55+
app.get('/api/market-data/status', async () => {
56+
const result = await hub.dispatch('market_db_status', {})
57+
return { success: result.success, data: result.data, message: result.message }
58+
})
59+
60+
app.get('/api/market-data/sync-state', async () => {
61+
const result = await hub.dispatch('market_db_sync_state', {})
62+
return { success: result.success, data: result.data, message: result.message }
63+
})
64+
65+
app.post<{ Body: { mode?: string; max_stocks?: number; jobs?: string[]; background?: boolean; force?: boolean; profile?: string } }>(
66+
'/api/market-data/sync',
67+
async (req) => {
68+
const body = req.body ?? {}
69+
const result = await hub.dispatch('market_db_sync', {
70+
mode: body.mode,
71+
max_stocks: body.max_stocks,
72+
jobs: body.jobs,
73+
background: body.background,
74+
force: body.force,
75+
profile: body.profile,
76+
})
77+
return { success: result.success, data: result.data, message: result.message, elapsed: result.elapsed }
78+
},
79+
)
80+
81+
app.get('/api/tushare/config', async () => {
82+
const r = await hub.dispatch('tushare_config', {})
83+
return { success: r.success, data: r.data, message: r.message }
84+
})
85+
86+
app.post<{ Body: { enabled?: boolean; token?: string } }>('/api/tushare/config', async (req) => {
87+
const body = req.body ?? {}
88+
const r = await hub.dispatch('tushare_config_save', {
89+
enabled: body.enabled,
90+
token: body.token,
91+
})
92+
return { success: r.success, data: r.data, message: r.message }
93+
})
94+
95+
app.post<{ Body: { token?: string } }>('/api/tushare/test', async (req) => {
96+
const r = await hub.dispatch('tushare_test', { token: req.body?.token })
97+
return { success: r.success, data: r.data, message: r.message }
98+
})
99+
54100
app.get('/api/config', async () => publicConfig(cfg))
55101

56102
app.patch<{ Body: { default_scorecard?: string; default_top_n?: number; default_model?: string } }>(

client-ui/src/api/client.ts

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,22 @@ const REQUEST_TIMEOUT = 10000 // 10s timeout for all API requests
77

88
async function fetchWithTimeout(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT): Promise<Response> {
99
const controller = new AbortController()
10-
const timer = setTimeout(() => controller.abort(), timeoutMs)
10+
let timedOut = false
11+
const timer = setTimeout(() => {
12+
timedOut = true
13+
controller.abort()
14+
}, timeoutMs)
1115
const external = init?.signal
1216
const onExternalAbort = () => controller.abort()
1317
external?.addEventListener('abort', onExternalAbort)
1418
try {
1519
const { signal: _ignored, ...rest } = init ?? {}
1620
return await fetch(path, { ...rest, signal: controller.signal })
21+
} catch (e) {
22+
if (timedOut && e instanceof Error && e.name === 'AbortError') {
23+
throw new Error('请求超时')
24+
}
25+
throw e
1726
} finally {
1827
clearTimeout(timer)
1928
external?.removeEventListener('abort', onExternalAbort)
@@ -33,13 +42,14 @@ export async function apiCall<T>(
3342
feature: string,
3443
params: Record<string, any> = {},
3544
init?: RequestInit,
45+
timeoutMs = REQUEST_TIMEOUT,
3646
): Promise<ApiResponse<T>> {
3747
const resp = await fetchWithTimeout(`${API_BASE}/research`, {
3848
method: 'POST',
3949
headers: { 'Content-Type': 'application/json' },
4050
body: JSON.stringify({ feature, params }),
4151
...init,
42-
})
52+
}, timeoutMs)
4353
if (!resp.ok) throw new Error(`API error: ${resp.status}`)
4454
return resp.json()
4555
}
@@ -56,14 +66,25 @@ export const research = {
5666
diagnose: (code: string) =>
5767
apiCall<StockDiagnosisData>('stock_diagnosis', { code }),
5868

59-
institutionRating: (code: string, groups?: string[]) =>
60-
apiCall<InstitutionRatingData>('institution_rating', { code, groups }),
69+
institutionRating: (code: string, groups?: string[], signal?: AbortSignal) =>
70+
apiCall<InstitutionRatingData>('institution_rating', { code, groups }, { signal }, 20000),
71+
72+
screen: (conditions: any[], scorecard = '综合评估', topN = 20, signal?: AbortSignal) =>
73+
apiCall<ScreeningData>('screening', { conditions, scorecard, top_n: topN }, { signal }, 120000),
6174

62-
screen: (conditions: any[], scorecard = '综合评估', topN = 20) =>
63-
apiCall<ScreeningData>('screening', { conditions, scorecard, top_n: topN }),
75+
marketDbStatus: () =>
76+
apiCall<import('../types/market').MarketDbStatusData>('market_db_status'),
77+
78+
marketDbSync: (mode: 'full' | 'incremental' | 'resume' = 'full', background = true, force = false) =>
79+
apiCall<{ started: boolean; running: boolean; mode: string }>(
80+
'market_db_sync',
81+
{ mode, background, force },
82+
undefined,
83+
background ? 15000 : 600000,
84+
),
6485

65-
strategySignals: (code: string) =>
66-
apiCall<StrategySignalData>('strategy_signal', { code }),
86+
strategySignals: (code: string, signal?: AbortSignal) =>
87+
apiCall<StrategySignalData>('strategy_signal', { code }, { signal }, 30000),
6788

6889
strategyVerify: (code: string, checkpoints = 30, forwardDays = 5) =>
6990
apiCall<StrategyVerifyData>('strategy_verify', { code, checkpoints, forward_days: forwardDays }),
@@ -83,6 +104,9 @@ export const research = {
83104
stockQuotes: (codes: string[]) =>
84105
apiCall<import('../types/market').StockQuotesData>('stock_quotes', { codes }),
85106

107+
watchlistRadar: (codes: string[], signal?: AbortSignal) =>
108+
apiCall<import('../types/schemas').WatchlistRadarData>('watchlist_radar', { codes }, { signal }, 15000),
109+
86110
stockKline: (code: string, count = 90) =>
87111
apiCall<import('../types/market').StockKlineData>('stock_kline', { code, count }),
88112

@@ -100,10 +124,12 @@ export const research = {
100124
{ signal },
101125
),
102126

103-
stockCyq: (code: string) =>
127+
stockCyq: (code: string, signal?: AbortSignal) =>
104128
apiCall<{ code: string; rows: import('../types/market').ChipDistributionPoint[]; latest: import('../types/market').ChipDistributionPoint }>(
105129
'stock_cyq',
106130
{ code },
131+
{ signal },
132+
15000,
107133
),
108134

109135
stockDetail: (code: string) =>
@@ -112,8 +138,8 @@ export const research = {
112138
backtest: (codes: string[], scorecard = '综合评估', periods = 5) =>
113139
apiCall<BacktestResultData>('backtest', { codes, scorecard, periods }),
114140

115-
latestEval: (code: string) =>
116-
apiCall<LatestEvalData>('latest_evaluation', { code }),
141+
latestEval: (code: string, signal?: AbortSignal) =>
142+
apiCall<LatestEvalData>('latest_evaluation', { code }, { signal }, 30000),
117143

118144
strategyReport: (code: string) =>
119145
apiCall<ReportTextData>('strategy_report', { code }),
@@ -134,6 +160,61 @@ export const research = {
134160
apiCall<import('../types/schemas').PortfolioSummaryData>('portfolio_summary', {}),
135161
}
136162

163+
export async function getMarketDataSyncState() {
164+
const resp = await jsonFetch<{ success: boolean; data: import('../types/market').MarketDataSyncState }>(
165+
'/market-data/sync-state',
166+
)
167+
if (!resp.data) throw new Error('无法获取同步状态')
168+
return resp.data
169+
}
170+
171+
export async function startMarketDataSync(
172+
mode: 'full' | 'incremental' | 'resume' = 'full',
173+
options: { force?: boolean; jobs?: string[]; profile?: string } = {},
174+
) {
175+
return jsonFetch<{ success: boolean; message?: string }>('/market-data/sync', {
176+
method: 'POST',
177+
headers: { 'Content-Type': 'application/json' },
178+
body: JSON.stringify({
179+
mode,
180+
background: true,
181+
force: options.force ?? false,
182+
jobs: options.jobs,
183+
profile: options.profile,
184+
}),
185+
})
186+
}
187+
188+
export interface TusharePublicConfig {
189+
enabled: boolean
190+
token: string
191+
token_configured: boolean
192+
token_preview: string
193+
config_path: string
194+
}
195+
196+
export async function getTushareConfig() {
197+
const resp = await jsonFetch<{ success: boolean; data: TusharePublicConfig }>('/tushare/config')
198+
if (!resp.data) throw new Error('无法读取 Tushare 配置')
199+
return resp.data
200+
}
201+
202+
export async function saveTushareConfig(payload: { enabled: boolean; token?: string }) {
203+
return jsonFetch<{ success: boolean; data: TusharePublicConfig; message?: string }>('/tushare/config', {
204+
method: 'POST',
205+
headers: { 'Content-Type': 'application/json' },
206+
body: JSON.stringify(payload),
207+
})
208+
}
209+
210+
export async function testTushareConfig(token?: string) {
211+
return jsonFetch<{ success: boolean; data: { ok: boolean; message: string }; message?: string }>('/tushare/test', {
212+
method: 'POST',
213+
headers: { 'Content-Type': 'application/json' },
214+
body: JSON.stringify(token ? { token } : {}),
215+
})
216+
}
217+
137218
export async function writerTypes() {
138219
const resp = await fetchWithTimeout(`${API_BASE}/writer/types`)
139220
if (!resp.ok) throw new Error('writer types failed')

client-ui/src/chat/ChatApp.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import SessionSidebar from './SessionSidebar'
44
import ChatView from './ChatView'
55
import SettingsPage from '../pages/SettingsPage'
66
import RightPanel from './RightPanel'
7+
import type { StockDiscussPayload } from '../market/StockDecisionCard'
78
import WorkspaceSplitDivider from './WorkspaceSplitDivider'
89
import {
910
listSessions, createSession, getSession, deleteSession, forkSession, clearSessionContext,
@@ -427,6 +428,35 @@ export default function ChatApp() {
427428
}
428429
}
429430

431+
const handleStockDiscuss = useCallback(async (payload: StockDiscussPayload) => {
432+
if (!activeId) {
433+
setError('请先新建或选择一个对话')
434+
return
435+
}
436+
try {
437+
const at = new Date().toISOString()
438+
const nextRef: SessionSelectionContextRef = {
439+
kind: 'selection',
440+
selectedText: payload.contextText,
441+
sourceMessageIndex: 0,
442+
sourceRole: 'user',
443+
anchorAt: at,
444+
preview: `${payload.topic === 'buy' ? '研讨买入' : '研讨卖出'} · ${payload.name}`,
445+
turns: [{
446+
role: 'user',
447+
content: payload.contextText,
448+
at,
449+
}],
450+
}
451+
const data = await setSessionContext(activeId, nextRef)
452+
setContextRef(data.contextRef ?? nextRef)
453+
setInput(payload.prompt)
454+
setError('')
455+
} catch (e) {
456+
setError(e instanceof Error ? e.message : '设置研讨上下文失败')
457+
}
458+
}, [activeId])
459+
430460
const handleEphemeralAsk = useCallback(async (
431461
message: string,
432462
selection: MessageSelection,
@@ -621,6 +651,7 @@ export default function ChatApp() {
621651
chromeToolbarReserve={chromeToolbarReserve}
622652
onToggleRightPanel={handleToggleRightPanel}
623653
onToggleChatColumn={canToggleChatColumn ? handleToggleChatColumn : undefined}
654+
onDiscussInChat={handleStockDiscuss}
624655
/>
625656
)}
626657
</div>

client-ui/src/chat/RightPanel.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
WORKSPACE_RIGHT_PANEL_DEFAULT_WIDTH,
77
} from '../desktop/constants'
88
import RightMarketPanel from '../market/RightMarketPanel'
9+
import type { StockDiscussPayload } from '../market/StockDecisionCard'
910

1011
const useStyles = makeStyles({
1112
panelShell: {
@@ -51,6 +52,7 @@ interface Props {
5152
chromeToolbarReserve?: number
5253
onToggleRightPanel?: () => void
5354
onToggleChatColumn?: () => void
55+
onDiscussInChat?: (payload: StockDiscussPayload) => void
5456
}
5557

5658
export default function RightPanel({
@@ -63,6 +65,7 @@ export default function RightPanel({
6365
chromeToolbarReserve = 0,
6466
onToggleRightPanel,
6567
onToggleChatColumn,
68+
onDiscussInChat,
6669
}: Props) {
6770
const s = useStyles()
6871

@@ -95,6 +98,7 @@ export default function RightPanel({
9598
chromeToolbarReserve={chromeToolbarReserve}
9699
onToggleRightPanel={visible ? onToggleRightPanel : undefined}
97100
onToggleChatColumn={visible ? onToggleChatColumn : undefined}
101+
onDiscussInChat={visible ? onDiscussInChat : undefined}
98102
/>
99103
</aside>
100104
</div>

0 commit comments

Comments
 (0)