#!/usr/bin/env python3 """Все числа TW-007 одним прогоном: сколько инструмент проходит за день и что из этого следует. ЗАЧЕМ ЭТОТ ФАЙЛ. Канал обещает, что всё измерено. Расчёт лежит рядом с кадрами и печатает ровно те числа, которые звучат голосом (правило введено на TW-013). ЧТО ПРОВЕРЯЕТСЯ. Четыре расхожих утверждения, все — числами: 1. «80–85 % времени инструмент проходит один ATR.» Под естественным прочтением («ход дня не меньше среднего») это невозможно: среднее на то и среднее. Считаем сразу несколько прочтений и показываем, при каком утверждение вообще становится осмысленным. 2. «Два ATR — 10 % времени, больше двух — 5 %.» 3. «Индикатор ATR неверен: паранормальные бары надо выбрасывать.» Проверяем не мнением, а предсказанием: чей рецепт точнее угадывает ход СЛЕДУЮЩЕГО дня. 4. «Прошёл 75 % дневного хода — дальше шансы практически нулевые.» На дневных барах это проверяется честно: сколько в среднем остаётся ходу у дня, который УЖЕ прошёл столько-то. Плюс то, о чём эти утверждения молчат: ATR в них — это high − low, то есть гепы в замер не входят. После TW-013 это уже не мелочь — меряем, насколько два определения расходятся. python atr.py # всё python atr.py # сам пишет results.txt (G-18) ДАННЫЕ. `04_assets/market/`: AAPL, TSLA, SPY (2 000 дневных баров каждый, 2018-09-20 … 2026-09-04) и BTC, ETH, SOL (по 1 000, 2023-12-12 … 2026-09-06). Крипта здесь участвует: вопрос про ход дня к ней относится так же, а гепов у неё нет — это само по себе проверка. """ from __future__ import annotations import io import json import sys from pathlib import Path from statistics import median STORE = Path(__file__).resolve().parents[1] / "market" STOCKS = ["AAPL", "TSLA", "SPY"] CRYPTO = ["BTCUSDT", "ETHUSDT", "SOLUSDT"] SYMS = STOCKS + CRYPTO WIN = 20 # окно среднего хода по умолчанию BIG, SMALL = 1.8, 0.5 # границы «паранормального» бара из утверждения 3 def load(sym: str) -> list[dict]: return json.load(io.open(STORE / f"{sym}-1d.json", encoding="utf-8"))["bars"] BARS = {s: load(s) for s in SYMS} def rng(b: dict) -> float: return b["h"] - b["l"] def true_rng(b: dict, prev: dict) -> float: """Настоящий диапазон: с учётом гепа. Урок его не называет.""" return max(b["h"], prev["c"]) - min(b["l"], prev["c"]) def mean_range(bars: list[dict], i: int, n: int = WIN) -> float: w = bars[max(0, i - n):i] return sum(rng(b) for b in w) / len(w) if w else 0.0 def ratios() -> list[float]: """Ход каждого дня, делённый на средний ход предыдущих двадцати.""" out = [] for sym in SYMS: bars = BARS[sym] for i in range(WIN, len(bars)): m = mean_range(bars, i) if m > 0: out.append(rng(bars[i]) / m) return sorted(out) def share(vals: list[float], lo: float, hi: float) -> float: return sum(1 for v in vals if lo <= v < hi) / len(vals) * 100 # ---------------------------------------------------------------- рецепты def plain(bars, i, n): return mean_range(bars, i, n) def trimmed(bars, i, n): """Рецепт утверждения 3: выбросить паранормальные, усреднить остальные.""" m = mean_range(bars, i, WIN) if m <= 0: return 0.0 w = [rng(b) for b in bars[max(0, i - n):i]] keep = [r for r in w if SMALL * m <= r <= BIG * m] return sum(keep) / len(keep) if keep else (sum(w) / len(w) if w else 0.0) def wilder(bars, i, n=14): """Классический ATR: истинный диапазон, скользящее среднее по Уайлдеру.""" if i < n + 1: return 0.0 tr = [true_rng(bars[j], bars[j - 1]) for j in range(i - n, i)] return sum(tr) / len(tr) def median_abs_error(recipe, n=None) -> float: """Насколько рецепт ошибается, предсказывая ход СЛЕДУЮЩЕГО дня. Мера — медиана |предсказание − факт|, делённая на средний ход, чтобы инструменты с разной ценой складывались. Мнение о рецепте здесь ни при чём: побеждает тот, кто ближе к завтрашнему бару. """ errs = [] for sym in SYMS: bars = BARS[sym] for i in range(WIN + 2, len(bars)): m = mean_range(bars, i) if m <= 0: continue pred = recipe(bars, i, n) if n else recipe(bars, i) if pred <= 0: continue errs.append(abs(pred - rng(bars[i])) / m) return median(errs) def main() -> int: sys.stdout.reconfigure(encoding="utf-8", errors="replace") print("ДАННЫЕ") for s in SYMS: b = BARS[s] print(f" {s:<8} {len(b)} баров {b[0]['d']} … {b[-1]['d']}") r = ratios() print(f" дней в счёте: {len(r)}\n") # ------------------------------------------------ 1. «80 % времени» print("УТВЕРЖДЕНИЕ 1 — «80–85 % времени инструмент проходит один ATR»") # ЧТО УТВЕРЖДЕНИЕ ЗНАЧИТ В ДНЯХ ГОДА. Голос s06 переводит его в дни # («two hundred and fifty trading days … roughly two hundred and ten of # them»): это арифметика утверждения, а не замер, но печататься она # обязана здесь же — иначе число в речи сверять не с чем. lo, hi = 0.80 * 250, 0.85 * 250 print(f" в днях года: 80–85 % из 250 — это {lo:.0f}–{hi:.0f} дней, " f"около {round(0.84 * 250)}") print(f" ход дня к среднему ходу: медиана {median(r):.2f}, " f"среднее {sum(r)/len(r):.2f}") print(f" не меньше одного среднего: {share(r, 1.0, 99):.0f} %" " ← естественное прочтение") print(f" в пределах ±20 % от среднего: {share(r, 0.8, 1.2):.0f} %") print(f" между 0.5 и 1.8 (то, что утверждение зовёт нормальным баром): " f"{share(r, SMALL, BIG):.0f} %") print(f" между 0.7 и 1.8: {share(r, 0.7, BIG):.0f} %\n") print(' корзины без перекрытия: ' + ' · '.join(f'{a}–{b}: {share(r, a, b):.1f} %' for a, b in ((0, 0.5), (0.5, 0.8), (0.8, 1.2), (1.2, 1.8), (1.8, 2.0), (2.0, 99))) + chr(10)) # 2. два ATR и больше print("УТВЕРЖДЕНИЕ 2 — «два ATR — 10 % времени, больше двух — 5 %»") print(f" от 1.8 до 2.2 среднего: {share(r, 1.8, 2.2):.1f} %") print(f" больше 2.0: {share(r, 2.0, 99):.1f} %") print(f" больше 1.8: {share(r, 1.8, 99):.1f} %") print(f" меньше 0.5: {share(r, 0.0, 0.5):.1f} %\n") # ------------------------------------------------ 3. чей рецепт точнее print("УТВЕРЖДЕНИЕ 3 — «индикатор неверен, паранормальные бары выбросить»") print(" ошибка предсказания хода следующего дня, в средних ходах:") for label, fn, n in ( ("среднее по 5 дням, как в утверждении", plain, 5), ("то же, но с выбросом паранормальных (рецепт утверждения)", trimmed, 5), ("среднее по 14 дням", plain, 14), ("среднее по 20 дням", plain, 20), ("классический ATR Уайлдера, 14 дней", wilder, None)): print(f" {label:<57} {median_abs_error(fn, n):.3f}") # Оговорка, без которой сравнение нечестно: Уайлдер считает ИСТИННЫЙ # диапазон, то есть предсказывает другую величину и на акциях завышен # на величину гепа. Ставить это ему в вину нельзя — можно только сказать, # что для замера «сколько инструмент пройдёт от high до low» он не тот # инструмент. Ниже — во сколько раз он завышен по построению. print(" (Уайлдер меряет истинный диапазон и на акциях завышен " "на величину гепа — см. последний раздел)") print() # ------------------------------------------------ 4. «прошёл 75 %» print("УТВЕРЖДЕНИЕ 4 — «прошёл 75 % дневного хода, дальше шансов нет»") print(" сколько ходу ОСТАЁТСЯ у дня, который уже прошёл столько:") print(f" {'пройдено':<12}{'таких дней':>12}{'остаётся, медиана':>22}" f"{'доля прошедших ещё 0.25':>27}") for th in (0.25, 0.50, 0.75, 1.00, 1.25): reached = [v for v in r if v >= th] if not reached: continue left = [v - th for v in reached] more = sum(1 for v in reached if v >= th + 0.25) / len(reached) * 100 print(f" {th:<12.2f}{len(reached):>12}{median(left):>22.2f}{more:>26.0f} %") print() # ------------------------------------------- 5. high−low против гепов print("О ЧЁМ УТВЕРЖДЕНИЯ МОЛЧАТ — ATR в них это high − low, гепы мимо") for group, name in ((STOCKS, "акции"), (CRYPTO, "крипта")): diff, big = [], 0 for sym in group: bars = BARS[sym] for i in range(1, len(bars)): a, t = rng(bars[i]), true_rng(bars[i], bars[i - 1]) if a > 0: diff.append(t / a) big += (t / a > 1.1) print(f" {name}: настоящий диапазон больше high − low в среднем " f"в {sum(diff)/len(diff):.3f} раза; " f"больше чем на 10 % — в {big/len(diff)*100:.1f} % дней") return 0 def _run() -> int: """Прогон со снимком: что напечатано, то и легло в `results.txt`. Раньше снимок писался перенаправлением (`python x.py > results.txt`), и прогон без перенаправления оставлял прежний файл: сравнение «снимок совпал» сравнивало копию саму с собой (G-18 второго аудита). Теперь — как у остальных этюдов курса. Снимок пишется только при удачном прогоне: обрубок опаснее отсутствия, он выглядит как настоящий отчёт. """ real = sys.stdout for s in (real, sys.stderr): if hasattr(s, "reconfigure"): s.reconfigure(encoding="utf-8", errors="replace") buf = io.StringIO() class _Tee: """Поток, который пишет в два места и не спорит с вызовами скрипта.""" encoding = "utf-8" errors = "replace" def write(self, text): real.write(text) buf.write(text) return len(text) def flush(self): real.flush() def reconfigure(self, **kw): pass def isatty(self): return False sys.stdout = _Tee() try: code = main() finally: sys.stdout = real if code == 0: out = Path(__file__).with_name("results.txt") out.write_text(buf.getvalue(), encoding="utf-8", newline="\n") print(f"\nснимок: {out}") return code if __name__ == "__main__": raise SystemExit(_run())