Initial commit

This commit is contained in:
Q committed 2026-04-22 00:56:29 +08:00
commit 8559f8e11e
47 files changed
+13510

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+14
View File
@@ -0,0 +1,14 @@
__pycache__/
*.pyc
*.pyo
.DS_Store
.venv/
venv/
.pytest_cache/
.mypy_cache/
data/cache/
data/scans/
data/trading-agent/decisions/
data/trading_agent_state.json
data/logs/
logs/
+20
View File
@@ -0,0 +1,20 @@
# License Notice
Copyright © 2026 Yaolin Wang. All rights reserved.
This repository is currently shared for evaluation, learning, and personal reference only.
You may review the source code and use it for personal, non-commercial reference. No permission is granted to:
- use this project commercially
- redistribute this project
- host or deploy this project as a paid or public service
- integrate this project into paid products or commercial workflows
- sublicense, resell, or repackage this project
Any commercial use, redistribution, hosted deployment, or integration into paid services requires prior written permission from the copyright holder.
For commercial licensing or other usage requests, please contact:
- X: https://x.com/ywa_ywa_ywa
- Telegram: https://t.me/ywa_yl
+128
View File
@@ -0,0 +1,128 @@
<p align="center">
<img src="dashboard/assets/aitd-readme-header.png" alt="AITD - AI Trading Agent for Everyone" width="520">
</p>
<p align="center">
<strong>中文</strong> | <a href="./README_EN.md">English</a>
</p>
# AITD - AI Trading Agent
🤖 AITD 是一个能让你在任何机器上部署AI交易机器人项目。可以在浏览器里完成配置、模拟盘验证和实盘运行。
<p align="center">
<img src="docs/overview.png" width="1200">
</p>
## 项目特色:
- ✍️ 完全可自定义交易 Prompt,最大化你的策略表现
- 可以定义AI交易员性格、交易核心原则、入场条件、仓位管理
- 支持将K线数据加入prompt辅助AI交易员决策
- 支持保存prompt, 加载、修改已保存prompt
- 🚀 支持动态交易池,方便自定义交易池
- 方便引用动态交易池,实时抓住热点品种
- 📈 支持自定义交易周期
- 🧠 实时查看AI交易员输入与决策
- 检查每轮AI交易员的输入信息是否符合预期
- 检查每轮AI交易员的交易操作是否符合预期
- 🕹️ 丰富的组合风控设置,详细的交易记录
- ⚙️ 一键切换模拟盘与实盘
- 🦞 支持主流AI模型Provider
- 🔑 完全本地运行,确保账户、prompt等信息的绝对安全
当前支持交易所
1. Crypto:
- ✅ Binance
- OKX (todo)
- BYBIT (todo)
- Gate (todo)
2. Futures:
- CTP (todo)
- IBKR (todo)
## 运行要求
✅ 只需要你的机器有`Python 3.11+`就可以完美运行
## 快速开始
下载后在项目根目录运行:
```bash
python3 run.py
```
也可以显式指定端口:
```bash
python3 run.py --port 1234
```
然后浏览器打开终端里打印出来的地址(默认端口号为8788),例如:
```text
http://127.0.0.1:8788/trader.html
```
## 第一次使用建议
- 准备好LLM API_KEY
- 准备好交易账户API和Secret, 在设置中将本机 IP 加入白名单(看各自交易所要求)
1. 先进入 `模拟盘` 页面
2. 在 `AI模型配置` 里填写 `Provider / Model / API Key / Base URL`
3. 如果需要代理,在 `代理配置` 里开启并填写代理地址
4. 在 `Prompt` 页面编辑交易逻辑并进行测试,确保AI模型工作正常
5. 在 `候选池` 页面选择:
- `静态候选池`:手动输入想要交易标的 symbols
- `动态候选池`:写 Python function 自动获取动态 symbols
6. 点击当前页面的 `启动交易`。
7. 在 `交易 / Prompt / Log` 里确认是否正常工作
8. 模拟盘完整跑通后,配置实盘账号,启动实盘交易
## 功能概览
- `交易` 显示账户、权益曲线、当前持仓、候选池和最近决策
<p align="center">
<img src="docs/portfolio.png" width="1200">
</p>
- `Prompt` 让用户修改交易逻辑,仓位管理逻辑,并测试 Prompt 是否能正常工作
<p align="center">
<img src="docs/custom_prompt.png" width="1200">
</p>
- `候选池` 支持静态 symbols 和动态 Python function 二选一
<p align="center">
<img src="docs/dynamic_asset_universe.png" width="1200">
</p>
- `Log` 显示当前进程的运行日志和错误信息
## 关键文件
- [run.py](./run.py)
启动入口
- [dashboard/](./dashboard)
浏览器界面资源
- [config/](./config)
默认配置文件
---
## 联系方式
- X: [@ywa_ywa_ywa](https://x.com/ywa_ywa_ywa)
- Telegram: [@ywa_yl](https://t.me/ywa_yl)
## 版权与使用
Copyright © 2026 Yaolin Wang. All rights reserved.
本仓库当前仅用于评估、学习和个人参考。
任何商业使用、重新分发、托管部署或付费服务集成,均需事先获得书面许可。
如需商用授权,请通过上方联系方式联系。
+127
View File
@@ -0,0 +1,127 @@
<p align="center">
<img src="dashboard/assets/aitd-readme-header.png" alt="AITD - AI Trading Agent for Everyone" width="520">
</p>
<p align="center">
<a href="./README.md">中文</a> | <strong>English</strong>
</p>
# AITD - AI Trading Agent
🤖 AITD is a local AI trading agent project that can run on almost any machine. You can configure it, validate in paper trading, and run live trading directly in the browser.
<p align="center">
<img src="docs/overview.png" width="1200">
</p>
## Highlights
- Fully customizable trading prompts so you can shape the strategy around your own style
- Define the AI trader role, core principles, entry rules, and position management rules
- Save prompts, load historical prompts, and switch to a saved prompt with one click
- Dynamic candidate universe support for flexible symbol selection
- Useful for sourcing hot symbols from your own workflow
- Custom trading intervals
- Real-time visibility into AI trader inputs and decisions
- Check whether the input data for each cycle looks correct
- Check whether the trading actions from each cycle look correct
- Rich portfolio risk controls and detailed trade records
- One-click switching between paper trading and live trading
- Support for major AI model providers
- Fully local runtime so account credentials, prompts, and other sensitive information stay on your machine
## Supported Exchanges
1. Crypto:
- ✅ Binance
- OKX (todo)
- BYBIT (todo)
- Gate (todo)
2. Futures:
- CTP (todo)
- IBKR (todo)
## Requirements
✅ All you need is `Python 3.11+`
## Quick Start
After downloading the project, run this in the project root:
```bash
python3 run.py
```
You can also specify the port explicitly:
```bash
python3 run.py --port 1234
```
Then open the local address printed in the terminal, for example:
```text
http://127.0.0.1:8788/trader.html
```
## First-Time Setup Suggestions
- Prepare your LLM `API_KEY`
- Prepare your exchange `API Key / Secret`, and add your local machine IP to the exchange whitelist if required
1. Start from the `Paper Trading` page.
2. In `AI Model Config`, fill in `Provider / Model / API Key / Base URL`.
3. If you need a proxy, enable it in `Proxy Config` and enter the proxy address.
4. In the `Prompt` tab, edit your trading logic and test it to make sure the AI model works correctly.
5. In the `Candidate Pool` tab, choose one of the following:
- `Static Candidate Pool`: manually enter the symbols you want to trade
- `Dynamic Candidate Pool`: write a Python function to fetch dynamic symbols
6. Click `Start Trading` on the current page.
7. Check `Trading / Prompt / Log` to confirm everything is working as expected.
8. After paper trading runs end to end successfully, configure your live account and start live trading.
## Feature Overview
- `Trading` shows account status, equity curve, open positions, candidate symbols, and recent decisions
<p align="center">
<img src="docs/portfolio.png" width="1200">
</p>
- `Prompt` lets you edit trading logic, adjust position management logic, and test whether the prompt works correctly
<p align="center">
<img src="docs/custom_prompt.png" width="1200">
</p>
- `Candidate Pool` supports either static symbols or a dynamic Python function
<p align="center">
<img src="docs/dynamic_asset_universe.png" width="1200">
</p>
- `Log` shows runtime logs and error messages from the current process
## Key Files
- [run.py](./run.py)
Startup entry point
- [dashboard/](./dashboard)
Browser UI assets
- [config/](./config)
Default configuration files
---
## Contact
- X: [@ywa_ywa_ywa](https://x.com/ywa_ywa_ywa)
- Telegram: [@ywa_yl](https://t.me/ywa_yl)
## Copyright and Usage
Copyright © 2026 Yaolin Wang. All rights reserved.
This repository is currently shared for evaluation, learning, and personal reference only.
Any commercial use, redistribution, hosted deployment, or integration into paid services requires prior written permission.
For commercial licensing, please contact me through the links above.
+285
View File
@@ -0,0 +1,285 @@
# AITD 使用说明
这份文档按“第一次拿到这个文件夹、几乎不了解项目”的情况来写。
## 1. 这是什么
AITD 是一个本地运行的交易代理页面。
它会:
- 读取你配置的候选池
- 获取 Binance Futures 行情
- 把行情、账户、持仓、风控和你的交易逻辑拼成模型输入
- 调用外部模型 API 生成交易决策
- 在本地网页里展示账户、持仓、候选池、最近决策和日志
## 2. 运行要求
只需要:
- `Python 3.11+`
- 浏览器
- 网络连接
不需要:
- Node.js
- npm
- 额外 Python 第三方包
## 3. 如何启动
在项目根目录运行:
```bash
python3 run.py
```
如果你想指定端口,也可以这样运行:
```bash
python3 run.py --port 1234
```
终端会输出一个本地地址,例如:
```text
http://127.0.0.1:8788/trader.html
```
把这个地址复制到浏览器打开即可。
如果 `8788` 被占用,程序会自动切到附近端口。
## 4. 页面怎么理解
页面大致分成两部分:
- 左侧
- 运行设置
- AI模型配置
- 代理配置
- 实盘账号配置
- 右侧
- `交易`
- `Prompt`
- `候选池`
- `Log`
顶部可以:
- 切换 `模拟盘 / 实盘`
- 启动或暂停当前页面对应的交易循环
- 在深色和浅色主题之间切换
## 5. 最推荐的首次使用流程
1. 运行 `python3 run.py`
2. 打开网页
3. 保持在 `模拟盘` 页面
4. 在 `AI模型配置` 填写:
- `Provider`
- `Model`
- `Base URL`
- `API Key`
5. 如果需要代理,在 `代理配置` 里填写:
- 是否启用代理
- 代理地址
- 不走代理的地址
6. 在 `Prompt` 页面填写交易逻辑
7. 在 `候选池` 页面选择:
- `静态候选池`
- 或 `动态候选池`
8. 点当前页面的 `启动交易`
9. 观察:
- `交易` tab 是否出现最近决策
- `Prompt` tab 的测试输出是否正常
- `Log` tab 是否有错误
## 6. Prompt 怎么改
你不需要直接编辑原始 JSON。
前端里有 4 个输入项:
- `role`
- `core_principles`
- `entry_preferences`
- `position_management`
保存后,后端会自动拼成:
```text
config/trading_prompt.json
```
你只负责交易逻辑本身。
系统会自动拼进去的内容包括:
- 当前行情
- 当前账户权益
- 当前持仓
- 风控上限
- 当前模式
## 7. Prompt 测试怎么用
`Prompt` 页面有一个测试按钮。
它会:
- 用你当前页面里的 Prompt 内容
- 加上系统上下文
- 调用模型 API
- 返回测试输出
它不会真的发单。
如果这里测试都不通过,就先不要启动交易。
## 8. 候选池怎么用
候选池有两种模式,二选一。
### 静态候选池
你手动输入一组 symbols,例如:
```text
BTCUSDT
ETHUSDT
SOLUSDT
```
保存后生效。
### 动态候选池
你写一个 Python function:
```python
def load_candidate_symbols(context):
return ["BTCUSDT", "ETHUSDT"]
```
这个函数会在每轮交易决策前自动执行。
你可以让它:
- 读本地文件
- 读本地数据库
- 请求外部 API
要求只有一个:
- `load_candidate_symbols` 最终返回 `list`
## 9. AI 模型配置
支持的 provider:
- GPT
- Claude
- DeepSeek
- Qwen
- 自定义 OpenAI 兼容接口
页面里 `Model` 默认是下拉列表,尽量避免拼写错误。
高级参数如:
- `Timeout`
- `Temperature`
- `Max Output Tokens`
默认是折叠的,按需展开即可。
## 10. 代理配置
代理配置会统一影响:
- Binance 公共行情请求
- Binance 实盘请求
- 模型 API 请求
如果你的网络不需要代理,可以保持关闭。
## 11. 模拟盘和实盘
### 模拟盘
- 默认更适合先测试
- 不会真的下单
### 实盘
只有同时满足下面条件才会真的下单:
- 当前在 `实盘` 页面
- `启用实盘` 已勾选
- `模拟下单` 未勾选
- `API Key / API Secret` 已填写
建议顺序:
1. 先把模拟盘跑通
2. 再填写 Binance 实盘凭证
3. 先用 `模拟下单`
4. 最后才开启真实实盘
## 12. 运行后哪些文件会变化
常见会更新的本地运行文件:
- `data/cache/...`
- `data/scans/latest.json`
- `data/trading_agent_state.json`
- `data/trading-agent/decisions/...`
这些都不建议提交到 GitHub。
## 13. 目录说明
- `run.py`
- 启动本地服务
- `backend/`
- Python 后端代码
- `dashboard/`
- 前端页面
- `config/trading_agent.json`
- 运行设置
- `config/llm_provider.json`
- AI 模型配置
- `config/network.json`
- 代理配置
- `config/live_trading.json`
- 实盘账号配置
- `config/fixed_universe.json`
- 静态候选池配置
- `config/candidate_source.py`
- 动态候选池函数
- `config/trading_prompt.json`
- 保存后的交易逻辑
## 14. 常见问题
### 页面里看到 “LLM API key is missing”
说明还没在 `AI模型配置` 里填模型 key。
### Prompt 测试失败
优先检查:
- `Provider`
- `Model`
- `Base URL`
- `API Key`
- 代理是否需要开启
### Live 页面没有真正下单
请检查:
- 当前是否在 `实盘` 页面
- `启用实盘` 是否已勾选
- `模拟下单` 是否已经关闭
- Binance `API Key / API Secret` 是否已填写
### 候选池在哪里改
有两种方式:
- 直接在页面的 `候选池` 里手动填写 symbols
- 启用动态候选池,在 `config/candidate_source.py` 里定义 `load_candidate_symbols(context)`
### 风控为什么不会跟着 Prompt 一起消失
这是故意的。
Prompt 只负责交易逻辑;
风险上限、持仓数量、回撤限制始终由 Python 后端强制控制。
+2
View File
@@ -0,0 +1,2 @@
"""Python runtime for the local trading agent."""
+819
View File
@@ -0,0 +1,819 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
import re
from typing import Any
from .exchanges.catalog import DEFAULT_EXCHANGE_ID, exchange_catalog_payload, exchange_config, normalize_exchange_id
from .utils import CONFIG_DIR, clamp, clean_bool, current_run_date, now_iso, read_json, write_json
TRADING_SETTINGS_PATH = CONFIG_DIR / "trading_agent.json"
DASHBOARD_SETTINGS_PATH = CONFIG_DIR / "dashboard_settings.json"
FIXED_UNIVERSE_PATH = CONFIG_DIR / "fixed_universe.json"
CANDIDATE_SOURCE_PATH = CONFIG_DIR / "candidate_source.py"
LIVE_TRADING_PATH = CONFIG_DIR / "live_trading.json"
LLM_PROVIDER_PATH = CONFIG_DIR / "llm_provider.json"
NETWORK_SETTINGS_PATH = CONFIG_DIR / "network.json"
PROMPT_SETTINGS_PATH = CONFIG_DIR / "trading_prompt.json"
PROMPT_LIBRARY_PATH = CONFIG_DIR / "trading_prompt_library.json"
DEFAULT_TRADING_SETTINGS = {
"version": 2,
"updated": "2026-04-20",
"description": "Python-only local trading agent settings.",
"mode": "paper",
"activeExchange": DEFAULT_EXCHANGE_ID,
"server": {
"host": "127.0.0.1",
"port": 8788,
},
"decisionIntervalMinutes": 15,
"initialCapitalUsd": 1000,
"maxNewPositionsPerCycle": 1,
"maxOpenPositions": 4,
"maxPositionNotionalUsd": 150,
"maxGrossExposurePct": 100,
"maxAccountDrawdownPct": 20,
"riskPerTradePct": 2.5,
"minConfidence": 60,
"paperFeesBps": 4,
"allowShorts": True,
"paperTrading": {
"enabled": False,
},
"liveTrading": {
"enabled": False,
},
"liveExecution": {
"configPath": "config/live_trading.json",
"useExchangeProtectionOrders": True,
"note": "Only enable live routing after paper verification, exchange API setup, and tiny-size dry-run validation."
}
}
DEFAULT_DASHBOARD_SETTINGS = {
"version": 1,
"updated": "2026-04-20",
"timezone": "Asia/Shanghai",
"pageAutoRefreshSeconds": 30,
"marketAutoScanEnabled": True,
"marketScanIntervalMinutes": 60,
"marketScanOffsetMinute": 7,
}
DEFAULT_LIVE_TRADING_SETTINGS = {
"version": 1,
"updated": "2026-04-20",
"exchange": DEFAULT_EXCHANGE_ID,
"market": exchange_config(DEFAULT_EXCHANGE_ID)["market"],
"enabled": False,
"baseUrl": exchange_config(DEFAULT_EXCHANGE_ID)["defaultBaseUrl"],
"apiKey": "",
"apiSecret": "",
"apiPassphrase": "",
"recvWindow": 5000,
"dryRun": True,
"positionMode": "oneway",
"marginType": "cross",
"defaultLeverage": 3,
"note": "Fill the API credentials, keep dryRun on first, and only disable dryRun after you have verified the full flow."
}
DEFAULT_PROVIDER_SETTINGS = {
"version": 1,
"updated": "2026-04-20",
"preset": "gpt",
"apiStyle": "openai",
"model": "gpt-5.4-mini",
"baseUrl": "https://api.openai.com/v1",
"apiKey": "",
"timeoutSeconds": 45,
"temperature": 0.2,
"maxOutputTokens": 1200,
"anthropicVersion": "2023-06-01",
"customHeaders": {},
}
DEFAULT_NETWORK_SETTINGS = {
"version": 1,
"updated": "2026-04-20",
"proxyEnabled": False,
"proxyUrl": "",
"noProxy": ["127.0.0.1", "localhost"],
}
DEFAULT_FIXED_UNIVERSE_SETTINGS = {
"version": 1,
"updated": "2026-04-20",
"description": "Fixed futures symbol universe for the open-source trading-agent build. Edit this list to change what the agent reviews.",
"symbols": [
"BTCUSDT",
"ETHUSDT",
"SOLUSDT",
"SUIUSDT",
"DOGEUSDT",
"1000PEPEUSDT",
"ENAUSDT",
"WIFUSDT",
"ARKMUSDT",
"BLURUSDT",
"API3USDT",
"ZECUSDT",
"ONDOUSDT",
"ARBUSDT",
"LINKUSDT",
"AAVEUSDT",
"CRVUSDT",
"FETUSDT",
"PENDLEUSDT",
"TIAUSDT",
],
"dynamicSource": {
"enabled": False,
"functionName": "load_candidate_symbols",
"functionFile": "config/candidate_source.py",
},
}
DEFAULT_CANDIDATE_SOURCE_CODE = """from pathlib import Path
import json
def load_candidate_symbols(context):
\"\"\"Return a list of futures symbols to scan.\"\"\"
manual_symbols = context["manual_symbols"]
# Example: read a local JSON file and return payload["symbols"]
# payload_path = Path(context["project_root"]) / "data" / "my_symbols.json"
# payload = json.loads(payload_path.read_text(encoding="utf-8"))
# return payload.get("symbols", [])
return manual_symbols
"""
PROMPT_KLINE_FEED_OPTIONS = ["1m", "5m", "15m"]
DEFAULT_PROMPT_SETTINGS = {
"version": 1,
"updated": "2026-04-20",
"name": "default_trading_logic",
"presetId": None,
"klineFeeds": {
"1m": {"enabled": False, "limit": 120},
"5m": {"enabled": False, "limit": 96},
"15m": {"enabled": True, "limit": 64},
},
"decision_logic": {
"role": "You are a careful crypto futures trader.",
"core_principles": [
"Protect capital first."
],
"entry_preferences": [
"Only trade clear setups."
],
"position_management": [
"Cut losers quickly and take profit simply."
],
"response_style": [
"Return strict JSON only.",
"Every action must include a short reason tied to current market structure."
]
}
}
DEFAULT_PROMPT_LIBRARY_SETTINGS = {
"version": 1,
"updated": "2026-04-21",
"prompts": [],
}
PROVIDER_PRESET_MAP = {
"gpt": {
"apiStyle": "openai",
"baseUrl": "https://api.openai.com/v1",
},
"deepseek": {
"apiStyle": "openai",
"baseUrl": "https://api.deepseek.com/v1",
},
"qwen": {
"apiStyle": "openai",
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
"claude": {
"apiStyle": "anthropic",
"baseUrl": "https://api.anthropic.com/v1",
},
"custom": {
"apiStyle": "openai",
"baseUrl": "",
},
}
def _with_default_file(path: Path, default_payload: dict[str, Any]) -> dict[str, Any]:
if not path.exists():
write_json(path, default_payload)
payload = read_json(path, {})
if not isinstance(payload, dict):
payload = {}
return payload
def _with_default_text_file(path: Path, default_text: str) -> str:
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(default_text.rstrip() + "\n", encoding="utf-8")
try:
return path.read_text(encoding="utf-8")
except Exception:
return default_text.rstrip() + "\n"
def read_trading_settings() -> dict[str, Any]:
payload = _with_default_file(TRADING_SETTINGS_PATH, DEFAULT_TRADING_SETTINGS)
normalized_payload = {key: value for key, value in payload.items() if key in DEFAULT_TRADING_SETTINGS}
live_settings = payload.get("liveExecution") if isinstance(payload.get("liveExecution"), dict) else {}
server_settings = payload.get("server") if isinstance(payload.get("server"), dict) else {}
paper_trading = payload.get("paperTrading") if isinstance(payload.get("paperTrading"), dict) else {}
live_trading = payload.get("liveTrading") if isinstance(payload.get("liveTrading"), dict) else {}
return {
**deepcopy(DEFAULT_TRADING_SETTINGS),
**normalized_payload,
"updated": payload.get("updated") or current_run_date(),
"mode": "live" if str(payload.get("mode", "paper")).strip().lower() == "live" else "paper",
"activeExchange": normalize_exchange_id(payload.get("activeExchange"), capability="market"),
"decisionIntervalMinutes": int(clamp(payload.get("decisionIntervalMinutes"), 5, 1440)),
"initialCapitalUsd": clamp(payload.get("initialCapitalUsd"), 1, 1_000_000),
"maxNewPositionsPerCycle": int(clamp(payload.get("maxNewPositionsPerCycle"), 1, 100)),
"maxOpenPositions": int(clamp(payload.get("maxOpenPositions"), 1, 1000)),
"maxPositionNotionalUsd": clamp(payload.get("maxPositionNotionalUsd"), 20, 10_000_000_000),
"maxGrossExposurePct": clamp(payload.get("maxGrossExposurePct"), 10, 10_000),
"maxAccountDrawdownPct": clamp(payload.get("maxAccountDrawdownPct"), 5, 90),
"riskPerTradePct": clamp(payload.get("riskPerTradePct"), 0.25, 100),
"minConfidence": clamp(payload.get("minConfidence"), 1, 100),
"paperFeesBps": clamp(payload.get("paperFeesBps"), 0, 50),
"allowShorts": clean_bool(payload.get("allowShorts"), True),
"paperTrading": {
**deepcopy(DEFAULT_TRADING_SETTINGS["paperTrading"]),
**paper_trading,
"enabled": clean_bool(paper_trading.get("enabled"), DEFAULT_TRADING_SETTINGS["paperTrading"]["enabled"]),
},
"liveTrading": {
**deepcopy(DEFAULT_TRADING_SETTINGS["liveTrading"]),
**live_trading,
"enabled": clean_bool(live_trading.get("enabled"), DEFAULT_TRADING_SETTINGS["liveTrading"]["enabled"]),
},
"server": {
"host": str(server_settings.get("host") or DEFAULT_TRADING_SETTINGS["server"]["host"]),
"port": int(clamp(server_settings.get("port"), 1024, 65535)),
},
"liveExecution": {
**deepcopy(DEFAULT_TRADING_SETTINGS["liveExecution"]),
"configPath": str(live_settings.get("configPath") or DEFAULT_TRADING_SETTINGS["liveExecution"]["configPath"]),
"useExchangeProtectionOrders": clean_bool(
live_settings.get("useExchangeProtectionOrders"),
DEFAULT_TRADING_SETTINGS["liveExecution"]["useExchangeProtectionOrders"],
),
},
}
def write_trading_settings(patch: dict[str, Any]) -> dict[str, Any]:
current = read_trading_settings()
normalized_patch = {key: value for key, value in patch.items() if key in current}
live_patch = patch.get("liveExecution") if isinstance(patch.get("liveExecution"), dict) else {}
server_patch = patch.get("server") if isinstance(patch.get("server"), dict) else {}
paper_trading_patch = patch.get("paperTrading") if isinstance(patch.get("paperTrading"), dict) else {}
live_trading_patch = patch.get("liveTrading") if isinstance(patch.get("liveTrading"), dict) else {}
next_payload = {
**current,
**normalized_patch,
"updated": current_run_date(),
"mode": "live" if str(patch.get("mode", current["mode"])).strip().lower() == "live" else "paper",
"activeExchange": normalize_exchange_id(patch.get("activeExchange", current["activeExchange"]), capability="market"),
"decisionIntervalMinutes": int(clamp(patch.get("decisionIntervalMinutes", current["decisionIntervalMinutes"]), 5, 1440)),
"initialCapitalUsd": clamp(patch.get("initialCapitalUsd", current["initialCapitalUsd"]), 1, 1_000_000),
"maxNewPositionsPerCycle": int(clamp(patch.get("maxNewPositionsPerCycle", current["maxNewPositionsPerCycle"]), 1, 100)),
"maxOpenPositions": int(clamp(patch.get("maxOpenPositions", current["maxOpenPositions"]), 1, 1000)),
"maxPositionNotionalUsd": clamp(patch.get("maxPositionNotionalUsd", current["maxPositionNotionalUsd"]), 20, 10_000_000_000),
"maxGrossExposurePct": clamp(patch.get("maxGrossExposurePct", current["maxGrossExposurePct"]), 10, 10_000),
"maxAccountDrawdownPct": clamp(patch.get("maxAccountDrawdownPct", current["maxAccountDrawdownPct"]), 5, 90),
"riskPerTradePct": clamp(patch.get("riskPerTradePct", current["riskPerTradePct"]), 0.25, 100),
"minConfidence": clamp(patch.get("minConfidence", current["minConfidence"]), 1, 100),
"paperFeesBps": clamp(patch.get("paperFeesBps", current["paperFeesBps"]), 0, 50),
"allowShorts": patch.get("allowShorts", current["allowShorts"]) is not False,
"paperTrading": {
**current["paperTrading"],
**paper_trading_patch,
"enabled": clean_bool(paper_trading_patch.get("enabled"), current["paperTrading"]["enabled"]),
},
"liveTrading": {
**current["liveTrading"],
**live_trading_patch,
"enabled": clean_bool(live_trading_patch.get("enabled"), current["liveTrading"]["enabled"]),
},
"server": {
"host": str(server_patch.get("host") or current["server"]["host"]),
"port": int(clamp(server_patch.get("port", current["server"]["port"]), 1024, 65535)),
},
"liveExecution": {
**current["liveExecution"],
"configPath": str(live_patch.get("configPath") or current["liveExecution"]["configPath"]),
"useExchangeProtectionOrders": clean_bool(
live_patch.get("useExchangeProtectionOrders"),
current["liveExecution"]["useExchangeProtectionOrders"],
),
},
}
write_json(TRADING_SETTINGS_PATH, next_payload)
return next_payload
def read_dashboard_settings() -> dict[str, Any]:
payload = _with_default_file(DASHBOARD_SETTINGS_PATH, DEFAULT_DASHBOARD_SETTINGS)
return {
**deepcopy(DEFAULT_DASHBOARD_SETTINGS),
**payload,
"updated": payload.get("updated") or current_run_date(),
"pageAutoRefreshSeconds": int(clamp(payload.get("pageAutoRefreshSeconds"), 10, 600)),
"marketAutoScanEnabled": clean_bool(payload.get("marketAutoScanEnabled"), True),
"marketScanIntervalMinutes": int(clamp(payload.get("marketScanIntervalMinutes"), 15, 1440)),
"marketScanOffsetMinute": int(clamp(payload.get("marketScanOffsetMinute"), 0, 59)),
}
def write_dashboard_settings(patch: dict[str, Any]) -> dict[str, Any]:
current = read_dashboard_settings()
next_payload = {
**current,
**patch,
"updated": current_run_date(),
"pageAutoRefreshSeconds": int(clamp(patch.get("pageAutoRefreshSeconds", current["pageAutoRefreshSeconds"]), 10, 600)),
"marketAutoScanEnabled": clean_bool(patch.get("marketAutoScanEnabled"), current["marketAutoScanEnabled"]),
"marketScanIntervalMinutes": int(clamp(patch.get("marketScanIntervalMinutes", current["marketScanIntervalMinutes"]), 15, 1440)),
"marketScanOffsetMinute": int(clamp(patch.get("marketScanOffsetMinute", current["marketScanOffsetMinute"]), 0, 59)),
}
write_json(DASHBOARD_SETTINGS_PATH, next_payload)
return next_payload
def read_candidate_source_code() -> str:
return _with_default_text_file(CANDIDATE_SOURCE_PATH, DEFAULT_CANDIDATE_SOURCE_CODE)
def write_candidate_source_code(code: str) -> str:
text = str(code or "").rstrip() + "\n"
CANDIDATE_SOURCE_PATH.parent.mkdir(parents=True, exist_ok=True)
CANDIDATE_SOURCE_PATH.write_text(text, encoding="utf-8")
return text
def _normalized_symbol_values(raw_symbols: Any, fallback: list[str]) -> list[str]:
if isinstance(raw_symbols, str):
candidate_values = [item.strip().upper() for item in raw_symbols.replace(",", "\n").splitlines()]
elif isinstance(raw_symbols, list):
candidate_values = [str(item).strip().upper() for item in raw_symbols]
else:
candidate_values = fallback
symbols: list[str] = []
for item in candidate_values:
if item and item not in symbols:
symbols.append(item)
return symbols
def _merged_fixed_universe_payload(current: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
raw_symbols = patch.get("symbols", current["symbols"])
dynamic_patch = patch.get("dynamicSource") if isinstance(patch.get("dynamicSource"), dict) else {}
symbols = _normalized_symbol_values(raw_symbols, current["symbols"])
return {
**current,
"updated": current_run_date(),
"symbols": symbols,
"dynamicSource": {
**current["dynamicSource"],
**dynamic_patch,
"enabled": clean_bool(dynamic_patch.get("enabled"), current["dynamicSource"]["enabled"]),
"functionName": str(dynamic_patch.get("functionName") or current["dynamicSource"]["functionName"]).strip() or DEFAULT_FIXED_UNIVERSE_SETTINGS["dynamicSource"]["functionName"],
"functionFile": str(dynamic_patch.get("functionFile") or current["dynamicSource"]["functionFile"]).strip() or DEFAULT_FIXED_UNIVERSE_SETTINGS["dynamicSource"]["functionFile"],
},
}
def read_fixed_universe() -> dict[str, Any]:
payload = _with_default_file(FIXED_UNIVERSE_PATH, DEFAULT_FIXED_UNIVERSE_SETTINGS)
_ = read_candidate_source_code()
payload_without_scoring = {key: value for key, value in payload.items() if key != "scoring"}
symbols = _normalized_symbol_values(payload.get("symbols", []), DEFAULT_FIXED_UNIVERSE_SETTINGS["symbols"])
dynamic_source = payload.get("dynamicSource") if isinstance(payload.get("dynamicSource"), dict) else {}
return {
**deepcopy(DEFAULT_FIXED_UNIVERSE_SETTINGS),
**payload_without_scoring,
"version": int(payload.get("version") or DEFAULT_FIXED_UNIVERSE_SETTINGS["version"]),
"updated": payload.get("updated") or current_run_date(),
"description": payload.get("description") or DEFAULT_FIXED_UNIVERSE_SETTINGS["description"],
"symbols": symbols,
"dynamicSource": {
**deepcopy(DEFAULT_FIXED_UNIVERSE_SETTINGS["dynamicSource"]),
**dynamic_source,
"enabled": clean_bool(dynamic_source.get("enabled"), DEFAULT_FIXED_UNIVERSE_SETTINGS["dynamicSource"]["enabled"]),
"functionName": str(dynamic_source.get("functionName") or DEFAULT_FIXED_UNIVERSE_SETTINGS["dynamicSource"]["functionName"]).strip(),
"functionFile": str(dynamic_source.get("functionFile") or DEFAULT_FIXED_UNIVERSE_SETTINGS["dynamicSource"]["functionFile"]).strip(),
},
}
def preview_fixed_universe(patch: dict[str, Any]) -> dict[str, Any]:
current = read_fixed_universe()
return _merged_fixed_universe_payload(current, patch)
def write_fixed_universe(patch: dict[str, Any]) -> dict[str, Any]:
next_payload = preview_fixed_universe(patch)
if "candidateSourceCode" in patch:
write_candidate_source_code(str(patch.get("candidateSourceCode") or ""))
write_json(FIXED_UNIVERSE_PATH, next_payload)
return next_payload
def _normalized_provider_defaults(preset: str) -> dict[str, Any]:
return PROVIDER_PRESET_MAP.get(preset, PROVIDER_PRESET_MAP["custom"])
def read_llm_provider() -> dict[str, Any]:
payload = _with_default_file(LLM_PROVIDER_PATH, DEFAULT_PROVIDER_SETTINGS)
preset = str(payload.get("preset") or DEFAULT_PROVIDER_SETTINGS["preset"]).strip().lower()
preset_defaults = _normalized_provider_defaults(preset)
base_url = str(payload.get("baseUrl") or preset_defaults["baseUrl"] or "").strip()
api_style = str(payload.get("apiStyle") or preset_defaults["apiStyle"]).strip().lower()
if api_style not in {"openai", "anthropic"}:
api_style = preset_defaults["apiStyle"]
return {
**deepcopy(DEFAULT_PROVIDER_SETTINGS),
**payload,
"updated": payload.get("updated") or current_run_date(),
"preset": preset if preset in PROVIDER_PRESET_MAP else "custom",
"apiStyle": api_style,
"model": str(payload.get("model") or DEFAULT_PROVIDER_SETTINGS["model"]).strip(),
"baseUrl": base_url,
"apiKey": str(payload.get("apiKey") or ""),
"timeoutSeconds": int(clamp(payload.get("timeoutSeconds"), 10, 180)),
"temperature": clamp(payload.get("temperature"), 0, 1.5),
"maxOutputTokens": int(clamp(payload.get("maxOutputTokens"), 256, 4096)),
"anthropicVersion": str(payload.get("anthropicVersion") or DEFAULT_PROVIDER_SETTINGS["anthropicVersion"]),
"customHeaders": payload.get("customHeaders") if isinstance(payload.get("customHeaders"), dict) else {},
}
def write_llm_provider(patch: dict[str, Any]) -> dict[str, Any]:
current = read_llm_provider()
preset = str(patch.get("preset", current["preset"])).strip().lower()
if preset not in PROVIDER_PRESET_MAP:
preset = "custom"
preset_defaults = _normalized_provider_defaults(preset)
api_style = str(patch.get("apiStyle", current["apiStyle"] or preset_defaults["apiStyle"])).strip().lower()
if api_style not in {"openai", "anthropic"}:
api_style = preset_defaults["apiStyle"]
next_payload = {
**current,
**patch,
"updated": current_run_date(),
"preset": preset,
"apiStyle": api_style,
"baseUrl": str(patch.get("baseUrl") or current.get("baseUrl") or preset_defaults["baseUrl"]).strip(),
"model": str(patch.get("model") or current["model"]).strip(),
"apiKey": str(patch.get("apiKey", current["apiKey"])),
"timeoutSeconds": int(clamp(patch.get("timeoutSeconds", current["timeoutSeconds"]), 10, 180)),
"temperature": clamp(patch.get("temperature", current["temperature"]), 0, 1.5),
"maxOutputTokens": int(clamp(patch.get("maxOutputTokens", current["maxOutputTokens"]), 256, 4096)),
"anthropicVersion": str(patch.get("anthropicVersion", current["anthropicVersion"])),
"customHeaders": patch.get("customHeaders") if isinstance(patch.get("customHeaders"), dict) else current["customHeaders"],
}
write_json(LLM_PROVIDER_PATH, next_payload)
return next_payload
def read_network_settings() -> dict[str, Any]:
payload = _with_default_file(NETWORK_SETTINGS_PATH, DEFAULT_NETWORK_SETTINGS)
no_proxy = payload.get("noProxy")
if isinstance(no_proxy, str):
normalized_no_proxy = [item.strip() for item in no_proxy.split(",") if item.strip()]
elif isinstance(no_proxy, list):
normalized_no_proxy = [str(item).strip() for item in no_proxy if str(item).strip()]
else:
normalized_no_proxy = deepcopy(DEFAULT_NETWORK_SETTINGS["noProxy"])
return {
**deepcopy(DEFAULT_NETWORK_SETTINGS),
**payload,
"updated": payload.get("updated") or current_run_date(),
"proxyEnabled": clean_bool(payload.get("proxyEnabled"), False),
"proxyUrl": str(payload.get("proxyUrl") or "").strip(),
"noProxy": normalized_no_proxy,
}
def write_network_settings(patch: dict[str, Any]) -> dict[str, Any]:
current = read_network_settings()
no_proxy = patch.get("noProxy", current["noProxy"])
if isinstance(no_proxy, str):
normalized_no_proxy = [item.strip() for item in no_proxy.split(",") if item.strip()]
elif isinstance(no_proxy, list):
normalized_no_proxy = [str(item).strip() for item in no_proxy if str(item).strip()]
else:
normalized_no_proxy = current["noProxy"]
next_payload = {
**current,
**patch,
"updated": current_run_date(),
"proxyEnabled": clean_bool(patch.get("proxyEnabled"), current["proxyEnabled"]),
"proxyUrl": str(patch.get("proxyUrl", current["proxyUrl"])).strip(),
"noProxy": normalized_no_proxy,
}
write_json(NETWORK_SETTINGS_PATH, next_payload)
return next_payload
def read_live_exchange_catalog() -> list[dict[str, Any]]:
return exchange_catalog_payload()
def read_live_trading_config() -> dict[str, Any]:
payload = _with_default_file(LIVE_TRADING_PATH, DEFAULT_LIVE_TRADING_SETTINGS)
normalized_payload = {key: value for key, value in payload.items() if key in DEFAULT_LIVE_TRADING_SETTINGS}
exchange_id = normalize_exchange_id(payload.get("exchange"), capability="trade")
exchange_meta = exchange_config(exchange_id)
enabled = clean_bool(payload.get("enabled"), False)
dry_run = False if enabled else clean_bool(payload.get("dryRun"), True)
return {
**deepcopy(DEFAULT_LIVE_TRADING_SETTINGS),
**normalized_payload,
"updated": payload.get("updated") or current_run_date(),
"exchange": exchange_id,
"market": exchange_meta["market"],
"enabled": enabled,
"dryRun": dry_run,
"recvWindow": int(clamp(payload.get("recvWindow"), 1000, 60000)),
"positionMode": "hedge" if str(payload.get("positionMode")).strip().lower() == "hedge" else "oneway",
"marginType": "isolated" if str(payload.get("marginType")).strip().lower() == "isolated" else "cross",
"defaultLeverage": int(clamp(payload.get("defaultLeverage"), 1, 125)),
"apiKey": str(payload.get("apiKey") or ""),
"apiSecret": str(payload.get("apiSecret") or ""),
"apiPassphrase": str(payload.get("apiPassphrase") or ""),
"baseUrl": str(payload.get("baseUrl") or exchange_meta["defaultBaseUrl"]),
"note": str(payload.get("note") or DEFAULT_LIVE_TRADING_SETTINGS["note"]),
}
def write_live_trading_config(patch: dict[str, Any]) -> dict[str, Any]:
current = read_live_trading_config()
normalized_patch = {key: value for key, value in patch.items() if key in current}
exchange_id = normalize_exchange_id(patch.get("exchange", current["exchange"]), capability="trade")
exchange_meta = exchange_config(exchange_id)
enabled = clean_bool(patch.get("enabled"), current["enabled"])
dry_run = False if enabled else clean_bool(patch.get("dryRun"), current["dryRun"])
requested_base_url = str(patch.get("baseUrl", current["baseUrl"])).strip()
if not requested_base_url:
requested_base_url = exchange_meta["defaultBaseUrl"]
next_payload = {
**current,
**normalized_patch,
"updated": current_run_date(),
"exchange": exchange_id,
"market": exchange_meta["market"],
"enabled": enabled,
"dryRun": dry_run,
"recvWindow": int(clamp(patch.get("recvWindow", current["recvWindow"]), 1000, 60000)),
"positionMode": "hedge" if str(patch.get("positionMode", current["positionMode"])).strip().lower() == "hedge" else "oneway",
"marginType": "isolated" if str(patch.get("marginType", current["marginType"])).strip().lower() == "isolated" else "cross",
"defaultLeverage": int(clamp(patch.get("defaultLeverage", current["defaultLeverage"]), 1, 125)),
"apiKey": str(patch.get("apiKey", current["apiKey"])),
"apiSecret": str(patch.get("apiSecret", current["apiSecret"])),
"apiPassphrase": str(patch.get("apiPassphrase", current["apiPassphrase"])),
"baseUrl": requested_base_url,
"note": str(patch.get("note", current["note"])),
}
write_json(LIVE_TRADING_PATH, next_payload)
return next_payload
def _normalized_prompt_kline_feeds(raw_feeds: Any, legacy_intervals: Any = None) -> dict[str, dict[str, Any]]:
defaults = deepcopy(DEFAULT_PROMPT_SETTINGS["klineFeeds"])
if isinstance(raw_feeds, dict):
normalized = {}
for interval in PROMPT_KLINE_FEED_OPTIONS:
current = raw_feeds.get(interval) if isinstance(raw_feeds.get(interval), dict) else {}
normalized[interval] = {
"enabled": clean_bool(current.get("enabled"), defaults[interval]["enabled"]),
"limit": int(clamp(current.get("limit"), 1, 300)),
}
if any(item["enabled"] for item in normalized.values()):
return normalized
normalized["15m"]["enabled"] = True
return normalized
enabled_from_legacy: set[str] = set()
if isinstance(legacy_intervals, str):
legacy_values = [item.strip().lower() for item in legacy_intervals.replace(",", "\n").splitlines()]
elif isinstance(legacy_intervals, list):
legacy_values = [str(item).strip().lower() for item in legacy_intervals]
else:
legacy_values = []
for item in legacy_values:
if item in PROMPT_KLINE_FEED_OPTIONS:
enabled_from_legacy.add(item)
normalized = deepcopy(defaults)
if enabled_from_legacy:
for interval in PROMPT_KLINE_FEED_OPTIONS:
normalized[interval]["enabled"] = interval in enabled_from_legacy
return normalized
def read_prompt_settings() -> dict[str, Any]:
payload = _with_default_file(PROMPT_SETTINGS_PATH, DEFAULT_PROMPT_SETTINGS)
decision_logic = payload.get("decision_logic")
if not isinstance(decision_logic, dict):
decision_logic = deepcopy(DEFAULT_PROMPT_SETTINGS["decision_logic"])
return {
**deepcopy(DEFAULT_PROMPT_SETTINGS),
**payload,
"updated": payload.get("updated") or current_run_date(),
"name": str(payload.get("name") or DEFAULT_PROMPT_SETTINGS["name"]),
"presetId": str(payload.get("presetId")).strip() if str(payload.get("presetId") or "").strip() else None,
"klineFeeds": _normalized_prompt_kline_feeds(payload.get("klineFeeds"), payload.get("klineIntervals")),
"decision_logic": decision_logic,
}
def write_prompt_settings(patch: dict[str, Any]) -> dict[str, Any]:
current = read_prompt_settings()
next_payload = {
**current,
**patch,
"updated": current_run_date(),
"name": str(patch.get("name", current["name"])),
"presetId": str(patch.get("presetId", current.get("presetId"))).strip() if str(patch.get("presetId", current.get("presetId")) or "").strip() else None,
"klineFeeds": _normalized_prompt_kline_feeds(patch.get("klineFeeds", current["klineFeeds"])),
"decision_logic": patch.get("decision_logic") if isinstance(patch.get("decision_logic"), dict) else current["decision_logic"],
}
write_json(PROMPT_SETTINGS_PATH, next_payload)
return next_payload
def _prompt_preset_name(value: Any, fallback: str = "untitled_prompt") -> str:
text = str(value or "").strip()
return text or fallback
def _prompt_preset_slug(value: str) -> str:
cleaned = re.sub(r"[^a-z0-9]+", "-", str(value or "").strip().lower()).strip("-")
return cleaned or "prompt"
def _normalized_prompt_preset(payload: dict[str, Any], *, preset_id: str | None = None) -> dict[str, Any]:
decision_logic = payload.get("decision_logic")
if not isinstance(decision_logic, dict):
decision_logic = deepcopy(DEFAULT_PROMPT_SETTINGS["decision_logic"])
return {
"id": str(preset_id or payload.get("id") or "").strip(),
"name": _prompt_preset_name(payload.get("name"), DEFAULT_PROMPT_SETTINGS["name"]),
"updatedAt": str(payload.get("updatedAt") or now_iso()),
"klineFeeds": _normalized_prompt_kline_feeds(payload.get("klineFeeds"), payload.get("klineIntervals")),
"decision_logic": {
**deepcopy(DEFAULT_PROMPT_SETTINGS["decision_logic"]),
**decision_logic,
"response_style": list(DEFAULT_PROMPT_SETTINGS["decision_logic"]["response_style"]),
},
}
def read_prompt_library() -> dict[str, Any]:
payload = _with_default_file(PROMPT_LIBRARY_PATH, DEFAULT_PROMPT_LIBRARY_SETTINGS)
prompts: list[dict[str, Any]] = []
seen_ids: set[str] = set()
raw_prompts = payload.get("prompts") if isinstance(payload.get("prompts"), list) else []
for index, item in enumerate(raw_prompts):
if not isinstance(item, dict):
continue
normalized = _normalized_prompt_preset(item)
preset_id = normalized["id"] or f"{_prompt_preset_slug(normalized['name'])}-{index + 1}"
while preset_id in seen_ids:
preset_id = f"{preset_id}-{len(seen_ids) + 1}"
normalized["id"] = preset_id
seen_ids.add(preset_id)
prompts.append(normalized)
prompts.sort(key=lambda item: str(item.get("updatedAt") or ""), reverse=True)
return {
**deepcopy(DEFAULT_PROMPT_LIBRARY_SETTINGS),
**payload,
"updated": payload.get("updated") or current_run_date(),
"prompts": prompts,
}
def _write_prompt_library_payload(prompts: list[dict[str, Any]]) -> dict[str, Any]:
payload = {
"version": DEFAULT_PROMPT_LIBRARY_SETTINGS["version"],
"updated": current_run_date(),
"prompts": prompts,
}
write_json(PROMPT_LIBRARY_PATH, payload)
return read_prompt_library()
def save_prompt_preset(payload: dict[str, Any]) -> dict[str, Any]:
library = read_prompt_library()
prompts = list(library["prompts"])
requested_id = str(payload.get("id") or payload.get("presetId") or "").strip()
if requested_id:
existing = next((item for item in prompts if item["id"] == requested_id), None)
if not existing:
raise ValueError(f"Prompt preset not found: {requested_id}")
preset = _normalized_prompt_preset(payload, preset_id=requested_id)
for index, item in enumerate(prompts):
if item["id"] == requested_id:
prompts[index] = preset
break
else:
base_slug = _prompt_preset_slug(_prompt_preset_name(payload.get("name"), DEFAULT_PROMPT_SETTINGS["name"]))
candidate_id = base_slug
suffix = 2
existing_ids = {item["id"] for item in prompts}
while candidate_id in existing_ids:
candidate_id = f"{base_slug}-{suffix}"
suffix += 1
preset = _normalized_prompt_preset(payload, preset_id=candidate_id)
prompts.append(preset)
library = _write_prompt_library_payload(prompts)
saved = next((item for item in library["prompts"] if item["id"] == preset["id"]), preset)
return {
"preset": saved,
"prompts": library["prompts"],
}
def read_prompt_preset(preset_id: str) -> dict[str, Any]:
target = str(preset_id or "").strip()
if not target:
raise ValueError("Prompt preset id is required.")
library = read_prompt_library()
preset = next((item for item in library["prompts"] if item["id"] == target), None)
if not preset:
raise ValueError(f"Prompt preset not found: {target}")
return preset
def rename_prompt_preset(preset_id: str, name: str) -> dict[str, Any]:
library = read_prompt_library()
prompts = list(library["prompts"])
target = str(preset_id or "").strip()
if not target:
raise ValueError("Prompt preset id is required.")
updated_name = _prompt_preset_name(name, DEFAULT_PROMPT_SETTINGS["name"])
updated_preset = None
for index, item in enumerate(prompts):
if item["id"] != target:
continue
updated_preset = {
**item,
"name": updated_name,
"updatedAt": now_iso(),
}
prompts[index] = updated_preset
break
if updated_preset is None:
raise ValueError(f"Prompt preset not found: {target}")
library = _write_prompt_library_payload(prompts)
saved = next((item for item in library["prompts"] if item["id"] == target), updated_preset)
return {
"preset": saved,
"prompts": library["prompts"],
}
def delete_prompt_preset(preset_id: str) -> dict[str, Any]:
library = read_prompt_library()
prompts = list(library["prompts"])
target = str(preset_id or "").strip()
if not target:
raise ValueError("Prompt preset id is required.")
remaining = [item for item in prompts if item["id"] != target]
if len(remaining) == len(prompts):
raise ValueError(f"Prompt preset not found: {target}")
library = _write_prompt_library_payload(remaining)
return {
"deletedId": target,
"prompts": library["prompts"],
}
+1667
View File
File diff suppressed because it is too large. Load diff
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from typing import Any
from .base import ExchangeGateway
_GATEWAYS: dict[str, ExchangeGateway] = {}
def _ensure_gateways() -> None:
if _GATEWAYS:
return
from .binance import BinanceGateway
from .bybit import BybitGateway
from .okx import OkxGateway
_GATEWAYS["binance"] = BinanceGateway()
_GATEWAYS["bybit"] = BybitGateway()
_GATEWAYS["okx"] = OkxGateway()
def get_exchange_gateway(exchange_id: str | None) -> ExchangeGateway:
_ensure_gateways()
key = str(exchange_id or "binance").strip().lower() or "binance"
if key not in _GATEWAYS:
supported = ", ".join(sorted(_GATEWAYS))
raise ValueError(f"Unsupported exchange: {key}. Supported exchanges: {supported}.")
return _GATEWAYS[key]
def get_live_exchange_gateway(live_config: dict[str, Any] | None = None) -> ExchangeGateway:
if live_config is None:
from ..config import read_live_trading_config
live_config = read_live_trading_config()
return get_exchange_gateway(live_config.get("exchange"))
def get_active_exchange_gateway(
exchange_id: str | None = None,
settings: dict[str, Any] | None = None,
) -> ExchangeGateway:
if exchange_id is not None:
return get_exchange_gateway(exchange_id)
if settings is None:
from ..config import read_trading_settings
settings = read_trading_settings()
return get_exchange_gateway(settings.get("activeExchange"))
def active_exchange_id(
exchange_id: str | None = None,
settings: dict[str, Any] | None = None,
) -> str:
if exchange_id is not None:
return str(exchange_id).strip().lower() or "binance"
if settings is None:
from ..config import read_trading_settings
settings = read_trading_settings()
return str(settings.get("activeExchange") or "binance").strip().lower() or "binance"
def base_asset_for_symbol(symbol: str, exchange_id: str | None = None) -> str:
normalized = str(symbol or "").strip().upper()
try:
return get_exchange_gateway(exchange_id).base_asset_from_symbol(normalized)
except Exception:
if normalized.endswith("USDT"):
return normalized[:-4]
if "-" in normalized:
return normalized.split("-", 1)[0]
return normalized
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class ExchangeGateway(ABC):
exchange_id: str = ""
display_name: str = ""
market_label: str = ""
default_backdrop_symbol: str = ""
def candidate_symbol_hint(self) -> str:
return f"{self.display_name} {self.market_label} symbols".strip()
def normalize_symbol(self, symbol: str) -> str:
return str(symbol or "").strip().upper()
@abstractmethod
def validate_symbol(self, symbol: str) -> bool:
raise NotImplementedError
@abstractmethod
def base_asset_from_symbol(self, symbol: str) -> str:
raise NotImplementedError
@abstractmethod
def fetch_all_tickers_24h(self) -> list[dict[str, Any]]:
raise NotImplementedError
@abstractmethod
def fetch_all_premium_index(self) -> list[dict[str, Any]]:
raise NotImplementedError
@abstractmethod
def fetch_ticker_24h(self, symbol: str) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
def fetch_premium(self, symbol: str) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
def fetch_klines(self, symbol: str, interval: str, limit: int) -> list[dict[str, Any]]:
raise NotImplementedError
@abstractmethod
def resolved_base_url(self, config: dict[str, Any]) -> str:
raise NotImplementedError
@abstractmethod
def live_execution_status(
self,
live_config: dict[str, Any] | None = None,
trading_settings: dict[str, Any] | None = None,
) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
def fetch_account_snapshot(self, config: dict[str, Any], session_started_at: str | None = None) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
def normalize_quantity(
self,
config: dict[str, Any],
symbol: str,
*,
reference_price: float | None = None,
quantity: float | None = None,
notional_usd: float | None = None,
) -> float:
raise NotImplementedError
@abstractmethod
def normalize_price(self, config: dict[str, Any], symbol: str, price: float) -> float:
raise NotImplementedError
@abstractmethod
def apply_symbol_settings(self, config: dict[str, Any], symbol: str) -> None:
raise NotImplementedError
@abstractmethod
def cancel_all_open_orders(self, config: dict[str, Any], symbol: str) -> Any:
raise NotImplementedError
@abstractmethod
def place_market_order(
self,
config: dict[str, Any],
*,
symbol: str,
side: str,
quantity: float,
reduce_only: bool = False,
) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
def place_protection_orders(
self,
config: dict[str, Any],
*,
symbol: str,
position_side: str,
stop_loss: float | None,
take_profit: float | None,
) -> list[dict[str, Any]]:
raise NotImplementedError
+728
View File
@@ -0,0 +1,728 @@
from __future__ import annotations
import hashlib
import hmac
import re
from typing import Any
from urllib.parse import urlencode
from ..config import read_live_trading_config, read_network_settings
from ..http_client import HttpRequestError, cached_get_json, request_json
from ..utils import clamp, now_iso, num
from .base import ExchangeGateway
from .catalog import exchange_config
class BinanceGateway(ExchangeGateway):
exchange_id = "binance"
display_name = "Binance"
market_label = "USDT futures"
default_backdrop_symbol = "BTCUSDT"
public_base_url = exchange_config("binance")["defaultBaseUrl"]
symbol_pattern = re.compile(r"^[A-Z0-9]{2,}USDT$")
transfer_income_types = {
"TRANSFER",
"INTERNAL_TRANSFER",
"CROSS_COLLATERAL_TRANSFER",
"COIN_SWAP_DEPOSIT",
"COIN_SWAP_WITHDRAW",
"STRATEGY_UMFUTURES_TRANSFER",
}
def __init__(self) -> None:
self._server_time_offset_ms = 0
@staticmethod
def _exchange_margin_type(value: Any) -> str:
normalized = str(value or "cross").strip().lower()
if normalized in {"isolated", "isolate"}:
return "ISOLATED"
return "CROSSED"
@staticmethod
def _exchange_position_side(side: str) -> str:
return "SHORT" if str(side or "").strip().lower() == "short" else "LONG"
def _current_position_mode(self, config: dict[str, Any]) -> str:
payload = self._signed_request_json(config, "GET", "/fapi/v1/positionSide/dual")
dual_side = payload.get("dualSidePosition") if isinstance(payload, dict) else False
if str(dual_side).strip().lower() == "true" or dual_side is True:
return "hedge"
return "oneway"
def _resolved_position_mode(self, config: dict[str, Any]) -> str:
cached = str(config.get("_resolvedPositionMode") or "").strip().lower()
if cached in {"hedge", "oneway"}:
return cached
fallback = "hedge" if str(config.get("positionMode") or "").strip().lower() == "hedge" else "oneway"
config["_resolvedPositionMode"] = fallback
return fallback
def validate_symbol(self, symbol: str) -> bool:
normalized = self.normalize_symbol(symbol)
return bool(self.symbol_pattern.fullmatch(normalized))
def base_asset_from_symbol(self, symbol: str) -> str:
normalized = self.normalize_symbol(symbol)
return normalized[:-4] if normalized.endswith("USDT") else normalized
def _cache_policy_for_kline_interval(self, interval: str) -> tuple[int, int]:
interval = str(interval or "").lower()
if interval == "1m":
return 20, 60 * 60
if interval == "5m":
return 30, 2 * 60 * 60
if interval == "15m":
return 60, 3 * 60 * 60
if interval == "1h":
return 5 * 60, 12 * 60 * 60
if interval == "4h":
return 15 * 60, 48 * 60 * 60
return 60, 6 * 60 * 60
def _query(self, base_url: str, endpoint: str, params: dict[str, Any] | None = None) -> str:
params = params or {}
filtered = {key: value for key, value in params.items() if value not in (None, "", [])}
encoded = urlencode(filtered)
if not encoded:
return f"{base_url.rstrip('/')}{endpoint}"
return f"{base_url.rstrip('/')}{endpoint}?{encoded}"
def _public_get_json(
self,
endpoint: str,
params: dict[str, Any] | None = None,
*,
namespace: str,
ttl_seconds: int,
max_stale_seconds: int,
) -> Any:
network_settings = read_network_settings()
url = self._query(self.public_base_url, endpoint, params)
return cached_get_json(
url,
namespace=f"binance_{namespace}",
ttl_seconds=ttl_seconds,
max_stale_seconds=max_stale_seconds,
timeout_seconds=45,
network_settings=network_settings,
)
def _parse_klines(self, rows: list[list[Any]] | None) -> list[dict[str, Any]]:
parsed: list[dict[str, Any]] = []
for row in rows or []:
close_value = num(row[4]) if len(row) > 4 else None
if close_value is None:
continue
parsed.append(
{
"openTime": row[0],
"open": num(row[1]),
"high": num(row[2]),
"low": num(row[3]),
"close": close_value,
"volume": num(row[5]),
"closeTime": row[6],
"quoteVolume": num(row[7]),
}
)
return parsed
def fetch_all_tickers_24h(self) -> list[dict[str, Any]]:
payload = self._public_get_json(
"/fapi/v1/ticker/24hr",
namespace="ticker24h_all",
ttl_seconds=60,
max_stale_seconds=45 * 60,
)
return payload if isinstance(payload, list) else []
def fetch_all_premium_index(self) -> list[dict[str, Any]]:
payload = self._public_get_json(
"/fapi/v1/premiumIndex",
namespace="premium_all",
ttl_seconds=60,
max_stale_seconds=45 * 60,
)
return payload if isinstance(payload, list) else []
def fetch_ticker_24h(self, symbol: str) -> dict[str, Any]:
payload = self._public_get_json(
"/fapi/v1/ticker/24hr",
params={"symbol": self.normalize_symbol(symbol)},
namespace="ticker24h_symbol",
ttl_seconds=60,
max_stale_seconds=45 * 60,
)
return {
"symbol": self.normalize_symbol(symbol),
"lastPrice": num(payload.get("lastPrice")),
"priceChangePct": num(payload.get("priceChangePercent")),
"quoteVolume": num(payload.get("quoteVolume")),
"highPrice": num(payload.get("highPrice")),
"lowPrice": num(payload.get("lowPrice")),
"count": num(payload.get("count")),
}
def fetch_premium(self, symbol: str) -> dict[str, Any]:
payload = self._public_get_json(
"/fapi/v1/premiumIndex",
params={"symbol": self.normalize_symbol(symbol)},
namespace="premium_symbol",
ttl_seconds=60,
max_stale_seconds=45 * 60,
)
return {
"symbol": self.normalize_symbol(symbol),
"markPrice": num(payload.get("markPrice")),
"indexPrice": num(payload.get("indexPrice")),
"fundingPct": (num(payload.get("lastFundingRate")) or 0) * 100,
"nextFundingTime": payload.get("nextFundingTime"),
}
def fetch_klines(self, symbol: str, interval: str, limit: int) -> list[dict[str, Any]]:
ttl_seconds, max_stale_seconds = self._cache_policy_for_kline_interval(interval)
rows = self._public_get_json(
"/fapi/v1/klines",
params={"symbol": self.normalize_symbol(symbol), "interval": interval, "limit": limit},
namespace=f"klines_{interval}",
ttl_seconds=ttl_seconds,
max_stale_seconds=max_stale_seconds,
)
return self._parse_klines(rows if isinstance(rows, list) else [])
def resolved_base_url(self, config: dict[str, Any]) -> str:
return str(config.get("baseUrl") or self.public_base_url)
def live_execution_status(
self,
live_config: dict[str, Any] | None = None,
trading_settings: dict[str, Any] | None = None,
) -> dict[str, Any]:
live_config = live_config or read_live_trading_config()
issues = []
if not live_config.get("apiKey"):
issues.append("Live trading API key is missing.")
if not live_config.get("apiSecret"):
issues.append("Live trading API secret is missing.")
resolved_position_mode = "hedge" if str(live_config.get("positionMode") or "").strip().lower() == "hedge" else "oneway"
if not issues:
try:
resolved_position_mode = self._current_position_mode(live_config)
live_config["_resolvedPositionMode"] = resolved_position_mode
except Exception:
live_config["_resolvedPositionMode"] = resolved_position_mode
can_sync = not issues
can_execute = can_sync and live_config.get("enabled") and not live_config.get("dryRun")
return {
"configEnabled": live_config.get("enabled") is True,
"dryRun": live_config.get("dryRun") is True,
"armed": can_execute,
"canSync": can_sync,
"canExecute": can_execute,
"issues": issues,
"baseUrl": self.resolved_base_url(live_config),
"exchange": self.exchange_id,
"positionMode": resolved_position_mode,
}
def _signed_params(self, config: dict[str, Any], params: dict[str, Any] | None = None) -> str:
payload = dict(params or {})
payload["timestamp"] = str(int(__import__("time").time() * 1000) + int(self._server_time_offset_ms or 0))
payload["recvWindow"] = str(int(config.get("recvWindow") or 5000))
query_string = urlencode({key: value for key, value in payload.items() if value not in (None, "")})
signature = hmac.new(
str(config["apiSecret"]).encode("utf-8"),
query_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"{query_string}&signature={signature}"
def _sync_server_time_offset(self, config: dict[str, Any]) -> None:
network_settings = read_network_settings()
base_url = self.resolved_base_url(config).rstrip("/")
payload = request_json(
"GET",
f"{base_url}/fapi/v1/time",
timeout_seconds=10,
network_settings=network_settings,
)
server_time = num(payload.get("serverTime")) if isinstance(payload, dict) else None
local_time = int(__import__("time").time() * 1000)
if server_time is not None:
self._server_time_offset_ms = int(server_time) - local_time
def _signed_request_json(
self,
config: dict[str, Any],
method: str,
endpoint: str,
params: dict[str, Any] | None = None,
) -> Any:
network_settings = read_network_settings()
base_url = self.resolved_base_url(config).rstrip("/")
headers_base = {"X-MBX-APIKEY": config["apiKey"]}
def send_once() -> Any:
query = self._signed_params(config, params)
url = f"{base_url}{endpoint}"
if method.upper() in {"GET", "DELETE"}:
return request_json(
method,
f"{url}?{query}",
headers=headers_base,
timeout_seconds=45,
network_settings=network_settings,
)
return request_json(
method,
url,
headers={
**headers_base,
"content-type": "application/x-www-form-urlencoded",
},
payload=query,
timeout_seconds=45,
network_settings=network_settings,
)
try:
return send_once()
except HttpRequestError as error:
if "-1021" in str(error):
self._sync_server_time_offset(config)
return send_once()
raise HttpRequestError(f"{error} [endpoint {method.upper()} {endpoint}]") from error
def _exchange_info(self, config: dict[str, Any]) -> dict[str, Any]:
network_settings = read_network_settings()
url = f"{self.resolved_base_url(config).rstrip('/')}/fapi/v1/exchangeInfo"
payload = cached_get_json(
url,
namespace="binance_live_exchange_info",
ttl_seconds=6 * 60 * 60,
max_stale_seconds=7 * 24 * 60 * 60,
timeout_seconds=45,
network_settings=network_settings,
)
return payload if isinstance(payload, dict) else {}
def _symbol_info(self, config: dict[str, Any], symbol: str) -> dict[str, Any]:
normalized = self.normalize_symbol(symbol)
info = self._exchange_info(config)
for item in info.get("symbols", []):
if self.normalize_symbol(item.get("symbol")) == normalized:
return item
raise ValueError(f"{self.display_name} symbol not found: {normalized}")
def _step_precision(self, step_size: str | float | int | None) -> int:
text = str(step_size or "1")
if "." not in text:
return 0
return len(text.rstrip("0").split(".")[1])
def _round_down_to_step(self, value: float, step_size: float, precision: int) -> float:
units = int(value / step_size)
return round(units * step_size, precision)
def _round_to_step(self, value: float, step_size: float, precision: int) -> float:
units = round(value / step_size)
return round(units * step_size, precision)
def normalize_quantity(
self,
config: dict[str, Any],
symbol: str,
*,
reference_price: float | None = None,
quantity: float | None = None,
notional_usd: float | None = None,
) -> float:
info = self._symbol_info(config, symbol)
filters = info.get("filters") or []
filter_row = next((item for item in filters if item.get("filterType") in {"MARKET_LOT_SIZE", "LOT_SIZE"}), None)
if not filter_row:
raise ValueError(f"Could not determine lot size for {self.normalize_symbol(symbol)}")
step_size = num(filter_row.get("stepSize")) or 1
min_qty = num(filter_row.get("minQty")) or 0
precision = self._step_precision(filter_row.get("stepSize"))
raw_quantity = quantity if quantity is not None else ((notional_usd or 0) / (reference_price or 0) if reference_price else 0)
normalized = self._round_down_to_step(max(0.0, float(raw_quantity or 0)), step_size, precision)
if normalized < min_qty:
raise ValueError(f"Quantity for {self.normalize_symbol(symbol)} is below exchange minimum.")
return normalized
def normalize_price(self, config: dict[str, Any], symbol: str, price: float) -> float:
info = self._symbol_info(config, symbol)
filters = info.get("filters") or []
filter_row = next((item for item in filters if item.get("filterType") == "PRICE_FILTER"), None)
if not filter_row:
return float(price)
tick_size = num(filter_row.get("tickSize")) or 0
if tick_size <= 0:
return float(price)
precision = self._step_precision(filter_row.get("tickSize"))
return self._round_to_step(float(price), tick_size, precision)
def _safe_signed_call(
self,
config: dict[str, Any],
method: str,
endpoint: str,
params: dict[str, Any] | None = None,
) -> Any:
try:
return self._signed_request_json(config, method, endpoint, params)
except Exception as error:
message = str(error)
if (
"No need to change margin type" in message
or "-4046" in message
or "-4067" in message
or "-4068" in message
):
return {"ignored": True}
raise
def _income_summary(self, config: dict[str, Any]) -> dict[str, Any]:
limit = 1000
now_ms = int(__import__("time").time() * 1000)
ninety_days_ms = 90 * 24 * 60 * 60 * 1000
rows = self._signed_request_json(
config,
"GET",
"/fapi/v1/income",
{
"startTime": now_ms - ninety_days_ms,
"endTime": now_ms,
"limit": limit,
},
)
entries = rows if isinstance(rows, list) else []
net_cashflow = 0.0
realized_pnl = 0.0
funding_fee = 0.0
commission = 0.0
other_income = 0.0
for row in entries:
if not isinstance(row, dict):
continue
income_type = str(row.get("incomeType") or "").strip().upper()
income_value = num(row.get("income")) or 0
if income_type in self.transfer_income_types:
net_cashflow += income_value
elif income_type == "REALIZED_PNL":
realized_pnl += income_value
elif income_type == "FUNDING_FEE":
funding_fee += income_value
elif income_type == "COMMISSION":
commission += income_value
else:
other_income += income_value
note = None
if len(entries) >= limit:
note = "Binance income history hit the 1000-row sync cap; very active accounts may need a deeper backfill."
return {
"netCashflowUsd": net_cashflow,
"incomeRealizedPnlUsd": realized_pnl,
"fundingFeeUsd": funding_fee,
"commissionUsd": commission,
"otherIncomeUsd": other_income,
"accountingUpdatedAt": now_iso(),
"accountingNote": note,
}
@staticmethod
def _session_start_ms(session_started_at: str | None) -> int | None:
if not session_started_at:
return None
try:
value = __import__("datetime").datetime.fromisoformat(str(session_started_at).replace("Z", "+00:00"))
except Exception:
return None
return int(value.timestamp() * 1000)
def _exchange_closed_trades(self, config: dict[str, Any], session_started_at: str | None) -> list[dict[str, Any]]:
session_start_ms = self._session_start_ms(session_started_at)
if session_start_ms is None:
return []
rows = self._signed_request_json(
config,
"GET",
"/fapi/v1/income",
{
"incomeType": "REALIZED_PNL",
"startTime": session_start_ms,
"endTime": int(__import__("time").time() * 1000),
"limit": 1000,
},
)
entries = rows if isinstance(rows, list) else []
closed_trades: list[dict[str, Any]] = []
for row in entries:
if not isinstance(row, dict):
continue
realized_pnl = num(row.get("income"))
if realized_pnl is None or abs(realized_pnl) <= 1e-12:
continue
symbol = self.normalize_symbol(row.get("symbol"))
closed_at_ms = num(row.get("time"))
closed_at = (
__import__("datetime").datetime.utcfromtimestamp(closed_at_ms / 1000).replace(microsecond=0).isoformat() + "Z"
if closed_at_ms is not None
else now_iso()
)
closed_trades.append(
{
"id": f"binance-realized-{row.get('tranId') or row.get('tradeId') or symbol}-{int(closed_at_ms or 0)}",
"symbol": symbol,
"baseAsset": self.base_asset_from_symbol(symbol),
"realizedPnl": realized_pnl,
"asset": str(row.get("asset") or "USDT").strip().upper() or "USDT",
"closedAt": closed_at,
"info": str(row.get("info") or "").strip(),
"source": "binance_realized_pnl",
}
)
closed_trades.sort(key=lambda item: str(item.get("closedAt") or ""))
return closed_trades
def apply_symbol_settings(self, config: dict[str, Any], symbol: str) -> None:
leverage = int(clamp(config.get("defaultLeverage"), 1, 125))
normalized = self.normalize_symbol(symbol)
self._safe_signed_call(config, "POST", "/fapi/v1/leverage", {"symbol": normalized, "leverage": leverage})
self._safe_signed_call(
config,
"POST",
"/fapi/v1/marginType",
{"symbol": normalized, "marginType": self._exchange_margin_type(config.get("marginType"))},
)
def fetch_account_snapshot(self, config: dict[str, Any], session_started_at: str | None = None) -> dict[str, Any]:
account = self._signed_request_json(config, "GET", "/fapi/v2/account")
positions = self._signed_request_json(config, "GET", "/fapi/v2/positionRisk")
open_orders_payload = self._signed_request_json(config, "GET", "/fapi/v1/openOrders")
open_algo_orders_payload = self._signed_request_json(config, "GET", "/fapi/v1/openAlgoOrders")
try:
config["_resolvedPositionMode"] = self._current_position_mode(config)
except Exception:
config["_resolvedPositionMode"] = self._resolved_position_mode(config)
accounting_summary = {
"netCashflowUsd": None,
"incomeRealizedPnlUsd": None,
"fundingFeeUsd": None,
"commissionUsd": None,
"otherIncomeUsd": None,
"accountingUpdatedAt": None,
"accountingNote": None,
}
try:
accounting_summary.update(self._income_summary(config))
except Exception as error:
accounting_summary["accountingNote"] = f"Binance income sync failed: {error}"
exchange_closed_trades: list[dict[str, Any]] = []
try:
exchange_closed_trades = self._exchange_closed_trades(config, session_started_at)
except Exception as error:
note = f"Binance closed-trade sync failed: {error}"
if accounting_summary.get("accountingNote"):
accounting_summary["accountingNote"] = f"{accounting_summary['accountingNote']} | {note}"
else:
accounting_summary["accountingNote"] = note
open_positions = []
for row in positions or []:
raw_amount = num(row.get("positionAmt"))
if raw_amount is None or abs(raw_amount) <= 1e-9:
continue
symbol = self.normalize_symbol(row.get("symbol"))
raw_position_side = str(row.get("positionSide") or "").strip().upper()
if raw_position_side == "LONG":
side = "long"
elif raw_position_side == "SHORT":
side = "short"
else:
side = "long" if raw_amount > 0 else "short"
quantity = abs(raw_amount)
entry_price = num(row.get("entryPrice")) or 0
mark_price = num(row.get("markPrice")) or entry_price
notional_usd = abs(num(row.get("notional")) or (mark_price * quantity))
open_positions.append(
{
"id": f"live-{symbol}-{side}",
"symbol": symbol,
"baseAsset": self.base_asset_from_symbol(symbol),
"side": side,
"quantity": quantity,
"initialQuantity": quantity,
"entryPrice": entry_price,
"notionalUsd": notional_usd,
"initialNotionalUsd": notional_usd,
"stopLoss": None,
"takeProfit": None,
"lastMarkPrice": mark_price,
"lastMarkTime": now_iso(),
"leverage": num(row.get("leverage")) or 1,
"status": "open",
"openedAt": None,
"updatedAt": now_iso(),
"source": self.exchange_id,
"entryReason": "synced_from_exchange",
"decisionId": None,
"exchangePositionSide": raw_position_side or ("BOTH" if self._resolved_position_mode(config) == "oneway" else self._exchange_position_side(side)),
}
)
open_orders = []
for row in open_orders_payload or []:
if not isinstance(row, dict):
continue
symbol = self.normalize_symbol(row.get("symbol"))
open_orders.append(
{
"id": f"std-{row.get('orderId') or row.get('clientOrderId') or symbol}",
"symbol": symbol,
"baseAsset": self.base_asset_from_symbol(symbol),
"side": str(row.get("side") or "").upper(),
"positionSide": str(row.get("positionSide") or "").upper(),
"type": str(row.get("type") or row.get("origType") or "").upper(),
"status": str(row.get("status") or "").upper(),
"price": num(row.get("price")),
"triggerPrice": num(row.get("stopPrice")),
"quantity": num(row.get("origQty")) or num(row.get("quantity")),
"reduceOnly": str(row.get("reduceOnly") or "").lower() == "true" or row.get("reduceOnly") is True,
"closePosition": str(row.get("closePosition") or "").lower() == "true" or row.get("closePosition") is True,
"workingType": str(row.get("workingType") or "").upper(),
"source": "binance_standard_order",
"updatedAt": now_iso(),
}
)
for row in open_algo_orders_payload or []:
if not isinstance(row, dict):
continue
symbol = self.normalize_symbol(row.get("symbol"))
open_orders.append(
{
"id": f"algo-{row.get('algoId') or row.get('clientAlgoId') or symbol}",
"symbol": symbol,
"baseAsset": self.base_asset_from_symbol(symbol),
"side": str(row.get("side") or "").upper(),
"positionSide": str(row.get("positionSide") or "").upper(),
"type": str(row.get("type") or row.get("algoType") or "").upper(),
"status": str(row.get("status") or "OPEN").upper(),
"price": num(row.get("price")),
"triggerPrice": num(row.get("triggerPrice")) or num(row.get("stopPrice")),
"quantity": num(row.get("quantity")) or num(row.get("origQty")),
"reduceOnly": str(row.get("reduceOnly") or "").lower() == "true" or row.get("reduceOnly") is True,
"closePosition": str(row.get("closePosition") or "").lower() == "true" or row.get("closePosition") is True,
"workingType": str(row.get("workingType") or "").upper(),
"source": "binance_algo_order",
"updatedAt": now_iso(),
}
)
return {
"walletBalanceUsd": num(account.get("totalWalletBalance")) or num(account.get("walletBalance")) or 0,
"equityUsd": num(account.get("totalMarginBalance")) or num(account.get("totalWalletBalance")) or 0,
"availableBalanceUsd": num(account.get("availableBalance")) or 0,
"unrealizedPnlUsd": num(account.get("totalUnrealizedProfit")) or 0,
"positionMode": self._resolved_position_mode(config),
**accounting_summary,
"exchangeClosedTrades": exchange_closed_trades,
"openPositions": open_positions,
"openOrders": open_orders,
"raw": {
"account": account,
"positions": positions,
"openOrders": open_orders_payload,
"openAlgoOrders": open_algo_orders_payload,
},
}
def cancel_all_open_orders(self, config: dict[str, Any], symbol: str) -> Any:
normalized = self.normalize_symbol(symbol)
standard_result = self._signed_request_json(config, "DELETE", "/fapi/v1/allOpenOrders", {"symbol": normalized})
algo_result = self._signed_request_json(config, "DELETE", "/fapi/v1/algoOpenOrders", {"symbol": normalized})
return {
"standard": standard_result,
"algo": algo_result,
}
def place_market_order(
self,
config: dict[str, Any],
*,
symbol: str,
side: str,
quantity: float,
reduce_only: bool = False,
) -> dict[str, Any]:
params = {
"symbol": self.normalize_symbol(symbol),
"side": str(side or "").upper(),
"type": "MARKET",
"quantity": quantity,
}
if self._resolved_position_mode(config) == "hedge":
if params["side"] == "BUY":
params["positionSide"] = "SHORT" if reduce_only else "LONG"
else:
params["positionSide"] = "LONG" if reduce_only else "SHORT"
elif reduce_only:
params["reduceOnly"] = "true"
return self._signed_request_json(config, "POST", "/fapi/v1/order", params)
def place_protection_orders(
self,
config: dict[str, Any],
*,
symbol: str,
position_side: str,
stop_loss: float | None,
take_profit: float | None,
) -> list[dict[str, Any]]:
created: list[dict[str, Any]] = []
if stop_loss is None and take_profit is None:
return created
normalized = self.normalize_symbol(symbol)
close_side = "SELL" if position_side == "long" else "BUY"
hedge_mode = self._resolved_position_mode(config) == "hedge"
exchange_position_side = self._exchange_position_side(position_side)
if stop_loss is not None:
stop_order = {
"algoType": "CONDITIONAL",
"symbol": normalized,
"side": close_side,
"type": "STOP_MARKET",
"triggerPrice": self.normalize_price(config, normalized, stop_loss),
"closePosition": "true",
"workingType": "MARK_PRICE",
}
if hedge_mode:
stop_order["positionSide"] = exchange_position_side
created.append(
self._signed_request_json(
config,
"POST",
"/fapi/v1/algoOrder",
stop_order,
)
)
if take_profit is not None:
take_profit_order = {
"algoType": "CONDITIONAL",
"symbol": normalized,
"side": close_side,
"type": "TAKE_PROFIT_MARKET",
"triggerPrice": self.normalize_price(config, normalized, take_profit),
"closePosition": "true",
"workingType": "MARK_PRICE",
}
if hedge_mode:
take_profit_order["positionSide"] = exchange_position_side
created.append(
self._signed_request_json(
config,
"POST",
"/fapi/v1/algoOrder",
take_profit_order,
)
)
return created
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import re
from typing import Any
from urllib.parse import urlencode
from ..config import read_live_trading_config, read_network_settings
from ..http_client import cached_get_json
from ..utils import num
from .base import ExchangeGateway
from .catalog import exchange_config
class BybitGateway(ExchangeGateway):
exchange_id = "bybit"
display_name = "Bybit"
market_label = "USDT linear perpetual"
default_backdrop_symbol = "BTCUSDT"
public_base_url = exchange_config("bybit")["defaultBaseUrl"]
symbol_pattern = re.compile(r"^[A-Z0-9]{2,}USDT$")
interval_map = {
"1m": "1",
"5m": "5",
"15m": "15",
"30m": "30",
"1h": "60",
"2h": "120",
"4h": "240",
"12h": "720",
}
def validate_symbol(self, symbol: str) -> bool:
normalized = self.normalize_symbol(symbol)
return bool(self.symbol_pattern.fullmatch(normalized))
def base_asset_from_symbol(self, symbol: str) -> str:
normalized = self.normalize_symbol(symbol)
return normalized[:-4] if normalized.endswith("USDT") else normalized
def _cache_policy_for_kline_interval(self, interval: str) -> tuple[int, int]:
interval = str(interval or "").lower()
if interval == "1m":
return 20, 60 * 60
if interval == "5m":
return 30, 2 * 60 * 60
if interval == "15m":
return 60, 3 * 60 * 60
if interval == "1h":
return 5 * 60, 12 * 60 * 60
if interval == "4h":
return 15 * 60, 48 * 60 * 60
return 60, 6 * 60 * 60
def resolved_base_url(self, config: dict[str, Any]) -> str:
return str(config.get("baseUrl") or self.public_base_url).strip() or self.public_base_url
def _query(self, base_url: str, endpoint: str, params: dict[str, Any] | None = None) -> str:
filtered = {key: value for key, value in (params or {}).items() if value not in (None, "", [], {})}
query = urlencode(filtered)
if not query:
return f"{base_url.rstrip('/')}{endpoint}"
return f"{base_url.rstrip('/')}{endpoint}?{query}"
def _bybit_result(self, payload: Any, *, endpoint: str) -> Any:
if not isinstance(payload, dict):
raise ValueError(f"Unexpected Bybit response for {endpoint}.")
ret_code = int(payload.get("retCode") or 0)
if ret_code != 0:
raise ValueError(f"Bybit {endpoint} failed: {payload.get('retMsg') or ret_code}")
result = payload.get("result")
return result if isinstance(result, dict) else {}
def _public_get_result(
self,
endpoint: str,
params: dict[str, Any] | None = None,
*,
namespace: str,
ttl_seconds: int,
max_stale_seconds: int,
) -> dict[str, Any]:
network_settings = read_network_settings()
url = self._query(self.public_base_url, endpoint, params)
payload = cached_get_json(
url,
namespace=f"bybit_{namespace}",
ttl_seconds=ttl_seconds,
max_stale_seconds=max_stale_seconds,
timeout_seconds=45,
network_settings=network_settings,
)
return self._bybit_result(payload, endpoint=endpoint)
def _map_ticker_row(self, row: dict[str, Any]) -> dict[str, Any]:
symbol = self.normalize_symbol(row.get("symbol"))
price_change_ratio = num(row.get("price24hPcnt")) or 0
return {
"symbol": symbol,
"lastPrice": num(row.get("lastPrice")),
"priceChangePct": price_change_ratio * 100,
"priceChangePercent": price_change_ratio * 100,
"quoteVolume": num(row.get("turnover24h")),
"highPrice": num(row.get("highPrice24h")),
"lowPrice": num(row.get("lowPrice24h")),
"openInterest": num(row.get("openInterest")),
"openInterestValue": num(row.get("openInterestValue")),
"fundingRate": num(row.get("fundingRate")),
"nextFundingTime": row.get("nextFundingTime"),
"indexPrice": num(row.get("indexPrice")),
"markPrice": num(row.get("markPrice")),
"raw": row,
}
def fetch_all_tickers_24h(self) -> list[dict[str, Any]]:
result = self._public_get_result(
"/v5/market/tickers",
{"category": "linear"},
namespace="tickers_linear_all",
ttl_seconds=60,
max_stale_seconds=45 * 60,
)
rows = result.get("list")
if not isinstance(rows, list):
return []
return [self._map_ticker_row(row) for row in rows if isinstance(row, dict)]
def fetch_all_premium_index(self) -> list[dict[str, Any]]:
return [
{
"symbol": row["symbol"],
"markPrice": row.get("markPrice"),
"indexPrice": row.get("indexPrice"),
"lastFundingRate": row.get("fundingRate"),
"fundingRate": row.get("fundingRate"),
"nextFundingTime": row.get("nextFundingTime"),
"raw": row.get("raw"),
}
for row in self.fetch_all_tickers_24h()
]
def fetch_ticker_24h(self, symbol: str) -> dict[str, Any]:
normalized = self.normalize_symbol(symbol)
result = self._public_get_result(
"/v5/market/tickers",
{"category": "linear", "symbol": normalized},
namespace=f"ticker_linear_{normalized}",
ttl_seconds=20,
max_stale_seconds=30 * 60,
)
rows = result.get("list")
row = rows[0] if isinstance(rows, list) and rows and isinstance(rows[0], dict) else {"symbol": normalized}
return self._map_ticker_row(row)
def fetch_premium(self, symbol: str) -> dict[str, Any]:
ticker = self.fetch_ticker_24h(symbol)
return {
"symbol": ticker["symbol"],
"markPrice": ticker.get("markPrice"),
"indexPrice": ticker.get("indexPrice"),
"lastFundingRate": ticker.get("fundingRate"),
"fundingRate": ticker.get("fundingRate"),
"fundingPct": (num(ticker.get("fundingRate")) or 0) * 100,
"nextFundingTime": ticker.get("nextFundingTime"),
"raw": ticker.get("raw"),
}
def fetch_klines(self, symbol: str, interval: str, limit: int) -> list[dict[str, Any]]:
normalized = self.normalize_symbol(symbol)
resolved_interval = self.interval_map.get(str(interval or "").lower())
if not resolved_interval:
raise ValueError(f"Unsupported Bybit kline interval: {interval}")
ttl_seconds, max_stale_seconds = self._cache_policy_for_kline_interval(interval)
result = self._public_get_result(
"/v5/market/kline",
{"category": "linear", "symbol": normalized, "interval": resolved_interval, "limit": int(limit)},
namespace=f"klines_linear_{normalized}_{resolved_interval}",
ttl_seconds=ttl_seconds,
max_stale_seconds=max_stale_seconds,
)
rows = result.get("list")
parsed: list[dict[str, Any]] = []
for row in reversed(rows if isinstance(rows, list) else []):
if not isinstance(row, list) or len(row) < 7:
continue
close_value = num(row[4])
if close_value is None:
continue
parsed.append(
{
"openTime": int(num(row[0]) or 0),
"open": num(row[1]),
"high": num(row[2]),
"low": num(row[3]),
"close": close_value,
"volume": num(row[5]),
"closeTime": int(num(row[0]) or 0),
"quoteVolume": num(row[6]),
}
)
return parsed
def live_execution_status(
self,
live_config: dict[str, Any] | None = None,
trading_settings: dict[str, Any] | None = None,
) -> dict[str, Any]:
live_config = live_config or read_live_trading_config()
issues = ["Bybit live trading is not wired in yet in this Python build."]
return {
"configEnabled": live_config.get("enabled") is True,
"dryRun": True,
"armed": False,
"canSync": False,
"canExecute": False,
"issues": issues,
"baseUrl": self.resolved_base_url(live_config),
"exchange": self.exchange_id,
}
def fetch_account_snapshot(self, config: dict[str, Any], session_started_at: str | None = None) -> dict[str, Any]:
raise NotImplementedError("Bybit live trading is not wired in yet.")
def normalize_quantity(
self,
config: dict[str, Any],
symbol: str,
*,
reference_price: float | None = None,
quantity: float | None = None,
notional_usd: float | None = None,
) -> float:
raise NotImplementedError("Bybit live trading is not wired in yet.")
def normalize_price(self, config: dict[str, Any], symbol: str, price: float) -> float:
raise NotImplementedError("Bybit live trading is not wired in yet.")
def apply_symbol_settings(self, config: dict[str, Any], symbol: str) -> None:
raise NotImplementedError("Bybit live trading is not wired in yet.")
def cancel_all_open_orders(self, config: dict[str, Any], symbol: str) -> Any:
raise NotImplementedError("Bybit live trading is not wired in yet.")
def place_market_order(
self,
config: dict[str, Any],
*,
symbol: str,
side: str,
quantity: float,
reduce_only: bool = False,
) -> dict[str, Any]:
raise NotImplementedError("Bybit live trading is not wired in yet.")
def place_protection_orders(
self,
config: dict[str, Any],
*,
symbol: str,
position_side: str,
stop_loss: float | None,
take_profit: float | None,
) -> list[dict[str, Any]]:
raise NotImplementedError("Bybit live trading is not wired in yet.")
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
EXCHANGE_CATALOG: dict[str, dict[str, Any]] = {
"binance": {
"id": "binance",
"label": "Binance",
"implemented": True,
"marketSupported": True,
"tradingSupported": True,
"market": "usdm_futures",
"defaultBaseUrl": "https://fapi.binance.com",
"apiKeyPlaceholder": "Binance API key",
"apiSecretPlaceholder": "Binance API secret",
"defaultLeverageMax": 125,
"notes": "当前版本已实现 Binance USDT 永续的行情与实盘接口。",
},
"okx": {
"id": "okx",
"label": "OKX",
"implemented": True,
"marketSupported": True,
"tradingSupported": True,
"market": "swap",
"defaultBaseUrl": "https://www.okx.com",
"apiKeyPlaceholder": "OKX API key",
"apiSecretPlaceholder": "OKX API secret",
"apiPassphrasePlaceholder": "OKX API passphrase",
"requiresPassphrase": True,
"defaultLeverageMax": 125,
"notes": "已实现 OKX 永续合约的行情与实盘接口,需要填写 API Passphrase。",
},
"bybit": {
"id": "bybit",
"label": "Bybit",
"implemented": True,
"marketSupported": True,
"tradingSupported": False,
"market": "linear",
"defaultBaseUrl": "https://api.bybit.com",
"apiKeyPlaceholder": "Bybit API key",
"apiSecretPlaceholder": "Bybit API secret",
"apiPassphrasePlaceholder": "",
"requiresPassphrase": False,
"defaultLeverageMax": 125,
"notes": "已实现 Bybit 线性永续公共行情,实盘接口会在后续版本接入。",
},
"gateio": {
"id": "gateio",
"label": "Gate.io",
"implemented": False,
"marketSupported": False,
"tradingSupported": False,
"market": "futures",
"defaultBaseUrl": "https://api.gateio.ws",
"apiKeyPlaceholder": "Gate.io API key",
"apiSecretPlaceholder": "Gate.io API secret",
"apiPassphrasePlaceholder": "",
"requiresPassphrase": False,
"defaultLeverageMax": 100,
"notes": "即将支持 Gate.io 合约接口。",
},
}
EXCHANGE_CATALOG["binance"]["apiPassphrasePlaceholder"] = ""
EXCHANGE_CATALOG["binance"]["requiresPassphrase"] = False
DEFAULT_EXCHANGE_ID = "binance"
def exchange_catalog_payload() -> list[dict[str, Any]]:
return [deepcopy(item) for item in EXCHANGE_CATALOG.values()]
def exchange_config(exchange_id: str | None = None) -> dict[str, Any]:
normalized = normalize_exchange_id(exchange_id)
return deepcopy(EXCHANGE_CATALOG[normalized])
def exchange_supports(exchange_id: Any, capability: str = "market") -> bool:
key = str(exchange_id or DEFAULT_EXCHANGE_ID).strip().lower() or DEFAULT_EXCHANGE_ID
meta = EXCHANGE_CATALOG.get(key)
if not meta:
return False
if capability == "trade":
return meta.get("tradingSupported") is True
if capability == "market":
return meta.get("marketSupported") is True
return meta.get("implemented") is True
def normalize_exchange_id(
exchange_id: Any,
*,
implemented_only: bool = False,
capability: str | None = None,
) -> str:
key = str(exchange_id or DEFAULT_EXCHANGE_ID).strip().lower() or DEFAULT_EXCHANGE_ID
if key not in EXCHANGE_CATALOG:
return DEFAULT_EXCHANGE_ID
if capability and not exchange_supports(key, capability):
return DEFAULT_EXCHANGE_ID
if implemented_only and not EXCHANGE_CATALOG[key]["implemented"]:
return DEFAULT_EXCHANGE_ID
return key
+602
View File
@@ -0,0 +1,602 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import re
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlencode
from ..config import read_live_trading_config, read_network_settings
from ..http_client import cached_get_json, request_json
from ..utils import clamp, now_iso, num
from .base import ExchangeGateway
from .catalog import exchange_config
class OkxGateway(ExchangeGateway):
exchange_id = "okx"
display_name = "OKX"
market_label = "USDT perpetual swap"
default_backdrop_symbol = "BTC-USDT-SWAP"
public_base_url = exchange_config("okx")["defaultBaseUrl"]
symbol_pattern = re.compile(r"^[A-Z0-9]+-USDT-SWAP$")
interval_map = {
"1m": "1m",
"5m": "5m",
"15m": "15m",
"30m": "30m",
"1h": "1H",
"2h": "2H",
"4h": "4H",
"12h": "12H",
}
def candidate_symbol_hint(self) -> str:
return "OKX USDT perpetual swap symbols"
def normalize_symbol(self, symbol: str) -> str:
normalized = str(symbol or "").strip().upper().replace("_", "-").replace("/", "-")
normalized = re.sub(r"-{2,}", "-", normalized)
return normalized
def validate_symbol(self, symbol: str) -> bool:
return bool(self.symbol_pattern.fullmatch(self.normalize_symbol(symbol)))
def base_asset_from_symbol(self, symbol: str) -> str:
normalized = self.normalize_symbol(symbol)
return normalized.split("-", 1)[0] if "-" in normalized else normalized
def _cache_policy_for_kline_interval(self, interval: str) -> tuple[int, int]:
interval = str(interval or "").lower()
if interval == "1m":
return 20, 60 * 60
if interval == "5m":
return 30, 2 * 60 * 60
if interval == "15m":
return 60, 3 * 60 * 60
if interval == "1h":
return 5 * 60, 12 * 60 * 60
if interval == "4h":
return 15 * 60, 48 * 60 * 60
return 60, 6 * 60 * 60
def resolved_base_url(self, config: dict[str, Any]) -> str:
base_url = str(config.get("baseUrl") or self.public_base_url).strip() or self.public_base_url
normalized = base_url.rstrip("/")
if normalized.endswith("/api/v5"):
normalized = normalized[:-7]
return normalized.rstrip("/")
def _query_string(self, params: dict[str, Any] | None = None) -> str:
filtered = {
key: value
for key, value in (params or {}).items()
if value not in (None, "", [], {})
}
return urlencode(filtered)
def _query(self, base_url: str, endpoint: str, params: dict[str, Any] | None = None) -> str:
query_string = self._query_string(params)
if not query_string:
return f"{base_url.rstrip('/')}{endpoint}"
return f"{base_url.rstrip('/')}{endpoint}?{query_string}"
def _okx_data(self, payload: Any, *, endpoint: str) -> Any:
if not isinstance(payload, dict):
raise ValueError(f"Unexpected OKX response for {endpoint}.")
code = str(payload.get("code") or "")
if code not in {"", "0"}:
raise ValueError(f"OKX {endpoint} failed: {payload.get('msg') or code}")
return payload.get("data")
def _public_get_data(
self,
endpoint: str,
params: dict[str, Any] | None = None,
*,
namespace: str,
ttl_seconds: int,
max_stale_seconds: int,
) -> Any:
network_settings = read_network_settings()
url = self._query(self.public_base_url, endpoint, params)
payload = cached_get_json(
url,
namespace=f"okx_{namespace}",
ttl_seconds=ttl_seconds,
max_stale_seconds=max_stale_seconds,
timeout_seconds=45,
network_settings=network_settings,
)
return self._okx_data(payload, endpoint=endpoint)
def _timestamp(self) -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _signed_request_json(
self,
config: dict[str, Any],
method: str,
endpoint: str,
*,
params: dict[str, Any] | None = None,
body: dict[str, Any] | list[dict[str, Any]] | None = None,
) -> Any:
network_settings = read_network_settings()
method_upper = method.upper()
query_string = self._query_string(params)
request_path = endpoint if not query_string else f"{endpoint}?{query_string}"
url = f"{self.resolved_base_url(config)}{request_path}"
body_text = "" if body is None else json.dumps(body, separators=(",", ":"), ensure_ascii=False)
timestamp = self._timestamp()
prehash = f"{timestamp}{method_upper}{request_path}{body_text}"
signature = base64.b64encode(
hmac.new(
str(config["apiSecret"]).encode("utf-8"),
prehash.encode("utf-8"),
hashlib.sha256,
).digest()
).decode("utf-8")
headers = {
"OK-ACCESS-KEY": str(config["apiKey"]),
"OK-ACCESS-SIGN": signature,
"OK-ACCESS-TIMESTAMP": timestamp,
"OK-ACCESS-PASSPHRASE": str(config["apiPassphrase"]),
"Content-Type": "application/json",
}
payload = request_json(
method_upper,
url,
headers=headers,
payload=body_text if body is not None else None,
timeout_seconds=45,
network_settings=network_settings,
)
return self._okx_data(payload, endpoint=endpoint)
def _instruments(self) -> list[dict[str, Any]]:
payload = self._public_get_data(
"/api/v5/public/instruments",
{"instType": "SWAP"},
namespace="public_instruments_swap",
ttl_seconds=6 * 60 * 60,
max_stale_seconds=7 * 24 * 60 * 60,
)
return payload if isinstance(payload, list) else []
def _symbol_info(self, symbol: str) -> dict[str, Any]:
normalized = self.normalize_symbol(symbol)
for item in self._instruments():
if self.normalize_symbol(item.get("instId")) == normalized:
return item
raise ValueError(f"{self.display_name} symbol not found: {normalized}")
def _step_precision(self, step_size: str | float | int | None) -> int:
text = str(step_size or "1")
if "." not in text:
return 0
return len(text.rstrip("0").split(".")[1])
def _round_down_to_step(self, value: float, step_size: float, precision: int) -> float:
units = int(value / step_size)
return round(units * step_size, precision)
def _round_to_step(self, value: float, step_size: float, precision: int) -> float:
units = round(value / step_size)
return round(units * step_size, precision)
def _contract_notional_usd(self, info: dict[str, Any], reference_price: float | None) -> float:
ct_val = num(info.get("ctVal")) or 0
if ct_val <= 0:
raise ValueError(f"Could not determine contract value for {info.get('instId')}.")
ct_val_ccy = str(info.get("ctValCcy") or "").upper()
base_ccy = str(info.get("baseCcy") or "").upper()
quote_ccy = str(info.get("quoteCcy") or info.get("settleCcy") or "").upper()
if ct_val_ccy and quote_ccy and ct_val_ccy == quote_ccy:
return ct_val
if ct_val_ccy and base_ccy and ct_val_ccy == base_ccy:
if not reference_price or reference_price <= 0:
raise ValueError(f"Reference price is required to size {info.get('instId')}.")
return ct_val * float(reference_price)
if str(info.get("ctType") or "").lower() == "linear":
if not reference_price or reference_price <= 0:
raise ValueError(f"Reference price is required to size {info.get('instId')}.")
return ct_val * float(reference_price)
return ct_val
def _format_number(self, value: float, precision: int = 12) -> str:
text = f"{float(value):.{precision}f}".rstrip("0").rstrip(".")
return text or "0"
def _map_ticker_row(self, row: dict[str, Any]) -> dict[str, Any]:
symbol = self.normalize_symbol(row.get("instId"))
last_price = num(row.get("last")) or 0
open_24h = num(row.get("open24h")) or num(row.get("sodUtc8")) or num(row.get("sodUtc0")) or 0
quote_volume = 0.0
base_volume = num(row.get("volCcy24h"))
if base_volume is not None and last_price > 0:
quote_volume = base_volume * last_price
else:
quote_volume = num(row.get("vol24h")) or 0
price_change_pct = ((last_price - open_24h) / open_24h) * 100 if last_price > 0 and open_24h > 0 else 0
return {
"symbol": symbol,
"lastPrice": last_price,
"highPrice": num(row.get("high24h")) or last_price,
"lowPrice": num(row.get("low24h")) or last_price,
"priceChangePercent": price_change_pct,
"quoteVolume": quote_volume,
"baseVolume": base_volume,
"ts": row.get("ts"),
"raw": row,
}
def fetch_all_tickers_24h(self) -> list[dict[str, Any]]:
rows = self._public_get_data(
"/api/v5/market/tickers",
{"instType": "SWAP"},
namespace="market_tickers_swap",
ttl_seconds=60,
max_stale_seconds=45 * 60,
)
if not isinstance(rows, list):
return []
return [self._map_ticker_row(row) for row in rows if isinstance(row, dict)]
def fetch_all_premium_index(self) -> list[dict[str, Any]]:
try:
rows = self._public_get_data(
"/api/v5/public/mark-price",
{"instType": "SWAP"},
namespace="public_mark_price_swap",
ttl_seconds=60,
max_stale_seconds=45 * 60,
)
except Exception:
return []
if not isinstance(rows, list):
return []
return [
{
"symbol": self.normalize_symbol(row.get("instId")),
"markPrice": num(row.get("markPx")),
"lastFundingRate": None,
"fundingRate": None,
"raw": row,
}
for row in rows
if isinstance(row, dict) and row.get("instId")
]
def fetch_ticker_24h(self, symbol: str) -> dict[str, Any]:
normalized = self.normalize_symbol(symbol)
rows = self._public_get_data(
"/api/v5/market/ticker",
{"instId": normalized},
namespace=f"market_ticker_{normalized}",
ttl_seconds=20,
max_stale_seconds=30 * 60,
)
row = rows[0] if isinstance(rows, list) and rows else {}
return self._map_ticker_row(row if isinstance(row, dict) else {})
def fetch_premium(self, symbol: str) -> dict[str, Any]:
normalized = self.normalize_symbol(symbol)
mark_row: dict[str, Any] = {}
funding_row: dict[str, Any] = {}
try:
rows = self._public_get_data(
"/api/v5/public/mark-price",
{"instType": "SWAP", "instId": normalized},
namespace=f"public_mark_price_{normalized}",
ttl_seconds=20,
max_stale_seconds=30 * 60,
)
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
mark_row = rows[0]
except Exception:
mark_row = {}
try:
rows = self._public_get_data(
"/api/v5/public/funding-rate",
{"instId": normalized},
namespace=f"public_funding_rate_{normalized}",
ttl_seconds=60,
max_stale_seconds=12 * 60 * 60,
)
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
funding_row = rows[0]
except Exception:
funding_row = {}
funding_rate = num(funding_row.get("fundingRate"))
return {
"symbol": normalized,
"markPrice": num(mark_row.get("markPx")) or num(funding_row.get("markPx")),
"lastFundingRate": funding_rate,
"fundingRate": funding_rate,
"fundingPct": (funding_rate or 0) * 100,
"nextFundingTime": funding_row.get("nextFundingTime"),
"raw": {
"mark": mark_row,
"funding": funding_row,
},
}
def fetch_klines(self, symbol: str, interval: str, limit: int) -> list[dict[str, Any]]:
normalized = self.normalize_symbol(symbol)
bar = self.interval_map.get(str(interval or "").lower())
if not bar:
raise ValueError(f"Unsupported OKX kline interval: {interval}")
ttl_seconds, max_stale_seconds = self._cache_policy_for_kline_interval(interval)
rows = self._public_get_data(
"/api/v5/market/history-candles",
{"instId": normalized, "bar": bar, "limit": int(clamp(limit, 1, 300))},
namespace=f"market_history_candles_{normalized}_{bar}",
ttl_seconds=ttl_seconds,
max_stale_seconds=max_stale_seconds,
)
parsed: list[dict[str, Any]] = []
for row in reversed(rows if isinstance(rows, list) else []):
if not isinstance(row, list) or len(row) < 8:
continue
close_value = num(row[4])
if close_value is None:
continue
parsed.append(
{
"openTime": int(num(row[0]) or 0),
"open": num(row[1]),
"high": num(row[2]),
"low": num(row[3]),
"close": close_value,
"volume": num(row[5]),
"closeTime": int(num(row[0]) or 0),
"quoteVolume": num(row[7]) or num(row[6]),
}
)
return parsed
def live_execution_status(
self,
live_config: dict[str, Any] | None = None,
trading_settings: dict[str, Any] | None = None,
) -> dict[str, Any]:
live_config = live_config or read_live_trading_config()
issues = []
if not live_config.get("apiKey"):
issues.append("Live trading API key is missing.")
if not live_config.get("apiSecret"):
issues.append("Live trading API secret is missing.")
if not live_config.get("apiPassphrase"):
issues.append("OKX API passphrase is missing.")
if live_config.get("positionMode") != "oneway":
issues.append("Only OKX net mode / one-way mode is supported in the Python build.")
can_sync = not issues
can_execute = can_sync and live_config.get("enabled") and not live_config.get("dryRun")
return {
"configEnabled": live_config.get("enabled") is True,
"dryRun": live_config.get("dryRun") is True,
"armed": can_execute,
"canSync": can_sync,
"canExecute": can_execute,
"issues": issues,
"baseUrl": self.resolved_base_url(live_config),
"exchange": self.exchange_id,
}
def normalize_quantity(
self,
config: dict[str, Any],
symbol: str,
*,
reference_price: float | None = None,
quantity: float | None = None,
notional_usd: float | None = None,
) -> float:
info = self._symbol_info(symbol)
lot_size = num(info.get("lotSz")) or 1
min_size = num(info.get("minSz")) or lot_size
precision = self._step_precision(info.get("lotSz"))
raw_quantity = quantity
if raw_quantity is None:
contract_notional = self._contract_notional_usd(info, reference_price)
raw_quantity = (notional_usd or 0) / contract_notional if contract_notional > 0 else 0
normalized = self._round_down_to_step(max(0.0, float(raw_quantity or 0)), lot_size, precision)
if normalized < min_size:
raise ValueError(f"Quantity for {self.normalize_symbol(symbol)} is below exchange minimum.")
return normalized
def normalize_price(self, config: dict[str, Any], symbol: str, price: float) -> float:
info = self._symbol_info(symbol)
tick_size = num(info.get("tickSz")) or 0
if tick_size <= 0:
return float(price)
precision = self._step_precision(info.get("tickSz"))
return self._round_to_step(float(price), tick_size, precision)
def apply_symbol_settings(self, config: dict[str, Any], symbol: str) -> None:
body = {
"instId": self.normalize_symbol(symbol),
"lever": self._format_number(int(clamp(config.get("defaultLeverage"), 1, 125)), 0),
"mgnMode": "isolated" if str(config.get("marginType") or "cross").lower() == "isolated" else "cross",
"posSide": "net",
}
self._signed_request_json(config, "POST", "/api/v5/account/set-leverage", body=body)
def fetch_account_snapshot(self, config: dict[str, Any], session_started_at: str | None = None) -> dict[str, Any]:
balances = self._signed_request_json(config, "GET", "/api/v5/account/balance")
positions = self._signed_request_json(config, "GET", "/api/v5/account/positions", params={"instType": "SWAP"})
account_row = balances[0] if isinstance(balances, list) and balances else {}
balance_details = account_row.get("details") if isinstance(account_row, dict) else []
usdt_detail = next(
(item for item in balance_details or [] if str(item.get("ccy") or "").upper() == "USDT"),
{},
)
open_positions: list[dict[str, Any]] = []
unrealized_pnl = 0.0
for row in positions if isinstance(positions, list) else []:
contracts = num(row.get("pos"))
if contracts is None or abs(contracts) <= 1e-9:
continue
symbol = self.normalize_symbol(row.get("instId"))
side_hint = str(row.get("posSide") or "").strip().lower()
side = "long" if side_hint == "long" or contracts > 0 else "short"
quantity = abs(contracts)
entry_price = num(row.get("avgPx")) or num(row.get("openAvgPx")) or 0
mark_price = num(row.get("markPx")) or entry_price
try:
contract_value_usd = self._contract_notional_usd(self._symbol_info(symbol), mark_price)
notional_usd = abs(quantity * contract_value_usd)
except Exception:
notional_usd = abs(num(row.get("notionalUsd")) or (mark_price * quantity))
position_upl = num(row.get("upl")) or 0
unrealized_pnl += position_upl
open_positions.append(
{
"id": f"live-{symbol}",
"symbol": symbol,
"baseAsset": self.base_asset_from_symbol(symbol),
"side": side,
"quantity": quantity,
"initialQuantity": quantity,
"entryPrice": entry_price,
"notionalUsd": notional_usd,
"initialNotionalUsd": notional_usd,
"stopLoss": None,
"takeProfit": None,
"lastMarkPrice": mark_price,
"lastMarkTime": now_iso(),
"leverage": num(row.get("lever")) or 1,
"status": "open",
"openedAt": None,
"updatedAt": now_iso(),
"source": self.exchange_id,
"entryReason": "synced_from_exchange",
"decisionId": None,
}
)
wallet_balance = num(account_row.get("totalEq")) or num(usdt_detail.get("eqUsd")) or num(usdt_detail.get("eq")) or 0
available_balance = num(usdt_detail.get("availEq")) or num(usdt_detail.get("availBal")) or 0
return {
"walletBalanceUsd": wallet_balance,
"equityUsd": num(account_row.get("totalEq")) or wallet_balance,
"availableBalanceUsd": available_balance,
"unrealizedPnlUsd": unrealized_pnl,
"openPositions": open_positions,
"raw": {
"balance": balances,
"positions": positions,
},
}
def cancel_all_open_orders(self, config: dict[str, Any], symbol: str) -> Any:
normalized = self.normalize_symbol(symbol)
result: dict[str, Any] = {"ordersCancelled": [], "algosCancelled": []}
pending_orders = self._signed_request_json(
config,
"GET",
"/api/v5/trade/orders-pending",
params={"instType": "SWAP", "instId": normalized},
)
orders_to_cancel = []
for row in pending_orders if isinstance(pending_orders, list) else []:
ord_id = str(row.get("ordId") or "").strip()
if ord_id:
orders_to_cancel.append({"instId": normalized, "ordId": ord_id})
if orders_to_cancel:
result["ordersCancelled"] = self._signed_request_json(
config,
"POST",
"/api/v5/trade/cancel-batch-orders",
body=orders_to_cancel[:20],
)
pending_algos = self._signed_request_json(
config,
"GET",
"/api/v5/trade/orders-algo-pending",
params={"ordType": "conditional"},
)
algos_to_cancel = []
for row in pending_algos if isinstance(pending_algos, list) else []:
if self.normalize_symbol(row.get("instId")) != normalized:
continue
algo_id = str(row.get("algoId") or "").strip()
if algo_id:
algos_to_cancel.append({"instId": normalized, "algoId": algo_id})
if algos_to_cancel:
result["algosCancelled"] = self._signed_request_json(
config,
"POST",
"/api/v5/trade/cancel-algos",
body=algos_to_cancel[:20],
)
return result
def place_market_order(
self,
config: dict[str, Any],
*,
symbol: str,
side: str,
quantity: float,
reduce_only: bool = False,
) -> dict[str, Any]:
body = {
"instId": self.normalize_symbol(symbol),
"tdMode": "isolated" if str(config.get("marginType") or "cross").lower() == "isolated" else "cross",
"side": str(side or "").strip().lower(),
"ordType": "market",
"sz": self._format_number(quantity),
"posSide": "net",
}
if reduce_only:
body["reduceOnly"] = "true"
rows = self._signed_request_json(config, "POST", "/api/v5/trade/order", body=body)
return rows[0] if isinstance(rows, list) and rows else {}
def place_protection_orders(
self,
config: dict[str, Any],
*,
symbol: str,
position_side: str,
stop_loss: float | None,
take_profit: float | None,
) -> list[dict[str, Any]]:
created: list[dict[str, Any]] = []
if stop_loss is None and take_profit is None:
return created
normalized = self.normalize_symbol(symbol)
close_side = "sell" if position_side == "long" else "buy"
base_payload = {
"instId": normalized,
"tdMode": "isolated" if str(config.get("marginType") or "cross").lower() == "isolated" else "cross",
"side": close_side,
"ordType": "conditional",
"posSide": "net",
"closeFraction": "1",
}
if stop_loss is not None:
payload = {
**base_payload,
"slTriggerPx": self._format_number(self.normalize_price(config, normalized, stop_loss)),
"slTriggerPxType": "mark",
"slOrdPx": "-1",
}
rows = self._signed_request_json(config, "POST", "/api/v5/trade/order-algo", body=payload)
if isinstance(rows, list):
created.extend(rows)
if take_profit is not None:
payload = {
**base_payload,
"tpTriggerPx": self._format_number(self.normalize_price(config, normalized, take_profit)),
"tpTriggerPxType": "mark",
"tpOrdPx": "-1",
}
rows = self._signed_request_json(config, "POST", "/api/v5/trade/order-algo", body=payload)
if isinstance(rows, list):
created.extend(rows)
return created
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import ProxyHandler, Request, build_opener
from .utils import DATA_DIR, now_iso, read_json, sha1_hex, write_json
CACHE_DIR = DATA_DIR / "cache" / "http"
class HttpRequestError(RuntimeError):
pass
def _cache_path(namespace: str, url: str) -> Path:
return CACHE_DIR / namespace / f"{sha1_hex(url)}.json"
def _cache_payload(path: Path, payload: Any, ttl_seconds: int, max_stale_seconds: int) -> None:
now_ms = int(__import__("time").time() * 1000)
write_json(
path,
{
"fetchedAt": now_iso(),
"fetchedAtMs": now_ms,
"expiresAtMs": now_ms + max(1, ttl_seconds) * 1000,
"staleUntilMs": now_ms + max(ttl_seconds, max_stale_seconds) * 1000,
"payload": payload,
},
)
def _cache_is_fresh(cache: dict[str, Any] | None) -> bool:
if not cache:
return False
return int(cache.get("expiresAtMs") or 0) > int(__import__("time").time() * 1000)
def _cache_is_usable(cache: dict[str, Any] | None) -> bool:
if not cache:
return False
return int(cache.get("staleUntilMs") or 0) > int(__import__("time").time() * 1000)
def _should_bypass_proxy(hostname: str, network_settings: dict[str, Any]) -> bool:
no_proxy = [item.lower() for item in network_settings.get("noProxy", [])]
host = (hostname or "").lower()
return any(host == item or host.endswith(f".{item}") for item in no_proxy)
def _build_opener(url: str, network_settings: dict[str, Any] | None):
if not network_settings:
return build_opener()
parsed = urlparse(url)
if not network_settings.get("proxyEnabled") or not network_settings.get("proxyUrl") or _should_bypass_proxy(parsed.hostname or "", network_settings):
return build_opener()
proxy_url = str(network_settings.get("proxyUrl") or "").strip()
scheme = parsed.scheme.lower()
proxies = {
"http": proxy_url,
"https": proxy_url,
}
if scheme in {"http", "https"}:
return build_opener(ProxyHandler(proxies))
return build_opener()
def request_text(
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
payload: Any = None,
timeout_seconds: int = 45,
network_settings: dict[str, Any] | None = None,
) -> str:
body: bytes | None
if payload is None:
body = None
elif isinstance(payload, (bytes, bytearray)):
body = bytes(payload)
elif isinstance(payload, str):
body = payload.encode("utf-8")
else:
body = json.dumps(payload).encode("utf-8")
request = Request(url=url, method=method.upper(), data=body)
merged_headers = {
"accept": "application/json",
"user-agent": "python-trading-agent/1.0",
}
if body is not None and "content-type" not in {key.lower() for key in (headers or {})}:
merged_headers["content-type"] = "application/json"
merged_headers.update(headers or {})
for key, value in merged_headers.items():
request.add_header(key, value)
opener = _build_opener(url, network_settings or {})
try:
with opener.open(request, timeout=timeout_seconds) as response:
return response.read().decode("utf-8")
except HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise HttpRequestError(f"{error.code} {error.reason}: {detail}") from error
except URLError as error:
raise HttpRequestError(str(error.reason)) from error
def request_json(
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
payload: Any = None,
timeout_seconds: int = 45,
network_settings: dict[str, Any] | None = None,
) -> Any:
text = request_text(
method,
url,
headers=headers,
payload=payload,
timeout_seconds=timeout_seconds,
network_settings=network_settings,
)
try:
return json.loads(text)
except json.JSONDecodeError as error:
snippet = text[:220].replace("\n", " ").strip()
if len(text) > 220:
snippet += "..."
message = f"invalid JSON response from {url}: {error}"
if snippet:
message += f" | response starts with: {snippet}"
raise HttpRequestError(message) from error
def cached_get_json(
url: str,
*,
namespace: str = "generic",
ttl_seconds: int = 60,
max_stale_seconds: int = 3600,
timeout_seconds: int = 45,
headers: dict[str, str] | None = None,
network_settings: dict[str, Any] | None = None,
) -> Any:
path = _cache_path(namespace, url)
cache = read_json(path, {})
if _cache_is_fresh(cache):
return cache.get("payload")
try:
payload = request_json(
"GET",
url,
headers=headers,
timeout_seconds=timeout_seconds,
network_settings=network_settings,
)
_cache_payload(path, payload, ttl_seconds, max_stale_seconds)
return payload
except Exception:
if _cache_is_usable(cache):
return cache.get("payload")
raise
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any
from .config import read_live_trading_config
from .exchanges import get_live_exchange_gateway
def live_execution_status(
live_config: dict[str, Any] | None = None,
trading_settings: dict[str, Any] | None = None,
) -> dict[str, Any]:
live_config = live_config or read_live_trading_config()
gateway = get_live_exchange_gateway(live_config)
return gateway.live_execution_status(live_config, trading_settings)
def resolved_base_url(config: dict[str, Any]) -> str:
gateway = get_live_exchange_gateway(config)
return gateway.resolved_base_url(config)
def normalize_quantity(config: dict[str, Any], symbol: str, *, reference_price: float | None = None, quantity: float | None = None, notional_usd: float | None = None) -> float:
gateway = get_live_exchange_gateway(config)
return gateway.normalize_quantity(
config,
symbol,
reference_price=reference_price,
quantity=quantity,
notional_usd=notional_usd,
)
def normalize_price(config: dict[str, Any], symbol: str, price: float) -> float:
gateway = get_live_exchange_gateway(config)
return gateway.normalize_price(config, symbol, price)
def apply_symbol_settings(config: dict[str, Any], symbol: str) -> None:
gateway = get_live_exchange_gateway(config)
gateway.apply_symbol_settings(config, symbol)
def fetch_account_snapshot(config: dict[str, Any], session_started_at: str | None = None) -> dict[str, Any]:
gateway = get_live_exchange_gateway(config)
return gateway.fetch_account_snapshot(config, session_started_at=session_started_at)
def cancel_all_open_orders(config: dict[str, Any], symbol: str) -> Any:
gateway = get_live_exchange_gateway(config)
return gateway.cancel_all_open_orders(config, symbol)
def place_market_order(config: dict[str, Any], *, symbol: str, side: str, quantity: float, reduce_only: bool = False) -> dict[str, Any]:
gateway = get_live_exchange_gateway(config)
return gateway.place_market_order(
config,
symbol=symbol,
side=side,
quantity=quantity,
reduce_only=reduce_only,
)
def place_protection_orders(config: dict[str, Any], *, symbol: str, position_side: str, stop_loss: float | None, take_profit: float | None) -> list[dict[str, Any]]:
gateway = get_live_exchange_gateway(config)
return gateway.place_protection_orders(
config,
symbol=symbol,
position_side=position_side,
stop_loss=stop_loss,
take_profit=take_profit,
)
+283
View File
@@ -0,0 +1,283 @@
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
from .config import read_llm_provider, read_network_settings, write_llm_provider
from .http_client import HttpRequestError, request_json
from .utils import parse_json_loose
def provider_issues(provider: dict[str, Any] | None = None) -> list[str]:
provider = provider or read_llm_provider()
issues = []
if not provider.get("baseUrl"):
issues.append("LLM base URL is missing.")
if not provider.get("apiKey"):
issues.append("LLM API key is missing.")
if not provider.get("model"):
issues.append("LLM model is missing.")
if provider.get("apiStyle") not in {"openai", "anthropic"}:
issues.append("LLM apiStyle must be openai or anthropic.")
return issues
def provider_status(provider: dict[str, Any] | None = None) -> dict[str, Any]:
provider = provider or read_llm_provider()
issues = provider_issues(provider)
return {
"preset": provider.get("preset"),
"apiStyle": provider.get("apiStyle"),
"model": provider.get("model"),
"baseUrl": provider.get("baseUrl"),
"configured": not issues,
"issues": issues,
}
def _join_endpoint(base_url: str, suffix: str) -> str:
base = str(base_url or "").rstrip("/")
if base.endswith(suffix):
return base
return f"{base}{suffix}"
def _normalized_api_base_url(base_url: str, api_style: str) -> str:
raw = str(base_url or "").strip().rstrip("/")
if not raw:
return raw
parsed = urlparse(raw)
path = str(parsed.path or "").rstrip("/")
if api_style in {"openai", "anthropic"} and not path:
return f"{raw}/v1"
return raw
def _gateway_hint(provider: dict[str, Any], request_url: str) -> str | None:
base_url = str(provider.get("baseUrl") or "").strip()
parsed = urlparse(base_url)
host = str(parsed.hostname or "").lower()
api_style = str(provider.get("apiStyle") or "").strip().lower()
if api_style == "anthropic" and host and host not in {"api.anthropic.com", "console.anthropic.com"}:
return (
"当前 Base URL 不是 Anthropic 官方域名。很多第三方网关虽然支持 Claude 模型名,"
"但接口实际上是 OpenAI compatible。系统会继续自动尝试 OpenAI compatible。"
f" 当前请求地址:{request_url}"
)
return None
def _provider_transport_candidates(provider: dict[str, Any]) -> list[str]:
configured = str(provider.get("apiStyle") or "").strip().lower()
candidates: list[str] = []
if configured in {"openai", "anthropic"}:
candidates.append(configured)
preset = str(provider.get("preset") or "").strip().lower()
host = str(urlparse(str(provider.get("baseUrl") or "")).hostname or "").lower()
if preset == "claude" and host and host not in {"api.anthropic.com", "console.anthropic.com"}:
fallback = "openai" if configured != "openai" else "anthropic"
if fallback not in candidates:
candidates.append(fallback)
if not candidates:
candidates.append("openai")
return candidates
def _persist_provider_api_style(provider: dict[str, Any], resolved_api_style: str) -> dict[str, Any] | None:
current_style = str(provider.get("apiStyle") or "").strip().lower()
if resolved_api_style == current_style:
return None
return write_llm_provider(
{
"preset": provider.get("preset"),
"apiStyle": resolved_api_style,
"model": provider.get("model"),
"baseUrl": provider.get("baseUrl"),
"apiKey": provider.get("apiKey"),
"timeoutSeconds": provider.get("timeoutSeconds"),
"temperature": provider.get("temperature"),
"maxOutputTokens": provider.get("maxOutputTokens"),
"anthropicVersion": provider.get("anthropicVersion"),
"customHeaders": provider.get("customHeaders") if isinstance(provider.get("customHeaders"), dict) else {},
}
)
def _openai_messages(prompt_text: str) -> list[dict[str, str]]:
return [
{
"role": "system",
"content": (
"You are a disciplined crypto futures trading assistant. "
"Return strict JSON only. Do not include markdown fences."
),
},
{
"role": "user",
"content": prompt_text,
},
]
def _anthropic_message(prompt_text: str) -> tuple[str, list[dict[str, Any]]]:
system = (
"You are a disciplined crypto futures trading assistant. "
"Return strict JSON only. Do not include markdown fences."
)
messages = [
{
"role": "user",
"content": prompt_text,
}
]
return system, messages
def _extract_openai_text(response: dict[str, Any]) -> str:
choices = response.get("choices") if isinstance(response.get("choices"), list) else []
if not choices:
raise ValueError("OpenAI-compatible response did not include choices.")
message = choices[0].get("message") if isinstance(choices[0], dict) else {}
content = message.get("content") if isinstance(message, dict) else None
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(str(item.get("text") or ""))
if text_parts:
return "\n".join(text_parts)
raise ValueError("OpenAI-compatible response did not include message content.")
def _extract_anthropic_text(response: dict[str, Any]) -> str:
content = response.get("content") if isinstance(response.get("content"), list) else []
text_parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text_parts.append(str(block.get("text") or ""))
if not text_parts:
raise ValueError("Anthropic response did not include text content.")
return "\n".join(text_parts)
def _request_provider_text(
prompt_text: str,
provider: dict[str, Any],
request_api_style: str,
network_settings: dict[str, Any],
) -> dict[str, Any]:
headers = dict(provider.get("customHeaders") or {})
timeout_seconds = int(provider.get("timeoutSeconds") or 45)
api_base_url = _normalized_api_base_url(provider.get("baseUrl") or "", request_api_style)
if request_api_style == "anthropic":
system, messages = _anthropic_message(prompt_text)
url = _join_endpoint(api_base_url, "/messages")
headers.update(
{
"x-api-key": provider["apiKey"],
"anthropic-version": provider.get("anthropicVersion") or "2023-06-01",
"content-type": "application/json",
}
)
payload = {
"model": provider["model"],
"system": system,
"messages": messages,
"temperature": provider["temperature"],
"max_tokens": provider["maxOutputTokens"],
}
try:
raw_response = request_json(
"POST",
url,
headers=headers,
payload=payload,
timeout_seconds=timeout_seconds,
network_settings=network_settings,
)
except HttpRequestError as error:
hint = _gateway_hint({**provider, "apiStyle": request_api_style}, url)
if hint:
raise HttpRequestError(f"{error} | {hint}") from error
raise
raw_text = _extract_anthropic_text(raw_response)
else:
url = _join_endpoint(api_base_url, "/chat/completions")
headers.update(
{
"Authorization": f"Bearer {provider['apiKey']}",
"content-type": "application/json",
}
)
payload = {
"model": provider["model"],
"messages": _openai_messages(prompt_text),
"temperature": provider["temperature"],
"max_tokens": provider["maxOutputTokens"],
}
try:
raw_response = request_json(
"POST",
url,
headers=headers,
payload=payload,
timeout_seconds=timeout_seconds,
network_settings=network_settings,
)
except HttpRequestError as error:
raise HttpRequestError(f"{error} | 当前请求地址:{url}") from error
raw_text = _extract_openai_text(raw_response)
return {
"rawResponse": raw_response,
"rawText": raw_text,
"requestUrl": url,
"resolvedBaseUrl": api_base_url,
"resolvedApiStyle": request_api_style,
}
def generate_trading_decision(prompt_text: str, provider: dict[str, Any] | None = None) -> dict[str, Any]:
provider = provider or read_llm_provider()
issues = provider_issues(provider)
if issues:
raise ValueError(" ".join(issues))
configured_api_style = str(provider.get("apiStyle") or "").strip().lower()
network_settings = read_network_settings()
attempts: list[str] = []
last_error: Exception | None = None
selected_result: dict[str, Any] | None = None
for request_api_style in _provider_transport_candidates(provider):
try:
selected_result = _request_provider_text(prompt_text, provider, request_api_style, network_settings)
break
except (HttpRequestError, ValueError) as error:
last_error = error
attempts.append(f"{request_api_style}: {error}")
continue
if selected_result is None:
if len(attempts) > 1:
raise HttpRequestError(f"模型网关自动探测失败。已尝试 { ' | '.join(attempts) }") from last_error
if last_error:
raise last_error
raise HttpRequestError("模型请求失败,未获得可用响应。")
resolved_api_style = selected_result["resolvedApiStyle"]
saved_provider = _persist_provider_api_style(provider, resolved_api_style)
parsed = parse_json_loose(selected_result["rawText"])
return {
"provider": {
"preset": provider.get("preset"),
"apiStyle": configured_api_style,
"resolvedApiStyle": resolved_api_style,
"model": provider.get("model"),
"baseUrl": provider.get("baseUrl"),
"resolvedBaseUrl": selected_result["resolvedBaseUrl"],
"requestUrl": selected_result["requestUrl"],
"autoConfigured": resolved_api_style != configured_api_style,
"autoConfiguredSaved": saved_provider is not None,
},
"rawResponse": selected_result["rawResponse"],
"rawText": selected_result["rawText"],
"parsed": parsed,
}
+687
View File
@@ -0,0 +1,687 @@
from __future__ import annotations
import contextlib
import io
import math
import re
import time
from pathlib import Path
from typing import Any
from .config import PROMPT_KLINE_FEED_OPTIONS, read_candidate_source_code, read_fixed_universe, read_trading_settings
from .exchanges.catalog import DEFAULT_EXCHANGE_ID, normalize_exchange_id
from .exchanges import get_active_exchange_gateway
from .utils import CONFIG_DIR, DATA_DIR, ROOT, clamp, current_run_date, now_iso, num, read_json, safe_last, write_json
LEGACY_LATEST_SCAN_PATH = DATA_DIR / "scans" / "latest.json"
SCAN_BUCKET_LIMITS = {
"research": 70,
"watch": 55,
"liquidityFloorUsd": 5_000_000,
}
def _latest_scan_path(exchange_id: str | None = None) -> Path:
if exchange_id is None:
settings = read_trading_settings()
exchange_id = settings.get("activeExchange")
normalized = normalize_exchange_id(exchange_id, capability="market")
return DATA_DIR / "scans" / normalized / "latest.json"
def read_latest_scan(exchange_id: str | None = None) -> dict[str, Any]:
if exchange_id is None:
settings = read_trading_settings()
exchange_id = settings.get("activeExchange")
normalized = normalize_exchange_id(exchange_id, capability="market")
payload = read_json(_latest_scan_path(normalized), None)
if not isinstance(payload, dict) and normalized == DEFAULT_EXCHANGE_ID:
payload = read_json(LEGACY_LATEST_SCAN_PATH, {})
if isinstance(payload, dict):
return {
"version": int(payload.get("version") or 1),
"runDate": payload.get("runDate"),
"fetchedAt": payload.get("fetchedAt"),
"source": payload.get("source") or "fixed_universe",
"exchange": str(payload.get("exchange") or normalized),
"universeSize": int(payload.get("universeSize") or 0),
"opportunities": payload.get("opportunities") if isinstance(payload.get("opportunities"), list) else [],
}
return {
"version": 1,
"runDate": None,
"fetchedAt": None,
"source": "fixed_universe",
"exchange": normalized,
"universeSize": 0,
"opportunities": [],
}
def _normalize_candidate_symbols(raw_symbols: Any) -> list[str]:
if isinstance(raw_symbols, str):
candidate_values = [item.strip().upper() for item in raw_symbols.replace(",", "\n").splitlines()]
elif isinstance(raw_symbols, (list, tuple, set)):
candidate_values = [str(item).strip().upper() for item in raw_symbols]
else:
candidate_values = []
symbols: list[str] = []
for item in candidate_values:
if item and item not in symbols:
symbols.append(item)
return symbols
def _split_valid_candidate_symbols(raw_symbols: Any, exchange_id: str | None = None) -> tuple[list[str], list[str]]:
gateway = get_active_exchange_gateway(exchange_id)
normalized = _normalize_candidate_symbols(raw_symbols)
valid: list[str] = []
invalid: list[str] = []
for symbol in normalized:
if gateway.validate_symbol(symbol):
valid.append(symbol)
else:
invalid.append(symbol)
return valid, invalid
def dynamic_candidate_source_enabled(universe: dict[str, Any] | None = None) -> bool:
universe = universe or read_fixed_universe()
return universe.get("dynamicSource", {}).get("enabled") is True
def resolve_candidate_symbols(
universe: dict[str, Any] | None = None,
code_override: str | None = None,
exchange_id: str | None = None,
) -> dict[str, Any]:
universe = universe or read_fixed_universe()
gateway = get_active_exchange_gateway(exchange_id)
dynamic_source = universe.get("dynamicSource", {}) if isinstance(universe.get("dynamicSource"), dict) else {}
if dynamic_source.get("enabled") is not True:
manual_symbols = list(universe.get("symbols", []))
valid_symbols, invalid_symbols = _split_valid_candidate_symbols(manual_symbols, exchange_id)
if not valid_symbols:
raise ValueError("Manual candidate pool is empty.")
return {
"mode": "manual_symbols",
"enabled": False,
"symbols": valid_symbols,
"invalidSymbols": invalid_symbols,
"stdout": "",
"note": "Using manually configured symbols.",
"durationMs": 0,
"functionName": dynamic_source.get("functionName") or "load_candidate_symbols",
}
source_code = str(code_override if code_override is not None else read_candidate_source_code())
function_name = str(dynamic_source.get("functionName") or "load_candidate_symbols").strip() or "load_candidate_symbols"
scope: dict[str, Any] = {"__builtins__": __builtins__}
context = {
"project_root": str(ROOT),
"config_dir": str(CONFIG_DIR),
"data_dir": str(DATA_DIR),
"manual_symbols": list(universe.get("symbols", [])),
"network_settings": read_network_settings(),
"run_date": current_run_date(),
"now_iso": now_iso(),
}
stdout_buffer = io.StringIO()
started = time.perf_counter()
with contextlib.redirect_stdout(stdout_buffer):
exec(source_code, scope)
func = scope.get(function_name)
if not callable(func):
raise ValueError(f"Dynamic candidate source must define `{function_name}(context)`.")
result = func(context)
duration_ms = int((time.perf_counter() - started) * 1000)
note = None
if isinstance(result, dict):
note = str(result.get("note") or result.get("message") or "").strip() or None
raw_symbols = result.get("symbols")
else:
raw_symbols = result
symbols, invalid_symbols = _split_valid_candidate_symbols(raw_symbols, exchange_id)
if not symbols:
raise ValueError(f"Dynamic candidate source returned no valid {gateway.candidate_symbol_hint()}.")
return {
"mode": "python_function",
"enabled": True,
"symbols": symbols,
"invalidSymbols": invalid_symbols,
"stdout": stdout_buffer.getvalue().strip(),
"note": note,
"durationMs": duration_ms,
"functionName": function_name,
}
def test_candidate_source(
universe: dict[str, Any] | None = None,
code_override: str | None = None,
exchange_id: str | None = None,
) -> dict[str, Any]:
resolved = resolve_candidate_symbols(universe=universe, code_override=code_override, exchange_id=exchange_id)
return {
"mode": resolved["mode"],
"enabled": resolved["enabled"],
"functionName": resolved["functionName"],
"durationMs": resolved["durationMs"],
"count": len(resolved["symbols"]),
"symbols": resolved["symbols"],
"invalidSymbols": resolved.get("invalidSymbols", []),
"stdout": resolved["stdout"],
"note": resolved["note"],
}
def score_symbol(*, price_change_pct: float, quote_volume: float, funding_pct: float, range_pct: float) -> float:
impulse = clamp(abs(price_change_pct) * 3.5, 0, 38)
volume_score = clamp((math.log10(quote_volume + 1) - 6.2) * 11, 0, 24) if quote_volume > 0 else 0
funding_skew = clamp(abs(funding_pct) * 260, 0, 14) if (
(price_change_pct > 0 and funding_pct < 0) or (price_change_pct < 0 and funding_pct > 0)
) else 0
range_score = clamp((range_pct - 3) * 1.3, 0, 20)
return clamp(28 + impulse + volume_score + funding_skew + range_score, 0, 100)
def directional_bias(price_change_pct: float, funding_pct: float) -> str:
if price_change_pct >= 1:
return "Bullish"
if price_change_pct <= -1:
return "Bearish"
if funding_pct < -0.02:
return "Bullish"
if funding_pct > 0.02:
return "Bearish"
return "Neutral"
def matched_strategies(price_change_pct: float, funding_pct: float, score: float) -> list[dict[str, Any]]:
if price_change_pct >= 4 and funding_pct < 0:
return [
{
"id": "mm_short_squeeze_fuel",
"name": "Short squeeze continuation",
"reason": "Positive impulse is rising while funding stays negative, so shorts may still be trapped.",
},
{
"id": "attention_event_speedrun",
"name": "Attention speedrun",
"reason": "The contract is already moving hard enough to warrant breakout-style review.",
},
]
if price_change_pct <= -4 and funding_pct > 0:
return [
{
"id": "mm_long_squeeze_fuel",
"name": "Long squeeze flush",
"reason": "Negative impulse is accelerating while funding stays positive, so crowded longs may keep unwinding.",
},
{
"id": "attention_event_flush",
"name": "Attention flush",
"reason": "The move is weak enough to keep short-side failure scenarios active.",
},
]
if score >= 70 and price_change_pct >= 0:
return [
{
"id": "alpha_contract_path",
"name": "Trend continuation path",
"reason": "Liquid contract with enough motion to keep trend-following entries on the table.",
}
]
if score >= 70 and price_change_pct < 0:
return [
{
"id": "attention_event_flush",
"name": "Weak trend continuation",
"reason": "Negative trend pressure is strong enough to keep pullback shorts interesting.",
}
]
return [
{
"id": "alpha_contract_path",
"name": "Wait for cleaner structure",
"reason": "Keep the name in review, but wait for better intraday structure before forcing entries.",
}
]
def detectors(*, price_change_pct: float, quote_volume: float, funding_pct: float, range_pct: float, min_quote_volume_usd: float) -> list[dict[str, Any]]:
volume_passed = quote_volume >= min_quote_volume_usd
impulse_passed = abs(price_change_pct) >= 4
skew_passed = (price_change_pct > 0 and funding_pct < 0.01) or (price_change_pct < 0 and funding_pct > -0.01)
range_passed = range_pct >= 6
needle_risk = range_pct >= 18 and abs(price_change_pct) < 3
return [
{
"id": "quote_volume_liquidity",
"label": "Liquidity",
"passed": volume_passed,
"effect": 10 if volume_passed else 0,
"reason": "Quote volume is large enough to support entries and exits." if volume_passed else "Liquidity is still below the preferred review floor.",
},
{
"id": "price_impulse",
"label": "Impulse",
"passed": impulse_passed,
"effect": 12 if impulse_passed else 0,
"reason": "24h price change is large enough to keep the name on active review." if impulse_passed else "Momentum is still too small to force priority review.",
},
{
"id": "funding_skew",
"label": "Funding skew",
"passed": skew_passed,
"effect": 8 if skew_passed else 0,
"reason": "Funding skew is not fighting the current move too aggressively." if skew_passed else "Funding does not add much directional confirmation right now.",
},
{
"id": "range_expansion",
"label": "Range expansion",
"passed": range_passed,
"effect": 8 if range_passed else 0,
"reason": "The daily range is open enough to justify active monitoring." if range_passed else "Range is still compressed.",
},
{
"id": "needle_trap_risk",
"label": "Needle trap risk",
"passed": needle_risk,
"effect": -10 if needle_risk else 0,
"reason": "Range is wide without directional follow-through, so wick risk is elevated." if needle_risk else "No obvious wick-trap penalty.",
},
]
def bucket_for_score(score: float, price_change_pct: float, quote_volume: float, thresholds: dict[str, Any] | None = None) -> str:
thresholds = thresholds or SCAN_BUCKET_LIMITS
if score >= thresholds["research"] and quote_volume >= thresholds["liquidityFloorUsd"]:
return "Research"
if score >= thresholds["watch"] or abs(price_change_pct) >= 3:
return "Watch"
return "No trade"
def refresh_candidate_pool(exchange_id: str | None = None) -> dict[str, Any]:
gateway = get_active_exchange_gateway(exchange_id)
universe = read_fixed_universe()
thresholds = SCAN_BUCKET_LIMITS
resolved_source = resolve_candidate_symbols(universe=universe, exchange_id=gateway.exchange_id)
symbols = resolved_source["symbols"]
if not symbols:
raise RuntimeError("config/fixed_universe.json does not contain any symbols.")
tickers = gateway.fetch_all_tickers_24h()
premium = gateway.fetch_all_premium_index()
ticker_by_symbol = {str(item.get("symbol") or "").upper(): item for item in (tickers or [])}
premium_by_symbol = {str(item.get("symbol") or "").upper(): item for item in (premium or [])}
opportunities: list[dict[str, Any]] = []
skipped_symbols: list[str] = []
for symbol in symbols:
ticker = ticker_by_symbol.get(symbol, {})
premium_row = premium_by_symbol.get(symbol, {})
if not ticker and not premium_row:
skipped_symbols.append(symbol)
continue
last_price = num(ticker.get("lastPrice")) or num(premium_row.get("markPrice")) or 0
high_price = num(ticker.get("highPrice")) or last_price
low_price = num(ticker.get("lowPrice")) or last_price
price_change_pct = num(ticker.get("priceChangePercent")) or 0
quote_volume = num(ticker.get("quoteVolume")) or 0
funding_pct = (num(premium_row.get("lastFundingRate")) or num(premium_row.get("fundingRate")) or 0) * 100
range_pct = ((high_price - low_price) / last_price) * 100 if last_price > 0 and high_price > 0 and low_price > 0 else 0
score = score_symbol(
price_change_pct=price_change_pct,
quote_volume=quote_volume,
funding_pct=funding_pct,
range_pct=range_pct,
)
action_bucket = bucket_for_score(score, price_change_pct, quote_volume, thresholds)
bias = directional_bias(price_change_pct, funding_pct)
strategies = matched_strategies(price_change_pct, funding_pct, score)
signal_detectors = detectors(
price_change_pct=price_change_pct,
quote_volume=quote_volume,
funding_pct=funding_pct,
range_pct=range_pct,
min_quote_volume_usd=thresholds["liquidityFloorUsd"],
)
flags = []
if range_pct >= 18:
flags.append("wide_range")
if abs(price_change_pct) >= 10:
flags.append("fast_move")
if quote_volume < thresholds["liquidityFloorUsd"]:
flags.append("light_liquidity")
opportunities.append(
{
"symbol": symbol,
"baseAsset": gateway.base_asset_from_symbol(symbol),
"score": round(score, 2),
"actionBucket": action_bucket,
"directionalBias": bias,
"summary": strategies[0]["reason"] if strategies else "Fixed-universe review candidate.",
"matchedStrategies": strategies,
"detectors": signal_detectors,
"flags": flags,
"market": {
"lastPrice": last_price,
"priceChangePct": price_change_pct,
"quoteVolume": quote_volume,
"fundingPct": funding_pct,
"rangePct": range_pct,
"openInterestNotional": None,
},
}
)
opportunities.sort(key=lambda item: float(item.get("score") or 0), reverse=True)
payload = {
"version": 1,
"runDate": current_run_date(),
"fetchedAt": __import__("datetime").datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
"source": resolved_source["mode"],
"exchange": gateway.exchange_id,
"universeSize": len(symbols),
"skippedSymbols": skipped_symbols,
"candidateSource": {
"mode": resolved_source["mode"],
"enabled": resolved_source["enabled"],
"functionName": resolved_source["functionName"],
"durationMs": resolved_source["durationMs"],
"invalidSymbols": resolved_source.get("invalidSymbols", []),
"stdout": resolved_source["stdout"],
"note": resolved_source["note"],
},
"opportunities": opportunities,
}
write_json(_latest_scan_path(gateway.exchange_id), payload)
if gateway.exchange_id == DEFAULT_EXCHANGE_ID:
write_json(LEGACY_LATEST_SCAN_PATH, payload)
return payload
def parse_klines(rows: list[list[Any]] | None) -> list[dict[str, Any]]:
parsed = []
for row in rows or []:
close_value = num(row[4]) if len(row) > 4 else None
if close_value is None:
continue
parsed.append(
{
"openTime": row[0],
"open": num(row[1]),
"high": num(row[2]),
"low": num(row[3]),
"close": close_value,
"volume": num(row[5]),
"closeTime": row[6],
"quoteVolume": num(row[7]),
}
)
return parsed
def average(values: list[float | None]) -> float | None:
filtered = [value for value in values if value is not None]
if not filtered:
return None
return sum(filtered) / len(filtered)
def ema(values: list[float | None], period: int) -> float | None:
filtered = [value for value in values if value is not None]
if not filtered:
return None
factor = 2 / (max(1, period) + 1)
current = filtered[0]
for value in filtered[1:]:
current = (value * factor) + (current * (1 - factor))
return current
def atr(candles: list[dict[str, Any]], period: int = 14) -> float | None:
if len(candles) < 2:
return None
true_ranges = []
for index in range(1, len(candles)):
current = candles[index]
previous = candles[index - 1]
high = num(current.get("high"))
low = num(current.get("low"))
previous_close = num(previous.get("close"))
if high is None or low is None or previous_close is None:
continue
true_ranges.append(max(high - low, abs(high - previous_close), abs(low - previous_close)))
return average(true_ranges[-max(1, period):])
def highest(candles: list[dict[str, Any]], field: str, count: int) -> float | None:
values = [num(item.get(field)) for item in candles[-max(1, count):]]
filtered = [value for value in values if value is not None]
return max(filtered) if filtered else None
def lowest(candles: list[dict[str, Any]], field: str, count: int) -> float | None:
values = [num(item.get(field)) for item in candles[-max(1, count):]]
filtered = [value for value in values if value is not None]
return min(filtered) if filtered else None
def pct_distance(left: float | None, right: float | None) -> float | None:
if left is None or right in (None, 0):
return None
return ((left - right) / right) * 100
PROMPT_KLINE_LIMIT = 20
PROMPT_KLINE_FETCH_SPECS = {
"1m": {"limit": 120},
"5m": {"limit": 96},
"15m": {"limit": 64},
}
def normalize_prompt_kline_feeds(raw_feeds: Any) -> dict[str, dict[str, Any]]:
normalized: dict[str, dict[str, Any]] = {}
source = raw_feeds if isinstance(raw_feeds, dict) else {}
for interval in PROMPT_KLINE_FEED_OPTIONS:
current = source.get(interval) if isinstance(source.get(interval), dict) else {}
normalized[interval] = {
"enabled": current.get("enabled") is True,
"limit": int(clamp(current.get("limit"), 1, 300)),
}
if not current:
normalized[interval]["limit"] = PROMPT_KLINE_FETCH_SPECS[interval]["limit"]
if not any(item["enabled"] for item in normalized.values()):
normalized["15m"]["enabled"] = True
return normalized
def enabled_prompt_kline_feeds(raw_feeds: Any) -> dict[str, dict[str, Any]]:
feeds = normalize_prompt_kline_feeds(raw_feeds)
return {
interval: feeds[interval]
for interval in PROMPT_KLINE_FEED_OPTIONS
if feeds[interval]["enabled"]
}
def compact_klines(candles: list[dict[str, Any]], limit: int = PROMPT_KLINE_LIMIT) -> list[dict[str, Any]]:
rows = candles[-max(1, limit):]
return [
{
"openTime": item.get("openTime"),
"open": item.get("open"),
"high": item.get("high"),
"low": item.get("low"),
"close": item.get("close"),
"volume": item.get("volume"),
"closeTime": item.get("closeTime"),
"quoteVolume": item.get("quoteVolume"),
}
for item in rows
]
def fetch_klines(symbol: str, interval: str, limit: int, exchange_id: str | None = None) -> list[dict[str, Any]]:
gateway = get_active_exchange_gateway(exchange_id)
return gateway.fetch_klines(symbol, interval, limit)
def fetch_ticker_24h(symbol: str, exchange_id: str | None = None) -> dict[str, Any]:
gateway = get_active_exchange_gateway(exchange_id)
return gateway.fetch_ticker_24h(symbol)
def fetch_premium(symbol: str, exchange_id: str | None = None) -> dict[str, Any]:
gateway = get_active_exchange_gateway(exchange_id)
return gateway.fetch_premium(symbol)
def _fetch_prompt_kline_map(
symbol: str,
prompt_kline_feeds: Any,
exchange_id: str | None = None,
) -> dict[str, list[dict[str, Any]]]:
feeds = enabled_prompt_kline_feeds(prompt_kline_feeds)
return {
interval: compact_klines(fetch_klines(symbol, interval, feed["limit"], exchange_id), feed["limit"])
for interval, feed in feeds.items()
}
def _default_entry_side(opportunity: dict[str, Any], candles: list[dict[str, Any]], allow_shorts: bool) -> str | None:
directional_bias = str(opportunity.get("directionalBias") or "").strip().lower()
if directional_bias == "bullish":
return "long"
if allow_shorts and directional_bias == "bearish":
return "short"
first_close = num(candles[0].get("close")) if candles else None
last_close = num(candles[-1].get("close")) if candles else None
if first_close is None or last_close is None or first_close == last_close:
return None
if last_close > first_close:
return "long"
if allow_shorts:
return "short"
return None
def _default_stop_and_target(side: str | None, price: float | None, candles: list[dict[str, Any]]) -> tuple[float | None, float | None]:
entry = num(price)
atr_value = atr(candles, 14)
if entry is None or side not in {"long", "short"}:
return None, None
raw_stop_pct = ((atr_value / entry) * 100 * 1.45) if atr_value else 3
stop_pct = clamp(raw_stop_pct, 3, 12)
reward_risk = 2.5
if side == "short":
stop_loss = entry * (1 + stop_pct / 100)
take_profit = entry - ((stop_loss - entry) * reward_risk)
return stop_loss, take_profit
stop_loss = entry * (1 - stop_pct / 100)
take_profit = entry + ((entry - stop_loss) * reward_risk)
return stop_loss, take_profit
def _default_entry_confidence(opportunity: dict[str, Any], candles: list[dict[str, Any]], default_side: str | None) -> float:
score = num(opportunity.get("score")) or 60
first_close = num(candles[0].get("close")) if candles else None
last_close = num(candles[-1].get("close")) if candles else None
trend_bonus = 0
if first_close is not None and last_close is not None and default_side in {"long", "short"}:
if default_side == "long" and last_close > first_close:
trend_bonus = 8
elif default_side == "short" and last_close < first_close:
trend_bonus = 8
else:
trend_bonus = -6
return clamp(score + trend_bonus, 1, 100)
def fetch_candidate_live_context(
symbol: str,
prompt_kline_feeds: dict[str, Any] | None = None,
exchange_id: str | None = None,
) -> dict[str, Any]:
ticker_24h = fetch_ticker_24h(symbol, exchange_id)
premium = fetch_premium(symbol, exchange_id)
feeds = normalize_prompt_kline_feeds(prompt_kline_feeds)
klines_by_interval = _fetch_prompt_kline_map(symbol, feeds, exchange_id)
return {
"symbol": symbol.upper(),
"ticker24h": ticker_24h,
"premium": premium,
"promptKlineFeeds": feeds,
"klinesByInterval": klines_by_interval,
}
def fetch_market_backdrop(
prompt_kline_feeds: dict[str, Any] | None = None,
exchange_id: str | None = None,
) -> dict[str, Any]:
gateway = get_active_exchange_gateway(exchange_id)
live = fetch_candidate_live_context(gateway.default_backdrop_symbol, prompt_kline_feeds, gateway.exchange_id)
price = num(live["premium"].get("markPrice")) or num(live["ticker24h"].get("lastPrice"))
return {
"symbol": gateway.default_backdrop_symbol,
"price": price,
"priceChangePct": live["ticker24h"].get("priceChangePct"),
"quoteVolume": live["ticker24h"].get("quoteVolume"),
"fundingPct": live["premium"].get("fundingPct"),
"klineFeeds": live["promptKlineFeeds"],
"klinesByInterval": live["klinesByInterval"],
}
def candidate_universe_from_scan(scan: dict[str, Any]) -> list[dict[str, Any]]:
ranked = sorted(
scan.get("opportunities", []),
key=lambda item: float(item.get("score") or 0),
reverse=True,
)
return ranked
def build_candidate_snapshot(
opportunity: dict[str, Any],
live: dict[str, Any],
settings: dict[str, Any],
exchange_id: str | None = None,
) -> dict[str, Any]:
gateway = get_active_exchange_gateway(exchange_id)
price = num(live["premium"].get("markPrice")) or num(live["ticker24h"].get("lastPrice")) or num(opportunity.get("market", {}).get("lastPrice"))
prompt_feeds = normalize_prompt_kline_feeds(live.get("promptKlineFeeds"))
if prompt_feeds["15m"]["enabled"]:
primary_interval = "15m"
elif prompt_feeds["5m"]["enabled"]:
primary_interval = "5m"
else:
primary_interval = "1m"
primary_klines = list((live.get("klinesByInterval") or {}).get(primary_interval) or [])
default_side = _default_entry_side(opportunity, primary_klines, settings["allowShorts"])
default_stop_loss, default_take_profit = _default_stop_and_target(default_side, price, primary_klines)
top_strategy = None
strategies = opportunity.get("matchedStrategies") or []
if strategies:
top_strategy = strategies[0].get("name") or strategies[0].get("reason")
return {
"symbol": opportunity["symbol"],
"baseAsset": opportunity.get("baseAsset") or gateway.base_asset_from_symbol(opportunity["symbol"]),
"price": price,
"priceChangePct": live["ticker24h"].get("priceChangePct"),
"quoteVolume": live["ticker24h"].get("quoteVolume"),
"fundingPct": live["premium"].get("fundingPct"),
"klineFeeds": prompt_feeds,
"klinesByInterval": live["klinesByInterval"],
"defaultSide": default_side,
"defaultStopLoss": default_stop_loss,
"defaultTakeProfit": default_take_profit,
"confidenceScore": _default_entry_confidence(opportunity, primary_klines, default_side),
"topStrategy": top_strategy or opportunity.get("summary") or "",
}
+724
View File
@@ -0,0 +1,724 @@
from __future__ import annotations
import ipaddress
import json
import socket
import threading
import time
from collections import deque
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from .config import (
DEFAULT_PROMPT_SETTINGS,
preview_fixed_universe,
read_candidate_source_code,
read_dashboard_settings,
read_fixed_universe,
read_live_exchange_catalog,
read_live_trading_config,
read_llm_provider,
read_network_settings,
delete_prompt_preset,
read_prompt_library,
read_prompt_preset,
read_prompt_settings,
read_trading_settings,
rename_prompt_preset,
save_prompt_preset,
write_dashboard_settings,
write_fixed_universe,
write_live_trading_config,
write_llm_provider,
write_network_settings,
write_prompt_settings,
write_trading_settings,
)
from .http_client import HttpRequestError, request_text
from .engine import (
flatten_active_account,
preview_trading_prompt_decision,
read_trading_state,
refresh_account_state_after_settings_save,
reset_trading_account,
run_trading_cycle_batch,
summarize_trading_state,
)
from .market import read_latest_scan, refresh_candidate_pool
from .market import test_candidate_source
from .utils import DASHBOARD_DIR, now_iso
SCHEDULE_TRIGGER_WINDOW_SECONDS = 20
PUBLIC_IP_PROBES = (
("ipify", "https://api.ipify.org"),
("ifconfig.me", "https://ifconfig.me/ip"),
("icanhazip", "https://icanhazip.com"),
)
def _prompt_lines(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
if isinstance(value, str):
return [item.strip() for item in value.splitlines() if item.strip()]
return []
def _friendly_ip_error(error: Any) -> str:
text = str(error or "").strip()
lowered = text.lower()
if not text or text == "None":
return "无法连接公网 IP 查询服务。"
if "expected pattern" in lowered:
return "代理地址格式可能不正确,或当前网络环境无法完成公网 IP 查询。"
if "nodename nor servname provided" in lowered or "name or service not known" in lowered:
return "无法解析公网 IP 查询服务域名。"
return text
def _detect_local_ip() -> str | None:
for target in ("1.1.1.1", "8.8.8.8"):
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect((target, 80))
candidate = str(sock.getsockname()[0] or "").strip()
if candidate and not candidate.startswith("127."):
ipaddress.ip_address(candidate)
return candidate
except OSError:
continue
try:
hostname = socket.gethostname()
for family, _, _, _, sockaddr in socket.getaddrinfo(hostname, None):
if family not in {socket.AF_INET, socket.AF_INET6}:
continue
candidate = str(sockaddr[0]).split("%", 1)[0].strip()
if not candidate:
continue
parsed = ipaddress.ip_address(candidate)
if parsed.is_loopback or parsed.is_unspecified:
continue
return candidate
except OSError:
return None
return None
def _network_ip_payload() -> dict[str, Any]:
network_settings = read_network_settings()
last_error = None
for source, url in PUBLIC_IP_PROBES:
try:
raw = request_text(
"GET",
url,
timeout_seconds=5,
network_settings=network_settings,
).strip()
ip_text = raw.splitlines()[0].strip()
ipaddress.ip_address(ip_text)
return {
"ip": ip_text,
"source": source,
"scope": "public",
"proxyEnabled": network_settings.get("proxyEnabled") is True,
"error": None,
}
except (HttpRequestError, OSError, ValueError) as error:
last_error = _friendly_ip_error(error)
local_ip = _detect_local_ip()
if local_ip:
return {
"ip": local_ip,
"source": "local",
"scope": "local",
"proxyEnabled": network_settings.get("proxyEnabled") is True,
"error": last_error,
}
return {
"ip": None,
"source": None,
"scope": "unknown",
"proxyEnabled": network_settings.get("proxyEnabled") is True,
"error": last_error or "无法获取本机 IP 地址。",
}
def _prompt_form_payload(prompt: dict[str, Any]) -> dict[str, Any]:
logic = prompt.get("decision_logic") if isinstance(prompt.get("decision_logic"), dict) else {}
core_principles = _prompt_lines(logic.get("core_principles"))
entry_preferences = _prompt_lines(logic.get("entry_preferences"))
position_management = _prompt_lines(logic.get("position_management"))
return {
**prompt,
"role": str(logic.get("role") or ""),
"corePrinciplesText": "\n".join(core_principles),
"entryPreferencesText": "\n".join(entry_preferences),
"positionManagementText": "\n".join(position_management),
"klineFeeds": prompt.get("klineFeeds") if isinstance(prompt.get("klineFeeds"), dict) else dict(DEFAULT_PROMPT_SETTINGS.get("klineFeeds", {})),
}
def _prompt_logic_from_payload(payload: dict[str, Any], fallback_prompt: dict[str, Any] | None = None) -> dict[str, Any]:
fallback_prompt = fallback_prompt or read_prompt_settings()
fallback_logic = fallback_prompt.get("decision_logic") if isinstance(fallback_prompt.get("decision_logic"), dict) else {}
if "rawJson" in payload:
parsed = json.loads(payload["rawJson"])
if not isinstance(parsed, dict):
raise ValueError("decision_logic payload must be an object.")
return parsed
response_style = DEFAULT_PROMPT_SETTINGS["decision_logic"]["response_style"]
return {
"role": str(payload.get("role") or fallback_logic.get("role") or "").strip(),
"core_principles": _prompt_lines(payload.get("corePrinciplesText", fallback_logic.get("core_principles", []))),
"entry_preferences": _prompt_lines(payload.get("entryPreferencesText", fallback_logic.get("entry_preferences", []))),
"position_management": _prompt_lines(payload.get("positionManagementText", fallback_logic.get("position_management", []))),
"response_style": list(response_style),
}
class AppRuntime:
def __init__(self) -> None:
self.lock = threading.Lock()
self.trade_lock = threading.Lock()
self.session_started_at = now_iso()
self.log_entries: deque[dict[str, Any]] = deque(maxlen=400)
self.scan_runner = {
"running": False,
"lastStartedAt": None,
"lastFinishedAt": None,
"lastError": None,
"lastReason": None,
}
self.trade_runners = {
"paper": {
"running": False,
"lastStartedAt": None,
"lastFinishedAt": None,
"lastError": None,
"lastReason": None,
},
"live": {
"running": False,
"lastStartedAt": None,
"lastFinishedAt": None,
"lastError": None,
"lastReason": None,
},
}
self._scheduler_started = False
def record_log(self, level: str, message: str) -> None:
level_text = (level or "INFO").upper()
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [{level_text}] {message}"
with self.lock:
self.log_entries.append(
{
"at": now_iso(),
"level": level_text,
"message": message,
"line": line,
}
)
print(line, flush=True)
def api_logs(self) -> dict[str, Any]:
with self.lock:
return {
"sessionStartedAt": self.session_started_at,
"entries": list(self.log_entries),
"paperRunner": dict(self.trade_runners["paper"]),
"liveRunner": dict(self.trade_runners["live"]),
"scanRunner": dict(self.scan_runner),
}
def api_latest(self) -> dict[str, Any]:
scan = read_latest_scan()
opportunities = scan.get("opportunities", [])
return {
"updatedAt": now_iso(),
"scan": {
"runDate": scan.get("runDate"),
"fetchedAt": scan.get("fetchedAt"),
"opportunities": len(opportunities),
"research": len([item for item in opportunities if item.get("actionBucket") == "Research"]),
"watch": len([item for item in opportunities if item.get("actionBucket") == "Watch"]),
"noTrade": len([item for item in opportunities if item.get("actionBucket") == "No trade"]),
"scanRunner": self.scan_runner,
},
}
def _run_scan_job(self, reason: str) -> None:
with self.lock:
self.scan_runner["running"] = True
self.scan_runner["lastStartedAt"] = now_iso()
self.scan_runner["lastFinishedAt"] = None
self.scan_runner["lastError"] = None
self.scan_runner["lastReason"] = reason
self.record_log("INFO", f"开始刷新候选池,触发原因:{reason}")
try:
refresh_candidate_pool()
latest = read_latest_scan()
self.record_log("INFO", f"候选池刷新完成,当前候选数:{len(latest.get('opportunities', []))}")
except Exception as error:
with self.lock:
self.scan_runner["lastError"] = str(error)
self.record_log("ERROR", f"候选池刷新失败:{error}")
finally:
with self.lock:
self.scan_runner["running"] = False
self.scan_runner["lastFinishedAt"] = now_iso()
def _run_trade_job(self, mode: str, reason: str) -> None:
with self.lock:
runner = self.trade_runners[mode]
runner["running"] = True
runner["lastStartedAt"] = now_iso()
runner["lastFinishedAt"] = None
runner["lastError"] = None
runner["lastReason"] = reason
self.record_log("INFO", f"开始执行{mode.upper()}交易决策循环,触发原因:{reason}")
try:
with self.trade_lock:
run_trading_cycle_batch(reason=reason, modes=[mode])
summary = summarize_trading_state()
latest_decision = summary.get("latestLiveDecision" if mode == "live" else "latestPaperDecision") or {}
self.record_log(
"INFO",
f"{mode.upper()}交易决策循环完成,latestDecision={latest_decision.get('id', 'n/a')}",
)
except Exception as error:
with self.lock:
self.trade_runners[mode]["lastError"] = str(error)
self.record_log("ERROR", f"{mode.upper()}交易决策循环失败:{error}")
finally:
with self.lock:
self.trade_runners[mode]["running"] = False
self.trade_runners[mode]["lastFinishedAt"] = now_iso()
def start_scan(self, reason: str = "manual") -> bool:
with self.lock:
if self.scan_runner["running"]:
return False
thread = threading.Thread(target=self._run_scan_job, args=(reason,), daemon=True)
thread.start()
return True
def start_trade(self, mode: str, reason: str = "manual") -> bool:
mode = "live" if str(mode).strip().lower() == "live" else "paper"
with self.lock:
if self.trade_runners[mode]["running"]:
return False
thread = threading.Thread(target=self._run_trade_job, args=(mode, reason), daemon=True)
thread.start()
return True
@staticmethod
def _aligned_slot(reference_ts: float, interval_minutes: int, offset_minutes: int = 0) -> tuple[float, float]:
timezone_offset = 8 * 60 * 60
interval_seconds = max(5, interval_minutes) * 60
adjusted = reference_ts + timezone_offset + (offset_minutes * 60)
slot_start_local = int(adjusted // interval_seconds) * interval_seconds
start_ts = slot_start_local - timezone_offset - (offset_minutes * 60)
end_ts = start_ts + interval_seconds
return start_ts, end_ts
@staticmethod
def _parse_timestamp(value: str | None) -> float | None:
if not value:
return None
try:
return __import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except Exception:
return None
def _latest_scheduled_trade_ts(self, mode: str) -> float | None:
settings = read_trading_settings()
state = read_trading_state(settings)
for decision in reversed(state.get(mode, {}).get("decisions", [])):
if decision.get("runnerReason") != "scheduled":
continue
return self._parse_timestamp(decision.get("startedAt"))
return None
def next_trade_due_at(self, mode: str) -> str:
mode = "live" if str(mode).strip().lower() == "live" else "paper"
settings = read_trading_settings()
now_ts = time.time()
start_ts, end_ts = self._aligned_slot(now_ts, settings["decisionIntervalMinutes"])
latest_ts = self._latest_scheduled_trade_ts(mode)
if latest_ts is not None and start_ts <= latest_ts < end_ts:
due_ts = end_ts
elif now_ts < start_ts + SCHEDULE_TRIGGER_WINDOW_SECONDS:
due_ts = start_ts
else:
due_ts = end_ts
return __import__("datetime").datetime.utcfromtimestamp(due_ts).replace(microsecond=0).isoformat() + "Z"
def _maybe_start_scheduled_scan(self) -> None:
settings = read_dashboard_settings()
if not settings["marketAutoScanEnabled"]:
return
if self.scan_runner["running"]:
return
now_ts = time.time()
scan = read_latest_scan()
fetched_at = scan.get("fetchedAt")
fetched_ts = None
if fetched_at:
try:
fetched_ts = __import__("datetime").datetime.fromisoformat(fetched_at.replace("Z", "+00:00")).timestamp()
except Exception:
fetched_ts = None
slot_start, slot_end = self._aligned_slot(now_ts, settings["marketScanIntervalMinutes"], settings["marketScanOffsetMinute"])
if fetched_ts is not None and slot_start <= fetched_ts < slot_end:
return
if slot_start <= now_ts < slot_start + SCHEDULE_TRIGGER_WINDOW_SECONDS:
self.start_scan("scheduled")
def _maybe_start_scheduled_trade(self) -> None:
settings = read_trading_settings()
now_ts = time.time()
start_ts, end_ts = self._aligned_slot(now_ts, settings["decisionIntervalMinutes"])
if not (start_ts <= now_ts < start_ts + SCHEDULE_TRIGGER_WINDOW_SECONDS):
return
for mode in ("paper", "live"):
if not settings.get(f"{mode}Trading", {}).get("enabled"):
continue
if self.trade_runners[mode]["running"]:
continue
latest_ts = self._latest_scheduled_trade_ts(mode)
if latest_ts is not None and start_ts <= latest_ts < end_ts:
continue
self.start_trade(mode, "scheduled")
def start_scheduler(self) -> None:
if self._scheduler_started:
return
self._scheduler_started = True
def loop() -> None:
while True:
try:
self._maybe_start_scheduled_scan()
self._maybe_start_scheduled_trade()
except Exception as error:
self.record_log("ERROR", f"调度器异常:{error}")
time.sleep(10)
thread = threading.Thread(target=loop, daemon=True)
thread.start()
self.record_log("INFO", "自动调度器已启动。")
def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
length = int(handler.headers.get("content-length") or 0)
if length <= 0:
return {}
raw = handler.rfile.read(length).decode("utf-8")
if not raw.strip():
return {}
payload = json.loads(raw)
if not isinstance(payload, dict):
raise ValueError("JSON body must be an object.")
return payload
def _json_response(handler: BaseHTTPRequestHandler, payload: Any, status: int = 200) -> None:
data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Cache-Control", "no-store")
handler.send_header("Content-Length", str(len(data)))
handler.end_headers()
handler.wfile.write(data)
def _text_response(handler: BaseHTTPRequestHandler, payload: str, status: int = 200, content_type: str = "text/plain; charset=utf-8") -> None:
data = payload.encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", content_type)
handler.send_header("Cache-Control", "no-store")
handler.send_header("Content-Length", str(len(data)))
handler.end_headers()
handler.wfile.write(data)
def _static_content_type(path: Path) -> str:
if path.suffix == ".html":
return "text/html; charset=utf-8"
if path.suffix == ".js":
return "text/javascript; charset=utf-8"
if path.suffix == ".css":
return "text/css; charset=utf-8"
if path.suffix == ".json":
return "application/json; charset=utf-8"
return "application/octet-stream"
class TradingAgentHandler(BaseHTTPRequestHandler):
runtime: AppRuntime
def do_GET(self) -> None: # noqa: N802
self._handle("GET")
def do_POST(self) -> None: # noqa: N802
self._handle("POST")
def log_message(self, format: str, *args) -> None: # noqa: A003
return
def _handle(self, method: str) -> None:
parsed = urlparse(self.path)
try:
if method == "GET" and parsed.path == "/api/latest":
return _json_response(self, self.runtime.api_latest())
if method == "GET" and parsed.path == "/api/opportunities":
return _json_response(self, read_latest_scan())
if method == "GET" and parsed.path == "/api/logs":
return _json_response(self, self.runtime.api_logs())
if method == "GET" and parsed.path == "/api/settings":
return _json_response(self, read_dashboard_settings())
if method == "POST" and parsed.path == "/api/settings":
result = write_dashboard_settings(_read_json_body(self))
self.runtime.record_log(
"INFO",
f"Dashboard 设置已保存,pageAutoRefreshSeconds={result.get('pageAutoRefreshSeconds')}",
)
return _json_response(self, result)
if method == "GET" and parsed.path == "/api/trading/settings":
return _json_response(self, {**read_trading_settings(), "exchangeCatalog": read_live_exchange_catalog()})
if method == "POST" and parsed.path == "/api/trading/settings":
result = write_trading_settings(_read_json_body(self))
refresh_result = refresh_account_state_after_settings_save()
live_sync_warnings = refresh_result.get("liveSyncWarnings") if isinstance(refresh_result, dict) else []
self.runtime.record_log(
"INFO",
"运行设置已保存,"
f"decisionIntervalMinutes={result.get('decisionIntervalMinutes')},"
f"activeExchange={result.get('activeExchange', 'binance')},"
f"paper={result.get('paperTrading', {}).get('enabled', False)},"
f"live={result.get('liveTrading', {}).get('enabled', False)}",
)
if live_sync_warnings:
self.runtime.record_log("INFO", f"保存运行设置后已刷新实盘账户:{'; '.join(str(item) for item in live_sync_warnings[:2])}")
else:
self.runtime.record_log("INFO", "保存运行设置后已刷新账户状态。")
return _json_response(self, {**result, "exchangeCatalog": read_live_exchange_catalog()})
if method == "GET" and parsed.path == "/api/trading/provider":
return _json_response(self, read_llm_provider())
if method == "POST" and parsed.path == "/api/trading/provider":
result = write_llm_provider(_read_json_body(self))
self.runtime.record_log(
"INFO",
f"模型配置已保存,provider={result.get('preset', 'custom')},model={result.get('model', 'n/a')}",
)
return _json_response(self, result)
if method == "GET" and parsed.path == "/api/trading/universe":
universe = read_fixed_universe()
universe["rawSymbols"] = "\n".join(universe.get("symbols", []))
universe["candidateSourceCode"] = read_candidate_source_code()
return _json_response(self, universe)
if method == "POST" and parsed.path == "/api/trading/universe":
result = write_fixed_universe(_read_json_body(self))
result["rawSymbols"] = "\n".join(result.get("symbols", []))
result["candidateSourceCode"] = read_candidate_source_code()
self.runtime.record_log(
"INFO",
f"候选池配置已保存,symbols={len(result.get('symbols', []))},dynamic={result.get('dynamicSource', {}).get('enabled', False)}",
)
return _json_response(self, result)
if method == "POST" and parsed.path == "/api/trading/universe/test":
payload = _read_json_body(self)
universe = preview_fixed_universe(payload)
code_override = payload.get("candidateSourceCode") if "candidateSourceCode" in payload else None
result = test_candidate_source(universe=universe, code_override=code_override)
self.runtime.record_log(
"INFO",
f"候选池测试已完成,mode={result.get('mode')},symbols={result.get('count', 0)}",
)
return _json_response(self, result)
if method == "GET" and parsed.path == "/api/trading/prompt":
prompt = read_prompt_settings()
return _json_response(self, _prompt_form_payload(prompt))
if method == "GET" and parsed.path == "/api/trading/prompt-library":
library = read_prompt_library()
return _json_response(
self,
{
"updated": library.get("updated"),
"prompts": [_prompt_form_payload(item) for item in library.get("prompts", [])],
},
)
if method == "POST" and parsed.path == "/api/trading/prompt":
payload = _read_json_body(self)
payload["decision_logic"] = _prompt_logic_from_payload(payload)
result = write_prompt_settings(payload)
self.runtime.record_log("INFO", f"交易逻辑已保存,name={result.get('name', 'default_trading_logic')}")
return _json_response(self, _prompt_form_payload(result))
if method == "POST" and parsed.path == "/api/trading/prompt-library/save":
payload = _read_json_body(self)
payload["decision_logic"] = _prompt_logic_from_payload(payload)
result = save_prompt_preset(payload)
preset = result.get("preset") if isinstance(result.get("preset"), dict) else {}
self.runtime.record_log("INFO", f"Prompt 模板已保存,name={preset.get('name', 'untitled')},id={preset.get('id', 'n/a')}")
return _json_response(
self,
{
"preset": _prompt_form_payload(preset),
"prompts": [_prompt_form_payload(item) for item in result.get("prompts", [])],
},
)
if method == "POST" and parsed.path == "/api/trading/prompt-library/use":
payload = _read_json_body(self)
preset = read_prompt_preset(str(payload.get("id") or ""))
result = write_prompt_settings(
{
"name": preset.get("name"),
"presetId": preset.get("id"),
"klineFeeds": preset.get("klineFeeds"),
"decision_logic": preset.get("decision_logic"),
}
)
self.runtime.record_log("INFO", f"Prompt 模板已启用,name={preset.get('name', 'untitled')},id={preset.get('id', 'n/a')}")
return _json_response(self, _prompt_form_payload(result))
if method == "POST" and parsed.path == "/api/trading/prompt-library/rename":
payload = _read_json_body(self)
result = rename_prompt_preset(str(payload.get("id") or ""), str(payload.get("name") or ""))
preset = result.get("preset") if isinstance(result.get("preset"), dict) else {}
self.runtime.record_log("INFO", f"Prompt 模板已重命名,name={preset.get('name', 'untitled')},id={preset.get('id', 'n/a')}")
return _json_response(
self,
{
"preset": _prompt_form_payload(preset),
"prompts": [_prompt_form_payload(item) for item in result.get("prompts", [])],
},
)
if method == "POST" and parsed.path == "/api/trading/prompt-library/delete":
payload = _read_json_body(self)
result = delete_prompt_preset(str(payload.get("id") or ""))
self.runtime.record_log("WARN", f"Prompt 模板已删除,id={result.get('deletedId', 'n/a')}")
return _json_response(
self,
{
"deletedId": result.get("deletedId"),
"prompts": [_prompt_form_payload(item) for item in result.get("prompts", [])],
},
)
if method == "POST" and parsed.path == "/api/trading/prompt/test":
payload = _read_json_body(self)
prompt_override = None
if {"role", "corePrinciplesText", "entryPreferencesText", "positionManagementText"} & set(payload.keys()) or "rawJson" in payload:
prompt_override = {
"name": payload.get("name") or "default_trading_logic",
"klineFeeds": payload.get("klineFeeds"),
"decision_logic": _prompt_logic_from_payload(payload),
}
mode = "live" if str(payload.get("mode") or "paper").strip().lower() == "live" else "paper"
result = preview_trading_prompt_decision(mode_override=mode, prompt_override=prompt_override)
provider_info = result.get("provider") if isinstance(result.get("provider"), dict) else {}
if provider_info.get("autoConfiguredSaved"):
self.runtime.record_log(
"INFO",
f"模型网关已自动识别并保存,preset={provider_info.get('preset', 'n/a')},apiStyle={provider_info.get('resolvedApiStyle', 'n/a')}",
)
self.runtime.record_log("INFO", f"Prompt 测试已完成,mode={mode},candidates={result.get('candidateCount', 0)}")
return _json_response(self, result)
if method == "GET" and parsed.path == "/api/trading/live-config":
return _json_response(self, {**read_live_trading_config(), "exchangeCatalog": read_live_exchange_catalog()})
if method == "POST" and parsed.path == "/api/trading/live-config":
result = write_live_trading_config(_read_json_body(self))
self.runtime.record_log("INFO", f"实盘账号配置已保存,exchange={result.get('exchange', 'binance')}")
return _json_response(self, {**result, "exchangeCatalog": read_live_exchange_catalog()})
if method == "GET" and parsed.path == "/api/network":
return _json_response(self, read_network_settings())
if method == "GET" and parsed.path == "/api/network/ip":
return _json_response(self, _network_ip_payload())
if method == "POST" and parsed.path == "/api/network":
result = write_network_settings(_read_json_body(self))
self.runtime.record_log("INFO", f"代理配置已保存,enabled={result.get('proxyEnabled', False)}")
return _json_response(self, result)
if method == "GET" and parsed.path == "/api/trading/state":
payload = summarize_trading_state()
payload["paperRunner"] = self.runtime.trade_runners["paper"]
payload["liveRunner"] = self.runtime.trade_runners["live"]
payload["scanRunner"] = self.runtime.scan_runner
payload["paperNextDecisionDueAt"] = self.runtime.next_trade_due_at("paper")
payload["liveNextDecisionDueAt"] = self.runtime.next_trade_due_at("live")
return _json_response(self, payload)
if method == "POST" and parsed.path == "/api/trading/run":
payload = _read_json_body(self)
mode = "live" if str(payload.get("mode") or "paper").strip().lower() == "live" else "paper"
started = self.runtime.start_trade(mode, "manual")
if not started:
self.runtime.record_log("WARN", f"收到手动{mode.upper()}交易请求,但上一轮仍在执行。")
return _json_response(self, {"started": started, "mode": mode, "runner": self.runtime.trade_runners[mode], "nextDecisionDueAt": self.runtime.next_trade_due_at(mode)})
if method == "POST" and parsed.path == "/api/trading/reset":
payload = _read_json_body(self)
reset_mode = payload.get("mode") or "paper"
result = reset_trading_account(str(reset_mode))
target_label = "实盘" if str(reset_mode).strip().lower() == "live" else "模拟盘"
self.runtime.record_log("WARN", f"{target_label}账户已重置,mode={reset_mode}")
return _json_response(self, result)
if method == "POST" and parsed.path == "/api/trading/flatten":
payload = _read_json_body(self)
mode = "live" if str(payload.get("mode") or "paper").strip().lower() == "live" else "paper"
result = flatten_active_account("manual_flatten", mode_override=mode)
self.runtime.record_log("WARN", f"已对{mode.upper()}执行全部平仓。")
return _json_response(self, result)
if method == "POST" and parsed.path == "/api/scan/run":
started = self.runtime.start_scan("manual")
if not started:
self.runtime.record_log("WARN", "收到手动候选池刷新请求,但上一轮刷新仍在执行。")
return _json_response(self, {"started": started, "scanRunner": self.runtime.scan_runner})
if method not in {"GET", "HEAD"}:
return _text_response(self, "Method not allowed", status=405)
return self._serve_static(parsed.path)
except Exception as error:
self.runtime.record_log("ERROR", f"{method} {parsed.path} 失败:{error}")
return _json_response(self, {"error": str(error)}, status=500)
def _serve_static(self, request_path: str) -> None:
relative = "index.html" if request_path == "/" else request_path.lstrip("/")
file_path = (DASHBOARD_DIR / relative).resolve()
dashboard_root = DASHBOARD_DIR.resolve()
if dashboard_root not in file_path.parents and file_path != dashboard_root:
return _text_response(self, "Forbidden", status=403)
if not file_path.exists() or not file_path.is_file():
return _text_response(self, "Not found", status=404)
payload = file_path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", _static_content_type(file_path))
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _next_available_port(host: str, preferred_port: int, max_checks: int = 20) -> int:
for port in range(preferred_port, preferred_port + max_checks + 1):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if sock.connect_ex((host, port)) != 0:
return port
raise RuntimeError(f"Could not find a free port near {preferred_port}.")
def start_server(port_override: int | None = None) -> None:
settings = read_trading_settings()
host = settings["server"]["host"]
preferred_port = int(port_override if port_override is not None else settings["server"]["port"])
if preferred_port < 1024 or preferred_port > 65535:
raise ValueError("Port must be between 1024 and 65535.")
port = _next_available_port(host, preferred_port)
runtime = AppRuntime()
TradingAgentHandler.runtime = runtime
server = ThreadingHTTPServer((host, port), TradingAgentHandler)
runtime.start_scheduler()
runtime.record_log("INFO", f"Trading Agent dashboard running at http://{host}:{port}/trader.html")
server.serve_forever()
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
import math
import re
from hashlib import sha1
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from datetime import datetime
ROOT = Path(__file__).resolve().parent.parent
CONFIG_DIR = ROOT / "config"
DATA_DIR = ROOT / "data"
DASHBOARD_DIR = ROOT / "dashboard"
def now_iso() -> str:
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
def current_run_date(timezone: str = "Asia/Shanghai") -> str:
return datetime.now(ZoneInfo(timezone)).strftime("%Y-%m-%d")
def read_json(path: Path, default: Any = None) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def num(value: Any) -> float | None:
if value in (None, "", False):
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
if math.isnan(parsed) or math.isinf(parsed):
return None
return parsed
def clamp(value: Any, minimum: float, maximum: float) -> float:
parsed = num(value)
if parsed is None:
return minimum
return max(minimum, min(maximum, parsed))
def clean_bool(value: Any, fallback: bool = False) -> bool:
if isinstance(value, bool):
return value
return fallback
def one_line(value: Any, limit: int = 220) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip()
if len(text) <= limit:
return text
return text[: max(0, limit - 3)].rstrip() + "..."
def safe_last(items: list[Any] | None) -> Any:
if not items:
return None
return items[-1]
def sha1_hex(value: str) -> str:
return sha1(value.encode("utf-8")).hexdigest()
def parse_json_loose(raw_text: str) -> Any:
text = str(raw_text or "").strip()
if not text:
raise ValueError("empty JSON payload")
try:
return json.loads(text)
except json.JSONDecodeError:
pass
fence_match = re.search(r"```(?:json)?\s*(\{.*\}|\[.*\])\s*```", text, re.S)
if fence_match:
return json.loads(fence_match.group(1))
brace_match = re.search(r"(\{.*\}|\[.*\])", text, re.S)
if brace_match:
return json.loads(brace_match.group(1))
raise ValueError("could not find JSON object in response")
+14
View File
@@ -0,0 +1,14 @@
from pathlib import Path
import json
def load_candidate_symbols(context):
"""Return a list of futures symbols to scan."""
manual_symbols = context["manual_symbols"]
# Example: read a local JSON file and return payload["symbols"]
# payload_path = Path(context["project_root"]) / "data" / "my_symbols.json"
# payload = json.loads(payload_path.read_text(encoding="utf-8"))
# return payload.get("symbols", [])
return manual_symbols
+9
View File
@@ -0,0 +1,9 @@
{
"version": 1,
"updated": "2026-04-22",
"timezone": "Asia/Shanghai",
"pageAutoRefreshSeconds": 30,
"marketAutoScanEnabled": true,
"marketScanIntervalMinutes": 60,
"marketScanOffsetMinute": 7
}
+32
View File
@@ -0,0 +1,32 @@
{
"version": 1,
"updated": "2026-04-22",
"description": "Fixed futures symbol universe for the open-source trading-agent build. Edit this list to change what the agent reviews.",
"symbols": [
"BTCUSDT",
"ETHUSDT",
"SOLUSDT",
"SUIUSDT",
"DOGEUSDT",
"1000PEPEUSDT",
"ENAUSDT",
"WIFUSDT",
"ARKMUSDT",
"BLURUSDT",
"API3USDT",
"ZECUSDT",
"ONDOUSDT",
"ARBUSDT",
"LINKUSDT",
"AAVEUSDT",
"CRVUSDT",
"FETUSDT",
"PENDLEUSDT",
"TIAUSDT"
],
"dynamicSource": {
"enabled": false,
"functionName": "load_candidate_symbols",
"functionFile": "config/candidate_source.py"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"version": 1,
"updated": "2026-04-22",
"exchange": "binance",
"market": "usdm_futures",
"enabled": false,
"baseUrl": "https://fapi.binance.com",
"apiKey": "",
"apiSecret": "",
"apiPassphrase": "",
"recvWindow": 5000,
"dryRun": true,
"positionMode": "oneway",
"marginType": "cross",
"defaultLeverage": 3,
"note": "Fill the API credentials, keep dryRun on first, and only disable dryRun after you have verified the full flow."
}
+14
View File
@@ -0,0 +1,14 @@
{
"version": 1,
"updated": "2026-04-22",
"preset": "gpt",
"apiStyle": "openai",
"model": "gpt-5.4-mini",
"baseUrl": "https://api.openai.com/v1",
"apiKey": "",
"timeoutSeconds": 45,
"temperature": 0.2,
"maxOutputTokens": 1200,
"anthropicVersion": "2023-06-01",
"customHeaders": {}
}
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"updated": "2026-04-22",
"proxyEnabled": false,
"proxyUrl": "",
"noProxy": [
"127.0.0.1",
"localhost"
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"version": 2,
"updated": "2026-04-22",
"description": "Python-only local trading agent settings.",
"mode": "paper",
"activeExchange": "binance",
"server": {
"host": "127.0.0.1",
"port": 8788
},
"decisionIntervalMinutes": 15,
"initialCapitalUsd": 1000,
"maxNewPositionsPerCycle": 1,
"maxOpenPositions": 4,
"maxPositionNotionalUsd": 150,
"maxGrossExposurePct": 100,
"maxAccountDrawdownPct": 20,
"riskPerTradePct": 2.5,
"minConfidence": 60,
"paperFeesBps": 4,
"allowShorts": true,
"paperTrading": {
"enabled": false
},
"liveTrading": {
"enabled": false
},
"liveExecution": {
"configPath": "config/live_trading.json",
"useExchangeProtectionOrders": true,
"note": "Only enable live routing after paper verification, exchange API setup, and tiny-size dry-run validation."
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"version": 1,
"updated": "2026-04-22",
"name": "default_trading_logic",
"presetId": null,
"klineFeeds": {
"1m": {
"enabled": false,
"limit": 120
},
"5m": {
"enabled": false,
"limit": 96
},
"15m": {
"enabled": true,
"limit": 64
}
},
"decision_logic": {
"role": "You are a careful crypto futures trader.",
"core_principles": [
"Protect capital first."
],
"entry_preferences": [
"Only trade clear setups."
],
"position_management": [
"Cut losers quickly and take profit simply."
],
"response_style": [
"Return strict JSON only.",
"Every action must include a short reason tied to current market structure."
]
},
"role": "You are a careful crypto futures trader.",
"corePrinciplesText": "Protect capital first.",
"entryPreferencesText": "Only trade clear setups.",
"positionManagementText": "Cut losers quickly and take profit simply."
}
+5
View File
@@ -0,0 +1,5 @@
{
"version": 1,
"updated": "2026-04-22",
"prompts": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=/trader.html">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trading Agent</title>
</head>
<body>
<p>Redirecting to <a href="/trader.html">/trader.html</a>...</p>
</body>
</html>
+3088
View File
File diff suppressed because it is too large. Load diff
+516
View File
@@ -0,0 +1,516 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AITD</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="topbar">
<div class="title-block">
<img class="brand-logo" src="/assets/aitd-readme-header.png" alt="AITD - AI Trading Agent for Everyone">
<div class="title-meta-row">
<p id="tradeRunMeta" class="meta">读取运行状态中…</p>
<button id="themeToggleBtn" type="button" class="secondary-button">切到浅色</button>
</div>
</div>
<div class="header-actions">
<span id="tradeRefreshHint" class="refresh-hint">自动刷新中</span>
<div class="top-actions-row">
<button id="toggleModeBtn" type="button" class="secondary-button">查看实盘</button>
<button id="flattenBtn" type="button" class="secondary-button">全部平仓</button>
<button id="resetBtn" type="button" class="secondary-button danger-outline" hidden>重置模拟盘</button>
</div>
<div class="top-actions-row top-actions-row-secondary">
<button id="refreshBtn" type="button" class="secondary-button">刷新</button>
</div>
</div>
</header>
<section class="summary-grid" aria-label="Trader summary">
<div class="metric">
<span class="metric-label">Mode</span>
<strong id="modeText">读取中</strong>
</div>
<div class="metric">
<span class="metric-label">Equity</span>
<strong id="equityText">$0</strong>
</div>
<div class="metric">
<span class="metric-label">Open</span>
<strong id="openCountText">0</strong>
</div>
<div class="metric">
<span class="metric-label">Drawdown</span>
<strong id="drawdownText">0%</strong>
</div>
<div class="metric metric-action">
<span class="metric-label">Runner</span>
<button id="modeRunnerBtn" type="button">启动交易</button>
<p id="modeRunnerMeta" class="meta metric-meta">默认暂停,到了下一次调度时间才会触发。</p>
</div>
</section>
<section class="status-strip-wrap" aria-label="Trader status">
<div id="statusBanner" class="status-banner"></div>
</section>
<main class="trader-layout">
<aside class="trader-side">
<section class="trader-section">
<div class="section-heading">
<h2>运行设置</h2>
</div>
<form id="tradingSettingsForm" class="settings-form compact-settings-form">
<label>
活动交易所
<select id="activeExchangeInput"></select>
</label>
<label>
决策间隔(分钟)
<input id="decisionIntervalInput" type="number" min="5" max="1440" step="5">
</label>
<label>
初始本金(USD)
<input id="initialCapitalInput" type="number" min="1" step="1">
</label>
<label>
每轮最多新开仓
<input id="maxNewPositionsInput" type="number" min="1" max="100">
</label>
<label>
最多持仓品种数
<input id="maxOpenPositionsInput" type="number" min="1" max="1000">
</label>
<label>
单笔最大市值(USD)
<input id="maxPositionNotionalInput" type="number" min="20" max="10000000000" step="10">
</label>
<label>
总敞口上限(%)
<input id="maxGrossExposureInput" type="number" min="10" max="10000" step="5">
</label>
<label>
账户最大回撤(%)
<input id="maxDrawdownInput" type="number" min="5" max="90" step="1">
</label>
<label>
单笔风险(%)
<input id="riskPerTradeInput" type="number" min="0.25" max="100" step="0.25">
</label>
<label>
最低置信度
<input id="minConfidenceInput" type="number" min="1" max="100" step="1">
</label>
<label id="paperFeesRow">
模拟盘手续费(bps)
<input id="paperFeesInput" type="number" min="0" max="50" step="1">
</label>
<label class="check-row">
<input id="allowShortsInput" type="checkbox" checked>
<span>允许做空</span>
</label>
<label>
页面自动刷新(秒)
<input id="pageAutoRefreshInput" type="number" min="10" max="600" step="5">
</label>
<div class="settings-actions">
<button id="saveTradingSettingsBtn" type="button">保存运行设置</button>
</div>
<p id="settingsFeedback" class="status-line">这些参数会影响下一轮调度与实际风险控制。</p>
</form>
</section>
<section id="liveConfigSection" class="trader-section" hidden>
<div class="section-heading">
<h2>实盘账号配置</h2>
</div>
<form id="liveConfigForm" class="stack-form">
<div class="info-row">
<span>本机 IP 地址</span>
<strong id="liveIpText">读取中…</strong>
</div>
<label>
交易所
<select id="liveExchangeInput"></select>
</label>
<p id="liveExchangeMeta" class="status-line compact-note">按交易所分别显示当前支持情况。</p>
<label class="check-row">
<input id="liveConfigEnabledInput" type="checkbox">
<span>启用实盘</span>
</label>
<label class="check-row">
<input id="liveDryRunInput" type="checkbox" checked>
<span>模拟下单</span>
</label>
<label>
API Key
<input id="liveApiKeyInput" type="password" placeholder="Binance API key">
</label>
<label>
API Secret
<input id="liveApiSecretInput" type="password" placeholder="Binance API secret">
</label>
<label id="liveApiPassphraseWrap" hidden>
API Passphrase
<input id="liveApiPassphraseInput" type="password" placeholder="OKX API passphrase">
</label>
<label>
Base URL
<input id="liveBaseUrlInput" type="text" placeholder="https://fapi.binance.com">
</label>
<label>
Default leverage
<input id="liveDefaultLeverageInput" type="number" min="1" max="125" step="1">
</label>
<label>
Margin type
<select id="liveMarginTypeInput">
<option value="cross">Cross</option>
<option value="isolated">Isolated</option>
</select>
</label>
<div class="settings-actions">
<button id="saveLiveConfigBtn" type="button">保存实盘配置</button>
</div>
<p id="liveConfigFeedback" class="status-line">“启用实盘”和“模拟下单”是二选一。出入金会由交易所账户流水自动同步。</p>
</form>
</section>
<section class="trader-section">
<div class="section-heading">
<h2>AI模型配置</h2>
</div>
<form id="providerForm" class="stack-form">
<label>
Provider 预设
<select id="providerPresetInput">
<option value="gpt">GPT / OpenAI</option>
<option value="claude">Claude / Anthropic</option>
<option value="deepseek">DeepSeek</option>
<option value="qwen">Qwen</option>
<option value="custom">Custom</option>
</select>
</label>
<label>
API 风格
<select id="providerApiStyleInput">
<option value="openai">OpenAI compatible</option>
<option value="anthropic">Anthropic</option>
</select>
</label>
<label>
Model
<select id="providerModelInput"></select>
</label>
<label id="providerCustomModelWrap" hidden>
自定义模型名
<input id="providerCustomModelInput" type="text" placeholder="your-model-name">
</label>
<label>
Base URL
<input id="providerBaseUrlInput" type="text" placeholder="https://api.openai.com/v1">
</label>
<label>
API Key
<input id="providerApiKeyInput" type="password" placeholder="sk-...">
</label>
<div class="settings-actions">
<button id="providerAdvancedToggleBtn" type="button" class="secondary-button">展开高级参数</button>
</div>
<div id="providerAdvancedFields" class="advanced-fields" hidden>
<label>
Timeout(秒)
<input id="providerTimeoutInput" type="number" min="10" max="180" step="1">
</label>
<label>
Temperature
<input id="providerTemperatureInput" type="number" min="0" max="1.5" step="0.1">
</label>
<label>
Max Output Tokens
<input id="providerMaxTokensInput" type="number" min="256" max="4096" step="64">
</label>
</div>
<div class="settings-actions">
<button id="saveProviderBtn" type="button">保存AI模型配置</button>
</div>
<p id="providerFeedback" class="status-line"></p>
</form>
</section>
<section class="trader-section">
<div class="section-heading">
<h2>代理配置</h2>
</div>
<form id="networkForm" class="stack-form">
<label class="check-row inline-check-row">
<input id="proxyEnabledInput" type="checkbox">
<span>启用代理</span>
</label>
<label>
代理地址
<input id="proxyUrlInput" type="text" placeholder="http://127.0.0.1:7890">
</label>
<label>
不走代理的地址
<input id="noProxyInput" type="text" placeholder="127.0.0.1,localhost">
</label>
<div class="settings-actions">
<button id="saveNetworkBtn" type="button">保存代理配置</button>
</div>
<p id="networkFeedback" class="status-line">关闭后会完全直连。</p>
</form>
</section>
</aside>
<section class="trader-main">
<div class="trader-tabs" role="tablist" aria-label="Trader main tabs">
<button type="button" class="tab-button active" data-tab-button="trade" role="tab" aria-selected="true">交易</button>
<button type="button" class="tab-button" data-tab-button="prompt" role="tab" aria-selected="false">Prompt</button>
<button type="button" class="tab-button" data-tab-button="universe" role="tab" aria-selected="false">候选池</button>
<button type="button" class="tab-button" data-tab-button="log" role="tab" aria-selected="false">Log</button>
</div>
<div class="trader-tab-panel is-active" data-tab-panel="trade">
<section class="trader-section">
<div class="section-heading">
<h2>当前账户</h2>
<p id="accountMeta" class="meta">等待账户状态</p>
</div>
<div id="accountGrid"></div>
</section>
<section class="trader-section">
<div class="section-heading">
<h2>当前持仓</h2>
<p id="positionMeta" class="meta">0 个持仓</p>
</div>
<div id="positionCards" class="trader-position-grid"></div>
</section>
<section class="trader-section">
<div class="section-heading">
<h2>已平仓位</h2>
<p id="closedPositionMeta" class="meta">读取中</p>
</div>
<div id="closedPositionList" class="trader-position-grid"></div>
</section>
<section class="trader-section">
<div class="section-heading">
<h2>候选池</h2>
<p id="candidateMeta" class="meta">读取中</p>
</div>
<div id="candidateList" class="candidate-list"></div>
</section>
<section class="trader-section">
<div class="section-heading">
<h2>最近决策</h2>
<p id="decisionMeta" class="meta">最近几轮 prompt、模型输出与动作</p>
</div>
<div id="decisionLog" class="decision-log"></div>
</section>
</div>
<div class="trader-tab-panel" data-tab-panel="prompt" hidden>
<section class="trader-section">
<div class="section-heading">
<h2>交易逻辑</h2>
</div>
<form id="promptForm" class="stack-form">
<label>
Prompt 名称
<input id="promptNameInput" type="text" placeholder="例如:trend_following_15m">
</label>
<label class="prompt-input-group">
<span class="prompt-input-head">
<span><code>role</code></span>
<button type="button" class="secondary-button prompt-expand-btn" data-prompt-expand="role">展开</button>
</span>
<textarea id="promptRoleInput" class="code-textarea code-textarea-short prompt-textarea-short" spellcheck="false" rows="3"></textarea>
</label>
<label class="prompt-input-group">
<span class="prompt-input-head">
<span><code>core_principles</code></span>
<button type="button" class="secondary-button prompt-expand-btn" data-prompt-expand="corePrinciples">展开</button>
</span>
<textarea id="promptCorePrinciplesInput" class="code-textarea code-textarea-short prompt-textarea-short" spellcheck="false" rows="3"></textarea>
</label>
<label class="prompt-input-group">
<span class="prompt-input-head">
<span><code>entry_preferences</code></span>
<button type="button" class="secondary-button prompt-expand-btn" data-prompt-expand="entryPreferences">展开</button>
</span>
<textarea id="promptEntryPreferencesInput" class="code-textarea code-textarea-short prompt-textarea-short" spellcheck="false" rows="3"></textarea>
</label>
<label class="prompt-input-group">
<span class="prompt-input-head">
<span><code>position_management</code></span>
<button type="button" class="secondary-button prompt-expand-btn" data-prompt-expand="positionManagement">展开</button>
</span>
<textarea id="promptPositionManagementInput" class="code-textarea code-textarea-short prompt-textarea-short" spellcheck="false" rows="3"></textarea>
</label>
<div class="prompt-kline-block">
<div class="prompt-kline-head">
<strong>加入 Prompt 的原始 K 线</strong>
<span class="meta">合理安排输入K线数量, 过多K线会增加模型思考时间,增加token消耗</span>
</div>
<div class="prompt-kline-table">
<label class="prompt-kline-row">
<span class="check-row prompt-kline-toggle">
<input type="checkbox" data-prompt-kline-enabled="1m">
<span>1m</span>
</span>
<span class="prompt-kline-count">
<span>根数</span>
<input type="number" min="1" max="300" step="1" data-prompt-kline-limit="1m">
</span>
</label>
<label class="prompt-kline-row">
<span class="check-row prompt-kline-toggle">
<input type="checkbox" data-prompt-kline-enabled="5m">
<span>5m</span>
</span>
<span class="prompt-kline-count">
<span>根数</span>
<input type="number" min="1" max="300" step="1" data-prompt-kline-limit="5m">
</span>
</label>
<label class="prompt-kline-row">
<span class="check-row prompt-kline-toggle">
<input type="checkbox" data-prompt-kline-enabled="15m">
<span>15m</span>
</span>
<span class="prompt-kline-count">
<span>根数</span>
<input type="number" min="1" max="300" step="1" data-prompt-kline-limit="15m">
</span>
</label>
</div>
</div>
<div class="settings-actions">
<button id="savePromptBtn" type="submit">保存当前使用</button>
<button id="savePromptPresetBtn" type="button" class="secondary-button">保存到已保存 Prompt</button>
<button id="testPromptBtn" type="button" class="secondary-button">测试交易逻辑</button>
</div>
<p id="promptPresetMeta" class="meta">当前编辑内容还没有关联到已保存 Prompt。</p>
<p id="promptFeedback" class="status-line"></p>
</form>
<div class="prompt-test-block">
<div class="section-heading">
<h2>测试输出</h2>
<p id="promptTestMeta" class="meta">点击“测试交易逻辑”后显示结果。</p>
</div>
<pre id="promptTestOutput" class="pre-block">尚未执行测试。</pre>
</div>
<div class="prompt-test-block">
<div class="section-heading">
<h2>已保存 Prompt</h2>
<p id="savedPromptMeta" class="meta">还没有已保存 Prompt。</p>
</div>
<div id="savedPromptList" class="saved-prompt-list"></div>
</div>
</section>
</div>
<div class="trader-tab-panel" data-tab-panel="universe" hidden>
<section class="trader-section">
<div class="section-heading">
<h2>候选池</h2>
<p id="universeMeta" class="meta">选择静态或动态其中一种候选池模式。</p>
</div>
<div class="universe-help-block">
<p>候选池是交易标的范围,每轮模型决策会从这个列表去选择是否交易。</p>
<p>静态候选池为手动输入,保存后生效。</p>
<p>动态候选池允许用户写 python function 读取文件或者读取外部 API,每轮决策前都会自动调用。</p>
<p><code>load_candidate_symbols</code> 函数输出要求为 <code>list</code>。</p>
<p>静态候选池和动态候选池二选一。</p>
</div>
<div class="universe-mode-row">
<label class="check-row">
<input id="staticUniverseEnabledInput" type="checkbox">
<span>静态候选池</span>
</label>
<label class="check-row">
<input id="dynamicUniverseEnabledInput" type="checkbox">
<span>动态候选池</span>
</label>
</div>
</section>
<section id="staticUniverseSection" class="trader-section">
<div class="section-heading">
<h2>静态候选池</h2>
<p class="meta">手动输入交易标的。</p>
</div>
<form id="universeForm" class="stack-form">
<label>
交易池 Symbols(每行一个)
<textarea id="universeSymbolsInput" class="code-textarea code-textarea-short" spellcheck="false"></textarea>
</label>
<div class="settings-actions">
<button id="saveUniverseBtn" type="button">保存候选池</button>
</div>
<p id="universeFeedback" class="status-line">保存后会自动刷新候选池,让交易页尽快显示新结果。</p>
</form>
</section>
<section id="dynamicUniverseSection" class="trader-section" hidden>
<div class="section-heading">
<h2>动态候选池</h2>
<p id="universePreviewMeta" class="meta">每轮决策前都会调用这个函数获取 symbols。</p>
</div>
<div class="stack-form">
<label>
Python 函数
<textarea id="candidateSourceCodeInput" class="code-textarea" spellcheck="false"></textarea>
</label>
<div class="settings-actions">
<button id="saveDynamicUniverseBtn" type="button">保存动态候选池</button>
<button id="testUniverseBtn" type="button" class="secondary-button">测试获取</button>
</div>
<p class="status-line">函数需要定义 <code>load_candidate_symbols(context)</code>,并返回 <code>list[str]</code>。</p>
</div>
<div class="prompt-test-block">
<div class="section-heading">
<h2>测试输出</h2>
<p id="universeTestMeta" class="meta">点击“测试获取”后显示结果。</p>
</div>
<pre id="universePreview" class="pre-block">尚未执行测试。</pre>
</div>
</section>
</div>
<div class="trader-tab-panel" data-tab-panel="log" hidden>
<section class="trader-section">
<div class="section-heading">
<h2>系统日志</h2>
<p id="logMeta" class="meta">读取运行状态中…</p>
</div>
<div id="logSummaryGrid" class="log-summary-grid"></div>
<pre id="logOutput" class="pre-block log-output">正在读取日志…</pre>
</section>
</div>
</section>
</main>
<div id="promptModal" class="modal-overlay" hidden>
<div class="modal-card">
<div class="section-heading">
<h2 id="promptModalTitle">编辑交易逻辑</h2>
<p id="promptModalMeta" class="meta">放大编辑后,点击“应用修改”回填到当前输入框。</p>
</div>
<textarea id="promptModalInput" class="code-textarea prompt-modal-textarea" spellcheck="false"></textarea>
<div class="settings-actions">
<button id="promptModalApplyBtn" type="button">应用修改</button>
<button id="promptModalCloseBtn" type="button" class="secondary-button">关闭</button>
</div>
</div>
</div>
<script src="/trader.js"></script>
</body>
</html>
+2714
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+4
View File
@@ -0,0 +1,4 @@
# Python 3.11+ required.
# This project currently uses only the Python standard library.
# Keeping this file allows users to run:
# pip install -r requirements.txt
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import argparse
from backend.server import start_server
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the AITD local dashboard server.")
parser.add_argument(
"--port",
type=int,
default=8788,
help="Port for the local dashboard server. Default: 8788",
)
return parser.parse_args()
if __name__ == "__main__":
args = _parse_args()
start_server(port_override=args.port)