Garda: `Level(p, text, color, "left")` punea tacut "left" in `dash` in loc de `label_at`, iar SVG-ul iesea fara linie punctata si fara nicio eroare - trei agenti au pierdut timp pe asta pe 13.09. Acum `__post_init__` crapa cu mesaj care spune ce argument voiai. Verificat pe cazurile valide si pe cele doua gresite. PChart: era copiat in cinci generatoare, iar copiile divergisera - toate cinci aveau hash-uri diferite si patru pastrau `spread()` cel vechi. Nucleul comun trece in gen/pchart.py; 385 de linii sterse, 11 adaugate. az_executie.py isi pastreaza PChart-ul: varianta lui calculeaza mediile dintr-un istoric dinainte de bara 0 si si le deseneaza singura. Unirea celor doua comportamente ca sa scap de un fisier ar fi riscat cele 10 diagrame de acolo, pentru zero castig. Ia doar `spread` reparat. Reparatia schimba 4 documente, deci PROGRESS gresea cand spunea ca nicio diagrama comisa nu sufera de bug: 08 si 10 se misca cu 0,2 px, dar 07 si 09 aveau etichete la 6 px de linia lor. Verificat cu ochii pe 3 diagrame randate - etichetele stau acum pe linia lor, cu liniuta de legatura unde a trebuit mutata. audit_diagrams: 0 probleme. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThN7ixZrowca8aTGrK1MUJ
122 lines
6.7 KiB
Python
122 lines
6.7 KiB
Python
"""Nucleul comun al generatoarelor de Analiza Zilnica: `PChart` si `spread`.
|
|
|
|
Era copiat in cinci generatoare, iar copiile divergisera: reparatia din
|
|
`spread()` (grupurile care se ating se centreaza pe media pozitiilor
|
|
naturale, nu doar se impig in jos - altfel o eticheta care cade fix pe
|
|
linia de deasupra decaleaza toate etichetele de sub ea) traia doar in
|
|
`az_pivoti.py`. Aici e versiunea buna, singura.
|
|
|
|
Un generator nou importa de aici:
|
|
|
|
from pchart import PChart, Seg, spread
|
|
"""
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
from svg_candles import Chart, Level, Mark, MA
|
|
|
|
GRAY, RED, GREEN, BLUE, ORANGE, PURPLE = "#777", "#c00", "#188a3e", "#1a5fb4", "#b06000", "#6a3d9a"
|
|
|
|
|
|
@dataclass
|
|
class Seg:
|
|
"""nivel valabil doar pe barele i0..i1 (o lună, un trimestru)"""
|
|
i0: int
|
|
i1: int
|
|
price: float
|
|
text: str
|
|
color: str = ORANGE
|
|
label_at: str = "out" # "out" marginea dreaptă | "lm" marginea stângă | "left"/"right" pe linie
|
|
dy: float = -5.0 # pentru "left"/"right": eticheta deasupra liniei (negativ) sau dedesubt
|
|
dash: str = ""
|
|
|
|
|
|
def spread(items, gap=14):
|
|
"""[(y, ...)] -> aceleași, cu y-urile împinse ca două etichete să nu se atingă"""
|
|
# grupurile care se ating se centrează pe media pozițiilor naturale (nu doar împinse în jos),
|
|
# altfel o etichetă care coincide cu linia de deasupra decalează toate etichetele de sub ea
|
|
groups = [] # [start_y, [items]]
|
|
for it in sorted(items, key=lambda t: t[0]):
|
|
groups.append([it[0], [it]])
|
|
while len(groups) > 1 and groups[-2][0] + gap * len(groups[-2][1]) > groups[-1][0]:
|
|
last = groups.pop()
|
|
its = groups[-1][1] + last[1]
|
|
groups[-1] = [sum(t[0] - gap * k for k, t in enumerate(its)) / len(its), its]
|
|
return [(g[0] + gap * k,) + tuple(t[1:]) for g in groups for k, t in enumerate(g[1])]
|
|
|
|
|
|
@dataclass
|
|
class PChart(Chart):
|
|
"""Chart cu niveluri pe perioade. Nivelurile (`levels`) devin segmente pe toată seria;
|
|
etichetele stau în margini (dreapta / stânga), niciodată peste candele."""
|
|
segs: list = field(default_factory=list)
|
|
seps: list = field(default_factory=list) # indecși unde începe o perioadă nouă
|
|
pad_px: float = 24.0 # spațiu între cel mai sus preț și titlu
|
|
lm: int = 104 # marginea stângă pentru etichetele "lm"
|
|
pad_bot_px: float = 0.0 # spațiu sub cel mai jos preț, pentru etichetele de sub candele
|
|
|
|
def render(self) -> str:
|
|
n = len(self.candles)
|
|
self.left = self.lm if any(s.label_at == "lm" for s in self.segs) else self.left
|
|
segs = list(self.segs) + [Seg(0, n - 1, lv.price, lv.text, lv.color, "out", dash="4 3")
|
|
for lv in self.levels]
|
|
saved_lv, saved_ma = list(self.levels), list(self.mas)
|
|
# lățimea: cât să încapă cea mai lungă etichetă din marginea dreaptă
|
|
longest = max([len(s.text) for s in segs if s.label_at == "out"] + [len(m.text) for m in saved_ma] + [0])
|
|
self.width = max(self.width, int(self._cx(n - 1) + self.gap / 2 + 6 + longest * 6.2 + 8))
|
|
top_price = max([c.h for c in self.candles] + [s.price for s in segs])
|
|
# prețurile segmentelor intră în scară ca niveluri invizibile
|
|
self.levels = [Level(s.price, "", "none") for s in segs]
|
|
self.levels.append(Level(top_price + self.pad_px / self.scale, "", "none"))
|
|
if self.pad_bot_px:
|
|
low_price = min([c.l for c in self.candles] + [s.price for s in segs])
|
|
self.levels.append(Level(low_price - self.pad_bot_px / self.scale, "", "none"))
|
|
self.mas = [MA(m.period, m.color, m.dash) for m in saved_ma] # eticheta MA o punem noi, în margine
|
|
svg = self.render_base()
|
|
self.levels, self.mas = saved_lv, saved_ma
|
|
parts = svg.split("\n ")
|
|
extra = []
|
|
for k in self.seps:
|
|
x = self._cx(k) - self.gap / 2
|
|
extra.append(f'<line x1="{x:.1f}" y1="{self.top - 4:.1f}" x2="{x:.1f}" '
|
|
f'y2="{self._body_bottom:.1f}" stroke="#bbb" stroke-dasharray="2 3"/>')
|
|
closes = [c.c for c in self.candles]
|
|
xr = self._cx(n - 1) + self.gap / 2 + 6
|
|
out_lab = [(self._y(s.price) + 4, s) for s in segs if s.label_at == "out"]
|
|
out_lab += [(self._y(sum(closes[-m.period:]) / m.period) + 4, Seg(0, 0, 0, m.text, m.color))
|
|
for m in saved_ma if m.text]
|
|
placed = {id(s): y for y, s in spread(out_lab)}
|
|
placed.update({id(s): y for y, s in spread([(self._y(s.price) + 4, s) for s in segs if s.label_at == "lm"])})
|
|
for y, s in out_lab: # etichetă mutată de pe linia ei: legătură scurtă până la ea
|
|
if abs(placed[id(s)] - y) > 3:
|
|
extra.append(f'<line x1="{xr - 6:.1f}" y1="{y - 4:.1f}" x2="{xr - 1:.1f}" '
|
|
f'y2="{placed[id(s)] - 4:.1f}" stroke="{s.color}" stroke-width="1"/>')
|
|
for s in segs:
|
|
x0, x1, y = self._cx(s.i0) - self.gap / 2, self._cx(s.i1) + self.gap / 2, self._y(s.price)
|
|
dash = f' stroke-dasharray="{s.dash}"' if s.dash else ""
|
|
extra.append(f'<line x1="{x0:.1f}" y1="{y:.1f}" x2="{x1:.1f}" y2="{y:.1f}" '
|
|
f'stroke="{s.color}" stroke-width="{1.5 if s.dash else 2}"{dash}/>')
|
|
if s.label_at == "out":
|
|
extra.append(f'<text x="{xr:.1f}" y="{placed[id(s)]:.1f}" fill="{s.color}">{s.text}</text>')
|
|
elif s.label_at == "lm":
|
|
extra.append(f'<text x="{x0 - 6:.1f}" y="{placed[id(s)]:.1f}" fill="{s.color}" '
|
|
f'text-anchor="end">{s.text}</text>')
|
|
elif s.label_at == "left":
|
|
extra.append(f'<text x="{x0 + 3:.1f}" y="{y + s.dy:.1f}" fill="{s.color}">{s.text}</text>')
|
|
else:
|
|
extra.append(f'<text x="{x1 - 3:.1f}" y="{y + s.dy:.1f}" fill="{s.color}" '
|
|
f'text-anchor="end">{s.text}</text>')
|
|
for y, s in out_lab:
|
|
if s.i1 == 0 and s.price == 0: # eticheta de MA
|
|
extra.append(f'<text x="{xr:.1f}" y="{placed[id(s)]:.1f}" fill="{s.color}">{s.text}</text>')
|
|
k = 2 if self.title else 1
|
|
return "\n ".join(parts[:k] + extra + parts[k:])
|
|
|
|
def render_base(self):
|
|
svg = Chart.render(self)
|
|
# nivelurile invizibile lasă linii/etichete goale: le scoatem
|
|
svg = re.sub(r'\n <line [^>]*stroke="none"[^>]*/>\n <text [^>]*fill="none"[^>]*></text>', "", svg)
|
|
prices = [p for c in self.candles for p in (c.o, c.h, c.l, c.c)] + [lv.price for lv in self.levels]
|
|
self._body_bottom = self.top + (max(prices) - min(prices)) * self.scale
|
|
return svg
|