Type/to search

Polymarket 足球路径收敛策略

Common strategy
Created: 2026-06-17 14:31:24
Last modified: 2 months ago
2
Follow
510
Followers

Polymarket 足球路径收敛策略

策略简介

本策略是一套用于 Polymarket 足球预测市场的路径收敛交易策略。它不是传统无风险套利,也不是单纯预测比赛胜负,而是尝试在两者之间构造一个更有保护性的组合。

策略围绕一场具体足球比赛,买入三类 Yes 合约:

text
目标队胜 0-0 精确比分 0-1 精确比分

其中“目标队胜”代表主判断,“0-0”和“0-1”代表保护路径。策略希望在比赛进程中,当组合盘口价值上升到目标利润区间时提前卖出,而不是一定持有到终场等待开奖。

核心逻辑

  1. 构造路径篮子

    策略默认构造如下组合:

    合约腿作用
    目标队胜 Yes主路径,押注强队或目标队最终获胜
    0-0 Yes保护比赛迟迟打不开局面的路径
    0-1 Yes保护弱队偷一个、目标队暂时不利的路径

    这个组合不是完备事件,并不覆盖所有比分。它覆盖的是“目标队最终赢球”以及部分低比分保护路径。

  2. 赛前检查市场成本

    策略会读取三条腿的 ask 价格,计算组合买入成本:

    text
    组合成本 = 目标队胜 ask + 0-0 ask + 0-1 ask

    只有当组合成本低于 ENTRY_MAX_COST 设置的最大允许成本时,才进入下一层判断。

  3. 使用泊松模型估算覆盖概率

    策略使用基础泊松进球模型,根据主队和客队预期进球数 λ_homeλ_away,估算目标队胜、0-0、0-1 等路径的理论概率。

    计算出的模型覆盖概率为:

    text
    模型覆盖概率 = P(目标队胜) + P(0-0) + P(0-1)
  4. 模型优势过滤

    策略要求模型覆盖概率高于市场成本,并且差值达到安全边际:

    text
    模型覆盖概率 - 市场成本 >= SAFETY_MARGIN

    这一步用于避免看到三条腿“便宜”就盲目买入,而是用一个透明的数学模型做基本过滤。

  5. 支持从市场比分盘口反推 λ

    策略可以手动设置 LAMBDA_HOMELAMBDA_AWAY,也可以开启 CALIBRATE_LAMBDA_FROM_MARKET,从多个精确比分盘口中拟合隐含进球强度。

    例如使用:

    text
    0-0, 0-1, 1-0, 1-1, 2-0, 2-1, 3-0

    这些比分只用于建模,不一定参与真实下单。

  6. 赛中动态更新模型

    比赛开始后,策略会从 Polymarket Gamma event 接口读取实时比分、比赛时间、是否完赛等状态。

    如果比分或时间发生变化,泊松模型会基于当前比分和剩余时间重新估算剩余路径概率,而不是静态沿用赛前判断。

  7. 用真实 bid 判断是否收敛止盈

    入场后,策略不再依赖模型幻想终局,而是读取三条腿当前 bid 价值:

    text
    当前组合可卖价值 = 各腿持仓数量 × 对应 bid 价格

    当组合可卖价值达到:

    text
    初始成本 + TARGET_PROFIT × SHARES

    策略会尝试卖出全部持仓,完成路径收敛。

  8. 比赛结束后停止主动交易

    如果比赛结束,策略会停止主动买卖,并进入等待 redeem 的状态。超过比赛管理窗口后,也会停止继续管理。

交易流程

text
读取 Polymarket 比赛 event ↓ 拼接目标队胜 + 精确比分保护腿合约 ↓ 获取 ask / bid 深度 ↓ 计算组合买入成本 ↓ 泊松模型估算覆盖概率 ↓ 成本和模型优势同时满足后买入篮子 ↓ 赛中持续读取比分和盘口 ↓ 组合 bid 价值达到目标利润后平仓 ↓ 比赛结束后停止主动交易并等待 redeem

主要参数

参数说明
EVENT_SLUGPolymarket 比赛事件 slug
WIN_SUFFIX目标获胜队伍后缀,例如 joraut
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 多场比赛,观察模型、盘口和成交之间的偏差。

相关文章

Source
Python
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()
Comment
All comments (0)
No data
No data
  • 1
Forums
PINE Language
Get the app
iPhone Download
© 2015 - ∞ INVENTOR PTE LTD (SG)