Skip to content

Commit e4998ad

Browse files
committed
feat: add web market review trigger
1 parent d6e4699 commit e4998ad

10 files changed

Lines changed: 247 additions & 3 deletions

File tree

api/v1/endpoints/analysis.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
import json
2121
import logging
2222
import re
23+
import threading
2324
from datetime import datetime
2425
from typing import Optional, Union, Dict, Any
2526

26-
from fastapi import APIRouter, HTTPException, Depends, Query
27+
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks
2728
from fastapi.responses import JSONResponse, StreamingResponse
2829

2930
from api.deps import get_config_dep
@@ -38,6 +39,8 @@
3839
TaskInfo,
3940
TaskListResponse,
4041
DuplicateTaskErrorResponse,
42+
MarketReviewRequest,
43+
MarketReviewAccepted,
4144
)
4245
from api.v1.schemas.common import ErrorResponse
4346
from api.v1.schemas.history import (
@@ -69,6 +72,24 @@
6972
router = APIRouter()
7073

7174
_SUPPORTED_FREE_TEXT_RE = re.compile(r"^[A-Za-z0-9.*\-+\u3400-\u9fff\s]+$")
75+
_market_review_lock = threading.Lock()
76+
_market_review_running = False
77+
78+
79+
def _run_market_review_background(send_notification: bool) -> None:
80+
"""Run market review after the API response has been accepted."""
81+
global _market_review_running
82+
try:
83+
from src.core.market_review import run_market_review
84+
from src.notification import NotificationService
85+
86+
notifier = NotificationService()
87+
run_market_review(notifier, send_notification=send_notification)
88+
except Exception as exc:
89+
logger.error("大盘复盘后台任务失败: %s", exc, exc_info=True)
90+
finally:
91+
with _market_review_lock:
92+
_market_review_running = False
7293

7394

7495
def _invalid_analysis_input_error() -> HTTPException:
@@ -392,6 +413,47 @@ def _handle_sync_analysis(
392413
)
393414

394415

416+
# ============================================================
417+
# POST /market-review - 触发大盘复盘
418+
# ============================================================
419+
420+
@router.post(
421+
"/market-review",
422+
response_model=MarketReviewAccepted,
423+
status_code=202,
424+
responses={
425+
202: {"description": "大盘复盘任务已接受", "model": MarketReviewAccepted},
426+
409: {"description": "大盘复盘正在执行", "model": ErrorResponse},
427+
500: {"description": "提交失败", "model": ErrorResponse},
428+
},
429+
summary="触发大盘复盘",
430+
description="提交一个后台大盘复盘任务,复用 CLI 的大盘复盘链路并保存报告。",
431+
)
432+
def trigger_market_review(
433+
request: MarketReviewRequest,
434+
background_tasks: BackgroundTasks,
435+
) -> MarketReviewAccepted:
436+
"""Trigger market review from Web/API without blocking the request."""
437+
global _market_review_running
438+
with _market_review_lock:
439+
if _market_review_running:
440+
raise HTTPException(
441+
status_code=409,
442+
detail={
443+
"error": "duplicate_market_review",
444+
"message": "大盘复盘正在执行中,请稍后再试",
445+
},
446+
)
447+
_market_review_running = True
448+
449+
background_tasks.add_task(_run_market_review_background, request.send_notification)
450+
return MarketReviewAccepted(
451+
status="accepted",
452+
message="大盘复盘任务已提交,完成后会保存报告并按配置推送通知",
453+
send_notification=request.send_notification,
454+
)
455+
456+
395457
# ============================================================
396458
# GET /tasks - 获取任务列表
397459
# ============================================================

api/v1/schemas/analysis.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,23 @@ class Config:
8787
}
8888

8989

90+
class MarketReviewRequest(BaseModel):
91+
"""Market review trigger parameters."""
92+
93+
send_notification: bool = Field(
94+
True,
95+
description="是否在大盘复盘完成后发送推送通知",
96+
)
97+
98+
99+
class MarketReviewAccepted(BaseModel):
100+
"""Market review background task accepted response."""
101+
102+
status: str = Field("accepted", description="提交状态")
103+
message: str = Field(..., description="提示信息")
104+
send_notification: bool = Field(..., description="是否发送通知")
105+
106+
90107
class AnalysisResultResponse(BaseModel):
91108
"""分析结果响应模型"""
92109

apps/dsa-web/src/api/analysis.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type {
66
AnalyzeResponse,
77
AnalyzeAsyncResponse,
88
AnalysisReport,
9+
MarketReviewAccepted,
10+
MarketReviewRequest,
911
TaskStatus,
1012
TaskListResponse,
1113
} from '../types/analysis';
@@ -87,6 +89,31 @@ export const analysisApi = {
8789
return toCamelCase<AnalyzeAsyncResponse>(response.data);
8890
},
8991

92+
/**
93+
* Trigger market review in background mode.
94+
*/
95+
triggerMarketReview: async (data: MarketReviewRequest = {}): Promise<MarketReviewAccepted> => {
96+
const response = await apiClient.post<Record<string, unknown>>(
97+
'/api/v1/analysis/market-review',
98+
{
99+
send_notification: data.sendNotification ?? true,
100+
},
101+
{
102+
validateStatus: (status) => status === 202 || status === 409,
103+
}
104+
);
105+
106+
if (response.status === 409) {
107+
const detail = response.data?.detail;
108+
const message = detail && typeof detail === 'object' && 'message' in detail
109+
? String((detail as { message?: unknown }).message || '')
110+
: String(response.data?.message || '');
111+
throw new Error(message || '大盘复盘正在执行中,请稍后再试');
112+
}
113+
114+
return toCamelCase<MarketReviewAccepted>(response.data);
115+
},
116+
90117
/**
91118
* Get async task status.
92119
* @param taskId Task ID

apps/dsa-web/src/pages/HomePage.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type React from 'react';
22
import { useCallback, useEffect, useMemo, useState } from 'react';
33
import { useNavigate } from 'react-router-dom';
4+
import { getParsedApiError, type ParsedApiError } from '../api/error';
5+
import { analysisApi } from '../api/analysis';
46
import { ApiErrorAlert, ConfirmDialog, Button, EmptyState, InlineAlert } from '../components/common';
57
import { DashboardStateBlock } from '../components/dashboard';
68
import { StockAutocomplete } from '../components/StockAutocomplete';
@@ -10,10 +12,19 @@ import { TaskPanel } from '../components/tasks';
1012
import { useDashboardLifecycle, useHomeDashboardState } from '../hooks';
1113
import { getReportText, normalizeReportLanguage } from '../utils/reportLanguage';
1214

15+
type MarketReviewNotice = {
16+
variant: 'success' | 'warning' | 'danger';
17+
title: string;
18+
message: string;
19+
} | null;
20+
1321
const HomePage: React.FC = () => {
1422
const navigate = useNavigate();
1523
const [sidebarOpen, setSidebarOpen] = useState(false);
1624
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
25+
const [isSubmittingMarketReview, setIsSubmittingMarketReview] = useState(false);
26+
const [marketReviewNotice, setMarketReviewNotice] = useState<MarketReviewNotice>(null);
27+
const [marketReviewError, setMarketReviewError] = useState<ParsedApiError | null>(null);
1728

1829
const {
1930
query,
@@ -113,6 +124,25 @@ const HomePage: React.FC = () => {
113124
});
114125
}, [selectedReport, submitAnalysis]);
115126

127+
const handleTriggerMarketReview = useCallback(async () => {
128+
setIsSubmittingMarketReview(true);
129+
setMarketReviewNotice(null);
130+
setMarketReviewError(null);
131+
try {
132+
const result = await analysisApi.triggerMarketReview({ sendNotification: notify });
133+
setMarketReviewNotice({
134+
variant: 'success',
135+
title: '大盘复盘已提交',
136+
message: result.message,
137+
});
138+
} catch (err: unknown) {
139+
setMarketReviewError(getParsedApiError(err));
140+
setMarketReviewNotice(null);
141+
} finally {
142+
setIsSubmittingMarketReview(false);
143+
}
144+
}, [notify]);
145+
116146
const handleDeleteSelectedHistory = useCallback(() => {
117147
void deleteSelectedHistory();
118148
setShowDeleteConfirm(false);
@@ -193,6 +223,17 @@ const HomePage: React.FC = () => {
193223
/>
194224
推送通知
195225
</label>
226+
<Button
227+
type="button"
228+
variant="home-action-report"
229+
size="md"
230+
isLoading={isSubmittingMarketReview}
231+
loadingText="提交中"
232+
onClick={() => void handleTriggerMarketReview()}
233+
className="h-10 flex-shrink-0 whitespace-nowrap"
234+
>
235+
大盘复盘
236+
</Button>
196237
<button
197238
type="button"
198239
onClick={() => handleSubmitAnalysis()}
@@ -235,6 +276,27 @@ const HomePage: React.FC = () => {
235276
</div>
236277
) : null}
237278

279+
{marketReviewNotice ? (
280+
<div className="px-3 pb-2 md:px-4">
281+
<InlineAlert
282+
variant={marketReviewNotice.variant}
283+
title={marketReviewNotice.title}
284+
message={marketReviewNotice.message}
285+
className="rounded-xl px-3 py-2 text-xs shadow-none"
286+
/>
287+
</div>
288+
) : null}
289+
290+
{marketReviewError ? (
291+
<div className="px-3 pb-2 md:px-4">
292+
<ApiErrorAlert
293+
error={marketReviewError}
294+
className="mb-1"
295+
onDismiss={() => setMarketReviewError(null)}
296+
/>
297+
</div>
298+
) : null}
299+
238300
<div className="flex-1 flex min-h-0 overflow-hidden">
239301
<div className="hidden min-h-0 w-64 shrink-0 flex-col overflow-hidden pl-4 pb-4 md:flex lg:w-72">
240302
{sidebarContent}

apps/dsa-web/src/pages/__tests__/HomePage.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ vi.mock('../../api/analysis', async () => {
3333
...actual,
3434
analysisApi: {
3535
analyzeAsync: vi.fn(),
36+
triggerMarketReview: vi.fn(),
3637
},
3738
};
3839
});
@@ -157,6 +158,33 @@ describe('HomePage', () => {
157158
expect(screen.getByText(/ 600519 /).closest('[role="alert"]')).toBeInTheDocument();
158159
});
159160

161+
it('submits market review from the home toolbar', async () => {
162+
vi.mocked(historyApi.getList).mockResolvedValue({
163+
total: 0,
164+
page: 1,
165+
limit: 20,
166+
items: [],
167+
});
168+
vi.mocked(analysisApi.triggerMarketReview).mockResolvedValue({
169+
status: 'accepted',
170+
sendNotification: true,
171+
message: '大盘复盘任务已提交',
172+
});
173+
174+
render(
175+
<MemoryRouter>
176+
<HomePage />
177+
</MemoryRouter>,
178+
);
179+
180+
fireEvent.click(await screen.findByRole('button', { name: '大盘复盘' }));
181+
182+
await waitFor(() => {
183+
expect(analysisApi.triggerMarketReview).toHaveBeenCalledWith({ sendNotification: true });
184+
});
185+
expect(await screen.findByText('大盘复盘已提交')).toBeInTheDocument();
186+
});
187+
160188
it('navigates to chat with report context when asking a follow-up question', async () => {
161189
vi.mocked(historyApi.getList).mockResolvedValue({
162190
total: 1,

apps/dsa-web/src/types/analysis.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ export interface AnalysisRequest {
1717
notify?: boolean;
1818
}
1919

20+
export interface MarketReviewRequest {
21+
sendNotification?: boolean;
22+
}
23+
24+
export interface MarketReviewAccepted {
25+
status: 'accepted';
26+
message: string;
27+
sendNotification: boolean;
28+
}
29+
2030
// ============ Report Types ============
2131

2232
export type ReportLanguage = 'zh' | 'en';

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
2525
- [测试] 补充设置项帮助元数据、API schema、前端弹窗交互测试,并修复 Bot 名称路由与调度时间 provider 测试的离线 CI 稳定性问题。
2626
- [修复] 港股日线跳过不支持港股的内置历史数据源,避免港股代码错配到非港股市场数据。
2727
- [修复] 修正分析 API 对北交所 `BJ` 前缀与 `.BJ` 后缀股票代码的校验,保持前端自动补全与 Tushare `ts_code` 调用格式一致。
28+
- [新功能] Web 首页新增“大盘复盘”按钮,通过 `POST /api/v1/analysis/market-review` 后台触发复盘并沿用通知配置。
2829

2930
## [3.15.0] - 2026-05-05
3031

docs/full-guide.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1076,7 +1076,7 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。
10761076
### 功能特性
10771077

10781078
- 📝 **配置管理** - 查看/修改自选股列表
1079-
- 🚀 **快速分析** - 通过 API 接口触发分析
1079+
- 🚀 **快速分析** - 通过 API 接口触发个股分析;首页也提供“大盘复盘”按钮,可在 Docker/server 模式下后台触发大盘复盘
10801080
- 📊 **实时进度** - 分析任务状态实时更新,支持多任务并行;普通分析链路在进入 LLM 阶段后会优先尝试 LiteLLM 流式生成,并通过任务 SSE 回灌更细粒度的 `message/progress`
10811081
- 📈 **回测验证** - 评估历史分析准确率,查询方向胜率与模拟收益
10821082
- 🔗 **API 文档** - 访问 `/docs` 查看 Swagger UI
@@ -1086,6 +1086,7 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。
10861086
| 接口 | 方法 | 说明 |
10871087
|------|------|------|
10881088
| `/api/v1/analysis/analyze` | POST | 触发股票分析 |
1089+
| `/api/v1/analysis/market-review` | POST | 后台触发大盘复盘;请求体可传 `{"send_notification": true}` |
10891090
| `/api/v1/analysis/tasks` | GET | 查询任务列表 |
10901091
| `/api/v1/analysis/tasks/stream` | GET (SSE) | 订阅任务实时状态流 |
10911092
| `/api/v1/analysis/status/{task_id}` | GET | 查询任务状态 |

docs/full-guide_EN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,7 @@ FastAPI provides RESTful API service for configuration management and triggering
922922
### Features
923923

924924
- **Configuration Management** - View/modify watchlist
925-
- **Quick Analysis** - Trigger analysis via API
925+
- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode
926926
- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE
927927
- **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns
928928
- **API Documentation** - Visit `/docs` for Swagger UI
@@ -932,6 +932,7 @@ FastAPI provides RESTful API service for configuration management and triggering
932932
| Endpoint | Method | Description |
933933
|------|------|------|
934934
| `/api/v1/analysis/analyze` | POST | Trigger stock analysis |
935+
| `/api/v1/analysis/market-review` | POST | Trigger a background market review; request body may pass `{"send_notification": true}` |
935936
| `/api/v1/analysis/tasks` | GET | Query task list |
936937
| `/api/v1/analysis/tasks/stream` | GET (SSE) | Subscribe to realtime task updates |
937938
| `/api/v1/analysis/status/{task_id}` | GET | Query task status |

0 commit comments

Comments
 (0)