Polymarket 足球路径收敛策略
Polymarket 足球路径收敛策略
策略简介
本策略是一套用于 Polymarket 足球预测市场的路径收敛交易策略。它不是传统无风险套利,也不是单纯预测比赛胜负,而是尝试在两者之间构造一个更有保护性的组合。
策略围绕一场具体足球比赛,买入三类 Yes 合约:
text
目标队胜
0-0 精确比分
0-1 精确比分
其中“目标队胜”代表主判断,“0-0”和“0-1”代表保护路径。策略希望在比赛进程中,当组合盘口价值上升到目标利润区间时提前卖出,而不是一定持有到终场等待开奖。
核心逻辑
-
构造路径篮子
策略默认构造如下组合:
合约腿 作用 目标队胜 Yes 主路径,押注强队或目标队最终获胜 0-0 Yes 保护比赛迟迟打不开局面的路径 0-1 Yes 保护弱队偷一个、目标队暂时不利的路径 这个组合不是完备事件,并不覆盖所有比分。它覆盖的是“目标队最终赢球”以及部分低比分保护路径。
-
赛前检查市场成本
策略会读取三条腿的 ask 价格,计算组合买入成本:
text组合成本 = 目标队胜 ask + 0-0 ask + 0-1 ask只有当组合成本低于
ENTRY_MAX_COST设置的最大允许成本时,才进入下一层判断。 -
使用泊松模型估算覆盖概率
策略使用基础泊松进球模型,根据主队和客队预期进球数
λ_home、λ_away,估算目标队胜、0-0、0-1 等路径的理论概率。计算出的模型覆盖概率为:
text模型覆盖概率 = P(目标队胜) + P(0-0) + P(0-1) -
模型优势过滤
策略要求模型覆盖概率高于市场成本,并且差值达到安全边际:
text模型覆盖概率 - 市场成本 >= SAFETY_MARGIN这一步用于避免看到三条腿“便宜”就盲目买入,而是用一个透明的数学模型做基本过滤。
-
支持从市场比分盘口反推 λ
策略可以手动设置
LAMBDA_HOME和LAMBDA_AWAY,也可以开启CALIBRATE_LAMBDA_FROM_MARKET,从多个精确比分盘口中拟合隐含进球强度。例如使用:
text0-0, 0-1, 1-0, 1-1, 2-0, 2-1, 3-0这些比分只用于建模,不一定参与真实下单。
-
赛中动态更新模型
比赛开始后,策略会从 Polymarket Gamma event 接口读取实时比分、比赛时间、是否完赛等状态。
如果比分或时间发生变化,泊松模型会基于当前比分和剩余时间重新估算剩余路径概率,而不是静态沿用赛前判断。
-
用真实 bid 判断是否收敛止盈
入场后,策略不再依赖模型幻想终局,而是读取三条腿当前 bid 价值:
text当前组合可卖价值 = 各腿持仓数量 × 对应 bid 价格当组合可卖价值达到:
text初始成本 + TARGET_PROFIT × SHARES策略会尝试卖出全部持仓,完成路径收敛。
-
比赛结束后停止主动交易
如果比赛结束,策略会停止主动买卖,并进入等待 redeem 的状态。超过比赛管理窗口后,也会停止继续管理。
交易流程
text
读取 Polymarket 比赛 event
↓
拼接目标队胜 + 精确比分保护腿合约
↓
获取 ask / bid 深度
↓
计算组合买入成本
↓
泊松模型估算覆盖概率
↓
成本和模型优势同时满足后买入篮子
↓
赛中持续读取比分和盘口
↓
组合 bid 价值达到目标利润后平仓
↓
比赛结束后停止主动交易并等待 redeem
主要参数
| 参数 | 说明 |
|---|---|
EVENT_SLUG | Polymarket 比赛事件 slug |
WIN_SUFFIX | 目标获胜队伍后缀,例如 jor、aut |
PROTECT_SCORES | 保护比分,默认 0-0,0-1 |
MODEL_SCORE_SAMPLES | 用于拟合泊松 λ 的比分样本 |
SHARES | 每条腿买入份数 |
ENTRY_MAX_COST | 最大允许组合买入成本 |
TARGET_PROFIT | 每份目标利润 |
SAFETY_MARGIN | 模型覆盖概率相对市场成本的安全边际 |
LAMBDA_HOME | 主队 90 分钟预期进球数 |
LAMBDA_AWAY | 客队 90 分钟预期进球数 |
CALIBRATE_LAMBDA_FROM_MARKET | 是否从市场比分盘口反推 λ |
MAX_GOALS | 泊松模型计算的最大进球数 |
MODEL_MATCH_MINUTES | 模型比赛时长,通常为 90 分钟 |
MIN_BID_SIZE | 平仓时要求的最小 bid 深度 |
PRE_MATCH_ENTRY_WINDOW_S | 赛前允许入场的时间窗口 |
MATCH_END_BUFFER_S | 比赛结束管理缓冲时间 |
BUY_SLIPPAGE | 买入滑点容忍 |
SELL_SLIPPAGE | 卖出滑点容忍 |
DRY_RUN | 是否只模拟、不真实下单 |
策略特点
- 面向 Polymarket 足球预测市场。
- 使用“目标队胜 + 低比分保护”的路径篮子。
- 不是无风险套利,而是带模型过滤的结构化预测交易。
- 使用泊松进球模型估算组合覆盖概率。
- 可从精确比分盘口拟合隐含进球强度。
- 入场看模型优势,出场看真实盘口 bid。
- 赛中结合比分和剩余时间动态更新判断。
- 支持 dry run,适合先观察和验证。
适用场景
本策略适合强弱差距较明显、主路径比较清晰、保护比分价格较低的足球比赛。
例如目标队明显更强,但市场对 0-0、0-1 这类低比分路径定价较便宜时,可以通过组合方式降低单纯买“目标队胜”的路径脆弱性。策略真正想捕捉的不是终场开奖,而是比赛中某个时刻盘口价值提前收敛的机会。
风险提示
- 本策略不是无风险套利,组合不覆盖所有结果。
- 1-1、0-2、2-2 等未覆盖路径可能导致明显亏损。
- 泊松模型非常简化,无法充分反映红牌、伤病、战术变化、VAR、临场状态等因素。
- Polymarket 盘口可能流动性不足,bid/ask 深度不够时难以及时成交。
- 比分数据、event 状态或 Gamma API 可能延迟或异常。
- 保护腿价格过高时,组合会失去意义。
- 强队也可能翻车,不能因为主路径胜率高就重仓。
- 建议严格控制单场仓位,并优先使用
DRY_RUN模式验证。
使用建议
- 优先选择强弱差距明显、市场主胜价格较高但保护腿仍便宜的比赛。
- 不建议用于五五开的比赛。
ENTRY_MAX_COST不宜设置过高,否则利润空间过小。SAFETY_MARGIN应保留一定冗余,避免模型误差直接吞掉优势。TARGET_PROFIT需要结合盘口流动性设置,过高可能难以触发,过低可能被滑点侵蚀。- 开启市场 λ 拟合前,应确认精确比分盘口有足够流动性和合理报价。
- 实盘前建议先 dry run 多场比赛,观察模型、盘口和成交之间的偏差。
相关文章
-
策略源码:Polymarket 足球路径收敛策略
包含完整 FMZ Python 策略代码,可查看 Polymarket Gamma 接口读取、合约腿构造、泊松模型、市场 λ 拟合、入场过滤、组合止盈和平仓逻辑。 -
策略文章:在套利和预测之间:一次世界杯路径收敛策略的朴素实验
文章详细解释了为什么该策略不是传统套利、为什么选择“目标队胜 + 0-0 + 0-1”组合,以及如何用泊松模型和真实盘口构造一个路径收敛交易实验。
import json
import math
import time
import requests
# ============================================================
# Polymarket 足球路径收敛策略 Python版 for FMZ
# 组合:目标队胜 + 0-0 + 0-1
# 逻辑:
# 1. 赛前用泊松模型检查覆盖概率是否大于市场成本
# 2. 买入保护篮子
# 3. 赛中当组合 bid 价值 >= 成本 + 目标利润时平仓
# ============================================================
GAMMA_BASE = "https://gamma-api.polymarket.com"
class STATE:
WATCHING = "WATCHING"
ENTERING = "ENTERING"
HOLDING = "HOLDING"
CLOSING = "CLOSING"
DONE = "DONE"
def cfg(name, default):
return globals().get(name, default)
# ===================== FMZ 参数默认值 =====================
# 在 FMZ 后台建参数时,用同名变量覆盖这些默认值。
EVENT_SLUG = cfg("EVENT_SLUG", "fifwc-aut-jor-2026-06-17")
WIN_SUFFIX = cfg("WIN_SUFFIX", "jor") # aut / jor / arg / alg ...
PROTECT_SCORES = cfg("PROTECT_SCORES", "0-0,0-1")
MODEL_SCORE_SAMPLES = cfg("MODEL_SCORE_SAMPLES", "0-0,0-1,1-0,1-1,2-0,2-1,3-0")
SHARES = float(cfg("SHARES", 5))
ENTRY_MAX_COST = float(cfg("ENTRY_MAX_COST", 0.92))
TARGET_PROFIT = float(cfg("TARGET_PROFIT", 0.02))
SAFETY_MARGIN = float(cfg("SAFETY_MARGIN", 0.01))
LAMBDA_HOME = float(cfg("LAMBDA_HOME", 2.2))
LAMBDA_AWAY = float(cfg("LAMBDA_AWAY", 0.8))
CALIBRATE_LAMBDA_FROM_MARKET = bool(cfg("CALIBRATE_LAMBDA_FROM_MARKET", False))
MAX_GOALS = int(cfg("MAX_GOALS", 10))
MODEL_MATCH_MINUTES = float(cfg("MODEL_MATCH_MINUTES", 90))
MIN_BID_SIZE = float(cfg("MIN_BID_SIZE", 5))
PRE_MATCH_ENTRY_WINDOW_S = int(cfg("PRE_MATCH_ENTRY_WINDOW_S", 3600))
MATCH_END_BUFFER_S = int(cfg("MATCH_END_BUFFER_S", 110 * 60))
ORDER_TIMEOUT_S = int(cfg("ORDER_TIMEOUT_S", 15))
SLEEP_MS = int(cfg("SLEEP_MS", 2000))
BUY_SLIPPAGE = float(cfg("BUY_SLIPPAGE", 0.002))
SELL_SLIPPAGE = float(cfg("SELL_SLIPPAGE", 0.002))
DRY_RUN = bool(cfg("DRY_RUN", False))
state = STATE.WATCHING
positions = {}
entry_cost = 0.0
start_ts = 0
last_score = None
last_model_snapshot = {}
# ===================== 通用工具 =====================
def now_ts():
return int(time.time())
def _n(x, digits=4):
try:
return round(float(x), digits)
except Exception:
return x
def get_json(url, **params):
r = requests.get(
url,
params={k: v for k, v in params.items() if v is not None},
timeout=20,
headers={"User-Agent": "Mozilla/5.0"},
)
r.raise_for_status()
return r.json()
def parse_iso_ts(value):
if not value:
return 0
value = value.replace("Z", "+00:00")
try:
from datetime import datetime
return int(datetime.fromisoformat(value).timestamp())
except Exception:
return 0
def parse_score(score):
if not score or "-" not in score:
return None
left, right = score.split("-", 1)
try:
return int(left), int(right)
except Exception:
return None
def parse_scores(value):
return [x.strip() for x in str(value).split(",") if x.strip()]
def parse_score_text(score):
left, right = score.split("-", 1)
return int(left), int(right)
def yes_symbol(slug):
return slug + "_USDC.Yes"
def build_legs():
legs = [
{
"name": "win",
"slug": EVENT_SLUG + "-" + WIN_SUFFIX,
"symbol": yes_symbol(EVENT_SLUG + "-" + WIN_SUFFIX),
"kind": "win",
}
]
for score in parse_scores(PROTECT_SCORES):
slug_score = score.replace("-", "-")
legs.append(
{
"name": "score_" + score.replace("-", "_"),
"slug": EVENT_SLUG + "-exact-score-" + slug_score,
"symbol": yes_symbol(EVENT_SLUG + "-exact-score-" + slug_score),
"kind": "score",
"score": score,
}
)
return legs
def build_model_score_legs():
legs = []
seen = set()
for score in parse_scores(MODEL_SCORE_SAMPLES):
if score in seen:
continue
seen.add(score)
slug_score = score.replace("-", "-")
legs.append(
{
"name": "model_score_" + score.replace("-", "_"),
"slug": EVENT_SLUG + "-exact-score-" + slug_score,
"symbol": yes_symbol(EVENT_SLUG + "-exact-score-" + slug_score),
"kind": "model_score",
"score": score,
}
)
return legs
# ===================== 泊松模型 =====================
def poisson_pmf(k, lam):
if k < 0:
return 0.0
return math.exp(-lam) * (lam ** k) / math.factorial(k)
def get_win_side():
suffix = WIN_SUFFIX.lower()
parts = EVENT_SLUG.split("-")
# slug 形如 fifwc-aut-jor-2026-06-17,parts[1]/parts[2] 是主客队缩写。
if len(parts) >= 3:
if suffix == parts[1].lower():
return "home"
if suffix == parts[2].lower():
return "away"
return "home"
def score_event_probability(score, lambda_home, lambda_away):
h, a = parse_score_text(score)
return poisson_pmf(h, lambda_home) * poisson_pmf(a, lambda_away)
def win_probability(lambda_home, lambda_away, win_side):
total = 0.0
for h in range(MAX_GOALS + 1):
for a in range(MAX_GOALS + 1):
if (win_side == "home" and h > a) or (win_side == "away" and a > h):
total += poisson_pmf(h, lambda_home) * poisson_pmf(a, lambda_away)
return total
def score_probability_live(score, lambda_home, lambda_away, minute, current_score):
target_h, target_a = parse_score_text(score)
current_h, current_a = current_score
if current_h > target_h or current_a > target_a:
return 0.0
ratio = max(0.0, MODEL_MATCH_MINUTES - float(minute)) / MODEL_MATCH_MINUTES
return poisson_pmf(target_h - current_h, lambda_home * ratio) * poisson_pmf(
target_a - current_a,
lambda_away * ratio,
)
def win_probability_live(lambda_home, lambda_away, win_side, minute, current_score):
current_h, current_a = current_score
ratio = max(0.0, MODEL_MATCH_MINUTES - float(minute)) / MODEL_MATCH_MINUTES
lh = lambda_home * ratio
la = lambda_away * ratio
total = 0.0
for add_h in range(MAX_GOALS + 1):
for add_a in range(MAX_GOALS + 1):
final_h = current_h + add_h
final_a = current_a + add_a
if (win_side == "home" and final_h > final_a) or (
win_side == "away" and final_a > final_h
):
total += poisson_pmf(add_h, lh) * poisson_pmf(add_a, la)
return total
def quote_probability(q):
if not q:
return None
bid = q.get("bid")
ask = q.get("ask")
if bid is not None and ask is not None:
p = (float(bid) + float(ask)) / 2.0
elif ask is not None:
p = float(ask)
elif bid is not None:
p = float(bid)
else:
return None
return max(0.001, min(0.999, p))
def fit_lambdas_from_score_markets(quotes, model_score_legs, event_state=None):
samples = []
live_score = event_state.get("score_tuple") if event_state else None
minute = event_state.get("elapsed") if event_state else None
is_live = bool(live_score and minute not in [None, ""])
for leg in model_score_legs:
q = quotes.get(leg["name"])
p_market = quote_probability(q)
if p_market is None:
continue
target_h, target_a = parse_score_text(leg["score"])
if is_live:
current_h, current_a = live_score
if current_h > target_h or current_a > target_a:
continue
samples.append((target_h - current_h, target_a - current_a, p_market, leg["score"]))
else:
samples.append((target_h, target_a, p_market, leg["score"]))
if len(samples) < 2:
return None
best = None
# 网格搜索足够透明,也适合 FMZ 环境;步长越小越慢。
for ih in range(5, 501, 5):
lh = ih / 100.0
for ia in range(5, 501, 5):
la = ia / 100.0
err = 0.0
for add_h, add_a, p_market, _score in samples:
p_model = poisson_pmf(add_h, lh) * poisson_pmf(add_a, la)
err += (p_model - p_market) ** 2
if best is None or err < best["err"]:
best = {"lambda_home": lh, "lambda_away": la, "err": err, "samples": samples}
if not best:
return None
if is_live:
ratio = max(0.01, max(0.0, MODEL_MATCH_MINUTES - float(minute)) / MODEL_MATCH_MINUTES)
# best 拟合的是剩余时间内的 lambda;转成 90分钟尺度,后续 live 函数会再乘剩余比例。
best["lambda_home"] = best["lambda_home"] / ratio
best["lambda_away"] = best["lambda_away"] / ratio
best["source"] = "live_score_markets"
else:
best["source"] = "pre_match_score_markets"
return best
def calibrated_lambdas(quotes, model_score_legs=None, event_state=None):
lambda_home = LAMBDA_HOME
lambda_away = LAMBDA_AWAY
if not CALIBRATE_LAMBDA_FROM_MARKET:
return lambda_home, lambda_away, "manual"
fitted = fit_lambdas_from_score_markets(quotes, model_score_legs or [], event_state)
if fitted:
return fitted["lambda_home"], fitted["lambda_away"], fitted["source"]
q00 = quotes.get("score_0_0")
q01 = quotes.get("score_0_1")
if not q00 or not q01 or not q00.get("ask") or not q01.get("ask"):
return lambda_home, lambda_away, "manual_fallback"
p00 = max(0.001, min(0.999, float(q00["ask"])))
p01 = max(0.001, min(0.999, float(q01["ask"])))
total_lambda = -math.log(p00)
away_lambda = p01 / p00
home_lambda = max(0.0, total_lambda - away_lambda)
return home_lambda, away_lambda, "market_00_01"
def model_cover_probability(legs, quotes, event_state=None, model_score_legs=None):
lambda_home, lambda_away, source = calibrated_lambdas(quotes, model_score_legs, event_state)
win_side = get_win_side()
live_score = event_state.get("score_tuple") if event_state else None
minute = event_state.get("elapsed") if event_state else None
if live_score and minute not in [None, ""]:
try:
p_win = win_probability_live(lambda_home, lambda_away, win_side, float(minute), live_score)
score_probs = {}
for leg in legs:
if leg["kind"] == "score":
score_probs[leg["score"]] = score_probability_live(
leg["score"],
lambda_home,
lambda_away,
float(minute),
live_score,
)
except Exception:
p_win = win_probability(lambda_home, lambda_away, win_side)
score_probs = {
leg["score"]: score_event_probability(leg["score"], lambda_home, lambda_away)
for leg in legs
if leg["kind"] == "score"
}
else:
p_win = win_probability(lambda_home, lambda_away, win_side)
score_probs = {
leg["score"]: score_event_probability(leg["score"], lambda_home, lambda_away)
for leg in legs
if leg["kind"] == "score"
}
# win 与 0-0/0-1 互斥时可直接相加;如用户配置了会与 win 重叠的比分,这里做一次去重扣减。
cover = p_win
for score, prob in score_probs.items():
h, a = parse_score_text(score)
overlaps_win = (win_side == "home" and h > a) or (win_side == "away" and a > h)
if not overlaps_win:
cover += prob
return {
"lambda_home": lambda_home,
"lambda_away": lambda_away,
"lambda_source": source,
"win_side": win_side,
"p_win": p_win,
"score_probs": score_probs,
"cover": cover,
}
# ===================== Gamma 比赛状态 =====================
def get_event():
data = get_json(GAMMA_BASE + "/events", slug=EVENT_SLUG)
if not data:
raise Exception("没找到 event: " + EVENT_SLUG)
return data[0]
def get_event_state():
e = get_event()
return {
"title": e.get("title"),
"start_time": e.get("startTime"),
"start_ts": parse_iso_ts(e.get("startTime")),
"score": e.get("score"),
"score_tuple": parse_score(e.get("score")),
"elapsed": e.get("elapsed"),
"period": e.get("period"),
"live": bool(e.get("live")),
"ended": bool(e.get("ended")),
"updated_at": e.get("updatedAt"),
}
# ===================== 行情与订单 =====================
def get_ticker(symbol):
try:
depth = exchange.GetDepth(symbol)
if (
not depth
or not depth.Asks
or not depth.Bids
or len(depth.Asks) == 0
or len(depth.Bids) == 0
):
return None
return {
"ask": float(depth.Asks[0].Price),
"ask_size": float(depth.Asks[0].Amount),
"bid": float(depth.Bids[0].Price),
"bid_size": float(depth.Bids[0].Amount),
}
except Exception as e:
Log("GetDepth 异常:", symbol, e)
return None
def get_position_amount(symbol):
try:
ps = exchange.GetPositions()
for p in ps:
if p.Symbol == symbol:
return float(p.Amount)
except Exception as e:
Log("GetPositions 异常:", e)
return 0.0
def query_order(order_id):
try:
return exchange.GetOrder(order_id)
except Exception as e:
Log("GetOrder 异常:", order_id, e)
return None
def cancel_and_confirm(order_id):
if not order_id:
return "cancelled"
try:
exchange.CancelOrder(order_id)
except Exception as e:
Log("撤单指令异常,继续确认:", order_id, e)
deadline = time.time() + ORDER_TIMEOUT_S
while time.time() < deadline:
o = query_order(order_id)
if o:
if o.Status == 1:
return "filled"
if o.Status == 2 or o.Status == 4:
return "cancelled"
Sleep(1000)
return "unknown"
def place_order_and_confirm(symbol, side, price, amount):
if side == "buy":
order_price = min(1, _n(price + BUY_SLIPPAGE, 4))
else:
order_price = max(0.001, _n(price - SELL_SLIPPAGE, 4))
Log("下单:", side, symbol, "price:", order_price, "amount:", amount)
if DRY_RUN:
Log("DRY_RUN: 不真实下单")
return {"order_id": "dry-run", "avg_price": order_price, "amount": amount}
order_id = exchange.CreateOrder(symbol, side, order_price, amount)
if not order_id:
Log("下单失败:", symbol)
return {"order_id": None, "avg_price": None, "amount": 0}
deadline = time.time() + ORDER_TIMEOUT_S
while time.time() < deadline:
o = query_order(order_id)
if o:
Log("订单状态:", order_id, "Status:", o.Status, "Deal:", o.DealAmount, "Avg:", o.AvgPrice)
if o.Status == 1:
return {
"order_id": order_id,
"avg_price": float(o.AvgPrice),
"amount": float(o.DealAmount or o.Amount),
}
if o.Status == 2 or o.Status == 4:
return {"order_id": order_id, "avg_price": None, "amount": 0}
Sleep(1000)
Log("订单超时,撤单:", order_id)
result = cancel_and_confirm(order_id)
if result == "filled":
o = query_order(order_id)
if o:
return {
"order_id": order_id,
"avg_price": float(o.AvgPrice),
"amount": float(o.DealAmount or o.Amount),
}
return {"order_id": order_id, "avg_price": None, "amount": 0}
# ===================== 策略计算 =====================
def get_quotes(legs):
quotes = {}
for leg in legs:
t = get_ticker(leg["symbol"])
quotes[leg["name"]] = t
return quotes
def basket_ask_cost(legs, quotes):
total = 0.0
for leg in legs:
q = quotes.get(leg["name"])
if not q or q["ask"] is None:
return None
if q["ask_size"] < SHARES:
Log("ask 深度不足:", leg["name"], q["ask_size"], "<", SHARES)
return None
total += q["ask"]
return total
def basket_bid_value(legs, quotes):
total = 0.0
tradable = True
for leg in legs:
pos = positions.get(leg["name"], {})
amount = float(pos.get("amount", 0))
if amount <= 0:
continue
q = quotes.get(leg["name"])
if not q or q["bid"] is None:
tradable = False
continue
if q["bid_size"] < min(amount, MIN_BID_SIZE):
tradable = False
total += amount * q["bid"]
return total, tradable
def current_position_cost():
return sum(float(p.get("amount", 0)) * float(p.get("avg_price", 0)) for p in positions.values())
def should_enter(event_state, cost, model):
ts = now_ts()
if event_state["ended"]:
return False, "比赛已结束"
if event_state["live"]:
return False, "已经开赛,不做初始入场"
if start_ts > 0 and ts < start_ts - PRE_MATCH_ENTRY_WINDOW_S:
return False, "未进入赛前开仓窗口"
if cost is None:
return False, "盘口不完整或深度不足"
if cost > ENTRY_MAX_COST:
return False, "成本过高"
if not model or model["cover"] - cost < SAFETY_MARGIN:
edge = None if not model else model["cover"] - cost
return False, "模型安全边际不足 edge=" + str(_n(edge))
return True, "满足开仓"
def should_close(event_state, legs, quotes):
cost = current_position_cost()
value, tradable = basket_bid_value(legs, quotes)
target = cost + TARGET_PROFIT * SHARES
score = event_state.get("score_tuple")
reason = "组合价值止盈"
if value >= target and tradable:
return True, reason, value, target
# 路径提示:这些不强制平仓,只做日志辅助。
if score:
h, a = score
if (h, a) == (0, 0):
reason = "0-0路径观察"
elif (h, a) == (0, 1):
reason = "0-1保护路径观察"
elif h + a > 0:
reason = "已进球路径,检查组合价值"
return False, reason, value, target
# ===================== 交易动作 =====================
def enter_basket(legs, quotes):
global entry_cost, state
state = STATE.ENTERING
positions.clear()
total_cost = 0.0
for leg in legs:
q = quotes[leg["name"]]
result = place_order_and_confirm(leg["symbol"], "buy", q["ask"], SHARES)
if result["avg_price"] is None:
Log("入场失败,尝试平掉已成交腿:", leg["name"])
close_all(legs, "入场失败风控")
state = STATE.DONE
return False
positions[leg["name"]] = {
"symbol": leg["symbol"],
"amount": result["amount"],
"avg_price": result["avg_price"],
}
total_cost += result["amount"] * result["avg_price"]
entry_cost = total_cost
state = STATE.HOLDING
Log("入场完成 | 成本:", _n(entry_cost), "目标平仓价值:", _n(entry_cost + TARGET_PROFIT * SHARES))
return True
def close_all(legs, reason):
global state
state = STATE.CLOSING
Log("开始平仓:", reason)
for leg in legs:
pos = positions.get(leg["name"])
if not pos:
continue
amount = get_position_amount(leg["symbol"])
if amount <= 0:
amount = float(pos.get("amount", 0))
if amount <= 0:
continue
q = get_ticker(leg["symbol"])
if not q or q["bid"] is None:
Log("无法平仓,缺少 bid:", leg["name"])
continue
result = place_order_and_confirm(leg["symbol"], "sell", q["bid"], amount)
if result["avg_price"] is None:
Log("平仓未完成,下轮重试:", leg["name"])
return False
state = STATE.DONE
Log("平仓完成:", reason)
return True
def do_redeem():
try:
ps = exchange.GetPositions()
count = 0
for p in ps:
if p.Info and p.Info.get("redeemable"):
Log("Redeem:", p.Symbol, p.Amount)
exchange.IO("redeem", p.Symbol, True)
count += 1
Sleep(300)
if count > 0:
Log("Redeem 完成:", count)
except Exception as e:
Log("Redeem 异常:", e)
# ===================== 状态展示 =====================
def log_status(event_state, legs, quotes, model=None):
rows = []
for leg in legs:
q = quotes.get(leg["name"])
pos = positions.get(leg["name"], {})
rows.append(
[
leg["name"],
leg["symbol"],
"-" if not q else str(_n(q["ask"])),
"-" if not q else str(_n(q["bid"])),
str(_n(pos.get("amount", 0))),
str(_n(pos.get("avg_price", 0))),
]
)
value, tradable = basket_bid_value(legs, quotes)
table = {
"type": "table",
"title": "足球路径收敛 | " + str(event_state.get("title")) + " | " + state,
"cols": ["腿", "Symbol", "Ask", "Bid", "持仓", "均价"],
"rows": rows,
}
summary = {
"type": "table",
"title": "比赛/组合状态",
"cols": ["字段", "值"],
"rows": [
["score", str(event_state.get("score"))],
["elapsed", str(event_state.get("elapsed"))],
["period", str(event_state.get("period"))],
["live", str(event_state.get("live"))],
["ended", str(event_state.get("ended"))],
["startTime", str(event_state.get("start_time"))],
["entry_cost", str(_n(current_position_cost()))],
["bid_value", str(_n(value))],
["target_value", str(_n(current_position_cost() + TARGET_PROFIT * SHARES))],
["tradable", str(tradable)],
["lambda_home", "-" if not model else str(_n(model["lambda_home"]))],
["lambda_away", "-" if not model else str(_n(model["lambda_away"]))],
["lambda_source", "-" if not model else str(model["lambda_source"])],
["model_cover", "-" if not model else str(_n(model["cover"]))],
],
}
LogStatus("`" + json.dumps(summary) + "`\n`" + json.dumps(table) + "`")
# ===================== 主循环 =====================
def main():
global state, start_ts, last_score
Log("足球路径收敛策略启动")
Log("EVENT_SLUG:", EVENT_SLUG, "WIN_SUFFIX:", WIN_SUFFIX, "PROTECT_SCORES:", PROTECT_SCORES)
Log("MODEL_SCORE_SAMPLES:", MODEL_SCORE_SAMPLES)
Log("SHARES:", SHARES, "ENTRY_MAX_COST:", ENTRY_MAX_COST, "TARGET_PROFIT/份:", TARGET_PROFIT)
Log("泊松模型 LAMBDA_HOME:", LAMBDA_HOME, "LAMBDA_AWAY:", LAMBDA_AWAY, "SAFETY_MARGIN:", SAFETY_MARGIN)
Log("MODEL_MATCH_MINUTES:", MODEL_MATCH_MINUTES, "使用 Gamma elapsed 作为比赛有效分钟,不使用墙上时间")
Log("CALIBRATE_LAMBDA_FROM_MARKET:", CALIBRATE_LAMBDA_FROM_MARKET)
Log("DRY_RUN:", DRY_RUN)
legs = build_legs()
model_score_legs = build_model_score_legs()
quote_legs = legs + model_score_legs
Log("策略腿:")
for leg in legs:
Log(leg["name"], leg["symbol"])
Log("泊松拟合比分样本:")
for leg in model_score_legs:
Log(leg["score"], leg["symbol"])
event_state = get_event_state()
start_ts = event_state["start_ts"]
Log("比赛:", event_state["title"], "startTime:", event_state["start_time"], "start_ts:", start_ts)
do_redeem()
while True:
try:
event_state = get_event_state()
quotes = get_quotes(quote_legs)
model = model_cover_probability(legs, quotes, event_state, model_score_legs)
log_status(event_state, legs, quotes, model)
score = event_state.get("score")
if score != last_score:
Log("比分变化:", last_score, "->", score)
last_score = score
if state == STATE.WATCHING:
cost = basket_ask_cost(legs, quotes)
ok, reason = should_enter(event_state, cost, model)
edge = None if cost is None else model["cover"] - cost
Log(
"入场检查:",
reason,
"cost:",
_n(cost) if cost is not None else None,
"model_cover:",
_n(model["cover"]),
"edge:",
_n(edge) if edge is not None else None,
)
if ok:
enter_basket(legs, quotes)
elif state == STATE.HOLDING:
if event_state["ended"]:
Log("比赛结束,停止主动交易,等待 redeem")
state = STATE.DONE
else:
ok, reason, value, target = should_close(event_state, legs, quotes)
Log("平仓检查:", reason, "value:", _n(value), "target:", _n(target))
if ok:
close_all(legs, reason)
elif state == STATE.CLOSING:
close_all(legs, "CLOSING重试")
elif state == STATE.DONE:
do_redeem()
# 比赛长时间结束保护
if start_ts and now_ts() > start_ts + MATCH_END_BUFFER_S and state in [STATE.WATCHING, STATE.HOLDING]:
Log("超过比赛管理窗口,停止策略")
state = STATE.DONE
except Exception as e:
Log("主循环异常:", e)
Sleep(SLEEP_MS)
if __name__ == "__main__":
main()
- 1