Install
openclaw skills install @joeyiptk/hkex-li-daily-factor-monitorFetch the latest HKEXnews "Daily Targeted Leverage Factor" announcements, extract factors from PDFs, and emit a human-readable Telegram digest.
openclaw skills install @joeyiptk/hkex-li-daily-factor-monitorhkex_li_daily_factor_monitor)You (the agent) perform the whole cycle by following this document: discover the most recent HKEXnews "Daily Targeted Leverage Factor" announcement(s) for L&I products, download and read their PDFs, extract each product's daily targeted leverage factor, then emit a human-readable Telegram digest — product rows in a monospace code block, grouped by applicable date, with source URLs below as your final message. You do not route or send it — OpenClaw delivers your output (a scheduled job's announce routing). This skill contains no channel and no credentials.
Tools used: curl, jq, pdftotext (poppler-utils), coreutils (date, sort,
awk, sed, mv, rm). python3 is used only as a PDF-text fallback when
pdftotext is absent (see scripts/pdf_to_text.py).
stock_code ↔ short_name pairs; the PDF gives the applicable
trading date and each product's factor. You join them.All reads/writes happen in one writable, per-user home directory, never in the skill's install folder (which may be read-only and is not reliably discoverable from a shell). Resolve the SAME paths at the start of every action (run, setup, doctor) with this exact block:
HOME_DIR="${HKEX_LI_MONITOR_HOME:-$HOME_DIR_DEFAULT}"
mkdir -p "$HOME_DIR"
CFG="$HOME_DIR/config.json" # settings (created at setup; falls back to skill's config.example.json)
$HKEX_LI_MONITOR_HOME overrides the location; else it defaults to
~/.config/hkex-li-daily-factor-monitor (or $HOME/.config/... on most systems),
writable on any machine and surviving reinstalls.config.tmp_dir (default
/tmp/hkex_li_daily_factor_monitor). If /tmp is locked down, set tmp_dir to
"$HOME_DIR/work". Everything in the scratch dir is deleted at end of run.$0/dirname to find paths, and do not write in the skill
folder.Full annotated reference: config.example.json in the skill folder. Schema:
{
"hkex_base_url": "https://www1.hkexnews.hk",
"hkex_search_endpoint": "/search/titleSearchServlet.do",
"hkex_search_params": {
"sortDir": "0", "sortByOptions": "DateTime", "category": "0",
"market": "SEHK", "stockId": "-1", "documentType": "-1",
"searchType": "0", "t": "-1", "lang": "EN", "rowRange": "2000"
},
"headline_keyword": "Daily Targeted Leverage Factor",
"category_keyword": "Matters relating to Collective Investment Schemes",
"product_type": "L&I",
"days_back": 7,
"run_mode": "latest",
"tmp_dir": "/tmp/hkex_li_daily_factor_monitor",
"user_agent": "Mozilla/5.0 (compatible; hkex-li-daily-factor-monitor)"
}
Load config, falling back to the skill's example copy if the user hasn't run setup:
if [ -f "$CFG" ]; then SRC="$CFG"; else SRC="config.example.json"; fi # example is a valid, working default
BASE=$(jq -r '.hkex_base_url' "$SRC")
EP=$(jq -r '.hkex_search_endpoint' "$SRC")
KW=$(jq -r '.headline_keyword' "$SRC")
CAT=$(jq -r '.category_keyword' "$SRC")
PTYPE=$(jq -r '.product_type' "$SRC")
DAYS=$(jq -r '.days_back // 7' "$SRC")
RUNMODE=$(jq -r '.run_mode // "latest"' "$SRC")
TMP=$(jq -r '.tmp_dir' "$SRC")
UA=$(jq -r '.user_agent' "$SRC")
QS=$(jq -r '.hkex_search_params | to_entries | map("\(.key)=\(.value)") | join("&")' "$SRC")
Run-time overrides (optional): the invoking message may set days_back or
run_mode (latest | all); prefer those over config when present.
doctor)Report PASS / FAIL for each; if anything required is missing, stop and return a clear error listing exactly what is missing — do not attempt a partial run.
# 1. Required tools
for t in curl jq; do command -v "$t" >/dev/null || echo "FAIL: missing required tool '$t'"; done
# 2. PDF text: pdftotext preferred; python3 fallback acceptable
if command -v pdftotext >/dev/null; then echo "PASS: pdftotext"; \
elif command -v python3 >/dev/null; then echo "PASS: python3 fallback (scripts/pdf_to_text.py)"; \
else echo "FAIL: need pdftotext (poppler-utils) OR python3 for PDF text extraction"; fi
# 3. Outbound HTTPS connectivity
curl -sS -o /dev/null -m 20 -A "$UA" -w "hkexnews: HTTP %{http_code}\n" https://www1.hkexnews.hk/ \
|| echo "FAIL: cannot reach https://www1.hkexnews.hk"
curl -sS -o /dev/null -m 20 -A "$UA" -w "sfc: HTTP %{http_code}\n" https://apps.sfc.hk/ \
|| echo "WARN: cannot reach https://apps.sfc.hk (not needed today; reserved for future extension)"
# 4. Writable home + scratch
( : > "$HOME_DIR/.wtest" && rm -f "$HOME_DIR/.wtest" ) || echo "FAIL: $HOME_DIR not writable (set HKEX_LI_MONITOR_HOME)"
mkdir -p "$TMP" 2>/dev/null && ( : > "$TMP/.wtest" && rm -f "$TMP/.wtest" ) || echo "FAIL: tmp_dir '$TMP' not writable (set config.tmp_dir)"
A 30x/200 from hkexnews counts as reachable. apps.sfc.hk is a soft check kept
for a possible future SFC cross-reference; its failure is a WARN, not a FAIL.
Run the storage block and the config-load block above. Then prepare a clean scratch dir and register cleanup so PDFs are removed even if a later step fails:
mkdir -p "$TMP"; rm -f "$TMP"/*.pdf 2>/dev/null # start clean
trap 'rm -f "$TMP"/*.pdf 2>/dev/null' EXIT # belt-and-braces cleanup (also done explicitly in Step 6)
Query a recent date window (not the servlet's title= param — free text there
returns 0 because it expects exact document-type codes), then filter the returned
records client-side on the headline keyword AND the CIS category. This is resilient
to HKEX category-code drift.
TODAY=$(date +%Y%m%d)
FROM=$(date -d "-${DAYS} days" +%Y%m%d 2>/dev/null || date -v-"${DAYS}"d +%Y%m%d)
URL="${BASE}${EP}?${QS}&fromDate=${FROM}&toDate=${TODAY}&title="
curl -sSL -m 60 -A "$UA" -H 'Accept: application/json' -o "$TMP/discover.json" -w '%{http_code}' "$URL" > "$TMP/http_code"
[ "$(cat "$TMP/http_code")" = 200 ] && jq -e . "$TMP/discover.json" >/dev/null 2>&1 \
|| { echo "ERROR: discovery request failed (HTTP $(cat "$TMP/http_code")) at ${BASE}${EP} — step: discover"; exit 1; }
# One TSV row per matching announcement:
# DATE_TIME <TAB> FILE_LINK <TAB> code1|code2|... <TAB> name1|name2|... <TAB> TITLE
jq -r --arg kw "$KW" --arg cat "$CAT" '
(.result | fromjson) as $r
| $r[]
| select((.TITLE // "" | ascii_downcase | contains($kw | ascii_downcase)))
| select((.LONG_TEXT // "" | ascii_downcase | contains($cat | ascii_downcase)))
| [ .DATE_TIME,
.FILE_LINK,
(.STOCK_CODE // "" | gsub("<br/>";"|")),
(.STOCK_NAME // "" | gsub("<br/>";"|")),
.TITLE ]
| @tsv
' "$TMP/discover.json" | sort -t$'\t' -k1,1r > "$TMP/matches.tsv" # newest first
Empty result → explicit error (do not emit an empty digest):
if [ ! -s "$TMP/matches.tsv" ]; then
rm -f "$TMP"/*.pdf 2>/dev/null
echo "No Daily Targeted Leverage Factor announcements found in the recent HKEXnews window under OTHERS (FUNDS, ETC)." >&2
exit 1
fi
run_mode selection:
latest (default): keep, for each distinct set of stock codes (col 3), only
the newest-dated row. Different managers/product families each publish their own
announcement daily, so "latest" = the newest factor for every product currently
publishing, not literally one file. De-dup:
awk -F'\t' '!seen[$3]++' "$TMP/matches.tsv" > "$TMP/selected.tsv" # rows are already newest-first
all: use every row — cp "$TMP/matches.tsv" "$TMP/selected.tsv".cat "$TMP/selected.tsv" — this is your work list (small).
For each row in selected.tsv (fields: DATE_TIME, PDF_PATH, CODES (|),
NAMES (|), TITLE):
ABS_URL="${BASE}${PDF_PATH}" # e.g. https://www1.hkexnews.hk/listedco/.../2026080700828.pdf
FN="$TMP/$(basename "$PDF_PATH")"
code=$(curl -sSL -m 90 -A "$UA" -o "$FN" -w '%{http_code}' "$ABS_URL")
[ "$code" = 200 ] && [ -s "$FN" ] \
|| { echo "ERROR: PDF download failed — url=$ABS_URL http=$code — step: download"; exit 1; }
# Extract text: pdftotext -layout preserves the product/factor columns best.
if command -v pdftotext >/dev/null; then
pdftotext -layout "$FN" "$FN.txt" 2>/dev/null || { echo "ERROR: pdftotext failed — url=$ABS_URL — step: parse"; exit 1; }
else
python3 scripts/pdf_to_text.py "$FN" > "$FN.txt" || { echo "ERROR: python fallback parse failed — url=$ABS_URL — step: parse"; exit 1; }
fi
[ -s "$FN.txt" ] || { echo "ERROR: empty PDF text — url=$ABS_URL — step: parse"; exit 1; }
Then you read "$FN.txt" and extract, per the heuristics below.
These are documented assumptions; apply judgement, don't hard-match positions.
applicable for <D Month YYYY> / applicable for the <D Month YYYY> (e.g. "applicable for 10 August 2026"). Convert to ISO YYYY-MM-DD
(→ 2026-08-10). This date applies to every product in that PDF. If two dates
appear, use the one attached to "Daily targeted leverage factor ... applicable
for".CSOP Samsung Electronics Daily Max (2x) Leveraged Product
USD Counter Stock Code: 09747
HKD Counter Stock Code: 07747
CSOP Samsung Electronics Daily Max (-2x) Inverse Product
Stock Code: 07347 (HKD Counter)
2x, -2x, 1.5x, -1x, etc. Parse to a signed decimal: 2x → 2.0,
-2x → -2.0, 1.5x → 1.5. Layout may wrap a long product name across lines
with the factor on the right — match by product name, not line number.CODES and NAMES |-lists from Step 2, positionally aligned), which is
authoritative. Cross-check that every metadata code appears in the PDF header;
note (but don't fail on) any mismatch. Zero-pad codes to 5 digits as HKEX does
(7347 → 07347).product_type from config ("L&I").Do NOT emit JSON. The only output of this skill is the Telegram digest in Step 5. Just keep track of these facts per stock code so you can render that digest:
07347).XL2CSOPSMSN-U).2.0, -1.0, 1.5, -2.0); Step 5 renders it as 2.0x / -2.0x.DATE_TIME, Hong Kong time.Also hold run_mode and the count of announcements/products for the header line.
There is no structured-payload output and no downstream JSON consumer — a raw JSON
dump in the channel is a mistake, not an acceptable alternative to the digest.
The channel is Telegram. Emit a plain-text digest with the product rows inside a
code block so columns stay aligned (Telegram renders code blocks in a
monospace font; it does NOT render pipe tables or <details>). Put source URLs
below the code block as plain lines — links inside a code block are not
clickable. Emit exactly this shape, nothing before the title:
HKEX L&I — Daily Targeted Leverage Factor
<generated_at, HKT> · <N> announcement(s) · <M> product(s) · mode: <run_mode>
```
Applicable <YYYY-MM-DD>
XI2CSOPSMSN -2.0x 07347
XL2CSOPHYNIX 2.0x 07709
XL2CSOPSMSN 2.0x 07747
XL2CSOPSMSN-U 2.0x 09747
Applicable <YYYY-MM-DD>
XI2CSOPCOIN -2.0x 07311
XL2CSOPCOIN 2.0x 07711
... (every remaining short name, one row per name) ...
```
Sources:
• <YYYY-MM-DD> → <absolute source_pdf_url>
• <YYYY-MM-DD> → <absolute source_pdf_url>
Digest rendering rules:
short_name. A product's HKD
and USD counters can share one short name; when they do, put every code for that
name on the one row, comma-separated in the trailing codes column
(e.g. XL2CSOPSMSN 2.0x 07747, 09747). All codes under one short name carry
the same factor.date: a
Applicable <date> header line, its rows, then a blank line before the next date.
Sort date groups ascending.07347 precedes 07709,
and a 09xxx-only name sorts after the 07xxx names. (Rows are name-led for
readability, but the order is by code — easy to scan to a code.)<short_name> <factor> <codes> — no
leading space; short_name left-justified padded to the width of the longest
short name in the run, two spaces, factor right-justified (width 6) so signs
line up, two spaces, the code(s). Keep lines short (≈26 chars for a single code)
so they fit a phone without horizontal scrolling; pad with spaces only, no tabs.x suffix — 2.0x, -2.0x, 1.5x. Keep the
.0. Use a plain hyphen - for the minus (renders reliably in Telegram mono).N = count of announcements, M = total products (count every
stock code, so a 2-code name counts as 2). Timestamp in Hong Kong time.Applicable <date> section (each its own titled code block) rather than
truncating — never cut a message mid-table.Delete all downloaded PDFs and extracted text; retain nothing (copyright):
rm -f "$TMP"/*.pdf "$TMP"/*.txt "$TMP"/discover.json "$TMP"/matches.tsv "$TMP"/selected.tsv "$TMP"/http_code 2>/dev/null
The EXIT trap from Step 1 also removes PDFs if the run aborted early.
The user prefers loud failures over quiet gaps.
No Daily Targeted Leverage Factor announcements found in the recent HKEXnews window under OTHERS (FUNDS, ETC).download (HTTP/empty), parse (pdftotext/python failed or empty text), or
pattern-extraction (found the doc but couldn't resolve a factor/date/code).$HOME_DIR, prove it is writable, and tell the user the exact path
where config/state will live.days_back (7), run_mode (latest),
headline_keyword, category_keyword, tmp_dir.$HOME_DIR/config.json from the schema atomically (write
config.json.tmp, then mv). Seed it from config.example.json.Create a daily OpenClaw cron job. L&I flexible-leverage managers publish the next trading day's factor after market close, so an evening HKT run (e.g. ~18:30) picks up that day's file. Embed the resolved home path so every scheduled run uses the same location:
HOME_DIR="${HKEX_LI_MONITOR_HOME:-$HOME/.config/hkex-li-daily-factor-monitor}"
openclaw cron add \
--name hkex-li-daily-factor-monitor \
--description "Daily HKEX L&I targeted leverage factor digest" \
--cron "30 18 * * 1-5" \
--tz "Asia/Hong_Kong" \
--tools exec \
--announce \
--message "Run the hkex-li-daily-factor-monitor skill now: perform one run exactly as its SKILL.md describes (run_mode=latest), and output the Telegram monospace digest (rows grouped by applicable date, source links below) as your final message."
Delivery/routing is OpenClaw's (--announce); this skill names no channel.
Inspect: openclaw cron list. Remove: openclaw cron rm --name hkex-li-daily-factor-monitor.
~/.config/hkex-li-daily-factor-monitor, override
HKEX_LI_MONITOR_HOME); nothing is read/written in the skill folder.EXIT trap. Only the extracted factor digest leaves the run.curl+jq; Python is a PDF
fallback only (scripts/pdf_to_text.py), used when pdftotext is unavailable.