selectia_rvol: 'sub jumatate fata de normal' pentru 0,61 -> '61 % dintr-o zi normala'. mdsrt_exemplu: legenda spunea ca nivelurile SPY sunt 'reale, din curs'; sunt reconstruite dupa ilustratia unui sumar Q&A. mbt_iesire: 2.04R -> 2,0R, cifra sursei, prin rotunjire la afisare, fara sa se atinga nicio serie; assert-ul verifica acum textul din SVG-ul randat, nu doar valoarea bruta. atmi_exemplu: '13 luni mai tarziu' era citit ca fapt al materialului - acum spune ca 13 e cifra desenului, materialul da 'aproximativ un an'; si cele patru marcaje de completare din desenele atmi sunt uniformizate. Plus cei trei agenti opencode scrisi pentru rundele astea: verif_strat, corector_strat, reparator_grafic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThN7ixZrowca8aTGrK1MUJ
327 lines
14 KiB
Python
327 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""Relative volume (RVOL) — bara zilei față de media ultimelor 10 bare.
|
||
|
||
Diagramă conceptuală (NU grafic de preț): pentru
|
||
`summaries/17_STRATEGII/04_selectia.md`, Nivel 2, Pasul 6. Citește
|
||
specificația `gen/strategii/selectia_rvol.spec.txt` și scrie
|
||
`gen/strategii/selectia_rvol.svg`.
|
||
|
||
Nu folosește `svg_candles` / `pchart`: SVG-ul e construit ca string, doar
|
||
stdlib. Fără `<g transform>` — toate coordonatele sunt absolute.
|
||
|
||
Ce arată:
|
||
- două panouri cu ACELAȘI istoric de 10 bare de volum și aceeași medie;
|
||
- linia mediei = 100 %, punctată, etichetată pe ambele panouri;
|
||
- bara „azi": stânga de 2x media (RVOL 200 %, cazul HIG), dreapta 0.61x
|
||
media (RVOL 0.61, cazul ULTA);
|
||
- banda de jos: sub 100 % e minus, nu veto (cele trei rezultate reale ale
|
||
scanerului de Buy Doji: 0.86, 1.03, 0.61);
|
||
- nota: media e pe 10 bare, nu pe 50 ca în setările implicite.
|
||
|
||
Singurul prag din material e „peste 100 %"; nu se desenează alt prag.
|
||
|
||
Rulare: .venv/bin/python gen/strategii/selectia_rvol.py
|
||
"""
|
||
import html
|
||
import re
|
||
import xml.etree.ElementTree as ET
|
||
from pathlib import Path
|
||
|
||
W, H = 700, 764
|
||
|
||
BLACK = "#000000"
|
||
GRAY = "#777777"
|
||
RED = "#c00"
|
||
GREEN = "#188a3e"
|
||
BLUE = "#1a5fb4"
|
||
ORANGE = "#b06000"
|
||
|
||
GREEN_BG = "#eef7f0"
|
||
RED_BG = "#fdeeee"
|
||
NOTE_BG = "#fdf0e0"
|
||
GRAY_BG = "#f7f7f7"
|
||
|
||
# ------------------------------------------------------- date (unități de volum)
|
||
# Istoricul e ales ca media lui să fie exact 100 -> „media = 100 %".
|
||
ISTORIC = [82, 95, 88, 91, 97, 140, 103, 110, 120, 74]
|
||
MEDIA = sum(ISTORIC) / len(ISTORIC) # = 100.0
|
||
AZI_HIG = 2.00 * MEDIA # RVOL 200 % (cazul HIG)
|
||
AZI_ULTA = 0.61 * MEDIA # RVOL 0.61 (cazul ULTA)
|
||
|
||
# ------------------------------------------------------- geometrie panouri
|
||
PAN_Y0, PAN_Y1 = 128.0, 438.0 # cadrul panoului
|
||
BASE = 430.0 # linia de 0 a volumului
|
||
UNIT = 0.84 # px per unitate de volum
|
||
PAN_W = 284.0
|
||
PAN_AX, PAN_BX = 40.0, 376.0 # marginea stângă a fiecărui panou
|
||
|
||
TEXTS = [] # (x, y, s, size, weight, fill, anchor)
|
||
RECTS = [] # (x, y, w, h, tag)
|
||
|
||
|
||
def add(x, y, s, size=13, weight="normal", fill=BLACK, anchor="middle"):
|
||
TEXTS.append((float(x), float(y), s, size, weight, fill, anchor))
|
||
return s
|
||
|
||
|
||
def est_w(s, size, weight):
|
||
factor = 0.62 if weight == "bold" else 0.56
|
||
return len(s) * size * factor
|
||
|
||
|
||
def txt(x, y, s, size=13, weight="normal", fill=BLACK, anchor="middle"):
|
||
return (f'<text x="{x:.1f}" y="{y:.1f}" font-size="{size}" '
|
||
f'font-weight="{weight}" fill="{fill}" text-anchor="{anchor}">'
|
||
f'{html.escape(s, quote=False)}</text>')
|
||
|
||
|
||
def rect(x, y, w, h, tag, fill="#ffffff", stroke=GRAY, sw=1.5, dash=None,
|
||
opacity=None):
|
||
RECTS.append((float(x), float(y), float(w), float(h), tag))
|
||
d = f' stroke-dasharray="{dash}"' if dash else ""
|
||
o = f' fill-opacity="{opacity}"' if opacity is not None else ""
|
||
return (f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
||
f'fill="{fill}"{o} stroke="{stroke}" stroke-width="{sw}"{d}/>')
|
||
|
||
|
||
def line(x1, y1, x2, y2, stroke=GRAY, width=1.0, dash=None):
|
||
d = f' stroke-dasharray="{dash}"' if dash else ""
|
||
return (f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
|
||
f'stroke="{stroke}" stroke-width="{width}"{d}/>')
|
||
|
||
|
||
def arrow(x1, y1, x2, y2, stroke=BLACK, width=1.6):
|
||
"""Săgeată verticală simplă (vârf triunghiular la capătul (x2, y2))."""
|
||
dy = 1 if y2 > y1 else -1
|
||
return [line(x1, y1, x2, y2 - dy * 7, stroke, width),
|
||
f'<polygon points="{x2:.1f},{y2:.1f} {x2 - 5:.1f},{y2 - dy * 8:.1f} '
|
||
f'{x2 + 5:.1f},{y2 - dy * 8:.1f}" fill="{stroke}"/>']
|
||
|
||
|
||
# ---------------------------------------------------------------- panou
|
||
BARS = {} # tag panou -> listă de (x_centru, y_top, înălțime, valoare)
|
||
|
||
|
||
def panou(tag, px, azi, culoare_azi, eticheta_azi, subeticheta_azi, captura):
|
||
"""Desenează un panou: 10 bare de istoric + linia mediei + bara „azi"."""
|
||
out = [rect(px, PAN_Y0, PAN_W, PAN_Y1 - PAN_Y0, f"pan_{tag}",
|
||
fill="#ffffff", stroke=GRAY, sw=1.2)]
|
||
vals = ISTORIC + [azi]
|
||
slot = PAN_W / len(vals)
|
||
bw = slot * 0.62
|
||
bars = []
|
||
for i, v in enumerate(vals):
|
||
cx = px + slot * (i + 0.5)
|
||
h = v * UNIT
|
||
top = BASE - h
|
||
ultima = (i == len(vals) - 1)
|
||
col = culoare_azi if ultima else "#9aa3ad"
|
||
out.append(f'<rect x="{cx - bw / 2:.1f}" y="{top:.1f}" width="{bw:.1f}" '
|
||
f'height="{h:.1f}" fill="{col}" '
|
||
f'fill-opacity="{1.0 if ultima else 0.55}" '
|
||
f'stroke="{col if ultima else "#7a828a"}" stroke-width="1"/>')
|
||
bars.append((cx, top, h, v))
|
||
BARS[tag] = bars
|
||
|
||
# linia de bază (volum 0) și linia mediei
|
||
out.append(line(px, BASE, px + PAN_W, BASE, BLACK, 1.2))
|
||
y_med = BASE - MEDIA * UNIT
|
||
out.append(line(px, y_med, px + PAN_W, y_med, ORANGE, 2.0, "5 3"))
|
||
# eticheta stă în stânga, unde barele sunt sub linie, ca să nu cadă peste ele
|
||
add(px + 6, y_med - 6, "media = 100 %", 13, "bold", ORANGE, "start")
|
||
|
||
# eticheta barei „azi" + săgeata către vârful ei
|
||
cx, top, _h, _v = bars[-1]
|
||
add(px + PAN_W / 2, PAN_Y0 + 24, eticheta_azi, 15, "bold", culoare_azi)
|
||
add(px + PAN_W / 2, PAN_Y0 + 44, subeticheta_azi, 13, "normal", BLACK)
|
||
out += arrow(cx, PAN_Y0 + 54, cx, top - 4, culoare_azi, 1.8)
|
||
|
||
# axa de jos: ce sunt barele
|
||
add(px + (PAN_W - slot) / 2 - slot * 0.3, BASE + 20,
|
||
"ultimele 10 bare de volum", 13, "normal", GRAY)
|
||
add(cx, BASE + 20, "azi", 13, "bold", culoare_azi)
|
||
|
||
# captura: de ce contează
|
||
add(px + PAN_W / 2, BASE + 48, captura[0], 13, "bold", culoare_azi)
|
||
add(px + PAN_W / 2, BASE + 68, captura[1], 13, "normal", BLACK)
|
||
return out
|
||
|
||
|
||
# =============================================================== CONȚINUT
|
||
p = []
|
||
|
||
add(W / 2, 34, "Relative volume (RVOL): volumul zilei față de media pe 10 bare",
|
||
17, "bold")
|
||
add(W / 2, 58, "RVOL = volumul de azi ÷ media ultimelor 10 bare de volum",
|
||
14, "bold", GRAY)
|
||
add(W / 2, 80,
|
||
"Măsoară dacă azi chiar participă lumea, nu doar tu. Singurul prag din curs: peste 100 %.",
|
||
13, "normal", GRAY)
|
||
|
||
add(PAN_AX + PAN_W / 2, 110, "Cazul HIG — peste prag", 15, "bold", GREEN)
|
||
add(PAN_BX + PAN_W / 2, 110, "Cazul ULTA — sub prag", 15, "bold", RED)
|
||
|
||
p += panou("hig", PAN_AX, AZI_HIG, GREEN,
|
||
"azi = 2 × media → RVOL 200 %",
|
||
"cel mai mare din cele 16 rezultate",
|
||
("peste 100 % = confirmare",
|
||
"se tranzacționează dublu față de normal"))
|
||
p += panou("ulta", PAN_BX, AZI_ULTA, RED,
|
||
"azi = 0,61 × media → RVOL 0.61",
|
||
"61 % dintr-o zi normală",
|
||
("sub 100 % = minus, NU veto",
|
||
"îl notezi, dar analiza continuă"))
|
||
|
||
# ---- banda „minus, nu veto"
|
||
BAND_Y = 526.0
|
||
p.append(rect(40, BAND_Y, 620, 112, "band_veto", fill=GRAY_BG, stroke=GRAY, sw=1.2))
|
||
add(60, BAND_Y + 26, "Sub 100 % e un minus, nu un veto", 15, "bold", BLACK, "start")
|
||
add(60, BAND_Y + 50,
|
||
"Pe scanerul real de Buy Doji, cele trei rezultate aveau RVOL 0.86, 1.03 și 0.61.",
|
||
13, "normal", BLACK, "start")
|
||
add(60, BAND_Y + 70,
|
||
"Un prag dur de genul „RVOL ≥ 1.2” ar fi dat zero rezultate în ziua aia.",
|
||
13, "normal", BLACK, "start")
|
||
add(60, BAND_Y + 94,
|
||
"De aceea RVOL e o coloană pe care o citești, nu un filtru care taie automat.",
|
||
13, "bold", BLACK, "start")
|
||
|
||
# ---- nota: media pe 10, nu pe 50
|
||
NOTE_Y = 654.0
|
||
p.append(rect(40, NOTE_Y, 620, 90, "note_10", fill=NOTE_BG, stroke=ORANGE, sw=1.4))
|
||
add(60, NOTE_Y + 26,
|
||
"Media e pe 10 bare, nu pe 50 (implicit în platforme).",
|
||
13, "bold", BLACK, "start")
|
||
add(60, NOTE_Y + 48,
|
||
"Cifra cursului e mai sensibilă la ziua curentă. Dacă vezi altă valoare în",
|
||
13, "normal", GRAY, "start")
|
||
add(60, NOTE_Y + 68,
|
||
"altă platformă, nu e greșeală de calcul, e altă lungime de medie.",
|
||
13, "normal", GRAY, "start")
|
||
|
||
# =============================================================== EMITERE
|
||
head = (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" '
|
||
f'width="{W}" height="{H}" font-family="system-ui, sans-serif" role="img" '
|
||
f'aria-label="Relative volume: doua panouri de volum cu aceeasi medie '
|
||
f'pe 10 bare; in stanga ziua de azi e de doua ori media (RVOL 200 la '
|
||
f'suta, cazul HIG), in dreapta 0.61 din medie (cazul ULTA); sub 100 la '
|
||
f'suta e minus, nu veto">')
|
||
body = [head, f'<rect x="0" y="0" width="{W}" height="{H}" fill="#ffffff"/>']
|
||
body += p
|
||
for x, y, s, size, weight, fill, anchor in TEXTS:
|
||
body.append(txt(x, y, s, size, weight, fill, anchor))
|
||
body.append("</svg>")
|
||
svg = "\n".join(body) + "\n"
|
||
|
||
# =============================================================== VERIFICARE
|
||
# 1. geometria desenată chiar spune ce spun etichetele: raportul dintre
|
||
# înălțimea barei „azi" și înălțimea liniei mediei (măsurate în px, de la
|
||
# linia de 0) trebuie să fie 2.00, respectiv 0.61.
|
||
h_media_px = BASE - (BASE - MEDIA * UNIT)
|
||
for tag, rvol_cerut in (("hig", 2.00), ("ulta", 0.61)):
|
||
h_azi_px = BARS[tag][-1][2]
|
||
assert abs(h_azi_px / h_media_px - rvol_cerut) < 0.005, \
|
||
f"{tag}: bara desenată dă RVOL {h_azi_px / h_media_px:.3f}, nu {rvol_cerut}"
|
||
|
||
# 2. istoricul e chiar de 10 bare și media lui e chiar linia trasată
|
||
for tag in ("hig", "ulta"):
|
||
istoric = [b[3] for b in BARS[tag][:-1]]
|
||
assert len(istoric) == 10, f"{tag}: {len(istoric)} bare de istoric, nu 10"
|
||
assert abs(sum(istoric) / 10 - MEDIA) < 1e-9, f"{tag}: media desenată nu e media barelor"
|
||
# istoricul e identic în ambele panouri (același simbol de referință vizual)
|
||
assert [b[3] for b in BARS["hig"][:-1]] == [b[3] for b in BARS["ulta"][:-1]]
|
||
|
||
# 3. bara „azi" din HIG e chiar cea mai înaltă, iar cea din ULTA e sub medie
|
||
assert BARS["hig"][-1][2] == max(b[2] for b in BARS["hig"]), "bara HIG nu e cea mai înaltă"
|
||
assert BARS["ulta"][-1][2] < h_media_px, "bara ULTA nu e sub linia mediei"
|
||
|
||
# 3c. eticheta de sub bara ULTA afirmă ce se vede în desen: 0,61 din medie =
|
||
# 61 % dintr-o zi normală, adică PESTE jumătate. Vechiul text „sub jumătate
|
||
# față de o zi normală" contrazicea bara desenată (61 % > 50 %) și pica.
|
||
_rvol_ulta = BARS["ulta"][-1][2] / h_media_px
|
||
assert round(_rvol_ulta * 100) == 61, \
|
||
f"bara ULTA măsoară {_rvol_ulta * 100:.0f} % din medie, eticheta nu poate spune 61 %"
|
||
assert _rvol_ulta > 0.5, \
|
||
f"bara ULTA e {_rvol_ulta:.2f} din medie, deci PESTE jumătate, nu sub ea"
|
||
assert "61 % dintr-o zi normală" in svg, "lipsește din desen procentul ULTA"
|
||
assert "jumătate" not in svg, 'a reapărut formularea greșită "sub jumătate"'
|
||
# și barele nu ies din cadrul panoului
|
||
for tag in ("hig", "ulta"):
|
||
for cx, top, h, v in BARS[tag]:
|
||
assert top >= PAN_Y0, f"{tag}: bară peste marginea de sus a panoului"
|
||
assert abs(top + h - BASE) < 1e-6, f"{tag}: bară care nu pleacă de la linia de 0"
|
||
|
||
# 3b. eticheta mediei nu cade peste nicio bară (e pusă în stânga, unde barele
|
||
# sunt sub linia mediei)
|
||
_y_med = BASE - MEDIA * UNIT
|
||
for tag in ("hig", "ulta"):
|
||
px = PAN_AX if tag == "hig" else PAN_BX
|
||
lat = est_w("media = 100 %", 13, "bold")
|
||
for cx, top, h, v in BARS[tag]:
|
||
if cx - 9 < px + 6 + lat and cx + 9 > px + 6:
|
||
assert top > _y_med - 6 + 3, \
|
||
f"{tag}: eticheta mediei cade peste o bară (valoare {v})"
|
||
|
||
# 4. nicio cifră care nu e desenată sau care nu vine din material
|
||
PERMISE = {"2", "61", "200", "100", "10", "50", "0.61", "0,61", "0.86", "1.03", "1.2", "16"}
|
||
for _x, _y, s, *_ in TEXTS:
|
||
for n in re.findall(r"\d+(?:[.,]\d+)?", s):
|
||
assert n in PERMISE, f"cifră fără acoperire în material: {n!r} în {s!r}"
|
||
# pragul inventat „ideal > 200 %" nu are voie să apară
|
||
assert "ideal" not in svg.lower(), "prag inventat de tip ideal peste N la suta"
|
||
|
||
# 5. textele obligatorii
|
||
for req in ("Relative volume (RVOL): volumul zilei față de media pe 10 bare",
|
||
"RVOL = volumul de azi ÷ media ultimelor 10 bare de volum",
|
||
"Singurul prag din curs: peste 100 %",
|
||
"media = 100 %",
|
||
"azi = 2 × media → RVOL 200 %",
|
||
"azi = 0,61 × media → RVOL 0.61",
|
||
"peste 100 % = confirmare",
|
||
"sub 100 % = minus, NU veto",
|
||
"Sub 100 % e un minus, nu un veto",
|
||
"RVOL 0.86, 1.03 și 0.61",
|
||
"zero rezultate în ziua aia",
|
||
"nu un filtru care taie automat",
|
||
"Media e pe 10 bare, nu pe 50"):
|
||
assert req in svg, f"lipsește din desen: {req!r}"
|
||
# eticheta mediei apare o dată pe fiecare panou
|
||
assert svg.count("media = 100 %") == 2, "linia mediei neetichetată pe ambele panouri"
|
||
|
||
# 6. fiecare etichetă: mărime >= 13, în pânză, fără suprapuneri
|
||
boxes = []
|
||
for x, y, s, size, weight, fill, anchor in TEXTS:
|
||
assert size >= 13, f"font sub 13px: {s!r} ({size})"
|
||
w = est_w(s, size, weight)
|
||
if anchor == "end":
|
||
left = x - w
|
||
elif anchor == "start":
|
||
left = x
|
||
else:
|
||
left = x - w / 2.0
|
||
right = left + w
|
||
top, bottom = y - size * 0.78, y + size * 0.22
|
||
assert -2 <= left and right <= W + 2, f"text în afara pânzei orizontal: {s!r}"
|
||
assert 0 <= top and bottom <= H, f"text în afara pânzei vertical: {s!r}"
|
||
boxes.append((left, right, top, bottom, s))
|
||
for i in range(len(boxes)):
|
||
for j in range(i + 1, len(boxes)):
|
||
l1, r1, t1, b1, s1 = boxes[i]
|
||
l2, r2, t2_, b2, s2 = boxes[j]
|
||
if r1 > l2 + 2 and r2 > l1 + 2 and b1 > t2_ + 2 and b2 > t1 + 2:
|
||
raise AssertionError(f"etichete suprapuse: {s1!r} / {s2!r}")
|
||
|
||
# 7. stil + XML valid, fără transformări (audit_diagrams dă fals-pozitive pe ele)
|
||
assert "filter=" not in svg and "gradient" not in svg, "fără umbre/gradienți"
|
||
assert "transform=" not in svg, "fără <g transform>: coordonate absolute"
|
||
assert 'font-family="system-ui, sans-serif"' in svg, "lipsește fontul cerut"
|
||
assert svg.lstrip().startswith("<svg "), "rădăcina nu e <svg>"
|
||
assert ET.fromstring(svg).tag.endswith("svg")
|
||
|
||
OUT = Path(__file__).with_suffix(".svg")
|
||
OUT.write_text(svg, encoding="utf-8")
|
||
print(f"scris {OUT} ({len(svg)} bytes, {len(TEXTS)} etichete, "
|
||
f"2 panouri x 11 bare, medie {MEDIA:.0f}, "
|
||
f"RVOL desenat {BARS['hig'][-1][2] / h_media_px:.2f} / "
|
||
f"{BARS['ulta'][-1][2] / h_media_px:.2f}, {W}x{H}), verificare OK")
|