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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ outputs/**
*.parquet
*.csv
!data/fixtures/a_share_instrument_catalog_v1.csv
!src/a_share_multifactor/data/a_share_instrument_catalog_v1.csv
_stock_list.json
.idea/
.vscode/
Expand Down
29 changes: 29 additions & 0 deletions docs/failure-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Research execution failure repair

The adapter retains complete continuous/account inputs on failures, uses typed
input errors and passes one remaining deadline through solve and integer repair.
Legacy allocation dictionaries are not silently upgraded to protocol v2.

`_replay(..., share_rules=...)` resolves rights from the existing ledger's actual
record-date snapshots. It does not maintain a second account. UTC normalization,
credit timing, confirmed account identity and sellable dates are validated.
Native snapshots use the resolved event's independent reference price. Original
unverified events stop exposed accounts; unheld assets do not invent entitlements.

Strict and sensitivity runs must have different identities and separate output
directories. Later announcements may corroborate the audit but cannot supply
earlier strategy information. Unidentified fractional rights remain unresolved
in strict runs; rounding assumptions are explicit in sensitivity branches.

Restore a checkpoint before the action to apply corrected evidence. Keep the
previous event stream, ledger and outputs. Retrying the same action does not
credit shares twice. Freeze accounts and default models until their separate
promotion gates pass.

## Ordinary-install fixture catalog

The default versioned instrument catalog is included inside the wheel. Its bytes
must match the retained research fixture at `data/fixtures/a_share_instrument_catalog_v1.csv`.
Explicit caller-supplied catalogs keep their existing behavior. This only fixes
resource lookup for installed packages; it does not change instrument facts or
the historical study inputs.
15 changes: 9 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ dependencies = [
"mini-racer==0.14.1",
"pyarrow>=14.0",
"matplotlib>=3.8",
"quant-data-kit[akshare] @ git+https://github.com/PureSaber/quant-data-kit.git@ad3440700b226497c40093197f1df4321b49d1e8",
"quant-factors @ git+https://github.com/PureSaber/quant-factors.git@1c798bc737bc92fa5eca85dd0663dc608104f721",
"quant-execution @ git+https://github.com/PureSaber/quant-execution.git@8423726a9d80b6509d2c016a7478fc0f40a29774",
"quant-lab @ git+https://github.com/PureSaber/quant-lab.git@4c48f8a678c8d29a7dc46294c99f0e668cd6c52b",
"quant-portfolio @ git+https://github.com/PureSaber/quant-portfolio.git@1a89b753a8a517b5435fbd978645779e96317dd3",
"quant-risk-monitor @ git+https://github.com/PureSaber/quant-risk-monitor.git@81a158a328af28e8c757f4e623c7a0b14d370df4",
"quant-data-kit[akshare] @ git+https://github.com/PureSaber/quant-data-kit.git@a84c7306a2f3ad6d2f2c90901d6525d091ab49d6",
"quant-factors @ git+https://github.com/PureSaber/quant-factors.git@f4e32e528ab1c46f14b249076e31c4782d52e224",
"quant-execution @ git+https://github.com/PureSaber/quant-execution.git@f31022edf5272b4cda6e7c0ff4d0eefa671a68fa",
"quant-lab @ git+https://github.com/PureSaber/quant-lab.git@4de34b4d620cc61adf74cec009957192ac41c91e",
"quant-portfolio @ git+https://github.com/PureSaber/quant-portfolio.git@c493ee1780325a0e5ade7ac25961cda2722eeaeb",
"quant-risk-monitor @ git+https://github.com/PureSaber/quant-risk-monitor.git@66a6d1e034713a7bece9293a198307d23b1882f0",
]

[project.optional-dependencies]
Expand All @@ -50,6 +50,9 @@ asm-disclosures = "a_share_multifactor.disclosures:main"
[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
a_share_multifactor = ["data/*.csv"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src", "."]
Expand Down
12 changes: 6 additions & 6 deletions requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -188,26 +188,26 @@ pyyaml==6.0.3
# quant-lab
# quant-portfolio
# quant-risk-monitor
quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@ad3440700b226497c40093197f1df4321b49d1e8
quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@a84c7306a2f3ad6d2f2c90901d6525d091ab49d6
# via
# a-share-multifactor (pyproject.toml)
# quant-execution
# quant-factors
# quant-lab
# quant-portfolio
# quant-risk-monitor
quant-execution @ git+https://github.com/PureSaber/quant-execution.git@8423726a9d80b6509d2c016a7478fc0f40a29774
quant-execution @ git+https://github.com/PureSaber/quant-execution.git@f31022edf5272b4cda6e7c0ff4d0eefa671a68fa
# via
# a-share-multifactor (pyproject.toml)
# quant-portfolio
# quant-risk-monitor
quant-factors @ git+https://github.com/PureSaber/quant-factors.git@1c798bc737bc92fa5eca85dd0663dc608104f721
quant-factors @ git+https://github.com/PureSaber/quant-factors.git@f4e32e528ab1c46f14b249076e31c4782d52e224
# via a-share-multifactor (pyproject.toml)
quant-lab @ git+https://github.com/PureSaber/quant-lab.git@4c48f8a678c8d29a7dc46294c99f0e668cd6c52b
quant-lab @ git+https://github.com/PureSaber/quant-lab.git@4de34b4d620cc61adf74cec009957192ac41c91e
# via a-share-multifactor (pyproject.toml)
quant-portfolio @ git+https://github.com/PureSaber/quant-portfolio.git@1a89b753a8a517b5435fbd978645779e96317dd3
quant-portfolio @ git+https://github.com/PureSaber/quant-portfolio.git@c493ee1780325a0e5ade7ac25961cda2722eeaeb
# via a-share-multifactor (pyproject.toml)
quant-risk-monitor @ git+https://github.com/PureSaber/quant-risk-monitor.git@81a158a328af28e8c757f4e623c7a0b14d370df4
quant-risk-monitor @ git+https://github.com/PureSaber/quant-risk-monitor.git@66a6d1e034713a7bece9293a198307d23b1882f0
# via a-share-multifactor (pyproject.toml)
referencing==0.37.0
# via
Expand Down
130 changes: 96 additions & 34 deletions src/a_share_multifactor/allocation_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

from __future__ import annotations

from dataclasses import asdict
from decimal import Decimal
from time import perf_counter

import numpy as np
import pandas as pd
from quant_portfolio.allocation_v2 import allocate_research
from quant_portfolio.executable import repair_integer_target
from quant_portfolio.solver import OptimizationError, constraint_residuals
from quant_portfolio.feasibility import minimum_risk_seed, problem_payload
from quant_portfolio.solver import AllocationInputError, OptimizationError, constraint_residuals

from a_share_multifactor.execution_models import decimal, quote_target_trade

Expand All @@ -30,6 +32,11 @@ def executable_allocation(strategy, plan, snapshot, nav):
)
# Benchmark-only zero positions need no executable quote or tradable mark.
traded_assets = set(plan["scores"].index) | set(current)
missing_marks = traded_assets - set(strategy._closing_prices)
if missing_marks:
raise AllocationInputError(
"known account marks are missing", reason="missing_mark", assets=missing_marks
)
marks = {a: decimal(strategy._closing_prices[a]) for a in traded_assets}
held_weights = pd.Series(
{a: float(Decimal(q) * marks[a] / nav) for a, q in current.items()}, dtype=float
Expand All @@ -38,9 +45,12 @@ def executable_allocation(strategy, plan, snapshot, nav):
lower, upper, buy_limits, sell_limits = {}, {}, {}, {}
lots = {}
fixed_targets = {}
odd_lot_sell_assets = set()
for asset in traded_assets:
spec = gate.instruments[asset]
lots[asset] = int(spec.metadata["lot_size"])
if spec.asset_class.value == "equity" and spec.venue in {"SSE", "SZSE"}:
odd_lot_sell_assets.add(asset)
held = current.get(asset, 0)
acquired = (
gate.ledger.acquired_today(asset, snapshot.event_time.date())
Expand All @@ -59,7 +69,13 @@ def executable_allocation(strategy, plan, snapshot, nav):
# Leave only the known unsellable/odd-lot remainder outside the
# investment universe. Neither the solver nor repair may retain a
# freely tradable excluded holding merely to save turnover costs.
sellable = (sellable // lots[asset]) * lots[asset]
remainder = held % lots[asset] if asset in odd_lot_sell_assets else 0
sellable = max(
(sellable // lots[asset]) * lots[asset],
remainder + ((sellable - remainder) // lots[asset]) * lots[asset]
if sellable >= remainder
else 0,
)
fixed_targets[asset] = held - sellable
sell_limits[asset], buy_limits[asset] = sellable, buyable
lower[asset] = float(Decimal(held - sellable) * marks[asset] / nav)
Expand All @@ -71,6 +87,21 @@ def executable_allocation(strategy, plan, snapshot, nav):
if asset in fixed_targets:
upper[asset] = lower[asset]
costs = strategy.costs
reproduction = {
"current_quantities": current,
"marks": {a: str(v) for a, v in marks.items()},
"lots": lots,
"cash": str(decimal(snapshot.cash_balances["CNY"])),
"nav": str(nav),
"max_buy": buy_limits,
"max_sell": sell_limits,
"fixed_targets": fixed_targets,
"odd_lot_sell_assets": sorted(odd_lot_sell_assets),
"costs": asdict(costs),
"decision_at": snapshot.event_time.isoformat(),
"allocation": dict(plan["allocation"]),
"non_cash_assets": str(gate.ledger.dividend_receivable_balance("CNY")),
}
objective_costs = plan.get("objective_costs", True)
if type(objective_costs) is not bool:
raise ValueError("objective_costs must be an explicit boolean")
Expand Down Expand Up @@ -103,21 +134,31 @@ def executable_allocation(strategy, plan, snapshot, nav):
)
if plan.get(key) is not None
}
result = allocate_research(
plan["scores"],
plan["trailing_returns"],
held_weights,
None,
invested_limit=plan["invested_limit"],
max_weight=plan["max_weight"],
config=plan["allocation"],
decision_at=snapshot.event_time,
lower_bounds=pd.Series(lower),
upper_bounds=pd.Series(upper),
buy_costs=buy_rates if objective_costs else buy_rates * 0,
sell_costs=sell_rates if objective_costs else sell_rates * 0,
**kwargs,
)
try:
remaining = timeout - (perf_counter() - started)
if remaining <= 0:
raise OptimizationError(
"timeout", "account input construction exhausted rebalance deadline"
)
result = allocate_research(
plan["scores"],
plan["trailing_returns"],
held_weights,
None,
invested_limit=plan["invested_limit"],
max_weight=plan["max_weight"],
config=plan["allocation"],
remaining_timeout_seconds=remaining,
decision_at=snapshot.event_time,
lower_bounds=pd.Series(lower),
upper_bounds=pd.Series(upper),
buy_costs=buy_rates if objective_costs else buy_rates * 0,
sell_costs=sell_rates if objective_costs else sell_rates * 0,
**kwargs,
)
except OptimizationError as exc:
exc.diagnostics = {**exc.diagnostics, "account_input": reproduction}
raise
validation = result.validation_context
settings, risk, wb = validation["settings"], validation["risk"], validation["benchmark"]
assets = validation["assets"]
Expand All @@ -140,6 +181,7 @@ def check(w):
factor_bounds=bounds,
risk=risk,
benchmark=wb,
detailed_asset_bounds=True,
)
if costs.max_holdings > 0:
residuals["max_holdings"] = int((vector > 1e-9).sum()) - costs.max_holdings
Expand All @@ -150,32 +192,52 @@ def quote(asset, delta):
gate.instruments[asset], delta, marks[asset], costs, snapshot.event_time
)

def seed(remaining):
point, info = minimum_risk_seed(validation, min(5.0, remaining / 2))
return pd.Series(point, index=assets).reindex(sorted(traded_assets)), info

# Reserve existing buy commitments; they must be cancelled/reconciled before
# a new full-account target can be planned, rather than double-spending cash.
if states["cash_reservations"] or states["position_reservations"]:
raise ValueError("allocation requires outstanding orders to be reconciled first")
raise AllocationInputError(
"allocation requires outstanding orders to be reconciled first",
reason="outstanding_orders",
)
cash = decimal(snapshot.cash_balances["CNY"])
remaining = timeout - (perf_counter() - started)
if remaining <= 0:
raise OptimizationError(
"timeout", "rebalance deadline exceeded before integer repair", result.diagnostics
)
repaired = repair_integer_target(
result.weights.reindex(sorted(traded_assets), fill_value=0),
current=current,
prices=marks,
lots=lots,
cash=cash,
nav=nav,
non_cash_assets=gate.ledger.dividend_receivable_balance("CNY"),
fixed_quantities=fixed_targets,
quote_trade=quote,
constraint_residuals=check,
max_invested=plan["invested_limit"],
max_buy=buy_limits,
max_sell=sell_limits,
timeout_seconds=remaining,
)
try:
repaired = repair_integer_target(
result.weights.reindex(sorted(traded_assets), fill_value=0),
current=current,
prices=marks,
lots=lots,
cash=cash,
nav=nav,
non_cash_assets=gate.ledger.dividend_receivable_balance("CNY"),
fixed_quantities=fixed_targets,
quote_trade=quote,
constraint_residuals=check,
max_invested=plan["invested_limit"],
max_buy=buy_limits,
max_sell=sell_limits,
timeout_seconds=remaining,
feasibility_seed=seed if risk is not None else None,
odd_lot_sell_assets=odd_lot_sell_assets,
)
except OptimizationError as exc:
exc.diagnostics = {
"stage": "integer_repair",
"continuous_weights": result.weights.to_dict(),
"continuous_solver": result.diagnostics,
"problem": problem_payload(validation),
"account_input": reproduction,
**exc.diagnostics,
}
raise
diagnostics = {**result.diagnostics, "execution": repaired.diagnostics}
diagnostics["objective_includes_cost_estimates"] = objective_costs
diagnostics["rebalance_seconds"] = perf_counter() - started
Expand Down
54 changes: 54 additions & 0 deletions src/a_share_multifactor/corporate_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,67 @@

from __future__ import annotations

from dataclasses import replace
from decimal import Decimal

import numpy as np
import pandas as pd
from quant_data_kit import CorporateActionEvent, FixedPoint


def resolve_share_event(event, rule, *, account_id, recorded_snapshots):
"""Resolve an ordinary-holder distribution using this account's record date.

Strict evidence and sensitivity assumptions are carried in the native event;
neither changes instrument precision or invents fractional cash payments.
"""
from quant_data_kit.share_entitlements import ShareEntitlement

record_date = pd.Timestamp(rule["record_date"]).date()
eligible = [
s
for s in recorded_snapshots
if pd.Timestamp(s.event_time).tz_convert("Asia/Shanghai").date() == record_date
]
if not eligible:
raise ValueError("share entitlement requires the actual record-date account snapshot")
snapshot = max(eligible, key=lambda s: s.event_time)
quantity = snapshot.positions.get(event.instrument_id, FixedPoint(0, 0))
ratio = Decimal(str(rule["ordinary_additional_ratio"]))
precision = max(0, -ratio.as_tuple().exponent)
status = rule.get("evidence_status", "unresolved")
confirmed = rule.get("confirmed_quantity")
if confirmed is not None and Decimal(str(confirmed)) != int(confirmed):
raise ValueError("confirmed share quantity must be an exact integer")
reference = rule.get("reference_price")
reference_fp = None
if reference is not None:
value = Decimal(str(reference))
scale = max(0, -value.as_tuple().exponent)
reference_fp = FixedPoint(int(value.scaleb(scale)), scale)
terms = ShareEntitlement(
entitlement_id=rule["entitlement_id"],
account_id=account_id,
record_date=record_date,
eligible_quantity=quantity,
ordinary_additional_ratio=FixedPoint(int(ratio.scaleb(precision)), precision),
known_at=pd.Timestamp(rule["known_at"]).tz_convert("UTC").to_pydatetime(),
credit_at=pd.Timestamp(rule["credit_at"]).tz_convert("UTC").to_pydatetime(),
sellable_date=pd.Timestamp(rule["sellable_date"]).date(),
evidence_source=rule["evidence_source"],
evidence_sha256=rule["evidence_sha256"],
evidence_status=status,
fractional_rule=rule["fractional_rule"],
confirmed_quantity=None if confirmed is None else FixedPoint(int(confirmed), 0),
reference_price=reference_fp,
)
if confirmed is not None and rule.get("confirmed_account_id") != account_id:
raise ValueError("confirmed registry quantity must identify the exact account")
return replace(
event, event_id=terms.entitlement_id + ":" + account_id, ratio=None, share_entitlement=terms
)


def action_events(
actions: pd.DataFrame | None,
raw: pd.DataFrame,
Expand Down
23 changes: 23 additions & 0 deletions src/a_share_multifactor/data/a_share_instrument_catalog_v1.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
symbol,asset_class,product_type,venue,price_scale,price_tick,quantity_step,lot_size,commission_rate,stamp_duty_rate,effective_from,effective_to,available_at
000001,equity,stock,SZSE,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
000002,equity,stock,SZSE,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
000003,equity,stock,SZSE,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
000004,equity,stock,SZSE,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
000005,equity,stock,SZSE,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
600000,equity,stock,SSE,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
600519,equity,stock,SSE,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
300750,equity,stock,SZSE,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
510300,etf,etf,SSE,3,0.001,1,100,0.0003,0,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
159919,etf,etf,SZSE,3,0.001,1,100,0.0003,0,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
A,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
B,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S000,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S001,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S002,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S003,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S004,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S005,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S006,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S007,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S008,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
S009,equity,fixture-stock,TEST,2,0.01,1,100,0.0003,0.0005,2020-01-01T00:00:00Z,2030-12-31T00:00:00Z,2020-01-01T00:00:00Z
Loading
Loading