feat(api+web): 任务列表管理(验收四轮批1):历史任务→任务列表改名;DELETE /task/{id}单/批删除(进行中拒删);GET /task/{id}/params参数回放+点任务ID跳参数页预填;真创建时间+耗时列(提交即记,进行中活计时);进行中禁查看;组合任务params全量存 [nas]
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""任务列表管理:删除 + 真创建时间/耗时 + 参数回放 (验收第四轮 批1)。
|
||||
|
||||
Direct route-call 风格与 test_portfolio_live.py 一致(不打 JWT)。
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from sanguo_api.app import create_app
|
||||
from sanguo_api.auth import set_jwt_config, create_token
|
||||
from sanguo_backtest.result_store import (
|
||||
BacktestResult,
|
||||
record_submission,
|
||||
save_result,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_env():
|
||||
"""Temp DB + app + token + mock orchestrator(真实 dict 注册表)。"""
|
||||
set_jwt_config(secret="test", expire_minutes=60)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = os.path.join(tmp, "test.db")
|
||||
app = create_app(db_path=db_path, file_dir=tmp)
|
||||
token = create_token("admin")
|
||||
orch = Mock()
|
||||
orch.db_path = db_path
|
||||
orch.pool._tasks = {}
|
||||
orch.pool.get_task.return_value = None # Mock 默认非 None 会挡 404 分支
|
||||
orch._pending = {}
|
||||
with patch("sanguo_api.routes.get_orchestrator", return_value=orch):
|
||||
yield TestClient(app), token, orch, db_path, tmp
|
||||
|
||||
|
||||
def _save_done_result(task_id: str, db_path: str, file_dir: str) -> None:
|
||||
save_result(BacktestResult(
|
||||
task_id=task_id, type="cta", status="done",
|
||||
strategy="AShareDoubleMaStrategy", symbol="600000",
|
||||
params={"fast_window": 10, "slow_window": 30},
|
||||
start="2024-01-01", end="2024-06-30",
|
||||
statistics={"total_return": 0.1},
|
||||
equity_curve=pd.DataFrame([{"date": "2024-01-02", "balance": 1_000_000}]),
|
||||
trades=pd.DataFrame([{"datetime": "2024-01-05", "price": 10.0, "volume": 100}]),
|
||||
), db_path=db_path, file_dir=file_dir)
|
||||
|
||||
|
||||
# ===== 真创建时间 + 耗时 =====
|
||||
|
||||
def test_list_tasks_uses_submission_time_and_duration(client_env):
|
||||
client, token, orch, db_path, tmp = client_env
|
||||
# 提交时间 = 2 小时前(直接写表,避免 sleep)
|
||||
_save_done_result("cta_abcd1234", db_path, tmp)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO task_submissions (task_id, submitted_at) "
|
||||
"VALUES ('cta_abcd1234', '2024-01-01 06:00:00')"
|
||||
)
|
||||
# backtest_stats.created_at 固定为结果插入时间 → 手动改成 08:00 模拟耗时 2h
|
||||
conn.execute("UPDATE backtest_stats SET created_at='2024-01-01 08:00:00'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.get("/api/v1/task", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
row = next(t for t in resp.json()["tasks"] if t["task_id"] == "cta_abcd1234")
|
||||
assert row["created_at"].startswith("2024-01-01 14:00") # UTC+8
|
||||
assert row["finished_at"].startswith("2024-01-01 16:00")
|
||||
assert row["duration_s"] == 7200
|
||||
|
||||
|
||||
def test_list_tasks_old_row_without_submission_degrades(client_env):
|
||||
client, token, orch, db_path, tmp = client_env
|
||||
_save_done_result("cta_old9999", db_path, tmp)
|
||||
resp = client.get("/api/v1/task", headers={"Authorization": f"Bearer {token}"})
|
||||
row = next(t for t in resp.json()["tasks"] if t["task_id"] == "cta_old9999")
|
||||
# 旧行为:created_at=落库时间,duration 缺失
|
||||
assert row["created_at"] == row["finished_at"]
|
||||
assert row["duration_s"] is None
|
||||
|
||||
|
||||
def test_record_submission_writes_utc_row(client_env):
|
||||
client, token, orch, db_path, tmp = client_env
|
||||
ts = record_submission("cta_new0001", db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
row = conn.execute(
|
||||
"SELECT submitted_at FROM task_submissions WHERE task_id='cta_new0001'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row is not None
|
||||
assert row[0] == ts
|
||||
|
||||
|
||||
# ===== 删除 =====
|
||||
|
||||
def test_delete_task_removes_row_and_files(client_env):
|
||||
client, token, orch, db_path, tmp = client_env
|
||||
_save_done_result("cta_del0001", db_path, tmp)
|
||||
conn = sqlite3.connect(db_path)
|
||||
eq_path, tr_path = conn.execute(
|
||||
"SELECT equity_path, trades_path FROM backtest_stats WHERE task_id='cta_del0001'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert eq_path and os.path.exists(eq_path)
|
||||
assert tr_path and os.path.exists(tr_path)
|
||||
|
||||
resp = client.delete("/api/v1/task/cta_del0001", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["deleted"] == 1
|
||||
assert not os.path.exists(eq_path)
|
||||
assert not os.path.exists(tr_path)
|
||||
|
||||
# 列表里也没了
|
||||
resp = client.get("/api/v1/task", headers={"Authorization": f"Bearer {token}"})
|
||||
assert all(t["task_id"] != "cta_del0001" for t in resp.json()["tasks"])
|
||||
|
||||
|
||||
def test_delete_running_task_refused(client_env):
|
||||
client, token, orch, db_path, tmp = client_env
|
||||
task = Mock()
|
||||
task.status.value = "running"
|
||||
orch.pool.get_task.return_value = task
|
||||
resp = client.delete("/api/v1/task/cta_run0001", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 400
|
||||
assert "进行中" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_delete_unknown_task_404(client_env):
|
||||
client, token, orch, db_path, tmp = client_env
|
||||
resp = client.delete("/api/v1/task/cta_none001", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ===== 参数回放 =====
|
||||
|
||||
def test_task_params_returns_saved_params(client_env):
|
||||
client, token, orch, db_path, tmp = client_env
|
||||
_save_done_result("cta_par0001", db_path, tmp)
|
||||
resp = client.get("/api/v1/task/cta_par0001/params", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "cta"
|
||||
assert data["strategy"] == "AShareDoubleMaStrategy"
|
||||
assert data["symbol"] == "600000"
|
||||
assert data["params"] == {"fast_window": 10, "slow_window": 30}
|
||||
assert data["start"] == "2024-01-01"
|
||||
|
||||
|
||||
def test_task_params_running_reads_pending_spec(client_env):
|
||||
client, token, orch, db_path, tmp = client_env
|
||||
task = Mock()
|
||||
task.status.value = "running"
|
||||
task.task_type = "portfolio"
|
||||
orch.pool.get_task.return_value = task
|
||||
orch._pending["portfolio_run01"] = {
|
||||
"strategy": "all_weather", "benchmark": "000300.XSHG",
|
||||
"start": "2024-01-01", "end": "2024-12-31", "max_pool": 30,
|
||||
}
|
||||
resp = client.get("/api/v1/task/portfolio_run01/params", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["strategy"] == "all_weather"
|
||||
assert data["symbol"] == "000300.XSHG" # portfolio spec 用 benchmark 兜底
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
def test_task_params_unknown_404(client_env):
|
||||
client, token, orch, db_path, tmp = client_env
|
||||
resp = client.get("/api/v1/task/cta_none002/params", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user