AI 自进化LOOP ENGINEER交易系统
单品种 AI 自进化交易系统
一、策略简介
本策略是一套运行在发明者量化平台上的单品种 AI 交易框架,适用于比特币、黄金、原油等加密货币永续合约或 TradFi 差价合约。
策略并不是简单地让大模型根据行情“猜涨跌”,而是建立了一套完整的:
行情分析 → AI 决策 → 风险执行 → 交易复盘 → 经验更新
闭环。
系统首先计算趋势、动量、波动率和量能等多维技术指标,同时获取与当前交易品种相关的新闻信息。随后,大语言模型结合技术指标、市场消息、当前持仓状态以及历史交易经验,输出结构化交易决策。
每笔交易结束后,系统会自动复盘交易结果,分析入场、持仓和离场过程中存在的问题,并将有效经验写入 Playbook 经验库。随着交易样本逐渐积累,系统可以在人工设定的安全边界内,对置信度阈值、基础仓位、止损和移动止盈参数进行小幅调整。
因此,这套策略的核心并不是某一个固定指标,而是一套可以持续记录、复盘和迭代的 AI 交易实验框架。
二、策略运行流程
策略按照以下五个阶段循环运行。
1. 市场感知
策略定时获取指定品种的 K 线数据,并使用纯 Python 计算多组技术指标,包括:
- EMA 快慢均线
- MACD
- RSI
- KDJ
- 布林带
- ATR
- 成交量相对均值
- 最近不同周期的价格变化率
这些指标分别从趋势、动量、波动率、量能和价格行为等维度描述当前市场状态。
策略没有直接将某一个指标作为固定买卖条件,而是将指标整理为结构化快照,交给 AI 进行综合判断。
2. 新闻信息补充
当配置 Brave Search API 后,策略会定时检索与交易品种相关的最新新闻。
例如交易黄金时,可以关注:
- 美联储政策
- 通货膨胀
- 美元指数
- 地缘冲突
- 非农就业数据
交易比特币时,可以关注:
- ETF 资金流
- 美联储利率
- 监管政策
- 链上事件
- 交易平台风险
新闻会经过链接去重,并保存到本地数据库中。
如果没有配置新闻接口,策略仍然可以仅使用技术指标运行。此时 AI 会将消息面状态标记为无数据,而不是虚构新闻结论。
3. AI 结构化决策
每次触发决策时,系统会向大语言模型提供以下信息:
- 当前技术指标快照
- 最新新闻摘要
- 当前持仓状态
- 当前 Playbook 经验库
- 当前策略参数
- 历史总结出的应做规则和应避免规则
AI 必须输出固定 JSON 格式,主要包括:
- 开多
- 开空
- 加多
- 加空
- 减仓
- 平仓
- 观望
- 决策置信度
- 技术面判断
- 消息面判断
- 决策理由
- 风险说明
策略要求 AI 综合多个指标维度,不能仅依赖单一指标下结论。
为了避免技术面和消息面相互冲突,策略设置了方向一致性约束:
当技术面偏多但消息面明显偏空时,AI 不允许直接开多;当技术面偏空但消息面明显偏多时,也不允许直接开空。
这类情况下,策略通常选择观望或平仓。
三、Playbook 经验库
Playbook 是本策略中的长期经验库。
它主要保存三类信息:
应做规则
例如:
- 趋势向上、MACD 柱持续增强且成交量同步放大时,可以提高多头信号权重。
- RSI 从中性区域向上突破时,比高位追涨更有参考价值。
- 新闻方向与技术趋势一致时,可以正常执行信号。
应避免规则
例如:
- RSI 已处于高位且价格接近布林带上轨时,避免追多。
- 技术面看多但新闻出现明确利空时,不开新仓。
- 连续亏损阶段降低仓位,避免频繁反手。
- 波动率突然放大时,避免使用过紧止损。
可调整参数
Playbook 可以保存并更新以下参数:
- 最低决策置信度
- 基础仓位比例
- 硬止损比例
- 移动止盈激活比例
- 移动止盈回撤比例
这些参数不是由 AI 任意修改,而是只能在人工预先设置的上下限范围内调整。
四、自动复盘机制
每笔交易平仓后,策略会自动生成一条完整交易记录,包括:
- 开仓时间
- 平仓时间
- 交易方向
- 入场价格
- 出场价格
- 持仓数量
- 盈亏比例
- 平仓原因
- 对应的入场决策
随后,AI 会对该笔交易进行复盘。
复盘内容包括:
- 交易结果属于盈利、亏损还是接近盈亏平衡
- 是否存在追涨杀跌
- 是否忽视消息面冲突
- 止损是否过紧或过宽
- 仓位是否过大
- 是否持仓时间过长
- 是否过早离场
- 本次交易可以总结出什么具体经验
复盘结果会保存到数据库中的 reflections 表中,供后续策略进化使用。
五、自进化机制
当积累一定数量的已复盘交易,或者达到设定的时间间隔后,策略会触发一次 Playbook 更新。
系统会将以下信息交给 AI:
- 当前 Playbook
- 最近若干笔交易复盘
- 总交易数量
- 胜率
- 平均收益率
- 平均盈利
- 平均亏损
- 常见错误类型
AI 根据这些信息生成新版本 Playbook,包括:
- 当前策略状态总结
- 新的应做规则
- 新的应避免规则
- 参数调整建议
参数建议还会经过代码层裁剪。
例如,人工规定基础仓位只能在 2% 至 10% 之间变化,那么即使 AI 建议将仓位提高到 30%,最终也只会被限制在允许范围内。
每次更新都会生成新的 Playbook 版本,旧版本和历史复盘记录仍然保存在数据库中。
需要注意的是,这里的“自进化”并不是 AI 自动修改策略代码,也不是自动发明新的交易算法。
它表示系统可以根据真实交易结果,持续更新经验规则,并在安全范围内调整部分执行参数。
六、仓位管理
策略根据账户权益、基础仓位比例、AI 置信度和杠杆计算下单数量。
大致逻辑为:
下单价值 = 账户权益 × 基础仓位比例 × 置信度系数 × 杠杆
其中置信度系数会受到限制,避免低置信度信号仍然使用完整仓位。
策略还设置了以下限制:
- 单品种最大仓位比例
- 最大加仓次数
- 最小下单数量
- 合约乘数换算
- 数量精度处理
- 杠杆限制
当当前持仓已经达到最大仓位时,新的加仓信号会被拒绝。
七、加仓与反手逻辑
当已有同方向持仓时,如果 AI 输出同方向加仓信号,并且置信度达到要求,策略可以继续加仓。
加仓后会重新计算平均持仓成本,并重新计算硬止损价格。
当已有持仓,而 AI 输出相反方向的开仓信号时,策略会先平掉原有仓位,再根据新信号置信度决定是否反手。
为了防止频繁加仓,策略通过最大加仓次数进行硬性限制。
八、止损与移动止盈
硬止损
开仓后,策略会根据当前 Playbook 中的硬止损比例计算止损价格。
多头止损价格:
入场价 ×(1 - 止损比例)
空头止损价格:
入场价 ×(1 + 止损比例)
硬止损属于代码层风险控制,不依赖 AI 临时判断。
移动止盈
策略持续记录持仓期间出现的最大浮盈。
当最大浮盈达到移动止盈激活条件后,策略进入移动止盈状态。
如果当前盈利相比历史最大盈利出现较大回撤,则触发平仓。
移动止盈回撤距离会同时参考:
- 最低绝对回撤幅度
- 最大浮盈乘以回撤比例
这样可以避免浮盈较小时止盈过度敏感,也能在盈利明显扩大后逐步保护利润。
九、安全设计
为了降低 AI 直接参与交易执行带来的风险,策略设置了多层安全限制。
AI 不能修改代码
AI 只能输出交易决策、经验规则和参数建议,不能修改策略源代码。
参数调整有上下限
所有参与进化的参数都设置了人工上下限,AI 无法突破。
仓位上限属于硬约束
单品种最大仓位、最大加仓次数和杠杆由代码控制,不参与 AI 进化。
技术面和消息面冲突时禁止开仓
当两者方向明显冲突时,策略只能观望或平仓。
低置信度信号不执行
即使 AI 输出开仓信号,只要置信度低于当前 Playbook 阈值,策略也不会下单。
默认通知模式
策略默认使用 notify 模式。
在该模式下,系统会正常完成行情分析、新闻分析、AI 决策、日志记录和信号展示,但不会真实下单。
只有将 TRADE_MODE 设置为 trade 后,策略才会进入实盘执行模式。
十、数据库记录
策略使用发明者量化平台提供的 SQLite 数据库保存长期信息,主要包括:
- 技术指标快照
- AI 决策记录
- 已完成交易
- 交易复盘
- Playbook 历史版本
- 新闻缓存
与普通只依靠运行内存的策略不同,即使策略停止后重新启动,历史交易、复盘记录和 Playbook 仍然可以继续使用。
轻量运行状态则使用 _G() 保存,例如:
- 当前持仓状态
- 最近决策
- 当前指标快照
- 最近新闻缓存
- 当前 Playbook
- 距离下一次进化的交易数量
十一、状态栏展示
策略在发明者量化状态栏中展示以下内容:
- 当前匹配合约
- 交易总数
- 胜率
- 平均盈亏
- 当前持仓
- 当前加仓次数
- 距离下一次进化的交易数量
- 当前进化状态
- 多维技术指标快照
- 最近 AI 决策
- 最近交易记录
- 最近复盘结果
- 当前 Playbook
- 最近系统运行步骤
通过状态栏可以观察策略从分析、决策、执行到复盘和进化的完整过程。
十二、支持的交互命令
策略支持通过发明者量化交互按钮执行以下操作:
- 强制触发一次 AI 决策
- 手动触发一次 Playbook 更新
- 暂停自动进化
- 恢复自动进化
- 清空当前运行状态
- 清空数据库和全部历史记录
其中清空数据库属于不可恢复操作,使用前应确认历史记录已经备份。
十三、主要配置参数
AI 接口
- LLM_API_KEY
- LLM_BASE_URL
- LLM_MODEL
接口需兼容常见的 Chat Completions 请求格式,并能够返回 JSON 文本。
新闻接口
- BRAVE_KEY
留空时跳过新闻检索。
交易品种
- INSTRUMENT_KEYWORD
- INSTRUMENT_DISPLAY_NAME
- NEWS_QUERY_TERMS
例如:
- BTC_USDT
- 比特币
- 美联储,ETF,监管,减半
K 线周期
支持:
- M15
- M30
- H1
- H4
- D1
决策频率
- 主循环间隔
- 新闻刷新间隔
- AI 决策冷却时间
- K 线获取数量
风险参数
- 最低置信度及其进化范围
- 基础仓位及其进化范围
- 单品种最大仓位
- 最大加仓次数
- 杠杆
- 硬止损及其进化范围
- 移动止盈激活比例及其进化范围
- 移动止盈回撤比例及其进化范围
进化参数
- 每隔多少笔已复盘交易触发进化
- 定时进化检查间隔
- 第一次进化所需的最小交易数量
- 每次进化回顾的复盘数量
十四、适用场景
这套策略更适合以下用途:
- 研究 AI 与传统技术指标结合的交易框架
- 验证新闻信息是否能改善技术信号
- 研究结构化 AI 决策
- 建立自动化交易复盘系统
- 测试经验库对后续交易行为的影响
- 对比不同模型在同一品种上的决策差异
- 观察风险参数在历史交易反馈下的变化
策略可以应用于趋势较明显、新闻驱动较强、流动性较好的单一品种。
十五、使用建议
初次运行时,建议使用 notify 模式,至少观察以下内容:
- AI 是否稳定输出合法 JSON
- 技术面和消息面判断是否合理
- 新闻检索结果是否与品种相关
- AI 决策频率是否过高
- 置信度分布是否正常
- 复盘结果是否具体
- Playbook 是否出现频繁大幅变化
- 合约数量和价格精度是否正确
- 开平仓方向是否符合所接交易所接口
在转为实盘前,建议先进行模拟盘或小资金测试,并适当降低:
- 基础仓位比例
- 最大单品种仓位
- 杠杆
- 最大加仓次数
同时提高:
- 最低决策置信度
- 第一次进化所需的交易数量
- 每次进化参考的样本数量
十六、风险说明
本策略属于 AI 辅助交易研究框架,不保证盈利。
大语言模型可能出现以下问题:
- 对指标关系理解错误
- 新闻信息判断偏差
- 输出格式不稳定
- 在样本较少时总结出错误经验
- 对短期随机盈亏进行过度拟合
- 生成看似合理但实际无效的交易理由
自动复盘和参数进化也不能消除市场风险。
当历史样本数量不足时,策略可能将偶然盈利误认为有效规律,或者将正常亏损误认为策略缺陷。
因此,建议将 AI 视为结构化分析和经验整理工具,而不是绝对正确的行情预测器。
实盘运行前,应充分进行模拟测试、参数检查和风险评估,并始终使用代码层仓位限制、硬止损和最大加仓次数等保护机制。
十七、策略总结
这套策略尝试解决的不是“让 AI 预测下一根 K 线”,而是让 AI 参与一个完整、可记录、可检查、可约束的交易流程。
它将传统量化指标作为市场数据输入,将新闻作为外部信息输入,将 Playbook 作为长期经验输入,再通过结构化决策、风险执行、交易复盘和经验更新形成闭环。
策略的主要特点可以概括为:
多指标分析、新闻辅助、结构化决策、自动复盘、经验积累、安全进化。
它更适合作为 AI 量化交易研究框架,而不是一套无需理解即可直接运行的黑盒盈利策略。
# -*- coding: utf-8 -*-
"""
单品种 AI 自进化交易系统 - 发明者量化(FMZ.COM) Python V1
适用标的:BTC / 黄金 / 原油 等任意单一 TradFi 或加密货币永续合约
=====================================================================
设计核心:Decide -> Execute -> Reflect -> Evolve 闭环自进化
=====================================================================
1. 指标层:纯 Python 手写多组技术指标(趋势/动量/波动率/量能),不依赖
平台 TA 库具体版本签名,保证跨环境稳定运行。
2. 新闻层:BraveSearch 抓取该品种相关新闻,去重缓存进数据库。
3. 决策层:LLM 综合"指标快照 + 新闻 + 当前 Playbook(经验库)"输出结构化
交易信号,要求技术面与消息面不冲突才允许开仓/加仓。
4. 执行层:仓位/止损/移动止盈参数从 Playbook 读取(不是写死常量),
支持同向加仓、反向平仓再反手,内置硬止损与移动止盈。
5. 复盘层:每笔平仓后,LLM 分析这笔交易的输赢原因,提炼中文经验写入
数据库(reflections 表)。
6. 进化层:每积累 N 笔复盘(或每隔固定时间),LLM 读取近期复盘 + 整体
胜率统计,输出新版本 Playbook(新的 do/avoid 规则 + 参数建议)。
参数建议会被裁剪进人工设定的安全上下限,写回数据库后立即生效,
下一次决策自动使用最新 Playbook —— 这就是"自进化"的闭环。
安全设计:
- AI 不能修改代码,只能在人工设定的硬性范围内微调数值参数。
- 硬止损、单标的仓位上限、最大加仓次数是代码层强约束,AI 建议无法突破。
- 开仓强制要求"技术面 + 消息面"双重不冲突确认。
- 默认 notify 模式,仅通知不实盘。
=====================================================================
以下常量由平台参数注入(在 FMZ 策略"参数"面板中创建,无需在代码中硬编码):
LLM_API_KEY / LLM_BASE_URL / LLM_MODEL LLM 接口配置
BRAVE_KEY BraveSearch Key(留空跳过新闻)
TRADE_MODE notify 仅通知 / trade 实盘
INSTRUMENT_KEYWORD 合约匹配关键字,如 "BTC_USDT" "XAU_USDT" "WTI_USDT"
INSTRUMENT_DISPLAY_NAME 中文显示名,如 "比特币" "黄金" "WTI原油"
NEWS_QUERY_TERMS 逗号分隔的新闻检索关键词,如 "美联储,ETF,减半"
KLINE_PERIOD_LABEL M15 / M30 / H1 / H4 / D1
CHECK_INTERVAL_S 主循环间隔秒
NEWS_REFRESH_S 新闻刷新间隔秒
DECISION_COOLDOWN_S 两次AI决策最小间隔秒
KLINE_LIMIT 拉取K线根数
MIN_CONFIDENCE / MIN_CONFIDENCE_FLOOR / MIN_CONFIDENCE_CEIL 最小置信度(初始值/进化下限/进化上限)
BASE_POS_PCT / BASE_POS_PCT_FLOOR / BASE_POS_PCT_CEIL 基础仓位比例(初始值/进化下限/进化上限)
MAX_SINGLE_POS_PCT 单标的最大仓位比例(硬约束,不参与进化)
MAX_TOTAL_ADD_COUNT 最大加仓次数(硬约束)
LEVERAGE 杠杆(硬约束)
HARD_STOP_PCT / HARD_STOP_PCT_MIN / HARD_STOP_PCT_MAX 硬止损%(初始值/进化下限/进化上限)
TRAIL_ACTIVATE_PCT / TRAIL_ACTIVATE_PCT_MIN / TRAIL_ACTIVATE_PCT_MAX 移动止盈激活%
TRAIL_GIVEBACK_PCT / TRAIL_GIVEBACK_PCT_MIN / TRAIL_GIVEBACK_PCT_MAX 移动止盈回撤%
EVOLUTION_TRADE_INTERVAL 每积累几笔已复盘交易触发一次进化
EVOLUTION_TIME_INTERVAL_S 兜底:每隔多久强制检查一次进化
EVOLUTION_MIN_TRADES 至少要有几笔复盘记录才允许第一次进化
REFLECTION_LOOKBACK 进化时回顾最近几笔复盘记录
=====================================================================
"""
import json
import math
import re
import time
import urllib.parse
import urllib.request
STORE_KEY = "ai_single_evolve_store_v1"
POSITION_STATE_KEY = "ai_single_evolve_pos_v1"
# =========================
# 主循环
# =========================
def main():
Log("单品种AI自进化交易系统启动 | {} | mode:{}".format(INSTRUMENT_DISPLAY_NAME, TRADE_MODE), "#00AAFF")
db_init()
store = load_store()
if not store.get("market"):
resolve_market(store)
load_or_init_playbook(store)
last_dashboard = 0
while True:
try:
handle_command(store)
if not store.get("market"):
resolve_market(store)
now = int(time.time())
if store.get("market"):
if BRAVE_KEY and now - store.get("last_news_refresh", 0) > NEWS_REFRESH_S:
news = fetch_instrument_news(store)
store["news_cache_recent"] = news
store["last_news_refresh"] = now
save_store(store)
if now - store.get("last_decision_time", 0) > DECISION_COOLDOWN_S:
run_decision_cycle(store)
store["last_decision_time"] = now
save_store(store)
monitor_position(store)
maybe_run_evolution(store)
if now - last_dashboard > 60:
show_dashboard(store)
last_dashboard = now
save_store(store)
except Exception as e:
Log("主循环异常: " + str(e), "#FF0000")
Sleep(CHECK_INTERVAL_S * 1000)
# =========================
# Store (工作记忆,轻量易变状态)
# =========================
def new_store():
return {
"market": None,
"indicator_snapshot": {},
"news_cache_recent": [],
"current_playbook": {},
"signals": [],
"step_logs": [],
"closed_trade_count_since_evolution": 0,
"evolution_paused": False,
"last_news_refresh": 0,
"last_decision_time": 0,
"last_evolution_check": 0,
}
def load_store():
store = _G(STORE_KEY)
if not store:
return new_store()
fresh = new_store()
for k, v in fresh.items():
if k not in store:
store[k] = v
return store
def save_store(store):
_G(STORE_KEY, store)
def record_step(store, step, data, color="#999999"):
row = {"time": int(time.time()), "step": step, "data": data}
store.setdefault("step_logs", []).insert(0, row)
store["step_logs"] = store["step_logs"][:300]
Log("STEP | {} | {}".format(step, json.dumps(data, ensure_ascii=False)[:400]), color)
def handle_command(store):
cmd = GetCommand()
if not cmd:
return
if cmd == "decision:force":
store["last_decision_time"] = 0
save_store(store)
Log("已强制触发下一轮决策", "#00CC66")
elif cmd == "evolution:trigger":
run_evolution(store, forced=True)
elif cmd == "evolution:pause":
store["evolution_paused"] = True
save_store(store)
Log("进化已暂停", "#FFAA00")
elif cmd == "evolution:resume":
store["evolution_paused"] = False
save_store(store)
Log("进化已恢复", "#00CC66")
elif cmd == "clear:store":
market = store.get("market")
store.clear()
store.update(new_store())
store["market"] = market
load_or_init_playbook(store)
save_store(store)
Log("运行态已重置(数据库历史交易/经验保留)", "#FFAA00")
elif cmd == "db:wipe":
for tbl in ["indicator_snapshot", "decisions", "trades", "reflections", "playbook", "news_cache"]:
db_exec("DELETE FROM {}".format(tbl))
store.clear()
store.update(new_store())
resolve_market(store)
load_or_init_playbook(store)
save_store(store)
Log("数据库与运行态已全部清空", "#FF0000")
# =========================
# 数据库层 (DBExec / SQLite - 长期记忆)
# =========================
def db_init():
db_exec('CREATE TABLE IF NOT EXISTS indicator_snapshot ('
'id INTEGER PRIMARY KEY AUTOINCREMENT, time INTEGER NOT NULL, '
'price REAL, snapshot_json TEXT NOT NULL);')
db_exec('CREATE TABLE IF NOT EXISTS decisions ('
'id INTEGER PRIMARY KEY AUTOINCREMENT, time INTEGER NOT NULL, '
'playbook_version INTEGER, action TEXT, direction TEXT, confidence INTEGER, '
'technical_view TEXT, news_view TEXT, reason TEXT, '
'indicators_json TEXT, news_json TEXT, executed INTEGER DEFAULT 0);')
db_exec('CREATE TABLE IF NOT EXISTS trades ('
'id INTEGER PRIMARY KEY AUTOINCREMENT, open_time INTEGER, close_time INTEGER, '
'direction TEXT, entry_price REAL, exit_price REAL, qty REAL, pnl_pct REAL, '
'close_reason TEXT, decision_id INTEGER, reflected INTEGER DEFAULT 0);')
db_exec('CREATE TABLE IF NOT EXISTS reflections ('
'id INTEGER PRIMARY KEY AUTOINCREMENT, trade_id INTEGER, time INTEGER, '
'outcome TEXT, mistake_type TEXT, lesson TEXT, tags TEXT, raw_json TEXT);')
db_exec('CREATE TABLE IF NOT EXISTS playbook ('
'id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER, created_at INTEGER, '
'summary TEXT, do_rules TEXT, avoid_rules TEXT, params_json TEXT, based_on_trades INTEGER);')
db_exec('CREATE TABLE IF NOT EXISTS news_cache ('
'id INTEGER PRIMARY KEY AUTOINCREMENT, time INTEGER, url TEXT UNIQUE, '
'title TEXT, desc TEXT, source TEXT, query TEXT);')
def db_exec(sql, *params):
try:
return DBExec(sql, *params) if params else DBExec(sql)
except Exception as e:
Log("DB执行失败 | {} | {}".format(sql[:100], str(e)), "#FF0000")
return None
def db_query(sql, *params):
res = db_exec(sql, *params)
cols, vals = _extract_cols_values(res)
out = []
for row in vals:
out.append({cols[i]: row[i] for i in range(min(len(cols), len(row)))})
return out
def _extract_cols_values(res):
if res is None:
return [], []
if isinstance(res, dict):
return res.get("columns", []) or [], res.get("values", []) or []
return getattr(res, "columns", []) or [], getattr(res, "values", []) or []
def db_last_id():
rows = db_query("select last_insert_rowid() as id")
return rows[0]["id"] if rows else None
# =========================
# 品种解析
# =========================
def resolve_market(store):
try:
ms = exchange.GetMarkets()
except Exception as e:
Log("GetMarkets失败: " + str(e), "#FF0000")
return
found_key, found_market = None, None
kw = INSTRUMENT_KEYWORD.upper()
# 优先级:永续 swap > 无后缀精确匹配 > 子串回退。
# 避免 "BTC_USDT" 误命中 "BTC_USDT.next_quarter" 等季度合约。
swap_hit = exact_hit = sub_hit = None
for key, market in ms.items():
ku = key.upper()
if ku == kw + ".SWAP":
swap_hit = (key, market)
break
if ku == kw:
exact_hit = exact_hit or (key, market)
elif kw in ku and sub_hit is None:
sub_hit = (key, market)
picked = swap_hit or exact_hit or sub_hit
if picked:
found_key, found_market = picked
if not found_key:
Log("未找到匹配 {} 的合约,请检查 INSTRUMENT_KEYWORD 参数".format(INSTRUMENT_KEYWORD), "#FF0000")
return
store["market"] = {
"contract": found_key,
"amountPrecision": found_market.get("AmountPrecision", 0),
"pricePrecision": found_market.get("PricePrecision", 2),
"ctVal": found_market.get("CtVal", 1) or 1,
"minQty": found_market.get("MinQty", 0) or 0,
}
save_store(store)
record_step(store, "market_resolved", {"contract": found_key}, "#00AAFF")
def resolve_period():
names = {"M15": "PERIOD_M15", "M30": "PERIOD_M30", "H1": "PERIOD_H1",
"H4": "PERIOD_H4", "D1": "PERIOD_D1"}
label = str(KLINE_PERIOD_LABEL).upper() if "KLINE_PERIOD_LABEL" in globals() else "H1"
name = names.get(label, "PERIOD_H1")
return globals().get(name, globals().get("PERIOD_H1"))
KLINE_PERIOD = None # 在 main 首次使用前惰性解析,避免参数注入时序问题
def get_kline_period():
global KLINE_PERIOD
if KLINE_PERIOD is None:
KLINE_PERIOD = resolve_period()
return KLINE_PERIOD
def get_records(contract, limit):
try:
bars = exchange.GetRecords(contract, get_kline_period()) or []
return bars[-limit:]
except Exception as e:
Log("GetRecords失败 | {} | {}".format(contract, str(e)), "#FFAA00")
return []
# =========================
# 指标层 (纯 Python 手写,不依赖平台TA库签名)
# =========================
def ema_full_series(values, period):
if not values:
return []
k = 2.0 / (period + 1)
out = [values[0]]
for v in values[1:]:
out.append(out[-1] * (1 - k) + v * k)
return out
def latest_ema(values, period):
series = ema_full_series(values, period)
return series[-1] if series else 0
def latest_rsi(values, period=14):
if len(values) < period + 1:
return 50.0
deltas = [values[i] - values[i - 1] for i in range(1, len(values))]
gains = [max(d, 0) for d in deltas]
losses = [max(-d, 0) for d in deltas]
avg_gain = mean(gains[:period])
avg_loss = mean(losses[:period])
for i in range(period, len(deltas)):
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
return 100 - 100 / (1 + rs)
def latest_macd(values, fast=12, slow=26, signal=9):
if len(values) < slow + signal:
return 0.0, 0.0, 0.0, 0.0
fast_series = ema_full_series(values, fast)
slow_series = ema_full_series(values, slow)
dif_series = [f - s for f, s in zip(fast_series, slow_series)]
dea_series = ema_full_series(dif_series, signal)
dif = dif_series[-1]
dea = dea_series[-1]
hist = dif - dea
hist_prev = (dif_series[-2] - dea_series[-2]) if len(dif_series) > 1 else hist
return dif, dea, hist, hist_prev
def latest_boll(values, period=20, mult=2):
window = values[-period:] if len(values) >= period else values
mid = mean(window)
sd = std(window)
return mid + mult * sd, mid, mid - mult * sd
def latest_atr(records, period=14):
if len(records) < 2:
return 0.0
trs = []
for i in range(1, len(records)):
h, l, pc = records[i]["High"], records[i]["Low"], records[i - 1]["Close"]
trs.append(max(h - l, abs(h - pc), abs(l - pc)))
window = trs[-period:] if len(trs) >= period else trs
return mean(window) if window else 0.0
def latest_kdj(records, n=9, k_period=3, d_period=3):
if len(records) < n:
return 50.0, 50.0, 50.0
closes = [r["Close"] for r in records]
highs = [r["High"] for r in records]
lows = [r["Low"] for r in records]
k, d = 50.0, 50.0
for i in range(n - 1, len(records)):
hh = max(highs[i - n + 1:i + 1])
ll = min(lows[i - n + 1:i + 1])
rsv = (closes[i] - ll) / (hh - ll) * 100 if hh > ll else 50.0
k = (k_period - 1) / k_period * k + 1 / k_period * rsv
d = (d_period - 1) / d_period * d + 1 / d_period * k
j = 3 * k - 2 * d
return k, d, j
def pct_change(closes, n):
if len(closes) <= n:
return 0.0
a, b = closes[-1 - n], closes[-1]
return (b - a) / a * 100 if a else 0.0
def build_indicator_snapshot(records):
if not records or len(records) < 60:
return None
closes = [r["Close"] for r in records]
price = closes[-1]
ema_fast = latest_ema(closes, 20)
ema_slow = latest_ema(closes, 60)
dif, dea, hist, hist_prev = latest_macd(closes, 12, 26, 9)
rsi_v = latest_rsi(closes, 14)
boll_upper, boll_mid, boll_lower = latest_boll(closes, 20, 2)
atr_v = latest_atr(records, 14)
k_v, d_v, j_v = latest_kdj(records, 9, 3, 3)
vols = [r.get("Volume", 0) or 0 for r in records[-20:]]
vol_ma20 = mean(vols) if vols else 0
vol_now = records[-1].get("Volume", 0) or 0
vol_ratio = vol_now / vol_ma20 if vol_ma20 > 0 else 1.0
band_width = boll_upper - boll_lower
band_pos = (price - boll_lower) / band_width if band_width > 0 else 0.5
return {
"price": round(price, 6),
"trend": {
"ema_fast": round(ema_fast, 6),
"ema_slow": round(ema_slow, 6),
"ema_bias_pct": round((ema_fast - ema_slow) / ema_slow * 100, 3) if ema_slow else 0,
"macd_dif": round(dif, 6),
"macd_dea": round(dea, 6),
"macd_hist": round(hist, 6),
"macd_hist_rising": bool(hist > hist_prev),
},
"momentum": {
"rsi14": round(rsi_v, 2),
"kdj_k": round(k_v, 2),
"kdj_d": round(d_v, 2),
"kdj_j": round(j_v, 2),
},
"volatility": {
"boll_upper": round(boll_upper, 6),
"boll_mid": round(boll_mid, 6),
"boll_lower": round(boll_lower, 6),
"band_position": round(clamp(band_pos, 0, 1), 3),
"atr": round(atr_v, 6),
"atr_pct": round(atr_v / price * 100, 3) if price else 0,
},
"volume": {"vol_ratio_to_ma20": round(vol_ratio, 3)},
"price_action": {
"pct_change_1bar": round(pct_change(closes, 1), 3),
"pct_change_6bar": round(pct_change(closes, 6), 3),
"pct_change_24bar": round(pct_change(closes, 24), 3),
},
"time": int(time.time()),
}
# =========================
# 新闻层 (BraveSearch)
# =========================
def fetch_instrument_news(store):
if not BRAVE_KEY:
return []
terms = [t.strip() for t in str(NEWS_QUERY_TERMS).split(",") if t.strip()]
queries = [
'"{}" 最新消息 价格走势'.format(INSTRUMENT_DISPLAY_NAME),
'"{}" 行情分析'.format(INSTRUMENT_DISPLAY_NAME),
]
for t in terms[:4]:
queries.append('"{}" {}'.format(INSTRUMENT_DISPLAY_NAME, t))
news = brave_news_search(queries[:6], 4)
for item in news:
db_exec('INSERT OR IGNORE INTO news_cache(time, url, title, desc, source, query) values(?,?,?,?,?,?)',
int(time.time()), item.get("url", ""), item.get("title", ""),
item.get("desc", ""), item.get("source", ""), item.get("query", ""))
record_step(store, "news_refreshed", {"count": len(news)}, "#00AAFF")
return news
def brave_news_search(queries, count):
out, seen = [], {}
for q in queries:
try:
url = ("https://api.search.brave.com/res/v1/news/search?q=" +
urllib.parse.quote(q) + "&count=" + str(count) + "&freshness=pd")
raw = http_request(url, "GET", {"X-Subscription-Token": BRAVE_KEY, "Accept": "application/json"}, None, 20)
data = json.loads(raw)
for item in data.get("results", []):
link = item.get("url", "") or item.get("title", "")
if not link or link in seen:
continue
seen[link] = True
source = item.get("profile", {}) or {}
out.append({
"title": item.get("title", "") or "",
"desc": item.get("description", "") or "",
"url": item.get("url", "") or "",
"age": item.get("age", "") or "",
"source": source.get("name", "") or "",
"query": q,
})
except Exception as e:
Log("BraveSearch失败 | {} | {}".format(q, str(e)), "#FFAA00")
Sleep(800)
return out
# =========================
# Playbook 经验库(进化载体)
# =========================
def default_playbook_params():
return {
"min_confidence": MIN_CONFIDENCE,
"base_pos_pct": BASE_POS_PCT,
"hard_stop_pct": HARD_STOP_PCT,
"trail_activate_pct": TRAIL_ACTIVATE_PCT,
"trail_giveback_pct": TRAIL_GIVEBACK_PCT,
}
def load_or_init_playbook(store):
rows = db_query("select * from playbook order by version desc limit 1")
if rows:
row = rows[0]
store["current_playbook"] = {
"version": row.get("version", 1),
"summary": row.get("summary", ""),
"do_rules": safe_json_loads(row.get("do_rules"), []),
"avoid_rules": safe_json_loads(row.get("avoid_rules"), []),
"params": safe_json_loads(row.get("params_json"), default_playbook_params()),
"based_on_trades": row.get("based_on_trades", 0),
}
else:
params = default_playbook_params()
now = int(time.time())
db_exec('INSERT INTO playbook(version, created_at, summary, do_rules, avoid_rules, params_json, based_on_trades) '
'values(?,?,?,?,?,?,?)',
1, now, "初始版本,尚无交易经验,按基础参数运行。",
json.dumps([], ensure_ascii=False), json.dumps([], ensure_ascii=False),
json.dumps(params, ensure_ascii=False), 0)
store["current_playbook"] = {
"version": 1, "summary": "初始版本,尚无交易经验,按基础参数运行。",
"do_rules": [], "avoid_rules": [], "params": params, "based_on_trades": 0,
}
save_store(store)
def playbook_param(store, key, config_default, lo, hi):
v = store.get("current_playbook", {}).get("params", {}).get(key, config_default)
try:
v = float(v)
except Exception:
v = config_default
return clamp(v, lo, hi)
# =========================
# 决策层
# =========================
def run_decision_cycle(store):
market = store.get("market")
if not market:
return
records = get_records(market["contract"], KLINE_LIMIT)
if not records or len(records) < 60:
return
decision = make_decision(store, records)
if not decision:
return
ticker = None
try:
ticker = exchange.GetTicker(market["contract"])
except Exception as e:
Log("GetTicker失败: " + str(e), "#FFAA00")
return
apply_decision(store, decision, ticker)
def make_decision(store, records):
snapshot = build_indicator_snapshot(records)
if not snapshot:
return None
store["indicator_snapshot"] = snapshot
db_exec('INSERT INTO indicator_snapshot(time, price, snapshot_json) values(?,?,?)',
snapshot["time"], snapshot["price"], json.dumps(snapshot, ensure_ascii=False))
news = store.get("news_cache_recent", [])
playbook = store.get("current_playbook", {})
pos = _G(POSITION_STATE_KEY) or {}
if not LLM_API_KEY:
record_step(store, "decision_skipped_no_llm", {}, "#FFAA00")
return None
schema = {
"action": "open_long|open_short|add_long|add_short|reduce|close|hold",
"confidence": 0,
"technical_view": "bullish|bearish|neutral|conflicted",
"news_view": "bullish|bearish|neutral|no_data",
"reason": "",
"key_indicators_cited": [""],
"suggested_stop_pct": 0.0,
"risk_notes": "",
}
prompt = "\n".join([
"你是{}单品种AI交易决策引擎。".format(INSTRUMENT_DISPLAY_NAME),
"当前playbook经验(do_rules必须遵守,avoid_rules必须避免):",
json.dumps(playbook, ensure_ascii=False),
"当前持仓状态(空字典表示无持仓):",
json.dumps(pos, ensure_ascii=False),
"多指标快照:",
json.dumps(snapshot, ensure_ascii=False),
"近期相关新闻(可能为空):",
json.dumps(news[:10], ensure_ascii=False),
"输出schema:",
json.dumps(schema, ensure_ascii=False),
"要求:",
"0. 综合趋势/动量/波动率/量能多个维度,不要只依赖单一指标下结论。",
"1. 只有technical_view与news_view不冲突(news_view为neutral/no_data,或方向与technical_view一致)才能给出open_long/open_short/add_long/add_short。",
"2. 如果news_view方向与technical_view相反,action必须是hold或close。",
"3. 已有持仓且趋势明显反转时,action应为close,不要硬扛。",
"4. confidence必须真实反映把握程度(0-100),不要习惯性给高分,把握不足给低分。",
"5. reason和risk_notes必须使用中文,并结合playbook中的do_rules/avoid_rules说明理由。",
"6. 字段名必须和schema完全一致,不要新增或改名字段。",
])
data = call_llm_json("你是严谨的量化交易决策引擎,只输出JSON,所有中文字段必须使用中文,不要输出markdown。", prompt, 1200)
if not data:
return None
decision = sanitize_decision(data)
db_exec('INSERT INTO decisions(time, playbook_version, action, direction, confidence, technical_view, news_view, '
'reason, indicators_json, news_json, executed) values(?,?,?,?,?,?,?,?,?,?,0)',
int(time.time()), playbook.get("version", 0), decision["action"], decision["direction"],
decision["confidence"], decision["technical_view"], decision["news_view"], decision["reason"],
json.dumps(snapshot, ensure_ascii=False), json.dumps(news[:10], ensure_ascii=False))
decision["id"] = db_last_id()
store.setdefault("signals", []).insert(0, {
"time": int(time.time()), "action": decision["action"], "confidence": decision["confidence"],
"technical_view": decision["technical_view"], "news_view": decision["news_view"],
"reason": decision["reason"],
})
store["signals"] = store["signals"][:50]
record_step(store, "decision_made", {
"action": decision["action"], "confidence": decision["confidence"],
"technical_view": decision["technical_view"], "news_view": decision["news_view"],
}, "#00AAFF")
return decision
def sanitize_decision(data):
action = str(data.get("action", "hold") or "hold").lower()
valid_actions = ["open_long", "open_short", "add_long", "add_short", "reduce", "close", "hold"]
if action not in valid_actions:
action = "hold"
direction = "long" if "long" in action else ("short" if "short" in action else "")
return {
"action": action,
"direction": direction,
"confidence": normalize_confidence(data.get("confidence", 0)),
"technical_view": data.get("technical_view", "neutral") or "neutral",
"news_view": data.get("news_view", "no_data") or "no_data",
"reason": data.get("reason", "") or "",
"key_indicators_cited": as_list(data.get("key_indicators_cited")),
"suggested_stop_pct": safe_float(data.get("suggested_stop_pct", 0)),
"risk_notes": data.get("risk_notes", "") or "",
}
# =========================
# 执行层
# =========================
def apply_decision(store, decision, ticker):
if not ticker:
return
action = decision["action"]
pos = _G(POSITION_STATE_KEY) or {}
has_pos = bool(pos and pos.get("qty", 0) > 0)
min_conf = playbook_param(store, "min_confidence", MIN_CONFIDENCE, MIN_CONFIDENCE_FLOOR, MIN_CONFIDENCE_CEIL)
if action in ("open_long", "open_short") and not has_pos:
if decision["confidence"] < min_conf:
record_step(store, "decision_skip_low_confidence",
{"action": action, "confidence": decision["confidence"], "min": min_conf}, "#999999")
return
open_position(store, decision, ticker, add_mode=False)
elif action in ("open_long", "open_short") and has_pos and pos.get("direction") != decision["direction"]:
close_position(store, ticker, "reverse_signal")
Sleep(1000)
if decision["confidence"] >= min_conf:
open_position(store, decision, ticker, add_mode=False)
elif action in ("add_long", "add_short") and has_pos and pos.get("direction") == decision["direction"]:
if decision["confidence"] < min_conf:
return
if pos.get("add_count", 0) >= MAX_TOTAL_ADD_COUNT:
record_step(store, "add_blocked_max_count", {"add_count": pos.get("add_count", 0)}, "#FFAA00")
return
open_position(store, decision, ticker, add_mode=True)
elif action in ("close", "reduce") and has_pos:
if action == "reduce":
reduce_position(store, ticker, 0.5, "ai_reduce")
else:
close_position(store, ticker, "ai_decision")
else:
record_step(store, "decision_hold", {"action": action, "confidence": decision["confidence"]}, "#999999")
def open_position(store, decision, ticker, add_mode):
market = store.get("market")
if not market or not ticker:
return False
if TRADE_MODE != "trade":
record_step(store, "trade_notify", {
"action": decision["action"], "direction": decision["direction"],
"confidence": decision["confidence"], "reason": decision["reason"],
}, "#00CC66")
return True
acc = None
try:
acc = exchange.GetAccount()
except Exception as e:
Log("GetAccount失败: " + str(e), "#FF0000")
return False
if not acc or not ticker or ticker.get("Last", 0) <= 0:
return False
equity = get_equity(acc)
base_pos_pct = playbook_param(store, "base_pos_pct", BASE_POS_PCT, BASE_POS_PCT_FLOOR, BASE_POS_PCT_CEIL)
size_pct = base_pos_pct * clamp(decision["confidence"] / 100.0, 0.4, 1.0)
qty = _N(equity * size_pct * LEVERAGE / ticker["Last"] / market["ctVal"], market["amountPrecision"])
if qty <= 0 or qty < market["minQty"]:
record_step(store, "trade_blocked", {"reason": "数量过小", "qty": qty}, "#FFAA00")
return False
pos = _G(POSITION_STATE_KEY) or {}
cur_qty = pos.get("qty", 0) if add_mode else 0
max_qty = _N(equity * MAX_SINGLE_POS_PCT * LEVERAGE / ticker["Last"] / market["ctVal"], market["amountPrecision"])
if cur_qty + qty > max_qty:
qty = _N(max(0, max_qty - cur_qty), market["amountPrecision"])
if qty <= 0 or qty < market["minQty"]:
record_step(store, "trade_blocked", {"reason": "已达单标的仓位上限"}, "#FFAA00")
return False
side = "buy" if decision["direction"] == "long" else "sell"
oid = None
try:
oid = exchange.CreateOrder(market["contract"], side, -1, qty)
except Exception as e:
Log("下单异常: " + str(e), "#FF0000")
if not oid:
record_step(store, "trade_failed", {"side": side, "qty": qty}, "#FF0000")
return False
hard_stop_pct = playbook_param(store, "hard_stop_pct", HARD_STOP_PCT, HARD_STOP_PCT_MIN, HARD_STOP_PCT_MAX)
now = int(time.time())
if add_mode and pos:
new_qty = pos.get("qty", 0) + qty
avg_entry = ((pos.get("entry", ticker["Last"]) * pos.get("qty", 0) + ticker["Last"] * qty) / new_qty
if new_qty > 0 else ticker["Last"])
pos["qty"] = new_qty
pos["entry"] = avg_entry
pos["add_count"] = pos.get("add_count", 0) + 1
else:
pos = {
"direction": decision["direction"], "qty": qty, "entry": ticker["Last"],
"opened_at": now, "add_count": 0, "peakPnlPct": 0, "trailActive": False,
"decision_id": decision.get("id"),
}
pos["stop"] = (pos["entry"] * (1 - hard_stop_pct / 100.0) if pos["direction"] == "long"
else pos["entry"] * (1 + hard_stop_pct / 100.0))
_G(POSITION_STATE_KEY, pos)
if decision.get("id"):
db_exec("UPDATE decisions SET executed=1 WHERE id=?", decision["id"])
record_step(store, "trade_opened", {
"direction": pos["direction"], "qty": qty, "price": ticker["Last"],
"add_mode": add_mode, "hard_stop_pct": hard_stop_pct,
}, "#00CC66")
return True
def close_position(store, ticker, reason):
pos = _G(POSITION_STATE_KEY)
if not pos or not pos.get("qty"):
return False
market = store.get("market")
if not market:
return False
if TRADE_MODE == "trade":
side = "closebuy" if pos["direction"] == "long" else "closesell"
oid = None
try:
oid = exchange.CreateOrder(market["contract"], side, -1, pos["qty"])
except Exception as e:
Log("平仓异常: " + str(e), "#FF0000")
if not oid:
record_step(store, "close_failed", {"reason": reason}, "#FF0000")
return False
exit_price = ticker["Last"] if ticker else pos.get("entry", 0)
pnl_pct = (exit_price - pos["entry"]) / pos["entry"] * 100 * (1 if pos["direction"] == "long" else -1)
now = int(time.time())
db_exec('INSERT INTO trades(open_time, close_time, direction, entry_price, exit_price, qty, pnl_pct, '
'close_reason, decision_id) values(?,?,?,?,?,?,?,?,?)',
pos.get("opened_at", now), now, pos["direction"], pos["entry"], exit_price,
pos["qty"], round(pnl_pct, 3), reason, pos.get("decision_id"))
trade_id = db_last_id()
record_step(store, "trade_closed", {
"direction": pos["direction"], "pnl_pct": round(pnl_pct, 3), "reason": reason,
}, "#00CC66" if pnl_pct > 0 else "#FF6666")
_G(POSITION_STATE_KEY, None)
if trade_id:
reflect_on_closed_trade(store, trade_id)
store["closed_trade_count_since_evolution"] = store.get("closed_trade_count_since_evolution", 0) + 1
save_store(store)
maybe_run_evolution(store)
return True
def reduce_position(store, ticker, ratio, reason):
pos = _G(POSITION_STATE_KEY)
if not pos or not pos.get("qty"):
return False
market = store.get("market")
if not market:
return False
cut = _N(max(0, pos["qty"] * clamp(ratio, 0.1, 1.0)), market["amountPrecision"])
if cut <= 0 or cut < market.get("minQty", 0):
return False
if cut >= pos["qty"]:
return close_position(store, ticker, reason)
if TRADE_MODE == "trade":
side = "closebuy" if pos["direction"] == "long" else "closesell"
oid = None
try:
oid = exchange.CreateOrder(market["contract"], side, -1, cut)
except Exception as e:
Log("减仓异常: " + str(e), "#FF0000")
if not oid:
record_step(store, "reduce_failed", {"reason": reason}, "#FF0000")
return False
pos["qty"] = _N(pos["qty"] - cut, market["amountPrecision"])
_G(POSITION_STATE_KEY, pos)
record_step(store, "position_reduced", {"cut": cut, "left": pos["qty"], "reason": reason}, "#FFAA00")
return True
def monitor_position(store):
pos = _G(POSITION_STATE_KEY)
if not pos or not pos.get("qty"):
return
market = store.get("market")
if not market:
return
ticker = None
try:
ticker = exchange.GetTicker(market["contract"])
except Exception as e:
Log("GetTicker失败: " + str(e), "#FFAA00")
return
if not ticker or ticker.get("Last", 0) <= 0:
return
direction = pos["direction"]
pnl_pct = (ticker["Last"] - pos["entry"]) / pos["entry"] * 100 * (1 if direction == "long" else -1)
if pnl_pct > pos.get("peakPnlPct", 0):
pos["peakPnlPct"] = pnl_pct
trail_activate = playbook_param(store, "trail_activate_pct", TRAIL_ACTIVATE_PCT,
TRAIL_ACTIVATE_PCT_MIN, TRAIL_ACTIVATE_PCT_MAX)
trail_giveback = playbook_param(store, "trail_giveback_pct", TRAIL_GIVEBACK_PCT,
TRAIL_GIVEBACK_PCT_MIN, TRAIL_GIVEBACK_PCT_MAX)
if not pos.get("trailActive") and pos.get("peakPnlPct", 0) >= trail_activate:
pos["trailActive"] = True
hard_stop = ticker["Last"] <= pos["stop"] if direction == "long" else ticker["Last"] >= pos["stop"]
trail_hit = False
if pos.get("trailActive"):
giveback = pos.get("peakPnlPct", 0) - pnl_pct
allowed = max(1.0, pos.get("peakPnlPct", 0) * trail_giveback / 100.0)
trail_hit = giveback >= allowed
if hard_stop or trail_hit:
close_position(store, ticker, "hard_stop" if hard_stop else "trailing_stop")
else:
_G(POSITION_STATE_KEY, pos)
def get_equity(acc):
return acc.get("Equity") or acc.get("Balance") or acc.get("Stocks") or 0
# =========================
# 复盘层
# =========================
def reflect_on_closed_trade(store, trade_id):
if not LLM_API_KEY:
return
rows = db_query("select * from trades where id=?", trade_id)
if not rows:
return
trade = rows[0]
entry_decision = None
if trade.get("decision_id"):
drows = db_query("select * from decisions where id=?", trade["decision_id"])
entry_decision = drows[0] if drows else None
schema = {
"outcome": "win|loss|breakeven",
"mistake_type": "none|chased_move|ignored_news_conflict|stop_too_tight|stop_too_wide|"
"overleveraged|held_too_long|exited_too_early|other",
"lesson": "",
"tags": [""],
}
prompt = "\n".join([
"对以下已平仓交易进行复盘:",
"交易记录:", json.dumps(trade, ensure_ascii=False),
"入场时的决策依据:", json.dumps(entry_decision, ensure_ascii=False) if entry_decision else "无记录",
"输出schema:", json.dumps(schema, ensure_ascii=False),
"要求:",
"1. outcome根据pnl_pct判断,明显为正=win,明显为负=loss,接近0=breakeven。",
"2. lesson必须是具体、可执行的中文经验总结,不要空泛(例如'RSI超过75追多容易被套,应等回调')。",
"3. mistake_type只在确实存在对应问题时才选择非none选项。",
"4. 字段名必须和schema完全一致。",
])
data = call_llm_json("你是交易复盘教练,只输出JSON,所有文本字段必须使用中文。", prompt, 900)
if not data:
return
now = int(time.time())
db_exec('INSERT INTO reflections(trade_id, time, outcome, mistake_type, lesson, tags, raw_json) '
'values(?,?,?,?,?,?,?)',
trade_id, now, data.get("outcome", ""), data.get("mistake_type", "none"),
data.get("lesson", ""), json.dumps(as_list(data.get("tags")), ensure_ascii=False),
json.dumps(data, ensure_ascii=False))
db_exec('UPDATE trades SET reflected=1 WHERE id=?', trade_id)
record_step(store, "reflection_saved", {
"trade_id": trade_id, "outcome": data.get("outcome"),
"lesson": short_text(data.get("lesson", ""), 80),
}, "#00AAFF")
# =========================
# 进化层 (核心:自进化闭环)
# =========================
def maybe_run_evolution(store):
if store.get("evolution_paused"):
return
total_reflected = db_query("select count(*) as c from reflections")
total_reflected = total_reflected[0]["c"] if total_reflected else 0
if total_reflected < EVOLUTION_MIN_TRADES:
return
now = int(time.time())
closed_since = store.get("closed_trade_count_since_evolution", 0)
trade_due = closed_since >= EVOLUTION_TRADE_INTERVAL
time_due = now - store.get("last_evolution_check", 0) > EVOLUTION_TIME_INTERVAL_S
if trade_due or time_due:
run_evolution(store)
def run_evolution(store, forced=False):
rows = db_query(
"select r.*, t.direction as t_direction, t.pnl_pct as t_pnl_pct, t.close_reason as t_close_reason "
"from reflections r left join trades t on r.trade_id = t.id order by r.time desc limit ?",
REFLECTION_LOOKBACK)
if not rows:
if not forced:
return
record_step(store, "evolution_skipped", {"reason": "暂无复盘记录"}, "#FFAA00")
return
if not LLM_API_KEY:
record_step(store, "evolution_skipped", {"reason": "LLM_API_KEY未配置"}, "#FFAA00")
return
stats = compute_trade_stats()
playbook = store.get("current_playbook", {})
schema = {
"summary": "",
"do_rules": [""],
"avoid_rules": [""],
"param_suggestions": {
"min_confidence": 0,
"base_pos_pct": 0.0,
"hard_stop_pct": 0.0,
"trail_activate_pct": 0.0,
"trail_giveback_pct": 0.0,
},
"confidence_in_update": 0,
"reasoning": "",
}
prompt = "\n".join([
"你是交易系统的自我进化复盘员,任务是基于历史交易复盘更新交易playbook(经验库)。",
"当前playbook:", json.dumps(playbook, ensure_ascii=False),
"整体统计:", json.dumps(stats, ensure_ascii=False),
"最近复盘记录:", json.dumps(rows[:REFLECTION_LOOKBACK], ensure_ascii=False),
"输出schema:", json.dumps(schema, ensure_ascii=False),
"要求:",
"1. do_rules/avoid_rules必须是具体、可执行的中文规则,例如'RSI>70且新闻利空时不要开多'。",
"2. param_suggestions只在有充分证据支持时才调整,避免频繁大幅震荡;证据不足时原样返回当前值。",
"3. summary是对当前策略状态的整体中文总结,控制在150字以内。",
"4. 字段名必须和schema完全一致,不要新增或改名。",
])
data = call_llm_json("你是量化交易复盘与策略进化专家,只输出JSON,所有文本字段必须使用中文。", prompt, 1600)
if not data:
record_step(store, "evolution_failed", {"reason": "LLM返回空"}, "#FFAA00")
return
old_params = playbook.get("params", default_playbook_params())
suggested = data.get("param_suggestions", {}) or {}
new_params = dict(old_params)
bounds = {
"min_confidence": (MIN_CONFIDENCE_FLOOR, MIN_CONFIDENCE_CEIL),
"base_pos_pct": (BASE_POS_PCT_FLOOR, BASE_POS_PCT_CEIL),
"hard_stop_pct": (HARD_STOP_PCT_MIN, HARD_STOP_PCT_MAX),
"trail_activate_pct": (TRAIL_ACTIVATE_PCT_MIN, TRAIL_ACTIVATE_PCT_MAX),
"trail_giveback_pct": (TRAIL_GIVEBACK_PCT_MIN, TRAIL_GIVEBACK_PCT_MAX),
}
for k, (lo, hi) in bounds.items():
if k in suggested:
try:
new_params[k] = round(clamp(float(suggested[k]), lo, hi), 4)
except Exception:
pass
new_version = playbook.get("version", 0) + 1
now = int(time.time())
do_rules = as_list(data.get("do_rules"))[:12]
avoid_rules = as_list(data.get("avoid_rules"))[:12]
summary = data.get("summary", "") or ""
db_exec('INSERT INTO playbook(version, created_at, summary, do_rules, avoid_rules, params_json, based_on_trades) '
'values(?,?,?,?,?,?,?)',
new_version, now, summary, json.dumps(do_rules, ensure_ascii=False),
json.dumps(avoid_rules, ensure_ascii=False), json.dumps(new_params, ensure_ascii=False), len(rows))
store["current_playbook"] = {
"version": new_version, "summary": summary, "do_rules": do_rules,
"avoid_rules": avoid_rules, "params": new_params, "based_on_trades": len(rows),
}
store["closed_trade_count_since_evolution"] = 0
store["last_evolution_check"] = now
save_store(store)
record_step(store, "evolution_done", {
"version": new_version, "summary": summary,
"old_params": old_params, "new_params": new_params, "based_on_trades": len(rows),
}, "#00CC66")
def compute_trade_stats():
rows = db_query("select pnl_pct from trades where close_time is not null order by close_time desc limit 100")
pnls = [r["pnl_pct"] for r in rows if r.get("pnl_pct") is not None]
if not pnls:
return {"total": 0, "win_rate": 0, "avg_pnl_pct": 0}
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
return {
"total": len(pnls),
"win_rate": round(len(wins) / len(pnls), 3),
"avg_pnl_pct": round(mean(pnls), 3),
"avg_win_pct": round(mean(wins), 3) if wins else 0,
"avg_loss_pct": round(mean(losses), 3) if losses else 0,
}
# =========================
# Dashboard
# =========================
def show_dashboard(store):
market = store.get("market") or {}
playbook = store.get("current_playbook", {})
pos = _G(POSITION_STATE_KEY) or {}
stats = compute_trade_stats()
header = "{} 单品种AI自进化交易系统 | {} | mode:{} | playbook v{}\n".format(
INSTRUMENT_DISPLAY_NAME, time.strftime("%Y-%m-%d %H:%M:%S"), TRADE_MODE, playbook.get("version", 0))
overview_rows = [
["合约", market.get("contract", "-")],
["总交易数", stats.get("total", 0)],
["胜率", "{}%".format(round(stats.get("win_rate", 0) * 100, 1))],
["平均盈亏%", stats.get("avg_pnl_pct", 0)],
["当前持仓", "{}/{}".format(pos.get("direction", "-"), fmt(pos.get("qty", 0))) if pos else "无"],
["累计加仓次数", pos.get("add_count", 0) if pos else 0],
["距下次进化", "{}/{}笔".format(store.get("closed_trade_count_since_evolution", 0), EVOLUTION_TRADE_INTERVAL)],
["进化状态", "已暂停" if store.get("evolution_paused") else "运行中"],
]
overview = make_table("系统概览", ["项目", "数值"], overview_rows)
snap = store.get("indicator_snapshot") or {}
ind_rows = []
if snap:
t, m = snap.get("trend", {}), snap.get("momentum", {})
v, vo, pa = snap.get("volatility", {}), snap.get("volume", {}), snap.get("price_action", {})
ind_rows = build_indicator_rows(snap, t, m, v, vo, pa)
if not ind_rows:
ind_rows = [["-", "-", "-", "-", "-", "-", "-"]]
indicator_table = make_table(
"📊 多指标快照",
["维度", "指标①", "数值①", "指标②", "数值②", "指标③", "数值③"],
ind_rows)
signal_rows = []
for s in store.get("signals", [])[:8]:
signal_rows.append([
time.strftime("%H:%M:%S", time.localtime(s.get("time", 0))), s.get("action"), s.get("confidence"),
s.get("technical_view"), s.get("news_view"), short_text(s.get("reason", ""), 40),
])
if not signal_rows:
signal_rows = [["-", "-", "-", "-", "-", "暂无"]]
signals_table = make_table("最近决策", ["时间", "动作", "置信度", "技术面", "消息面", "理由"], signal_rows)
trade_rows = []
for t in db_query("select * from trades order by close_time desc limit 8"):
trade_rows.append([
time.strftime("%H:%M:%S", time.localtime(t.get("close_time") or 0)), t.get("direction"),
fmt(t.get("entry_price")), fmt(t.get("exit_price")), fmt(t.get("pnl_pct")), t.get("close_reason"),
])
if not trade_rows:
trade_rows = [["-", "-", "-", "-", "-", "暂无"]]
trades_table = make_table("最近交易", ["平仓时间", "方向", "入场", "出场", "盈亏%", "原因"], trade_rows)
ref_rows = []
for r in db_query("select * from reflections order by time desc limit 6"):
ref_rows.append([
time.strftime("%H:%M:%S", time.localtime(r.get("time") or 0)), r.get("outcome"),
r.get("mistake_type"), short_text(r.get("lesson", ""), 60),
])
if not ref_rows:
ref_rows = [["-", "-", "-", "暂无"]]
reflections_table = make_table("最近复盘", ["时间", "结果", "问题类型", "经验"], ref_rows)
playbook_rows = [
["版本", playbook.get("version", 0)],
["摘要", short_text(playbook.get("summary", ""), 80)],
["Do规则", " | ".join([short_text(x, 30) for x in playbook.get("do_rules", [])[:5]]) or "-"],
["Avoid规则", " | ".join([short_text(x, 30) for x in playbook.get("avoid_rules", [])[:5]]) or "-"],
["当前参数", json.dumps(playbook.get("params", {}), ensure_ascii=False)],
]
playbook_table = make_table("当前Playbook(经验库)", ["项目", "内容"], playbook_rows)
step_rows = []
for row in store.get("step_logs", [])[:8]:
step_rows.append([
time.strftime("%H:%M:%S", time.localtime(row.get("time", 0))), row.get("step", ""),
short_text(json.dumps(row.get("data", {}), ensure_ascii=False), 90),
])
if not step_rows:
step_rows = [["-", "-", "暂无"]]
steps_table = make_table("最近步骤", ["时间", "步骤", "内容"], step_rows)
LogStatus(
header +
status_table(overview) +
status_table(indicator_table) +
status_table(signals_table) +
status_table(trades_table) +
status_table(reflections_table) +
status_table(playbook_table) +
status_table(steps_table)
)
def build_indicator_rows(snap, t, m, v, vo, pa):
price = snap.get("price")
ema_f, ema_s = t.get("ema_fast"), t.get("ema_slow")
bias = t.get("ema_bias_pct", 0)
# 趋势方向标记:快线在慢线上方且价格在快线上方 -> 多头排列
trend_tag = "多头↑" if (num(ema_f) > num(ema_s) and num(price) > num(ema_f)) else \
("空头↓" if (num(ema_f) < num(ema_s) and num(price) < num(ema_f)) else "震荡→")
hist = t.get("macd_hist")
macd_tag = "红↑" if num(hist) > 0 else "绿↓"
rsi = m.get("rsi14")
rsi_tag = rsi_state(rsi)
k, d = m.get("kdj_k"), m.get("kdj_d")
kdj_tag = "金叉↑" if num(k) > num(d) else "死叉↓"
bp = v.get("band_position")
bp_tag = band_state(bp)
chg1 = pa.get("pct_change_1bar")
chg6 = pa.get("pct_change_6bar")
return [
["趋势",
"价格", fmt_price(price),
"EMA20", fmt_price(ema_f),
"EMA60", fmt_price(ema_s)],
["趋势",
"均线状态", trend_tag,
"快慢乖离", fmt_pct(bias),
"MACD柱", "{} {}".format(fmt_price(hist), macd_tag)],
["动量",
"RSI14", "{} {}".format(fmt_num(rsi), rsi_tag),
"KDJ_K", fmt_num(k),
"KDJ状态", "{} {}".format(fmt_num(d), kdj_tag)],
["通道",
"BOLL上", fmt_price(v.get("boll_upper")),
"BOLL下", fmt_price(v.get("boll_lower")),
"带宽位置", "{} {}".format(fmt_num(bp), bp_tag)],
["波动量能",
"ATR%", fmt_pct(v.get("atr_pct")),
"量比", fmt_num(vo.get("vol_ratio_to_ma20")),
"近1/6根%", "{} / {}".format(fmt_pct(chg1), fmt_pct(chg6))],
]
def rsi_state(rsi):
r = num(rsi)
if r >= 70:
return "超买"
if r <= 30:
return "超卖"
if r >= 55:
return "偏强"
if r <= 45:
return "偏弱"
return "中性"
def band_state(bp):
b = num(bp)
if b >= 0.8:
return "近上轨"
if b <= 0.2:
return "近下轨"
if b >= 0.55:
return "中上"
if b <= 0.45:
return "中下"
return "居中"
def num(x):
try:
return float(x)
except Exception:
return 0.0
def fmt_price(x):
v = num(x)
if abs(v) >= 1000:
return "{:,.1f}".format(v)
if abs(v) >= 1:
return "{:.3f}".format(v)
return "{:.5f}".format(v)
def fmt_num(x):
if x is None:
return "-"
return "{:.2f}".format(num(x))
def fmt_pct(x):
if x is None:
return "-"
return "{:+.2f}%".format(num(x))
def make_table(title, cols, rows):
return {"type": "table", "title": title, "cols": cols, "rows": rows}
def status_table(table):
return "\n`" + json.dumps(table, ensure_ascii=False) + "`\n"
# =========================
# 工具函数
# =========================
def call_llm_json(system_prompt, user_prompt, max_tokens):
body = json.dumps({
"model": LLM_MODEL,
"temperature": 0.1,
"max_tokens": max_tokens,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
})
try:
raw = http_request(LLM_BASE_URL, "POST", {
"Content-Type": "application/json",
"Authorization": "Bearer " + LLM_API_KEY,
}, body, 40)
data = json.loads(raw)
if data.get("error"):
Log("LLM错误: " + json.dumps(data["error"], ensure_ascii=False), "#FF0000")
return None
return json.loads(clean_json_text(data["choices"][0]["message"]["content"]))
except Exception as e:
Log("LLM解析失败: " + str(e), "#FFAA00")
return None
def http_request(url, method, headers, body, timeout):
data = body.encode("utf-8") if body else None
req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
resp = urllib.request.urlopen(req, timeout=timeout)
try:
return resp.read().decode("utf-8", errors="ignore")
finally:
resp.close()
def clean_json_text(text):
text = re.sub(r"<think>[\s\S]*?</think>", "", text or "").strip()
text = re.sub(r"```json\s*|```\s*", "", text).strip()
first, last = text.find("{"), text.rfind("}")
return text[first:last + 1] if first >= 0 and last > first else text
def normalize_confidence(value):
try:
v = float(value or 0)
except Exception:
return 0
if 0 < v <= 1:
v = v * 100
return int(round(max(0, min(100, v))))
def safe_float(value, default=0.0):
try:
return float(value)
except Exception:
return default
def safe_json_loads(text, default):
try:
return json.loads(text) if text else default
except Exception:
return default
def as_list(x):
if x is None:
return []
if isinstance(x, list):
return x
return [x]
def mean(arr):
return sum(arr) / len(arr) if arr else 0.0
def std(arr):
if len(arr) < 2:
return 0.0
m = mean(arr)
return math.sqrt(sum((x - m) ** 2 for x in arr) / (len(arr) - 1))
def clamp(x, lo, hi):
return max(lo, min(hi, x))
def short_text(text, n):
text = str(text or "").replace("\n", " ").replace("\r", " ")
return text[:n] + ("..." if len(text) > n else "")
def fmt(x):
try:
return round(float(x), 4)
except Exception:
return x- 1