#!/usr/bin/env python3 """Все числа TW-024 одним прогоном. Тема L-07: уровни, которые видят все. ЗАЧЕМ ЭТОТ ФАЙЛ ЛЕЖИТ В ПРОЕКТЕ. Канал обещает: всё измерено, а не заявлено. Обещание стоит ровно столько, сколько стоит возможность пересчитать. ПОЧЕМУ ВОПРОС ПОСТАВЛЕН ИНАЧЕ, ЧЕМ В ПЛАНЕ КУРСА. План формулировал тему так: «круглые числа, старые хаи, экстремумы сессии работают потому, что их видят все». Предыдущий ролик (TW-023, расчёт `ranking.py`) это утверждение ИЗМЕРИЛ и опроверг: видимый уровень отскакивает не лучше незаметного, а на акциях хуже на четыре–одиннадцать пунктов. Оставить прежнюю формулировку значило бы рассказывать то, что собственный расчёт канала уже опроверг. Поэтому вопрос здесь другой и честный: если видимый уровень не даёт отскока, что он тогда делает? Проверяются три возможности. 1. СОБЫТИЕ. День, когда цена доходит до видимого уровня, — обычный день или день с увеличенным ходом? Если ход растёт, уровень собирает действие, даже когда не разворачивает цену. 2. ЛОЖНЫЙ ПРОБОЙ. Доля случаев, когда цена вышла за уровень и вернулась обратно в течение двух дней. Это ровно та механика, на которой стоит модуль сетапов. 3. ПРОДОЛЖЕНИЕ. Если уровень всё-таки пробит, дальше цена уходит дальше или останавливается? Здесь видимость может работать в обратную сторону. ВИДИМОСТЬ. Уровень считается видимым, если он экстремум за шестьдесят дней — такой, что его заметно на графике с одного взгляда. Определение то же, что в TW-023: менять мерку между роликами нельзя. ДАННЫЕ. `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" NAMES = ["AAPL", "TSLA", "SPY", "BTCUSDT", "ETHUSDT", "SOLUSDT"] CRYPTO = {"BTCUSDT", "ETHUSDT", "SOLUSDT"} WING = 2 NEAR = 0.25 # подход: ближе четверти обычного дня ROLL = 60 # обычный ход: медиана high−low за 60 баров до бара HORIZON = 120 SEEN = 60 # окно «виден всем» BACK = 2 # за сколько дней ложный пробой должен вернуться ON = 5 # на сколько дней смотрим продолжение после пробоя 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 def adr(r: list[dict]) -> list[float]: """Обычный дневной ход НА КАЖДЫЙ БАР: медиана high−low за ROLL баров ДО него. Одна медиана в долларах за весь ряд мерила годы, а не дни: цена Apple выросла в пять с половиной раз, и «широких» баров выходило 0 % в 2019-м и 60 % в 2026-м, а в разметку TW-025 попадало спрятанное будущее. Первые 20 баров ряда берут медиану первых двадцати. """ return [st.median([b["h"] - b["l"] for b in # без будущего (B-47): первые 20 баров — по тому, что было до них (r[max(0, i - ROLL):i] if i >= 20 else (r[:i] or r[:1]))]) for i in range(len(r))] def levels(r: list[dict]) -> list[dict]: out = [] for i in range(WING, len(r) - WING): 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)) out.append({"i": i, "price": price, "kind": kind, "seen": seen}) return out def first_touch(r: list[dict], lv: dict, a: float) -> int | None: # допуск касания — четверть обычного хода на день касания: a[j] # ИЗЛОМ ИЗВЕСТЕН ТОЛЬКО ЧЕРЕЗ WING БАРОВ (9 октября 2026). Его # определяют соседи справа, и до закрытия бара i + WING уровня ещё нет. # Поиск касания с бара i + 1 засчитывал как раз эти бары: у максимума они # по определению ниже его, часто в пределах допуска, и цена после них уже # уходит прочь — отскок им дарило само определение излома. В расчёте # TW-023 так легли 828 из 1701 первых касаний (49 %), и «отскакивали» они # в 97 % случаев против 81 % у остальных. Торговать их нельзя. for j in range(lv["i"] + 1 + WING, min(lv["i"] + 1 + HORIZON, len(r))): if r[j]["l"] - NEAR * a[j] <= lv["price"] <= r[j]["h"] + NEAR * a[j]: return j return None def day_size(r: list[dict], j: int, a: float) -> float: """Ход дня касания в долях обычного дня.""" return (r[j]["h"] - r[j]["l"]) / a[j] def poked(r: list[dict], lv: dict, j: int, a: float) -> bool | None: """Вышла ли цена за уровень в день касания, и вернулась ли обратно. None — если за уровень не выходили вовсе: тогда вопрос о ложном пробое не стоит, и такой день не должен разбавлять статистику. """ out = (r[j]["h"] > lv["price"] + 0.1 * a[j] if lv["kind"] == "high" else r[j]["l"] < lv["price"] - 0.1 * a[j]) if not out: return None seg = r[j:min(j + 1 + BACK, len(r))] if lv["kind"] == "high": return any(b["c"] < lv["price"] for b in seg) return any(b["c"] > lv["price"] for b in seg) def carried(r: list[dict], lv: dict, j: int, a: float) -> float | None: """Насколько далеко цена ушла за уровень, если закрылась за ним. Меряется в обычных дневных ходах за ON дней. None — если закрытия за уровнем не было: продолжать было нечего. """ closed = (r[j]["c"] > lv["price"] if lv["kind"] == "high" else r[j]["c"] < lv["price"]) if not closed: return None seg = r[j + 1:min(j + 1 + ON, len(r))] if not seg: return None if lv["kind"] == "high": return (max(b["h"] for b in seg) - lv["price"]) / a[j] return (lv["price"] - min(b["l"] for b in seg)) / a[j] def main() -> int: for s in (sys.stdout, sys.stderr): if hasattr(s, "reconfigure"): s.reconfigure(encoding="utf-8", errors="replace") rows = {n: bars(n) for n in NAMES} print("ДАННЫЕ") for n, r in rows.items(): print(f" {n:9} {len(r)} баров {r[0]['d']} … {r[-1]['d']}") data = {} for n, r in rows.items(): a = adr(r) got = [] for lv in levels(r): j = first_touch(r, lv, a) if j is None: continue got.append({"seen": lv["seen"], "size": day_size(r, j, a), "poke": poked(r, lv, j, a), "carry": carried(r, lv, j, a), "hit": j, "kind": lv["kind"], "price": lv["price"]}) # ОДИН ДЕНЬ — ОДНО КАСАНИЕ В СТОРОНУ. Один бар может дойти сразу # до трёх изломов, и каждый запишется отдельным наблюдением с почти # одинаковым исходом. Из совпавших остаётся уровень, ближайший # к закрытию дня. Найдено 10 сентября 2026 на расчёте TW-030. best = {} for g in got: key = (g["hit"], g["kind"]) # ВЧЕРАШНЕЕ ЗАКРЫТИЕ, А НЕ ЗАКРЫТИЕ ДНЯ КАСАНИЯ (B-47, 9 октября 2026). # При входе по касанию закрытия этого дня ещё нет, а выбор по нему # сдвигал результат: у TW-027 вход по касанию со стопом в ход и трейлом # −0.028R → −0.063R, у TW-034 вилка 0.144R → 0.203R. Выбор по открытию # и по вчерашнему закрытию дают одно и то же — выбросом было закрытие. near = abs(r[g["hit"] - 1]["c"] - g["price"]) if key not in best or near < best[key]["_near"]: best[key] = dict(g, _near=near) data[n] = [{k: v for k, v in g.items() if k != "_near"} for g in sorted(best.values(), key=lambda x: x["hit"])] # ПОГРЕШНОСТИ (9 октября 2026). Вывод стоял на медианах по шести и счёте # знаков, без погрешности: «шесть пунктов реже» нечем было отличить от # шума. Доля — по биному, медиана — бутстрепом (300 пересборок, # семя постоянное). Шесть инструментов складываются с весами по # точности, как в расчётах TW-022 и TW-023. import random as _random def share_err(A, B): pa = (sum(A) + 0.5) / (len(A) + 1) pb = (sum(B) + 0.5) / (len(B) + 1) return ((pa * (1 - pa) / max(len(A), 1) + pb * (1 - pb) / max(len(B), 1)) * 1e4) ** 0.5 def med_err(A, B): if len(A) < 2 or len(B) < 2: return float("inf") rng = _random.Random(7) vals = [] for _ in range(300): a_ = st.median(rng.choices(A, k=len(A))) b_ = st.median(rng.choices(B, k=len(B))) vals.append(a_ - b_) return st.pstdev(vals) def pool(pairs): w = [(d, 1 / e ** 2) for d, e in pairs if e and e != float("inf")] den = sum(x for _, x in w) return sum(d * x for d, x in w) / den, den ** -0.5 def said(d, e): return (f"ПОМЕРЕНА ({abs(d) / e:.1f} погр.)" if abs(d) >= 2 * e else f"не померена ({abs(d) / e:.1f} погр.)") size_pairs, poke_pairs, carry_pairs = [], [], [] print(f"\n1 · РАЗМЕР ДНЯ, В КОТОРЫЙ ЦЕНА ДОШЛА ДО УРОВНЯ") print(" В обычных дневных ходах этого инструмента, медиана:") print(f" {'':11}{'виден всем':>13}{'незаметный':>13}{'разница':>10}") diffs = [] for n, got in data.items(): a_ = st.median([g["size"] for g in got if g["seen"]] or [0]) b_ = st.median([g["size"] for g in got if not g["seen"]] or [0]) diffs.append(a_ - b_) e_ = med_err([g["size"] for g in got if g["seen"]], [g["size"] for g in got if not g["seen"]]) size_pairs.append((a_ - b_, e_)) print(f" {n:9} {a_:12.2f} {b_:12.2f} {a_ - b_:+9.2f} ±{e_:.2f}") med_size = st.median(diffs) size_pool, size_pool_e = pool(size_pairs) print(f" → медиана разницы: {st.median(diffs):+.2f} обычного дня") print(f" → по шести вместе: {size_pool:+.2f} ± {size_pool_e:.2f} — " f"{said(size_pool, size_pool_e)}") print(f"\n2 · ЛОЖНЫЙ ПРОБОЙ: ВЫШЛИ ЗА УРОВЕНЬ И ВЕРНУЛИСЬ ЗА {BACK} ДНЯ") print(f" {'':11}{'виден всем':>13}{'незаметный':>13}{'разница':>10}") diffs = [] for n, got in data.items(): A = [g["poke"] for g in got if g["seen"] and g["poke"] is not None] B = [g["poke"] for g in got if not g["seen"] and g["poke"] is not None] a_ = sum(A) / len(A) * 100 if A else 0.0 b_ = sum(B) / len(B) * 100 if B else 0.0 diffs.append(a_ - b_) e_ = share_err(A, B) poke_pairs.append((a_ - b_, e_)) print(f" {n:9} {a_:12.1f}% {b_:12.1f}% {a_ - b_:+9.1f} ±{e_:4.1f}" f" (n {len(A)} / {len(B)})") med_poke = st.median(diffs) poke_by = dict(zip(data.keys(), diffs)) poke_pool, poke_pool_e = pool(poke_pairs) print(f" → медиана разницы: {st.median(diffs):+.1f} п.п.") print(f" → по шести вместе: {poke_pool:+.1f} ± {poke_pool_e:.1f} п.п. — " f"{said(poke_pool, poke_pool_e)}") print(f"\n3 · ЕСЛИ ЗАКРЫЛИСЬ ЗА УРОВНЕМ — КАК ДАЛЕКО УШЛИ ЗА {ON} ДНЕЙ") print(" В обычных дневных ходах, медиана:") print(f" {'':11}{'виден всем':>13}{'незаметный':>13}{'разница':>10}") diffs = [] for n, got in data.items(): A = [g["carry"] for g in got if g["seen"] and g["carry"] is not None] B = [g["carry"] for g in got if not g["seen"] and g["carry"] is not None] a_ = st.median(A) if A else 0.0 b_ = st.median(B) if B else 0.0 diffs.append(a_ - b_) e_ = med_err(A, B) carry_pairs.append((a_ - b_, e_)) print(f" {n:9} {a_:12.2f} {b_:12.2f} {a_ - b_:+9.2f} ±{e_:.2f}" f" (n {len(A)} / {len(B)})") med_carry = st.median(diffs) carry_by = dict(zip(data.keys(), diffs)) carry_pool, carry_pool_e = pool(carry_pairs) print(f" → медиана разницы: {st.median(diffs):+.2f} обычного дня") print(f" → по шести вместе: {carry_pool:+.2f} ± {carry_pool_e:.2f} — " f"{said(carry_pool, carry_pool_e)}") print("\n4 · ТА ЖЕ ТРОЙКА ИЗМЕРЕНИЙ НА ОБЩЕМ ОКНЕ") print(" Разделы выше считались на всей истории, а у акций она с 2018") print(" года, у крипты — с конца 2023. Расхождение знака между двумя") print(" классами могло быть свойством КАЛЕНДАРЯ, а не рынка. Здесь всё") print(" пересчитано с общей даты — начала крипто-рядов.") # ОБЩЕЕ ОКНО ХРАНИТСЯ ПОИМЕННО. Вывод ниже утверждает, что знак # стал общим для обоих классов — это проверяется только по # каждому инструменту отдельно, а не по одной медиане. same: dict[str, dict] = {} start = min(r[0]["d"][:10] for n, r in rows.items() if n in CRYPTO) print(f" Общее окно: с {start}") print(f" {'':11}{'ход дня':>10}{'ложный пробой':>16}{'продолжение':>14}") for n, r in rows.items(): cut = [b for b in r if b["d"][:10] >= start] a = adr(r)[len(r) - len(cut):] rec = [] for lv in levels(cut): j = first_touch(cut, lv, a) if j is None: continue rec.append({"seen": lv["seen"], "size": day_size(cut, j, a), "poke": poked(cut, lv, j, a), "carry": carried(cut, lv, j, a)}) sz = (st.median([g["size"] for g in rec if g["seen"]] or [0]) - st.median([g["size"] for g in rec if not g["seen"]] or [0])) A = [g["poke"] for g in rec if g["seen"] and g["poke"] is not None] B = [g["poke"] for g in rec if not g["seen"] and g["poke"] is not None] pk = ((sum(A) / len(A) if A else 0) - (sum(B) / len(B) if B else 0)) * 100 C = [g["carry"] for g in rec if g["seen"] and g["carry"] is not None] Dd = [g["carry"] for g in rec if not g["seen"] and g["carry"] is not None] cr = (st.median(C) if C else 0) - (st.median(Dd) if Dd else 0) same[n] = {"size": sz, "poke": pk, "carry": cr} print(f" {n:9} {sz:+9.2f} {pk:+15.1f} {cr:+13.2f}") print(" (разница «виден всем» минус «незаметный», как в разделах выше)") # ВЫВОД СЧИТАЕТСЯ. Прежде числа стояли словами: «минус шесть # с половиной», «у Apple минус двадцать два», «от минус ноль целых # четыре до плюс ноль целых шесть». Таблицы менялись, слова нет. poke_all = [v["poke"] for v in same.values()] carry_all = [v["carry"] for v in same.values()] size_all = {n: v["size"] for n, v in same.items()} st_size = [size_all[n] for n in NAMES if n not in CRYPTO] cr_size = [size_all[n] for n in NAMES if n in CRYPTO] worst_poke = min(poke_by, key=poke_by.get) print("") print("ВЫВОД, ПОСЧИТАННЫЙ ЭТИМ ЖЕ ПРОГОНОМ.") print("") rows_ = [("Ложный пробой у видимого уровня против незаметного", poke_pool, poke_pool_e, "п.п.", poke_by), ("Продолжение после закрытия за уровнем", carry_pool, carry_pool_e, "обычного дня", carry_by), ("Ход дня, в который цена дошла до уровня", size_pool, size_pool_e, "обычного дня", dict(zip(data.keys(), [d for d, _ in size_pairs])))] found = [] for title, d, e, unit, by in rows_: ok = abs(d) >= 2 * e if ok: found.append(title) print(f"{title}: {d:+.2f} ± {e:.2f} {unit} — " + ("померено." if ok else "не померено, в пределах двух погрешностей.")) print(f" Минус у {sum(1 for x in by.values() if x < 0)} из {len(by)}," f" плюс у {sum(1 for x in by.values() if x > 0)}.") print("") if found: print("Померено: " + "; ".join(found) + ".") else: print("Ни одна из трёх разниц не выходит за две погрешности: на дневных") print("барах видимый уровень не отличается от незаметного ни пробоями,") print("ни продолжением, ни размером дня касания.") print("") # КАЛЕНДАРЬ ПРОВЕРЯЕТСЯ, А НЕ РАССКАЗЫВАЕТСЯ. Прежде здесь стояла фраза # «расхождение исчезло, знак стал общим» — верная для прежних чисел. stocks = [n for n in NAMES if n not in CRYPTO] coins = [n for n in NAMES if n in CRYPTO] split_full = (all(carry_by[n] < 0 for n in stocks) and all(carry_by[n] > 0 for n in coins)) pos = sum(1 for x in carry_all if x > 0) print("Продолжение на всей истории: акции " + ", ".join( f"{n} {carry_by[n]:+.2f}" for n in stocks) + "; монеты " + ", ".join( f"{n} {carry_by[n]:+.2f}" for n in coins) + ".") print("На общем окне: " + ", ".join(f"{n} {same[n]['carry']:+.2f}" for n in NAMES) + f" — плюс у {pos} из {len(carry_all)}.") if split_full: print("На всей истории классы расходятся знаком, но истории у них разной") print("длины: сравнивать классы можно только на общем окне.") both = "сохранился" if (max(st_size) < 0 < min(cr_size)) else "НЕ сохранился" print(f"Ход дня на общем окне: знак акций против монет {both}; акции от" f" {min(st_size):+.2f} до {max(st_size):+.2f}, монеты от" f" {min(cr_size):+.2f} до {max(cr_size):+.2f} обычного дня.") return 0 def _run() -> int: """Прогон со снимком: что напечатано, то и легло в `results.txt`. Пока файл клали руками, он отставал от скрипта, и сценарий писался по устаревшим числам. Здесь отставать нечему. Снимок пишется только при удачном прогоне. Обрубок опаснее отсутствия: он выглядит как настоящий отчёт. """ import io 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())