"""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}")