Type/to search

KOL蒸馏共识策略 v4.0

Common strategy
Created: 2026-04-29 11:33:44
Last modified: a month ago
2
Follow
496
Followers

KOL 蒸馏共识策略 v4.0

策略简介

本策略基于 crypto-kol-quant 项目的研究思路,将 99 位加密交易员的交易经验、能力偏好和历史有效性,转化为可计算的共识信号,并在发明者量化平台中实现自动运行。

策略并不是简单的 KOL 情绪跟单,也不是统计市场上看多或看空声音的数量,而是尝试把交易员的判断框架拆解成一组能力因子,再结合当前市场状态,模拟不同交易员在当下行情中的判断,最后根据历史 IC 权重聚合成一个多空共识信号。

策略默认以 BTC 日线为主要分析对象,适合中低频趋势判断和研究型实盘验证。

核心逻辑

  1. 读取交易员画像与历史 IC 权重

    策略会优先读取本地缓存中的交易员画像文件和历史 IC 数据。如果本地没有缓存,则从 GitHub 项目 crypto-kol-quant 自动下载 99 位交易员画像和对应的历史有效性权重。

  2. 构建市场状态变量

    策略使用 BTC 日线 K 线构建趋势、动量、波动率和价格位置等特征,包括均线、EMA、RSI、MACD、区间高低点、波动率等指标。

  3. 引入宏观背景数据

    除了价格本身,策略还会获取 DXY、黄金、美债收益率、标普指数等宏观数据,用于判断美元环境、风险偏好和流动性状态。

  4. 计算能力因子

    策略将交易员常见的判断方式抽象为能力因子,例如趋势延续、均线结构、RSI 背离、布林压缩突破、Wyckoff、SMC、ICT、宏观风险偏好等。

  5. 映射回交易员个人信号

    每位交易员画像中都有不同的能力偏好和权重。策略不会直接把所有因子简单相加,而是根据每位交易员关注的能力因子,计算其在当前市场下的个人多空信号。

  6. 生成 KOL 共识信号

    策略会根据交易员历史 IC 对个人信号进行加权聚合,输出多个共识结果,其中最核心的是 trust_adjusted 信号。正 IC 交易员顺向使用,负 IC 交易员可作为反向指标使用。

  7. 自动交易执行

    trust_adjusted 高于开多阈值时,策略开多;低于开空阈值时,策略开空;当信号回到中性区间或反向突破阈值时,策略平仓。

  8. 风控与仪表板

    策略内置止损、止盈、紧急减仓、模拟盘/实盘切换、净值曲线、交易日志和多维度信号展示,便于观察共识信号、交易员贡献和账户表现。

信号解释

信号含义
trust_adjusted核心交易信号,根据交易员个人信号和历史 IC 调整后得到
ic_weighted仅对正 IC 交易员进行加权后的共识信号
equal_mean所有交易员信号的等权平均
long_count当前偏多交易员数量
short_count当前偏空交易员数量
neutral_count当前中性交易员数量
by_school不同交易学派的信号分布
top_traders当前贡献较高的顺向交易员
reverse_traders当前可作为反向参考的交易员

交易规则

条件动作
trust_adjusted > LONG_THRESH开多
trust_adjusted < SHORT_THRESH开空
持多时信号转弱或反向平多
持空时信号转弱或反向平空
达到止损比例强制止损
达到止盈比例强制止盈
出现短时间不利剧烈波动紧急减仓

主要参数

参数默认值说明
TRADE_MODEpaper交易模式,支持模拟盘和实盘
SYMBOLBTC_USDT.swap交易合约
INIT_CAPITAL10000模拟盘初始资金
LONG_THRESH0.05开多阈值
SHORT_THRESH-0.05开空阈值
CLOSE_THRESH0.02平仓阈值
POSITION_RATIO0.90仓位使用比例
LEVERAGE2杠杆倍数
STOP_LOSS_PCT0.06止损比例
TAKE_PROFIT_PCT0.18止盈比例
REBALANCE_HOURS24再平衡间隔
LOOKBACK_BARS600K 线回看条数
MACRO_LOOKBACK_DAYS90宏观数据回看天数

策略特点

  • 将交易员经验拆解为可计算的能力因子。
  • 不是简单情绪统计,而是基于交易员画像重建个人判断。
  • 使用历史 IC 对交易员信号进行加权。
  • 支持正向交易员和反向交易员共同参与共识计算。
  • 同时结合技术面、结构面、周期面和宏观变量。
  • 支持模拟盘、实盘、止损止盈、紧急减仓和仪表板展示。
  • 适合中低频研究和共识信号验证,不适合高频交易。

风险提示

  • 本策略属于研究型策略,核心价值在于验证“交易经验蒸馏为共识信号”的可行性,并不代表已经具备稳定盈利能力。
  • 当前交易员画像和 IC 权重大多是静态输入,尚未实现在线自适应更新。
  • 策略主要基于 BTC 日线框架,信号频率较低,不适合短线高频交易。
  • 宏观数据依赖外部接口,可能出现请求失败、延迟或数据缺失。
  • KOL 能力画像并不等于真实交易员当前观点,策略模拟的是交易框架,而不是实时观点。
  • 使用实盘前建议先长期模拟运行,观察信号稳定性、换手频率、回撤和滑点影响。
  • 杠杆交易存在爆仓风险,应根据账户规模降低仓位和杠杆。

适用场景

本策略适合希望研究“交易员经验蒸馏”“KOL 共识信号”“多因子认知聚合”的用户,也适合用于观察不同交易框架在 BTC 日线市场中的综合判断。

它更像是一个可运行的研究原型,而不是传统意义上的成熟交易系统。使用者可以基于该框架继续优化能力因子、交易员权重、IC 更新机制、风控模块和多品种扩展能力。

相关文章

Source
Python
'''
================================================================
 KOL蒸馏共识策略 v4.0  ·  发明者量化(FMZ) Python版

 默认优先读取本地缓存:
   /tmp/crypto_kol_quant_cache/profiles_v2.json
   /tmp/crypto_kol_quant_cache/trader_composite_ic.csv

 首次无缓存时自动从 GitHub 下载:
   profiles_v2/*.json (99份交易员画像)
   quant_factors/trader_composite_ic.csv (IC权重)
   https://github.com/0xquqi/crypto-kol-quant

 完整移植 crypto-kol-quant 项目:
   ① 实时 BTC 日线K线 (FMZ exchange.GetRecords)
   ② 实时宏观数据 DXY/GOLD/US2Y/SPX (Yahoo Finance)
   ③ 50+技术特征 + 宏观特征 (feature_engine.py)
   ④ 全部68个有效因子 (factor IDs 与 profiles 完全对齐)
   ⑤ 99交易员共识: trust_adjusted / ic_weighted / by_school
   ⑥ 完整交易: 模拟/实盘 + 止损止盈 + 紧急减仓 + 仪表板
================================================================
'''

import json, os, csv, math, time, io, sys, importlib, subprocess
import urllib.request
from datetime import datetime, timezone

# ================================================================
#  依赖自动安装 (兼容 FMZ Python 3.6)
# ================================================================
DEPS_DIR = "/tmp/fmz_pydeps"

def _safe_log(*args):
    try:
        Log(*args)
    except:
        try:
            print(*args)
        except:
            pass

def _ensure_deps_dir():
    try:
        if not os.path.exists(DEPS_DIR):
            os.makedirs(DEPS_DIR)
    except Exception as e:
        _safe_log("创建依赖目录失败:", str(e))
    if DEPS_DIR not in sys.path:
        sys.path.insert(0, DEPS_DIR)

def _run_cmd(cmd):
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    out, _ = p.communicate()
    text = out.decode("utf-8", errors="ignore") if out else ""
    if text:
        for line in text.splitlines()[-20:]:
            _safe_log(line)
    if p.returncode != 0:
        raise Exception("命令失败: {} | code={}".format(" ".join(cmd), p.returncode))

def _ensure_package(import_name, pip_spec):
    _ensure_deps_dir()
    importlib.invalidate_caches()
    try:
        return importlib.import_module(import_name)
    except ImportError:
        _safe_log("⚠️ 缺少依赖: {},尝试安装到 {}".format(pip_spec, DEPS_DIR))

    cmd = [
        sys.executable, "-m", "pip", "install",
        "--no-cache-dir",
        "--target", DEPS_DIR,
        pip_spec
    ]

    try:
        _safe_log("执行: {}".format(" ".join(cmd)))
        _run_cmd(cmd)
        importlib.invalidate_caches()
        if DEPS_DIR not in sys.path:
            sys.path.insert(0, DEPS_DIR)
        module = importlib.import_module(import_name)
        _safe_log("✅ 安装成功: {}".format(pip_spec))
        return module
    except Exception as e:
        raise Exception("依赖安装失败: {} | {}".format(pip_spec, e))

np = _ensure_package("numpy", "numpy==1.19.5")
pd = _ensure_package("pandas", "pandas==1.1.5")

# ================================================================
#  GitHub 数据源 + 本地缓存
# ================================================================
GITHUB_RAW = "https://raw.githubusercontent.com/0xquqi/crypto-kol-quant/main"
GITHUB_API = "https://api.github.com/repos/0xquqi/crypto-kol-quant/contents"

CACHE_DIR = "/tmp/crypto_kol_quant_cache"
PROFILES_CACHE_FILE = os.path.join(CACHE_DIR, "profiles_v2.json")
IC_CACHE_FILE = os.path.join(CACHE_DIR, "trader_composite_ic.csv")
USE_REMOTE_LATEST = False

def _http_get(url, timeout=30):
    req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
    return urllib.request.urlopen(req, timeout=timeout).read()

def _ensure_cache_dir():
    try:
        if not os.path.exists(CACHE_DIR):
            os.makedirs(CACHE_DIR)
    except Exception as e:
        Log("⚠️ 创建缓存目录失败: {}".format(e))

def _read_text(path):
    with io.open(path, "r", encoding="utf-8") as f:
        return f.read()

def _write_text(path, text):
    _ensure_cache_dir()
    with io.open(path, "w", encoding="utf-8") as f:
        f.write(text)

def _read_json(path):
    return json.loads(_read_text(path))

def _write_json(path, obj):
    _write_text(path, json.dumps(obj, ensure_ascii=False))

# ================================================================
#  FMZ 参数面板变量
# ================================================================
try:    TRADE_MODE
except: TRADE_MODE = "paper"

try:    SYMBOL
except: SYMBOL = "BTC_USDT.swap"

try:    INIT_CAPITAL
except: INIT_CAPITAL = 10000.0

try:    LONG_THRESH
except: LONG_THRESH = 0.05

try:    SHORT_THRESH
except: SHORT_THRESH = -0.05

try:    CLOSE_THRESH
except: CLOSE_THRESH = 0.02

try:    POSITION_RATIO
except: POSITION_RATIO = 0.90

try:    LEVERAGE
except: LEVERAGE = 2.0

try:    STOP_LOSS_PCT
except: STOP_LOSS_PCT = 0.06

try:    TAKE_PROFIT_PCT
except: TAKE_PROFIT_PCT = 0.18

try:    REBALANCE_HOURS
except: REBALANCE_HOURS = 24

try:    LOOKBACK_BARS
except: LOOKBACK_BARS = 600

try:    MACRO_LOOKBACK_DAYS
except: MACRO_LOOKBACK_DAYS = 90

FAST_INTERVAL = 15 * 60 * 1000
MID_INTERVAL  = 30 * 60 * 1000
SLOW_INTERVAL = 60 * 60 * 1000
PNL_INTERVAL  =      60 * 1000
EMERGENCY_DROP   = 0.05
EMERGENCY_REDUCE = 0.50

# ================================================================
#  一、宏观数据获取 (Yahoo Finance)
# ================================================================
YAHOO_SYMBOLS = {
    'DXY':  'DX-Y.NYB',
    'GOLD': 'GC=F',
    'US2Y': '^TNX',
    'SPX':  '^GSPC',
}

def fetch_yahoo(yf_symbol, days=120):
    end_ts   = int(time.time())
    start_ts = end_ts - days * 86400
    url = ("https://query1.finance.yahoo.com/v8/finance/chart/{}"
           "?period1={}&period2={}&interval=1d".format(yf_symbol, start_ts, end_ts))
    try:
        data   = json.loads(_http_get(url))
        r      = data['chart']['result'][0]
        ts     = r['timestamp']
        closes = r['indicators']['quote'][0]['close']
        result = {}
        for i, t in enumerate(ts):
            c = closes[i]
            if c is not None:
                result[datetime.utcfromtimestamp(t).strftime('%Y-%m-%d')] = float(c)
        return result
    except Exception as e:
        Log("⚠️ Yahoo Finance [{}] 失败: {}".format(yf_symbol, e))
        return {}

def fetch_macro(days=120):
    macro = {}
    for name, sym in YAHOO_SYMBOLS.items():
        raw = fetch_yahoo(sym, days)
        if raw:
            s = pd.Series(raw)
            s.index = pd.to_datetime(s.index, utc=True)
            macro[name] = s.sort_index()
            Log("✅ 宏观[{}] {} 条 最新{}".format(name, len(s), s.index[-1].date()))
        else:
            Log("⚠️ 宏观[{}] 空".format(name))
    return macro

# ================================================================
#  二、特征工程 (feature_engine.py)
# ================================================================
def _sma(s, n):  return s.rolling(n, min_periods=1).mean()
def _ema(s, n):  return s.ewm(span=n, adjust=False).mean()

def _rsi(s, n=14):
    diff = s.diff()
    ag = diff.clip(lower=0).ewm(alpha=1/n, min_periods=n, adjust=False).mean()
    al = (-diff).clip(lower=0).ewm(alpha=1/n, min_periods=n, adjust=False).mean()
    return 100 - 100 / (1 + ag / al.replace(0, np.nan))

def _macd(c, fast=12, slow=26, sig=9):
    line = _ema(c, fast) - _ema(c, slow)
    sl   = _ema(line, sig)
    return line, sl, line - sl

def _bollinger(c, n=20, k=2):
    m = _sma(c, n)
    sd = c.rolling(n, min_periods=1).std(ddof=0)
    return m+k*sd, m, m-k*sd, 2*k*sd/m.replace(0, np.nan)

def _atr(h, l, c, n=14):
    tr = pd.concat([h-l, (h-c.shift()).abs(), (l-c.shift()).abs()], axis=1).max(axis=1)
    return tr.ewm(alpha=1/n, min_periods=n, adjust=False).mean()

def _adx(h, l, c, n=14):
    up = h.diff()
    dn = -l.diff()
    pdm = np.where((up > dn) & (up > 0), up, 0.0)
    mdm = np.where((dn > up) & (dn > 0), dn, 0.0)
    tr  = pd.concat([h-l, (h-c.shift()).abs(), (l-c.shift()).abs()], axis=1).max(axis=1)
    atr_= tr.ewm(alpha=1/n, min_periods=n, adjust=False).mean()
    pdi = 100 * pd.Series(pdm, index=h.index).ewm(alpha=1/n, min_periods=n, adjust=False).mean() / atr_
    mdi = 100 * pd.Series(mdm, index=h.index).ewm(alpha=1/n, min_periods=n, adjust=False).mean() / atr_
    dx  = 100 * (pdi-mdi).abs() / (pdi+mdi).replace(0, np.nan)
    return dx.ewm(alpha=1/n, min_periods=n, adjust=False).mean()

def build_features(records, macro=None):
    df = pd.DataFrame(records)

    if 'Time' in df.columns:
        df = df.rename(columns={
            'Time': 'time_ms',
            'Open': 'open',
            'High': 'high',
            'Low': 'low',
            'Close': 'close',
            'Volume': 'volume'
        })
    elif 'time_ms' not in df.columns:
        df = pd.DataFrame(records, columns=['time_ms','open','high','low','close','volume'])

    df['date'] = pd.to_datetime(df['time_ms'], unit='ms', utc=True)
    df = df.set_index('date').sort_index()

    c, h, l, o = df['close'], df['high'], df['low'], df['open']

    f = pd.DataFrame(index=df.index)
    f[['close','open','high','low','volume']] = df[['close','open','high','low','volume']]

    f['ma20']   = _sma(c,20);   f['ma50']   = _sma(c,50)
    f['ma100']  = _sma(c,100);  f['ma200']  = _sma(c,200)
    f['ema20']  = _ema(c,20);   f['ema50']  = _ema(c,50)
    f['ma_20w'] = _sma(c,140);  f['ma_50w'] = _sma(c,350)
    f['ma_200w']= _sma(c,1400); f['ema_50w']= _ema(c,350)

    f['rsi14']       = _rsi(c,14)
    f['rsi14_prev']  = f['rsi14'].shift(1)
    ml, ms, mh       = _macd(c)
    f['macd']        = ml
    f['macd_sig']    = ms
    f['macd_hist']   = mh
    f['macd_hist_prev'] = mh.shift(1)

    bbu, bbm, bbl, bbw = _bollinger(c)
    f['bb_upper'] = bbu
    f['bb_mid']   = bbm
    f['bb_lower'] = bbl
    f['bb_width'] = bbw
    f['bb_width_20pctile'] = bbw.rolling(100, min_periods=30).quantile(0.2)
    f['bb_width_80pctile'] = bbw.rolling(100, min_periods=30).quantile(0.8)

    f['atr14'] = _atr(h,l,c,14)
    f['adx14'] = _adx(h,l,c,14)

    for n in [1,5,7,14,30,60,90]:
        f['ret_{}d'.format(n)] = c.pct_change(n)

    logr = np.log(c / c.shift(1))
    f['rv30'] = logr.rolling(30, min_periods=10).std() * np.sqrt(365)

    def _last_pct_rank(x):
        s = pd.Series(x)
        s = s.dropna()
        if len(s) == 0:
            return np.nan
        last = s.iloc[-1]
        return float((s <= last).sum()) / float(len(s))

    f['rv30_pctile'] = f['rv30'].rolling(200, min_periods=60).apply(_last_pct_rank, raw=False)

    f['high_20d']  = h.rolling(20, min_periods=1).max()
    f['low_20d']   = l.rolling(20, min_periods=1).min()
    f['high_50d']  = h.rolling(50, min_periods=1).max()
    f['low_50d']   = l.rolling(50, min_periods=1).min()
    f['high_200d'] = h.rolling(200, min_periods=1).max()
    f['low_200d']  = l.rolling(200, min_periods=1).min()
    f['pct_from_high_50d']  = (c-f['high_50d'])/f['high_50d']
    f['pct_from_high_200d'] = (c-f['high_200d'])/f['high_200d']
    f['pct_from_ma_200w']   = (c-f['ma_200w'])/f['ma_200w'].replace(0,np.nan)
    f['pct_from_ma_50w']    = (c-f['ma_50w'])/f['ma_50w'].replace(0,np.nan)

    rng = (h-l).replace(0,np.nan)
    f['body_pct']       = (c-o).abs()/rng
    f['upper_wick_pct'] = (h-c.where(c>o,o))/rng
    f['lower_wick_pct'] = (c.where(c<o,o)-l)/rng
    f['is_green']       = (c>o).astype(int)

    f['hh_count_20d'] = (h>h.shift(1)).rolling(20, min_periods=1).sum()
    f['ll_count_20d'] = (l<l.shift(1)).rolling(20, min_periods=1).sum()
    f['uptrend_20d']   = (f['hh_count_20d']>f['ll_count_20d']).astype(int)
    f['downtrend_20d'] = (f['ll_count_20d']>f['hh_count_20d']).astype(int)

    f['price_above_ma200']     = (c>f['ma200']).astype(int)
    f['price_above_ma50']      = (c>f['ma50']).astype(int)
    f['ma50_above_ma200']      = (f['ma50']>f['ma200']).astype(int)
    f['ma50_above_ma200_prev'] = f['ma50_above_ma200'].shift(1)
    f['days_below_ma200']      = (c<f['ma200']).groupby((c>=f['ma200']).cumsum()).cumcount()

    sw_h = f['high_50d']
    sw_l = f['low_50d']
    f['fib_382'] = sw_h-0.382*(sw_h-sw_l)
    f['fib_500'] = sw_h-0.500*(sw_h-sw_l)
    f['fib_618'] = sw_h-0.618*(sw_h-sw_l)
    f['fib_786'] = sw_h-0.786*(sw_h-sw_l)

    f['month']       = df.index.month
    f['day_of_week'] = df.index.dayofweek

    pct_rng = (f['high_20d']-f['low_20d'])/c.replace(0,np.nan)
    f['tight_range_20d'] = (pct_rng<0.08).astype(int)
    f['tight_range_streak'] = f['tight_range_20d'].groupby((f['tight_range_20d']==0).cumsum()).cumcount()

    typ = (h + l + c) / 3
    wt = (h - l).fillna(0) + 1e-9
    qstr = df.index.to_period('Q').astype(str)
    grp = pd.DataFrame({'tw': typ * wt, 'w': wt, 'q': qstr}, index=df.index)
    cum_tw = grp.groupby('q')['tw'].cumsum()
    cum_w = grp.groupby('q')['w'].cumsum()
    f['qvwap'] = cum_tw / cum_w.replace(0, np.nan)

    f['ret_4y'] = c.pct_change(365*4)
    f['days_below_ma_200w'] = (c<f['ma_200w']).groupby((c>=f['ma_200w']).cumsum()).cumcount()

    if macro:
        def _align(s):
            return s.reindex(f.index, method='ffill')
        if 'DXY' in macro:
            dxy = _align(macro['DXY'])
            f['dxy_ret_20d']    = dxy.pct_change(20)
            f['dxy_trend_down'] = (dxy.pct_change(20)<-0.01).astype(int)
        if 'GOLD' in macro:
            f['gold_ret_20d'] = _align(macro['GOLD']).pct_change(20)
        if 'US2Y' in macro:
            y = _align(macro['US2Y'])
            f['y_ret_20d']       = y.pct_change(20)
            f['y_slope_20d_neg'] = (y.diff(20)<0).astype(int)
        if 'SPX' in macro:
            spx = _align(macro['SPX'])
            f['spx_ret_20d']  = spx.pct_change(20)
            f['spx_trend_up'] = (spx.pct_change(20)>0).astype(int)
    return f

# ================================================================
#  三、因子评估
# ================================================================
HALVINGS = [
    pd.Timestamp('2012-11-28', tz='UTC'),
    pd.Timestamp('2016-07-09', tz='UTC'),
    pd.Timestamp('2020-05-11', tz='UTC'),
    pd.Timestamp('2024-04-19', tz='UTC'),
    pd.Timestamp('2028-04-01', tz='UTC'),
]

def _days_since_halving(ts):
    last = None
    for h in HALVINGS:
        if h <= ts:
            last = h
    return (ts - last).days if last else 9999

def _get(row, key, default=0.0):
    v = row.get(key, default) if isinstance(row, dict) else getattr(row, key, default)
    if v is None or (isinstance(v, float) and math.isnan(v)):
        return default
    return float(v)

def _safe_int(v, default=0):
    try:
        if v is None:
            return default
        if isinstance(v, float) and math.isnan(v):
            return default
        return int(v)
    except:
        return default

def _prev(f, col, n=1):
    if col not in f.columns:
        return 0.0
    if len(f) > n:
        v = f[col].shift(n).iloc[-1]
        return float(v) if v is not None and not (isinstance(v, float) and math.isnan(v)) else 0.0
    return 0.0

def _rmax(f, col, n, sh=0):
    s = f[col].rolling(n, min_periods=max(1, n//3)).max()
    if sh:
        s = s.shift(sh)
    v = s.iloc[-1]
    return float(v) if not (isinstance(v, float) and math.isnan(v)) else 0.0

def _rmin(f, col, n, sh=0):
    s = f[col].rolling(n, min_periods=max(1, n//3)).min()
    if sh:
        s = s.shift(sh)
    v = s.iloc[-1]
    return float(v) if not (isinstance(v, float) and math.isnan(v)) else 0.0

def evaluate_factors(f):
    row    = f.iloc[-1]
    now_ts = f.index[-1]
    s      = {}

    close  = _get(row,'close')
    o_val  = _get(row,'open',close)
    h_val  = _get(row,'high',close)
    l_val  = _get(row,'low',close)
    rsi14  = _get(row,'rsi14',50)
    adx14  = _get(row,'adx14',0)
    ret1   = _get(row,'ret_1d',0)
    ret5   = _get(row,'ret_5d',0)
    ret7   = _get(row,'ret_7d',0)
    ret30  = _get(row,'ret_30d',0)
    rv30   = _get(row,'rv30',0.5)
    month  = _safe_int(_get(row,'month',1), 1)
    dow    = _safe_int(_get(row,'day_of_week',0), 0)
    is_green = bool(_get(row,'is_green',0))
    above200 = bool(_get(row,'price_above_ma200',0))
    ma50_gt  = bool(_get(row,'ma50_above_ma200',0))
    ma50_prev= bool(_get(row,'ma50_above_ma200_prev',0))
    high_20d = _get(row,'high_20d',close)
    low_20d  = _get(row,'low_20d',close)
    high_50d = _get(row,'high_50d',close)
    low_50d  = _get(row,'low_50d',close)
    ma_200w  = _get(row,'ma_200w',0)
    ma_20w   = _get(row,'ma_20w',0)
    ma50_v   = _get(row,'ma50',close)
    days_below = _safe_int(_get(row,'days_below_ma200',0), 0)
    streak     = _safe_int(_get(row,'tight_range_streak',0), 0)
    mh        = _get(row,'macd_hist',0)
    mhp       = _get(row,'macd_hist_prev',0)
    bb_u      = _get(row,'bb_upper',close*1.02)
    bb_l      = _get(row,'bb_lower',close*0.98)
    bb_w      = _get(row,'bb_width',0.05)
    bb_w20    = _get(row,'bb_width_20pctile',0.04)
    fib618    = _get(row,'fib_618',0)
    qvwap     = _get(row,'qvwap',0)
    body_pct  = _get(row,'body_pct',0.5)
    upper_wick = _get(row,'upper_wick_pct',0)
    lower_wick = _get(row,'lower_wick_pct',0)
    hh20       = _get(row,'hh_count_20d',0)
    ll20       = _get(row,'ll_count_20d',0)
    uptrend20  = bool(_get(row,'uptrend_20d',0))
    ret4y      = _get(row,'ret_4y', np.nan)
    rv30_pctile = _get(row,'rv30_pctile',0.5)

    rsi_prev    = _prev(f,'rsi14',1)
    close_p1    = _prev(f,'close',1)
    open_p1     = _prev(f,'open',1)
    close_5ago  = _prev(f,'close',5)
    close_20ago = _prev(f,'close',20)
    rsi14_20ago = _prev(f,'rsi14',20)
    rv30_20ago  = _prev(f,'rv30',20)
    rv30_30ago  = _prev(f,'rv30',30)
    bb_w_p1     = _prev(f,'bb_width',1)
    bb_w20_p1   = _prev(f,'bb_width_20pctile',1)
    h20d_p1     = _prev(f,'high_20d',1)
    l20d_p1     = _prev(f,'low_20d',1)
    h20d_p2     = _prev(f,'high_20d',2)
    l20d_p2     = _prev(f,'low_20d',2)
    h50d_p1     = _prev(f,'high_50d',1)
    l50d_p1     = _prev(f,'low_50d',1)
    h50d_10     = _prev(f,'high_50d',10)
    l20d_p5     = _prev(f,'low_20d',5)
    h20d_p6     = _prev(f,'high_20d',6)
    close_p4    = _prev(f,'close',4)
    h_p2        = _prev(f,'high',2)
    l_p2        = _prev(f,'low',2)
    ma_20w_p1   = _prev(f,'ma_20w',1)
    ma_20w_p5   = _prev(f,'ma_20w',5)
    ret5_5ago   = _prev(f,'ret_5d',5)
    close_60ago = _prev(f,'close',60)
    low_200d_60 = _prev(f,'low_200d',60)
    bb_w_20ago  = _prev(f,'bb_width',20)
    h20d_10ago  = _prev(f,'high_20d',10)
    l20d_10ago  = _prev(f,'low_20d',10)

    dxy_r20  = _get(row,'dxy_ret_20d',  np.nan)
    gold_r20 = _get(row,'gold_ret_20d', np.nan)
    y_r20    = _get(row,'y_ret_20d',    np.nan)
    spx_r20  = _get(row,'spx_ret_20d',  np.nan)

    def _nm(v):
        return isinstance(v, float) and math.isnan(v)

    # ── PATTERN (23) ──
    h5d_p1 = _rmax(f,'high',5,sh=1)
    s['cap_001_falling_wedge_breakout'] = (
        0.65 if (ret30<-0.05 and rv30<rv30_20ago*0.9 and rv30_20ago>0 and close>h5d_p1) else 0.0)

    l5d_p1 = _rmin(f,'low',5,sh=1)
    s['cap_002_rising_wedge_breakdown'] = (
        -0.60 if (ret30>0.05 and rv30<rv30_20ago*0.9 and rv30_20ago>0 and close<l5d_p1) else 0.0)

    h3d_p1 = _rmax(f,'high',3,sh=1)
    consol  = abs(ret5)<0.03 and adx14<20
    s['cap_003_bull_flag'] = 0.7 if (ret5_5ago>0.10 and consol and close>h3d_p1) else 0.0

    l3d_p1 = _rmin(f,'low',3,sh=1)
    s['cap_004_bear_flag'] = -0.7 if (ret5_5ago<-0.10 and consol and close<l3d_p1) else 0.0

    if len(f) >= 20:
        try:
            hage_raw = f['high'].rolling(60, min_periods=20).apply(
                lambda x: float(np.argmax(x)), raw=True
            ).iloc[-1]
            hage = _safe_int(60 - hage_raw, 0)
        except:
            hage = 0
    else:
        hage = 0

    s['cap_005_head_shoulders_top'] = -0.5 if (close<low_20d and hage>10 and rsi14<50) else 0.0

    if len(f) >= 20:
        try:
            lage_raw = f['low'].rolling(60, min_periods=20).apply(
                lambda x: float(np.argmin(x)), raw=True
            ).iloc[-1]
            lage = _safe_int(60 - lage_raw, 0)
        except:
            lage = 0
    else:
        lage = 0

    s['cap_006_inverse_head_shoulders'] = 0.5 if (close>high_20d and lage>10 and rsi14>50) else 0.0

    pk_r = _rmax(f,'high',15)
    pk_p = _rmax(f,'high',15,sh=15)
    eq_pk = abs(pk_r-pk_p)/max(pk_r,1)<0.03 if pk_p>0 else False
    s['cap_007_double_top'] = -0.6 if (eq_pk and close<low_20d) else 0.0

    lw_r = _rmin(f,'low',15)
    lw_p = _rmin(f,'low',15,sh=15)
    eq_lw = abs(lw_r-lw_p)/max(abs(lw_r),1)<0.03 if lw_p>0 else False
    s['cap_008_double_bottom'] = 0.6 if (eq_lw and close>high_20d) else 0.0

    h90p1 = _rmax(f,'high',90,sh=1)
    s['cap_009_cup_and_handle'] = (
        0.5 if (close_60ago<low_200d_60*1.1 and low_200d_60>0
                and 0<rv30_30ago<0.5 and close>h90p1) else 0.0)

    flat_h = abs(high_20d-h20d_10ago)/max(high_20d,1)<0.02 if h20d_10ago>0 else False
    s['cap_010_ascending_triangle'] = 0.6 if (flat_h and low_20d>l20d_10ago and close>high_20d) else 0.0

    flat_l = abs(low_20d-l20d_10ago)/max(abs(low_20d),1)<0.02 if l20d_10ago>0 else False
    s['cap_011_descending_triangle'] = -0.6 if (flat_l and high_20d<h20d_10ago and close<low_20d) else 0.0

    s['cap_012_sfp_swing_failure'] = (
        -0.6 if (h_val>h20d_p1 and close<h20d_p1) else
         0.6 if (l_val<l20d_p1 and close>l20d_p1) else 0.0)

    rng_20 = high_20d - low_20d
    rp_20  = (close-low_20d)/rng_20 if rng_20>0 else 0.5
    s['cap_013_range_fade_high_low'] = (
        -0.5 if (adx14<20 and rp_20>0.85) else
         0.5 if (adx14<20 and rp_20<0.15) else 0.0)

    near_ma50 = abs(close-ma50_v)/close<0.02 if close>0 else False
    s['cap_014_trend_pullback_continuation'] = 0.6 if (ma50_gt and near_ma50 and is_green) else 0.0

    if len(f)>=10:
        thr_hh = bool((f['high']>f['high'].shift(1)).rolling(10,min_periods=3).sum().iloc[-1]>=5)
    else:
        thr_hh = False
    s['cap_050_three_drives_pattern'] = -0.5 if (thr_hh and rsi14>70) else 0.0

    h30_6 = _rmax(f,'high',30,sh=6)
    ll_20_1 = _rmin(f,'low',20,sh=1)
    s['cap_051_quasimodo'] = -0.55 if (close_5ago>h30_6 and h30_6>0 and close<ll_20_1 and ll_20_1>0) else 0.0

    h10p1 = _rmax(f,'high',10,sh=1)
    l10p1 = _rmin(f,'low',10,sh=1)
    s['cap_052_liquidity_grab_sweep'] = (
         0.65 if (l_val<l10p1 and lower_wick>0.5 and is_green)     else
        -0.65 if (h_val>h10p1 and upper_wick>0.5 and not is_green) else 0.0)

    s['cap_053_doji_reversal'] = 0.0

    b_now = abs(close-o_val)
    b_prev = abs(close_p1-open_p1)
    bull_eng = b_now>b_prev and is_green and o_val<close_p1 and close>open_p1
    bear_eng = b_now>b_prev and not is_green and o_val>close_p1 and close<open_p1
    s['cap_054_engulfing_reversal'] = 0.55 if bull_eng else (-0.55 if bear_eng else 0.0)

    rng_v = h_val-l_val if h_val>l_val else 1.0
    b_raw = abs(close-o_val)
    up_pin = upper_wick>0.6 and b_raw<rng_v*0.25 and abs(h_val-high_20d)/max(h_val,1)<0.01
    lo_pin = lower_wick>0.6 and b_raw<rng_v*0.25 and abs(l_val-low_20d)/max(abs(l_val),1)<0.01
    s['cap_055_pin_bar_rejection'] = -0.55 if up_pin else (0.55 if lo_pin else 0.0)

    if len(f)>=10:
        bwc = int((f['lower_wick_pct']>0.4).rolling(10,min_periods=2).sum().iloc[-1])
    else:
        bwc=0
    s['cap_056_double_needle_bottom'] = 0.55 if bwc>=2 else 0.0

    fb_up = close_p1>h20d_p2 and close<h20d_p2 if h20d_p2>0 else False
    fb_dn = close_p1<l20d_p2 and close>l20d_p2 if l20d_p2>0 else False
    s['cap_057_fake_breakout_trap'] = -0.55 if fb_up else (0.55 if fb_dn else 0.0)

    if len(f)>=45:
        m30s = f['low'].rolling(30,min_periods=5).min()
        ec   = ((f['low']-m30s).abs()/m30s.clip(lower=1)<0.03).rolling(45,min_periods=3).sum()
        tc   = float(ec.iloc[-1])
    else:
        tc=0.0
    s['cap_058_three_bottom_structure'] = 0.6 if (tc>=3 and close>high_20d) else 0.0

    # ── STRUCTURAL (8) ──
    s['cap_023_elliott_wave_3'] = 0.6 if (ret30>0.20 and 55<=rsi14<=80 and hh20>ll20) else 0.0

    s['cap_024_wyckoff_accumulation'] = (
        0.75 if (close<low_50d*1.03 and l_val<l50d_p1 and l50d_p1>0
                 and close>l50d_p1 and lower_wick>0.4) else 0.0)

    s['cap_025_wyckoff_distribution'] = (
        -0.75 if (close>high_50d*0.97 and h_val>h50d_p1 and h50d_p1>0
                  and close<h50d_p1 and upper_wick>0.4) else 0.0)

    was_bo = close_5ago > h20d_p6 if h20d_p6>0 else False
    rtest  = abs(close-close_5ago)/max(close_5ago,1)<0.02 if close_5ago>0 else False
    s['cap_026_smc_order_block'] = 0.5 if (was_bo and rtest) else 0.0

    s['cap_048_ict_breaker_block'] = (
        -0.5 if (close_p4<l20d_p5 and l20d_p5>0
                 and abs(close-l20d_p5)/max(l20d_p5,1)<0.02) else 0.0)

    s['cap_049_ict_fair_value_gap'] = (
         0.5 if (h_p2>0 and l_val>h_p2) else
        -0.5 if (l_p2>0 and h_val<l_p2) else 0.0)

    s['cap_065_btc_dominance_shift'] = 0.0

    s['cap_hh_defense'] = 0.55 if (low_20d>low_50d and uptrend20 and close>low_20d*1.02) else 0.0

    # ── INDICATOR (9) ──
    s['cap_015_rsi_bullish_divergence'] = (
        0.7 if (close<close_20ago and close<low_20d and rsi14>rsi14_20ago) else 0.0)

    s['cap_016_rsi_bearish_divergence'] = (
        -0.7 if (close>close_20ago and close>high_20d and rsi14<rsi14_20ago) else 0.0)

    s['cap_017_rsi_oversold_bounce'] = 0.6 if (rsi14>30 and rsi_prev<=30) else 0.0
    s['cap_018_ma_golden_cross'] = 0.6 if (ma50_gt and not ma50_prev) else 0.0
    s['cap_019_ma_death_cross']  = -0.6 if (not ma50_gt and ma50_prev) else 0.0

    s['cap_020_macd_histogram_cross'] = (
        0.5 if (mh>0 and mhp<=0) else -0.5 if (mh<0 and mhp>=0) else 0.0)

    squeezed = bb_w_p1<bb_w20_p1 if bb_w20_p1>0 else False
    s['cap_021_bollinger_squeeze_breakout'] = (
         0.6 if (squeezed and close>bb_u) else
        -0.6 if (squeezed and close<bb_l) else 0.0)

    if fib618>0 and abs(close-fib618)/close<0.02:
        s['cap_022_fib_618_retracement_support'] = 0.55 if is_green else 0.0
    else:
        s['cap_022_fib_618_retracement_support'] = 0.0

    s['cap_069_moving_average_reclaim'] = 0.65 if (above200 and days_below>=3) else 0.0

    # ── REGIME (8) ──
    days_h = _days_since_halving(now_ts)

    s['cap_037_halving_cycle_phase'] = (
         0.6 if 180<=days_h<540  else
        -0.5 if 540<=days_h<720  else
        -0.3 if days_h>=720      else 0.0)

    s['cap_038_4year_cycle'] = (
         0.5 if 180<=days_h<540   else
        -0.4 if 540<=days_h<1080  else 0.0)

    s['cap_041_dont_catch_falling_knives'] = -0.55 if (ret1<-0.08 and not is_green) else 0.0

    s['cap_044_regime_trending_up']   = 1.0 if (above200 and ma50_gt  and adx14>25) else 0.0
    s['cap_045_regime_trending_down'] = 1.0 if (not above200 and not ma50_gt and adx14>25) else 0.0

    if adx14<20 and bb_w20>0 and bb_w<bb_w20:
        rp46 = max(0.0, min(1.0, (close-low_20d)/rng_20)) if rng_20>0 else 0.5
        s['cap_046_regime_ranging'] = float((0.5-rp46)*2)
    else:
        s['cap_046_regime_ranging'] = 0.0

    s['cap_047_regime_volatile'] = 0.0
    s['cap_070_parabolic_exhaustion'] = -1.0 if (ret7>0.25 and rsi14>80) else 0.0

    # ── MACRO (4) ──
    s['cap_027_dxy_inverse_btc']       = 0.4 if (not _nm(dxy_r20)  and dxy_r20<-0.01) else 0.0
    s['cap_028_spx_risk_on_off']       = 0.4 if (not _nm(spx_r20)  and spx_r20>0.02) else 0.0
    s['cap_029_yields_liquidity']      = 0.4 if (not _nm(y_r20)    and y_r20<-0.02) else 0.0
    s['cap_030_gold_flight_to_safety'] = 0.3 if (not _nm(gold_r20) and gold_r20>0.05) else 0.0

    # ── MOCK (19) ──
    for mid in ['cap_031_funding_extreme_negative','cap_032_funding_extreme_positive',
                'cap_033_oi_climb_price_flat','cap_034_liquidation_cluster_magnet',
                'cap_035_exchange_inflow_bearish','cap_036_lth_holding',
                'cap_039_fomc_risk_off','cap_040_etf_flows_proxy',
                'cap_042_position_sizing_cap','cap_043_cut_losses_early',
                'cap_059_funding_divergence','cap_060_basis_blowout',
                'cap_061_options_skew_bullish','cap_062_m2_growth_proxy',
                'cap_063_ism_pmi_cycle','cap_064_credit_spreads_widening',
                'cap_066_stablecoin_supply_growth','cap_067_nvt_valuation_extreme',
                'cap_068_mvrv_zscore_cycle']:
        s[mid] = 0.0

    return s

# ================================================================
#  四、Profile & IC 加载 (默认优先本地缓存)
# ================================================================
_profiles_cache = None
_ic_cache       = None

def load_profiles():
    global _profiles_cache
    if _profiles_cache is not None:
        return _profiles_cache

    profiles = []

    if (not USE_REMOTE_LATEST) and os.path.exists(PROFILES_CACHE_FILE):
        try:
            profiles = _read_json(PROFILES_CACHE_FILE)
            _profiles_cache = profiles
            Log("✅ Profiles 使用本地缓存: {} 份".format(len(profiles)))
            return profiles
        except Exception as e:
            Log("⚠️ 读取 Profiles 本地缓存失败: {}".format(e))

    Log("━━ 从GitHub下载交易员Profiles ━━")
    try:
        items  = json.loads(_http_get("{}/profiles_v2".format(GITHUB_API)))
        jfiles = [i for i in items if i['name'].endswith('.json')]
        Log("发现 {} 份Profile,开始下载...".format(len(jfiles)))
        ok = 0
        for item in jfiles:
            try:
                p = json.loads(_http_get(item['download_url']))
                caps = {c['id']: float(c.get('weight', 0.5))
                        for c in p.get('capabilities_used', [])}
                profiles.append({
                    'handle': p.get('handle', item['name'][:-5]),
                    'caps': caps
                })
                ok += 1
            except Exception as e:
                Log("⚠️ Profile[{}] 失败: {}".format(item['name'], e))
        Log("✅ Profile下载完成: {}/{} 份".format(ok, len(jfiles)))

        if profiles:
            try:
                _write_json(PROFILES_CACHE_FILE, profiles)
                Log("💾 Profiles 已写入本地缓存")
            except Exception as e:
                Log("⚠️ 写入 Profiles 缓存失败: {}".format(e))
    except Exception as e:
        Log("⚠️ GitHub目录请求失败: {}".format(e))
        if os.path.exists(PROFILES_CACHE_FILE):
            try:
                profiles = _read_json(PROFILES_CACHE_FILE)
                Log("⚠️ 改用旧的 Profiles 本地缓存: {} 份".format(len(profiles)))
            except Exception as e2:
                Log("⚠️ 旧 Profiles 缓存也不可用: {}".format(e2))

    _profiles_cache = profiles
    return profiles

def load_ic():
    global _ic_cache
    if _ic_cache is not None:
        return _ic_cache

    ic = {}

    if (not USE_REMOTE_LATEST) and os.path.exists(IC_CACHE_FILE):
        try:
            raw = _read_text(IC_CACHE_FILE)
            for row in csv.DictReader(raw.splitlines()):
                try:
                    ic[row['handle']] = {
                        'ic_30d':       float(row.get('ic_30d', 0) or 0),
                        'school':       row.get('school', '') or '',
                        'bias_default': row.get('bias_default', '') or '',
                    }
                except:
                    pass
            _ic_cache = ic
            Log("✅ IC 使用本地缓存: {} 条".format(len(ic)))
            return ic
        except Exception as e:
            Log("⚠️ 读取 IC 本地缓存失败: {}".format(e))

    Log("━━ 从GitHub下载IC数据 ━━")
    try:
        raw = _http_get("{}/quant_factors/trader_composite_ic.csv".format(GITHUB_RAW)).decode('utf-8')
        for row in csv.DictReader(raw.splitlines()):
            try:
                ic[row['handle']] = {
                    'ic_30d':       float(row.get('ic_30d', 0) or 0),
                    'school':       row.get('school', '') or '',
                    'bias_default': row.get('bias_default', '') or '',
                }
            except:
                pass
        Log("✅ IC数据下载完成: {} 条".format(len(ic)))

        if ic:
            try:
                _write_text(IC_CACHE_FILE, raw)
                Log("💾 IC 已写入本地缓存")
            except Exception as e:
                Log("⚠️ 写入 IC 缓存失败: {}".format(e))
    except Exception as e:
        Log("⚠️ IC数据下载失败: {}".format(e))
        if os.path.exists(IC_CACHE_FILE):
            try:
                raw = _read_text(IC_CACHE_FILE)
                for row in csv.DictReader(raw.splitlines()):
                    try:
                        ic[row['handle']] = {
                            'ic_30d':       float(row.get('ic_30d', 0) or 0),
                            'school':       row.get('school', '') or '',
                            'bias_default': row.get('bias_default', '') or '',
                        }
                    except:
                        pass
                Log("⚠️ 改用旧的 IC 本地缓存: {} 条".format(len(ic)))
            except Exception as e2:
                Log("⚠️ 旧 IC 缓存也不可用: {}".format(e2))

    _ic_cache = ic
    return ic

# ================================================================
#  五、共识计算
# ================================================================
def compute_consensus(factor_scores):
    profiles = load_profiles()
    ic_data  = load_ic()
    if not profiles:
        return {'trust_adjusted':0.0,'ic_weighted':0.0,'equal_mean':0.0,
                'long_count':0,'short_count':0,'neutral_count':0,
                'top_factors':[],'by_school':{},'top_traders':[],'reverse_traders':[]}

    trader_signals = []
    for p in profiles:
        sig = 0.0
        wt = 0.0
        for cap_id, w in p['caps'].items():
            score = factor_scores.get(cap_id, 0.0)
            sig += w * score
            wt += abs(w)
        trader_raw = sig / wt if wt > 0 else 0.0
        ic_info    = ic_data.get(p['handle'], {})
        trader_signals.append({
            'handle':       p['handle'],
            'signal':       trader_raw,
            'ic':           ic_info.get('ic_30d', 0.0),
            'school':       ic_info.get('school', 'unknown') or 'unknown',
            'bias_default': ic_info.get('bias_default', ''),
        })

    long_n  = sum(1 for t in trader_signals if t['signal'] >  0.03)
    short_n = sum(1 for t in trader_signals if t['signal'] < -0.03)
    neut_n  = len(trader_signals) - long_n - short_n
    eq_mean = sum(t['signal'] for t in trader_signals) / max(len(trader_signals), 1)

    pos_w = sum(max(t['ic'], 0) for t in trader_signals)
    ic_wt = (sum(t['signal']*max(t['ic'],0) for t in trader_signals)/pos_w
             if pos_w > 0 else 0.0)

    abs_w = sum(abs(t['ic']) for t in trader_signals)
    trust = (sum((t['signal'] if t['ic']>=0 else -t['signal'])*abs(t['ic'])
                 for t in trader_signals)/abs_w if abs_w > 0 else 0.0)

    top_factors = sorted([(k,v) for k,v in factor_scores.items() if abs(v)>0.05],
                         key=lambda x:abs(x[1]), reverse=True)[:10]

    by_school = {}
    for t in trader_signals:
        sc = t['school'] or 'unknown'
        if sc not in by_school:
            by_school[sc] = {'count':0,'long':0,'short':0,'neutral':0,'signals':[]}
        by_school[sc]['count'] += 1
        by_school[sc]['signals'].append(t['signal'])
        if   t['signal'] >  0.03: by_school[sc]['long']    += 1
        elif t['signal'] < -0.03: by_school[sc]['short']   += 1
        else:                     by_school[sc]['neutral']  += 1
    for sc, d in by_school.items():
        d['mean_signal'] = sum(d['signals']) / max(len(d['signals']), 1)

    top_traders = sorted([t for t in trader_signals if t['ic'] >  0.05],
                         key=lambda x:x['ic'], reverse=True)[:15]
    reverse_traders = sorted([t for t in trader_signals if t['ic'] < -0.05],
                             key=lambda x:x['ic'])[:10]

    return {'trust_adjusted':trust,'ic_weighted':ic_wt,'equal_mean':eq_mean,
            'long_count':long_n,'short_count':short_n,'neutral_count':neut_n,
            'top_factors':top_factors,'by_school':by_school,
            'top_traders':top_traders,'reverse_traders':reverse_traders}

# ================================================================
#  六、交易执行
# ================================================================
def safe_get_cash():
    v = _G('pt_cash')
    return float(INIT_CAPITAL) if v is None else float(v)

def calc_equity(price):
    cash = safe_get_cash()
    pos  = json.loads(_G('pt_position') or 'null')
    if not pos:
        return cash
    unreal = (pos['margin']+(price-pos['entry_price'])*pos['qty'] if pos['side']=='long'
              else pos['margin']+(pos['entry_price']-price)*pos['qty'])
    return cash + unreal

def paper_open(side, price, signal_val):
    equity = calc_equity(price)
    try:
        pr = float(POSITION_RATIO)
        lv = float(LEVERAGE)
    except:
        pr = 0.9
        lv = 2.0
    strength = min(abs(signal_val)*4, 1.0)
    nominal  = equity * pr * lv * strength
    margin   = nominal / lv
    cash     = safe_get_cash()
    if margin > cash-1:
        margin = max(cash-1, 0)
        nominal = margin * lv
    if margin < 10:
        Log("⚠️ 保证金不足10U")
        return False
    qty = nominal / price
    cash -= margin
    pos = {'side':side,'entry_price':price,'qty':qty,'margin':margin,
           'leverage':lv,'open_time':_D()}
    _append_log({'action':'open','side':side,'price':round(price,4),
                 'qty':round(qty,6),'margin':round(margin,2),'time':_D()})
    _G('pt_position',json.dumps(pos))
    _G('pt_cash',cash)
    Log("📂 开{}仓 价:{:.4f} qty:{:.6f} 保:{:.2f}U sig:{:.4f}".format(side,price,qty,margin,signal_val))
    return True

def paper_close(price, reason="signal"):
    pos = json.loads(_G('pt_position') or 'null')
    if not pos:
        return False
    cash = safe_get_cash()
    realized = float(_G('pt_realized_pnl') or 0)
    pnl = ((price-pos['entry_price'])*pos['qty'] if pos['side']=='long'
           else (pos['entry_price']-price)*pos['qty'])
    cash += pos['margin'] + pnl
    realized += pnl
    _append_log({'action':'close','side':pos['side'],'reason':reason,
                 'entry':round(pos['entry_price'],4),'close':round(price,4),
                 'pnl':round(pnl,4),'time':_D()})
    _G('pt_position',json.dumps(None))
    _G('pt_cash',cash)
    _G('pt_realized_pnl',realized)
    Log("📤 平{}仓{} 开:{:.4f} 平:{:.4f} PnL:${:.4f} [{}]".format(
        pos['side'],'🟢' if pnl>=0 else '🔴',pos['entry_price'],price,pnl,reason))
    return True

def emergency_reduce_paper(price, side, ratio=0.5):
    pos = json.loads(_G('pt_position') or 'null')
    if not pos:
        return
    rq = pos['qty'] * ratio
    rm = pos['margin'] * ratio
    pnl = ((price-pos['entry_price'])*rq if side=='long' else (pos['entry_price']-price)*rq)
    cash = safe_get_cash() + rm + pnl
    realized = float(_G('pt_realized_pnl') or 0) + pnl
    pos['qty'] -= rq
    pos['margin'] -= rm
    _append_log({'action':'emergencyReduce','side':side,'price':round(price,4),
                 'pnl':round(pnl,4),'ratio':ratio,'time':_D()})
    _G('pt_position',json.dumps(None) if pos['qty']<1e-8 else json.dumps(pos))
    _G('pt_cash',cash)
    _G('pt_realized_pnl',realized)
    Log("🚨 紧急减仓[{}] {:.0f}% PnL:${:.4f}".format(side,ratio*100,pnl))

def live_open(side, price):
    try:
        acc = exchange.GetAccount()
        if not acc:
            return False
        try:
            pr = float(POSITION_RATIO)
            lv = float(LEVERAGE)
        except:
            pr = 0.9
            lv = 2.0
        equity = float(acc.get('Balance',0)) + float(acc.get('FrozenBalance',0))
        nominal = equity * pr * lv
        qty = nominal / price
        if nominal < 10:
            return False
        oid = exchange.CreateOrder(SYMBOL,'buy' if side=='long' else 'sell',-1,qty)
        Log("✅ 实盘开{}仓 oid:{} qty:{:.6f}".format(side,oid,qty))
        return bool(oid)
    except Exception as e:
        Log("⚠️ 实盘开仓异常:", str(e))
        return False

def live_close(reason="signal"):
    try:
        positions = exchange.GetPositions(SYMBOL)
        if not positions:
            return False
        for p in positions:
            if p['Amount']<=0:
                continue
            oid = exchange.CreateOrder(SYMBOL,'closebuy' if p['Type']==0 else 'closesell',-1,p['Amount'])
            Log("✅ 实盘平仓 oid:{} [{}]".format(oid,reason))
        return True
    except Exception as e:
        Log("⚠️ 实盘平仓异常:", str(e))
        return False

def emergency_reduce_live(ratio=0.5):
    try:
        for p in (exchange.GetPositions(SYMBOL) or []):
            if p['Amount']<=0:
                continue
            exchange.CreateOrder(SYMBOL,'closebuy' if p['Type']==0 else 'closesell',-1,p['Amount']*ratio)
            Log("🚨 实盘紧急减仓 {:.0f}%".format(ratio*100))
    except Exception as e:
        Log("⚠️ 实盘紧急减仓异常:", str(e))

def _append_log(entry):
    tlog = json.loads(_G('pt_trade_log') or '[]')
    tlog.append(entry)
    if len(tlog)>100:
        tlog=tlog[-100:]
    _G('pt_trade_log',json.dumps(tlog))

# ================================================================
#  七、风险控制
# ================================================================
def check_sl_tp(price):
    try:
        sl = float(STOP_LOSS_PCT)
        tp = float(TAKE_PROFIT_PCT)
    except:
        sl = 0.06
        tp = 0.18
    mode = _G('rp_trade_mode') or TRADE_MODE
    if mode=='live':
        try:
            pos = exchange.GetPositions(SYMBOL)
            if not pos:
                return None
            p = pos[0]
            entry = float(p.get('Price',0))
            if entry<=0:
                return None
            chg = (price-entry)/entry if p['Type']==0 else (entry-price)/entry
        except:
            return None
    else:
        pos = json.loads(_G('pt_position') or 'null')
        if not pos:
            return None
        entry = pos['entry_price']
        chg = (price-entry)/entry if pos['side']=='long' else (entry-price)/entry
    if chg<=-sl:
        return 'sl'
    if chg>=tp:
        return 'tp'
    return None

def check_emergency(price):
    last = _G('rp_last_check_price')
    if last is None:
        _G('rp_last_check_price',price)
        return
    last = float(last)
    mode = _G('rp_trade_mode') or TRADE_MODE
    pos = json.loads(_G('pt_position') or 'null')
    if mode=='live':
        try:
            positions = exchange.GetPositions(SYMBOL)
            pos = positions[0] if positions else None
            if not pos:
                _G('rp_last_check_price',price)
                return
            side='long' if pos['Type']==0 else 'short'
        except:
            return
    else:
        if not pos:
            _G('rp_last_check_price',price)
            return
        side = pos['side']
    drop = (last-price)/last if side=='long' else (price-last)/last
    if drop>=EMERGENCY_DROP:
        cnt = (_G('rp_emergency_count') or 0) + 1
        _G('rp_emergency_count',cnt)
        Log("🚨 紧急风控[{}] 不利{:.2f}% 第{}次".format(side,drop*100,cnt))
        if mode=='live':
            emergency_reduce_live(EMERGENCY_REDUCE)
        else:
            emergency_reduce_paper(price,side,EMERGENCY_REDUCE)
    else:
        _G('rp_last_check_price',price)

def get_current_side():
    mode = _G('rp_trade_mode') or TRADE_MODE
    if mode=='live':
        try:
            pos = exchange.GetPositions(SYMBOL)
            if not pos:
                return 'none'
            for p in pos:
                if p['Amount']>0:
                    return 'long' if p['Type']==0 else 'short'
        except:
            pass
        return 'none'
    pos = json.loads(_G('pt_position') or 'null')
    return pos['side'] if pos else 'none'

# ================================================================
#  八、自适应轮询 & 1分钟持仓刷新
# ================================================================
def get_adaptive_sleep(rv30_val):
    try:
        av = float(rv30_val) if rv30_val else 0
    except:
        av = 0
    if av>0.50:
        Log("⚠️ 高波动{:.1f}%→15min".format(av*100))
        return FAST_INTERVAL
    if av>0.30:
        Log("📊 中波动{:.1f}%→30min".format(av*100))
        return MID_INTERVAL
    Log("✅ 低波动{:.1f}%→60min".format(av*100))
    return SLOW_INTERVAL

def sleep_with_pnl_refresh(total_ms, chart, init_cap):
    elapsed = 0
    while elapsed<total_ms:
        step = min(PNL_INTERVAL,total_ms-elapsed)
        Sleep(step)
        elapsed += step
        try:
            ticker = exchange.GetTicker(SYMBOL)
            if not ticker:
                continue
            price = float(ticker['Last'])
            equity = calc_equity(price)
            chart.add(0,[int(time.time()*1000),equity])
            LogProfit(equity-float(init_cap),'&')
            pos = json.loads(_G('pt_position') or 'null')
            if pos:
                unreal = ((price-pos['entry_price'])*pos['qty'] if pos['side']=='long'
                          else (pos['entry_price']-price)*pos['qty'])
                LogStatus("💓 1min刷新 价:{:.4f} [{}]浮盈:${:.4f} 权益:${:.2f} {}".format(
                    price,pos['side'],unreal,equity,_D()))
        except Exception as e:
            Log("刷新异常:",str(e))

# ================================================================
#  九、主策略决策
# ================================================================
def run_once(mode, price, consensus):
    sig = consensus['trust_adjusted']
    try:
        lt = float(LONG_THRESH)
        st = float(SHORT_THRESH)
        ct = float(CLOSE_THRESH)
    except:
        lt = 0.05
        st = -0.05
        ct = 0.02
    sltp = check_sl_tp(price)
    if sltp:
        Log("触发{} 价:{}".format('止损🚨' if sltp=='sl' else '止盈✅',price))
        (live_close(sltp) if mode=='live' else paper_close(price,sltp))
        return
    side = get_current_side()
    if side=='none':
        if sig>lt:
            (live_open('long', price) if mode=='live' else paper_open('long', price, sig))
        elif sig<st:
            (live_open('short', price) if mode=='live' else paper_open('short', price, sig))
        else:
            Log("⚪ 中性{:.4f},观望".format(sig))
    elif side=='long':
        if abs(sig)<ct or sig<st:
            Log("📉 平多 sig={:.4f}".format(sig))
            (live_close('signal_reverse') if mode=='live' else paper_close(price,'signal_reverse'))
        else:
            Log("✋ 持多 sig={:.4f}".format(sig))
    elif side=='short':
        if abs(sig)<ct or sig>lt:
            Log("📈 平空 sig={:.4f}".format(sig))
            (live_close('signal_reverse') if mode=='live' else paper_close(price,'signal_reverse'))
        else:
            Log("✋ 持空 sig={:.4f}".format(sig))

# ================================================================
#  十、仪表板
# ================================================================
def render_dashboard(consensus, price, equity, init_cap, run_cnt, sleep_ms):
    mode = _G('rp_trade_mode') or TRADE_MODE
    mtag = '🔴 实盘' if mode=='live' else '🟢 模拟'
    cash = safe_get_cash()
    realized = float(_G('pt_realized_pnl') or 0)
    pnl = equity-float(init_cap)
    pct = pnl/float(init_cap)*100 if init_cap else 0
    emgc = _G('rp_emergency_count') or 0
    ivdesc = {FAST_INTERVAL:'15min高波',MID_INTERVAL:'30min中波',SLOW_INTERVAL:'60min低波'}.get(sleep_ms,'?')
    sig = consensus['trust_adjusted']
    lt = float(LONG_THRESH or 0.05)
    st = float(SHORT_THRESH or -0.05)
    stag = ('🟢 BULLISH({:.4f})'.format(sig) if sig>lt else
            '🔴 BEARISH({:.4f})'.format(sig) if sig<st else
            '⚪ NEUTRAL({:.4f})'.format(sig))
    pos = json.loads(_G('pt_position') or 'null')
    if pos:
        unreal = ((price-pos['entry_price'])*pos['qty'] if pos['side']=='long'
                  else (pos['entry_price']-price)*pos['qty'])
        pdesc = "[{}]qty:{:.6f}@{:.4f} 浮盈:${:.4f}".format(pos['side'],pos['qty'],pos['entry_price'],unreal)
    else:
        pdesc = "无持仓"

    t1 = {'type':'table','title':'📊 账户 ({}) | {}'.format(mtag,_D()),
        'cols':['模式','权益','盈亏','收益率','现金','已实现','紧急','次数','轮询','操作'],
        'rows':[[mtag,'${:.2f}'.format(equity),
                 ('+$' if pnl>=0 else'-$')+'{:.2f}'.format(abs(pnl)),
                 ('+' if pct>=0 else'')+'{:.2f}%'.format(pct),
                 '${:.2f}'.format(cash),('+$' if realized>=0 else'-$')+'{:.2f}'.format(abs(realized)),
                 '🚨{}次'.format(emgc),'🔄{}次'.format(run_cnt),'⏱'+ivdesc,
                 [{'type':'button','cmd':'切换模式','name':'切换模式'},
                  {'type':'button','cmd':'全部平仓','name':'📤全平'},
                  {'type':'button','cmd':'重置模拟','name':'🔄重置'},
                  {'type':'button','cmd':'立即再平衡','name':'🔁立即再平衡'}]]]}

    t2 = {'type':'table','title':'🧠 99人KOL共识',
        'cols':['信号','Trust-Adj','IC加权','等权均值','看多','看空','中性','持仓'],
        'rows':[[stag,'{:.4f}'.format(consensus['trust_adjusted']),
                 '{:.4f}'.format(consensus['ic_weighted']),
                 '{:.4f}'.format(consensus.get('equal_mean',0)),
                 str(consensus['long_count']),str(consensus['short_count']),
                 str(consensus['neutral_count']),pdesc]]}

    sc_rows = []
    for sc,d in sorted(consensus.get('by_school',{}).items(), key=lambda x:x[1]['count'], reverse=True):
        m = d.get('mean_signal',0)
        sc_rows.append([sc,str(d['count']),
                        ('🟢' if m>0.03 else '🔴' if m<-0.03 else '⚪')+' {:.3f}'.format(m),
                        str(d['long']),str(d['short']),str(d['neutral'])])
    if not sc_rows:
        sc_rows=[['暂无','-','-','-','-','-']]
    t3 = {'type':'table','title':'🏫 By School 学派分组',
        'cols':['学派','人数','均值','多','空','中'],'rows':sc_rows}

    frows = [[fid,('🟢 ' if v>0 else '🔴 ')+'{:.3f}'.format(v)]
           for fid,v in consensus['top_factors']]
    if not frows:
        frows=[['无激活因子','-']]
    t4 = {'type':'table','title':'⚡ 激活因子 Top10',
        'cols':['因子ID','得分'],'rows':frows}

    tprows = []
    for t in consensus.get('top_traders',[]):
        sv = t['signal']
        arr='🟢' if sv>0.1 else '🔴' if sv<-0.1 else '⚪'
        tprows.append(['@'+t['handle'][:20],'{:+.2f}'.format(t['ic']),
                       arr+' {:+.3f}'.format(sv),t.get('school','?')[:12]])
    if not tprows:
        tprows=[['暂无','-','-','-']]
    t5 = {'type':'table','title':'🌟 Top15高IC交易员',
        'cols':['交易员','IC','信号','学派'],'rows':tprows}

    rvrows = []
    for t in consensus.get('reverse_traders',[]):
        sv = t['signal']
        fl = -sv
        arr='🟢' if fl>0.1 else '🔴' if fl<-0.1 else '⚪'
        rvrows.append(['@'+t['handle'][:20],'{:+.2f}'.format(t['ic']),
                       '{:+.3f}'.format(sv),arr+' {:+.3f}'.format(fl),t.get('school','?')[:12]])
    if not rvrows:
        rvrows=[['暂无','-','-','-','-']]
    t6 = {'type':'table','title':'🔄 Top10反向指标交易员',
        'cols':['交易员','IC','原始','翻转','学派'],'rows':rvrows}

    tlog = json.loads(_G('pt_trade_log') or '[]')
    trrows = [[('🟢多' if t.get('side')=='long' else '🔴空'),t.get('action','-'),
              '{:.4f}'.format(t.get('close',t.get('price',t.get('entry',0)))),
              ('${:.4f}'.format(t.get('pnl',0)) if 'pnl' in t else '-'),
              str(t.get('reason',t.get('time','-')))[-16:]]
             for t in reversed(tlog[-8:])]
    if not trrows:
        trrows=[['暂无','-','-','-','-']]
    t7 = {'type':'table','title':'📜 近期成交',
        'cols':['方向','动作','价格','PnL','原因/时间'],'rows':trrows}

    LogStatus(
        '{} | {} | {} | ⏱{} | v4.0\n'.format(mtag,_D(),stag,ivdesc)+
        '`'+json.dumps(t1)+'`\n`'+json.dumps(t2)+'`\n`'+json.dumps(t3)+'`\n'+
        '`'+json.dumps(t4)+'`\n`'+json.dumps(t5)+'`\n`'+json.dumps(t6)+'`\n'+
        '`'+json.dumps(t7)+'`')

# ================================================================
#  十一、命令处理
# ================================================================
def handle_command(price):
    cmd = GetCommand()
    if not cmd:
        return
    mode = _G('rp_trade_mode') or TRADE_MODE
    if cmd=='切换模式':
        nxt = 'live' if mode=='paper' else 'paper'
        _G('rp_trade_mode',nxt)
        Log('🔄 模式切换:',mode,'→',nxt)
    elif cmd=='全部平仓':
        (live_close('manual') if mode=='live' else paper_close(price,'manual'))
    elif cmd=='重置模拟':
        try:
            ic = float(INIT_CAPITAL)
        except:
            ic = 10000.0
        _G('pt_cash',ic)
        _G('pt_position',json.dumps(None))
        _G('pt_trade_log',json.dumps([]))
        _G('pt_realized_pnl',0.0)
        _G('rp_emergency_count',0)
        _G('rp_last_check_price',None)
        global _profiles_cache, _ic_cache
        _profiles_cache = None
        _ic_cache = None
        Log('🔄 重置完成,保留本地缓存,下次优先读取缓存')
    elif cmd=='立即再平衡':
        _G('rp_force_rebal',True)
        Log('🔁 已标记立即再平衡')

# ================================================================
#  十二、初始化
# ================================================================
def init_state():
    if _G('kol_initialized'):
        return
    _G('kol_initialized',True)
    _G('rp_trade_mode',TRADE_MODE)
    try:
        ic = float(INIT_CAPITAL)
    except:
        ic = 10000.0
    _G('pt_cash',ic)
    _G('pt_init_capital',ic)
    _G('pt_position',json.dumps(None))
    _G('pt_trade_log',json.dumps([]))
    _G('pt_realized_pnl',0.0)
    _G('kol_run_count',0)
    _G('rp_emergency_count',0)
    _G('rp_last_check_price',None)
    _G('rp_last_rebal_time',0)
    _G('rp_force_rebal',False)
    Log('='*62)
    Log('  KOL蒸馏共识策略 v4.0 — 默认优先本地缓存')
    Log('  数据源: https://github.com/0xquqi/crypto-kol-quant')
    Log('  模式:', '🔴 实盘' if TRADE_MODE=='live' else '🟢 模拟')
    Log('  合约:', SYMBOL, '| 初始资金:', ic, 'USDT')
    Log('  缓存目录:', CACHE_DIR)
    Log('='*62)

# ================================================================
#  十三、主循环
# ================================================================
def main():
    init_state()
    try:
        init_cap = float(INIT_CAPITAL)
    except:
        init_cap = 10000.0

    chart = Chart({'title':{'text':'KOL蒸馏共识策略 v4.0 · 净值曲线'},
                 'xAxis':{'type':'datetime'},
                 'yAxis':{'title':{'text':'权益(USDT)'}},
                 'series':[{'name':'账户权益','type':'line','data':[]}]})

    load_profiles()
    load_ic()

    sleep_ms = SLOW_INTERVAL
    macro = {}
    last_macro = 0
    last_rebal = float(_G('rp_last_rebal_time') or 0)

    while True:
        try:
            ticker = exchange.GetTicker(SYMBOL)
            if not ticker:
                Log("⚠️ 无行情")
                Sleep(30000)
                continue
            price = float(ticker['Last'])

            handle_command(price)
            run_cnt = (_G('kol_run_count') or 0) + 1
            _G('kol_run_count',run_cnt)
            mode = _G('rp_trade_mode') or TRADE_MODE
            check_emergency(price)

            now_ts = int(time.time())
            if now_ts-last_macro>6*3600:
                Log("── 刷新宏观 ──")
                macro = fetch_macro(days=int(MACRO_LOOKBACK_DAYS))
                last_macro = now_ts

            try:
                lb = int(LOOKBACK_BARS)
            except:
                lb = 600
            records = exchange.GetRecords(SYMBOL,PERIOD_D1)
            if not records or len(records)<50:
                Log("⚠️ K线不足")
                Sleep(60000)
                continue
            records = records[-lb:]

            feat_df = build_features(records, macro if macro else None)
            factor_scores = evaluate_factors(feat_df)
            active = sum(1 for v in factor_scores.values() if abs(v)>0.05)
            Log("因子:{} 激活:{} 个".format(len(factor_scores),active))

            consensus = compute_consensus(factor_scores)
            Log("共识 trust={:.4f} ic={:.4f} eq={:.4f} L{}/S{}/N{} 学派{}个".format(
                consensus['trust_adjusted'],consensus['ic_weighted'],
                consensus.get('equal_mean',0),consensus['long_count'],
                consensus['short_count'],consensus['neutral_count'],
                len(consensus.get('by_school',{}))))
            
            rv30_val = float(feat_df['rv30'].iloc[-1]) if 'rv30' in feat_df.columns else 0.3
            sleep_ms = get_adaptive_sleep(rv30_val)

            try:
                rh = float(REBALANCE_HOURS)
            except:
                rh = 24.0
            force = _G('rp_force_rebal') or False
            time_due = (now_ts-float(last_rebal)) > rh*3600
            if force or time_due:
                Log("═══ 决策 {} ═══".format('强制' if force else '定时{:.0f}h'.format(rh)))
                run_once(mode,price,consensus)
                last_rebal = now_ts
                _G('rp_last_rebal_time',now_ts)
                _G('rp_force_rebal',False)
            else:
                run_once(mode,price,consensus)

            equity = calc_equity(price)
            if mode=='live':
                try:
                    acc = exchange.GetAccount()
                    equity = float(acc['Balance'])+float(acc['FrozenBalance']) if acc else init_cap
                except:
                    pass
            chart.add(0,[int(time.time()*1000),equity])
            LogProfit(equity-init_cap,'&')
            render_dashboard(consensus,price,equity,init_cap,run_cnt,sleep_ms)

        except Exception as e:
            Log("🔥 主循环异常:",str(e))

        sleep_with_pnl_refresh(sleep_ms,chart,init_cap)
Strategy parameters
Strategy parameters
交易模式 (Optional)
合约代码 (Optional)
初始资金(USDT) (Optional)
开多阈值 (Optional)
开空阈值 (Optional)
平仓阈值 (Optional)
仓位比例 (Optional)
杠杆倍数 (Optional)
止损比例 (Optional)
止盈比例 (Optional)
再平衡间隔(小时) (Optional)
K线回看条数 (Optional)
宏观回看天数 (Optional)
Commands
切换模式
切换模式
📤 全部平仓
全部平仓
🔄 重置模拟
重置模拟
🔁 立即再平衡
立即再平衡
Comment
All comments (0)
No data
No data
  • 1
Forums
PINE Language
Get the app
iPhone Download
© 2015 - ∞ INVENTOR PTE LTD (SG)