Files
Auto-Trader/agent/backtest/engines/global_futures.py
T
Haozhe Wu 8a6c578a76 chore: cleanup pass — 3 latent bug fixes + 913 LOC net delete
Bug fixes
- CompositeEngine no longer misroutes bare Chinese-futures codes like
  RB2410 to GlobalFuturesEngine. _is_china_futures moved into a shared
  _market_hooks module with a case-normalized product table and a
  non-CN exchange guard. 9 new regression cases (RB2410 / rb2410 /
  RB2410.SHFE / CL.NYMEX / M2412.CBOT / CF2412.ICE / AU2412.COMEX /
  FG2412.EUREX / bare CN-collision still routes Chinese).
- Session FTS5 indexes now persist timestamps so cross-session search
  can sort by date. Same path also fixes a re-upsert that was
  wall-clocking every session's started_at on title-rename.
- Vite dev-mode proxy gained the missing /alpha entry; AlphaZoo page
  resolves on npm run dev.

Cleanup
- Deleted swarm/api_models.py + swarm/mailbox.py + SwarmDashboard.tsx
  (dead modules / orphaned UI after swarm-via-agent flow).
- Deleted SessionService.resume_attempt + get_attempts + get_attempt
  + SessionStore.list_attempts + Attempt.mark_waiting_user chain
  (half-baked human-in-the-loop from initial commit).
- Deleted ChatLLM.achat (async sibling never called) and
  _extract_balanced_json (only-tested helper).
- Deleted american_exercise_value in options_portfolio.py
  (driver inlines bs_price logic).
- Deleted _compute_ic_series_reference in factor_analysis_core.py
  (kept-as-reference, no callers).
- Collapsed MEMORY_TYPES duplication between cli.py and
  memory.persistent; restored set-equality invariant assert.
- Deleted runSwarm (~197 LOC), swarmDash state, swarmCancelRef
  write-only ref (orphaned by new agent-driven swarm flow).
- Dropped 76 unused 'vw = vwap(...)' boilerplate lines across
  gtja191 alphas + their now-unused vwap imports.
- Cleared 11 F401 unused-import sites in tools/, agent/, providers/.

Test infrastructure
- test_e2e_harness_v2.py (real-LLM e2e) gated behind
  VIBE_TRADING_RUN_LIVE_E2E so CI no longer flips shape based on
  env-key presence. CI workflow now has explicit --ignore.
- test_auto_compact_with_long_history: monkeypatch TOKEN_THRESHOLD so
  the test deterministically triggers compact instead of relying on
  unrelated LLM behaviour.
- test_compact_tool_explicit: assertion corrected — compact tool is
  special-cased in loop._process_tool_calls and does NOT emit
  tool_call events. Assert on the 'compact' event instead.
- TestSwarmE2E: per-test @pytest.mark.timeout overrides matching the
  internal 300s poll deadlines; cancel test now polls for terminal
  state instead of fixed sleep(3).

Tooling
- ruff per-file-ignores for factors/zoo/** (3783 → 0 F401 noise);
  F841 stays active in zoo to catch real formula bugs.
- frontend tsconfig: noUnusedLocals + noUnusedParameters as
  regression guards.
- highlight.js hoisted to direct dependency (was transitive via
  rehype-highlight).
- Vite proxy: dropped unused /run, /skills, /health, /api, /system;
  added /alpha.

Annotations
- direction parameter renamed to _direction in 6 calc_commission
  signatures (reserved for future borrow-fee asymmetry).
- _MARGIN_PER_CONTRACT in global_futures.py annotated as reference
  data.
- Backtest engines' _calc_pct_change variants cross-reference each
  other in docstrings.

Docs
- 2026-05-18 news entry added to README.md / README_zh.md /
  README_ja.md / README_ko.md / README_ar.md; 2026-05-15 entry
  rolled into the Earlier news <details>.
- Contributors heading: v0.1.7 cycle → v0.1.8 cycle in all 5
  READMEs.

126 files changed, +363 / -1276.
2026-05-18 15:16:24 +08:00

252 lines
8.3 KiB
Python

"""Global futures backtest engine (CME / ICE / Eurex).
Market rules:
- Nearly 24x5 (CME Globex: Sun 17:00 - Fri 16:00 CT, daily pause 16:00-17:00)
- Margin: initial + maintenance (exchange-set per contract)
- Limit up/down: dynamic for equity index, fixed for commodities
- Contract multiplier: per-product (ES=$50/pt, CL=$1000/bbl, GC=$100/oz)
- Commission: per-contract ($1-3 per side typical)
- Minimum unit: 1 contract
- Roll/expiry: not modeled (assumes continuous front-month data)
"""
from __future__ import annotations
import re
import pandas as pd
from backtest.engines.futures_base import FuturesBaseEngine
# ── Contract multiplier (USD per point / per unit) ──
_MULTIPLIER: dict[str, float] = {
# Equity index (CME)
"ES": 50, "NQ": 20, "YM": 5, "RTY": 50,
# Micro equity index
"MES": 5, "MNQ": 2, "MYM": 0.5, "M2K": 5,
# Energy (NYMEX)
"CL": 1000, "NG": 10000, "RB": 42000, "HO": 42000,
# Metals (COMEX)
"GC": 100, "SI": 5000, "HG": 25000, "PL": 50, "PA": 100,
# Micro metals
"MGC": 10, "SIL": 1000,
# Grains (CBOT)
"ZC": 50, "ZS": 50, "ZW": 50, "ZM": 100, "ZL": 600,
# Bonds (CBOT)
"ZB": 1000, "ZN": 1000, "ZF": 1000, "ZT": 2000,
# Currencies (CME)
"6E": 125000, "6J": 12500000, "6B": 62500, "6A": 100000, "6C": 100000,
# Softs (ICE)
"KC": 37500, "SB": 112000, "CC": 10, "CT": 50000,
# Livestock (CME)
"LE": 400, "HE": 400, "GF": 500,
# Eurex
"FESX": 10, "FDAX": 25, "FGBL": 1000,
}
# ── Margin per contract (approximate USD, initial margin) ──
# Reference table — future use for margin-call checks. Not yet consumed.
_MARGIN_PER_CONTRACT: dict[str, float] = {
# Equity index
"ES": 12650, "NQ": 17600, "YM": 8800, "RTY": 6600,
"MES": 1265, "MNQ": 1760,
# Energy
"CL": 6270, "NG": 3300,
# Metals
"GC": 9950, "SI": 11000, "HG": 4400, "PL": 3300,
"MGC": 995,
# Grains
"ZC": 1650, "ZS": 2200, "ZW": 1925,
# Bonds
"ZB": 4400, "ZN": 2200, "ZF": 1375,
# Currencies
"6E": 2475, "6J": 3300, "6B": 2750,
}
# ── Price limit (fraction of prev settlement) ──
_PRICE_LIMIT: dict[str, float] = {
# Equity index: 7% (Level 1), simplified to single level
"ES": 0.07, "NQ": 0.07, "YM": 0.07, "RTY": 0.07,
"MES": 0.07, "MNQ": 0.07,
# Energy: varies, typically ~$10-15 for CL
# Not easily expressed as %, skip for most commodities
}
# ── Per-contract commission (USD, one side) ──
_COMMISSION_PER_CONTRACT: dict[str, float] = {
"ES": 2.25, "NQ": 2.25, "YM": 2.25, "RTY": 2.25,
"MES": 0.62, "MNQ": 0.62,
"CL": 2.25, "NG": 2.25,
"GC": 2.25, "SI": 2.25, "HG": 2.25,
"MGC": 0.62,
"ZC": 2.25, "ZS": 2.25, "ZW": 2.25,
"ZB": 1.52, "ZN": 1.52, "ZF": 1.02,
"6E": 2.25, "6J": 2.25, "6B": 2.25,
}
_DEFAULT_COMMISSION = 2.50
_MONTH_CODES = set("FGHJKMNQUVXZ")
def _extract_product(symbol: str) -> str:
"""Extract product code from futures symbol.
Handles CME conventions:
- Product + month-code + year: ESZ4, CLF25, GCM2025
- Product + YYMM: CL2412, NQ2503
- Product.exchange: ES.CME
- Bare product: ES
Args:
symbol: Futures symbol string.
Returns:
Product code (e.g. 'ES', 'CL', 'GC').
"""
code = symbol.split(".")[0].upper()
# Pattern 1: product + month-code + year (ESZ4, CLF25, GCM2025)
m = re.match(r"([A-Z]{2,4})([FGHJKMNQUVXZ])(\d{1,4})$", code)
if m:
return m.group(1)
# Pattern 2: product + YYMM (NQ2503, CL2412)
m = re.match(r"([A-Z]+)(\d{4})$", code)
if m:
return m.group(1)
# Pattern 3: bare product or fallback
m = re.match(r"([A-Z]+)", code)
return m.group(1) if m else code
class GlobalFuturesEngine(FuturesBaseEngine):
"""International futures engine (CME/CBOT/NYMEX/COMEX/ICE/Eurex).
Config keys:
- slippage: default 0.0003
- commission_per_contract: override, default varies by product
"""
def __init__(self, config: dict):
# Leverage: most futures have 5-15% margin → 7-20x leverage.
# Price is unknown at init, so use a reasonable fixed default.
# User can override via config["leverage"].
leverage = config.get("leverage", 10.0)
config = {**config, "leverage": leverage}
super().__init__(config)
self.slippage_rate: float = config.get("slippage", 0.0003)
self._comm_override = config.get("commission_per_contract")
def can_execute(self, symbol: str, direction: int, bar: pd.Series) -> bool:
"""Global futures: T+0, both directions, limit checks for equity index.
Args:
symbol: Futures symbol.
direction: 1 (long), -1 (short), 0 (close).
bar: Current bar data.
Returns:
True if allowed.
"""
product = _extract_product(symbol)
limit = _PRICE_LIMIT.get(product)
if limit is None:
return True # no price limit for most commodities
pct_chg = _calc_pct_change(bar)
if pct_chg is not None:
if direction == 1 and pct_chg >= limit - 0.001:
return False # limit-up
if direction == -1 and pct_chg <= -limit + 0.001:
return False # limit-down
if direction == 0:
pos = self.positions.get(symbol)
if pos is not None:
if pos.direction == 1 and pct_chg <= -limit + 0.001:
return False
if pos.direction == -1 and pct_chg >= limit - 0.001:
return False
return True
def round_size(self, raw_size: float, price: float) -> float:
"""Integer contracts, minimum 1."""
return max(int(raw_size), 0)
def calc_commission(self, size: float, price: float, _direction: int, is_open: bool) -> float:
"""Per-contract fixed commission (uses _active_symbol for product lookup).
``_direction`` is unused — reserved for future borrow/financing
asymmetry on short positions.
"""
if self._comm_override is not None:
return size * self._comm_override
return self.calc_commission_for_symbol(self._active_symbol, size, price, is_open)
def calc_commission_for_symbol(
self, symbol: str, size: float, price: float, is_open: bool,
) -> float:
"""Symbol-aware commission.
Args:
symbol: Futures code.
size: Number of contracts.
price: Execution price (unused — fixed per-lot).
is_open: Opening or closing.
Returns:
Commission in USD.
"""
product = _extract_product(symbol)
rate = _COMMISSION_PER_CONTRACT.get(product, _DEFAULT_COMMISSION)
return size * rate
def apply_slippage(self, price: float, direction: int) -> float:
"""Slippage model for liquid global futures."""
return price * (1 + direction * self.slippage_rate)
def get_contract_multiplier(self, symbol: str) -> float:
"""Product-specific contract multiplier."""
product = _extract_product(symbol)
return float(_MULTIPLIER.get(product, 50))
# ── Helpers ──
# Note: china_a uses close/pre_close-only; china_futures prioritises
# settle/pre_settle. This global-futures variant prefers close/pre_close
# because CME data feeds (yfinance/IB) expose continuous close more
# reliably than settlement. See those modules for the equity /
# China-futures equivalents.
def _calc_pct_change(bar: pd.Series):
"""Calculate bar price change percentage.
Priority: close/pre_close > settle/pre_settle > pct_chg.
Falls back to pct_chg only when price fields are absent.
"""
close = bar.get("close")
pre_close = bar.get("pre_close")
if close is not None and pre_close is not None and pre_close > 0:
return (float(close) - float(pre_close)) / float(pre_close)
settle = bar.get("settle")
pre_settle = bar.get("pre_settle")
if settle is not None and pre_settle is not None and pre_settle > 0:
return (float(settle) - float(pre_settle)) / float(pre_settle)
if "pct_chg" in bar.index:
val = bar["pct_chg"]
if pd.notna(val):
raw = float(val)
# Heuristic: values > 1 are likely percentage points
return raw / 100.0 if abs(raw) > 1.0 else raw
return None