{"spec_id":"point-and-figure-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\npoint-and-figure-basic: Point and Figure Chart\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os as _os\nimport sys as _sys\n\n\n# This file is named pygal.py — remove its directory from sys.path first so\n# Python resolves `pygal` to the installed package, not this file itself.\n_here = _os.path.abspath(_os.path.dirname(__file__))\n_sys.path = [p for p in _sys.path if _os.path.abspath(p or \".\") != _here]\n\nimport os\nimport xml.etree.ElementTree as ET\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\")\n\n# Data\nnp.random.seed(42)\nn_days = 150\nbase_price = 100.0\nreturns = np.random.normal(0.001, 0.02, n_days)\nprices = base_price * np.cumprod(1 + returns)\n\n# P&F parameters\nbox_size = 2.0\nreversal = 3\n\n# P&F algorithm\ncolumns = []\ncurrent_direction = None\ncurrent_column_start = np.floor(prices[0] / box_size) * box_size\ncurrent_column_end = current_column_start\n\nfor price in prices[1:]:\n    rounded_price = np.floor(price / box_size) * box_size\n\n    if current_direction is None:\n        if rounded_price >= current_column_end + box_size:\n            current_direction = \"X\"\n            current_column_end = rounded_price\n        elif rounded_price <= current_column_end - box_size:\n            current_direction = \"O\"\n            current_column_end = rounded_price\n    elif current_direction == \"X\":\n        if rounded_price >= current_column_end + box_size:\n            current_column_end = rounded_price\n        elif rounded_price <= current_column_end - (reversal * box_size):\n            columns.append((current_column_start, current_column_end, \"X\"))\n            current_column_start = current_column_end - box_size\n            current_column_end = rounded_price\n            current_direction = \"O\"\n    else:\n        if rounded_price <= current_column_end - box_size:\n            current_column_end = rounded_price\n        elif rounded_price >= current_column_end + (reversal * box_size):\n            columns.append((current_column_start, current_column_end, \"O\"))\n            current_column_start = current_column_end + box_size\n            current_column_end = rounded_price\n            current_direction = \"X\"\n\nif current_direction:\n    columns.append((current_column_start, current_column_end, current_direction))\n\n# Price range for y-axis\nall_prices = [p for start, end, _ in columns for p in (start, end)]\ny_min = min(all_prices) - box_size\ny_max = max(all_prices) + box_size\ny_labels = list(np.arange(y_min, y_max + box_size, box_size))\n\n# Style\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=3,\n)\n\n# Plot — no chart-level stroke setting so per-series stroke works correctly\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"point-and-figure-basic · python · pygal · anyplot.ai\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=4,\n    show_y_guides=True,\n    show_x_guides=False,\n    x_title=\"Column (Reversal)\",\n    y_title=\"Price ($)\",\n    margin=80,\n    margin_bottom=200,\n    margin_left=220,\n    margin_top=120,\n    margin_right=100,\n    y_labels=y_labels,\n    range=(y_min, y_max),\n    dots_size=18,\n)\n\n# Build X and O data points\nx_points = []\no_points = []\n\nfor col_idx, (start, end, direction) in enumerate(columns):\n    low = min(start, end)\n    high = max(start, end)\n    price_levels = np.arange(low, high + box_size * 0.5, box_size)\n    if direction == \"X\":\n        x_points.extend((col_idx, lv) for lv in price_levels)\n    else:\n        o_points.extend((col_idx, lv) for lv in price_levels)\n\n# Per-series stroke=False keeps dots unconnected; overrides chart default\nchart.add(\"X (Rising)\", x_points, stroke=False)\nchart.add(\"O (Falling)\", o_points, stroke=False)\n\n# Trend lines — per-series stroke=True draws the line\nsupport_lows = [(ci, min(s, e)) for ci, (s, e, d) in enumerate(columns) if d == \"O\"]\nresistance_highs = [(ci, max(s, e)) for ci, (s, e, d) in enumerate(columns) if d == \"X\"]\n\nif len(support_lows) >= 2:\n    c0, p0 = support_lows[0]\n    c1 = min(c0 + 6, len(columns) - 1)\n    chart.add(\n        \"Support (45°)\",\n        [(c0, p0), (c1, p0 + (c1 - c0) * box_size)],\n        stroke=True,\n        dots_size=4,\n        stroke_style={\"width\": 4, \"dasharray\": \"8, 6\"},\n    )\n\nif len(resistance_highs) >= 2:\n    c0, p0 = resistance_highs[0]\n    c1 = min(c0 + 6, len(columns) - 1)\n    chart.add(\n        \"Resistance (45°)\",\n        [(c0, p0), (c1, p0 - (c1 - c0) * box_size)],\n        stroke=True,\n        dots_size=4,\n        stroke_style={\"width\": 4, \"dasharray\": \"8, 6\"},\n    )\n\n\ndef add_pnf_text_markers(svg_bytes):\n    \"\"\"Post-process pygal SVG: hide circle markers for X/O series, add text characters.\"\"\"\n    SVG_NS = \"http://www.w3.org/2000/svg\"\n    ET.register_namespace(\"\", SVG_NS)\n    ET.register_namespace(\"xlink\", \"http://www.w3.org/1999/xlink\")\n\n    root = ET.fromstring(svg_bytes)\n    parent_map = {child: parent for parent in root.iter() for child in parent}\n\n    # serie-0 = X (Rising), serie-1 = O (Falling)\n    series_symbols = {0: (\"X\", IMPRINT[0]), 1: (\"O\", IMPRINT[1])}\n\n    for serie_idx, (symbol, color) in series_symbols.items():\n        for g in root.iter(f\"{{{SVG_NS}}}g\"):\n            parts = g.get(\"class\", \"\").split()\n            if \"series\" not in parts or f\"serie-{serie_idx}\" not in parts:\n                continue\n            for circle in g.iter(f\"{{{SVG_NS}}}circle\"):\n                cx = circle.get(\"cx\")\n                cy = circle.get(\"cy\")\n                if cx is None or cy is None:\n                    continue\n                # Hide circle; leave in DOM so hover tooltips still work in HTML\n                circle.set(\"opacity\", \"0\")\n                # Add literal X or O text at the same position\n                text = ET.Element(f\"{{{SVG_NS}}}text\")\n                text.set(\"x\", cx)\n                text.set(\"y\", cy)\n                text.set(\"text-anchor\", \"middle\")\n                text.set(\"dominant-baseline\", \"central\")\n                text.set(\"font-size\", \"36\")\n                text.set(\"font-weight\", \"bold\")\n                text.set(\"fill\", color)\n                text.set(\"font-family\", \"monospace, sans-serif\")\n                text.text = symbol\n                parent_map[circle].append(text)\n\n    return ET.tostring(root, encoding=\"unicode\").encode(\"utf-8\")\n\n\n# Render SVG, post-process to replace circle markers with X/O text, then save\nsvg_original = chart.render()\nsvg_modified = add_pnf_text_markers(svg_original)\n\n# PNG — from modified SVG with X/O text characters\nwith open(f\"plot-{THEME}.png\", \"wb\") as f:\n    cairosvg.svg2png(bytestring=svg_modified, write_to=f)\n\n# HTML — modified SVG (X/O text visible; hidden circles retain hover interaction)\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(svg_modified)\n"}