Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions aios_core/quant/ml_predictor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
]
Expand All @@ -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]
Expand Down Expand Up @@ -130,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)
Expand Down
44 changes: 32 additions & 12 deletions aios_core/quant/rl_signal_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -153,37 +166,44 @@ 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:
return base
# мультиактив-модель: 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
with torch.no_grad():
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,
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions coordination/PROJECT_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 запрещён. Закрыто в 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`.

## Текущий архитектурный срез
Expand Down
Original file line number Diff line number Diff line change
@@ -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 должен стать достижимым корректной моделью, не ослаблением порогов)`
Loading
Loading