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')}"}