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