#!/usr/bin/env python3 """Все числа TW-029 одним прогоном. Тема S-05: диапазон и возврат к границе. ЗАЧЕМ ЭТОТ ФАЙЛ ЛЕЖИТ В ПРОЕКТЕ. Канал обещает: всё измерено, а не заявлено. Обещание стоит ровно столько, сколько стоит возможность пересчитать. ОТКУДА ВОПРОС. TW-028 измерил пробой в плюс, а потом разделил сделки по направлению и выяснил, что весь плюс — в длинной стороне, на окне, где шесть инструментов в основном росли. То есть измерено было направление окна, а не сетап. Сделка в диапазоне устроена так, что этой болезни у неё быть НЕ ДОЛЖНО: она покупает низ той же полосы, где продаёт верх. Направления у неё нет по построению. Это проверяемое утверждение, и здесь оно проверяется. ЧТО СЧИТАЕТСЯ. ДИАПАЗОН: за последние RANGE_WIN дней полоса «максимум минус минимум» попадает в узкую треть всех полос ЭТОГО инструмента, и обе кромки кто-то трогал — иначе плавно съезжающий канал засчитался бы за диапазон. Порог берётся из самого инструмента, а не выбирается руками: см. NARROW_Q ниже. СИГНАЛ: цена дошла до кромки полосы. Низ — покупка, верх — продажа. ВХОД: по кромке (лимитная заявка стоит там) — или по открытию следующего дня, если день закрылся обратно внутрь полосы. СТОП: за кромкой на 0,5 или 1,0 дневного хода; или за экстремумом бара касания. ВЫХОД: противоположная кромка полосы; цель в два стопа; выход по времени; сопровождение по экстремуму предыдущего дня. КОНТРОЛЬ: тот же вход по тем же кромкам, когда рынок НЕ в диапазоне. Без контрольной группы «работает» не значит ничего: надо сравнить одинаковые вопросы, а не сетап с пустотой. ЧЕСТНОСТЬ УЧЁТА — та же, что в TW-026…TW-028: • в день входа засчитывается стоп, но не цель: внутри дня порядка событий мы не знаем, и подарок себе тут стоит целого ролика; • вход по кромке считается только если цена реально до неё дошла, а на гепе исполнение идёт по открытию, а не по кромке; • сочетание «вход по касанию + стоп за экстремумом бара касания» ИСКЛЮЧЕНО: экстремум бара не известен, пока бар не закрылся. Ровно это сочетание в TW-027 давало ровно −1R и выглядело замером; • выбор лучшего сочетания проверяется на второй половине окна, которая в выборе не участвовала. ДАННЫЕ. `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"] RANGE_WIN = 20 # сколько дней смотрим назад, определяя полосу # ГРАНИЦА «УЗКО / ШИРОКО» НЕ ВЫБИРАЕТСЯ РУКАМИ. Первый заход стоял на # «полоса не шире трёх дневных ходов», и это дало 110 сигналов на шесть # инструментов — у SPY два. На таком числе не измеряют ничего, а подбирать # порог, пока сигналов не станет достаточно, — это подгонка под ответ. # Поэтому порог берётся из самого инструмента: узкая треть всех его # двадцатидневных полос против широкой трети. Ни одного выбранного числа. NARROW_Q = 1 / 3 WIDE_Q = 2 / 3 EDGE = 0.15 # «кромку трогали» — в пределах этой доли полосы HOLD = 10 # держим не дольше стольких дней TARGET = 2.0 ROLL = 60 # обычный ход: медиана high−low за 60 баров до бара ENTRIES = ["touch", "next"] STOPS = [0.5, 1.0, "bar"] EXITS = ["far", "target", "time", "trail"] # Медиана ширины полосы по сигналам в диапазоне. Заполняется в main, когда # сигналы уже посчитаны: делить выборку пополам по числу, взятому из неё же, # честнее, чем по круглому числу, придуманному заранее. MED = [0.0] 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 band(r: list[dict], i: int) -> tuple[float, float]: """Полоса по RANGE_WIN дням ДО дня i. День i в неё не входит.""" w = r[i - RANGE_WIN:i] return min(b["l"] for b in w), max(b["h"] for b in w) def touched_both(r: list[dict], i: int, lo: float, hi: float) -> bool: """Обе кромки кто-то трогал. Без этого условия в «диапазон» попадает плавный съезд: полоса узкая, но цена идёт по ней наискось и ни одной кромки не касается дважды. Торговать в таком «диапазоне» нечего — кромок ещё нет. """ w = r[i - RANGE_WIN:i] room = (hi - lo) * EDGE return (any(b["l"] <= lo + room for b in w) and any(b["h"] >= hi - room for b in w)) def quantile(vals: list[float], q: float) -> float: v = sorted(vals) return v[min(len(v) - 1, int(q * len(v)))] HIST = ROLL # порог дня i — по ширинам стольких дней ДО него (B-15) def signals(r: list[dict], a: float, hist: int = HIST, fixed: float | None = None) -> list[dict]: """Касания кромки: и в диапазоне, и вне его — контроль тем же вопросом. ПОРОГ — ТОЛЬКО ПО ПРОШЛОМУ (B-15 второго аудита, 8 октября 2026). До этого узкая и широкая трети считались по ширинам ВСЕГО окна: порог дня в 2024 году знал ширины 2026-го, а выбор на первой половине окна — ширины проверочной. Теперь порог дня i — квантиль ширин `hist` дней строго до i; длина та же, что у окна обычного хода (ROLL), чтобы не вводить нового числа. Дни без такой истории в сигналы не идут. Ответ от длины истории зависит, поэтому вывод печатает его и при 120 днях — как проверку. `fixed` — первая попытка определения из s06: полоса не шире `fixed` дневных ходов. Без условия «обе кромки тронуты» (его s11 добавляет позже) и без контроля — так, как она и была сказана. """ all_days = range(RANGE_WIN, len(r) - HOLD - 2) widths = {i: (band(r, i)[1] - band(r, i)[0]) / a[i] for i in all_days if a[i]} out, last = [], {} for i in all_days: if i not in widths: continue lo, hi = band(r, i) width = hi - lo if width <= 0: continue if fixed is not None: tight, loose = widths[i] <= fixed, False else: past = [widths[k] for k in range(i - hist, i) if k in widths] if len(past) < hist: continue narrow = quantile(past, NARROW_Q) wide = quantile(past, WIDE_Q) tight = widths[i] <= narrow and touched_both(r, i, lo, hi) loose = widths[i] >= wide if not tight and not loose: continue # Цена дошла до кромки. Обе стороны считаются отдельными сигналами: # день, задевший обе, — это два разных вопроса к рынку. for side, edge, far in (("long", lo, hi), ("short", hi, lo)): hit = r[i]["l"] <= edge if side == "long" else r[i]["h"] >= edge if not hit or i - last.get(side, -99) <= HOLD: continue last[side] = i # вход внутрь полосы к концу дня — то самое «подтверждение» back = (r[i]["c"] > edge if side == "long" else r[i]["c"] < edge) out.append({"day": i, "edge": edge, "far": far, "side": side, "in_range": tight, "back": back, "width": width / a[i]}) return out def run(r, a, sig, entry, stop_rule, exit_rule, cond=None) -> tuple[int, float]: """Один набор решений. cond — необязательный фильтр по условию.""" n, total = 0, 0.0 for s in sig: if cond and not cond(s): continue i, edge, far, side = s["day"], s["edge"], s["far"], s["side"] if entry == "touch": # Лимитная заявка стоит на кромке. Но если день ОТКРЫЛСЯ уже # за ней, исполнение идёт по открытию — гепом, а не по заявке. o = r[i]["o"] fill = min(o, edge) if side == "long" else max(o, edge) day = i else: if not s["back"] or i + 1 >= len(r): continue day, fill = i + 1, r[i + 1]["o"] if stop_rule == "bar": stop = r[i]["l"] if side == "long" else r[i]["h"] else: stop = (edge - stop_rule * a[i] if side == "long" else edge + stop_rule * a[i]) # ВХОД, УЖЕ СТОЯЩИЙ ЗА СТОПОМ, — НЕ СДЕЛКА. Если геп унёс цену # дальше стопа, позиция закрылась бы в момент открытия, и брать # её нельзя — а расчёт брал и писал ровно −1R. # Найдено 10 сентября 2026 на TSLA в расчёте TW-030. if ((side == "long" and fill <= stop) or (side == "short" and fill >= stop)): continue if a[i] is None: # бар без истории хода не торгуется continue gap = abs(fill - stop) if gap <= 0: continue target = (fill + TARGET * gap if side == "long" else fill - TARGET * gap) res = None for j in range(day, min(day + 1 + HOLD, len(r))): first = (j == day) hi_j, lo_j, c_j = r[j]["h"], r[j]["l"], r[j]["c"] if side == "long": if lo_j <= stop: res = -1.0 break if first: continue if exit_rule == "far" and hi_j >= far: res = (far - fill) / gap break if exit_rule == "target" and hi_j >= target: res = TARGET break if exit_rule == "trail" and c_j < r[j - 1]["l"]: res = (c_j - fill) / gap break else: if hi_j >= stop: res = -1.0 break if first: continue if exit_rule == "far" and lo_j <= far: res = (fill - far) / gap break if exit_rule == "target" and lo_j <= target: res = TARGET break if exit_rule == "trail" and c_j > r[j - 1]["h"]: res = (fill - c_j) / gap break if res is None: last = r[min(day + HOLD, len(r) - 1)]["c"] res = ((last - fill) / gap if side == "long" else (fill - last) / gap) n += 1 total += res * min(1.0, gap / (FLOOR * a[i])) # пол риска (П11) return n, total def legal(entry, stop_rule) -> bool: """Вход по касанию со стопом за экстремумом бара касания невозможен. Экстремум бара становится известен только на его закрытии, а заявка стоит внутри дня. Сочетание выглядит как замер, а даёт ровно −1R на каждой сделке: стоп ставится ровно туда, где цена уже побывала. Поймано в TW-027; здесь исключено с самого начала. """ return not (entry == "touch" and stop_rule == "bar") def sweep(rows, sigs, cond=None, half=None): """Все законные сочетания. Возвращает {название: (сделок, средний R)}.""" res = {} for entry in ENTRIES: for stop_rule in STOPS: if not legal(entry, stop_rule): continue for exit_rule in EXITS: n = tot = 0 for name, r in rows.items(): a = adr(r) sig = sigs[name] if half is not None: mid = len(r) // 2 sig = [s for s in sig if (s["day"] < mid) == (half == 0)] k, t = run(r, a, sig, entry, stop_rule, exit_rule, cond) n += k tot += t key = f"{entry} · stop {stop_rule} · exit {exit_rule}" res[key] = (n, tot / n if n else 0.0) return res def per_signal(rows, sigs, cond=None) -> list[float]: """Средний результат КАЖДОГО сигнала по всем законным сочетаниям. ЕДИНИЦА — СИГНАЛ, А НЕ СДЕЛКА. Одно касание входит в десяток сочетаний, и погрешность по сделкам считала бы его десять раз — выборка выглядела бы втрое независимее, чем есть (B-14 второго аудита). """ out = [] for name, r in rows.items(): a = adr(r) for s in sigs[name]: if cond and not cond(s): continue vals = [] for entry in ENTRIES: for stop_rule in STOPS: if not legal(entry, stop_rule): continue for exit_rule in EXITS: k, tot = run(r, a, [s], entry, stop_rule, exit_rule) if k: vals.append(tot / k) if vals: out.append(st.mean(vals)) return out def diff_err(a: list[float], b: list[float]) -> tuple[float, float]: """-> (разница средних, её погрешность).""" ea = st.pstdev(a) / len(a) ** 0.5 eb = st.pstdev(b) / len(b) ** 0.5 return st.mean(a) - st.mean(b), (ea * ea + eb * eb) ** 0.5 def avg(rows, sigs, cond=None) -> tuple[int, float]: """Среднее по всем законным сочетаниям — «сетап вообще», без подгонки.""" tab = sweep(rows, sigs, cond) n = sum(v[0] for v in tab.values()) if not n: return 0, 0.0 return n, sum(v[0] * v[1] for v in tab.values()) / n 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()} say("ДАННЫЕ") say(f" Общее окно с {start}. Шесть инструментов, дневные бары.") for n, r in rows.items(): say(f" {n:9} {len(r)} баров, дневной ход на конец окна {adr(r)[-1]:.2f}") sigs = {n: signals(r, adr(r)) for n, r in rows.items()} say("") say("СЫРЬЁ: КАСАНИЯ КРОМКИ") tot_in = tot_out = 0 for n in rows: v = sigs[n] a = sum(1 for s in v if s["in_range"]) b = len(v) - a tot_in += a tot_out += b say(f" {n:9} в диапазоне {a:5} вне диапазона (контроль) {b:5}") say(f" {'ВСЕГО':9} в диапазоне {tot_in:5} вне диапазона {tot_out:5}") # ПЕРВАЯ ПОПЫТКА ОПРЕДЕЛЕНИЯ — ПЕЧАТАЕТСЯ. Голос s07 называл её итог # («fifty three signals … SPY had one»), комментарий выше — «110 … у # SPY два», аудит — 39 и ни одного: ни одна из трёх цифр нигде не # печаталась. Теперь печатается на нынешней норме обычного хода. say("") say("ПЕРВАЯ ПОПЫТКА (s06–s07): полоса не шире трёх дневных ходов") first = {n: signals(r, adr(r), fixed=3.0) for n, r in rows.items()} for n in rows: say(f" {n:9} {len(first[n]):5} сигналов") say(f" {'ВСЕГО':9} {sum(len(v) for v in first.values()):5}") say("") say("1 · СЕТАП ПРОТИВ КОНТРОЛЯ, СРЕДНЕЕ ПО ЗАКОННЫМ СОЧЕТАНИЯМ") n_in, r_in = avg(rows, sigs, lambda s: s["in_range"]) n_out, r_out = avg(rows, sigs, lambda s: not s["in_range"]) say(f" в диапазоне {n_in:6} сделок {r_in:+.3f} R") say(f" вне диапазона {n_out:6} сделок {r_out:+.3f} R") say(f" разница {r_in - r_out:+.3f} R") d_, e_ = diff_err(per_signal(rows, sigs, lambda s: s["in_range"]), per_signal(rows, sigs, lambda s: not s["in_range"])) say(f" по сигналам: разница {d_:+.3f} R, погрешность {e_:.3f}, " f"это {abs(d_) / e_:.1f} погрешности") # ЧУВСТВИТЕЛЬНОСТЬ К ДЛИНЕ ИСТОРИИ ПОРОГА (B-15): то же при 120 днях. sig120 = {n: signals(r, adr(r), hist=120) for n, r in rows.items()} n_a, r_a = avg(rows, sig120, lambda s: s["in_range"]) n_b, r_b = avg(rows, sig120, lambda s: not s["in_range"]) say(f" проверка: порог по 120 дням — в диапазоне {r_a:+.3f} R " f"({n_a}), контроль {r_b:+.3f} R ({n_b})") say("") say("2 · ГЛАВНАЯ ПРОВЕРКА: ЕСТЬ ЛИ У СДЕЛКИ НАПРАВЛЕНИЕ") say(" (в TW-028 весь плюс пробоя оказался в длинной стороне)") for label, cond in ( ("длинные (низ полосы)", lambda s: s["in_range"] and s["side"] == "long"), ("короткие (верх полосы)", lambda s: s["in_range"] and s["side"] == "short")): n, v = avg(rows, sigs, cond) say(f" {label:24} {n:6} сделок {v:+.3f} R") d_, e_ = diff_err( per_signal(rows, sigs, lambda s: s["in_range"] and s["side"] == "long"), per_signal(rows, sigs, lambda s: s["in_range"] and s["side"] == "short")) say(f" по сигналам: длинные минус короткие {d_:+.3f} R, погрешность " f"{e_:.3f}, это {abs(d_) / e_:.1f} погрешности") say("") say("3 · ТРИ РЕШЕНИЯ, ТОЛЬКО В ДИАПАЗОНЕ") # ВНУТРИ ОДНОГО ВХОДА, а не поперёк него. Стоп «за экстремумом бара» # законен только при входе на следующий день, и в общей сводке его # строка состояла бы из одних таких входов — то есть сравнивала бы # не стопы, а входы. Ровно этот подлог трижды ломал расчёт TW-026. tab = sweep(rows, sigs, lambda s: s["in_range"]) for entry in ENTRIES: part = {k: v for k, v in tab.items() if k.startswith(f"{entry} ")} n = sum(v[0] for v in part.values()) v = sum(x[0] * x[1] for x in part.values()) / n if n else 0.0 say(f" ВХОД {entry:<6} всего {n:6} сделок {v:+.3f} R") for stop_rule in STOPS: if not legal(entry, stop_rule): say(f" стоп {str(stop_rule):<9} — невозможно: экстремум " f"бара не известен до закрытия") continue sub = {k: x for k, x in part.items() if f"stop {stop_rule} " in k} nn = sum(x[0] for x in sub.values()) vv = sum(x[0] * x[1] for x in sub.values()) / nn if nn else 0.0 say(f" стоп {str(stop_rule):<9} {nn:6} сделок {vv:+.3f} R") for exit_rule in EXITS: sub = {k: x for k, x in part.items() if k.endswith(f"exit {exit_rule}")} nn = sum(x[0] for x in sub.values()) vv = sum(x[0] * x[1] for x in sub.values()) / nn if nn else 0.0 say(f" выход {exit_rule:<8} {nn:6} сделок {vv:+.3f} R") MED[0] = st.median([s["width"] for n in rows for s in sigs[n] if s["in_range"]]) say("") say(f"4 · ШИРИНА ПОЛОСЫ, МЕДИАНА {MED[0]:.2f} ДНЕВНЫХ ХОДА") for label, cond in ( ("полоса уже медианы", lambda s: s["in_range"] and s["width"] <= MED[0]), ("полоса шире медианы", lambda s: s["in_range"] and s["width"] > MED[0])): n, v = avg(rows, sigs, cond) say(f" {label:24} {n:6} сделок {v:+.3f} R") d_, e_ = diff_err( per_signal(rows, sigs, lambda s: s["in_range"] and s["width"] <= MED[0]), per_signal(rows, sigs, lambda s: s["in_range"] and s["width"] > MED[0])) say(f" по сигналам: узкая минус широкая {d_:+.3f} R, погрешность " f"{e_:.3f}, это {abs(d_) / e_:.1f} погрешности") say("") say("4b · ХВАТАЕТ ЛИ В ПОЛОСЕ МЕСТА, ЧТОБЫ ОТБИТЬ СТОП") # ЗАЧЕМ. Цель «два стопа» оказалась худшим выходом. Похоже, дело не # в цели как таковой: у диапазона есть потолок, и два стопа в него # часто просто не помещаются. Это проверяемо — считаем, сколько стопов # укладывается от кромки до кромки. # ПО КАЖДОМУ СТОПУ ОТДЕЛЬНО (B-16 второго аудита). Прежде здесь печаталась # одна медиана по смеси стопа в полхода и стопа в целый ход — 4,8, потом # 5,1 — и голос говорил «около пяти таких стопов» про стоп в полхода, # у которого полоса шире вдвое. Смесь двух единиц — не число ни одной. room = [] for stop_rule in (0.5, 1.0): rs, narrow = [], [] for n_, r_ in rows.items(): for sg in sigs[n_]: if not sg["in_range"]: continue rs.append(sg["width"] / stop_rule) # ход дня сигнала сокращается if sg["width"] <= MED[0]: narrow.append(sg["width"] / stop_rule) room += rs say(f" стоп {stop_rule}: от кромки до кромки, медиана {st.median(rs):.1f} " f"стопа; в узкой половине полос {st.median(narrow):.1f}") room.sort() short = sum(1 for x in room if x < TARGET) say(f" цель в {TARGET:.0f} стопа не помещается в полосу " f"в {short * 100 / len(room):.0f}% случаев из {len(room)}") # ОТ ВХОДА, А НЕ ОТ КРОМКИ (B-11 второго аудита). Сделка входит по # открытию следующего дня, и цель в два стопа отмеряется от входа; # счёт «от кромки до кромки» отвечал не на тот вопрос и объявлял, что # цель помещается всегда. for stop_rule in (0.5, 1.0): fit = tot = 0 for n_, r_ in rows.items(): a = adr(r_) for sg in sigs[n_]: i = sg["day"] if not sg["in_range"] or not sg["back"] or i + 1 >= len(r_): continue if a[i] is None: continue side, edge, far = sg["side"], sg["edge"], sg["far"] fill = r_[i + 1]["o"] stop = (edge - stop_rule * a[i] if side == "long" else edge + stop_rule * a[i]) if (side == "long" and fill <= stop) or (side == "short" and fill >= stop): continue gap = abs(fill - stop) if gap <= 0: continue target = fill + TARGET * gap if side == "long" else fill - TARGET * gap tot += 1 fit += (target <= far) if side == "long" else (target >= far) say(f" от входа следующего утра, стоп {stop_rule:.1f}: цель в " f"{TARGET:.0f} стопа помещается в {fit} из {tot} " f"({fit * 100 / tot:.0f}%)") say(" Разбираем исходы, а не догадку.") say("") say("4c · ИСХОДЫ ДВУХ ВЫХОДОВ, ВХОД НА СЛЕДУЮЩИЙ ДЕНЬ, СТОП 0,5") for exit_rule in ("target", "far"): wins = stops_hit = other = 0 acc = 0.0 for n_, r_ in rows.items(): a = adr(r_) for sg in sigs[n_]: if not sg["in_range"]: continue k, t = run(r_, a, [sg], "next", 0.5, exit_rule) if not k: continue acc += t if t <= -0.999: stops_hit += 1 elif exit_rule == "target" and t >= TARGET - 1e-6: wins += 1 elif exit_rule == "far" and t >= 1.0: wins += 1 else: other += 1 n = wins + stops_hit + other say(f" выход {exit_rule:<7} {n:5} сделок " f"стоп {stops_hit * 100 / n:4.0f}% " f"крупный плюс {wins * 100 / n:4.0f}% " f"остальное {other * 100 / n:4.0f}% " f"итог {acc / n:+.3f} R") say("") say("5 · ВЫБОР НА ПЕРВОЙ ПОЛОВИНЕ, ЗАМЕР НА ВТОРОЙ") first = sweep(rows, sigs, lambda s: s["in_range"], half=0) second = sweep(rows, sigs, lambda s: s["in_range"], half=1) good = [(v[1], k) for k, v in first.items() if v[0] >= 60] if good: best_r, best = max(good) say(f" лучшее на первой половине: {best}") say(f" {first[best][0]:6} сделок {best_r:+.3f} R") n2, r2 = second[best] say(f" оно же на второй половине:") say(f" {n2:6} сделок {r2:+.3f} R") say(f" потеря при переходе: {r2 - best_r:+.3f} R") else: say(" на первой половине не набралось сочетания с 60 сделками") say("") say("6 · ПО ИНСТРУМЕНТАМ, ТОЛЬКО В ДИАПАЗОНЕ") for n_, r_ in rows.items(): a = adr(r_) k = t = 0 for entry in ENTRIES: for stop_rule in STOPS: if not legal(entry, stop_rule): continue for exit_rule in EXITS: kk, tt = run(r_, a, sigs[n_], entry, stop_rule, exit_rule, lambda s: s["in_range"]) k += kk t += tt say(f" {n_:9} {k:6} сделок {(t / k if k else 0):+.3f} R") OUT.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"\nнаписано: {OUT}") return 0 if __name__ == "__main__": raise SystemExit(main())