1. HKEX Data Fetch

1.1 Data Source

Monthly returns for Swire Pacific (0019.HK) are fetched from the Hong Kong Exchanges and Clearing Limited (HKEX) disclosure website. The HKEX Title Search is used to locate and download the PDF monthly return filings.

1.2 Fetch Process

The fetch script (fetch_hkex_monthly_returns.py) performs the following steps:

  1. Stock lookup — Resolves the ticker 0019.HK to HKEX stockId 30 via the prefix.do API (or uses a preconfigured mapping for Swire Pacific).

  2. Search — Queries the title search with:

    • searchKey: “Monthly Return”
    • market: SEHK
    • stockId: 30 (Swire Pacific)
    • Date range: 1999/04/01 to current year (YYYY/MM/DD format)
  3. Pagination — Uses Playwright to automate the browser and click “Load More” until all results are loaded (HKEX shows 100 results per page; without pagination only the most recent returns would be retrieved).

  4. Download — Extracts PDF links from the search results, downloads each file, and saves to returns_data/0019_HK/ using the original filename (e.g. 2026030201662.pdf, ltn20090202409.pdf).

  5. Incremental runs — Maintains an index (returns_data/0019_HK_index.json) of fetched URLs and filenames so subsequent runs only download new returns.

1.3 Parse Process

The parse script (parse_share_counts.py) extracts share counts from each stored PDF:

  • Period end date — From the document header or “month ended” text.
  • Share counts — For Class A (HKEX 00019) and Class B (HKEX 00087) from:
    • Section II “Movements in Issued Share Capital” (older format), or
    • Per-class blocks with “Balance at close of the month” (newer format).

Output is written to share_counts.csv with columns: period_end_date, share_type, share_count, source_file.


2. Fetch Summary

Fetch summary
Item Detail
Stock Swire Pacific (0019.HK)
HKEX codes Class A: 00019, Class B: 00087
Data source HKEX Title Search — Monthly Return filings
Date range 2009-01-31 to 2026-02-28
PDFs fetched 206
Periods parsed 159
Monthly returns by year
Year Returns
2009 12
2010 12
2011 12
2012 12
2013 12
2014 12
2015 12
2016 12
2017 12
2018 12
2019 12
2020 12
2021 12
2022 12
2023 12
2024 12
2025 12
2026 2

3. Latest Share Count

As at 28 February 2026:

Latest share count position
Share type Share count Source file
Class A 778,988,000 returns_data/0019_HK/2026030201662.pdf
Class B 2,848,932,500 returns_data/0019_HK/2026030201662.pdf

Class A: 778,988,000 shares (HKEX 00019)
Class B: 2,848,932,500 shares (HKEX 00087)


4. Share Charts

4.1 Class A

Class A share count over time

Class A share count over time

4.2 Class B

Class B share count over time

Class B share count over time


5. Sources

Data sources
Data Source
HKEX Title Search https://www1.hkexnews.hk/search/titlesearch.xhtml
Monthly return PDFs returns_data/0019_HK/*.pdf (206 files)
Share count parsing parse_share_counts.py → share_counts.csv
Charts chart_share_counts.py → charts/share_count_*.png

Scripts and files:

  • fetch_hkex_monthly_returns.py — Fetches monthly return PDFs from HKEX
  • parse_share_counts.py — Parses PDFs to extract share counts
  • chart_share_counts.py — Generates line charts
  • returns_data/0019_HK_index.json — Index of fetched filings
  • share_counts.csv — Parsed share counts (period_end_date, share_type, share_count, source_file)
  • charts/share_count_class_a.png, charts/share_count_class_b.png — Charts

HKEX links:


Appendix A. fetch_hkex_monthly_returns.py

Fetches monthly return PDFs from HKEX. Run: python fetch_hkex_monthly_returns.py --ticker 0019.HK

#!/usr/bin/env python3
"""
Fetch and store monthly returns from HKEX for Hong Kong listed companies.
Supports incremental fetching - only downloads new returns on subsequent runs.
Displays an annual calendar summary of fetched returns.
"""

import re
import os
import json
from pathlib import Path
from datetime import datetime
from urllib.parse import urljoin, urlencode
import requests
from bs4 import BeautifulSoup

# Configuration
BASE_URL = "https://www1.hkexnews.hk"
SEARCH_URL = f"{BASE_URL}/search/titlesearch.xhtml"
DATA_DIR = Path(__file__).parent / "returns_data"

# Swire Pacific: 0019.HK -> stockId 30 (from HKEX prefix.do API)
STOCK_CONFIG = {
    "0019.HK": {"stock_id": 30, "name": "Swire Pacific"},
}


def get_stock_id(ticker: str):
    """Resolve stock ticker to HKEX stockId. Uses prefix.do API for lookup."""
    if ticker in STOCK_CONFIG:
        return STOCK_CONFIG[ticker]["stock_id"]
    # Extract numeric part (e.g. 0019 from 0019.HK)
    code = re.sub(r"[^\d]", "", ticker).lstrip("0") or "0"
    if len(code) < 4:
        code = code.zfill(4)
    try:
        r = requests.get(
            f"{BASE_URL}/search/prefix.do",
            params={"callback": "callback", "lang": "EN", "type": "A", "name": code, "market": "SEHK"},
            timeout=15,
        )
        r.raise_for_status()
        # Parse JSONP: callback({"stockInfo":[{"stockId":30,"code":"00019",...}]})
        text = r.text
        if text.startswith("callback("):
            text = text[8:-1]  # strip callback( and );
        data = json.loads(text)
        for s in data.get("stockInfo", []):
            if s.get("code", "").replace("0", "") == code.replace("0", "") or s["code"] == code.zfill(5):
                return s["stockId"]
    except Exception:
        pass
    return None


def get_index_path(ticker: str) -> Path:
    """Get index file path for a ticker."""
    return DATA_DIR / f"{ticker.replace('.', '_')}_index.json"


def load_index(ticker: str) -> dict:
    """Load the index of already-fetched returns for a ticker."""
    path = get_index_path(ticker)
    if path.exists():
        try:
            with open(path) as f:
                return json.load(f)
        except json.JSONDecodeError:
            pass
    return {"fetched_urls": [], "by_year_month": {}, "fetched_filenames": []}


def save_index(ticker: str, index: dict) -> None:
    """Save the index of fetched returns for a ticker."""
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    with open(get_index_path(ticker), "w") as f:
        json.dump(index, f, indent=2)


def fetch_search_page(stock_id: int, from_date: str, to_date: str, session=None) -> str:
    """Fetch one page of search results for the given date range."""
    params = {
        "category": 0,
        "lang": "EN",
        "market": "SEHK",
        "stockId": stock_id,
        "searchKey": "Monthly Return",
        "fromDate": from_date,
        "toDate": to_date,
    }
    url = f"{SEARCH_URL}?{urlencode(params)}"
    sess = session or requests.Session()
    r = sess.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"})
    r.raise_for_status()
    return r.text


def fetch_all_pages_with_playwright(stock_id: int, from_date: str, to_date: str) -> str:
    """
    Use Playwright to fetch all results by clicking Load More until exhausted.
    Falls back to single page if playwright not available.
    """
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        return fetch_search_page(stock_id, from_date, to_date)

    params = {
        "category": 0, "lang": "EN", "market": "SEHK", "stockId": stock_id,
        "searchKey": "Monthly Return", "fromDate": from_date, "toDate": to_date,
    }
    url = f"{SEARCH_URL}?{urlencode(params)}"

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url, wait_until="networkidle", timeout=60000)
        # Click "Load More" until no more
        for _ in range(20):  # Max ~2000 records at 100 per page
            load_more = page.query_selector('a:has-text("LOAD MORE")')
            if not load_more:
                break
            load_more.click()
            page.wait_for_timeout(1500)
        html = page.content()
        browser.close()
    return html


def parse_monthly_return_links(html: str):
    """
    Parse HTML for monthly return PDF links.
    Returns list of (url, title, month_end_date).
    """
    soup = BeautifulSoup(html, "html.parser")
    results = []
    # Links to PDFs
    for a in soup.find_all("a", href=True):
        href = a.get("href", "")
        if ".pdf" not in href.lower():
            continue
        title = (a.get_text() or "").strip()
        # Match "Monthly Return" documents (various phrasings)
        if "monthly return" not in title.lower() and "month ended" not in title.lower():
            continue
        # Extract month end date from title
        date_key = "unknown"
        # Format: "month ended 28/02/2026" or "28th February 2026"
        month_match = re.search(
            r"(?:month ended|for the month ended)\s+(?:(\d{1,2})(?:st|nd|rd|th)?\s+)?(\w+)\s+(\d{4})",
            title,
            re.I,
        )
        if month_match:
            day, month_str, year = month_match.groups()
            month_map = {
                "january": "01", "february": "02", "march": "03", "april": "04",
                "may": "05", "june": "06", "july": "07", "august": "08",
                "september": "09", "october": "10", "november": "11", "december": "12",
            }
            month = month_map.get((month_str or "").lower(), "01")
            date_key = f"{year}-{month}"
        else:
            # Format: "month ended 28/02/2026" (DD/MM/YYYY)
            ddmmyy = re.search(r"(?:month ended|ended)\s+(\d{1,2})/(\d{1,2})/(\d{4})", title, re.I)
            if ddmmyy:
                day, month, year = ddmmyy.groups()
                date_key = f"{year}-{month.zfill(2)}"
        if date_key == "unknown":
            # Fallback: extract from URL path e.g. /2026/0302/2026030201662.pdf
            path_match = re.search(r"/(\d{4})/(\d{4})/\d+\.pdf", href)
            if path_match:
                year, mdd = path_match.groups()
                date_key = f"{year}-{mdd[:2]}"
        full_url = urljoin(BASE_URL, href) if href.startswith("/") else href
        results.append((full_url, title, date_key))
    return results


def download_pdf(url: str, dest_path: Path) -> bool:
    """Download a PDF to the given path."""
    try:
        r = requests.get(url, timeout=60, stream=True)
        r.raise_for_status()
        dest_path.parent.mkdir(parents=True, exist_ok=True)
        with open(dest_path, "wb") as f:
            for chunk in r.iter_content(chunk_size=8192):
                f.write(chunk)
        return True
    except Exception:
        return False


def fetch_monthly_returns(ticker: str = "0019.HK") -> None:
    """
    Fetch monthly returns for the given ticker.
    Uses year-by-year search to avoid pagination limits and ensure full coverage
    from 1999 through the current year.
    """
    stock_id = get_stock_id(ticker)
    if not stock_id:
        raise ValueError(f"Could not resolve stock ID for {ticker}")

    DATA_DIR.mkdir(parents=True, exist_ok=True)
    ticker_dir = DATA_DIR / ticker.replace(".", "_")
    ticker_dir.mkdir(exist_ok=True)

    index = load_index(ticker)
    fetched = set(index.get("fetched_urls", []))
    by_ym = dict(index.get("by_year_month", {}))
    fetched_filenames = set(index.get("fetched_filenames", []))

    # Initial pass: scan stored folder for existing files (ensures incremental on rerun)
    for f in ticker_dir.glob("*.pdf"):
        if f.name not in fetched_filenames:
            fetched_filenames.add(f.name)
            # Infer year-month from filename (e.g. 2026030201662.pdf -> 2026-03)
            m = re.match(r"(\d{4})(\d{2})\d{2}\d+\.pdf", f.name)
            if m:
                y, mo = m.groups()
                key = f"{y}-{mo}"
                by_ym[key] = by_ym.get(key, 0) + 1
    index["fetched_filenames"] = list(fetched_filenames)
    index["by_year_month"] = by_ym
    save_index(ticker, index)

    current_year = datetime.now().year
    # Full range: HKEX uses YYYY/MM/DD; data since 1 April 1999
    from_date = "1999/04/01"
    to_date = f"{current_year + 1}/12/31"

    # Try Playwright for full pagination (Load More); fallback to single page
    try:
        html = fetch_all_pages_with_playwright(stock_id, from_date, to_date)
    except Exception as e:
        print(f"Note: Playwright failed ({e}), using single page. Install: pip install playwright && playwright install chromium")
        html = fetch_search_page(stock_id, from_date, to_date)

    all_links = parse_monthly_return_links(html)

    # Deduplicate by URL
    seen_urls = set()
    unique_links = []
    for url, title, date_key in all_links:
        if url not in seen_urls:
            seen_urls.add(url)
            unique_links.append((url, title, date_key))

    new_count = 0
    for url, title, date_key in unique_links:
        filename = url.split("/")[-1]
        # Skip if already have (by URL or by filename in folder)
        if url in fetched or filename in fetched_filenames or (ticker_dir / filename).exists():
            continue
        dest = ticker_dir / filename
        if download_pdf(url, dest):
            fetched.add(url)
            fetched_filenames.add(filename)
            new_count += 1
            year, month = date_key.split("-")[:2] if "-" in date_key else ("unknown", "unknown")
            key = f"{year}-{month}"
            by_ym[key] = by_ym.get(key, 0) + 1

    index["fetched_urls"] = list(fetched)
    index["fetched_filenames"] = list(fetched_filenames)
    index["by_year_month"] = by_ym
    save_index(ticker, index)

    print(f"Fetched {new_count} new monthly returns for {ticker}.")
    print_calendar_summary(by_ym)


def print_calendar_summary(by_year_month: dict) -> None:
    """Print an annual calendar-style table of fetched returns."""
    if not by_year_month:
        print("No returns fetched yet.")
        return

    # Group by year and month
    years = {}
    for ym, count in by_year_month.items():
        if "-" in ym and ym != "unknown":
            y, m = ym.split("-")[:2]
            if y not in years:
                years[y] = {}
            years[y][int(m)] = count

    if not years:
        print("No valid year-month data.")
        return

    sorted_years = sorted(years.keys())
    months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

    # Header
    header = "Year | " + " | ".join(f"{m:>3}" for m in months) + " | Total"
    print("\n" + "=" * len(header))
    print("Monthly Returns Fetched by Year (Calendar View)")
    print("=" * len(header))
    print(header)
    print("-" * len(header))

    for year in sorted_years:
        row = [str(year)]
        total = 0
        for m in range(1, 13):
            cnt = years[year].get(m, 0)
            row.append(f"{cnt:>3}" if cnt else "  -")
            total += cnt
        row.append(f"{total:>3}")
        print(" | ".join(row))

    print("-" * len(header))
    grand_total = sum(sum(years[y].values()) for y in years)
    print(f"Total returns: {grand_total}")


def main() -> None:
    import argparse
    parser = argparse.ArgumentParser(description="Fetch HKEX monthly returns for Hong Kong listed companies")
    parser.add_argument("--ticker", default="0019.HK", help="Stock ticker (e.g. 0019.HK for Swire Pacific)")
    parser.add_argument("--summary-only", action="store_true", help="Only show calendar summary, no fetch")
    args = parser.parse_args()

    if args.summary_only:
        index = load_index(args.ticker)
        print_calendar_summary(index.get("by_year_month", {}))
    else:
        fetch_monthly_returns(args.ticker)


if __name__ == "__main__":
    main()

Appendix B. parse_share_counts.py

Parses PDFs to extract share counts. Run: python parse_share_counts.py (after fetch)

#!/usr/bin/env python3
"""
Parse HKEX monthly return PDFs to extract share counts by share type.
Outputs CSV: period_end_date, share_type, share_count, source_file
"""

import re
import csv
from pathlib import Path
from datetime import datetime
from typing import Optional, List, Tuple

# Try pdfplumber first (better for tables), fallback to PyPDF2
try:
    import pdfplumber
    HAS_PDFPLUMBER = True
except ImportError:
    HAS_PDFPLUMBER = False

try:
    import pypdf
    HAS_PYPDF = True
except ImportError:
    try:
        import PyPDF2 as pypdf
        HAS_PYPDF = True
    except ImportError:
        HAS_PYPDF = False

DATA_DIR = Path(__file__).parent / "returns_data"
OUTPUT_CSV = Path(__file__).parent / "share_counts.csv"


def extract_text_pdfplumber(pdf_path: Path) -> str:
    """Extract text using pdfplumber."""
    text_parts = []
    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages:
            t = page.extract_text()
            if t:
                text_parts.append(t)
    return "\n".join(text_parts)


def extract_text_pypdf(pdf_path: Path) -> str:
    """Extract text using PyPDF2/pypdf."""
    text_parts = []
    with open(pdf_path, "rb") as f:
        reader = pypdf.PdfReader(f)
        for page in reader.pages:
            t = page.extract_text()
            if t:
                text_parts.append(t)
    return "\n".join(text_parts)


def extract_text(pdf_path: Path) -> str:
    """Extract text from PDF using available library."""
    if HAS_PDFPLUMBER:
        return extract_text_pdfplumber(pdf_path)
    if HAS_PYPDF:
        return extract_text_pypdf(pdf_path)
    raise ImportError("Install pdfplumber or pypdf: pip install pdfplumber")


def parse_period_end_date(text: str) -> Optional[str]:
    """Extract period end date from PDF text. Returns YYYY-MM-DD."""
    # Format 1: "For the month ended (dd/mm/yyyy) : 28/02/2009"
    m = re.search(r"For the month ended\s*\(?dd/mm/yyyy\)?\s*:\s*(\d{1,2})/(\d{1,2})/(\d{4})", text, re.I)
    if m:
        d, mo, y = m.groups()
        return f"{y}-{mo.zfill(2)}-{d.zfill(2)}"

    # Format 2: "For the month ended: 28 February 2026"
    months = {
        "january": "01", "february": "02", "march": "03", "april": "04",
        "may": "05", "june": "06", "july": "07", "august": "08",
        "september": "09", "october": "10", "november": "11", "december": "12",
    }
    m = re.search(
        r"For the month ended:\s*(\d{1,2})(?:st|nd|rd|th)?\s+(\w+)\s+(\d{4})",
        text,
        re.I,
    )
    if m:
        d, month_str, y = m.groups()
        mo = months.get(month_str.lower(), "01")
        return f"{y}-{mo}-{d.zfill(2)}"

    # Format 3: "(dd/mm/yyyy) : 30/06/2020" near start
    m = re.search(r"\(dd/mm/yyyy\)\s*:\s*(\d{1,2})/(\d{1,2})/(\d{4})", text, re.I)
    if m:
        d, mo, y = m.groups()
        return f"{y}-{mo.zfill(2)}-{d.zfill(2)}"

    return None


def _parse_int(s: str) -> Optional[int]:
    """Parse number string, return int or None if invalid."""
    n = s.replace(",", "").replace(" ", "").strip()
    if n.isdigit():
        v = int(n)
        # Sanity: issued shares should be at least 100k
        if v >= 100_000:
            return v
    return None


def parse_share_counts(text: str) -> List[Tuple[str, int]]:
    """
    Extract share counts by type. Returns list of (share_type, count).
    Swire Pacific: Class A (00019), Class B (00087).
    Prefer Section II (Issued) over Section I (Authorised).
    """
    results = []
    text_clean = text.replace(",", "")

    # --- Format A (older): Section II "Movements in Issued Share Capital"
    # Balance at close of the month 905,578,500 2,995,220,000
    # Columns (1)=A, (2)=B. Must be in Section II (issued), not Section I.
    section2 = re.search(
        r"II\.\s*Movements in Issued Share Capital.*?Balance at close of\s+(?:the\s+)?month\s+([\d\s]+?)(?:\s+N/A|N/A|\s*III\.)",
        text_clean,
        re.I | re.DOTALL,
    )
    if section2:
        block = section2.group(1)
        # Match numbers 100,000+ (avoid (1) (2) column headers)
        nums = re.findall(r"\b(\d{6,})\b", block)
        valid = []
        for n in nums:
            v = _parse_int(n)
            if v and v not in valid:  # avoid duplicates
                valid.append(v)
        if len(valid) >= 2:
            # A shares < B shares typically for Swire
            valid.sort()
            return [("Class A", valid[0]), ("Class B", valid[1])]

    # --- Format B (newer, ~2018+): Per-class blocks with "Balance at close of the month"
    # Stock code 00019 Description Class A ... Balance at close of the month 778,988,000
    for code, label in [("00019", "Class A"), ("00087", "Class B")]:
        # Match block for this stock code, then Balance at close - capture FIRST number only
        pattern = rf"(?:Stock code.*?{code}|{code}.*?Class [AB]).*?Balance at close of (?:the )?month\s+(\d{{6,}})"
        m = re.search(pattern, text_clean, re.I | re.DOTALL)
        if m:
            v = _parse_int(m.group(1))
            if v:
                results.append((label, v))

    if len(results) >= 2:
        return results

    # --- Format C: Section I (authorised) - fallback only when Section II not found
    section2_exists = "II." in text and "Movements in Issued" in text
    if not section2_exists:
        for code, label in [("00019", "Class A"), ("00087", "Class B")]:
            pattern = rf"Stock code\s*:\s*{code}.*?Balance at close of (?:the )?month\s+(\d{{6,}})"
            m = re.search(pattern, text_clean, re.I | re.DOTALL)
            if m:
                v = _parse_int(m.group(1))
                if v:
                    results.append((label, v))

    return results


def parse_pdf(pdf_path: Path) -> List[dict]:
    """
    Parse a single PDF. Returns list of dicts with period_end_date, share_type, share_count, source_file.
    """
    try:
        text = extract_text(pdf_path)
    except Exception as e:
        return []  # Skip failed PDFs

    period_end = parse_period_end_date(text)
    share_counts = parse_share_counts(text)

    if not period_end or not share_counts:
        return []

    # Relative path for source_file
    try:
        source_file = str(pdf_path.relative_to(Path(__file__).parent))
    except ValueError:
        source_file = str(pdf_path)

    return [
        {
            "period_end_date": period_end,
            "share_type": st,
            "share_count": cnt,
            "source_file": source_file,
        }
        for st, cnt in share_counts
    ]


def main():
    import argparse
    parser = argparse.ArgumentParser(description="Parse HKEX monthly return PDFs for share counts")
    parser.add_argument("--ticker", default="0019.HK", help="Stock ticker (e.g. 0019.HK)")
    parser.add_argument("-o", "--output", default=None, help="Output CSV path (default: share_counts.csv)")
    args = parser.parse_args()

    if not HAS_PDFPLUMBER and not HAS_PYPDF:
        print("Error: Install pdfplumber or pypdf: pip install pdfplumber")
        return 1

    ticker_dir = DATA_DIR / args.ticker.replace(".", "_")
    if not ticker_dir.exists():
        print(f"Error: No data folder at {ticker_dir}")
        return 1

    output_path = Path(args.output) if args.output else OUTPUT_CSV

    pdf_files = sorted(ticker_dir.glob("*.pdf"))
    print(f"Parsing {len(pdf_files)} PDFs from {ticker_dir}...")

    all_rows = []
    for pdf_path in pdf_files:
        rows = parse_pdf(pdf_path)
        all_rows.extend(rows)

    # Sort by period_end_date, then share_type
    all_rows.sort(key=lambda r: (r["period_end_date"], r["share_type"]))

    # Write CSV
    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(
            f,
            fieldnames=["period_end_date", "share_type", "share_count", "source_file"],
            extrasaction="ignore",
        )
        writer.writeheader()
        writer.writerows(all_rows)

    periods = len(set(r["period_end_date"] for r in all_rows))
    print(f"Wrote {len(all_rows)} rows ({periods} periods) to {output_path}")
    return 0


if __name__ == "__main__":
    exit(main())

Appendix C. chart_share_counts.py

Generates line charts. Run: python chart_share_counts.py (after parse)

#!/usr/bin/env python3
"""
Create line charts of share count over time for each share type (Class A, Class B).
Reads from share_counts.csv and saves separate charts.
"""

import csv
from pathlib import Path
from datetime import datetime

try:
    import matplotlib.pyplot as plt
    import matplotlib.dates as mdates
    from matplotlib.ticker import FuncFormatter
except ImportError:
    print("Error: Install matplotlib: pip install matplotlib")
    exit(1)

SCRIPT_DIR = Path(__file__).parent
INPUT_CSV = SCRIPT_DIR / "share_counts.csv"
OUTPUT_DIR = SCRIPT_DIR / "charts"


def load_data():
    """Load share counts from CSV. Returns dict: {share_type: [(date, count), ...]}."""
    by_type = {}
    with open(INPUT_CSV, "r", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            stype = row["share_type"]
            date = datetime.strptime(row["period_end_date"], "%Y-%m-%d")
            count = int(row["share_count"])
            by_type.setdefault(stype, []).append((date, count))
    for stype in by_type:
        by_type[stype].sort(key=lambda x: x[0])
    return by_type


def format_count(n):
    """Format large numbers with M/B suffix for axis labels."""
    if n >= 1e9:
        return f"{n / 1e9:.1f}B"
    if n >= 1e6:
        return f"{n / 1e6:.0f}M"
    if n >= 1e3:
        return f"{n / 1e3:.0f}K"
    return str(int(n))


def plot_share_type(share_type, dates, counts, output_path):
    """Draw a single line chart for one share type."""
    fig, ax = plt.subplots(figsize=(10, 5))
    ax.plot(dates, counts, color="#2563eb", linewidth=2, marker="o", markersize=3)

    ax.set_xlabel("Period end date")
    ax.set_ylabel("Share count")
    ax.set_title(f"Swire Pacific (0019.HK) – {share_type} share count over time")
    ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
    ax.xaxis.set_major_locator(mdates.YearLocator())
    plt.xticks(rotation=45)
    ax.yaxis.set_major_formatter(FuncFormatter(lambda x, _: format_count(x)))
    ax.grid(True, alpha=0.3)
    fig.tight_layout()
    fig.savefig(output_path, dpi=150, bbox_inches="tight")
    plt.close()


def main():
    if not INPUT_CSV.exists():
        print(f"Error: {INPUT_CSV} not found. Run parse_share_counts.py first.")
        return 1

    OUTPUT_DIR.mkdir(exist_ok=True)

    data = load_data()
    if not data:
        print("No data in CSV.")
        return 1

    for share_type in sorted(data.keys()):
        dates, counts = zip(*data[share_type])
        safe_name = share_type.replace(" ", "_").lower()
        out_path = OUTPUT_DIR / f"share_count_{safe_name}.png"
        plot_share_type(share_type, dates, counts, out_path)
        print(f"Saved {out_path}")

    return 0


if __name__ == "__main__":
    exit(main())