#!/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ă `` — 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'' f'{html.escape(s, quote=False)}') 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'') def line(x1, y1, x2, y2, stroke=GRAY, width=1.0, dash=None): d = f' stroke-dasharray="{dash}"' if dash else "" return (f'') 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''] # ---------------------------------------------------------------- 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'') 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", "sub jumătate față de 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'') body = [head, f''] 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 = "\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" # ș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", "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ă : coordonate absolute" assert 'font-family="system-ui, sans-serif"' in svg, "lipsește fontul cerut" assert svg.lstrip().startswith("" 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")