#!/usr/bin/env python3 """Все числа TW-030 одним прогоном. Тема S-06: что должно быть верно ДО пробоя. ЗАЧЕМ ЭТОТ ФАЙЛ ЛЕЖИТ В ПРОЕКТЕ. Канал обещает: всё измерено, а не заявлено. Обещание стоит ровно столько, сколько стоит возможность пересчитать. ОТКУДА ВОПРОС. TW-028 измерил пробой в плюс и нашёл три условия отбора — видимый уровень, проверенный уровень, обычный бар. А потом разделил сделки по направлению, и весь плюс оказался в длинной стороне: окно росло. То есть часть «условий» могла быть не условиями, а тем же направлением в профиль. ПОЭТОМУ ЗДЕСЬ ГЛАВНОЕ ПРАВИЛО ТАКОЕ. Каждое условие меряется ОТДЕЛЬНО для пробоев вверх и вниз. Условие, которое помогает только вверх, на этом окне неотличимо от «покупай растущий рынок» и в ролик идёт как таковое. ЧТО ЗДЕСЬ НОВОГО ПРОТИВ TW-028. Там условия описывали САМ УРОВЕНЬ и САМ БАР. Здесь — состояние рынка ДО пробоя, то есть то, что видно заранее: СЖАТИЕ: ширина двадцатидневной полосы перед пробоем, в дневных ходах. Порог не выбирается руками: узкая треть собственных полос инструмента против широкой трети (та же мерка, что в TW-029). СОГЛАСИЕ: направление шестидесяти дней до пробоя совпадает с направлением пробоя или спорит с ним. ВЫТЯНУТОСТЬ: насколько уровень уже отстоит от двадцатидневного среднего, в дневных ходах. «Цена уже сходила» — это плата за вход. ВОЗРАСТ: сколько дней уровню на момент пробоя. ВИДИМОСТЬ: шестидесятидневный экстремум или нет —условие из TW-028, перемеренное здесь внутри каждого направления. ПРАВИЛА СДЕЛКИ НЕ ПОДБИРАЮТСЯ. Взяты те, что выиграли в TW-028: вход по открытию следующего дня, стоп 0,5 дневного хода за уровнем, выход по времени. Подбирать их заново на тех же данных значило бы измерять подгонку, а не условия. ЧЕСТНОСТЬ УЧЁТА — та же, что в TW-026…TW-029: в день входа засчитывается стоп, но не цель; всё считается в R; выбор проверяется на второй половине окна, которая в выборе не участвовала. ДАННЫЕ. `04_assets/market/*-1d.json`, шесть инструментов, общее окно. Объёма в этих данных нет, и условие «объём на пробое» здесь не проверяется — это названо в ролике вслух, а не замолчано. """ from __future__ import annotations import json import statistics as st import sys from pathlib import Path STUDIO = Path(__file__).resolve().parents[3] MARKET = STUDIO / "tickwright" / "04_assets" / "market" OUT = Path(__file__).resolve().parent / "results.txt" STOCKS = ["AAPL", "TSLA", "SPY"] CRYPTO = ["BTCUSDT", "ETHUSDT", "SOLUSDT"] WING = 2 # излом: экстремум выше соседей с обеих сторон SEEN = 60 # окно «видимости» уровня BAND = 20 # окно сжатия и среднего TREND = 60 # окно согласия HOLD = 10 # выход по времени STOP = 0.5 # стоп в дневных ходах за уровнем ROLL = 60 # обычный ход: медиана high−low за 60 баров до бара NARROW_Q = 1 / 3 WIDE_Q = 2 / 3 def bars(name: str) -> list[dict]: d = json.loads((MARKET / f"{name}-1d.json").read_text(encoding="utf-8")) return d["bars"] if isinstance(d, dict) and "bars" in d else d # ОБЩИЙ УЧЁТ СДЕЛОК (П11). Свой `adr` и своё понятие R у каждого # этюда были источником противоречий курса: одно сочетание на одних и # тех же касаниях давало разный знак в разных уроках. Теперь обычный # ход, пол риска и погрешность — одни на все этюды модулей S и Y. sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "shared" / "pipeline")) from tw_accounting import adr, FLOOR # noqa: E402 def quantile(vals: list[float], q: float) -> float: v = sorted(vals) return v[min(len(v) - 1, int(q * len(v)))] HIST = ROLL # порог дня пробоя — по ширинам стольких дней ДО него (B-15) def signals(r: list[dict], a: float, hist: int = HIST) -> list[dict]: """Пробои с признаками, известными ДО пробоя. Каждый признак считается по барам строго ДО дня входа. Это не формальность: признак, подсмотренный на день вперёд, даёт красивую таблицу и ноль пользы. """ widths = {} for i in range(BAND, len(r)): w = r[i - BAND:i] widths[i] = (max(b["h"] for b in w) - min(b["l"] for b in w)) / a[i] # ПОРОГ — ТОЛЬКО ПО ПРОШЛОМУ (B-15 второго аудита, 8 октября 2026). # Раньше узкая и широкая трети считались по ширинам ВСЕГО окна: порог # пробоя в 2024 году знал ширины 2026-го. Теперь — по `hist` дням # строго до дня пробоя; без такой истории пробой ни узким, ни широким # не считается (в выборке остаётся — он нужен остальным условиям). out = [] for i in range(max(WING, BAND, TREND), len(r) - WING - HOLD - 2): left, right = r[i - WING:i], r[i + 1:i + 1 + WING] for kind, price in (("high", r[i]["h"]), ("low", r[i]["l"])): ok = (all(price > b["h"] for b in left + right) if kind == "high" else all(price < b["l"] for b in left + right)) if not ok: continue w = r[max(0, i - SEEN):i + 1] seen = (price >= max(b["h"] for b in w) if kind == "high" else price <= min(b["l"] for b in w)) # Первое закрытие за уровнем — и это пробой. Второй пробой той же # линии другое событие и в выборку не идёт. day = None for j in range(i + 1, min(i + 1 + 120, len(r) - HOLD - 2)): if (r[j]["c"] > price if kind == "high" else r[j]["c"] < price): day = j break if day is None or day not in widths: continue # Без истории порога пробой остаётся в выборке (он нужен девяти # другим условиям), но ни узким, ни широким не считается. past = [widths[k] for k in range(day - hist, day) if k in widths] if len(past) >= hist: narrow = quantile(past, NARROW_Q) wide = quantile(past, WIDE_Q) else: narrow, wide = float("-inf"), float("inf") w20 = r[day - BAND:day] mean = sum(b["c"] for b in w20) / len(w20) before = r[day - TREND]["c"] trend_up = r[day - 1]["c"] > before out.append({ "day": day, "price": price, "kind": kind, "seen": seen, "width": widths[day], "tight": widths[day] <= narrow, "loose": widths[day] >= wide, # Согласие: направление пробоя совпадает с направлением # шестидесяти дней до него. "agree": trend_up == (kind == "high"), # Вытянутость: сколько дневных ходов между уровнем и средним. "stretch": abs(price - mean) / a[day], "age": day - i, }) # ОДИН ДЕНЬ — ОДНА СДЕЛКА В СТОРОНУ. Одно закрытие может оказаться ниже # сразу трёх минимумов подряд, и каждый из них формально «пробит впервые». # Считать это тремя сделками нельзя: трейдер видит одно движение и входит # один раз, а выборка при этом раздувается втрое одинаковыми исходами. # Найдено на TSLA: двадцать две короткие сделки в сжатии дали ровно −1R # каждая, и тринадцать дней входа на двадцать два сигнала. # # Из совпавших остаётся уровень, ближайший к закрытию дня: остальные # тем же движением пройдены раньше и отдельным событием не являются. best: dict[tuple[int, str], dict] = {} for s in out: key = (s["day"], s["kind"]) near = abs(r[s["day"]]["c"] - s["price"]) if key not in best or near < best[key]["_near"]: best[key] = dict(s, _near=near) return [{k: v for k, v in s.items() if k != "_near"} for s in sorted(best.values(), key=lambda x: x["day"])] def trade(r: list[dict], a: float, s: dict) -> float | None: """Одна сделка по правилам, выигравшим в TW-028. Ничего не подбирается.""" day0, price, kind = s["day"], s["price"], s["kind"] if day0 + 1 >= len(r): return None day, fill = day0 + 1, r[day0 + 1]["o"] stop = price - STOP * a[day0] if kind == "high" else price + STOP * a[day0] # ВХОД, УЖЕ СТОЯЩИЙ ЗА СТОПОМ, — НЕ СДЕЛКА. Если за ночь цена ушла # гепом дальше стопа, «стоп» оказывается по ту сторону входа: для # короткой позиции ниже цены входа, для длинной выше. Такую позицию # не открывают — а расчёт открывал и записывал ровно −1R. # На TSLA это давало тринадцать коротких сделок подряд с −1R каждая. if (kind == "high" and fill <= stop) or (kind == "low" and fill >= stop): return None if a[day0] is None: # бар без истории хода не торгуется return None # Пол риска — тот же, что у всех этюдов: FLOOR из tw_accounting. gap = max(abs(fill - stop), FLOOR * a[day0]) cut = min(1.0, abs(fill - stop) / (FLOOR * a[day0])) if gap <= 0: return None for j in range(day, min(day + 1 + HOLD, len(r))): if kind == "high": if r[j]["l"] <= stop: return -cut else: if r[j]["h"] >= stop: return -cut last = r[min(day + HOLD, len(r) - 1)]["c"] return (last - fill) / gap if kind == "high" else (fill - last) / gap def score(rows, sigs, cond=None, side=None, half=None) -> tuple[int, float]: n, total = 0, 0.0 for name, r in rows.items(): a = adr(r) mid = len(r) // 2 for s in sigs[name]: if side and s["kind"] != side: continue if half is not None and (s["day"] < mid) != (half == 0): continue if cond and not cond(s): continue res = trade(r, a, s) if res is None: continue n += 1 total += res # пол риска уже внутри trade(): gap и cut return n, (total / n if n else 0.0) def main() -> int: for s in (sys.stdout, sys.stderr): if hasattr(s, "reconfigure"): s.reconfigure(encoding="utf-8", errors="replace") lines: list[str] = [] def say(t: str = "") -> None: print(t) lines.append(t) rows = {n: bars(n) for n in STOCKS + CRYPTO} start = min(r[0]["d"][:10] for n, r in rows.items() if n in CRYPTO) rows = {n: [b for b in r if b["d"][:10] >= start] for n, r in rows.items()} sigs = {n: signals(r, adr(r)) for n, r in rows.items()} # Проверка чувствительности к длине истории порога (B-15). sigs120 = {n: signals(r, adr(r), hist=120) for n, r in rows.items()} say("ДАННЫЕ") say(f" Общее окно с {start}. Шесть инструментов, дневные бары.") say(" Объёма в этих данных нет — условие «объём на пробое» не проверяется.") say("") say("СЫРЬЁ: ПРОБОЕВ") tot_up = tot_dn = 0 for n in rows: up = sum(1 for s in sigs[n] if s["kind"] == "high") dn = len(sigs[n]) - up tot_up += up tot_dn += dn say(f" {n:9} вверх {up:5} вниз {dn:5}") say(f" {'ВСЕГО':9} вверх {tot_up:5} вниз {tot_dn:5}") say("") say("ПРАВИЛА СДЕЛКИ ВЗЯТЫ ИЗ TW-028 И НЕ ПОДБИРАЮТСЯ") say(f" вход по открытию следующего дня · стоп {STOP} дневного хода " f"за уровнем · выход по времени через {HOLD} дней") say("") say("0 · БАЗА, БЕЗ УСЛОВИЙ") for label, side in (("все пробои", None), ("вверх", "high"), ("вниз", "low")): n, v = score(rows, sigs, side=side) say(f" {label:24} {n:6} сделок {v:+.3f} R") CONDS = [ ("сжатие: узкая полоса", lambda s: s["tight"]), ("сжатие: широкая полоса", lambda s: s["loose"]), ("согласие с трендом", lambda s: s["agree"]), ("спор с трендом", lambda s: not s["agree"]), ("вытянутость до 1 хода", lambda s: s["stretch"] <= 1.0), ("вытянутость больше 2 ходов", lambda s: s["stretch"] > 2.0), ("уровню меньше 20 дней", lambda s: s["age"] < 20), ("уровню больше 60 дней", lambda s: s["age"] > 60), ("видимый уровень", lambda s: s["seen"]), ("незаметный уровень", lambda s: not s["seen"]), ] say("") say("1 · КАЖДОЕ УСЛОВИЕ ВНУТРИ СВОЕГО НАПРАВЛЕНИЯ") say(" (условие, которое помогает только вверх, — это окно, а не условие)") say(f" {'условие':28} {'вверх':>18} {'вниз':>18}") keep = [] for label, cond in CONDS: nu, vu = score(rows, sigs, cond, side="high") nd, vd = score(rows, sigs, cond, side="low") mark = "" if nu >= 40 and nd >= 40: base_u = score(rows, sigs, side="high")[1] base_d = score(rows, sigs, side="low")[1] if vu > base_u and vd > base_d: mark = " ← лучше базы в обе стороны" keep.append((label, cond)) say(f" {label:28} {nu:5} {vu:+.3f} R {nd:5} {vd:+.3f} R{mark}") # ПРОВЕРКА: сжатие при пороге по 120 дням (B-15). Ответ про сжатие # не должен зависеть от выбранной длины истории — если зависит, это # надо видеть. for label, key in (("сжатие узкое, порог 120 дней", "tight"), ("сжатие широкое, порог 120 дней", "loose")): nu, vu = score(rows, sigs120, lambda s, k=key: s[k], side="high") nd, vd = score(rows, sigs120, lambda s, k=key: s[k], side="low") say(f" {label:28} {nu:5} {vu:+.3f} R {nd:5} {vd:+.3f} R") say("") say("2 · ЧТО УЦЕЛЕЛО") if keep: for label, _ in keep: say(f" · {label}") else: say(" ни одно условие не улучшает обе стороны разом") say("") say("3 · УСЛОВИЯ ВМЕСТЕ, ЕСЛИ ИХ БОЛЬШЕ ОДНОГО") if len(keep) > 1: both = lambda s: all(c(s) for _, c in keep) # noqa: E731 for label, side in (("вверх", "high"), ("вниз", "low")): n, v = score(rows, sigs, both, side=side) say(f" все уцелевшие, {label:6} {n:6} сделок {v:+.3f} R") else: say(" складывать нечего") say("") say("4 · ВЫБОР НА ПЕРВОЙ ПОЛОВИНЕ, ЗАМЕР НА ВТОРОЙ") first = [] for label, cond in CONDS: n, v = score(rows, sigs, cond, half=0) if n >= 60: first.append((v, label, cond)) if first: v1, label, cond = max(first) n2, v2 = score(rows, sigs, cond, half=1) say(f" лучшее условие на первой половине: {label}") say(f" первая половина {score(rows, sigs, cond, half=0)[0]:5} " f"сделок {v1:+.3f} R") say(f" вторая половина {n2:5} сделок {v2:+.3f} R") say(f" потеря при переходе: {v2 - v1:+.3f} R") # ПО СТОРОНАМ (B-19 второго аудита). «Почти ничего не потеряно» — # сумма двух сторон в растущем окне; по сторонам картина другая, # и голос не может утверждать её, пока она не напечатана. for side_label, side in (("вверх", "high"), ("вниз", "low")): a0, b0 = score(rows, sigs, cond, side=side, half=0) a1, b1 = score(rows, sigs, cond, side=side, half=1) say(f" {side_label:6} первая {a0:5} {b0:+.3f} R " f"вторая {a1:5} {b1:+.3f} R") for side_label, side in (("вверх", "high"), ("вниз", "low")): a0, b0 = score(rows, sigs, side=side, half=0) a1, b1 = score(rows, sigs, side=side, half=1) say(f" база {side_label:6} первая {a0:5} {b0:+.3f} R " f"вторая {a1:5} {b1:+.3f} R") else: say(" на первой половине не набралось условия с 60 сделками") say("") say("5 · УЦЕЛЕВШЕЕ УСЛОВИЕ НА ВТОРОЙ ПОЛОВИНЕ ОКНА") say(" (проверяется то, что прошло отбор, а не то, что лучше всех)") def tight(s: dict) -> bool: return s["tight"] for label, side in (("вверх", "high"), ("вниз", "low")): n0, v0 = score(rows, sigs, tight, side=side, half=0) n1, v1 = score(rows, sigs, tight, side=side, half=1) say(f" сжатие, {label:6} первая {n0:5} {v0:+.3f} R " f"вторая {n1:5} {v1:+.3f} R") say("") say("6 · СЖАТИЕ ПО ИНСТРУМЕНТАМ") for n_, r_ in rows.items(): one, one_s = {n_: r_}, {n_: sigs[n_]} nu, vu = score(one, one_s, tight, side="high") nd, vd = score(one, one_s, tight, side="low") say(f" {n_:9} вверх {nu:4} {vu:+.3f} R вниз {nd:4} {vd:+.3f} R") say("") say("7 · ПРОВЕРКА НА ПОДОЗРИТЕЛЬНО ОДИНАКОВЫЙ ИСХОД") say(" Срез, где все сделки кончились одинаково, — это не результат,") say(" а признак ошибки в учёте. Именно так нашлись двойная запись") say(" одного движения и вход, уже стоящий за стопом: на TSLA двадцать") say(" две короткие сделки подряд дали ровно −1R каждая.") smelly = 0 for n_, r_ in rows.items(): a = adr(r_) for label, cond in CONDS: for side in ("high", "low"): res = [trade(r_, a, s) for s in sigs[n_] if s["kind"] == side and cond(s)] res = [x for x in res if x is not None] if len(res) >= 10 and len(set(round(x, 6) for x in res)) == 1: smelly += 1 say(f" ПОДОЗРИТЕЛЬНО {n_} · {label} · {side}: " f"{len(res)} сделок, все {res[0]:+.3f} R") if not smelly: say(" чисто: срезов с одинаковым исходом нет") OUT.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"\nнаписано: {OUT}") return 0 if __name__ == "__main__": raise SystemExit(main())