433 lines
16 KiB
Python
433 lines
16 KiB
Python
import os
|
||
import re
|
||
import argparse
|
||
import unicodedata
|
||
from dataclasses import dataclass, field
|
||
from typing import List, Dict, Optional
|
||
from pathlib import Path
|
||
|
||
|
||
def sanitize_path_name(name: str) -> str:
|
||
name = unicodedata.normalize('NFKD', name)
|
||
name = name.encode('ascii', 'ignore').decode('ascii')
|
||
name = re.sub(r'[^\w\-_.]', '_', name)
|
||
name = re.sub(r'_{2,}', '_', name)
|
||
return name.strip('_')
|
||
|
||
|
||
@dataclass
|
||
class SetParameter:
|
||
name: str
|
||
param_type: str
|
||
default: float
|
||
min_value: float
|
||
max_value: float
|
||
step: float
|
||
optimize: bool
|
||
|
||
|
||
@dataclass
|
||
class SetFileResult:
|
||
file_path: str
|
||
ea_name: str
|
||
parameters: Dict[str, SetParameter] = field(default_factory=dict)
|
||
optimize_count: int = 0
|
||
fixed_count: int = 0
|
||
|
||
|
||
MT5_NATIVE_PATTERN = re.compile(r'^(\w+)\s+<([^>]+)>\s+<([^>]+)>')
|
||
OUR_FORMAT_PATTERN = re.compile(r'^(\w+)=([^|]+)\|\|([^|]+)\|\|([^|]+)\|\|([^|]+)\|\|([YN])')
|
||
|
||
|
||
def detect_encoding(file_path: str) -> str:
|
||
with open(file_path, 'rb') as f:
|
||
raw = f.read(4)
|
||
if raw[:2] == b'\xff\xfe':
|
||
return 'utf-16-le'
|
||
elif raw[:2] == b'\xfe\xff':
|
||
return 'utf-16-be'
|
||
elif raw[:3] == b'\xef\xbb\xbf':
|
||
return 'utf-8-sig'
|
||
return 'utf-8'
|
||
|
||
|
||
def detect_set_format(line: str) -> str:
|
||
if '=' in line and '||' in line:
|
||
return 'our_format'
|
||
elif '<' in line and '>' in line:
|
||
return 'mt5_native'
|
||
elif line.strip().startswith(';'):
|
||
return 'comment'
|
||
elif line.strip() == '':
|
||
return 'empty'
|
||
return 'unknown'
|
||
|
||
|
||
def parse_set_line(line: str) -> Optional[SetParameter]:
|
||
line = line.strip()
|
||
if not line or line.startswith(';'):
|
||
return None
|
||
|
||
fmt = detect_set_format(line)
|
||
if fmt == 'our_format':
|
||
m = OUR_FORMAT_PATTERN.match(line)
|
||
if m:
|
||
name, cur, start, step, stop, opt = m.groups()
|
||
|
||
cur_lower = cur.lower()
|
||
if cur_lower in ('true', 'false'):
|
||
is_true = cur_lower == 'true'
|
||
return SetParameter(
|
||
name=name,
|
||
param_type='bool',
|
||
default=1.0 if is_true else 0.0,
|
||
min_value=0.0,
|
||
max_value=1.0,
|
||
step=1.0,
|
||
optimize=(opt.upper() == 'Y')
|
||
)
|
||
|
||
return SetParameter(
|
||
name=name,
|
||
param_type='double',
|
||
default=float(cur),
|
||
min_value=float(start),
|
||
max_value=float(stop),
|
||
step=float(step),
|
||
optimize=(opt.upper() == 'Y')
|
||
)
|
||
elif fmt == 'mt5_native':
|
||
m = MT5_NATIVE_PATTERN.match(line)
|
||
if m:
|
||
name, val1, val2 = m.groups()
|
||
val1l = val1.lower().strip()
|
||
val2l = val2.lower().strip()
|
||
|
||
if val1l in ('true', 'false') or val2l in ('true', 'false'):
|
||
is_true = val1l == 'true' or val2l == 'true'
|
||
return SetParameter(
|
||
name=name,
|
||
param_type='bool',
|
||
default=1.0 if is_true else 0.0,
|
||
min_value=0.0,
|
||
max_value=1.0,
|
||
step=1.0,
|
||
optimize=False
|
||
)
|
||
|
||
try:
|
||
v1 = float(val1)
|
||
v2 = float(val2)
|
||
return SetParameter(
|
||
name=name,
|
||
param_type='double',
|
||
default=v1,
|
||
min_value=min(v1, v2),
|
||
max_value=max(v1, v2),
|
||
step=abs(v2 - v1) if v1 != v2 else 0,
|
||
optimize=False
|
||
)
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def parse_set_file(file_path: str) -> SetFileResult:
|
||
result = SetFileResult(file_path=file_path, ea_name='')
|
||
|
||
path = Path(file_path)
|
||
result.ea_name = path.stem.replace('_optimization', '').replace('_params', '')
|
||
|
||
enc = detect_encoding(file_path)
|
||
with open(file_path, 'r', encoding=enc) as f:
|
||
content = f.read()
|
||
|
||
for line in content.split('\n'):
|
||
param = parse_set_line(line)
|
||
if param:
|
||
result.parameters[param.name] = param
|
||
|
||
result.optimize_count = sum(1 for p in result.parameters.values() if p.optimize)
|
||
result.fixed_count = sum(1 for p in result.parameters.values() if not p.optimize)
|
||
|
||
return result
|
||
|
||
|
||
def parse_set_content(content: str) -> SetFileResult:
|
||
result = SetFileResult(file_path='memory', ea_name='')
|
||
|
||
for line in content.split('\n'):
|
||
param = parse_set_line(line)
|
||
if param:
|
||
result.parameters[param.name] = param
|
||
|
||
result.optimize_count = sum(1 for p in result.parameters.values() if p.optimize)
|
||
result.fixed_count = sum(1 for p in result.parameters.values() if not p.optimize)
|
||
|
||
return result
|
||
|
||
|
||
def scan_folder(folder_path: str) -> List[SetFileResult]:
|
||
results = []
|
||
folder = Path(folder_path)
|
||
|
||
if folder.is_file():
|
||
return [parse_set_file(str(folder))]
|
||
|
||
for set_file in folder.rglob('*.set'):
|
||
try:
|
||
result = parse_set_file(str(set_file))
|
||
results.append(result)
|
||
except Exception as e:
|
||
print(f'Warning: Failed to parse {set_file}: {e}')
|
||
|
||
return sorted(results, key=lambda x: x.ea_name)
|
||
|
||
|
||
def batch_scan(file_paths: List[str]) -> List[SetFileResult]:
|
||
results = []
|
||
for path in file_paths:
|
||
if os.path.exists(path):
|
||
try:
|
||
result = parse_set_file(path)
|
||
results.append(result)
|
||
except Exception as e:
|
||
print(f'Warning: Failed to parse {path}: {e}')
|
||
return results
|
||
|
||
|
||
def generate_prompt(results: List[SetFileResult], output_dir: str = '.') -> List[str]:
|
||
output_files = []
|
||
|
||
for result in results:
|
||
lines = []
|
||
lines.append('=' * 80)
|
||
lines.append(f'EA 参数优化分析请求 - {result.ea_name}')
|
||
lines.append('=' * 80)
|
||
lines.append('')
|
||
lines.append('## 基本信息')
|
||
lines.append(f'- EA名称: {result.ea_name}')
|
||
lines.append(f'- SET文件: {result.file_path}')
|
||
lines.append(f'- 参数总数: {len(result.parameters)} 个')
|
||
lines.append('')
|
||
|
||
lines.append('## 参数详情表')
|
||
lines.append('')
|
||
lines.append('| 参数名 | 类型 | 当前值 | 最小值 | 最大值 | 步进 | 建议范围 |')
|
||
lines.append('|--------|------|--------|--------|--------|------|----------|')
|
||
|
||
for name, param in sorted(result.parameters.items()):
|
||
suggest = f'{param.min_value}~{param.max_value}'
|
||
if param.param_type == 'int':
|
||
suggest = f'{int(param.min_value)}~{int(param.max_value)} (步进:{int(param.step)})'
|
||
elif param.param_type == 'double':
|
||
suggest = f'{param.min_value}~{param.max_value} (步进:{param.step})'
|
||
|
||
lines.append(f'| {name} | {param.param_type} | {param.default} | {param.min_value} | {param.max_value} | {param.step} | {suggest} |')
|
||
|
||
lines.append('')
|
||
lines.append('## 参数分析任务')
|
||
lines.append('')
|
||
lines.append('请分析以上所有参数,根据策略逻辑和交易逻辑判断:')
|
||
lines.append('')
|
||
lines.append('1. **哪些参数应该参与优化(optimize: true)**')
|
||
lines.append(' - 通常是影响策略核心逻辑、盈亏比、风险的关键参数')
|
||
lines.append(' - 例如:手数、止损止盈倍数、加仓间隔、风控阈值等')
|
||
lines.append('')
|
||
lines.append('2. **哪些参数应该保持固定(optimize: false)**')
|
||
lines.append(' - 通常是风控类、开关类、显示类参数')
|
||
lines.append(' - 例如:开关标识、魔数、面板位置等')
|
||
lines.append('')
|
||
lines.append('3. **推荐优化范围调整**')
|
||
lines.append(' - 检查当前范围是否合理')
|
||
lines.append(' - 给出你认为更合适的 min/max/step')
|
||
lines.append('')
|
||
lines.append('4. **重要参数优先级**')
|
||
lines.append(' - 哪些2-3个参数对策略影响最大,需要重点优化')
|
||
lines.append('')
|
||
lines.append('## 输出要求')
|
||
lines.append('')
|
||
lines.append('请输出符合以下格式的完整JSON配置文件:')
|
||
lines.append('')
|
||
lines.append('```json')
|
||
lines.append('{')
|
||
lines.append(f' "ea_name": "{result.ea_name}",')
|
||
safe_ea_name = sanitize_path_name(result.ea_name)
|
||
lines.append(f' "ea_path": "MY-EA\\\\{safe_ea_name}.ex5", // {result.ea_name}')
|
||
lines.append(' "description": "Edited via GUI",')
|
||
lines.append(' "search_strategy": "auto",')
|
||
lines.append(' "parameters": {')
|
||
param_list = list(result.parameters.items())
|
||
for i, (name, param) in enumerate(param_list):
|
||
comma = ',' if i < len(param_list) - 1 else ''
|
||
lines.append(f' "{name}": {{')
|
||
lines.append(f' "type": "{param.param_type}",')
|
||
lines.append(f' "default": {param.default},')
|
||
lines.append(f' "min": {param.min_value},')
|
||
lines.append(f' "max": {param.max_value},')
|
||
lines.append(f' "step": {param.step},')
|
||
lines.append(f' "optimize": true // 请根据分析填写 true 或 false')
|
||
lines.append(f' }}{comma}')
|
||
lines.append(' },')
|
||
lines.append(' "test_config": {')
|
||
lines.append(' "symbol": "XAUUSD", // 请填写交易品种')
|
||
lines.append(' "period": "M1", // 请填写周期: M1/M5/M15/H1/H4/D1')
|
||
lines.append(' "from_date": "2026.01.01", // 请填写开始日期')
|
||
lines.append(' "to_date": "2026.01.04", // 请填写结束日期')
|
||
lines.append(' "model": 0, // 建模方式: 0=Every Tick, 1=1分钟OHLC, 2=仅开盘价, 3=数学计算, 4=真实Tick')
|
||
lines.append(' "execution_delay": 0, // 执行延迟: 0=无延迟, -1=随机延迟, 正数=固定延迟毫秒')
|
||
lines.append(' "optimization_mode": 2, // 优化模式: 0=禁用, 1=慢速完整算法, 2=快速遗传算法, 3=MarketWatch所有符号')
|
||
lines.append(' "optimization_criterion": 1, // 优化指标: 0=余额最大, 1=盈利因子最大, 2=期望收益, 3=回撤最小, 4=恢复因子, 5=夏普比率, 6=自定义, 7=复合指标')
|
||
lines.append(' "deposit": 10000, // 初始保证金')
|
||
lines.append(' "leverage": "1:100", // 杠杆')
|
||
lines.append(' "timeout_min": 60 // 等待优化结果的超时(分钟)')
|
||
lines.append(' },')
|
||
lines.append(' "walk_forward": {')
|
||
lines.append(' "enabled": true, // 是否启用前向测试')
|
||
lines.append(' "forward_mode": 2, // 前向模式: 0=禁用, 1=OOS 50%, 2=OOS 33%, 3=OOS 25%, 4=自定义日期')
|
||
lines.append(' "forward_date": "" // 仅当 forward_mode=4 时必填, 格式 YYYY.MM.DD')
|
||
lines.append(' }')
|
||
lines.append('}')
|
||
lines.append('```')
|
||
lines.append('')
|
||
lines.append('注意:')
|
||
lines.append(f'- EA原始名称: {result.ea_name}')
|
||
lines.append(f'- ea_path 使用ASCII安全名称: {safe_ea_name}')
|
||
lines.append('- 实际EA文件应放在 MY-EA 目录下,文件名需与 ea_path 中的名称一致')
|
||
lines.append('- 每个参数的 "optimize" 字段需要你根据策略分析来填写')
|
||
lines.append('- optimize: true = 参与优化, optimize: false = 保持固定')
|
||
lines.append('- test_config 和 walk_forward 部分请根据回测需求填写或调整')
|
||
lines.append('- ea_path 路径使用双反斜杠 `\\\\`')
|
||
lines.append('')
|
||
lines.append('=' * 80)
|
||
|
||
output_path = os.path.join(output_dir, f'{result.ea_name}_optimization_prompt.txt')
|
||
with open(output_path, 'w', encoding='utf-8') as f:
|
||
f.write('\n'.join(lines))
|
||
|
||
output_files.append(output_path)
|
||
print(f'Generated: {output_path}')
|
||
|
||
return output_files
|
||
|
||
|
||
def interactive_scan():
|
||
print('请选择扫描模式:')
|
||
print('1. 扫描单个 SET 文件')
|
||
print('2. 扫描文件夹下所有 SET 文件')
|
||
print('3. 批量扫描多个 SET 文件')
|
||
print('4. 从剪贴板读取 SET 内容')
|
||
print('0. 退出')
|
||
print('')
|
||
|
||
choice = input('请输入选项 (0-4): ').strip()
|
||
|
||
if choice == '1':
|
||
path = input('请输入 SET 文件路径: ').strip()
|
||
if os.path.exists(path):
|
||
results = [parse_set_file(path)]
|
||
output_dir = os.path.dirname(path) or '.'
|
||
generate_prompt(results, output_dir)
|
||
else:
|
||
print('文件不存在')
|
||
|
||
elif choice == '2':
|
||
path = input('请输入文件夹路径: ').strip()
|
||
if os.path.exists(path):
|
||
results = scan_folder(path)
|
||
generate_prompt(results, path)
|
||
else:
|
||
print('文件夹不存在')
|
||
|
||
elif choice == '3':
|
||
print('请输入 SET 文件路径(每行一个,输入空行结束):')
|
||
paths = []
|
||
while True:
|
||
line = input().strip()
|
||
if not line:
|
||
break
|
||
if os.path.exists(line):
|
||
paths.append(line)
|
||
else:
|
||
print(f'文件不存在: {line}')
|
||
if paths:
|
||
results = batch_scan(paths)
|
||
generate_prompt(results, os.path.dirname(paths[0]) if len(paths) == 1 else '.')
|
||
|
||
elif choice == '4':
|
||
print('请粘贴 SET 文件内容(输入空行结束):')
|
||
lines = []
|
||
while True:
|
||
line = input()
|
||
if not line.strip():
|
||
break
|
||
lines.append(line)
|
||
if lines:
|
||
content = '\n'.join(lines)
|
||
result = parse_set_content(content)
|
||
result.ea_name = input('请输入 EA 名称: ').strip() or 'EA'
|
||
generate_prompt([result], '.')
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description='SET 文件扫描工具 - 生成 AI 优化提示词',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog='''
|
||
示例:
|
||
python scan_set_prompt.py --file C:\\path\\to\\EA.set
|
||
python scan_set_prompt.py --folder C:\\path\\to\\set_files
|
||
python scan_set_prompt.py --batch a.set b.set c.set
|
||
python scan_set_prompt.py --interactive
|
||
'''
|
||
)
|
||
|
||
group = parser.add_mutually_exclusive_group(required=True)
|
||
group.add_argument('--file', '-f', metavar='PATH', help='扫描单个 SET 文件')
|
||
group.add_argument('--folder', '-d', metavar='PATH', help='扫描文件夹下所有 SET 文件')
|
||
group.add_argument('--batch', '-b', nargs='+', metavar='PATH', help='批量扫描多个 SET 文件')
|
||
group.add_argument('--interactive', '-i', action='store_true', help='交互式扫描')
|
||
|
||
parser.add_argument('--output', '-o', metavar='DIR', default='.', help='输出目录 (默认当前目录)')
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.interactive:
|
||
interactive_scan()
|
||
return
|
||
|
||
results = []
|
||
|
||
if args.file:
|
||
if not os.path.exists(args.file):
|
||
print(f'Error: 文件不存在 {args.file}')
|
||
return
|
||
results = [parse_set_file(args.file)]
|
||
output_dir = os.path.dirname(args.file) or args.output
|
||
|
||
elif args.folder:
|
||
if not os.path.exists(args.folder):
|
||
print(f'Error: 文件夹不存在 {args.folder}')
|
||
return
|
||
results = scan_folder(args.folder)
|
||
output_dir = args.folder
|
||
|
||
elif args.batch:
|
||
existing = [p for p in args.batch if os.path.exists(p)]
|
||
if not existing:
|
||
print('Error: 所有文件都不存在')
|
||
return
|
||
results = batch_scan(existing)
|
||
output_dir = args.output
|
||
|
||
if not results:
|
||
print('Warning: 没有找到任何 SET 文件')
|
||
return
|
||
|
||
output_files = generate_prompt(results, output_dir)
|
||
print(f'\n完成!共生成 {len(output_files)} 个提示词文件')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|