#!/usr/bin/env python3 """Все числа TW-027 одним прогоном. Тема S-01: отбой от уровня по частям. ЗАЧЕМ ЭТОТ ФАЙЛ ЛЕЖИТ В ПРОЕКТЕ. Канал обещает: всё измерено, а не заявлено. Обещание стоит ровно столько, сколько стоит возможность пересчитать. ПОЧЕМУ ВОПРОС ПОСТАВЛЕН ТАК. Предыдущий ролик (TW-026, расчёт `samesetup.py`) измерил механический отбой от уровня и получил минус на всех шести инструментах. Делать после этого ролик «как торговать отбой» нельзя. Поэтому здесь вопрос другой: сетап состоит из ТРЁХ решений, и каждое принимают по привычке. Что даёт каждое из них по отдельности? 1. ВХОД. По касанию уровня — лимитной заявкой, как в TW-026. Или по подтверждению: дождаться закрытия бара обратно от уровня и входить на следующий день. 2. СТОП. За уровнем на 0,5 / 1,0 / 1,5 дневного хода — или за экстремумом того бара, который коснулся уровня. 3. ВЫХОД. Фиксированная цель в два стопа; выход по времени через десять дней; или сопровождение — выход по закрытию ниже (выше) минимума (максимума) прошлого дня. Итого двадцать четыре сочетания на каждом из шести инструментов. Считается всё, а не только то, что подтвердит заранее выбранный ответ. ЧЕСТНОСТЬ УЧЁТА — ТА ЖЕ, ЧТО В TW-026, и она там стоила трёх переписываний: вход только если цена действительно дошла; подход к уровню — с правильной стороны; в день входа засчитывается стоп, но не цель. ДАННЫЕ. `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" STOCKS = ["AAPL", "TSLA", "SPY"] CRYPTO = ["BTCUSDT", "ETHUSDT", "SOLUSDT"] WING = 2 LIVE = 3.0 HOLD = 10 TARGET = 2.0 ROLL = 60 # обычный ход: медиана high−low за 60 баров до бара ENTRIES = ["touch", "confirm"] STOPS = [0.5, 1.0, 1.5, "bar"] EXITS = ["target", "time", "trail"] 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 signals(r: list[dict], a: float) -> list[dict]: """Касания непроверенных уровней с правильной стороны — сырьё сетапа.""" out = [] for i in range(WING, 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 hit = None for j in range(i + 1, min(i + 1 + 120, len(r) - HOLD - 2)): if r[j]["l"] <= price <= r[j]["h"]: hit = j break if hit is None or a[hit] is None: continue # бар без истории хода не торгуется prev = r[hit - 1]["c"] if kind == "high" and prev >= price: continue if kind == "low" and prev <= price: continue if abs(prev - price) / a[hit] > LIVE: continue out.append({"hit": hit, "price": price, "kind": kind}) # ОДИН ДЕНЬ — ОДНО КАСАНИЕ В СТОРОНУ. Один бар может задеть сразу # несколько изломов подряд, и каждый формально «коснулся впервые». # Считать это несколькими сделками нельзя: трейдер видит одно движение # и входит один раз, а выборка раздувается почти одинаковыми исходами # и делает вид, что независимых наблюдений втрое больше. # # Найдено 10 сентября 2026 при работе над TW-030 и перепроверено здесь. # Из совпавших остаётся уровень, ближайший к закрытию дня. best: dict[tuple[int, str], dict] = {} for s in out: key = (s["hit"], s["kind"]) # ВЧЕРАШНЕЕ ЗАКРЫТИЕ, А НЕ ЗАКРЫТИЕ ДНЯ КАСАНИЯ (B-47, 9 октября 2026). # При входе по касанию закрытия этого дня ещё нет, а выбор по нему # сдвигал результат: у TW-027 вход по касанию со стопом в ход и трейлом # −0.028R → −0.063R, у TW-034 вилка 0.144R → 0.203R. Выбор по открытию # и по вчерашнему закрытию дают одно и то же — выбросом было закрытие. near = abs(r[s["hit"] - 1]["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["hit"])] def run(r: list[dict], a: float, sig: list[dict], entry: str, stop_rule, exit_rule: str, impossible: bool = False) -> tuple[int, float, float]: """Один набор решений. Возвращает число сделок, сумму результатов в R и медианный размер стопа в процентах цены — последнее нужно, чтобы перевести комиссию в доли R (тем же способом, что в TW-026: круг, делённый на стоп). `impossible=True` снимает запрет на сочетание «вход по касанию + стоп за экстремумом бара касания». Нужен ровно затем, чтобы фраза ролика про это сочетание опиралась на прогон, а не на память о прежней версии расчёта. """ n, total, pcts = 0, 0.0, [] for s in sig: hit, price, kind = s["hit"], s["price"], s["kind"] if entry == "touch": day, fill = hit, price else: # ПОДТВЕРЖДЕНИЕ. Бар касания должен закрыться обратно от уровня; # вход по открытию следующего дня — то есть по цене, которую # реально можно получить, а не по своей же линии. b = r[hit] back = (b["c"] < price if kind == "high" else b["c"] > price) if not back or hit + 1 >= len(r): continue day, fill = hit + 1, r[hit + 1]["o"] if stop_rule == "bar": # НЕВОЗМОЖНОЕ СОЧЕТАНИЕ. Экстремум бара касания известен только # после его закрытия, а вход «по касанию» происходит внутри # этого же бара. Ставить стоп за тем, чего ещё нет, нельзя — # в первой версии расчёта это давало ровно минус один R на всех # сделках, потому что стоп срабатывал в момент входа. if entry == "touch" and not impossible: continue stop = (r[hit]["h"] if kind == "high" else r[hit]["l"]) gap = abs(stop - fill) if gap <= 0: continue else: stop = (price + stop_rule * a[hit] if kind == "high" else price - stop_rule * a[hit]) gap = abs(stop - fill) if gap <= 0: continue # ВХОД, УЖЕ СТОЯЩИЙ ЗА СТОПОМ, — НЕ СДЕЛКА. У короткой позиции # (продажа от сопротивления) стоп выше входа, у длинной ниже. # Если геп унёс цену дальше стопа, позиция закрылась бы в момент # открытия, и брать её нельзя — а расчёт брал и писал ровно −1R. # Найдено 10 сентября 2026 на TSLA в расчёте TW-030. if not impossible and ((kind == "high" and fill >= stop) or (kind == "low" and fill <= stop)): continue target = (fill - TARGET * gap if kind == "high" else fill + TARGET * gap) res = None for j in range(day, min(day + 1 + HOLD, len(r))): first = (j == day) hi, lo = r[j]["h"], r[j]["l"] if kind == "high": if hi >= stop: res = -1.0 break if exit_rule == "target" and not first and lo <= target: res = TARGET break if (exit_rule == "trail" and not first and r[j]["c"] > r[j - 1]["h"]): res = (fill - r[j]["c"]) / gap break else: if lo <= stop: res = -1.0 break if exit_rule == "target" and not first and hi >= target: res = TARGET break if (exit_rule == "trail" and not first and r[j]["c"] < r[j - 1]["l"]): res = (r[j]["c"] - fill) / gap break if res is None: last = r[min(day + HOLD, len(r) - 1)]["c"] res = ((fill - last) / gap if kind == "high" else (last - fill) / gap) n += 1 # пол риска теперь общий (FLOOR из tw_accounting), а не # записанный числом здесь total += res if impossible else res * min(1.0, gap / (FLOOR * a[hit])) pcts.append(gap / fill * 100) return n, total, (st.median(pcts) if pcts 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 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()} print("ДАННЫЕ") print(f" Общее окно с {start}.") for n, r in rows.items(): print(f" {n:9} {len(r)} баров") sig = {n: signals(r, adr(r)) for n, r in rows.items()} print("\nСЫРЬЁ: КАСАНИЙ НЕПРОВЕРЕННЫХ УРОВНЕЙ") for n in rows: print(f" {n:9} {len(sig[n]):5}") print("\n1 · ВСЕ ДВАДЦАТЬ ЧЕТЫРЕ СОЧЕТАНИЯ, СРЕДНЕЕ ПО ШЕСТИ") print(" Средний результат сделки в R, до издержек.") print(f" {'вход':>9}{'стоп':>7}{'выход':>9}{'сделок':>9}{'R/сделку':>11}") table = [] for entry in ENTRIES: for stop_rule in STOPS: for exit_rule in EXITS: tot_n, tot_r = 0, 0.0 per = [] for n, r in rows.items(): a = adr(r) k, s_, _ = run(r, a, sig[n], entry, stop_rule, exit_rule) if k: per.append(s_ / k) tot_n += k tot_r += s_ if not tot_n: continue avg = tot_r / tot_n table.append((avg, entry, stop_rule, exit_rule, tot_n, per)) name = (stop_rule if isinstance(stop_rule, str) else f"{stop_rule:.1f}") print(f" {entry:>9}{name:>7}{exit_rule:>9}{tot_n:9}" f"{avg:+10.3f}R") table.sort(reverse=True) print("\n2 · ЛУЧШЕЕ И ХУДШЕЕ") for tag, row in (("лучшее", table[0]), ("худшее", table[-1])): avg, entry, stop_rule, exit_rule, k, per = row name = stop_rule if isinstance(stop_rule, str) else f"{stop_rule:.1f}" print(f" {tag}: вход {entry}, стоп {name}, выход {exit_rule} — " f"{avg:+.3f}R на {k} сделках") print(f" по инструментам: " f"{', '.join(f'{x:+.2f}' for x in per)}") print("\n3 · ЧТО РЕШАЕТ КАЖДОЕ ИЗ ТРЁХ РЕШЕНИЙ ПО ОТДЕЛЬНОСТИ") print(" Среднее по всем сочетаниям, где это решение зафиксировано:") dec = {} for label, idx, options in (("вход", 1, ENTRIES), ("стоп", 2, STOPS), ("выход", 3, EXITS)): print(f" {label}:") for opt in options: vals = [t[0] for t in table if t[idx] == opt] if vals: name = opt if isinstance(opt, str) else f"{opt:.1f}" dec[(label, name)] = st.mean(vals) print(f" {name:>8} {st.mean(vals):+.3f}R" f" (лучшее {max(vals):+.3f})") # ВХОД — НА ОБЩИХ СТОПАХ, ПАРАМИ. Среднее «confirm» выше среднего «touch» # только потому, что у подтверждения есть стоп «за баром касания», а у # входа по касанию его быть не может: бар ещё не закрылся (B-05 второго # аудита). Сравнение решения «как входить» честно только там, где стоп # и выход одинаковы у обоих. common = [s for s in STOPS if all(any(t[1] == e and t[2] == s for t in table) for e in ENTRIES)] pairs = [] for s in common: for x in EXITS: a_ = [t[0] for t in table if t[1] == "touch" and t[2] == s and t[3] == x] b_ = [t[0] for t in table if t[1] == "confirm" and t[2] == s and t[3] == x] if a_ and b_: pairs.append((a_[0], b_[0])) ahead = sum(1 for a_, b_ in pairs if b_ > a_) print(f" вход на общих стопах ({', '.join(f'{s:.1f}' for s in common)}), " f"парами по выходу:") print(f" touch {st.mean(a_ for a_, _ in pairs):+.3f}R " f"confirm {st.mean(b_ for _, b_ in pairs):+.3f}R " f"confirm впереди в {ahead} парах из {len(pairs)}") print("\n4 · ПРОВЕРКА ВЫБОРА НА ОТДЕЛЬНОЙ ПОЛОВИНЕ") print(" Лучшее из двух десятков сочетаний — это выбор, сделанный") print(" ПО ТЕМ ЖЕ данным, на которых он потом хвалится. Поэтому:") print(" первая половина окна — чтобы выбрать, вторая — чтобы измерить.") half = {n: (r[:len(r) // 2], r[len(r) // 2:]) for n, r in rows.items()} best, best_avg = None, -99.0 for entry in ENTRIES: for stop_rule in STOPS: for exit_rule in EXITS: k_all, r_all = 0, 0.0 for n, (first, _) in half.items(): a = adr(first) k, s_, _ = run(first, a, signals(first, a), entry, stop_rule, exit_rule) k_all += k r_all += s_ if k_all and r_all / k_all > best_avg: best_avg = r_all / k_all best = (entry, stop_rule, exit_rule) best_n = k_all entry, stop_rule, exit_rule = best name = stop_rule if isinstance(stop_rule, str) else f"{stop_rule:.1f}" print(f" Выбрано на первой половине: вход {entry}, стоп {name}, " f"выход {exit_rule} — {best_avg:+.3f}R на {best_n} сделках") k_all, r_all, per = 0, 0.0, [] for n, (_, second) in half.items(): a = adr(rows[n])[len(half[n][0]):] # ход второй половины знает только прошлое k, s_, _ = run(second, a, signals(second, a), entry, stop_rule, exit_rule) if k: per.append(s_ / k) k_all += k r_all += s_ out = r_all / k_all if k_all else 0.0 k_all_check = k_all print(f" На второй половине то же сочетание: {out:+.3f}R " f"на {k_all_check} сделках") print(f" по инструментам: {', '.join(f'{x:+.2f}' for x in per)}") # СКОЛЬКО СОЧЕТАНИЙ ХУЖЕ НА ВТОРОЙ ПОЛОВИНЕ. Голос s39 называет это # число («and it is not only the winner»), а вывод до 8 октября его не # печатал — сверять было не с чем (B-26 второго аудита). Ход второй # половины — как у выбранного сочетания: только прошлое. worse = legal = 0 for entry_ in ENTRIES: for stop_ in STOPS: for exit_ in EXITS: k1 = r1 = k2 = r2 = 0 for n, (first, second) in half.items(): a1 = adr(first) k, s_, _ = run(first, a1, signals(first, a1), entry_, stop_, exit_) k1, r1 = k1 + k, r1 + s_ a2 = adr(rows[n])[len(first):] k, s_, _ = run(second, a2, signals(second, a2), entry_, stop_, exit_) k2, r2 = k2 + k, r2 + s_ if not k1 or not k2: continue legal += 1 worse += r2 / k2 < r1 / k1 print(f" хуже на второй половине: {worse} сочетаний из {legal}") # РАЗДЕЛ 5 · ИЗДЕРЖКИ. Таблица раздела 1 — до издержек, и на ней # часть сочетаний слегка в плюсе. Фраза «ни одно улучшение не выводит # сетап в плюс» без вычета комиссии непроверяема. Способ тот же, # что в TW-026: круг, делённый на стоп в долях цены. FEE_STOCK, FEE_CRYPTO = 0.0005, 0.0020 print("\n5 · ТЕ ЖЕ СОЧЕТАНИЯ ПОСЛЕ ИЗДЕРЖЕК") print(f" Круг стоит {FEE_STOCK:.2%} позиции на акциях и" f" {FEE_CRYPTO:.2%} на крипте.") print(f" {'вход':>9}{'стоп':>7}{'выход':>9}{'до':>10}{'после':>10}") nets = [] for avg, entry, stop_rule, exit_rule, k, per in table: tot_n, tot_net = 0, 0.0 for n, r in rows.items(): kk, ss, pct = run(r, adr(r), sig[n], entry, stop_rule, exit_rule) if not kk or pct <= 0: continue fee = FEE_CRYPTO if n in CRYPTO else FEE_STOCK tot_n += kk tot_net += ss - fee / (pct / 100) * kk if not tot_n: continue net = tot_net / tot_n nets.append((net, entry, stop_rule, exit_rule)) name = stop_rule if isinstance(stop_rule, str) else f"{stop_rule:.1f}" print(f" {entry:>9}{name:>7}{exit_rule:>9}{avg:+9.3f}R{net:+9.3f}R") nets.sort(reverse=True) # РАЗДЕЛ 6 · ТО ЖЕ ЛЕКАРСТВО ДЛЯ ТОГО ЖЕ СОЧЕТАНИЯ. Лучшее после # издержек — всё равно лучшее ИЗ ДВАДЦАТИ ОДНОГО, выбранное по тем же # данным, на которых измерено. Раздел 4 показал, чего стоит такой # выбор; здесь он применяется к нему самому. best_net, ne, ns, nx = nets[0] ns_name = ns if isinstance(ns, str) else f"{ns:.1f}" print("\n6 · ЛУЧШЕЕ ПОСЛЕ ИЗДЕРЖЕК — НА ОТДЕЛЬНОЙ ПОЛОВИНЕ") print(f" Сочетание: вход {ne}, стоп {ns_name}, выход {nx}" f" — {best_net:+.3f}R по всему окну.") halves = [] for tag, which in (("первая половина", 0), ("вторая половина", 1)): k_all, r_all, net_all = 0, 0.0, 0.0 for n, pair in half.items(): part = pair[which] a = adr(rows[n])[len(pair[0]) * which:] kk, ss, pct = run(part, a, signals(part, a), ne, ns, nx) if not kk or pct <= 0: continue fee = FEE_CRYPTO if n in CRYPTO else FEE_STOCK k_all += kk r_all += ss net_all += ss - fee / (pct / 100) * kk avg_h = r_all / k_all if k_all else 0.0 net_h = net_all / k_all if k_all else 0.0 halves.append(net_h) print(f" {tag}: {avg_h:+.3f}R до издержек, {net_h:+.3f}R после," f" {k_all} сделок") # НЕВОЗМОЖНОЕ СОЧЕТАНИЕ — ПРОГОНОМ. Вход по касанию со стопом # за экстремумом бара касания требует знать максимум дня до его # закрытия. Запрет снимается флагом, чтобы было видно, что именно # он спасает, а не слова о нём. imp_k, imp_r = 0, 0.0 for n, r in rows.items(): k, s_, _ = run(r, adr(r), sig[n], "touch", "bar", "target", impossible=True) imp_k += k imp_r += s_ imp = imp_r / imp_k if imp_k else 0.0 # ПОРОГ РАЗЛИЧИМОСТИ. Разница в тысячные доли R — это не «лучше», # это одно и то же. Прежний вывод называл победителем вариант, # обходивший соседа на одну тысячную. EPS = 0.01 def better(label, a_name, b_name): a_v, b_v = dec[(label, a_name)], dec[(label, b_name)] if abs(a_v - b_v) < EPS: return (f" · {a_name} и {b_name} неразличимы:" f" {a_v:+.3f} против {b_v:+.3f} R") win, lose = ((a_name, b_name) if a_v > b_v else (b_name, a_name)) return (f" · {win} лучше, чем {lose}:" f" {max(a_v, b_v):+.3f} против {min(a_v, b_v):+.3f} R") print("\nВЫВОД, ПОСЧИТАННЫЙ ЭТИМ ЖЕ ПРОГОНОМ.") print("") print("ГЛАВНОЕ — В РАЗДЕЛЕ ЧЕТЫРЕ, И ЭТО НЕ ПРО ОТБОЙ.") print(f"Лучшее из {len(table)} сочетаний дало на первой половине окна") print(f"{best_avg:+.3f} R на сделку — результат, ради которого продают") print(f"курсы. То же сочетание на второй половине: {out:+.3f} R.") print(f"{best_n} сделок в выборе, {k_all_check} в проверке — выборки") print("сопоставимы, а результат не воспроизвёлся. Разница между ними —") print("не рынок. Это то, что выбор делался по тем же данным,") print("на которых потом хвалился.") print("") print("ЧТО УСТОЙЧИВО, И ПОЭТОМУ ПОЛЕЗНО. Устойчиво не лучшее сочетание,") print("а направление отдельных решений — среднее по всем сочетаниям,") print(f"где решение зафиксировано. Разницу меньше {EPS:.2f} R считаем") print("неразличимой:") print(better("вход", "confirm", "touch")) print(better("выход", "trail", "target")) print(better("стоп", "1.0", "0.5")) print(better("стоп", "1.0", "1.5")) print("") print("НИ ОДНО ИЗ ЭТИХ УЛУЧШЕНИЙ НЕ ДАЁТ СЕТАПУ УСТОЙЧИВОГО ПЛЮСА.") good = [r for r in table if r[0] > 0] good_net = [r for r in nets if r[0] > 0] print(f"До издержек в плюсе {len(good)} сочетания из {len(table)},") print(f"после издержек — {len(good_net)}; лучшее из них,") print(f"вход {ne}, стоп {ns_name}, выход {nx}, даёт {best_net:+.3f} R.") if halves[1] <= 0: print("Но это лучшее ИЗ ДВАДЦАТИ ОДНОГО, выбранное по тем же данным,") print(f"на которых измерено: на отдельной второй половине оно даёт") print(f"{halves[1]:+.3f} R после издержек. То есть выживает ровно") print("до первой честной проверки — как и всё остальное здесь.") else: print("ВНИМАНИЕ: это сочетание держит плюс и на отдельной второй") print(f"половине ({halves[1]:+.3f} R после издержек). Утверждение") print("ролика «ничего не спасает» на этих данных требует правки.") print("") print("И ОДНО СОЧЕТАНИЕ ВООБЩЕ НЕВОЗМОЖНО. Вход по касанию со стопом") print("за экстремумом бара касания требует знать максимум дня до его") print("закрытия. Со снятым запретом оно даёт") print(f"{imp:+.3f} R на каждой из {imp_k} сделок — стоп срабатывает") print("в момент входа. Это единственный случай, когда ошибка") print("в постановке видна по числу сразу.") 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())