Userul a respins mostra de format tutorial: prea lunga, prea multe trimiteri in text, termeni tehnici nedefiniti, prea multe cifre fara desen. Cerintele lui sunt acum contract in STYLE.md (FORMAT TUTORIAL v2). Textul: 8430 -> 4190 de cuvinte. Nivel 1 (din avion, cu mini-glosar) -> nivel 2 (6 pasi mecanici, o diagrama fiecare) -> oglinda short -> nivel 3 (rafinari). Trimiterile fisier:linie ies din corp si se aduna la final, cu legenda de prescurtari. Doua tabele in tot documentul. Toate cele 14 diagrame refacute: zone dreptunghiulare de evidentiere si sageti care spun si de ce, nu doar ce. svg_candles primeste Zone si Arrow; render() nu mai muta obiectul la iesire. Trei corecturi de continut: - un singur trade prin toata procedura (doji 50.10/49.25, intrare 50.40, stop 49.20, 1R 1.20, TP1 51.60); seria e acum o functie comuna, importata de stop/transe/iesire - short: stopul urca la 100.15, ca sa fie deasupra maximului cum cere regula oglindita; cifra cursului ramane in text ca observatie - LABU: trei minime dar doua atingeri ale pivotului 86.00, ceea ce impaca "triple bottom" cu "al doilea test", ambele din transcriere Reparat si bug-ul vechi din buy_doji_sell.svg: titlul peste eticheta dojiului. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThN7ixZrowca8aTGrK1MUJ
498 lines
24 KiB
Python
498 lines
24 KiB
Python
"""
|
|
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'<svg viewBox="0 0 {self.width} {height:.0f}" xmlns="http://www.w3.org/2000/svg" '
|
|
f'style="width:100%;height:auto;font:12px sans-serif">']
|
|
|
|
if self.title:
|
|
out.append(f'<text x="24" y="{self.top - (56 if self.trend == "down" else 0) - 8:.0f}" '
|
|
f'fill="#333" style="font-weight:bold">{self.title}</text>')
|
|
|
|
# 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'<rect x="{x0:.1f}" y="{y0:.1f}" width="{x1 - x0:.1f}" height="{y1 - y0:.1f}" '
|
|
f'fill="{z.color}" fill-opacity="0.08" stroke="{z.color}" stroke-width="1"/>')
|
|
if z.text:
|
|
out.append(f'<text x="{x0:.1f}" y="{self._zone_label_y(z):.1f}" fill="{z.color}" '
|
|
f'style="font-weight:bold">{z.text}</text>')
|
|
|
|
# nivelurile: linie pe toată lățimea, eticheta deasupra liniei
|
|
for lv in self.levels:
|
|
y = self._y(lv.price)
|
|
out.append(f'<line x1="24" y1="{y:.1f}" x2="{self.width - 14}" y2="{y:.1f}" '
|
|
f'stroke="{lv.color}" stroke-width="1.5" stroke-dasharray="{lv.dash}"/>')
|
|
if lv.label_at == "left":
|
|
out.append(f'<text x="30" y="{y - 6:.1f}" fill="{lv.color}">{lv.text}</text>')
|
|
else:
|
|
out.append(f'<text x="{self.width - 28}" y="{y - 6:.1f}" fill="{lv.color}" '
|
|
f'text-anchor="end">{lv.text}</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'<polyline points="24,{y_start + dy:.1f} {x0 - 8:.0f},{y_start:.1f}" '
|
|
f'fill="none" stroke="#999" stroke-width="2"/>')
|
|
# 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'<text x="24" y="{ty:.1f}" fill="#999">'
|
|
f'{"downtrend anterior" if self.trend == "down" else "uptrend anterior"}</text>')
|
|
|
|
# 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'<polyline points="{" ".join(pts)}" fill="none" '
|
|
f'stroke="{ma.color}" stroke-width="1.5"{dash}/>')
|
|
if ma.text:
|
|
last = pts[-1].split(",")
|
|
out.append(f'<text x="{float(last[0]) + 6:.0f}" y="{float(last[1]) - 6:.0f}" '
|
|
f'fill="{ma.color}">{ma.text}</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'<line x1="{cx:.1f}" y1="{self._y(cd.h):.1f}" x2="{cx:.1f}" '
|
|
f'y2="{self._y(cd.l):.1f}" stroke="#1a1a1a" stroke-width="1.2"/>')
|
|
h = max(self._y(bot_body) - self._y(top_body), 1.6)
|
|
out.append(f'<rect x="{cx - self.body_w / 2:.1f}" y="{self._y(top_body):.1f}" '
|
|
f'width="{self.body_w}" height="{h:.1f}" fill="{color}"/>')
|
|
if cd.tag:
|
|
out.append(f'<text x="{cx:.1f}" y="{panel_bottom + 18:.1f}" fill="{color}" '
|
|
f'text-anchor="middle" style="font-weight:bold">{cd.tag}</text>')
|
|
|
|
# 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'<line x1="24" y1="{vol_base:.1f}" x2="{self.width - 14}" '
|
|
f'y2="{vol_base:.1f}" stroke="#888"/>')
|
|
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'<rect x="{self._cx(i) - self.body_w / 2:.1f}" '
|
|
f'y="{vol_base - bh:.1f}" width="{self.body_w}" '
|
|
f'height="{bh:.1f}" fill="{color}" opacity="0.55"/>')
|
|
out.append(f'<text x="24" y="{vol_top - 6:.1f}" fill="#555">{self.vol_label}</text>')
|
|
|
|
# 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'<text x="{cx:.1f}" y="{y:.1f}" fill="{mk.color}" '
|
|
f'text-anchor="{anchor}" style="font-weight:bold">{mk.text}</text>')
|
|
|
|
# săgețile: linie de la text la țintă + vârf triunghi (nu marker, ca
|
|
# să nu depindă de suportul <defs> în randare); un <text> 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'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{bx:.1f}" y2="{by:.1f}" '
|
|
f'stroke="{a.color}" stroke-width="1.8"/>')
|
|
out.append(f'<polygon points="{x2:.1f},{y2:.1f} {bx - 4 * uy:.1f},{by + 4 * ux:.1f} '
|
|
f'{bx + 4 * uy:.1f},{by - 4 * ux:.1f}" fill="{a.color}"/>')
|
|
for t, y in zip(g["lines"], g["base"]):
|
|
out.append(f'<text x="{g["tx"]:.1f}" y="{y:.1f}" fill="{a.color}" '
|
|
f'text-anchor="{g["anchor"]}" style="font-weight:bold">{t}</text>')
|
|
|
|
# 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'<text x="24" y="{ny:.1f}" fill="{color}">{cd.note}</text>')
|
|
ny += 17
|
|
|
|
if self.caption:
|
|
out.append(f'<text x="24" y="{ny + 8:.0f}" fill="#555">{self.caption}</text>')
|
|
out.append("</svg>")
|
|
return "\n ".join(out).replace("\n </svg>", "\n</svg>")
|
|
|
|
|
|
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 "<svg" in svg and svg.count("\n\n") == 0, "linie goală în svg rupe markdown-ul"
|
|
|
|
# serie continuă: open-ul fiecărei bare = close-ul precedentei
|
|
cds = walk(100.0, [(1.0, 0.2, 0.3), (-0.5, 0.4, 0.1), (2.0, 0.1, 0.2)],
|
|
vols=[10.0, 20.0, 40.0])
|
|
for a, b in zip(cds, cds[1:]):
|
|
assert abs(a.c - b.o) < 1e-9, "serie cu gol artificial între bare"
|
|
|
|
# panoul de volum: înălțimea barei ∝ volum
|
|
ch2 = Chart(candles=cds, scale=40, gap=14, body_w=9, vol_h=70)
|
|
svg2 = ch2.render()
|
|
hs = [float(m) for m in __import__("re").findall(r'height="([\d.]+)" fill="#\w+" opacity', svg2)]
|
|
assert len(hs) == 3, f"panoul de volum a desenat {len(hs)} bare"
|
|
assert abs(hs[2] / hs[0] - 4.0) < 0.01, f"volum 4x desenat la {hs[2]/hs[0]:.2f}x"
|
|
assert abs(hs[1] / hs[0] - 2.0) < 0.01, f"volum 2x desenat la {hs[1]/hs[0]:.2f}x"
|
|
# structura din close-uri e păstrată exact
|
|
cl = [100.0, 98.0, 99.0, 96.5, 98.5, 101.0]
|
|
ser = from_closes(cl, wick=0.2)
|
|
assert [round(c.c, 2) for c in ser] == cl, "close-urile nu sunt respectate"
|
|
assert min(c.l for c in ser[:3]) > 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'<text x="([-\d.]+)" y="([-\d.]+)"', svg3):
|
|
assert 0 <= float(x) <= vw and 11 <= float(y) <= vh, f"text în afara pânzei la ({x}, {y})"
|
|
tip = ch3._arrow_geom(ch3.arrows[1])["tip"]
|
|
assert abs(tip[1] - (ch3._y(ser[3].l) + 4)) < 1e-6, "vârful săgeții below nu e sub low"
|
|
# a doua randare a aceluiași obiect trebuie să iasă identică
|
|
ch3.title, ch3.trend = "T", "down"
|
|
assert ch3.render() == ch3.render(), "render() modifică obiectul: a doua randare diferă"
|
|
assert (ch3.top, ch3.left, ch3.width) == (34, 64, 200), "render() a lăsat top/left/width modificate"
|
|
assert svg3.index("fill-opacity") < svg3.index("<rect", svg3.index("fill-opacity") + 1), \
|
|
"zona trebuie desenată înaintea candelelor"
|
|
assert svg3.count("<polygon") == 3 and "pe close</text>" in svg3
|
|
|
|
print("selftest ok: 2R la exact 2R, serie fără goluri, volum proporțional, zone+săgeți")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
selftest()
|