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.
The fetch script (fetch_hkex_monthly_returns.py)
performs the following steps:
Stock lookup — Resolves the ticker 0019.HK to
HKEX stockId 30 via the prefix.do API (or
uses a preconfigured mapping for Swire Pacific).
Search — Queries the title search with:
searchKey: “Monthly Return”market: SEHKstockId: 30 (Swire Pacific)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).
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).
Incremental runs — Maintains an index
(returns_data/0019_HK_index.json) of fetched URLs and
filenames so subsequent runs only download new returns.
The parse script (parse_share_counts.py) extracts share
counts from each stored PDF:
Output is written to share_counts.csv with columns:
period_end_date, share_type,
share_count, source_file.
| 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 |
| 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 HKEXparse_share_counts.py — Parses PDFs to extract share
countschart_share_counts.py — Generates line chartsreturns_data/0019_HK_index.json — Index of fetched
filingsshare_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 — ChartsHKEX links:
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()