From f9b6d77ebd1686a399a9d3ba0444a6bb8155c8a9 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Wed, 19 Aug 2026 22:21:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(live):=20B3=E9=A2=84=E7=AE=97=E7=A1=AC?= =?UTF-8?q?=E9=99=90=E5=88=B6+GET=20/live/budget-info=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E9=A2=84=E7=AE=97=E5=88=B6=E9=97=B8=E9=97=A8?= =?UTF-8?q?(spec=C2=A7B3)=E2=80=94=E2=80=94=E2=91=A0=5Fbudget=5Fstate:?= =?UTF-8?q?=E5=89=A9=E4=BD=99=3DQMT=E8=B4=A6=E6=88=B7=E7=8E=B0=E9=87=91?= =?UTF-8?q?=E2=88=92=CE=A3=E5=90=8C=E8=B4=A6=E6=88=B7=E5=85=B6=E4=BB=96?= =?UTF-8?q?=E5=AE=9E=E7=9B=98=E5=AE=9E=E4=BE=8B=E9=A2=84=E7=AE=97(?= =?UTF-8?q?=E4=B8=8D=E5=88=86=E8=BF=90=E8=A1=8C/=E5=81=9C=E6=AD=A2,?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E5=AE=9E=E4=BE=8B=E6=8C=81=E4=BB=93=E4=BB=8D?= =?UTF-8?q?=E5=8D=A0=E8=B5=84=E9=87=91);=E5=BF=AB=E7=85=A7=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1/=E8=BF=87=E6=9C=9F(>10min)=E2=86=92fresh=3DFalse+rema?= =?UTF-8?q?ining=3DNone,fail-closed=E4=B8=8D=E7=8C=9C=E6=95=B0=E2=91=A1cre?= =?UTF-8?q?ate=5Flive=E8=90=BD=E5=BA=93=E5=89=8D=5Fenforce=5Fbudget:?= =?UTF-8?q?=E8=B6=85=E9=99=90400=E6=8A=A5=E6=96=87=E5=B8=A6=E7=8E=B0?= =?UTF-8?q?=E9=87=91/=E5=B7=B2=E5=88=86=E9=85=8D/=E5=89=A9=E4=BD=99?= =?UTF-8?q?=E4=B8=89=E6=95=B0;=E5=BF=AB=E7=85=A7=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8400(=E9=87=8D=E5=BB=BA=E6=9C=9FQMT=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=97=B6=E5=88=9B=E5=BB=BA=E8=A2=AB=E6=AD=A3=E7=A1=AE=E6=8B=92?= =?UTF-8?q?=E7=BB=9D)=E2=91=A2update=5Flive=E8=A1=A5initial=5Fcapital?= =?UTF-8?q?=E5=8F=AF=E7=BC=96=E8=BE=91+=E6=A0=A1=E9=AA=8C(=E4=BB=85?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E6=88=96=E8=B4=A6=E5=8F=B7=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E8=A7=A6=E5=8F=91,=E6=94=B9=E5=90=8D=E4=B8=8D=E8=A7=A6?= =?UTF-8?q?=E5=8F=91;exclude=E8=87=AA=E8=BA=AB=E6=97=A7=E5=80=BC=E9=98=B2?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=AE=A1=E5=85=A5=CE=A3)=E2=91=A3GET=20/live?= =?UTF-8?q?/budget-info(=E5=BF=85=E9=A1=BB=E6=B3=A8=E5=86=8C=E5=9C=A8/live?= =?UTF-8?q?/{aid}=E5=89=8D,FastAPI=E8=B7=AF=E5=BE=84=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E4=B8=8D=E6=8C=89=E7=B1=BB=E5=9E=8B=E5=88=86=E6=B5=81=E5=90=A6?= =?UTF-8?q?=E5=88=99422)=E2=86=92{account=5Fcash,allocated,remaining,fresh?= =?UTF-8?q?,snapshot=5Fat}=E4=BE=9B=E5=89=8D=E7=AB=AF=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=80=BC+=E5=8D=A0=E7=94=A8=E7=8E=87=E6=9D=A1=E2=91=A4?= =?UTF-8?q?=E5=AD=98=E9=87=8F=E4=B8=8D=E8=BF=BD=E6=BA=AF(=E4=BB=85?= =?UTF-8?q?=E6=96=B0=E5=BB=BA/=E4=BF=AE=E6=94=B9=E8=B5=B0=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C);+14=E9=A2=84=E7=AE=97=E6=B5=8B=E8=AF=95,=E6=97=A7?= =?UTF-8?q?=E6=B5=8B=E8=AF=95fixture=E8=A1=A5=E5=BF=AB=E7=85=A7=E6=92=AD?= =?UTF-8?q?=E7=A7=8D(test=5Fportfolio=5Flive/test=5Finstance=5Fbinding,B3?= =?UTF-8?q?=E5=90=8Ecreate=E6=96=B0=E5=A5=91=E7=BA=A6);api+data=5Fplatform?= =?UTF-8?q?=20273=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_api/routes_live.py | 82 ++++++++++++++ tests/api/test_instance_binding.py | 5 + tests/api/test_live_budget.py | 176 +++++++++++++++++++++++++++++ tests/api/test_portfolio_live.py | 7 ++ 4 files changed, 270 insertions(+) create mode 100644 tests/api/test_live_budget.py diff --git a/sanguo_api/routes_live.py b/sanguo_api/routes_live.py index 5042cfb..cf8dec9 100644 --- a/sanguo_api/routes_live.py +++ b/sanguo_api/routes_live.py @@ -153,6 +153,8 @@ def create_live(req: LiveCreateRequest): payload["mini_path"] = ( os.environ.get("SANGUO_QMT_PATH") or _DEFAULT_MINI_PATH ) + # B3 预算硬限制:Σ预算 ≤ 账户现金(fail-closed,快照不可用拒绝创建) + _enforce_budget(db, payload["account"], payload["initial_capital"]) aid = save_account(db, {**payload, "status": "stopped"}) return {"account_id": aid, "status": "stopped"} @@ -192,6 +194,70 @@ def list_lives(): return {"accounts": items} +# ===== B3 预算硬限制(spec §multi-strategy-instance-budget §B3) ===== + +def _budget_state( + db: str, account: str, exclude_aid: int | None = None +) -> dict: + """预算占用状态:剩余 = 账户现金 − Σ 同账户其他实盘实例预算。 + + fail-closed:快照缺失/过期(>10min)→ fresh=False,remaining=None(不猜数)。 + Σ 不分运行/停止——停止的实例持仓仍占着账户资金。 + """ + from sanguo_live.persistence import ( + get_fresh_account_snapshot, list_accounts, + ) + + acc = (account or "").strip() + allocated = sum( + float(r.get("initial_capital") or 0) + for r in list_accounts(db) + if (r.get("account") or "").strip() == acc + and r.get("id") != exclude_aid + ) + snap = get_fresh_account_snapshot(db, acc) if acc else None + if snap is None: + return {"account": acc, "fresh": False, "account_cash": None, + "allocated": allocated, "remaining": None, + "snapshot_at": None} + cash = float(snap.get("cash") or 0) + return {"account": acc, "fresh": True, "account_cash": cash, + "allocated": allocated, "remaining": cash - allocated, + "snapshot_at": snap.get("updated_at")} + + +def _enforce_budget( + db: str, account: str, initial_capital: float, + exclude_aid: int | None = None, +) -> dict: + """新建/修改实盘的预算校验:超限或快照不可用 → 400(报文带剩余数)。""" + st = _budget_state(db, account, exclude_aid) + if not st["fresh"]: + raise HTTPException( + 400, f"账户 {account} 快照不可用(未采集或超过10分钟),稍后再试") + if float(initial_capital) > st["remaining"]: + raise HTTPException( + 400, + f"预算超限:账户可用现金 {st['account_cash']:.0f}," + f"已分配 {st['allocated']:.0f},剩余可分配 {st['remaining']:.0f}" + f"(本次 {float(initial_capital):.0f})") + return st + + +@router.get("/live/budget-info", dependencies=[Depends(verify_token)]) +def get_budget_info(account: str = ""): + """预算信息(前端表单默认值=remaining + 占用率条)。 + + 注意:必须注册在 /live/{aid} 之前(FastAPI 路径匹配不按类型分流, + 否则 budget-info 会被 {aid} 吃掉 422)。快照不新鲜时 fresh=False、 + remaining=None——前端禁用提交并提示稍后再试(fail-closed 同款语义)。 + """ + db = _db_path["path"] + if not db: + raise HTTPException(400, "db 未配置") + return _budget_state(db, account) + + @router.get("/live/{aid}", dependencies=[Depends(verify_token)]) def get_live(aid: int): from sanguo_live.persistence import get_account, get_first_balance, get_last_balance @@ -290,6 +356,8 @@ class LiveUpdateRequest(BaseModel): strategy_name: str | None = None setting: dict | None = None interval: str | None = None + # B3:预算可改(initial_capital 含义升级为资金额度) + initial_capital: float | None = None @router.put("/live/{aid}", dependencies=[Depends(verify_token)]) @@ -305,6 +373,18 @@ def update_live(aid: int, req: LiveUpdateRequest): raise HTTPException(404, "account not found") if acc["status"] == "running": raise HTTPException(400, "运行中不可编辑,请先停止实例") + # B3:预算或账号变更时校验(改名等不触发——不是预算事件,夜间 QMT 关闭 + # 也能改名)。exclude 自身:自己的旧预算不重复计入 Σ。 + if req.initial_capital is not None or ( + req.account and req.account != acc["account"] + ): + _enforce_budget( + db, + req.account if req.account is not None else acc["account"], + req.initial_capital if req.initial_capital is not None + else float(acc.get("initial_capital") or 0), + exclude_aid=aid, + ) sets, args = [], [] field_map = { "name": req.name, "account": req.account, "vt_symbol": req.vt_symbol, @@ -320,6 +400,8 @@ def update_live(aid: int, req: LiveUpdateRequest): sets.append(f"{col}=?"); args.append(v) if req.setting is not None: sets.append("setting=?"); args.append(json.dumps(req.setting)) + if req.initial_capital is not None: + sets.append("initial_capital=?"); args.append(req.initial_capital) if not sets: return {"account_id": aid, "updated": False} with sqlite3.connect(db) as conn: diff --git a/tests/api/test_instance_binding.py b/tests/api/test_instance_binding.py index 358de2f..5211ee3 100644 --- a/tests/api/test_instance_binding.py +++ b/tests/api/test_instance_binding.py @@ -21,6 +21,11 @@ def _setup(tmp_path, monkeypatch): app = create_app(db_path=pdb) set_paper_db(pdb) set_live_db(ldb) + # B3 起 live/create 需新鲜账户快照,测试给用到的账号播种一份 + from sanguo_live import persistence as _lp + for _acc in ("8886000519", "123"): + _lp.upsert_account_snapshot(ldb, _acc, cash=1e9, market_value=0, + total=1e9, positions=[]) return TestClient(app), create_token("admin") diff --git a/tests/api/test_live_budget.py b/tests/api/test_live_budget.py new file mode 100644 index 0000000..4045de0 --- /dev/null +++ b/tests/api/test_live_budget.py @@ -0,0 +1,176 @@ +"""Tests for B3 预算硬限制(spec §B3):create/update 校验 + /live/budget-info。 + +直调路由函数(与 test_portfolio_live.py 同风格,免 auth)。 +核心断言:剩余=现金−Σ其他实例预算;超限/快照过期 → 400 fail-closed。 +""" +import sqlite3 +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi import HTTPException + +from sanguo_api import routes_live as rl +from sanguo_live import persistence as lp + + +@pytest.fixture() +def live_db(tmp_path): + db = str(tmp_path / "live.db") + rl.set_db_path(db) + return db + + +def _snapshot(db, account="66639661", cash=1_000_000.0, age_min=0): + lp.upsert_account_snapshot( + db, account, cash=cash, market_value=0.0, total=cash, positions=[]) + if age_min: + old = (datetime.now(timezone.utc) + - timedelta(minutes=age_min)).isoformat() + with sqlite3.connect(db) as conn: + conn.execute( + "UPDATE qmt_account_snapshot SET updated_at=? WHERE account=?", + (old, account)) + + +def _create(db, capital=500_000.0, account="66639661"): + req = rl.LiveCreateRequest( + account=account, strategy_name="p1", strategy_type="portfolio", + strategy_class="all_weather", initial_capital=capital) + return rl.create_live(req)["account_id"] + + +# ---------------- budget-info ---------------- + +def test_budget_info_basic(live_db): + _snapshot(live_db, cash=1_000_000) + _create(live_db, capital=300_000) # 已分配 30 万 + st = rl.get_budget_info("66639661") + assert st["fresh"] is True + assert st["account_cash"] == 1_000_000 + assert st["allocated"] == 300_000 + assert st["remaining"] == 700_000 + + +def test_budget_info_includes_stopped(live_db): + """停止实例持仓仍占资金 → Σ 不分状态。""" + _snapshot(live_db, cash=1_000_000) + aid = _create(live_db, capital=300_000) + lp.update_account_status(live_db, aid, "stopped") + assert rl.get_budget_info("66639661")["allocated"] == 300_000 + + +def test_budget_info_stale_fail_closed(live_db): + _snapshot(live_db, cash=1_000_000, age_min=30) + st = rl.get_budget_info("66639661") + assert st["fresh"] is False + assert st["account_cash"] is None + assert st["remaining"] is None + + +def test_budget_info_missing_snapshot(live_db): + st = rl.get_budget_info("NO_SUCH") + assert st["fresh"] is False + assert st["remaining"] is None + + +# ---------------- create 校验 ---------------- + +def test_create_within_budget_ok(live_db): + _snapshot(live_db, cash=1_000_000) + _create(live_db, capital=300_000) + _create(live_db, capital=700_000) # 恰好用满 + assert rl.get_budget_info("66639661")["remaining"] == 0 + + +def test_create_over_budget_400_with_remaining(live_db): + _snapshot(live_db, cash=1_000_000) + _create(live_db, capital=300_000) + with pytest.raises(HTTPException) as e: + _create(live_db, capital=800_000) + assert e.value.status_code == 400 + assert "700000" in e.value.detail # 报文带剩余数 + assert "剩余可分配" in e.value.detail + + +def test_create_snapshot_missing_400(live_db): + """快照不存在 → fail-closed(不猜数)。""" + with pytest.raises(HTTPException) as e: + _create(live_db) + assert e.value.status_code == 400 + assert "快照不可用" in e.value.detail + + +def test_create_snapshot_stale_400(live_db): + _snapshot(live_db, cash=1_000_000, age_min=30) + with pytest.raises(HTTPException) as e: + _create(live_db) + assert e.value.status_code == 400 + + +def test_create_budget_only_checks_same_qmt_account(live_db): + """Σ 只算同 QMT 账号——别的账号的实例不挤本账户池。""" + _snapshot(live_db, account="A1", cash=500_000) + _snapshot(live_db, account="A2", cash=900_000) + _create(live_db, account="A1", capital=400_000) + # A1 剩 10 万,但 A2 独立 + assert rl.get_budget_info("A2")["remaining"] == 900_000 + + +# ---------------- update 校验 ---------------- + +def _make_editable(live_db): + _snapshot(live_db, cash=1_000_000) # create 需新鲜快照 + return _create(live_db, capital=300_000) + + +def _age_snapshot(db, account="66639661"): + old = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat() + with sqlite3.connect(db) as conn: + conn.execute( + "UPDATE qmt_account_snapshot SET updated_at=? WHERE account=?", + (old, account)) + + +def test_update_budget_over_400_excludes_self(live_db): + """改预算超限 → 400;Σ 不含自身旧值(300 万自身不重复计)。""" + aid = _make_editable(live_db) # 快照 100万 + # 另一实例占了 50 万 → 剩余 50 万;自己 30 万不计入 + _create(live_db, capital=500_000) + with pytest.raises(HTTPException) as e: + rl.update_live(aid, rl.LiveUpdateRequest(initial_capital=600_000)) + assert e.value.status_code == 400 + assert "500000" in e.value.detail + + +def test_update_budget_within_ok(live_db): + aid = _make_editable(live_db) + _create(live_db, capital=500_000) + rl.update_live(aid, rl.LiveUpdateRequest(initial_capital=500_000)) + acc = lp.get_account(live_db, aid) + assert acc["initial_capital"] == 500_000 + + +def test_update_rename_skips_budget_check(live_db): + """仅改名不触发校验——快照过期也能改名(不是预算事件)。""" + aid = _make_editable(live_db) + _age_snapshot(live_db) + rl.update_live(aid, rl.LiveUpdateRequest(name="new_name")) + assert lp.get_account(live_db, aid)["name"] == "new_name" + + +def test_update_budget_change_blocked_when_stale(live_db): + """快照过期时改预算 → 400(改预算必须看到新鲜现金)。""" + aid = _make_editable(live_db) + _age_snapshot(live_db) + with pytest.raises(HTTPException) as e: + rl.update_live(aid, rl.LiveUpdateRequest(initial_capital=100_000)) + assert e.value.status_code == 400 + assert "快照不可用" in e.value.detail + + +def test_delete_frees_budget(live_db): + """删除实例释放预算(重建流程依赖)。""" + _snapshot(live_db, cash=1_000_000) + aid = _create(live_db, capital=300_000) + rl.delete_live(aid) + assert rl.get_budget_info("66639661")["remaining"] == 1_000_000 diff --git a/tests/api/test_portfolio_live.py b/tests/api/test_portfolio_live.py index 98b9ab2..400cde2 100644 --- a/tests/api/test_portfolio_live.py +++ b/tests/api/test_portfolio_live.py @@ -17,6 +17,10 @@ from sanguo_live import runner as live_runner def live_db(tmp_path): db = str(tmp_path / "live.db") rl.set_db_path(db) + # B3 起 create 校验预算(需新鲜账户快照),测试播种一份 + live_persistence.upsert_account_snapshot( + db, "66639661", cash=1_000_000_000.0, market_value=0.0, + total=1_000_000_000.0, positions=[]) return db @@ -264,6 +268,9 @@ def test_update_live_normalizes_vt_symbol(tmp_path, monkeypatch): db = os.path.join(str(tmp_path), "l.db") app = create_app(db_path=db) set_db_path(db) + from sanguo_live import persistence as _lp + _lp.upsert_account_snapshot(db, "A1", cash=1e9, market_value=0, + total=1e9, positions=[]) # B3 预算校验前置 c = TestClient(app) h = {"Authorization": f"Bearer {create_token('admin')}"}