m2d_exemplu (Riot): eticheta zicea 'close peste bear trail = iesi' pe o bara
care se inchidea SUB linie - doar fitilul trecea, adica exact cazul pe care
capitolul il interzice de trei ori. Seria refacuta, cu assert care recalculeaza
trail-ul din serie si cere ca bara de iesire sa inchida peste el.
mbp_exemplu: candela M se inchidea la 660.50 sub banda de 660.90, iar
generatorul chiar afirma relatia inversa ('assert BAND > c'); desenul arata un
setup invalid ca valid. Plus stopul 662.71, care plutea 1.71 deasupra extremei
celor 4 bare - exact marja pe care capitolul o declara absenta la MBP.
Ambele reparate, cu assert pe fiecare.
atmi_orizont: trei etichete purtau 'fisier:linie' in desen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ThN7ixZrowca8aTGrK1MUJ
387 lines
16 KiB
Python
387 lines
16 KiB
Python
"""Diagrama din `gen/strategii/m2d_exemplu.spec.txt` (TIP: PRET).
|
||
|
||
Exemplul real Riot din curs (`14_strategia_1_m2d.md:227-236`): SHORT pe TF mic
|
||
3 min, cu TF mare 15 min în Sell. Pullback-ul Buy de pe TF mic (M) e urmat de
|
||
primul Sell (A = trigger); SL = cel mai sus high din 4 candele = +0.85%,
|
||
TP1 = 1:1 = -0.85%, iar ieșirea se face pe violarea bear trail-ului la +4.55%
|
||
(risc/recompensă efectiv ≈ 1:5.3).
|
||
|
||
Reale din curs: procentele 0.85% / 1.42% / 4.55% și contextul calitativ.
|
||
Construită de mine: axa de preț absolută, intrarea fixată la 100.00 ca reper.
|
||
Legenda spune explicit lucrul ăsta.
|
||
|
||
Serie continuă de 30 de candele din lista de close-uri din specificație,
|
||
open = close-ul precedentului, wick-uri alese ca să scoată exact nivelurile
|
||
(candela 13: high 100.85 = SL), panou de volum dedesubt.
|
||
|
||
Bear trail-ul e calculat din serie cu regula capitolului (linia sare pe
|
||
high-ul barei care se închide sub low-ul precedentei; violare = o bară care
|
||
se închide PESTE linie) și desenat ca scară portocalie. Seria e construită
|
||
astfel încât fitilele să atingă linia de trei ori (barele 16, 20, 24) fără
|
||
s-o violeze, iar ieșirea să vină abia la bara 29, care închide peste linie.
|
||
"""
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, "/workspace/atm-curs-dl")
|
||
from svg_candles import Chart, Level, Mark, from_closes # noqa: E402
|
||
|
||
OUT = Path(__file__).resolve().parent / "m2d_exemplu.svg"
|
||
|
||
# ── nivelurile cerute de specificație ──────────────────────────────────────
|
||
ENTRY = 100.00 # close-ul candelei de trigger 14
|
||
SL = 100.85 # max high pe candelele 11:15 = +0.85%
|
||
TP1 = 99.15 # entry - 0.85% (1:1)
|
||
EXIT = 95.45 # close-ul candelei 29 = +4.55%
|
||
|
||
# ── seria: 30 de close-uri, exact ca în specificație ───────────────────────
|
||
closes = [
|
||
97.6, 97.1, 96.7, 96.3, 95.9, 95.6, 95.9, # 0-6 coborâre
|
||
96.4, 96.9, 97.5, 98.2, 99.0, 99.7, 100.3, # 7-13 pullback Buy (M)
|
||
100.00, # 14 trigger Sell (A)
|
||
99.45, 99.80, 98.58, 98.10, 97.60, 97.95, 97.40, 96.90, 96.40, 96.75,
|
||
96.20, 95.70, 95.20, 95.00, 95.45, # 15-29 bear trail
|
||
]
|
||
assert len(closes) == 30, len(closes)
|
||
|
||
# wick-uri: candela 13 are nevoie de un wick sus până la 100.85 (SL-ul de 4
|
||
# candele); barele 16, 20 și 24 sunt retestări ale liniei de trail, deci au
|
||
# fitil lung în sus care atinge linia, dar se închid sub ea. Restul sunt mici,
|
||
# ca seria să arate ca un grafic real.
|
||
wicks = [
|
||
(0.15, 0.20), (0.12, 0.18), (0.15, 0.15), (0.10, 0.20), (0.12, 0.25),
|
||
(0.10, 0.30), (0.25, 0.15),
|
||
(0.20, 0.15), (0.15, 0.20), (0.20, 0.15), (0.20, 0.20), (0.20, 0.18),
|
||
(0.25, 0.15), (SL - 100.3, 0.15), # 13: high = 100.85
|
||
(0.15, 0.25), # 14: trigger Sell
|
||
(0.15, 0.15), # 15
|
||
(0.45, 0.10), # 16 retest trail
|
||
(0.15, 0.15), # 17
|
||
(0.15, 0.15), # 18
|
||
(0.15, 0.15), # 19
|
||
(0.40, 0.10), # 20 retest trail
|
||
(0.15, 0.15), # 21
|
||
(0.15, 0.15), # 22
|
||
(0.15, 0.15), # 23
|
||
(0.40, 0.10), # 24 retest trail
|
||
(0.15, 0.15), # 25
|
||
(0.15, 0.15), # 26
|
||
(0.15, 0.15), # 27
|
||
(0.05, 0.05), # 28: coboară trail-ul
|
||
(0.20, 0.10), # 29: close peste trail
|
||
]
|
||
assert len(wicks) == 30, len(wicks)
|
||
|
||
vols = [
|
||
48, 45, 42, 40, 44, 50, 46,
|
||
42, 44, 40, 43, 47, 52, 68,
|
||
85,
|
||
60, 55, 52, 48, 46, 44, 42, 40, 43, 41, 44, 42, 40, 45, 72,
|
||
]
|
||
assert len(vols) == 30, len(vols)
|
||
|
||
cds = from_closes(closes, wicks=wicks, vols=vols, start=98.0)
|
||
|
||
# ── assert-urile cerute de specificație ────────────────────────────────────
|
||
# 1. entry == close-ul candelei de trigger 14
|
||
assert round(cds[14].c, 2) == ENTRY == 100.00, cds[14].c
|
||
|
||
# 2. SL == max high pe candelele 11:15
|
||
max_h = max(c.h for c in cds[11:15])
|
||
assert round(max_h, 2) == SL == 100.85, max_h
|
||
|
||
# 3. SL = +0.85% față de entry
|
||
assert abs((SL - ENTRY) / ENTRY - 0.0085) < 1e-4
|
||
|
||
# 4. TP1 = entry − (SL − entry) = 1:1
|
||
assert TP1 == round(ENTRY - (SL - ENTRY), 2) == 99.15, TP1
|
||
assert abs((ENTRY - TP1) / ENTRY - 0.0085) < 1e-4
|
||
|
||
# 5. exit == close-ul candelei 29
|
||
assert round(cds[29].c, 2) == EXIT == 95.45, cds[29].c
|
||
|
||
# 6. R efectiv ≈ 1:5.3
|
||
assert abs((ENTRY - EXIT) / (SL - ENTRY) - 5.35) < 0.05
|
||
|
||
# 7. niciun close după trigger nu trece peste SL
|
||
assert max(c.c for c in cds[15:]) < SL, max(c.c for c in cds[15:])
|
||
|
||
# 8. fiecare open == close-ul barei anterioare
|
||
for a, b in zip(cds, cds[1:]):
|
||
assert abs(a.c - b.o) < 1e-9, "serie cu gol artificial între bare"
|
||
|
||
# 9. candela 14 închide în jos după pullback-ul Buy (7-13)
|
||
assert cds[14].c < cds[13].c, (cds[13].c, cds[14].c)
|
||
assert any(cds[i].c > cds[i].o for i in range(7, 14)), "pullback Buy absent"
|
||
|
||
# 10. bara 17 e la 1.42% profit (procentul real din curs)
|
||
assert abs((ENTRY - cds[17].c) / ENTRY - 0.0142) < 1e-4, cds[17].c
|
||
|
||
|
||
# ── bear trail, recalculat din serie cu regula capitolului ─────────────────
|
||
# Linia sare pe high-ul fiecărei bare care se închide sub low-ul precedentei;
|
||
# violarea = prima bară care se închide PESTE linie (fitilul singur nu scoate).
|
||
def bear_trail(candles, trig):
|
||
"""linie[i] = nivelul trail-ului în vigoare la începutul barei i (None până
|
||
se formează linia); iesire = prima bară închisă peste linie."""
|
||
linie, linie_la, iesire = None, [], None
|
||
for i, c in enumerate(candles):
|
||
linie_la.append(linie)
|
||
if i > trig and linie is not None and c.c > linie:
|
||
iesire = i
|
||
break
|
||
if i > trig:
|
||
p = candles[i - 1]
|
||
if c.c < p.l:
|
||
linie = c.h
|
||
while len(linie_la) < len(candles):
|
||
linie_la.append(linie)
|
||
return linie_la, iesire
|
||
|
||
|
||
LINIE, EXIT_BAR = bear_trail(cds, 14)
|
||
|
||
# 11. ieșirea e pe bara 29, care se închide PESTE linia de trail (nu doar fitilul)
|
||
assert EXIT_BAR == 29, f"ieșirea trebuie să fie pe bara 29, nu {EXIT_BAR}"
|
||
TRAIL_EXIT = LINIE[29]
|
||
assert TRAIL_EXIT is not None and cds[29].c > TRAIL_EXIT, (cds[29].c, TRAIL_EXIT)
|
||
|
||
# 12. linia nu urcă niciodată (e trail, nu nivel fix)
|
||
pas = [v for v in LINIE[15:30] if v is not None]
|
||
assert all(b <= a + 1e-9 for a, b in zip(pas, pas[1:])), pas
|
||
|
||
# 13. nicio bară dinainte de ieșire nu închide peste linie
|
||
for i in range(15, 29):
|
||
if LINIE[i] is not None:
|
||
assert cds[i].c < LINIE[i], (i, cds[i].c, LINIE[i])
|
||
|
||
# 14. fitilele au atins linia de mai multe ori fără s-o violeze pe close
|
||
atingeri = [i for i in range(15, 29) if LINIE[i] is not None
|
||
and cds[i].h >= LINIE[i] - 1e-9 and cds[i].c < LINIE[i]]
|
||
assert len(atingeri) >= 2, f"fitilele ating linia doar la barele {atingeri}"
|
||
assert set(atingeri) >= {16, 20, 24}, f"retestările lipsesc: {atingeri}"
|
||
|
||
# ── geometria, ca să așez marks-urile „below" fără suprapunere ─────────────
|
||
SCALE = 42
|
||
levels = [
|
||
Level(SL, "SL 100.85 = +0.85%", "#c00"),
|
||
Level(ENTRY, "intrare 100.00 (trigger Sell)", "#333"),
|
||
Level(TP1, "TP1 99.15 = 1:1", "#1a5fb4"),
|
||
Level(EXIT, "ieșire 95.45 = +4.55% ≈ 1:5.3", "#188a3e"),
|
||
]
|
||
prices = [p for c in cds for p in (c.o, c.h, c.l, c.c)] + [lv.price for lv in levels]
|
||
pmax, pmin = max(prices), min(prices)
|
||
top = 34 + 22 # implicit + titlu, fără segment de trend
|
||
body_bottom = top + (pmax - pmin) * SCALE
|
||
|
||
|
||
def y(price: float) -> float:
|
||
return top + (pmax - price) * SCALE
|
||
|
||
|
||
# ── așezarea etichetelor: caut un y unde textul nu atinge vreun corp de
|
||
# candelă și nu se suprapune cu altă etichetă. Sub grafic sunt doar ~14 px
|
||
# liberi (body_bottom .. body_bottom+14), deci etichetele lungi trebuie
|
||
# coborâte/diferite pe rânduri. Fără asta, „A" și „M" trec peste candele.
|
||
GAP, LEFT, WIDTH, BODY_W = 15, 44, 700, 10
|
||
|
||
|
||
def cx(i: int) -> float:
|
||
return LEFT + i * GAP
|
||
|
||
|
||
def text_x(i: int, text: str) -> tuple[float, float]:
|
||
"""(x0, x1) al textului bold ancorat pe candela i, ca în svg_candles."""
|
||
x = cx(i)
|
||
w = len(text) * 7.44
|
||
if x < 90:
|
||
return x - 12, x - 12 + w
|
||
if x > WIDTH - 90:
|
||
return x + 12 - w, x + 12
|
||
return x - w / 2, x + w / 2
|
||
|
||
|
||
corpuri = []
|
||
for i, c in enumerate(cds):
|
||
y0, y1 = y(max(c.o, c.c)), y(min(c.o, c.c))
|
||
y1 = max(y1, y0 + 1.6)
|
||
corpuri.append((cx(i) - BODY_W / 2, cx(i) + BODY_W / 2, y0, y1))
|
||
|
||
linii = [y(lv.price) for lv in levels]
|
||
|
||
|
||
def liber_fara_puse(i: int, text: str, yv: float) -> bool:
|
||
x0, x1 = text_x(i, text)
|
||
ty0, ty1 = yv - 10, yv + 3
|
||
if x0 < -2 or x1 > WIDTH + 2 or yv < top + 12 or yv > body_bottom + 14:
|
||
return False
|
||
for bx0, bx1, by0, by1 in corpuri:
|
||
if x0 < bx1 - 2 and bx0 < x1 - 2 and ty0 < by1 - 2 and by0 < ty1 - 2:
|
||
return False
|
||
# nicio linie de nivel (SL/entry/TP1/exit) prin mijlocul textului
|
||
for ly in linii:
|
||
if ty0 - 1 <= ly <= ty1 + 1:
|
||
return False
|
||
return True
|
||
|
||
|
||
def conflict(a: tuple, b: tuple) -> bool:
|
||
"""Două etichete se bat dacă au aceeași bandă de y și se suprapun pe x."""
|
||
ax0, ax1, ay = a
|
||
bx0, bx1, by = b
|
||
return abs(ay - by) < 13 and ax0 < bx1 - 2 and bx0 < ax1 - 2
|
||
|
||
|
||
puse: list[tuple] = []
|
||
definiții = [
|
||
(0, "TF mare (15 min) în Sell", "#c00", "above"),
|
||
(7, "M: Buy Momentum (pullback)", "#0891b2", "above"),
|
||
(14, "A: primul Sell = trigger, intri short", "#c00", "below"),
|
||
(17, "deja peste TP1 (1.42% din curs)", "#188a3e", "below"),
|
||
(29, "close peste bear trail = ieși", "#188a3e", "below"),
|
||
]
|
||
|
||
|
||
def natural_y(i: int, where: str) -> float:
|
||
# „below": sub candelă, dar înainte de panoul de volum. Plafonul e cu 1 px
|
||
# sub limita de jos a candidaților, altfel când bara i are chiar minimul
|
||
# seriei nu mai rămâne niciun y întreg de testat și plasarea pică.
|
||
return (y(cds[i].h) - 10 if where == "above"
|
||
else min(y(cds[i].l) + 20, body_bottom + 13))
|
||
|
||
|
||
# candidați: toate y-urile care nu ating niciun corp de candelă, ordonate
|
||
# după cât de aproape sunt de poziția naturală (cu preferință pentru partea
|
||
# cerută de specificație: „above" în sus, „below" în jos)
|
||
candidati = []
|
||
for i, text, _color, where in definiții:
|
||
nat = natural_y(i, where)
|
||
ys = [yy for yy in range(int(top + 12), int(body_bottom + 14) + 1)
|
||
if liber_fara_puse(i, text, yy)]
|
||
if where == "below":
|
||
# o etichetă „below" rămâne sub candelă: nu urcă peste preț
|
||
ys = [yy for yy in ys if yy >= nat]
|
||
ys.sort(key=lambda yv: yv - nat)
|
||
else:
|
||
# „above" preferă în sus, dar dacă nu încape coboară sub serie
|
||
ys.sort(key=lambda yv: (abs(yv - nat), 0 if yv <= nat else 1))
|
||
candidati.append(ys)
|
||
assert ys, f"nicio poziție liberă pentru {text!r}"
|
||
|
||
# backtracking: plasez întâi etichetele cu cele mai puține opțiuni
|
||
ordine = sorted(range(len(definiții)), key=lambda k: len(candidati[k]))
|
||
y_final = [None] * len(definiții)
|
||
|
||
|
||
def rezolva(k: int) -> bool:
|
||
if k == len(ordine):
|
||
return True
|
||
idx = ordine[k]
|
||
ci = definiții[idx][0]
|
||
for yv in candidati[idx]:
|
||
box = (*text_x(ci, definiții[idx][1]), yv)
|
||
if all(not conflict(box, p) for p in puse):
|
||
puse.append(box)
|
||
y_final[idx] = yv
|
||
if rezolva(k + 1):
|
||
return True
|
||
puse.pop()
|
||
return False
|
||
|
||
|
||
assert rezolva(0), "nu găsesc o așezare fără suprapuneri pentru etichete"
|
||
|
||
marks = [Mark(i, text, color, where, dy=y_final[k] - natural_y(i, where))
|
||
for k, (i, text, color, where) in enumerate(definiții)]
|
||
assert all(y_final), y_final
|
||
|
||
|
||
ch = Chart(
|
||
candles=cds,
|
||
levels=levels,
|
||
marks=marks,
|
||
scale=SCALE, gap=GAP, body_w=BODY_W, left=LEFT, width=WIDTH, vol_h=70,
|
||
vol_label="volum",
|
||
title="RIOT — 3 min (TF mare 15 min în Sell)",
|
||
caption="Axa de preț e construită (intrarea = 100.00); procentele sunt reale din curs.",
|
||
)
|
||
svg = ch.render()
|
||
assert "<svg" in svg and svg.count("\n\n") == 0, "linie goală în SVG"
|
||
|
||
# ── linia de trail, desenată ca scară portocalie ───────────────────────────
|
||
# Nivelul desenat la bara i e cel în vigoare după bara i (linia sare pe bara
|
||
# care a închis sub low-ul precedentei). Eticheta stă la stânga primului
|
||
# palier, ca să nu treacă peste fitilul lung al barei 13.
|
||
def nivel_desen(i: int):
|
||
return LINIE[i + 1] if i + 1 < len(LINIE) else LINIE[i]
|
||
|
||
|
||
trail_parts, prev_y = [], None
|
||
for i in range(15, len(cds)):
|
||
v = nivel_desen(i)
|
||
if v is None:
|
||
continue
|
||
x0, x1, yv = cx(i) - GAP / 2, cx(i) + GAP / 2, y(v)
|
||
trail_parts.append(
|
||
f'<line x1="{x0:.1f}" y1="{yv:.1f}" x2="{x1:.1f}" y2="{yv:.1f}" '
|
||
f'stroke="#b06000" stroke-width="2"/>')
|
||
if prev_y is not None and abs(prev_y - yv) > 1e-9:
|
||
trail_parts.append(
|
||
f'<line x1="{x0:.1f}" y1="{prev_y:.1f}" x2="{x0:.1f}" y2="{yv:.1f}" '
|
||
f'stroke="#b06000" stroke-width="1.2" stroke-dasharray="2 2"/>')
|
||
prev_y = yv
|
||
trail_parts.append(
|
||
f'<text x="{cx(13) - 6:.1f}" y="{y(nivel_desen(15)) - 14:.1f}" fill="#b06000" '
|
||
f'text-anchor="end" style="font-weight:bold">bear trail</text>')
|
||
svg = svg.replace("\n</svg>", "\n " + "\n ".join(trail_parts) + "\n</svg>")
|
||
|
||
# ── verificare: fiecare etichetă e în pânză și nu se suprapune alta ────────
|
||
m = re.search(r'viewBox="0 0 (\d+) (\d+)"', svg)
|
||
W, H = int(m.group(1)), int(m.group(2))
|
||
texte = re.findall(r'<text x="([\d.]+)" y="([\d.]+)"([^>]*)>([^<]*)</text>', svg)
|
||
cutii, cutii_text = [], []
|
||
for xs, ys, attrs, continut in texte:
|
||
x, yv = float(xs), float(ys)
|
||
w = len(continut) * (7.44 if "bold" in attrs else 6.72)
|
||
if 'text-anchor="end"' in attrs:
|
||
x0 = x - w
|
||
elif 'text-anchor="middle"' in attrs:
|
||
x0 = x - w / 2
|
||
else:
|
||
x0 = x
|
||
assert -2 <= x0 and x0 + w <= W + 2, f"etichetă în afara pânzei: {continut!r}"
|
||
assert 0 <= yv <= H, f"etichetă în afara înălțimii: {continut!r}"
|
||
cutii.append((x0, x0 + w, yv, continut))
|
||
cutii_text.append((x0, x0 + w, yv - 10, yv + 3, continut))
|
||
for i in range(len(cutii)):
|
||
for j in range(i + 1, len(cutii)):
|
||
a, b = cutii[i], cutii[j]
|
||
if abs(a[2] - b[2]) < 13 and a[0] < b[1] - 2 and b[0] < a[1] - 2:
|
||
raise AssertionError(f"texte suprapuse: {a[3]!r} / {b[3]!r}")
|
||
|
||
# ── verificare: nicio etichetă peste un corp de candelă ────────────────────
|
||
corpuri = []
|
||
for mm in re.finditer(
|
||
r'<rect x="([\d.]+)" y="([\d.]+)" width="10" height="([\d.]+)" '
|
||
r'fill="#[0-9a-f]{3,6}"/>', svg):
|
||
x0, y0, hh = map(float, mm.groups())
|
||
corpuri.append((x0, x0 + 10, y0, y0 + hh))
|
||
for tx0, tx1, ty0, ty1, continut in cutii_text:
|
||
for cx0, cx1, cy0, cy1 in corpuri:
|
||
if tx0 < cx1 - 2 and cx0 < tx1 - 2 and ty0 < cy1 - 2 and cy0 < ty1 - 2:
|
||
raise AssertionError(
|
||
f"etichetă peste candelă: {continut!r} la y~{ty0:.0f} "
|
||
f"({tx0:.0f}-{tx1:.0f}) vs rect {cx0:.0f}-{cx1:.0f} "
|
||
f"y {cy0:.0f}-{cy1:.0f}")
|
||
|
||
# legenda mai spune explicit că axa e construită (cerință de fidelitate)
|
||
assert "construită" in svg and "procentele sunt reale" in svg, \
|
||
"lipsește nota despre axa construită"
|
||
|
||
OUT.write_text(svg, encoding="utf-8")
|
||
print(f"serie {len(cds)} candele | intrare {ENTRY:.2f} @ bara 14 | "
|
||
f"SL {SL:.2f} (+0.85%) | TP1 {TP1:.2f} (1:1) | "
|
||
f"ieșire {EXIT:.2f} (+4.55%, R {(ENTRY - EXIT) / (SL - ENTRY):.2f})")
|
||
print(f"scris {OUT}")
|