Initial commit
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
本仓库当前仅用于评估、学习和个人参考。
|
||||
任何商业使用、重新分发、托管部署或付费服务集成,均需事先获得书面许可。
|
||||
|
||||
如需商用授权,请通过上方联系方式联系。
|
||||
@@ -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.
|
||||
@@ -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 后端强制控制。
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Python runtime for the local trading agent."""
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 "",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated": "2026-04-22",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"pageAutoRefreshSeconds": 30,
|
||||
"marketAutoScanEnabled": true,
|
||||
"marketScanIntervalMinutes": 60,
|
||||
"marketScanOffsetMinute": 7
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated": "2026-04-22",
|
||||
"proxyEnabled": false,
|
||||
"proxyUrl": "",
|
||||
"noProxy": [
|
||||
"127.0.0.1",
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated": "2026-04-22",
|
||||
"prompts": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 251 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 871 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 301 KiB |
@@ -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
|
||||
@@ -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)
|
||||