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 new file mode 100644 index 0000000..bbebb96 --- /dev/null +++ b/docs/failure-repair.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index c5b310b..1971f32 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] @@ -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/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/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 e1eccef..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 @@ -570,7 +568,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 +598,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 +613,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 +907,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 +1043,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 +1066,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 +1092,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 +1163,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 +1274,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 +1346,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 +1436,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_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() + ) 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