Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,9 @@ from quant_data_kit.providers.akshare import fetch_hs300_constituents
缺少可核验发布时间的估值记录使用 `available_at=NaT`、`availability_basis=unknown`,
严格 PIT 消费者应拒绝它们。历史指数成分回溯包含查询终点之后、当前快照之前的调整。
升级后应重新抓取旧价格缓存;旧缓存的单位和时间语义不能仅靠升级包自动修复。

## 机构研究扩展(2026-10-11)

追加并购事件修订库与历史可见读取;数据契约见 [CORPORATE_EVENTS](docs/CORPORATE_EVENTS.md)。

统一入口与合成输入用法见相邻仓库 [quant-workspace/profiles/institutional-research](../quant-workspace/profiles/institutional-research/README.md)。此增量提供离线合成验收、账户回放和独立 Studio 入口;真实市场数据与权限仍须明确提供。验收命令及边界见上述入口。
32 changes: 32 additions & 0 deletions docs/CORPORATE_EVENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Corporate events v1

`quant_data_kit.us_research.corporate_events` provides `import_events(database, records)`
and `asof_events(database, timestamp)` for a local append-only SQLite store.

Required fields (unknown fields are rejected):

| Field | Meaning |
|---|---|
| event_id, revision | Stable deal ID; contiguous positive revisions starting at 1 |
| event_type | `merger` in version 1 |
| target, acquirer | Exact instrument IDs; acquirer may be empty only for cash consideration |
| currency | Three-letter settlement currency |
| announced_at, available_at | Timezone-aware announcement and revision availability; availability cannot precede announcement |
| expected_close_at | Expected completion time, an assumption known with the revision |
| status | announced, pending, completed, terminated |
| cash_per_share, exchange_ratio | Nonnegative decimal strings, with positive total consideration |
| source_ref | Reviewed source URL/document reference, or explicit synthetic reference |
| evidence_kind | synthetic or retrospective; the store does not certify historical PIT |

Reimporting the same canonical row is idempotent. A conflicting overwrite, noncontiguous
revision, nonincreasing availability, changed identity or reopened terminal event is
rejected transactionally. SHA-256 binds every payload to its database index; all rows
are validated on read. `asof_events` selects the last visible revision per deal, including
visible completed/terminated records. Consumers decide whether these remain eligible.

The immutable price bundle continues to use `puresaber.us-research/1`. Event terms and
raw prices must use the same share units. Historical accuracy, exhaustive deal coverage,
source licensing, publication timing verification and calibrated probabilities require
additional evidence. No automatic web scraping or financial-news interpretation is included.

Usage and synthetic-input generation: `quant-workspace/profiles/institutional-research/README.md`.
210 changes: 210 additions & 0 deletions src/quant_data_kit/us_research/corporate_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""Append-only, revision-aware corporate-event research store (SQLite).

Manual/source-reviewed imports only. A source reference and timestamp are input
claims, not independent historical-PIT certification. No network or order API.
"""

from __future__ import annotations

import hashlib
import json
import sqlite3
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from urllib.parse import quote

SCHEMA = "qdk.corporate-events/1"
FIELDS = {
"event_id",
"revision",
"event_type",
"target",
"acquirer",
"currency",
"announced_at",
"available_at",
"status",
"cash_per_share",
"exchange_ratio",
"expected_close_at",
"source_ref",
"evidence_kind",
}


def utc(value):
stamp = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if stamp.tzinfo is None or stamp.utcoffset() is None:
raise ValueError("timezone-aware timestamp required")
return stamp.astimezone(timezone.utc)


def number(value, name, *, minimum=None):
if isinstance(value, bool):
raise TypeError(f"{name}: boolean is not numeric")
try:
result = Decimal(str(value))
except (InvalidOperation, TypeError) as exc:
raise ValueError(f"{name}: decimal required") from exc
if not result.is_finite() or (minimum is not None and result < minimum):
raise ValueError(f"{name}: invalid finite range")
return result


def validate(raw):
if not isinstance(raw, dict) or set(raw) != FIELDS:
raise ValueError(f"corporate event requires exactly {sorted(FIELDS)}")
row = dict(raw)
for name in ("event_id", "target", "currency", "source_ref"):
if not isinstance(row[name], str) or not row[name].strip():
raise ValueError(f"nonempty {name} required")
if len(row["currency"]) != 3 or not row["currency"].isalpha():
raise ValueError("three-letter currency required")
row["currency"] = row["currency"].upper()
if type(row["revision"]) is not int or row["revision"] < 1:
raise ValueError("positive integer revision required")
if row["event_type"] != "merger":
raise ValueError("v1 supports announced cash/fixed-ratio mergers only")
if row["status"] not in {"announced", "pending", "completed", "terminated"}:
raise ValueError("unsupported merger status")
if row["evidence_kind"] not in {"synthetic", "retrospective"}:
raise ValueError("manual event records cannot certify historical_pit")
for name in ("announced_at", "available_at", "expected_close_at"):
row[name] = utc(row[name]).isoformat()
if utc(row["available_at"]) < utc(row["announced_at"]):
raise ValueError("availability cannot precede announcement")
if utc(row["expected_close_at"]) <= utc(row["announced_at"]):
raise ValueError("expected completion must follow announcement")
for name in ("cash_per_share", "exchange_ratio"):
row[name] = str(number(row[name], name, minimum=0))
if number(row["cash_per_share"], "cash") + number(row["exchange_ratio"], "ratio") <= 0:
raise ValueError("positive consideration required")
if not isinstance(row["acquirer"], str):
raise TypeError("acquirer must be a string; empty only for cash deals")
if number(row["exchange_ratio"], "ratio") > 0 and not row["acquirer"].strip():
raise ValueError("stock consideration requires an acquirer instrument")
if row["target"] == row["acquirer"]:
raise ValueError("target and acquirer must differ")
return row


def canonical(row):
return json.dumps(row, sort_keys=True, ensure_ascii=False, separators=(",", ":"))


def _connect(path, *, readonly=False):
path = Path(path).resolve()
if readonly:
# pathlib encodes Windows \\?\ as URI authority %3F, which SQLite rejects.
# SQLite's Windows VFS handles extended filenames after URI decoding.
name = path.as_posix()
if name.startswith("//?/UNC/"):
name = "//" + name[8:]
elif name.startswith("//?/"):
name = name[4:]
uri = "file:" + ("//" if name.startswith("//") else "") + quote(name, safe="/:")
db = sqlite3.connect(uri + "?mode=ro", uri=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(path)
db.execute("CREATE TABLE IF NOT EXISTS metadata (schema TEXT NOT NULL)")
if not db.execute("SELECT schema FROM metadata").fetchall():
db.execute("INSERT INTO metadata VALUES (?)", (SCHEMA,))
db.execute(
"CREATE TABLE IF NOT EXISTS revisions (event_id TEXT, revision INTEGER, "
"available_at TEXT NOT NULL, payload TEXT NOT NULL, sha256 TEXT NOT NULL, "
"PRIMARY KEY(event_id, revision))"
)
db.commit()
if db.execute("SELECT schema FROM metadata").fetchall() != [(SCHEMA,)]:
db.close()
raise ValueError("unsupported corporate-event database schema")
return db


def _records(db):
result = []
for event_id, revision, available, payload, digest in db.execute(
"SELECT event_id,revision,available_at,payload,sha256 FROM revisions "
"ORDER BY event_id,revision"
):
if hashlib.sha256(payload.encode()).hexdigest() != digest:
raise ValueError("corporate-event record checksum mismatch")
row = validate(json.loads(payload))
if (row["event_id"], row["revision"], row["available_at"]) != (
event_id,
revision,
available,
):
raise ValueError("corporate-event index disagrees with payload")
result.append(row)
_validate_history(result)
return result


def _validate_history(rows):
previous = {}
for row in sorted(rows, key=lambda r: (r["event_id"], r["revision"])):
old = previous.get(row["event_id"])
if old:
if row["revision"] != old["revision"] + 1:
raise ValueError("event revisions must be contiguous")
if utc(row["available_at"]) <= utc(old["available_at"]):
raise ValueError("revisions must become available strictly later")
if any(
row[k] != old[k]
for k in ("target", "announced_at", "event_type", "currency", "evidence_kind")
):
raise ValueError("revision changes event identity")
if old["status"] in {"completed", "terminated"} and row["status"] != old["status"]:
raise ValueError("terminal status cannot reopen; use a new event ID")
elif row["revision"] != 1:
raise ValueError("first event revision must be 1")
previous[row["event_id"]] = row


def import_events(path, rows):
rows = [validate(row) for row in rows]
if not rows:
raise ValueError("empty import")
db = _connect(path)
inserted = 0
try:
with db:
current = {(r["event_id"], r["revision"]): r for r in _records(db)}
for row in rows:
key = (row["event_id"], row["revision"])
if key in current:
if canonical(current[key]) != canonical(row):
raise ValueError("immutable revision conflict; append a new revision")
continue
current[key] = row
payload = canonical(row)
db.execute(
"INSERT INTO revisions VALUES (?,?,?,?,?)",
(
*key,
row["available_at"],
payload,
hashlib.sha256(payload.encode()).hexdigest(),
),
)
inserted += 1
_validate_history(current.values())
finally:
db.close()
return {"schema": SCHEMA, "inserted": inserted, "submitted": len(rows)}


def asof_events(path, at):
at = utc(at)
db = _connect(path, readonly=True)
try:
latest = {}
for row in _records(db):
if utc(row["available_at"]) <= at:
latest[row["event_id"]] = row
return [latest[key] for key in sorted(latest)]
finally:
db.close()
125 changes: 125 additions & 0 deletions tests/test_corporate_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import sqlite3

import pytest

from quant_data_kit.us_research.corporate_events import (
asof_events,
import_events,
number,
utc,
validate,
)


def event(**changes):
return {
"event_id": "demo",
"revision": 1,
"event_type": "merger",
"target": "T",
"acquirer": "A",
"currency": "usd",
"announced_at": "2025-01-01T00:00:00Z",
"available_at": "2025-01-01T00:00:01Z",
"expected_close_at": "2025-06-01T00:00:00Z",
"status": "announced",
"cash_per_share": "50",
"exchange_ratio": "1",
"source_ref": "synthetic",
"evidence_kind": "synthetic",
**changes,
}


def test_append_idempotency_and_asof(tmp_path):
path = tmp_path / "events.sqlite"
first, final = (
event(),
event(revision=2, available_at="2025-01-02T00:00:00Z", status="completed"),
)
assert import_events(path, [first, final])["inserted"] == 2
assert import_events(path, [first])["inserted"] == 0
assert asof_events(path, "2024-01-01T00:00:00Z") == []
assert asof_events(path, "2025-01-01T01:00:00Z")[0]["revision"] == 1
assert asof_events(path, "2025-01-03T00:00:00Z")[0]["status"] == "completed"
with pytest.raises(ValueError, match="conflict"):
import_events(path, [event(cash_per_share="51")])


@pytest.mark.parametrize(
"changes",
[
{"extra": 1},
{"event_id": ""},
{"currency": "12"},
{"revision": True},
{"event_type": "split"},
{"status": "unknown"},
{"evidence_kind": "historical_pit"},
{"available_at": "2024-01-01T00:00:00Z"},
{"expected_close_at": "2024-01-01T00:00:00Z"},
{"cash_per_share": "0", "exchange_ratio": "0"},
{"acquirer": ""},
{"acquirer": "T"},
{"cash_per_share": "NaN"},
{"cash_per_share": "-1"},
{"cash_per_share": "x"},
],
)
def test_invalid_event(changes):
with pytest.raises(ValueError):
validate(event(**changes))


def test_invalid_types_and_times():
with pytest.raises(TypeError):
number(True, "quantity")
with pytest.raises(TypeError):
validate(event(acquirer=1))
with pytest.raises(ValueError):
utc("2025-01-01")
with pytest.raises(ValueError):
validate([])


@pytest.mark.parametrize(
"changes",
[
{"revision": 3},
{"available_at": "2025-01-01T00:00:01Z"},
{"target": "OTHER"},
{"status": "pending"},
],
)
def test_invalid_history_rolls_back(tmp_path, changes):
path = tmp_path / "events.sqlite"
import_events(path, [event(status="completed")])
row = event(revision=2, status="completed", available_at="2025-01-02T00:00:00Z")
row.update(changes)
with pytest.raises(ValueError):
import_events(path, [row])
assert len(asof_events(path, "2025-02-01T00:00:00Z")) == 1


@pytest.mark.parametrize(
"sql",
[
"UPDATE metadata SET schema='bad'",
"UPDATE revisions SET sha256='bad'",
"UPDATE revisions SET revision=3",
],
)
def test_tamper_rejected(tmp_path, sql):
path = tmp_path / "events.sqlite"
import_events(path, [event()])
with sqlite3.connect(path) as db:
db.execute(sql)
with pytest.raises(ValueError):
asof_events(path, "2025-02-01T00:00:00Z")


def test_empty_and_noninitial_import(tmp_path):
with pytest.raises(ValueError, match="empty"):
import_events(tmp_path / "e.sqlite", [])
with pytest.raises(ValueError, match="first"):
import_events(tmp_path / "e.sqlite", [event(revision=2)])
Loading