"""Buy hammer — A+ vs. mediocru (două panouri, exemple construite).
Citește `gen/strategii/hammer_mbt10_bh_aplus.spec.txt`
(marcajul ``, Partea A §2.2).
Același pattern ca formă (downtrend → hammer → confirmare), context opus:
- stânga (A+): downtrend de 12 candele de la 50.00 spre 42.80, hammer-ul cu
low 41.90 chiar pe suportul monthly 42.00 (0,24 % sub nivel; corp 0.10,
fitil jos 0.90), confirmarea închide 42.95 peste high-ul 42.90, pe volum
peste media ultimelor 10 bare — „acumulare";
- dreapta (NU): fără downtrend real (range lateral 44–46), hammer-ul în
mijlocul intervalului (o=45.00, c=h=45.10, l=44.10), departe de orice
nivel (suportul construit 42.50 e la 3,6 % sub low), confirmarea închide
tot 45.15 peste 45.10, dar pe volum slab — context invalid.
Nivelurile 42.00 / 42.50 și cifrele de preț sunt construite (materialul nu
dă prețuri pentru acest contrast); regulile (locația la nivel, downtrend,
volum) sunt din curs. Două `Chart`-uri randate separat, lipite într-un SVG
wrapper: coordonatele fiecărui panou rămân corecte prin construcție.
Notă de serie: specificația listează `close[11]=42.40` (A+) și `44.60` (NU),
dar descrie hammer-ul cu `o=42.80 / c=42.90`, respectiv `o=45.00 / c=45.10`,
iar assert 9 cere `close == high`. Cum open-ul fiecărei bare = close-ul
precedentei (STYLE.md), am ridicat close[11] la 42.80 / 45.00 și close[12]
la 42.90 / 45.10, ca seria să susțină exact OHLC-ul din etichetă. Restul
close-urilor sunt cele din specificație.
Rulează: python3 gen/strategii/hammer_mbt10_bh_aplus.py
Scrie: gen/strategii/hammer_mbt10_bh_aplus.svg
"""
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 / "hammer_mbt10_bh_aplus.svg"
GRAY, RED, GREEN, BLUE, ORANGE = "#777", "#c00", "#188a3e", "#1a5fb4", "#b06000"
HAM_A, CONF_A = 12, 13 # indicii în panoul A+
HAM_N, CONF_N = 12, 13 # indicii în panoul NU
SUPORT_A = 42.00 # suport monthly (A+)
SUPORT_N = 42.50 # cel mai apropiat nivel construit (NU)
# ============================================================ PANOUL A+ (20 bare)
# 12 bare de downtrend 50.00 -> 42.80, hammer la 42.00, confirmare, apoi urcare.
CLOSES_A = [
50.00, 49.10, 49.30, 48.20, 47.30, 47.60, 46.40, 45.50, 44.60, 43.70,
43.00, 42.80,
42.90, # 12 = hammer: o=42.80, c=h=42.90, l=41.90 (H=C, fitil jos 0.90)
42.95, # 13 = confirmare, close > 42.90 => intrare
43.40, 43.80, 44.20, 44.00, 44.50, 44.80,
]
assert len(CLOSES_A) == 20, len(CLOSES_A)
WICKS_A = [(0.25, 0.30)] * 20
WICKS_A[HAM_A] = (0.00, 0.90) # high 42.90, low 41.90 (pe suportul 42.00)
WICKS_A[CONF_A] = (0.20, 0.20) # confirmarea: o=42.90, c=42.95
# Volum ales de generator (materialul nu dă volum): scade pe downtrend, climax
# pe hammer, spike pe confirmare („acumulare"), moderat după.
VOLS_A = [54, 52, 51, 49, 48, 47, 46, 45, 44, 43,
42, 45,
64, # 12 = hammer: exces de vânzare
90, # 13 = confirmare: volum peste medie
74, 70, 66, 62, 60, 58]
assert len(VOLS_A) == 20
cds_a = from_closes(CLOSES_A, wicks=WICKS_A, vols=VOLS_A, start=50.60)
ham_a, conf_a = cds_a[HAM_A], cds_a[CONF_A]
# ============================================================ PANOUL NU (20 bare)
# range lateral 44-46, fără downtrend; hammer în mijloc, confirmare pe volum slab.
CLOSES_N = [
46.00, 45.30, 45.80, 45.10, 45.60, 44.90, 45.40, 44.80, 45.20, 44.70,
45.10, 45.00,
45.10, # 12 = hammer: o=45.00, c=h=45.10, l=44.10 (H=C, fitil jos 0.90)
45.15, # 13 = confirmare, close > 45.10
44.90, 44.70, 44.40, 44.20, 44.00, 43.80,
]
assert len(CLOSES_N) == 20, len(CLOSES_N)
WICKS_N = [(0.25, 0.30)] * 20
WICKS_N[HAM_N] = (0.00, 0.90) # high 45.10, low 44.10
WICKS_N[CONF_N] = (0.20, 0.20)
# Volum ales de generator: lateral, constant, confirmarea pe volum slab.
VOLS_N = [52, 50, 53, 49, 51, 48, 52, 50, 49, 51,
50, 49,
56, # 12
31, # 13 = confirmare: volum slab
52, 50, 48, 51, 49, 47]
assert len(VOLS_N) == 20
cds_n = from_closes(CLOSES_N, wicks=WICKS_N, vols=VOLS_N, start=45.60)
ham_n, conf_n = cds_n[HAM_N], cds_n[CONF_N]
# ============================================================ assert-urile din spec
# 1. A+: low[12] la <= 0,5 % de 42.00 (41.90 e la 0,24 %)
dist_a = abs(ham_a.l - SUPORT_A) / SUPORT_A
assert dist_a <= 0.005, f"low hammer A+ {ham_a.l} la {dist_a * 100:.2f} % de 42.00"
# low-ul hammer-ului chiar trece sub nivel (fitilul sparge suportul)
assert ham_a.l < SUPORT_A <= ham_a.h, "hammer-ul A+ nu are fitilul pe/la nivel"
# 2. A+: close[13] > high[12] (tehnic valid)
assert conf_a.c > ham_a.h, f"A+ close {conf_a.c} <= high hammer {ham_a.h}"
assert abs(conf_a.c - 42.95) < 1e-9
# 3. A+: volumul pe bara 13 > media volumelor pe 10 bare
media_a = sum(VOLS_A[CONF_A - 10:CONF_A]) / 10
assert VOLS_A[CONF_A] > media_a, f"vol confirmare A+ {VOLS_A[CONF_A]} <= media {media_a}"
# 4. A+: close[12] < close[0] - 5 (downtrend vizibil)
assert cds_a[HAM_A].c < cds_a[0].c - 5, "downtrendul A+ nu e vizibil"
# 5. NU: (low[12] - 42.50) / 42.50 > 0.02 (hammer-ul NU e departe de nivel)
dist_n = (ham_n.l - SUPORT_N) / SUPORT_N
assert dist_n > 0.02, f"low hammer NU {ham_n.l} prea aproape de 42.50 ({dist_n:.1%})"
# eticheta „la -3,6 %" din spec: (44.10 - 42.50) / 44.10
pct_n = (ham_n.l - SUPORT_N) / ham_n.l * 100
assert round(pct_n, 1) == 3.6, f"procentul NU e {pct_n:.2f} %, nu 3,6 %"
# 6. NU: close[13] > high[12] (tehnic valid)
assert conf_n.c > ham_n.h, f"NU close {conf_n.c} <= high hammer {ham_n.h}"
assert abs(conf_n.c - 45.15) < 1e-9
# 7. NU: volumul pe bara 13 < media volumelor pe 10 bare
media_n = sum(VOLS_N[CONF_N - 10:CONF_N]) / 10
assert VOLS_N[CONF_N] < media_n, f"vol confirmare NU {VOLS_N[CONF_N]} >= media {media_n}"
# 8. NU: |close[12] - close[0]| <= 1.5 (fără downtrend real, range lateral)
assert abs(cds_n[HAM_N].c - cds_n[0].c) <= 1.5, "NU nu e range lateral"
# 9. ambele hammer: fitil jos >= 2x corp si close == high (fitil de sus inexistent)
for tag, o, l, c, h in (
("A+", ham_a.o, ham_a.l, ham_a.c, ham_a.h),
("NU", ham_n.o, ham_n.l, ham_n.c, ham_n.h)):
assert (o - l) >= 2 * (c - o), f"fitilul hammer-ului {tag} < 2x corpul"
assert abs(c - h) < 1e-9, f"hammer-ul {tag} are fitil de sus"
# OHLC-ul descris în spec (eticheta e adevărul)
assert abs(ham_a.o - 42.80) < 1e-9 and abs(ham_a.c - 42.90) < 1e-9 and abs(ham_a.l - 41.90) < 1e-9
assert abs(ham_n.o - 45.00) < 1e-9 and abs(ham_n.c - 45.10) < 1e-9 and abs(ham_n.l - 44.10) < 1e-9
# context: hammer-ul A+ e la baza downtrendului, NU nu are downtrend
assert min(c.l for c in cds_a[:HAM_A]) > ham_a.l, "low-ul hammer-ului A+ nu e minimul"
assert abs(cds_a[-1].c - 44.80) < 1e-9 and cds_a[-1].c > conf_a.c, "A+ nu urcă după intrare"
print(f"A+ hammer {ham_a.o:.2f}/{ham_a.c:.2f}/{ham_a.h:.2f}/{ham_a.l:.2f} "
f"conf {conf_a.c:.2f} > high {ham_a.h:.2f} vol {VOLS_A[CONF_A]} > medie {media_a:.1f}")
print(f"NU hammer {ham_n.o:.2f}/{ham_n.c:.2f}/{ham_n.h:.2f}/{ham_n.l:.2f} "
f"conf {conf_n.c:.2f} > high {ham_n.h:.2f} vol {VOLS_N[CONF_N]} < medie {media_n:.1f}")
print(f"NU low {ham_n.l:.2f} vs suport {SUPORT_N:.2f} = -{pct_n:.1f} %")
# ============================================================ desenele celor două panouri
PW = 430 # lățimea fiecărui panou
SCALE = 26.0 # aceeași scară în ambele panouri (comparație cinstită)
ch_a = Chart(
candles=cds_a,
levels=[Level(SUPORT_A, "suport monthly 42.00", GREEN, label_at="left")],
marks=[Mark(HAM_A, "hammer la nivel (low 41.90)", ORANGE, "below", dy=0),
Mark(CONF_A, "acumulare", GREEN, "above", dy=-26)],
mas=[], scale=SCALE, gap=15, body_w=9, left=44, width=PW, vol_h=60,
vol_label="volum",
title="A+ — hammer la suport monthly, volum în acumulare",
caption="bara 13: 42.95 > high 42.90, pe volum peste medie.",
)
ch_n = Chart(
candles=cds_n,
levels=[Level(SUPORT_N, "fără nivel apropiat (suport 42.50 la −3,6 %)", GRAY,
label_at="left")],
marks=[Mark(HAM_N, "hammer în mijloc de range", ORANGE, "below", dy=0),
Mark(CONF_N, "volum slab", RED, "above", dy=0)],
mas=[], scale=SCALE, gap=15, body_w=9, left=44, width=PW, vol_h=60,
vol_label="volum",
title="Mediocru — hammer în mijloc de range, fără nivel",
caption="bara 13: 45.15 > 45.10, dar pe volum slab.",
)
# ============================================================ wrapper: două panouri alăturate
def inner(svg: str) -> str:
"""Conținutul dintre tag-urile ")]
def vb_height(svg: str) -> float:
m = re.search(r'viewBox="0 0 [\d.]+ ([\d.]+)"', svg)
assert m, "nu găsesc viewBox în panoul randat"
return float(m.group(1))
PANEL_GAP, PAD = 24, 8
TITLE_H, FOOTER_H = 30, 38
svg_a, svg_n = ch_a.render(), ch_n.render()
HA, HN = vb_height(svg_a), vb_height(svg_n)
assert svg_a.count("\n\n") == 0 and svg_n.count("\n\n") == 0, "linie goală în panou"
TOTAL_W = PAD + PW + PANEL_GAP + PW + PAD
TOTAL_H = PAD + TITLE_H + max(HA, HN) + FOOTER_H + PAD
OFF_A = (PAD, PAD + TITLE_H)
OFF_N = (PAD + PW + PANEL_GAP, PAD + TITLE_H)
TOP_TITLE = "Buy hammer A+ vs. mediocru (două panouri, exemple construite)"
FOOTER1 = ("Reguli din curs: 26_2025-06-11.md:701-703 (locația la nivel), "
"05_patternuri_candele.md:77-80, 05_volum.md:2169.")
FOOTER2 = ("Nivelurile 42.00 / 42.50 și cifrele de preț sunt construite "
"(materialul nu dă prețuri pentru acest contrast).")
FOOTER = FOOTER1 + " " + FOOTER2
wrapper = (
f''
)
# ============================================================ verificare pe SVG-ul final
def texts(svg: str):
"""Toate (x, y, text-anchor, text) din elementele ale unui fragment."""
out = []
for m in re.finditer(r']*)>(.*?)', svg):
x, y, attrs, t = float(m.group(1)), float(m.group(2)), m.group(3), m.group(4)
if t.startswith("<"):
continue
am = re.search(r'text-anchor="(\w+)"', attrs)
out.append((x, y, am.group(1) if am else "start", t))
return out
def span(x: float, anchor: str, t: str) -> tuple[float, float]:
"""Intervalul orizontal aproximativ al unui text, față de ancora lui."""
w = len(t) * 12 * 0.56
left = x - w if anchor == "end" else (x - w / 2 if anchor == "middle" else x)
return left, left + w
assert wrapper.count("\n\n") == 0, "linie goală în SVG"
# fiecare text (după translatarea panoului) e în întregime în interiorul pânzei
all_txt = []
for frag, (ox, oy) in ((inner(svg_a), OFF_A), (inner(svg_n), OFF_N)):
for x, y, anchor, t in texts(frag):
gx, gy = x + ox, y + oy
gl, gr = gx + span(0, anchor, t)[0], gx + span(0, anchor, t)[1]
assert ox <= gl and gr <= ox + PW, f"text iese din panou: {t!r} ({gl:.0f}..{gr:.0f})"
assert -1 <= gl and gr <= TOTAL_W + 1, f"text ieșit pe orizontală: {t!r} ({gl:.0f}..{gr:.0f})"
assert 0 <= gy <= TOTAL_H, f"text ieșit pe verticală: {t!r} ({gy:.0f})"
all_txt.append((gx, gy, anchor, t))
# textele din wrapper (titlu + footer), în coordonate globale
for x, y, anchor, t in ((TOTAL_W / 2, 20.0, "middle", TOP_TITLE),
(TOTAL_W / 2, TOTAL_H - PAD - 14, "middle", FOOTER1),
(TOTAL_W / 2, TOTAL_H - PAD, "middle", FOOTER2)):
gl, gr = span(x, anchor, t)
assert -1 <= gl and gr <= TOTAL_W + 1, f"text din wrapper iese din pânză: {t!r}"
assert 0 <= y <= TOTAL_H, f"text din wrapper în afara înălțimii: {t!r}"
all_txt.append((x, y, anchor, t))
assert len(all_txt) >= 10, "prea puține etichete"
# nu se suprapun două texte pe aceeași poziție sau pe rândul apropiat
for i in range(len(all_txt)):
xi, yi, ai, si = all_txt[i]
li, ri = (xi + span(0, ai, si)[0], xi + span(0, ai, si)[1])
for j in range(i + 1, len(all_txt)):
xj, yj, aj, sj = all_txt[j]
lj, rj = xj + span(0, aj, sj)[0], xj + span(0, aj, sj)[1]
if abs(yi - yj) < 12 and li < rj - 4 and lj < ri - 4:
raise AssertionError(f"texte suprapuse: {si!r} / {sj!r}")
# etichetele cerute de spec există în desen
for cheie in ("suport monthly 42.00", "hammer la nivel", "acumulare",
"fără nivel apropiat (suport 42.50 la −3,6 %)", "volum slab",
"A+ — hammer la suport monthly, volum în acumulare",
"Mediocru — hammer în mijloc de range, fără nivel"):
assert cheie in wrapper, f"lipsește din desen: {cheie!r}"
OUT.write_text(wrapper, encoding="utf-8")
print(f"scris {OUT.name}: {TOTAL_W}x{TOTAL_H:.0f}, "
f"{len(all_txt)} etichete, verificare OK")