Files
atm-curs-dl/gen/strategii/selectia_rvol.py
Claude Agent e58763d26d 04_selectia: ultimele doua diagrame (rvol, trinity)
Pasul 6 si cele 3 stele, singurele doua marcaje ramase LIPSA.
Ambele conceptuale, dupa tiparul lui selectia_scor / selectia_cadrane.
Singurul prag desenat la RVOL e 100 %, cat da sursa; trinity ilustreaza
doar sensul din acest capitol (RS + Power + volum in cadranul 1).

Assert-urile masoara in pixeli raportul bara/medie (2.00 si 0.61), deci
o schimbare de scara sau de serie face eticheta sa pice, nu sa minta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ThN7ixZrowca8aTGrK1MUJ
2026-09-20 19:08:04 +00:00

316 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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",
"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'<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"
# ș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ă <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")