"""Diagrama de ansamblu ATMI (strategii/05_atmi.md, marcajul `ansamblu`). „SPY — daily: corecție ≥4 % → zona PPQ → BIM + verde → intrare → TP +10 %". Structura seriei și nivelurile vin din spec-ul `gen/strategii/atmi_ansamblu.spec.txt`. Toate cifrele desenate (100.00 swing high, 96.00 intrare, 105.60 = +10 %) sunt din exemplul „DA" al cursului (`summaries/04_MODUL_3/03_strategia_2_atmi.md:185-247`). Benzile PPQ/SQ sunt schematice: cursul spune explicit că nu dă cifrele lor (`...:143`), deci nu poartă prețuri. Rulează: python3 gen/strategii/atmi_ansamblu.py Scrie lângă el `atmi_ansamblu.svg`. """ import re import sys from pathlib import Path sys.path.insert(0, "/workspace/atm-curs-dl") from svg_candles import Chart, Level, Mark, MA, from_closes # noqa: E402 OUT = Path(__file__).resolve().parent / "atmi_ansamblu.svg" GRAY, RED, GREEN, BLUE, ORANGE = "#777", "#c00", "#188a3e", "#1a5fb4", "#b06000" PURPLE = "#7b2fbf" # folosit în materialul de curs pentru PPQ # ---------------------------------------------------------------- seria (33 bare) # 15 bare de urcare ~88 -> 100, 8 de corecție 100 -> 96 (ultima = intrarea), # 10 de urcare 96 -> 105.60. open-ul fiecărei bare = close-ul precedentei. UP_A = [96.60, 97.10, 97.60, 98.00, 98.50, 98.20, 98.70, 99.20, 99.60, 99.30, 99.70, 100.00] # 0-11 CORR = [99.30, 98.50, 97.80, 97.20, 96.70, 96.30, 95.90, 96.00] # 12-19 (18 = BIM) UP_B = [96.60, 97.20, 97.90, 98.60, 99.40, 100.30, 101.40, 102.60, 103.40, 104.20, 104.90, 105.60] # 20-31 CLOSES = UP_A + CORR + UP_B assert len(CLOSES) == 32, len(CLOSES) PEAK, BIM, ENTRY, LAST = 11, 18, 19, 31 # fitile (sus, jos) per bară; jos pe corecție ținut ≥ 0 ca minimul să fie fix 96.00 WICKS = ([(0.25, 0.25)] * 11 + [(0.00, 0.25)] # 11: vârful atinge exact 100.00 + [(0.00, 0.20), (0.05, 0.20), (0.05, 0.20), (0.05, 0.20), (0.05, 0.20), (0.05, 0.20), (0.05, 0.35), # 18: bara de BIM, fundul la 95.55 (0.15, 0.05)] # 19: candela verde de intrare + [(0.25, 0.00)] + [(0.25, 0.25)] * 10 + [(0.00, 0.20)]) # 31: închide/atinge exact 105.60 assert len(WICKS) == len(CLOSES), (len(WICKS), len(CLOSES)) VOLS = ([44, 47, 42, 50, 45, 41, 48, 43, 46, 51, 44, 58] # 0-11 urcare + [64, 70, 66, 61, 58, 74, 96, 88] # 12-19 corecție + BIM/intrare + [55, 58, 52, 60, 57, 63, 59, 66, 70, 74, 78, 84]) # 20-31 urcare spre TP assert len(VOLS) == len(CLOSES), (len(VOLS), len(CLOSES)) cds = from_closes(CLOSES, wicks=WICKS, vols=VOLS, start=96.20) # ---------------------------------------------------------------- assert-urile din spec high = 100.00 entry = cds[ENTRY].c tp = cds[LAST].h assert abs((high - 96.00) / high * 100 - 4.0) < 0.05 # corecția e 4.0 % assert abs(entry - 96.00) < 0.05 # intrarea = close-ul candelei verzi assert abs(tp / entry - 1.10) < 0.005 # TP = +10 % assert abs(tp - max(c.h for c in cds[-12:])) < 1e-9 # TP atins la final, e maximul barelor assert BIM < ENTRY # BIM înainte de candela verde assert abs(min(c.l for c in cds[12:20]) - 95.55) < 1e-9 # fundul corecției = 95.55 assert cds[ENTRY].c >= cds[ENTRY].o # candela de intrare e verde assert abs(cds[PEAK].h - high) < 1e-9 # swing high = 100.00 assert abs(cds[LAST].c - tp) < 1e-9 # ultima bară închide pe TP levels = [ Level(high, "100.00 — swing high recent", "#333", label_at="left"), Level(entry, f"intrare {entry:.2f}", GREEN, label_at="right"), Level(tp, f"TP +10 % = {tp:.2f}", BLUE, label_at="right"), ] # nicio linie de stop-loss și niciun nivel sub intrare (absența SL e regula) assert all("stop" not in lv.text.lower() for lv in levels) assert all(lv.price >= entry - 1e-9 for lv in levels) marks = [ Mark(13, "corecție 4,0 %", ORANGE, "below", dy=26), Mark(BIM, "BIM", "#0a7f9e", "above"), Mark(ENTRY, "prima candelă verde → intrare", GREEN, "below", dy=30), Mark(LAST, "ieși cu tot", BLUE, "below", dy=22), ] caption = ("100.00 / 96.00 / 105.60 sunt cifrele exemplului din curs; PPQ 95.80 e calculat " "în diagrama pasului 1 (cursul nu dă valorile pivoților).") ch = Chart(candles=cds, levels=levels, marks=marks, scale=22.0, gap=17, body_w=9, left=44, width=720, vol_h=70, vol_gap=72, top=88, vol_label="volum", caption=caption) svg = ch.render() def band(price: float, half: float, color: str, label: str) -> str: """Bandă schematică orizontală, desenată în spatele candelelor. Se injectează în SVG după randare, pentru că `Chart` nu are benzi; y-ul se calculează cu aceeași scară (`ch._y`), deci banda e aliniată corect. """ y_top, y_bot = ch._y(price + half), ch._y(price - half) return (f'' f'{label}') # PPQ 95.80 e pivotul trimestrial calculat în diagrama pasului 1; banda e # zona de cumpărare din jurul lui. SQ 90.80 e sub scara acestui desen, deci # nu se desenează aici — apare pe diagrama pasului 1. PPQ = 95.80 assert abs(min(c.l for c in cds) - 95.55) < 1e-9 assert PPQ - 0.30 <= min(c.l for c in cds) <= PPQ + 0.30, "fundul nu cade în banda PPQ" assert entry > PPQ, "candela verde trebuie să închidă peste pivot" ppq = band(PPQ, 0.30, PURPLE, f"zona PPQ {PPQ:.2f} — aici cumperi") sq = "" # „fără stop-loss standard" stă sub zona de intrare, unde nu are candele no_sl = (f'fără stop-loss standard — nicio linie sub intrare') # titlul e injectat deasupra, ca rând separat de eticheta TP (care stă chiar # sub marginea de sus a panoului); altfel cele două texte se calcă la dreapta titlu = ('' 'SPY — daily: corecție ≥4 % → zona PPQ → BIM + verde → intrare → TP +10 %') i = svg.index(">") + 1 svg = svg[:i] + "\n " + "\n ".join([ppq, no_sl, titlu]) + svg[i:] # ---------------------------------------------------------------- caption pe două rânduri # Captionul are 135 de caractere, adică ~794 px de la x=24 într-o pânză lată de # 720: se rupe pe două rânduri, ca în selectia_ansamblu.py. def _wrap(text: str, max_chars: int) -> list[str]: lines, cur = [], "" for w in text.split(): if cur and len(cur) + 1 + len(w) > max_chars: lines.append(cur) cur = w else: cur = f"{cur} {w}".strip() if cur: lines.append(cur) return lines cap_lines = _wrap(caption, 80) assert len(cap_lines) == 2, cap_lines m = re.search(r'' + re.escape(caption) + r"", svg) assert m, "caption-ul nu a fost găsit în SVG" y0 = int(m.group(1)) wrapped = "\n ".join(f'{ln}' for i, ln in enumerate(cap_lines)) svg = svg[:m.start()] + wrapped + svg[m.end():] vm = re.search(r'viewBox="0 0 (\d+) (\d+)"', svg) assert vm, "nu găsesc viewBox-ul" svg = svg.replace(vm.group(0), f'viewBox="0 0 {vm.group(1)} {int(vm.group(2)) + 17 * (len(cap_lines) - 1)}"', 1) # fiecare rând de caption CHIAR DESENAT trebuie să încapă în lățimea pânzei. # CHAR_W = 5.7 e lățimea medie per caracter calibrată de audit_diagrams.py; # assertul prinde exact defectul vechi, când captionul era un singur text de # 135 de caractere: 24 + 135 × 5.7 = 794 > 720. CHAR_W = 5.7 vw = int(vm.group(1)) for m_cap in re.finditer(r'([^<]*)', svg): ln = m_cap.group(1) x1 = 24 + len(ln) * CHAR_W assert x1 <= vw, f"rând de caption tăiat la marginea dreaptă ({x1:.0f} > {vw}): {ln!r}" assert svg.count("\n\n") == 0, "linie goală în SVG rupe blocul din markdown" assert ".md:" not in svg, "trimitere fișier:linie în SVG" OUT.write_text(svg) print(f"scris {OUT.name}: {len(cds)} candele, intrare {entry:.2f}, TP {tp:.2f}")