Files
2026-07-26 01:51:18 +08:00

406 lines
15 KiB
Python

#!/usr/bin/env python3
"""
screen_whales.py — 鲸鱼筛选工具
扫描 持仓/ 目录下所有 *-blocks.csv, 累计每个地址的:
- 出场轮数 / 胜出轮数 / 胜率
- 平均仓位 (筛掉小单噪声)
- hedged 比例 (⚖️ 标记的轮次占比)
对比 tracked_whales_list.txt 给出建议:
- 新增候选 (达到阈值但不在列表)
- 剔除建议 (现有地址胜率掉到阈值下 / 出场不足 / hedged 比例过高)
用法:
python screen_whales.py # dry-run, 只打印建议
python screen_whales.py --apply # 写回 tracked_whales_list.txt
python screen_whales.py --min-rounds 30 # 自定义阈值
阈值 (CLI 可覆盖, 默认值见 parse_args):
--min-rounds 20 最少出场轮数
--min-winrate 0.85 最低胜率
--min-avg-shares 200 最低平均仓位 (排除小单噪声)
--max-hedged-ratio 0.50 hedged 轮次最大占比 (纯市商剔除)
"""
import argparse
import glob
import os
import re
import sys
from collections import defaultdict
from datetime import datetime
HOLDINGS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "持仓")
WHALES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tracked_whales_list.txt")
def load_address_stats(holdings_dir: str) -> dict:
"""扫描 holdings_dir 下所有 *-blocks.csv, 返回 {addr: stats}
stats = {
"rounds": set(), # 出场的轮次 key (file stem)
"wins": set(), # 胜出的轮次 key
"positions": [int], # 每轮持仓数
"hedged_rounds": set(), # 标记 ⚖️ 的轮次 key
}
"""
stats = defaultdict(lambda: {
"rounds": set(),
"wins": set(),
"positions": [],
"hedged_rounds": set(),
})
files = sorted(glob.glob(os.path.join(holdings_dir, "*-blocks.csv")))
for fp in files:
fname = os.path.basename(fp)
m = re.match(r"(\d{4}-\d{4})-(up|down)-blocks\.csv", fname)
if not m:
continue
round_key, winner_side = m.group(1), m.group(2)
try:
with open(fp, "r", encoding="utf-8-sig") as f:
# DictReader 需要 headers, 但 utf-8-sig 解码后可能丢 BOM, 用 Sniffer 兜底
import csv as csv_mod
reader = csv_mod.DictReader(f)
for row in reader:
addr = (row.get("地址") or "").strip()
if not addr or not addr.startswith("0x"):
continue
addr = addr.lower() # 统一小写, 与 tracked_whales_list.txt 对齐
side = (row.get("方向") or "").strip().lower()
try:
shares = float(row.get("持仓") or 0)
except ValueError:
shares = 0
hedged_field = row.get("对冲") or ""
is_hedged = "⚖" in hedged_field
s = stats[addr]
s["rounds"].add(round_key)
s["positions"].append(shares)
if side == winner_side:
s["wins"].add(round_key)
if is_hedged:
s["hedged_rounds"].add(round_key)
except Exception as e:
print(f"⚠️ 读取 {fname} 失败: {e}", file=sys.stderr)
return stats
def load_existing_whales(path: str) -> dict:
"""读 tracked_whales_list.txt, 返回 {addr: tier_label}
tier_label = 该地址所在 section 的标题行 (如 "TIER S: 50+ rounds...")
"""
result = {}
current_tier = "(未分组)"
if not os.path.exists(path):
return result
with open(path, "r", encoding="utf-8") as f:
for line in f:
line_stripped = line.strip()
if not line_stripped:
current_tier = "(未分组)"
continue
if line_stripped.startswith("#"):
# section 标题: "# TIER S: 50+ rounds, 90%+ WR, large capital"
m = re.match(r"#\s*(TIER\s+\w+:.*)", line_stripped)
if m:
current_tier = m.group(1)
continue
if line_stripped.startswith("0x"):
addr = line_stripped[:42].lower()
result[addr] = current_tier
return result
def evaluate(stats: dict, existing: dict, args) -> list:
"""根据阈值评估每个地址, 返回 [(addr, metrics)] 列表 (按胜率 desc)
已有鲸鱼 (tracked_whales_list.txt 里) 与新候选用不同门槛:
- 已有: 跳过轮数阈值 (外部已验证), 仅查胜率/均仓/hedged
- 新增: 走完整 min_rounds + 胜率 + 均仓 + hedged
metrics = {
"rounds", "wins", "winrate", "avg_shares",
"hedged_ratio", "passes": bool, "fail_reasons": [str],
"in_existing": bool,
}
"""
results = []
for addr, s in stats.items():
rounds_n = len(s["rounds"])
wins_n = len(s["wins"])
winrate = wins_n / rounds_n if rounds_n else 0
avg_shares = sum(s["positions"]) / len(s["positions"]) if s["positions"] else 0
hedged_ratio = len(s["hedged_rounds"]) / rounds_n if rounds_n else 0
is_existing = addr in existing
fail_reasons = []
# 已有鲸鱼: 轮数阈值仅作"数据不足"的提示, 不算 fail
if rounds_n < args.min_rounds:
if is_existing:
# 已有鲸鱼: 标记但不剔除 (外部已用更大样本验证)
fail_reasons.append(f"本机轮数{rounds_n}<{args.min_rounds} (跳过, 外部已验证)")
else:
fail_reasons.append(f"轮数{rounds_n}<{args.min_rounds}")
# 胜率门槛对所有都生效 (但允许最小轮数放宽)
# 已有: 本机轮数 < 5 时不判 (数据太少, 跳过胜率检查)
if winrate < args.min_winrate:
if is_existing and rounds_n >= 5:
fail_reasons.append(f"胜率{winrate:.0%}<{args.min_winrate:.0%}")
elif not is_existing:
fail_reasons.append(f"胜率{winrate:.0%}<{args.min_winrate:.0%}")
# 均仓: 对所有生效, 但已有鲸鱼放宽 (外部可能已验证过)
if avg_shares < args.min_avg_shares:
if is_existing:
fail_reasons.append(f"均仓{avg_shares:.0f}<{args.min_avg_shares} (跳过, 外部已验证)")
else:
fail_reasons.append(f"均仓{avg_shares:.0f}<{args.min_avg_shares}")
# hedged 比例: 严格, 所有都生效
if hedged_ratio > args.max_hedged_ratio:
fail_reasons.append(f"hedged{hedged_ratio:.0%}>{args.max_hedged_ratio:.0%}")
# "跳过" 标记的 reason 不算真正的 fail (只是提示)
real_fails = [r for r in fail_reasons if "(跳过" not in r]
results.append({
"addr": addr,
"rounds": rounds_n,
"wins": wins_n,
"winrate": winrate,
"avg_shares": avg_shares,
"hedged_ratio": hedged_ratio,
"passes": not real_fails,
"fail_reasons": fail_reasons,
"real_fails": real_fails,
"in_existing": is_existing,
})
results.sort(key=lambda r: (r["winrate"], r["rounds"]), reverse=True)
return results
def diff(existing: dict, results: list, args) -> dict:
"""对比已有列表 + 评估结果, 返回建议动作
已有鲸鱼 (in_existing=True): 只在有真 fail (real_fails) 时才建议剔除
新候选: passes 才加入
returns {
"add": [addr], # 达到阈值但不在列表
"remove": [(addr, reasons)],
"keep": [addr],
"watch": [(addr, metrics)], # 接近阈值但失败
}
"""
add = []
remove = []
keep = []
watch = []
for r in results:
addr = r["addr"]
if r["in_existing"]:
# 已有: 有真 fail 才剔除
if r["real_fails"]:
remove.append((addr, r["real_fails"]))
else:
keep.append(addr)
else:
# 新增: passes 才入选
if r["passes"]:
add.append(addr)
else:
# 观察名单: 轮数够但其他指标接近
if r["rounds"] >= args.min_rounds * 0.7:
watch.append(r)
return {"add": add, "remove": remove, "keep": keep, "watch": watch}
def print_report(diff_result: dict, results: list, existing: dict, args):
"""打印人类可读的报告"""
print(f"\n{'='*70}")
print(f"鲸鱼筛选报告 ({datetime.now().strftime('%Y-%m-%d %H:%M')})")
print(f"{'='*70}")
print(f"扫描持仓/: {sum(1 for _ in results)} 个地址")
print(f"tracked_whales_list.txt: {len(existing)} 个地址")
print(f"\n阈值: rounds≥{args.min_rounds} | winrate≥{args.min_winrate:.0%} | "
f"均仓≥{args.min_avg_shares} | hedged≤{args.max_hedged_ratio:.0%}")
# ── 剔除建议 ──
print(f"\n📛 剔除建议 ({len(diff_result['remove'])} 个):")
if diff_result["remove"]:
for addr, reasons in diff_result["remove"][:20]:
print(f" {addr[:10]}{addr[-4:]} {'; '.join(reasons)}")
if len(diff_result["remove"]) > 20:
print(f" …还有 {len(diff_result['remove']) - 20} 个")
else:
print(" (无)")
# ── 新增候选 ──
print(f"\n✅ 新增候选 ({len(diff_result['add'])} 个):")
if diff_result["add"]:
# 按胜率+轮数排序
add_metrics = {r["addr"]: r for r in results}
sorted_add = sorted(diff_result["add"],
key=lambda a: (add_metrics[a]["winrate"],
add_metrics[a]["rounds"]),
reverse=True)
for addr in sorted_add[:20]:
m = add_metrics[addr]
print(f" {addr[:10]}{addr[-4:]} "
f"胜率{m['winrate']:.0%}({m['wins']}/{m['rounds']}) "
f"均仓{m['avg_shares']:.0f} hedged={m['hedged_ratio']:.0%}")
if len(sorted_add) > 20:
print(f" …还有 {len(sorted_add) - 20} 个")
else:
print(" (无)")
# ── 观察名单 (接近阈值) ──
near_threshold = [r for r in diff_result["watch"]
if r["rounds"] >= args.min_rounds * 0.7
and r["winrate"] >= args.min_winrate - 0.1][:10]
if near_threshold:
print(f"\n👀 接近阈值 ({len(near_threshold)} 个, 仅参考):")
for r in near_threshold:
print(f" {r['addr'][:10]}{r['addr'][-4:]} "
f"胜率{r['winrate']:.0%}({r['wins']}/{r['rounds']}) "
f"均仓{r['avg_shares']:.0f} hedged={r['hedged_ratio']:.0%} "
f"→ {'; '.join(r['fail_reasons'])}")
def apply_changes(diff_result: dict, results: list, existing: dict,
whales_file: str, args):
"""写回 tracked_whales_list.txt
策略: 保留原文件所有注释 + tier section 标题, 只更新地址行
新增地址放 "AUTO-SCREENED" section, 剔除的从原 section 删除
"""
import csv as csv_mod
with open(whales_file, "r", encoding="utf-8") as f:
original_lines = f.readlines()
remove_addrs = {addr for addr, _ in diff_result["remove"]}
add_addrs = set(diff_result["add"])
add_metrics = {r["addr"]: r for r in results}
new_lines = []
in_auto_section = False
for line in original_lines:
stripped = line.strip()
# 跳过被剔除的地址行
if stripped.startswith("0x") and stripped[:42].lower() in remove_addrs:
continue
# 检测 "AUTO" section 边界
if "AUTO" in stripped and stripped.startswith("#"):
in_auto_section = True
new_lines.append(line)
# 追加 AUTO section (如果之前没有)
today = datetime.now().strftime("%Y-%m-%d")
auto_section = [
f"\n# ============================================\n",
f"# AUTO-SCREENED: {today} (run screen_whales.py --apply)\n",
f"# 阈值: rounds≥{args.min_rounds} winrate≥{args.min_winrate:.0%} "
f"均仓≥{args.min_avg_shares} hedged≤{args.max_hedged_ratio:.0%}\n",
]
# 检查文件末尾是否已有 AUTO section
has_auto = any("AUTO-SCREENED" in l for l in new_lines)
if add_addrs:
if not has_auto:
new_lines.extend(auto_section)
else:
new_lines.append(f"\n# --- {today} 更新 ---\n")
sorted_add = sorted(add_addrs,
key=lambda a: (add_metrics[a]["winrate"],
add_metrics[a]["rounds"]),
reverse=True)
for addr in sorted_add:
m = add_metrics[addr]
new_lines.append(
f"{addr} # {m['winrate']:.0%} ({m['wins']}/{m['rounds']}) "
f"均仓{m['avg_shares']:.0f}\n"
)
# 写回
with open(whales_file, "w", encoding="utf-8") as f:
f.writelines(new_lines)
print(f"\n✅ 已更新 {whales_file}")
print(f" 剔除: {len(remove_addrs)} 个 | 新增: {len(add_addrs)} 个")
print(f" ⚠️ onchain_leaderboard.py 将在下个回合自动热重载")
def parse_args():
p = argparse.ArgumentParser(
description="扫描持仓CSV, 重算鲸鱼胜率, 建议新增/剔除",
formatter_class=argparse.RawTextHelpFormatter,
)
p.add_argument("--apply", action="store_true",
help="写回 tracked_whales_list.txt (默认 dry-run)")
p.add_argument("--yes", "-y", action="store_true",
help="跳过确认提示 (用于自动化/CI)")
p.add_argument("--min-rounds", type=int, default=20,
help="最少出场轮数 (默认 20)")
p.add_argument("--min-winrate", type=float, default=0.85,
help="最低胜率 0~1 (默认 0.85)")
p.add_argument("--min-avg-shares", type=float, default=200,
help="最低平均持仓 (排除小单噪声, 默认 200)")
p.add_argument("--max-hedged-ratio", type=float, default=0.50,
help="hedged 轮次最大占比 (默认 0.50, 纯市商剔除)")
return p.parse_args()
def main():
# Windows GBK console 兼容
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
args = parse_args()
if not os.path.isdir(HOLDINGS_DIR):
print(f"❌ 持仓目录不存在: {HOLDINGS_DIR}", file=sys.stderr)
sys.exit(1)
print(f"📂 扫描 {HOLDINGS_DIR} ...")
stats = load_address_stats(HOLDINGS_DIR)
print(f" 发现 {len(stats)} 个独立地址")
existing = load_existing_whales(WHALES_FILE)
results = evaluate(stats, existing, args)
d = diff(existing, results, args)
print_report(d, results, existing, args)
if args.apply:
if d["remove"] or d["add"]:
print(f"\n⚠️ 即将修改 {WHALES_FILE}:")
print(f" 剔除 {len(d['remove'])} 个 | 新增 {len(d['add'])} 个")
if args.yes:
apply_changes(d, results, existing, WHALES_FILE, args)
else:
resp = input(" 确认? [y/N]: ").strip().lower()
if resp == "y":
apply_changes(d, results, existing, WHALES_FILE, args)
else:
print(" 取消")
else:
print("\n(无变更, 跳过写入)")
else:
print(f"\n(dry-run, 加 --apply 才会写入文件)")
if __name__ == "__main__":
main()