From bc9e2ee587e7782758f70a6465dd4c74f7059b50 Mon Sep 17 00:00:00 2001 From: PureSaber Date: Thu, 15 Oct 2026 19:02:02 +0800 Subject: [PATCH 1/2] Retain allocation failures and resolve evidenced share rights through native accounts --- docs/failure-repair.md | 21 +++ pyproject.toml | 12 +- requirements.lock | 12 +- .../allocation_execution.py | 130 +++++++++++++----- src/a_share_multifactor/corporate_actions.py | 54 ++++++++ src/a_share_multifactor/run_contract.py | 77 +++++++++-- tests/test_research_execution_guards.py | 55 ++++++++ 7 files changed, 305 insertions(+), 56 deletions(-) create mode 100644 docs/failure-repair.md diff --git a/docs/failure-repair.md b/docs/failure-repair.md new file mode 100644 index 0000000..cde851c --- /dev/null +++ b/docs/failure-repair.md @@ -0,0 +1,21 @@ +# 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. diff --git a/pyproject.toml b/pyproject.toml index c5b310b..55b0734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.lock b/requirements.lock index f53266e..665aeec 100644 --- a/requirements.lock +++ b/requirements.lock @@ -188,7 +188,7 @@ 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 @@ -196,18 +196,18 @@ quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@ad3440700b2 # 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 diff --git a/src/a_share_multifactor/allocation_execution.py b/src/a_share_multifactor/allocation_execution.py index a0e6d80..074a1c0 100644 --- a/src/a_share_multifactor/allocation_execution.py +++ b/src/a_share_multifactor/allocation_execution.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import asdict from decimal import Decimal from time import perf_counter @@ -9,7 +10,8 @@ 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 @@ -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 @@ -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()) @@ -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) @@ -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") @@ -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"] @@ -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 @@ -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 diff --git a/src/a_share_multifactor/corporate_actions.py b/src/a_share_multifactor/corporate_actions.py index e0f947b..d397066 100644 --- a/src/a_share_multifactor/corporate_actions.py +++ b/src/a_share_multifactor/corporate_actions.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace from decimal import Decimal import numpy as np @@ -9,6 +10,59 @@ 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, diff --git a/src/a_share_multifactor/run_contract.py b/src/a_share_multifactor/run_contract.py index e1eccef..83f708e 100644 --- a/src/a_share_multifactor/run_contract.py +++ b/src/a_share_multifactor/run_contract.py @@ -570,7 +570,11 @@ def retry_diagnostics(self) -> tuple[dict[str, Any], ...]: return tuple(self._retry_diagnostics) def _allocation_target(self, day: date, snapshot, nav: Decimal) -> dict[str, int]: - from quant_portfolio import research_allocation_weights, validate_research_allocation + from quant_portfolio import ( + AllocationInputError, + research_allocation_weights, + validate_research_allocation, + ) plan = self.allocation_schedule[day] if "initial_max_turnover" in plan: @@ -596,9 +600,11 @@ def _allocation_target(self, day: date, snapshot, nav: Decimal) -> dict[str, int } missing_current_marks = sorted(set(held) - set(self._closing_prices)) if missing_current_marks: - raise ValueError( + raise AllocationInputError( "Current-NAV allocation is missing a close for held positions: " - f"{missing_current_marks}" + f"{missing_current_marks}", + reason="missing_held_mark", + assets=missing_current_marks, ) current_weights = pd.Series( { @@ -609,7 +615,16 @@ def _allocation_target(self, day: date, snapshot, nav: Decimal) -> dict[str, int ) if scores.empty or plan["invested_limit"] <= 0: if float(current_weights.abs().sum()) > float(settings["max_turnover"]) + 1e-10: - raise ValueError("allocation.max_turnover is infeasible for liquidation") + from quant_portfolio import OptimizationError + + raise OptimizationError( + "infeasible", + "allocation.max_turnover is infeasible for liquidation", + { + "stage": "continuous", + "required_turnover": float(current_weights.abs().sum()), + }, + ) weights = pd.Series(dtype=float) target = {} elif settings.get("protocol_version") == 2 or plan.get("risk_snapshot") is not None: @@ -894,9 +909,9 @@ def _after_close(self, context, event): try: target = self._allocation_target(event.trading_day, snapshot, nav_decimal) - except (OptimizationError, ValueError) as exc: - status = exc.status if isinstance(exc, OptimizationError) else "input_unavailable" - diagnostics = exc.diagnostics if isinstance(exc, OptimizationError) else {} + except OptimizationError as exc: + status = exc.status + diagnostics = exc.serializable_diagnostics() self._deferred_targets = {} self._target_active = False self._allocation_decisions.append( @@ -1030,9 +1045,10 @@ def _after_close(self, context, event): class ReplayInputError(ValueError): """An evidenced event the exact account cannot process; the run is rolled back.""" - def __init__(self, event, reason): + def __init__(self, event, reason, diagnostics=None): self.occurred_at = event.available_at self.instrument_id = event.instrument_id + self.diagnostics = diagnostics or {"stage": "execution_input", "reason": str(reason)} super().__init__(f"{event.instrument_id} at {event.available_at.isoformat()}: {reason}") @@ -1052,17 +1068,25 @@ class _RecordingLedger(ExactAccountLedger): """Capture snapshots from the exact ledger instance used by QExec.""" def __init__( - self, *args: Any, recording_times=None, cache_risk_views=False, **kwargs: Any + self, + *args: Any, + recording_times=None, + cache_risk_views=False, + share_rules=None, + **kwargs: Any, ) -> None: self._recording_times = recording_times self._cache_risk_views = cache_risk_views self._read_views = {} self._view_mutation_depth = 0 + self._share_rules = share_rules or {} + self._resolved_share_events = {} self._recorded_snapshots: dict[datetime, Any] = {} super().__init__(*args, **kwargs) def reset(self, *, opened_at: datetime | None = None) -> None: self._read_views.clear() + self._resolved_share_events.clear() super().reset(opened_at=opened_at) self._read_views.clear() self._recorded_snapshots = {} @@ -1070,15 +1094,18 @@ def reset(self, *, opened_at: datetime | None = None) -> None: def capture_state(self) -> dict[str, object]: state = super().capture_state() state["recorded_snapshots"] = self._recorded_snapshots.copy() + state["resolved_share_events"] = self._resolved_share_events.copy() return state def restore_state(self, state: dict[str, object]) -> None: self._read_views.clear() base_state = dict(state) recorded_snapshots = base_state.pop("recorded_snapshots") + resolved = base_state.pop("resolved_share_events") super().restore_state(base_state) self._read_views.clear() self._recorded_snapshots = recorded_snapshots.copy() + self._resolved_share_events = resolved.copy() def _read_view(self, kind, at, callback): if not self._cache_risk_views or self._view_mutation_depth: @@ -1138,11 +1165,28 @@ def _mutate_event(self, callback, event, **kwargs): return self._mutate_views(callback, event, **kwargs) except (ValueError, DataValidationError) as exc: if isinstance(event, CorporateActionEvent): - raise ReplayInputError(event, str(exc)) from exc + raise ReplayInputError(event, str(exc), getattr(exc, "diagnostics", None)) from exc raise def apply(self, event: Any, **kwargs: Any) -> Any: + if isinstance(event, CorporateActionEvent) and event.event_id in self._share_rules: + from a_share_multifactor.corporate_actions import resolve_share_event + + original = event + try: + event = resolve_share_event( + event, + self._share_rules[event.event_id], + account_id=self.account_id, + recorded_snapshots=self.recorded_snapshots, + ) + except ValueError as exc: + raise ReplayInputError( + original, str(exc), getattr(exc, "diagnostics", None) + ) from exc result = self._mutate_event(super().apply, event, **kwargs) + if isinstance(event, CorporateActionEvent) and event.share_entitlement is not None: + self._resolved_share_events[event.event_id] = event self._record(event.event_time) return result @@ -1232,6 +1276,7 @@ def _replay( snapshot_frequency: str = "event", input_guards: tuple = (), cache_risk_views: bool = False, + share_rules: dict | None = None, ) -> CertifiedReplay: if not account_id.strip() or not strategy_id.strip(): raise ValueError("account_id and strategy_id must be non-empty") @@ -1303,6 +1348,7 @@ def _replay( money_scale=_MONEY_SCALE, recording_times=set(last_daily_event.values()) if snapshot_frequency == "daily" else None, cache_risk_views=cache_risk_views, + share_rules=share_rules, ) broker = DeterministicBroker() from quant_risk_monitor.cross_asset import ( @@ -1392,9 +1438,20 @@ def _replay( snapshot_times = {snapshot.event_time for snapshot in snapshots} mark_by_time: dict[datetime, dict[str, FixedPoint]] = {} current_marks: dict[str, FixedPoint] = {} + resolved_events = { + (e.instrument_id, e.available_at): e for e in ledger._resolved_share_events.values() + } for event in events: + if isinstance(event, CorporateActionEvent): + event = resolved_events.get((event.instrument_id, event.available_at), event) if isinstance(event, BarEvent): current_marks[event.instrument_id] = event.close_price + elif ( + isinstance(event, CorporateActionEvent) + and event.share_entitlement is not None + and event.share_entitlement.reference_price is not None + ): + current_marks[event.instrument_id] = event.share_entitlement.reference_price elif getattr(event, "ratio", None) and event.instrument_id in current_marks: current_marks[event.instrument_id] = _fixed( _decimal(current_marks[event.instrument_id]) / _decimal(event.ratio), _MONEY_SCALE diff --git a/tests/test_research_execution_guards.py b/tests/test_research_execution_guards.py index ab2977d..e32fd14 100644 --- a/tests/test_research_execution_guards.py +++ b/tests/test_research_execution_guards.py @@ -131,6 +131,61 @@ def test_fractional_entitlement_is_annotated_and_not_rounded(tmp_path): assert caught.value.occurred_at == events[0].available_at +@pytest.mark.parametrize("branch, added", [("sensitivity_floor", 200), ("sensitivity_ceil", 201)]) +def test_share_rule_uses_record_date_account_and_can_liquidate_odd_remainder( + tmp_path, branch, added +): + dates, raw, adjusted, actions, config, catalog = distribution_fixture( + tmp_path, ratio="1.400595" + ) + events = action_events(actions, raw, adjusted, dates[0], dates[-1]) + extra = raw.iloc[[-1]].copy() + extra["date"] = dates[-1] + pd.offsets.BDay(1) + raw = pd.concat([raw, extra], ignore_index=True) + rule = { + "entitlement_id": "distribution", + "record_date": str(dates[1].date()), + "ordinary_additional_ratio": "0.400595", + "known_at": dates[0].tz_localize("Asia/Shanghai").isoformat(), + "credit_at": events[0].available_at.isoformat(), + "sellable_date": str(dates[2].date()), + "evidence_source": "synthetic-issuer", + "evidence_sha256": "a" * 64, + "fractional_rule": "largest_fraction", + "evidence_status": branch, + "reference_price": "6.50", + } + schedule = {dates[0].date(): {"600036": 500}, dates[3].date(): {}} + kwargs = { + "catalog_path": catalog, + "corporate_actions": events, + "target_schedule": schedule, + "share_rules": {"distribution": rule}, + "snapshot_frequency": "event", + } + replay = _replay(raw, config, "share-branch", **kwargs) + action_positions = replay.frames["positions"] + action_positions = action_positions[ + pd.to_datetime(action_positions.event_time, utc=True).eq(events[0].available_at) + ] + assert len(action_positions) == 1 + assert Decimal(int(action_positions.iloc[0].mark_price_units)).scaleb( + -int(action_positions.iloc[0].mark_price_scale) + ) == Decimal("6.50") + assert all(q.units == 0 for q in replay.ledger.snapshot().positions.values()) + sold = replay.frames["fills"].query('side == "sell"') + assert int(sold.quantity_units.sum()) == 500 + added + assert replay.ledger.snapshot().cash_balances["CNY"].to_decimal() == ( + Decimal(5500) + Decimal(500 + added) * Decimal("9.2") + ) + repeated = _replay(raw, config, "share-branch", **kwargs) + assert repeated.ledger.snapshot() == replay.ledger.snapshot() + rule["evidence_status"] = "unresolved" + with pytest.raises(ReplayInputError) as caught: + _replay(raw, config, "strict-share", **kwargs) + assert caught.value.diagnostics["reason"] == "entitlement_unresolved" + + def test_initial_budget_retries_but_never_resets_after_publication(monkeypatch): from a_share_multifactor import allocation_execution From 9db58d540764a4240311c80ff1a1f2aa7cf6ab01 Mon Sep 17 00:00:00 2001 From: PureSaber Date: Fri, 16 Oct 2026 15:40:31 +0800 Subject: [PATCH 2/2] Package the default instrument catalog for ordinary installs --- .gitignore | 1 + docs/failure-repair.md | 8 ++++ pyproject.toml | 3 ++ .../data/a_share_instrument_catalog_v1.csv | 23 +++++++++ src/a_share_multifactor/run_contract.py | 4 +- tests/test_packaged_catalog.py | 48 +++++++++++++++++++ 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 src/a_share_multifactor/data/a_share_instrument_catalog_v1.csv create mode 100644 tests/test_packaged_catalog.py diff --git a/.gitignore b/.gitignore index e98a43d..57082d2 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/failure-repair.md b/docs/failure-repair.md index cde851c..bbebb96 100644 --- a/docs/failure-repair.md +++ b/docs/failure-repair.md @@ -19,3 +19,11 @@ 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. diff --git a/pyproject.toml b/pyproject.toml index 55b0734..1971f32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", "."] diff --git a/src/a_share_multifactor/data/a_share_instrument_catalog_v1.csv b/src/a_share_multifactor/data/a_share_instrument_catalog_v1.csv new file mode 100644 index 0000000..d502f1d --- /dev/null +++ b/src/a_share_multifactor/data/a_share_instrument_catalog_v1.csv @@ -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 diff --git a/src/a_share_multifactor/run_contract.py b/src/a_share_multifactor/run_contract.py index 83f708e..6a5ada2 100644 --- a/src/a_share_multifactor/run_contract.py +++ b/src/a_share_multifactor/run_contract.py @@ -48,9 +48,7 @@ from a_share_multifactor.performance import return_statistics from a_share_multifactor.quantile_backtest import BacktestResult, _assign_quantiles -_CATALOG_PATH = ( - Path(__file__).resolve().parents[2] / "data" / "fixtures" / "a_share_instrument_catalog_v1.csv" -) +_CATALOG_PATH = Path(__file__).resolve().parent / "data" / "a_share_instrument_catalog_v1.csv" _STRATEGY_ID = "a-share-multifactor-qexec" _ACCOUNT_ID = "a-share-multifactor-account" _MONEY_SCALE = 8 diff --git a/tests/test_packaged_catalog.py b/tests/test_packaged_catalog.py new file mode 100644 index 0000000..c5e3b9c --- /dev/null +++ b/tests/test_packaged_catalog.py @@ -0,0 +1,48 @@ +"""The ordinary wheel must carry the same explicit catalog as research fixtures.""" + +import subprocess +import sys +from pathlib import Path +from zipfile import ZipFile + +from a_share_multifactor import run_contract + +ROOT = Path(__file__).resolve().parents[1] +RESOURCE = "a_share_multifactor/data/a_share_instrument_catalog_v1.csv" + + +def test_default_catalog_is_packaged(): + catalog = run_contract._CATALOG_PATH + assert catalog.parent.parent == Path(run_contract.__file__).resolve().parent + assert ( + catalog.read_bytes() + == (ROOT / "data/fixtures/a_share_instrument_catalog_v1.csv").read_bytes() + ) + assert {"000001", "510300"}.issubset(set(run_contract.load_fixture_catalog()["symbol"])) + + +def test_wheel_catalog(tmp_path): + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + "--no-index", + "--no-deps", + "--no-build-isolation", + "--wheel-dir", + str(tmp_path), + str(ROOT), + ], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ) + (wheel,) = tmp_path.glob("a_share_multifactor-*.whl") + with ZipFile(wheel) as archive: + assert ( + archive.read(RESOURCE) + == (ROOT / "data/fixtures/a_share_instrument_catalog_v1.csv").read_bytes() + )