diff --git a/.github/workflows/glm-review.yml b/.github/workflows/glm-review.yml new file mode 100644 index 00000000..74d3518d --- /dev/null +++ b/.github/workflows/glm-review.yml @@ -0,0 +1,191 @@ +name: GLM PR Review + +# PR 打开 / reopen / 每次 push 新 commit(synchronize)时自动 review diff。 +# 防邮件洪水靠三道闸而非「少触发」:① concurrency + cancel-in-progress 让同一 PR +# 新 push 立刻取消上一轮;② review step 只维护**一条 sticky 总结评论** +# (gh pr comment --edit-last 原地覆盖,GitHub 对编辑评论不发邮件,仅首条发一封); +# ③ 不逐行贴 inline 评论。需对历史 commit 重审可去 Actions 页跑 workflow_dispatch。 +# 非阻塞设计:即使 GLM 出错(API 超时、token 过期等), +# 本 workflow 不会阻止 PR 合并。review 评论是锦上添花,不是门禁。 +# Auth: 智谱 GLM API key 存在 GitHub Actions Secret ZHIPUAI_API_KEY + +on: + pull_request: + types: [opened, reopened, synchronize] + workflow_dispatch: + inputs: + pr_number: + description: 手动指定 PR 编号(留空 = 用 workflow_dispatch 的当前分支) + required: false + +# 同一 PR 新 push 立刻取消上一次 review,避免重复评论 +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + review: + name: GLM-5.2 · auto-review PR + runs-on: ubuntu-latest + continue-on-error: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch PR diff + id: diff + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + gh pr diff "$PR_NUMBER" --color never > /tmp/pr_diff.txt + echo "diff_lines=$(wc -l < /tmp/pr_diff.txt)" >> $GITHUB_OUTPUT + # diff 太长截断(GLM 上下文窗口虽大,但 token 和成本也要考虑) + if [ "$(wc -c < /tmp/pr_diff.txt)" -gt 80000 ]; then + head -c 80000 /tmp/pr_diff.txt > /tmp/pr_diff_truncated.txt + echo -e "\n\n[注意:diff 过大已截断至 80KB,完整 diff 请到 PR Files 页查看]" >> /tmp/pr_diff_truncated.txt + mv /tmp/pr_diff_truncated.txt /tmp/pr_diff.txt + fi + + - name: Fetch PR metadata + id: meta + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + echo "title<> $GITHUB_OUTPUT + gh pr view "$PR_NUMBER" --json title,body,headRefName,baseRefName --jq '"\(.title) | \(.headRefName) → \(.baseRefName)"' >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Call Zhipu GLM-5.2 for review + id: review + env: + ZHIPUAI_API_KEY: ${{ secrets.ZHIPUAI_API_KEY }} + PR_TITLE: ${{ steps.meta.outputs.title }} + run: | + DIFF_CONTENT=$(cat /tmp/pr_diff.txt | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))') + + PROMPT=$(cat << 'END_PROMPT' +你是一个资深代码审查者。请审查以下 PR diff。 + +## 审查要求 +1. 先一句话总结这个 PR 的目的 +2. 逐维度评估(命中才提,不硬凑): + - **正确性**:边界条件、空值、off-by-one、错误假设、异常路径没处理 + - **设计/架构**:职责放错层、越过模块边界、重复造轮子 + - **契约/兼容**:改了公共接口/schema/API 是否破坏现有调用方 + - **错误处理**:失败是被静默吞掉还是显式处理 + - **资源/性能**:无界增长、N+1、循环无上限 + - **安全**:注入、越权、密钥泄漏 +3. **severity 阈值**:只提 ≥ medium 的问题;nit/风格跳过 +4. **误报闸**:必须能说出具体失败场景,说不出就不提 +5. **不重复 lint**:ruff/tsc/mypy 已能抓的不要再提 +6. **格式要求**:用 JSON 输出,每条带 severity( critical|major|medium ) + file + line + summary + failure_scenario + +如果没有任何 medium 以上问题,只回复一个 JSON: {"summary": "LGTM,没发现问题", "findings": []} + +其他情况回复 JSON: +{ + "summary": "一句话总结", + "findings": [ + {"severity": "major", "file": "path/to/file.py", "line": 42, "summary": "描述", "failure_scenario": "什么输入/时序下出问题"}, + ... + ] +} +END_PROMPT + + RESPONSE=$(curl -s -w "\n%{http_code}" --request POST \ + --url "https://yuanyuaicloud.cn/v1/chat/completions" \ + --header "Authorization: Bearer $ZHIPUAI_API_KEY" \ + --header "Content-Type: application/json" \ + --data "$(python3 -c " +import json, os +d = { + 'model': 'glm-5.2', + 'messages': [ + {'role': 'system', 'content': '''$(echo "$PROMPT" | python3 -c 'import sys; print(sys.stdin.read().replace(chr(39), chr(39)+chr(39)+chr(39)))')'''}, + {'role': 'user', 'content': '## PR 标题\n$PR_TITLE\n\n## Diff\n$DIFF_CONTENT'} + ], + 'temperature': 0.1, + 'max_tokens': 4096 +} +print(json.dumps(d)) +")") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" != "200" ]; then + echo "API error: $HTTP_CODE $BODY" + echo "result='GLM API 调用失败(HTTP $HTTP_CODE),请检查 ZHIPUAI_API_KEY 和网络连接。'" > /tmp/review_body.txt + exit 0 + fi + + # 提取 content + CONTENT=$(echo "$BODY" | python3 -c 'import sys,json; print(json.loads(sys.stdin.read())["choices"][0]["message"]["content"])') + echo "$CONTENT" > /tmp/review_result.json + + # 生成评论正文 + python3 -c " +import json, sys +try: + with open('/tmp/review_result.json') as f: + data = json.load(f) +except json.JSONDecodeError: + # 不是 JSON 也接受(模型可能直接输出自然语言) + with open('/tmp/review_result.json') as f: + text = f.read() + with open('/tmp/review_body.txt', 'w') as out: + out.write(text) + sys.exit(0) + +lines = [] +lines.append('## 🤖 GLM-5.2 PR Review') +lines.append('') +lines.append(data.get('summary', '')) +lines.append('') + +findings = data.get('findings', []) +if not findings: + lines.append('✅ 未发现 medium 以上问题。') +else: + # 按 severity 排序 + severity_order = {'critical': 0, 'major': 1, 'medium': 2} + findings.sort(key=lambda x: severity_order.get(x.get('severity','medium'), 99)) + + for f in findings: + sev = f.get('severity', 'medium') + label = {'critical': '🔴', 'major': '🟠', 'medium': '🟡'}.get(sev, '🟡') + file_line = f.get('file', '?') + if f.get('line'): + file_line += f':{f[\"line\"]}' + lines.append(f'{label} **[{sev.upper()}]** {file_line}') + lines.append(f' - {f.get(\"summary\", \"\")}') + if f.get('failure_scenario'): + lines.append(f' - *失败场景:{f[\"failure_scenario\"]}*') + lines.append('') + +with open('/tmp/review_body.txt', 'w') as out: + out.write('\n'.join(lines)) +" + + - name: Post/Update sticky comment + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + REVIEW_BODY=$(cat /tmp/review_body.txt) + # 加标记行方便辨识 + MARKER="" + SHA=$(git rev-parse --short HEAD) + FULL_BODY="${MARKER}\nReview base: ${SHA}\n\n${REVIEW_BODY}" + gh pr comment "$PR_NUMBER" --edit-last --create-if-none --body "$(echo -e "$FULL_BODY")" + + - name: Log review status + if: always() + run: | + echo "GLM review completed (non-blocking)" \ No newline at end of file diff --git a/.github/workflows/glm.yml b/.github/workflows/glm.yml new file mode 100644 index 00000000..fcdf7f69 --- /dev/null +++ b/.github/workflows/glm.yml @@ -0,0 +1,142 @@ +name: GLM @mention 互动 + +# 在 PR/issue/review comment 里 @glm 触发 GLM-5.2 对话。 +# 本 workflow 不阻塞 CI/CD,失败不影响 PR 合并。 +# Auth: 智谱 GLM API key 存在 GitHub Actions Secret ZHIPUAI_API_KEY + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + issues: + types: [opened, assigned] + +jobs: + glm: + name: GLM-5.2 · @glm 触发 + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@glm')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@glm')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@glm')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@glm') || contains(github.event.issue.title, '@glm'))) + runs-on: ubuntu-latest + continue-on-error: true + permissions: + contents: read + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Collect context + id: context + env: + GH_TOKEN: ${{ github.token }} + run: | + case "${{ github.event_name }}" in + issue_comment) + ISSUE_NUM="${{ github.event.issue.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## Issue #$ISSUE_NUM" >> $GITHUB_OUTPUT + gh issue view "$ISSUE_NUM" --json title,body --jq '"### \(.title)\n\n\(.body // "无正文")"' >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### @glm 评论" >> $GITHUB_OUTPUT + echo "${{ github.event.comment.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$ISSUE_NUM" >> $GITHUB_OUTPUT + echo "type=issue" >> $GITHUB_OUTPUT + ;; + pull_request_review_comment) + PR_NUM="${{ github.event.pull_request.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## PR #$PR_NUM Review Comment" >> $GITHUB_OUTPUT + echo "File: ${{ github.event.comment.path }}" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### @glm 评论" >> $GITHUB_OUTPUT + echo "${{ github.event.comment.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$PR_NUM" >> $GITHUB_OUTPUT + echo "type=pr" >> $GITHUB_OUTPUT + ;; + pull_request_review) + PR_NUM="${{ github.event.pull_request.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## PR #$PR_NUM Review" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### @glm 评论" >> $GITHUB_OUTPUT + echo "${{ github.event.review.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$PR_NUM" >> $GITHUB_OUTPUT + echo "type=pr" >> $GITHUB_OUTPUT + ;; + issues) + ISSUE_NUM="${{ github.event.issue.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## Issue #$ISSUE_NUM" >> $GITHUB_OUTPUT + echo "### 标题" >> $GITHUB_OUTPUT + echo "${{ github.event.issue.title }}" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### 正文" >> $GITHUB_OUTPUT + echo "${{ github.event.issue.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$ISSUE_NUM" >> $GITHUB_OUTPUT + echo "type=issue" >> $GITHUB_OUTPUT + ;; + esac + + - name: Call Zhipu GLM-5.2 + id: call + env: + ZHIPUAI_API_KEY: ${{ secrets.ZHIPUAI_API_KEY }} + CONTEXT: ${{ steps.context.outputs.context }} + TARGET: ${{ steps.context.outputs.target }} + TYPE: ${{ steps.context.outputs.type }} + run: | + # 用 python3 构造请求体,避免 bash JSON 转义坑 + RESPONSE=$(curl -s -w "\n%{http_code}" --request POST \ + --url "https://yuanyuaicloud.cn/v1/chat/completions" \ + --header "Authorization: Bearer $ZHIPUAI_API_KEY" \ + --header "Content-Type: application/json" \ + --data "$(python3 -c " +import json, os +ctx = os.environ.get('CONTEXT', '') +d = { + 'model': 'glm-5.2', + 'messages': [ + {'role': 'system', 'content': '你是 Inalpha 仓库的 AI 助手。用户通过 @glm 触发你的回复。请用中文回答。'}, + {'role': 'user', 'content': ctx} + ], + 'temperature': 0.7, + 'max_tokens': 2048 +} +print(json.dumps(d)) +")") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" != "200" ]; then + echo "API error: $HTTP_CODE $BODY" + exit 0 + fi + + CONTENT=$(echo "$BODY" | python3 -c 'import sys,json; print(json.loads(sys.stdin.read())["choices"][0]["message"]["content"])') + echo "$CONTENT" > /tmp/glm_reply.txt + + - name: Post reply + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.context.outputs.target }} + TYPE: ${{ steps.context.outputs.type }} + run: | + REPLY=$(cat /tmp/glm_reply.txt) + if [ "$TYPE" = "issue" ]; then + gh issue comment "$TARGET" --body "🤖 **GLM-5.2**:\n\n$REPLY" + else + gh pr comment "$TARGET" --body "🤖 **GLM-5.2**:\n\n$REPLY" + fi \ No newline at end of file diff --git a/infra/migrations/versions/0025_backtest_runs_account_id.py b/infra/migrations/versions/0025_backtest_runs_account_id.py new file mode 100644 index 00000000..a8bf0480 --- /dev/null +++ b/infra/migrations/versions/0025_backtest_runs_account_id.py @@ -0,0 +1,45 @@ +"""account_id 补洞 —— backtest_runs 按用户隔离(多租户上线必修) + +Revision ID: 0025 +Revises: 0024 +Create Date: 2026-07-02 + +背景(PR #129 合并后上线验证):test2 用户登录后发现策略实验室 + 活动回测日志能 +看到其他用户的回测与候选——上一轮多用户登录部署时漏了这两个端点的 per-account 过滤。 + +strategy_candidates 表已有 ``owner_account_id`` 列,问题只在 API 层没透传过滤; +本迁移专注 **backtest_runs 缺 account_id 列**——不加列无法过滤,comment 自己也写了 +"坑(单租户假设):backtest_runs 表无 owner 列……开放多用户前必须补 owner 过滤"。 + +``account_id TEXT``(可空,向后兼容老行)。索引 ``(account_id, created_at DESC)`` +覆盖「查本人最近 N 条回测」的 8s 轮询热路径。 +""" +from __future__ import annotations + +from alembic import op + +revision: str = "0025" +down_revision: str | None = "0024" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE backtest_runs + ADD COLUMN IF NOT EXISTS account_id TEXT + """ + ) + op.execute( + "CREATE INDEX IF NOT EXISTS backtest_runs_account_created_idx " + "ON backtest_runs (account_id, created_at DESC) " + "WHERE account_id IS NOT NULL" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS backtest_runs_account_created_idx") + op.execute( + "ALTER TABLE backtest_runs DROP COLUMN IF EXISTS account_id" + ) diff --git a/services/paper/src/inalpha_paper/api/backtest.py b/services/paper/src/inalpha_paper/api/backtest.py index 699060d7..54b3b7c7 100644 --- a/services/paper/src/inalpha_paper/api/backtest.py +++ b/services/paper/src/inalpha_paper/api/backtest.py @@ -14,6 +14,7 @@ from inalpha_shared.db import DBConn from inalpha_shared.errors import UnauthorizedError, ValidationError +from ..account_id import account_id_from_user from ..config import PaperSettings, get_paper_settings from ..data_client import DataClient from ..runner import run_backtest as _run_backtest @@ -41,10 +42,10 @@ async def post_backtest( req: BacktestRequest, db: DBConn, settings: Annotated[PaperSettings, Depends(get_paper_settings)], - _user: Annotated[User, Depends(get_current_user)], + user: Annotated[User, Depends(get_current_user)], authorization: Annotated[str | None, Header()] = None, ) -> BacktestResponse: - """跑回测:拉数据 → 实例化策略 → 跑引擎 → 落库 → 返回报告。 + """跑回测:拉数据 → 实例化策略 → 跑引擎 → 落库(带 account_id) → 返回报告。 D-9 起:``strategy_id`` 与 ``candidate_id`` 二选一(Pydantic ``model_validator`` 保证两者必有其一)。candidate 路径下 strategy_id 校验跳过——LLM 候选不在内置 @@ -73,7 +74,9 @@ async def post_backtest( user_token = authorization.removeprefix("Bearer ").strip() async with DataClient(settings.data_service_url, user_token) as data_client: - return await _run_backtest(req, data_client, conn=db) + return await _run_backtest( + req, data_client, conn=db, account_id=str(account_id_from_user(user)), + ) @router.post("/backtest/cv", response_model=CVBacktestResponse) @@ -81,7 +84,7 @@ async def post_backtest_cv( req: CVBacktestRequest, db: DBConn, settings: Annotated[PaperSettings, Depends(get_paper_settings)], - _user: Annotated[User, Depends(get_current_user)], + user: Annotated[User, Depends(get_current_user)], authorization: Annotated[str | None, Header()] = None, ) -> CVBacktestResponse: """多路径时序交叉验证回测(ADR-0028):输出样本外 Sharpe 分布 + DSR。 @@ -107,7 +110,9 @@ async def post_backtest_cv( user_token = authorization.removeprefix("Bearer ").strip() async with DataClient(settings.data_service_url, user_token) as data_client: - return await _run_cv(req, data_client, conn=db) + return await _run_cv( + req, data_client, conn=db, account_id=str(account_id_from_user(user)), + ) @router.post("/backtest/sensitivity", response_model=SensitivityResponse) @@ -115,7 +120,7 @@ async def post_backtest_sensitivity( req: SensitivityRequest, db: DBConn, settings: Annotated[PaperSettings, Depends(get_paper_settings)], - _user: Annotated[User, Depends(get_current_user)], + user: Annotated[User, Depends(get_current_user)], authorization: Annotated[str | None, Header()] = None, ) -> SensitivityResponse: """参数邻域敏感性检查(D-12):base + one-at-a-time ±pct 扰动各跑一次回测。 @@ -142,7 +147,9 @@ async def post_backtest_sensitivity( user_token = authorization.removeprefix("Bearer ").strip() async with DataClient(settings.data_service_url, user_token) as data_client: - return await _run_sensitivity(req, data_client, conn=db) + return await _run_sensitivity( + req, data_client, conn=db, account_id=str(account_id_from_user(user)), + ) @router.get("/strategies", response_model=dict) @@ -156,27 +163,25 @@ async def get_strategies( @router.get("/backtest_runs", response_model=list[BacktestRunSummary]) async def get_backtest_runs( db: DBConn, - _user: Annotated[User, Depends(get_current_user)], + user: Annotated[User, Depends(get_current_user)], research_id: Annotated[UUID | None, Query()] = None, strategy_code: Annotated[str | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, ) -> list[BacktestRunSummary]: - """查历史回测。 - - 可按 ``research_id`` 或 ``strategy_code`` 过滤(同时给 → 优先用 research_id); - **都不给则返回全局最近 N 条**(控制台「Agent 活动」聚合流用)。 - 用途:agent 决策"是否复用上一次回测",避免重复计算同 params 的 backtest。 - - 坑(单租户假设):backtest_runs 表无 owner 列,本端点不按用户隔离 —— - 当前部署为单操作者控制台可接受;开放多用户前必须补 owner 过滤, - 且重新评估 limit 上限(全局最近 N 条会成为跨租户数据查探面)。 - """ + """查本账户历史回测(按 account_id 隔离,不再全局看别人的)。""" + acct = account_id_from_user(user) if research_id is not None: - rows = await backtest_runs_store.list_by_research(db, research_id, limit=limit) + rows = await backtest_runs_store.list_by_research( + db, research_id, limit=limit, account_id=str(acct), + ) elif strategy_code is not None: - rows = await backtest_runs_store.list_by_strategy(db, strategy_code, limit=limit) + rows = await backtest_runs_store.list_by_strategy( + db, strategy_code, limit=limit, account_id=str(acct), + ) else: - rows = await backtest_runs_store.list_recent(db, limit=limit) + rows = await backtest_runs_store.list_recent( + db, limit=limit, account_id=str(acct), + ) return [ BacktestRunSummary( diff --git a/services/paper/src/inalpha_paper/api/strategy_candidates.py b/services/paper/src/inalpha_paper/api/strategy_candidates.py index 1268e751..c0493af9 100644 --- a/services/paper/src/inalpha_paper/api/strategy_candidates.py +++ b/services/paper/src/inalpha_paper/api/strategy_candidates.py @@ -179,7 +179,7 @@ async def get_strategy_candidate( ) async def list_strategy_candidates( db: DBConn, - _user: Annotated[User, Depends(get_current_user)], + user: Annotated[User, Depends(get_current_user)], status: Annotated[str | None, Query(description="可选过滤 status")] = None, author_id: Annotated[ UUID | None, @@ -187,7 +187,7 @@ async def list_strategy_candidates( ] = None, limit: Annotated[int, Query(ge=1, le=200)] = 50, ) -> list[StrategyCandidateSummary]: - """列候选(fitness DESC, NULLS LAST, created_at DESC)。 + """列本人候选(按 owner_account_id 隔离,fitness DESC,NULLS LAST,created_at DESC)。 不返回 ``code`` 字段省带宽;要看源码用 ``GET /strategy_candidates/{id}``。 """ @@ -196,6 +196,7 @@ async def list_strategy_candidates( status=status, author_id=author_id, limit=limit, + owner_account_id=str(account_id_from_user(user)), ) return [ StrategyCandidateSummary( diff --git a/services/paper/src/inalpha_paper/runner.py b/services/paper/src/inalpha_paper/runner.py index 29117396..7b73c14e 100644 --- a/services/paper/src/inalpha_paper/runner.py +++ b/services/paper/src/inalpha_paper/runner.py @@ -80,6 +80,7 @@ async def run_backtest( data_client: DataClient, *, conn: AsyncConnection | None = None, + account_id: str | None = None, ) -> BacktestResponse: """执行一次完整 backtest:拉 bars → 实例化 strategy → 跑 engine → 组装响应。 @@ -284,6 +285,7 @@ async def run_backtest( started_at=started_at, finished_at=finished_at, validation=validation, + account_id=account_id, ) # 6a. candidate 路径:回写 candidates 表(最近一次 metrics / fitness) if req.candidate_id is not None: @@ -479,6 +481,7 @@ async def _persist_run( started_at: datetime, finished_at: datetime, validation: ValidationBlock | None = None, + account_id: str | None = None, ) -> UUID | None: """写一行 backtest_runs + 逐笔成交(同一事务)。失败 log warning 后返 None,不阻断回测响应。 @@ -535,6 +538,7 @@ async def _persist_run( strategy_hint=req.strategy_hint, started_at=started_at, finished_at=finished_at, + account_id=account_id, ) if report.fills: await backtest_trades_store.insert_fills(conn, run_id, report.fills) @@ -578,6 +582,7 @@ async def run_cv( data_client: DataClient, *, conn: AsyncConnection | None = None, + account_id: str | None = None, ) -> CVBacktestResponse: """跑多路径时序交叉验证回测(ADR-0028):拉数据 → 造策略工厂 → 切分 → 聚合分布。 diff --git a/services/paper/src/inalpha_paper/sensitivity.py b/services/paper/src/inalpha_paper/sensitivity.py index 1d49a689..e2079cde 100644 --- a/services/paper/src/inalpha_paper/sensitivity.py +++ b/services/paper/src/inalpha_paper/sensitivity.py @@ -118,6 +118,7 @@ async def run_sensitivity( data_client: DataClient, *, conn: AsyncConnection | None = None, + account_id: str | None = None, ) -> SensitivityResponse: """拉一次 bars,base + 邻域并发跑引擎,返回敏感性摘要。 diff --git a/services/paper/src/inalpha_paper/storage/backtest_runs.py b/services/paper/src/inalpha_paper/storage/backtest_runs.py index facd1768..ec8da390 100644 --- a/services/paper/src/inalpha_paper/storage/backtest_runs.py +++ b/services/paper/src/inalpha_paper/storage/backtest_runs.py @@ -40,6 +40,7 @@ async def insert_run( started_at: datetime | None = None, finished_at: datetime | None = None, created_by: UUID | None = None, + account_id: str | None = None, ) -> UUID: """落一行 backtest_runs,返回生成的 run_id。 @@ -50,7 +51,7 @@ async def insert_run( status: 'done' / 'failed' / etc,CHECK 约束见 migration 0001 research_id: 触发本次回测的 research 产物 ID(可空) strategy_hint: 触发本次回测的原始 strategy_hint dict(审计用) - """ + account_id: 账户归属(migration 0025 补列,与 strategy_runs.account_id 同源)""" run_id = uuid4() params = config.get("params") or {} params_hash = compute_params_hash(strategy_code, params) @@ -61,11 +62,11 @@ async def insert_run( INSERT INTO backtest_runs ( id, strategy_id, strategy_code, config, status, metrics, research_id, params_hash, strategy_hint, - started_at, finished_at, created_by + started_at, finished_at, created_by, account_id ) VALUES ( %s, NULL, %s, %s, %s, %s, %s, %s, %s, - %s, %s, %s + %s, %s, %s, %s ) """, ( @@ -80,6 +81,7 @@ async def insert_run( started_at, finished_at, str(created_by) if created_by else None, + account_id, ), ) return run_id @@ -90,22 +92,24 @@ async def list_by_research( research_id: UUID, *, limit: int = 20, + account_id: str | None = None, ) -> list[dict[str, Any]]: - """按 research_id 拉历史回测(按 created_at DESC)。 + """按 research_id 拉历史回测(按 created_at DESC),可选按 account_id 过滤。 返回 dict list,含 id/strategy_code/config/metrics/params_hash/created_at。 """ + clause = "AND account_id = %s" if account_id else "" async with conn.cursor() as cur: await cur.execute( - """ + f""" SELECT id, strategy_code, config, metrics, params_hash, research_id, strategy_hint, created_at, status FROM backtest_runs - WHERE research_id = %s + WHERE research_id = %s {clause} ORDER BY created_at DESC LIMIT %s """, - (str(research_id), limit), + (str(research_id), *([account_id] if account_id else []), limit), ) rows = await cur.fetchall() return [_row_to_dict(r) for r in rows] @@ -116,19 +120,21 @@ async def list_by_strategy( strategy_code: str, *, limit: int = 20, + account_id: str | None = None, ) -> list[dict[str, Any]]: - """按 strategy_code 拉历史回测(按 created_at DESC)。""" + """按 strategy_code 拉历史回测(按 created_at DESC),可选按 account_id 过滤。""" + clause = "AND account_id = %s" if account_id else "" async with conn.cursor() as cur: await cur.execute( - """ + f""" SELECT id, strategy_code, config, metrics, params_hash, research_id, strategy_hint, created_at, status FROM backtest_runs - WHERE strategy_code = %s + WHERE strategy_code = %s {clause} ORDER BY created_at DESC LIMIT %s """, - (strategy_code, limit), + (strategy_code, *([account_id] if account_id else []), limit), ) rows = await cur.fetchall() return [_row_to_dict(r) for r in rows] @@ -174,18 +180,21 @@ async def list_recent( conn: AsyncConnection, *, limit: int = 20, + account_id: str | None = None, ) -> list[dict[str, Any]]: - """全局最近回测(按 created_at DESC)—— 控制台「Agent 活动」聚合流用。""" + """按 account_id(若给)查最近回测(按 created_at DESC),供活动流/策略实验室。""" + clause = "WHERE account_id = %s" if account_id else "" async with conn.cursor() as cur: await cur.execute( - """ + f""" SELECT id, strategy_code, config, metrics, params_hash, research_id, strategy_hint, created_at, status FROM backtest_runs + {clause} ORDER BY created_at DESC LIMIT %s """, - (limit,), + (*([account_id] if account_id else []), limit), ) rows = await cur.fetchall() return [_row_to_dict(r) for r in rows] diff --git a/services/paper/src/inalpha_paper/storage/strategy_candidates.py b/services/paper/src/inalpha_paper/storage/strategy_candidates.py index b29fd779..39a0c1b0 100644 --- a/services/paper/src/inalpha_paper/storage/strategy_candidates.py +++ b/services/paper/src/inalpha_paper/storage/strategy_candidates.py @@ -177,12 +177,14 @@ async def list_candidates( status: str | None = None, author_id: UUID | None = None, limit: int = 50, + owner_account_id: str | None = None, ) -> list[dict[str, Any]]: """列候选(按 fitness DESC, created_at DESC,未跑过回测的排最后)。 Args: status: 可选过滤 'candidate' / 'rejected' / 'promoted' author_id: 可选只看某用户创建的 + owner_account_id: 多租户——按账户过滤,填了只看本人候选,不填看全局(仅 dev) """ sql = ( "SELECT id, code, code_hash, description, author, author_id, " @@ -191,6 +193,9 @@ async def list_candidates( "FROM strategy_candidates WHERE 1=1" ) params: list[Any] = [] + if owner_account_id is not None: + sql += " AND owner_account_id = %s" + params.append(owner_account_id) if status is not None: sql += " AND status = %s" params.append(status)