Initial commit: Hermes Agent skills collection

- Trading skills (OKX, dividend, lottery, quantitative)
- Creative skills (ASCII art, diagrams, video)
- Development skills (GitHub, debugging, TDD)
- Research skills (arXiv, blog monitoring)
- Productivity skills (email, documents, notes)
- MCP integration skills
- Custom user skills
This commit is contained in:
Hermes Skills Manager
2026-07-05 02:31:15 -04:00
commit 6770bc9b9d
908 changed files with 239614 additions and 0 deletions
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Telegram Callback Query Handler
监听inline keyboard按钮点击,执行交易确认/取消
"""
import os
import re
import sys
import json
import time
import requests
import subprocess
def _load_env():
env_path = os.path.expanduser("~/.hermes/.env")
with open(env_path) as f:
for line in f:
m = re.match(r'TELEGRAM_BOT_TOKEN=(.*)', line.strip())
if m:
return m.group(1).strip()
return ''
BOT_TOKEN = _load_env()
PROXY = 'http://127.0.0.1:7890'
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
NOTIFIER = os.path.join(SCRIPT_DIR, "trade_notifier.py")
LAST_UPDATE_FILE = os.path.expanduser("~/.hermes/trading/last_update_id")
os.makedirs(os.path.dirname(LAST_UPDATE_FILE), exist_ok=True)
def get_updates(offset=None, timeout=30):
"""Long-poll for updates"""
url = f"https://api.telegram.org/bot{BOT_TOKEN}/getUpdates"
params = {"timeout": timeout, "allowed_updates": '["callback_query"]'}
if offset:
params["offset"] = offset
resp = requests.get(url, params=params, proxies={"https": PROXY, "http": PROXY}, timeout=timeout+10)
return resp.json()
def load_last_update_id():
"""Load last processed update ID"""
try:
with open(LAST_UPDATE_FILE) as f:
return int(f.read().strip())
except:
return None
def save_last_update_id(update_id):
"""Save last processed update ID"""
with open(LAST_UPDATE_FILE, "w") as f:
f.write(str(update_id))
def handle_callback_query(update):
"""Process a callback query"""
cb = update.get("callback_query", {})
if not cb:
return
callback_data = cb.get("data", "")
callback_query_id = cb.get("id", "")
message = cb.get("message", {})
chat_id = str(message.get("chat", {}).get("id", ""))
message_id = message.get("message_id", 0)
print(f"[{time.strftime('%H:%M:%S')}] Callback: {callback_data} from chat {chat_id}")
# Call trade_notifier.py callback handler
result = subprocess.run(
["python3", NOTIFIER, "callback", callback_data, str(chat_id), str(message_id), callback_query_id],
capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" Error: {result.stderr[:200]}")
else:
print(f" Result: {result.stdout[:200]}")
def main():
print("🔄 Callback handler started, waiting for button clicks...")
last_id = load_last_update_id()
while True:
try:
result = get_updates(offset=(last_id + 1) if last_id else None, timeout=30)
if not result.get("ok"):
print(f"API error: {result}")
time.sleep(5)
continue
updates = result.get("result", [])
for update in updates:
update_id = update.get("update_id", 0)
if update.get("callback_query"):
handle_callback_query(update)
last_id = update_id
save_last_update_id(last_id)
except requests.exceptions.Timeout:
continue
except KeyboardInterrupt:
print("\n🛑 Stopped")
break
except Exception as e:
print(f"Error: {e}")
time.sleep(5)
if __name__ == "__main__":
main()
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
Trading config loader
从 config.json 读取所有交易参数,消除硬编码
"""
import json, os
CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.json')
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
# Singleton
_config = None
def get_config():
global _config
if _config is None:
_config = load_config()
return _config
def get(section, key, default=None):
"""获取配置值: get('atr', 'multiplier')"""
cfg = get_config()
return cfg.get(section, {}).get(key, default)
@@ -0,0 +1,280 @@
"""
性价比检查模块
供 okx_position_advisor.py 调用
"""
import os, sys
sys.path.insert(0, os.path.dirname(__file__))
from config_loader import get as cfg
def get_okx_fee_rate(inst_type='SWAP'):
"""
从OKX API获取实际费率
返回: (maker_rate, taker_rate) 正数表示收费,负数表示返佣
"""
import requests, hmac, hashlib, base64, time, os, re
# 读取凭证
creds = {}
with open(os.path.expanduser("~/.bashrc")) as f:
for line in f:
m = re.match(r'export\s+(OKX_\w+)=(.*)', line.strip())
if m:
val = m.group(2).strip().strip('"').strip("'")
creds[m.group(1)] = val
api_key = creds.get('OKX_API_KEY', '')
secret = creds.get('OKX_SECRET', '')
passphrase = creds.get('OKX_PASSPHRASE', '')
proxies = {"http": "http://127.0.0.1:7890", "https": "http://127.0.0.1:7890"}
ts = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
path = f"/api/v5/account/trade-fee?instType={inst_type}"
msg = f"{ts}GET{path}"
sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).digest()
sig_b64 = base64.b64encode(sig).decode()
headers = {
"OK-ACCESS-KEY": api_key,
"OK-ACCESS-SIGN": sig_b64,
"OK-ACCESS-TIMESTAMP": ts,
"OK-ACCESS-PASSPHRASE": passphrase,
}
try:
r = requests.get(f"https://www.okx.com{path}", headers=headers, proxies=proxies, timeout=15)
data = r.json()
if data['code'] == '0' and data['data']:
maker = float(data['data'][0]['maker'])
taker = float(data['data'][0]['taker'])
return maker, taker
except Exception as e:
pass
# 默认费率 (fallback)
return 0.0002, 0.0005
def calc_cost_performance(entry_price, sl_price, tp_price, contracts, ct_val, leverage, fee_rate=None):
"""
计算开仓性价比
参数:
entry_price: 入场价
sl_price: 止损价
tp_price: 止盈价
contracts: 合约张数
ct_val: 合约面值 (如ETH=0.1)
leverage: 杠杆倍数
fee_rate: 单边手续费率 (默认从OKX API获取)
返回:
dict: {
'rr_ratio': 盈亏比,
'tp_distance': TP距离,
'sl_distance': SL距离,
'profit_amount': 盈利金额(USDT),
'loss_amount': 亏损金额(USDT),
'fee_cost': 手续费(USDT),
'fee_pct': 手续费占盈利百分比,
'rating': 'high'/'medium'/'low',
'rating_emoji': ''/'⚠️'/'',
'rating_text': '性价比高'/'性价比一般'/'性价比低',
'auto_execute': True/False,
'reason': 原因说明
}
"""
# 如果没有传入费率,从OKX API获取
if fee_rate is None:
maker_rate, taker_rate = get_okx_fee_rate()
# 用taker费率(市价单)- 可能是负数(返佣)
fee_rate = taker_rate
if fee_rate is None:
fee_rate = cfg('cost_performance', 'fee_rate', 0.0005)
# 计算距离
tp_distance = abs(tp_price - entry_price)
sl_distance = abs(sl_price - entry_price)
# 防止除零
if sl_distance == 0:
return {
'rr_ratio': 0,
'tp_distance': tp_distance,
'sl_distance': sl_distance,
'profit_amount': 0,
'loss_amount': 0,
'fee_cost': 0,
'fee_pct': 100,
'rating': 'low',
'rating_emoji': '',
'rating_text': '性价比低',
'auto_execute': False,
'reason': '止损距离为0'
}
# 盈亏比
rr_ratio = tp_distance / sl_distance
# 盈亏金额
position_size = contracts * ct_val
profit_amount = tp_distance * position_size
loss_amount = sl_distance * position_size
# 手续费 (开+平, 含杠杆)
# 注意:fee_rate可能是负数(返佣),此时fee_cost也是负数(即赚手续费)
notional_value = entry_price * position_size
fee_cost = notional_value * fee_rate * 2 # 手续费基于名义价值,不乘杠杆
# 手续费占盈利百分比(返佣时为负数,表示额外收益)
if profit_amount > 0:
fee_pct = (fee_cost / profit_amount * 100)
else:
fee_pct = 100 if fee_cost >= 0 else -100
# 性价比评级
# 注意:返佣时fee_pct为负数,表示额外收益,应该提高评级
reasons = []
# 计算净盈利(盈利 + 返佣 或 盈利 - 手续费)
net_profit = profit_amount + fee_cost # fee_cost为负时是返佣,为正时是收费
rr_high = cfg('cost_performance', 'rr_high', 2.0)
rr_medium = cfg('cost_performance', 'rr_medium', 1.5)
fee_high = cfg('cost_performance', 'fee_high_pct', 10)
fee_medium = cfg('cost_performance', 'fee_medium_pct', 5)
min_profit = cfg('position_sizing', 'min_profit_usdt', 10)
if rr_ratio >= rr_high and net_profit >= min_profit:
# 盈亏比达标 且 净盈利达标
if fee_pct < 0: # 返佣
rating = 'high'
rating_emoji = ''
rating_text = '性价比高'
auto_execute = True
elif fee_pct < fee_medium: # 低费率
rating = 'high'
rating_emoji = ''
rating_text = '性价比高'
auto_execute = True
else: # 高费率
rating = 'medium'
rating_emoji = '⚠️'
rating_text = '性价比一般'
auto_execute = False
elif rr_ratio >= rr_medium and net_profit >= min_profit:
rating = 'medium'
rating_emoji = '⚠️'
rating_text = '性价比一般'
auto_execute = False
else:
rating = 'low'
rating_emoji = ''
rating_text = '性价比低'
auto_execute = False
# 具体原因
if rr_ratio < rr_medium:
reasons.append(f'盈亏比{rr_ratio:.1f}:1<1.5:1')
elif rr_ratio < rr_high:
reasons.append(f'盈亏比{rr_ratio:.1f}:1偏低')
if fee_pct > fee_high:
reasons.append(f'手续费占比{fee_pct:.0f}%过高')
elif fee_pct > fee_medium:
reasons.append(f'手续费占比{fee_pct:.0f}%偏高')
elif fee_pct < 0:
reasons.append(f'返佣{abs(fee_pct):.0f}%')
if net_profit < min_profit:
reasons.append(f'净盈利{net_profit:.1f}USDT<5USDT')
reason = '; '.join(reasons) if reasons else ('盈亏比≥2:1, 手续费合理, 盈利达标' if rating == 'high' else '')
return {
'rr_ratio': round(rr_ratio, 2),
'tp_distance': round(tp_distance, 4),
'sl_distance': round(sl_distance, 4),
'profit_amount': round(profit_amount, 2),
'loss_amount': round(loss_amount, 2),
'fee_cost': round(fee_cost, 2),
'fee_pct': round(fee_pct, 2),
'net_profit': round(net_profit, 2), # 新增:净盈利
'rating': rating,
'rating_emoji': rating_emoji,
'rating_text': rating_text,
'auto_execute': auto_execute,
'reason': reason
}
def calc_min_contracts_for_profit(tp_distance, ct_val, min_profit=None):
if min_profit is None:
min_profit = cfg('position_sizing', 'min_profit_usdt', 10)
"""
计算达到最小盈利所需的合约张数
参数:
tp_distance: TP距离
ct_val: 合约面值
min_profit: 最小盈利额 (默认10USDT)
返回:
int: 需要的合约张数 (向上取整)
"""
if tp_distance <= 0 or ct_val <= 0:
return 0
# 盈利 = tp_distance * ct_val * contracts
# contracts = min_profit / (tp_distance * ct_val)
raw_contracts = min_profit / (tp_distance * ct_val)
# 向上取整到lot_sz (这里先取整,外面再处理)
import math
return math.ceil(raw_contracts)
# 测试
if __name__ == '__main__':
# 测试案例1: 性价比高
check1 = calc_cost_performance(
entry_price=1700,
sl_price=1666,
tp_price=1775,
contracts=6,
ct_val=0.1,
leverage=25
)
print("测试1 - ETH做多 (性价比高):")
print(f" 盈亏比: {check1['rr_ratio']}:1")
print(f" 盈利: {check1['profit_amount']} USDT")
print(f" 手续费: {check1['fee_cost']} USDT ({check1['fee_pct']}%)")
print(f" 评级: {check1['rating_text']}")
print(f" 自动开仓: {check1['auto_execute']}")
print()
# 测试案例2: 性价比低 (盈利<5USDT)
check2 = calc_cost_performance(
entry_price=67.21,
sl_price=70.57,
tp_price=63.85,
contracts=1,
ct_val=0.1,
leverage=10
)
print("测试2 - HYPE做空 (盈利<5USDT):")
print(f" 盈亏比: {check2['rr_ratio']}:1")
print(f" 盈利: {check2['profit_amount']} USDT")
print(f" 手续费: {check2['fee_cost']} USDT ({check2['fee_pct']}%)")
print(f" 评级: {check2['rating_text']}")
print(f" 原因: {check2['reason']}")
print(f" 自动开仓: {check2['auto_execute']}")
print()
# 测试案例3: 计算最小张数
min_contracts = calc_min_contracts_for_profit(
tp_distance=3.36,
ct_val=0.1,
min_profit=10
)
print(f"测试3 - HYPE最小张数: {min_contracts}张 (盈利={3.36*0.1*min_contracts:.1f}USDT)")
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
修正跟单方案金额。
用法: echo "原始信号文本" | python3 fix_recommendation.py
或: python3 fix_recommendation.py "原始信号文本"
从信号文本提取币种/方向/杠杆,调advisor脚本获取正确金额,替换原消息中的跟单方案部分。
"""
import sys
import re
import json
import subprocess
from pathlib import Path
ADVISOR = Path.home() / ".hermes/skills/trading/okx-auto-position/scripts/okx_position_advisor.py"
def extract_from_signal(text):
"""从信号文本提取关键字段"""
fields = {}
# 币种
m = re.search(r'跟单建议\s*\|\s*(\w+)', text)
if m:
fields['symbol'] = m.group(1)
# 方向
if '做多' in text:
fields['side'] = 'long'
elif '做空' in text:
fields['side'] = 'short'
# 杠杆
m = re.search(r'(\d+)x', text)
if m:
fields['leverage'] = m.group(1)
# 交易员
m = re.search(r'📊\s*(\S+)', text)
if m:
fields['trader'] = m.group(1)
# 交易员仓位
m = re.search(r'📊\s*\S+\s+([\d,.]+\s*\w+)', text)
if m:
fields['trader_pos'] = m.group(1)
# 交易员价值
m = re.search(r'(价值\$?([\d,.]+)', text)
if m:
fields['trader_value'] = m.group(1)
# 入场价
m = re.search(r'入场:\s*\$?([\d,.]+)', text)
if m:
fields['entry'] = m.group(1).replace(',', '')
# 浮盈
m = re.search(r'浮[盈亏]:\s*([+-]?\$?[\d,.]+)', text)
if m:
fields['pnl'] = m.group(1).replace('$', '').replace(',', '')
return fields
def run_advisor(symbol, side, leverage):
"""调advisor脚本获取正确数据"""
# Don't add /USDT - advisor handles symbol format internally
cmd = ['python3', str(ADVISOR), '--symbol', symbol, '--side', side, '--leverage', str(leverage), '--json']
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=str(ADVISOR.parent))
if result.returncode == 0:
return json.loads(result.stdout)
except Exception as e:
return {'error': str(e)}
return {'error': 'advisor failed'}
def rebuild_message(original, fields, rec):
"""用正确数据重建消息"""
if 'error' in rec:
return f"⚠️ advisor错误: {rec['error']}\n\n{original}"
symbol = fields.get('symbol', '?')
side_cn = '做多' if fields.get('side') == 'long' else '做空'
emoji = '🟩' if fields.get('side') == 'long' else '🟥'
leverage = fields.get('leverage', '10')
trader = fields.get('trader', '?')
trader_pos = fields.get('trader_pos', '?')
trader_value = fields.get('trader_value', '?')
entry = fields.get('entry', '?')
pnl = fields.get('pnl', '0')
# 性价比
cc = rec.get('cost_check', {})
rr = cc.get('rr_ratio', rec.get('rr', 0))
profit = cc.get('profit_amount', rec.get('tp_pnl', 0))
fee = cc.get('fee_cost', 0)
fee_pct = cc.get('fee_pct', 0)
net = cc.get('net_profit', 0)
rating_emoji = cc.get('rating_emoji', '⚠️')
rating_text = cc.get('rating_text', '未知')
pnl_float = float(pnl) if pnl else 0
pnl_emoji = '🔥' if pnl_float > 0 else '🔴'
pnl_sign = '+' if pnl_float > 0 else ''
# 提取原始消息的趋势分析和ATR部分
trend_match = re.search(r'(📈 趋势分析.*?)(?=🛡️)', original, re.DOTALL)
trend_block = trend_match.group(1).strip() if trend_match else "📈 趋势分析\n• 数据加载中"
atr_match = re.search(r'(🛡️ ATR检查.*?)(?=📐|🎯|回复)', original, re.DOTALL)
atr_block = atr_match.group(1).strip() if atr_match else "🛡️ ATR检查\n• 数据加载中"
msg = f"""⚡ 跟单建议 | {symbol} {side_cn} {emoji} {leverage}x
📊 {trader} {trader_pos}(价值${trader_value})← 信号源,非你的仓位
入场: ${entry} | 当前: ${rec['price']}
浮盈: {pnl_sign}{pnl_float:.0f} {pnl_emoji} | 强平距: ${rec.get('liq_price', '?')}
{trend_block}
{atr_block}
📐 性价比检查(基于你的推荐仓位)
• 你的仓位: {rec['contracts']}张(保证金{rec['margin']:.2f} USDT
• 盈亏比: {rr}:1 {'' if rr >= 2 else '⚠️' if rr >= 1.5 else ''}
• 盈利额: +{profit:.2f} USDT {'' if profit >= 10 else '❌ <10U保底'}
• 手续费: {fee:.2f} USDT ({fee_pct:.1f}%) {'' if fee_pct < 5 else ''}
• 净盈利: {net:.2f} USDT {'' if net >= 10 else ''}
• 评级: {rating_emoji} {rating_text}
🎯 跟单方案(基于你的账户数据)
• 入场: ${rec['price']}(市价)
• 止损: ${rec['sl_price']}-{rec['sl_pct']:.1f}%-{rec['sl_pnl']:.2f} USDT
• 止盈: ${rec['tp_price']}+{rec['tp_pct']:.1f}%+{rec['tp_pnl']:.2f} USDT
• 仓位: {rec['contracts']}张(保证金{rec['margin']:.2f} USDT
• 强平: ${rec.get('liq_price', '?')}
回复 Y 确认跟单 / N 取消"""
return msg
def main():
# Get input
if len(sys.argv) > 1:
text = ' '.join(sys.argv[1:])
else:
text = sys.stdin.read()
if not text.strip():
print("用法: python3 fix_recommendation.py '信号文本'")
return
# Extract fields
fields = extract_from_signal(text)
if not fields.get('symbol') or not fields.get('side'):
print("⚠️ 无法解析信号文本")
print(text)
return
# Run advisor
leverage = fields.get('leverage', '10')
rec = run_advisor(fields['symbol'], fields['side'], leverage)
# Rebuild message
result = rebuild_message(text, fields, rec)
print(result)
if __name__ == '__main__':
main()
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
格式化交易信号推送消息。
用法: python3 format_signal.py --symbol HYPE --side long --leverage 10 --trader "麻吉大哥" --trader-pos "3,900 HYPE" --trader-value "$275,703" --trader-entry 71.1826 --trader-pnl -1910 --signal-type A
输出: 完整的含📐性价比区块的推送消息(可直接push_to_qq.sh
"""
import argparse
import json
import sys
from pathlib import Path
# Add parent to path
sys.path.insert(0, str(Path(__file__).parent))
def main():
parser = argparse.ArgumentParser(description='格式化交易信号推送消息')
parser.add_argument('--symbol', required=True, help='币种 (如 HYPE)')
parser.add_argument('--side', required=True, help='方向 (long/short)')
parser.add_argument('--leverage', type=int, default=10, help='杠杆')
parser.add_argument('--trader', required=True, help='交易员名称')
parser.add_argument('--trader-pos', required=True, help='交易员仓位 (如 "3,900 HYPE")')
parser.add_argument('--trader-value', required=True, help='交易员仓位价值 (如 "$275,703")')
parser.add_argument('--trader-entry', type=float, required=True, help='交易员入场价')
parser.add_argument('--trader-pnl', type=float, default=0, help='交易员浮盈(负=浮亏)')
parser.add_argument('--signal-type', default='A', help='信号类型 (A加仓/B减仓/C新开仓)')
parser.add_argument('--json', action='store_true', help='输出JSON而非格式化文本')
args = parser.parse_args()
# Import and run advisor
from okx_position_advisor import load_credentials, create_exchange, get_account_info, recommend_position, format_recommendation
creds = load_credentials()
exchange = create_exchange(creds)
acct_info = get_account_info(exchange)
symbol = args.symbol
if '/' not in symbol:
symbol = f"{symbol}/USDT"
try:
rec = recommend_position(symbol, args.side, args.leverage, exchange, acct_info)
except ZeroDivisionError:
print(f"⚠️ 余额不足(可用0 USDT),无法开仓 {args.symbol}")
sys.exit(0)
except Exception as e:
print(f"❌ 错误: {e}", file=sys.stderr)
sys.exit(1)
if 'error' in rec:
print(f"❌ 错误: {rec['error']}", file=sys.stderr)
sys.exit(1)
# Handle zero balance gracefully
if rec.get('contracts', 0) == 0:
print(f"⚠️ 余额不足,无法开仓 {args.symbol}")
sys.exit(0)
# Format output
side_cn = '做多' if args.side == 'long' else '做空'
emoji = '🟩' if args.side == 'long' else '🟥'
signal_label = {'A': 'A类加仓', 'B': 'B类减仓', 'C': 'C类新开仓'}.get(args.signal_type, args.signal_type)
pnl_emoji = '🔥' if args.trader_pnl > 0 else '🔴'
pnl_sign = '+' if args.trader_pnl > 0 else ''
# Cost check from advisor
cc = rec.get('cost_check', {})
rr = cc.get('rr_ratio', rec.get('rr', 0))
profit = cc.get('profit_amount', rec.get('tp_pnl', 0))
fee = cc.get('fee_cost', 0)
fee_pct = cc.get('fee_pct', 0)
net = cc.get('net_profit', 0)
rating_emoji = cc.get('rating_emoji', '⚠️')
rating_text = cc.get('rating_text', '未知')
msg = f"""⚡ 跟单建议 | {args.symbol} {side_cn} {emoji} {args.leverage}x{signal_label}
📊 {args.trader} {args.trader_pos}(价值{args.trader_value})← 信号源,非你的仓位
入场: ${args.trader_entry} | 当前: ${rec['price']}
浮盈: {pnl_sign}{args.trader_pnl:.0f} {pnl_emoji} | 强平距: ${rec.get('liq_price', '?')}
📐 性价比检查(基于你的推荐仓位)
• 你的仓位: {rec['contracts']}张(保证金{rec['margin']:.2f} USDT
• 盈亏比: {rr}:1 {'' if rr >= 2 else '⚠️' if rr >= 1.5 else ''}
• 盈利额: +{profit:.2f} USDT {'' if profit >= 10 else '❌ <10U保底'}
• 手续费: {fee:.2f} USDT ({fee_pct:.1f}%) {'' if fee_pct < 5 else ''}
• 净盈利: {net:.2f} USDT {'' if net >= 10 else ''}
• 评级: {rating_emoji} {rating_text}
🎯 跟单方案(基于你的账户数据)
• 入场: ${rec['price']}(市价)
• 止损: ${rec['sl_price']}-{rec['sl_pct']:.1f}%-{rec['sl_pnl']:.2f} USDT
• 止盈: ${rec['tp_price']}+{rec['tp_pct']:.1f}%+{rec['tp_pnl']:.2f} USDT
• 仓位: {rec['contracts']}张(保证金{rec['margin']:.2f} USDT
• 强平: ${rec.get('liq_price', '?')}
回复 Y 确认跟单 / N 取消"""
if args.json:
print(json.dumps({'message': msg, 'recommendation': rec}, ensure_ascii=False, indent=2))
else:
print(msg)
if __name__ == '__main__':
main()
@@ -0,0 +1,791 @@
#!/usr/bin/env python3
"""
OKX Auto Position Advisor
根据余额自动推荐开仓数量+止盈止损位
Usage:
python3 okx_position_advisor.py --symbol ETH --side short --leverage 10
python3 okx_position_advisor.py --symbol BTC --side long --leverage 5
python3 okx_position_advisor.py --symbol ETH --side short # 默认10x
"""
import re
import os
import sys
import json
import argparse
import ccxt
import math
# Import cost performance module
sys.path.insert(0, os.path.dirname(__file__))
from cost_performance import calc_cost_performance, calc_min_contracts_for_profit
from config_loader import get as cfg
def load_credentials():
"""Load OKX credentials from ~/.bashrc"""
creds = {}
with open(os.path.expanduser("~/.bashrc")) as f:
for line in f:
m = re.match(r'export\s+(OKX_\w+)=(.*)', line.strip())
if m:
val = m.group(2).strip()
if val.startswith('"') and val.endswith('"'):
val = val[1:-1]
elif val.startswith("'") and val.endswith("'"):
val = val[1:-1]
creds[m.group(1)] = val
return creds
def create_exchange(creds):
"""Create ccxt OKX exchange instance with proxy"""
return ccxt.okx({
'apiKey': creds['OKX_API_KEY'],
'secret': creds['OKX_SECRET'],
'password': creds['OKX_PASSPHRASE'],
'proxies': {
'http': 'http://127.0.0.1:7890',
'https': 'http://127.0.0.1:7890',
},
'options': {'defaultType': 'swap'},
})
def get_account_info(exchange):
"""Get account balance and positions"""
balance = exchange.fetch_balance()
usdt_free = float(balance.get('USDT', {}).get('free', 0))
usdt_total = float(balance.get('USDT', {}).get('total', 0))
positions = exchange.fetch_positions()
active = []
for p in positions:
if float(p.get('contracts', 0)) > 0:
active.append({
'symbol': p['symbol'],
'side': p['side'],
'contracts': float(p['contracts']),
'entry': float(p['entryPrice']) if p.get('entryPrice') else 0,
'pnl': float(p.get('unrealizedPnl', 0)),
'liq': float(p.get('liquidationPrice', 0)) if p.get('liquidationPrice') else 0,
})
return {
'usdt_free': usdt_free,
'usdt_total': usdt_total,
'positions': active,
}
def get_instrument(exchange, inst_id):
"""Get contract specifications"""
inst = exchange.public_get_public_instruments({
'instType': 'SWAP',
'instId': inst_id,
})
spec = inst['data'][0]
return {
'ct_val': float(spec['ctVal']), # contract value in base currency
'min_sz': float(spec['minSz']), # minimum order size
'lot_sz': float(spec['lotSz']), # order step size
'ct_mult': float(spec.get('ctMult', 1)),
'inst_id': inst_id,
}
def calc_atr(exchange, symbol, timeframe='4h', periods=30):
"""Calculate Average True Range"""
try:
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=periods)
if len(ohlcv) < 5:
return None
true_ranges = []
for i in range(1, len(ohlcv)):
high = ohlcv[i][2]
low = ohlcv[i][3]
prev_close = ohlcv[i - 1][4]
tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
true_ranges.append(tr)
return sum(true_ranges) / len(true_ranges)
except Exception:
return None
def calc_multi_atr(exchange, symbol):
"""多周期ATR融合: 1H×0.5 + 4H×0.3 + 1D×0.2 × 1.5
比单用4H ATR更灵敏——1H应对短期波动,4H做主心骨,1D兜底。
"""
try:
atr_1h = calc_atr(exchange, symbol, '1h', 24)
atr_4h = calc_atr(exchange, symbol, '4h', 30)
atr_1d = calc_atr(exchange, symbol, '1d', 14)
values = [v for v in [atr_1h, atr_4h, atr_1d] if v is not None]
if not values:
return None, None, None, None
if atr_1h is not None and atr_4h is not None and atr_1d is not None:
fused = (atr_1h * cfg('atr','weight_1h',0.5) + atr_4h * cfg('atr','weight_4h',0.3) + atr_1d * cfg('atr','weight_1d',0.2)) * cfg('atr','multiplier',1.5)
elif atr_4h is not None:
fused = atr_4h * cfg('atr','multiplier',1.5)
else:
fused = sum(values) / len(values) * cfg('atr','multiplier',1.5)
return fused, atr_1h, atr_4h, atr_1d
except Exception:
return None, None, None, None
def estimate_trend_strength(exchange, symbol):
"""通过EMA12-EMA26斜率估算趋势强度
Returns: ('strong_up'|'strong_down'|'ranging'|'weak_trend', slope_pct)
"""
try:
ohlcv = exchange.fetch_ohlcv(symbol, '4h', limit=30)
closes = [c[4] for c in ohlcv[-26:]]
if len(closes) < 14:
return 'weak_trend', 0
ema12 = sum(closes[-12:]) / 12
ema26 = sum(closes) / 26
slope = (ema12 - ema26) / ema26 * 100
if slope > 0.5: return 'strong_up', round(slope, 2)
if slope < -0.5: return 'strong_down', round(slope, 2)
if abs(slope) < 0.1: return 'ranging', round(slope, 2)
return 'weak_trend', round(slope, 2)
except Exception:
return 'weak_trend', 0
def _rr_by_trend():
return cfg('rr_by_trend', 'strong_up', 3.0), cfg('rr_by_trend', 'strong_down', 3.0), cfg('rr_by_trend', 'weak_trend', 2.0), cfg('rr_by_trend', 'ranging', 1.5)
RR_BY_TREND = {
'strong_up': cfg('rr_by_trend', 'strong_up', 3.0),
'strong_down': cfg('rr_by_trend', 'strong_down', 3.0),
'weak_trend': cfg('rr_by_trend', 'weak_trend', 2.0),
'ranging': cfg('rr_by_trend', 'ranging', 1.5),
}
TREND_LABEL = {
'strong_up': '强上升趋势',
'strong_down': '强下降趋势',
'weak_trend': '弱趋势',
'ranging': '震荡',
}
def recommend_position(symbol, side, leverage, exchange, acct_info):
"""Calculate recommended position size, TP, SL"""
# Get current price
ticker = exchange.fetch_ticker(symbol)
price = ticker['last']
# Get instrument specs
inst_id = symbol.replace('/', '-').replace(':USDT', '-SWAP').replace(':USD', '-SWAP')
# Handle common formats: ETH/USDT:USDT -> ETH-USDT-SWAP
parts = symbol.split('/')
base = parts[0]
inst_id = f"{base}-USDT-SWAP"
spec = get_instrument(exchange, inst_id)
ct_val = spec['ct_val']
min_sz = spec['min_sz']
lot_sz = spec['lot_sz']
# Cap leverage for safety
max_lev = cfg('position_sizing', 'max_leverage', 20)
if leverage > max_lev:
leverage = max_lev
if leverage < 1:
leverage = 1
# Position sizing: use 45% of available balance
avail_margin = acct_info['usdt_free'] * cfg('position_sizing', 'balance_utilization', 0.45)
margin_per_contract = ct_val * price / leverage
if margin_per_contract <= 0:
return {'error': 'Invalid margin calculation'}
raw_contracts = avail_margin / margin_per_contract
# Round down to lot_sz
contracts = int(raw_contracts / lot_sz) * lot_sz
contracts = max(contracts, min_sz)
if contracts < min_sz:
return {
'error': f'余额不足: 需要至少 {margin_per_contract * min_sz:.2f} USDT, 可用 {acct_info["usdt_free"]:.2f} USDT'
}
# Calculate A+E+D multi-timeframe ATR fusion (方案A)
fused_atr, atr_1h, atr_4h, atr_1d = calc_multi_atr(exchange, symbol)
if fused_atr and fused_atr > 0:
sl_distance = fused_atr # fused_atr already includes ×1.5 multiplier
else:
# Fallback: fixed percentage
sl_distance = price * cfg('atr', 'fallback_sl_pct', 0.03)
# Adaptive R:R based on trend strength (方案D)
trend, slope = estimate_trend_strength(exchange, symbol)
rr_target = RR_BY_TREND.get(trend, 2.0)
tp_distance = sl_distance * rr_target
# Calculate TP/SL prices
if side == 'sell': # Short
tp_price = price - tp_distance
sl_price = price + sl_distance
else: # Long
tp_price = price + tp_distance
sl_price = price - sl_distance
# Calculate liquidation price estimate
if side == 'sell':
liq_price = price * (1 + 1 / leverage * cfg('safety', 'liq_estimate_factor', 0.9)) # ~90% of theoretical max
else:
liq_price = price * (1 - 1 / leverage * cfg('safety', 'liq_estimate_factor', 0.9))
# Safety check: SL must be inside liquidation (20% buffer)
if side == 'sell':
# Short: SL is above entry, liq is further above
# max_sl = entry + (liq - entry) * 0.8
max_sl = price + (liq_price - price) * cfg('safety', 'liq_buffer', 0.8)
if sl_price > max_sl:
sl_price = max_sl
tp_price = price - (sl_price - price) * 2 # Maintain R:R
else:
# Long: SL is below entry, liq is further below
# min_sl = entry - (entry - liq) * 0.8
min_sl = price - (price - liq_price) * cfg('safety', 'liq_buffer', 0.8)
if sl_price < min_sl:
sl_price = min_sl
tp_price = price + (price - sl_price) * 2
# Calculate percentages
tp_pct = abs(tp_price - price) / price * 100
sl_pct = abs(sl_price - price) / price * 100
liq_pct = abs(liq_price - price) / price * 100
# Risk/reward ratio
rr = tp_pct / sl_pct if sl_pct > 0 else 0
# Total margin used
total_margin = contracts * margin_per_contract
margin_pct = total_margin / acct_info['usdt_free'] * 100
# Estimated P&L
tp_pnl = contracts * ct_val * abs(tp_price - price)
sl_pnl = contracts * ct_val * abs(sl_price - price)
# Cost-performance check (性价比检查)
fee_rate = cfg('cost_performance', 'fee_rate', 0.0005)
cost_check = calc_cost_performance(
entry_price=price,
sl_price=sl_price,
tp_price=tp_price,
contracts=contracts,
ct_val=ct_val,
leverage=leverage,
fee_rate=fee_rate
)
# If profit < 5 USDT, adjust contracts to meet minimum
min_profit = cfg('position_sizing', 'min_profit_usdt', 10)
if cost_check['profit_amount'] < min_profit:
tp_distance = abs(tp_price - price)
min_contracts = calc_min_contracts_for_profit(tp_distance, ct_val, min_profit=min_profit)
# Round up to lot_sz
min_contracts = math.ceil(min_contracts / lot_sz) * lot_sz
if min_contracts * margin_per_contract <= acct_info['usdt_free']:
contracts = min_contracts
# Recalculate P&L
tp_pnl = contracts * ct_val * abs(tp_price - price)
sl_pnl = contracts * ct_val * abs(sl_price - price)
total_margin = contracts * margin_per_contract
margin_pct = total_margin / acct_info['usdt_free'] * 100
# Recalculate cost check
cost_check = calc_cost_performance(
entry_price=price,
sl_price=sl_price,
tp_price=tp_price,
contracts=contracts,
ct_val=ct_val,
leverage=leverage,
fee_rate=fee_rate
)
return {
'symbol': f"{base}/USDT",
'side': side,
'side_cn': '做空' if side == 'sell' else '做多',
'leverage': leverage,
'price': price,
'contracts': contracts,
'base_amount': contracts * ct_val,
'margin': round(total_margin, 2),
'margin_pct': round(margin_pct, 1),
'tp_price': round(tp_price, 2),
'tp_pct': round(tp_pct, 2),
'tp_pnl': round(tp_pnl, 2),
'sl_price': round(sl_price, 2),
'sl_pct': round(sl_pct, 2),
'sl_pnl': round(sl_pnl, 2),
'rr': round(rr, 1),
'liq_price': round(liq_price, 2),
'liq_pct': round(liq_pct, 1),
'atr_fused': round(fused_atr, 2) if fused_atr else None,
'atr_1h': round(atr_1h, 2) if atr_1h else None,
'atr_4h': round(atr_4h, 2) if atr_4h else None,
'atr_1d': round(atr_1d, 2) if atr_1d else None,
'trend': trend,
'trend_label': TREND_LABEL.get(trend, ''),
'slope': slope,
'inst_id': inst_id,
'ct_val': ct_val,
'min_sz': min_sz,
'acct_free': round(acct_info['usdt_free'], 2),
'cost_check': cost_check,
'auto_execute': cost_check['auto_execute'],
}
def format_recommendation(rec):
"""Format recommendation as readable text"""
if 'error' in rec:
return f"{rec['error']}"
cost_check = rec.get('cost_check', {})
rating = cost_check.get('rating', 'unknown')
rating_emoji = cost_check.get('rating_emoji', '')
rating_text = cost_check.get('rating_text', '')
auto_execute = rec.get('auto_execute', False)
# 根据性价比等级选择模板
if rating == 'high':
# 性价比高 - 自动开仓后推送
lines = [
f"✅ **{rec['symbol']} {rec['side_cn']}** 自动开仓",
f"",
f"📊 方向: {rec['side_cn']} | 杠杆: **{rec['leverage']}x**",
f"📍 入场: **{rec['price']}**",
f"🛑 止损: **{rec['sl_price']}** (-{rec['sl_pct']}%)",
f"🎯 止盈: **{rec['tp_price']}** (+{rec['tp_pct']}%)",
f"📐 盈亏比: **{rec['rr']}:1** ✅",
f"",
f"📦 张数: **{rec['contracts']}张** ({rec['base_amount']}个)",
f"💰 保证金: {rec['margin']} USDT ({rec['margin_pct']}%)",
f"",
f"⚖️ 盈利: {cost_check['profit_amount']} USDT | 手续费: {cost_check['fee_cost']} USDT ({cost_check['fee_pct']}%)",
]
elif rating == 'medium':
# 性价比一般 - 等确认
lines = [
f"⚠️ **{rec['symbol']} {rec['side_cn']}** 性价比一般",
f"",
f"📊 方向: {rec['side_cn']} | 杠杆: **{rec['leverage']}x**",
f"📍 入场: **{rec['price']}**",
f"🛑 止损: **{rec['sl_price']}** (-{rec['sl_pct']}%)",
f"🎯 止盈: **{rec['tp_price']}** (+{rec['tp_pct']}%)",
f"📐 盈亏比: **{rec['rr']}:1** ⚠️",
f"",
f"📦 张数: **{rec['contracts']}张** ({rec['base_amount']}个)",
f"💰 保证金: {rec['margin']} USDT ({rec['margin_pct']}%)",
f"",
f"⚠️ {cost_check.get('reason', '')}",
f"",
f"回复 **Y** 仍要开仓 / **N** 取消",
]
else:
# 性价比低 - 不建议
lines = [
f"❌ **{rec['symbol']} {rec['side_cn']}** 性价比低,不建议",
f"",
f"📊 方向: {rec['side_cn']} | 杠杆: **{rec['leverage']}x**",
f"📍 入场: **{rec['price']}**",
f"🛑 止损: **{rec['sl_price']}** (-{rec['sl_pct']}%)",
f"🎯 止盈: **{rec['tp_price']}** (+{rec['tp_pct']}%)",
f"📐 盈亏比: **{rec['rr']}:1** ❌",
f"",
f"{cost_check.get('reason', '')}",
f"",
f"💡 建议:观望或等更好入场点",
]
# 添加ATR和趋势信息
if rec.get('atr_fused'):
lines.append(f"📊 多周期ATR: 融合${rec['atr_fused']} (1H=${rec.get('atr_1h','?')} 4H=${rec.get('atr_4h','?')} 1D=${rec.get('atr_1d','?')})")
if rec.get('trend_label'):
lines.append(f"🧭 趋势: {rec['trend_label']} (斜率{rec.get('slope','?')}%)")
return '\n'.join(lines)
def execute_order(exchange, rec):
"""Execute the order after user confirmation"""
symbol = f"{rec['symbol'].split('/')[0]}/USDT:USDT"
inst_id = rec['inst_id']
side = rec['side']
contracts = rec['contracts']
leverage = rec['leverage']
results = {'steps': []}
# 1. Set leverage
try:
exchange.set_leverage(leverage, symbol)
results['steps'].append({'step': 'leverage', 'status': 'ok'})
except Exception as e:
results['steps'].append({'step': 'leverage', 'status': 'warn', 'msg': str(e)})
# 2. Place market order
try:
if side == 'sell':
order = exchange.create_market_sell_order(symbol, contracts, params={'tdMode': 'cross'})
else:
order = exchange.create_market_buy_order(symbol, contracts, params={'tdMode': 'cross'})
results['order'] = {
'id': order['id'],
'status': order['status'],
'side': side,
'amount': contracts,
}
results['steps'].append({'step': 'order', 'status': 'ok', 'order_id': order['id']})
except Exception as e:
results['steps'].append({'step': 'order', 'status': 'error', 'msg': str(e)})
return results
# 3. Wait for position update
import time
time.sleep(2)
# 4. Cancel existing algo orders for this instrument (避免多开止盈止损单)
cancelled = 0
for otype in ['oco', 'conditional']:
try:
resp = exchange.private_get_trade_orders_algo_pending({
'ordType': otype,
'instId': inst_id,
})
for algo in resp.get('data', []):
try:
exchange.private_post_trade_cancel_algos([{
'algoId': algo['algoId'],
'instId': inst_id,
}])
cancelled += 1
except Exception:
pass
except Exception:
pass
if cancelled > 0:
results['steps'].append({'step': 'cancel_old_algos', 'status': 'ok', 'cancelled': cancelled})
time.sleep(0.5) # wait for cancellation to propagate
# 5. Set TP/SL via OCO algo order
try:
# For OCO: tpOrdPx=-1 and slOrdPx=-1 means market order on trigger
if side == 'sell':
# Short: TP trigger below, SL trigger above
algo_params = {
'instId': inst_id,
'tdMode': 'cross',
'side': 'buy', # buy to close short
'posSide': 'net',
'ordType': 'oco',
'sz': str(contracts),
'tpTriggerPx': str(rec['tp_price']),
'tpOrdPx': '-1',
'tpTriggerPxType': 'last',
'slTriggerPx': str(rec['sl_price']),
'slOrdPx': '-1',
'slTriggerPxType': 'last',
'reduceOnly': 'true',
}
else:
# Long: TP trigger above, SL trigger below
algo_params = {
'instId': inst_id,
'tdMode': 'cross',
'side': 'sell', # sell to close long
'posSide': 'net',
'ordType': 'oco',
'sz': str(contracts),
'tpTriggerPx': str(rec['tp_price']),
'tpOrdPx': '-1',
'tpTriggerPxType': 'last',
'slTriggerPx': str(rec['sl_price']),
'slOrdPx': '-1',
'slTriggerPxType': 'last',
'reduceOnly': 'true',
}
resp = exchange.private_post_trade_order_algo(algo_params)
if resp.get('data') and resp['data'][0].get('algoId'):
algo_id = resp['data'][0]['algoId']
results['algo'] = {'id': algo_id, 'tp': rec['tp_price'], 'sl': rec['sl_price']}
results['steps'].append({'step': 'tp_sl', 'status': 'ok', 'algo_id': algo_id})
else:
results['steps'].append({'step': 'tp_sl', 'status': 'warn', 'msg': str(resp)})
except Exception as e:
results['steps'].append({'step': 'tp_sl', 'status': 'error', 'msg': str(e)})
# 5. Verify position
try:
positions = exchange.fetch_positions([symbol])
for p in positions:
if float(p.get('contracts', 0)) > 0:
results['position'] = {
'side': p['side'],
'contracts': float(p['contracts']),
'entry': float(p['entryPrice']) if p.get('entryPrice') else 0,
'liq': float(p.get('liquidationPrice', 0)) if p.get('liquidationPrice') else 0,
'pnl': float(p.get('unrealizedPnl', 0)),
}
except Exception:
pass
return results
def format_execution_result(results):
"""Format execution result for user"""
lines = []
for step in results.get('steps', []):
if step['step'] == 'leverage':
if step['status'] == 'ok':
lines.append("✅ 杠杆设置成功")
else:
lines.append(f"⚠️ 杠杆: {step.get('msg', '')}")
elif step['step'] == 'order':
if step['status'] == 'ok':
lines.append(f"✅ 下单成功 (ID: {step['order_id']})")
else:
lines.append(f"❌ 下单失败: {step.get('msg', '')}")
return '\n'.join(lines)
elif step['step'] == 'cancel_old_algos':
lines.append(f"🧹 已清理 {step['cancelled']} 个旧止盈止损单")
elif step['step'] == 'tp_sl':
if step['status'] == 'ok':
lines.append(f"✅ 止盈止损设置成功 (ID: {step['algo_id']})")
else:
lines.append(f"⚠️ 止盈止损: {step.get('msg', '')}")
pos = results.get('position')
if pos:
lines.extend([
"",
"📊 **持仓确认:**",
f"• 方向: {pos['side']}",
f"• 数量: {pos['contracts']}",
f"• 入场价: **{pos['entry']}**",
f"• 清算价: {pos['liq']}",
])
algo = results.get('algo')
if algo:
lines.extend([
f"• 🎯 止盈: {algo['tp']}",
f"• 🛑 止损: {algo['sl']}",
])
return '\n'.join(lines)
def close_position(exchange, symbol, inst_id):
"""Close all positions for a symbol and cancel algo orders"""
results = {'steps': []}
# 1. Get current position
positions = exchange.fetch_positions([symbol])
pos = None
for p in positions:
if float(p.get('contracts', 0)) > 0:
pos = p
break
if not pos:
results['steps'].append({'step': 'check', 'status': 'none', 'msg': '没有持仓'})
return results
contracts = float(pos['contracts'])
side = pos['side']
entry = float(pos['entryPrice'])
pnl = float(pos.get('unrealizedPnl', 0))
# 2. Cancel all algo orders
for otype in ['oco', 'conditional']:
try:
resp = exchange.private_get_trade_orders_algo_pending({
'ordType': otype,
'instId': inst_id,
})
for algo in resp.get('data', []):
try:
exchange.private_post_trade_cancel_algos([{
'algoId': algo['algoId'],
'instId': inst_id,
}])
except Exception:
pass
except Exception:
pass
results['steps'].append({'step': 'cancel_algos', 'status': 'ok'})
# 3. Close position with market order
try:
if side == 'short':
order = exchange.create_market_buy_order(symbol, contracts, params={
'tdMode': 'cross',
'reduceOnly': True,
})
else:
order = exchange.create_market_sell_order(symbol, contracts, params={
'tdMode': 'cross',
'reduceOnly': True,
})
results['steps'].append({'step': 'close', 'status': 'ok', 'order_id': order['id']})
except Exception as e:
results['steps'].append({'step': 'close', 'status': 'error', 'msg': str(e)})
return results
# 4. Wait and verify
import time
time.sleep(2)
# 5. Get close price from trades
try:
fills = exchange.fetch_my_trades(symbol, limit=1)
close_price = float(fills[0]['price']) if fills else 0
except Exception:
close_price = 0
results['closed'] = {
'symbol': symbol.split('/')[0] + '/USDT',
'side': side,
'contracts': contracts,
'entry': entry,
'close_price': close_price,
'pnl': pnl,
}
return results
def format_close_result(results):
"""Format close position result"""
lines = []
for step in results.get('steps', []):
if step['step'] == 'none':
return f"{step['msg']}"
elif step['step'] == 'close':
if step['status'] == 'ok':
lines.append("✅ 平仓成功")
else:
lines.append(f"❌ 平仓失败: {step.get('msg', '')}")
return '\n'.join(lines)
c = results.get('closed')
if c:
pnl_emoji = "🟢" if c['pnl'] >= 0 else "🔴"
lines.extend([
f"",
f"📊 **{c['symbol']} 平仓确认:**",
f"• 方向: {c['side']}",
f"• 数量: {c['contracts']}",
f"• 入场价: {c['entry']}",
f"• 平仓价: **{c['close_price']}**",
f"{pnl_emoji} 盈亏: **{c['pnl']:.2f} USDT**",
f"• 已取消止盈止损",
])
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description='OKX Position Advisor')
parser.add_argument('--symbol', required=True, help='Base currency: ETH, BTC, SOL...')
parser.add_argument('--side', choices=['long', 'short', 'buy', 'sell'],
help='Position direction (required for open, optional for close)')
parser.add_argument('--leverage', type=int, default=10, help='Leverage (default: 10)')
parser.add_argument('--execute', action='store_true', help='Execute order (requires prior --json output)')
parser.add_argument('--rec-json', type=str, help='Recommendation JSON to execute')
parser.add_argument('--close', action='store_true', help='Close position for symbol')
parser.add_argument('--close-all', action='store_true', help='Close all positions')
parser.add_argument('--json', action='store_true', help='Output as JSON')
args = parser.parse_args()
# Normalize side (only needed for open)
if args.side:
side = 'sell' if args.side in ('short', 'sell') else 'buy'
else:
side = None
# Load credentials and create exchange
creds = load_credentials()
exchange = create_exchange(creds)
# Build symbol
symbol = f"{args.symbol.upper()}/USDT:USDT"
inst_id = f"{args.symbol.upper()}-USDT-SWAP"
# Close mode
if args.close:
results = close_position(exchange, symbol, inst_id)
print(format_close_result(results))
return
if args.close_all:
positions = exchange.fetch_positions()
active = [p for p in positions if float(p.get('contracts', 0)) > 0]
if not active:
print("️ 没有持仓")
return
for p in active:
sym = p['symbol']
iid = sym.split('/')[0].replace(':USDT', '') + '-USDT-SWAP'
results = close_position(exchange, sym, iid)
print(format_close_result(results))
print()
return
# Open mode requires --side
if not side:
print("❌ 开仓需要指定 --side (long/short/buy/sell)")
return
# Get account info
acct_info = get_account_info(exchange)
# Calculate recommendation
rec = recommend_position(symbol, side, args.leverage, exchange, acct_info)
# Execute mode: run the order
if args.execute and args.rec_json:
rec = json.loads(args.rec_json)
results = execute_order(exchange, rec)
# Output JSON for trade_signal_handler to parse
if args.json:
print(json.dumps(results, ensure_ascii=False))
else:
print(format_execution_result(results))
return
# Auto-execute mode: if cost-performance is high, execute directly
if rec.get('auto_execute') and not args.json:
print(f"✅ 性价比高,自动开仓...")
results = execute_order(exchange, rec)
print(format_execution_result(results))
return
if args.json:
print(json.dumps(rec, indent=2, ensure_ascii=False))
else:
print(format_recommendation(rec))
if __name__ == '__main__':
main()
@@ -0,0 +1,494 @@
#!/usr/bin/env python3
"""
交易信号处理器(no_agent模式):
1. 解析TG信号文本
2. 调advisor脚本获取正确金额
3. 格式化含📐完整模板
4. 推QQ
5. 信号去重/合并
用法: python3 process_signal.py "信号文本"
或: echo "信号文本" | python3 process_signal.py
cron模式: 作为no_agent cron job的script使用
"""
import sys
import re
import json
import subprocess
import sqlite3
import hashlib
from pathlib import Path
from datetime import datetime, timedelta
SKILL_DIR = Path.home() / ".hermes/skills/trading/okx-auto-position"
ADVISOR = SKILL_DIR / "scripts" / "okx_position_advisor.py"
QQ_PUSH = Path.home() / ".hermes/scripts/push_to_qq.sh"
SIGNAL_DB = Path.home() / ".hermes/trading/signal_history.db"
DEDUP_DB = Path.home() / ".hermes/trading/signal_dedup.db"
# Import signal tracker
sys.path.insert(0, str(SKILL_DIR / "scripts"))
from signal_tracker import format_comparison, record_signal as _tracker_record, record_confirmed, format_trader_rating
# ─── 解析 ────────────────────────────────────────────────────────────────
def parse_signal(text):
"""从TG信号文本提取关键字段"""
fields = {}
# 交易员
m = re.search(r'【([^】]{1,20})】', text)
if m:
fields['trader'] = m.group(1)
# 字段映射
extractors = {
'symbol': r'【币种】\s*[:]?\s*(\S+)',
'side': r'【方向】\s*[:]?\s*(做多|做空)',
'leverage':r'【杠杆】\s*[:]?\s*(\d+)',
'size': r'【仓位大小】\s*[:]?\s*([\d,.]+)',
'value': r'【仓位价值】\s*[:]?\s*\$?\s*([\d,.]+)',
'entry': r'【开仓价】\s*[:]?\s*([\d,.]+)',
'current': r'【当前价】\s*[:]?\s*([\d,.]+)',
'pnl': r'【未实现盈亏】\s*[:]?\s*([-\d,.]+)',
'margin': r'【保证金】\s*[:]?\s*\$?\s*([\d,.]+)',
}
for key, pattern in extractors.items():
m = re.search(pattern, text)
if m:
fields[key] = m.group(1).replace(',', '')
# 清理symbol
if 'symbol' in fields:
sym = fields['symbol']
sym = re.sub(r'\|.*$', '', sym) # 去掉 |永续|10x
sym = sym.replace('USDT', '').strip()
fields['symbol'] = sym
# 方向转英文
if fields.get('side', '').startswith('做多'):
fields['side_en'] = 'long'
else:
fields['side_en'] = 'short'
return fields
# ─── 去重 ────────────────────────────────────────────────────────────────
def init_dedup_db():
conn = sqlite3.connect(str(DEDUP_DB))
conn.execute("""
CREATE TABLE IF NOT EXISTS recent_signals (
id TEXT PRIMARY KEY,
symbol TEXT,
trader TEXT,
timestamp REAL,
raw_text TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS processed (
msg_hash TEXT PRIMARY KEY,
processed_at REAL
)
""")
conn.commit()
return conn
def is_duplicate(conn, text, symbol, trader):
"""检查是否重复信号(同交易员同币种2分钟内)"""
msg_hash = hashlib.md5(text.encode()).hexdigest()
# 检查完全相同的消息
row = conn.execute(
"SELECT 1 FROM processed WHERE msg_hash = ?", (msg_hash,)
).fetchone()
if row:
return True
# 检查同交易员同币种2分钟内的信号
cutoff = datetime.now().timestamp() - 120 # 2分钟
row = conn.execute(
"""SELECT 1 FROM recent_signals
WHERE symbol = ? AND trader = ? AND timestamp > ?
ORDER BY timestamp DESC LIMIT 1""",
(symbol, trader, cutoff)
).fetchone()
return row is not None
def record_signal(conn, text, symbol, trader):
"""记录信号用于去重"""
msg_hash = hashlib.md5(text.encode()).hexdigest()
now = datetime.now().timestamp()
conn.execute(
"INSERT OR REPLACE INTO processed (msg_hash, processed_at) VALUES (?, ?)",
(msg_hash, now)
)
conn.execute(
"INSERT OR REPLACE INTO recent_signals (id, symbol, trader, timestamp, raw_text) VALUES (?, ?, ?, ?, ?)",
(msg_hash, symbol, trader, now, text[:500])
)
# 清理1小时前的记录
cutoff = now - 3600
conn.execute("DELETE FROM recent_signals WHERE timestamp < ?", (cutoff,))
conn.execute("DELETE FROM processed WHERE processed_at < ?", (cutoff,))
conn.commit()
# ─── Advisor ──────────────────────────────────────────────────────────────
def run_advisor(symbol, side, leverage):
"""调advisor脚本获取正确数据"""
cmd = [
'python3', str(ADVISOR),
'--symbol', symbol,
'--side', side,
'--leverage', str(leverage),
'--json'
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=30,
cwd=str(ADVISOR.parent)
)
if result.returncode == 0:
return json.loads(result.stdout)
else:
return {'error': result.stderr.strip()[:200]}
except subprocess.TimeoutExpired:
return {'error': 'advisor超时'}
except json.JSONDecodeError:
return {'error': 'advisor输出非JSON'}
except Exception as e:
return {'error': str(e)}
# ─── 分类 ────────────────────────────────────────────────────────────────
def classify_signal(fields):
"""判断信号类型:加仓/新开仓/减仓/平仓"""
text = fields.get('_raw', '')
# 平仓信号
if '平仓' in text or '止盈' in text or '止损' in text:
return 'close'
# 减仓信号
pnl = float(fields.get('pnl', '0').replace('+', ''))
if '减仓' in text or (pnl < 0 and '' in text):
return 'reduce'
# 默认为新开仓或加仓(由advisor判断)
return 'open'
# ─── 格式化 ──────────────────────────────────────────────────────────────
def format_message(fields, rec, signal_type):
"""格式化完整推送消息"""
if 'error' in rec:
return f"⚠️ advisor错误: {rec['error']}"
symbol = fields.get('symbol', '?')
side_cn = fields.get('side', '做多')
emoji = '🟩' if fields.get('side_en') == 'long' else '🟥'
leverage = fields.get('leverage', '10')
trader = fields.get('trader', '?')
size = fields.get('size', '?')
value = fields.get('value', '?')
entry_price = fields.get('entry', '?')
pnl_str = fields.get('pnl', '0')
pnl = float(pnl_str.replace('+', '')) if pnl_str else 0
current = rec.get('price', fields.get('current', '?'))
pnl_emoji = '🔥' if pnl > 0 else '🔴'
pnl_sign = '+' if pnl > 0 else ''
# 性价比
cc = rec.get('cost_check', {})
rr = cc.get('rr_ratio', rec.get('rr', 0))
profit = cc.get('profit_amount', rec.get('tp_pnl', 0))
fee = cc.get('fee_cost', 0)
fee_pct = cc.get('fee_pct', 0)
net = cc.get('net_profit', 0)
rating_emoji = cc.get('rating_emoji', '⚠️')
rating_text = cc.get('rating_text', '未知')
# 信号类型标签
type_labels = {
'open': '新开仓' if not fields.get('_is_add') else 'A类加仓',
'reduce': 'B类减仓',
'close': '平仓',
}
type_label = type_labels.get(signal_type, signal_type)
# 信号源仓位(只展示,不参与计算)
src_info = f"📊 {trader} {size} {symbol}(价值${value})← 信号源,非你的仓位"
# 仓位变化对比
try:
current_size = float(fields.get('size', '0').replace(',', ''))
comparison = format_comparison(trader, symbol, current_size)
except:
comparison = ""
# 交易员评分
try:
trader_rating = format_trader_rating(trader)
except:
trader_rating = ""
msg = f"""⚡ 跟单建议 | {symbol} {side_cn} {emoji} {leverage}x{type_label}
{src_info}
入场: ${entry_price} | 当前: ${current}
浮盈: {pnl_sign}{pnl:.0f} {pnl_emoji}
📊 仓位变化
{comparison}
{trader_rating}
📐 性价比检查(基于你的推荐仓位)
• 你的仓位: {rec['contracts']}张(保证金{rec['margin']:.2f} USDT
• 盈亏比: {rr}:1 {'' if rr >= 2 else '⚠️' if rr >= 1.5 else ''}
• 盈利额: +{profit:.2f} USDT {'' if profit >= 10 else '❌ <10U保底'}
• 手续费: {fee:.2f} USDT ({fee_pct:.1f}%) {'' if fee_pct < 5 else ''}
• 净盈利: {net:.2f} USDT {'' if net >= 10 else ''}
• 评级: {rating_emoji} {rating_text}
• SL: ${rec['sl_price']}-{rec['sl_pct']:.1f}%
• TP: ${rec['tp_price']}+{rec['tp_pct']:.1f}%
回复 Y 确认跟单 / N 取消"""
# 如果余额不足,替换跟单方案
if rec.get('contracts', 0) == 0:
msg = f"""⚡ 跟单建议 | {symbol} {side_cn} {emoji} {leverage}x{type_label}
{src_info}
入场: ${entry_price} | 当前: ${current}
浮盈: {pnl_sign}{pnl:.0f} {pnl_emoji}
⚠️ 余额不足,无法开仓
• 可用: {rec.get('acct_free', 0):.2f} USDT
• 需要: ~{rec.get('margin', 0):.2f} USDT
💡 建议:等待其他仓位止盈释放保证金"""
return msg
# ─── 推送 ────────────────────────────────────────────────────────────────
def push_to_qq(message):
"""推送到QQ"""
try:
result = subprocess.run(
['bash', str(QQ_PUSH), message],
capture_output=True, text=True, timeout=15
)
return result.returncode == 0
except:
return False
# ─── 执行订单 ────────────────────────────────────────────────────────────
def execute_order(symbol, side, leverage, rec):
"""执行开仓订单"""
cmd = [
'python3', str(ADVISOR),
'--symbol', symbol,
'--side', side,
'--leverage', str(leverage),
'--execute', '--json',
'--rec-json', json.dumps(rec)
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=30,
cwd=str(ADVISOR.parent)
)
if result.returncode == 0:
return json.loads(result.stdout)
else:
return {'error': result.stderr.strip()[:200]}
except Exception as e:
return {'error': str(e)}
def format_execution_result(fields, rec, exec_result):
"""格式化执行结果"""
symbol = fields.get('symbol', '?')
side_cn = fields.get('side', '做多')
emoji = '🟩' if fields.get('side_en') == 'long' else '🟥'
leverage = fields.get('leverage', '10')
trader = fields.get('trader', '?')
size = fields.get('size', '?')
value = fields.get('value', '?')
cc = rec.get('cost_check', {})
rr = cc.get('rr_ratio', rec.get('rr', 0))
profit = cc.get('profit_amount', rec.get('tp_pnl', 0))
fee = cc.get('fee_cost', 0)
fee_pct = cc.get('fee_pct', 0)
net = cc.get('net_profit', 0)
rating_emoji = cc.get('rating_emoji', '⚠️')
rating_text = cc.get('rating_text', '未知')
pos = exec_result.get('position', {})
algo = exec_result.get('algo', {})
msg = f"""{symbol} {side_cn} {emoji} {leverage}x 自动开仓
📊 信号源: {trader} {size} {symbol}(价值${value}
📐 性价比检查
• 盈亏比: {rr}:1 ✅
• 盈利额: +{profit:.2f} USDT ✅
• 手续费: {fee:.2f} USDT ({fee_pct:.1f}%) ✅
• 净盈利: {net:.2f} USDT ✅
• 评级: {rating_emoji} {rating_text}
✅ 执行结果
• 入场: ${pos.get('entry', rec.get('price', '?'))}
• 仓位: {pos.get('contracts', rec.get('contracts', '?'))}
• TP: ${algo.get('tp', rec.get('tp_price', '?'))}
• SL: ${algo.get('sl', rec.get('sl_price', '?'))}
• 强平: ${pos.get('liq', '?')}
━━━ 当前全部持仓 ━━━
(查询中..."""
# 尝试获取当前全部持仓
try:
acct_cmd = ['python3', '-c', f'''
import sys
sys.path.insert(0, "{ADVISOR.parent}")
from okx_position_advisor import load_credentials, create_exchange, get_account_info
creds = load_credentials()
exchange = create_exchange(creds)
info = get_account_info(exchange)
print(f"Free: {{info['usdt_free']:.2f}}")
for p in info['positions']:
print(f" {{p['symbol']}}: {{p['contracts']}}张 UPL={{p['pnl']:.2f}}")
''']
acct_result = subprocess.run(acct_cmd, capture_output=True, text=True, timeout=15)
if acct_result.returncode == 0:
msg = msg.replace("(查询中...", f"\n```\n{acct_result.stdout.strip()}\n```")
except:
pass
return msg
# ─── 主流程 ──────────────────────────────────────────────────────────────
def process_signal(text):
"""处理一条信号"""
# 解析
fields = parse_signal(text)
fields['_raw'] = text
if not fields.get('symbol') or not fields.get('side'):
return "⚠️ 无法解析信号"
symbol = fields['symbol']
side = fields['side_en']
leverage = fields.get('leverage', '10')
trader = fields.get('trader', '未知')
# 去重
dedup_conn = init_dedup_db()
if is_duplicate(dedup_conn, text, symbol, trader):
dedup_conn.close()
return "⏭️ 重复信号,跳过"
# 分类
signal_type = classify_signal(fields)
# 平仓信号直接推送
if signal_type == 'close':
msg = f"""🔔 {trader} {symbol}平仓提醒
{text[text.find("入场"):text.find("回复")].strip() if "入场" in text else "详情见原始信号"}
💡 操作建议
• 若已跟单{symbol},建议同步止盈/止损"""
record_signal(dedup_conn, text, symbol, trader)
dedup_conn.close()
push_to_qq(msg)
return "✅ 平仓信号已推送"
# 调advisor
rec = run_advisor(symbol, side, leverage)
if 'error' in rec:
record_signal(dedup_conn, text, symbol, trader)
dedup_conn.close()
return f"⚠️ advisor错误: {rec['error']}"
# 性价比检查
cc = rec.get('cost_check', {})
rr = cc.get('rr_ratio', rec.get('rr', 0))
profit = cc.get('profit_amount', rec.get('tp_pnl', 0))
fee_pct = cc.get('fee_pct', 0)
auto_execute = cc.get('auto_execute', False) or (rr >= 2 and fee_pct < 5 and profit >= 10)
if auto_execute and signal_type == 'open':
# 性价比高 + 新开仓 → 自动执行
exec_result = execute_order(symbol, side, leverage, rec)
if exec_result and 'error' not in exec_result:
msg = format_execution_result(fields, rec, exec_result)
_tracker_record(trader=trader, symbol=symbol, side=side,
leverage=int(leverage) if leverage else 10,
trader_size=float(fields.get('size', '0').replace(',', '')),
trader_entry=float(fields.get('entry', '0').replace(',', '')),
trader_pnl=float(fields.get('pnl', '0').replace(',', '')),
raw_text=text, outcome='auto_executed')
else:
# 执行失败,降级为确认模式
auto_execute = False
msg = format_message(fields, rec, signal_type)
_tracker_record(trader=trader, symbol=symbol, side=side,
leverage=int(leverage) if leverage else 10,
trader_size=float(fields.get('size', '0').replace(',', '')),
trader_entry=float(fields.get('entry', '0').replace(',', '')),
trader_pnl=float(fields.get('pnl', '0').replace(',', '')),
raw_text=text, outcome='pushed')
else:
# 需要确认或减仓信号
msg = format_message(fields, rec, signal_type)
_tracker_record(trader=trader, symbol=symbol, side=side,
leverage=int(leverage) if leverage else 10,
trader_size=float(fields.get('size', '0').replace(',', '')),
trader_entry=float(fields.get('entry', '0').replace(',', '')),
trader_pnl=float(fields.get('pnl', '0').replace(',', '')),
raw_text=text, outcome='pushed')
# 记录去重
record_signal(dedup_conn, text, symbol, trader)
dedup_conn.close()
# 推送
success = push_to_qq(msg)
if success:
return f"✅ 已推送 | {symbol} {side} {leverage}x | {rec['contracts']}张 | 性价比{rec.get('cost_check', {}).get('rating_text', '?')}"
else:
return f"❌ 推送失败"
def main():
if len(sys.argv) > 1:
text = ' '.join(sys.argv[1:])
else:
text = sys.stdin.read()
if not text.strip():
print("用法: python3 process_signal.py '信号文本'")
print("或: echo '信号文本' | python3 process_signal.py")
return
result = process_signal(text)
print(result)
if __name__ == '__main__':
main()
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
QQ Bot API 直推脚本(备用推送方式)
当 hermes send 因 delivery context 跳过时使用。
直接从 ~/.hermes/.env 读取凭证,通过 QQ Bot API 发送 C2C 消息。
用法:
python3 qq_push.py "消息内容"
echo "消息" | python3 qq_push.py
凭证:从 ~/.hermes/.env 读取 QQ_APP_ID, QQ_CLIENT_SECRET, QQ_ALLOWED_USERS
"""
import os, sys, json, urllib.request
def read_env(path):
"""从 .env 文件读取变量"""
creds = {}
for line in open(path).read().splitlines():
line = line.strip()
if '=' in line and not line.startswith('#'):
k, v = line.split('=', 1)
creds[k.strip()] = v.strip().strip("'\"").strip('"')
return creds
def send_qq_msg(msg, app_id, secret, openid):
"""通过 QQ Bot API 发送 C2C 消息"""
# 1. 获取 access token
token_data = json.dumps({
'appId': app_id,
'clientSecret': secret
}).encode()
req = urllib.request.Request(
'https://bots.qq.com/app/getAppAccessToken',
data=token_data,
headers={'Content-Type': 'application/json'},
method='POST'
)
resp = urllib.request.urlopen(req, timeout=15)
token = json.loads(resp.read())['access_token']
# 2. 发送消息
body = json.dumps({'content': msg, 'msg_type': 0}).encode()
req2 = urllib.request.Request(
f'https://api.sgroup.qq.com/v2/users/{openid}/messages',
data=body,
headers={
'Content-Type': 'application/json',
'Authorization': f'QQBot {token}'
},
method='POST'
)
resp2 = urllib.request.urlopen(req2, timeout=15)
result = json.loads(resp2.read())
return result.get('id', 'unknown')
if __name__ == '__main__':
msg = sys.argv[1] if len(sys.argv) > 1 else sys.stdin.read().strip()
if not msg:
print('Usage: qq_push.py "message"', file=sys.stderr)
sys.exit(1)
env = read_env(os.path.expanduser('~/.hermes/.env'))
app_id = env.get('QQ_APP_ID', '')
secret = env.get('QQ_CLIENT_SECRET', '')
openid = env.get('QQ_ALLOWED_USERS', 'B1EF50442496D57C1B4F3890501C34C2')
if not app_id or not secret:
print('❌ QQ credentials not found in ~/.hermes/.env', file=sys.stderr)
sys.exit(1)
try:
msg_id = send_qq_msg(msg, app_id, secret, openid)
print(f'✅ Sent! msg_id: {msg_id}')
except urllib.error.HTTPError as e:
print(f'❌ HTTP {e.code}: {e.read().decode()[:200]}', file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f'{e}', file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,468 @@
#!/usr/bin/env python3
"""
信号历史数据库 - 记录所有交易信号
用法:
python3 signal_db.py log '<原始信号文本>'
python3 signal_db.py history [--trader NAME] [--symbol BTC] [--days 7] [--limit 20]
python3 signal_db.py stats
python3 signal_db.py traders
"""
import sqlite3
import os
import re
import json
import sys
import time
from datetime import datetime, timedelta
DB_PATH = os.path.expanduser("~/.hermes/trading/signal_history.db")
def get_conn():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_conn()
conn.execute("""
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
time_str TEXT NOT NULL,
trader TEXT,
symbol TEXT,
side TEXT,
leverage INTEGER,
raw_size REAL,
raw_unit TEXT,
entry_price REAL,
current_price REAL,
margin REAL,
margin_unit TEXT,
margin_mode TEXT,
pnl REAL,
pnl_pct REAL,
leverage_change TEXT,
raw_text TEXT NOT NULL,
outcome TEXT DEFAULT 'pending',
outcome_time REAL,
outcome_detail TEXT
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_signals_time ON signals(timestamp DESC)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_signals_trader ON signals(trader)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_signals_symbol ON signals(symbol)
""")
conn.commit()
conn.close()
def extract_trader(text):
"""Extract trader name from signal text.
Common patterns:
- 【熬鹰资本】 (standalone 【name】 on its own line, no colon)
- 【交易员】xxx
- 【老师】xxx
- 交易员: xxx
- 来自xxx:
- [xxx] at the beginning
- @username
- Name followed by colon (e.g. "张三: BTC做多")
- Name followed by signal keywords
"""
# Priority 1: Standalone 【name】 on its own line (no colon after)
# This matches 【熬鹰资本】 but NOT 【币种】: xxx
standalone = re.search(r'^【([^】]{1,20})】\s*$', text, re.MULTILINE)
if standalone:
return standalone.group(1).strip()
patterns = [
r'【交易员】\s*(.+?)(?:\n|$|【)',
r'【老师】\s*(.+?)(?:\n|$|【)',
r'【来源】\s*(.+?)(?:\n|$|【)',
r'【策略】\s*(.+?)(?:\n|$|【)',
r'交易员[:]\s*(.+?)(?:\n|$)',
r'老师[:]\s*(.+?)(?:\n|$)',
r'来源[:]\s*(.+?)(?:\n|$)',
r'策略师[:]\s*(.+?)(?:\n|$)',
r'^\[([^\]]+)\]', # [TraderName] at start
r'^(@\w+)', # @username at start
r'^(\S+?)\s*[:]\s*(?:【|BTC|ETH|做多|做空|开多|开空)', # Name: signal
r'^(\S{2,10})\s+(?:【|BTC|ETH|做多|做空|开多|开空)', # Name signal (no colon)
]
for p in patterns:
m = re.search(p, text, re.MULTILINE)
if m:
name = m.group(1).strip()
# Filter out non-name matches
if len(name) > 1 and len(name) < 30 and not re.match(r'^[\d.]+$', name):
return name
return None
def extract_signal_fields(text):
"""Parse signal text for key fields."""
result = {'trader': extract_trader(text)}
# Symbol - multiple patterns
m = re.search(r'(?:【币种】|币种[:]\s*)(\w+)', text)
if not m:
m = re.search(r'([A-Z]{2,10})USDT', text)
if not m:
# Bare symbol before direction keywords (e.g. "ETH做空", "BTC 开多")
m = re.search(r'\b([A-Z]{2,10})\s*(?:做多|做空|开多|开空|做多|做空|long|short)', text, re.IGNORECASE)
if m:
raw = m.group(1).upper().replace("USDT", "").replace("/USDT", "").replace(":USDT", "")
if len(raw) >= 2:
result['symbol'] = raw
# Side
if re.search(r'(做空|卖出|short|sell|空单|开空)', text, re.IGNORECASE):
result['side'] = 'short'
elif re.search(r'(做多|买入|long|buy|多单|开多)', text, re.IGNORECASE):
result['side'] = 'long'
# Leverage from field
m = re.search(r'(?:【币种】|币种[:]\s*)[^\n]*?(\d+)\s*[xX倍]', text)
if not m:
m = re.search(r'(\d+)\s*[xX倍]', text)
result['leverage'] = int(m.group(1)) if m else None
# Size
m = re.search(r'(?:【仓位】|仓位[:]\s*)([\d,.]+)\s*(\w+)', text)
if m:
result['raw_size'] = float(m.group(1).replace(",", ""))
result['raw_unit'] = m.group(2)
# Entry price
m = re.search(r'【开仓价】\s*[:]?\s*([\d,.]+)', text)
if m:
result['entry_price'] = float(m.group(1).replace(",", ""))
# Current price
m = re.search(r'【当前价】\s*[:]?\s*([\d,.]+)', text)
if m:
result['current_price'] = float(m.group(1).replace(",", ""))
# Margin
m = re.search(r'【保证金】\s*[:]?\s*([\d,.]+)\s*(\w+)', text)
if m:
result['margin'] = float(m.group(1).replace(",", ""))
result['margin_unit'] = m.group(2)
# Margin mode (全仓/逐仓)
m = re.search(r'(全仓|逐仓)', text)
if m:
result['margin_mode'] = m.group(1)
# PnL
m = re.search(r'【收益额】\s*[:]?\s*([-\d,.]+)\s*(\w+)', text)
if m:
result['pnl'] = float(m.group(1).replace(",", ""))
m = re.search(r'【收益额】\s*[:]?\s*[-\d,.]+\s*\w+\(([-\d.]+)%\)', text)
if m:
result['pnl_pct'] = float(m.group(1))
# Leverage change (e.g. "5→10")
m = re.search(r'修改了杠杆\s*(\d+)\s*[→>→]\s*(\d+)', text)
if m:
result['leverage_change'] = f"{m.group(1)}{m.group(2)}"
# Is close signal
result['is_close'] = bool(re.search(r'(平仓|止盈|止损|close|全平)', text, re.IGNORECASE))
return result
def log_signal(raw_text):
"""Log a signal to the database."""
init_db()
fields = extract_signal_fields(raw_text)
conn = get_conn()
now = time.time()
time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute("""
INSERT INTO signals (timestamp, time_str, trader, symbol, side, leverage,
raw_size, raw_unit, entry_price, current_price,
margin, margin_unit, margin_mode, pnl, pnl_pct,
leverage_change, raw_text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
now, time_str,
fields.get('trader'),
fields.get('symbol'),
fields.get('side'),
fields.get('leverage'),
fields.get('raw_size'),
fields.get('raw_unit'),
fields.get('entry_price'),
fields.get('current_price'),
fields.get('margin'),
fields.get('margin_unit'),
fields.get('margin_mode'),
fields.get('pnl'),
fields.get('pnl_pct'),
fields.get('leverage_change'),
raw_text,
))
signal_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.commit()
conn.close()
return {
'id': signal_id,
'time': time_str,
'trader': fields.get('trader'),
'symbol': fields.get('symbol'),
'side': fields.get('side'),
'leverage': fields.get('leverage'),
}
def update_outcome(signal_id, outcome, detail=""):
"""Update signal outcome (confirmed/cancelled/expired)."""
conn = get_conn()
conn.execute("""
UPDATE signals SET outcome=?, outcome_time=?, outcome_detail=?
WHERE id=?
""", (outcome, time.time(), detail, signal_id))
conn.commit()
conn.close()
def find_latest_signal_id(symbol):
"""Find the most recent pending signal ID for a symbol."""
conn = get_conn()
row = conn.execute("""
SELECT id FROM signals WHERE symbol=? AND outcome='pending'
ORDER BY timestamp DESC LIMIT 1
""", (symbol,)).fetchone()
conn.close()
return row['id'] if row else None
def query_history(trader=None, symbol=None, days=7, limit=20):
"""Query signal history with filters."""
init_db()
conn = get_conn()
conditions = ["timestamp > ?"]
params = [time.time() - days * 86400]
if trader:
conditions.append("trader LIKE ?")
params.append(f"%{trader}%")
if symbol:
conditions.append("symbol LIKE ?")
params.append(f"%{symbol}%")
where = " AND ".join(conditions)
rows = conn.execute(f"""
SELECT * FROM signals WHERE {where}
ORDER BY timestamp DESC LIMIT ?
""", params + [limit]).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_trader_stats():
"""Get stats per trader."""
init_db()
conn = get_conn()
rows = conn.execute("""
SELECT
trader,
COUNT(*) as total,
SUM(CASE WHEN outcome='confirmed' THEN 1 ELSE 0 END) as confirmed,
SUM(CASE WHEN outcome='cancelled' THEN 1 ELSE 0 END) as cancelled,
SUM(CASE WHEN outcome='pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN outcome='expired' THEN 1 ELSE 0 END) as expired,
GROUP_CONCAT(DISTINCT symbol) as symbols
FROM signals
GROUP BY trader
ORDER BY total DESC
""").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_summary_stats():
"""Get overall summary stats."""
init_db()
conn = get_conn()
total = conn.execute("SELECT COUNT(*) as c FROM signals").fetchone()['c']
today = conn.execute(
"SELECT COUNT(*) as c FROM signals WHERE timestamp > ?",
(time.time() - 86400,)
).fetchone()['c']
by_outcome = conn.execute("""
SELECT outcome, COUNT(*) as c FROM signals GROUP BY outcome
""").fetchall()
by_side = conn.execute("""
SELECT side, COUNT(*) as c FROM signals WHERE side IS NOT NULL GROUP BY side
""").fetchall()
top_symbols = conn.execute("""
SELECT symbol, COUNT(*) as c FROM signals
WHERE symbol IS NOT NULL
GROUP BY symbol ORDER BY c DESC LIMIT 5
""").fetchall()
conn.close()
return {
'total': total,
'today': today,
'by_outcome': {r['outcome']: r['c'] for r in by_outcome},
'by_side': {r['side']: r['c'] for r in by_side},
'top_symbols': [(r['symbol'], r['c']) for r in top_symbols],
}
def format_history(signals):
"""Format history for display."""
if not signals:
return "📭 暂无信号记录"
lines = ["📋 **信号历史记录**\n"]
for s in signals:
side_cn = "做多" if s['side'] == 'long' else ("做空" if s['side'] == 'short' else "?")
outcome_emoji = {
'confirmed': '', 'cancelled': '', 'pending': '', 'expired': ''
}.get(s['outcome'], '')
trader = s['trader'] or '未知'
lev = f"{s['leverage']}x" if s['leverage'] else '?x'
# Extra info
extra = []
if s.get('entry_price'):
extra.append(f"入场{s['entry_price']}")
if s.get('pnl'):
pnl_str = f"{s['pnl']:+,.0f}"
if s.get('pnl_pct'):
pnl_str += f"({s['pnl_pct']:+.1f}%)"
extra.append(f"盈亏{pnl_str}")
if s.get('margin'):
extra.append(f"保证金{s['margin']:,.0f}")
if s.get('margin_mode'):
extra.append(s['margin_mode'])
if s.get('leverage_change'):
extra.append(f"杠杆{s['leverage_change']}")
extra_str = " | " + " ".join(extra) if extra else ""
lines.append(
f"{outcome_emoji} #{s['id']} | {s['time_str']} | "
f"👤{trader} | {s['symbol'] or '?'} {side_cn} | "
f"{lev}{extra_str}"
)
return "\n".join(lines)
def format_stats(stats):
"""Format stats for display."""
lines = ["📊 **信号统计**\n"]
lines.append(f"总计: {stats['total']}")
lines.append(f"今日: {stats['today']}\n")
if stats['by_outcome']:
lines.append("**按结果:**")
for k, v in stats['by_outcome'].items():
emoji = {'confirmed': '', 'cancelled': '', 'pending': '', 'expired': ''}.get(k, '')
lines.append(f" {emoji} {k}: {v}")
if stats['by_side']:
lines.append("\n**按方向:**")
for k, v in stats['by_side'].items():
cn = "做多" if k == 'long' else "做空"
lines.append(f" {cn}: {v}")
if stats['top_symbols']:
lines.append("\n**热门币种:**")
for sym, cnt in stats['top_symbols']:
lines.append(f" {sym}: {cnt}")
return "\n".join(lines)
def format_traders(traders):
"""Format trader stats for display."""
if not traders:
return "📭 暂无交易员数据"
lines = ["👤 **交易员统计**\n"]
for t in traders:
name = t['trader'] or '未知'
lines.append(
f"**{name}**: {t['total']}条信号 | "
f"{t['confirmed']}{t['cancelled']}{t['pending']} | "
f"币种: {t['symbols'] or '-'}"
)
return "\n".join(lines)
def main():
if len(sys.argv) < 2:
print("用法: signal_db.py <log|history|stats|traders|update> [args]")
sys.exit(1)
action = sys.argv[1]
if action == "log":
if len(sys.argv) < 3:
print("用法: signal_db.py log '<raw_text>'")
sys.exit(1)
raw_text = sys.argv[2]
result = log_signal(raw_text)
print(json.dumps(result, ensure_ascii=False))
elif action == "history":
import argparse
# Simple arg parsing
trader = symbol = None
days = 7
limit = 20
for i in range(2, len(sys.argv)):
if sys.argv[i] == "--trader" and i + 1 < len(sys.argv):
trader = sys.argv[i + 1]
elif sys.argv[i] == "--symbol" and i + 1 < len(sys.argv):
symbol = sys.argv[i + 1]
elif sys.argv[i] == "--days" and i + 1 < len(sys.argv):
days = int(sys.argv[i + 1])
elif sys.argv[i] == "--limit" and i + 1 < len(sys.argv):
limit = int(sys.argv[i + 1])
signals = query_history(trader, symbol, days, limit)
print(format_history(signals))
elif action == "stats":
stats = get_summary_stats()
print(format_stats(stats))
elif action == "traders":
traders = get_trader_stats()
print(format_traders(traders))
elif action == "update":
if len(sys.argv) < 4:
print("用法: signal_db.py update <signal_id> <outcome> [detail]")
sys.exit(1)
signal_id = int(sys.argv[2])
outcome = sys.argv[3]
detail = sys.argv[4] if len(sys.argv) > 4 else ""
update_outcome(signal_id, outcome, detail)
print(f"✅ Updated signal #{signal_id}{outcome}")
else:
print(f"Unknown action: {action}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""
信号历史跟踪DB
记录每次确认的信号,用于对比加仓/减仓趋势。
表结构:
- confirmed_signals: 已确认的信号(用户回复Y后记录)
- position_history: 仓位变化历史
"""
import sqlite3
from pathlib import Path
from datetime import datetime
DB_PATH = Path.home() / ".hermes/trading/signal_history.db"
def get_conn():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS confirmed_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
trader TEXT NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
leverage INTEGER,
trader_size REAL,
trader_entry REAL,
trader_pnl REAL,
our_contracts REAL,
our_margin REAL,
our_entry REAL,
outcome TEXT DEFAULT 'confirmed',
raw_text TEXT
);
CREATE TABLE IF NOT EXISTS position_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
trader TEXT NOT NULL,
symbol TEXT NOT NULL,
size REAL NOT NULL,
entry_price REAL,
pnl REAL,
signal_type TEXT
);
CREATE INDEX IF NOT EXISTS idx_confirmed_trader_symbol
ON confirmed_signals(trader, symbol, timestamp);
CREATE INDEX IF NOT EXISTS idx_history_trader_symbol
ON position_history(trader, symbol, timestamp);
""")
conn.commit()
return conn
def record_confirmed(trader, symbol, side, leverage, trader_size, trader_entry, trader_pnl, our_contracts, our_margin, our_entry, raw_text=""):
"""记录已确认的信号"""
conn = init_db()
conn.execute("""
INSERT INTO confirmed_signals
(timestamp, trader, symbol, side, leverage, trader_size, trader_entry, trader_pnl, our_contracts, our_margin, our_entry, raw_text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (datetime.now().isoformat(), trader, symbol, side, leverage,
trader_size, trader_entry, trader_pnl, our_contracts, our_margin, our_entry, raw_text[:2000]))
conn.execute("""
INSERT INTO position_history
(timestamp, trader, symbol, size, entry_price, pnl, signal_type)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (datetime.now().isoformat(), trader, symbol, trader_size, trader_entry, trader_pnl, 'confirmed'))
conn.commit()
conn.close()
def record_signal(trader, symbol, side, leverage, trader_size, trader_entry, trader_pnl, raw_text="", outcome="pushed"):
"""记录推送的信号(不管是否确认)"""
conn = init_db()
conn.execute("""
INSERT INTO confirmed_signals
(timestamp, trader, symbol, side, leverage, trader_size, trader_entry, trader_pnl, outcome, raw_text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (datetime.now().isoformat(), trader, symbol, side, leverage,
trader_size, trader_entry, trader_pnl, outcome, raw_text[:2000]))
conn.execute("""
INSERT INTO position_history
(timestamp, trader, symbol, size, entry_price, pnl, signal_type)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (datetime.now().isoformat(), trader, symbol, trader_size, trader_entry, trader_pnl, outcome))
conn.commit()
conn.close()
def get_last_confirmed(trader, symbol):
"""获取上次确认的信号"""
conn = init_db()
row = conn.execute("""
SELECT * FROM confirmed_signals
WHERE trader = ? AND symbol = ? AND outcome = 'confirmed'
ORDER BY timestamp DESC LIMIT 1
""", (trader, symbol)).fetchone()
conn.close()
return dict(row) if row else None
def get_last_signal(trader, symbol):
"""获取上次推送的信号(不管是否确认)"""
conn = init_db()
row = conn.execute("""
SELECT * FROM confirmed_signals
WHERE trader = ? AND symbol = ?
ORDER BY timestamp DESC LIMIT 1
""", (trader, symbol)).fetchone()
conn.close()
return dict(row) if row else None
def get_position_trend(trader, symbol, limit=5):
"""获取仓位变化趋势"""
conn = init_db()
rows = conn.execute("""
SELECT * FROM position_history
WHERE trader = ? AND symbol = ?
ORDER BY timestamp DESC LIMIT ?
""", (trader, symbol, limit)).fetchall()
conn.close()
return [dict(r) for r in rows]
def compare_position(trader, symbol, current_size):
"""对比当前仓位与上次,返回变化描述"""
last = get_last_signal(trader, symbol)
if not last:
return None, "首次出现"
last_size = last.get('trader_size', 0)
if not last_size or last_size == 0:
return None, "上次仓位未知"
change = current_size - last_size
change_pct = (change / last_size) * 100
if abs(change_pct) < 1:
return last_size, "仓位不变"
elif change > 0:
return last_size, f"加仓 +{change_pct:.1f}%"
else:
return last_size, f"减仓 {change_pct:.1f}%"
def format_comparison(trader, symbol, current_size):
"""格式化对比信息"""
last_size, desc = compare_position(trader, symbol, current_size)
if last_size is None:
return f"{trader} {symbol}: 首次出现,仓位 {current_size:,.0f}"
if "不变" in desc:
return f"{trader} {symbol}: 仓位不变 {current_size:,.0f}"
elif "加仓" in desc:
return f"• 📈 {trader} {symbol}: {last_size:,.0f}{current_size:,.0f}{desc}"
elif "减仓" in desc:
return f"• 📉 {trader} {symbol}: {last_size:,.0f}{current_size:,.0f}{desc}"
else:
return f"{trader} {symbol}: {last_size:,.0f}{current_size:,.0f}{desc}"
# ─── 交易员统计 ──────────────────────────────────────────────────────────
def get_trader_stats(trader=None):
"""获取交易员统计数据"""
conn = init_db()
if trader:
rows = conn.execute("""
SELECT trader, symbol, side, outcome, trader_pnl, timestamp
FROM confirmed_signals
WHERE trader = ?
ORDER BY timestamp DESC
""", (trader,)).fetchall()
else:
rows = conn.execute("""
SELECT trader, symbol, side, outcome, trader_pnl, timestamp
FROM confirmed_signals
ORDER BY trader, timestamp DESC
""").fetchall()
conn.close()
# 按交易员分组
stats = {}
for row in rows:
r = dict(row)
t = r['trader']
if t not in stats:
stats[t] = {
'trader': t,
'total': 0,
'pushed': 0,
'confirmed': 0,
'auto_executed': 0,
'cancelled': 0,
'wins': 0,
'losses': 0,
'total_pnl': 0,
'trades': [],
}
s = stats[t]
s['total'] += 1
outcome = r.get('outcome', 'pushed')
if outcome in s:
s[outcome] += 1
pnl = r.get('trader_pnl', 0) or 0
s['total_pnl'] += pnl
if pnl > 0:
s['wins'] += 1
elif pnl < 0:
s['losses'] += 1
s['trades'].append({
'symbol': r['symbol'],
'side': r['side'],
'pnl': pnl,
'outcome': outcome,
'time': r['timestamp'],
})
# 计算胜率
for t in stats:
s = stats[t]
decided = s['wins'] + s['losses']
s['win_rate'] = (s['wins'] / decided * 100) if decided > 0 else 0
s['avg_pnl'] = (s['total_pnl'] / s['total']) if s['total'] > 0 else 0
return stats
def format_trader_rating(trader):
"""格式化交易员评分(用于推送模板)"""
stats = get_trader_stats(trader)
if trader not in stats or stats[trader]['total'] < 2:
return f"📊 {trader}: 数据不足(信号<2条)"
s = stats[trader]
win_rate = s['win_rate']
total = s['total']
total_pnl = s['total_pnl']
# 评分等级
if win_rate >= 70:
rating = "⭐⭐⭐⭐⭐ 精准"
elif win_rate >= 60:
rating = "⭐⭐⭐⭐ 可靠"
elif win_rate >= 50:
rating = "⭐⭐⭐ 一般"
elif win_rate >= 40:
rating = "⭐⭐ 谨慎"
else:
rating = "⭐ 高风险"
# 最近3笔
recent = s['trades'][:3]
recent_str = "".join([
f"{t['symbol']}{'+' if t['pnl']>0 else ''}{t['pnl']:.0f}"
for t in recent
])
return f"""📊 {trader} 胜率评级: {rating}
• 胜率: {win_rate:.0f}%{s['wins']}胜/{s['losses']}负/{total}总)
• 总盈亏: {'+' if total_pnl>0 else ''}{total_pnl:.0f} USDT
• 最近: {recent_str}"""
def get_all_traders_summary():
"""获取所有交易员的汇总表"""
stats = get_trader_stats()
if not stats:
return "暂无交易员数据"
lines = ["| 交易员 | 胜率 | 总盈亏 | 信号数 |",
"|--------|------|--------|--------|"]
for t, s in sorted(stats.items(), key=lambda x: x[1]['win_rate'], reverse=True):
win_rate = s['win_rate']
total_pnl = s['total_pnl']
emoji = "" * min(5, max(1, int(win_rate / 20)))
lines.append(
f"| {t} | {emoji} {win_rate:.0f}% | {'+' if total_pnl>0 else ''}{total_pnl:.0f} | {s['total']} |"
)
return "\n".join(lines)
# CLI
if __name__ == '__main__':
import sys
if len(sys.argv) < 2:
print("用法:")
print(" python3 signal_tracker.py compare 麻吉大哥 HYPE 12000")
print(" python3 signal_tracker.py history 麻吉大哥 HYPE")
print(" python3 signal_tracker.py record 麻吉大哥 HYPE long 10 12000 70.8 -3500")
print(" python3 signal_tracker.py rating 麻吉大哥")
print(" python3 signal_tracker.py summary")
sys.exit(0)
cmd = sys.argv[1]
if cmd == 'compare' and len(sys.argv) >= 5:
trader = sys.argv[2]
symbol = sys.argv[3]
size = float(sys.argv[4])
print(format_comparison(trader, symbol, size))
elif cmd == 'history' and len(sys.argv) >= 4:
trader = sys.argv[2]
symbol = sys.argv[3]
trend = get_position_trend(trader, symbol)
for t in trend:
print(f" {t['timestamp'][:16]} | {t['size']:,.0f} | {t.get('pnl', 0):+.0f} | {t['signal_type']}")
elif cmd == 'record' and len(sys.argv) >= 8:
trader = sys.argv[2]
symbol = sys.argv[3]
side = sys.argv[4]
leverage = int(sys.argv[5])
size = float(sys.argv[6])
entry = float(sys.argv[7])
pnl = float(sys.argv[8]) if len(sys.argv) > 8 else 0
record_signal(trader, symbol, side, leverage, size, entry, pnl)
print(f"✅ 已记录: {trader} {symbol} {side} {leverage}x {size:,.0f} @{entry}")
elif cmd == 'rating' and len(sys.argv) >= 3:
trader = sys.argv[2]
print(format_trader_rating(trader))
elif cmd == 'summary':
print(get_all_traders_summary())
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""
TG信号监听器(no_agent模式):
从Telegram forwarder数据库读取新信号→调advisor脚本→格式化含📐→推QQ
用法: python3 tg_signal_monitor.py
配合cron: */1 * * * * python3 ~/.hermes/skills/trading/okx-auto-position/scripts/tg_signal_monitor.py
"""
import sqlite3
import json
import subprocess
import sys
import os
import re
from pathlib import Path
from datetime import datetime
# Paths
FORWARDER_DB = "/tmp/forward.db" # TG forwarder DB (docker cp出来)
STATE_FILE = Path.home() / ".hermes/trading/.signal_monitor_state"
SKILL_DIR = Path.home() / ".hermes/skills/trading/okx-auto-position"
ADVISOR_SCRIPT = SKILL_DIR / "scripts" / "okx_position_advisor.py"
FORMAT_SCRIPT = SKILL_DIR / "scripts" / "format_signal.py"
QQ_PUSH = Path.home() / ".hermes/scripts/push_to_qq.sh"
SIGNAL_HISTORY_DB = Path.home() / ".hermes/trading/signal_history.db"
def get_last_msg_id():
"""Read last processed message ID"""
if STATE_FILE.exists():
return int(STATE_FILE.read_text().strip())
return 0
def save_last_msg_id(msg_id):
"""Save last processed message ID"""
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(str(msg_id))
def parse_signal(text):
"""Parse TG signal text, extract key fields"""
# Extract trader name
trader_match = re.search(r'【([^】]{1,20})】', text)
trader = trader_match.group(1) if trader_match else "未知"
# Extract fields
fields = {}
patterns = {
'symbol': r'【币种】\s*[:]?\s*(\S+)',
'side': r'【方向】\s*[:]?\s*(做多|做空)',
'leverage': r'【杠杆】\s*[:]?\s*(\d+)',
'size': r'【仓位大小】\s*[:]?\s*([\d,.]+)',
'value': r'【仓位价值】\s*[:]?\s*\$?\s*([\d,.]+)',
'entry': r'【开仓价】\s*[:]?\s*([\d,.]+)',
'current': r'【当前价】\s*[:]?\s*([\d,.]+)',
'pnl': r'【未实现盈亏】\s*[:]?\s*([-\d,.]+)',
}
for key, pattern in patterns.items():
match = re.search(pattern, text)
if match:
fields[key] = match.group(1).replace(',', '')
return trader, fields
def classify_signal(fields, current_positions):
"""Classify as A(加仓) or C(新开仓)"""
symbol = fields.get('symbol', '').replace('USDT', '').replace('/USDT', '').strip()
for pos in current_positions:
if symbol.upper() in pos['symbol'].upper():
return 'A' # 加仓
return 'C' # 新开仓
def run_format_script(fields, trader, signal_type):
"""Run format_signal.py and return the formatted message"""
symbol = fields.get('symbol', '').replace('USDT', '').replace('/USDT', '').strip()
side = 'long' if fields.get('side', '').startswith('做多') else 'short'
leverage = fields.get('leverage', '10')
size = fields.get('size', '0')
value = fields.get('value', '$0')
entry = fields.get('entry', '0')
pnl = fields.get('pnl', '0')
if not value.startswith('$'):
value = f'${value}'
cmd = [
'python3', str(FORMAT_SCRIPT),
'--symbol', symbol,
'--side', side,
'--leverage', leverage,
'--trader', trader,
'--trader-pos', f'{size} {symbol}',
'--trader-value', value,
'--trader-entry', entry,
'--trader-pnl', pnl,
'--signal-type', signal_type,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
return result.stdout.strip()
else:
return f"⚠️ format_signal.py 错误: {result.stderr.strip()}"
except subprocess.TimeoutExpired:
return "⚠️ format_signal.py 超时"
except Exception as e:
return f"⚠️ 执行错误: {e}"
def push_to_qq(message):
"""Push message to QQ via push_to_qq.sh"""
try:
result = subprocess.run(
['bash', str(QQ_PUSH), message],
capture_output=True, text=True, timeout=15
)
return result.returncode == 0
except:
return False
def log_to_db(trader, symbol, side, leverage, raw_text, outcome='pushed'):
"""Log signal to history database"""
try:
conn = sqlite3.connect(str(SIGNAL_HISTORY_DB))
conn.execute("""
INSERT INTO signals (timestamp, trader, symbol, side, leverage, raw_text, outcome)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (datetime.now().isoformat(), trader, symbol, side, leverage, raw_text, outcome))
conn.commit()
conn.close()
except:
pass
def main():
# Check if forwarder DB exists
if not Path(FORWARDER_DB).exists():
# Try to copy from docker
try:
subprocess.run(
['docker', 'cp', 'telegram-forwarder:/app/db/forward.db', FORWARDER_DB],
capture_output=True, timeout=10
)
except:
print("❌ 无法获取forwarder DB")
return
last_id = get_last_msg_id()
try:
conn = sqlite3.connect(FORWARDER_DB)
conn.row_factory = sqlite3.Row
# Get new messages from the forwarder
cursor = conn.execute("""
SELECT id, message_text, created_at
FROM forwarded_messages
WHERE id > ? AND chat_id = '-1003966251111'
ORDER BY id ASC
LIMIT 10
""", (last_id,))
messages = cursor.fetchall()
conn.close()
if not messages:
return # No new messages, silent exit
for msg in messages:
text = msg['message_text'] or ''
msg_id = msg['id']
# Skip non-signal messages
if '【币种】' not in text and '【方向】' not in text:
save_last_msg_id(msg_id)
continue
# Parse signal
trader, fields = parse_signal(text)
if not fields.get('symbol') or not fields.get('side'):
save_last_msg_id(msg_id)
continue
# Classify (simplified - always treat as new for now)
signal_type = 'C'
# Run format_signal.py
message = run_format_script(fields, trader, signal_type)
if message and '⚠️' not in message:
# Push to QQ
success = push_to_qq(message)
# Log to DB
symbol = fields.get('symbol', '').replace('USDT', '').strip()
side = 'long' if fields.get('side', '').startswith('做多') else 'short'
log_to_db(trader, symbol, side, fields.get('leverage', '10'), text,
'pushed' if success else 'push_failed')
save_last_msg_id(msg_id)
except sqlite3.OperationalError as e:
print(f"❌ DB错误: {e}")
except Exception as e:
print(f"❌ 错误: {e}")
if __name__ == '__main__':
main()
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""
交易通知器 - 带Inline Keyboard按钮的推送
用法:
python3 trade_notifier.py notify '{"symbol":"BTC","side":"long","leverage":10,...}'
python3 trade_notifier.py callback <callback_data> # 处理按钮点击
"""
import os
import re
import sys
import json
import time
import requests
def _load_env():
"""Load TELEGRAM_BOT_TOKEN from ~/.hermes/.env"""
env_path = os.path.expanduser("~/.hermes/.env")
with open(env_path) as f:
for line in f:
m = re.match(r'TELEGRAM_BOT_TOKEN=(.*)', line.strip())
if m:
return m.group(1).strip()
return ''
BOT_TOKEN = _load_env()
PROXY = 'http://127.0.0.1:7890'
PENDING_DIR = os.path.expanduser("~/.hermes/trading/pending")
CALLBACK_LOG = os.path.expanduser("~/.hermes/trading/callbacks.jsonl")
os.makedirs(PENDING_DIR, exist_ok=True)
def send_message_with_buttons(chat_id, text, buttons=None):
"""Send message, optionally with inline keyboard buttons"""
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
}
if buttons:
payload["reply_markup"] = json.dumps({"inline_keyboard": buttons})
resp = requests.post(url, data=payload, proxies={"https": PROXY, "http": PROXY}, timeout=15)
return resp.json()
def edit_message_buttons(chat_id, message_id, text, buttons=None):
"""Edit message text and optionally update buttons"""
url = f"https://api.telegram.org/bot{BOT_TOKEN}/editMessageText"
payload = {
"chat_id": chat_id,
"message_id": message_id,
"text": text,
"parse_mode": "Markdown",
}
if buttons:
payload["reply_markup"] = json.dumps({"inline_keyboard": buttons})
resp = requests.post(url, data=payload, proxies={"https": PROXY, "http": PROXY}, timeout=15)
return resp.json()
def answer_callback(callback_query_id, text=""):
"""Answer callback query to remove loading state"""
url = f"https://api.telegram.org/bot{BOT_TOKEN}/answerCallbackQuery"
payload = {"callback_query_id": callback_query_id}
if text:
payload["text"] = text
resp = requests.post(url, data=payload, proxies={"https": PROXY, "http": PROXY}, timeout=10)
return resp.json()
def format_recommendation(rec):
"""Format recommendation for display"""
symbol = rec.get("symbol", "?")
side_cn = rec.get("side_cn", "做多" if rec.get("side") in ("long", "buy") else "做空")
leverage = rec.get("leverage", 10)
contracts = rec.get("contracts", 0)
entry = rec.get("price", 0)
tp = rec.get("tp_price", 0)
sl = rec.get("sl_price", 0)
margin = rec.get("margin", 0)
margin_pct = rec.get("margin_pct", 0)
tp_pct = rec.get("tp_pct", 0) # 标的价格变动%
sl_pct = rec.get("sl_pct", 0)
tp_pnl = rec.get("tp_pnl", 0)
sl_pnl = rec.get("sl_pnl", 0)
rr = rec.get("rr", 0)
liq_price = rec.get("liq_price", 0)
liq_pct = rec.get("liq_pct", 0)
balance = rec.get("acct_free", 0)
# 保证金收益率
tp_margin_pct = (tp_pnl / margin * 100) if margin > 0 else 0
sl_margin_pct = (sl_pnl / margin * 100) if margin > 0 else 0
lines = [
f"📊 *{symbol} {side_cn}* — 仓位推荐",
"",
f"💰 可用余额: {balance:.2f} USDT",
f"📈 当前价: *{entry}*",
"",
"*开仓方案:*",
f"• 方向: {side_cn}",
f"• 杠杆: *{leverage}x*",
f"• 张数: *{contracts}张*",
f"• 保证金: {margin:.2f} USDT ({margin_pct:.0f}%)",
"",
"*止盈止损:*",
f"• 🎯 止盈: *{tp}* (保证金+{tp_margin_pct:.0f}%) → +{tp_pnl:.2f} USDT",
f"• 🛑 止损: *{sl}* (保证金-{sl_margin_pct:.0f}%) → -{sl_pnl:.2f} USDT",
f"• 📐 盈亏比: *{rr}:1*",
]
if liq_price:
lines.append(f"• ⚠️ 清算价: {liq_price} (距离 {liq_pct}%)")
return "\n".join(lines)
def notify(chat_id, rec_json):
"""Send trade recommendation (text only, no buttons to avoid polling conflict)"""
rec = json.loads(rec_json) if isinstance(rec_json, str) else rec_json
symbol = rec.get("symbol", "").split("/")[0].replace("USDT", "")
side = rec.get("side", "long")
# Save pending
pending_path = os.path.join(PENDING_DIR, f"{symbol}.json")
with open(pending_path, "w") as f:
json.dump({"symbol": symbol, "side": side, "rec": rec, "timestamp": time.time()}, f)
text = format_recommendation(rec)
# No buttons - use text Y/N reply instead (avoids getUpdates conflict with gateway)
result = send_message_with_buttons(chat_id, text, None)
return result
def handle_callback(callback_data, chat_id, message_id, callback_query_id):
"""Handle button click"""
action, symbol = callback_data.split(":", 1)
# Log callback
with open(CALLBACK_LOG, "a") as f:
f.write(json.dumps({"action": action, "symbol": symbol, "time": time.time(), "chat_id": chat_id}) + "\n")
if action == "trade_confirm":
# Load pending
pending_path = os.path.join(PENDING_DIR, f"{symbol}.json")
if not os.path.exists(pending_path):
answer_callback(callback_query_id, "❌ 未找到待确认交易")
return {"error": "no pending"}
with open(pending_path) as f:
pending = json.load(f)
rec = pending["rec"]
# Execute trade
import subprocess
script_dir = os.path.dirname(os.path.abspath(__file__))
advisor = os.path.join(script_dir, "okx_position_advisor.py")
rec_str = json.dumps(rec, ensure_ascii=False)
cmd = ["bash", "-c", f"source ~/.bashrc && python3 {advisor} --symbol {symbol} --side {rec.get('side','long')} --execute --json --rec-json '{rec_str}'"]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
# Remove pending
os.remove(pending_path)
if result.returncode != 0:
answer_callback(callback_query_id, "❌ 下单失败")
edit_message_buttons(chat_id, message_id, f"❌ *{symbol} 下单失败*\n\n{result.stderr[:200]}")
return {"error": result.stderr}
try:
exec_result = json.loads(result.stdout)
except:
exec_result = {"raw": result.stdout}
# Format result
side_cn = "做多" if rec.get("side") in ("long", "buy") else "做空"
result_text = f"✅ *{symbol} {side_cn} 开仓成功*\n\n"
for step in exec_result.get("steps", []):
if step.get("status") == "ok":
if step["step"] == "leverage":
result_text += "✅ 杠杆设置成功\n"
elif step["step"] == "order":
result_text += f"✅ 下单成功 (ID: {step.get('order_id', '?')})\n"
elif step["step"] == "tp_sl":
result_text += f"✅ 止盈止损设置成功\n"
pos = exec_result.get("position")
if pos:
pnl_emoji = "🟢" if pos.get("pnl", 0) >= 0 else "🔴"
result_text += f"\n📊 *持仓确认:*\n"
result_text += f"• 数量: {pos.get('contracts', '?')}\n"
result_text += f"• 入场价: *{pos.get('entry', '?')}*\n"
result_text += f"{pnl_emoji} 浮盈: {pos.get('pnl', 0):.2f} USDT\n"
algo = exec_result.get("algo")
if algo:
result_text += f"\n🎯 止盈: *{algo.get('tp', '?')}*\n"
result_text += f"🛑 止损: *{algo.get('sl', '?')}*\n"
answer_callback(callback_query_id, "✅ 已下单")
edit_message_buttons(chat_id, message_id, result_text)
return exec_result
elif action == "trade_cancel":
# Remove pending
pending_path = os.path.join(PENDING_DIR, f"{symbol}.json")
if os.path.exists(pending_path):
os.remove(pending_path)
answer_callback(callback_query_id, "❌ 已取消")
edit_message_buttons(chat_id, message_id, f"❌ *{symbol} 交易已取消*")
return {"cancelled": True}
def main():
if len(sys.argv) < 2:
print("用法: trade_notifier.py notify|callback [args]")
sys.exit(1)
action = sys.argv[1]
if action == "notify":
if len(sys.argv) < 4:
print("用法: trade_notifier.py notify <chat_id> <rec_json>")
sys.exit(1)
chat_id = sys.argv[2]
rec_json = sys.argv[3]
result = notify(chat_id, rec_json)
print(json.dumps(result, ensure_ascii=False))
elif action == "callback":
if len(sys.argv) < 6:
print("用法: trade_notifier.py callback <callback_data> <chat_id> <message_id> <callback_query_id>")
sys.exit(1)
callback_data = sys.argv[2]
chat_id = sys.argv[3]
message_id = sys.argv[4]
callback_query_id = sys.argv[5]
result = handle_callback(callback_data, chat_id, message_id, callback_query_id)
print(json.dumps(result, ensure_ascii=False, default=str))
else:
print(f"Unknown action: {action}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""
交易信号处理器 - 一体化脚本
用法:
python3 trade_signal_handler.py signal "【币种】BTCUSDT|永续|10x\n【方向】做多\n【仓位】0.5 BTC"
python3 trade_signal_handler.py confirm BTC
python3 trade_signal_handler.py cancel BTC
python3 trade_signal_handler.py status
"""
import re
import os
import sys
import json
import time
import glob
import subprocess
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ADVISOR_SCRIPT = os.path.join(SCRIPT_DIR, "okx_position_advisor.py")
SIGNAL_DB_SCRIPT = os.path.join(SCRIPT_DIR, "signal_db.py")
PENDING_DIR = os.path.expanduser("~/.hermes/trading/pending")
os.makedirs(PENDING_DIR, exist_ok=True)
def log_signal_to_db(signal_text):
"""Log signal to history database, return signal_id or None"""
try:
result = subprocess.run(
[sys.executable, SIGNAL_DB_SCRIPT, "log", signal_text],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
return json.loads(result.stdout).get("id")
except Exception:
pass
return None
def update_signal_outcome(signal_id, outcome, detail=""):
"""Update signal outcome in database"""
if not signal_id:
return
try:
subprocess.run(
[sys.executable, SIGNAL_DB_SCRIPT, "update", str(signal_id), outcome, detail],
capture_output=True, text=True, timeout=10,
)
except Exception:
pass
def parse_signal(text):
"""Parse trading signal text, extract symbol/direction/leverage/size"""
result = {}
# 币种: BTCUSDT|永续|10x or 【币种】BTCUSDT
symbol_match = re.search(r'(?:【币种】|币种[:]\s*)(\w+)', text)
if not symbol_match:
symbol_match = re.search(r'([A-Z]{2,10})USDT', text)
if symbol_match:
raw = symbol_match.group(1).upper()
raw = raw.replace("USDT", "").replace("/USDT", "").replace(":USDT", "")
result["symbol"] = raw
else:
return None
# 方向
if re.search(r'(做空|卖出|short|sell|空单|开空)', text, re.IGNORECASE):
result["side"] = "short"
elif re.search(r'(做多|买入|long|buy|多单|开多)', text, re.IGNORECASE):
result["side"] = "long"
else:
return None
# 杠杆
lev_match = re.search(r'(\d+)\s*[xX倍]', text)
result["leverage"] = int(lev_match.group(1)) if lev_match else 10
# 仓位数量
size_match = re.search(r'(?:【仓位】|仓位[:]\s*)([\d,.]+)\s*(\w+)', text)
if size_match:
result["raw_size"] = float(size_match.group(1).replace(",", ""))
result["raw_unit"] = size_match.group(2)
# 是否加仓/平仓
result["is_add"] = bool(re.search(r'(加仓|追仓)', text))
result["is_close"] = bool(re.search(r'(平仓|止盈|止损|close|全平)', text, re.IGNORECASE))
return result
def save_pending(symbol, rec_json, signal_text, signal_id=None):
"""Save pending recommendation to file"""
path = os.path.join(PENDING_DIR, f"{symbol.upper()}.json")
data = {
"symbol": symbol.upper(),
"rec": rec_json,
"signal": signal_text,
"signal_id": signal_id,
"timestamp": time.time(),
"time_str": time.strftime("%Y-%m-%d %H:%M:%S"),
}
with open(path, "w") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return path
def load_pending(symbol):
"""Load pending recommendation"""
path = os.path.join(PENDING_DIR, f"{symbol.upper()}.json")
if not os.path.exists(path):
return None
with open(path) as f:
return json.load(f)
def remove_pending(symbol):
"""Remove pending recommendation"""
path = os.path.join(PENDING_DIR, f"{symbol.upper()}.json")
if os.path.exists(path):
os.remove(path)
def run_advisor(symbol, side, leverage):
"""Run the advisor script and return JSON result"""
cmd = [
sys.executable, ADVISOR_SCRIPT,
"--symbol", symbol,
"--side", side,
"--leverage", str(leverage),
"--json",
]
env = os.environ.copy()
# Source bashrc to get OKX credentials
result = subprocess.run(
["bash", "-c", f"source ~/.bashrc && {' '.join(cmd)}"],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
return {"error": result.stderr.strip() or "Advisor script failed"}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"error": f"Invalid JSON output: {result.stdout[:200]}"}
def execute_trade(rec_json):
"""Execute the trade using the advisor script"""
import shlex
rec_str = json.dumps(rec_json, ensure_ascii=False)
symbol = rec_json.get("symbol", "").split("/")[0]
side = rec_json.get("side", "")
cmd = f"source ~/.bashrc && python3 {ADVISOR_SCRIPT} --symbol {symbol} --side {side} --execute --json --rec-json {shlex.quote(rec_str)}"
result = subprocess.run(
["bash", "-c", cmd],
capture_output=True, text=True, timeout=60,
)
if result.returncode != 0:
return {"error": result.stderr.strip() or "Execution failed"}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"raw": result.stdout.strip()}
def format_recommendation(rec, signal_text=""):
"""Format recommendation for user display"""
symbol = rec.get("symbol", "?")
side = rec.get("side", "?")
side_cn = "做多" if side in ("long", "buy") else "做空"
leverage = rec.get("leverage", 10)
contracts = rec.get("contracts", 0)
entry = rec.get("entry_price", 0)
tp = rec.get("tp_price", 0)
sl = rec.get("sl_price", 0)
margin = rec.get("margin_used", 0)
balance = rec.get("balance", 0)
margin_pct = rec.get("margin_pct", 0)
tp_pct = rec.get("tp_pct", 0) # 标的价格变动%
sl_pct = rec.get("sl_pct", 0)
tp_pnl = rec.get("tp_pnl", 0)
sl_pnl = rec.get("sl_pnl", 0)
# 保证金收益率
tp_margin_pct = (tp_pnl / margin * 100) if margin > 0 else 0
sl_margin_pct = (sl_pnl / margin * 100) if margin > 0 else 0
liq_price = rec.get("liq_price", 0)
liq_pct = rec.get("liq_pct", 0)
rr = rec.get("rr_ratio", 0)
lines = [
f"📊 **{symbol}USDT {side_cn}** - 仓位推荐",
"",
f"💰 可用余额: {balance:.2f} USDT",
f"📈 当前价: **{entry}**",
"",
"**开仓方案:**",
f"• 方向: {side_cn}",
f"• 杠杆: **{leverage}x**",
f"• 张数: **{contracts}张**",
f"• 保证金: {margin:.2f} USDT ({margin_pct:.0f}%)",
"",
"**止盈止损:**",
f"• 🎯 止盈: **{tp}** (保证金+{tp_margin_pct:.0f}%) → +{tp_pnl:.2f} USDT",
f"• 🛑 止损: **{sl}** (保证金-{sl_margin_pct:.0f}%) → -{sl_pnl:.2f} USDT",
f"• 📐 盈亏比: **{rr:.1f}:1**",
]
if liq_price:
lines.append(f"• ⚠️ 清算价: {liq_price} (距离 {liq_pct:.1f}%)")
lines.extend([
"",
"回复 **Y** 确认下单",
"回复 **N** 取消",
])
return "\n".join(lines)
def format_execution_result(result, symbol, side):
"""Format execution result for user display"""
if "error" in result:
return f"❌ **{symbol}USDT 下单失败**\n\n{result['error']}"
side_cn = "做多" if side in ("long", "buy") else "做空"
lines = [f"✅ **{symbol}USDT {side_cn} 开仓成功**"]
# Parse steps
for step in result.get('steps', []):
if step['step'] == 'leverage':
if step['status'] == 'ok':
lines.append("✅ 杠杆设置成功")
else:
lines.append(f"⚠️ 杠杆: {step.get('msg', '')}")
elif step['step'] == 'order':
if step['status'] == 'ok':
lines.append(f"✅ 下单成功 (ID: {step['order_id']})")
else:
lines.append(f"❌ 下单失败: {step.get('msg', '')}")
return '\n'.join(lines)
elif step['step'] == 'tp_sl':
if step['status'] == 'ok':
lines.append(f"✅ 止盈止损设置成功 (ID: {step['algo_id']})")
else:
lines.append(f"⚠️ 止盈止损: {step.get('msg', '')}")
# Position info
pos = result.get('position')
if pos:
pnl_emoji = "🟢" if pos.get('pnl', 0) >= 0 else "🔴"
lines.extend([
"",
"📊 **持仓确认:**",
f"• 方向: {side_cn}",
f"• 数量: {pos.get('contracts', '?')}",
f"• 入场价: **{pos.get('entry', '?')}**",
f"{pnl_emoji} 浮盈: {pos.get('pnl', 0):.2f} USDT",
])
# TP/SL info
algo = result.get('algo')
if algo:
lines.extend([
"",
"🎯 **止盈止损:**",
f"• 止盈: **{algo.get('tp', '?')}**",
f"• 止损: **{algo.get('sl', '?')}**",
])
return "\n".join(lines)
def main():
if len(sys.argv) < 2:
print("用法: trade_signal_handler.py <signal|confirm|cancel|status> [args]")
sys.exit(1)
action = sys.argv[1]
if action == "signal":
if len(sys.argv) < 3:
print("用法: trade_signal_handler.py signal '<signal_text>'")
sys.exit(1)
signal_text = sys.argv[2]
parsed = parse_signal(signal_text)
if not parsed:
print(json.dumps({"error": "无法解析信号", "raw": signal_text}))
sys.exit(1)
if parsed.get("is_close"):
# 平仓信号
print(json.dumps({"action": "close", "symbol": parsed["symbol"]}))
sys.exit(0)
# 记录信号到数据库
signal_id = log_signal_to_db(signal_text)
# 计算仓位
rec = run_advisor(parsed["symbol"], parsed["side"], parsed["leverage"])
if "error" in rec:
if signal_id:
update_signal_outcome(signal_id, "error", rec["error"])
print(json.dumps(rec))
sys.exit(1)
# 保存待确认
save_pending(parsed["symbol"], rec, signal_text, signal_id)
# 输出推荐
output = {
"action": "recommend",
"symbol": parsed["symbol"],
"side": parsed["side"],
"recommendation": rec,
"display": format_recommendation(rec, signal_text),
}
print(json.dumps(output, ensure_ascii=False))
elif action == "confirm":
if len(sys.argv) < 3:
print("用法: trade_signal_handler.py confirm <SYMBOL>")
sys.exit(1)
symbol = sys.argv[2].upper().replace("USDT", "")
pending = load_pending(symbol)
if not pending:
print(json.dumps({"error": f"没有待确认的 {symbol} 交易"}))
sys.exit(1)
rec = pending["rec"]
signal_id = pending.get("signal_id")
result = execute_trade(rec)
# Only remove pending if execution succeeded
if not result.get("error"):
remove_pending(symbol)
if signal_id:
update_signal_outcome(signal_id, "confirmed", json.dumps(result, ensure_ascii=False)[:500])
else:
if signal_id:
update_signal_outcome(signal_id, "error", result.get("error", "")[:200])
output = {
"action": "executed",
"symbol": symbol,
"side": rec.get("side"),
"result": result,
"display": format_execution_result(result, symbol, rec.get("side")),
}
print(json.dumps(output, ensure_ascii=False))
elif action == "cancel":
if len(sys.argv) < 3:
print("用法: trade_signal_handler.py cancel <SYMBOL>")
sys.exit(1)
symbol = sys.argv[2].upper().replace("USDT", "")
pending = load_pending(symbol)
signal_id = pending.get("signal_id") if pending else None
remove_pending(symbol)
if signal_id:
update_signal_outcome(signal_id, "cancelled")
print(json.dumps({"action": "cancelled", "symbol": symbol}))
elif action == "status":
pending_files = glob.glob(os.path.join(PENDING_DIR, "*.json"))
if not pending_files:
print(json.dumps({"pending": []}))
else:
pending = []
for f in pending_files:
with open(f) as fh:
d = json.load(fh)
pending.append({
"symbol": d["symbol"],
"side": d["rec"].get("side"),
"time": d.get("time_str", d.get("timestamp", "unknown")),
})
print(json.dumps({"pending": pending}, ensure_ascii=False))
elif action == "history":
# Forward to signal_db.py
result = subprocess.run(
[sys.executable, SIGNAL_DB_SCRIPT, "history"] + sys.argv[2:],
capture_output=True, text=True, timeout=10,
)
print(result.stdout)
if result.returncode != 0 and result.stderr:
print(result.stderr, file=sys.stderr)
elif action == "stats":
result = subprocess.run(
[sys.executable, SIGNAL_DB_SCRIPT, "stats"],
capture_output=True, text=True, timeout=10,
)
print(result.stdout)
elif action == "traders":
result = subprocess.run(
[sys.executable, SIGNAL_DB_SCRIPT, "traders"],
capture_output=True, text=True, timeout=10,
)
print(result.stdout)
else:
print(f"Unknown action: {action}")
sys.exit(1)
if __name__ == "__main__":
main()