fix: repair backtest empty result handling (#1779)

This commit is contained in:
zhulinsen
2026-06-24 21:52:07 +08:00
committed by GitHub
parent 0344901c6b
commit b308e44827
19 changed files with 2324 additions and 52 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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):

View File

@@ -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 });
});
});

View File

@@ -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',

View File

@@ -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));
}

View File

@@ -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 (

View File

@@ -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 () => {

View File

@@ -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 ============

View File

@@ -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

View File

@@ -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

View File

@@ -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/>.

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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",

View File

@@ -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")

View File

@@ -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"