Do BIST insiders' disclosed purchases predict returns you can actually capture?
They predict. You cannot capture them. This is the pipeline that measures both, and the second half is the finding.
Insider-cluster events on Borsa İstanbul, 2015-2018. Entry is t+1 after the KAP disclosure is public, returns are measured in excess of XU100 over the same held interval, and the round-trip cost is estimated per trade from that stock's own OHLC (Abdi-Ranaldo 2017) rather than assumed as a flat fee.
| Horizon | N | Gross AR | Cost | Net AR | t (net) |
|---|---|---|---|---|---|
| 5d | 1,070 | +0.66% | 3.37% | −2.71% | −12.91 |
| 20d | 1,070 | +2.02% | 3.37% | −1.35% | −3.31 |
| 60d | 1,071 | +2.18% | 3.37% | −1.20% | −1.86 |
The signal is real. The gross abnormal return is significantly positive at every horizon (20d: +2.07%, t = 5.36, N = 1,079). And it is not tradeable, because insider clusters fire in illiquid small caps whose bid-ask spread is wider than the alpha: the median round trip costs 1.93%, the upper quartile 4.34%. Nothing survives crossing it twice. At 60 days the net loss is no longer statistically distinguishable from zero (t = −1.86) - which buys nothing: the point estimate is still negative, and "you might merely break even after three months" is not an edge either.
That is the whole finding, and it is why this repository exists. A gross number is not an edge; an edge is what is left after the market takes its cut.
Everything below is the machinery required to be able to say that honestly - and the audit trail of the silent data faults that had to be found first, each of which had moved the number:
| KAP's list endpoint truncates at 2,000 rows, keeping the newest | ~75% of every month was being discarded |
The transaction-date regex accepted / but not . |
every filing before 2021 parsed to zero transactions |
| Fixed column indices into a variable-width table | 21% of stored rows were silently wrong |
| WAF disconnects were caught and skipped | 12% of disclosures vanished, run still reported SUCCESS |
| Prices fetched in one batch; one bad symbol poisoned the rest | looked exactly like survivorship bias |
| yfinance serves nothing for a delisted ticker | 31% of clusters dropped - the dead ones, the worst outcomes |
| The cost model read the total-return index as if it were a price | the tick floor and ADV were wrong on 32% of ticker-days |
KAP's relatedStocks is not always one ticker |
KRDMA, KRDMB, KRDMD joined to no price row; 14 clusters left every result in silence |
The delisting fault was fixed by loading Borsa İstanbul's own end-of-day bulletin, which is survivorship-clean by construction: 1.3M rows, 749 tickers, against yfinance's 185. It also carries the VBTS tradability flags - which, once measured, turned out to touch only 1% of entries and not to be the constraint at all.
The cost fault is the one worth dwelling on, because it sat directly under the number that
decides the answer. close_try is a chained total-return index - correct for returns,
since a bonus issue halves the print and a raw series would read it as a 50% loss. It is
not a price. But the tick floor is 0.01 TRY on the exchange's grid and ADV is price ×
volume, and both were being fed the index. BIST companies issue bonus shares constantly,
so the two series pull apart: a median 0.98× but ranging 0.60× to 118×. The error was not
one-directional, so it was not conservative - it simply mispriced trades, worst in the
serial bonus-issuers, which are small caps, which are precisely where the tick floor binds
and where tradeability is decided. Fixing it raised N from 1,032 to 1,070 and left the
verdict standing.
-
Scrapes Turkey's public-disclosure platform (KAP) for SPK II-15.1 individual-insider transaction reports - Turkey's regulatory equivalent of SEC Form 4.
-
Reverse-engineers KAP's undocumented Java-serialized
byte[]PDF wrapper, parses the DKB transaction tables (Turkish-locale numbers, Windows-1252 encoded), and stores normalised rows in PostgreSQL with cryptographic deduplication. -
Measures market-adjusted abnormal returns (5/20/60 trading-day horizons) over detected insider-cluster events. Entry is t+1 after the KAP disclosure is public - never the private transaction date - and every return is measured in excess of XU100 over the same held interval. Estimates ship with a Wilson interval, a t-test, and a power gate that returns
INSUFFICIENT_POWERrather than a number it cannot support. Seedocs/METHODOLOGY.md. -
Generates forensic briefs (HTML + PDF) per BIST ticker, combining insider-transaction history, board-interlock graphs, and (optionally) Türkiye Ticaret Sicil Gazetesi cross-references.
-
Prices trades against the exchange's own bulletin, not a retail feed: survivorship- clean by construction, corporate-action-adjusted by chaining the exchange's restated previous close, and carrying the VBTS gross-settlement and suspension flags.
-
Refuses to answer when it cannot.
compute_base_ratereturnsINSUFFICIENT_POWERbelow ~784 events andSURVIVORSHIP_BIASEDwhen too many clusters cannot be priced. Both gates fired during this work, and both were right.
What is claimed, precisely: a statistically strong gross abnormal return (20d: +2.07%, t = 5.36, N = 1,079, survivorship-clean) that does not survive a per-trade cost estimate. The window is 2015-2018 - a single regime - so the result is not yet regime-conditional, and that is stated rather than glossed. Remaining gaps are in
docs/METHODOLOGY.md, not left for a reader to discover.
TrailingEdge, BIST'in SPK II-15.1 (Pay Alım Satım Bildirimi) kapsamındaki şirket-içi alım-satım bildirimlerini KAP üzerinden çekip PostgreSQL'e yazan, üzerine ileriye dönük getiri ölçümü ve şirket-bazlı forensic rapor üreten bir veri-mühendisliği projesidir. ABD'deki SEC Form 4 takipçilerinin (ör. OpenInsider) Türk sermaye piyasaları için referans implementasyonu olarak tasarlandı: şeffaf, yeniden üretilebilir, audit-trail'li, açık kaynak.
KAP - operated by Merkezi Kayıt Kuruluşu (MKK) under Türkiye's capital markets framework - exposes the entire insider-disclosure feed publicly, yet there is no open analytical layer comparable to U.S. SEC Form 4 trackers. TrailingEdge fills that gap: a transparent, audit-logged, reproducible pipeline that any regulator, researcher, or market participant can stand up locally in under thirty minutes.
The project also serves as a working reference for several non-trivial integration problems:
- KAP's undocumented Java object-serialization wrapper around PDF downloads
- Turkish-locale numeric / date / encoding handling in
pdfminer - Idempotent disclosure ingest under KAP's silent 2,000-record result cap - the endpoint truncates to the newest rows and drops the older head of the range with no error and no cursor, so any window that returns at the cap is presumed incomplete and bisected until it fits
- Forensic graph analytics over board-interlock data via NetworkX
cp .env.example .env # set DATABASE_URL and KAP_BASE_URL
docker-compose up -d # postgres
uv sync # python deps
alembic upgrade head # schemaThe CLI installs as trailingedge (short alias: te). To install straight
from source: pip install git+https://github.com/caganco/trailingedge.
Daily ingest:
trailingedge scrape kap-insider --last-hours 24 # last day
trailingedge scrape kap-insider --last-hours 168 # last week
trailingedge scrape kap-insider --since 2026-05-01 --until 2026-05-27Insider-activity brief for a single ticker (HTML + PDF):
trailingedge report generate --ticker SARKYtrailingedge prices backfill # XU100 benchmark (yfinance)
python scripts/load_official_prices.py # exchange bulletin: survivorship-clean
trailingedge signal detect # clusters + market-adjusted outcomes
python scripts/check_forward_returns.py # gross abnormal return, with its gates
python scripts/net_of_cost.py # the one that decides itnet_of_cost.py is the script that answers the question:
=== Abnormal return, NET of round-trip cost (order 25,000 TRY) ===
spread: Abdi-Ranaldo (2017) from the stock's own OHLC, per trade
dropped (no cost estimate): 27
round-trip cost: median 1.93% p25 1.19% p75 4.34%
HORIZON N GROSS AR% COST% NET AR% HIT% 95% CI t VERDICT
5d 1070 0.66 3.37 -2.71 26.9 [24.3, 29.7] -12.91 LOSES MONEY (net)
20d 1070 2.02 3.37 -1.35 41.3 [38.4, 44.3] -3.31 LOSES MONEY (net)
60d 1071 2.18 3.37 -1.20 44.4 [41.5, 47.4] -1.86 NO EDGE (net)
The spread is not a parameter. It is estimated for each trade from the 30 sessions of that stock's own OHLC before entry - which is also the only estimator that works on the delisted names the bulletin carries and no quote feed does. A flat fee would have made the answer come out the other way.
| Concern | Implementation |
|---|---|
| HTTP | httpx async + aiolimiter (2 RPS cap) + tenacity retry on 429/503/timeout |
| PDF unwrap | Java byte[] serialization stripped at offset 23 (4-byte BE length prefix) |
| PDF parse | pdfminer.six with Windows-1252 awareness; date-anchored row extraction |
| Number parse | 1.234,56 → Decimal("1234.56") with explicit sign handling |
| Idempotency | SHA-256(name|date|type|count|price) natural key + ON CONFLICT DO NOTHING |
| Audit | scraper_runs table with RUNNING → SUCCESS/FAILED/PARTIAL state machine |
| Schema | SQLAlchemy 2.0 typed Mapped[...] ORM + Alembic migrations |
| Result cap | KAP truncates to 2,000 newest rows and silently drops the older head; windows returning at the cap are bisected recursively until complete |
| Names | rapidfuzz token_sort_ratio + Turkish ASCII transliteration for cross-source joins |
| Graph | NetworkX over board_interlocks materialised view with REFRESH CONCURRENTLY |
| OCR (optional) | PyMuPDF render @ 300 DPI → Tesseract -l tur for Ticaret Sicil gazettes |
KAP API
└─ POST /tr/api/disclosure/members/byCriteria ──► list (filter DKB)
└─ GET /tr/api/notification/attachment-detail ──► detail + objId
└─ GET /tr/api/file/download/{objId} ──► PDF (Java-wrapped)
│
▼
parse_dkb_transactions (pdfminer)
│
▼
KapRepository
(upsert disclosure + txs)
│
▼
PostgreSQL
│
┌──────────────────────┬───────────────┴──────────────────┐
▼ ▼ ▼
cluster detection forward returns forensic brief
(≥N insiders, Δt) (5/20/60-day horizons) (HTML + PDF)
Detailed data flow, design decisions, and Turkish-locale edge cases:
docs/ARCHITECTURE.md,
docs/DATA_DICTIONARY.md,
docs/KAP_ENDPOINT_NOTES.md.
src/trailing_edge/
core/ config, logging, async http client, db, tz helpers
scrapers/
kap/ KAP HTTP client + DKB/ODA parser + orchestrator
ticaret_sicil/ CAPTCHA-gated TSG client + OCR pipeline (optional)
models/ SQLAlchemy ORM (disclosures, transactions, graph, signal)
storage/ repository / upsert layer
signals/ cluster detection, forward returns, base rates, graph
reports/ daily signal, forensic brief, network, cross-reference
data/ yfinance price ingest
cli/ click entrypoint (trailingedge ...)
docs/ architecture, data dictionary, KAP endpoint reconnaissance
scripts/ backfill, fixture acquisition, ops scripts
migrations/ alembic
tests/ unit/ (no DB) and integration/ (TEST_DATABASE_URL)
uv run pytest tests/unit/ -v # no DB required
uv run pytest tests/integration/ -v # requires TEST_DATABASE_URLThese are documented openly so consumers can judge the analytics layer honestly:
- Disclosure timing.
transaction_datein the PDF can pre-date thepublished_atof the KAP filing by several days. The current cluster-return measurement useswindow_end(= last transaction date) as the entry-price anchor. Strict point-in-time backtesting should instead usemax(transaction_date, published_at)to avoid leaking the filing date forward - tracked for the next analytics revision. - Sample size. Forward-return base rates are computed over a small (~30-signal) live window. A historical backfill is required before the numbers can be treated as anything stronger than indicative.
- Excess returns. Returns are absolute, not benchmarked against the XU100 index. An excess-over-benchmark view is straightforward to add but out of scope for the current phase.
- ODA disclosures. Fund-company threshold-crossing reports (Article 12) are stored at the disclosure level but not yet parsed into the transaction table.
- TSG OCR. The Türkiye Ticaret Sicil Gazetesi pipeline is CAPTCHA-gated (semi-automatic) and intended for forensic enrichment, not for any market-signal claim.
Phase 1 - working end-to-end pipeline (ingest + analytics + briefs) on a single-node deployment. Production hardening (HA Postgres, scheduled ingest, alerting) is out of scope for this revision.
Not affiliated. TrailingEdge is an independent, non-commercial, open-source project. It has no connection to, and no sponsorship or endorsement from, Borsa İstanbul A.Ş., the Public Disclosure Platform (KAP), Merkezi Kayıt Kuruluşu (MKK), or the Capital Markets Board of Türkiye (SPK). "Borsa İstanbul", "BIST", "KAP", "MKK" and "SPK" are names or marks of their respective owners, used here only descriptively to identify the public market and the official disclosure sources this project analyses. All data is derived from publicly available regulatory disclosures.
Not investment advice. This is a research and data-engineering tool. Nothing it produces is investment advice, a recommendation, or a solicitation to buy or sell any security. Forward-return figures are empirical base rates over small samples and carry no predictive guarantee. Use at your own risk.