Cea mai grava, si cea care a cerut refacerea seriei: exemplul central de MBT-10 era luat de pe 60 de minute si bagat in procedura de daily, cu barele numite "zile", desi sursa spune explicit de ce nu merge pe daily. Pe langa asta, suportul fusese schimbat din 88.00 (testat de trei ori, cu compresie, cum e in sursa) in 87.50 cu un double bottom si o divergenta de volum imprumutate din alt exemplu. Seria e refacuta pe exemplul materialului, cu assert pe cele trei atingeri ale lui 88.00 si pe compresia varfurilor. Adaosurile au fost scoase, nu doar marcate: cu ele in desen, suportul din sursa n-ar fi avut sens. Aritmetica a supravietuit neatinsa. Restul: stopul de 60.40 sta acum sub o structura reala; pivotul daily nu mai e o linie fixa peste tot graficul, ci un segment peste ziua intrarii; SL-ul de pe LABU respecta regula proprie de 5 centi; citatul de la A2 nu mai e dat ca al autorului; pragul de 1-2% isi spune originea (cursul da 0,5%, pe 60 min); o singura conventie de distanta, impartirea la nivel; swing low definit la fel in pas si in glosar. eticheta_libera a prins 7 suprapuneri reale. La bh_aplus a trebuit rulat pe fiecare panou separat: wrapper-ul le muta cu <g transform>, pe care unealta nu-l citeste - de notat pentru data viitoare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThN7ixZrowca8aTGrK1MUJ
300 lines
15 KiB
Python
300 lines
15 KiB
Python
"""Buy hammer — A+ vs. mediocru (două panouri, exemple construite).
|
||
|
||
Citește `gen/strategii/hammer_mbt10_bh_aplus.spec.txt`
|
||
(marcajul `<!-- DIAGRAMA: bh_aplus -->`, 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,8 % sub nivel, cu aceeași convenție
|
||
de măsurare ca la pasul A2: diferența împărțită la nivel), 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")
|
||
sys.path.insert(0, "/workspace/atm-curs-dl/gen")
|
||
from eticheta_libera import eticheta_libera # noqa: E402
|
||
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%})"
|
||
# „cât de departe e nivelul" se măsoară la fel în tot capitolul: diferența
|
||
# împărțită la NIVEL, nu la low-ul hammer-ului (pasul A2 face așa, iar
|
||
# eticheta de aici împărțea la low și dădea alt număr — 3,6 % în loc de 3,8 %)
|
||
pct_n = (ham_n.l - SUPORT_N) / SUPORT_N * 100
|
||
assert round(pct_n, 1) == 3.8, f"procentul NU e {pct_n:.2f} %, nu 3,8 %"
|
||
# aceeași convenție pe panoul A+: 0,24 % = (42.00 - 41.90) / 42.00
|
||
assert round(abs(ham_a.l - SUPORT_A) / SUPORT_A * 100, 2) == 0.24
|
||
ET_NIVEL_N = ("fără nivel apropiat (suport 42.50 la −"
|
||
+ f"{pct_n:.1f}".replace(".", ",") + " %)")
|
||
|
||
# 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",
|
||
caption="bara 13: 42.95 peste 42.90, volum bun.",
|
||
)
|
||
ch_n = Chart(
|
||
candles=cds_n,
|
||
levels=[Level(SUPORT_N, ET_NIVEL_N, 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 fără nivel",
|
||
caption="bara 13: 45.15 peste 45.10, volum slab.",
|
||
)
|
||
|
||
# ============================================================ wrapper: două panouri alăturate
|
||
def inner(svg: str) -> str:
|
||
"""Conținutul dintre tag-urile <svg> ale unui panou, fără elementul rădăcină."""
|
||
return svg[svg.index(">") + 1:svg.rindex("</svg>")]
|
||
|
||
|
||
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"
|
||
# pe fiecare panou separat: wrapper-ul le mută cu <g transform>, iar
|
||
# `eticheta_libera` citește coordonate brute, deci nu merge pe el.
|
||
# Etichetele de nivel stau lipite de linia lor prin construcție (segmente=False);
|
||
# ce nu au voie e să cadă peste candele sau peste barele de volum.
|
||
eticheta_libera(svg_a, "bh_aplus/A+",
|
||
{"suport monthly 42.00", "hammer la nivel (low 41.90)", "acumulare"},
|
||
rects=True, segmente=False)
|
||
eticheta_libera(svg_n, "bh_aplus/NU",
|
||
{ET_NIVEL_N, "hammer în mijloc de range", "volum slab"},
|
||
rects=True, segmente=False)
|
||
|
||
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: locația la un nivel major, scăderea dinainte și volumul; "
|
||
"trimiterile stau în fișa de surse.")
|
||
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'<svg viewBox="0 0 {TOTAL_W} {TOTAL_H:.0f}" width="{TOTAL_W}" height="{TOTAL_H:.0f}" '
|
||
f'xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;font:12px sans-serif">'
|
||
f'<text x="{TOTAL_W / 2:.0f}" y="20" fill="#333" text-anchor="middle" '
|
||
f'style="font-weight:bold">{TOP_TITLE}</text>'
|
||
f'<g transform="translate({OFF_A[0]},{OFF_A[1]})">{inner(svg_a)}</g>'
|
||
f'<g transform="translate({OFF_N[0]},{OFF_N[1]})">{inner(svg_n)}</g>'
|
||
f'<text x="{TOTAL_W / 2:.0f}" y="{TOTAL_H - PAD - 14:.0f}" fill="#555" '
|
||
f'text-anchor="middle" style="font-size:10px">{FOOTER1}</text>'
|
||
f'<text x="{TOTAL_W / 2:.0f}" y="{TOTAL_H - PAD:.0f}" fill="#555" '
|
||
f'text-anchor="middle" style="font-size:10px">{FOOTER2}</text>'
|
||
f'</svg>'
|
||
)
|
||
|
||
# ============================================================ verificare pe SVG-ul final
|
||
def texts(svg: str):
|
||
"""Toate (x, y, text-anchor, text) din elementele <text> ale unui fragment."""
|
||
out = []
|
||
for m in re.finditer(r'<text x="([-\d.]+)" y="([-\d.]+)"([^>]*)>(.*?)</text>', 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",
|
||
ET_NIVEL_N, "volum slab",
|
||
"A+ — hammer la suport monthly",
|
||
"Mediocru — hammer 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")
|