buy_doji: refacut ca tutorial pe 3 niveluri, cu diagrame adnotate

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
This commit is contained in:
Claude Agent
2026-09-19 21:09:31 +00:00
parent aff03870e8
commit 8b7b5f6b44
50 changed files with 7011 additions and 6921 deletions

View File

@@ -89,6 +89,37 @@ class MA:
_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]
@@ -108,6 +139,17 @@ class Chart:
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
@@ -115,10 +157,80 @@ class Chart:
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,
@@ -127,6 +239,10 @@ class Chart:
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]
@@ -148,6 +264,15 @@ class Chart:
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)
@@ -234,6 +359,23 @@ class Chart:
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
@@ -319,7 +461,36 @@ def selftest():
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"
print("selftest ok: 2R la exact 2R, serie fără goluri, volum proporțional")
# 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__":