Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/tools_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,25 @@
],
"additionalProperties": false
}
},
{
"name": "switch_account",
"description": "切换同花顺客户端当前活跃的资金账户(向 xiadan 窗口发送 Alt+N,N=账户在客户端账户下拉列表中的槽位序号)。仅在 xiadan 登录了多个账户时有意义。**盲切**:本工具不核验切换是否成功,受控端对账户身份无感知;切换后所有工具(查询/下单)都作用于新的当前账户。调用方必须紧接着用 balance/position 做指纹核对、确认账户无误后再继续操作。",
"inputSchema": {
"type": "object",
"properties": {
"slot": {
"type": "integer",
"description": "账户槽位序号(1-9),对应快捷键 Alt+N,与客户端账户下拉列表顺序一致",
"minimum": 1,
"maximum": 9
}
},
"required": [
"slot"
],
"additionalProperties": false
}
}
]
}
23 changes: 22 additions & 1 deletion src/trader/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@
"required": ["entrust_no"],
"additionalProperties": False
}
},
{
"name": "switch_account",
"description": "切换同花顺客户端当前活跃的资金账户(向 xiadan 窗口发送 Alt+N,N=账户在客户端账户下拉列表中的槽位序号)。仅在 xiadan 登录了多个账户时有意义。**盲切**:本工具不核验切换是否成功,受控端对账户身份无感知;切换后所有工具(查询/下单)都作用于新的当前账户。调用方必须紧接着用 balance/position 做指纹核对、确认账户无误后再继续操作。",
"inputSchema": {
"type": "object",
"properties": {
"slot": {
"type": "integer",
"description": "账户槽位序号(1-9),对应快捷键 Alt+N,与客户端账户下拉列表顺序一致",
"minimum": 1,
"maximum": 9
}
},
"required": ["slot"],
"additionalProperties": False
}
}
]
}
Expand All @@ -173,7 +190,7 @@ def load_tools_schema() -> dict[str, Any]:
schema = json.loads(json.dumps(FALLBACK_TOOLS_SCHEMA))

if not cfg.enable_ths_plugin:
trading_names = {"balance", "position", "orders_active", "orders_filled", "settlement", "watchlist", "buy", "sell", "cancel"}
trading_names = {"balance", "position", "orders_active", "orders_filled", "settlement", "watchlist", "buy", "sell", "cancel", "switch_account"}
schema["tools"] = [t for t in schema["tools"] if t.get("name") not in trading_names]

return schema
Expand All @@ -189,6 +206,7 @@ def load_tools_schema() -> dict[str, Any]:
"buy",
"sell",
"cancel",
"switch_account",
}


Expand Down Expand Up @@ -227,6 +245,7 @@ async def handle_call(
"buy",
"sell",
"cancel",
"switch_account",
}
if method in trading_methods and not cfg.enable_ths_plugin:
reply["ok"] = False
Expand Down Expand Up @@ -286,6 +305,8 @@ async def _invoke() -> Any:
return r
if method == "cancel":
return await backend.cancel(params.get("entrust_no"))
if method == "switch_account":
return await backend.switch_account(params.get("slot"))
return {"code": 1, "error": "内部错误"}

try:
Expand Down
38 changes: 38 additions & 0 deletions src/trader/ths/win.py
Original file line number Diff line number Diff line change
Expand Up @@ -1928,3 +1928,41 @@ async def cancel(self, entrust_no: str) -> dict[str, Any]:
if bound_err:
return bound_err
return await asyncio.to_thread(self._do_cancel, entrust_no)

async def switch_account(self, slot: Any) -> dict[str, Any]:
try:
slot = int(slot)
except (TypeError, ValueError):
return {"code": 1, "status": "failed",
"msg": f"slot 参数无效:{slot!r},须为 1-9 的整数"}
if not 1 <= slot <= 9:
return {"code": 1, "status": "failed",
"msg": f"slot 超出范围:{slot},须为 1-9 的整数"}
bound_err = self._ensure_bound()
if bound_err:
return bound_err
return await asyncio.to_thread(self.do_switch_account, slot)

def do_switch_account(self, slot: int):
"""向 xiadan 发送 Alt+N,切换多账户登录下的当前活跃资金账户。

盲切:新版 xiadan 的账户下拉框给每个已登录账户注册了 Alt+1..Alt+9
加速键(与下拉列表顺序一致)。这里只负责把窗口拉到前台并发按键,
不核验切换结果——受控端对账户身份保持无感知,切换后由调用方用
balance/position 做指纹核对再继续操作。"""
_activate_window(self.hwnd_main)
hot_key(["alt", str(slot)])
# 切换会触发资金/持仓面板重载,稍等再放行后续操作。
time.sleep(sleep_time * 2)
return {
"code": 0,
"status": "succeed",
"data": {
"slot": slot,
"msg": (
f"已向同花顺窗口发送 Alt+{slot}(盲切,未核验结果)。"
"后续所有查询/下单都作用于切换后的当前账户,"
"请先用 balance/position 核对账户身份再继续。"
),
},
}
19 changes: 18 additions & 1 deletion tests/test_dispatcher_envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ async def sell(self, stock_no, amount, price, client_order_id):
async def cancel(self, entrust_no):
return await self._run("cancel", entrust_no)

async def switch_account(self, slot):
return await self._run("switch_account", slot)


def _call(frame, result):
backend = FakeBackend(result)
Expand Down Expand Up @@ -181,11 +184,25 @@ def test_tools_list_returns_correct_schema():
assert "buy" in tool_names
assert "sell" in tool_names
assert "cancel" in tool_names

assert "switch_account" in tool_names

# 确保没有多余的 code 字段嵌套在 result 中
assert "code" not in reply["result"]


def test_switch_account_forwards_slot_and_single_layer_reply():
"""switch_account 路由到后端并透传 slot,回执保持单层信封。"""
frame = {"type": "call", "id": "sw-1", "method": "switch_account",
"params": {"slot": 2}}
reply, backend = _call(frame, {"code": 0, "status": "succeed",
"data": {"slot": 2}})

assert reply["ok"] is True
assert reply["id"] == "sw-1"
assert reply["result"]["data"]["slot"] == 2
assert backend.calls == [("switch_account", (2,))]


def test_fallback_schema_matches_file_schema():
"""验证内置的 FALLBACK_TOOLS_SCHEMA 与 docs/tools_schema.json 完全一致,防止三源漂移"""
import json
Expand Down
51 changes: 51 additions & 0 deletions tests/test_switch_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""switch_account 的 slot 参数闸:非法值必须在碰任何 Win32 之前被拒绝。

多账户盲切是真钱路径的入口——slot 打错(0、负数、字符串、None)绝不能
落到 hot_key 发键,必须在 async 包装层就地拦下并给出明确文案。
绑定/发键层用打桩隔离,全部用例可在非 Windows 平台运行。
"""
import asyncio

from trader.ths.win import WinThsBackend


def _switch(slot):
return asyncio.run(WinThsBackend().switch_account(slot))


def test_rejects_non_integer_slot():
for bad in (None, "abc", [1], {}):
result = _switch(bad)
assert result["code"] == 1
assert "slot 参数无效" in result["msg"]


def test_rejects_out_of_range_slot():
for bad in (0, -1, 10, 99):
result = _switch(bad)
assert result["code"] == 1
assert "slot 超出范围" in result["msg"]


def test_valid_slot_passes_gate_and_reaches_bind(monkeypatch):
"""合法 slot(含 '2' 这类可转数字串)通过参数闸、走到绑定阶段。"""
backend = WinThsBackend()
bind_err = {"code": 1, "error": "未绑定(桩)"}
monkeypatch.setattr(backend, "_ensure_bound", lambda: bind_err)
for ok in (1, 2, 9, "2"):
result = asyncio.run(backend.switch_account(ok))
assert result is bind_err


def test_coerced_int_slot_forwarded_to_do_switch(monkeypatch):
"""slot 以 int 形态透传给 do_switch_account('2' → 2)。"""
backend = WinThsBackend()
monkeypatch.setattr(backend, "_ensure_bound", lambda: None)
seen = []
monkeypatch.setattr(
backend, "do_switch_account",
lambda slot: (seen.append(slot) or {"code": 0, "data": {"slot": slot}}),
)
result = asyncio.run(backend.switch_account("2"))
assert result["code"] == 0
assert seen == [2]
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading