KOL蒸馏共识策略 v4.0


创建日期: 2026-04-29 11:33:44 最后修改: 2026-04-29 15:40:47
复制: 10 点击次数: 138
avatar of ianzeng123 ianzeng123
2
关注
469
关注者
策略源码
'''
================================================================
 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)