#!/usr/bin/env python3 """Все числа TW-023 одним прогоном. Тема L-06: какой уровень из десяти торговать. ЗАЧЕМ ЭТОТ ФАЙЛ ЛЕЖИТ В ПРОЕКТЕ. Канал обещает: всё измерено, а не заявлено. Обещание стоит ровно столько, сколько стоит возможность пересчитать. ОТКУДА ВОПРОС. Предыдущий ролик (TW-022, расчёт `turns.py`) показал: оснований для уровня около шестидесяти на сотню дней, а само основание даёт прибавку к отскоку в среднем восемь с половиной пунктов — эффект есть, но торговать любую такую линию нельзя. Значит нужен отбор. Здесь проверяются пять признаков, по которым уровни обычно ранжируют. 1. СКОЛЬКО РАЗ УРОВЕНЬ УЖЕ ПРОВЕРЯЛИ. Расхожее: чем больше касаний, тем сильнее уровень. Есть и обратное мнение: каждое касание съедает заявки, и уровень слабеет. Проверяются оба. 2. ВОЗРАСТ. Свежий уровень против старого. 3. ВИДИМОСТЬ. Совпадает ли уровень с экстремумом за шестьдесят дней — то есть виден ли он всем, а не только тому, кто листал график. 4. КРУГЛОЕ ЧИСЛО. Близость к круглой цене. 5. ЧЕМ СОЗДАН. Широкий бар или обычный. КАК СРАВНИВАЕТСЯ. Для каждого признака уровни делятся на группы, и в каждой считается доля отскоков при ПЕРВОМ возврате цены. База — все изломы того же инструмента. Разница с базой в процентных пунктах и есть ответ: работает признак или это фольклор. ОПРЕДЕЛЕНИЯ ТЕ ЖЕ, ЧТО В TW-022, и это принципиально: касание — подход ближе четверти обычного дня, отскок — уход на полдня обратно за пять дней. Менять мерку между роликами нельзя, иначе числа перестанут быть сравнимыми. ДАННЫЕ. `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"] WING = 2 # излом: экстремум выше (ниже) двух соседей с каждой стороны WIDE = 1.8 # «широкий бар» — во столько раз больше обычного дня NEAR = 0.25 # подход: ближе четверти обычного дня ROLL = 60 # обычный ход: медиана high−low за 60 баров до бара AWAY = 0.5 # отскок: полдня в обратную сторону AHEAD = 5 # за сколько дней должен случиться отскок HORIZON = 120 # сколько дней ждём возврата SEEN = 60 # окно «виден всем»: экстремум за столько дней 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], a: float) -> 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 window = r[max(0, i - SEEN):i + 1] seen = (price >= max(b["h"] for b in window) if kind == "high" else price <= min(b["l"] for b in window)) step = 10 ** (len(str(int(price))) - 1) if price >= 1 else 0.1 near_round = abs(price - round(price / step) * step) < 0.02 * price out.append({"i": i, "price": price, "kind": kind, "seen": seen, "round": near_round, "wide": (r[i]["h"] - r[i]["l"]) > WIDE * a[i]}) return out def touches(r: list[dict], lv: dict, a: float) -> list[int]: """ВСЕ раздельные касания уровня, пока он жив. ПОЧЕМУ НЕ ОДНО ПЕРВОЕ. Первая версия этого расчёта считала признак «сколько раз уровень уже проверяли» на первом же касании — где таких проверок по определению ноль. Все группы вышли пустыми, и таблица показывала нули. Признак существует только на ВТОРОМ и дальше касании, поэтому здесь собирается вся последовательность. РАЗДЕЛЬНЫЕ — значит между ними цена уходила дальше половины дневного хода: иначе неделя топтания у линии считалась бы пятью тестами. УРОВЕНЬ ЖИВЁТ, ПОКА НЕ ПРОБИТ. После закрытия за ним дальше чем на один дневной ход это уже не тот уровень, и считать его касания значит мерить другое событие. """ out, away = [], True # ИЗЛОМ ИЗВЕСТЕН ТОЛЬКО ЧЕРЕЗ 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))): beyond = (r[j]["c"] > lv["price"] + a[j] if lv["kind"] == "high" else r[j]["c"] < lv["price"] - a[j]) if beyond: break touching = r[j]["l"] - NEAR * a[j] <= lv["price"] <= r[j]["h"] + NEAR * a[j] if touching and away: out.append(j) away = False elif not touching and abs(r[j]["c"] - lv["price"]) > AWAY * a[j]: away = True return out def bounced(r: list[dict], lv: dict, hit: int, a: float) -> bool: seg = r[hit + 1:min(hit + 1 + AHEAD, len(r))] if not seg: return False if lv["kind"] == "high": return min(b["l"] for b in seg) <= lv["price"] - AWAY * a[hit] return max(b["h"] for b in seg) >= lv["price"] + AWAY * a[hit] def share(vals: list[bool]) -> float: return sum(vals) / len(vals) * 100 if vals else 0.0 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, a): for k, hit in enumerate(touches(r, lv, a)): got.append(dict(lv, hit=hit, back=bounced(r, lv, hit, a), age=hit - lv["i"], tests=k)) # ОДИН ДЕНЬ — ОДНО КАСАНИЕ В СТОРОНУ. Один бар может задеть три # излома подряд, и каждый запишется отдельным наблюдением с почти # одинаковым исходом: выборка выглядит независимее, чем есть. # На этих данных повторов 27 %. Из совпавших остаётся уровень, # ближайший к закрытию дня. # # Найдено 10 сентября 2026 на расчёте TW-030 и проверено здесь. best: dict[tuple[int, str], dict] = {} 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"])] print("\nБАЗА: ВСЕ ИЗЛОМЫ, ДОЖДАВШИЕСЯ ВОЗВРАТА") base = {} for n, got in data.items(): base[n] = share([g["back"] for g in got]) print(f" {n:9} {len(got):5} уровней · отскок {base[n]:5.1f}%") # РАЗБОР ХРАНИТСЯ, А НЕ ТОЛЬКО ПЕЧАТАЕТСЯ. Вывод внизу файла # собирается из этих же чисел: иначе он живёт отдельной # жизнью и через неделю противоречит таблицам над собой. keep: dict[str, dict] = {} print("\n1 · СКОЛЬКО РАЗ УРОВЕНЬ УЖЕ ПРОВЕРЯЛИ ДО ЭТОГО КАСАНИЯ") print(f" {'':11}{'0 тестов':>11}{'1':>9}{'2+':>9}{'2+ − 0':>10}" f"{'касаний с 2+':>15}") diffs = [] once = [] # «один тест» против «ни одного» — это число # называет TW-025 s16 («вычесть 10.7»), а печаталось только # «два и больше». Число, которое произносит ролик, обязано быть # напечатано расчётом, иначе сверять его не с чем. for n, got in data.items(): v0 = [g["back"] for g in got if g["tests"] == 0] v1 = [g["back"] for g in got if g["tests"] == 1] v2 = [g["back"] for g in got if g["tests"] >= 2] g0, g1, g2 = share(v0), share(v1), share(v2) diffs.append((n, g2 - g0)) once.append(g1 - g0) print(f" {n:9} {g0:10.1f}% {g1:8.1f}% {g2:8.1f}% {g2 - g0:+9.1f}" f"{len(v2):11} из {len(got)}") keep["tests"] = dict(diffs) print(f" → «два и больше» против «ни одного»: медиана " f"{st.median([x for _, x in diffs]):+.1f} п.п.") print(f" → «один» против «ни одного»: медиана " f"{st.median(once):+.1f} п.п.") print("\n2 · ВОЗРАСТ УРОВНЯ К МОМЕНТУ КАСАНИЯ") print(f" {'':11}{'до 10 дней':>13}{'10–40':>9}{'40+':>9}{'40+ − до 10':>14}") diffs = [] for n, got in data.items(): y = share([g["back"] for g in got if g["age"] <= 10]) m = share([g["back"] for g in got if 10 < g["age"] <= 40]) o = share([g["back"] for g in got if g["age"] > 40]) diffs.append((n, o - y)) print(f" {n:9} {y:12.1f}% {m:8.1f}% {o:8.1f}% {o - y:+13.1f}") keep["age"] = dict(diffs) print(f" → старые против свежих: медиана {st.median([x for _, x in diffs]):+.1f} п.п.") print(f"\n3 · ВИДЕН ЛИ УРОВЕНЬ ВСЕМ (экстремум за {SEEN} дней)") print(f" {'':11}{'виден':>9}{'не виден':>11}{'разница':>10}") diffs = [] for n, got in data.items(): a_ = share([g["back"] for g in got if g["seen"]]) b_ = share([g["back"] for g in got if not g["seen"]]) diffs.append((n, a_ - b_)) print(f" {n:9} {a_:8.1f}% {b_:10.1f}% {a_ - b_:+9.1f}") keep["seen"] = dict(diffs) print(f" → видимый против незаметного: медиана {st.median([x for _, x in diffs]):+.1f} п.п.") print("\n4 · КРУГЛОЕ ЧИСЛО ПОБЛИЗОСТИ") print(f" {'':11}{'круглый':>10}{'обычный':>10}{'разница':>10}") diffs = [] for n, got in data.items(): a_ = share([g["back"] for g in got if g["round"]]) b_ = share([g["back"] for g in got if not g["round"]]) diffs.append((n, a_ - b_)) print(f" {n:9} {a_:9.1f}% {b_:9.1f}% {a_ - b_:+9.1f}") keep["round"] = dict(diffs) print(f" → круглый против обычного: медиана {st.median([x for _, x in diffs]):+.1f} п.п.") print("\n5 · ЧЕМ СОЗДАН УРОВЕНЬ") print(f" {'':11}{'широкий бар':>14}{'обычный':>10}{'разница':>10}") diffs = [] for n, got in data.items(): a_ = share([g["back"] for g in got if g["wide"]]) b_ = share([g["back"] for g in got if not g["wide"]]) diffs.append((n, a_ - b_)) print(f" {n:9} {a_:13.1f}% {b_:9.1f}% {a_ - b_:+9.1f}") keep["wide"] = dict(diffs) print(f" → широкий против обычного: медиана {st.median([x for _, x in diffs]):+.1f} п.п.") print("\n6 · ЛУЧШИЙ ПРИЗНАК ПРОТИВ ХУДШЕГО, ВМЕСТЕ") print(" Уровень, который виден всем И проверялся минимум дважды,") print(" против уровня, который не виден и не проверялся ни разу:") tops, bots = [], [] for n, got in data.items(): top = share([g["back"] for g in got if g["seen"] and g["tests"] >= 2]) bot = share([g["back"] for g in got if not g["seen"] and g["tests"] == 0]) nt = len([g for g in got if g["seen"] and g["tests"] >= 2]) tops.append(top) bots.append(bot) print(f" {n:9} отбор {top:5.1f}% (n={nt:4}) остальное {bot:5.1f}%" f" {top - bot:+6.1f}") keep["combo"] = {n: t_ - b_ for n, t_, b_ in zip(data.keys(), tops, bots)} print(f" → медиана разницы: " f"{st.median([t - b for t, b in zip(tops, bots)]):+.1f} п.п.") # ВЫВОД СЧИТАЕТСЯ. Прежде тут стояли те же утверждения числами, # набранными словами; таблицы менялись, слова нет, и описание ролика # обещало «минус пятнадцать» там, где посчитано минус тринадцать. def med(key): return st.median(list(keep[key].values())) def negs(key): return sum(1 for x in keep[key].values() if x < 0) def poss(key): return sum(1 for x in keep[key].values() if x > 0) def worst(key, group): pick = {n: x for n, x in keep[key].items() if n in group} return min(pick, key=pick.get), min(pick.values()) def best(key, group=None): pick = keep[key] if group is None else { n: x for n, x in keep[key].items() if n in group} return max(pick, key=pick.get), max(pick.values()) # ПОГРЕШНОСТЬ У КАЖДОГО ПРИЗНАКА (9 октября 2026). Правило канала — # разница померена, только если она не меньше двух своих погрешностей, — # здесь не применялось: вывод стоял на медианах и счёте знаков. После # правки окна подтверждения излома медианы сжались в разы, и без # погрешности уже не сказать, что из них отличимо от нуля. # Разница берётся по каждому инструменту отдельно и складывается с # весами по точности (обратная дисперсия): монеты и акции отскакивают # с разной базовой частотой, и простая сумма касаний мерила бы состав # выборки, а не признак. Касания одного уровня не вполне независимы, # так что погрешность скорее занижена, чем завышена. def strat(split): num = den = 0.0 for got in data.values(): a_, b_ = split(got) if len(a_) < 2 or len(b_) < 2: continue pa = (sum(a_) + 0.5) / (len(a_) + 1) pb = (sum(b_) + 0.5) / (len(b_) + 1) var = (pa * (1 - pa) / len(a_) + pb * (1 - pb) / len(b_)) * 1e4 num += (share(a_) - share(b_)) / var den += 1 / var return num / den, den ** -0.5 def pick(cond_a, cond_b): return lambda got: ([g["back"] for g in got if cond_a(g)], [g["back"] for g in got if cond_b(g)]) splits = { "tests": ("проверен 2+ раз против ни разу", pick(lambda g: g["tests"] >= 2, lambda g: g["tests"] == 0)), "once": ("проверен 1 раз против ни разу", pick(lambda g: g["tests"] == 1, lambda g: g["tests"] == 0)), # «Хотя бы раз» — тот признак, по которому TW-025 ставит балл: # непроверенный уровень против любого проверенного. "any": ("проверен хотя бы раз против ни разу", pick(lambda g: g["tests"] >= 1, lambda g: g["tests"] == 0)), "age": ("старше 40 дней против младше 10", pick(lambda g: g["age"] > 40, lambda g: g["age"] <= 10)), "seen": (f"экстремум за {SEEN} дней против незаметного", pick(lambda g: g["seen"], lambda g: not g["seen"])), "round": ("круглое число против обычного", pick(lambda g: g["round"], lambda g: not g["round"])), "wide": ("излом на широком баре против обычного", pick(lambda g: g["wide"], lambda g: not g["wide"])), "combo": ("виден и проверен 2+ против не виден и не проверен", pick(lambda g: g["seen"] and g["tests"] >= 2, lambda g: not g["seen"] and g["tests"] == 0)), } print("") print("7 · ЧТО ИЗ ЭТОГО ПОМЕРЕНО") print(" Разница по каждому инструменту, сложенная с весами по точности.") meas = {} for key, (title, split) in splits.items(): d, e = strat(split) meas[key] = (d, e, abs(d) >= 2 * e) print(f" {title:50} {d:+6.1f} ± {e:3.1f} п.п. " f"{'ПОМЕРЕНА' if meas[key][2] else 'не померена'}" f" ({abs(d) / e:.1f} погр.)") six = len(keep["tests"]) print("") print("ВЫВОД, ПОСЧИТАННЫЙ ЭТИМ ЖЕ ПРОГОНОМ.") print("") claims = [("tests", "«Уровень, проверенный много раз, сильнее»"), ("seen", "«Уровень, который видят все, сильнее»"), ("round", "«Круглое число держит»"), ("age", "«Старый уровень надёжнее свежего»"), ("wide", "«Излом на широком баре сильнее»")] for key, claim in claims: d, e, ok = meas[key] if ok: how = "померено, и " + ("в минус: обещание перевёрнуто" if d < 0 else "в плюс: обещание подтвердилось") else: how = "не померено: разница в пределах двух погрешностей" print(f"{claim}: {d:+.1f} ± {e:.1f} пункта — {how}.") print(f" Медиана по инструментам {med(key):+.1f}; минус у {negs(key)}" f" из {six}, плюс у {poss(key)}.") d, e, ok = meas["once"] print(f"Один тест против ни одного: {d:+.1f} ± {e:.1f} пункта" f" ({'померено' if ok else 'не померено'}).") d, e, ok = meas["any"] print(f"Хотя бы один тест против ни одного: {d:+.1f} ± {e:.1f} пункта" f" ({'померено' if ok else 'не померено'}).") print("") d, e, ok = meas["combo"] lo = min(keep["combo"].values()) hi = max(keep["combo"].values()) print("Отбор по двум «народным» признакам сразу — виден всем и проверен") print(f"дважды — против незаметного и непроверенного: {d:+.1f} ± {e:.1f}" f" пункта ({'померено' if ok else 'не померено'}), медиана") print(f"{med('combo'):+.1f}, разброс от {lo:+.1f} до {hi:+.1f}.") thin = min(keep["combo"], key=lambda n: keep["combo"][n]) print("ОСТОРОЖНО: такой отбор оставляет единицы уровней на инструмент,") print(f"и крайнее значение {keep['combo'][thin]:+.1f} у {thin} держится на" f" нескольких случаях.") 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())