"""Diagrama din `gen/strategii/m2d_exemplu.spec.txt` (TIP: PRET). Exemplul real Riot din curs (`14_strategia_1_m2d.md:227-236`): SHORT pe TF mic 3 min, cu TF mare 15 min în Sell. Pullback-ul Buy de pe TF mic (M) e urmat de primul Sell (A = trigger); SL = cel mai sus high din 4 candele = +0.85%, TP1 = 1:1 = -0.85%, iar ieșirea se face pe violarea bear trail-ului la +4.55% (risc/recompensă efectiv ≈ 1:5.3). Reale din curs: procentele 0.85% / 1.42% / 4.55% și contextul calitativ. Construită de mine: axa de preț absolută, intrarea fixată la 100.00 ca reper. Legenda spune explicit lucrul ăsta. Serie continuă de 30 de candele din lista de close-uri din specificație, open = close-ul precedentului, wick-uri alese ca să scoată exact nivelurile (candela 13: high 100.85 = SL), panou de volum dedesubt. """ import re import sys from pathlib import Path sys.path.insert(0, "/workspace/atm-curs-dl") from svg_candles import Chart, Level, Mark, from_closes # noqa: E402 OUT = Path(__file__).resolve().parent / "m2d_exemplu.svg" # ── nivelurile cerute de specificație ────────────────────────────────────── ENTRY = 100.00 # close-ul candelei de trigger 14 SL = 100.85 # max high pe candelele 11:15 = +0.85% TP1 = 99.15 # entry - 0.85% (1:1) EXIT = 95.45 # close-ul candelei 29 = +4.55% # ── seria: 30 de close-uri, exact ca în specificație ─────────────────────── closes = [ 97.6, 97.1, 96.7, 96.3, 95.9, 95.6, 95.9, # 0-6 coborâre 96.4, 96.9, 97.5, 98.2, 99.0, 99.7, 100.3, # 7-13 pullback Buy (M) 100.00, # 14 trigger Sell (A) 99.5, 99.0, 98.5, 98.0, 97.6, 97.2, 96.9, 96.6, 96.3, 96.1, 95.8, 95.5, 95.2, 95.35, 95.45, # 15-29 bear trail ] assert len(closes) == 30, len(closes) # wick-uri: candela 13 are nevoie de un wick sus până la 100.85 (SL-ul de 4 # candele); restul sunt mici, ca seria să arate ca un grafic real. wicks = [ (0.15, 0.20), (0.12, 0.18), (0.15, 0.15), (0.10, 0.20), (0.12, 0.25), (0.10, 0.30), (0.25, 0.15), (0.20, 0.15), (0.15, 0.20), (0.20, 0.15), (0.20, 0.20), (0.20, 0.18), (0.25, 0.15), (SL - 100.3, 0.15), # 13: high = 100.85 (0.15, 0.25), # 14: trigger Sell (0.15, 0.20), (0.15, 0.18), (0.12, 0.20), (0.15, 0.18), (0.15, 0.20), (0.12, 0.18), (0.15, 0.20), (0.12, 0.18), (0.15, 0.20), (0.12, 0.18), (0.15, 0.20), (0.12, 0.18), (0.15, 0.20), (0.20, 0.15), (0.30, 0.10), ] assert len(wicks) == 30, len(wicks) vols = [ 48, 45, 42, 40, 44, 50, 46, 42, 44, 40, 43, 47, 52, 68, 85, 60, 55, 52, 48, 46, 44, 42, 40, 43, 41, 44, 42, 40, 45, 72, ] assert len(vols) == 30, len(vols) cds = from_closes(closes, wicks=wicks, vols=vols, start=98.0) # ── assert-urile cerute de specificație ──────────────────────────────────── # 1. entry == close-ul candelei de trigger 14 assert round(cds[14].c, 2) == ENTRY == 100.00, cds[14].c # 2. SL == max high pe candelele 11:15 max_h = max(c.h for c in cds[11:15]) assert round(max_h, 2) == SL == 100.85, max_h # 3. SL = +0.85% față de entry assert abs((SL - ENTRY) / ENTRY - 0.0085) < 1e-4 # 4. TP1 = entry − (SL − entry) = 1:1 assert TP1 == round(ENTRY - (SL - ENTRY), 2) == 99.15, TP1 assert abs((ENTRY - TP1) / ENTRY - 0.0085) < 1e-4 # 5. exit == close-ul candelei 29 assert round(cds[29].c, 2) == EXIT == 95.45, cds[29].c # 6. R efectiv ≈ 1:5.3 assert abs((ENTRY - EXIT) / (SL - ENTRY) - 5.35) < 0.05 # 7. niciun close după trigger nu trece peste SL assert max(c.c for c in cds[15:]) < SL, max(c.c for c in cds[15:]) # 8. fiecare open == close-ul barei anterioare for a, b in zip(cds, cds[1:]): assert abs(a.c - b.o) < 1e-9, "serie cu gol artificial între bare" # 9. candela 14 închide în jos după pullback-ul Buy (7-13) assert cds[14].c < cds[13].c, (cds[13].c, cds[14].c) assert any(cds[i].c > cds[i].o for i in range(7, 14)), "pullback Buy absent" # ── geometria, ca să așez marks-urile „below" fără suprapunere ───────────── SCALE = 42 levels = [ Level(SL, "SL 100.85 = +0.85%", "#c00"), Level(ENTRY, "intrare 100.00 (trigger Sell)", "#333"), Level(TP1, "TP1 99.15 = 1:1", "#1a5fb4"), Level(EXIT, "ieșire 95.45 = +4.55% ≈ 1:5.3", "#188a3e"), ] prices = [p for c in cds for p in (c.o, c.h, c.l, c.c)] + [lv.price for lv in levels] pmax, pmin = max(prices), min(prices) top = 34 + 22 # implicit + titlu, fără segment de trend body_bottom = top + (pmax - pmin) * SCALE def y(price: float) -> float: return top + (pmax - price) * SCALE # ── așezarea etichetelor: caut un y unde textul nu atinge vreun corp de # candelă și nu se suprapune cu altă etichetă. Sub grafic sunt doar ~14 px # liberi (body_bottom .. body_bottom+14), deci etichetele lungi trebuie # coborâte/diferite pe rânduri. Fără asta, „A" și „M" trec peste candele. GAP, LEFT, WIDTH, BODY_W = 15, 44, 700, 10 def cx(i: int) -> float: return LEFT + i * GAP def text_x(i: int, text: str) -> tuple[float, float]: """(x0, x1) al textului bold ancorat pe candela i, ca în svg_candles.""" x = cx(i) w = len(text) * 7.44 if x < 90: return x - 12, x - 12 + w if x > WIDTH - 90: return x + 12 - w, x + 12 return x - w / 2, x + w / 2 corpuri = [] for i, c in enumerate(cds): y0, y1 = y(max(c.o, c.c)), y(min(c.o, c.c)) y1 = max(y1, y0 + 1.6) corpuri.append((cx(i) - BODY_W / 2, cx(i) + BODY_W / 2, y0, y1)) linii = [y(lv.price) for lv in levels] def liber_fara_puse(i: int, text: str, yv: float) -> bool: x0, x1 = text_x(i, text) ty0, ty1 = yv - 10, yv + 3 if x0 < -2 or x1 > WIDTH + 2 or yv < top + 12 or yv > body_bottom + 14: return False for bx0, bx1, by0, by1 in corpuri: if x0 < bx1 - 2 and bx0 < x1 - 2 and ty0 < by1 - 2 and by0 < ty1 - 2: return False # nicio linie de nivel (SL/entry/TP1/exit) prin mijlocul textului for ly in linii: if ty0 - 1 <= ly <= ty1 + 1: return False return True def conflict(a: tuple, b: tuple) -> bool: """Două etichete se bat dacă au aceeași bandă de y și se suprapun pe x.""" ax0, ax1, ay = a bx0, bx1, by = b return abs(ay - by) < 13 and ax0 < bx1 - 2 and bx0 < ax1 - 2 puse: list[tuple] = [] definiții = [ (0, "TF mare (15 min) în Sell", "#c00", "above"), (7, "M: Buy Momentum (pullback)", "#0891b2", "above"), (14, "A: primul Sell = trigger, intri short", "#c00", "below"), (17, "deja peste TP1 (1.42% din curs)", "#188a3e", "below"), (29, "close peste bear trail = ieși", "#188a3e", "below"), ] def natural_y(i: int, where: str) -> float: return (y(cds[i].h) - 10 if where == "above" else min(y(cds[i].l) + 20, body_bottom + 14)) # candidați: toate y-urile care nu ating niciun corp de candelă, ordonate # după cât de aproape sunt de poziția naturală (cu preferință pentru partea # cerută de specificație: „above" în sus, „below" în jos) candidati = [] for i, text, _color, where in definiții: nat = natural_y(i, where) ys = [yy for yy in range(int(top + 12), int(body_bottom + 14) + 1) if liber_fara_puse(i, text, yy)] if where == "below": # o etichetă „below" rămâne sub candelă: nu urcă peste preț ys = [yy for yy in ys if yy >= nat] ys.sort(key=lambda yv: yv - nat) else: # „above" preferă în sus, dar dacă nu încape coboară sub serie ys.sort(key=lambda yv: (abs(yv - nat), 0 if yv <= nat else 1)) candidati.append(ys) assert ys, f"nicio poziție liberă pentru {text!r}" # backtracking: plasez întâi etichetele cu cele mai puține opțiuni ordine = sorted(range(len(definiții)), key=lambda k: len(candidati[k])) y_final = [None] * len(definiții) def rezolva(k: int) -> bool: if k == len(ordine): return True idx = ordine[k] ci = definiții[idx][0] for yv in candidati[idx]: box = (*text_x(ci, definiții[idx][1]), yv) if all(not conflict(box, p) for p in puse): puse.append(box) y_final[idx] = yv if rezolva(k + 1): return True puse.pop() return False assert rezolva(0), "nu găsesc o așezare fără suprapuneri pentru etichete" marks = [Mark(i, text, color, where, dy=y_final[k] - natural_y(i, where)) for k, (i, text, color, where) in enumerate(definiții)] assert all(y_final), y_final ch = Chart( candles=cds, levels=levels, marks=marks, scale=SCALE, gap=GAP, body_w=BODY_W, left=LEFT, width=WIDTH, vol_h=70, vol_label="volum", title="RIOT — 3 min (TF mare 15 min în Sell)", caption="Axa de preț e construită (intrarea = 100.00); procentele sunt reale din curs.", ) svg = ch.render() assert "]*)>([^<]*)', svg) cutii, cutii_text = [], [] for xs, ys, attrs, continut in texte: x, yv = float(xs), float(ys) w = len(continut) * (7.44 if "bold" in attrs else 6.72) if 'text-anchor="end"' in attrs: x0 = x - w elif 'text-anchor="middle"' in attrs: x0 = x - w / 2 else: x0 = x assert -2 <= x0 and x0 + w <= W + 2, f"etichetă în afara pânzei: {continut!r}" assert 0 <= yv <= H, f"etichetă în afara înălțimii: {continut!r}" cutii.append((x0, x0 + w, yv, continut)) cutii_text.append((x0, x0 + w, yv - 10, yv + 3, continut)) for i in range(len(cutii)): for j in range(i + 1, len(cutii)): a, b = cutii[i], cutii[j] if abs(a[2] - b[2]) < 13 and a[0] < b[1] - 2 and b[0] < a[1] - 2: raise AssertionError(f"texte suprapuse: {a[3]!r} / {b[3]!r}") # ── verificare: nicio etichetă peste un corp de candelă ──────────────────── corpuri = [] for mm in re.finditer( r'', svg): x0, y0, hh = map(float, mm.groups()) corpuri.append((x0, x0 + 10, y0, y0 + hh)) for tx0, tx1, ty0, ty1, continut in cutii_text: for cx0, cx1, cy0, cy1 in corpuri: if tx0 < cx1 - 2 and cx0 < tx1 - 2 and ty0 < cy1 - 2 and cy0 < ty1 - 2: raise AssertionError( f"etichetă peste candelă: {continut!r} la y~{ty0:.0f} " f"({tx0:.0f}-{tx1:.0f}) vs rect {cx0:.0f}-{cx1:.0f} " f"y {cy0:.0f}-{cy1:.0f}") # legenda mai spune explicit că axa e construită (cerință de fidelitate) assert "construită" in svg and "procentele sunt reale" in svg, \ "lipsește nota despre axa construită" OUT.write_text(svg, encoding="utf-8") print(f"serie {len(cds)} candele | intrare {ENTRY:.2f} @ bara 14 | " f"SL {SL:.2f} (+0.85%) | TP1 {TP1:.2f} (1:1) | " f"ieșire {EXIT:.2f} (+4.55%, R {(ENTRY - EXIT) / (SL - ENTRY):.2f})") print(f"scris {OUT}")