"""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. Bear trail-ul e calculat din serie cu regula capitolului (linia sare pe high-ul barei care se închide sub low-ul precedentei; violare = o bară care se închide PESTE linie) și desenat ca scară portocalie. Seria e construită astfel încât fitilele să atingă linia de trei ori (barele 16, 20, 24) fără s-o violeze, iar ieșirea să vină abia la bara 29, care închide peste linie. """ 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.45, 99.80, 98.58, 98.10, 97.60, 97.95, 97.40, 96.90, 96.40, 96.75, 96.20, 95.70, 95.20, 95.00, 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); barele 16, 20 și 24 sunt retestări ale liniei de trail, deci au # fitil lung în sus care atinge linia, dar se închid sub ea. 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.15), # 15 (0.45, 0.10), # 16 retest trail (0.15, 0.15), # 17 (0.15, 0.15), # 18 (0.15, 0.15), # 19 (0.40, 0.10), # 20 retest trail (0.15, 0.15), # 21 (0.15, 0.15), # 22 (0.15, 0.15), # 23 (0.40, 0.10), # 24 retest trail (0.15, 0.15), # 25 (0.15, 0.15), # 26 (0.15, 0.15), # 27 (0.05, 0.05), # 28: coboară trail-ul (0.20, 0.10), # 29: close peste trail ] 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" # 10. bara 17 e la 1.42% profit (procentul real din curs) assert abs((ENTRY - cds[17].c) / ENTRY - 0.0142) < 1e-4, cds[17].c # ── bear trail, recalculat din serie cu regula capitolului ───────────────── # Linia sare pe high-ul fiecărei bare care se închide sub low-ul precedentei; # violarea = prima bară care se închide PESTE linie (fitilul singur nu scoate). def bear_trail(candles, trig): """linie[i] = nivelul trail-ului în vigoare la începutul barei i (None până se formează linia); iesire = prima bară închisă peste linie.""" linie, linie_la, iesire = None, [], None for i, c in enumerate(candles): linie_la.append(linie) if i > trig and linie is not None and c.c > linie: iesire = i break if i > trig: p = candles[i - 1] if c.c < p.l: linie = c.h while len(linie_la) < len(candles): linie_la.append(linie) return linie_la, iesire LINIE, EXIT_BAR = bear_trail(cds, 14) # 11. ieșirea e pe bara 29, care se închide PESTE linia de trail (nu doar fitilul) assert EXIT_BAR == 29, f"ieșirea trebuie să fie pe bara 29, nu {EXIT_BAR}" TRAIL_EXIT = LINIE[29] assert TRAIL_EXIT is not None and cds[29].c > TRAIL_EXIT, (cds[29].c, TRAIL_EXIT) # 12. linia nu urcă niciodată (e trail, nu nivel fix) pas = [v for v in LINIE[15:30] if v is not None] assert all(b <= a + 1e-9 for a, b in zip(pas, pas[1:])), pas # 13. nicio bară dinainte de ieșire nu închide peste linie for i in range(15, 29): if LINIE[i] is not None: assert cds[i].c < LINIE[i], (i, cds[i].c, LINIE[i]) # 14. fitilele au atins linia de mai multe ori fără s-o violeze pe close atingeri = [i for i in range(15, 29) if LINIE[i] is not None and cds[i].h >= LINIE[i] - 1e-9 and cds[i].c < LINIE[i]] assert len(atingeri) >= 2, f"fitilele ating linia doar la barele {atingeri}" assert set(atingeri) >= {16, 20, 24}, f"retestările lipsesc: {atingeri}" # ── 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: # „below": sub candelă, dar înainte de panoul de volum. Plafonul e cu 1 px # sub limita de jos a candidaților, altfel când bara i are chiar minimul # seriei nu mai rămâne niciun y întreg de testat și plasarea pică. return (y(cds[i].h) - 10 if where == "above" else min(y(cds[i].l) + 20, body_bottom + 13)) # 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 "') if prev_y is not None and abs(prev_y - yv) > 1e-9: trail_parts.append( f'') prev_y = yv trail_parts.append( f'bear trail') svg = svg.replace("\n", "\n " + "\n ".join(trail_parts) + "\n") # ── verificare: fiecare etichetă e în pânză și nu se suprapune alta ──────── m = re.search(r'viewBox="0 0 (\d+) (\d+)"', svg) W, H = int(m.group(1)), int(m.group(2)) texte = re.findall(r']*)>([^<]*)', 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}")