Switch LLM provider to z.ai GLM

This commit is contained in:
Azimkin
2026-09-21 20:35:08 +02:00
parent 0df55d295c
commit 316eefe58a
10 changed files with 219 additions and 35 deletions

View File

@@ -1,6 +1,6 @@
# LLM Trade Bot # LLM Trade Bot
Автономный DeepSeek-агент для выделенного **Bybit Demo Trading** либо **Binance Testnet** аккаунта. Один процесс работает только с одной биржей, выбранной в `config.toml`; одновременная торговля на обеих не поддерживается. Бот анализирует Spot и `linear` USDT-рынки два раза в день, умеет искать рыночный контекст в интернете и управляется через Telegram. Автономный GLM-агент через **z.ai** для выделенного **Bybit Demo Trading** либо **Binance Testnet** аккаунта. Один процесс работает только с одной биржей, выбранной в `config.toml`; одновременная торговля на обеих не поддерживается. Бот анализирует Spot и `linear` USDT-рынки два раза в день, умеет искать рыночный контекст в интернете и управляется через Telegram.
`linear` — публичное имя USDⓈ-M Futures: оно намеренно сохранено в инструментах агента и промптах. Production endpoints не поддерживаются. Для Binance адреса Spot Testnet и USDⓈ-M Futures Demo Testnet зафиксированы в коде и не настраиваются через TOML. `linear` — публичное имя USDⓈ-M Futures: оно намеренно сохранено в инструментах агента и промптах. Production endpoints не поддерживаются. Для Binance адреса Spot Testnet и USDⓈ-M Futures Demo Testnet зафиксированы в коде и не настраиваются через TOML.
@@ -24,10 +24,12 @@
2. Для Bybit создайте ключ в основном аккаунте после переключения в **Demo Trading**, а не на testnet. Настройте Unified account в `ISOLATED_MARGIN` и one-way position mode. 2. Для Bybit создайте ключ в основном аккаунте после переключения в **Demo Trading**, а не на testnet. Настройте Unified account в `ISOLATED_MARGIN` и one-way position mode.
3. Для Binance создайте два отдельных набора ключей: Spot Testnet на [testnet.binance.vision](https://testnet.binance.vision/) и USDⓈ-M Futures Testnet/Demo. У ключей должны быть права чтения и торговли. Futures-аккаунт должен использовать one-way mode; бот проверит это при запуске и выставит isolated margin/плечо перед входом. 3. Для Binance создайте два отдельных набора ключей: Spot Testnet на [testnet.binance.vision](https://testnet.binance.vision/) и USDⓈ-M Futures Testnet/Demo. У ключей должны быть права чтения и торговли. Futures-аккаунт должен использовать one-way mode; бот проверит это при запуске и выставит isolated margin/плечо перед входом.
4. Создайте Telegram-бота и получите `api_id`/`api_hash` приложения Telegram. 4. Создайте Telegram-бота и получите `api_id`/`api_hash` приложения Telegram.
5. Получите DeepSeek API key и Brave Search API key. 5. Получите z.ai API key и Brave Search API key.
6. Скопируйте `config.example.toml` в `config.toml`, а `prompts.example.md` в `prompts.md`. 6. Скопируйте `config.example.toml` в `config.toml`, а `prompts.example.md` в `prompts.md`.
7. Замените секреты только выбранного провайдера. Локальные конфиги исключены из Git. 7. Замените секреты только выбранного провайдера. Локальные конфиги исключены из Git.
Для GLM используйте раздел `[zai]`: базовый URL `https://api.z.ai/api/paas/v4` и модель `glm-4.6` уже заданы в примере. Ключ создаётся в z.ai; раздел `[deepseek]` больше не поддерживается.
```powershell ```powershell
Copy-Item config.example.toml config.toml Copy-Item config.example.toml config.toml
Copy-Item prompts.example.md prompts.md Copy-Item prompts.example.md prompts.md

View File

@@ -1,7 +1,7 @@
[deepseek] [zai]
api_key = "replace-me" api_key = "replace-me"
base_url = "https://api.deepseek.com" base_url = "https://api.z.ai/api/paas/v4"
model = "deepseek-flash" model = "glm-4.6"
thinking = true thinking = true
max_tool_rounds = 256 max_tool_rounds = 256

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "llm-trade-bot" name = "llm-trade-bot"
version = "0.1.0" version = "0.1.0"
description = "Autonomous DeepSeek trading agent for Bybit Demo" description = "Autonomous GLM trading agent for Bybit Demo"
readme = "README.md" readme = "README.md"
authors = [ authors = [
{ name = "Azimkin", email = "112006710+Azimkin@users.noreply.github.com" } { name = "Azimkin", email = "112006710+Azimkin@users.noreply.github.com" }

View File

@@ -15,7 +15,6 @@ from micro_agents import (
Injected, Injected,
LlmMessage, LlmMessage,
MessageAdded, MessageAdded,
OpenAIBasedLLM,
SessionContext, SessionContext,
Tool, Tool,
ToolCallFailed, ToolCallFailed,
@@ -31,6 +30,7 @@ from llm_trade_bot.models import Category, OpenTradeRequest, OrderType, Side
from llm_trade_bot.prompts import PromptStore from llm_trade_bot.prompts import PromptStore
from llm_trade_bot.risk import RiskEngine from llm_trade_bot.risk import RiskEngine
from llm_trade_bot.web_tools import SafeWebTools from llm_trade_bot.web_tools import SafeWebTools
from llm_trade_bot.zai_llm import ZaiLLM
class AgentTools: class AgentTools:
@@ -353,18 +353,18 @@ class AgentRunner:
self.exchange = exchange self.exchange = exchange
self.bybit = exchange self.bybit = exchange
self.prompts = PromptStore(config.prompts.file_path) self.prompts = PromptStore(config.prompts.file_path)
llm = OpenAIBasedLLM( llm = ZaiLLM(
api_url=config.deepseek.base_url, api_url=config.zai.base_url,
api_key=config.deepseek.api_key, api_key=config.zai.api_key,
model=config.deepseek.model, model=config.zai.model,
) )
self.agent_tools = AgentTools(config, database, exchange) self.agent_tools = AgentTools(config, database, exchange)
self.aggregator = Aggregator( self.aggregator = Aggregator(
llm, llm,
ToolManager(self.agent_tools.tools()), ToolManager(self.agent_tools.tools()),
master_prompt=_master_prompt, master_prompt=_master_prompt,
max_tool_rounds=config.deepseek.max_tool_rounds, max_tool_rounds=config.zai.max_tool_rounds,
thinking=config.deepseek.thinking, thinking=config.zai.thinking,
) )
async def run(self, trigger: str) -> str: async def run(self, trigger: str) -> str:

View File

@@ -11,7 +11,7 @@ from llm_trade_bot.service import TradeBotService
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Autonomous DeepSeek trader for Bybit Demo or Binance Testnet") parser = argparse.ArgumentParser(description="Autonomous GLM trader for Bybit Demo or Binance Testnet")
parser.add_argument("--config", default="config.toml", help="Path to TOML configuration") parser.add_argument("--config", default="config.toml", help="Path to TOML configuration")
parser.add_argument("--log-level", default="INFO", choices=("DEBUG", "INFO", "WARNING", "ERROR")) parser.add_argument("--log-level", default="INFO", choices=("DEBUG", "INFO", "WARNING", "ERROR"))
args = parser.parse_args() args = parser.parse_args()

View File

@@ -13,10 +13,10 @@ class ConfigError(ValueError):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class DeepSeekConfig: class ZaiConfig:
api_key: str api_key: str
base_url: str = "https://api.deepseek.com" base_url: str = "https://api.z.ai/api/paas/v4"
model: str = "deepseek-flash" model: str = "glm-4.6"
thinking: bool = True thinking: bool = True
max_tool_rounds: int = 16 max_tool_rounds: int = 16
@@ -99,7 +99,7 @@ class StorageConfig:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class AppConfig: class AppConfig:
deepseek: DeepSeekConfig zai: ZaiConfig
bybit: BybitConfig | None bybit: BybitConfig | None
telegram: TelegramConfig telegram: TelegramConfig
brave: BraveConfig brave: BraveConfig
@@ -121,7 +121,7 @@ def load_config(path: str | Path) -> AppConfig:
except tomllib.TOMLDecodeError as error: except tomllib.TOMLDecodeError as error:
raise ConfigError(f"Invalid TOML in {config_path}: {error}") from error raise ConfigError(f"Invalid TOML in {config_path}: {error}") from error
deepseek_raw = _section(raw, "deepseek") zai_raw = _section(raw, "zai")
exchange_raw = raw.get("exchange") exchange_raw = raw.get("exchange")
if exchange_raw is None: if exchange_raw is None:
if not isinstance(raw.get("bybit"), dict): if not isinstance(raw.get("bybit"), dict):
@@ -142,15 +142,15 @@ def load_config(path: str | Path) -> AppConfig:
risk_raw = _section(raw, "risk") risk_raw = _section(raw, "risk")
storage_raw = _section(raw, "storage") storage_raw = _section(raw, "storage")
deepseek = DeepSeekConfig( zai = ZaiConfig(
api_key=_secret(deepseek_raw, "api_key"), api_key=_secret(zai_raw, "api_key"),
base_url=_string(deepseek_raw, "base_url", "https://api.deepseek.com").rstrip("/"), base_url=_string(zai_raw, "base_url", "https://api.z.ai/api/paas/v4").rstrip("/"),
model=_string(deepseek_raw, "model", "deepseek-flash"), model=_string(zai_raw, "model", "glm-4.6"),
thinking=_boolean(deepseek_raw, "thinking", True), thinking=_boolean(zai_raw, "thinking", True),
max_tool_rounds=_integer(deepseek_raw, "max_tool_rounds", 16, minimum=1, maximum=256), max_tool_rounds=_integer(zai_raw, "max_tool_rounds", 16, minimum=1, maximum=256),
) )
if deepseek.base_url != "https://api.deepseek.com": if zai.base_url != "https://api.z.ai/api/paas/v4":
raise ConfigError("deepseek.base_url must be https://api.deepseek.com") raise ConfigError("zai.base_url must be https://api.z.ai/api/paas/v4")
bybit: BybitConfig | None = None bybit: BybitConfig | None = None
binance: BinanceConfig | None = None binance: BinanceConfig | None = None
@@ -228,7 +228,7 @@ def load_config(path: str | Path) -> AppConfig:
max_notes=_integer(storage_raw, "max_notes", 50, minimum=1, maximum=1000), max_notes=_integer(storage_raw, "max_notes", 50, minimum=1, maximum=1000),
max_notes_chars=_integer(storage_raw, "max_notes_chars", 20_000, minimum=100, maximum=200_000), max_notes_chars=_integer(storage_raw, "max_notes_chars", 20_000, minimum=100, maximum=200_000),
) )
return AppConfig(deepseek, bybit, telegram, brave, prompts, schedule, scanner, risk, storage, exchange, binance) return AppConfig(zai, bybit, telegram, brave, prompts, schedule, scanner, risk, storage, exchange, binance)
def _reject_binance_endpoint_overrides(section: dict[str, Any]) -> None: def _reject_binance_endpoint_overrides(section: dict[str, Any]) -> None:

View File

@@ -0,0 +1,148 @@
"""z.ai adapter for the provider-neutral micro-agents interface."""
from __future__ import annotations
from typing import Any
import orjson
from micro_agents import OpenAIBasedLLM
from micro_agents.conversation import (
Conversation,
LlmMessage,
Message,
ReasoningMessage,
ToolCallMessage,
ToolCallResponseMessage,
UserMessage,
)
import micro_agents.tools as tools_package
class ZaiLLM(OpenAIBasedLLM):
"""Use z.ai's OpenAI-compatible Chat Completions endpoint.
``OpenAIBasedLLM`` in micro-agents targets OpenAI's Responses API, which
z.ai does not expose. This adapter retains its tool-schema inference while
translating the agent conversation to the Chat Completions format.
"""
async def ask(
self,
conversation: Conversation,
tools: list[tools_package.Tool],
*,
instructions: str | None = None,
thinking: bool | None = None,
) -> list[Message]:
extra_body: dict[str, object] | None = None
if thinking is not None:
extra_body = {"thinking": {"type": "enabled" if thinking else "disabled"}}
response = await self._ai.chat.completions.create(
model=self._model,
messages=self._serialize_conversation(conversation, instructions),
tools=self._serialize_chat_tools(tools),
stream=False,
extra_body=extra_body,
)
if not response.choices:
raise ValueError("z.ai returned no choices")
message = response.choices[0].message
tool_calls = message.tool_calls or []
out: list[Message] = []
# GLM returns this field when thinking is enabled. Keeping it in the
# conversation lets the next call include it with the tool invocation.
reasoning = getattr(message, "reasoning_content", None)
if isinstance(reasoning, str) and reasoning:
out.append(ReasoningMessage(reasoning))
# A Chat Completions response with tool calls is represented by one
# assistant message. The content is normally empty in that case; do
# not create a second assistant message before the tool-call message.
if not tool_calls and isinstance(message.content, str) and message.content:
out.append(LlmMessage(message.content))
for call in tool_calls:
function = call.function
if not isinstance(call.id, str) or not isinstance(function.name, str):
raise ValueError("z.ai returned an invalid function call")
try:
arguments = orjson.loads(function.arguments)
except orjson.JSONDecodeError as error:
raise ValueError("z.ai returned invalid JSON function arguments") from error
if not isinstance(arguments, dict):
raise ValueError("z.ai function-call arguments must be an object")
out.append(ToolCallMessage(call.id, function.name, arguments))
return out
def _serialize_chat_tools(self, tools: list[tools_package.Tool]) -> list[dict[str, object]]:
result: list[dict[str, object]] = []
for tool in tools:
schema = self._serialize_tool(tool)
result.append(
{
"type": "function",
"function": {
"name": schema["name"],
"description": schema["description"],
"parameters": schema["parameters"],
},
}
)
return result
@staticmethod
def _serialize_conversation(conversation: Conversation, instructions: str | None) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
if instructions:
messages.append({"role": "system", "content": instructions})
items = conversation.messages
index = 0
pending_reasoning: str | None = None
while index < len(items):
item = items[index]
if isinstance(item, ReasoningMessage):
pending_reasoning = item.text()
index += 1
continue
if isinstance(item, UserMessage):
messages.append({"role": "user", "content": item.message()})
elif isinstance(item, LlmMessage):
assistant: dict[str, Any] = {"role": "assistant", "content": item.message()}
if pending_reasoning:
assistant["reasoning_content"] = pending_reasoning
pending_reasoning = None
messages.append(assistant)
elif isinstance(item, ToolCallMessage):
calls: list[dict[str, object]] = []
while index < len(items) and isinstance(items[index], ToolCallMessage):
call = items[index]
calls.append(
{
"id": call.unique_id(),
"type": "function",
"function": {
"name": call.tool_name(),
"arguments": orjson.dumps(call.arguments()).decode("utf-8"),
},
}
)
index += 1
assistant = {"role": "assistant", "content": None, "tool_calls": calls}
if pending_reasoning:
assistant["reasoning_content"] = pending_reasoning
pending_reasoning = None
messages.append(assistant)
continue
elif isinstance(item, ToolCallResponseMessage):
messages.append(
{
"role": "tool",
"tool_call_id": item.unique_id(),
"content": item.text(),
}
)
else:
raise ValueError(f"Unknown type of message {type(item).__name__}")
index += 1
return messages

View File

@@ -10,7 +10,7 @@ from llm_trade_bot.config import (
AppConfig, AppConfig,
BraveConfig, BraveConfig,
BybitConfig, BybitConfig,
DeepSeekConfig, ZaiConfig,
PromptsConfig, PromptsConfig,
RiskConfig, RiskConfig,
ScannerConfig, ScannerConfig,
@@ -48,7 +48,7 @@ class FakeBybit:
def make_config(database_path: Path) -> AppConfig: def make_config(database_path: Path) -> AppConfig:
return AppConfig( return AppConfig(
DeepSeekConfig("key"), ZaiConfig("key"),
BybitConfig("key", "secret"), BybitConfig("key", "secret"),
TelegramConfig(1, "hash", "token", frozenset({1})), TelegramConfig(1, "hash", "token", frozenset({1})),
BraveConfig("key"), BraveConfig("key"),

View File

@@ -5,8 +5,8 @@ import pytest
from llm_trade_bot.config import ConfigError, load_config from llm_trade_bot.config import ConfigError, load_config
CONFIG = """ CONFIG = """
[deepseek] [zai]
api_key = "deepseek-key" api_key = "zai-key"
[bybit] [bybit]
api_key = "bybit-key" api_key = "bybit-key"
api_secret = "bybit-secret" api_secret = "bybit-secret"
@@ -51,7 +51,7 @@ def write_config(tmp_path: Path, content: str = CONFIG) -> Path:
def test_loads_and_resolves_database_path(tmp_path: Path): def test_loads_and_resolves_database_path(tmp_path: Path):
path = write_config(tmp_path) path = write_config(tmp_path)
config = load_config(path) config = load_config(path)
assert config.deepseek.model == "deepseek-flash" assert config.zai.model == "glm-4.6"
assert config.telegram.admin_user_ids == frozenset({42}) assert config.telegram.admin_user_ids == frozenset({42})
assert config.storage.database_path == tmp_path / "state.db" assert config.storage.database_path == tmp_path / "state.db"
assert config.prompts.file_path == tmp_path / "prompts.md" assert config.prompts.file_path == tmp_path / "prompts.md"
@@ -63,8 +63,8 @@ def test_rejects_risk_above_hard_limit(tmp_path: Path):
load_config(path) load_config(path)
def test_rejects_non_official_deepseek_endpoint(tmp_path: Path): def test_rejects_non_official_zai_endpoint(tmp_path: Path):
changed = CONFIG.replace("api_key = \"deepseek-key\"", 'api_key = "deepseek-key"\nbase_url = "https://example.com"', 1) changed = CONFIG.replace("api_key = \"zai-key\"", 'api_key = "zai-key"\nbase_url = "https://example.com"', 1)
path = write_config(tmp_path, changed) path = write_config(tmp_path, changed)
with pytest.raises(ConfigError, match="base_url"): with pytest.raises(ConfigError, match="base_url"):
load_config(path) load_config(path)

34
tests/test_zai_llm.py Normal file
View File

@@ -0,0 +1,34 @@
from micro_agents.conversation import Conversation, ReasoningMessage, ToolCallMessage, ToolCallResponseMessage, UserMessage
from llm_trade_bot.zai_llm import ZaiLLM
def test_serializes_glm_tool_round_in_chat_completions_format():
conversation = Conversation(
[
UserMessage("analyse BTC"),
ReasoningMessage("I need market data."),
ToolCallMessage("call-1", "get_market_data", {"symbol": "BTCUSDT"}),
ToolCallResponseMessage("call-1", '{"last_price":"100000"}'),
]
)
messages = ZaiLLM._serialize_conversation(conversation, "Follow the risk limits.")
assert messages == [
{"role": "system", "content": "Follow the risk limits."},
{"role": "user", "content": "analyse BTC"},
{
"role": "assistant",
"content": None,
"reasoning_content": "I need market data.",
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {"name": "get_market_data", "arguments": '{"symbol":"BTCUSDT"}'},
}
],
},
{"role": "tool", "tool_call_id": "call-1", "content": '{"last_price":"100000"}'},
]