""" Generator de diagrame de candele pentru summaries/. Motivul existenței: diagramele desenate de mână ies la scară greșită. În `01_MODUL_1/05_patternuri_candele.md` ținta de 2R era desenată la distanța de 1R față de intrare (75 px/unitate în jos, 41 px/unitate în sus), iar la Marubozu eticheta de open era pusă deasupra celei de close — axa de preț inversată. Aici coordonatele se calculează din prețuri, deci scara e corectă prin construcție. Convenție: un singur `scale` (px per unitate de preț) pentru tot desenul. Două stiluri de desen: 1. **Ilustrativ** (implicit, `gap=92`, 3-6 candele): pentru definiții de pattern-uri, unde contează forma unei candele, nu trendul. 2. **Grafic real** (`gap=14`, `body_w=9`, 25-45 de candele, `vol_h=70`): serie continuă de preț cu panou de volum dedesubt, ca pe TradingView. Userul a cerut explicit stilul ăsta pentru orice diagramă unde trebuie să se vadă un trend sau relația preț-volum. Etichetează candelele care contează cu `marks`, nu cu `tag` — la 40 de candele etichetele per candelă se suprapun. """ import re from dataclasses import dataclass, field def _check_dash(dash: str, cls: str) -> None: """`dash` e un stroke-dasharray SVG ("4 3", "" etc.), nu o pozitie. Garda exista pentru ca `Level(p, text, color, "left")` pune tacut "left" in `dash` in loc de `label_at`, iar SVG-ul iese fara linie punctata si fara nicio eroare. Trei agenti au pierdut timp pe asta pe 13.09.2026. """ if dash and not re.fullmatch(r"[\d.,\s]+", dash): raise ValueError( f"{cls}.dash={dash!r} nu e un stroke-dasharray. " f"Daca voiai pozitia etichetei, argumentul se cheama label_at " f"si se da pe nume: {cls}(..., label_at={dash!r})." ) @dataclass class Candle: o: float h: float l: float c: float tag: str = "" # eticheta de sub candelă (stil ilustrativ) note: str = "" # linie de text în blocul de sub grafic color: str | None = None # implicit: verde dacă c >= o vol: float | None = None # volumul barei, pentru panoul de volum @dataclass class Level: price: float text: str color: str = "#1a5fb4" dash: str = "4 3" label_at: str = "right" # "right" | "left" — unde stă eticheta def __post_init__(self) -> None: _check_dash(self.dash, "Level") if self.label_at not in ("right", "left"): raise ValueError(f"Level.label_at={self.label_at!r}, astept 'right' sau 'left'") @dataclass class Mark: """Etichetă ancorată pe o candelă anume, pentru serii lungi.""" i: int # indexul candelei text: str color: str = "#1a5fb4" where: str = "above" # "above" | "below" — față de candelă dy: float = 0.0 # ajustare fină, dacă se suprapune cu altceva @dataclass class MA: """Medie mobilă simplă, calculată din close-uri.""" period: int color: str = "#b06000" dash: str = "" text: str = "" def __post_init__(self) -> None: _check_dash(self.dash, "MA") @dataclass class Zone: """Dreptunghi de evidențiere peste candelele i0..i1, desenat în spatele lor.""" i0: int # prima candelă acoperită i1: int # ultima candelă acoperită (inclusiv) text: str = "" # eticheta zonei, deasupra dreptunghiului color: str = "#1a5fb4" p_top: float | None = None # implicit: cel mai mare high din interval + 8 px p_bot: float | None = None # implicit: cel mai mic low din interval + 8 px def __post_init__(self) -> None: if not 0 <= self.i0 <= self.i1: raise ValueError(f"Zone(i0={self.i0}, i1={self.i1}): astept 0 <= i0 <= i1") @dataclass class Arrow: """Săgeată cu text spre o candelă: „aici intri / aici stopul / aici ținta".""" i: int # candela țintită text: str # textul săgeții; "\n" rupe rândul price: float | None = None # implicit: high (above), low (below), close (left/right) where: str = "above" # "above" | "below" | "left" | "right" — unde stă textul color: str = "#1a5fb4" dx: float = 0.0 # mută textul (și coada săgeții); vârful rămâne pe țintă dy: float = 0.0 def __post_init__(self) -> None: if self.where not in ("above", "below", "left", "right"): raise ValueError(f"Arrow.where={self.where!r}, astept above/below/left/right") @dataclass class Chart: candles: list[Candle] levels: list[Level] = field(default_factory=list) marks: list[Mark] = field(default_factory=list) mas: list[MA] = field(default_factory=list) scale: float = 60.0 # px per unitate de preț width: int = 620 body_w: int = 26 gap: int = 92 # distanță între candele left: int = 64 # x-ul primei candele top: int = 34 # spațiu deasupra celui mai mare preț label_gap: int = 34 # spațiu sub cea mai joasă candelă pentru etichete caption: str = "" trend: str = "" # "down" / "up" / "" — segment de trend înainte vol_h: float = 0.0 # înălțimea panoului de volum (0 = fără panou) vol_gap: float = 26.0 # spațiu între panoul de preț și cel de volum vol_label: str = "volum" title: str = "" # ex. "SPY — daily" zones: list[Zone] = field(default_factory=list) arrows: list[Arrow] = field(default_factory=list) def __post_init__(self) -> None: n = len(self.candles) for z in self.zones: if z.i1 >= n: raise ValueError(f"Zone(i0={z.i0}, i1={z.i1}): graficul are doar {n} candele") for a in self.arrows: if not 0 <= a.i < n: raise ValueError(f"Arrow(i={a.i}): graficul are doar {n} candele") def _y(self, price: float) -> float: return self.top + (self._pmax - price) * self.scale def _cx(self, i: int) -> float: return self.left + i * self.gap def _zone_box(self, z: Zone) -> tuple[float, float, float, float]: cds = self.candles[z.i0:z.i1 + 1] pad = self.body_w / 2 + 5 y0 = self._y(z.p_top) if z.p_top is not None else self._y(max(c.h for c in cds)) - 8 y1 = self._y(z.p_bot) if z.p_bot is not None else self._y(min(c.l for c in cds)) + 8 return self._cx(z.i0) - pad, y0, self._cx(z.i1) + pad, y1 def _zone_label_y(self, z: Zone) -> float: """Baseline-ul etichetei: deasupra zonei și a candelelor din dreptul textului.""" x0, y0, _, _ = self._zone_box(z) x1 = x0 + len(z.text) * 6.2 tops = [self._y(c.h) for j, c in enumerate(self.candles) if x0 - self.body_w / 2 < self._cx(j) < x1 + self.body_w / 2] return min([y0] + [t - 4 for t in tops]) - 5 def _arrow_geom(self, a: Arrow) -> dict: """Vârf, coadă, rânduri de text și bbox-ul textului (x0, x1, ytop, ybot).""" LH, CW = 14, 6.2 # înălțime rând, lățime caracter bold 12px cd, cx = self.candles[a.i], self._cx(a.i) lines = a.text.split("\n") tw = max(len(t) for t in lines) * CW if a.where in ("above", "below"): up = a.where == "above" price = a.price if a.price is not None else (cd.h if up else cd.l) tip = (cx, self._y(price) + (-4 if up else 4)) # textul stă peste candelele vecine din dreptul lui: urcă/coboară # peste cea mai extremă, altfel acoperă fitilele near = [c for j, c in enumerate(self.candles) if abs(self._cx(j) - cx - a.dx) < tw / 2 + self.body_w] + [cd] if up: ty = min(tip[1] - 26, min(self._y(c.h) for c in near) - 8) + a.dy base = [ty - 5 - LH * k for k in range(len(lines))][::-1] else: ty = max(tip[1] + 26, max(self._y(c.l) for c in near) + 8) + a.dy base = [ty + 14 + LH * k for k in range(len(lines))] tail, tx, anchor = (cx + a.dx, ty), cx + a.dx, "middle" x0 = tx - tw / 2 else: sgn = 1 if a.where == "right" else -1 price = a.price if a.price is not None else cd.c tip = (cx + sgn * (self.body_w / 2 + 4), self._y(price)) tail = (tip[0] + sgn * 30 + a.dx, tip[1] + a.dy) tx = tail[0] + sgn * 4 base = [tail[1] + 4 - LH * (len(lines) - 1) / 2 + LH * k for k in range(len(lines))] anchor = "start" if sgn > 0 else "end" x0 = tx if sgn > 0 else tx - tw return dict(tip=tip, tail=tail, tx=tx, anchor=anchor, lines=lines, base=base, box=(x0, x0 + tw, base[0] - 11, base[-1] + 3)) def render(self) -> str: # _render mută top/left/width ca să facă loc titlului, trendului și # săgeților; le restaurăm, altfel a doua randare a aceluiași Chart iese deplasată saved = self.top, self.left, self.width try: return self._render() finally: self.top, self.left, self.width = saved def _render(self) -> str: prices = [p for c in self.candles for p in (c.o, c.h, c.l, c.c)] prices += [lv.price for lv in self.levels] prices += [p for z in self.zones for p in (z.p_top, z.p_bot) if p is not None] prices += [a.price for a in self.arrows if a.price is not None] self._pmax, pmin = max(prices), min(prices) # textul săgeților și etichetele zonelor nu au voie să iasă din pânză: # mută graficul în jos / la dreapta și lărgește canvasul cât e nevoie boxes = [self._arrow_geom(a)["box"] for a in self.arrows] boxes += [(x0, x0 + len(z.text) * 6.2, self._zone_label_y(z) - 11, y0) for z in self.zones if z.text for x0, y0, _, _ in [self._zone_box(z)]] if boxes: self.top += max(0, 14 - min(b[2] for b in boxes)) shift = max(0, 6 - min(b[0] for b in boxes)) self.left += shift self.width = max(self.width + int(shift), int(max(b[1] for b in boxes) + shift + 8)) if self.title: self.top += 22 # segmentul de trend iese din zona candelelor: rezervă loc pentru el, # altfel se suprapune peste etichete sau peste marginea desenului if self.trend == "down": self.top += 56 body_bottom = self.top + (self._pmax - pmin) * self.scale extra = 62 if self.trend == "up" else 0 low = [self._arrow_geom(a)["box"][3] for a in self.arrows] low += [self._zone_box(z)[3] for z in self.zones] if low: extra = max(extra, max(low) - body_bottom + 6) panel_bottom = body_bottom + extra vols = [c.vol for c in self.candles if c.vol is not None] vol_top = vol_base = None if self.vol_h and vols: vol_top = panel_bottom + self.vol_gap vol_base = vol_top + self.vol_h panel_bottom = vol_base n_notes = sum(1 for c in self.candles if c.note) tags = any(c.tag for c in self.candles) height = (panel_bottom + (self.label_gap if tags else 18) + 17 * n_notes + (24 if self.caption else 8)) out = [f''] if self.title: out.append(f'{self.title}') # zonele primele, ca să stea în spatele nivelurilor și candelelor for z in self.zones: x0, y0, x1, y1 = self._zone_box(z) out.append(f'') if z.text: out.append(f'{z.text}') # nivelurile: linie pe toată lățimea, eticheta deasupra liniei for lv in self.levels: y = self._y(lv.price) out.append(f'') if lv.label_at == "left": out.append(f'{lv.text}') else: out.append(f'{lv.text}') x0 = self.left if self.trend: y_start = self._y(self.candles[0].h if self.trend == "down" else self.candles[0].l) dy = -46 if self.trend == "down" else 46 out.append(f'') # la downtrend linia coboară spre dreapta (etichetă deasupra), # la uptrend urcă (etichetă dedesubt) — altfel textul stă pe linie ty = y_start + dy - 12 if self.trend == "down" else y_start + dy + 20 out.append(f'' f'{"downtrend anterior" if self.trend == "down" else "uptrend anterior"}') # mediile mobile, sub candele ca ordine de desen closes = [c.c for c in self.candles] for ma in self.mas: pts = [] for i in range(len(closes)): if i + 1 >= ma.period: avg = sum(closes[i + 1 - ma.period:i + 1]) / ma.period pts.append(f"{self._cx(i):.1f},{self._y(avg):.1f}") if len(pts) >= 2: dash = f' stroke-dasharray="{ma.dash}"' if ma.dash else "" out.append(f'') if ma.text: last = pts[-1].split(",") out.append(f'{ma.text}') for i, cd in enumerate(self.candles): cx = self._cx(i) top_body, bot_body = max(cd.o, cd.c), min(cd.o, cd.c) color = cd.color or ("#188a3e" if cd.c >= cd.o else "#c00") out.append(f'') h = max(self._y(bot_body) - self._y(top_body), 1.6) out.append(f'') if cd.tag: out.append(f'{cd.tag}') # panoul de volum: bare colorate pe direcția candelei if vol_top is not None: assert vol_base is not None vmax = max(vols) out.append(f'') for i, cd in enumerate(self.candles): if cd.vol is None: continue bh = cd.vol / vmax * self.vol_h color = cd.color or ("#188a3e" if cd.c >= cd.o else "#c00") out.append(f'') out.append(f'{self.vol_label}') # etichete ancorate pe candele anume for mk in self.marks: cd = self.candles[mk.i] cx = self._cx(mk.i) if mk.where == "above": y = self._y(cd.h) - 10 + mk.dy else: # sub candelă, dar niciodată peste panoul de volum y = min(self._y(cd.l) + 20 + mk.dy, body_bottom + 14) anchor = "middle" if cx < 90: anchor, cx = "start", cx - 12 elif cx > self.width - 90: anchor, cx = "end", cx + 12 out.append(f'{mk.text}') # săgețile: linie de la text la țintă + vârf triunghi (nu marker, ca # să nu depindă de suportul în randare); un per rând, # ca audit_diagrams.py să le măsoare corect for a in self.arrows: g = self._arrow_geom(a) (x1, y1), (x2, y2) = g["tail"], g["tip"] d = max(((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5, 1e-9) ux, uy = (x2 - x1) / d, (y2 - y1) / d bx, by = x2 - 8 * ux, y2 - 8 * uy out.append(f'') out.append(f'') for t, y in zip(g["lines"], g["base"]): out.append(f'{t}') # detaliile ca bloc stânga-jos, câte o linie per candelă: centrate sub # candelă se suprapun între ele de îndată ce textul depășește `gap`. ny = panel_bottom + (self.label_gap if tags else 18) + 4 for cd in self.candles: if cd.note: color = cd.color or ("#188a3e" if cd.c >= cd.o else "#c00") out.append(f'{cd.note}') ny += 17 if self.caption: out.append(f'{self.caption}') out.append("") return "\n ".join(out).replace("\n ", "\n") def walk(start: float, moves: list[tuple[float, float, float]], vols: list[float] | None = None) -> list[Candle]: """Construiește o serie continuă de candele dintr-un preț de start. Fiecare `move` e (delta_close, wick_sus, wick_jos) față de open-ul barei, iar open-ul fiecărei bare e close-ul barei anterioare — deci seria nu are goluri artificiale, exact ca pe un grafic real. """ out, o = [], start for i, (dc, wu, wd) in enumerate(moves): c = o + dc h = max(o, c) + wu l = min(o, c) - wd out.append(Candle(o, h, l, c, vol=(vols[i] if vols else None))) o = c return out def from_closes(closes: list[float], wick: float = 0.0, wicks: list[tuple[float, float]] | None = None, vols: list[float] | None = None, start: float | None = None) -> list[Candle]: """Serie continuă de candele dintr-o listă de close-uri. Open-ul fiecărei bare = close-ul precedentei, deci structura (swing high-uri, swing low-uri) e exact cea din lista de close-uri — util când diagrama trebuie să susțină o afirmație despre niveluri. """ out = [] o = start if start is not None else closes[0] for i, c in enumerate(closes): wu, wd = wicks[i] if wicks else (wick, wick) out.append(Candle(o, max(o, c) + wu, min(o, c) - wd, c, vol=(vols[i] if vols else None))) o = c return out def selftest(): """Scara trebuie să fie aceeași oriunde, iar volumul proporțional.""" ch = Chart(candles=[Candle(o=63.05, h=63.30, l=62.10, c=63.30)], levels=[Level(62.05, "SL"), Level(63.35, "entry"), Level(65.95, "TP 2R")], scale=60) svg = ch.render() ys = {lv.price: ch._y(lv.price) for lv in ch.levels} risc = ys[62.05] - ys[63.35] # px, în jos castig = ys[63.35] - ys[65.95] # px, în sus assert abs(castig / risc - 2.0) < 0.01, f"2R desenat la {castig/risc:.2f}R" assert " min(c.l for c in ser[3:5]), \ "al doilea picior ar trebui să fie mai jos" # zone și săgeți: validare, text în pânză, vârful pe țintă for bad in (lambda: Arrow(0, "x", where="up"), lambda: Zone(3, 1), lambda: Chart(candles=ser, arrows=[Arrow(6, "x")]), lambda: Chart(candles=ser, zones=[Zone(4, 6)])): try: bad() raise AssertionError("validarea n-a prins argumente greșite") except ValueError: pass ch3 = Chart(candles=ser, scale=40, gap=14, body_w=9, width=200, zones=[Zone(1, 3, "baza")], arrows=[Arrow(0, "intrare\npe close", where="above"), Arrow(3, "stop", where="below"), Arrow(5, "ținta de 2R, foarte departe", where="right")]) svg3 = ch3.render() vw, vh = map(float, re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', svg3).groups()) assert vw > 200, "textul săgeții din dreapta iese din pânză" for x, y in re.findall(r'" in svg3 print("selftest ok: 2R la exact 2R, serie fără goluri, volum proporțional, zone+săgeți") if __name__ == "__main__": selftest()