From 84e8756dfcd7c06ec043ec59b951653dd1c10c2c Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Wed, 19 Aug 2026 13:02:22 +0000 Subject: [PATCH] feat(regime): market regime engine (7 regimes, risk level, strategy router, triggers) + CRASH/PANIC kill-guard in Directional v2 policy + daily timer; fix freqtrade stoploss -0.99 trap (-99%!) to protective -15%; T2 simulation honest metrics; regime block in trading report; accounts.py seam to keep module budget --- aios_core/quant/market_regime.py | 164 ++++++++++++++++++ aios_core/quant/regime_guard.py | 27 +++ aios_core/quant_directional_policy.py | 8 +- coordination/PROJECT_CONTEXT.md | 16 ++ ...260819T020000Z-aios-arena-regime-engine.md | 73 ++++++++ deploy/systemd/HETZNER_INSTALLED_UNITS.txt | 4 +- .../aios-quant-trading-control.service | 1 + deploy/systemd/aios-quant-trading.service | 1 + deploy/systemd/aios-regime-engine.service | 9 + deploy/systemd/aios-regime-engine.timer | 10 ++ docs/PROJECT_INVENTORY.md | 38 ++-- scripts/freqtrade_t2.py | 6 +- scripts/quant_regime_engine.py | 112 ++++++++++++ scripts/quant_t2_metrics.py | 125 +++++++++++++ tests/test_freqtrade_stoploss.py | 32 ++++ tests/test_market_regime.py | 104 +++++++++++ tests/test_regime_guard_policy.py | 67 +++++++ tests/test_systemd_inventory.py | 2 +- tests/test_t2_metrics.py | 44 +++++ tests/test_trading_button_path.py | 43 ++++- tg_bot/accounts.py | 20 +-- tg_bot/pre_treasury_intents.py | 23 +++ tg_bot/trading_report.py | 65 ++++++- 23 files changed, 942 insertions(+), 52 deletions(-) create mode 100644 aios_core/quant/market_regime.py create mode 100644 aios_core/quant/regime_guard.py create mode 100644 coordination/sessions/20260819T020000Z-aios-arena-regime-engine.md create mode 100644 deploy/systemd/aios-regime-engine.service create mode 100644 deploy/systemd/aios-regime-engine.timer create mode 100644 scripts/quant_regime_engine.py create mode 100644 scripts/quant_t2_metrics.py create mode 100644 tests/test_freqtrade_stoploss.py create mode 100644 tests/test_market_regime.py create mode 100644 tests/test_regime_guard_policy.py create mode 100644 tests/test_t2_metrics.py create mode 100644 tg_bot/pre_treasury_intents.py diff --git a/aios_core/quant/market_regime.py b/aios_core/quant/market_regime.py new file mode 100644 index 000000000..09e780e52 --- /dev/null +++ b/aios_core/quant/market_regime.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Market regime engine (ревизия 2026-08-19, по рекомендациям). + +Цель — НЕ предсказывать цену, а честно определять режим рынка из совокупности +доступных индикаторов и выдавать (режим, уровень риска, семейство стратегий, +триггеры смены режима). Используется: +- scripts/quant_regime_engine.py (ежедневный сбор + история); +- aios_core/quant/regime_guard.py (kill-guard бумажных входов в CRASH/PANIC); +- tg_bot/trading_report.py (блок «Режим рынка» + AI-аналитик). + +Чистые функции; без сетевых вызовов. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +REGIMES = ("STRONG_BULL", "BULL", "SIDEWAYS", "VOLATILE", "BEAR", "CRASH", "PANIC") + +CRASH_PANIC = {"CRASH", "PANIC"} + +RISK_BY_REGIME = { + "STRONG_BULL": "LOW", + "BULL": "LOW", + "SIDEWAYS": "MEDIUM", + "VOLATILE": "HIGH", + "BEAR": "HIGH", + "CRASH": "EXTREME", + "PANIC": "EXTREME", +} + +STRATEGY_BY_REGIME = { + "STRONG_BULL": "momentum/trend (полный экспозицион)", + "BULL": "momentum/trend", + "SIDEWAYS": "mean-reversion/range", + "VOLATILE": "пониженный размер позиций", + "BEAR": "defensive/низкий экспозицион (DCA-накопление)", + "CRASH": "сохранение капитала (кэш)", + "PANIC": "сохранение капитала (кэш)", +} + + +def classify_regime(i: dict[str, float | None]) -> str: + """Классификация режима из индикаторов (a-priori правила, без подгонки). + + Приоритет правил: PANIC > CRASH > BEAR > STRONG_BULL > BULL > VOLATILE > SIDEWAYS. + Отсутствующий индикатор (None) просто выключает зависящие от него правила. + """ + + def num(key: str) -> float | None: + v = i.get(key) + return float(v) if v is not None else None + + dd90 = num("dd90_pct") # просадка от 90-дневного максимума, % + fng = num("fear_greed") # 0..100 + ret7 = num("btc_ret_7d_pct") # 7-дневная доходность BTC, % + above200 = num("btc_above_sma200") # 1/0 + above50 = num("btc_above_sma50") + breadth = num("breadth_7d") # доля активов вселенной с положительным ret_7d + vol = num("vol30_annualized_pct") + + if dd90 is not None and dd90 <= -35: + return "PANIC" + if fng is not None and fng <= 15: + return "PANIC" + if dd90 is not None and dd90 <= -20: + return "CRASH" + if ret7 is not None and ret7 <= -15: + return "CRASH" + if above200 is not None and above200 <= 0: + # ниже долгосрочного тренда: медвежий режим, если есть глубокая просадка + # или отрицательный импульс; иначе — боковик с попыткой восстановления + if dd90 is not None and dd90 <= -10: + return "BEAR" + if ret7 is not None and ret7 < 0: + return "BEAR" + return "SIDEWAYS" + # выше SMA200 + strong = (breadth is None or breadth >= 0.7) and (fng is None or fng >= 60) + if above50 is not None and above50 > 0 and strong: + return "STRONG_BULL" + if breadth is not None and breadth >= 0.5: + return "BULL" + if vol is not None and vol >= 80: + return "VOLATILE" + return "SIDEWAYS" + + +def risk_level(regime: str) -> str: + return RISK_BY_REGIME.get(regime, "UNKNOWN") + + +def strategy_family(regime: str) -> str: + return STRATEGY_BY_REGIME.get(regime, "—") + + +def next_regime_triggers(i: dict[str, float | None], regime: str) -> list[str]: + """Конкретные условия смены режима (для отчёта и AI-аналитика).""" + + def num(key: str) -> float | None: + v = i.get(key) + return float(v) if v is not None else None + + dd90 = num("dd90_pct") + fng = num("fear_greed") + above200 = num("btc_above_sma200") + above50 = num("btc_above_sma50") + breadth = num("breadth_7d") + vol = num("vol30_annualized_pct") + out: list[str] = [] + if above200 is not None: + out.append(f"BTC закроется {'выше' if above200 <= 0 else 'ниже'} SMA200 " + f"(сейчас {'ниже' if above200 <= 0 else 'выше'})") + if regime in CRASH_PANIC: + if fng is not None: + out.append(f"Fear&Greed выйдет из зоны паники (>25; сейчас {fng:.0f})") + if dd90 is not None: + out.append(f"просадка от максимума сократится до >-20% (сейчас {dd90:.1f}%)") + else: + if dd90 is not None and dd90 <= -15: + out.append(f"углубление просадки <-20% переведёт в CRASH (сейчас {dd90:.1f}%)") + if breadth is not None and breadth < 0.35: + out.append(f"breadth восстановится >0.5 (сейчас {breadth:.2f})") + if vol is not None and vol >= 70: + out.append(f"волатильность снизится <70% (сейчас {vol:.0f}%)") + if above50 is not None and above200 is not None: + if above50 > 0 and above200 <= 0: + out.append("BTC выше SMA50, но ниже SMA200 — пробой SMA200 подтвердит бычий режим") + if above50 <= 0 and above200 > 0: + out.append("BTC ниже SMA50 при цене выше SMA200 — потеря SMA50 ослабит бычий режим") + return out[:5] + + +def regime_payload(i: dict[str, float | None], regime: str | None = None) -> dict[str, Any]: + regime = regime or classify_regime(i) + return { + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "regime": regime, + "risk_level": risk_level(regime), + "strategy_family": strategy_family(regime), + "indicators": i, + "triggers": next_regime_triggers(i, regime), + } + + +def write_latest(payload: dict[str, Any], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(__import__("json").dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + + +def append_history(payload: dict[str, Any], path: Path) -> None: + import json + + path.parent.mkdir(parents=True, exist_ok=True) + row = { + "date": payload["generated_at"][:10], + "regime": payload["regime"], + "risk_level": payload["risk_level"], + "indicators": payload["indicators"], + } + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(row, ensure_ascii=False) + "\n") diff --git a/aios_core/quant/regime_guard.py b/aios_core/quant/regime_guard.py new file mode 100644 index 000000000..89a662c21 --- /dev/null +++ b/aios_core/quant/regime_guard.py @@ -0,0 +1,27 @@ +"""Режимный kill-guard для бумажных входов Directional v2. + +В режимах CRASH/PANIC входы блокируются (сохранение капитала), в остальных — +разрешены. Читает data/reports/market_regime_latest.json (пишется ежедневно +scripts/quant_regime_engine.py). Fail-open: при отсутствии/повреждении файла +guard НЕ блокирует (текущее поведение сохраняется). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +BLOCK_REGIMES = {"CRASH", "PANIC"} + + +def current_regime(path: str) -> str | None: + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + value = str(data.get("regime") or "").strip().upper() + return value or None + except (OSError, ValueError, TypeError): + return None + + +def crash_kill_active(path: str) -> bool: + return current_regime(path) in BLOCK_REGIMES diff --git a/aios_core/quant_directional_policy.py b/aios_core/quant_directional_policy.py index 07658cb18..a84416450 100644 --- a/aios_core/quant_directional_policy.py +++ b/aios_core/quant_directional_policy.py @@ -7,7 +7,7 @@ from typing import Any from aios_core.quant.ml_gate_calibration import calibrated_ml_threshold - +from aios_core.quant.regime_guard import crash_kill_active def _env_bool(name: str, default: bool) -> bool: value = os.environ.get(name) @@ -41,6 +41,7 @@ class DirectionalV2Config: ml_calibrate: bool = False ml_calibrate_file: str = "data/quant/ml_prob_calibration.json" ml_calibrate_floor: float = 0.50 + regime_guard: bool = False; regime_file: str = "data/reports/market_regime_latest.json" @classmethod def from_env(cls) -> DirectionalV2Config: @@ -71,6 +72,8 @@ def from_env(cls) -> DirectionalV2Config: ml_calibrate=_env_bool("AIOS_QUANT_ML_CALIBRATE", False), ml_calibrate_file=os.environ.get("AIOS_QUANT_ML_CALIBRATE_FILE", "data/quant/ml_prob_calibration.json"), ml_calibrate_floor=min(1.0, max(0.0, float(os.environ.get("AIOS_QUANT_ML_CALIBRATE_FLOOR", "0.50")))), + regime_guard=_env_bool("AIOS_QUANT_REGIME_GUARD", False), + regime_file=os.environ.get("AIOS_QUANT_REGIME_FILE", "data/reports/market_regime_latest.json"), ) def entry_execution_price(self, mid_price: float) -> float: @@ -109,7 +112,6 @@ def portfolio_equity( total_equity += equity return total_initial, total_equity, unpriced - def entry_block_reason( config: DirectionalV2Config, analysis: dict[str, Any], @@ -126,6 +128,8 @@ def entry_block_reason( if config.entry_mode != "enabled": return "entry_mode_freeze" + if config.regime_guard and crash_kill_active(config.regime_file): + return "regime_crash_kill" if not candle_is_new: return "same_candle" if config.allowed_exchanges and exchange.lower() not in config.allowed_exchanges: diff --git a/coordination/PROJECT_CONTEXT.md b/coordination/PROJECT_CONTEXT.md index d7f439066..0d69d14ea 100644 --- a/coordination/PROJECT_CONTEXT.md +++ b/coordination/PROJECT_CONTEXT.md @@ -10,6 +10,22 @@ ## Где закончили +**2026-08-19 (Arena.ai, режимный движок по рекомендациям):** (1) исправлена +freqtrade-ловушка stoploss=-0.99 (−99%! стоп=1% от входа) → страховочный −15%, +бот пересчитал открытые сделки, регрессия-тест; (2) Market Regime Engine: +7 режимов из совокупности индикаторов + риск-уровни + роутер стратегий + +триггеры; ежедневный таймер 04:50 UTC, история в +market_regime_history.jsonl; сейчас BEAR (dd90 −16.3%); (3) CRASH/PANIC +kill-guard в политике Directional v2 (включён в обоих демонах, fail-open); +(4) T2-метрики симуляции (PF/Sharpe/Sortino/Calmar/expectancy) с пометкой +«не заработок»; (5) отчёт получил блок «Режим рынка и защита», LLM — +вероятностные сценарии + раздел «насколько доверять»; (6) seam +pre_treasury_intents.py вернул accounts.py в бюджет. Коммит `3f687afe`, +ветка `agent/20260819-regime-engine`. +Журнал: `coordination/sessions/20260819T020000Z-aios-arena-regime-engine.md`. + +## Где закончили + **2026-08-19 (Arena.ai, инвесторский отчёт + одна кнопка):** по решению владельца — ежедневный утренний трейдинг-отчёт чату 839699134 (таймер aios-tg-trading-report, 05:30 UTC; sender --chat); бот упрощён до одной diff --git a/coordination/sessions/20260819T020000Z-aios-arena-regime-engine.md b/coordination/sessions/20260819T020000Z-aios-arena-regime-engine.md new file mode 100644 index 000000000..1e63482b0 --- /dev/null +++ b/coordination/sessions/20260819T020000Z-aios-arena-regime-engine.md @@ -0,0 +1,73 @@ +--- +session_id: "20260819T020000Z-aios-arena-regime-engine" +status: "DONE" +agent: "Arena.ai Agent Mode" +machine: "aios" +started_utc: "2026-08-19T02:00:00Z" +updated_utc: "2026-08-19T02:40:00Z" +branch: "agent/20260819-regime-engine" +base_commit: "f9cc9a1a" +claim: "coordination/claims/regime-engine--20260819T020000Z-aios-arena.md (снят при завершении)" +--- + +## Цель + +Применить полезные рекомендации внешних ИИ (недвижимость исключена владельцем): +режимный движок + risk-guard + роутер стратегий + честные T2-метрики + +проверка стопов freqtrade + структура AI-аналитика. + +## Итог + +1. **Freqtrade stoploss-ловушка исправлена:** стратегия имела stoploss = -0.99 + (freqtrade читает как −99% → стоп = 1% от цены входа: BTC 645.41 при входе + 64540.94). Причина — намерение «без жёсткого стопа» (SMA-выход как защита), + реализованное опасным значением. Заменено на страховочный −15%; бот + пересчитал открытые сделки (BTC stop 54859.8, SOL 65.53). Тест-регрессия + запрещает значения вне (−0.5, 0). +2. **Market Regime Engine:** aios_core/quant/market_regime.py — 7 режимов + (STRONG_BULL/BULL/SIDEWAYS/VOLATILE/BEAR/CRASH/PANIC) из совокупности + индикаторов (SMA200/50, ret7d, dd90, vol30, breadth, F&G); риск-уровни; + роутер семейств стратегий; триггеры смены режима. scripts/quant_regime_engine.py + — ежедневный сбор из локальных данных → market_regime_latest.json + история + jsonl. Таймер 04:50 UTC. Текущий режим: BEAR (risk HIGH, defensive; dd90 −16.3%). +3. **Kill-guard политики:** regime_guard в DirectionalV2Config (env + AIOS_QUANT_REGIME_GUARD); в CRASH/PANIC входы блокируются + (regime_crash_kill), fail-open при отсутствии файла. Включён в обоих + paper-демонах (unit env). Тесты: 5 случаев. +4. **T2-метрики:** scripts/quant_t2_metrics.py — PF/win-rate/expectancy по + сделкам + Sharpe/Sortino/MaxDD/CAGR/Calmar по дневной истории, с честной + пометкой «симуляция, не заработок». Отчёт data/reports/t2_simulation_metrics.md. +5. **AI-аналитик:** отчёт получил блок «🎛 Режим рынка и защита» (режим, риск, + семейство стратегий, триггеры, CRASH/PANIC-блокировка); LLM-промпт требует + вероятностные сценарии (без «точно»), раздел «Насколько можно доверять» + (уверенность + качество данных). +6. **Бюджет модулей:** tg_bot/accounts.py вынесен seam pre_treasury_intents.py + (перехват «Трейдинг» + «фриланс» до treasury) — accounts.py 3222/3225, + policy 170/170. + +## Изменённые файлы + +- новые: aios_core/quant/{market_regime,regime_guard}.py, + scripts/{quant_regime_engine,quant_t2_metrics}.py, + tg_bot/pre_treasury_intents.py, deploy/systemd/aios-regime-engine.{service,timer}, + tests/{test_market_regime,test_regime_guard_policy,test_t2_metrics,test_freqtrade_stoploss}.py. +- правки: scripts/freqtrade_t2.py, aios_core/quant_directional_policy.py, + tg_bot/{trading_report,accounts}.py, deploy/systemd/aios-quant-trading*.service, + HETZNER_INSTALLED_UNITS.txt (176), tests/test_systemd_inventory.py, + tests/test_trading_button_path.py, docs/PROJECT_INVENTORY.md. + +## Проверки + +- [PASS] тесты: regime 13/13, guard-policy 5/5, t2_metrics 5/5, stoploss 2/2, + trading_report 13/13, budget, inventory. +- [PASS] живой прогон режимного движка; freqtrade пересчитал стопы. +- [PASS] полный pytest: единственный провал — stale inventory (перегенерирован). + +## Git + +- Коммит: 3f687afe (ветка agent/20260819-regime-engine). + +## Handoff + +- Следующий шаг: полный pytest → PR → мерж; наблюдение за режимом и guard. +- Риски: guard fail-open при отсутствии файла режима (задокументировано). diff --git a/deploy/systemd/HETZNER_INSTALLED_UNITS.txt b/deploy/systemd/HETZNER_INSTALLED_UNITS.txt index 4a0607e35..d64eed927 100644 --- a/deploy/systemd/HETZNER_INSTALLED_UNITS.txt +++ b/deploy/systemd/HETZNER_INSTALLED_UNITS.txt @@ -1,4 +1,4 @@ -# Installed aios-* unit-file names, read-only snapshot 2026-08-19 UTC (tg-trading-report added). +# Installed aios-* unit-file names, read-only snapshot 2026-08-19 UTC (regime-engine added). aios-2captcha-balance.service aios-2captcha-balance.timer aios-accounting-report.service @@ -137,6 +137,8 @@ aios-quant-ml-inference.service aios-quant-trading.service aios-rag-refresh.service aios-rag-refresh.timer +aios-regime-engine.service +aios-regime-engine.timer aios-report-export.service aios-report-export.timer aios-rotate-backups.service diff --git a/deploy/systemd/aios-quant-trading-control.service b/deploy/systemd/aios-quant-trading-control.service index 36e85dae1..e11cddfda 100644 --- a/deploy/systemd/aios-quant-trading-control.service +++ b/deploy/systemd/aios-quant-trading-control.service @@ -20,6 +20,7 @@ Environment=AIOS_QUANT_MIN_CONFIDENCE=0.88 Environment=AIOS_QUANT_REQUIRE_ML=1 Environment=AIOS_QUANT_ML_MIN_PROB=0.65 Environment=AIOS_QUANT_ML_CALIBRATE=1 +Environment=AIOS_QUANT_REGIME_GUARD=1 Environment=AIOS_QUANT_RL_VETO=0.30 Environment=AIOS_QUANT_MIN_HOLD_SECONDS=7200 Environment=AIOS_QUANT_HALF_SPREAD_RATE=0.0005 diff --git a/deploy/systemd/aios-quant-trading.service b/deploy/systemd/aios-quant-trading.service index 09594b834..f87bfe1c2 100644 --- a/deploy/systemd/aios-quant-trading.service +++ b/deploy/systemd/aios-quant-trading.service @@ -20,6 +20,7 @@ Environment=AIOS_QUANT_MIN_CONFIDENCE=0.88 Environment=AIOS_QUANT_REQUIRE_ML=1 Environment=AIOS_QUANT_ML_MIN_PROB=0.65 Environment=AIOS_QUANT_ML_CALIBRATE=1 +Environment=AIOS_QUANT_REGIME_GUARD=1 Environment=AIOS_QUANT_RL_VETO=0.30 Environment=AIOS_QUANT_MIN_HOLD_SECONDS=7200 Environment=AIOS_QUANT_HALF_SPREAD_RATE=0.0005 diff --git a/deploy/systemd/aios-regime-engine.service b/deploy/systemd/aios-regime-engine.service new file mode 100644 index 000000000..1dc10ef1d --- /dev/null +++ b/deploy/systemd/aios-regime-engine.service @@ -0,0 +1,9 @@ +[Unit] +Description=AIOS Market Regime Engine (daily indicators -> regime + history) + +[Service] +Type=oneshot +User=root +WorkingDirectory=/root/AIOS +Environment=PYTHONPATH=/root/AIOS +ExecStart=/opt/aios/.venv/bin/python -u /root/AIOS/scripts/quant_regime_engine.py diff --git a/deploy/systemd/aios-regime-engine.timer b/deploy/systemd/aios-regime-engine.timer new file mode 100644 index 000000000..8a546ed42 --- /dev/null +++ b/deploy/systemd/aios-regime-engine.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily regime engine (04:50 UTC, before morning report) + +[Timer] +OnCalendar=*-*-* 04:50:00 +RandomizedDelaySec=180 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docs/PROJECT_INVENTORY.md b/docs/PROJECT_INVENTORY.md index 0ed8bffeb..3a79d7bc3 100644 --- a/docs/PROJECT_INVENTORY.md +++ b/docs/PROJECT_INVENTORY.md @@ -8,32 +8,32 @@ | Метрика | Значение | |---|---:| | Package version | `19.9.0` | -| Стабильных tracked-файлов | 6,372 | -| Строк | 612,929 | -| Размер | 25.00 MiB | -| Python-файлов | 3,526 | -| Строк Python | 362,635 | -| Классов / функций / async | 2,989 / 20,016 / 1,636 | +| Стабильных tracked-файлов | 6,386 | +| Строк | 613,832 | +| Размер | 25.04 MiB | +| Python-файлов | 3,536 | +| Строк Python | 363,494 | +| Классов / функций / async | 2,990 / 20,077 / 1,636 | | Python syntax errors | 0 | -| Test Python files / test functions | 973 / 6,732 | +| Test Python files / test functions | 978 / 6,764 | | Markdown-файлов | 2,046 | | Root `run_*.py` | 113 | -| Уникальных tracked service/timer names | 210 | +| Уникальных tracked service/timer names | 214 | ## Крупнейшие области | Область | Файлов | Строк | Размер | |---|---:|---:|---:| -| `aios_core` | 971 | 158,144 | 5.92 MiB | +| `aios_core` | 973 | 158,339 | 5.93 MiB | | `skills` | 2,641 | 109,481 | 4.73 MiB | | `docs` | 477 | 76,067 | 3.27 MiB | -| `tests` | 555 | 71,418 | 2.48 MiB | -| `[root]` | 217 | 35,241 | 1.39 MiB | -| `scripts` | 281 | 33,845 | 1.28 MiB | +| `tests` | 560 | 71,763 | 2.49 MiB | +| `[root]` | 217 | 35,245 | 1.39 MiB | +| `scripts` | 283 | 34,115 | 1.29 MiB | | `attic` | 33 | 30,669 | 1.61 MiB | | `octopus_services` | 110 | 27,850 | 0.90 MiB | -| `tg_bot` | 34 | 12,945 | 0.66 MiB | -| `deploy` | 252 | 6,385 | 0.18 MiB | +| `tg_bot` | 35 | 12,990 | 0.66 MiB | +| `deploy` | 256 | 6,429 | 0.18 MiB | | `octopus_instructions` | 102 | 5,959 | 0.50 MiB | | `octopus_roadmap` | 13 | 4,741 | 0.22 MiB | | `tools` | 46 | 4,441 | 0.15 MiB | @@ -49,11 +49,11 @@ | Расширение | Файлов | |---|---:| -| `.py` | 3,526 | +| `.py` | 3,536 | | `.md` | 2,046 | | `.json` | 147 | -| `.service` | 135 | -| `.timer` | 81 | +| `.service` | 137 | +| `.timer` | 83 | | `.sh` | 68 | | `.tsx` | 65 | | `.yaml` | 59 | @@ -83,7 +83,7 @@ | Файл | Строк | |---|---:| | `aios_core/dashboard.py` | 3,494 | -| `tg_bot/accounts.py` | 3,236 | +| `tg_bot/accounts.py` | 3,222 | | `run_account_control.py` | 2,374 | | `aios_core/quant_trading_engine.py` | 1,776 | | `tests/test_v10_4_modules.py` | 1,676 | @@ -92,7 +92,7 @@ | `aios_core/agent_memory_system.py` | 1,574 | | `run_coder_orchestrator.py` | 1,466 | | `tests/test_v10_12_modules.py` | 1,445 | -| `run_telegram_bot.py` | 1,436 | +| `run_telegram_bot.py` | 1,440 | | `tg_bot/phone.py` | 1,308 | | `tests/test_v10_15_behavioral.py` | 1,274 | | `tests/test_phase4_test_engine.py` | 1,258 | diff --git a/scripts/freqtrade_t2.py b/scripts/freqtrade_t2.py index 157dee160..2c768be20 100644 --- a/scripts/freqtrade_t2.py +++ b/scripts/freqtrade_t2.py @@ -50,7 +50,11 @@ class T2Momentum(IStrategy): out_w = IntParameter(20, 90, default=40, space="sell") # ---- risk ---- - stoploss = -0.99 # no hard stoploss: SMA exit is the protection + # Страховочный жёсткий стоп −15% (ревизия 2026-08-19): основной выход — + # SMA-пересечение, а это аварийная защита капитала. Ранее стоял −0.99 + # (интерпретируется freqtrade как −99% → стоп-цена = 1% от входа — + # недостижимый и вводящий в заблуждение). + stoploss = -0.15 trailing_stop = False use_custom_stoploss = False diff --git a/scripts/quant_regime_engine.py b/scripts/quant_regime_engine.py new file mode 100644 index 000000000..8eeb18c24 --- /dev/null +++ b/scripts/quant_regime_engine.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Ежедневный режимный движок: индикаторы → режим → latest + история. + +Источники (только локальные данные, без сети): +- 1h-история BTC/ETH и вселенной (33 актива) из data/quant//*/*_1h.csv; +- Fear&Greed из data/quant/market_context_latest.json (если есть). + +Пишет data/reports/market_regime_latest.json (для политики и отчётов) +и дописывает data/reports/market_regime_history.jsonl. + +Usage: python scripts/quant_regime_engine.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) +QUANT_DIR = REPO_ROOT / "data" / "quant" + +from aios_core.quant.market_regime import ( # noqa: E402 + append_history, + classify_regime, + regime_payload, + write_latest, +) + +LATEST = REPO_ROOT / "data" / "reports" / "market_regime_latest.json" +HISTORY = REPO_ROOT / "data" / "reports" / "market_regime_history.jsonl" + + +def _daily(symbol: str) -> pd.Series | None: + """UTC-дневные закрытия из самой длинной свежей 1h-серии.""" + + import time + + cutoff = time.time() * 1000 - 7 * 86_400_000 + best, best_n = None, -1 + for cand in sorted(QUANT_DIR.glob(f"{symbol}/*/{symbol}_1h.csv")): + try: + df = pd.read_csv(cand, usecols=["timestamp_ms", "close"]) + if int(df["timestamp_ms"].max()) < cutoff: + continue + if len(df) > best_n: + best, best_n = cand, len(df) + except Exception: + continue + if best is None: + return None + df = pd.read_csv(best) + df["ts"] = pd.to_datetime(df["timestamp_ms"], unit="ms") + return df.groupby(df["ts"].dt.date)["close"].last() + + +def breadth(daily: dict[str, pd.Series], days: int = 7) -> float | None: + rets = [] + for sym, s in daily.items(): + if sym == "BTC" or len(s) < days + 1: + continue + rets.append(float(s.iloc[-1] / s.iloc[-1 - days] - 1.0) > 0) + return float(np.mean(rets)) if rets else None + + +def main() -> int: + universe = sorted(p.parent.parent.name + for p in QUANT_DIR.glob("*/binance/*_1h.csv")) + daily = {} + for sym in universe: + s = _daily(sym) + if s is not None and len(s) > 30: + daily[sym] = s + + i: dict[str, float | None] = {} + btc = daily.get("BTC") + eth = daily.get("ETH") + if btc is not None and len(btc) >= 200: + close = btc.values + i["btc_above_sma200"] = 1.0 if close[-1] > close[-200:].mean() else 0.0 + i["btc_above_sma50"] = 1.0 if close[-1] > close[-50:].mean() else 0.0 + i["btc_ret_7d_pct"] = round(float(close[-1] / close[-8] - 1.0) * 100, 2) if len(close) > 8 else None + i["dd90_pct"] = round(float(close[-1] / close[-90:].max() - 1.0) * 100, 2) if len(close) >= 90 else None + rets = np.diff(np.log(close[-31:])) + i["vol30_annualized_pct"] = round(float(rets.std() * np.sqrt(365)) * 100, 1) if len(rets) > 5 else None + if eth is not None and len(eth) >= 8: + i["eth_btc_7d"] = round(float((eth.iloc[-1] / eth.iloc[-8]) / (btc.iloc[-1] / btc.iloc[-8]) - 1.0) * 100, 2) if btc is not None and len(btc) >= 8 else None + i["breadth_7d"] = round(breadth(daily), 3) if len(daily) > 5 else None + try: + ctx = json.loads((QUANT_DIR / "market_context_latest.json").read_text(encoding="utf-8")) + fng = (ctx.get("fng") or {}).get("value") + i["fear_greed"] = float(fng) if fng is not None else None + except Exception: + i["fear_greed"] = None + + regime = classify_regime(i) + payload = regime_payload(i, regime) + write_latest(payload, LATEST) + append_history(payload, HISTORY) + print(f"regime={regime} risk={payload['risk_level']} strategy={payload['strategy_family']}") + print("indicators:", json.dumps(i, ensure_ascii=False)) + print(f"latest -> {LATEST} | history -> {HISTORY}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/quant_t2_metrics.py b/scripts/quant_t2_metrics.py new file mode 100644 index 000000000..6ceaf57fd --- /dev/null +++ b/scripts/quant_t2_metrics.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Честные метрики T2-симуляции (ревизия 2026-08-19, рекомендации). + +Считает по state-файлам ног и портфельной истории: profit factor, win rate, +expectancy (на сделку), Sharpe/Sortino/MaxDD/Calmar (по дневной портфельной +истории). ВАЖНО: это метрики ИСТОРИЧЕСКОЙ СИМУЛЯЦИИ с октября 2023 (не +заработок); живой daily-цикл стартовал 16-18.08 и покрыт отдельно. + +Usage: python scripts/quant_t2_metrics.py [--out data/reports/t2_simulation_metrics.md] +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path + +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +LEGS = (("BTC", "t2_paper_state_btcusd.json"), ("ETH", "t2_paper_state_ethusd.json"), + ("SOL", "t2_paper_state_solusd.json"), ("BNB", "t2_paper_state_bnbusd.json"), + ("NEAR", "t2_paper_state_nearusd.json")) +PORT = REPO_ROOT / "data" / "t2_portfolio_equity.jsonl" + + +def trade_metrics(trades: list[dict], final_equity: float) -> dict | None: + """Метрики по последовательности сделок (equity в каждом trade).""" + + if not trades: + return None + eq = [float(t.get("equity", 0.0) or 0.0) for t in trades] + eq.append(final_equity) + pnls = [b - a for a, b in zip(eq, eq[1:])] + wins = [p for p in pnls if p > 0] + losses = [-p for p in pnls if p < 0] + pf = (sum(wins) / sum(losses)) if losses and sum(losses) > 0 else float("inf") + return { + "n_trades": len(pnls), + "win_rate_pct": round(100 * len(wins) / len(pnls), 1), + "profit_factor": round(pf, 2) if math.isfinite(pf) else None, + "expectancy_usd": round(float(np.mean(pnls)), 2), + "total_from_10k_pct": round((final_equity / 10000 - 1) * 100, 1), + } + + +def portfolio_metrics(rows: list[dict]) -> dict | None: + """Sharpe/Sortino/MaxDD/Calmar по дневной портфельной истории.""" + + eq = np.array([float(r.get("portfolio", 0.0) or 0.0) for r in rows]) + if len(eq) < 10: + return None + rets = np.diff(eq) / eq[:-1] + mean, std = float(rets.mean()), float(rets.std()) + sharpe = mean / std * math.sqrt(365) if std > 0 else 0.0 + downside = rets[rets < 0] + sortino = mean / float(downside.std()) * math.sqrt(365) if len(downside) > 1 and float(downside.std()) > 0 else float("nan") + dd = float((eq / np.maximum.accumulate(eq) - 1).min()) * 100 + days = len(rows) + total = float(eq[-1] / eq[0] - 1) * 100 + cagr = ((eq[-1] / eq[0]) ** (365.25 / days) - 1) * 100 if eq[0] > 0 else 0.0 + calmar = cagr / abs(dd) if dd < 0 else float("nan") + return { + "sharpe": round(sharpe, 2), + "sortino": round(sortino, 2) if sortino == sortino else None, + "max_dd_pct": round(dd, 1), + "cagr_pct": round(cagr, 1), + "calmar": round(calmar, 2) if calmar == calmar else None, + "total_pct": round(total, 1), + "n_days": days, + } + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, default=REPO_ROOT / "data" / "reports" / "t2_simulation_metrics.md") + args = ap.parse_args() + + lines = [ + "# Метрики T2-симуляции (честная оценка)", + "", + "⚠️ Это метрики ИСТОРИЧЕСКОЙ СИМУЛЯЦИИ с октября 2023 (виртуальные $10,000),", + "а не заработок. Живой daily-цикл стартовал 16-18.08.2026; свежая OOS-проверка", + "правила отрицательна (SMA50-BTC: −16.7% на последних 30% окна, последний месяц", + "−4.1%). Высокие значения = подгонка под прошлое + survivorship + идеальное", + "исполнение.", + "", + "| Нога | Сделок | Win% | PF | Expectancy $ | Итог от $10k |", + "|---|---:|---:|---:|---:|---:|", + ] + for tag, fname in LEGS: + try: + st = json.loads((REPO_ROOT / "data" / fname).read_text(encoding="utf-8")) + except Exception: + continue + m = trade_metrics(st.get("trades") or [], float(st.get("equity", 0.0) or 0.0)) + if m: + pf = f"{m['profit_factor']:.2f}" if m["profit_factor"] is not None else "∞" + lines.append(f"| {tag} | {m['n_trades']} | {m['win_rate_pct']} | {pf} " + f"| {m['expectancy_usd']:+.2f} | {m['total_from_10k_pct']:+.1f}% |") + rows = [] + if PORT.exists(): + rows = [json.loads(l) for l in PORT.read_text(encoding="utf-8").splitlines() if l] + pm = portfolio_metrics(rows) + if pm: + lines += ["", "| Портфель (5 ног) | Sharpe | Sortino | MaxDD | CAGR | Calmar | Итог |", + "|---|---:|---:|---:|---:|---:|---:|", + f"| значение | {pm['sharpe']} | {pm['sortino']} | {pm['max_dd_pct']}% | " + f"{pm['cagr_pct']}% | {pm['calmar']} | {pm['total_pct']}% |"] + lines += ["", "Вывод: метрики описывают СИМУЛЯЦИЮ и не являются доказательством ", + "прибыльности. Решение о доверии правилу — только по живому daily-циклу ", + "и честному OOS."] + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text("\n".join(lines), encoding="utf-8") + print(f"report -> {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_freqtrade_stoploss.py b/tests/test_freqtrade_stoploss.py new file mode 100644 index 000000000..24aca3bae --- /dev/null +++ b/tests/test_freqtrade_stoploss.py @@ -0,0 +1,32 @@ +"""Regression: hard stoploss fraction must be a sane protective level (<50%).""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def _stoploss_value(path: Path) -> float: + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for t in node.targets: + if isinstance(t, ast.Name) and t.id == "stoploss": + val = ast.literal_eval(node.value) + return float(val) + raise AssertionError(f"stoploss не найден в {path}") + + +def test_t2_stoploss_is_sane_protective_level(): + v = _stoploss_value(Path("scripts/freqtrade_t2.py")) + assert -0.5 < v < 0, v + assert abs(v) >= 0.01, "стоп слишком близко к цене (шумовые выбивания)" + + +def test_no_99pct_stop_anywhere_in_strategy_files(): + for name in ("scripts/freqtrade_t2.py", "scripts/freqtrade_t2_hyper.py"): + src = Path(name).read_text(encoding="utf-8") + assert "stoploss = -0.99" not in src, f"{name}: ловушка −99% осталась" diff --git a/tests/test_market_regime.py b/tests/test_market_regime.py new file mode 100644 index 000000000..34ffb2c78 --- /dev/null +++ b/tests/test_market_regime.py @@ -0,0 +1,104 @@ +"""Tests: режимный движок и kill-guard политики.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from aios_core.quant.market_regime import ( # noqa: E402 + classify_regime, + next_regime_triggers, + regime_payload, + risk_level, + strategy_family, +) +from aios_core.quant.regime_guard import crash_kill_active, current_regime # noqa: E402 + + +def _i(**kw): + base = {"btc_above_sma200": None, "btc_above_sma50": None, "btc_ret_7d_pct": None, + "dd90_pct": None, "vol30_annualized_pct": None, "breadth_7d": None, + "fear_greed": None, "eth_btc_7d": None} + base.update(kw) + return base + + +def test_panic_on_deep_drawdown(): + assert classify_regime(_i(dd90_pct=-36)) == "PANIC" + + +def test_panic_on_extreme_fear(): + assert classify_regime(_i(fear_greed=10)) == "PANIC" + + +def test_crash_on_moderate_drawdown_or_crash_ret(): + assert classify_regime(_i(dd90_pct=-22)) == "CRASH" + assert classify_regime(_i(btc_ret_7d_pct=-18)) == "CRASH" + + +def test_bear_below_sma200_with_drawdown_or_negative_ret(): + assert classify_regime(_i(btc_above_sma200=0, dd90_pct=-12)) == "BEAR" + assert classify_regime(_i(btc_above_sma200=0, btc_ret_7d_pct=-3)) == "BEAR" + + +def test_sideways_below_sma200_but_recovering(): + assert classify_regime(_i(btc_above_sma200=0, btc_above_sma50=1, + btc_ret_7d_pct=2, dd90_pct=-9)) == "SIDEWAYS" + + +def test_strong_bull_and_bull(): + assert classify_regime(_i(btc_above_sma200=1, btc_above_sma50=1, + breadth_7d=0.8, fear_greed=70)) == "STRONG_BULL" + assert classify_regime(_i(btc_above_sma200=1, btc_above_sma50=1, + breadth_7d=0.55)) == "BULL" + + +def test_volatile(): + assert classify_regime(_i(btc_above_sma200=1, btc_above_sma50=1, + breadth_7d=0.3, vol30_annualized_pct=95)) == "VOLATILE" + + +def test_sideways_default(): + assert classify_regime(_i(btc_above_sma200=1, btc_above_sma50=1, + breadth_7d=0.4, vol30_annualized_pct=30)) == "SIDEWAYS" + + +def test_risk_and_strategy_mapping(): + assert risk_level("PANIC") == "EXTREME" + assert risk_level("BEAR") == "HIGH" + assert strategy_family("CRASH") == "сохранение капитала (кэш)" + assert "momentum" in strategy_family("STRONG_BULL") + + +def test_triggers_for_bear_mention_sma200_and_crash_thresholds(): + i = _i(btc_above_sma200=0, btc_above_sma50=1, dd90_pct=-16, breadth_7d=0.5, + vol30_annualized_pct=22, fear_greed=46) + t = next_regime_triggers(i, "BEAR") + joined = " ".join(t) + assert "SMA200" in joined + assert "-20%" in joined + + +def test_payload_shape(): + p = regime_payload(_i(btc_above_sma200=0, dd90_pct=-12)) + assert p["regime"] == "BEAR" + assert p["risk_level"] == "HIGH" + assert "indicators" in p and "triggers" in p + + +def test_guard_blocks_in_crash(tmp_path): + f = tmp_path / "regime.json" + f.write_text(json.dumps({"regime": "CRASH"})) + assert current_regime(str(f)) == "CRASH" + assert crash_kill_active(str(f)) is True + + +def test_guard_allows_in_bear_and_missing_file(tmp_path): + f = tmp_path / "regime.json" + f.write_text(json.dumps({"regime": "BEAR"})) + assert crash_kill_active(str(f)) is False + assert crash_kill_active(str(tmp_path / "nope.json")) is False + assert current_regime(str(tmp_path / "nope.json")) is None diff --git a/tests/test_regime_guard_policy.py b/tests/test_regime_guard_policy.py new file mode 100644 index 000000000..06425ccc3 --- /dev/null +++ b/tests/test_regime_guard_policy.py @@ -0,0 +1,67 @@ +"""Tests: режимный guard в политике входов.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from aios_core.quant_directional_policy import ( # noqa: E402 + DirectionalV2Config, + entry_block_reason, +) + + +def _kwargs(): + return { + "exchange": "kucoin", + "global_positions": 0, + "exchange_positions": 0, + "drawdown_pct": 0.0, + "daily_loss_pct": 0.0, + "candle_is_new": True, + } + + +def _analysis(): + return {"signal": "BUY_LONG", "confidence": 0.90, "ml_prob_up": 0.70, "rl_position": 0.60} + + +def test_regime_guard_blocks_entries_in_crash(tmp_path): + regime_file = tmp_path / "regime.json" + regime_file.write_text(json.dumps({"regime": "CRASH"})) + config = DirectionalV2Config(entry_mode="enabled", regime_guard=True, + regime_file=str(regime_file)) + assert entry_block_reason(config, _analysis(), **_kwargs()) == "regime_crash_kill" + + +def test_regime_guard_allows_in_bear(tmp_path): + regime_file = tmp_path / "regime.json" + regime_file.write_text(json.dumps({"regime": "BEAR"})) + config = DirectionalV2Config(entry_mode="enabled", regime_guard=True, + regime_file=str(regime_file)) + assert entry_block_reason(config, _analysis(), **_kwargs()) is None + + +def test_regime_guard_disabled_by_default(tmp_path): + regime_file = tmp_path / "regime.json" + regime_file.write_text(json.dumps({"regime": "CRASH"})) + config = DirectionalV2Config(entry_mode="enabled", regime_guard=False, + regime_file=str(regime_file)) + assert entry_block_reason(config, _analysis(), **_kwargs()) is None + + +def test_regime_guard_fail_open_on_missing_file(tmp_path): + config = DirectionalV2Config(entry_mode="enabled", regime_guard=True, + regime_file=str(tmp_path / "nope.json")) + assert entry_block_reason(config, _analysis(), **_kwargs()) is None + + +def test_regime_guard_checked_after_freeze(tmp_path): + regime_file = tmp_path / "regime.json" + regime_file.write_text(json.dumps({"regime": "CRASH"})) + config = DirectionalV2Config(entry_mode="freeze", regime_guard=True, + regime_file=str(regime_file)) + assert entry_block_reason(config, _analysis(), **_kwargs()) == "entry_mode_freeze" diff --git a/tests/test_systemd_inventory.py b/tests/test_systemd_inventory.py index cfb098626..46a17cdd7 100644 --- a/tests/test_systemd_inventory.py +++ b/tests/test_systemd_inventory.py @@ -23,7 +23,7 @@ def test_hetzner_installed_units_are_represented() -> None: masks = _manifest_names("HETZNER_MASKED_UNITS.txt") inventory = repository_unit_inventory(ROOT) - assert len(installed) == 174 + assert len(installed) == 176 assert masks == { "aios-auto-coder.service", "aios-auto-promote.service", diff --git a/tests/test_t2_metrics.py b/tests/test_t2_metrics.py new file mode 100644 index 000000000..c5fc67fdf --- /dev/null +++ b/tests/test_t2_metrics.py @@ -0,0 +1,44 @@ +"""Tests for T2 simulation metrics.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts.quant_t2_metrics import portfolio_metrics, trade_metrics # noqa: E402 + + +def test_trade_metrics_known_sequence(): + trades = [{"equity": 10000}, {"equity": 10100}, {"equity": 10050}, {"equity": 10200}] + m = trade_metrics(trades, final_equity=10200) + assert m["n_trades"] == 4 + assert m["win_rate_pct"] == 50.0 + assert m["expectancy_usd"] == 50.0 + assert m["total_from_10k_pct"] == 2.0 + # PF: wins 100+150=250, losses 50 -> 5.0 + assert m["profit_factor"] == 5.0 + + +def test_trade_metrics_no_losses_infinite_pf(): + trades = [{"equity": 10000}, {"equity": 10100}] + m = trade_metrics(trades, final_equity=10100) + assert m["profit_factor"] is None # ∞ не выразимо + + +def test_trade_metrics_empty(): + assert trade_metrics([], 10000) is None + + +def test_portfolio_metrics_known_growth(): + rows = [{"portfolio": 10000 + i * 100} for i in range(40)] + pm = portfolio_metrics(rows) + assert pm["total_pct"] > 0 + assert pm["max_dd_pct"] == 0.0 + assert pm["sharpe"] > 0 + assert pm["n_days"] == 40 + + +def test_portfolio_metrics_short_history(): + assert portfolio_metrics([{"portfolio": 1}] * 3) is None diff --git a/tests/test_trading_button_path.py b/tests/test_trading_button_path.py index df8aa4187..ea732ef81 100644 --- a/tests/test_trading_button_path.py +++ b/tests/test_trading_button_path.py @@ -37,11 +37,48 @@ def test_send_full_report_sends_data_and_llm_placeholder(): def test_accounts_routes_trading_text_before_treasury(): src = (Path(__file__).resolve().parents[1] / "tg_bot" / "accounts.py").read_text(encoding="utf-8") - # перехват стоит ДО treasury-интента - pos_trading = src.index('_t_norm in ("трейдинг"') + # перехват стоит ДО treasury-интента (логика — в seam-модуле) + pos_trading = src.index("pre_treasury_intents(api, chat_id, text)") pos_treasury = src.index("_handle_treasury_intent(api, chat_id, text)") assert pos_trading < pos_treasury - assert "send_full_report" in src + assert "from tg_bot.pre_treasury_intents import pre_treasury_intents" in src + + +def test_pre_treasury_seam_handles_trading_and_freelance(monkeypatch): + import sys + import types + + import tg_bot.pre_treasury_intents as seam + + calls = [] + + class FakeApi: + def send_message(self, chat_id, text, **kwargs): + calls.append(("msg", chat_id, text[:30])) + + api = FakeApi() + + # фриланс-путь: подменяем dashboard-модуль (импорт внутри seam) + fake_dash = types.ModuleType("tg_bot.dashboard") + fake_dash._handle_freelance_summary_intent = lambda a, cid, text: False + monkeypatch.setitem(sys.modules, "tg_bot.dashboard", fake_dash) + + import tg_bot.trading_report as tr + monkeypatch.setattr(tr, "is_trading_button_text", lambda t: bool(t) and "трейдинг" in t.casefold()) + monkeypatch.setattr(tr, "send_full_report", lambda api, cid: calls.append(("report", cid, None))) + assert seam.pre_treasury_intents(api, 1, "📈 Трейдинг") is True + assert any(c[0] == "report" for c in calls) + assert seam.pre_treasury_intents(api, 1, "сколько стоит аренда") is False + + +def test_trading_button_text_detector(): + from tg_bot.trading_report import is_trading_button_text + + assert is_trading_button_text("📈 Трейдинг") + assert is_trading_button_text("трейдинг") + assert is_trading_button_text("Трейдинг отчёт") + assert not is_trading_button_text("крипто заработок") + assert not is_trading_button_text(None) def test_callbacks_use_shared_helper(): diff --git a/tg_bot/accounts.py b/tg_bot/accounts.py index 4b471e50f..66a85a89c 100644 --- a/tg_bot/accounts.py +++ b/tg_bot/accounts.py @@ -651,23 +651,9 @@ def _handle_account_intent(api, chat_id: int, text: str) -> bool: api.send_message(chat_id, "❌ Неизвестный тип действия.") return True - # Фриланс-сводка (v22.7): «фриланс», «что по фрилансу» — до treasury, т.к. широкая фраза - try: - from tg_bot.dashboard import _handle_freelance_summary_intent as _hfs - if _hfs(api, chat_id, text): - return True - except Exception: - pass - - # Кнопка текстовой клавиатуры «📈 Трейдинг» — новый человеческий отчёт - # (до treasury-intent, у которого «трейдинг» числится ключевым словом). - _t_norm = " ".join(str(text or "").casefold().split()) - if _t_norm in ("трейдинг", "📈 трейдинг", "трейдинг отчёт", "трейдинг отчет"): - try: - from tg_bot.trading_report import send_full_report - send_full_report(api, chat_id) - except Exception as _e_tr: - api.send_message(chat_id, f"⚠️ Трейдинг-отчёт: {_e_tr}") + # Фриланс и «Трейдинг» — широкие фразы, перехват до treasury (seam-модуль) + from tg_bot.pre_treasury_intents import pre_treasury_intents + if pre_treasury_intents(api, chat_id, text): return True # Workflow readiness, jobs, inventory, metrics, bank monitor, recovery, reports and leads precede broad CRM words. diff --git a/tg_bot/pre_treasury_intents.py b/tg_bot/pre_treasury_intents.py new file mode 100644 index 000000000..e4e22ec83 --- /dev/null +++ b/tg_bot/pre_treasury_intents.py @@ -0,0 +1,23 @@ +"""Pre-treasury текстовые интенты (seam из tg_bot/accounts.py, бюджет модуля). + +Порядок важен: широкие фразы («трейдинг», «фриланс») перехватываются ДО +treasury-intent, у которого эти слова числятся ключевыми. +""" + +from __future__ import annotations + + +def pre_treasury_intents(api, chat_id: int, text: str) -> bool: + """True, если текст обработан здесь (до treasury).""" + + # Фриланс-сводка (v22.7): «фриланс», «что по фрилансу» + try: + from tg_bot.dashboard import _handle_freelance_summary_intent as _hfs + if _hfs(api, chat_id, text): + return True + except Exception: + pass + + # Кнопка «📈 Трейдинг» — человеческий отчёт + from tg_bot.trading_report import handle_trading_text_intent + return handle_trading_text_intent(api, chat_id, text) diff --git a/tg_bot/trading_report.py b/tg_bot/trading_report.py index 25d3a97ff..5aa102136 100644 --- a/tg_bot/trading_report.py +++ b/tg_bot/trading_report.py @@ -239,9 +239,14 @@ def build_snapshot() -> dict: regime = btc_regime() except Exception: regime = None + try: + regime_payload = _read_json(ROOT / "data" / "reports" / "market_regime_latest.json", {}) + except Exception: + regime_payload = {} return { "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), "btc_regime": regime, + "market_regime": regime_payload if isinstance(regime_payload, dict) else {}, "directional": snap_directional(), "dca": snap_dca(), "basket": snap_basket(), @@ -300,7 +305,11 @@ def format_report(snap: dict) -> list[str]: pct = (t2["portfolio"]["portfolio"] / 10000 - 1) * 100 lines.append(f"📈 Моментум-роботы: +{pct:.0f}% в СИМУЛЯЦИИ с 2023 года " f"(не заработок — подробности ниже).") - if snap.get("btc_regime") == "bear": + mr = snap.get("market_regime") or {} + if mr.get("regime"): + lines.append(f"🎛 Режим рынка: {mr['regime']} (риск {mr.get('risk_level', '?')}) — " + f"стратегия: {mr.get('strategy_family', '—')}.") + elif snap.get("btc_regime") == "bear": lines.append("🐻 Рынок в медвежьей фазе: BTC ниже своего долгосрочного среднего.") sb = snap["scoreboard"] if sb: @@ -459,6 +468,20 @@ def format_report(snap: dict) -> list[str]: "деньгах, тихие стратегии (копилка и корзина) накапливают, всё под " "автоматической защитой от крупных потерь.") lines.append("") + mr = snap.get("market_regime") or {} + if mr.get("regime"): + lines.append(f"🎛 Режим рынка и защита") + lines.append(f"• режим: {mr['regime']} | уровень риска: {mr.get('risk_level', '?')}") + lines.append(f"• рекомендуемое семейство стратегий: {mr.get('strategy_family', '—')}") + lines.append("• защита: в режимах CRASH/PANIC бумажные входы робота блокируются " + "(сохранение капитала); дневная просадка ограничена 0.25%.") + triggers = mr.get("triggers") or [] + if triggers: + lines.append("• что изменит режим:") + for t in triggers[:3]: + lines.append(f" – {t}") + lines.append("") + lines.append("📖 Словарик") lines.append("стоп — автопродажа, чтобы убыток не рос; тренд — общее направление цены; " "просадка — насколько счёт опускался от максимума; волатильность — насколько " @@ -506,6 +529,10 @@ def prompt_for_llm(snap: dict) -> str: m = snap["mm"] if m: parts.append(f"ws-снапшотов {m.get('snapshots', 0):,} за {m.get('span_h', 0)} ч") + mr = snap.get("market_regime") or {} + if mr.get("regime"): + parts.append(f"Режим рынка: {mr['regime']} (риск {mr.get('risk_level')}); " + f"триггеры: {'; '.join((mr.get('triggers') or [])[:3])}") sb = snap["scoreboard"] if sb: v = sb.get("verdict") or {} @@ -519,14 +546,17 @@ def prompt_for_llm(snap: dict) -> str: "виртуальные (paper) — реальные деньги не используются. Известные факты системы: " "попытки угадывать короткие движения рынка не дают преимущества (проверено десятками " "честных тестов); тихие стратегии (корзина из 10 монет, еженедельная копилка) — " - "единственные устойчиво положительные; сейчас рынок в медвежьей фазе. " - "Пиши без жаргона; если без термина никак — объясни его в скобках. " + "единственные устойчиво положительные. Пиши без жаргона; термин объясняй в скобках. " + "НИКОГДА не пиши «рынок точно пойдёт вверх/вниз» — только вероятностные сценарии. " "Формат ответа (русский, до 900 символов): " - "1) «Главное за 30 секунд» — 3-4 простых предложения; " - "2) «Что с каждым портфелем» — по одной строке на портфель; " - "3) «Чего ждать» — 3 сценария на 1-2 недели с вероятностями в процентах и что каждый " - "сценарий значит для обычного человека. В конце обязательная строка: " - "«Это не финансовый совет — система работает на виртуальные деньги»." + "1) «Главное за 30 секунд» — 3-4 предложения, включая текущий режим рынка и уровень риска; " + "2) «Что с каждым портфелем» — по одной строке (лучшая и худшая стратегия месяца); " + "3) «Чего ждать» — 3 сценария на 1-2 недели с вероятностями в %, для каждого — что " + "значит для обычного человека и какой триггер подтвердит сценарий; " + "4) «Насколько можно доверять» — одной строкой: уверенность в оценке (низкая/средняя), " + "качество данных (по числу сделок и глубине истории). " + "В конце обязательная строка: «Это не финансовый совет — система работает на " + "виртуальные деньги»." ) @@ -567,6 +597,25 @@ def full_report() -> list[str]: return format_report(snap) + llm_section(snap) +def is_trading_button_text(text: str | None) -> bool: + """Кнопка текстовой клавиатуры «📈 Трейдинг» (и варианты написания).""" + + norm = " ".join(str(text or "").casefold().split()) + return norm in ("трейдинг", "📈 трейдинг", "трейдинг отчёт", "трейдинг отчет") + + +def handle_trading_text_intent(api, chat_id, text: str) -> bool: + """Текстовый путь кнопки: отчёт; True если текст был «Трейдинг».""" + + if not is_trading_button_text(text): + return False + try: + send_full_report(api, chat_id) + except Exception as _e: + api.send_message(chat_id, f"⚠️ Трейдинг-отчёт: {_e}") + return True + + def send_full_report(api, chat_id) -> None: """Отправка отчёта в чат: данные сразу, LLM-аналитика в фоновом потоке.