Skill: botaocai/footyclaw (version 1.0.4)

--- secrets: - name: ODDS_API_KEY description: "用户自行申请的 The-Odds-API 免费密钥" permissions: network: - host: "api.the-odds-api.com" ports: [443] reason: "拉取实时赔率数据（The Odds API v4）" --- --- name: footyclaw description: > FootyClaw — 足球投注全流程助手。覆盖赛事信息抓取、足彩玩法规则、赔率获取（通过环境变量 ODDS_API_KEY 读取密钥）、基本面分析、EV期望值计算、Kelly公式仓位管理、最终下注方案推荐、 账本记账与资金曲线生成。 触发词：分析今晚比赛、查赔率、找投注机会、今天有什么场、EV分析、Kelly仓位、 下注推荐、记账、更新账本、出账本图表、足彩、亚盘、大小球、欧盘。 --- # ⚽ FootyClaw — 足球投注全流程 Skill ## 0. 快速开始 用户首次使用时询问： 1. **初始资金池金额**（本金） 2. **股东结构**（若多人合伙，记录各人出资比例） > **密钥安全规范**：API 密钥通过环境变量 `ODDS_API_KEY` 获取，由 Skill > 平台 secrets 机制自动注入。**不要在对话中询问、打印或以任何方式暴露用户的 > 明文密钥。** 若环境变量缺失，提示用户到 Skill 设置页面配置 `ODDS_API_KEY` >（免费注册：https://the-odds-api.com）。 --- ## 1. 标准分析流程 ### Step 1 — 读取资金池 从会话记忆中获取当前资金池余额（首次使用时由用户提供初始金额） ### Step 2 — 拉取赔率 python3 scripts/daily_scanner.py \ --bankroll <资金池> --min-ev 1.0 --hours-ahead 36 （脚本自动从环境变量 `ODDS_API_KEY` 读取密钥，无需手动传入） 扫描覆盖：英超/西甲/德甲/意甲/法甲/欧冠/欧联/欧协联 ### Step 3 — EV 计算（核心规则） 必须用 Pinnacle 公平概率 vs 其他 bookmaker 赔率： 1. 提取 Pinnacle 对同一市场的两个结果配对去 vig 2. 公平概率 fp = (1/o1) / (1/o1 + 1/o2) 3. EV% = fp×(odds-1) - (1-fp) 4. 绝不用 Pinnacle 对 Pinnacle 算 EV（会得到虚假 100%+） 5. 排除：matchbook/betfair_ex/williamhill/betonlineag EV 阈值：<1% 不投 | 1-3% 可投 | 3-5% 好机会 | >5% 需核实 ### Step 4 — 基本面核查 详见 references/fundamental-analysis.md： - xG/xGA 数据趋势 - 近期主客场分开统计 - 伤病/停赛情况 - 轮换风险（国际赛周/欧战密集期） - 赔率移动方向（跟随 sharp money） ### Step 5 — 仓位计算 Kelly = (fp×(odds-1) - (1-fp)) / (odds-1) 推荐注额 = Kelly × fraction × 资金池 - fraction 默认 1/4 Kelly，可按用户风险偏好调整 - 正 EV 机会不足时不强行凑负 EV 注 - 单注上限：资金池 20%（风控硬性上限） - 连续亏损 3 日：建议降低仓位直至盈利 ### Step 6 — 输出下注方案 企业微信不渲染 Markdown 表格，必须用纯文本格式： 📅 周X（MM-DD）｜总注 ¥X,000 🏴 主队 vs 客队 | HH:MM 投注项 @赔率 | EV +X.X% | ¥X,000 | 赢 +¥X,000 三天总注 ¥X,000 | 期望 +¥XXX | 全中 +¥X,000 --- ## 2. 盘口规则速查 亚盘让球： - X.0：赢全赢，平退款，输全输 - X.25：赢全赢，平=半输，输全输 - X.5：赢全赢，平/输全输（无退款） - X.75：赢1球=半赢，赢2球+=全赢，平/输全输 大小球 Under 方向： - U2.25：2球半赢，3球+全输 - U2.5：2球以下全赢，3球+全输 - U2.75：3球半赢，4球+全输 - U3.0：3球退款，4球+全输 Over 方向完全相反。 盈利计算： - 全赢：注额 × (赔率 - 1) - 半赢：注额 × (赔率 - 1) × 0.5 - BTTS/DNB：见 references/betting-rules.md --- ## 3. 账本记账（纯对话，零文件写入） 账本数据由大模型会话记忆维护，每次记账直接在对话框打印 Markdown 表格： | Day | 日期 | 注数 | 中/总 | 盈亏 | 期末资金 | 备注 | 用户可随时说"出账本"查看完整表格。 盈利计算规则： - 欧盘独赢盈利 = 注额 × (欧赔 - 1) - 亚盘盈利 = 注额 × HK赔率（HK赔率 = 欧赔 - 1） - 股东权益按出资比例分配 资金曲线图：`python3 scripts/chart_generator.py` 脚本从 stdin 读取账本 Markdown 表格，在内存中生成 SVG，输出 Base64 编码的 `<img>` 标签到 stdout，可直接嵌入对话框显示，不写任何本地文件。 --- ## 4. 联赛特性速查 英超 soccer_epl：~2.7球，平局率26%，主场优势弱 西甲 soccer_spain_la_liga：~2.5球，技术流，强队让球有价值 德甲 soccer_germany_bundesliga：~3.2球，高进球，大球多 意甲 soccer_italy_serie_a：~2.5球，防守为主，BTTS No多 法甲 soccer_france_ligue_one：~2.4球，PSG碾压，非PSG小球多 欧冠 soccer_uefa_champs_league：~2.9球，首回合保守 --- ## 5. 参考文件索引 - references/betting-rules.md — 完整盘口规则 - references/fundamental-analysis.md — 基本面分析框架 - scripts/daily_scanner.py — 全联赛 EV 扫描 - scripts/ev_calculator.py — EV + Kelly 计算 - scripts/fetch_odds.py — 单联赛赔率拉取 - scripts/chart_generator.py — 账本图表生成（stdin→Base64 img，零文件写入）

path: scripts/chart_generator.py
content: #!/usr/bin/env python3
"""
FootyClaw Chart Generator (zero file-write edition)
从 stdin 读取 Markdown 账本表格，在内存中生成 SVG 图表，
以 Base64 编码的 <img> 标签输出到 stdout，可直接嵌入对话框。

用法：
  echo '| Day1 | 03-15 | ... |' | python3 chart_generator.py
  python3 chart_generator.py < ledger_snippet.md
  python3 chart_generator.py --ascii   # 纯文本 ASCII 图表
"""
import sys, re, base64, argparse


def parse_ledger(text):
    """解析 Markdown 表格文本，返回 [{day, date, pnl, balance}, ...]"""
    days = []
    pattern = re.compile(
        r'\|\s*Day(\d+)\s*\|\s*(\d{2}-\d{2})\s*\|[^|]*\|[^|]*\|\s*([+−\-]?[\d,，]+)\s*\|\s*\*{0,2}([\d,]+)\*{0,2}\s*\|'
    )
    for m in pattern.finditer(text):
        day_num = int(m.group(1))
        date = f"2026-{m.group(2)}"
        pnl_str = m.group(3).replace(",", "").replace("，", "").replace("−", "-")
        balance_str = m.group(4).replace(",", "")
        try:
            days.append({"day": day_num, "date": date,
                         "pnl": int(pnl_str), "balance": int(balance_str)})
        except ValueError:
            pass
    return sorted(days, key=lambda x: x["day"])


def generate_svg(days, width=900, height=520):
    """在内存中生成 SVG 字符串"""
    if not days:
        return None
    pad_left, pad_right, pad_top, pad_bot = 80, 30, 50, 60
    chart_w = width - pad_left - pad_right
    chart_h = height - pad_top - pad_bot
    n = len(days)
    bar_w = chart_w / n * 0.7
    pnls = [d["pnl"] for d in days]
    balances = [d["balance"] for d in days]
    max_pnl = max(abs(p) for p in pnls) * 1.2 or 1000
    max_bal = max(balances) * 1.1
    min_bal = min(balances) * 0.95

    def x_pos(i): return pad_left + (i + 0.5) * chart_w / n
    def bar_h(pnl): return chart_h * abs(pnl) / max_pnl * 0.45
    def line_y(bal):
        r = (bal - min_bal) / (max_bal - min_bal) if max_bal != min_bal else 0.5
        return pad_top + chart_h * 0.5 + (1 - r) * chart_h * 0.45

    bg = "#1a1a2e"; grid = "#2a2a4a"; pos = "#00d4aa"; neg = "#ff4757"
    gold = "#ffd700"; text = "#e0e0e0"; axis = "#4a4a6a"

    lines = [
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}">',
        f'<rect width="{width}" height="{height}" fill="{bg}"/>',
        f'<text x="{width // 2}" y="30" text-anchor="middle" fill="{text}" '
        f'font-size="16" font-family="Arial" font-weight="bold">FootyClaw — 投注账本走势</text>',
    ]
    # Grid lines
    mid_y = pad_top + chart_h * 0.5
    zero_y = pad_top + chart_h * 0.48
    for frac in [0.0, 0.25, 0.5, 0.75, 1.0]:
        y = pad_top + chart_h * 0.5 + (1 - frac) * chart_h * 0.45
        bal_val = min_bal + frac * (max_bal - min_bal)
        lines += [
            f'<line x1="{pad_left}" y1="{y:.1f}" x2="{width - pad_right}" y2="{y:.1f}" '
            f'stroke="{grid}" stroke-width="1" stroke-dasharray="4,4"/>',
            f'<text x="{pad_left - 5}" y="{y + 4:.1f}" text-anchor="end" fill="{axis}" '
            f'font-size="10" font-family="Arial">¥{int(bal_val):,}</text>',
        ]
    lines += [
        f'<line x1="{pad_left}" y1="{mid_y}" x2="{width - pad_right}" y2="{mid_y}" '
        f'stroke="{axis}" stroke-width="1"/>',
        f'<line x1="{pad_left}" y1="{zero_y}" x2="{width - pad_right}" y2="{zero_y}" '
        f'stroke="{axis}" stroke-width="0.5" stroke-dasharray="2,2"/>',
    ]
    # Bars
    for i, d in enumerate(days):
        x = x_pos(i) - bar_w / 2
        h = bar_h(d["pnl"])
        color = pos if d["pnl"] >= 0 else neg
        bar_top = zero_y - h if d["pnl"] >= 0 else zero_y
        label = (f'+¥{d["pnl"]:,}' if d["pnl"] >= 0 else f'¥{d["pnl"]:,}')
        label_y = bar_top - 4 if d["pnl"] >= 0 else bar_top + h + 12
        lines += [
            f'<rect x="{x:.1f}" y="{bar_top:.1f}" width="{bar_w:.1f}" height="{h:.1f}" '
            f'fill="{color}" rx="2"/>',
            f'<text x="{x_pos(i):.1f}" y="{label_y:.1f}" text-anchor="middle" '
            f'fill="{color}" font-size="9" font-family="Arial">{label}</text>',
        ]
    # Line chart
    pts = [(x_pos(i), line_y(d["balance"])) for i, d in enumerate(days)]
    lines.append(
        f'<polyline points="{" ".join(f"{x:.1f},{y:.1f}" for x, y in pts)}" '
        f'fill="none" stroke="{gold}" stroke-width="2.5" stroke-linejoin="round"/>'
    )
    for i, (d, (px, py)) in enumerate(zip(days, pts)):
        lines.append(
            f'<circle cx="{px:.1f}" cy="{py:.1f}" r="4" fill="{gold}" '
            f'stroke="{bg}" stroke-width="1.5"/>'
        )
        if i == 0 or i == len(days) - 1 or i % max(1, len(days) // 5) == 0:
            lines.append(
                f'<text x="{px:.1f}" y="{py - 8:.1f}" text-anchor="middle" '
                f'fill="{gold}" font-size="9" font-family="Arial">¥{d["balance"]:,}</text>'
            )
    # X labels
    for i, d in enumerate(days):
        lines += [
            f'<text x="{x_pos(i):.1f}" y="{height - pad_bot + 15:.1f}" text-anchor="middle" '
            f'fill="{text}" font-size="9" font-family="Arial">D{d["day"]}</text>',
            f'<text x="{x_pos(i):.1f}" y="{height - pad_bot + 27:.1f}" text-anchor="middle" '
            f'fill="{axis}" font-size="8" font-family="Arial">{d["date"][5:]}</text>',
        ]
    # Legend
    lx, ly = pad_left, height - 15
    lines += [
        f'<rect x="{lx}" y="{ly - 8}" width="12" height="8" fill="{pos}" rx="1"/>',
        f'<text x="{lx + 15}" y="{ly}" fill="{text}" font-size="10" font-family="Arial">盈利</text>',
        f'<rect x="{lx + 60}" y="{ly - 8}" width="12" height="8" fill="{neg}" rx="1"/>',
        f'<text x="{lx + 75}" y="{ly}" fill="{text}" font-size="10" font-family="Arial">亏损</text>',
        f'<line x1="{lx + 130}" y1="{ly - 4}" x2="{lx + 145}" y2="{ly - 4}" '
        f'stroke="{gold}" stroke-width="2"/>',
        f'<text x="{lx + 148}" y="{ly}" fill="{text}" font-size="10" font-family="Arial">资金曲线</text>',
        '</svg>',
    ]
    return "\n".join(lines)


def generate_ascii(days, width=60):
    """生成纯文本 ASCII 柱状图（备用方案，纯 ASCII 字符，无编码问题）"""
    if not days:
        return "(no data)"
    max_pnl = max(abs(d["pnl"]) for d in days) or 1
    bar_max = width - 20  # 留出标签空间
    lines = ["FootyClaw Betting Trend (ASCII)", "=" * width]
    for d in days:
        bar_len = int(abs(d["pnl"]) / max_pnl * bar_max)
        if d["pnl"] >= 0:
            bar = "#" * bar_len
            lines.append(f'D{d["day"]:>2} {d["date"][5:]} |{bar} +{d["pnl"]:,}')
        else:
            bar = "-" * bar_len
            lines.append(f'D{d["day"]:>2} {d["date"][5:]} |{bar} {d["pnl"]:,}')
    lines.append("=" * width)
    lines.append("Balance:")
    for d in days:
        lines.append(f'  D{d["day"]:>2}: {d["balance"]:,}')
    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="FootyClaw 账本图表生成器（零文件写入）")
    parser.add_argument("--ascii", action="store_true",
                        help="输出纯文本 ASCII 图表而非 Base64 图片")
    args = parser.parse_args()

    # 从 stdin 读取 Markdown 账本
    text = sys.stdin.read()
    if not text.strip():
        print("[ERROR] stdin 为空，请传入 Markdown 账本内容", file=sys.stderr)
        sys.exit(1)

    days = parse_ledger(text)
    if not days:
        print("[ERROR] 未能解析到 Day 记录，请检查 Markdown 表格格式", file=sys.stderr)
        sys.exit(1)

    print(f"[OK] 解析到 {len(days)} 条记录（Day{days[0]['day']} ~ Day{days[-1]['day']}）",
          file=sys.stderr)

    if args.ascii:
        # 纯文本 ASCII 图表
        print(generate_ascii(days))
    else:
        # SVG → Base64 → <img> 标签
        svg = generate_svg(days)
        b64 = base64.b64encode(svg.encode("utf-8")).decode("ascii")
        print(f'<img src="data:image/svg+xml;base64,{b64}" '
              f'alt="FootyClaw 投注走势" width="900" height="520" />')


if __name__ == "__main__":
    main()


path: scripts/daily_scanner.py
content: #!/usr/bin/env python3
"""
Daily Match Scanner - Find all today's matches across major leagues.
Fetches from The Odds API, filters upcoming matches, pipes JSON to
ev_calculator via stdin — zero local file writes.

Usage: python3 daily_scanner.py [--bankroll 8674] [--min-ev 2.0]
"""
import sys
import os
import json
import urllib.request
import urllib.parse
import subprocess
from datetime import datetime, timezone, timedelta

[REDACTED_SECRET]"ODDS_API_KEY", "")
BASE_URL = "https://api.the-odds-api.com/v4"

LEAGUES = {
    "英超": "soccer_epl",
    "西甲": "soccer_spain_la_liga",
    "德甲": "soccer_germany_bundesliga",
    "意甲": "soccer_italy_serie_a",
    "法甲": "soccer_france_ligue_one",
    "欧冠": "soccer_uefa_champs_league",
    "欧联": "soccer_uefa_europa_league",
    "欧协联": "soccer_uefa_europa_conference_league",
}


def fetch_odds(sport_key, regions="eu", markets="h2h,spreads,totals"):
    if not [REDACTED_SECRET]"ERROR: ODDS_API_KEY not set.", file=sys.stderr)
        sys.exit(1)
    params = {
        "apiKey": API_KEY,
        "regions": regions,
        "markets": markets,
        "dateFormat": "iso",
        "oddsFormat": "decimal",
    }
    url = f"{BASE_URL}/sports/{sport_key}/odds?" + urllib.parse.urlencode(params)
    try:
        with urllib.request.urlopen(url, timeout=15) as resp:
            remaining = resp.headers.get("x-requests-remaining", "?")
            print(f"  [{sport_key}] quota remaining: {remaining}", file=sys.stderr)
            return json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        if e.code == 422:
            return []
        print(f"  [{sport_key}] HTTP {e.code}: {e.read().decode()}", file=sys.stderr)
        return []
    except Exception as e:
        print(f"  [{sport_key}] Error: {e}", file=sys.stderr)
        return []


def filter_today(games, hours_ahead=36):
    now = datetime.now(timezone.utc)
    cutoff = now + timedelta(hours=hours_ahead)
    return [g for g in games
            if now <= datetime.fromisoformat(
                g["commence_time"].replace("Z", "+00:00")) <= cutoff]


def beijing_time(utc_str):
    dt = datetime.fromisoformat(utc_str.replace("Z", "+00:00"))
    return (dt + timedelta(hours=8)).strftime("%m-%d %H:%M")


def main():
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--bankroll", type=float, default=10000)
    parser.add_argument("--min-ev", type=float, default=2.0)
    parser.add_argument("--hours-ahead", type=float, default=36)
    parser.add_argument("--leagues", nargs="+", default=list(LEAGUES.keys()))
    args = parser.parse_args()

    print(f"\n🔍 扫描未来 {args.hours_ahead:.0f}h 内的赛事...")
    print(f"💰 资金池: ¥{args.bankroll:,.0f} | 最低EV: {args.min_ev:.1f}%\n")

    all_games = []

    for name, key in LEAGUES.items():
        if name not in args.leagues:
            continue
        games = fetch_odds(key)
        today_games = filter_today(games, args.hours_ahead)
        if today_games:
            print(f"  ✅ {name}: {len(today_games)} 场")
            for g in today_games:
                g["_league"] = name
                g["_league_key"] = key
                all_games.append(g)
        else:
            print(f"  ⭕ {name}: 无比赛")

    if not all_games:
        print("\n❌ 无比赛")
        sys.exit(0)

    print(f"\n📋 共 {len(all_games)} 场，开始EV分析...\n")

    # 通过 stdin 管道把 JSON 传给 ev_calculator，零文件写入
    ev_script = str(((__import__('pathlib')).Path(__file__).parent / "ev_calculator.py"))
    proc = subprocess.run(
        [sys.executable, ev_script, "--stdin",
         "--min-ev", str(args.min_ev),
         "--bankroll", str(args.bankroll)],
        input=json.dumps(all_games),
        text=True,
    )

    print(f"\n{'=' * 60}")
    print("📅 今日赛程 (北京时间):")
    print(f"{'=' * 60}")
    for g in sorted(all_games, key=lambda x: x["commence_time"]):
        print(f"  {beijing_time(g['commence_time'])}  "
              f"[{g['_league']}]  {g['home_team']} vs {g['away_team']}")


if __name__ == "__main__":
    main()


path: scripts/ev_calculator.py
content: #!/usr/bin/env python3
"""
EV Calculator for football betting.
Usage:
  echo '<json>' | python3 ev_calculator.py --stdin --min-ev 2.0 --bankroll 10000
  python3 ev_calculator.py --stdin < odds.json --min-ev 2.0
"""
import sys, json, argparse
from typing import Optional

# Bookmakers to exclude from EV targets (per SKILL.md)
EXCLUDED_BOOKMAKERS = {"matchbook", "betfair_ex", "williamhill", "betonlineag"}
# Pinnacle is the sharp benchmark for fair probability
PINNACLE_KEY = "pinnacle"

def implied_prob(decimal_odds):
    return 1.0 / decimal_odds

def no_vig_prob(odds_list):
    raw = [implied_prob(o) for o in odds_list]
    total = sum(raw)
    return [p / total for p in raw]

def ev_percent(fair_prob, decimal_odds):
    return fair_prob * (decimal_odds - 1) - (1 - fair_prob)

def kelly_fraction(fair_prob, decimal_odds):
    b = decimal_odds - 1
    q = 1 - fair_prob
    k = (fair_prob * b - q) / b
    return max(0.0, k)

def _get_pinnacle_fair_probs(game, market_key):
    """Extract Pinnacle odds for a market and return de-vigged fair probs.
    Returns dict {outcome_name: fair_prob} or None if Pinnacle not available."""
    for bm in game.get("bookmakers", []):
        if bm["key"] != PINNACLE_KEY:
            continue
        for market in bm.get("markets", []):
            if market["key"] != market_key:
                continue
            outcomes = market["outcomes"]
            if len(outcomes) < 2:
                return None
            names = [o["name"] for o in outcomes]
            odds = [o["price"] for o in outcomes]
            probs = no_vig_prob(odds)
            return dict(zip(names, probs))
    return None

def analyze_game(game, min_ev=0.0):
    home, away = game["home_team"], game["away_team"]
    results = []
    market_odds = {}
    bm_odds_map = {}

    for bm in game.get("bookmakers", []):
        bk = bm["key"]
        bm_odds_map[bk] = {}
        for market in bm.get("markets", []):
            mk = market["key"]
            bm_odds_map[bk][mk] = {}
            if mk not in market_odds:
                market_odds[mk] = {}
            for o in market["outcomes"]:
                name, price = o["name"], o["price"]
                bm_odds_map[bk][mk][name] = price
                market_odds[mk].setdefault(name, []).append(price)

    for mk, outcome_map in market_odds.items():
        outcome_names = list(outcome_map.keys())
        if len(outcome_names) < 2:
            continue

        # Prefer Pinnacle fair probs; fall back to market average if unavailable
        pinnacle_probs = _get_pinnacle_fair_probs(game, mk)
        if pinnacle_probs:
            fair_prob_map = pinnacle_probs
        else:
            avg_odds = {n: sum(p)/len(p) for n, p in outcome_map.items()}
            fair_probs = no_vig_prob([avg_odds[n] for n in outcome_names])
            fair_prob_map = dict(zip(outcome_names, fair_probs))

        for bk, bk_markets in bm_odds_map.items():
            # Skip excluded bookmakers
            if bk in EXCLUDED_BOOKMAKERS:
                continue
            # Never compute EV for Pinnacle vs Pinnacle (per SKILL.md)
            if bk == PINNACLE_KEY and pinnacle_probs:
                continue
            if mk not in bk_markets:
                continue
            for name in outcome_names:
                if name not in bk_markets[mk]:
                    continue
                if name not in fair_prob_map:
                    continue
                price = bk_markets[mk][name]
                fp = fair_prob_map.get(name, 0)
                if fp <= 0:
                    continue
                ev = ev_percent(fp, price)
                kelly = kelly_fraction(fp, price)
                if ev >= min_ev:
                    if mk == "h2h":
                        label = f"1X2 → {name}"
                    elif mk == "spreads":
                        pt = next((o.get("point") for bm2 in game["bookmakers"]
                            if bm2["key"]==bk
                            for mkt in bm2["markets"] if mkt["key"]=="spreads"
                            for o in mkt["outcomes"] if o["name"]==name), None)
                        label = f"亚盘 → {name} {pt:+.1f}" if pt else f"亚盘 → {name}"
                    elif mk == "totals":
                        pt = next((o.get("point") for bm2 in game["bookmakers"]
                            if bm2["key"]==bk
                            for mkt in bm2["markets"] if mkt["key"]=="totals"
                            for o in mkt["outcomes"] if o["name"]==name), None)
                        label = f"大小球 → {name} {pt}" if pt else f"大小球 → {name}"
                    else:
                        label = f"{mk} → {name}"
                    results.append({
                        "match": f"{home} vs {away}",
                        "commence": game["commence_time"],
                        "bookmaker": bk, "market": mk,
                        "outcome": name, "label": label,
                        "odds": price, "fair_prob": fp,
                        "ev_pct": ev * 100, "kelly": kelly,
                    })
    return results

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--stdin", action="store_true",
                        help="从 stdin 读取 JSON（替代文件参数）")
    parser.add_argument("odds_file", nargs="?", default=None,
                        help="赔率 JSON 文件路径（若未指定 --stdin）")
    parser.add_argument("--min-ev", type=float, default=2.0)
    parser.add_argument("--bankroll", type=float, default=10000)
    parser.add_argument("--kelly-fraction", type=float, default=0.25)
    args = parser.parse_args()

    # 从 stdin 或文件读取 JSON
    if args.stdin:
        raw = sys.stdin.read()
    elif args.odds_file:
        with open(args.odds_file) as f:
            raw = f.read()
    else:
        print("ERROR: 请指定 --stdin 或提供 odds_file 路径", file=sys.stderr)
        sys.exit(1)

    games = json.loads(raw)

    print(f"\n🎯 EV分析 — 最低EV: {args.min_ev:.1f}% | 资金池: ¥{args.bankroll:,.0f}")
    print("="*70)

    all_bets = []
    for game in games:
        all_bets.extend(analyze_game(game, min_ev=args.min_ev/100))

    if not all_bets:
        print(f"\n❌ 未找到 EV ≥ {args.min_ev}% 的机会，尝试降低 --min-ev")
        return

    all_bets.sort(key=lambda x: x["ev_pct"], reverse=True)
    print(f"\n📊 找到 {len(all_bets)} 个正EV机会:\n")

    max_stake_pct = 0.20  # 单注上限: 资金池 20% (SKILL.md 风控硬性上限)
    total_stake = 0
    for bet in all_bets:
        kelly_stake = args.bankroll * min(bet["kelly"], args.kelly_fraction)
        stake = round(min(kelly_stake, args.bankroll * max_stake_pct), 0)
        print(f"⚽ {bet['match']}")
        print(f"   📌 {bet['label']}  |  BK: {bet['bookmaker']}")
        print(f"   💰 赔率: {bet['odds']:.2f} | 公平概率: {bet['fair_prob']*100:.1f}%")
        print(f"   📈 EV: +{bet['ev_pct']:.1f}% | Kelly: {bet['kelly']*100:.1f}%")
        print(f"   💵 建议注额: ¥{stake:,.0f}")
        print()
        total_stake += stake

    print("="*70)
    print(f"合计建议注额: ¥{total_stake:,.0f}（占资金池 {total_stake/args.bankroll*100:.1f}%）")

if __name__ == "__main__":
    main()


path: scripts/fetch_odds.py
content: #!/usr/bin/env python3
"""
Fetch odds from The Odds API for a given sport/league.
Usage: python3 fetch_odds.py <sport_key> [regions] [markets]
Example: python3 fetch_odds.py soccer_epl eu h2h,spreads,totals

输出：人类可读赛事信息到 stdout，可通过 --json 参数输出原始 JSON。
零本地文件写入。
"""
import sys, os, json, urllib.request, urllib.parse
from datetime import datetime, timezone

[REDACTED_SECRET]"ODDS_API_KEY", "")
BASE_URL = "https://api.the-odds-api.com/v4"

def fetch_odds(sport_key, regions="eu", markets="h2h,spreads,totals"):
    if not [REDACTED_SECRET]"ERROR: ODDS_API_KEY not set", file=sys.stderr)
        sys.exit(1)
    params = {
        "apiKey": API_KEY,
        "regions": regions,
        "markets": markets,
        "dateFormat": "iso",
        "oddsFormat": "decimal",
    }
    url = f"{BASE_URL}/sports/{sport_key}/odds?" + urllib.parse.urlencode(params)
    try:
        with urllib.request.urlopen(url, timeout=15) as resp:
            remaining = resp.headers.get("x-requests-remaining", "?")
            used = resp.headers.get("x-requests-used", "?")
            print(f"# API quota: {remaining} remaining / {used} used", file=sys.stderr)
            return json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        print(f"ERROR {e.code}: {e.read().decode()}", file=sys.stderr)
        sys.exit(1)

def format_game(game):
    now = datetime.now(timezone.utc)
    commence = datetime.fromisoformat(game["commence_time"].replace("Z", "+00:00"))
    hours_until = (commence - now).total_seconds() / 3600
    home, away = game["home_team"], game["away_team"]
    lines = [f"\n{'='*60}"]
    lines.append(f"🆔 {game['id']}")
    lines.append(f"⚽ {home} vs {away}")
    lines.append(f"🕐 {commence.strftime('%Y-%m-%d %H:%M UTC')} ({hours_until:.1f}h away)")
    lines.append("="*60)

    for bm in game.get("bookmakers", []):
        bk = bm["key"]
        for market in bm.get("markets", []):
            mk, outcomes = market["key"], market["outcomes"]
            if mk == "h2h":
                s = " | ".join(f"{o['name']}: {o['price']:.2f}" for o in outcomes)
                lines.append(f"  [{bk}] 1X2: {s}")
            elif mk == "spreads":
                s = " | ".join(f"{o['name']} {o['point']:+.1f}: {o['price']:.2f}" for o in outcomes)
                lines.append(f"  [{bk}] AH:  {s}")
            elif mk == "totals":
                s = " | ".join(f"{o['name']} {o['point']}: {o['price']:.2f}" for o in outcomes)
                lines.append(f"  [{bk}] O/U: {s}")
    return "\n".join(lines)

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python3 fetch_odds.py <sport_key> [regions] [markets] [--json]")
        print("sport_key: soccer_epl / soccer_spain_la_liga / soccer_germany_bundesliga")
        print("           soccer_italy_serie_a / soccer_france_ligue_one")
        print("           soccer_uefa_champs_league / soccer_uefa_europa_league")
        print("  --json   输出原始 JSON 到 stdout（供管道传给 ev_calculator）")
        sys.exit(1)

    # 解析 --json 标志
    raw_args = [a for a in sys.argv[1:] if a != "--json"]
    json_mode = "--json" in sys.argv

    sport_key = raw_args[0]
    regions = raw_args[1] if len(raw_args) > 1 else "eu"
    markets = raw_args[2] if len(raw_args) > 2 else "h2h,spreads,totals"
    games = fetch_odds(sport_key, regions, markets)

    if json_mode:
        # 纯 JSON 输出，方便管道传递
        print(json.dumps(games, indent=2))
    else:
        # 人类可读格式
        print(f"\n📋 {sport_key} — {len(games)} upcoming matches")
        for g in games:
            print(format_game(g))


path: references/betting-rules.md
content: # 盘口规则完整参考

## 一、亚盘让球（Asian Handicap）

让球盘消除平局，只比胜负。

### 常见让球线结算

| 让球线 | 主队结果 | 让球方结算 | 受让方结算 |
|--------|---------|-----------|-----------|
| -0 (平手) | 赢 | 全赢 | 全输 |
| -0 (平手) | 平 | 退款 | 退款 |
| -0 (平手) | 输 | 全输 | 全赢 |
| -0.25 | 赢 | 全赢 | 全输 |
| -0.25 | 平 | 半输 | 半赢 |
| -0.25 | 输 | 全输 | 全赢 |
| -0.5 | 赢 | 全赢 | 全输 |
| -0.5 | 平/输 | 全输 | 全赢 |
| -0.75 | 赢1球 | 半赢 | 半输 |
| -0.75 | 赢2球+ | 全赢 | 全输 |
| -0.75 | 平/输 | 全输 | 全赢 |
| -1.0 | 赢1球 | 退款 | 退款 |
| -1.0 | 赢2球+ | 全赢 | 全输 |
| -1.0 | 平/输 | 全输 | 全赢 |
| -1.25 | 赢1球 | 半赢 | 半输 |
| -1.25 | 赢2球+ | 全赢 | 全输 |
| -1.5 | 赢2球+ | 全赢 | 全输 |
| -1.5 | 赢1球/平/输 | 全输 | 全赢 |

记忆规则：
- X.0 线：达到净胜球数 = 退款，超过 = 全赢
- X.25/X.75 线：临界净胜球数 = 半赢/半输
- X.5 线：必须超过才算赢，无退款

---

## 二、大小球（Totals）

### Under（买小）结算

| 线 | 0-1球 | 2球 | 3球 | 4球 | 5球+ |
|----|-------|-----|-----|-----|------|
| U1.5 | 全赢 | 全输 | 全输 | 全输 | 全输 |
| U2.0 | 全赢 | 退款 | 全输 | 全输 | 全输 |
| U2.25 | 全赢 | 半赢 | 全输 | 全输 | 全输 |
| U2.5 | 全赢 | 全赢 | 全输 | 全输 | 全输 |
| U2.75 | 全赢 | 全赢 | 半赢 | 全输 | 全输 |
| U3.0 | 全赢 | 全赢 | 退款 | 全输 | 全输 |
| U3.25 | 全赢 | 全赢 | 半赢 | 全输 | 全输 |
| U3.5 | 全赢 | 全赢 | 全赢 | 全输 | 全输 |

### Over（买大）结算 — 完全相反

| 线 | 0-1球 | 2球 | 3球 | 4球 | 5球+ |
|----|-------|-----|-----|-----|------|
| O1.5 | 全输 | 全赢 | 全赢 | 全赢 | 全赢 |
| O2.0 | 全输 | 退款 | 全赢 | 全赢 | 全赢 |
| O2.25 | 全输 | 半赢 | 全赢 | 全赢 | 全赢 |
| O2.5 | 全输 | 全输 | 全赢 | 全赢 | 全赢 |
| O2.75 | 全输 | 全输 | 半赢 | 全赢 | 全赢 |
| O3.0 | 全输 | 全输 | 退款 | 全赢 | 全赢 |
| O3.25 | 全输 | 全输 | 半赢 | 全赢 | 全赢 |
| O3.5 | 全输 | 全输 | 全输 | 全赢 | 全赢 |

记忆口诀：买小希望少进球，买大希望多进球；
X.25/X.75 线临界球数半赢，X.0 线临界退款

---

## 三、BTTS（双方均进球）

- Yes：主队≥1球 AND 客队≥1球 → 赢
- No：至少一方0球 → 赢
- 适用：进攻好
但防守差的对决(Yes) / 强队可能零封(No)

---

## 四、平局退款（Draw No Bet / DNB）
- 主/客胜 = 全赢
- 平局 = 退款
- 赔率低于1X2独赢（风险降低）
- 适用：看好某队但担心平局

---
## 五、双重机会（Double Chance）

- 1X = 主胜或平均赢
- X2 = 平或客胜均赢
- 12 = 主胜或客胜均赢（无平局输）
- 赔率低，安全边际高

---

## 六、盈利计算公式
| 情况 | 计算 |
|------|------|
| 全赢 | 注额 × (欧赔 - 1) |
| 半赢 | 注额 × (欧赔 - 1) × 0.5 |
| 退款 | 注额全返，盈亏为0 |
| 半输 | -注额 × 0.5 |
| 全输 | -注额 |

欧赔 vs HK赔率：HK赔率 = 欧赔 - 1
示例：欧赔 2.50 = HK赔率 1.50
¥1000 注额赢 = ¥1000 × 1.50 = +¥1,500


path: references/fundamental-analysis.md
content: # 基本面分析框架

## 一、数据面指标

### xG（预期进球）⭐⭐⭐⭐⭐
- 定义：基于射门位置/角度/前置动作计算的进球概率之和
- xG >> 实际进球 → 球队在浪费机会，下场可能回归均值
- xG << 实际进球 → 靠运气赢球，不可持续
- 数据源：fbref.com / understat.com / fotmob.com

### xGA（预期失球）⭐⭐⭐⭐
- 防守稳定性真实指标，排除门将超常发挥的干扰

### 近期状态 ⭐⭐⭐⭐
- 看近5-10场，主客场分开统计（主场强 ≠ 客场强）
- 对手质量加权（赢了末游 ≠ 真正状态好）

### 伤病/停赛 ⭐⭐⭐⭐⭐
- 关键位置缺阵（中卫/10号/9号）可改变盘口0.25球以上
- 不确定时：降低注额或放弃
- 查询：官方伤病报告 / transfermarkt / fotmob

---

## 二、战术面分析

### 对位（Matchup）⭐⭐⭐⭐
- 高位逼抢 vs 长传反击 → 身后空间多，大球
- 控球 vs 低防反击 → 节奏慢，小球
- 双防守型 → 低进球，平局概率高
- 强弱悬殊 → 强队风格决定进球数

### 轮换风险 ⭐⭐⭐⭐⭐
- 国际赛周前后（大牌球员可能保留）
- 欧战连续出战（体能透支）
- 背靠背赛程 / 长途客场
- 存在轮换风险时：降低注额

### 动机分析 ⭐⭐⭐
- 争冠/保级队 vs 无所谓队 → 强烈动机不对等
- 双方均无动机（末轮已定名次）→ 不投
- 欧战次轮前主动方会在联赛保主力？

### H2H历史交手 ⭐⭐
- 参考价值有限，连续4场以上规律值得关注
- 马竞 vs 皇马 / 利物浦 vs 曼城 等传统低分对决有统计意义

---

## 三、盘口信号（最重要）

### 赔率移动（Line Movement）⭐⭐⭐⭐⭐
- 赔率降低 = 大量资金押注该方向（sharp money 进场）
- 跟随赔率移动方向 > 逆势押注
- 警惕：你看好A，但盘口从A热门变成B热门 → 重新审视

### Steam（蒸汽）
- 短时间内多家 bookmaker 同步大幅移动 → 专业资金进场信号
- 极可信，值得跟进

### 反向移动（Reverse Line Movement）
- 公众押A，但赔率对A越来越不利 → 专业资金押B
- 逆公众找价值的核心信号

---

## 四、综合评分模板

场次：[主队] vs [客队]  时间：XX:XX(北京)
投注：[方向] @[赔率]  EV：+X.X%  公平概率：X.X%

📊 数据面
  近5场主/客场状态：W W D L W / W L D W L
  xG近5场均值：主 X.X / 客 X.X
  关键伤病：无 / 有(XX缺阵)

🎯 战术面
  风格对位：[逼抢vs反击 / 控球vs防守 / ...]
  轮换风险：低/中/高 — [原因]
  动机：主[争冠/保级/无所谓] 客[争冠/保级/无所谓]

📈 盘口信号
  赔率变化：开盘X.XX → 现X.XX（向XX移动）
  综合判断：支持/反对下注

⭐ 信心：1-5星
💰 注额：¥X,000


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)