#!/usr/bin/env python3
"""Fluxul de selecție — palnia de filtrare de la scanner la lista scurtă.
Diagramă conceptuală (NU grafic de preț): palnie cu 6 trepte care se
îngustează de sus în jos, apoi lista scurtă; cutie separată cu ce rămâne pe
nivelul 3. Pentru `summaries/17_STRATEGII/04_selectia.md`, nivelul 1 — harta
celor șase pași din nivelul 2. Scrie `gen/strategii/selectia_funnel.svg`.
Fiecare treaptă spune pragul ȘI de ce există pragul; trimiterile la material
nu stau pe desen, ci în `summaries/17_STRATEGII/fise/04_selectia_surse.md`.
Nu folosește `svg_candles` / `pchart`: SVG-ul e construit ca string, doar
stdlib.
Fiecare treaptă are titlul, pragul și motivul pragului. Toate cifrele din
desen (pragurile, „max ~300 de simboluri”, „top 10”) vin din material; nu se
inventează valori de RS sau de sectoare.
Rulare: .venv/bin/python gen/strategii/selectia_funnel.py
"""
import html
import re
from pathlib import Path
W = 700
H = 840
BLACK = "#000000"
GRAY = "#777777"
RED = "#c00"
GREEN = "#188a3e"
BLUE = "#1a5fb4"
ORANGE = "#b06000"
STEP_FILL = ("#eef3fb", "#e4edf9") # alternanță foarte fină între trepte
GREEN_BG = "#eef7f0"
NOTE_BG = "#fdf0e0"
# ---------------------------------------------------------------- geometrie
CX = W / 2.0 # 350: axa palniei
TOPW = 610.0 # lățimea gurii palniei
NARROW = 30.0 # cât se strânge pe fiecare treaptă
Y0 = 100.0
HSTEP = 76.0
LIST_W, LIST_Y, LIST_H = 410.0, 566.0, 62.0
NOTE = {"x": 40.0, "y": 648.0, "w": 620.0, "h": 176.0}
# ---------------------------------------------------------------- conținut
# (titlu, subtitlu, [linii de sursă])
STEPS = [
("Universul și lichiditatea",
"close peste 30-40 $; volum mediu ≥ 500.000 de acțiuni, ideal peste 1 milion",
["de ce: pe un simbol subțire, ordinul tău mută singur prețul"]),
("Scorul fundamental",
"ATM score peste 7-8 (12-13 = spuma); F-score 6-9",
["de ce: instituționalii cumpără bilanț, nu desen"]),
("Sectorul care domină",
"numeri din ce sector vin cele mai multe semnale",
["de ce: multe semnale în același sector = bani care intră acolo"]),
("Suport sau pivot, după downtrend",
"proximitate până la 3 % (4 % „e cam departe”)",
["de ce: la un nivel, cumpărătorii au motiv să apară"]),
("Relative strength: cadranul 1 sau 2",
"simbolul se mișcă mai bine decât indicele de referință",
["de ce: cumperi ce urcă mai tare decât piața"]),
("Relative volume peste 100 %",
"candela de volum / media ultimelor 10 candele",
["de ce: confirmă că azi chiar participă lumea, nu doar tu"]),
]
LIST = ("Listă scurtă",
"câteva idei pe care le urmărești",
"nicio poziție deschisă: trigger-ul e al altui capitol")
TITLE = "Fluxul de selecție — de la scanner la lista scurtă"
SUBTITLE = ("selecția se face înainte de semnalul tehnic; "
"ea nu spune CÂND intri")
CAPTION = "Palnia se îngustează de sus în jos: 6 filtre, apoi lista scurtă."
NOTE_TITLE = "Ce nu intră în cei șase pași (rafinări, nivelul 3)"
NOTE1 = ["Sortarea finală după 3 stele (Trinity: relative strength, Power și",
"volum, toate în cadranul 1) și departajarea între doi candidați",
"rămași: trend, apoi volum, apoi fundamental."]
NOTE1_SRC = "un scor de 7 nu e motiv să-l iei, dacă ai altul mai bun"
NOTE2 = ["Radarscreen (maximum ~300 de simboluri: cele 11 sectoare × top 10):",
"același flux, rulat pe o listă fixă, nu pe tot scanerul."]
NOTE2_SRC = "după o corecție mare, cauți doji sau hammer la suporturile mari"
# ---------------------------------------------------------------- colect
TEXTS = [] # (x, y, s, size, weight, fill, anchor)
SHAPES = [] # (x, y, w, h, tag) bbox-uri pentru verificare
WIDTHS = [] # (w_sus, w_jos) pe fiecare treaptă, pentru verificat conectarea
def add(x, y, s, size=13, weight="normal", fill=BLACK, anchor="middle"):
TEXTS.append((x, 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, rx=0):
SHAPES.append((x, y, w, h, tag))
r = f' rx="{rx}"' if rx else ""
return (f'')
def trapezoid(y, wt, wb, h, index):
"""Treaptă de palnie: muchia de sus lată wt, cea de jos wb."""
xtl, xtr = CX - wt / 2.0, CX + wt / 2.0
xbl, xbr = CX - wb / 2.0, CX + wb / 2.0
SHAPES.append((xbl, y, wb, h, f"step{index}"))
WIDTHS.append((wt, wb))
d = (f"M {xtl:.1f} {y:.1f} L {xtr:.1f} {y:.1f} "
f"L {xbr:.1f} {y + h:.1f} L {xbl:.1f} {y + h:.1f} Z")
fill = STEP_FILL[index % 2]
return (f'')
# =============================================================== CONȚINUT
p = []
# ---- titlu / subtitlu / caption
add(W / 2, 34, TITLE, 20, "bold")
add(W / 2, 57, SUBTITLE, 13, "normal", GRAY)
add(W / 2, 79, CAPTION, 13, "normal", GRAY)
# ---- palnia: 6 trepte
for i, (titlu, subtitlu, surse) in enumerate(STEPS):
y = Y0 + i * HSTEP
wt = TOPW - i * NARROW
wb = TOPW - (i + 1) * NARROW
p.append(trapezoid(y, wt, wb, HSTEP, i))
add(CX, y + 25, f"{i + 1}. {titlu}", 14.5, "bold", BLACK)
add(CX, y + 45, subtitlu, 13, "normal", BLACK)
for k, src in enumerate(surse):
add(CX, y + 60 + k * 15, src, 13, "normal", GRAY)
# ---- lista scurtă (vârful palniei, cutie separată)
p.append(rect(CX - LIST_W / 2, LIST_Y, LIST_W, LIST_H, "list",
fill=GREEN_BG, stroke=GREEN, sw=1.8, rx=8))
add(CX, LIST_Y + 24, LIST[0], 16, "bold", GREEN)
add(CX, LIST_Y + 43, LIST[1], 13, "normal", BLACK)
add(CX, LIST_Y + 57, LIST[2], 13, "normal", GRAY)
# ---- cutia de note laterale
p.append(rect(NOTE["x"], NOTE["y"], NOTE["w"], NOTE["h"], "note",
fill=NOTE_BG, stroke=ORANGE, sw=1.6, rx=8))
nx = NOTE["x"] + 20
add(nx, NOTE["y"] + 26, NOTE_TITLE, 15, "bold", ORANGE, "start")
add(nx, NOTE["y"] + 50, NOTE1[0], 13, "normal", BLACK, "start")
add(nx, NOTE["y"] + 68, NOTE1[1], 13, "normal", BLACK, "start")
add(nx, NOTE["y"] + 86, NOTE1[2], 13, "normal", BLACK, "start")
add(nx, NOTE["y"] + 104, NOTE1_SRC, 13, "normal", GRAY, "start")
add(nx, NOTE["y"] + 130, NOTE2[0], 13, "normal", BLACK, "start")
add(nx, NOTE["y"] + 148, NOTE2[1], 13, "normal", BLACK, "start")
add(nx, NOTE["y"] + 166, NOTE2_SRC, 13, "normal", GRAY, "start")
# =============================================================== EMITERE
head = (f'")
svg = "\n".join(body) + "\n"
# =============================================================== VERIFICARE
# 1. palnia are exact 8 trepte, conectate și tot mai înguste
assert len(STEPS) == 6, f"aștept 6 trepte, am {len(STEPS)}"
traps = [s for s in SHAPES if s[4].startswith("step")]
assert len(traps) == 6, f"aștept 6 trapezoide, am {len(traps)}"
assert len(WIDTHS) == 6
for i, (wt, wb) in enumerate(WIDTHS):
assert wt > wb, f"treapta {i} nu se îngustează"
if i:
assert abs(WIDTHS[i][0] - WIDTHS[i - 1][1]) < 1e-9, \
f"treapta {i} nu continuă muchia treptei {i - 1}"
assert WIDTHS[-1][1] < WIDTHS[0][0], "palnia nu se îngustează de sus în jos"
assert LIST_W < traps[-1][2], "lista scurtă nu e mai îngustă decât vârful palniei"
# 2. toate titlurile și subtitlurile din spec sunt desenate
for i, (titlu, subtitlu, surse) in enumerate(STEPS):
assert any(t[2] == f"{i + 1}. {titlu}" for t in TEXTS), \
f"lipsește titlul treptei {i + 1}: {titlu!r}"
assert any(t[2] == subtitlu for t in TEXTS), \
f"lipsește subtitlul treptei {i + 1}: {subtitlu!r}"
for src in surse:
assert src in svg, f"lipsește «de ce»-ul treptei {i + 1}: {src!r}"
assert surse[0].startswith("de ce:"), f"treapta {i + 1} nu spune de ce"
assert any(t[2] == LIST[0] for t in TEXTS), "lipsește «Listă scurtă»"
assert "câteva idei pe care le urmărești" in svg
# 3. titlul, subtitlul și notele din spec sunt exact cele desenate
assert TITLE in svg, "lipsește titlul din spec"
assert SUBTITLE in svg, "lipsește subtitlul din spec"
for line in NOTE1 + [NOTE1_SRC] + NOTE2 + [NOTE2_SRC]:
assert line in svg, f"lipsește din note: {line!r}"
# 4. fiecare textul încape în lățimea treptei lui (muchia de jos, cea îngustă)
for i in range(len(STEPS)):
ytop = Y0 + i * HSTEP
ybot = ytop + HSTEP
wb = TOPW - (i + 1) * NARROW
for x, y, s, size, weight, fill, anchor in TEXTS:
if ytop <= y <= ybot:
w = est_w(s, size, weight)
assert w <= wb - 14, f"treapta {i + 1}: text prea lat: {s!r} ({w:.0f})"
# textul din lista scurtă și din note încape în cutiile lor
for x, y, s, size, weight, fill, anchor in TEXTS:
if LIST_Y <= y <= LIST_Y + LIST_H:
assert est_w(s, size, weight) <= LIST_W - 16, f"listă: text prea lat: {s!r}"
if NOTE["y"] <= y <= NOTE["y"] + NOTE["h"]:
assert est_w(s, size, weight) <= NOTE["w"] - 40, f"notă: text prea lat: {s!r}"
# 5. toate etichetele în panză + fără suprapuneri
boxes = []
for x, y, s, size, weight, fill, anchor in TEXTS:
assert size >= 13, f"font sub 13px: {s!r}"
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 panzei orizontal: {s!r}"
assert 0 <= top and bottom <= H, f"text în afara panzei 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}")
# 6. cutiile stau una sub alta, în ordinea palnie -> listă -> note
assert LIST_Y >= Y0 + len(STEPS) * HSTEP, "lista scurtă se suprapune cu palnia"
assert NOTE["y"] >= LIST_Y + LIST_H, "notele se suprapun cu lista scurtă"
assert NOTE["y"] + NOTE["h"] <= H, "notele ies din panză"
# 7. stil: fără umbre / gradienți, fontul cerut; fără prețuri inventate
assert "filter=" not in svg and "gradient" not in svg, "fără umbre/gradienti"
assert 'font-family="system-ui, sans-serif"' in svg, "lipsește fontul cerut"
assert "