#!/usr/bin/env python3 """Все числа TW-014 одним прогоном: «запас хода» как фильтр. ЗАЧЕМ ЭТОТ ФАЙЛ. Канал обещает, что всё измерено. Расчёт лежит рядом с кадрами и печатает ровно те числа, которые звучат голосом (правило введено на TW-013). ЧТО ПРОВЕРЯЕТСЯ. 1. «Зашёл в зону — в 80 % случаев дотянут до следующего уровня, и лишь в 20 % развернёт сходу.» Так говорят про переход цены через уровень. Утверждение точное и проверяемое, если задать уровни правилом, а не глазом. 2. «Перед сопротивлением лонговать неадекватно: мало запаса хода.» Считаем, КАК ЧАСТО этого запаса действительно мало — то есть как часто расстояние до следующего уровня меньше одного дневного хода. 3. Фильтр остатка. TW-007 показал, что остаток ОБЩЕГО хода дня после трёх четвертей перестаёт зависеть от пройденного. Здесь другой вопрос, направленный: если день уже прошёл много, меньше ли ход СЛЕДУЮЩЕГО дня в ту же сторону. УРОВНИ ЗАДАЮТСЯ ПРАВИЛОМ. Разворотная точка: бар, чей максимум выше максимумов PIVOT баров слева и справа (для минимума — зеркально). Правило грубое и намеренно простое: любое усложнение здесь — это подгонка под ответ, а проверяем мы чужое утверждение, а не своё. ЧЕГО НЕ ДЕЛАЕТ. Дневные бары не видят середины дня, поэтому «сколько пройдено к моменту входа» здесь заменено на «сколько прошёл предыдущий день». Это честная замена, но замена, и в ролике она названа. python room.py python room.py # сам пишет results.txt (G-18) ДАННЫЕ. `04_assets/market/`: AAPL, TSLA, SPY (по 2000 дневных баров) и BTC, ETH, SOL (по 1000). """ 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" SYMS = ["AAPL", "TSLA", "SPY", "BTCUSDT", "ETHUSDT", "SOLUSDT"] ADR_N = 20 # окно среднего дневного хода — как во всех роликах канала PIVOT = 5 # столько баров слева и справа делают разворотную точку HORIZON = 20 # сколько сессий даём цене дойти до следующего уровня BARS = {s: json.load(io.open(STORE / f"{s}-1d.json", encoding="utf-8"))["bars"] for s in SYMS} def adr(bars: list[dict], i: int, n: int = ADR_N) -> float: w = bars[max(0, i - n):i] return sum(b["h"] - b["l"] for b in w) / len(w) if w else 0.0 def pivots(bars: list[dict]) -> tuple[list[tuple[int, float]], list[tuple[int, float]]]: """Разворотные максимумы и минимумы по простому правилу окна.""" highs, lows = [], [] for i in range(PIVOT, len(bars) - PIVOT): w = bars[i - PIVOT:i + PIVOT + 1] if bars[i]["h"] == max(b["h"] for b in w): highs.append((i, bars[i]["h"])) if bars[i]["l"] == min(b["l"] for b in w): lows.append((i, bars[i]["l"])) return highs, lows def levels_at(pv: list[tuple[int, float]], i: int) -> list[float]: """Уровни, СУЩЕСТВОВАВШИЕ на момент i. Разворотная точка становится известна только через PIVOT баров после себя — иначе мы заглядываем в будущее и меряем не рынок, а собственную осведомлённость.""" return [p for j, p in pv if j + PIVOT <= i] def zone_test() -> tuple[int, float, float]: """Утверждение 1: перешёл уровень — дотянут ли до следующего. Событие: закрытие пересекает уровень снизу вверх. Спрашиваем, достигнет ли цена СЛЕДУЮЩЕГО уровня выше, прежде чем закроется обратно под первым. Ответ «дотянули», «вернулись» или «ни то ни другое за HORIZON сессий». """ reached = back = neither = 0 waits = [] for sym in SYMS: bars = BARS[sym] highs, lows = pivots(bars) every = sorted(highs + lows, key=lambda p: p[0]) for i in range(ADR_N + PIVOT, len(bars) - HORIZON): known = levels_at(every, i) if not known: continue c0, c1 = bars[i - 1]["c"], bars[i]["c"] crossed = [lv for lv in known if c0 <= lv < c1] if not crossed: continue entry = max(crossed) # уровень, который прошли above = [lv for lv in known if lv > c1] if not above: continue target = min(above) # следующий уровень выше for k in range(i + 1, i + 1 + HORIZON): if bars[k]["h"] >= target: reached += 1 waits.append(k - i) break if bars[k]["c"] < entry: back += 1 break else: neither += 1 total = reached + back + neither return total, reached / total * 100, median(waits) if waits else 0.0 def room_test() -> tuple[int, float, float]: """Утверждение 2: как часто до следующего уровня меньше одного дня хода.""" small, n, ratios = 0, 0, [] for sym in SYMS: bars = BARS[sym] highs, _ = pivots(bars) for i in range(ADR_N + PIVOT, len(bars) - 1): a = adr(bars, i) above = [lv for j, lv in highs if j + PIVOT <= i and lv > bars[i]["c"]] if a <= 0 or not above: continue room = min(above) - bars[i]["c"] n += 1 ratios.append(room / a) small += room < a return n, small / n * 100, median(ratios) def stops_test() -> dict: """Запас в СТОПАХ на самих сетапах — при двух способах поставить стоп. ЗАЧЕМ (A-22 второго аудита). Голос делил медиану запаса на десятую дневного хода: «0.65 / 0.1 = шесть с половиной стопа». Это стоп ОТ ВХОДА. А модуль стопов (K-02) ставит стоп ЗА УРОВНЕМ и меряет риск от входа до него: риск = (вход − уровень) + десятая хода. Тот же запас в таких стопах короче — и считать его надо на сетапах, а не на всех барах подряд. Сетап — как в утверждении 1: закрытие пересекло уровень снизу вверх, вход по этому закрытию, следующий уровень выше — цель. """ from_entry, past_level, room_a, gap_a = [], [], [], [] for sym in SYMS: bars = BARS[sym] highs, lows = pivots(bars) every = sorted(highs + lows, key=lambda p: p[0]) # Та же выборка, что в утверждении 1: сетапы, у которых есть # HORIZON сессий будущего, — чтобы «1 987 пересечений» описания # и этот замер были об одних и тех же сделках. for i in range(ADR_N + PIVOT, len(bars) - HORIZON): known = levels_at(every, i) if not known: continue c0, c1 = bars[i - 1]["c"], bars[i]["c"] crossed = [lv for lv in known if c0 <= lv < c1] above = [lv for lv in known if lv > c1] a = adr(bars, i) if not crossed or not above or a <= 0: continue level, room = max(crossed), min(above) - c1 stop = 0.1 * a from_entry.append(room / stop) past_level.append(room / ((c1 - level) + stop)) room_a.append(room / a) gap_a.append((c1 - level) / a) n = len(from_entry) return {"n": n, "room": median(room_a), "gap": median(gap_a), "entry_med": median(from_entry), "entry_ok": sum(x >= 6 for x in from_entry) / n * 100, "level_med": median(past_level), "level_ok": sum(x >= 6 for x in past_level) / n * 100} def spent_test() -> list[tuple[str, int, float, float]]: """Утверждение 3: после большого дня меньше ли ход в ту же сторону. Направление берём по знаку хода предыдущего дня, продолжение — ход следующего дня в ту же сторону И ПРОТИВ НЕЁ, плюс весь диапазон следующего дня. """ buckets = {"меньше 0.5": [], "0.5–1.0": [], "1.0–1.5": [], "больше 1.5": []} for sym in SYMS: bars = BARS[sym] for i in range(ADR_N, len(bars) - 1): a = adr(bars, i) if a <= 0: continue spent = (bars[i]["h"] - bars[i]["l"]) / a move = bars[i]["c"] - bars[i - 1]["c"] if move == 0: continue nxt = bars[i + 1] go = (nxt["h"] - bars[i]["c"]) if move > 0 else (bars[i]["c"] - nxt["l"]); back = (bars[i]["c"] - nxt["l"]) if move > 0 else (nxt["h"] - bars[i]["c"]) key = ("меньше 0.5" if spent < 0.5 else "0.5–1.0" if spent < 1.0 else "1.0–1.5" if spent < 1.5 else "больше 1.5") buckets[key].append((max(0.0, go) / a, max(0.0, back) / a, (nxt["h"] - nxt["l"]) / a)) return [(k, len(v), median(x[0] for x in v), median(x[1] for x in v), median(x[2] for x in v)) for k, v in buckets.items() if v] def example(sym: str = "AAPL", span: int = 8, tail: int = 3) -> dict | None: """Случай для кадров s10 и s16 — выбранный правилом, а не глазом. ЗАЧЕМ ЗДЕСЬ. Числа примера звучат голосом, значит их печатает скрипт: иначе через год их нечем сверить, а один раз цены в этом ролике уже были выдуманы. Условия ниже — не подгонка под красивый ответ, а список того, чего требует сама сцена. 1. Закрытие переходит уровень вверх — это событие утверждения. 2. Выше есть следующий уровень, и запаса до него меньше дневного хода. 3. МАКСИМУМ дня входа НЕ доходит до следующего уровня. Без этого свеча на кадре протыкает линию, до которой мы говорим «два доллара запаса», и картинка спорит с текстом. 4. Цена потом всё-таки доходит до уровня — иначе нечем показать, что само утверждение верно, а сделка всё равно не стоила входа. 5. Запас, делённый на стоп (0,1 дневного хода), меньше СТОПОВ — то есть фильтр эту сделку отвергает, и отвергает с запасом. Порог здесь строже шести не потому, что так честнее мерить, а потому, что пример на 5,9 стопа ничему не учит: граничный случай читается как спор о точности, а сцена не о ней. 6. Размах окна не больше SPREAD запасов: если запас — сотая доля высоты кадра, зритель его просто не увидит. Пункты 5 и 6 — требования СЦЕНЫ, а не замера. Замер выше их не знает и от них не меняется; здесь они выбирают, что показать. """ SPREAD, СТОПОВ = 7, 4 bars = BARS[sym] highs, lows = pivots(bars) every = sorted(highs + lows, key=lambda p: p[0]) for i in range(len(bars) - HORIZON - 1, ADR_N + PIVOT, -1): known = levels_at(every, i) a = adr(bars, i) if not known or a <= 0: continue c0, c1 = bars[i - 1]["c"], bars[i]["c"] crossed = [lv for lv in known if c0 <= lv < c1] above = [lv for lv in known if lv > c1] if not crossed or not above: continue entry, target = max(crossed), min(above) room = target - c1 if room >= a or bars[i]["h"] >= target: continue if room / (0.1 * a) >= СТОПОВ: continue hit = None for k in range(i + 1, i + 1 + HORIZON): if bars[k]["c"] < entry: break if bars[k]["h"] >= target: hit = k break if hit is None: continue lo, hi = max(0, i - span), min(len(bars) - 1, hit + tail) w = bars[lo:hi + 1] spread = max(b["h"] for b in w) - min(b["l"] for b in w) if spread > SPREAD * room: continue return {"sym": sym, "date": bars[i]["d"], "close": c1, "entry": entry, "target": target, "room": room, "adr": a, "stop": 0.1 * a, "stops": room / (0.1 * a), "hit_date": bars[hit]["d"], "hit_high": bars[hit]["h"], "sessions": hit - i, "setup_from": bars[lo]["d"], "setup_to": bars[i]["d"], "story_to": bars[hi]["d"], "spread": spread, "stop_hit": next(((bars[k]["d"], round(bars[k]["l"], 2)) for k in range(i + 1, hit + 1) if bars[k]["l"] <= c1 - 0.1 * a), None)} return None 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']}") print(f" разворотная точка: окно ±{PIVOT} баров\n") n, share, wait = zone_test() print("УТВЕРЖДЕНИЕ 1 — «зашёл в зону, в 80 % дотянут до следующего уровня»") print(f" переходов уровня вверх: {n}") print(f" дошли до следующего уровня: {share:.0f} %") print(f" медиана ожидания: {wait:.0f} сессий\n") n, small, med = room_test() print("УТВЕРЖДЕНИЕ 2 — «перед сопротивлением мало запаса хода»") print(f" замеров: {n}") print(f" до следующего уровня меньше одного дневного хода: {small:.0f} %") print(f" медиана запаса: {med:.2f} дневного хода\n") print("РАСПРЕДЕЛЕНИЕ ЗАПАСА — для кадра-гистограммы") edges = [(0.0, 0.25), (0.25, 0.5), (0.5, 0.75), (0.75, 1.0), (1.0, 1.5), (1.5, 99)] ratios = [] for sym in SYMS: bars = BARS[sym] highs, _ = pivots(bars) for i in range(ADR_N + PIVOT, len(bars) - 1): a = adr(bars, i) above = [lv for j, lv in highs if j + PIVOT <= i and lv > bars[i]["c"]] if a > 0 and above: ratios.append((min(above) - bars[i]["c"]) / a) for lo, hi in edges: n = sum(1 for r in ratios if lo <= r < hi) label = f"{lo:.2f}–{hi:.2f}" if hi < 90 else f"больше {lo:.1f}" print(f" {label:<14}{n:>7}{n / len(ratios) * 100:>7.0f} %") print() s = stops_test() print("ЗАПАС В СТОПАХ НА САМИХ СЕТАПАХ — два способа поставить стоп") print(f" сетапов (пересечение уровня снизу): {s['n']}") print(f" медиана запаса до следующего уровня: {s['room']:.2f} дневного хода") print(f" медиана расстояния от входа до пройденного уровня: {s['gap']:.2f} хода") print(f" стоп на десятую хода ОТ ВХОДА: медиана {s['entry_med']:.1f} стопа, " f"шесть и больше — {s['entry_ok']:.0f} %") print(f" стоп на десятую хода ЗА УРОВНЕМ (модуль стопов), риск от входа: " f"медиана {s['level_med']:.1f} стопа, шесть и больше — {s['level_ok']:.0f} %") print() print("УТВЕРЖДЕНИЕ 3 — после большого дня меньше ли ход в ту же сторону") print(f" {'ход дня':<14}{'дней':>8}{'по ходу, медиана':>20}" f"{'против хода':>14}{'диапазон завтра':>18}") total = 0 for key, cnt, med, back, rng in spent_test(): print(f" {key:<14}{cnt:>8}{med:>20.2f}{back:>14.2f}{rng:>18.2f}") total += cnt # ИТОГ ПЕЧАТАЕТСЯ: кадр этой таблицы подписан «8,865 days», а вывод до # 8 октября давал только строки — число на экране было суммой, которую # никто не напечатал и, значит, никто не сверял. print(f" {'всего':<14}{total:>8}") print() print("ПРИМЕР ДЛЯ КАДРОВ s10 и s16 — выбран правилом, см. example()") e = example() if not e: print(" подходящего случая нет — кадры-графики брать неоткуда") else: print(f" {e['sym']} {e['date']}: закрытие {e['close']:.2f}, " f"перешли уровень {e['entry']:.2f}") print(f" следующий уровень выше: {e['target']:.2f}, " f"запас {e['room']:.2f}") print(f" дневной ход {e['adr']:.2f}, стоп {e['stop']:.2f}, " f"запас в стопах {e['stops']:.1f}") print(f" дошли {e['hit_date']} (максимум {e['hit_high']:.2f}), " f"через {e['sessions']} сессий") print(f" стоп {e['close'] - e['stop']:.2f} задет раньше цели: {e['stop_hit']}") # ТОТ ЖЕ ПРИМЕР СО СТОПОМ МОДУЛЯ СТОПОВ (A-22): стоп за пройденным # уровнем на ту же десятую хода, риск — от входа до него. k02 = e["entry"] - e["stop"] risk = e["close"] - k02 low = min(BARS[e["sym"]][k]["l"] for k in range(len(BARS[e["sym"]])) if e["date"] < BARS[e["sym"]][k]["d"] <= e["hit_date"]) print(f" стоп за уровнем {k02:.2f}, риск от входа {risk:.2f}, " f"запас в таких стопах {e['room'] / risk:.1f}; " f"минимум до цели {low:.2f}") print(f" окно кадра s10: {e['setup_from']} — {e['setup_to']}") print(f" окно кадра s16: {e['setup_from']} — {e['story_to']}, " f"размах {e['spread']:.2f} = {e['spread'] / e['room']:.1f} запаса") print() 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())