diff --git a/sanguo_portfolio/live_strategy.py b/sanguo_portfolio/live_strategy.py index 9c28c66..2a064ed 100644 --- a/sanguo_portfolio/live_strategy.py +++ b/sanguo_portfolio/live_strategy.py @@ -158,8 +158,14 @@ def _instance_order_wrappers(ledger, bt_otv, bt_ov): return _done(bt_order(security, -sell, *args, **kwargs)) price = _price(security, own) if price <= 0: - logger.warning("[instance-order] %s 无有效价格 → 透传引擎 target 语义", security) - return _done(bt_otv(security, value, *args, **kwargs)) + # issue#38 fail-closed:绝不透传引擎 target 语义——引擎按全账户 + # 视图算 target,账户有量会替别家卖到目标值(300059 账户级放行的 + # 变体)。无价=市价拿不到且账本无此票无成本可回退 → 拒单, + # 宁可少买不可互卖。 + logger.warning( + "[instance-order] %s 无有效价格(账本无成本可回退) → 拒单 " + "fail-closed,不透传引擎账户级 target 语义", security) + return None target = int(value / price) // lot * lot diff = target - int(own["amount"]) if diff >= lot: @@ -181,9 +187,12 @@ def _instance_order_wrappers(ledger, bt_otv, bt_ov): own = _own(security) price = _price(security, own) if price <= 0: - logger.warning("[instance-order] %s 无有效价格 → 负 order_value 透传引擎", - security) - return _done(bt_ov(security, value, *args, **kwargs)) + # issue#38 fail-closed:负 order_value 无价折算不出股数,透传引擎 + # =按全账户卖出,可卖别家归属——拒单。 + logger.warning( + "[instance-order] %s 无有效价格(账本无成本可回退) → 负 order_value " + "拒单 fail-closed,不透传引擎账户级卖出", security) + return None want = int(abs(value) / price + 0.999) # 向上取整再由可卖量硬顶 sell = _sell_shares(security, want, own, "按价值卖出") if sell <= 0: diff --git a/tests/portfolio/test_live_instance_orders.py b/tests/portfolio/test_live_instance_orders.py index 68fb2bb..89a3199 100644 --- a/tests/portfolio/test_live_instance_orders.py +++ b/tests/portfolio/test_live_instance_orders.py @@ -200,3 +200,44 @@ def test_order_done_hook_silent_when_no_order(fakes): ret = otv("513030.XSHG", 0) assert ret is None and orders == [] assert fired == [] + + +# ------------------ 修复一A(issue#38):闸门认账本,账户级语义绝不放行 ------------------ +def test_double_sell_within_attribution_window_blocked(fakes): + """08-24 300059 事故形态:同窗两次清仓,第一笔 4400 已即时归因(191270c)→ + 第二笔闸门读到账本可卖 0 → 拒单,绝不碰 18 归属的另 4400(B 修法链路闸门侧钉死)。""" + orders, _, _ = fakes + led = _ledger_with("300059.XSHE", 4400, price=2.0, trade_date="2026-08-20") + # 模拟 191270c 即时归因:下单返回 → 同步把本笔卖出落账本 + led.on_order_done = lambda: led.apply_trade( + False, "300059.XSHE", 2.0, 4400, "t-sync", "2026-08-25") + otv, _ov = _instance_order_wrappers(led, None, None) + first = otv("300059.XSHE", 0) + second = otv("300059.XSHE", 0) + assert orders == [("300059.XSHE", -4400)] # 只有一笔,第二笔不再下 + assert first is not None and second is None + + +def test_otv_no_price_no_ledger_holding_fail_closed(fakes, monkeypatch): + """issue#38:无价且账本无此票(无成本可回退)→ 拒单 fail-closed。旧版透传 + 引擎 target 语义按全账户视图算 target,账户有量会替别家卖到目标值—— + 闸门数据源退化到账户级=300059 账户级放行的变体,必须堵死。""" + orders, _, otv_calls = fakes + import bullet_trade.data.api as bt_data + monkeypatch.setattr(bt_data, "get_current_data", lambda: _FakeCurrentData(0.0)) + led = _ledger_with("ANY.XSHG", 0) # 账本无 NEW.XSHG + otv, _ov = _instance_order_wrappers(led, None, None) + out = otv("NEW.XSHG", 60000) + assert out is None and orders == [] and otv_calls == [] + + +def test_negative_order_value_no_price_fail_closed(fakes, monkeypatch): + """issue#38:负 order_value 无价(账本无成本可折算)→ 拒单,不透传引擎 + 按全账户卖出。""" + orders, _, ov_calls = fakes + import bullet_trade.data.api as bt_data + monkeypatch.setattr(bt_data, "get_current_data", lambda: _FakeCurrentData(0.0)) + led = _ledger_with("ANY.XSHG", 0) + _otv, ov = _instance_order_wrappers(led, None, None) + out = ov("OTHER.XSHG", -30000) + assert out is None and orders == [] and ov_calls == []