mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: repair backtest empty result handling (#1779)
This commit is contained in:
26
.github/PULL_REQUEST_TEMPLATE.md
vendored
26
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -22,6 +22,20 @@ For English contributors: please fill in English. All fields marked (EN) accept
|
||||
请列出本 PR 修改的模块和文件范围。
|
||||
*(EN) List the modules and files changed in this PR.*
|
||||
|
||||
> 注意:请按实际 `git diff` 全量列出文件范围(建议注明文件总数),避免遗漏文档/后端/API/前端文件导致描述不一致。
|
||||
|
||||
> 建议先执行并粘贴以下命令输出,避免与实际 diff 不一致:
|
||||
|
||||
```bash
|
||||
BASE_REF=$(git merge-base HEAD origin/main)
|
||||
git diff --stat "$BASE_REF"..HEAD
|
||||
git diff --name-only "$BASE_REF"..HEAD
|
||||
```
|
||||
|
||||
- 文件总数 / 变更行数(建议粘贴 `git diff --stat "$BASE_REF"..HEAD`):
|
||||
- 文件清单(按实际 diff 全量,逐项列出):
|
||||
- 文档更新文件(`docs/*`):
|
||||
|
||||
## Issue Link
|
||||
|
||||
必须填写以下之一 / Fill in one of:
|
||||
@@ -47,8 +61,16 @@ python -m pytest -m "not network"
|
||||
若本 PR 修改报告格式、报告渲染效果或 Web UI 界面,请在此处附受影响报告 / 页面截图;涉及前后差异时,优先附前后对比。Issue / PR 过程截图、审查截图、一次性验收截图和临时可视证据请放在 PR 描述、PR 评论、GitHub 附件、Actions artifact 或外部可访问链接中,不要作为仓库文件合入。
|
||||
*(EN) If this PR changes report formatting, report rendering, or Web UI, attach screenshots of the affected report/page here; before/after screenshots are preferred when relevant. Issue/PR process screenshots, review screenshots, one-off acceptance screenshots, and temporary visual evidence should be linked from the PR body/comments, GitHub attachments, Actions artifacts, or external accessible evidence; do not commit them as repository files.)*
|
||||
|
||||
- 截图链接 / Screenshot links:
|
||||
- 不适用原因 / Reason if not applicable:
|
||||
> 如截图无法获取,请在“原因”中明确写明替代证据(如 Playwright/e2e 产物路径、审查链接)及其可追溯命令,不得留空。
|
||||
>
|
||||
> 若本 PR 修改 Web UI,建议至少补一条可复现路径,例如:
|
||||
>
|
||||
> - Playwright 截图产物:`apps/dsa-web/e2e/smoke.spec.ts`(`npx playwright test apps/dsa-web/e2e/smoke.spec.ts --grep "backtest page renders filter controls after login"`)
|
||||
> - 审查证据链接:可直接使用 Actions 产物、GitHub 评论附件或外部可访问链接。
|
||||
|
||||
- 截图链接 / Screenshot links(必填):
|
||||
- 前后对比 / Before & After(如有):
|
||||
- 不适用原因 / Reason if not applicable(必填):
|
||||
|
||||
## Compatibility And Risk
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ def _validate_analysis_date_range(
|
||||
response_model=BacktestRunResponse,
|
||||
responses={
|
||||
200: {"description": "回测执行完成"},
|
||||
400: {"description": "请求参数错误", "model": ErrorResponse},
|
||||
500: {"description": "服务器错误", "model": ErrorResponse},
|
||||
},
|
||||
summary="触发回测",
|
||||
@@ -57,15 +58,25 @@ def run_backtest(
|
||||
db_manager: DatabaseManager = Depends(get_database_manager),
|
||||
) -> BacktestRunResponse:
|
||||
try:
|
||||
_validate_analysis_date_range(request.analysis_date_from, request.analysis_date_to)
|
||||
service = BacktestService(db_manager)
|
||||
stats = service.run_backtest(
|
||||
code=request.code,
|
||||
force=request.force,
|
||||
eval_window_days=request.eval_window_days,
|
||||
min_age_days=request.min_age_days,
|
||||
analysis_date_from=request.analysis_date_from,
|
||||
analysis_date_to=request.analysis_date_to,
|
||||
limit=request.limit,
|
||||
)
|
||||
return BacktestRunResponse(**stats)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invalid_params", "message": str(exc)},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"回测执行失败: {exc}", exc_info=True)
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -16,6 +17,8 @@ class BacktestRunRequest(BaseModel):
|
||||
force: bool = Field(False, description="强制重新计算")
|
||||
eval_window_days: Optional[int] = Field(None, ge=1, le=120, description="评估窗口(交易日数)")
|
||||
min_age_days: Optional[int] = Field(None, ge=0, le=365, description="分析记录最小天龄(0=不限)")
|
||||
analysis_date_from: Optional[date] = Field(None, description="分析日期起始(含)")
|
||||
analysis_date_to: Optional[date] = Field(None, description="分析日期结束(含)")
|
||||
limit: int = Field(200, ge=1, le=2000, description="最多处理的分析记录数")
|
||||
|
||||
|
||||
@@ -25,6 +28,12 @@ class BacktestRunResponse(BaseModel):
|
||||
completed: int = Field(..., description="完成回测数")
|
||||
insufficient: int = Field(..., description="数据不足数")
|
||||
errors: int = Field(..., description="错误数")
|
||||
applied_eval_window_days: Optional[int] = Field(
|
||||
...,
|
||||
description="实际生效的评估窗口(交易日数)",
|
||||
)
|
||||
message: Optional[str] = Field(None, description="空结果或降级时的诊断说明")
|
||||
diagnostics: Dict[str, Any] = Field(default_factory=dict, description="回测筛选与诊断信息")
|
||||
|
||||
|
||||
class BacktestResultItem(BaseModel):
|
||||
|
||||
@@ -199,7 +199,7 @@ test.describe('web smoke', () => {
|
||||
await captureSmokeScreenshot(page, testInfo, 'smoke-settings-page-en');
|
||||
});
|
||||
|
||||
test('backtest page renders filter controls after login', async ({ page }) => {
|
||||
test('backtest page renders filter controls after login', async ({ page }, testInfo) => {
|
||||
await login(page);
|
||||
|
||||
// Navigate to backtest page by clicking the link
|
||||
@@ -212,5 +212,7 @@ test.describe('web smoke', () => {
|
||||
await expect(filterInput).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('button', { name: '筛选' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '运行回测' })).toBeVisible();
|
||||
|
||||
await captureSmokeScreenshot(page, testInfo, 'smoke-backtest-page-zh', { fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,11 +17,13 @@ export const backtestApi = {
|
||||
*/
|
||||
run: async (params: BacktestRunRequest = {}): Promise<BacktestRunResponse> => {
|
||||
const requestData: Record<string, unknown> = {};
|
||||
if (params.code) requestData.code = params.code;
|
||||
if (params.code?.trim()) requestData.code = params.code.trim();
|
||||
if (params.force) requestData.force = params.force;
|
||||
if (params.evalWindowDays) requestData.eval_window_days = params.evalWindowDays;
|
||||
if (params.evalWindowDays != null) requestData.eval_window_days = params.evalWindowDays;
|
||||
if (params.minAgeDays != null) requestData.min_age_days = params.minAgeDays;
|
||||
if (params.limit) requestData.limit = params.limit;
|
||||
if (params.analysisDateFrom) requestData.analysis_date_from = params.analysisDateFrom;
|
||||
if (params.analysisDateTo) requestData.analysis_date_to = params.analysisDateTo;
|
||||
if (params.limit != null) requestData.limit = params.limit;
|
||||
|
||||
const response = await apiClient.post<Record<string, unknown>>(
|
||||
'/api/v1/backtest/run',
|
||||
|
||||
@@ -2649,6 +2649,7 @@ textarea {
|
||||
.backtest-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
@@ -2660,6 +2661,11 @@ textarea {
|
||||
.backtest-summary .label {
|
||||
color: var(--text-secondary-text);
|
||||
}
|
||||
.backtest-summary .label.message {
|
||||
flex-basis: 100%;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
}
|
||||
.backtest-summary .value {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
@@ -49,6 +49,26 @@ function phaseLabel(row: BacktestResultItem, language: UiLanguage): string {
|
||||
return (row.marketPhase ? BACKTEST_PHASE_LABELS[language][row.marketPhase] : undefined) || row.marketPhase || '--';
|
||||
}
|
||||
|
||||
function normalizeBacktestCode(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.toUpperCase();
|
||||
}
|
||||
|
||||
function parseEvalWindowDays(value: string): number | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function labelFromMap(value: string | null | undefined, labels: Record<string, string>): string {
|
||||
if (!value) return '--';
|
||||
return labels[value] ?? value;
|
||||
@@ -214,6 +234,9 @@ const RunSummary: React.FC<{ data: BacktestRunResponse; language: UiLanguage }>
|
||||
{data.errors > 0 && (
|
||||
<span className="label">{text.errors} <span className="value danger">{data.errors}</span></span>
|
||||
)}
|
||||
{data.message && (
|
||||
<span className="label message">{data.message}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -254,7 +277,7 @@ const BacktestPage: React.FC = () => {
|
||||
const [overallPerf, setOverallPerf] = useState<PerformanceMetrics | null>(null);
|
||||
const [stockPerf, setStockPerf] = useState<PerformanceMetrics | null>(null);
|
||||
const [isLoadingPerf, setIsLoadingPerf] = useState(false);
|
||||
const effectiveWindowDays = evalDays ? parseInt(evalDays, 10) : overallPerf?.evalWindowDays;
|
||||
const effectiveWindowDays = parseEvalWindowDays(evalDays) ?? overallPerf?.evalWindowDays;
|
||||
const isNextDayValidation = effectiveWindowDays === 1;
|
||||
const showNextDayActualColumns = isNextDayValidation;
|
||||
|
||||
@@ -350,18 +373,30 @@ const BacktestPage: React.FC = () => {
|
||||
setRunResult(null);
|
||||
setRunError(null);
|
||||
try {
|
||||
const code = codeFilter.trim() || undefined;
|
||||
const evalWindowDays = evalDays ? parseInt(evalDays, 10) : undefined;
|
||||
const code = normalizeBacktestCode(codeFilter);
|
||||
const requestedEvalWindowDays = parseEvalWindowDays(evalDays);
|
||||
const dateFrom = analysisDateFrom || undefined;
|
||||
const dateTo = analysisDateTo || undefined;
|
||||
const response = await backtestApi.run({
|
||||
code,
|
||||
force: forceRerun || undefined,
|
||||
minAgeDays: forceRerun ? 0 : undefined,
|
||||
evalWindowDays,
|
||||
evalWindowDays: requestedEvalWindowDays,
|
||||
analysisDateFrom: dateFrom,
|
||||
analysisDateTo: dateTo,
|
||||
});
|
||||
setRunResult(response);
|
||||
const effectiveEvalWindowDays =
|
||||
response.appliedEvalWindowDays
|
||||
?? requestedEvalWindowDays
|
||||
?? parseEvalWindowDays(evalDays)
|
||||
?? overallPerf?.evalWindowDays;
|
||||
if (effectiveEvalWindowDays != null) {
|
||||
setEvalDays(String(effectiveEvalWindowDays));
|
||||
}
|
||||
// Refresh data with same eval_window_days
|
||||
fetchResults(1, codeFilter.trim() || undefined, evalWindowDays, analysisDateFrom, analysisDateTo, phaseFilter);
|
||||
fetchPerformance(codeFilter.trim() || undefined, evalWindowDays, analysisDateFrom, analysisDateTo, phaseFilter);
|
||||
fetchResults(1, code, effectiveEvalWindowDays, dateFrom, dateTo, phaseFilter);
|
||||
fetchPerformance(code, effectiveEvalWindowDays, dateFrom, dateTo, phaseFilter);
|
||||
} catch (err) {
|
||||
setRunError(getParsedApiError(err));
|
||||
} finally {
|
||||
@@ -371,8 +406,8 @@ const BacktestPage: React.FC = () => {
|
||||
|
||||
// Filter by code
|
||||
const handleFilter = () => {
|
||||
const code = codeFilter.trim() || undefined;
|
||||
const windowDays = evalDays ? parseInt(evalDays, 10) : undefined;
|
||||
const code = normalizeBacktestCode(codeFilter);
|
||||
const windowDays = parseEvalWindowDays(evalDays);
|
||||
setCurrentPage(1);
|
||||
fetchResults(1, code, windowDays, analysisDateFrom, analysisDateTo, phaseFilter);
|
||||
fetchPerformance(code, windowDays, analysisDateFrom, analysisDateTo, phaseFilter);
|
||||
@@ -385,7 +420,7 @@ const BacktestPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleShowNextDay = () => {
|
||||
const code = codeFilter.trim() || undefined;
|
||||
const code = normalizeBacktestCode(codeFilter);
|
||||
setEvalDays('1');
|
||||
setCurrentPage(1);
|
||||
fetchResults(1, code, 1, analysisDateFrom, analysisDateTo, phaseFilter);
|
||||
@@ -395,8 +430,8 @@ const BacktestPage: React.FC = () => {
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(totalResults / pageSize);
|
||||
const handlePageChange = (page: number) => {
|
||||
const windowDays = evalDays ? parseInt(evalDays, 10) : undefined;
|
||||
fetchResults(page, codeFilter.trim() || undefined, windowDays, analysisDateFrom, analysisDateTo, phaseFilter);
|
||||
const windowDays = parseEvalWindowDays(evalDays);
|
||||
fetchResults(page, normalizeBacktestCode(codeFilter), windowDays, analysisDateFrom, analysisDateTo, phaseFilter);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -258,44 +258,123 @@ describe('BacktestPage', () => {
|
||||
});
|
||||
|
||||
it('runs a backtest and refreshes results using the shared filter values', async () => {
|
||||
mockRun.mockResolvedValueOnce({
|
||||
processed: 0,
|
||||
saved: 0,
|
||||
completed: 0,
|
||||
insufficient: 0,
|
||||
errors: 0,
|
||||
message: '未找到符合条件的历史分析记录',
|
||||
diagnostics: { emptyReason: 'no_matching_analysis' },
|
||||
});
|
||||
render(<BacktestPage />);
|
||||
|
||||
const filterInput = await screen.findByPlaceholderText('按股票代码筛选(留空表示全部)');
|
||||
const windowInput = screen.getByPlaceholderText('10');
|
||||
const fromInput = screen.getByLabelText('分析开始日期');
|
||||
const toInput = screen.getByLabelText('分析结束日期');
|
||||
|
||||
fireEvent.change(filterInput, { target: { value: 'tsla' } });
|
||||
fireEvent.change(filterInput, { target: { value: '600519.SH' } });
|
||||
fireEvent.change(windowInput, { target: { value: '15' } });
|
||||
fireEvent.change(fromInput, { target: { value: '2026-03-01' } });
|
||||
fireEvent.change(toInput, { target: { value: '2026-03-31' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '运行回测' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRun).toHaveBeenCalledWith({
|
||||
code: 'TSLA',
|
||||
code: '600519.SH',
|
||||
force: undefined,
|
||||
minAgeDays: undefined,
|
||||
evalWindowDays: 15,
|
||||
analysisDateFrom: '2026-03-01',
|
||||
analysisDateTo: '2026-03-31',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetResults).toHaveBeenLastCalledWith({
|
||||
code: 'TSLA',
|
||||
code: '600519.SH',
|
||||
evalWindowDays: 15,
|
||||
analysisDateFrom: undefined,
|
||||
analysisDateTo: undefined,
|
||||
analysisDateFrom: '2026-03-01',
|
||||
analysisDateTo: '2026-03-31',
|
||||
analysisPhase: undefined,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
expect(mockGetStockPerformance).toHaveBeenLastCalledWith('TSLA', {
|
||||
expect(mockGetStockPerformance).toHaveBeenLastCalledWith('600519.SH', {
|
||||
evalWindowDays: 15,
|
||||
analysisDateFrom: undefined,
|
||||
analysisDateTo: undefined,
|
||||
analysisDateFrom: '2026-03-01',
|
||||
analysisDateTo: '2026-03-31',
|
||||
analysisPhase: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText('已处理:')).toBeInTheDocument();
|
||||
expect(screen.getByText('已保存:')).toBeInTheDocument();
|
||||
expect(screen.getByText('未找到符合条件的历史分析记录')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses backend-applied eval window when run input is empty', async () => {
|
||||
mockRun.mockResolvedValueOnce({
|
||||
processed: 0,
|
||||
saved: 0,
|
||||
completed: 0,
|
||||
insufficient: 0,
|
||||
errors: 0,
|
||||
appliedEvalWindowDays: 10,
|
||||
message: '未找到符合条件的历史分析记录',
|
||||
diagnostics: { emptyReason: 'no_matching_analysis' },
|
||||
});
|
||||
render(<BacktestPage />);
|
||||
|
||||
const filterInput = await screen.findByPlaceholderText('按股票代码筛选(留空表示全部)');
|
||||
const windowInput = screen.getByPlaceholderText('10');
|
||||
const fromInput = screen.getByLabelText('分析开始日期');
|
||||
const toInput = screen.getByLabelText('分析结束日期');
|
||||
|
||||
fireEvent.change(filterInput, { target: { value: '600519.SH' } });
|
||||
fireEvent.change(windowInput, { target: { value: '' } });
|
||||
fireEvent.change(fromInput, { target: { value: '2026-03-01' } });
|
||||
fireEvent.change(toInput, { target: { value: '2026-03-31' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '运行回测' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRun).toHaveBeenCalledWith({
|
||||
code: '600519.SH',
|
||||
force: undefined,
|
||||
minAgeDays: undefined,
|
||||
evalWindowDays: undefined,
|
||||
analysisDateFrom: '2026-03-01',
|
||||
analysisDateTo: '2026-03-31',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(windowInput).toHaveValue(10);
|
||||
expect(mockGetResults).toHaveBeenLastCalledWith({
|
||||
code: '600519.SH',
|
||||
evalWindowDays: 10,
|
||||
analysisDateFrom: '2026-03-01',
|
||||
analysisDateTo: '2026-03-31',
|
||||
analysisPhase: undefined,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
expect(mockGetStockPerformance).toHaveBeenLastCalledWith('600519.SH', {
|
||||
evalWindowDays: 10,
|
||||
analysisDateFrom: '2026-03-01',
|
||||
analysisDateTo: '2026-03-31',
|
||||
analysisPhase: undefined,
|
||||
});
|
||||
expect(mockGetOverallPerformance).toHaveBeenLastCalledWith({
|
||||
evalWindowDays: 10,
|
||||
analysisDateFrom: '2026-03-01',
|
||||
analysisDateTo: '2026-03-31',
|
||||
analysisPhase: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText('未找到符合条件的历史分析记录')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to next-day validation with the 1D shortcut', async () => {
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface BacktestRunRequest {
|
||||
force?: boolean;
|
||||
evalWindowDays?: number;
|
||||
minAgeDays?: number;
|
||||
analysisDateFrom?: string;
|
||||
analysisDateTo?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -23,6 +25,9 @@ export interface BacktestRunResponse {
|
||||
completed: number;
|
||||
insufficient: number;
|
||||
errors: number;
|
||||
appliedEvalWindowDays?: number;
|
||||
message?: string | null;
|
||||
diagnostics?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============ Result Item ============
|
||||
|
||||
@@ -38,6 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [修复] `main.py --serve-only` 在低配主机上因 uvicorn 在 3.0s 启动自检窗口内才惰性 import 应用(litellm + 整个 app 树)导致超时退出、容器反复重启;改为在计时前于调用线程预先 import app 对象再交给 uvicorn,启动自检不再误杀慢启动。
|
||||
- [修复] Docker 镜像预置 efinance 缓存目录(efinance/data)属主给非 root 运行用户 dsa,修复 A 股 efinance 数据源因写 search-cache.json 触发 PermissionError 而每次抓取失败降级的问题。
|
||||
- [修复] Docker 部署中 Web 设置页保存自定义 Webhook 模板时自动转义 `$content_json` 等应用占位符,并在运行时还原,避免 Compose 重新部署将其展开为空。
|
||||
- [修复] 修复 Web 回测运行未传分析日期范围、股票代码未归一化导致后端成功返回但结果为空的问题,并为空候选和行情不足返回诊断信息。
|
||||
- [文档] 补充回测请求链路说明:`analysis_date_from/analysis_date_to` 与 `code` 的输入边界、归一化与筛选顺序,以及历史行情不足或候选集为空时回测返回成功响应,在 `message` 与 `diagnostics`(含 `empty_reason`)中提供可诊断信息,并同步更新 `docs/full-guide.md`、`docs/full-guide_EN.md` 示例。
|
||||
- [修复] 回测代码匹配新增非法市场后缀/长度兜底:如 `600519.HK`、`600519.SZ`、`SH000001` 不再静默回落到其它有效代码,并在日期筛选重跑时对齐旧回测结果的分析日期,避免历史快照日期命中但结果列表仍为空。
|
||||
|
||||
## [3.23.0] - 2026-06-20
|
||||
|
||||
|
||||
@@ -1523,6 +1523,9 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。
|
||||
> 说明(Issue #1520):列表中的模型名展示字段仅来源于历史快照中的 `model_used`,仅用于历史回溯展示,不影响运行时模型模型路由(`litellm_model`、`llm_model_list`)、Provider、Base URL 与配置迁移/清理语义。回退方式为回退本次提交,现网历史查询/抽屉/接口链路兼容性保持不变。
|
||||
> 说明:历史详情、同步分析响应和 completed 任务状态会在 `report.details.analysis_context_pack_overview` 返回低敏输入数据块 overview;其中同步分析响应依赖本次已持久化的 `analysis_history.context_snapshot`,`SAVE_CONTEXT_SNAPSHOT=false` 时新记录不保证返回 overview。`details.context_snapshot` 会剥离该顶层字段,不返回完整 `AnalysisContextPack` 或 Prompt summary。
|
||||
> 说明:`POST /api/v1/agent/chat` 与 `POST /api/v1/agent/chat/stream` 会把前端传入的 `context.stock_code` 作为问股当前标的基线,但服务端会先重新判定 stock scope。前端从历史报告进入问股后会持续发送 active stock context;切回或重载已有会话时,会根据已加载的历史用户消息恢复基础 `{stock_code, stock_name: null}`。服务端会在每轮消息中重新判定 `maintain` / `switch` / `compare`:未明确切换时,带 `stock_code` 的股票工具调用只能访问当前标的;显式切换会清理旧标的历史摘要和预取数据;含比较/对比/vs/差异/相比等明确比较意图或多个非当前明确股票代码的问题允许本轮明确出现的多个代码,但不改写当前标的。若模型误把 TTM、PE、MACD、KDJ 等金融缩写、移动均线语境下的 `MA` 指标词,或 SH/SZ/BJ/HK/SS 等交易所片段当成股票代码调用工具,后端会返回不可重试的 `stock_scope_violation` 工具结果,而不会执行对应股票工具。工具名只解析注册表中的精确名称;任何 provider namespace 或 suffix 都不会路由到已有工具。
|
||||
> 说明:`POST /api/v1/backtest/run` 新增 `analysis_date_from` / `analysis_date_to`(`YYYY-MM-DD`)请求参数用于按历史分析日期筛选候选;若 `analysis_date_from > analysis_date_to`,接口返回 400 `invalid_params`。
|
||||
> 说明:回测执行成功但无新入库结果时,`BacktestRunResponse.message` 返回可读诊断说明,`diagnostics` 返回排查上下文(示例:`empty_reason`、`analysis_date_from`、`analysis_date_to`、`eval_window_days`、`min_age_days`、`limit`)。
|
||||
> 说明:`GET /api/v1/backtest/results`、`GET /api/v1/backtest/performance`、`GET /api/v1/backtest/performance/{code}` 同步支持 `analysis_date_from`、`analysis_date_to`;不传时保持历史行为。
|
||||
|
||||
> 兼容性审计证据:
|
||||
> - 官方来源:LiteLLM OpenAI-compatible provider 文档 <https://docs.litellm.ai/docs/providers/openai_compatible>;OpenAI Chat API 文档 <https://platform.openai.com/docs/api-reference/chat/create>;DeepSeek API 文档 <https://api-docs.deepseek.com/>。
|
||||
@@ -1572,6 +1575,16 @@ curl -X POST http://127.0.0.1:8000/api/v1/backtest/run \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"code": "600519", "force": false}'
|
||||
|
||||
# 触发回测(按分析日期范围)
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/backtest/run \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"analysis_date_from": "2026-05-01", "analysis_date_to": "2026-05-31", "limit": 100}'
|
||||
|
||||
# 触发回测(指定股票 + 日期范围 + 强制重跑)
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/backtest/run \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"code": "600519", "force": true, "analysis_date_from": "2026-05-01", "analysis_date_to": "2026-05-31"}'
|
||||
|
||||
# 查询整体回测表现
|
||||
curl http://127.0.0.1:8000/api/v1/backtest/performance
|
||||
|
||||
|
||||
@@ -1353,6 +1353,9 @@ For this feature, the product behavior is:
|
||||
> Issue #1520 compatibility note: The `model`/`model_used` returned here is read-only historical snapshot metadata from each record, used only for trend drawer/history display. It does not alter runtime model/model-provider/base URL resolution, config migration, or cleanup semantics in the analysis path. Rollback is by reverting this commit; history query, API response shapes, and UI drawer consumption remain compatible.
|
||||
> Note: history detail, sync analysis responses, and completed task status responses expose a low-sensitivity input data-block overview at `report.details.analysis_context_pack_overview`; sync analysis responses depend on the just-persisted `analysis_history.context_snapshot`, so new records do not guarantee the overview when `SAVE_CONTEXT_SNAPSHOT=false`. `details.context_snapshot` strips that top-level field and does not return the full `AnalysisContextPack` or prompt summary.
|
||||
> Note: `POST /api/v1/agent/chat` and `POST /api/v1/agent/chat/stream` use the frontend-provided `context.stock_code` as the active Ask Stock baseline only after server-side stock-scope resolution. Each turn is classified as `maintain`, `switch`, or `compare`: unchanged follow-ups can call stock-scoped tools only for the current stock; explicit switches clear stale stock summaries and prefetched context; comparison prompts such as compare/vs/difference allow the explicitly mentioned codes for that turn without rewriting the current stock. If a model attempts to call a stock tool with financial abbreviations such as TTM, PE, MACD, KDJ, contextual indicator tokens such as `MA` in moving-average prompts, or exchange fragments such as SH/SZ/BJ/HK/SS, the backend returns a non-retriable `stock_scope_violation` tool result instead of executing that stock tool. Tool names are resolved only by exact registry name; provider namespaces or suffixes are not routed to existing tools.
|
||||
> Note: `POST /api/v1/backtest/run` adds `analysis_date_from` / `analysis_date_to` (`YYYY-MM-DD`) to filter candidates by analysis date range. When `analysis_date_from > analysis_date_to`, it returns 400 `invalid_params`.
|
||||
> Note: When backtest runs successfully but yields no new persisted rows, `BacktestRunResponse.message` carries a readable diagnostic and `diagnostics` returns troubleshooting context (for example `empty_reason`, `analysis_date_from`, `analysis_date_to`, `eval_window_days`, `min_age_days`, `limit`).
|
||||
> Note: `GET /api/v1/backtest/results`, `GET /api/v1/backtest/performance`, and `GET /api/v1/backtest/performance/{code}` all support `analysis_date_from` and `analysis_date_to` consistently. Omitting them keeps historical default behavior.
|
||||
|
||||
> Compatibility audit evidence:
|
||||
> - Official references: LiteLLM OpenAI-compatible provider documentation <https://docs.litellm.ai/docs/providers/openai_compatible>, OpenAI Chat API <https://platform.openai.com/docs/api-reference/chat/create>, and DeepSeek API docs <https://api-docs.deepseek.com/>.
|
||||
|
||||
@@ -13,6 +13,10 @@ from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, delete, desc, func, or_, select
|
||||
|
||||
from data_provider.base import is_bse_code
|
||||
from src.core.backtest_engine import OVERALL_SENTINEL_CODE
|
||||
from src.services.stock_code_utils import normalize_code as normalize_backtest_code
|
||||
|
||||
from src.storage import BacktestResult, BacktestSummary, DatabaseManager, AnalysisHistory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,6 +45,7 @@ class BacktestRepository:
|
||||
code: Optional[str],
|
||||
min_age_days: int,
|
||||
limit: int,
|
||||
offset: int = 0,
|
||||
eval_window_days: int,
|
||||
engine_version: str,
|
||||
force: bool,
|
||||
@@ -51,7 +56,7 @@ class BacktestRepository:
|
||||
with self.db.get_session() as session:
|
||||
conditions = [AnalysisHistory.created_at <= cutoff_dt]
|
||||
if code:
|
||||
conditions.append(AnalysisHistory.code == code)
|
||||
conditions.extend(self._build_code_conditions(AnalysisHistory.code, code))
|
||||
conditions.append(
|
||||
or_(
|
||||
AnalysisHistory.report_type.is_(None),
|
||||
@@ -70,10 +75,68 @@ class BacktestRepository:
|
||||
)
|
||||
query = query.where(AnalysisHistory.id.not_in(existing_ids))
|
||||
|
||||
query = query.order_by(desc(AnalysisHistory.created_at)).limit(limit)
|
||||
query = query.order_by(desc(AnalysisHistory.created_at)).offset(offset).limit(limit)
|
||||
rows = session.execute(query).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
def align_existing_result_dates(
|
||||
self,
|
||||
*,
|
||||
code: Optional[str],
|
||||
min_age_days: int,
|
||||
eval_window_days: int,
|
||||
engine_version: str,
|
||||
analysis_date_from: Optional[date],
|
||||
analysis_date_to: Optional[date],
|
||||
) -> int:
|
||||
"""Align legacy result dates to their linked analysis snapshot date.
|
||||
|
||||
Older backtest rows may have stored the trading/start daily date instead
|
||||
of the historical analysis snapshot date. When a date-filtered run skips
|
||||
already-existing rows, those legacy rows would remain invisible to the
|
||||
same date-filtered result query. Updating the stored result date keeps
|
||||
rerun and query semantics aligned without inserting duplicate rows.
|
||||
"""
|
||||
cutoff_dt = datetime.now() - timedelta(days=min_age_days)
|
||||
|
||||
with self.db.get_session() as session:
|
||||
conditions = [
|
||||
AnalysisHistory.created_at <= cutoff_dt,
|
||||
BacktestResult.eval_window_days == eval_window_days,
|
||||
BacktestResult.engine_version == engine_version,
|
||||
or_(
|
||||
AnalysisHistory.report_type.is_(None),
|
||||
AnalysisHistory.report_type != MARKET_REVIEW_REPORT_TYPE,
|
||||
),
|
||||
]
|
||||
if code:
|
||||
conditions.extend(self._build_code_conditions(AnalysisHistory.code, code))
|
||||
|
||||
rows = session.execute(
|
||||
select(BacktestResult, AnalysisHistory)
|
||||
.join(AnalysisHistory, AnalysisHistory.id == BacktestResult.analysis_history_id)
|
||||
.where(and_(*conditions))
|
||||
).all()
|
||||
|
||||
updated = 0
|
||||
for result, analysis in rows:
|
||||
analysis_date = self.parse_analysis_date_from_snapshot(analysis.context_snapshot)
|
||||
if analysis_date is None and analysis.created_at is not None:
|
||||
analysis_date = analysis.created_at.date()
|
||||
if analysis_date is None:
|
||||
continue
|
||||
if analysis_date_from is not None and analysis_date < analysis_date_from:
|
||||
continue
|
||||
if analysis_date_to is not None and analysis_date > analysis_date_to:
|
||||
continue
|
||||
if result.analysis_date != analysis_date:
|
||||
result.analysis_date = analysis_date
|
||||
updated += 1
|
||||
|
||||
if updated:
|
||||
session.commit()
|
||||
return updated
|
||||
|
||||
def save_result(self, result: BacktestResult) -> None:
|
||||
with self.db.get_session() as session:
|
||||
session.add(result)
|
||||
@@ -345,9 +408,10 @@ class BacktestRepository:
|
||||
with self.db.get_session() as session:
|
||||
conditions = [
|
||||
BacktestSummary.scope == scope,
|
||||
BacktestSummary.code == code,
|
||||
BacktestSummary.engine_version == engine_version,
|
||||
]
|
||||
if code:
|
||||
conditions.extend(self._build_code_conditions(BacktestSummary.code, code))
|
||||
if eval_window_days is not None:
|
||||
conditions.append(BacktestSummary.eval_window_days == eval_window_days)
|
||||
|
||||
@@ -424,7 +488,7 @@ class BacktestRepository:
|
||||
) -> List[object]:
|
||||
conditions = []
|
||||
if code:
|
||||
conditions.append(BacktestResult.code == code)
|
||||
conditions.extend(BacktestRepository._build_code_conditions(BacktestResult.code, code))
|
||||
if eval_window_days is not None:
|
||||
conditions.append(BacktestResult.eval_window_days == eval_window_days)
|
||||
if engine_version:
|
||||
@@ -437,3 +501,148 @@ class BacktestRepository:
|
||||
cutoff = datetime.now() - timedelta(days=int(days))
|
||||
conditions.append(BacktestResult.evaluated_at >= cutoff)
|
||||
return conditions
|
||||
|
||||
@staticmethod
|
||||
def _build_code_conditions(column, code: str) -> List[object]:
|
||||
if not code:
|
||||
return []
|
||||
|
||||
raw_code = str(code).strip()
|
||||
if raw_code.lower() == OVERALL_SENTINEL_CODE.lower():
|
||||
raw_code = OVERALL_SENTINEL_CODE
|
||||
else:
|
||||
raw_code = raw_code.upper()
|
||||
|
||||
normalized_code = normalize_backtest_code(raw_code)
|
||||
|
||||
candidates = [raw_code]
|
||||
if normalized_code and normalized_code != raw_code:
|
||||
candidates.append(normalized_code)
|
||||
candidates.extend(BacktestRepository._build_market_code_variants(raw_code, normalized_code))
|
||||
|
||||
if len(candidates) == 1:
|
||||
return [column == candidates[0]]
|
||||
|
||||
unique = list(dict.fromkeys(candidates))
|
||||
return [or_(*[column == candidate for candidate in unique])]
|
||||
|
||||
@staticmethod
|
||||
def _build_hk_market_variants(hk_digits: str) -> List[str]:
|
||||
"""Build normalized HK variants for padded/unpadded code shapes."""
|
||||
if not hk_digits.isdigit() or not hk_digits:
|
||||
return []
|
||||
|
||||
padded = hk_digits.zfill(5)
|
||||
unpadded = padded.lstrip("0") or "0"
|
||||
|
||||
variants: List[str] = [
|
||||
f"HK{padded}",
|
||||
f"{padded}.HK",
|
||||
padded,
|
||||
f"HK{unpadded}",
|
||||
f"{unpadded}.HK",
|
||||
f"HK.{padded}",
|
||||
]
|
||||
if unpadded == padded:
|
||||
variants.pop(3)
|
||||
variants.pop(3)
|
||||
|
||||
# Keep legacy no-leading-zero bare form for 1-3 digit inputs.
|
||||
if len(unpadded) <= 3 and unpadded != padded:
|
||||
variants.append(unpadded)
|
||||
variants.append(f"HK.{unpadded}")
|
||||
|
||||
return variants
|
||||
|
||||
@staticmethod
|
||||
def _build_market_code_variants(raw_code: str, normalized_code: str) -> List[str]:
|
||||
"""Return additional market-formatted variants for safe stock-code matching."""
|
||||
variants: List[str] = []
|
||||
if not raw_code:
|
||||
return variants
|
||||
|
||||
raw_code_upper = raw_code.upper()
|
||||
normalized_upper = normalized_code.upper() if normalized_code else ""
|
||||
|
||||
def _add_us_variants(code: str) -> None:
|
||||
if not code:
|
||||
return
|
||||
if code.endswith(".US"):
|
||||
bare = code[:-3]
|
||||
if bare.isalpha() and 1 <= len(bare) <= 5:
|
||||
variants.append(bare)
|
||||
return
|
||||
if "." not in code and code.isalpha() and 1 <= len(code) <= 5:
|
||||
variants.append(f"{code}.US")
|
||||
|
||||
_add_us_variants(raw_code_upper)
|
||||
if normalized_upper != raw_code_upper:
|
||||
_add_us_variants(normalized_upper)
|
||||
|
||||
def _explicit_exchange() -> Optional[str]:
|
||||
if raw_code_upper.startswith(("SH", "SS")) or raw_code_upper.endswith((".SH", ".SS")):
|
||||
return "SH"
|
||||
if raw_code_upper.startswith("SZ") or raw_code_upper.endswith(".SZ"):
|
||||
return "SZ"
|
||||
if raw_code_upper.startswith("BJ") or raw_code_upper.endswith(".BJ"):
|
||||
return "BJ"
|
||||
return None
|
||||
|
||||
def _exchange_by_code(base: str) -> str:
|
||||
if is_bse_code(base):
|
||||
return "BJ"
|
||||
if base.startswith(("5", "6")):
|
||||
return "SH"
|
||||
return "SZ"
|
||||
|
||||
if normalized_upper.isdigit() and len(normalized_upper) == 6:
|
||||
explicit_exchange = _explicit_exchange()
|
||||
if explicit_exchange is not None and explicit_exchange != _exchange_by_code(normalized_upper):
|
||||
return []
|
||||
|
||||
if raw_code_upper.startswith(("SH", "SS")) or raw_code_upper.endswith(".SH") or raw_code_upper.endswith(".SS"):
|
||||
exchange = "SH"
|
||||
elif raw_code_upper.startswith("SZ") or raw_code_upper.endswith(".SZ"):
|
||||
exchange = "SZ"
|
||||
elif raw_code_upper.startswith("BJ") or raw_code_upper.endswith(".BJ") or is_bse_code(normalized_upper):
|
||||
exchange = "BJ"
|
||||
elif normalized_upper.startswith(("5", "6", "9")):
|
||||
exchange = "SH"
|
||||
else:
|
||||
exchange = "SZ"
|
||||
|
||||
variants.append(f"{exchange}{normalized_upper}")
|
||||
variants.append(f"{normalized_upper}.{exchange}")
|
||||
variants.append(f"{exchange}.{normalized_upper}")
|
||||
if exchange == "SH":
|
||||
variants.append(f"SS{normalized_upper}")
|
||||
variants.append(f"{normalized_upper}.SS")
|
||||
variants.append(f"SS.{normalized_upper}")
|
||||
|
||||
if (
|
||||
normalized_upper.startswith("HK")
|
||||
and len(normalized_upper) > 2
|
||||
and normalized_upper[2:].isdigit()
|
||||
and len(normalized_upper[2:]) <= 5
|
||||
):
|
||||
variants.extend(BacktestRepository._build_hk_market_variants(normalized_upper[2:]))
|
||||
|
||||
if (
|
||||
raw_code_upper.startswith("HK.")
|
||||
and raw_code_upper[3:].isdigit()
|
||||
and len(raw_code_upper[3:]) <= 5
|
||||
):
|
||||
variants.extend(BacktestRepository._build_hk_market_variants(raw_code_upper[3:]))
|
||||
|
||||
if (
|
||||
raw_code_upper.endswith(".HK")
|
||||
and raw_code_upper[:-3].isdigit()
|
||||
and 1 <= len(raw_code_upper[:-3]) <= 5
|
||||
):
|
||||
hk_digits = raw_code_upper.rsplit(".", 1)[0]
|
||||
variants.extend(BacktestRepository._build_hk_market_variants(hk_digits))
|
||||
|
||||
if raw_code_upper.isdigit() and len(raw_code_upper) in (4, 5):
|
||||
variants.extend(BacktestRepository._build_hk_market_variants(raw_code_upper))
|
||||
|
||||
return variants
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
from data_provider.base import canonical_stock_code, normalize_stock_code
|
||||
from src.config import get_config
|
||||
from src.core.backtest_engine import OVERALL_SENTINEL_CODE, BacktestEngine, EvaluationConfig
|
||||
from src.market_phase_summary import extract_market_phase_summary, normalize_analysis_phase_bucket
|
||||
@@ -18,6 +19,7 @@ from src.repositories.stock_repo import StockRepository
|
||||
from src.schemas.decision_action import build_action_fields
|
||||
from src.storage import BacktestResult, BacktestSummary, DatabaseManager
|
||||
from src.utils.data_processing import parse_json_field
|
||||
from src.services.stock_code_utils import normalize_code as normalize_backtest_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,10 +41,18 @@ class BacktestService:
|
||||
force: bool = False,
|
||||
eval_window_days: Optional[int] = None,
|
||||
min_age_days: Optional[int] = None,
|
||||
analysis_date_from: Optional[date] = None,
|
||||
analysis_date_to: Optional[date] = None,
|
||||
limit: int = 200,
|
||||
) -> Dict[str, Any]:
|
||||
config = get_config()
|
||||
|
||||
if analysis_date_from and analysis_date_to and analysis_date_from > analysis_date_to:
|
||||
raise ValueError("analysis_date_from cannot be after analysis_date_to")
|
||||
|
||||
query_code = self._normalize_code(code)
|
||||
diagnostic_code = self._normalize_code_for_display(code)
|
||||
|
||||
if eval_window_days is None:
|
||||
eval_window_days = getattr(config, "backtest_eval_window_days", 10)
|
||||
if min_age_days is None:
|
||||
@@ -57,13 +67,16 @@ class BacktestService:
|
||||
engine_version=str(engine_version),
|
||||
)
|
||||
|
||||
candidates = self.repo.get_candidates(
|
||||
code=code,
|
||||
limit_int = int(limit)
|
||||
candidates = self._get_run_candidates(
|
||||
code=query_code,
|
||||
min_age_days=int(min_age_days),
|
||||
limit=int(limit),
|
||||
limit=limit_int,
|
||||
eval_window_days=int(eval_window_days),
|
||||
engine_version=str(engine_version),
|
||||
force=force,
|
||||
analysis_date_from=analysis_date_from,
|
||||
analysis_date_to=analysis_date_to,
|
||||
)
|
||||
|
||||
processed = 0
|
||||
@@ -76,7 +89,9 @@ class BacktestService:
|
||||
|
||||
for analysis in candidates:
|
||||
processed += 1
|
||||
touched_codes.add(analysis.code)
|
||||
normalized_code = self._normalize_summary_code(analysis.code)
|
||||
if normalized_code:
|
||||
touched_codes.add(normalized_code)
|
||||
|
||||
try:
|
||||
analysis_date = self._resolve_analysis_date(analysis)
|
||||
@@ -94,11 +109,23 @@ class BacktestService:
|
||||
)
|
||||
)
|
||||
continue
|
||||
start_daily = self.stock_repo.get_start_daily(code=analysis.code, analysis_date=analysis_date)
|
||||
daily_code_candidates = self._build_daily_code_candidates(analysis.code)
|
||||
start_daily = self._get_start_daily_for_candidates(
|
||||
code_candidates=daily_code_candidates,
|
||||
analysis_date=analysis_date,
|
||||
)
|
||||
|
||||
if start_daily is None or start_daily.close is None:
|
||||
self._try_fill_daily_data(code=analysis.code, analysis_date=analysis_date, eval_window_days=eval_window_days)
|
||||
start_daily = self.stock_repo.get_start_daily(code=analysis.code, analysis_date=analysis_date)
|
||||
refill_code = daily_code_candidates[0] if daily_code_candidates else analysis.code
|
||||
self._try_fill_daily_data(
|
||||
code=refill_code,
|
||||
analysis_date=analysis_date,
|
||||
eval_window_days=eval_window_days,
|
||||
)
|
||||
start_daily = self._get_start_daily_for_candidates(
|
||||
code_candidates=daily_code_candidates,
|
||||
analysis_date=analysis_date,
|
||||
)
|
||||
|
||||
if start_daily is None or start_daily.close is None:
|
||||
insufficient += 1
|
||||
@@ -116,19 +143,34 @@ class BacktestService:
|
||||
)
|
||||
continue
|
||||
|
||||
forward_bars = self.stock_repo.get_forward_bars(
|
||||
code=analysis.code,
|
||||
matched_daily_code = start_daily.code or (
|
||||
daily_code_candidates[0] if daily_code_candidates else analysis.code
|
||||
)
|
||||
forward_bars = self._get_forward_bars_by_candidates(
|
||||
code_candidates=daily_code_candidates,
|
||||
analysis_date=start_daily.date,
|
||||
eval_window_days=int(eval_window_days),
|
||||
preferred_code=matched_daily_code,
|
||||
)
|
||||
|
||||
if len(forward_bars) < int(eval_window_days):
|
||||
self._try_fill_daily_data(code=analysis.code, analysis_date=start_daily.date, eval_window_days=eval_window_days)
|
||||
forward_bars = self.stock_repo.get_forward_bars(
|
||||
code=analysis.code,
|
||||
analysis_date=start_daily.date,
|
||||
eval_window_days=int(eval_window_days),
|
||||
)
|
||||
for fill_code in self._ordered_candidate_codes(
|
||||
code_candidates=daily_code_candidates,
|
||||
preferred_code=matched_daily_code,
|
||||
):
|
||||
self._try_fill_daily_data(
|
||||
code=fill_code,
|
||||
analysis_date=start_daily.date,
|
||||
eval_window_days=eval_window_days,
|
||||
)
|
||||
forward_bars = self._get_forward_bars_by_candidates(
|
||||
code_candidates=daily_code_candidates,
|
||||
analysis_date=start_daily.date,
|
||||
eval_window_days=int(eval_window_days),
|
||||
preferred_code=matched_daily_code,
|
||||
)
|
||||
if len(forward_bars) >= int(eval_window_days):
|
||||
break
|
||||
|
||||
evaluation = BacktestEngine.evaluate_single(
|
||||
operation_advice=analysis.operation_advice,
|
||||
@@ -152,7 +194,7 @@ class BacktestService:
|
||||
BacktestResult(
|
||||
analysis_history_id=analysis.id,
|
||||
code=analysis.code,
|
||||
analysis_date=evaluation.get("analysis_date"),
|
||||
analysis_date=analysis_date,
|
||||
eval_window_days=int(evaluation.get("eval_window_days") or eval_window_days),
|
||||
engine_version=str(evaluation.get("engine_version") or engine_version),
|
||||
eval_status=str(evaluation.get("eval_status") or "error"),
|
||||
@@ -208,14 +250,333 @@ class BacktestService:
|
||||
engine_version=str(engine_version),
|
||||
)
|
||||
|
||||
has_matching_analysis = False
|
||||
aligned_existing_result_dates = 0
|
||||
has_analysis_date_filter = analysis_date_from is not None or analysis_date_to is not None
|
||||
if not force and has_analysis_date_filter:
|
||||
aligned_existing_result_dates = self.repo.align_existing_result_dates(
|
||||
code=query_code,
|
||||
min_age_days=int(min_age_days),
|
||||
eval_window_days=int(eval_window_days),
|
||||
engine_version=str(engine_version),
|
||||
analysis_date_from=analysis_date_from,
|
||||
analysis_date_to=analysis_date_to,
|
||||
)
|
||||
if not force and processed == 0:
|
||||
has_matching_analysis = self._has_matching_analysis_for_run(
|
||||
code=query_code,
|
||||
min_age_days=int(min_age_days),
|
||||
eval_window_days=int(eval_window_days),
|
||||
engine_version=str(engine_version),
|
||||
analysis_date_from=analysis_date_from,
|
||||
analysis_date_to=analysis_date_to,
|
||||
)
|
||||
|
||||
diagnostics = self._build_run_diagnostics(
|
||||
code=diagnostic_code,
|
||||
eval_window_days=int(eval_window_days),
|
||||
min_age_days=int(min_age_days),
|
||||
limit=limit_int,
|
||||
analysis_date_from=analysis_date_from,
|
||||
analysis_date_to=analysis_date_to,
|
||||
processed=processed,
|
||||
saved=saved,
|
||||
completed=completed,
|
||||
insufficient=insufficient,
|
||||
errors=errors,
|
||||
has_matching_analysis=has_matching_analysis,
|
||||
aligned_existing_result_dates=aligned_existing_result_dates,
|
||||
)
|
||||
|
||||
return {
|
||||
"processed": processed,
|
||||
"saved": saved,
|
||||
"completed": completed,
|
||||
"insufficient": insufficient,
|
||||
"errors": errors,
|
||||
"applied_eval_window_days": int(eval_window_days),
|
||||
"message": diagnostics.get("message"),
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
def _get_run_candidates(
|
||||
self,
|
||||
*,
|
||||
code: Optional[str],
|
||||
min_age_days: int,
|
||||
limit: int,
|
||||
eval_window_days: int,
|
||||
engine_version: str,
|
||||
force: bool,
|
||||
analysis_date_from: Optional[date],
|
||||
analysis_date_to: Optional[date],
|
||||
) -> List[Any]:
|
||||
if limit <= 0:
|
||||
return []
|
||||
|
||||
if analysis_date_from is None and analysis_date_to is None:
|
||||
return self.repo.get_candidates(
|
||||
code=code,
|
||||
min_age_days=min_age_days,
|
||||
limit=limit,
|
||||
eval_window_days=eval_window_days,
|
||||
engine_version=engine_version,
|
||||
force=force,
|
||||
)
|
||||
|
||||
matched: List[Any] = []
|
||||
offset = 0
|
||||
page_size = min(max(limit, 200), 1000)
|
||||
|
||||
while len(matched) < limit:
|
||||
batch = self.repo.get_candidates(
|
||||
code=code,
|
||||
min_age_days=min_age_days,
|
||||
limit=page_size,
|
||||
offset=offset,
|
||||
eval_window_days=eval_window_days,
|
||||
engine_version=engine_version,
|
||||
force=force,
|
||||
)
|
||||
if not batch:
|
||||
break
|
||||
|
||||
matched.extend(
|
||||
self._filter_candidates_by_analysis_date(
|
||||
batch,
|
||||
analysis_date_from=analysis_date_from,
|
||||
analysis_date_to=analysis_date_to,
|
||||
)
|
||||
)
|
||||
offset += len(batch)
|
||||
if len(batch) < page_size:
|
||||
break
|
||||
|
||||
return matched[:limit]
|
||||
|
||||
def _has_matching_analysis_for_run(
|
||||
self,
|
||||
*,
|
||||
code: Optional[str],
|
||||
min_age_days: int,
|
||||
eval_window_days: int,
|
||||
engine_version: str,
|
||||
analysis_date_from: Optional[date],
|
||||
analysis_date_to: Optional[date],
|
||||
) -> bool:
|
||||
"""Check if historical analysis rows match the same run filters, ignoring backtest history."""
|
||||
if analysis_date_from is None and analysis_date_to is None:
|
||||
return bool(
|
||||
self.repo.get_candidates(
|
||||
code=code,
|
||||
min_age_days=min_age_days,
|
||||
limit=1,
|
||||
eval_window_days=eval_window_days,
|
||||
engine_version=engine_version,
|
||||
force=True,
|
||||
)
|
||||
)
|
||||
|
||||
return len(
|
||||
self._get_run_candidates(
|
||||
code=code,
|
||||
min_age_days=min_age_days,
|
||||
limit=1,
|
||||
eval_window_days=eval_window_days,
|
||||
engine_version=engine_version,
|
||||
force=True,
|
||||
analysis_date_from=analysis_date_from,
|
||||
analysis_date_to=analysis_date_to,
|
||||
)
|
||||
) > 0
|
||||
|
||||
def _filter_candidates_by_analysis_date(
|
||||
self,
|
||||
candidates: List[Any],
|
||||
*,
|
||||
analysis_date_from: Optional[date],
|
||||
analysis_date_to: Optional[date],
|
||||
) -> List[Any]:
|
||||
if analysis_date_from is None and analysis_date_to is None:
|
||||
return candidates
|
||||
|
||||
filtered: List[Any] = []
|
||||
for analysis in candidates:
|
||||
analysis_date = self._resolve_analysis_date(analysis)
|
||||
if analysis_date is None:
|
||||
continue
|
||||
if analysis_date_from is not None and analysis_date < analysis_date_from:
|
||||
continue
|
||||
if analysis_date_to is not None and analysis_date > analysis_date_to:
|
||||
continue
|
||||
filtered.append(analysis)
|
||||
return filtered
|
||||
|
||||
def _get_start_daily_for_candidates(self, *, code_candidates: List[str], analysis_date: date):
|
||||
best_daily = None
|
||||
best_rank = len(code_candidates)
|
||||
for rank, candidate in enumerate(code_candidates):
|
||||
daily = self.stock_repo.get_start_daily(code=candidate, analysis_date=analysis_date)
|
||||
if daily is None:
|
||||
continue
|
||||
if best_daily is None or daily.date > best_daily.date or (
|
||||
daily.date == best_daily.date and rank < best_rank
|
||||
):
|
||||
best_daily = daily
|
||||
best_rank = rank
|
||||
return best_daily
|
||||
|
||||
@staticmethod
|
||||
def _build_daily_code_candidates(code: Optional[str]) -> List[str]:
|
||||
if not code:
|
||||
return []
|
||||
|
||||
raw_code = str(code).strip()
|
||||
if not raw_code:
|
||||
return []
|
||||
|
||||
raw_code = raw_code.upper()
|
||||
normalized_code = normalize_stock_code(raw_code)
|
||||
backtest_normalized_code = normalize_backtest_code(raw_code)
|
||||
candidates = [raw_code]
|
||||
for candidate in (normalized_code, backtest_normalized_code):
|
||||
if candidate and candidate != raw_code:
|
||||
candidates.append(candidate)
|
||||
for candidate in list(candidates):
|
||||
candidates.extend(BacktestRepository._build_market_code_variants(raw_code, candidate))
|
||||
return list(dict.fromkeys(candidate for candidate in candidates if candidate))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_code(code: Optional[str]) -> Optional[str]:
|
||||
if not code:
|
||||
return None
|
||||
|
||||
normalized = normalize_backtest_code(str(code).strip())
|
||||
if normalized is None:
|
||||
raise ValueError(f"非法股票代码格式: {code}")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_summary_code(code: Optional[str]) -> Optional[str]:
|
||||
if not code:
|
||||
return None
|
||||
raw_code = str(code).strip()
|
||||
normalized = normalize_stock_code(raw_code)
|
||||
backtest_normalized = normalize_backtest_code(raw_code)
|
||||
if raw_code.upper().startswith("SS") and backtest_normalized and backtest_normalized != normalized:
|
||||
normalized = backtest_normalized
|
||||
return canonical_stock_code(normalized or raw_code)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_code_for_display(code: Optional[str]) -> Optional[str]:
|
||||
if not code:
|
||||
return None
|
||||
|
||||
normalized = normalize_backtest_code(str(code).strip())
|
||||
if normalized is None:
|
||||
raise ValueError(f"非法股票代码格式: {code}")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _ordered_candidate_codes(
|
||||
*,
|
||||
code_candidates: List[str],
|
||||
preferred_code: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
ordered = list(dict.fromkeys(code_candidates))
|
||||
if not ordered:
|
||||
return []
|
||||
|
||||
if not preferred_code:
|
||||
return ordered
|
||||
|
||||
normalized_preferred = preferred_code.strip()
|
||||
if normalized_preferred and normalized_preferred in ordered:
|
||||
return [normalized_preferred] + [code for code in ordered if code != normalized_preferred]
|
||||
return ordered
|
||||
|
||||
def _get_forward_bars_by_candidates(
|
||||
self,
|
||||
*,
|
||||
code_candidates: List[str],
|
||||
analysis_date: date,
|
||||
eval_window_days: int,
|
||||
preferred_code: Optional[str] = None,
|
||||
) -> List[Any]:
|
||||
ordered_codes = BacktestService._ordered_candidate_codes(
|
||||
code_candidates=code_candidates,
|
||||
preferred_code=preferred_code,
|
||||
)
|
||||
|
||||
if not ordered_codes:
|
||||
return []
|
||||
|
||||
best_bars: List[Any] = []
|
||||
for code in ordered_codes:
|
||||
if not code:
|
||||
continue
|
||||
|
||||
bars = self.stock_repo.get_forward_bars(
|
||||
code=code,
|
||||
analysis_date=analysis_date,
|
||||
eval_window_days=eval_window_days,
|
||||
)
|
||||
if len(bars) >= eval_window_days:
|
||||
return bars
|
||||
if len(bars) > len(best_bars):
|
||||
best_bars = bars
|
||||
return best_bars
|
||||
|
||||
@staticmethod
|
||||
def _build_run_diagnostics(
|
||||
*,
|
||||
code: Optional[str],
|
||||
eval_window_days: int,
|
||||
min_age_days: int,
|
||||
limit: int,
|
||||
analysis_date_from: Optional[date],
|
||||
analysis_date_to: Optional[date],
|
||||
processed: int,
|
||||
saved: int,
|
||||
completed: int,
|
||||
insufficient: int,
|
||||
errors: int,
|
||||
has_matching_analysis: bool = False,
|
||||
aligned_existing_result_dates: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
diagnostics: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"eval_window_days": eval_window_days,
|
||||
"min_age_days": min_age_days,
|
||||
"limit": limit,
|
||||
"analysis_date_from": analysis_date_from.isoformat() if analysis_date_from else None,
|
||||
"analysis_date_to": analysis_date_to.isoformat() if analysis_date_to else None,
|
||||
}
|
||||
if aligned_existing_result_dates:
|
||||
diagnostics["aligned_existing_result_dates"] = aligned_existing_result_dates
|
||||
|
||||
message: Optional[str] = None
|
||||
if processed == 0:
|
||||
if has_matching_analysis:
|
||||
diagnostics["empty_reason"] = "no_new_results"
|
||||
message = "历史分析记录已存在,当前筛选条件下没有新的回测任务可执行。"
|
||||
else:
|
||||
diagnostics["empty_reason"] = "no_matching_analysis"
|
||||
message = "未找到符合条件的历史分析记录,请检查股票代码、分析日期范围、最小天龄或是否已生成历史分析。"
|
||||
elif completed == 0 and insufficient > 0 and errors == 0:
|
||||
diagnostics["empty_reason"] = "insufficient_daily_data"
|
||||
message = "已找到历史分析记录,但可用日线行情不足,无法完成回测。"
|
||||
elif completed == 0 and errors > 0:
|
||||
diagnostics["empty_reason"] = "evaluation_error"
|
||||
message = "已找到历史分析记录,但回测计算失败,请查看后端日志或放宽筛选条件。"
|
||||
elif saved == 0 and completed == 0:
|
||||
diagnostics["empty_reason"] = "no_new_results"
|
||||
message = "没有写入新的回测结果;如需覆盖已有结果,请启用强制重跑。"
|
||||
|
||||
if message:
|
||||
diagnostics["message"] = message
|
||||
return diagnostics
|
||||
|
||||
def get_recent_evaluations(
|
||||
self,
|
||||
*,
|
||||
@@ -229,6 +590,7 @@ class BacktestService:
|
||||
) -> Dict[str, Any]:
|
||||
config = get_config()
|
||||
engine_version = str(getattr(config, "backtest_engine_version", "v1"))
|
||||
code = self._normalize_code(code)
|
||||
|
||||
phase_bucket = self._normalize_phase_filter(analysis_phase)
|
||||
if eval_window_days is None and (analysis_date_from is not None or analysis_date_to is not None or phase_bucket is not None):
|
||||
@@ -289,6 +651,7 @@ class BacktestService:
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
config = get_config()
|
||||
engine_version = str(getattr(config, "backtest_engine_version", "v1"))
|
||||
code = self._normalize_code(code)
|
||||
lookup_code = OVERALL_SENTINEL_CODE if scope == "overall" else code
|
||||
|
||||
phase_bucket = self._normalize_phase_filter(analysis_phase)
|
||||
@@ -585,10 +948,15 @@ class BacktestService:
|
||||
self.repo.upsert_summary(overall_summary)
|
||||
|
||||
for code in touched_codes:
|
||||
normalized_code = self._normalize_summary_code(code)
|
||||
if not normalized_code:
|
||||
continue
|
||||
|
||||
code_conditions = BacktestRepository._build_code_conditions(BacktestResult.code, normalized_code)
|
||||
rows = session.execute(
|
||||
select(BacktestResult).where(
|
||||
and_(
|
||||
BacktestResult.code == code,
|
||||
*code_conditions,
|
||||
BacktestResult.eval_window_days == eval_window_days,
|
||||
BacktestResult.engine_version == engine_version,
|
||||
)
|
||||
@@ -597,7 +965,7 @@ class BacktestService:
|
||||
data = BacktestEngine.compute_summary(
|
||||
results=rows,
|
||||
scope="stock",
|
||||
code=code,
|
||||
code=normalized_code,
|
||||
eval_window_days=eval_window_days,
|
||||
engine_version=engine_version,
|
||||
)
|
||||
|
||||
@@ -35,17 +35,38 @@ _SUFFIX_DIGIT_LENS: dict = {
|
||||
_PRESERVE_SUFFIXES = {".T", ".KS", ".KQ"}
|
||||
|
||||
|
||||
def _infer_cn_exchange(base: str) -> str:
|
||||
"""Infer CN exchange from a 6-digit A/B-share code."""
|
||||
if not (base.isdigit() and len(base) == 6):
|
||||
return ""
|
||||
|
||||
if is_bse_code(base):
|
||||
return "BJ"
|
||||
if base.startswith(("5", "6", "9")):
|
||||
return "SH"
|
||||
return "SZ"
|
||||
|
||||
|
||||
def _valid_exchange_code(exchange: str, base: str, digit_lens: tuple[int, ...]) -> bool:
|
||||
if not (base.isdigit() and len(base) in digit_lens):
|
||||
return False
|
||||
if exchange in {"SH", "SS"}:
|
||||
return _infer_cn_exchange(base) == "SH"
|
||||
if exchange == "SZ":
|
||||
return _infer_cn_exchange(base) == "SZ"
|
||||
if exchange == "BJ":
|
||||
return is_bse_code(base)
|
||||
return _infer_cn_exchange(base) == "BJ"
|
||||
return True
|
||||
|
||||
|
||||
def _strip_exchange_prefix(text: str) -> Optional[str]:
|
||||
"""Strip leading exchange prefix (SH/SZ/HK etc.) and return the bare digits, or None."""
|
||||
for prefix, digit_lens in _PREFIX_DIGIT_LENS.items():
|
||||
dotted_prefix = f"{prefix}."
|
||||
if text.startswith(dotted_prefix):
|
||||
base = text[len(dotted_prefix):]
|
||||
if _valid_exchange_code(prefix, base, digit_lens):
|
||||
return base.zfill(5) if prefix == "HK" else base
|
||||
if text.startswith(prefix):
|
||||
base = text[len(prefix):]
|
||||
if _valid_exchange_code(prefix, base, digit_lens):
|
||||
@@ -87,7 +108,7 @@ def normalize_code(raw: str) -> Optional[str]:
|
||||
Supports:
|
||||
- Plain digit codes: 600519, 00700
|
||||
- Suffix format: 600519.SH, 600519.SZ, 920493.BJ, 00700.HK
|
||||
- Prefix format: SH600519, SZ000001, BJ920493, HK00700 (case-insensitive)
|
||||
- Prefix format: SH600519, SH.600519, SZ000001, BJ920493, HK00700 (case-insensitive)
|
||||
- US ticker symbols: AAPL, TSLA
|
||||
"""
|
||||
text = raw.strip().upper()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
@@ -13,6 +14,13 @@ from data_provider.base import BaseFetcher, DataFetchError, DataFetcherManager
|
||||
from data_provider.efinance_fetcher import EfinanceFetcher
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_daily_source_health():
|
||||
DataFetcherManager.reset_daily_source_health()
|
||||
yield
|
||||
DataFetcherManager.reset_daily_source_health()
|
||||
|
||||
|
||||
def _make_efinance_fetcher() -> EfinanceFetcher:
|
||||
with patch(
|
||||
"data_provider.efinance_fetcher.get_config",
|
||||
|
||||
@@ -395,6 +395,8 @@ def test_litellm_openai_prompt_cache_key_is_not_passed_through_without_verified_
|
||||
timeout=15,
|
||||
)
|
||||
if completed.returncode == 77:
|
||||
if "LOCAL_SOCKET_UNAVAILABLE" in completed.stdout + completed.stderr:
|
||||
pytest.skip("local loopback sockets are unavailable")
|
||||
pytest.skip("litellm is not installed")
|
||||
if completed.returncode == 78:
|
||||
pytest.skip("local socket creation is not permitted in this environment")
|
||||
|
||||
@@ -58,6 +58,11 @@ class TestIsCodeLike:
|
||||
def test_suffix_sh_rejects_5_digit_base(self):
|
||||
assert is_code_like("00700.SH") is False
|
||||
|
||||
def test_suffix_a_share_rejects_wrong_exchange(self):
|
||||
assert is_code_like("600519.SZ") is False
|
||||
assert is_code_like("000001.SH") is False
|
||||
assert is_code_like("920748.SH") is False
|
||||
|
||||
# --- Exchange prefix format (Issue #6 fix) ---
|
||||
def test_prefix_sh_upper(self):
|
||||
assert is_code_like("SH600519") is True
|
||||
@@ -86,6 +91,20 @@ class TestIsCodeLike:
|
||||
def test_prefix_hk_rejects_6_digit_base(self):
|
||||
assert is_code_like("HK600519") is False
|
||||
|
||||
def test_dotted_prefix_cn(self):
|
||||
assert is_code_like("SH.600519") is True
|
||||
assert is_code_like("SZ.000001") is True
|
||||
assert is_code_like("BJ.920493") is True
|
||||
|
||||
def test_dotted_prefix_bj_rejects_non_bse_base(self):
|
||||
assert is_code_like("BJ.600519") is False
|
||||
|
||||
def test_prefix_a_share_rejects_wrong_exchange(self):
|
||||
assert is_code_like("SH000001") is False
|
||||
assert is_code_like("SZ600519") is False
|
||||
assert is_code_like("SH920748") is False
|
||||
assert is_code_like("SH.920748") is False
|
||||
|
||||
# --- US tickers ---
|
||||
def test_us_ticker(self):
|
||||
assert is_code_like("AAPL") is True
|
||||
@@ -146,6 +165,11 @@ class TestNormalizeCode:
|
||||
def test_suffix_sh_rejects_5_digit_base(self):
|
||||
assert normalize_code("00700.SH") is None
|
||||
|
||||
def test_suffix_a_share_rejects_wrong_exchange(self):
|
||||
assert normalize_code("600519.SZ") is None
|
||||
assert normalize_code("000001.SH") is None
|
||||
assert normalize_code("920748.SH") is None
|
||||
|
||||
# --- Exchange prefix format (Issue #6 fix) ---
|
||||
def test_prefix_sh_upper(self):
|
||||
assert normalize_code("SH600519") == "600519"
|
||||
@@ -174,6 +198,28 @@ class TestNormalizeCode:
|
||||
def test_prefix_hk_rejects_6_digit_base(self):
|
||||
assert normalize_code("HK600519") is None
|
||||
|
||||
def test_dotted_prefix_cn_strips(self):
|
||||
assert normalize_code("SH.600519") == "600519"
|
||||
assert normalize_code("SZ.000001") == "000001"
|
||||
assert normalize_code("BJ.920493") == "920493"
|
||||
|
||||
def test_dotted_prefix_bj_rejects_non_bse_base(self):
|
||||
assert normalize_code("BJ.600519") is None
|
||||
|
||||
def test_prefix_a_share_rejects_wrong_exchange(self):
|
||||
assert normalize_code("SH000001") is None
|
||||
assert normalize_code("SZ600519") is None
|
||||
assert normalize_code("SH920748") is None
|
||||
assert normalize_code("SH.920748") is None
|
||||
|
||||
def test_bse_exchange_prefix_suffix_regression(self):
|
||||
assert normalize_code("920748.BJ") == "920748"
|
||||
assert normalize_code("BJ920748") == "920748"
|
||||
assert is_code_like("920748.BJ") is True
|
||||
assert is_code_like("BJ920748") is True
|
||||
assert normalize_code("bj920748") == "920748"
|
||||
assert is_code_like("bj.920748") is True
|
||||
|
||||
# --- US tickers ---
|
||||
def test_us_ticker(self):
|
||||
assert normalize_code("AAPL") == "AAPL"
|
||||
|
||||
Reference in New Issue
Block a user