@clawhub-botaocai-a80560974b
FootyClaw — 足球投注全流程助手。覆盖赛事信息抓取、足彩玩法规则、赔率获取(需用户提供 The Odds API Key)、基本面分析、EV期望值计算、Kelly公式仓位管理、最终下注方案推荐、 账本记账与资金曲线生成。 触发词:分析今晚比赛、查赔率、找投注机会、今天有什么场、EV分析、Kelly仓位、...
---
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,零文件写入)
FILE:scripts/chart_generator.py
#!/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()
FILE:scripts/daily_scanner.py
#!/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
API_KEY = os.environ.get("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 API_KEY:
print("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()
FILE:scripts/ev_calculator.py
#!/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()
FILE:scripts/fetch_odds.py
#!/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
API_KEY = os.environ.get("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 API_KEY:
print("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))
FILE:references/betting-rules.md
# 盘口规则完整参考
## 一、亚盘让球(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
FILE:references/fundamental-analysis.md
# 基本面分析框架
## 一、数据面指标
### 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