powerquant-cli 0.2.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- powerquant_cli-0.2.6.dist-info/METADATA +62 -0
- powerquant_cli-0.2.6.dist-info/RECORD +355 -0
- powerquant_cli-0.2.6.dist-info/WHEEL +4 -0
- powerquant_cli-0.2.6.dist-info/entry_points.txt +2 -0
- powerquant_cli-0.2.6.dist-info/licenses/LICENSE +36 -0
- powerquant_cli-0.2.6.dist-info/licenses/licenses/LICENSE.vnpy +21 -0
- pqdata/__init__.py +6 -0
- pqdata/client.py +389 -0
- pqdata/download.py +60 -0
- pqdata/exceptions.py +14 -0
- pqdata/schema/__init__.py +1 -0
- pqdata/schema/adjustment_schema.py +67 -0
- pqdata/schema/bar_content_schema.py +24 -0
- pqdata/schema/bar_schema.py +56 -0
- pqdata/schema/corporate_action_schema.py +46 -0
- pqdata/schema/history_schema.py +61 -0
- pqdata/schema/pit_daily_window.py +16 -0
- pqdata/schema/query_schema.py +53 -0
- pqdata/schema/quote_schema.py +73 -0
- pqdata/schema/reference_schema.py +48 -0
- pqdata/schema/reliability_schema.py +152 -0
- quant_cli/__init__.py +0 -0
- quant_cli/api.py +39 -0
- quant_cli/app/__init__.py +0 -0
- quant_cli/app/backtest/__init__.py +0 -0
- quant_cli/app/backtest/cli/__init__.py +0 -0
- quant_cli/app/backtest/cli/local_backtest_cli.py +72 -0
- quant_cli/app/backtest/local_backtest_dependency.py +10 -0
- quant_cli/app/backtest/local_backtest_schema.py +49 -0
- quant_cli/app/backtest/repository/__init__.py +0 -0
- quant_cli/app/backtest/repository/backtest_local_repository.py +67 -0
- quant_cli/app/backtest/service/__init__.py +0 -0
- quant_cli/app/backtest/service/local_backtest_service.py +271 -0
- quant_cli/app/data/__init__.py +0 -0
- quant_cli/app/data/cli/__init__.py +0 -0
- quant_cli/app/data/cli/data_cli.py +78 -0
- quant_cli/app/data/data_dependency.py +35 -0
- quant_cli/app/data/repository/__init__.py +0 -0
- quant_cli/app/data/repository/parquet_repository.py +124 -0
- quant_cli/app/data/schema/__init__.py +0 -0
- quant_cli/app/data/schema/dataset_audit_schema.py +37 -0
- quant_cli/app/data/schema/dataset_request_schema.py +28 -0
- quant_cli/app/data/schema/parquet_file_schema.py +17 -0
- quant_cli/app/data/service/__init__.py +0 -0
- quant_cli/app/data/service/bar_cache_service.py +287 -0
- quant_cli/app/data/service/dataset_audit_service.py +137 -0
- quant_cli/app/data/service/dataset_load_service.py +115 -0
- quant_cli/app/data/service/dataset_prepare_service.py +187 -0
- quant_cli/app/factor/__init__.py +0 -0
- quant_cli/app/factor/cli/__init__.py +0 -0
- quant_cli/app/factor/cli/factor_definition_cli.py +81 -0
- quant_cli/app/factor/cli/local_factor_cli.py +102 -0
- quant_cli/app/factor/cli/local_selection_cli.py +58 -0
- quant_cli/app/factor/interface/__init__.py +0 -0
- quant_cli/app/factor/interface/local_selection_store.py +12 -0
- quant_cli/app/factor/local_factor_dependency.py +38 -0
- quant_cli/app/factor/repository/__init__.py +0 -0
- quant_cli/app/factor/repository/factor_local_repository.py +154 -0
- quant_cli/app/factor/repository/selection_local_repository.py +67 -0
- quant_cli/app/factor/schema/local_selection_schema.py +56 -0
- quant_cli/app/factor/service/__init__.py +0 -0
- quant_cli/app/factor/service/factor_data_prepare_service.py +181 -0
- quant_cli/app/factor/service/local_factor_query_service.py +146 -0
- quant_cli/app/factor/service/local_factor_service.py +120 -0
- quant_cli/app/factor/service/local_selection_service.py +139 -0
- quant_cli/app/job/__init__.py +0 -0
- quant_cli/app/job/cli/__init__.py +0 -0
- quant_cli/app/job/cli/local_job_cli.py +69 -0
- quant_cli/app/job/schema/__init__.py +0 -0
- quant_cli/app/job/schema/local_job_schema.py +11 -0
- quant_cli/app/job/service/__init__.py +0 -0
- quant_cli/app/job/service/local_job_service.py +60 -0
- quant_cli/app/quant_cli.py +49 -0
- quant_cli/app/research/__init__.py +0 -0
- quant_cli/app/research/cli/__init__.py +0 -0
- quant_cli/app/research/cli/local_research_cli.py +93 -0
- quant_cli/app/research/local_research_dependency.py +10 -0
- quant_cli/app/research/repository/__init__.py +0 -0
- quant_cli/app/research/repository/research_local_repository.py +67 -0
- quant_cli/app/research/schema/__init__.py +0 -0
- quant_cli/app/research/schema/local_research_schema.py +26 -0
- quant_cli/app/research/service/__init__.py +0 -0
- quant_cli/app/research/service/local_research_execution_service.py +105 -0
- quant_cli/app/research/service/local_research_service.py +101 -0
- quant_cli/app/strategy/__init__.py +0 -0
- quant_cli/app/strategy/cli/__init__.py +0 -0
- quant_cli/app/strategy/cli/strategy_cli.py +104 -0
- quant_cli/common/__init__.py +0 -0
- quant_cli/common/constants.py +7 -0
- quant_cli/common/database/__init__.py +0 -0
- quant_cli/common/database/duckdb_connection.py +30 -0
- quant_cli/common/http/__init__.py +0 -0
- quant_cli/common/http/definition_client.py +54 -0
- quant_cli/common/utils/__init__.py +0 -0
- quant_cli/common/utils/cli_utils.py +37 -0
- quant_cli/common/utils/platform_utils.py +11 -0
- quant_cli/common/utils/runtime_environment.py +12 -0
- quant_cli/examples/cached_factor_screen.py +165 -0
- quant_cli/examples/factors/ma_close_10.py +10 -0
- quant_cli/examples/factors/max_drawdown_20d.py +18 -0
- quant_cli/examples/factors/momentum_20d.py +13 -0
- quant_cli/examples/factors/multi_intraday_screen.py +16 -0
- quant_cli/examples/factors/risk_adjusted_momentum_20d.py +18 -0
- quant_cli/examples/factors/volume_ratio_5d.py +14 -0
- quant_cli/examples/factors/volume_weighted_close_pressure.py +14 -0
- quant_cli/examples/full_market_screen.py +189 -0
- quant_cli/examples/local_quant_validation.py +196 -0
- quant_cli/examples/published_definition_validation.py +219 -0
- quant_cli/examples/research/factor_pit_coverage.py +87 -0
- quant_cli/examples/research/ipo_first_day.py +348 -0
- quant_cli/examples/selection/README.md +59 -0
- quant_cli/examples/selection/backtest.json +18 -0
- quant_cli/examples/selection/buy_hold.py +7 -0
- quant_cli/examples/selection/compute.json +15 -0
- quant_cli/examples/selection/prepare.json +13 -0
- quant_cli/examples/selection/select.json +44 -0
- quant_cli/examples/strategies/bar_roundtrip.py +12 -0
- quant_cli/examples/strategies/buy_and_hold.py +11 -0
- quant_cli/examples/strategies/contrarian_timing.py +14 -0
- quant_cli/examples/strategies/factor_gate_vwap.py +22 -0
- quant_cli/examples/strategies/ipo_next_day_vwap.py +31 -0
- quant_cli/examples/strategies/momentum_timing.py +14 -0
- quant_cli/examples/strategies/monthly_equal_weight.py +19 -0
- quant_cli/examples/strategies/session_roundtrip.py +16 -0
- quant_cli/examples/strategies/trend_drawdown_guard.py +22 -0
- quant_cli/examples/strategies/weekly_risk_adjusted_rotation.py +61 -0
- quant_cli/examples/unified_snapshot_validation.py +260 -0
- quant_core/__init__.py +0 -0
- quant_core/app/SPEC.md +18 -0
- quant_core/app/__init__.py +0 -0
- quant_core/app/backtest/SPEC.md +65 -0
- quant_core/app/backtest/__init__.py +1 -0
- quant_core/app/backtest/backtest_input_schema.py +20 -0
- quant_core/app/backtest/backtest_run.py +393 -0
- quant_core/app/backtest/data_guard.py +77 -0
- quant_core/app/backtest/historical_clock.py +26 -0
- quant_core/app/backtest/legacy_result_schema.py +34 -0
- quant_core/app/backtest/metrics.py +194 -0
- quant_core/app/backtest/performance_report.py +61 -0
- quant_core/app/backtest/report_schema.py +64 -0
- quant_core/app/backtest/schema.py +477 -0
- quant_core/app/backtest/service/__init__.py +0 -0
- quant_core/app/backtest/service/backtest_execution_service.py +114 -0
- quant_core/app/data/__init__.py +0 -0
- quant_core/app/data/constants.py +284 -0
- quant_core/app/data/domain/__init__.py +0 -0
- quant_core/app/data/domain/trading_quantity_value.py +57 -0
- quant_core/app/data/exceptions.py +95 -0
- quant_core/app/data/interface/__init__.py +0 -0
- quant_core/app/data/interface/adjustment_factor_query.py +21 -0
- quant_core/app/data/interface/market_data_query.py +71 -0
- quant_core/app/data/interface/market_data_verification.py +20 -0
- quant_core/app/data/interface/market_event_source.py +11 -0
- quant_core/app/data/interface/market_revision_query.py +8 -0
- quant_core/app/data/interface/market_runtime_repository.py +44 -0
- quant_core/app/data/interface/market_time_slice_source.py +11 -0
- quant_core/app/data/repository/__init__.py +0 -0
- quant_core/app/data/schema/__init__.py +1 -0
- quant_core/app/data/schema/adjustment_schema.py +67 -0
- quant_core/app/data/schema/corporate_action_schema.py +46 -0
- quant_core/app/data/schema/event_schema.py +506 -0
- quant_core/app/data/schema/exchange_operating_state.py +37 -0
- quant_core/app/data/schema/exchange_state_schema.py +106 -0
- quant_core/app/data/schema/history_schema.py +48 -0
- quant_core/app/data/schema/instrument_identity.py +69 -0
- quant_core/app/data/schema/market_schema.py +128 -0
- quant_core/app/data/schema/pit_daily_window.py +16 -0
- quant_core/app/data/schema/quote_schema.py +73 -0
- quant_core/app/data/schema/reliability_schema.py +152 -0
- quant_core/app/data/schema/snapshot_schema.py +82 -0
- quant_core/app/data/schema/trading_state_schema.py +211 -0
- quant_core/app/data/service/__init__.py +0 -0
- quant_core/app/data/service/calendar_coverage_service.py +63 -0
- quant_core/app/data/service/prepared_data_verification_service.py +40 -0
- quant_core/app/data/service/prepared_market_service.py +189 -0
- quant_core/app/data/service/price_adjustment_service.py +235 -0
- quant_core/app/factor/LICENSE.vnpy +21 -0
- quant_core/app/factor/__init__.py +13 -0
- quant_core/app/factor/builtin/__init__.py +0 -0
- quant_core/app/factor/builtin/alpha101_recipe.py +1095 -0
- quant_core/app/factor/builtin/alpha158_recipe.py +1284 -0
- quant_core/app/factor/builtin/factor_recipe_catalog.py +100 -0
- quant_core/app/factor/computation/__init__.py +0 -0
- quant_core/app/factor/computation/factor_batch_evaluator.py +141 -0
- quant_core/app/factor/computation/factor_expression.py +106 -0
- quant_core/app/factor/computation/factor_source_loader.py +24 -0
- quant_core/app/factor/computation/operators/__init__.py +0 -0
- quant_core/app/factor/computation/operators/arithmetic_operator.py +182 -0
- quant_core/app/factor/computation/operators/cross_section_operator.py +66 -0
- quant_core/app/factor/computation/operators/time_series_operator.py +342 -0
- quant_core/app/factor/computation/panel_evaluator.py +59 -0
- quant_core/app/factor/computation/panel_expression.py +193 -0
- quant_core/app/factor/computation/python_factor_executor.py +87 -0
- quant_core/app/factor/computation/universe_filter.py +25 -0
- quant_core/app/factor/constants.py +114 -0
- quant_core/app/factor/exceptions.py +35 -0
- quant_core/app/factor/factor_dependency.py +65 -0
- quant_core/app/factor/interface/__init__.py +0 -0
- quant_core/app/factor/interface/factor_compute.py +12 -0
- quant_core/app/factor/interface/factor_execution.py +15 -0
- quant_core/app/factor/interface/factor_local_data.py +42 -0
- quant_core/app/factor/interface/factor_panel_compute.py +18 -0
- quant_core/app/factor/interface/factor_panel_input.py +12 -0
- quant_core/app/factor/interface/factor_query_store.py +39 -0
- quant_core/app/factor/interface/public/__init__.py +0 -0
- quant_core/app/factor/interface/public/factor_diagnostics.py +18 -0
- quant_core/app/factor/interface/public/factor_exposure_query.py +12 -0
- quant_core/app/factor/interface/public/factor_local.py +15 -0
- quant_core/app/factor/interface/public/factor_query.py +57 -0
- quant_core/app/factor/interface/public/factor_selection.py +20 -0
- quant_core/app/factor/model/__init__.py +0 -0
- quant_core/app/factor/repository/__init__.py +0 -0
- quant_core/app/factor/schema/__init__.py +1 -0
- quant_core/app/factor/schema/diagnostic_types.py +42 -0
- quant_core/app/factor/schema/factor_artifact_schema.py +36 -0
- quant_core/app/factor/schema/factor_computation_schema.py +102 -0
- quant_core/app/factor/schema/factor_compute_request.py +24 -0
- quant_core/app/factor/schema/factor_contract.py +257 -0
- quant_core/app/factor/schema/factor_input_schema.py +32 -0
- quant_core/app/factor/schema/factor_panel.py +65 -0
- quant_core/app/factor/schema/factor_publication_schema.py +41 -0
- quant_core/app/factor/schema/factor_query_result_schema.py +12 -0
- quant_core/app/factor/schema/factor_result_schema.py +6 -0
- quant_core/app/factor/schema/local_factor_schema.py +42 -0
- quant_core/app/factor/schema/panel_policy.py +13 -0
- quant_core/app/factor/schema/selection_report.py +116 -0
- quant_core/app/factor/schema/selection_types.py +135 -0
- quant_core/app/factor/service/__init__.py +1 -0
- quant_core/app/factor/service/factor_compute_service.py +140 -0
- quant_core/app/factor/service/factor_diagnostic_runner.py +134 -0
- quant_core/app/factor/service/factor_diagnostics_service.py +140 -0
- quant_core/app/factor/service/factor_exposure_service.py +24 -0
- quant_core/app/factor/service/factor_input_service.py +258 -0
- quant_core/app/factor/service/factor_local_execution_service.py +51 -0
- quant_core/app/factor/service/factor_panel_service.py +86 -0
- quant_core/app/factor/service/factor_runtime_service.py +306 -0
- quant_core/app/factor/service/factor_selection_runner.py +88 -0
- quant_core/app/factor/service/published_factor_execution_service.py +160 -0
- quant_core/app/factor/service/selection_service.py +234 -0
- quant_core/app/factor/visibility.py +22 -0
- quant_core/app/opportunity/SPEC.md +45 -0
- quant_core/app/opportunity/__init__.py +1 -0
- quant_core/app/order_execution/SPEC.md +64 -0
- quant_core/app/order_execution/__init__.py +1 -0
- quant_core/app/order_execution/bar_matcher.py +55 -0
- quant_core/app/order_execution/constants.py +142 -0
- quant_core/app/order_execution/domain/__init__.py +45 -0
- quant_core/app/order_execution/domain/execution_batch.py +108 -0
- quant_core/app/order_execution/domain/order_entity.py +197 -0
- quant_core/app/order_execution/domain/order_event.py +87 -0
- quant_core/app/order_execution/domain/order_policy.py +29 -0
- quant_core/app/order_execution/domain/order_value.py +142 -0
- quant_core/app/order_execution/fee_model.py +60 -0
- quant_core/app/order_execution/interface/__init__.py +0 -0
- quant_core/app/order_execution/interface/order_account.py +10 -0
- quant_core/app/order_execution/interface/order_trading_rules.py +10 -0
- quant_core/app/order_execution/interface/public/__init__.py +0 -0
- quant_core/app/order_execution/interface/public/execution_account.py +53 -0
- quant_core/app/order_execution/interface/public/order_events.py +19 -0
- quant_core/app/order_execution/interface/public/order_execution.py +107 -0
- quant_core/app/order_execution/interface/public/order_runtime.py +30 -0
- quant_core/app/order_execution/interface/public/order_submission.py +17 -0
- quant_core/app/order_execution/order_event_bus.py +28 -0
- quant_core/app/order_execution/order_execution_dependency.py +48 -0
- quant_core/app/order_execution/order_submission_service.py +301 -0
- quant_core/app/order_execution/schema/__init__.py +0 -0
- quant_core/app/order_execution/schema/order_request_schema.py +88 -0
- quant_core/app/order_execution/simulation_broker.py +883 -0
- quant_core/app/order_execution/slippage_model.py +40 -0
- quant_core/app/order_execution/tradability_policy.py +174 -0
- quant_core/app/order_execution/validators.py +40 -0
- quant_core/app/portfolio/SPEC.md +69 -0
- quant_core/app/portfolio/__init__.py +0 -0
- quant_core/app/portfolio/domain/__init__.py +32 -0
- quant_core/app/portfolio/domain/entitlement_entity.py +45 -0
- quant_core/app/portfolio/domain/portfolio_entity.py +511 -0
- quant_core/app/portfolio/domain/portfolio_event.py +59 -0
- quant_core/app/portfolio/domain/portfolio_value.py +21 -0
- quant_core/app/portfolio/domain/position_entity.py +165 -0
- quant_core/app/portfolio/domain/reservation_entity.py +69 -0
- quant_core/app/portfolio/interface/__init__.py +0 -0
- quant_core/app/portfolio/interface/public/__init__.py +0 -0
- quant_core/app/portfolio/interface/public/portfolio.py +77 -0
- quant_core/app/portfolio/interface/public/simulation_account.py +16 -0
- quant_core/app/portfolio/ledger_schema.py +130 -0
- quant_core/app/portfolio/order_account_listener.py +36 -0
- quant_core/app/portfolio/portfolio_dependency.py +21 -0
- quant_core/app/portfolio/portfolio_schema.py +204 -0
- quant_core/app/portfolio/simulated_portfolio.py +427 -0
- quant_core/app/research/SPEC.md +57 -0
- quant_core/app/research/__init__.py +0 -0
- quant_core/app/research/adapter/__init__.py +0 -0
- quant_core/app/research/adapter/market_label_query.py +84 -0
- quant_core/app/research/interface/__init__.py +0 -0
- quant_core/app/research/interface/research_dataset.py +47 -0
- quant_core/app/research/interface/research_evaluation.py +19 -0
- quant_core/app/research/schema/__init__.py +0 -0
- quant_core/app/research/schema/comparison_schema.py +12 -0
- quant_core/app/research/schema/dataset_schema.py +112 -0
- quant_core/app/research/schema/research_schema.py +120 -0
- quant_core/app/research/service/__init__.py +0 -0
- quant_core/app/research/service/research_dataset_service.py +80 -0
- quant_core/app/research/service/research_evaluation_service.py +56 -0
- quant_core/app/research/service/research_service.py +171 -0
- quant_core/app/strategy/SPEC.md +215 -0
- quant_core/app/strategy/__init__.py +1 -0
- quant_core/app/strategy/constants.py +87 -0
- quant_core/app/strategy/exceptions.py +32 -0
- quant_core/app/strategy/interface/__init__.py +0 -0
- quant_core/app/strategy/interface/public/__init__.py +0 -0
- quant_core/app/strategy/interface/public/portfolio_construction.py +19 -0
- quant_core/app/strategy/interface/public/strategy.py +30 -0
- quant_core/app/strategy/interface/public/strategy_market_query.py +53 -0
- quant_core/app/strategy/interface/public/strategy_portfolio_reader.py +33 -0
- quant_core/app/strategy/interface/strategy_factor_reader.py +19 -0
- quant_core/app/strategy/interface/strategy_order_reader.py +14 -0
- quant_core/app/strategy/schema/__init__.py +0 -0
- quant_core/app/strategy/schema/portfolio_schema.py +28 -0
- quant_core/app/strategy/schema/portfolio_target.py +70 -0
- quant_core/app/strategy/schema/python_strategy_schema.py +94 -0
- quant_core/app/strategy/schema/screener_schema.py +51 -0
- quant_core/app/strategy/schema/signal_schema.py +33 -0
- quant_core/app/strategy/schema/strategy_publication_schema.py +32 -0
- quant_core/app/strategy/schema/strategy_schema.py +96 -0
- quant_core/app/strategy/service/__init__.py +0 -0
- quant_core/app/strategy/service/factor_top_n_strategy.py +103 -0
- quant_core/app/strategy/service/portfolio_construction_service.py +142 -0
- quant_core/app/strategy/service/python_strategy_service.py +65 -0
- quant_core/app/strategy/service/screener_service.py +77 -0
- quant_core/app/strategy/service/store_factor_reader.py +120 -0
- quant_core/app/strategy/service/strategy_market_view.py +120 -0
- quant_core/app/strategy/service/strategy_runtime_service.py +319 -0
- quant_core/app/strategy/service/strategy_scheduler.py +50 -0
- quant_core/app/strategy/strategies/__init__.py +7 -0
- quant_core/app/strategy/strategies/momentum_volume_breakout.py +157 -0
- quant_core/app/strategy/strategy_context.py +316 -0
- quant_core/app/strategy/strategy_dependency.py +101 -0
- quant_core/app/trading_runtime/SPEC.md +70 -0
- quant_core/app/trading_runtime/__init__.py +1 -0
- quant_core/app/trading_runtime/domain/__init__.py +0 -0
- quant_core/app/trading_runtime/replay/__init__.py +0 -0
- quant_core/app/trading_runtime/replay/daily_state_expand.py +49 -0
- quant_core/app/trading_runtime/replay/exchange_state_resolver.py +90 -0
- quant_core/app/trading_runtime/replay/market_runtime_service.py +573 -0
- quant_core/app/trading_runtime/replay/market_state_resolver.py +150 -0
- quant_core/app/trading_runtime/runtime_ports.py +64 -0
- quant_core/app/trading_runtime/runtime_schema.py +129 -0
- quant_core/app/trading_runtime/runtime_session.py +361 -0
- quant_core/app/trading_runtime/strategy_portfolio_adapter.py +58 -0
- quant_core/app/trading_runtime/trading_rule_book.py +37 -0
- quant_core/app/trading_runtime/trading_rule_query.py +12 -0
- quant_core/common/__init__.py +0 -0
- quant_core/common/exceptions/__init__.py +0 -0
- quant_core/common/exceptions/base.py +89 -0
- quant_core/common/local_run.py +45 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Portfolio 聚合根:拥有账户可变状态和内部实体集合。"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from decimal import ROUND_FLOOR, Decimal
|
|
6
|
+
|
|
7
|
+
from quant_core.app.order_execution.constants import OrderSide
|
|
8
|
+
from quant_core.app.order_execution.domain.order_event import Fill
|
|
9
|
+
from quant_core.app.portfolio.domain.entitlement_entity import DividendEntitlement
|
|
10
|
+
from quant_core.app.portfolio.domain.portfolio_event import (
|
|
11
|
+
ClosedLot,
|
|
12
|
+
CorporateActionApplied,
|
|
13
|
+
FillApplied,
|
|
14
|
+
ReservationAction,
|
|
15
|
+
ReservationChanged,
|
|
16
|
+
)
|
|
17
|
+
from quant_core.app.portfolio.domain.portfolio_value import (
|
|
18
|
+
CorporateActionInstruction,
|
|
19
|
+
CorporateActionPhase,
|
|
20
|
+
)
|
|
21
|
+
from quant_core.app.portfolio.domain.position_entity import Position, PositionLot
|
|
22
|
+
from quant_core.app.portfolio.domain.reservation_entity import OrderReservation
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def finite_decimal(value: object, field: str) -> Decimal:
|
|
26
|
+
"""领域金额边界:禁止 float 和非有限 Decimal。"""
|
|
27
|
+
if isinstance(value, float):
|
|
28
|
+
raise ValueError(f"{field} 必须使用 Decimal,禁止 float(避免二进制浮点误差)")
|
|
29
|
+
if not isinstance(value, (Decimal, int, str)):
|
|
30
|
+
raise ValueError(f"{field} 必须是 Decimal、整数或数字字符串")
|
|
31
|
+
converted = value if isinstance(value, Decimal) else Decimal(value)
|
|
32
|
+
if not converted.is_finite():
|
|
33
|
+
raise ValueError(f"{field} 必须是有限 Decimal,禁止 NaN / Infinity")
|
|
34
|
+
return converted
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Portfolio:
|
|
38
|
+
"""资金、持仓、冻结、结算与公司行动状态的唯一聚合根。"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
account_id: str,
|
|
43
|
+
initial_cash: Decimal,
|
|
44
|
+
initial_market_time: datetime,
|
|
45
|
+
) -> None:
|
|
46
|
+
if (
|
|
47
|
+
not account_id
|
|
48
|
+
or account_id.strip() != account_id
|
|
49
|
+
or any(char.isspace() for char in account_id)
|
|
50
|
+
):
|
|
51
|
+
raise ValueError("account_id 不允许为空或包含空白字符")
|
|
52
|
+
if initial_market_time.tzinfo is None:
|
|
53
|
+
raise ValueError("initial_market_time 必须 timezone-aware")
|
|
54
|
+
cash = finite_decimal(initial_cash, "initial_cash")
|
|
55
|
+
if cash < 0:
|
|
56
|
+
raise ValueError(f"initial_cash 必须 >= 0,实际 {cash}")
|
|
57
|
+
self._account_id = account_id
|
|
58
|
+
self._cash = cash
|
|
59
|
+
self._frozen_cash = Decimal("0")
|
|
60
|
+
self._dividend_receivable = Decimal("0")
|
|
61
|
+
self._positions: dict[str, Position] = {}
|
|
62
|
+
self._realized_pnl = Decimal("0")
|
|
63
|
+
self._applied_fill_ids: set[str] = set()
|
|
64
|
+
self._position_lots: list[PositionLot] = []
|
|
65
|
+
self._order_reservations: dict[str, OrderReservation] = {}
|
|
66
|
+
self._dividend_entitlements: list[DividendEntitlement] = []
|
|
67
|
+
self._state_time = initial_market_time
|
|
68
|
+
self._last_settled_day: date | None = None
|
|
69
|
+
self._corporate_action_entitlements: dict[str, int] = {}
|
|
70
|
+
self._corporate_action_receivables: dict[str, Decimal] = {}
|
|
71
|
+
self._corporate_action_bonuses: dict[str, int] = {}
|
|
72
|
+
self._corporate_action_bonus_lots: dict[str, tuple[str, ...]] = {}
|
|
73
|
+
self._locked_bonus_by_instrument: dict[str, int] = {}
|
|
74
|
+
self._applied_corporate_action_phases: set[tuple[str, str]] = set()
|
|
75
|
+
|
|
76
|
+
def apply_fill(self, fill: Fill) -> FillApplied:
|
|
77
|
+
"""原子应用成交到现金、持仓、批次、冻结与股息税状态。"""
|
|
78
|
+
if fill.fill_id in self._applied_fill_ids:
|
|
79
|
+
raise ValueError(f"重复 Fill 被拒绝:fill_id {fill.fill_id} 已成功应用过一次")
|
|
80
|
+
self._check_event_time(fill.fill_time, "fill_time")
|
|
81
|
+
fee_total = finite_decimal(fill.fees.total, "fees.total")
|
|
82
|
+
gross = finite_decimal(fill.price, "price") * Decimal(fill.quantity)
|
|
83
|
+
reservation = self._order_reservations.get(fill.order_id)
|
|
84
|
+
if reservation is not None:
|
|
85
|
+
reservation.ensure_matches(fill.instrument_id, fill.side)
|
|
86
|
+
reservation_cash_delta = Decimal("0")
|
|
87
|
+
reservation_quantity_delta = 0
|
|
88
|
+
|
|
89
|
+
if fill.side is OrderSide.BUY:
|
|
90
|
+
cost = gross + fee_total
|
|
91
|
+
released_cash = (
|
|
92
|
+
(
|
|
93
|
+
reservation.remaining_cash
|
|
94
|
+
* Decimal(fill.quantity)
|
|
95
|
+
/ Decimal(reservation.remaining_quantity)
|
|
96
|
+
)
|
|
97
|
+
if reservation is not None
|
|
98
|
+
else Decimal(0)
|
|
99
|
+
)
|
|
100
|
+
spendable = self._cash + released_cash
|
|
101
|
+
if cost > spendable:
|
|
102
|
+
raise ValueError(f"可用资金不足以买入:需要 {cost},可用 {spendable}")
|
|
103
|
+
if reservation is None:
|
|
104
|
+
self._cash -= cost
|
|
105
|
+
else:
|
|
106
|
+
reservation.consume(fill.quantity, released_cash)
|
|
107
|
+
self._frozen_cash -= released_cash
|
|
108
|
+
self._cash += released_cash - cost
|
|
109
|
+
reservation_cash_delta = -released_cash
|
|
110
|
+
reservation_quantity_delta = -fill.quantity
|
|
111
|
+
unit_cost = cost / Decimal(fill.quantity)
|
|
112
|
+
self._position_lots.append(
|
|
113
|
+
PositionLot(
|
|
114
|
+
lot_id=f"lot:{fill.fill_id}",
|
|
115
|
+
instrument_id=fill.instrument_id,
|
|
116
|
+
source_id=fill.fill_id,
|
|
117
|
+
acquired_at=fill.fill_time,
|
|
118
|
+
quantity=fill.quantity,
|
|
119
|
+
unit_cost=unit_cost,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
position = self._positions.get(fill.instrument_id)
|
|
123
|
+
if position is None:
|
|
124
|
+
self._positions[fill.instrument_id] = Position(fill.quantity, fill.price, fee_total)
|
|
125
|
+
else:
|
|
126
|
+
position.apply_buy(fill.quantity, fill.price, cost)
|
|
127
|
+
closed_lots: tuple[ClosedLot, ...] = ()
|
|
128
|
+
dividend_tax = Decimal("0")
|
|
129
|
+
else:
|
|
130
|
+
position = self._positions.get(fill.instrument_id)
|
|
131
|
+
if position is None:
|
|
132
|
+
raise ValueError(f"卖出被拒绝:{fill.instrument_id} 无持仓")
|
|
133
|
+
if fill.quantity > position.available_quantity:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"卖出被拒绝:数量 {fill.quantity} 超过可卖 "
|
|
136
|
+
f"{position.available_quantity}(T+1)"
|
|
137
|
+
)
|
|
138
|
+
cash_after = self._cash + gross - fee_total
|
|
139
|
+
if cash_after < 0:
|
|
140
|
+
raise ValueError(f"卖出被拒绝:记账后现金 {cash_after} 为负")
|
|
141
|
+
planned = self._plan_closed_lots(fill, gross, fee_total)
|
|
142
|
+
if reservation is not None and fill.quantity > reservation.remaining_quantity:
|
|
143
|
+
raise ValueError("卖出 Fill 超过订单冻结证券数量")
|
|
144
|
+
dividend_tax, consumptions = self._plan_dividend_tax(planned)
|
|
145
|
+
cash_after -= dividend_tax
|
|
146
|
+
if cash_after < 0:
|
|
147
|
+
raise ValueError(f"卖出及股息税记账后现金 {cash_after} 为负")
|
|
148
|
+
self._cash = cash_after
|
|
149
|
+
if reservation is not None:
|
|
150
|
+
reservation.consume(fill.quantity)
|
|
151
|
+
reservation_quantity_delta = -fill.quantity
|
|
152
|
+
realized = sum((trade.realized_pnl for trade in planned), Decimal("0"))
|
|
153
|
+
position.apply_sell(fill.quantity, fill.price, realized)
|
|
154
|
+
self._realized_pnl += realized
|
|
155
|
+
for trade in planned:
|
|
156
|
+
lot = self._lot_by_id(trade.position_lot_id)
|
|
157
|
+
lot.close(trade.quantity)
|
|
158
|
+
for entitlement, quantity in consumptions:
|
|
159
|
+
entitlement.consume(quantity)
|
|
160
|
+
if position.quantity == 0:
|
|
161
|
+
del self._positions[fill.instrument_id]
|
|
162
|
+
closed_lots = tuple(planned)
|
|
163
|
+
|
|
164
|
+
self._state_time = fill.fill_time
|
|
165
|
+
self._applied_fill_ids.add(fill.fill_id)
|
|
166
|
+
return FillApplied(
|
|
167
|
+
gross=gross,
|
|
168
|
+
closed_lots=closed_lots,
|
|
169
|
+
dividend_tax=dividend_tax,
|
|
170
|
+
reservation_cash_delta=reservation_cash_delta,
|
|
171
|
+
reservation_quantity_delta=reservation_quantity_delta,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def apply_corporate_action(
|
|
175
|
+
self, instruction: CorporateActionInstruction, at: datetime
|
|
176
|
+
) -> CorporateActionApplied | None:
|
|
177
|
+
"""按登记、除权、红股上市和派息阶段推进公司行动状态机。"""
|
|
178
|
+
self._check_event_time(at, "corporate_action_time")
|
|
179
|
+
key = (instruction.action_id, instruction.phase.value)
|
|
180
|
+
if key in self._applied_corporate_action_phases:
|
|
181
|
+
return None
|
|
182
|
+
action_id = instruction.action_id
|
|
183
|
+
instrument_id = instruction.instrument_id
|
|
184
|
+
position = self._positions.get(instrument_id)
|
|
185
|
+
receivable_delta = Decimal("0")
|
|
186
|
+
cash_delta = Decimal("0")
|
|
187
|
+
|
|
188
|
+
if instruction.phase is CorporateActionPhase.RECORD:
|
|
189
|
+
self._corporate_action_entitlements[action_id] = (
|
|
190
|
+
0 if position is None else position.quantity
|
|
191
|
+
)
|
|
192
|
+
for lot in self._open_lots(instrument_id):
|
|
193
|
+
self._dividend_entitlements.append(
|
|
194
|
+
DividendEntitlement(
|
|
195
|
+
action_id,
|
|
196
|
+
lot.lot_id,
|
|
197
|
+
lot.remaining_quantity,
|
|
198
|
+
instruction.cash_per_share,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
if action_id not in self._corporate_action_entitlements:
|
|
203
|
+
raise ValueError(f"公司行动 {action_id} 缺少股权登记阶段,禁止推断权益")
|
|
204
|
+
entitled = self._corporate_action_entitlements[action_id]
|
|
205
|
+
if instruction.phase is CorporateActionPhase.EX_DATE:
|
|
206
|
+
receivable = Decimal(entitled) * instruction.cash_per_share
|
|
207
|
+
bonus_decimal = Decimal(entitled) * instruction.stock_ratio
|
|
208
|
+
bonus = int(bonus_decimal)
|
|
209
|
+
if Decimal(bonus) != bonus_decimal:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"公司行动 {action_id} 产生非整数送转股 {bonus_decimal},MVP 禁止静默取整"
|
|
212
|
+
)
|
|
213
|
+
if bonus and position is None:
|
|
214
|
+
raise ValueError("登记日有权益但除权日持仓缺失,状态矛盾")
|
|
215
|
+
if instruction.cash_per_share:
|
|
216
|
+
self._dividend_receivable += receivable
|
|
217
|
+
self._corporate_action_receivables[action_id] = receivable
|
|
218
|
+
receivable_delta = receivable
|
|
219
|
+
if bonus:
|
|
220
|
+
assert position is not None
|
|
221
|
+
entitlements = [
|
|
222
|
+
item for item in self._dividend_entitlements if item.action_id == action_id
|
|
223
|
+
]
|
|
224
|
+
exact = [
|
|
225
|
+
Decimal(item.remaining_quantity) * instruction.stock_ratio
|
|
226
|
+
for item in entitlements
|
|
227
|
+
]
|
|
228
|
+
allocations = [int(v.to_integral_value(rounding=ROUND_FLOOR)) for v in exact]
|
|
229
|
+
remainder = bonus - sum(allocations)
|
|
230
|
+
order = sorted(
|
|
231
|
+
range(len(entitlements)),
|
|
232
|
+
key=lambda index: (exact[index] - Decimal(allocations[index]), -index),
|
|
233
|
+
reverse=True,
|
|
234
|
+
)
|
|
235
|
+
for index in order[:remainder]:
|
|
236
|
+
allocations[index] += 1
|
|
237
|
+
bonus_lot_ids: list[str] = []
|
|
238
|
+
for entitlement, allocated in zip(entitlements, allocations, strict=True):
|
|
239
|
+
if not allocated:
|
|
240
|
+
continue
|
|
241
|
+
original = self._lot_by_id(entitlement.lot_id)
|
|
242
|
+
adjusted_cost = original.apply_bonus_cost(allocated)
|
|
243
|
+
lot_id = f"lot:{action_id}:bonus:{original.lot_id}"
|
|
244
|
+
self._position_lots.append(
|
|
245
|
+
PositionLot(
|
|
246
|
+
lot_id=lot_id,
|
|
247
|
+
instrument_id=instrument_id,
|
|
248
|
+
source_id=action_id,
|
|
249
|
+
acquired_at=original.acquired_at,
|
|
250
|
+
quantity=allocated,
|
|
251
|
+
unit_cost=adjusted_cost,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
bonus_lot_ids.append(lot_id)
|
|
255
|
+
position.apply_bonus(bonus)
|
|
256
|
+
self._corporate_action_bonus_lots[action_id] = tuple(bonus_lot_ids)
|
|
257
|
+
if instruction.stock_ratio:
|
|
258
|
+
self._corporate_action_bonuses[action_id] = bonus
|
|
259
|
+
self._locked_bonus_by_instrument[instrument_id] = (
|
|
260
|
+
self._locked_bonus_by_instrument.get(instrument_id, 0) + bonus
|
|
261
|
+
)
|
|
262
|
+
elif instruction.phase is CorporateActionPhase.LISTING_DATE:
|
|
263
|
+
bonus = self._corporate_action_bonuses.pop(action_id, None)
|
|
264
|
+
if bonus is None:
|
|
265
|
+
raise ValueError(f"公司行动 {action_id} 缺少除权阶段送转股")
|
|
266
|
+
if bonus:
|
|
267
|
+
if position is None:
|
|
268
|
+
raise ValueError("红股上市日持仓缺失,状态矛盾")
|
|
269
|
+
locked = self._locked_bonus_by_instrument.get(instrument_id, 0)
|
|
270
|
+
if locked < bonus or position.pending_settlement_quantity < bonus:
|
|
271
|
+
raise ValueError("红股锁定数量与持仓待结算数量矛盾")
|
|
272
|
+
position.release_bonus(bonus)
|
|
273
|
+
for lot_id in self._corporate_action_bonus_lots.pop(action_id, ()):
|
|
274
|
+
lot = self._lot_by_id(lot_id)
|
|
275
|
+
lot.release(at.date())
|
|
276
|
+
remaining = locked - bonus
|
|
277
|
+
if remaining:
|
|
278
|
+
self._locked_bonus_by_instrument[instrument_id] = remaining
|
|
279
|
+
else:
|
|
280
|
+
self._locked_bonus_by_instrument.pop(instrument_id, None)
|
|
281
|
+
elif instruction.phase is CorporateActionPhase.PAY_DATE:
|
|
282
|
+
receivable = self._corporate_action_receivables.pop(action_id, None)
|
|
283
|
+
if receivable is None:
|
|
284
|
+
raise ValueError(f"公司行动 {action_id} 缺少除权阶段应收股息")
|
|
285
|
+
self._dividend_receivable -= receivable
|
|
286
|
+
self._cash += receivable
|
|
287
|
+
receivable_delta = -receivable
|
|
288
|
+
cash_delta = receivable
|
|
289
|
+
self._state_time = at
|
|
290
|
+
self._applied_corporate_action_phases.add(key)
|
|
291
|
+
return CorporateActionApplied(
|
|
292
|
+
action_id, instrument_id, instruction.phase.value, receivable_delta, cash_delta
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def mark_to_market(self, prices: Mapping[str, Decimal], at: datetime) -> None:
|
|
296
|
+
"""原子校验估值输入后,只更新当前持仓并推进组合时间。"""
|
|
297
|
+
self._check_event_time(at, "at")
|
|
298
|
+
normalized: dict[str, Decimal] = {}
|
|
299
|
+
for instrument_id, price in prices.items():
|
|
300
|
+
canonical = _canonical_instrument_id(instrument_id)
|
|
301
|
+
checked = finite_decimal(price, f"price[{instrument_id}]")
|
|
302
|
+
if checked <= 0:
|
|
303
|
+
raise ValueError(f"估值价格必须 > 0,实际 {checked}")
|
|
304
|
+
normalized[canonical] = checked
|
|
305
|
+
for canonical, price in normalized.items():
|
|
306
|
+
position = self._positions.get(canonical)
|
|
307
|
+
if position is not None:
|
|
308
|
+
position.mark(price)
|
|
309
|
+
self._state_time = at
|
|
310
|
+
|
|
311
|
+
def settle_trading_day(self, trading_day: date) -> None:
|
|
312
|
+
"""释放普通 T+1 持仓;红股继续锁定到上市阶段。"""
|
|
313
|
+
if self._last_settled_day is not None and trading_day < self._last_settled_day:
|
|
314
|
+
raise ValueError(f"交易日不允许回退:{trading_day} < 已结算 {self._last_settled_day}")
|
|
315
|
+
if self._last_settled_day == trading_day:
|
|
316
|
+
return
|
|
317
|
+
for instrument_id, position in self._positions.items():
|
|
318
|
+
position.settle(self._locked_bonus_by_instrument.get(instrument_id, 0))
|
|
319
|
+
locked_bonus_lot_ids = {
|
|
320
|
+
lot_id for lot_ids in self._corporate_action_bonus_lots.values() for lot_id in lot_ids
|
|
321
|
+
}
|
|
322
|
+
for lot in self._position_lots:
|
|
323
|
+
if (
|
|
324
|
+
lot.remaining_quantity
|
|
325
|
+
and lot.available_quantity < lot.remaining_quantity
|
|
326
|
+
and lot.lot_id not in locked_bonus_lot_ids
|
|
327
|
+
):
|
|
328
|
+
lot.release(trading_day)
|
|
329
|
+
self._last_settled_day = trading_day
|
|
330
|
+
|
|
331
|
+
def available_cash(self) -> Decimal:
|
|
332
|
+
return self._cash
|
|
333
|
+
|
|
334
|
+
def available_quantity(self, instrument_id: str) -> int:
|
|
335
|
+
canonical = _canonical_instrument_id(instrument_id)
|
|
336
|
+
position = self._positions.get(canonical)
|
|
337
|
+
if position is None:
|
|
338
|
+
return 0
|
|
339
|
+
frozen = sum(
|
|
340
|
+
reservation.remaining_quantity
|
|
341
|
+
for reservation in self._order_reservations.values()
|
|
342
|
+
if reservation.instrument_id == canonical and reservation.side is OrderSide.SELL
|
|
343
|
+
)
|
|
344
|
+
return position.available_quantity - frozen
|
|
345
|
+
|
|
346
|
+
def reserved_cash(self, order_id: str) -> Decimal:
|
|
347
|
+
reservation = self._order_reservations.get(order_id)
|
|
348
|
+
return Decimal("0") if reservation is None else reservation.remaining_cash
|
|
349
|
+
|
|
350
|
+
def reserved_quantity(self, order_id: str) -> int:
|
|
351
|
+
reservation = self._order_reservations.get(order_id)
|
|
352
|
+
return 0 if reservation is None else reservation.remaining_quantity
|
|
353
|
+
|
|
354
|
+
def reserve_order_resources(
|
|
355
|
+
self,
|
|
356
|
+
order_id: str,
|
|
357
|
+
instrument_id: str,
|
|
358
|
+
side: OrderSide,
|
|
359
|
+
quantity: int,
|
|
360
|
+
cash_amount: Decimal,
|
|
361
|
+
event_time: datetime,
|
|
362
|
+
) -> ReservationChanged | None:
|
|
363
|
+
"""原子冻结订单资源;失败不改变聚合。"""
|
|
364
|
+
self._check_event_time(event_time, "reservation_time")
|
|
365
|
+
canonical = _canonical_instrument_id(instrument_id)
|
|
366
|
+
amount = finite_decimal(cash_amount, "cash_amount")
|
|
367
|
+
if quantity <= 0 or order_id in self._order_reservations:
|
|
368
|
+
return None
|
|
369
|
+
if side is OrderSide.BUY:
|
|
370
|
+
if amount <= 0 or amount > self._cash:
|
|
371
|
+
return None
|
|
372
|
+
elif side is OrderSide.SELL:
|
|
373
|
+
if amount != 0 or quantity > self.available_quantity(canonical):
|
|
374
|
+
return None
|
|
375
|
+
else:
|
|
376
|
+
raise ValueError("side 必须是 OrderSide")
|
|
377
|
+
reservation = OrderReservation(
|
|
378
|
+
order_id=order_id,
|
|
379
|
+
instrument_id=canonical,
|
|
380
|
+
side=side,
|
|
381
|
+
remaining_quantity=quantity,
|
|
382
|
+
remaining_cash=amount,
|
|
383
|
+
)
|
|
384
|
+
self._order_reservations[order_id] = reservation
|
|
385
|
+
if amount:
|
|
386
|
+
self._cash -= amount
|
|
387
|
+
self._frozen_cash += amount
|
|
388
|
+
return ReservationChanged(
|
|
389
|
+
order_id,
|
|
390
|
+
canonical,
|
|
391
|
+
side,
|
|
392
|
+
ReservationAction.RESERVE,
|
|
393
|
+
amount,
|
|
394
|
+
quantity,
|
|
395
|
+
event_time,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def release_order_resources(
|
|
399
|
+
self, order_id: str, event_time: datetime
|
|
400
|
+
) -> ReservationChanged | None:
|
|
401
|
+
"""释放订单剩余冻结资源;不存在时幂等返回 None。"""
|
|
402
|
+
self._check_event_time(event_time, "reservation_release_time")
|
|
403
|
+
reservation = self._order_reservations.pop(order_id, None)
|
|
404
|
+
if reservation is None:
|
|
405
|
+
return None
|
|
406
|
+
cash = reservation.remaining_cash
|
|
407
|
+
quantity = reservation.remaining_quantity
|
|
408
|
+
if cash:
|
|
409
|
+
self._frozen_cash -= cash
|
|
410
|
+
self._cash += cash
|
|
411
|
+
return ReservationChanged(
|
|
412
|
+
reservation.order_id,
|
|
413
|
+
reservation.instrument_id,
|
|
414
|
+
reservation.side,
|
|
415
|
+
ReservationAction.RELEASE,
|
|
416
|
+
-cash,
|
|
417
|
+
-quantity,
|
|
418
|
+
event_time,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def _open_lots(self, instrument_id: str) -> list[PositionLot]:
|
|
422
|
+
return [
|
|
423
|
+
lot
|
|
424
|
+
for lot in self._position_lots
|
|
425
|
+
if lot.instrument_id == instrument_id and lot.remaining_quantity > 0
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
def _lot_by_id(self, lot_id: str) -> PositionLot:
|
|
429
|
+
return next(lot for lot in self._position_lots if lot.lot_id == lot_id)
|
|
430
|
+
|
|
431
|
+
def _plan_closed_lots(self, fill: Fill, gross: Decimal, fee_total: Decimal) -> list[ClosedLot]:
|
|
432
|
+
remaining = fill.quantity
|
|
433
|
+
planned: list[ClosedLot] = []
|
|
434
|
+
for lot in self._open_lots(fill.instrument_id):
|
|
435
|
+
if remaining == 0:
|
|
436
|
+
break
|
|
437
|
+
quantity = min(remaining, lot.available_quantity)
|
|
438
|
+
if quantity == 0:
|
|
439
|
+
continue
|
|
440
|
+
ratio = Decimal(quantity) / Decimal(fill.quantity)
|
|
441
|
+
proceeds = gross * ratio
|
|
442
|
+
sell_fees = fee_total * ratio
|
|
443
|
+
cost_basis = lot.unit_cost * Decimal(quantity)
|
|
444
|
+
planned.append(
|
|
445
|
+
ClosedLot(
|
|
446
|
+
trade_id=f"trade:{fill.fill_id}:{lot.lot_id}",
|
|
447
|
+
instrument_id=fill.instrument_id,
|
|
448
|
+
position_lot_id=lot.lot_id,
|
|
449
|
+
buy_source_id=lot.source_id,
|
|
450
|
+
sell_fill_id=fill.fill_id,
|
|
451
|
+
quantity=quantity,
|
|
452
|
+
opened_at=lot.acquired_at,
|
|
453
|
+
closed_at=fill.fill_time,
|
|
454
|
+
cost_basis=cost_basis,
|
|
455
|
+
proceeds=proceeds,
|
|
456
|
+
sell_fees=sell_fees,
|
|
457
|
+
realized_pnl=proceeds - sell_fees - cost_basis,
|
|
458
|
+
holding_days=(fill.fill_time.date() - lot.acquired_at.date()).days,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
remaining -= quantity
|
|
462
|
+
if remaining:
|
|
463
|
+
raise ValueError(f"可用 PositionLot 不足:尚缺 {remaining}")
|
|
464
|
+
return planned
|
|
465
|
+
|
|
466
|
+
def _plan_dividend_tax(
|
|
467
|
+
self, closed_lots: list[ClosedLot]
|
|
468
|
+
) -> tuple[Decimal, list[tuple[DividendEntitlement, int]]]:
|
|
469
|
+
tax = Decimal("0")
|
|
470
|
+
consumptions: list[tuple[DividendEntitlement, int]] = []
|
|
471
|
+
planned_by_entitlement: dict[int, int] = {}
|
|
472
|
+
for trade in closed_lots:
|
|
473
|
+
for entitlement in self._dividend_entitlements:
|
|
474
|
+
if entitlement.lot_id != trade.position_lot_id:
|
|
475
|
+
continue
|
|
476
|
+
already_planned = planned_by_entitlement.get(id(entitlement), 0)
|
|
477
|
+
quantity = min(
|
|
478
|
+
entitlement.remaining_quantity - already_planned,
|
|
479
|
+
trade.quantity,
|
|
480
|
+
)
|
|
481
|
+
if quantity == 0:
|
|
482
|
+
continue
|
|
483
|
+
rate = (
|
|
484
|
+
Decimal("0.20")
|
|
485
|
+
if trade.holding_days <= 30
|
|
486
|
+
else Decimal("0.10")
|
|
487
|
+
if trade.holding_days <= 365
|
|
488
|
+
else Decimal("0")
|
|
489
|
+
)
|
|
490
|
+
tax += entitlement.gross_cash_for(quantity) * rate
|
|
491
|
+
planned_by_entitlement[id(entitlement)] = already_planned + quantity
|
|
492
|
+
consumptions.append((entitlement, quantity))
|
|
493
|
+
return tax, consumptions
|
|
494
|
+
|
|
495
|
+
def _check_event_time(self, event_time: datetime, field: str) -> None:
|
|
496
|
+
if event_time.tzinfo is None:
|
|
497
|
+
raise ValueError(f"{field} 必须 timezone-aware")
|
|
498
|
+
if event_time < self._state_time:
|
|
499
|
+
raise ValueError(f"{field} {event_time} 不允许早于当前状态时间 {self._state_time}")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _canonical_instrument_id(value: str) -> str:
|
|
503
|
+
parts = value.split(".")
|
|
504
|
+
if (
|
|
505
|
+
len(parts) != 3
|
|
506
|
+
or any(not part for part in parts)
|
|
507
|
+
or parts[0] != parts[0].upper()
|
|
508
|
+
or parts[1] != parts[1].upper()
|
|
509
|
+
):
|
|
510
|
+
raise ValueError(f"非法 Canonical instrument_id:{value!r}")
|
|
511
|
+
return value
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Portfolio 聚合产生的不可变领域事实。"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
from quant_core.app.order_execution.constants import OrderSide
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReservationAction(str, Enum):
|
|
12
|
+
RESERVE = "reserve"
|
|
13
|
+
RELEASE = "release"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ReservationChanged:
|
|
18
|
+
order_id: str
|
|
19
|
+
instrument_id: str
|
|
20
|
+
side: OrderSide
|
|
21
|
+
action: ReservationAction
|
|
22
|
+
cash_delta: Decimal
|
|
23
|
+
quantity_delta: int
|
|
24
|
+
event_time: datetime
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ClosedLot:
|
|
29
|
+
trade_id: str
|
|
30
|
+
instrument_id: str
|
|
31
|
+
position_lot_id: str
|
|
32
|
+
buy_source_id: str
|
|
33
|
+
sell_fill_id: str
|
|
34
|
+
quantity: int
|
|
35
|
+
opened_at: datetime
|
|
36
|
+
closed_at: datetime
|
|
37
|
+
cost_basis: Decimal
|
|
38
|
+
proceeds: Decimal
|
|
39
|
+
sell_fees: Decimal
|
|
40
|
+
realized_pnl: Decimal
|
|
41
|
+
holding_days: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class FillApplied:
|
|
46
|
+
gross: Decimal
|
|
47
|
+
closed_lots: tuple[ClosedLot, ...] = ()
|
|
48
|
+
dividend_tax: Decimal = Decimal("0")
|
|
49
|
+
reservation_cash_delta: Decimal = Decimal("0")
|
|
50
|
+
reservation_quantity_delta: int = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class CorporateActionApplied:
|
|
55
|
+
action_id: str
|
|
56
|
+
instrument_id: str
|
|
57
|
+
phase: str
|
|
58
|
+
receivable_delta: Decimal = Decimal("0")
|
|
59
|
+
cash_delta: Decimal = Decimal("0")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Portfolio 领域的不可变输入值。"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CorporateActionPhase(str, Enum):
|
|
9
|
+
RECORD = "record"
|
|
10
|
+
EX_DATE = "ex_date"
|
|
11
|
+
LISTING_DATE = "listing_date"
|
|
12
|
+
PAY_DATE = "pay_date"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class CorporateActionInstruction:
|
|
17
|
+
action_id: str
|
|
18
|
+
instrument_id: str
|
|
19
|
+
phase: CorporateActionPhase
|
|
20
|
+
cash_per_share: Decimal
|
|
21
|
+
stock_ratio: Decimal
|