svg_candles: serie continua, panou de volum, medii mobile, etichete ancorate
Userul a cerut ca diagramele de pret sa arate ca un grafic real (TradingView): linie continua de pret si volum, ca sa se vada trendul. Candelele izolate cu spatii mari nu spun nimic despre trend. Adaugat in svg_candles.py: - from_closes() / walk(): serii continue, open = close-ul barei anterioare, cu structura controlata exact din close-uri - panou de volum (vol_h), bare colorate pe directia candelei - MA: medii mobile calculate din close-uri - Mark: etichete ancorate pe candele anume (la 30 de bare, tag-urile per candela se suprapun) - title, Level.label_at - selftest extins: volum proportional, serie fara goluri, structura pastrata STYLE.md: regula noua, cu cele doua stiluri (serie de 25-45 de bare pentru orice grafic de pret; stilul vechi de 3-6 candele doar pentru definitii de pattern-uri de o candela) si obligatia de assert in generator. gen/exemplu_grafic_real.py: referinta de copiat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtSTyTmt6AbCL9j5ajEDm1
This commit is contained in:
202
svg_candles.py
202
svg_candles.py
@@ -9,6 +9,17 @@ 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.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -20,9 +31,10 @@ class Candle:
|
||||
h: float
|
||||
l: float
|
||||
c: float
|
||||
tag: str = "" # eticheta de sub candelă
|
||||
note: str = "" # a doua linie de etichetă
|
||||
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
|
||||
@@ -31,12 +43,34 @@ class Level:
|
||||
text: str
|
||||
color: str = "#1a5fb4"
|
||||
dash: str = "4 3"
|
||||
label_at: str = "right" # "right" | "left" — unde stă eticheta
|
||||
|
||||
|
||||
@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 = ""
|
||||
|
||||
|
||||
@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
|
||||
@@ -46,33 +80,60 @@ class Chart:
|
||||
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"
|
||||
|
||||
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 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]
|
||||
self._pmax, pmin = max(prices), min(prices)
|
||||
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
|
||||
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)
|
||||
height = body_bottom + extra + self.label_gap + 17 * n_notes + (24 if self.caption else 8)
|
||||
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">']
|
||||
|
||||
# nivelurile: linie pe toată lățimea, eticheta deasupra liniei la dreapta
|
||||
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>')
|
||||
|
||||
# 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}"/>')
|
||||
out.append(f'<text x="{self.width - 28}" y="{y - 6:.1f}" fill="{lv.color}" '
|
||||
f'text-anchor="end">{lv.text}</text>')
|
||||
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:
|
||||
@@ -86,22 +147,72 @@ class Chart:
|
||||
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 = x0 + i * self.gap
|
||||
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}" y1="{self._y(cd.h):.1f}" x2="{cx}" y2="{self._y(cd.l):.1f}" '
|
||||
f'stroke="#1a1a1a" stroke-width="1.5"/>')
|
||||
h = max(self._y(bot_body) - self._y(top_body), 2.0)
|
||||
out.append(f'<rect x="{cx - self.body_w // 2}" y="{self._y(top_body):.1f}" '
|
||||
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}" y="{body_bottom + extra + 18:.1f}" fill="{color}" '
|
||||
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>')
|
||||
|
||||
# detaliile OHLC ca bloc stânga-jos, câte o linie per candelă: centrate sub
|
||||
# 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>')
|
||||
|
||||
# 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 = body_bottom + extra + self.label_gap + 4
|
||||
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")
|
||||
@@ -114,8 +225,46 @@ class Chart:
|
||||
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():
|
||||
"""O unitate de preț trebuie să dea aceeași distanță oriunde pe desen."""
|
||||
"""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)
|
||||
@@ -125,7 +274,28 @@ def selftest():
|
||||
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"
|
||||
print("selftest ok: 2R desenat la exact 2R, fără linii goale")
|
||||
|
||||
# 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"
|
||||
|
||||
print("selftest ok: 2R la exact 2R, serie fără goluri, volum proporțional")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user