From 77b51f6678105311a92f9bbcb0247ed2b4d92561 Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Tue, 18 Aug 2026 15:53:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(basket):=20apply=20owner=20'=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20=D0=BB=D1=83=D1=87=D1=88=D0=B5'=20-=20live=20basket=20?= =?UTF-8?q?vol-targeting=20(inverse-vol-30d=20weights,=20honest=20cash=20f?= =?UTF-8?q?low,=20longest-fresh-series=20loader=20for=20TON/kraken),=20bea?= =?UTF-8?q?r=20regime=20(BTC **Статус 2026-08-18:** владелец делегировал «делай как лучше» — применено: +> (1) live-корзина переведена на vol-targeting (inverse_vol_30d, бенчмарк-история +> сброшена — правило сменилось, линия чистая; TON: kraken-серия, делистнутая +> binance отсеивается фильтром свежести); (2) медвежий режим (BTC уведомление в утренний брифинг; (3) DCA не менялся. + + + 1. **Live-корзину перевести на vol-targeting (V4)** — тот же всегда-в-рынке профиль, просадка втрое ниже в медвежьем режиме; правило прозрачное (1/σ30d, нормализация), данные уже собираются. diff --git a/docs/PROJECT_INVENTORY.md b/docs/PROJECT_INVENTORY.md index 98ea8be77..e084823e6 100644 --- a/docs/PROJECT_INVENTORY.md +++ b/docs/PROJECT_INVENTORY.md @@ -8,14 +8,14 @@ | Метрика | Значение | |---|---:| | Package version | `19.9.0` | -| Стабильных tracked-файлов | 6,366 | -| Строк | 611,589 | -| Размер | 24.94 MiB | -| Python-файлов | 3,520 | -| Строк Python | 361,303 | -| Классов / функций / async | 2,988 / 19,953 / 1,636 | +| Стабильных tracked-файлов | 6,367 | +| Строк | 611,797 | +| Размер | 24.95 MiB | +| Python-файлов | 3,521 | +| Строк Python | 361,503 | +| Классов / функций / async | 2,988 / 19,967 / 1,636 | | Python syntax errors | 0 | -| Test Python files / test functions | 969 / 6,709 | +| Test Python files / test functions | 970 / 6,716 | | Markdown-файлов | 2,046 | | Root `run_*.py` | 113 | | Уникальных tracked service/timer names | 210 | @@ -26,10 +26,10 @@ |---|---:|---:|---:| | `aios_core` | 971 | 158,144 | 5.92 MiB | | `skills` | 2,641 | 109,481 | 4.73 MiB | -| `docs` | 477 | 76,059 | 3.27 MiB | -| `tests` | 551 | 71,086 | 2.47 MiB | -| `[root]` | 217 | 35,157 | 1.38 MiB | -| `scripts` | 280 | 33,645 | 1.28 MiB | +| `docs` | 477 | 76,067 | 3.27 MiB | +| `tests` | 552 | 71,157 | 2.47 MiB | +| `[root]` | 217 | 35,184 | 1.39 MiB | +| `scripts` | 280 | 33,747 | 1.28 MiB | | `attic` | 33 | 30,669 | 1.61 MiB | | `octopus_services` | 110 | 27,850 | 0.90 MiB | | `tg_bot` | 33 | 12,229 | 0.62 MiB | @@ -49,7 +49,7 @@ | Расширение | Файлов | |---|---:| -| `.py` | 3,520 | +| `.py` | 3,521 | | `.md` | 2,046 | | `.json` | 147 | | `.service` | 135 | diff --git a/run_morning_brief.py b/run_morning_brief.py index e80f227a4..f86b2e00b 100755 --- a/run_morning_brief.py +++ b/run_morning_brief.py @@ -66,6 +66,27 @@ def _read(path: Path, default): return default +def btc_regime(csv_path: Path | None = None) -> str | None: + """'bear' когда BTC-дневное закрытие ниже SMA200, 'bull' выше; None без + достаточных данных (рекомендация BASKET_VARIANTS 2026-08-17: уведомление + о медвежьем режиме без автоматических действий).""" + + import pandas as pd + + path = Path(csv_path) if csv_path else ROOT / "data" / "quant" / "BTC" / "binance" / "BTC_1h.csv" + try: + df = pd.read_csv(path) + df["day"] = pd.to_datetime(df["timestamp_ms"], unit="ms").dt.strftime("%Y-%m-%d") + d = df.groupby("day")["close"].last() + if len(d) < 201: + return None + last = float(d.iloc[-1]) + sma = float(d.iloc[-200:].mean()) + return "bear" if last < sma else "bull" + except Exception: + return None + + def _ab_paper_line(main_path: Path, control_path: Path) -> str | None: """Directional-v2 paper A/B digest; None when portfolio files are missing.""" @@ -278,6 +299,12 @@ def build() -> str: if ab_line: lines.append(ab_line) + # Медвежий режим (BTC < SMA200) — пассивная рекомендация, не действие + regime = btc_regime() + if regime == "bear": + lines.append("🐻 Медвежий режим: BTC ниже SMA200 — для пассивных вложений " + "DCA/кэш предпочтительнее lump-sum (BASKET_VARIANTS 2026-08-17)") + # Quant Signal Monitor (read-only WATCH-сигналы) try: qs = _read(ROOT / "data" / "reports" / "quant_signal_product.json", {}) diff --git a/scripts/run_basket_paper.py b/scripts/run_basket_paper.py index 1fbc0620c..ed2fc3203 100644 --- a/scripts/run_basket_paper.py +++ b/scripts/run_basket_paper.py @@ -32,18 +32,109 @@ HISTORY_FILE = REPO_ROOT / "data" / "reports" / "basket_paper.jsonl" -def daily_close(symbol: str, day: str) -> float | None: - """Last 1h close on or before `day` (YYYY-MM-DD).""" +def newest_csv(symbol: str) -> Path | None: + """CSV с самым свежим баром среди всех бирж (TON: binance делистнут + 30.06 — используется kraken).""" + + candidates = sorted(QUANT_DIR.glob(f"{symbol}/*/{symbol}_1h.csv")) + best: Path | None = None + best_ts = -1 + for cand in candidates: + try: + df = pd.read_csv(cand, usecols=["timestamp_ms"]) + ts = int(df["timestamp_ms"].max()) + except Exception: + continue + if ts > best_ts: + best, best_ts = cand, ts + return best + + +def longest_fresh_csv(symbol: str, freshness_days: int = 7) -> Path | None: + """Самая длинная серия среди бирж со свежим баром (за freshness_days). + + Для волатильности важна длина истории, а не самый свежий бар + (TON: kraken 33 дня vs bitstamp 26 — оба свежие, kraken длиннее). + Делистнутые серии (например TON/binance, заканчивается 30.06) + автоматически отсеиваются фильтром свежести. + """ - csv_paths = sorted(QUANT_DIR.glob(f"{symbol}/binance/{symbol}_1h.csv")) - if not csv_paths: + import time + + cutoff = time.time() * 1000 - freshness_days * 86_400_000 + best: Path | None = None + best_n = -1 + for cand in sorted(QUANT_DIR.glob(f"{symbol}/*/{symbol}_1h.csv")): + try: + df = pd.read_csv(cand, usecols=["timestamp_ms"]) + ts = int(df["timestamp_ms"].max()) + except Exception: + continue + if ts >= cutoff and len(df) > best_n: + best, best_n = cand, len(df) + return best + + +def _daily_closes_frame(symbol: str) -> pd.Series | None: + path = longest_fresh_csv(symbol) + if path is None: return None - df = pd.read_csv(csv_paths[0]) + df = pd.read_csv(path) df["day"] = pd.to_datetime(df["timestamp_ms"], unit="ms").dt.strftime("%Y-%m-%d") - sel = df[df["day"] <= day] + return df.groupby("day")["close"].last() + + +def daily_close(symbol: str, day: str) -> float | None: + """Last 1h close on or before `day` (YYYY-MM-DD) на свежайшей бирже.""" + + frame = _daily_closes_frame(symbol) + if frame is None: + return None + sel = frame[frame.index <= day] if sel.empty: return None - return float(sel.iloc[-1]["close"]) + return float(sel.iloc[-1]) + + +def daily_closes(symbol: str, limit: int | None = None) -> list[float] | None: + """Последние дневные закрытия (для волатильности).""" + + frame = _daily_closes_frame(symbol) + if frame is None: + return None + if limit is not None: + frame = frame.tail(limit) + return [float(v) for v in frame.values] + + +def inverse_vol_weights(series: dict[str, list | None], window: int = 30) -> dict[str, float]: + """Веса ∝ 1/σ дневных доходностей за `window` дней (чистая функция). + + Символ без достаточной истории получает вес 0; если волатильность не + определена ни у кого — равные веса. + """ + + import numpy as np + + vols: dict[str, float | None] = {} + for sym, closes in series.items(): + if not closes or len(closes) < window + 1: + vols[sym] = None + continue + rets = np.diff(np.log(np.asarray(closes[-(window + 1):], dtype=float))) + std = float(rets.std()) + vols[sym] = std if std > 0 else None + known = {s: v for s, v in vols.items() if v is not None} + if not known: + n = max(1, len(series)) + return {s: 1.0 / n for s in series} + inv = {s: 1.0 / vol for s, vol in known.items()} + total = sum(inv.values()) + weights = {s: inv[s] / total for s in inv} + for s in series: + if s not in weights: + weights[s] = 0.0 + return weights def mark(state: dict, day: str) -> dict | None: @@ -55,30 +146,38 @@ def mark(state: dict, day: str) -> dict | None: if px is None: return None prices[sym] = px - value = sum(state["holdings"].get(sym, 0.0) * px for sym, px in prices.items()) + value = state.get("cash_usd", 0.0) + sum( + state["holdings"].get(sym, 0.0) * px for sym, px in prices.items()) return {"day": day, "value_usd": round(value, 2), "prices": prices} -def rebalance(state: dict, day: str, prices: dict[str, float]) -> dict: - """Monthly rebalance to equal weights (fee per traded leg). - - On the very first rebalance the whole cash is invested equally. - """ +def rebalance(state: dict, day: str, prices: dict[str, float], + weights: dict[str, float] | None = None) -> dict: + """Monthly rebalance to target weights (fee per traded leg, honest cash + flow). weights=None -> equal weights. On the first rebalance the whole + cash is invested.""" - value = sum(state["holdings"].get(sym, 0.0) * prices[sym] for sym in prices) + if weights is None: + weights = {sym: 1.0 / len(prices) for sym in prices} + value = state.get("cash_usd", 0.0) + sum( + state["holdings"].get(sym, 0.0) * prices[sym] for sym in prices) if value <= 0 and state.get("cash_usd", 0.0) > 0: value = state["cash_usd"] state["cash_usd"] = 0.0 - target = value / len(prices) fees = 0.0 holdings = {} + cash = state.get("cash_usd", 0.0) for sym, px in prices.items(): qty = state["holdings"].get(sym, 0.0) - target_qty = target / px - traded = abs(target_qty - qty) * px + target_qty = value * weights.get(sym, 0.0) / px + diff = target_qty - qty + traded = abs(diff) * px fees += traded * FEE + cash -= diff * px # покупки тратят кэш, продажи возвращают holdings[sym] = target_qty + cash -= fees state["holdings"] = holdings + state["cash_usd"] = cash state["fees_paid_usd"] = state.get("fees_paid_usd", 0.0) + fees state["last_rebalance"] = day return state @@ -110,7 +209,10 @@ def main() -> int: # ежемесячный ребаланс: в первый прогон нового месяца (или самый первый) if state["last_rebalance"] is None or today[:7] != state["last_rebalance"][:7]: - rebalance(state, today, row["prices"]) + vol_series = {sym: daily_closes(sym, limit=31) for sym in TOP10} + weights = inverse_vol_weights(vol_series) + rebalance(state, today, row["prices"], weights) + state["weights_rule"] = "inverse_vol_30d" row = mark(state, today) state["updated"] = today diff --git a/tests/test_basket_paper.py b/tests/test_basket_paper.py index 4dae3d62d..d1632f5c4 100644 --- a/tests/test_basket_paper.py +++ b/tests/test_basket_paper.py @@ -18,8 +18,8 @@ def test_first_rebalance_invests_cash_equally(): rebalance(state, "2026-08-17", prices) for sym in TOP10: assert abs(state["holdings"][sym] - 1.0) < 1e-9 # $100 each / $100 px - assert state["cash_usd"] == 0.0 - # 10 legs * $100 * 0.1% = $1.0 + # 10 legs * $100 * 0.1% = $1.0 списано с кэша (честный денежный поток) + assert abs(state["cash_usd"] + 1.0) < 1e-9 assert abs(state["fees_paid_usd"] - 1.0) < 1e-9 assert state["last_rebalance"] == "2026-08-17" diff --git a/tests/test_best_move.py b/tests/test_best_move.py new file mode 100644 index 000000000..75f6e2d57 --- /dev/null +++ b/tests/test_best_move.py @@ -0,0 +1,71 @@ +"""Tests for the best-move changes (vol-targeting basket + bear regime).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts.run_basket_paper import inverse_vol_weights # noqa: E402 +from run_morning_brief import btc_regime # noqa: E402 + + +def test_inverse_vol_weights_low_vol_gets_more(): + rng = np.random.default_rng(1) + n = 40 + calm = list(np.exp(np.cumsum(rng.normal(0, 0.002, n)))) + wild = list(np.exp(np.cumsum(rng.normal(0, 0.02, n)))) + w = inverse_vol_weights({"A": calm, "B": wild}, window=30) + assert abs(sum(w.values()) - 1.0) < 1e-9 + assert w["A"] > w["B"] # спокойный актив получает больший вес + assert w["A"] > 0 and w["B"] > 0 + + +def test_inverse_vol_weights_short_history_zero_weight(): + rng = np.random.default_rng(2) + calm = list(np.exp(np.cumsum(rng.normal(0, 0.005, 40)))) + w = inverse_vol_weights({"A": calm, "B": [1.0, 2.0]}, window=30) + assert abs(sum(w.values()) - 1.0) < 1e-9 + assert w["A"] == 1.0 # единственный с волатильностью + assert w["B"] == 0.0 # недостаточно истории + + +def test_inverse_vol_weights_all_unknown_equal_fallback(): + w = inverse_vol_weights({"A": [1.0], "B": [2.0]}, window=30) + assert abs(w["A"] - 0.5) < 1e-9 and abs(w["B"] - 0.5) < 1e-9 + + +def _regime_csv(tmp_path, direction: int) -> Path: + import pandas as pd + + n = 220 + close = 100.0 + direction * np.arange(n) * 0.1 + df = pd.DataFrame({ + "timestamp_ms": (pd.Timestamp("2026-01-01").value // 10**6 + + np.arange(n) * 86_400_000), # один бар в день + "close": close, + }) + p = tmp_path / "BTC_1h.csv" + df.to_csv(p, index=False) + return p + + +def test_btc_regime_bear(tmp_path): + assert btc_regime(_regime_csv(tmp_path, direction=-1)) == "bear" + + +def test_btc_regime_bull(tmp_path): + assert btc_regime(_regime_csv(tmp_path, direction=+1)) == "bull" + + +def test_btc_regime_short_data(tmp_path): + p = tmp_path / "BTC_1h.csv" + p.write_text("timestamp_ms,close\n1,100\n2,101\n", encoding="utf-8") + assert btc_regime(p) is None + + +def test_btc_regime_missing_file(tmp_path): + assert btc_regime(tmp_path / "nope.csv") is None