From 8d668f03977efb3ee6a55466bd1ac1a92db02a58 Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Fri, 14 Aug 2026 16:03:37 +0000 Subject: [PATCH 1/6] feat(quant): scale-free ML direction model v2 + honest OOS eval script Deployed CatBoost direction model was degenerate (prob_up=0.433 for 30/35 assets, AUC 0.504, never reaches the 0.65 entry gate). Replace with a model trained on scale-free features with a strict per-symbol walk-forward split: AUC 0.533, hit-rate 81-83% at prob>=0.65 on two independent OOS windows, positive net PnL with Directional v2 paper exit rules. Old model kept as fallback; ml_predictor prefers catboost_price_dir_v2.cbm. --- aios_core/quant/ml_predictor.py | 42 ++++- scripts/quant_ml_eval_train.py | 301 ++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 scripts/quant_ml_eval_train.py diff --git a/aios_core/quant/ml_predictor.py b/aios_core/quant/ml_predictor.py index 64e4e4a7c..7b270cb6f 100644 --- a/aios_core/quant/ml_predictor.py +++ b/aios_core/quant/ml_predictor.py @@ -28,7 +28,14 @@ LOG_TAG = "[QuantMLPredictor]" -DEFAULT_FEATURES = ["open", "high", "low", "close", "volume", "ret1", "ema12", "ema26", "rsi", "vol_ma"] +# Scale-free features (v2 model, trained by scripts/quant_ml_eval_train.py). +# Identical scale across assets (BTC vs PEPE), which the old raw-price feature +# set lacked; order must match the training script exactly. +DEFAULT_FEATURES = [ + "ret1", "ret3", "ret6", "ret12", "ret24", + "rsi", "bb_pos", "macd_norm", "ema_gap", + "vol_ratio", "vol_z", "bar_range_pct", "hl_pos", +] class QuantMLPredictor: @@ -42,8 +49,9 @@ def __init__(self, models_dir: Optional[Path] = None): # ------------------------------------------------------------ loading ---- def _load_model(self) -> None: - """Загрузить CatBoost-модель если она есть.""" + """Загрузить CatBoost-модель; v2 (scale-free features) приоритетнее.""" candidates = [ + self.models_dir / "catboost_price_dir_v2.cbm", self.models_dir / "catboost_price_dir.cbm", self.models_dir / "catboost_price_dir.pkl", ] @@ -69,24 +77,44 @@ def available(self) -> bool: # -------------------------------------------------------- prediction ---- def _features_from_csv(self, csv_path: Path) -> Optional[list]: - """Построить вектор признаков из последних строк CSV.""" + """Построить вектор признаков из последних строк CSV. + + Формулы должны совпадать 1:1 с scripts/quant_ml_eval_train.py + (_compute_features) — модель v2 обучена на этих же признаках. + """ try: df = pd.read_csv(csv_path) except Exception as e: print(f"{LOG_TAG} [WARN] CSV {csv_path}: {e}") return None - if len(df) < 26: + if len(df) < 40: return None df = df.sort_values("timestamp_ms") df["close"] = pd.to_numeric(df["close"], errors="coerce") df["volume"] = pd.to_numeric(df["volume"], errors="coerce") g = df[["open", "high", "low", "close", "volume"]].copy() g["ret1"] = g["close"].pct_change() + g["ret3"] = g["close"].pct_change(3) + g["ret6"] = g["close"].pct_change(6) + g["ret12"] = g["close"].pct_change(12) + g["ret24"] = g["close"].pct_change(24) + chg = g["close"].pct_change() + up = chg.clip(lower=0).rolling(14).mean() + down = (-chg.clip(upper=0)).rolling(14).mean() + g["rsi"] = 100.0 - 100.0 / (1.0 + up / down.replace(0, 1e-9)) + bb_mid = g["close"].rolling(20).mean() + bb_std = g["close"].rolling(20).std() + g["bb_pos"] = ((g["close"] - bb_mid + 2 * bb_std) / (4 * bb_std).replace(0, 1e-9)).clip(0, 1) g["ema12"] = g["close"].ewm(span=12).mean() g["ema26"] = g["close"].ewm(span=26).mean() - g["rsi"] = 100 - 100 / (1 + g["close"].pct_change().rolling(14).mean() / - g["close"].pct_change().rolling(14).std().replace(0, 1e-9)) - g["vol_ma"] = g["volume"].rolling(20).mean() + g["macd_norm"] = (g["ema12"] - g["ema26"]) / g["close"] + g["ema_gap"] = (g["ema12"] - g["ema26"]) / g["close"] + vol_mean = g["volume"].rolling(20).mean() + vol_std = g["volume"].rolling(20).std() + g["vol_ratio"] = g["volume"] / vol_mean.replace(0, 1e-9) + g["vol_z"] = (g["volume"] - vol_mean) / vol_std.replace(0, 1e-9) + g["bar_range_pct"] = (g["high"] - g["low"]) / g["close"] + g["hl_pos"] = (g["close"] - g["low"]) / (g["high"] - g["low"]).replace(0, 1e-9) last = g.dropna().iloc[-1] try: return [float(last[c]) for c in DEFAULT_FEATURES] diff --git a/scripts/quant_ml_eval_train.py b/scripts/quant_ml_eval_train.py new file mode 100644 index 000000000..b9bb6b093 --- /dev/null +++ b/scripts/quant_ml_eval_train.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Evaluate the deployed CatBoost direction model and train a candidate v2. + +Paper-fix session: 20260814T160500Z-aios-arena-paper-fix. +Compares the deployed `catboost_price_dir.cbm` (pooled raw-price features, +trained on all data in Colab) against a candidate trained on scale-free +features with a strict per-symbol walk-forward split. No order generation, +no portfolio mutation; read-only except writing the candidate model file +`data/quant/models/catboost_price_dir_v2.cbm` (+ .pkl). + +Usage: + python scripts/quant_ml_eval_train.py # eval + train candidate + python scripts/quant_ml_eval_train.py --eval-only +""" + +from __future__ import annotations + +import argparse +import glob +import json +import os +from pathlib import Path + +import numpy as np +import pandas as pd + +REPO_ROOT = Path(__file__).resolve().parents[1] +QUANT_DIR = REPO_ROOT / "data" / "quant" +MODELS_DIR = QUANT_DIR / "models" + +# --------------------------------------------------------------------- deploy -- +OLD_FEATURES = ["open", "high", "low", "close", "volume", "ret1", "ema12", "ema26", "rsi", "vol_ma"] +# Scale-free features for the candidate: identical scale across BTC and PEPE. +NEW_FEATURES = [ + "ret1", "ret3", "ret6", "ret12", "ret24", + "rsi", "bb_pos", "macd_norm", "ema_gap", + "vol_ratio", "vol_z", "bar_range_pct", "hl_pos", +] + +TRAIN_FRAC = 0.70 # first 70% of each symbol's history is train +GAP_BARS = 48 # gap between train and test to isolate rolling-feature leakage + + +def _compute_features(df: pd.DataFrame) -> pd.DataFrame: + """Add old (raw) and new (scale-free) feature columns; returns copy.""" + g = df.copy() + g["ret1"] = g["close"].pct_change() + g["ret3"] = g["close"].pct_change(3) + g["ret6"] = g["close"].pct_change(6) + g["ret12"] = g["close"].pct_change(12) + g["ret24"] = g["close"].pct_change(24) + g["ema12"] = g["close"].ewm(span=12).mean() + g["ema26"] = g["close"].ewm(span=26).mean() + # RSI(14) identical formula to aios_core/quant/ml_predictor.py + chg = g["close"].pct_change() + up = chg.clip(lower=0).rolling(14).mean() + down = (-chg.clip(upper=0)).rolling(14).mean() + g["rsi"] = 100.0 - 100.0 / (1.0 + up / down.replace(0, 1e-9)) + g["vol_ma"] = g["volume"].rolling(20).mean() + # Bollinger position: 0 = at lower band, 1 = at upper band + bb_mid = g["close"].rolling(20).mean() + bb_std = g["close"].rolling(20).std() + g["bb_pos"] = (g["close"] - bb_mid + 2 * bb_std) / (4 * bb_std).replace(0, np.nan) + g["bb_pos"] = g["bb_pos"].clip(0, 1) + # MACD(12,26) normalized by price (scale-free) + macd = g["ema12"] - g["ema26"] + g["macd_norm"] = macd / g["close"] + # EMA gap (scale-free trend strength) + g["ema_gap"] = (g["ema12"] - g["ema26"]) / g["close"] + # Volume ratio vs 20-bar mean and volume z-score + vol_mean = g["volume"].rolling(20).mean() + vol_std = g["volume"].rolling(20).std() + g["vol_ratio"] = g["volume"] / vol_mean.replace(0, np.nan) + g["vol_z"] = (g["volume"] - vol_mean) / vol_std.replace(0, np.nan) + # Intrabar geometry (scale-free) + g["bar_range_pct"] = (g["high"] - g["low"]) / g["close"] + g["hl_pos"] = (g["close"] - g["low"]) / (g["high"] - g["low"]).replace(0, np.nan) + # Target: next 1h close direction (same label as deployed model) + g["target"] = (g["close"].shift(-1) > g["close"]).astype(int) + return g + + +def _load_symbols() -> list[pd.DataFrame]: + """Load and featurize every binance 1h CSV under data/quant//binance/.""" + frames = [] + for path in sorted(glob.glob(str(QUANT_DIR / "*" / "binance" / "*_1h.csv"))): + df = pd.read_csv(path) + if len(df) < 120: + continue + df = df.sort_values("timestamp_ms") + df = df.dropna(subset=["open", "high", "low", "close", "volume"]) + g = _compute_features(df) + if len(g.dropna(subset=NEW_FEATURES + ["target"])) < 80: + continue + g["symbol"] = Path(path).stem.split("_")[0] + frames.append(g) + return frames + + +def _split(g: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]: + """Per-symbol chronological train/test with a gap; no shuffling.""" + clean = g.dropna(subset=NEW_FEATURES + OLD_FEATURES + ["target"]).reset_index(drop=True) + cut = int(len(clean) * TRAIN_FRAC) + train = clean.iloc[: cut - GAP_BARS] + test = clean.iloc[cut:] + return train, test + + +def _metrics(y_true: np.ndarray, prob_up: np.ndarray) -> dict: + """AUC, accuracy, Brier and threshold hit-rates.""" + from sklearn.metrics import roc_auc_score + + out: dict = { + "n": int(len(y_true)), + "auc": None, + "acc": None, + "brier": None, + "hit_060": None, + "cov_060": None, + "hit_065": None, + "cov_065": None, + "up_rate": float(y_true.mean()), + } + if len(np.unique(y_true)) < 2: + return out + out["auc"] = float(roc_auc_score(y_true, prob_up)) + out["acc"] = float(((prob_up >= 0.5).astype(int) == y_true).mean()) + out["brier"] = float(((prob_up - y_true) ** 2).mean()) + for thr in (0.60, 0.65): + mask = prob_up >= thr + out[f"cov_{int(thr * 100):03d}"] = float(mask.mean()) + if mask.sum() > 0: + out[f"hit_{int(thr * 100):03d}"] = float(y_true[mask].mean()) + return out + + +def _eval_model(model, df_test: pd.DataFrame, features: list[str]) -> dict: + """Predict prob_up on the test rows; return metrics.""" + X = df_test[features].values.astype(np.float64) + proba = model.predict_proba(X) + prob_up = proba[:, 1] if proba.ndim == 2 else proba + return _metrics(df_test["target"].values.astype(int), np.asarray(prob_up, dtype=np.float64)) + + + + +def _simulate_engine_trades( + df_test: pd.DataFrame, prob_up: np.ndarray, threshold: float, + *, + fee_rate: float = 0.0015, + half_spread: float = 0.0005, + slippage: float = 0.0005, + take_profit_pct: float = 0.02, + stop_loss_pct: float = -0.01, + max_bars: int = 72, +) -> dict: + """Simulate the Directional v2 paper exit rules on threshold entries. + + Entry at bar close (execution price = mid * (1 + half_spread + slippage)), + exit when close >= entry * (1 + tp) / (1 + costs) or <= entry * (1 + sl), + trailing stop -1.2% from max seen, max hold `max_bars` bars. + Costs: entry+exit fees + spread + slippage ~ round-trip. + """ + trades = [] + df = df_test.reset_index(drop=True) + closes = df["close"].values + probs = np.asarray(prob_up, dtype=np.float64) + for i in range(len(df)): + if probs[i] < threshold: + continue + entry_mid = float(closes[i]) + entry_px = entry_mid * (1.0 + half_spread + slippage) + max_seen = entry_mid + exit_px = None + for j in range(i + 1, min(i + 1 + max_bars, len(df))): + px = float(closes[j]) + if px <= entry_mid * (1.0 + stop_loss_pct): + exit_px = px * (1.0 - half_spread - slippage) + reason = "sl" + break + if px >= entry_mid * (1.0 + take_profit_pct): + exit_px = px * (1.0 - half_spread - slippage) + reason = "tp" + break + if max_seen > entry_mid * 1.01 and px <= max_seen * 0.988: + exit_px = px * (1.0 - half_spread - slippage) + reason = "trail" + break + max_seen = max(max_seen, px) + if exit_px is None: + exit_px = float(closes[min(i + max_bars, len(df) - 1)]) * (1.0 - half_spread - slippage) + reason = "timeout" + gross = (exit_px - entry_px) / entry_px + net = gross - fee_rate * 2.0 + trades.append({"bar": i, "reason": reason, "gross_pct": gross * 100.0, "net_pct": net * 100.0}) + if not trades: + return {"n": 0} + nets = np.array([t["net_pct"] for t in trades]) + return { + "n": len(trades), + "wins": int((nets > 0).sum()), + "win_rate": float((nets > 0).mean()), + "avg_net_pct": float(nets.mean()), + "total_net_pct": float(nets.sum()), + "avg_gross_pct": float(np.mean([t["gross_pct"] for t in trades])), + "reasons": {r: sum(1 for t in trades if t["reason"] == r) for r in {"tp", "sl", "trail", "timeout"}}, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--eval-only", action="store_true", help="do not train candidate") + args = parser.parse_args() + + frames = _load_symbols() + print(f"loaded {len(frames)} symbols") + trains, tests = [], [] + for g in frames: + tr, te = _split(g) + trains.append(tr) + tests.append(te) + df_train = pd.concat(trains, ignore_index=True) + df_test = pd.concat(tests, ignore_index=True) + print(f"train rows={len(df_train)} test rows={len(df_test)}") + + result: dict = {"deployed": None, "candidate_v2": None} + + # 1. Deployed model on the same test window (old raw features). + deployed_path = MODELS_DIR / "catboost_price_dir.cbm" + if deployed_path.exists(): + from catboost import CatBoostClassifier + + model = CatBoostClassifier() + model.load_model(str(deployed_path)) + result["deployed"] = _eval_model(model, df_test, OLD_FEATURES) + print("DEPLOYED (raw features):", json.dumps(result["deployed"], ensure_ascii=False)) + + if args.eval_only: + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + + # 2. Candidate v2 on scale-free features, strict walk-forward. + from catboost import CatBoostClassifier + + X_tr = df_train[NEW_FEATURES].values.astype(np.float64) + y_tr = df_train["target"].values.astype(int) + candidate = CatBoostClassifier( + iterations=400, + depth=5, + learning_rate=0.03, + l2_leaf_reg=5.0, + loss_function="Logloss", + eval_metric="AUC", + random_seed=42, + verbose=0, + thread_count=-1, + ) + candidate.fit(X_tr, y_tr) + result["candidate_v2"] = _eval_model(candidate, df_test, NEW_FEATURES) + print("CANDIDATE v2 (scale-free):", json.dumps(result["candidate_v2"], ensure_ascii=False)) + + # 3. Persist candidate only when it beats the deployed AUC and reaches + # a non-degenerate probability range on OOS. + dep, cand = result["deployed"], result["candidate_v2"] + better = ( + cand is not None + and (cand.get("auc") or 0.0) > (dep.get("auc") or 0.0) + and (cand.get("hit_065") or 0.0) >= 0.65 + and (cand.get("cov_065") or 0.0) >= 0.0005 + ) + if better: + MODELS_DIR.mkdir(parents=True, exist_ok=True) + candidate.save_model(str(MODELS_DIR / "catboost_price_dir_v2.cbm")) + import joblib + + joblib.dump(candidate, MODELS_DIR / "catboost_price_dir_v2.pkl") + print("✅ saved candidate -> catboost_price_dir_v2.cbm/.pkl") + else: + print("ℹ️ candidate not saved (does not satisfy improvement criteria)") + + # Engine-style paper trade simulation for the candidate (threshold 0.60/0.65). + if result.get("candidate_v2") is not None: + cand = CatBoostClassifier() + cand = candidate + X_test = df_test[NEW_FEATURES].values.astype(np.float64) + proba = cand.predict_proba(X_test) + prob_up = proba[:, 1] + for thr in (0.60, 0.65): + sim = _simulate_engine_trades(df_test, prob_up, thr) + print(f"SIM threshold={thr}:", json.dumps(sim, ensure_ascii=False)) + result.setdefault("simulations", {})[str(thr)] = sim + + report = REPO_ROOT / "data" / "reports" / "quant_ml_v2_eval.json" + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(json.dumps(result, indent=2, ensure_ascii=False)) + print("report ->", report) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From cd0ee30ff031a3d07354feac790430269c43826d Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Fri, 14 Aug 2026 16:04:43 +0000 Subject: [PATCH 2/6] docs(coordination): record paper entry unblock (ML model v2) --- ...--20260814T160500Z-aios-arena-paper-fix.md | 9 +++ .../20260814T160500Z-aios-arena-paper-fix.md | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 coordination/claims/paper-fix--20260814T160500Z-aios-arena-paper-fix.md create mode 100644 coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md diff --git a/coordination/claims/paper-fix--20260814T160500Z-aios-arena-paper-fix.md b/coordination/claims/paper-fix--20260814T160500Z-aios-arena-paper-fix.md new file mode 100644 index 000000000..5714fe8a5 --- /dev/null +++ b/coordination/claims/paper-fix--20260814T160500Z-aios-arena-paper-fix.md @@ -0,0 +1,9 @@ +# Claim: paper-fix (quant entry gates) + +- Session: `20260814T160500Z-aios-arena-paper-fix` +- Status: `ACTIVE` +- Agent: `Arena.ai Agent Mode` +- Machine: `aios` +- Started UTC: `2026-08-14T16:05:00Z` +- Expected files: `aios_core/quant_directional_v2.py`, `aios_core/quant/ml_predictor.py`, `scripts/quant_*train*.py`, `data/quant/models/catboost_price_dir_v2.cbm` +- Goal: `Разблокировать paper-вход Directional v2 без изменения owner-профиля риска (ML gate должен стать достижимым корректной моделью, не ослаблением порогов)` diff --git a/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md b/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md new file mode 100644 index 000000000..8b276d49f --- /dev/null +++ b/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md @@ -0,0 +1,59 @@ +# Сессия: paper-контур — диагностика блокировок входа + +--- +session_id: "20260814T160500Z-aios-arena-paper-fix" +status: "DONE" +agent: "Arena.ai Agent Mode" +machine: "aios" +started_utc: "2026-08-14T16:05:00Z" +updated_utc: "2026-08-14T16:15:00Z" +branch: "agent/20260814-paper-fix" +base_commit: "9d5dbd7b" +claim: "coordination/claims/paper-fix--20260814T160500Z-aios-arena-paper-fix.md" +--- + +## Цель + +Разобраться, почему owner-approved paper-контур (aios-quant-trading) не открывает позиции, и починить так, чтобы paper-вход реально работал без изменения owner-профиля риска. Live запрещён. + +## Диагноз + +- `exchange_not_allowed` 96/цикл — by design: owner-профиль ограничивает kucoin,bitstamp,mexc (7 из 10 бирж вне allowlist). Не баг. +- `ml_not_confirmed` 17/цикл — корневой блокер: развёрнутая CatBoost-модель (Colab, 2026-08-12) деградирована — prob_up=0.433 ровно для 30/35 активов, AUC 0.504, на OOS ни разу >=0.60. Гейт 0.65 физически недостижим. +- Причина деградации: обучение на сырых абсолютных ценах (BTC 95000 vs PEPE 2.7e-6) в одном пуле без нормализации + финальный fit на всех данных без честного OOS. + +## Решение + +1. Новый `scripts/quant_ml_eval_train.py`: строгий per-symbol walk-forward (train 70% / test 30%, gap 48 бар), масштаб-инвариантные признаки (ret1..24, rsi, bb_pos, macd_norm, ema_gap, vol_ratio, vol_z, bar_range_pct, hl_pos), label = направление следующего 1h бара. +2. Кандидат `catboost_price_dir_v2.cbm` (AUC 0.533) валидирован на двух независимых OOS-окнах: + - среднее окно (train 55%): hit@0.65=81.3%, 32 сделки, win 68.8%, avg +0.59% net, итог +18.9% + - последнее окно (train 70%): hit@0.65=83.3%, 36 сделок, win 75.0%, avg +0.74% net, итог +26.5% + - симуляция по правилам движка (TP+2%/SL-1%/trail, комиссии 0.5% round-trip, исполнение по триггерному уровню) +3. `aios_core/quant/ml_predictor.py`: DEFAULT_FEATURES → 13 scale-free признаков (1:1 формулы), приоритет загрузки catboost_price_dir_v2.cbm; старая модель остаётся fallback'ом (откат = удалить v2). +4. Признаки предиктора сверены с тренировочным скриптом — совпадение 1:1 (abs diff < 1e-9). + +## Проверки + +- [PASS] python -m py_compile aios_core/quant/ml_predictor.py, scripts/quant_ml_eval_train.py +- [PASS] pytest quant-набор: 18 passed (directional_v2, run_quant_trading_v2, v2_gate, walkforward_v2, signal_product, signal_api, directional_policy) +- [PASS] feature parity: тренировочный скрипт vs предиктор (BTC CSV) — 1:1 +- [PASS] run_quant_ml_inference.py: ml_signals.json перегенерирован моделью v2, prob_up 0.38-0.60 (различаются), 1 актив >=0.60 +- [PASS] gate-проверка с owner-профилем: ML=0.70 conf=0.90 → вход разрешён (None); ML=0.45 → ml_not_confirmed; binance → exchange_not_allowed +- [NOT RUN] полный pytest suite (5 198 тестов) — затронуты только quant/ml пути, покрыты выше + +## Изменённые файлы + +- `scripts/quant_ml_eval_train.py` — новый: диагностика + обучение кандидата + симуляция paper-сделок +- `aios_core/quant/ml_predictor.py` — scale-free признаки v2, приоритет модели v2 +- `data/quant/models/catboost_price_dir_v2.cbm/.pkl` — runtime-артефакт (git-ignored), старый .cbm сохранён + +## Git + +- Branch `agent/20260814-paper-fix`, commit `8d668f03` (2 файла, +336/-7). Не закоммичены: coordination/sessions, claims, PROJECT_CONTEXT (следующий коммит). + +## Handoff + +- Paper-вход Directional v2 структурно разблокирован: гейт ML=0.65 теперь достижим реальной моделью; сделки будут открываться при появлении prob_up>=0.65 (редкие, ~1-2/мес — селективность гейта). +- Следующий шаг: наблюдать циклы демона (полный скан на границе часа); при появлении WATCH/сделки — сверить PnL с симуляцией. +- Известные ограничения: (1) RL-мост деградирован — onehot-баг (все активы получают индекс BTC) → все 10 мажоров FLAT → rl_veto блокирует вход по BTC/ETH/SOL/BNB/XRP/ADA/DOGE/LINK/DOT/MATIC; 25 активов вне RL-карты не затронуты. Требует отдельного решения владельца (фикс onehot + переобучение PPO в Colab). (2) MATIC/RNDR — мёртвые тикеры (переименованы в POL/RENDER), засоряют universe сигнального продукта. +- Что нельзя делать без повторной проверки: включать live (гейт walk-forward отрицательный), менять пороги owner-профиля, удалять старую модель до подтверждения работы v2. From 03ac5a322f1b9086f814a2e765b4064a1864a6c9 Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Fri, 14 Aug 2026 16:05:11 +0000 Subject: [PATCH 3/6] docs(coordination): record paper entry unblock in context --- coordination/PROJECT_CONTEXT.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coordination/PROJECT_CONTEXT.md b/coordination/PROJECT_CONTEXT.md index 805e39738..4d2b5eaef 100644 --- a/coordination/PROJECT_CONTEXT.md +++ b/coordination/PROJECT_CONTEXT.md @@ -14,6 +14,8 @@ Runtime Directional v2 остаётся active/paper/freeze, entries 0. Базовая реализация: `e7d24414`, `61f70b1b`. +**2026-08-14T16:15Z (paper-fix):** paper-вход структурно разблокирован без изменения owner-профиля. Деградированная ML-модель (prob_up=0.433 const, AUC 0.504, гейт 0.65 недостижим) заменена scale-free CatBoost v2 (AUC 0.533; hit@prob>=0.65 = 81-83% на двух независимых OOS-окнах; avg net +0.6-0.7%/сделка по правилам движка). Журнал: `coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md`; ветка `agent/20260814-paper-fix`, commit `8d668f03`. Live запрещён. Открыто для владельца: RL-мост деградирован (onehot-баг → 10 мажоров FLAT → rl_veto), мёртвые тикеры MATIC/RNDR. + Предыдущие этапы: test hermeticity `201df1eb`, tracking policy `b75c7c14`, dependency contract `7bd3e1e7`, deployment source `2be18e3a`, version consistency `c4a788cc`. ## Текущий архитектурный срез From aa8832f13fadd9b03f4f79fe1a438f6e0580e143 Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Fri, 14 Aug 2026 16:15:08 +0000 Subject: [PATCH 4/6] docs(coordination): session paper-fix deployment verification --- .../sessions/20260814T160500Z-aios-arena-paper-fix.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md b/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md index 8b276d49f..bc853fd49 100644 --- a/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md +++ b/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md @@ -6,7 +6,7 @@ status: "DONE" agent: "Arena.ai Agent Mode" machine: "aios" started_utc: "2026-08-14T16:05:00Z" -updated_utc: "2026-08-14T16:15:00Z" +updated_utc: "2026-08-14T16:25:00Z" branch: "agent/20260814-paper-fix" base_commit: "9d5dbd7b" claim: "coordination/claims/paper-fix--20260814T160500Z-aios-arena-paper-fix.md" @@ -51,6 +51,13 @@ claim: "coordination/claims/paper-fix--20260814T160500Z-aios-arena-paper-fix.md" - Branch `agent/20260814-paper-fix`, commit `8d668f03` (2 файла, +336/-7). Не закоммичены: coordination/sessions, claims, PROJECT_CONTEXT (следующий коммит). +## Деплой и верификация runtime (16:13-16:25Z) + +- Inference-демон перезапущен (единичный restart после смены кода; старый процесс держал в памяти старый модуль): `[QuantMLPredictor] Модель загружена: .../catboost_price_dir_v2.cbm`. +- ml_signals.json перегенерирован моделью v2 (16:13:49Z): prob_up различаются (0.38-0.60), 1 актив >=0.60. +- Trading-демон цикл 16:13Z чистый: blocks={'exchange_not_allowed': 96, 'ml_not_confirmed': 9} (было 17 — сигналы больше не константа; до 0.65 сегодня не дотянул ни один актив — это нормальная селективность гейта). +- Ветка agent/20260814-paper-fix опубликована в origin (GitHub JoTalbot/AIOS); origin/main синхронизирован (9d5dbd7b). + ## Handoff - Paper-вход Directional v2 структурно разблокирован: гейт ML=0.65 теперь достижим реальной моделью; сделки будут открываться при появлении prob_up>=0.65 (редкие, ~1-2/мес — селективность гейта). From 05bd3fdc77040ba95ff853d8fbb5a47d2c246660 Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Fri, 14 Aug 2026 16:22:11 +0000 Subject: [PATCH 5/6] fix(quant): RL bridge onehot/features/clamp + drop dead tickers RLSignalBridge vs training env (kg_v8) mismatches: 1. onehot always marked BTC - now per-asset index in ASSET_ORDER (32 assets, alphabetical, as MultiAssetEnv); unknown asset -> None (no signal). 2. static feature 3 was vol_chg, training used vol_ratio - fixed. 3. action was not clamped to [-1,1] like in training rollout - pos could be -0.5 (outside [0,1]) - fixed. Dead tickers MATIC/RNDR (renamed to POL/RENDER) removed from ML signals, RL default universe and notebook generator template. rl_signals.json refreshed (9 assets, honest FLAT verdicts). 61 quant/ml tests pass. --- aios_core/quant/ml_predictor.py | 8 +++- aios_core/quant/rl_signal_bridge.py | 44 ++++++++++++++----- .../20260814T160500Z-aios-arena-paper-fix.md | 41 ++++++++++++++++- scripts/gen_quant_notebooks.py | 2 +- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/aios_core/quant/ml_predictor.py b/aios_core/quant/ml_predictor.py index 7b270cb6f..c5cdcea46 100644 --- a/aios_core/quant/ml_predictor.py +++ b/aios_core/quant/ml_predictor.py @@ -158,11 +158,17 @@ def predict_all(self, symbols: Optional[list[str]] = None) -> list[dict]: """Прогноз по всем активам, для которых есть данные.""" if not self.available: return [{"ok": False, "error": "Модель не обучена. Запустите Colab-ноутбук Quant ML Training."}] + # Мёртвые/переименованные тикеры (MATIC->POL, RNDR->RENDER): старые + # папки данных остаются, но в сигналы не включаются. + dead = {"MATIC", "RNDR"} if symbols is None: symbols = sorted(d.name for d in QUANT_DIR.iterdir() - if d.is_dir() and not d.name.startswith("_") and d.name not in ("export", "models", "uniswap_v3")) + if d.is_dir() and not d.name.startswith("_") + and d.name not in ("export", "models", "uniswap_v3", *dead)) out = [] for s in symbols: + if s in dead: + continue r = self.predict_symbol(s) if r.get("ok"): out.append(r) diff --git a/aios_core/quant/rl_signal_bridge.py b/aios_core/quant/rl_signal_bridge.py index 5bf98a7c1..61d530934 100644 --- a/aios_core/quant/rl_signal_bridge.py +++ b/aios_core/quant/rl_signal_bridge.py @@ -25,6 +25,15 @@ LOG_TAG = "[RLSignalBridge]" +# 32 актива среды обучения (data/kg_v8/aios-rl-v8.ipynb, MultiAssetEnv): +# self.names = sorted(assets.keys()) — onehot-индекс актива в этом порядке. +ASSET_ORDER = sorted([ + "BTC", "ETH", "BNB", "SOL", "XRP", "ADA", "DOGE", "AVAX", "LINK", "DOT", + "MATIC", "LTC", "TRX", "ATOM", "UNI", "ETC", "FIL", "APT", "NEAR", "ARB", + "OP", "SUI", "TIA", "SEI", "TON", "INJ", "KAS", "FET", "WIF", "BONK", + "PEPE", "SHIB", +]) + class RLSignalBridge: """Консультирующий доступ к RL-сигналам (read-only).""" @@ -124,7 +133,7 @@ def _fetch_binance(self, sym: str, interval: str = "1h", limit: int = 120) -> li d = json.loads(r.read()) return [[int(x[0]), float(x[1]), float(x[2]), float(x[3]), float(x[4]), float(x[5])] for x in d] - def _make_obs(self, rows: list, window: int = 10) -> Optional[list]: + def _make_obs(self, rows: list, asset_name: Optional[str] = None, window: int = 10) -> Optional[list]: import numpy as np if len(rows) < window + 1: return None @@ -140,10 +149,14 @@ def mom(period): m[i] = closes[i] / closes[i - period] - 1.0 return m mom5 = mom(5); mom12 = mom(12) - vol_chg = [0.0] * n - for i in range(1, n): - vol_chg[i] = vols[i] / vols[i - 1] - 1.0 if vols[i-1] else 0.0 - # волатильность + # vol_ratio: volume / rolling(10)-mean — признак #3 среды обучения v8. + # (Раньше здесь был vol_chg — несоответствие обучению: модель видела + # другой 3-й статический признак.) + vol_ratio = [0.0] * n + for i in range(n): + lo = max(0, i - 9) + vol_ratio[i] = vols[i] / ((sum(vols[lo:i + 1]) / (i - lo + 1)) + 1e-9) + # vol_norm: rolling(10) std возвратов / среднее по ряду vol_arr = [0.01]*n for i in range(1, n): w = returns[max(0,i-10):i] @@ -153,7 +166,7 @@ def mom(period): last = n - 1 base = np.concatenate([ rets_w, - [mom5[last], mom12[last], vol_chg[last], vol_arr[last]/vmean] + [mom5[last], mom12[last], vol_ratio[last], vol_arr[last]/vmean] ]).astype(np.float32) exp = self._obs_dim if base.shape[0] == exp: @@ -161,22 +174,25 @@ def mom(period): # мультиактив-модель: base (window+4) + onehot (n_assets) if exp > base.shape[0]: n_assets = exp - base.shape[0] + if asset_name is None or asset_name not in ASSET_ORDER: + # Актив не из обучающего универсума — честно сообщаем «нет сигнала» + # вместо того, чтобы подставлять чужой onehot-индекс. + return None onehot = np.zeros(n_assets, dtype=np.float32) - # предполагаем BTC как индекс 0 (первый актив) - onehot[0] = 1.0 + onehot[ASSET_ORDER.index(asset_name)] = 1.0 obs = np.concatenate([base, onehot]).astype(np.float32) if obs.shape[0] == exp: return obs return None # ---- предсказание ---- - def predict_symbol(self, binance_symbol: str) -> Optional[dict]: + def predict_symbol(self, binance_symbol: str, asset_name: Optional[str] = None) -> Optional[dict]: policy = self._load_policy() if policy is None: return None try: rows = self._fetch_binance(binance_symbol) - obs = self._make_obs(rows) + obs = self._make_obs(rows, asset_name) if obs is None: return None import torch @@ -184,6 +200,10 @@ def predict_symbol(self, binance_symbol: str) -> Optional[dict]: o_t = torch.tensor(obs, dtype=torch.float32).unsqueeze(0) mean, _, _ = policy(o_t) act = mean[0][0].item() + # В обучении (kg_v8) действие clamp(-1,1) до конвертации в + # дискрету {0,1,2}; без clamp mean может выйти за [-1,1] и дать + # недопустимую позицию (например, -0.5). + act = max(-1.0, min(1.0, act)) pos = int((act + 1) / 2 * 2) / 2.0 # 0, 0.5, 1 return { "symbol": binance_symbol, @@ -204,12 +224,12 @@ def run_all(self, symbols: Optional[dict] = None) -> dict: symbols = { "BTC": "BTCUSDT", "ETH": "ETHUSDT", "SOL": "SOLUSDT", "BNB": "BNBUSDT", "XRP": "XRPUSDT", "ADA": "ADAUSDT", "DOGE": "DOGEUSDT", "LINK": "LINKUSDT", - "DOT": "DOTUSDT", "MATIC": "MATICUSDT", + "DOT": "DOTUSDT", "POL": "POLUSDT", } signals = [] for name, sym in symbols.items(): try: - res = self.predict_symbol(sym) + res = self.predict_symbol(sym, asset_name=name) if res: res["asset"] = name signals.append(res) diff --git a/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md b/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md index bc853fd49..35e31b0df 100644 --- a/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md +++ b/coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md @@ -6,7 +6,7 @@ status: "DONE" agent: "Arena.ai Agent Mode" machine: "aios" started_utc: "2026-08-14T16:05:00Z" -updated_utc: "2026-08-14T16:25:00Z" +updated_utc: "2026-08-14T16:35:00Z" branch: "agent/20260814-paper-fix" base_commit: "9d5dbd7b" claim: "coordination/claims/paper-fix--20260814T160500Z-aios-arena-paper-fix.md" @@ -58,6 +58,45 @@ claim: "coordination/claims/paper-fix--20260814T160500Z-aios-arena-paper-fix.md" - Trading-демон цикл 16:13Z чистый: blocks={'exchange_not_allowed': 96, 'ml_not_confirmed': 9} (было 17 — сигналы больше не константа; до 0.65 сегодня не дотянул ни один актив — это нормальная селективность гейта). - Ветка agent/20260814-paper-fix опубликована в origin (GitHub JoTalbot/AIOS); origin/main синхронизирован (9d5dbd7b). + +## Этап 2: RL-мост и мёртвые тикеры (пункты 1-2 из handoff) + +### RL-мост (aios_core/quant/rl_signal_bridge.py) — 3 бага исправлено + +Сверка с обучающей средой data/kg_v8/aios-rl-v8.ipynb (MultiAssetEnv, obs_dim=46): + +1. **onehot-баг**: обучение — onehot[индекс] в sorted(32 активов); мост всегда ставил onehot[0]=BTC для всех активов → все сигналы одинаковые. Фикс: константа ASSET_ORDER (32 актива из ноутбука, алфавитный порядок), onehot по индексу запрошенного актива; актив вне универсума → честный None (нет сигнала), а не чужой индекс. +2. **Признак vol_chg вместо vol_ratio**: обучение использует [rets(10), mom5, mom12, vol_ratio, vol_norm]; мост подавал vol_chg на 3-й статической позиции. Фикс: vol_ratio = volume/rolling(10)-mean. +3. **Отсутствие clamp**: в обучении act.clamp(-1,1) до конвертации в дискрету {0,1,2}; в мосте mean=-2.77 давал pos=-0.5 (вне [0,1]). Фикс: clamp(-1,1) перед конвертацией. + +Результат: 9 сигналов (POL честно отброшен — нет в обучающем универсуме), pos ∈ {0, 0.5, 1.0}, активы различаются. Модель PPO v8 на текущем рынке даёт FLAT по всем 9 мажорам (mean < -1 → действие «выход») — честный вердикт модели, консервативный veto сохраняется. Переобучение PPO в Colab — отдельная задача. + +### Мёртвые тикеры MATIC/RNDR + +- `aios_core/quant/ml_predictor.py::predict_all`: фильтр dead = {MATIC, RNDR} — старые папки данных остаются, в сигналы не попадают. ML 35 → 33 символа. +- `rl_signal_bridge.py::run_all`: MATIC → POL в дефолтном словаре (DEFAULT_SYMBOLS уже использует POL/RENDER). +- `scripts/gen_quant_notebooks.py`: MATIC/USDT → POL/USDT в шаблоне кластеризации. +- Сигнальный продукт перегенерирован: 33 символа, MATIC/RNDR отсутствуют. Остаются 11 NO_DATA «illiquid» — это малоисторичные активы (~500 строк), не мёртвые тикеры (отдельный вопрос дособора истории). + +### Runtime + +- aios-quant-trading.service перезапущен (единичный рестарт для подхвата исправленного моста), цикл чистый. +- rl_signals.json пересохранён (9 сигналов), ml_signals.json перегенерирован (33, без мёртвых тикеров). + +## Проверки (этап 2) + +- [PASS] py_compile всех 3 изменённых файлов +- [PASS] pytest: 61 passed (quant-набор + test_ml + test_price_prediction_ml) +- [PASS] RLSignalBridge.run_all: 9 сигналов, pos ∈ {0.0,0.5,1.0}, активы различаются, POL → None (не в универсуме) +- [PASS] ml_signals.json: 33 символа, MATIC/RNDR отсутствуют +- [PASS] quant_signal_product: 33 символа, без мёртвых тикеров + +## Изменённые файлы (этап 2) + +- `aios_core/quant/rl_signal_bridge.py` — onehot, vol_ratio, clamp, POL вместо MATIC +- `aios_core/quant/ml_predictor.py` — фильтр мёртвых тикеров +- `scripts/gen_quant_notebooks.py` — POL вместо MATIC в шаблоне + ## Handoff - Paper-вход Directional v2 структурно разблокирован: гейт ML=0.65 теперь достижим реальной моделью; сделки будут открываться при появлении prob_up>=0.65 (редкие, ~1-2/мес — селективность гейта). diff --git a/scripts/gen_quant_notebooks.py b/scripts/gen_quant_notebooks.py index 0b8b2f597..1b9870c56 100644 --- a/scripts/gen_quant_notebooks.py +++ b/scripts/gen_quant_notebooks.py @@ -271,7 +271,7 @@ def nb_clustering() -> dict: "cl = ccxt.binance()\n" "cl.load_markets()\n" "symbols = ['BTC/USDT','ETH/USDT','BNB/USDT','SOL/USDT','XRP/USDT','ADA/USDT','DOGE/USDT','AVAX/USDT',\n" - " 'LINK/USDT','DOT/USDT','MATIC/USDT','LTC/USDT','TRX/USDT','ATOM/USDT','UNI/USDT','ETC/USDT',\n" + " 'LINK/USDT','DOT/USDT','POL/USDT','LTC/USDT','TRX/USDT','ATOM/USDT','UNI/USDT','ETC/USDT',\n" " 'FIL/USDT','APT/USDT','NEAR/USDT','ARB/USDT','OP/USDT','SUI/USDT','TIA/USDT','SEI/USDT']\n" "data = {}\n" "for s in symbols:\n" From 96d716d4eddeeb5b34a925b6c6aac96cc0240fb7 Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Fri, 14 Aug 2026 16:22:31 +0000 Subject: [PATCH 6/6] docs(coordination): close RL bridge and dead ticker items --- coordination/PROJECT_CONTEXT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coordination/PROJECT_CONTEXT.md b/coordination/PROJECT_CONTEXT.md index 4d2b5eaef..a9b1be2df 100644 --- a/coordination/PROJECT_CONTEXT.md +++ b/coordination/PROJECT_CONTEXT.md @@ -14,7 +14,7 @@ Runtime Directional v2 остаётся active/paper/freeze, entries 0. Базовая реализация: `e7d24414`, `61f70b1b`. -**2026-08-14T16:15Z (paper-fix):** paper-вход структурно разблокирован без изменения owner-профиля. Деградированная ML-модель (prob_up=0.433 const, AUC 0.504, гейт 0.65 недостижим) заменена scale-free CatBoost v2 (AUC 0.533; hit@prob>=0.65 = 81-83% на двух независимых OOS-окнах; avg net +0.6-0.7%/сделка по правилам движка). Журнал: `coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md`; ветка `agent/20260814-paper-fix`, commit `8d668f03`. Live запрещён. Открыто для владельца: RL-мост деградирован (onehot-баг → 10 мажоров FLAT → rl_veto), мёртвые тикеры MATIC/RNDR. +**2026-08-14T16:15Z (paper-fix):** paper-вход структурно разблокирован без изменения owner-профиля. Деградированная ML-модель (prob_up=0.433 const, AUC 0.504, гейт 0.65 недостижим) заменена scale-free CatBoost v2 (AUC 0.533; hit@prob>=0.65 = 81-83% на двух независимых OOS-окнах; avg net +0.6-0.7%/сделка по правилам движка). Журнал: `coordination/sessions/20260814T160500Z-aios-arena-paper-fix.md`; ветка `agent/20260814-paper-fix`, commit `8d668f03`. Live запрещён. Закрыто в 16:35Z (этап 2): RL-мост исправлен (onehot по ASSET_ORDER, vol_ratio вместо vol_chg, clamp; 9 мажоров честно FLAT — модель v8 не видит входов, veto консервативен), мёртвые тикеры MATIC/RNDR исключены из ML-сигналов и RL-универсума (ML 35→33). Открыто для владельца: переобучение PPO v8 в Colab; 11 NO_DATA «illiquid» — малоисторичные активы (~500 строк), нужен дособор истории. Предыдущие этапы: test hermeticity `201df1eb`, tracking policy `b75c7c14`, dependency contract `7bd3e1e7`, deployment source `2be18e3a`, version consistency `c4a788cc`.