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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ editable-build requirements for Python3.10-3.12. Every registry package is fixed
version. The `dev` extra names Python3.10's conditional compatibility dependencies explicitly so
a lock compiled on Python3.12 remains complete for the whole matrix. The internal package is also
fixed to the reviewed research-data revision in this development branch:
`quant-data-kit@8fed47b8f62694c36830dec270cfa21759133f2f`, from
`quant-data-kit@7a8813b3d1e52f476f8fe51f05ea714be41a52bb`, from
`https://github.com/PureSaber/quant-data-kit.git`. The project declaration and lock
use the same immutable source as the daily A-share research stack, so a clean
use the same immutable source as the US research stack in this branch, so a clean
resolver does not combine incompatible direct URLs. Existing release tags remain unchanged.

Regenerate the lock only after reviewing dependency changes in `pyproject.toml`:
Expand Down
11 changes: 11 additions & 0 deletions docs/FINANCIAL_FOUNDATIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 03 / 04 公司行动与未知状态执行

`ledger.apply_corporate_action(ActionTerms(...), at=...)` 接受 QDK financial.actions 契约,复用现有精确双式账本,不制造成交。支持拆股、分红权益/支付、换股、分拆、供股权分派/显式行权、碎股现金和终止现金。source/evidence_id、原价口径、目标估值及成本分配必须明确。失败恢复全部账本状态;同 ID 同条款幂等,不同条款拒绝。

分红权益先产生应收,支付才入现金。碎股现金需要 retired_quantity;供股需要 election_quantity,不能默认全额认购;新认购股的 lot 日期为行权日。复杂转换只支持同币种、单位乘数、非做空现金证券,不含自动 FX/税费/税务成本规则。目标必须先注册;不支持 streaming artifact sink 的原子行动批次。复杂事件必须与交易和估值按时间顺序回放。

底层账本现金不是市场交收模型:美股/港股适配在行权前另外检查已交收可用现金。其他调用者也必须提供市场层约束。分拆/合并目标价格来自显式输入,不是自动找价。

`resolve_a_share_replay_status` 保留缺失 flag 为 unknown。RuleBookRiskGate 对 unknown/no_restriction 返回 MARKET_STATUS_UNKNOWN。InstrumentSpec.metadata["requires_status_evidence"]="true" 启用严格模式:只有行情、没有状态事件时不默认开市;状态不能沿用到下一 trading_day。旧数据未启用该开关保留兼容行为;严格研究必须启用或每日显式投递状态。

测试:tests/test_financial_actions.py(财富/成本守恒、幂等、现金、失败回滚),tests/test_rules.py(状态缺失与跨日过期)。不涉及真实券商订单。
13 changes: 13 additions & 0 deletions docs/us-cash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# US cash research account

`quant_execution.us_cash.USCashAccount` uses `ExactAccountLedger` for fills, fees, corporate actions and valuation. Cash is booked on trade date; `buying_power` separately subtracts unsettled sale proceeds. This is a conservative cash account using settled funds, not a margin/PDT or live brokerage model.

Standard equity settlement uses T+2 before 2024-05-28 and T+1 from that date. Same-day resale is allowed for holdings bought using settled funds. Account units permit six decimal places for normalized-price and fractional-share research; no broker fillability claim is implied.

The daily model releases buying power on the settlement date, without intraday clearing or broker-specific holds. Account mutations and cash queries cannot precede the current ledger state; an identical already-applied trade remains idempotent. Instruments must be USD cash equities/ETFs with unit multiplier.

Explicit `us_equity`/`us_etf` product types select `USCashEquityRule`. Legacy A-share product types preserve their prior rules. Costs are supplied assumptions, not a fixed regulatory fee schedule.

`terminal_cash` corporate events retire all shares and remove book cost, posting a final known cash amount and realized P&L. They require a zero share ratio and nonnegative cash in the settlement currency. A zero recovery must be explicit upstream evidence, never a substitute for missing prices.

Fractional equity sells now quantize posted cash and removed book cost before computing P&L. This corrects a one-unit rounding imbalance caused by independently quantizing all three amounts.
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ requires-python = ">=3.10"
dependencies = [
"pyarrow>=14.0",
"jsonschema>=4.20",
"quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@104f1ef8a3b1278c0ea5420fadaa9d1d863ce726",
"quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@271a65ee383158b3dd7570e19a3f2b4123f09f79",
]

[project.optional-dependencies]
us-research = ["exchange-calendars>=4.5,<5"]
dev = [
"exchange-calendars>=4.5,<5",
"exceptiongroup>=1",
"pytest>=7.4",
"pytest-cov>=5.0",
Expand Down
32 changes: 20 additions & 12 deletions requirements.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# python -m piptools compile --extra dev --build-deps-for editable --allow-unsafe --strip-extras --resolver backtracking --index-url https://pypi.org/simple --constraint requirements-constraints.txt --output-file requirements.lock pyproject.toml
# pip-compile --allow-unsafe --build-deps-for=editable --constraint=requirements-constraints.txt --extra=dev --index-url=https://pypi.org/simple --no-emit-index-url --no-index --output-file=requirements.lock --strip-extras pyproject.toml
#
attrs==26.1.0
# via
Expand All @@ -15,9 +15,9 @@ coverage==7.16.0
duckdb==1.5.5
# via quant-data-kit
exceptiongroup==1.3.1
# via
# pytest
# quant-execution (pyproject.toml)
# via quant-execution (pyproject.toml)
exchange-calendars==4.13.2
# via quant-execution (pyproject.toml)
iniconfig==2.3.0
# via pytest
jsonschema==4.26.0
Expand All @@ -28,16 +28,21 @@ jsonschema-rs==0.52.1
# via quant-data-kit
jsonschema-specifications==2025.9.1
# via jsonschema
korean-lunar-calendar==0.4.0
# via exchange-calendars
numpy==2.2.6
# via
# exchange-calendars
# pandas
# quant-data-kit
orjson==3.12.0
# via quant-data-kit
packaging==26.3
# via pytest
pandas==2.3.3
# via quant-data-kit
# via
# exchange-calendars
# quant-data-kit
pluggy==1.6.0
# via
# pytest
Expand All @@ -48,6 +53,8 @@ pyarrow==25.0.1
# quant-execution (pyproject.toml)
pygments==2.21.0
# via pytest
pyluach==2.3.0
# via exchange-calendars
pytest==9.1.1
# via
# pytest-cov
Expand All @@ -60,7 +67,7 @@ pytz==2026.3.post1
# via pandas
pyyaml==6.0.3
# via quant-data-kit
quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@104f1ef8a3b1278c0ea5420fadaa9d1d863ce726
quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@271a65ee383158b3dd7570e19a3f2b4123f09f79
# via quant-execution (pyproject.toml)
referencing==0.37.0
# via
Expand All @@ -76,17 +83,18 @@ ruff==0.16.5
six==1.17.0
# via python-dateutil
tomli==2.4.1
# via
# coverage
# pytest
# quant-execution (pyproject.toml)
# via quant-execution (pyproject.toml)
toolz==1.1.0
# via exchange-calendars
typing-extensions==4.16.0
# via
# exceptiongroup
# quant-data-kit
# referencing
tzdata==2026.3
# via pandas
# via
# exchange-calendars
# pandas
websockets==15.0.1
# via quant-data-kit

Expand Down
253 changes: 253 additions & 0 deletions src/quant_execution/corporate_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
"""Atomic, exact corporate-action application to the existing ledger.

No synthetic fills, no automatic rights election, and no inferred FX or cost
allocation. Complex conversions currently require same-currency cash equities.
Fractional quantities must be represented by the declared instrument step;
cash-in-lieu is a separate evidenced event, never a guessed rounding price.
"""

from decimal import Decimal

from quant_data_kit import CorporateActionEvent
from quant_data_kit.exceptions import ValidationError
from quant_data_kit.financial.actions import ActionTerms
from quant_data_kit.financial.common import number, utc

from ._fixed import decimal, fixed
from .contracts import LedgerEventType, LedgerTransaction


def apply_action(ledger, action: ActionTerms, *, at):
ledger._require_mutable()
stamp = utc(at).to_pydatetime()
if stamp < utc(action.effective_at) or stamp < utc(action.available_at):
raise ValidationError("corporate action is not effective and known yet")
fingerprint = action.fingerprint()
key = f"financial-action:{action.event_id}"
if key in ledger._event_fingerprints:
if ledger._event_fingerprints[key] != fingerprint:
raise ValidationError("corporate action ID reused with changed terms")
return ledger.snapshot()
if stamp < ledger.snapshot().event_time:
raise ValidationError("corporate action cannot reverse account time")
if ledger._artifact_sink is not None:
raise ValidationError("complex action batch requires nonstreaming atomic replay")
spec = ledger._spec(action.instrument_id)
if ledger._is_derivative(spec) or spec.settlement_currency != action.currency:
raise ValidationError("action requires matching cash-asset currency")
state = ledger.capture_state()
try:
if action.kind == "cash_in_lieu":
_cash_in_lieu(ledger, action, stamp)
elif action.kind in {"split", "dividend_entitlement", "dividend_payment", "terminal_cash"}:
kind = {
"dividend_entitlement": "cash_dividend_entitlement",
"dividend_payment": "cash_dividend_payment",
}.get(action.kind, action.kind)
cash = (
None
if action.kind == "split"
else fixed(number(action.cash_per_unit), ledger.money_scale)
)
ratio = fixed(number(action.ratio), 12) if action.kind == "split" else None
if action.kind == "terminal_cash":
ratio = fixed(Decimal(0), 0)
ledger.apply(
CorporateActionEvent(
event_id=key,
instrument_id=action.instrument_id,
action_type=kind,
event_time=stamp,
received_at=stamp,
available_at=stamp,
source=action.source + "#" + action.evidence_id,
trading_day=stamp.date(),
session_id="financial-actions",
sequence=0,
effective_date=(
utc(action.entitlement_date + "T00:00:00Z").date()
if action.entitlement_date
else stamp.date()
),
ratio=ratio,
cash_amount=cash,
currency=action.currency if cash is not None else None,
)
)
else:
_convert(ledger, action, stamp)
ledger._event_fingerprints[key] = fingerprint
return ledger.snapshot(stamp)
except Exception:
ledger.restore_state(state)
raise


def _cash_in_lieu(ledger, action, stamp):
spec = ledger._spec(action.instrument_id)
old = ledger._positions.get(action.instrument_id, Decimal(0))
retired = number(action.retired_quantity)
if retired > old or retired % decimal(spec.quantity_step):
raise ValidationError("cash-in-lieu quantity exceeds holding or violates declared step")
cost = decimal(
fixed(
ledger._position_cost(action.instrument_id, derivative=False) * retired / old,
ledger.money_scale,
)
)
cash = decimal(fixed(retired * number(action.cash_per_unit), ledger.money_scale))
currency = action.currency
postings = [
ledger._posting("assets:cash", currency, cash),
ledger._posting(
"assets:position_cost", currency, -cost, instrument_id=action.instrument_id
),
ledger._posting(
"income:realized_pnl", currency, cost - cash, instrument_id=action.instrument_id
),
]
for account, delta in (("assets:position", -retired), ("memo:position_counter", retired)):
postings.append(
ledger._posting(
account,
currency,
Decimal(0),
instrument_id=action.instrument_id,
quantity_delta=delta,
quantity_scale=spec.quantity_step.scale,
)
)
tx = ledger._make_transaction(
event_type=LedgerEventType.CORPORATE_ACTION,
reference_id=action.event_id,
idempotency_key=f"financial-action:{action.event_id}",
event_time=stamp,
postings=tuple(postings),
)
LedgerTransaction.__post_init__(tx)
ledger._post(tx)
remaining, lots = retired, []
for acquired, quantity in ledger._position_lots.get(action.instrument_id, []):
take = min(quantity, remaining)
remaining -= take
if quantity > take:
lots.append((acquired, quantity - take))
if remaining:
raise ValidationError("cash-in-lieu quantity is not backed by holding lots")
ledger._position_lots[action.instrument_id] = lots
if retired == old:
ledger._marks.pop(action.instrument_id, None)
ledger._event_time = stamp


def _convert(ledger, action, stamp):
parent, target = ledger._spec(action.instrument_id), ledger._spec(action.target_id)
if (
ledger._is_derivative(target)
or target.settlement_currency != action.currency
or decimal(parent.contract_multiplier) != 1
or decimal(target.contract_multiplier) != 1
):
raise ValidationError("conversion requires same-currency unit-multiplier cash assets")
old = ledger._positions.get(action.instrument_id, Decimal(0))
if old < 0:
raise ValidationError("short corporate-action conversion is not supported")
exercise = action.kind == "rights_exercise"
distribution = action.kind in {"spin_off", "rights_distribution"}
eligible = number(action.election_quantity) if exercise else old
if eligible > old:
raise ValidationError("election exceeds held rights")
added = eligible * number(action.ratio)
removed = Decimal(0) if distribution else eligible
if added % decimal(target.quantity_step) or removed % decimal(parent.quantity_step):
raise ValidationError(
"fractional entitlement requires explicit step or cash-in-lieu evidence"
)
cost = ledger._position_cost(action.instrument_id, derivative=False)
moved = (
cost
* (eligible / old if old else Decimal(0))
* (Decimal(1) if exercise else number(action.cost_fraction))
)
moved = decimal(fixed(moved, ledger.money_scale))
removed_cost = (
moved
if distribution
else decimal(fixed(cost * (eligible / old if old else Decimal(0)), ledger.money_scale))
)
cash = eligible * number(action.cash_per_unit) * (-1 if exercise else 1)
cash = decimal(fixed(cash, ledger.money_scale))
if ledger.cash_balance(action.currency) + cash < 0:
raise ValidationError("insufficient cash for explicitly elected rights subscription")
target_cost = moved - cash if exercise else moved
postings = [
ledger._posting("assets:cash", action.currency, cash),
ledger._posting(
"assets:position_cost",
action.currency,
-removed_cost,
instrument_id=action.instrument_id,
),
ledger._posting(
"assets:position_cost", action.currency, target_cost, instrument_id=action.target_id
),
ledger._posting(
"income:realized_pnl",
action.currency,
removed_cost - target_cost - cash,
instrument_id=action.instrument_id,
),
]
for instrument, delta, step in (
(action.instrument_id, -removed, parent.quantity_step),
(action.target_id, added, target.quantity_step),
):
for account, sign in (("assets:position", 1), ("memo:position_counter", -1)):
postings.append(
ledger._posting(
account,
action.currency,
Decimal(0),
instrument_id=instrument,
quantity_delta=delta * sign,
quantity_scale=step.scale,
)
)
tx = ledger._make_transaction(
event_type=LedgerEventType.CORPORATE_ACTION,
reference_id=action.event_id,
idempotency_key=f"financial-action:{action.event_id}",
event_time=stamp,
postings=tuple(postings),
)
# _make_transaction is a trusted fast path; independently verify exact balance here.
LedgerTransaction.__post_init__(tx)
if any(
p.amount.units or (p.quantity_delta is not None and p.quantity_delta.units)
for p in postings
):
ledger._post(tx)
if removed:
residual, consumed = [], []
remaining = removed
for acquired, quantity in ledger._position_lots.get(action.instrument_id, []):
taken = min(remaining, quantity)
remaining -= taken
if taken:
consumed.append(
(stamp.date() if exercise else acquired, taken * number(action.ratio))
)
if quantity > taken:
residual.append((acquired, quantity - taken))
if remaining:
raise ValidationError("position lots do not cover conversion")
ledger._position_lots[action.instrument_id] = residual
else:
consumed = [(stamp.date(), added)] if added else []
ledger._position_lots.setdefault(action.target_id, []).extend(consumed)
ledger._marks[action.target_id] = (number(action.target_mark), stamp, action.event_id)
if distribution:
ledger._marks[action.instrument_id] = (number(action.parent_mark), stamp, action.event_id)
elif not ledger._positions.get(action.instrument_id):
ledger._marks.pop(action.instrument_id, None)
ledger._event_time = stamp
Loading
Loading