chanmacro: Signal Expectancy Engine V1 — Market Memory System

Phase A-C complete: 4 core factors, regime detection, signal tracking, Bayesian expectancy.

chanmacro/ (32 files, ~4000 lines):
- models: 12 enums + 15 Pydantic v2 models (DateAwareModel, MarketStateVector, etc.)
- fetchers: OHLCV + Breadth (from data_provider) + Derivatives (new endpoint)
- scoring: Price Structure / Breadth (quantile buckets) / OI Matrix (5 discrete states) / Volatility Regime
- regime_detector: 3-state (TREND/RANGE/PANIC), factor-locked (Price+Breadth+Vol), versioned, 2-day confirmation
- expectancy: SignalTracker (record+outcomes), TimeDecay (half-life=180d), BayesianExpectancyEngine (Empirical Bayes, Leveled, SufficiencyGuard)
- validation: FactorValidator (IC/ICIR/Hit Ratio), RegimeValidator (MI/KL/ANOVA), TransitionValidator (stability)
- CLI: fetch|score|regime|track|backfill|expectancy|validate|serve
- tests: 52 passing (models, scoring, regime, expectancy)

data_provider:
- /api/derivatives endpoint: funding rate, OI, OI change, basis
- _derivatives storage: same persist pattern as K-line (merge→lock→snapshot→atomic write)
- background refresh every 60s

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jackyu66git
2026-06-24 17:44:55 +08:00
co-authored by Claude
parent 34040575c1
commit 71951019fb
43 changed files with 5036 additions and 2 deletions
+169 -2
View File
@@ -260,6 +260,9 @@ class DataProvider:
# 尝试加载历史恢复点
self._load_resume_since()
self._update_callbacks: List = []
# 衍生品数据: 与 K 线一样的模式 — symbol -> List[Dict], 按 timestamp 去重
self._derivatives: Dict[str, List[Dict]] = {symbol: [] for symbol in self.symbols}
self._derivatives_dir: Path = self.data_dir / "derivatives"
def _load_config(self) -> Dict[str, object]:
"""读取 JSON 配置文件。"""
@@ -460,6 +463,117 @@ class DataProvider:
symbol_safe = symbol.replace("/", "_").replace(":", "_")
return self.data_dir / timeframe / f"{self.exchange.id}_{symbol_safe}_{timeframe}.csv"
def _derivatives_file_path(self, symbol: str) -> Path:
"""衍生品 CSV 路径:data_dir/derivatives/exchange_symbol.csv。"""
symbol_safe = symbol.replace("/", "_").replace(":", "_")
return self._derivatives_dir / f"{self.exchange.id}_{symbol_safe}.csv"
def _load_derivatives_local(self, symbol: str) -> List[Dict]:
"""加载本地衍生品历史数据。"""
path = self._derivatives_file_path(symbol)
if not path.exists():
return []
loaded = []
with path.open("r", encoding="utf-8", newline="") as fp:
for row in csv.DictReader(fp):
try:
loaded.append({
"timestamp": int(row["timestamp"]),
"datetime": row.get("datetime", ""),
"funding_rate": float(row.get("funding_rate", 0)),
"open_interest": float(row.get("open_interest", 0)),
"oi_change_pct": float(row.get("oi_change_pct", 0)) if row.get("oi_change_pct") else None,
"basis": float(row.get("basis", 0)) if row.get("basis") else None,
})
except (KeyError, ValueError):
continue
loaded.sort(key=lambda item: item["timestamp"])
return loaded
def _fetch_derivatives_sync(self, symbol: str) -> Optional[List[Dict]]:
"""用 REST 获取单个币对的费率、OI 和基差(同步,供后台线程调用)。
返回单条记录列表,与 K 线 fetcher 返回格式一致。"""
try:
funding = self.exchange.fetch_funding_rate(symbol)
oi = self.exchange.fetch_open_interest(symbol)
ticker = self.exchange.fetch_ticker(symbol)
except Exception as e:
logger.warning("衍生品获取失败 %s: %s", symbol, e)
return None
now_ms = int(time.time() * 1000)
record = {
"timestamp": now_ms,
"datetime": to_utc_iso(now_ms),
"funding_rate": float(funding.get("fundingRate", 0)) if funding else 0,
"open_interest": float(oi.get("openInterestAmount", 0)) if oi else 0,
"oi_change_pct": None,
"basis": None,
}
# OI 变化 (与上一条对比)
with self._lock:
history = self._derivatives.get(symbol, [])
if history:
prev = history[-1]
prev_oi = prev.get("open_interest", 0)
if prev_oi > 0 and record["open_interest"] > 0:
record["oi_change_pct"] = round(
(record["open_interest"] - prev_oi) / prev_oi * 100, 2
)
# 基差 (期货-现货)/现货
if ticker:
spot_symbol = symbol.split(":")[0]
try:
spot_ticker = self.exchange.fetch_ticker(spot_symbol)
future_price = float(ticker.get("last", 0))
spot_price = float(spot_ticker.get("last", 0))
if spot_price > 0 and future_price > 0:
record["basis"] = round(
(future_price - spot_price) / spot_price * 100, 2
)
except Exception:
pass
return [record]
def _merge_derivatives(
self,
base: List[Dict],
new_records: List[Dict],
) -> List[Dict]:
"""按 timestamp 去重合并衍生品记录,新数据覆盖同时间戳旧数据。与 _merge_candles 模式一致。"""
merged = {entry["timestamp"]: entry for entry in base}
for record in new_records:
merged[record["timestamp"]] = record
return list(sorted(merged.values(), key=lambda item: item["timestamp"]))
def _write_derivatives_to_disk(self, symbol: str, records: List[Dict]) -> None:
"""将衍生品历史数据写入 CSV。与 _write_to_disk 模式一致:先写 tmp 再 replace。"""
if not records:
return
path = self._derivatives_file_path(symbol)
path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = ["timestamp", "datetime", "funding_rate", "open_interest",
"oi_change_pct", "basis"]
tmp_path = path.with_suffix(".tmp")
try:
with tmp_path.open("w", encoding="utf-8", newline="") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for r in records:
row = {}
for k in fieldnames:
val = r.get(k, "")
row[k] = "" if val is None else val
writer.writerow(row)
tmp_path.replace(path)
except Exception as e:
logger.error("衍生品落盘失败 %s: %s", symbol, e)
if tmp_path.exists():
tmp_path.unlink()
def _load_local(self, symbol: str, timeframe: str) -> List[Dict[str, float]]:
"""启动时从磁盘加载已有 K 线,损坏行跳过,按时间排序。"""
path = self._data_file_path(symbol, timeframe)
@@ -799,14 +913,38 @@ class DataProvider:
except Exception as exc:
logger.error("数据更新回调异常: %s", exc)
def _derivatives_refresh_loop(self) -> None:
"""后台线程:每 60 秒拉取一次衍生品数据。与 K 线 watch 模式一致:拉取 → 合并 → 写入内存。"""
# 首次启动先加载本地历史
for symbol in self.symbols:
history = self._load_derivatives_local(symbol)
if history:
with self._lock:
self._derivatives[symbol] = history
logger.info("衍生品 %s 加载本地记录: %d", symbol, len(history))
DERIVATIVES_INTERVAL = 60
while not self._stop_event.wait(DERIVATIVES_INTERVAL):
for symbol in self.symbols:
try:
new_records = self._fetch_derivatives_sync(symbol)
if new_records:
with self._lock:
base = self._derivatives.get(symbol, [])
self._derivatives[symbol] = self._merge_derivatives(base, new_records)
except Exception as e:
logger.debug("衍生品刷新失败 %s: %s", symbol, e)
def start_background_workers(self) -> None:
"""启动后台线程:冷启动回填 + 周期性落盘。WebSocket 监听由 lifespan 异步启动。"""
"""启动后台线程:冷启动回填 + 周期性落盘 + 衍生品刷新。WebSocket 监听由 lifespan 异步启动。"""
self._stop_event.clear()
self._backfill_thread = threading.Thread(target=self._cold_start_backfill, name="backfill-loop", daemon=True)
self._persist_thread = threading.Thread(target=self._persist_loop, name="persist-loop", daemon=True)
self._derivatives_thread = threading.Thread(target=self._derivatives_refresh_loop, name="derivatives-loop", daemon=True)
self._backfill_thread.start()
self._persist_thread.start()
logger.info("后台线程已启动(回填 + 落盘)")
self._derivatives_thread.start()
logger.info("后台线程已启动(回填 + 落盘 + 衍生品)")
def start_watch_tasks(self) -> None:
"""在当前 asyncio event loop 上启动 WebSocket 监听。必须在 lifespan 内调用。"""
@@ -857,6 +995,15 @@ class DataProvider:
if timeframe in MAX_CANDLES_IN_MEMORY:
continue # 内存只有尾部 N 根,不覆盖 CSV 全量
self._write_to_disk(symbol, timeframe, data)
# 周期性落盘衍生品数据(与 K 线一致:锁内复制 → 完整覆写 CSV)
with self._lock:
deriv_snapshot = {
symbol: list(records) for symbol, records in self._derivatives.items()
}
for symbol, records in deriv_snapshot.items():
if records:
self._write_derivatives_to_disk(symbol, records)
# 周期性也保存一次恢复点,保证一致性
self._save_resume_since()
@@ -1068,6 +1215,26 @@ def create_app(provider: DataProvider) -> FastAPI:
data = provider.get_klines(symbol=symbol, timeframe=tf, start_time=start, end_time=end, limit=limit)
return data
@app.get("/api/derivatives")
async def api_derivatives(
symbol: str = Query("BTC/USDT:USDT", description="如 BTC/USDT:USDT"),
):
"""返回指定币对的衍生品数据快照(资金费率、OI、基差)。"""
with provider._lock:
records = list(provider._derivatives.get(symbol, []))
if not records:
raise HTTPException(status_code=404, detail=f"衍生品数据不可用: {symbol}")
record = records[-1] # 最新一条
return {
"symbol": symbol,
"timestamp": record.get("timestamp"),
"datetime": record.get("datetime"),
"funding_rate": record.get("funding_rate"),
"open_interest": record.get("open_interest"),
"oi_change_pct": record.get("oi_change_pct"),
"basis": record.get("basis"),
}
homepage_path = Path(__file__).resolve().parent / "homepage.html"
docs_path = Path(__file__).resolve().parent / "api_docs.html"