diff --git a/.env.example b/.env.example index 8553b5f..ba55b2c 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,10 @@ RISK_SMTP_SENDER= RISK_SMTP_USE_SSL=true RISK_SMTP_TIMEOUT_SECONDS=30 +# 投顾灰度:开启后仅白名单客户和管理员可访问投顾业务;多个客户 ID 用逗号分隔。 +ADVISOR_ROLLOUT_ENABLED=false +ADVISOR_ROLLOUT_CUSTOMER_IDS= + # 限流:按“用户 + 方法 + 路由”在窗口内计数,超限返回 429 RATE_LIMITED + Retry-After。 # 默认 600/60s(10 QPS)是为本机验收与集成测试留足余量的宽松值;生产按真实容量收紧。 # Redis 不可用时自动降级为放行(限流是保护措施,不能因后端故障拒绝正常请求)。 diff --git a/.gitignore b/.gitignore index c268022..847e1b4 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ pytest-cache-files-*/ # 同理:仓库根的 `.workdir/` 是测试脚本的工作目录。 data/ .workdir/ +.migration-backups/ diff --git a/alembic/versions/20260910_advisor_data_quality_backtest.py b/alembic/versions/20260910_advisor_data_quality_backtest.py new file mode 100644 index 0000000..a86b92b --- /dev/null +++ b/alembic/versions/20260910_advisor_data_quality_backtest.py @@ -0,0 +1,76 @@ +"""add advisory market-data quality and allocation backtest records + +Compatibility proof: this revision creates only the additive +``advisor_product_data_quality_snapshot`` and ``advisor_allocation_backtest_run`` +tables. It does not alter, rename, delete, reuse, or retype a baseline table +or an existing field. +""" + +from alembic import op + +revision = "20260910_adv_quality_backtest" +down_revision = "20260910_adv_quote_resilience" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_product_data_quality_snapshot ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + as_of_date DATE NOT NULL, + observation_count BIGINT UNSIGNED NOT NULL, + expected_trading_days BIGINT UNSIGNED NOT NULL, + price_coverage_pct DECIMAL(7,4) NOT NULL, + turnover_coverage_pct DECIMAL(7,4) NOT NULL, + max_abs_daily_return_pct DECIMAL(10,4) NULL, + status VARCHAR(16) NOT NULL, + reason_codes JSON NOT NULL, + rule_version VARCHAR(16) NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_data_quality_snapshot (product_id, as_of_date), + KEY idx_advisor_product_data_quality_latest (product_id, status, as_of_date), + CONSTRAINT chk_advisor_product_data_quality_status + CHECK (status IN ('accepted', 'rejected')), + CONSTRAINT fk_advisor_product_data_quality_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + op.execute( + """ + CREATE TABLE advisor_allocation_backtest_run ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + backtest_no VARCHAR(36) NOT NULL, + started_on DATE NOT NULL, + ended_on DATE NOT NULL, + profile_risk_level VARCHAR(8) NOT NULL, + return_target_lower_pct DECIMAL(7,4) NOT NULL, + max_drawdown_pct DECIMAL(7,4) NOT NULL, + liquidity_requirement VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + observation_count BIGINT UNSIGNED NOT NULL, + static_total_return_pct DECIMAL(12,4) NULL, + dynamic_total_return_pct DECIMAL(12,4) NULL, + static_max_drawdown_pct DECIMAL(12,4) NULL, + dynamic_max_drawdown_pct DECIMAL(12,4) NULL, + dynamic_rebalance_count BIGINT UNSIGNED NOT NULL, + liquidity_history_coverage_pct DECIMAL(7,4) NOT NULL, + limitations JSON NOT NULL, + strategy_version VARCHAR(32) NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_allocation_backtest_run (backtest_no), + KEY idx_advisor_allocation_backtest_lookup (status, ended_on, created_at), + CONSTRAINT chk_advisor_allocation_backtest_status + CHECK (status IN ('ready', 'partial', 'data_quality_required', 'insufficient_history')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("advisory quality and backtest records must not be dropped automatically") diff --git a/alembic/versions/20260910_advisor_governance_monitor_and_quotes.py b/alembic/versions/20260910_advisor_governance_monitor_and_quotes.py new file mode 100644 index 0000000..d4390bd --- /dev/null +++ b/alembic/versions/20260910_advisor_governance_monitor_and_quotes.py @@ -0,0 +1,105 @@ +"""add product governance monitoring and exchange quote snapshots + +Compatibility proof: this revision creates three additive advisory tables only. +No baseline table or existing baseline field is altered, renamed, deleted, +reused, or retyped. +""" + +from alembic import op + +revision = "20260910_adv_monitor_quotes" +down_revision = "20260910_adv_product_governance" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_product_governance_candidate_snapshot ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + data_kind VARCHAR(16) NOT NULL, + sales_institution VARCHAR(128) NULL, + observed_at DATETIME(6) NOT NULL, + effective_from DATE NOT NULL, + risk_level VARCHAR(8) NULL, + payload JSON NOT NULL, + source_url VARCHAR(1024) NOT NULL, + document_title VARCHAR(256) NOT NULL, + document_published_at DATE NULL, + document_sha256 CHAR(64) NOT NULL, + source VARCHAR(64) NOT NULL, + review_status VARCHAR(16) NOT NULL, + reviewed_by BIGINT UNSIGNED NULL, + reviewed_at DATETIME(6) NULL, + review_comment VARCHAR(1000) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_governance_candidate + (product_id, data_kind, document_sha256), + KEY idx_advisor_product_governance_review + (review_status, data_kind, observed_at DESC, product_id), + CONSTRAINT chk_advisor_product_governance_candidate_kind + CHECK (data_kind IN ('suitability', 'contract')), + CONSTRAINT chk_advisor_product_governance_candidate_risk + CHECK (risk_level IS NULL OR risk_level IN ('R1', 'R2', 'R3', 'R4', 'R5')), + CONSTRAINT chk_advisor_product_governance_candidate_status + CHECK (review_status IN ('pending_review', 'approved', 'rejected')), + CONSTRAINT fk_advisor_product_governance_candidate_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + op.execute( + """ + CREATE TABLE advisor_product_governance_sync_run ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + run_no VARCHAR(36) NOT NULL, + source VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + product_count BIGINT UNSIGNED NOT NULL, + change_count BIGINT UNSIGNED NOT NULL, + error_count BIGINT UNSIGNED NOT NULL, + detail JSON NOT NULL, + started_at DATETIME(6) NOT NULL, + completed_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_governance_sync_run (run_no), + KEY idx_advisor_product_governance_sync_run (status, started_at DESC), + CONSTRAINT chk_advisor_product_governance_sync_status + CHECK (status IN ('running', 'succeeded', 'failed')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + op.execute( + """ + CREATE TABLE advisor_product_market_quote_snapshot ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + observed_at DATETIME(6) NOT NULL, + last_price DECIMAL(18,6) NOT NULL, + previous_close DECIMAL(18,6) NULL, + change_pct DECIMAL(10,4) NULL, + volume DECIMAL(24,4) NULL, + turnover_amount DECIMAL(24,2) NULL, + quote_status VARCHAR(16) NOT NULL, + source VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + KEY idx_advisor_product_market_quote_latest (product_id, observed_at DESC), + CONSTRAINT chk_advisor_product_market_quote_price CHECK (last_price > 0), + CONSTRAINT chk_advisor_product_market_quote_status + CHECK (quote_status IN ('active', 'stale')), + CONSTRAINT fk_advisor_product_market_quote_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("advisory governance and quote records must not be dropped automatically") diff --git a/alembic/versions/20260910_advisor_investment_goal.py b/alembic/versions/20260910_advisor_investment_goal.py new file mode 100644 index 0000000..fc08329 --- /dev/null +++ b/alembic/versions/20260910_advisor_investment_goal.py @@ -0,0 +1,68 @@ +"""add advisory investment goal table + +Compatibility proof: this revision only creates ``advisor_investment_goal``. It does not +alter, rename, delete, or reuse any table or field defined by the immutable baseline. +``client_facing_content`` is referenced as its existing, unchanged content-review resource. +""" + +from alembic import op + +revision = "20260910_advisor_investment_goal" +down_revision = "20260910_drop_review_separation" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_investment_goal ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + goal_no VARCHAR(36) NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(24) NOT NULL, + annualized_return_lower_pct DECIMAL(7,4) NOT NULL, + annualized_return_upper_pct DECIMAL(7,4) NOT NULL, + max_drawdown_pct DECIMAL(7,4) NOT NULL, + liquidity_requirement VARCHAR(32) NOT NULL, + investment_horizon_months INT UNSIGNED NOT NULL, + benchmark_name VARCHAR(128) NOT NULL, + notes TEXT NULL, + source VARCHAR(16) NOT NULL, + goal_book_content_id BIGINT UNSIGNED NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + confirmed_by BIGINT UNSIGNED NULL, + confirmed_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_investment_goal_no (goal_no), + UNIQUE KEY uk_advisor_investment_goal_book (goal_book_content_id), + KEY idx_advisor_investment_goal_customer_status (customer_id, status, created_at), + CONSTRAINT chk_advisor_investment_goal_status + CHECK (status IN ('pending_confirmation', 'confirmed', 'superseded')), + CONSTRAINT chk_advisor_investment_goal_return_range + CHECK (annualized_return_lower_pct <= annualized_return_upper_pct), + CONSTRAINT chk_advisor_investment_goal_return_lower + CHECK (annualized_return_lower_pct BETWEEN 0 AND 100), + CONSTRAINT chk_advisor_investment_goal_return_upper + CHECK (annualized_return_upper_pct BETWEEN 0 AND 100), + CONSTRAINT chk_advisor_investment_goal_drawdown + CHECK (max_drawdown_pct BETWEEN 0 AND 100), + CONSTRAINT chk_advisor_investment_goal_horizon + CHECK (investment_horizon_months BETWEEN 1 AND 600), + CONSTRAINT fk_advisor_investment_goal_customer + FOREIGN KEY (customer_id) REFERENCES sys_user(id), + CONSTRAINT fk_advisor_investment_goal_book + FOREIGN KEY (goal_book_content_id) REFERENCES client_facing_content(id), + CONSTRAINT fk_advisor_investment_goal_creator + FOREIGN KEY (created_by) REFERENCES sys_user(id), + CONSTRAINT fk_advisor_investment_goal_confirmer + FOREIGN KEY (confirmed_by) REFERENCES sys_user(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("投资目标记录不得自动删除,请使用前向兼容迁移") diff --git a/alembic/versions/20260910_advisor_market_quote_resilience.py b/alembic/versions/20260910_advisor_market_quote_resilience.py new file mode 100644 index 0000000..7a43704 --- /dev/null +++ b/alembic/versions/20260910_advisor_market_quote_resilience.py @@ -0,0 +1,91 @@ +"""add resilient market quote source monitoring + +Compatibility proof: this revision creates additive advisory market-data +tables only. It does not alter, rename, remove, reuse, or retype any baseline +table or field. +""" + +from alembic import op + +revision = "20260910_adv_quote_resilience" +down_revision = "20260910_adv_monitor_quotes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_market_quote_source_run ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + sync_run_no VARCHAR(36) NOT NULL, + source VARCHAR(64) NOT NULL, + priority_order TINYINT UNSIGNED NOT NULL, + status VARCHAR(16) NOT NULL, + requested_count BIGINT UNSIGNED NOT NULL, + quote_count BIGINT UNSIGNED NOT NULL, + error_type VARCHAR(128) NULL, + started_at DATETIME(6) NOT NULL, + completed_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + KEY idx_advisor_market_quote_source_run_source (source, created_at DESC), + KEY idx_advisor_market_quote_source_run_sync (sync_run_no, priority_order), + CONSTRAINT chk_advisor_market_quote_source_run_status + CHECK (status IN ('succeeded', 'degraded', 'failed')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + op.execute( + """ + CREATE TABLE advisor_market_quote_source_health ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + source VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + consecutive_failure_count BIGINT UNSIGNED NOT NULL, + last_success_at DATETIME(6) NULL, + last_failure_at DATETIME(6) NULL, + last_error_type VARCHAR(128) NULL, + last_requested_count BIGINT UNSIGNED NOT NULL, + last_quote_count BIGINT UNSIGNED NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_market_quote_source_health_source (source), + KEY idx_advisor_market_quote_source_health_status (status, updated_at DESC), + CONSTRAINT chk_advisor_market_quote_source_health_status + CHECK (status IN ('healthy', 'degraded', 'failed')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + op.execute( + """ + CREATE TABLE advisor_market_quote_alert ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + alert_no VARCHAR(36) NOT NULL, + source VARCHAR(64) NOT NULL, + alert_type VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL, + occurrence_count BIGINT UNSIGNED NOT NULL, + detail JSON NOT NULL, + first_observed_at DATETIME(6) NOT NULL, + last_observed_at DATETIME(6) NOT NULL, + resolved_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_market_quote_alert_no (alert_no), + KEY idx_advisor_market_quote_alert_open + (status, source, alert_type, last_observed_at DESC), + CONSTRAINT chk_advisor_market_quote_alert_status + CHECK (status IN ('open', 'resolved')), + CONSTRAINT chk_advisor_market_quote_alert_severity + CHECK (severity IN ('medium', 'high')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("market-data health records must not be dropped automatically") diff --git a/alembic/versions/20260910_advisor_offsite_fund_reference.py b/alembic/versions/20260910_advisor_offsite_fund_reference.py new file mode 100644 index 0000000..38f434d --- /dev/null +++ b/alembic/versions/20260910_advisor_offsite_fund_reference.py @@ -0,0 +1,47 @@ +"""add offsite fund reference catalogue + +Compatibility proof: this revision only creates the additive +``advisor_offsite_fund_reference`` table. It does not alter, rename, delete, +reuse, or change the definition of any baseline table or field. The table is +reference-only and is intentionally isolated from exchange-traded simulation +tables. +""" + +from alembic import op + +revision = "20260910_adv_offsite_ref" +down_revision = "20260910_adv_product_ref" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_offsite_fund_reference ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + fund_code VARCHAR(32) NOT NULL, + fund_name VARCHAR(128) NOT NULL, + fund_type VARCHAR(64) NOT NULL, + risk_level VARCHAR(8) NOT NULL, + current_nav DECIMAL(18,6) NULL, + current_nav_at DATETIME NULL, + fund_asset_scale_billion DECIMAL(18,4) NULL, + scale_as_of_date DATE NULL, + source VARCHAR(64) NOT NULL, + risk_mapping_version VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_offsite_fund_reference_code (fund_code), + KEY idx_advisor_offsite_fund_reference_risk (risk_level, status), + CONSTRAINT chk_advisor_offsite_fund_reference_risk + CHECK (risk_level IN ('R1', 'R2', 'R3', 'R4', 'R5')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("offsite fund reference catalogue must not be dropped automatically") diff --git a/alembic/versions/20260910_advisor_portfolio_projection_checkpoint.py b/alembic/versions/20260910_advisor_portfolio_projection_checkpoint.py new file mode 100644 index 0000000..267b8c1 --- /dev/null +++ b/alembic/versions/20260910_advisor_portfolio_projection_checkpoint.py @@ -0,0 +1,34 @@ +"""add advisory portfolio graph projection checkpoint + +Compatibility proof: this revision only creates the additive +``advisor_portfolio_projection_checkpoint`` table. It does not alter, rename, +delete, reuse, or change the definition of any baseline table or field. +""" + +from alembic import op + +revision = "20260910_adv_projection_ckpt" +down_revision = "20260910_adv_industry_exp" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_portfolio_projection_checkpoint ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL, + holding_updated_at DATETIME(6) NULL, + exposure_updated_at DATETIME(6) NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_portfolio_projection_customer (customer_id), + KEY idx_advisor_portfolio_projection_updated (updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("portfolio graph projection checkpoints must not be dropped automatically") diff --git a/alembic/versions/20260910_advisor_product_asset_classification.py b/alembic/versions/20260910_advisor_product_asset_classification.py new file mode 100644 index 0000000..b008e08 --- /dev/null +++ b/alembic/versions/20260910_advisor_product_asset_classification.py @@ -0,0 +1,43 @@ +"""add advisory product asset classification snapshots + +Compatibility proof: this revision only creates the additive +``advisor_product_asset_classification`` table. No baseline table or field is +altered, renamed, deleted, reused, or retyped. +""" + +from alembic import op + +revision = "20260910_adv_asset_class" +down_revision = "20260910_adv_metric_snapshot" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_product_asset_classification ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + as_of_date DATE NOT NULL, + asset_class VARCHAR(32) NOT NULL, + source VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_asset_classification (product_id, as_of_date), + KEY idx_advisor_product_asset_classification_current (product_id, status, as_of_date), + CONSTRAINT chk_advisor_product_asset_classification_class + CHECK (asset_class IN ('cash_management_etf', 'bond_etf', 'equity_etf')), + CONSTRAINT chk_advisor_product_asset_classification_status + CHECK (status IN ('active', 'superseded')), + CONSTRAINT fk_advisor_product_asset_classification_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("advisory asset classifications must not be dropped automatically") diff --git a/alembic/versions/20260910_advisor_product_governance_reference.py b/alembic/versions/20260910_advisor_product_governance_reference.py new file mode 100644 index 0000000..3e376fd --- /dev/null +++ b/alembic/versions/20260910_advisor_product_governance_reference.py @@ -0,0 +1,100 @@ +"""add authoritative advisory product governance references + +Compatibility proof: this revision only creates the additive +``advisor_product_suitability_reference`` and +``advisor_product_contract_snapshot`` tables. No baseline table or baseline +field is altered, renamed, deleted, reused, or retyped. +""" + +from alembic import op + +revision = "20260910_adv_product_governance" +down_revision = "20260910_adv_price_history" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_product_suitability_reference ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + sales_institution VARCHAR(128) NOT NULL, + risk_level VARCHAR(8) NOT NULL, + effective_from DATE NOT NULL, + effective_until DATE NULL, + source_url VARCHAR(1024) NOT NULL, + document_title VARCHAR(256) NOT NULL, + document_published_at DATE NULL, + document_sha256 CHAR(64) NOT NULL, + source VARCHAR(64) NOT NULL, + review_status VARCHAR(16) NOT NULL, + verified_by VARCHAR(128) NULL, + verified_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_suitability_reference + (product_id, sales_institution, effective_from), + KEY idx_advisor_product_suitability_lookup + (sales_institution, review_status, effective_from, effective_until, product_id), + CONSTRAINT chk_advisor_product_suitability_risk + CHECK (risk_level IN ('R1', 'R2', 'R3', 'R4', 'R5')), + CONSTRAINT chk_advisor_product_suitability_period + CHECK (effective_until IS NULL OR effective_until >= effective_from), + CONSTRAINT chk_advisor_product_suitability_review + CHECK (review_status IN ('pending_review', 'verified', 'superseded', 'test_only')), + CONSTRAINT fk_advisor_product_suitability_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + op.execute( + """ + CREATE TABLE advisor_product_contract_snapshot ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + effective_from DATE NOT NULL, + effective_until DATE NULL, + fund_type VARCHAR(64) NOT NULL, + investment_scope TEXT NOT NULL, + performance_benchmark VARCHAR(256) NULL, + risk_return_characteristics TEXT NOT NULL, + custodian_name VARCHAR(128) NULL, + management_fee_rate_pct DECIMAL(9,6) NULL, + custodian_fee_rate_pct DECIMAL(9,6) NULL, + inception_date DATE NULL, + source_url VARCHAR(1024) NOT NULL, + document_title VARCHAR(256) NOT NULL, + document_published_at DATE NULL, + document_sha256 CHAR(64) NOT NULL, + source VARCHAR(64) NOT NULL, + review_status VARCHAR(16) NOT NULL, + verified_by VARCHAR(128) NULL, + verified_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_contract_snapshot (product_id, effective_from), + KEY idx_advisor_product_contract_lookup + (product_id, review_status, effective_from, effective_until), + CONSTRAINT chk_advisor_product_contract_period + CHECK (effective_until IS NULL OR effective_until >= effective_from), + CONSTRAINT chk_advisor_product_contract_review + CHECK (review_status IN ('pending_review', 'verified', 'superseded', 'test_only')), + CONSTRAINT chk_advisor_product_contract_management_fee + CHECK (management_fee_rate_pct IS NULL OR management_fee_rate_pct >= 0), + CONSTRAINT chk_advisor_product_contract_custodian_fee + CHECK (custodian_fee_rate_pct IS NULL OR custodian_fee_rate_pct >= 0), + CONSTRAINT fk_advisor_product_contract_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError( + "authoritative product governance references must not be dropped automatically" + ) diff --git a/alembic/versions/20260910_advisor_product_industry_exposure.py b/alembic/versions/20260910_advisor_product_industry_exposure.py new file mode 100644 index 0000000..703c465 --- /dev/null +++ b/alembic/versions/20260910_advisor_product_industry_exposure.py @@ -0,0 +1,47 @@ +"""add advisor product industry exposure reference table + +Compatibility proof: this revision only creates ``advisor_product_industry_exposure``. +It does not alter, rename, delete, or reuse any baseline table or field. The table +references unchanged ``fin_product.id`` and is read by advisory analysis only. +""" + +from alembic import op + +revision = "20260910_adv_industry_exp" +down_revision = "20260910_advisor_investment_goal" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_product_industry_exposure ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + industry_code VARCHAR(32) NOT NULL, + industry_name VARCHAR(128) NOT NULL, + exposure_weight_pct DECIMAL(7,4) NOT NULL, + as_of_date DATE NOT NULL, + source VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_industry_exposure_snapshot + (product_id, industry_code, as_of_date), + KEY idx_advisor_product_industry_exposure_current + (product_id, status, as_of_date), + CONSTRAINT chk_advisor_product_industry_exposure_weight + CHECK (exposure_weight_pct >= 0 AND exposure_weight_pct <= 100), + CONSTRAINT chk_advisor_product_industry_exposure_status + CHECK (status IN ('active', 'superseded')), + CONSTRAINT fk_advisor_product_industry_exposure_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("产品行业暴露参考数据不得自动删除,请使用前向兼容迁移") diff --git a/alembic/versions/20260910_advisor_product_metric_snapshot.py b/alembic/versions/20260910_advisor_product_metric_snapshot.py new file mode 100644 index 0000000..69feaf6 --- /dev/null +++ b/alembic/versions/20260910_advisor_product_metric_snapshot.py @@ -0,0 +1,43 @@ +"""add advisory product metric snapshots + +Compatibility proof: this revision only creates the additive +``advisor_product_metric_snapshot`` table. It does not alter, rename, delete, +reuse, or change the definition of any baseline table or field. +""" + +from alembic import op + +revision = "20260910_adv_metric_snapshot" +down_revision = "20260910_adv_projection_ckpt" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_product_metric_snapshot ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + as_of_date DATE NOT NULL, + trailing_20d_return_pct DECIMAL(10,4) NULL, + trailing_120d_return_pct DECIMAL(10,4) NULL, + annualized_volatility_pct DECIMAL(10,4) NULL, + max_drawdown_pct DECIMAL(10,4) NULL, + average_daily_turnover_amount DECIMAL(24,2) NULL, + observation_count INT UNSIGNED NOT NULL, + source VARCHAR(64) NOT NULL, + calculation_version VARCHAR(16) NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_metric_snapshot (product_id, as_of_date), + KEY idx_advisor_product_metric_latest (product_id, as_of_date), + CONSTRAINT fk_advisor_product_metric_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("advisory metric snapshots must not be dropped automatically") diff --git a/alembic/versions/20260910_advisor_product_price_history.py b/alembic/versions/20260910_advisor_product_price_history.py new file mode 100644 index 0000000..df21471 --- /dev/null +++ b/alembic/versions/20260910_advisor_product_price_history.py @@ -0,0 +1,46 @@ +"""add advisory product price history + +Compatibility proof: this revision only creates the additive +``advisor_product_price_history`` table. No baseline table or field is altered, +renamed, deleted, reused, or retyped. The table is the forward-compatible +history store used alongside immutable baseline market tables. +""" + +from alembic import op + +revision = "20260910_adv_price_history" +down_revision = "20260910_adv_offsite_ref" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_product_price_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + trade_date DATE NOT NULL, + price_kind VARCHAR(16) NOT NULL, + close_price DECIMAL(18,6) NOT NULL, + turnover_amount DECIMAL(24,2) NULL, + source VARCHAR(64) NOT NULL, + source_updated_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_price_history (product_id, trade_date, price_kind), + KEY idx_advisor_product_price_history_read (product_id, trade_date DESC), + CONSTRAINT chk_advisor_product_price_history_kind + CHECK (price_kind IN ('fund_nav', 'market_close')), + CONSTRAINT chk_advisor_product_price_history_close + CHECK (close_price > 0), + CONSTRAINT fk_advisor_product_price_history_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("advisory product price history must not be dropped automatically") diff --git a/alembic/versions/20260910_advisor_product_reference_snapshot.py b/alembic/versions/20260910_advisor_product_reference_snapshot.py new file mode 100644 index 0000000..2a300fd --- /dev/null +++ b/alembic/versions/20260910_advisor_product_reference_snapshot.py @@ -0,0 +1,42 @@ +"""add advisory product reference snapshots + +Compatibility proof: this revision only creates the additive +``advisor_product_reference_snapshot`` table. No baseline table or baseline +field is altered, renamed, deleted, reused, or retyped. +""" + +from alembic import op + +revision = "20260910_adv_product_ref" +down_revision = "20260910_adv_asset_class" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_product_reference_snapshot ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + as_of_date DATE NOT NULL, + fund_type VARCHAR(64) NOT NULL, + fund_asset_scale_billion DECIMAL(18,4) NOT NULL, + source VARCHAR(64) NOT NULL, + risk_mapping_version VARCHAR(32) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_product_reference_snapshot (product_id, as_of_date), + KEY idx_advisor_product_reference_latest (product_id, as_of_date), + CONSTRAINT chk_advisor_product_reference_scale + CHECK (fund_asset_scale_billion >= 0), + CONSTRAINT fk_advisor_product_reference_product + FOREIGN KEY (product_id) REFERENCES fin_product(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("advisory product reference snapshots must not be dropped automatically") diff --git a/alembic/versions/20260911_advisor_goal_conversation.py b/alembic/versions/20260911_advisor_goal_conversation.py new file mode 100644 index 0000000..6444103 --- /dev/null +++ b/alembic/versions/20260911_advisor_goal_conversation.py @@ -0,0 +1,41 @@ +"""add append-only investment-goal conversation extraction records + +Compatibility proof: this revision creates only the additive +``advisor_goal_conversation_extraction`` table. It does not alter, rename, +delete, reuse, or retype any baseline table or existing field. +""" + +from alembic import op + +revision = "20260911_adv_goal_conversation" +down_revision = "20260910_adv_quality_backtest" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE advisor_goal_conversation_extraction ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + message_id BIGINT UNSIGNED NOT NULL, + session_id VARCHAR(64) NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL, + extraction_version VARCHAR(32) NOT NULL, + extracted_fields JSON NOT NULL, + missing_fields JSON NOT NULL, + status VARCHAR(32) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_goal_conversation_message (message_id), + KEY idx_advisor_goal_conversation_session (session_id, customer_id, id), + CONSTRAINT chk_advisor_goal_conversation_status + CHECK (status IN ('partial', 'complete', 'clarification_limit')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + + +def downgrade() -> None: + raise RuntimeError("goal conversation extraction records must not be dropped automatically") diff --git a/alembic/versions/20260911_advisor_profile_tag_governance.py b/alembic/versions/20260911_advisor_profile_tag_governance.py new file mode 100644 index 0000000..b527193 --- /dev/null +++ b/alembic/versions/20260911_advisor_profile_tag_governance.py @@ -0,0 +1,121 @@ +"""add profile-tag provenance, confidence, and drift-review records + +Compatibility proof: this revision creates only the additive +``advisor_profile_drift_review`` and ``advisor_profile_tag`` tables. It does +not alter, rename, delete, reuse, or change any baseline table or field. Its +backfill reads existing current snapshots and writes only new tag records. +""" + +from alembic import op + +revision = "20260911_adv_profile_tags" +down_revision = "20260911_adv_goal_conversation" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS advisor_profile_drift_review ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + drift_no VARCHAR(36) NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL, + source_assessment_id BIGINT UNSIGNED NOT NULL, + candidate_profile_uuid CHAR(36) NOT NULL, + candidate_profile_version BIGINT UNSIGNED NOT NULL, + candidate_snapshot JSON NOT NULL, + candidate_generation_basis JSON NOT NULL, + changed_tags JSON NOT NULL, + status VARCHAR(16) NOT NULL, + reviewer_user_id BIGINT UNSIGNED NULL, + reviewed_at DATETIME(6) NULL, + review_comment VARCHAR(1000) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_profile_drift_no (drift_no), + UNIQUE KEY uk_advisor_profile_drift_candidate_uuid (candidate_profile_uuid), + KEY idx_advisor_profile_drift_queue (status, created_at), + KEY idx_advisor_profile_drift_customer (customer_id, status, id), + CONSTRAINT chk_advisor_profile_drift_status + CHECK (status IN ('pending_review', 'approved', 'rejected')), + CONSTRAINT fk_advisor_profile_drift_customer + FOREIGN KEY (customer_id) REFERENCES sys_user(id), + CONSTRAINT fk_advisor_profile_drift_assessment + FOREIGN KEY (source_assessment_id) REFERENCES fin_risk_assessment(id), + CONSTRAINT fk_advisor_profile_drift_reviewer + FOREIGN KEY (reviewer_user_id) REFERENCES sys_user(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + op.execute( + """ + CREATE TABLE IF NOT EXISTS advisor_profile_tag ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tag_uuid CHAR(36) NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL, + tag_key VARCHAR(64) NOT NULL, + tag_value JSON NOT NULL, + tag_value_hash CHAR(64) NOT NULL, + confidence DECIMAL(5,4) NOT NULL, + source_type VARCHAR(32) NOT NULL, + source_reference VARCHAR(128) NOT NULL, + source_confidence DECIMAL(5,4) NOT NULL, + profile_version BIGINT UNSIGNED NOT NULL, + drift_review_id BIGINT UNSIGNED NULL, + previous_tag_id BIGINT UNSIGNED NULL, + drift_reason VARCHAR(32) NULL, + status VARCHAR(16) NOT NULL, + active_customer_tag VARCHAR(160) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_advisor_profile_tag_uuid (tag_uuid), + UNIQUE KEY uk_advisor_profile_tag_active (active_customer_tag), + KEY idx_advisor_profile_tag_customer (customer_id, tag_key, status, id), + KEY idx_advisor_profile_tag_review (drift_review_id, status, id), + CONSTRAINT chk_advisor_profile_tag_confidence CHECK (confidence BETWEEN 0 AND 1), + CONSTRAINT chk_advisor_profile_tag_source_confidence + CHECK (source_confidence BETWEEN 0 AND 1), + CONSTRAINT chk_advisor_profile_tag_status + CHECK (status IN ('active', 'pending_review', 'superseded', 'rejected')), + CONSTRAINT fk_advisor_profile_tag_customer + FOREIGN KEY (customer_id) REFERENCES sys_user(id), + CONSTRAINT fk_advisor_profile_tag_review + FOREIGN KEY (drift_review_id) REFERENCES advisor_profile_drift_review(id), + CONSTRAINT fk_advisor_profile_tag_previous + FOREIGN KEY (previous_tag_id) REFERENCES advisor_profile_tag(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """ + ) + for tag_key, json_path in ( + ("risk_level", "$.risk_level"), + ("risk_profile", "$.risk_profile"), + ("investment_horizon", "$.investment_horizon"), + ("preferred_asset_classes", "$.preferred_asset_classes"), + ): + op.execute( + f""" + INSERT IGNORE INTO advisor_profile_tag ( + tag_uuid, customer_id, tag_key, tag_value, tag_value_hash, + confidence, source_type, source_reference, source_confidence, + profile_version, drift_review_id, previous_tag_id, drift_reason, + status, active_customer_tag, created_at, updated_at + ) + SELECT + UUID(), p.customer_id, '{tag_key}', JSON_EXTRACT(p.snapshot, '{json_path}'), + SHA2(CAST(JSON_EXTRACT(p.snapshot, '{json_path}') AS CHAR), 256), + 0.7500, 'formal_risk_assessment', + CONCAT('profile_snapshot:', COALESCE(p.profile_uuid, CAST(p.id AS CHAR))), + 0.8000, p.version, NULL, NULL, NULL, + 'active', CONCAT(p.customer_id, CHAR(58), '{tag_key}'), NOW(6), NOW(6) + FROM profile_snapshots p + WHERE p.is_current = 1 + AND JSON_EXTRACT(p.snapshot, '{json_path}') IS NOT NULL + """ + ) + + +def downgrade() -> None: + raise RuntimeError("画像标签证据与漂移复核记录不得自动删除,请使用前向兼容迁移") diff --git a/alembic/versions/20260911_merge_advisor_risk_heads.py b/alembic/versions/20260911_merge_advisor_risk_heads.py new file mode 100644 index 0000000..7cc67f7 --- /dev/null +++ b/alembic/versions/20260911_merge_advisor_risk_heads.py @@ -0,0 +1,20 @@ +"""Merge the advisor and risk migration heads without schema changes. + +The advisor migration chain and the latest qyqy risk-index chain are both +valid descendants of the same platform baseline. This revision only joins +their Alembic graph; it does not create, alter, rename, delete, or reuse any +database table or field. +""" + +revision = "20260911_merge_adv_risk_heads" +down_revision = ("20260911_adv_profile_tags", "20260911_merge_risk_heads") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """No-op merge revision.""" + + +def downgrade() -> None: + """No-op: only the Alembic graph marker is removed.""" diff --git a/app/api/controllers/admin.py b/app/api/controllers/admin.py index 618e2b7..a5235ec 100644 --- a/app/api/controllers/admin.py +++ b/app/api/controllers/admin.py @@ -17,35 +17,84 @@ from app.api.schemas.admin import ( ReviewPayload, RoutingPayload, ) +from app.core.advisor_backtest_contracts import AllocationBacktestQuery from app.core.contracts import RequestContext +from app.core.profile_governance_contracts import ProfileDriftReviewRequest from app.service.admin_service import AdminService +from app.service.allocation_backtest_service import AllocationBacktestService +from app.service.profile_governance_service import ProfileGovernanceService -router = APIRouter(prefix="/api/v1/admin", tags=["platform-admin"], - dependencies=[Depends(enforce_rate_limit)]) +router = APIRouter( + prefix="/api/v1/admin", tags=["platform-admin"], dependencies=[Depends(enforce_rate_limit)] +) + + +@router.post("/advisor/asset-allocation-backtests", status_code=201) +async def run_asset_allocation_backtest( + payload: AllocationBacktestQuery, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await AllocationBacktestService().run(payload, context, key) + + +@router.get("/advisor/profile-tags") +async def list_profile_tags( + customer_id: int = Query(gt=0), + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await ProfileGovernanceService().tags(customer_id, context) + + +@router.get("/advisor/profile-drift-reviews") +async def list_profile_drift_reviews( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await ProfileGovernanceService().pending_reviews(context) + + +@router.post("/advisor/profile-drift-reviews/{review_id}/reviews") +async def review_profile_drift( + payload: ProfileDriftReviewRequest, + review_id: int = Path(gt=0), + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await ProfileGovernanceService().review(review_id, payload, context, key) def register_resource( - resource: str, schema: type[BaseModel], id_name: str, *, scoped: bool = False, - update: bool = True, detail: bool = True, + resource: str, + schema: type[BaseModel], + id_name: str, + *, + scoped: bool = False, + update: bool = True, + detail: bool = True, ) -> None: prefix = f"/config-releases/{{release_id}}/{resource}" if scoped else f"/{resource}" async def create( - payload: BaseModel, response: Response, release_id: int | None = None, + payload: BaseModel, + response: Response, + release_id: int | None = None, context: RequestContext = Depends(build_request_context), # noqa: B008 key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, Any]: - result = await AdminService().mutate(resource, context, payload.model_dump(mode="json"), - key, None, release_id=release_id) + result = await AdminService().mutate( + resource, context, payload.model_dump(mode="json"), key, None, release_id=release_id + ) response.headers["ETag"] = f'"{result["meta"]["etag"]}"' return result create.__annotations__["payload"] = schema - router.add_api_route(prefix, create, methods=["POST"], status_code=201, - operation_id=f"create_{resource}") + router.add_api_route( + prefix, create, methods=["POST"], status_code=201, operation_id=f"create_{resource}" + ) async def list_rows( - release_id: int | None = None, limit: int = Query(default=20, ge=1, le=100), + release_id: int | None = None, + limit: int = Query(default=20, ge=1, le=100), cursor: str | None = Query(default=None), context: RequestContext = Depends(build_request_context), # noqa: B008 ) -> dict[str, Any]: @@ -57,7 +106,8 @@ def register_resource( router.add_api_route(prefix, list_rows, methods=["GET"], operation_id=f"list_{resource}") async def get( - response: Response, row_id: int = Path(alias=id_name, gt=0), + response: Response, + row_id: int = Path(alias=id_name, gt=0), context: RequestContext = Depends(build_request_context), # noqa: B008 ) -> dict[str, Any]: result = await AdminService().query(resource, context, row_id=row_id) @@ -65,43 +115,67 @@ def register_resource( return result if detail: - router.add_api_route(f"{prefix}/{{{id_name}}}", get, methods=["GET"], - operation_id=f"get_{resource}") + router.add_api_route( + f"{prefix}/{{{id_name}}}", get, methods=["GET"], operation_id=f"get_{resource}" + ) async def put( - payload: BaseModel, response: Response, row_id: int = Path(alias=id_name, gt=0), + payload: BaseModel, + response: Response, + row_id: int = Path(alias=id_name, gt=0), release_id: int | None = None, context: RequestContext = Depends(build_request_context), # noqa: B008 key: str | None = Header(default=None, alias="Idempotency-Key"), if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict[str, Any]: - result = await AdminService().mutate(resource, context, payload.model_dump(mode="json"), - key, if_match, row_id=row_id, release_id=release_id) + result = await AdminService().mutate( + resource, + context, + payload.model_dump(mode="json"), + key, + if_match, + row_id=row_id, + release_id=release_id, + ) response.headers["ETag"] = f'"{result["meta"]["etag"]}"' return result put.__annotations__["payload"] = schema if update: - router.add_api_route(f"{prefix}/{{{id_name}}}", put, methods=["PUT"], - operation_id=f"update_{resource}") + router.add_api_route( + f"{prefix}/{{{id_name}}}", put, methods=["PUT"], operation_id=f"update_{resource}" + ) def register_transition(resource: str, id_name: str, action: str) -> None: async def transition( - payload: BaseModel, response: Response, row_id: int = Path(alias=id_name, gt=0), + payload: BaseModel, + response: Response, + row_id: int = Path(alias=id_name, gt=0), context: RequestContext = Depends(build_request_context), # noqa: B008 key: str | None = Header(default=None, alias="Idempotency-Key"), if_match: str | None = Header(default=None, alias="If-Match"), ) -> dict[str, Any]: - result = await AdminService().mutate(resource, context, payload.model_dump(mode="json"), - key, if_match, row_id=row_id, action=action) + result = await AdminService().mutate( + resource, + context, + payload.model_dump(mode="json"), + key, + if_match, + row_id=row_id, + action=action, + ) response.headers["ETag"] = f'"{result["meta"]["etag"]}"' return result transition.__annotations__["payload"] = ReviewPayload if action == "reviews" else EmptyPayload - router.add_api_route(f"/{resource}/{{{id_name}}}/{action}", transition, methods=["POST"], - status_code=201 if action == "rollbacks" else 200, - operation_id=f"{action}_{resource}") + router.add_api_route( + f"/{resource}/{{{id_name}}}/{action}", + transition, + methods=["POST"], + status_code=201 if action == "rollbacks" else 200, + operation_id=f"{action}_{resource}", + ) register_resource("config-releases", ReleasePayload, "release_id", update=False) diff --git a/app/api/controllers/asset_allocation.py b/app/api/controllers/asset_allocation.py new file mode 100644 index 0000000..cae031c --- /dev/null +++ b/app/api/controllers/asset_allocation.py @@ -0,0 +1,25 @@ +"""Analysis-only dynamic asset allocation endpoint.""" + +from fastapi import APIRouter, Depends + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.rate_limit import enforce_rate_limit +from app.api.schemas.asset_allocation import AssetAllocationQuery +from app.core.contracts import RequestContext +from app.service.advisor_rollout_service import enforce_advisor_rollout +from app.service.asset_allocation_service import AssetAllocationService + +router = APIRouter( + prefix="/api/v1/advisor", tags=["advisor-asset-allocation"], + dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)], +) + + +@router.post("/asset-allocation") +async def generate_asset_allocation( + payload: AssetAllocationQuery, + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await AssetAllocationService(enforce_profile_governance=True).generate_for_agent( + payload, context + ) diff --git a/app/api/controllers/investment_goals.py b/app/api/controllers/investment_goals.py new file mode 100644 index 0000000..84bc83f --- /dev/null +++ b/app/api/controllers/investment_goals.py @@ -0,0 +1,84 @@ +"""Investment-goal collection and goal-book review endpoints.""" + +from typing import Any + +from fastapi import APIRouter, Depends, Header, status + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.rate_limit import enforce_rate_limit +from app.api.schemas.investment_goals import ( + InvestmentGoalBookPublish, + InvestmentGoalBookReview, + InvestmentGoalConfirmation, + InvestmentGoalCreate, +) +from app.core.contracts import RequestContext +from app.service.advisor_rollout_service import enforce_advisor_rollout +from app.service.investment_goal_service import InvestmentGoalService + +router = APIRouter( + prefix="/api/v1/advisor", tags=["advisor-investment-goals"], + dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)], +) + + +@router.post("/investment-goals", status_code=status.HTTP_201_CREATED) +async def create_investment_goal( + payload: InvestmentGoalCreate, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await InvestmentGoalService().create(payload, context, key) + + +@router.get("/investment-goals/current") +async def current_own_investment_goal( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await InvestmentGoalService().current(int(context.user_id), context) + + +@router.get("/customers/{customer_id}/investment-goals/current") +async def current_customer_investment_goal( + customer_id: int, + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await InvestmentGoalService().current(customer_id, context) + + +@router.post("/investment-goals/{goal_no}/confirmations") +async def confirm_investment_goal( + goal_no: str, + payload: InvestmentGoalConfirmation, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await InvestmentGoalService().confirm(goal_no, context, key) + + +@router.get("/investment-goals/{goal_no}/goal-book") +async def investment_goal_book( + goal_no: str, + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, Any]: + return await InvestmentGoalService().goal_book(goal_no, context) + + +@router.post("/investment-goals/{goal_no}/goal-book/reviews") +async def review_investment_goal_book( + goal_no: str, + payload: InvestmentGoalBookReview, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await InvestmentGoalService().review_book(goal_no, payload, context, key) + + +@router.post("/investment-goals/{goal_no}/goal-book/publications") +async def publish_investment_goal_book( + goal_no: str, + payload: InvestmentGoalBookPublish, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await InvestmentGoalService().publish_book(goal_no, payload, context, key) diff --git a/app/api/controllers/onboarding.py b/app/api/controllers/onboarding.py new file mode 100644 index 0000000..32ae473 --- /dev/null +++ b/app/api/controllers/onboarding.py @@ -0,0 +1,26 @@ +"""Mandatory customer onboarding endpoints.""" + +from fastapi import APIRouter, Depends, Header, status + +from app.api.dependencies.auth import build_request_context +from app.api.schemas.risk_questionnaire import RiskQuestionnaireSubmission +from app.core.contracts import RequestContext +from app.service.risk_questionnaire_service import RiskQuestionnaireService + +router = APIRouter(prefix="/api/v1/onboarding", tags=["customer-onboarding"]) + + +@router.get("/risk-questionnaire") +async def get_risk_questionnaire( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await RiskQuestionnaireService().questionnaire(context) + + +@router.post("/risk-questionnaire/submissions", status_code=status.HTTP_201_CREATED) +async def submit_risk_questionnaire( + payload: RiskQuestionnaireSubmission, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await RiskQuestionnaireService().submit(payload, context, key) diff --git a/app/api/controllers/portfolio_analysis.py b/app/api/controllers/portfolio_analysis.py new file mode 100644 index 0000000..6b2c578 --- /dev/null +++ b/app/api/controllers/portfolio_analysis.py @@ -0,0 +1,25 @@ +"""Read-only portfolio-analysis endpoint.""" + +from fastapi import APIRouter, Depends + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.rate_limit import enforce_rate_limit +from app.api.schemas.portfolio_analysis import PortfolioAnalysisQuery +from app.core.contracts import RequestContext +from app.service.advisor_rollout_service import enforce_advisor_rollout +from app.service.portfolio_analysis_service import PortfolioAnalysisService + +router = APIRouter( + prefix="/api/v1/advisor", tags=["advisor-portfolio-analysis"], + dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)], +) + + +@router.post("/portfolio-analysis") +async def analyze_portfolio( + payload: PortfolioAnalysisQuery, + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await PortfolioAnalysisService(enforce_profile_governance=True).analyze_for_agent( + payload, context + ) diff --git a/app/api/controllers/recommendations.py b/app/api/controllers/recommendations.py new file mode 100644 index 0000000..90d76e8 --- /dev/null +++ b/app/api/controllers/recommendations.py @@ -0,0 +1,74 @@ +"""Recommendation generation and reviewed publication endpoints.""" + +from typing import Any + +from fastapi import APIRouter, Depends, Header, Path + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.rate_limit import enforce_rate_limit +from app.core.contracts import RequestContext +from app.core.product_recommendation_contracts import ProductRecommendationQuery +from app.service.advisor_rollout_service import enforce_advisor_rollout +from app.service.product_recommendation_service import ProductRecommendationService + +advisor_router = APIRouter( + prefix="/api/v1/advisor", + tags=["advisor-recommendations"], + dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)], +) +admin_router = APIRouter( + prefix="/api/v1/admin", + tags=["platform-admin"], + dependencies=[Depends(enforce_rate_limit)], +) + + +@advisor_router.post("/recommendations") +async def generate_recommendation( + payload: ProductRecommendationQuery, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await ProductRecommendationService(enforce_profile_governance=True).generate( + payload, context, key + ) + + +@advisor_router.get("/recommendations/published") +async def published_recommendations( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + return await ProductRecommendationService().published(context) + + +@admin_router.post( + "/advisor/recommendations/{content_id}/reviews", + dependencies=[Depends(enforce_advisor_rollout)], +) +async def review_recommendation( + payload: dict[str, Any], + content_id: int = Path(gt=0), + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + decision = payload.get("decision") + if decision not in {"approved", "rejected"}: + from app.core.errors import ValidationAgentError + + raise ValidationAgentError("decision 必须为 approved 或 rejected") + comment = payload.get("comment", "") + if not isinstance(comment, str): + raise ValueError("comment must be a string") + return await ProductRecommendationService().review(content_id, decision, comment, context, key) + + +@admin_router.post( + "/advisor/recommendations/{content_id}/publications", + dependencies=[Depends(enforce_advisor_rollout)], +) +async def publish_recommendation( + content_id: int = Path(gt=0), + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + return await ProductRecommendationService().publish(content_id, context, key) diff --git a/app/api/dependencies/auth.py b/app/api/dependencies/auth.py index 8595847..0547a99 100644 --- a/app/api/dependencies/auth.py +++ b/app/api/dependencies/auth.py @@ -54,4 +54,11 @@ async def build_request_context( # 令牌非法、账号停用、角色读取失败一律按 401 处理,形态完全一致。 raise unauthorized() from exc request.state.request_context = context + if not request.url.path.startswith("/api/v1/onboarding/"): + from app.service.risk_questionnaire_service import RiskQuestionnaireService + + if await RiskQuestionnaireService().is_required(context): + from app.core.errors import OnboardingRequiredError + + raise OnboardingRequiredError("请先完成开户风险测评问卷") return context diff --git a/app/api/schemas/asset_allocation.py b/app/api/schemas/asset_allocation.py new file mode 100644 index 0000000..3d2bb7f --- /dev/null +++ b/app/api/schemas/asset_allocation.py @@ -0,0 +1,3 @@ +from app.core.advisor_allocation_contracts import AssetAllocationQuery + +__all__ = ["AssetAllocationQuery"] diff --git a/app/api/schemas/investment_goals.py b/app/api/schemas/investment_goals.py new file mode 100644 index 0000000..7cd64b1 --- /dev/null +++ b/app/api/schemas/investment_goals.py @@ -0,0 +1,15 @@ +"""HTTP DTOs for investment-goal collection and goal-book review.""" + +from app.core.investment_goal_contracts import ( + InvestmentGoalBookPublish, + InvestmentGoalBookReview, + InvestmentGoalConfirmation, + InvestmentGoalCreate, +) + +__all__ = [ + "InvestmentGoalBookPublish", + "InvestmentGoalBookReview", + "InvestmentGoalConfirmation", + "InvestmentGoalCreate", +] diff --git a/app/api/schemas/portfolio_analysis.py b/app/api/schemas/portfolio_analysis.py new file mode 100644 index 0000000..c526088 --- /dev/null +++ b/app/api/schemas/portfolio_analysis.py @@ -0,0 +1,3 @@ +from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery + +__all__ = ["PortfolioAnalysisQuery"] diff --git a/app/api/schemas/risk_questionnaire.py b/app/api/schemas/risk_questionnaire.py new file mode 100644 index 0000000..45472c7 --- /dev/null +++ b/app/api/schemas/risk_questionnaire.py @@ -0,0 +1,5 @@ +"""HTTP DTOs for customer opening risk questionnaires.""" + +from app.core.risk_questionnaire_contracts import RiskQuestionnaireSubmission + +__all__ = ["RiskQuestionnaireSubmission"] diff --git a/app/core/advisor_allocation_contracts.py b/app/core/advisor_allocation_contracts.py new file mode 100644 index 0000000..56625e2 --- /dev/null +++ b/app/core/advisor_allocation_contracts.py @@ -0,0 +1,7 @@ +"""Contracts for analysis-only asset allocation.""" + +from pydantic import BaseModel, ConfigDict + + +class AssetAllocationQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/app/core/advisor_backtest_contracts.py b/app/core/advisor_backtest_contracts.py new file mode 100644 index 0000000..6286a46 --- /dev/null +++ b/app/core/advisor_backtest_contracts.py @@ -0,0 +1,29 @@ +"""Contracts for administrator-run advisory allocation backtests.""" + +from datetime import date +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class AllocationBacktestQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + started_on: date + ended_on: date + profile_risk_level: int = Field(ge=1, le=5) + return_target_lower_pct: Decimal = Field(ge=Decimal("0"), le=Decimal("100")) + max_drawdown_pct: Decimal = Field(ge=Decimal("0"), le=Decimal("100")) + liquidity_requirement: str + + @model_validator(mode="after") + def validate_range(self) -> "AllocationBacktestQuery": + if self.ended_on <= self.started_on: + raise ValueError("ended_on must be after started_on") + if (self.ended_on - self.started_on).days > 1825: + raise ValueError("backtest range must not exceed five years") + if self.liquidity_requirement not in { + "daily", "within_7_days", "within_30_days", "over_30_days" + }: + raise ValueError("unknown liquidity requirement") + return self diff --git a/app/core/config.py b/app/core/config.py index f7d8e1b..f052f08 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -113,6 +113,10 @@ class Settings(BaseSettings): risk_scan_run_immediately: bool = False risk_scan_retry_limit: int = Field(default=2, ge=0, le=5) risk_scan_poll_seconds: float = Field(default=30, gt=0) + # 投顾灰度默认关闭,只有显式开启并配置客户白名单后才限制客户流量。 + # 管理员角色始终可进入,便于审核、发布和故障处置。 + advisor_rollout_enabled: bool = False + advisor_rollout_customer_ids: str = "" model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") diff --git a/app/core/errors.py b/app/core/errors.py index 1a5ac77..67064cc 100644 --- a/app/core/errors.py +++ b/app/core/errors.py @@ -53,6 +53,10 @@ class ForbiddenAgentError(AgentError): status_code = 403 +class OnboardingRequiredError(ForbiddenAgentError): + """开户前置条件未满足,沿用文档登记的权限错误码。""" + + class AgentPermissionDeniedError(ForbiddenAgentError): """`AGENT_PERMISSION_DENIED` 的语义化别名,供新代码使用。""" diff --git a/app/core/investment_goal_contracts.py b/app/core/investment_goal_contracts.py new file mode 100644 index 0000000..1fe1dc4 --- /dev/null +++ b/app/core/investment_goal_contracts.py @@ -0,0 +1,75 @@ +"""Contracts for structured investment-goal collection.""" + +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +LiquidityRequirement = Literal[ + "daily", + "within_7_days", + "within_30_days", + "over_30_days", +] + + +class InvestmentGoalCreate(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + customer_id: int | None = Field(default=None, gt=0) + annualized_return_lower_pct: Decimal = Field(ge=0, le=100, max_digits=7, decimal_places=4) + annualized_return_upper_pct: Decimal = Field(ge=0, le=100, max_digits=7, decimal_places=4) + max_drawdown_pct: Decimal = Field(ge=0, le=100, max_digits=7, decimal_places=4) + liquidity_requirement: LiquidityRequirement + investment_horizon_months: int = Field(ge=1, le=600) + benchmark_name: str = Field(min_length=1, max_length=128) + notes: str | None = Field(default=None, max_length=1000) + + @field_validator("benchmark_name", "notes") + @classmethod + def normalize_text(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("text must not be blank") + if any(ord(char) < 32 and char not in "\n\t" for char in normalized): + raise ValueError("text must not contain control characters") + return normalized + + @model_validator(mode="after") + def validate_return_range(self) -> "InvestmentGoalCreate": + if self.annualized_return_lower_pct > self.annualized_return_upper_pct: + raise ValueError("annualized return lower bound must not exceed upper bound") + return self + + +class InvestmentGoalConfirmation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + confirmed: Literal[True] + + +class InvestmentGoalBookReview(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + decision: Literal["approved", "rejected"] + comment: str | None = Field(default=None, max_length=1000) + + @field_validator("comment") + @classmethod + def normalize_comment(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + +class InvestmentGoalBookPublish(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + publish: Literal[True] + + +class InvestmentGoalQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/app/core/portfolio_analysis_contracts.py b/app/core/portfolio_analysis_contracts.py new file mode 100644 index 0000000..a132538 --- /dev/null +++ b/app/core/portfolio_analysis_contracts.py @@ -0,0 +1,7 @@ +"""Read-only contract for the governed portfolio-analysis tool.""" + +from pydantic import BaseModel, ConfigDict + + +class PortfolioAnalysisQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/app/core/product_comparison_contracts.py b/app/core/product_comparison_contracts.py new file mode 100644 index 0000000..8837e4f --- /dev/null +++ b/app/core/product_comparison_contracts.py @@ -0,0 +1,13 @@ +"""Read-only contract for comparing exchange-traded products.""" + +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints + +ProductCode = Annotated[str, StringConstraints(pattern=r"^\d{6}$")] + + +class ProductComparisonQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + product_codes: Annotated[tuple[ProductCode, ...], Field(min_length=2, max_length=4)] diff --git a/app/core/product_recommendation_contracts.py b/app/core/product_recommendation_contracts.py new file mode 100644 index 0000000..8299db1 --- /dev/null +++ b/app/core/product_recommendation_contracts.py @@ -0,0 +1,9 @@ +"""Contracts for exchange-traded fund recommendations.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class ProductRecommendationQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + limit: int = Field(default=3, ge=1, le=3) diff --git a/app/core/profile_governance_contracts.py b/app/core/profile_governance_contracts.py new file mode 100644 index 0000000..ab1f47a --- /dev/null +++ b/app/core/profile_governance_contracts.py @@ -0,0 +1,12 @@ +"""Contracts for internal customer-profile drift review.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class ProfileDriftReviewRequest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + decision: Literal["approved", "rejected"] + comment: str = Field(default="", max_length=1000) diff --git a/app/core/risk_questionnaire_contracts.py b/app/core/risk_questionnaire_contracts.py new file mode 100644 index 0000000..abc15a5 --- /dev/null +++ b/app/core/risk_questionnaire_contracts.py @@ -0,0 +1,170 @@ +"""Customer-facing opening questionnaire contract without scoring details.""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, model_validator + +QUESTIONNAIRE_VERSION = "opening-risk-v1" + +QUESTIONS: tuple[dict[str, Any], ...] = ( + { + "id": "q1", + "title": "您的职业是?", + "options": [ + "经济和金融行业人员", + "党政群机关、社会组织工作人员", + "事业单位工作人员", + "法律、社会和宗教人员", + "教科文卫专业人员", + "社会生产服务和生活服务人员", + "企业单位工作人员", + "生产制造及有关人员", + "工程技术人员", + "农林牧渔水利生产及辅助人员", + "军人", + "学生", + "无业", + "个体户", + "自由职业者", + "不便分类的其他从业人员", + ], + }, + {"id": "q2", "title": "您的学历是?", "options": ["硕士及以上", "本科", "大专", "高中及以下"]}, + { + "id": "q3", + "title": "您的主要收入来源是?", + "options": [ + "工资、劳务报酬", + "生产经营所得", + "出租、出售房地产等非金融性资产收入", + "利息、股息、转让等金融资产收入", + "无固定收入", + ], + }, + { + "id": "q4", + "title": "您的家庭可支配年收入为(折合人民币):", + "options": [ + "500 万元以上", + "100-500 万元", + "50-100 万元", + "50 万元以下", + ], + }, + { + "id": "q5", + "title": ("在您每年的家庭可支配收入中,可用于金融投资(储蓄存款除外)的比例为:"), + "options": [ + "大于 50%", + "25% 至 50%", + "10% 至 25%", + "小于 10%", + ], + }, + { + "id": "q6", + "title": "您是否有尚未清偿的债务:", + "options": [ + "没有", + "有,占资产比例不超过 10%", + "有,占资产比例不超过 20%", + "有,占资产比例不超过 50%", + "有,占资产比例超过 50%", + ], + }, + { + "id": "q7", + "title": "您的投资经历:", + "options": [ + "大部分投资于股票、外汇、期货等", + "大部分投资于基金、股票、信托产品等", + "资产均衡分布于存款、国债、银行理财产品、股票、基金等", + "大部分投资于存款、国债等", + "没有证券期货投资知识或金融投资经验", + ], + }, + { + "id": "q8", + "title": "您有多少年投资基金、股票、信托或金融衍生产品等风险投资品的经验:", + "options": [ + "5 年以上", + "2 至 5 年", + "少于 2 年", + "没有经验", + ], + }, + { + "id": "q9", + "title": "您计划投资多久:", + "options": [ + "5 年以上", + "3 至 5 年", + "1 至 3 年", + "1 年以下", + ], + }, + { + "id": "q10", + "title": "您打算重点投资于哪些种类的产品:", + "options": [ + "A、债券、货币市场基金、债券基金等固定收益类投资品种", + "B、股票、混合型基金、股票型基金等权益类投资品种及 A 选项中的投资品种", + "C、期货、期权等金融衍生品及 B 选项中的投资品种", + "D、C 选项中的投资品种及其他产品或者服务", + ], + }, + { + "id": "q11", + "title": "以下哪项描述最符合您的投资态度:", + "options": [ + "希望取得高收益,能够接受长期波动,包括本金亏损", + "寻求资金的较高收益,愿意为此承担有限的本金亏损", + "稳健投资,愿意接受短期亏损,但无法接受可能出现的大幅波动", + "保守投资,能够容忍少量本金亏损,愿意承担一定幅度的收益波动", + "厌恶风险,不愿承受任何投资损失,追求稳定回报", + ], + }, + { + "id": "q12", + "title": ( + "产品 A 预期收益 10%、损失较小;产品 B 预期收益 30%、亏损较大。您会怎么支配投资:" + ), + "options": [ + "全部投资于 B", + "大部分投资于 B", + "大部分投资于 A", + "全部投资于 A", + ], + }, + { + "id": "q13", + "title": "您认为自己能承受的最大投资损失是多少:", + "options": [ + "可超过 50%", + "30%-50%", + "10%-30%", + "10% 以内", + ], + }, +) +QUESTION_OPTION_COUNTS = {str(item["id"]): len(item["options"]) for item in QUESTIONS} +DECLARATION = ( + "本人保证提供的信息真实、准确、完整,知晓并确认信息发生重要变化、可能影响投资者分类的," + "应当及时更新并告知平台。" +) + + +class RiskQuestionnaireSubmission(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + answers: dict[str, int] + declaration_accepted: Literal[True] + + @model_validator(mode="after") + def validate_answers(self) -> "RiskQuestionnaireSubmission": + if set(self.answers) != set(QUESTION_OPTION_COUNTS): + raise ValueError("answers must include every questionnaire question exactly once") + for question_id, option_id in self.answers.items(): + if not 1 <= option_id <= QUESTION_OPTION_COUNTS[question_id]: + raise ValueError(f"invalid option for {question_id}") + return self diff --git a/app/infrastructure/neo4j_graph_driver.py b/app/infrastructure/neo4j_graph_driver.py new file mode 100644 index 0000000..dfe03f5 --- /dev/null +++ b/app/infrastructure/neo4j_graph_driver.py @@ -0,0 +1,27 @@ +"""Bounded Neo4j adapter used only behind RelationshipService.""" + +import asyncio +from collections.abc import Sequence +from typing import Any + +from neo4j import AsyncGraphDatabase + +from app.core.config import Settings + + +class Neo4jGraphDriver: + def __init__(self, settings: Settings, *, timeout_seconds: float = 2.0) -> None: + self.settings = settings + self.timeout_seconds = timeout_seconds + self._driver: Any = None + + async def execute_query(self, query: str, **parameters: Any) -> Sequence[Any]: + if self._driver is None: + self._driver = AsyncGraphDatabase.driver( + self.settings.neo4j_uri, + auth=(self.settings.neo4j_username, self.settings.neo4j_password), + ) + async with asyncio.timeout(self.timeout_seconds): + async with self._driver.session(database=self.settings.neo4j_database) as session: + result = await session.run(query, parameters) + return [record.data() async for record in result] diff --git a/app/main.py b/app/main.py index bc793eb..f7fb398 100644 --- a/app/main.py +++ b/app/main.py @@ -5,14 +5,24 @@ from fastapi.responses import JSONResponse from app.api.controllers.admin import router as admin_router from app.api.controllers.agent_runs import router as agent_runs_router +from app.api.controllers.asset_allocation import router as asset_allocation_router from app.api.controllers.conversations import router as conversations_router from app.api.controllers.health import router as health_router +from app.api.controllers.investment_goals import router as investment_goals_router from app.api.controllers.knowledge import router as knowledge_router from app.api.controllers.knowledge_management import router as knowledge_management_router from app.api.controllers.offsite_fund import operation_router as offsite_operation_router from app.api.controllers.offsite_fund import router as offsite_fund_router +from app.api.controllers.onboarding import router as onboarding_router +from app.api.controllers.portfolio_analysis import router as portfolio_analysis_router from app.api.controllers.promotion_material import router as promotion_material_router from app.api.controllers.public_platform import router as public_platform_router +from app.api.controllers.recommendations import ( + admin_router as recommendation_admin_router, +) +from app.api.controllers.recommendations import ( + advisor_router as recommendation_advisor_router, +) from app.api.controllers.risk import router as risk_router from app.api.middleware import attach_trace_id from app.core.config import get_settings @@ -110,6 +120,12 @@ def create_app() -> FastAPI: application.include_router(knowledge_router) application.include_router(knowledge_management_router) application.include_router(health_router) + application.include_router(onboarding_router) + application.include_router(investment_goals_router) + application.include_router(portfolio_analysis_router) + application.include_router(asset_allocation_router) + application.include_router(recommendation_advisor_router) + application.include_router(recommendation_admin_router) application.include_router(admin_router) return application diff --git a/app/model/advisor_product.py b/app/model/advisor_product.py new file mode 100644 index 0000000..9d225e2 --- /dev/null +++ b/app/model/advisor_product.py @@ -0,0 +1,284 @@ +"""ORM mappings for additive advisory product reference tables. + +The immutable ``fin_product`` catalogue remains mapped by ``FundProduct`` in +``app.model.fund``. This module only maps the new advisory evidence tables. +""" + +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import JSON, BigInteger, Date, DateTime, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base + + +class AdvisorProductIndustryExposure(Base): + __tablename__ = "advisor_product_industry_exposure" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + industry_code: Mapped[str] = mapped_column(String(32), nullable=False) + industry_name: Mapped[str] = mapped_column(String(128), nullable=False) + exposure_weight_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + as_of_date: Mapped[date] = mapped_column(Date, nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductReferenceSnapshot(Base): + __tablename__ = "advisor_product_reference_snapshot" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + as_of_date: Mapped[date] = mapped_column(Date, nullable=False) + fund_type: Mapped[str] = mapped_column(String(64), nullable=False) + fund_asset_scale_billion: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + risk_mapping_version: Mapped[str] = mapped_column(String(32), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductPriceHistory(Base): + __tablename__ = "advisor_product_price_history" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + trade_date: Mapped[date] = mapped_column(Date, nullable=False) + price_kind: Mapped[str] = mapped_column(String(16), nullable=False) + close_price: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) + turnover_amount: Mapped[Decimal | None] = mapped_column(Numeric(24, 2)) + source: Mapped[str] = mapped_column(String(64), nullable=False) + source_updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductMetricSnapshot(Base): + __tablename__ = "advisor_product_metric_snapshot" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + as_of_date: Mapped[date] = mapped_column(Date, nullable=False) + trailing_20d_return_pct: Mapped[Decimal | None] = mapped_column(Numeric(10, 4)) + trailing_120d_return_pct: Mapped[Decimal | None] = mapped_column(Numeric(10, 4)) + annualized_volatility_pct: Mapped[Decimal | None] = mapped_column(Numeric(10, 4)) + max_drawdown_pct: Mapped[Decimal | None] = mapped_column(Numeric(10, 4)) + average_daily_turnover_amount: Mapped[Decimal | None] = mapped_column(Numeric(24, 2)) + observation_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + calculation_version: Mapped[str] = mapped_column(String(16), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductAssetClassification(Base): + __tablename__ = "advisor_product_asset_classification" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + as_of_date: Mapped[date] = mapped_column(Date, nullable=False) + asset_class: Mapped[str] = mapped_column(String(32), nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductDataQualitySnapshot(Base): + __tablename__ = "advisor_product_data_quality_snapshot" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + as_of_date: Mapped[date] = mapped_column(Date, nullable=False) + observation_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + expected_trading_days: Mapped[int] = mapped_column(BigInteger, nullable=False) + price_coverage_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + turnover_coverage_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + max_abs_daily_return_pct: Mapped[Decimal | None] = mapped_column(Numeric(10, 4)) + status: Mapped[str] = mapped_column(String(16), nullable=False) + reason_codes: Mapped[list[str]] = mapped_column(JSON, nullable=False) + rule_version: Mapped[str] = mapped_column(String(16), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorAllocationBacktestRun(Base): + __tablename__ = "advisor_allocation_backtest_run" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + backtest_no: Mapped[str] = mapped_column(String(36), unique=True, nullable=False) + started_on: Mapped[date] = mapped_column(Date, nullable=False) + ended_on: Mapped[date] = mapped_column(Date, nullable=False) + profile_risk_level: Mapped[str] = mapped_column(String(8), nullable=False) + return_target_lower_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + max_drawdown_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + liquidity_requirement: Mapped[str] = mapped_column(String(32), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + observation_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + static_total_return_pct: Mapped[Decimal | None] = mapped_column(Numeric(12, 4)) + dynamic_total_return_pct: Mapped[Decimal | None] = mapped_column(Numeric(12, 4)) + static_max_drawdown_pct: Mapped[Decimal | None] = mapped_column(Numeric(12, 4)) + dynamic_max_drawdown_pct: Mapped[Decimal | None] = mapped_column(Numeric(12, 4)) + dynamic_rebalance_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + liquidity_history_coverage_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + limitations: Mapped[list[str]] = mapped_column(JSON, nullable=False) + strategy_version: Mapped[str] = mapped_column(String(32), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductSuitabilityReference(Base): + __tablename__ = "advisor_product_suitability_reference" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + sales_institution: Mapped[str] = mapped_column(String(128), nullable=False) + risk_level: Mapped[str] = mapped_column(String(8), nullable=False) + effective_from: Mapped[date] = mapped_column(Date, nullable=False) + effective_until: Mapped[date | None] = mapped_column(Date) + source_url: Mapped[str] = mapped_column(String(1024), nullable=False) + document_title: Mapped[str] = mapped_column(String(256), nullable=False) + document_published_at: Mapped[date | None] = mapped_column(Date) + document_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + review_status: Mapped[str] = mapped_column(String(16), nullable=False) + verified_by: Mapped[str | None] = mapped_column(String(128)) + verified_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductContractSnapshot(Base): + __tablename__ = "advisor_product_contract_snapshot" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + effective_from: Mapped[date] = mapped_column(Date, nullable=False) + effective_until: Mapped[date | None] = mapped_column(Date) + fund_type: Mapped[str] = mapped_column(String(64), nullable=False) + investment_scope: Mapped[str] = mapped_column(Text, nullable=False) + performance_benchmark: Mapped[str | None] = mapped_column(String(256)) + risk_return_characteristics: Mapped[str] = mapped_column(Text, nullable=False) + custodian_name: Mapped[str | None] = mapped_column(String(128)) + management_fee_rate_pct: Mapped[Decimal | None] = mapped_column(Numeric(9, 6)) + custodian_fee_rate_pct: Mapped[Decimal | None] = mapped_column(Numeric(9, 6)) + inception_date: Mapped[date | None] = mapped_column(Date) + source_url: Mapped[str] = mapped_column(String(1024), nullable=False) + document_title: Mapped[str] = mapped_column(String(256), nullable=False) + document_published_at: Mapped[date | None] = mapped_column(Date) + document_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + review_status: Mapped[str] = mapped_column(String(16), nullable=False) + verified_by: Mapped[str | None] = mapped_column(String(128)) + verified_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductGovernanceCandidate(Base): + __tablename__ = "advisor_product_governance_candidate_snapshot" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + data_kind: Mapped[str] = mapped_column(String(16), nullable=False) + sales_institution: Mapped[str | None] = mapped_column(String(128)) + observed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + effective_from: Mapped[date] = mapped_column(Date, nullable=False) + risk_level: Mapped[str | None] = mapped_column(String(8)) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + source_url: Mapped[str] = mapped_column(String(1024), nullable=False) + document_title: Mapped[str] = mapped_column(String(256), nullable=False) + document_published_at: Mapped[date | None] = mapped_column(Date) + document_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + review_status: Mapped[str] = mapped_column(String(16), nullable=False) + reviewed_by: Mapped[int | None] = mapped_column(BigInteger) + reviewed_at: Mapped[datetime | None] = mapped_column(DateTime) + review_comment: Mapped[str | None] = mapped_column(String(1000)) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductGovernanceSyncRun(Base): + __tablename__ = "advisor_product_governance_sync_run" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + run_no: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) + source: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False) + product_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + change_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + error_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + detail: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + completed_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProductMarketQuoteSnapshot(Base): + __tablename__ = "advisor_product_market_quote_snapshot" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + product_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + observed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + last_price: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) + previous_close: Mapped[Decimal | None] = mapped_column(Numeric(18, 6)) + change_pct: Mapped[Decimal | None] = mapped_column(Numeric(10, 4)) + volume: Mapped[Decimal | None] = mapped_column(Numeric(24, 4)) + turnover_amount: Mapped[Decimal | None] = mapped_column(Numeric(24, 2)) + quote_status: Mapped[str] = mapped_column(String(16), nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorMarketQuoteSourceRun(Base): + __tablename__ = "advisor_market_quote_source_run" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + sync_run_no: Mapped[str] = mapped_column(String(36), nullable=False) + source: Mapped[str] = mapped_column(String(64), nullable=False) + priority_order: Mapped[int] = mapped_column(BigInteger, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False) + requested_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + quote_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + error_type: Mapped[str | None] = mapped_column(String(128)) + started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + completed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorMarketQuoteSourceHealth(Base): + __tablename__ = "advisor_market_quote_source_health" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + source: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + status: Mapped[str] = mapped_column(String(16), nullable=False) + consecutive_failure_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + last_success_at: Mapped[datetime | None] = mapped_column(DateTime) + last_failure_at: Mapped[datetime | None] = mapped_column(DateTime) + last_error_type: Mapped[str | None] = mapped_column(String(128)) + last_requested_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + last_quote_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorMarketQuoteAlert(Base): + __tablename__ = "advisor_market_quote_alert" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + alert_no: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) + source: Mapped[str] = mapped_column(String(64), nullable=False) + alert_type: Mapped[str] = mapped_column(String(64), nullable=False) + severity: Mapped[str] = mapped_column(String(16), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False) + occurrence_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + detail: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + first_observed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + last_observed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/app/model/goal_conversation.py b/app/model/goal_conversation.py new file mode 100644 index 0000000..9a1241f --- /dev/null +++ b/app/model/goal_conversation.py @@ -0,0 +1,26 @@ +"""Internal persistence model for conversational investment-goal extraction.""" + +from datetime import datetime +from typing import Any + +from sqlalchemy import JSON, BigInteger, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base + + +class AdvisorGoalConversationExtraction(Base): + """Append-only extraction snapshot; never exposed in the customer API.""" + + __tablename__ = "advisor_goal_conversation_extraction" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + message_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False) + session_id: Mapped[str] = mapped_column(String(64), nullable=False) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + extraction_version: Mapped[str] = mapped_column(String(32), nullable=False) + extracted_fields: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + missing_fields: Mapped[list[str]] = mapped_column(JSON, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/app/model/investment_goal.py b/app/model/investment_goal.py new file mode 100644 index 0000000..0e26db4 --- /dev/null +++ b/app/model/investment_goal.py @@ -0,0 +1,53 @@ +"""Persistence models for goals and their client-facing goal books.""" + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import JSON, BigInteger, DateTime, Integer, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base + + +class ClientFacingContent(Base): + """Unchanged baseline content-review resource used by goal books.""" + + __tablename__ = "client_facing_content" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + content_type: Mapped[str] = mapped_column(String(32), nullable=False) + draft_content: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + generated_by_portal: Mapped[str] = mapped_column(String(32), nullable=False) + review_status: Mapped[str] = mapped_column(String(16), nullable=False) + reviewer_user_id: Mapped[int | None] = mapped_column(BigInteger) + reviewed_at: Mapped[datetime | None] = mapped_column(DateTime) + published_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorInvestmentGoal(Base): + """Collected goal version; only a confirmed version feeds advisory tools.""" + + __tablename__ = "advisor_investment_goal" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + goal_no: Mapped[str] = mapped_column(String(36), unique=True, nullable=False) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + status: Mapped[str] = mapped_column(String(24), nullable=False) + annualized_return_lower_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + annualized_return_upper_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + max_drawdown_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False) + liquidity_requirement: Mapped[str] = mapped_column(String(32), nullable=False) + investment_horizon_months: Mapped[int] = mapped_column(Integer, nullable=False) + benchmark_name: Mapped[str] = mapped_column(String(128), nullable=False) + notes: Mapped[str | None] = mapped_column(Text) + source: Mapped[str] = mapped_column(String(16), nullable=False) + goal_book_content_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True) + created_by: Mapped[int] = mapped_column(BigInteger, nullable=False) + confirmed_by: Mapped[int | None] = mapped_column(BigInteger) + confirmed_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/app/model/profile_tag.py b/app/model/profile_tag.py new file mode 100644 index 0000000..27eb8ba --- /dev/null +++ b/app/model/profile_tag.py @@ -0,0 +1,57 @@ +"""Append-only evidence and review records for internal customer-profile tags.""" + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import JSON, BigInteger, DateTime, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base + + +class AdvisorProfileDriftReview(Base): + """A candidate profile held for review when its tags materially drift.""" + + __tablename__ = "advisor_profile_drift_review" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + drift_no: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + source_assessment_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + candidate_profile_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) + candidate_profile_version: Mapped[int] = mapped_column(BigInteger, nullable=False) + candidate_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + candidate_generation_basis: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + changed_tags: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False) + reviewer_user_id: Mapped[int | None] = mapped_column(BigInteger) + reviewed_at: Mapped[datetime | None] = mapped_column(DateTime) + review_comment: Mapped[str | None] = mapped_column(String(1000)) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +class AdvisorProfileTag(Base): + """A tag value with evidence lineage. Exactly one active row exists per tag key.""" + + __tablename__ = "advisor_profile_tag" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + tag_uuid: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + tag_key: Mapped[str] = mapped_column(String(64), nullable=False) + tag_value: Mapped[Any] = mapped_column(JSON, nullable=False) + tag_value_hash: Mapped[str] = mapped_column(String(64), nullable=False) + confidence: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False) + source_type: Mapped[str] = mapped_column(String(32), nullable=False) + source_reference: Mapped[str] = mapped_column(String(128), nullable=False) + source_confidence: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False) + profile_version: Mapped[int] = mapped_column(BigInteger, nullable=False) + drift_review_id: Mapped[int | None] = mapped_column(BigInteger) + previous_tag_id: Mapped[int | None] = mapped_column(BigInteger) + drift_reason: Mapped[str | None] = mapped_column(String(32)) + status: Mapped[str] = mapped_column(String(16), nullable=False) + active_customer_tag: Mapped[str | None] = mapped_column(String(160), unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/app/model/risk_questionnaire.py b/app/model/risk_questionnaire.py new file mode 100644 index 0000000..6bd96bb --- /dev/null +++ b/app/model/risk_questionnaire.py @@ -0,0 +1,30 @@ +"""Additive projection model for opening-risk questionnaire profiles.""" + +from datetime import datetime +from typing import Any + +from sqlalchemy import JSON, BigInteger, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base +from app.model.fund import FundRiskAssessment as RiskAssessment + + +class ProfileSnapshot(Base): + __tablename__ = "profile_snapshots" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + profile_uuid: Mapped[str | None] = mapped_column(String(36), unique=True) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + version: Mapped[int] = mapped_column(BigInteger, nullable=False) + snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + generation_basis: Mapped[dict[str, Any] | None] = mapped_column(JSON) + snapshot_hash: Mapped[str | None] = mapped_column(String(64)) + is_current: Mapped[bool] = mapped_column(nullable=False, default=False) + current_customer_id: Mapped[int | None] = mapped_column(BigInteger) + generated_at: Mapped[datetime | None] = mapped_column(DateTime) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + +__all__ = ["ProfileSnapshot", "RiskAssessment"] diff --git a/app/repository/advisor_product_repository.py b/app/repository/advisor_product_repository.py new file mode 100644 index 0000000..a3aa651 --- /dev/null +++ b/app/repository/advisor_product_repository.py @@ -0,0 +1,305 @@ +"""Read-only product evidence repository for the advisory service.""" + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import Protocol, TypeVar + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.model.advisor_product import ( + AdvisorProductContractSnapshot, + AdvisorProductGovernanceCandidate, + AdvisorProductMarketQuoteSnapshot, + AdvisorProductMetricSnapshot, + AdvisorProductReferenceSnapshot, + AdvisorProductSuitabilityReference, +) +from app.model.fund import FundProduct + + +@dataclass(frozen=True, slots=True) +class ProductSuitabilityEvidence: + risk_level: str + sales_institution: str + source_url: str + document_title: str + document_sha256: str + effective_from: date + + +@dataclass(frozen=True, slots=True) +class ProductContractEvidence: + fund_type: str + investment_scope: str + performance_benchmark: str | None + risk_return_characteristics: str + custodian_name: str | None + management_fee_rate_pct: Decimal | None + custodian_fee_rate_pct: Decimal | None + inception_date: date | None + source_url: str + document_title: str + document_sha256: str + document_published_at: date | None + + +@dataclass(frozen=True, slots=True) +class ProductLiquidityEvidence: + average_daily_turnover_amount: Decimal | None + latest_quote_observed_at: datetime | None + status: str + + +@dataclass(frozen=True, slots=True) +class AuthoritativeProductCandidate: + product: FundProduct + suitability: ProductSuitabilityEvidence + contract: ProductContractEvidence + asset_scale_billion: Decimal | None = None + market_quote: AdvisorProductMarketQuoteSnapshot | None = None + liquidity: ProductLiquidityEvidence | None = None + + +class _ProductRow(Protocol): + product_id: int + + +ProductRow = TypeVar("ProductRow", bound=_ProductRow) + + +class AdvisorProductRepository: + """Read verified, current, exchange-traded product facts only.""" + + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def tradable_products( + self, + now: datetime, + *, + fund_manager: str | None = None, + product_codes: tuple[str, ...] | None = None, + limit: int = 50, + ) -> list[FundProduct]: + statement = select(FundProduct).where( + FundProduct.status == "上市", + FundProduct.exchange_code.in_(("SSE", "SZSE")), + or_(FundProduct.open_start_at.is_(None), FundProduct.open_start_at <= now), + or_(FundProduct.open_end_at.is_(None), FundProduct.open_end_at > now), + ) + if fund_manager is not None: + statement = statement.where(FundProduct.fund_manager == fund_manager) + if product_codes is not None: + statement = statement.where(FundProduct.product_code.in_(product_codes)) + statement = statement.order_by(FundProduct.id.asc()).limit(limit) + return list(await self.session.scalars(statement)) + + async def authoritative_tradable_products( + self, + now: datetime, + *, + sales_institution: str, + fund_manager: str | None = None, + quote_max_age_seconds: int = 300, + min_asset_scale_billion: Decimal | None = None, + liquidity_requirement: str | None = None, + limit: int = 50, + ) -> list[AuthoritativeProductCandidate]: + """Apply evidence gates before any product reaches an Agent. + + The baseline ``fin_product.risk_level`` is never treated as the + sales-institution suitability rating. Missing or unverified evidence + excludes a product (fail closed). + """ + products = await self.tradable_products(now, fund_manager=fund_manager, limit=limit) + if not products: + return [] + ids = [product.id for product in products] + as_of = now.date() + suitability_rows = list(await self.session.scalars(select( + AdvisorProductSuitabilityReference + ).where( + AdvisorProductSuitabilityReference.product_id.in_(ids), + AdvisorProductSuitabilityReference.sales_institution == sales_institution, + AdvisorProductSuitabilityReference.review_status == "verified", + AdvisorProductSuitabilityReference.effective_from <= as_of, + or_( + AdvisorProductSuitabilityReference.effective_until.is_(None), + AdvisorProductSuitabilityReference.effective_until >= as_of, + ), + AdvisorProductSuitabilityReference.source_url != "", + AdvisorProductSuitabilityReference.document_sha256 != "", + ).order_by( + AdvisorProductSuitabilityReference.product_id, + AdvisorProductSuitabilityReference.effective_from.desc(), + ))) + contract_rows = list(await self.session.scalars(select( + AdvisorProductContractSnapshot + ).where( + AdvisorProductContractSnapshot.product_id.in_(ids), + AdvisorProductContractSnapshot.review_status == "verified", + AdvisorProductContractSnapshot.effective_from <= as_of, + or_( + AdvisorProductContractSnapshot.effective_until.is_(None), + AdvisorProductContractSnapshot.effective_until >= as_of, + ), + AdvisorProductContractSnapshot.source_url != "", + AdvisorProductContractSnapshot.document_sha256 != "", + ).order_by( + AdvisorProductContractSnapshot.product_id, + AdvisorProductContractSnapshot.effective_from.desc(), + ))) + reference_rows = list(await self.session.scalars(select( + AdvisorProductReferenceSnapshot + ).where( + AdvisorProductReferenceSnapshot.product_id.in_(ids), + AdvisorProductReferenceSnapshot.as_of_date <= as_of, + ).order_by( + AdvisorProductReferenceSnapshot.product_id, + AdvisorProductReferenceSnapshot.as_of_date.desc(), + ))) + metric_rows = list(await self.session.scalars(select( + AdvisorProductMetricSnapshot + ).where( + AdvisorProductMetricSnapshot.product_id.in_(ids), + AdvisorProductMetricSnapshot.as_of_date <= as_of, + ).order_by( + AdvisorProductMetricSnapshot.product_id, + AdvisorProductMetricSnapshot.as_of_date.desc(), + ))) + pending_ids = set(await self.session.scalars(select( + AdvisorProductGovernanceCandidate.product_id + ).where( + AdvisorProductGovernanceCandidate.product_id.in_(ids), + AdvisorProductGovernanceCandidate.review_status == "pending_review", + ))) + quote_rows = list(await self.session.scalars(select( + AdvisorProductMarketQuoteSnapshot + ).where( + AdvisorProductMarketQuoteSnapshot.product_id.in_(ids), + AdvisorProductMarketQuoteSnapshot.quote_status == "active", + ).order_by( + AdvisorProductMarketQuoteSnapshot.product_id, + AdvisorProductMarketQuoteSnapshot.observed_at.desc(), + ))) + suitability_by_product = self._latest_by_product(suitability_rows) + contract_by_product = self._latest_by_product(contract_rows) + reference_by_product = self._latest_by_product(reference_rows) + metric_by_product = self._latest_by_product(metric_rows) + quote_cutoff = now - timedelta(seconds=quote_max_age_seconds) + quote_by_product: dict[int, AdvisorProductMarketQuoteSnapshot] = {} + for quote in quote_rows: + if quote.observed_at >= quote_cutoff: + quote_by_product.setdefault(quote.product_id, quote) + + result: list[AuthoritativeProductCandidate] = [] + for product in products: + suitability = suitability_by_product.get(product.id) + contract = contract_by_product.get(product.id) + if product.id in pending_ids or suitability is None or contract is None: + continue + reference = reference_by_product.get(product.id) + scale = reference.fund_asset_scale_billion if reference else None + if ( + min_asset_scale_billion is not None + and suitability.risk_level != "R1" + and (scale is None or scale < min_asset_scale_billion) + ): + continue + metric = metric_by_product.get(product.id) + latest_quote = quote_by_product.get(product.id) + liquidity = self._liquidity_evidence(metric, latest_quote, liquidity_requirement) + if liquidity is not None and liquidity.status == "insufficient": + continue + result.append(AuthoritativeProductCandidate( + product=product, + suitability=ProductSuitabilityEvidence( + risk_level=suitability.risk_level, + sales_institution=suitability.sales_institution, + source_url=suitability.source_url, + document_title=suitability.document_title, + document_sha256=suitability.document_sha256, + effective_from=suitability.effective_from, + ), + contract=ProductContractEvidence( + fund_type=contract.fund_type, + investment_scope=contract.investment_scope, + performance_benchmark=contract.performance_benchmark, + risk_return_characteristics=contract.risk_return_characteristics, + custodian_name=contract.custodian_name, + management_fee_rate_pct=contract.management_fee_rate_pct, + custodian_fee_rate_pct=contract.custodian_fee_rate_pct, + inception_date=contract.inception_date, + source_url=contract.source_url, + document_title=contract.document_title, + document_sha256=contract.document_sha256, + document_published_at=contract.document_published_at, + ), + asset_scale_billion=scale, + market_quote=latest_quote, + liquidity=liquidity, + )) + return result + + @staticmethod + def _liquidity_evidence( + metric: AdvisorProductMetricSnapshot | None, + quote: AdvisorProductMarketQuoteSnapshot | None, + requirement: str | None, + ) -> ProductLiquidityEvidence | None: + if metric is None and quote is None and requirement is None: + return None + thresholds = { + "daily": Decimal("10000000"), + "within_7_days": Decimal("1000000"), + "within_30_days": Decimal("100000"), + "over_30_days": Decimal("0"), + } + if requirement is not None and requirement not in thresholds: + raise ValueError("unknown liquidity requirement") + average = metric.average_daily_turnover_amount if metric is not None else None + status = "unknown" if average is None else "available" + if requirement is not None and (average is None or average < thresholds[requirement]): + status = "insufficient" + return ProductLiquidityEvidence( + average_daily_turnover_amount=average, + latest_quote_observed_at=quote.observed_at if quote is not None else None, + status=status, + ) + + @staticmethod + def hard_suitability_filter( + candidates: Sequence[AuthoritativeProductCandidate], customer_risk_level: int + ) -> tuple[list[AuthoritativeProductCandidate], list[dict[str, object]]]: + """Apply R-level hard filtering before ranking or model generation.""" + if not 1 <= customer_risk_level <= 5: + raise ValueError("customer risk level must be between 1 and 5") + selected: list[AuthoritativeProductCandidate] = [] + excluded: list[dict[str, object]] = [] + for candidate in candidates: + product_level = candidate.suitability.risk_level.upper().removeprefix("R") + if not product_level.isdigit() or not 1 <= int(product_level) <= 5: + excluded.append({ + "product_code": candidate.product.product_code, + "reason_code": "PRODUCT_RISK_LEVEL_INVALID", + "reason": "产品权威适当性等级无法解析,已失败关闭。", + }) + elif int(product_level) > customer_risk_level: + excluded.append({ + "product_code": candidate.product.product_code, + "reason_code": "RISK_LEVEL_MISMATCH", + "reason": "产品风险等级高于客户风险承受等级,已排除。", + }) + else: + selected.append(candidate) + return selected, excluded + + @staticmethod + def _latest_by_product(rows: Sequence[ProductRow]) -> dict[int, ProductRow]: + result: dict[int, ProductRow] = {} + for row in rows: + result.setdefault(int(row.product_id), row) + return result diff --git a/app/repository/investment_goal_repository.py b/app/repository/investment_goal_repository.py new file mode 100644 index 0000000..7647f4d --- /dev/null +++ b/app/repository/investment_goal_repository.py @@ -0,0 +1,53 @@ +"""Repository for investment goals and reviewed goal-book content.""" + +from datetime import datetime +from typing import cast + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent + + +class InvestmentGoalRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + def add_goal(self, goal: AdvisorInvestmentGoal) -> None: + self.session.add(goal) + + def add_goal_book(self, content: ClientFacingContent) -> None: + self.session.add(content) + + async def goal(self, goal_no: str, *, lock: bool = False) -> AdvisorInvestmentGoal | None: + query = select(AdvisorInvestmentGoal).where(AdvisorInvestmentGoal.goal_no == goal_no) + if lock: + query = query.with_for_update() + return cast(AdvisorInvestmentGoal | None, await self.session.scalar(query)) + + async def latest_for_customer(self, customer_id: int) -> AdvisorInvestmentGoal | None: + return cast(AdvisorInvestmentGoal | None, await self.session.scalar( + select(AdvisorInvestmentGoal) + .where( + AdvisorInvestmentGoal.customer_id == customer_id, + AdvisorInvestmentGoal.status != "superseded", + ) + .order_by(AdvisorInvestmentGoal.created_at.desc(), AdvisorInvestmentGoal.id.desc()) + .limit(1) + )) + + async def goal_book(self, content_id: int) -> ClientFacingContent | None: + return await self.session.get(ClientFacingContent, content_id) + + async def supersede_confirmed( + self, customer_id: int, except_goal_no: str, now: datetime + ) -> None: + await self.session.execute( + update(AdvisorInvestmentGoal) + .where( + AdvisorInvestmentGoal.customer_id == customer_id, + AdvisorInvestmentGoal.status == "confirmed", + AdvisorInvestmentGoal.goal_no != except_goal_no, + ) + .values(status="superseded", updated_at=now) + ) diff --git a/app/repository/platform_repository.py b/app/repository/platform_repository.py index ef891d6..068a365 100644 --- a/app/repository/platform_repository.py +++ b/app/repository/platform_repository.py @@ -20,6 +20,7 @@ TABLES = frozenset( "svc_handover_ticket", "fin_knowledge_meta", "profile_snapshots", + "client_facing_content", } ) diff --git a/app/repository/portfolio_analysis_repository.py b/app/repository/portfolio_analysis_repository.py new file mode 100644 index 0000000..a150048 --- /dev/null +++ b/app/repository/portfolio_analysis_repository.py @@ -0,0 +1,119 @@ +"""Read-only repository for holdings and advisory reference data.""" + +from datetime import date + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.model.advisor_product import ( + AdvisorProductAssetClassification, + AdvisorProductDataQualitySnapshot, + AdvisorProductIndustryExposure, + AdvisorProductMetricSnapshot, +) +from app.model.fund import FundHolding, FundProduct + + +class PortfolioAnalysisRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def positions(self, customer_id: int) -> list[tuple[FundHolding, FundProduct]]: + statement = ( + select(FundHolding, FundProduct) + .join(FundProduct, FundProduct.id == FundHolding.product_id) + .where( + FundHolding.customer_id == customer_id, + FundHolding.total_quantity > 0, + FundHolding.status.in_(("持有中", "held")), + ) + .order_by(FundHolding.market_value.desc(), FundHolding.id.asc()) + ) + return [(row[0], row[1]) for row in (await self.session.execute(statement)).all()] + + async def latest_industry_exposures( + self, product_ids: tuple[int, ...], as_of_date: date + ) -> dict[int, list[AdvisorProductIndustryExposure]]: + if not product_ids: + return {} + rows = await self.session.scalars( + select(AdvisorProductIndustryExposure) + .where( + AdvisorProductIndustryExposure.product_id.in_(product_ids), + AdvisorProductIndustryExposure.as_of_date <= as_of_date, + AdvisorProductIndustryExposure.status == "active", + ) + .order_by( + AdvisorProductIndustryExposure.product_id, + AdvisorProductIndustryExposure.as_of_date.desc(), + AdvisorProductIndustryExposure.id, + ) + ) + selected: dict[int, list[AdvisorProductIndustryExposure]] = {} + latest_dates: dict[int, date] = {} + for row in rows: + latest = latest_dates.setdefault(row.product_id, row.as_of_date) + if row.as_of_date == latest: + selected.setdefault(row.product_id, []).append(row) + return selected + + async def latest_metrics( + self, product_ids: tuple[int, ...], as_of_date: date + ) -> dict[int, AdvisorProductMetricSnapshot]: + if not product_ids: + return {} + rows = await self.session.scalars( + select(AdvisorProductMetricSnapshot) + .where( + AdvisorProductMetricSnapshot.product_id.in_(product_ids), + AdvisorProductMetricSnapshot.as_of_date <= as_of_date, + ) + .order_by( + AdvisorProductMetricSnapshot.product_id, + AdvisorProductMetricSnapshot.as_of_date.desc(), + AdvisorProductMetricSnapshot.id, + ) + ) + selected: dict[int, AdvisorProductMetricSnapshot] = {} + for row in rows: + selected.setdefault(row.product_id, row) + return selected + + async def latest_asset_classifications( + self, product_ids: tuple[int, ...], as_of_date: date + ) -> dict[int, AdvisorProductAssetClassification]: + if not product_ids: + return {} + rows = await self.session.scalars( + select(AdvisorProductAssetClassification).where( + AdvisorProductAssetClassification.product_id.in_(product_ids), + AdvisorProductAssetClassification.as_of_date <= as_of_date, + AdvisorProductAssetClassification.status == "active", + ).order_by( + AdvisorProductAssetClassification.product_id, + AdvisorProductAssetClassification.as_of_date.desc(), + ) + ) + selected: dict[int, AdvisorProductAssetClassification] = {} + for row in rows: + selected.setdefault(row.product_id, row) + return selected + + async def latest_quality( + self, product_ids: tuple[int, ...], as_of_date: date + ) -> dict[int, AdvisorProductDataQualitySnapshot]: + if not product_ids: + return {} + rows = await self.session.scalars( + select(AdvisorProductDataQualitySnapshot).where( + AdvisorProductDataQualitySnapshot.product_id.in_(product_ids), + AdvisorProductDataQualitySnapshot.as_of_date <= as_of_date, + ).order_by( + AdvisorProductDataQualitySnapshot.product_id, + AdvisorProductDataQualitySnapshot.as_of_date.desc(), + ) + ) + selected: dict[int, AdvisorProductDataQualitySnapshot] = {} + for row in rows: + selected.setdefault(row.product_id, row) + return selected diff --git a/app/repository/risk_questionnaire_repository.py b/app/repository/risk_questionnaire_repository.py new file mode 100644 index 0000000..3372601 --- /dev/null +++ b/app/repository/risk_questionnaire_repository.py @@ -0,0 +1,171 @@ +"""Repository for formal risk assessments and internal profile projections.""" + +from datetime import datetime +from typing import cast + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.model.fund import FundRiskAssessment as RiskAssessment +from app.model.memory import MemorySyncOutbox +from app.model.profile_tag import AdvisorProfileDriftReview, AdvisorProfileTag +from app.model.risk_questionnaire import ProfileSnapshot + + +class RiskQuestionnaireRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def has_valid_assessment(self, customer_id: int, now: datetime) -> bool: + return await self.session.scalar( + select(RiskAssessment.id) + .where(RiskAssessment.customer_id == customer_id, RiskAssessment.valid_until > now) + .limit(1) + ) is not None + + async def assessments_since(self, customer_id: int, since: datetime) -> int: + value = await self.session.scalar( + select(func.count()) + .select_from(RiskAssessment) + .where(RiskAssessment.customer_id == customer_id, RiskAssessment.assessed_at >= since) + ) + return int(value or 0) + + async def next_profile_version(self, customer_id: int) -> int: + value = await self.session.scalar( + select(func.coalesce(func.max(ProfileSnapshot.version), 0)).where( + ProfileSnapshot.customer_id == customer_id + ) + ) + return int(value or 0) + 1 + + async def deactivate_current_profile(self, customer_id: int, now: datetime) -> None: + await self.session.execute( + update(ProfileSnapshot) + .where(ProfileSnapshot.customer_id == customer_id, ProfileSnapshot.is_current.is_(True)) + .values(is_current=False, updated_at=now) + ) + + def add_assessment(self, assessment: RiskAssessment) -> None: + self.session.add(assessment) + + def add_profile(self, profile: ProfileSnapshot) -> None: + self.session.add(profile) + + def add_sync_event(self, event: MemorySyncOutbox) -> None: + self.session.add(event) + + async def latest_assessment(self, customer_id: int) -> RiskAssessment | None: + return cast(RiskAssessment | None, await self.session.scalar( + select(RiskAssessment) + .where(RiskAssessment.customer_id == customer_id) + .order_by(RiskAssessment.assessed_at.desc(), RiskAssessment.id.desc()) + .limit(1) + )) + + async def current_profile(self, customer_id: int) -> ProfileSnapshot | None: + return cast(ProfileSnapshot | None, await self.session.scalar( + select(ProfileSnapshot) + .where( + ProfileSnapshot.customer_id == customer_id, + ProfileSnapshot.is_current.is_(True), + ) + .order_by(ProfileSnapshot.version.desc(), ProfileSnapshot.id.desc()) + .limit(1) + )) + + async def active_tags( + self, customer_id: int, *, lock: bool = False + ) -> list[AdvisorProfileTag]: + statement = ( + select(AdvisorProfileTag) + .where( + AdvisorProfileTag.customer_id == customer_id, + AdvisorProfileTag.status == "active", + ) + .order_by(AdvisorProfileTag.tag_key.asc(), AdvisorProfileTag.id.desc()) + ) + if lock: + statement = statement.with_for_update() + return list(await self.session.scalars(statement)) + + async def tags(self, customer_id: int, *, limit: int = 100) -> list[AdvisorProfileTag]: + return list(await self.session.scalars( + select(AdvisorProfileTag) + .where(AdvisorProfileTag.customer_id == customer_id) + .order_by(AdvisorProfileTag.tag_key.asc(), AdvisorProfileTag.id.desc()) + .limit(limit) + )) + + async def pending_drift_review( + self, customer_id: int, *, lock: bool = False + ) -> AdvisorProfileDriftReview | None: + statement = ( + select(AdvisorProfileDriftReview) + .where( + AdvisorProfileDriftReview.customer_id == customer_id, + AdvisorProfileDriftReview.status == "pending_review", + ) + .order_by( + AdvisorProfileDriftReview.created_at.desc(), + AdvisorProfileDriftReview.id.desc(), + ) + .limit(1) + ) + if lock: + statement = statement.with_for_update() + return cast(AdvisorProfileDriftReview | None, await self.session.scalar(statement)) + + async def drift_review( + self, review_id: int, *, lock: bool = False + ) -> AdvisorProfileDriftReview | None: + statement = select(AdvisorProfileDriftReview).where( + AdvisorProfileDriftReview.id == review_id + ) + if lock: + statement = statement.with_for_update() + return cast(AdvisorProfileDriftReview | None, await self.session.scalar(statement)) + + async def pending_reviews(self, *, limit: int) -> list[AdvisorProfileDriftReview]: + return list(await self.session.scalars( + select(AdvisorProfileDriftReview) + .where(AdvisorProfileDriftReview.status == "pending_review") + .order_by( + AdvisorProfileDriftReview.created_at.asc(), + AdvisorProfileDriftReview.id.asc(), + ) + .limit(limit) + )) + + async def tags_for_review( + self, review_id: int, *, lock: bool = False + ) -> list[AdvisorProfileTag]: + statement = ( + select(AdvisorProfileTag) + .where(AdvisorProfileTag.drift_review_id == review_id) + .order_by(AdvisorProfileTag.tag_key.asc(), AdvisorProfileTag.id.asc()) + ) + if lock: + statement = statement.with_for_update() + return list(await self.session.scalars(statement)) + + async def supersede_active_tags( + self, customer_id: int, tag_keys: tuple[str, ...], now: datetime + ) -> None: + if not tag_keys: + return + await self.session.execute( + update(AdvisorProfileTag) + .where( + AdvisorProfileTag.customer_id == customer_id, + AdvisorProfileTag.tag_key.in_(tag_keys), + AdvisorProfileTag.status == "active", + ) + .values(status="superseded", active_customer_tag=None, updated_at=now) + ) + + def add_tag(self, tag: AdvisorProfileTag) -> None: + self.session.add(tag) + + def add_drift_review(self, review: AdvisorProfileDriftReview) -> None: + self.session.add(review) diff --git a/app/service/advisor_rollout_service.py b/app/service/advisor_rollout_service.py new file mode 100644 index 0000000..ae6df27 --- /dev/null +++ b/app/service/advisor_rollout_service.py @@ -0,0 +1,63 @@ +"""投顾灰度发布闸门。 + +灰度配置使用环境变量,避免为发布控制改动冻结的数据库基线。白名单只允许客户 +身份进入投顾业务;管理员保留审核、发布和故障处置权限。未命中时统一复用权限拒绝 +错误,不向客户暴露灰度配置细节。 +""" + +from datetime import UTC, datetime + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.database import get_session +from app.core.config import get_settings +from app.core.contracts import RequestContext +from app.core.errors import ForbiddenAgentError +from app.model.audit import InteractionAudit + + +class AdvisorRolloutService: + ADMIN_ROLES = frozenset({"admin", "super_admin"}) + + def __init__(self, session: AsyncSession | None = None) -> None: + self.session = session + + @staticmethod + def customer_ids(raw: str) -> frozenset[str]: + return frozenset(item.strip() for item in raw.split(",") if item.strip()) + + @classmethod + def is_allowed(cls, context: RequestContext) -> bool: + settings = get_settings() + if not settings.advisor_rollout_enabled: + return True + if cls.ADMIN_ROLES.intersection(context.roles): + return True + allowed = cls.customer_ids(settings.advisor_rollout_customer_ids) + identities = {context.user_id, *context.customer_ids} + return bool(allowed.intersection(identities)) + + async def ensure_allowed(self, context: RequestContext) -> None: + if self.is_allowed(context): + return + if self.session is not None: + self.session.add(InteractionAudit( + actor_type="user", + actor_id=int(context.user_id), + portal=context.portal, + action_type="advisor.rollout_denied", + detail={"trace_id": context.trace_id, "reason": "not_in_rollout"}, + created_at=datetime.now(UTC).replace(tzinfo=None), + )) + await self.session.commit() + raise ForbiddenAgentError("当前投顾功能尚未对该账号开放") + + +async def enforce_advisor_rollout( + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> None: + """FastAPI 依赖:保护投顾业务路由,认证依赖先完成身份解析。""" + await AdvisorRolloutService(session).ensure_allowed(context) diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index 582155b..b71c2c0 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -4,11 +4,16 @@ from typing import Any, cast from sqlalchemy.ext.asyncio import AsyncSession +from app.core.advisor_allocation_contracts import AssetAllocationQuery from app.core.config import get_settings from app.core.errors import RecoverableAgentError from app.core.fund_contracts import FundQuoteQuery +from app.core.investment_goal_contracts import InvestmentGoalQuery from app.core.knowledge_contracts import KnowledgeSearchInput from app.core.nl2sql_contracts import FinancialNL2SQLInput +from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery +from app.core.product_comparison_contracts import ProductComparisonQuery +from app.core.product_recommendation_contracts import ProductRecommendationQuery from app.core.risk_contracts import RiskAlertEvidenceQuery, RiskAlertQuery from app.infrastructure.fund_quote_cache import FundQuoteCache from app.infrastructure.graph import build_graph_driver @@ -17,6 +22,7 @@ from app.infrastructure.milvus_knowledge_writer import MilvusKnowledgeWriter from app.infrastructure.vector_memory import VectorMemoryAdapter from app.service.agent.factory import AgentFactory from app.service.agent.governance import PlatformGovernance +from app.service.agent.implementations.advisor import AdvisorAgent from app.service.agent.implementations.customer_service import CustomerServiceAgent from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent from app.service.agent.implementations.platform_probe import ( @@ -31,6 +37,7 @@ from app.service.agent.implementations.platform_probe import ( from app.service.agent.implementations.risk_agent import RiskAgent from app.service.agent.offsite_fund_agent import OffsiteFundAgent from app.service.agent.promotion_material_agent import PromotionMaterialAgent +from app.service.asset_allocation_service import asset_allocation_tool from app.service.customer_profile_service import ( CustomerProfileQuery, query_customer_profile_tool, @@ -38,6 +45,7 @@ from app.service.customer_profile_service import ( from app.service.financial_nl2sql_service import query_financial_data_tool from app.service.fund_quote_service import query_fund_quote_tool from app.service.intent_classifier import IntentClassifier +from app.service.investment_goal_service import investment_goal_query_tool from app.service.knowledge_search_service import KnowledgeSearchService from app.service.knowledge_tool import knowledge_search_tool from app.service.memory_recall_service import MemoryRecallService @@ -48,6 +56,9 @@ from app.service.model_gateway import ( ModelEmbeddingService, ModelGenerationService, ) +from app.service.portfolio_analysis_service import portfolio_analysis_tool +from app.service.product_comparison_service import product_comparison_tool +from app.service.product_recommendation_service import product_recommendation_tool from app.service.relationship_service import RelationshipService from app.service.risk_tools import ( get_alert_evidence_tool, @@ -306,6 +317,45 @@ def get_agent_factory() -> AgentFactory: required_permission="risk:alert:read", allowed_roles=("risk_operator", "admin"), )) + registry.register(ToolDefinition( + name="query_investment_goal", + input_model=InvestmentGoalQuery, + handler=cast(Any, investment_goal_query_tool), + required_permission="investment-goal:read:self", + allowed_roles=("customer", "advisor", "operator", "admin"), + )) + registry.register(ToolDefinition( + name="analyze_portfolio", + input_model=PortfolioAnalysisQuery, + handler=cast(Any, portfolio_analysis_tool), + required_permission="portfolio-analysis:read:self", + allowed_roles=("customer", "advisor", "operator", "admin"), + timeout_seconds=10, + )) + registry.register(ToolDefinition( + name="generate_asset_allocation", + input_model=AssetAllocationQuery, + handler=cast(Any, asset_allocation_tool), + required_permission="asset-allocation:generate:self", + allowed_roles=("customer", "advisor", "operator", "admin"), + timeout_seconds=15, + )) + registry.register(ToolDefinition( + name="recommend_products", + input_model=ProductRecommendationQuery, + handler=cast(Any, product_recommendation_tool), + required_permission="product-recommendation:generate:self", + allowed_roles=("customer", "advisor", "operator", "admin"), + timeout_seconds=15, + )) + registry.register(ToolDefinition( + name="compare_products", + input_model=ProductComparisonQuery, + handler=cast(Any, product_comparison_tool), + required_permission="product-comparison:read:self", + allowed_roles=("customer", "advisor", "operator", "admin"), + timeout_seconds=10, + )) model_service = get_model_service() endpoint_resolver = DatabaseModelEndpointResolver() @@ -361,3 +411,7 @@ def register_business_agents(factory: AgentFactory) -> None: PlatformProbeAgent.definition, lambda _context: PlatformProbeAgent(PlatformProbeAgent.definition), ) + factory.register( + AdvisorAgent.definition, + lambda _context: AdvisorAgent(AdvisorAgent.definition), + ) diff --git a/app/service/agent/implementations/advisor.py b/app/service/agent/implementations/advisor.py new file mode 100644 index 0000000..5a285bd --- /dev/null +++ b/app/service/agent/implementations/advisor.py @@ -0,0 +1,189 @@ +"""投顾 Agent 的公共底座实现。""" + +import re +from typing import Any + +from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext +from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent +from app.service.goal_conversation_service import GoalConversationService + + +class AdvisorAgent(FundQueryDemoAgent): + """通过公共 BaseAgent 链路运行的最小投顾 Agent。""" + + definition = AgentDefinition( + agent_type="advisor", + version="0.1.0", + allowed_roles=("customer", "advisor", "operator", "admin"), + allowed_portals=("api",), + allowed_tools=( + "query_fund_quote", + "query_investment_goal", + "analyze_portfolio", + "generate_asset_allocation", + "recommend_products", + "compare_products", + ), + supported_intents=( + "fund_quote", + "investment_goal", + "portfolio_analysis", + "asset_allocation", + "product_recommend", + "comparison", + ), + ) + + async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: + if ( + self._classified_intent is not None + and self._classified_intent.intent == "investment_goal" + ): + if GoalConversationService.should_collect(request.message): + goal_result = await GoalConversationService().process( + session_id=request.session_id, customer_id=int(context.user_id), + trace_id=context.trace_id, message=request.message, + ) + return CoreResult(text=GoalConversationService.customer_prompt(goal_result)) + output = await self.call_tool( + "query_investment_goal", {}, intent="investment_goal", context=context + ) + if not isinstance(output, dict): + return CoreResult(text="当前没有已确认的投资目标,暂不能用于配置或产品推荐。") + return CoreResult(text=self._describe_goal(output)) + if ( + self._classified_intent is not None + and self._classified_intent.intent == "portfolio_analysis" + ): + output = await self.call_tool( + "analyze_portfolio", {}, intent="portfolio_analysis", context=context + ) + return CoreResult(text=self._describe_portfolio(output)) + if ( + self._classified_intent is not None + and self._classified_intent.intent == "asset_allocation" + ): + output = await self.call_tool( + "generate_asset_allocation", {}, intent="asset_allocation", context=context + ) + return CoreResult(text=self._describe_allocation(output)) + if ( + self._classified_intent is not None + and self._classified_intent.intent == "product_recommend" + ): + output = await self.call_tool( + "recommend_products", {"limit": 3}, intent="product_recommend", context=context + ) + return CoreResult(text=self._describe_recommendation(output)) + if ( + self._classified_intent is not None + and self._classified_intent.intent == "comparison" + ): + codes = tuple(dict.fromkeys(re.findall(r"(? str: + return ( + f"当前投资目标:年化收益目标 {goal['annualized_return_lower_pct']}%-" + f"{goal['annualized_return_upper_pct']}%,最大回撤 {goal['max_drawdown_pct']}%," + f"流动性要求 {goal['liquidity_requirement']},投资期限 " + f"{goal['investment_horizon_months']} 个月,业绩比较基准 {goal['benchmark_name']}。" + "以上为目标采集结果,不构成收益承诺或交易指令。" + ) + + @staticmethod + def _describe_portfolio(result: object) -> str: + if not isinstance(result, dict): + return "持仓分析暂不可用,请稍后重试。" + status = result.get("status") + if status == "no_positions": + return "当前没有可分析的场内基金持仓。" + if status == "valuation_required": + return "当前持仓缺少可用市值,暂不能计算集中度。" + summary = result.get("summary") + if not isinstance(summary, dict): + return "持仓分析数据不完整,请稍后重试。" + concentration = result.get("product_concentration") + hhi = concentration.get("hhi") if isinstance(concentration, dict) else None + return ( + f"持仓分析完成:共 {summary.get('position_count')} 个产品," + f"总市值 {summary.get('total_market_value')},产品集中度 HHI 为 {hhi}。" + "分析结果仅供参考,不生成交易指令。" + ) + + @staticmethod + def _describe_allocation(result: object) -> str: + if not isinstance(result, dict): + return "资产配置分析暂不可用,请稍后重试。" + status = result.get("status") + if status == "profile_required": + return "当前缺少有效风险画像,暂不能生成资产配置。" + if status == "investment_goal_required": + return "当前没有已确认的投资目标,暂不能生成资产配置。" + if status != "ready": + return "资产配置分析数据不完整,请稍后重试。" + allocation = result.get("allocation") + if not isinstance(allocation, list) or not allocation: + return "当前没有足够的场内基金数据生成资产配置。" + parts = [ + f"{item.get('label', item.get('asset_class'))} {item.get('target_pct')}%" + for item in allocation + if isinstance(item, dict) + ] + optimization = result.get("optimization") + dynamic = isinstance(optimization, dict) and bool(optimization.get("dynamic")) + mode = "动态历史因子优化" if dynamic else "静态配置(历史数据覆盖不足)" + return ( + f"资产配置分析完成({mode}):" + + ",".join(parts) + + "。该结果综合考虑收益目标、最大回撤、流动性和投资期限," + "仅供分析参考,不构成交易指令。" + ) + + @staticmethod + def _describe_recommendation(result: object) -> str: + if not isinstance(result, dict): + return "产品推荐暂不可用,请稍后重试。" + if result.get("status") == "profile_required": + return "当前缺少有效风险画像,暂不能推荐产品。" + if result.get("status") == "investment_goal_required": + return "当前没有已确认的投资目标,暂不能推荐产品。" + products = result.get("products") + if not isinstance(products, list) or not products: + return "当前没有通过适当性和证据校验的场内基金产品。" + names = [ + f"{item.get('product_code')} {item.get('product_name')}" + for item in products + if isinstance(item, dict) + ] + return "推荐分析结果:" + "、".join(names) + "。方案须经审核发布,不构成交易指令。" + + @staticmethod + def _describe_comparison(result: object) -> str: + if not isinstance(result, dict): + return "基金对比分析暂不可用,请稍后重试。" + if result.get("status") == "evidence_required": + return "部分基金缺少当前有效的权威证据,暂不能完成可靠对比。" + products = result.get("products") + if result.get("status") != "ready" or not isinstance(products, list): + return "对比分析需要至少两个有效的场内基金产品。" + names = [ + f"{item.get('product_code')} {item.get('product_name')}" + for item in products if isinstance(item, dict) + ] + common = result.get("common_industries") + common_text = "、".join(str(item) for item in common) if isinstance(common, list) else "无" + return ( + "对比分析:" + ";".join(names) + + f"。共同行业暴露:{common_text}。仅供分析参考,不构成交易指令。" + ) diff --git a/app/service/agent_persistence_service.py b/app/service/agent_persistence_service.py index 9fe1cde..3446e0e 100644 --- a/app/service/agent_persistence_service.py +++ b/app/service/agent_persistence_service.py @@ -10,6 +10,7 @@ from app.core.errors import RunLeaseLostError from app.model.audit import InteractionAudit from app.model.conversation import ConversationMessage from app.model.platform import AgentRun, DomainEventOutbox, RequestIdempotency +from app.model.session import ConversationSession #: 治理层追加免责声明时使用的分隔形状(`app/service/agent/governance.py` 里定义)。 #: 这里只用于**审计留痕**,不参与任何判定:判据是"末尾是否出现这个形状"。 @@ -78,6 +79,13 @@ class AgentPersistenceService: run.completed_at = now run.updated_at = now run.locked_until = None + session_row = await self.session.scalar(select(ConversationSession).where( + ConversationSession.session_id == run.session_id, + ConversationSession.user_id == run.user_id, + ).with_for_update()) + if session_row is not None and result.result.intent is not None: + session_row.last_intent = result.result.intent.intent + session_row.updated_at = now self.session.add(InteractionAudit( actor_type="agent", actor_id=run.user_id, target_customer_id=run.user_id, session_id=run.session_id, portal="agent", action_type="agent.run_completed", diff --git a/app/service/agent_run_application_service.py b/app/service/agent_run_application_service.py index a3cb464..8f4dce0 100644 --- a/app/service/agent_run_application_service.py +++ b/app/service/agent_run_application_service.py @@ -19,6 +19,7 @@ from app.model.conversation import ConversationMessage from app.model.platform import AgentRun, RequestIdempotency from app.model.session import ConversationSession from app.repository.outbox_repository import OutboxRepository +from app.service.advisor_rollout_service import AdvisorRolloutService from app.service.agent.bootstrap import get_agent_factory from app.service.agent.factory import AgentFactory @@ -36,6 +37,8 @@ class AgentRunApplicationService: self.factory = factory if factory is not None else get_agent_factory() async def accept(self, request: AgentRequest, context: RequestContext) -> RunAccepted: + if request.agent_type == "advisor": + await AdvisorRolloutService(self.session).ensure_allowed(context) try: self.factory.authorize(request.agent_type, context) except ForbiddenAgentError: diff --git a/app/service/allocation_backtest_service.py b/app/service/allocation_backtest_service.py new file mode 100644 index 0000000..0815f06 --- /dev/null +++ b/app/service/allocation_backtest_service.py @@ -0,0 +1,346 @@ +"""Historical, analysis-only validation of static and dynamic allocations.""" + +from collections import defaultdict +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from datetime import UTC, date, datetime +from decimal import Decimal +from typing import Any +from uuid import uuid4 + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.advisor_backtest_contracts import AllocationBacktestQuery +from app.core.contracts import RequestContext +from app.infrastructure.db import SessionFactory +from app.model.advisor_product import ( + AdvisorAllocationBacktestRun, + AdvisorProductPriceHistory, +) +from app.model.audit import InteractionAudit +from app.repository.advisor_product_repository import AdvisorProductRepository +from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository +from app.service.asset_allocation_service import AssetAllocationService +from app.service.authorization_service import AuthorizationService +from app.service.dynamic_allocation_optimizer import ( + ASSET_CLASSES, + AssetClassMarketMetric, + DynamicAllocationOptimizer, +) +from app.service.product_governance_monitor_service import SALES_INSTITUTION + + +@dataclass(frozen=True) +class BacktestObservation: + trade_date: date + returns_pct: dict[str, Decimal] + liquidity_observed: bool + + +@dataclass(frozen=True) +class Performance: + total_return_pct: Decimal + max_drawdown_pct: Decimal + + +@dataclass(frozen=True) +class BacktestResult: + status: str + observation_count: int + static: Performance | None + dynamic: Performance | None + dynamic_rebalance_count: int + liquidity_history_coverage_pct: Decimal + limitations: tuple[str, ...] + + +class AllocationBacktestEngine: + @staticmethod + def run( + observations: Sequence[BacktestObservation], + static_weights: dict[str, int], + dynamic_weights: dict[date, dict[str, int]], + ) -> BacktestResult: + ordered = sorted(observations, key=lambda item: item.trade_date) + liquidity_count = sum(item.liquidity_observed for item in ordered) + liquidity_coverage = ( + Decimal(liquidity_count) / Decimal(len(ordered)) * Decimal("100") + if ordered + else Decimal() + ) + limitations: list[str] = [] + if len(ordered) < 120: + limitations.append("历史观察不足 120 个交易日,动态优化无法覆盖完整窗口。") + if liquidity_coverage < 80: + limitations.append("流动性历史字段覆盖率低于 80%,流动性结论受限。") + status = "ready" + if len(ordered) < 20: + status = "insufficient_history" + elif liquidity_coverage < 80: + status = "partial" + return BacktestResult( + status=status, + observation_count=len(ordered), + static=( + AllocationBacktestEngine._performance(ordered, static_weights) if ordered else None + ), + dynamic=( + AllocationBacktestEngine._performance_by_date( + ordered, dynamic_weights, static_weights + ) + if ordered + else None + ), + dynamic_rebalance_count=sum( + 1 for item in ordered if item.trade_date in dynamic_weights + ), + liquidity_history_coverage_pct=liquidity_coverage, + limitations=tuple(limitations), + ) + + @staticmethod + def _performance( + observations: Sequence[BacktestObservation], weights: dict[str, int] + ) -> Performance: + return AllocationBacktestEngine._performance_by_date(observations, {}, weights) + + @staticmethod + def _performance_by_date( + observations: Sequence[BacktestObservation], + weights_by_date: dict[date, dict[str, int]], + fallback: dict[str, int], + ) -> Performance: + value = Decimal("1") + peak = value + max_drawdown = Decimal() + for observation in observations: + weights = weights_by_date.get(observation.trade_date, fallback) + daily_return = sum( + Decimal(weight) + / Decimal("100") + * observation.returns_pct.get(asset_class, Decimal()) + / Decimal("100") + for asset_class, weight in weights.items() + ) + value *= Decimal("1") + daily_return + peak = max(peak, value) + if peak > 0: + max_drawdown = min(max_drawdown, value / peak - Decimal("1")) + return Performance( + total_return_pct=((value - Decimal("1")) * Decimal("100")).quantize(Decimal("0.0001")), + max_drawdown_pct=(max_drawdown * Decimal("100")).quantize(Decimal("0.0001")), + ) + + +class AllocationBacktestService: + def __init__(self, *, session_factory: Callable[[], Any] = SessionFactory) -> None: + self.session_factory = session_factory + + async def run( + self, payload: AllocationBacktestQuery, context: RequestContext, key: str | None = None + ) -> dict[str, object]: + await AuthorizationService.require(context, "asset-allocation:backtest", admin=True) + result = await self._calculate(payload) + + async def operation(session: AsyncSession) -> dict[str, Any]: + row = AdvisorAllocationBacktestRun( + backtest_no=f"AB-{uuid4().hex[:24]}", + started_on=payload.started_on, + ended_on=payload.ended_on, + profile_risk_level=f"C{payload.profile_risk_level}", + return_target_lower_pct=Decimal(payload.return_target_lower_pct), + max_drawdown_pct=Decimal(payload.max_drawdown_pct), + liquidity_requirement=payload.liquidity_requirement, + status=result.status, + observation_count=result.observation_count, + static_total_return_pct=result.static.total_return_pct if result.static else None, + dynamic_total_return_pct=result.dynamic.total_return_pct + if result.dynamic + else None, + static_max_drawdown_pct=result.static.max_drawdown_pct if result.static else None, + dynamic_max_drawdown_pct=result.dynamic.max_drawdown_pct + if result.dynamic + else None, + dynamic_rebalance_count=result.dynamic_rebalance_count, + liquidity_history_coverage_pct=result.liquidity_history_coverage_pct, + limitations=list(result.limitations), + strategy_version="constrained_historical_multi_factor_v1", + created_at=datetime.now(UTC).replace(tzinfo=None), + ) + session.add(row) + session.add( + InteractionAudit( + actor_type="user", + actor_id=int(context.user_id), + portal=context.portal, + action_type="advisor.allocation_backtest_created", + detail={"backtest_no": row.backtest_no, "trace_id": context.trace_id}, + created_at=datetime.now(UTC).replace(tzinfo=None), + ) + ) + await session.flush() + return {"data": self._view(row), "meta": {"trace_id": context.trace_id}} + + from app.service.api_transaction_service import ApiTransactionService + + return await ApiTransactionService().execute( + context, + f"advisor:allocation-backtests:{payload.started_on}:{payload.ended_on}", + key, + payload.model_dump(mode="json"), + operation, + ) + + async def _calculate(self, payload: AllocationBacktestQuery) -> BacktestResult: + end_at = datetime.combine(payload.ended_on, datetime.max.time()) + async with self.session_factory() as session: + candidates = await AdvisorProductRepository(session).authoritative_tradable_products( + end_at, sales_institution=SALES_INSTITUTION, limit=100 + ) + candidates, _ = AdvisorProductRepository.hard_suitability_filter( + candidates, payload.profile_risk_level + ) + product_ids = tuple(item.product.id for item in candidates) + repository = PortfolioAnalysisRepository(session) + classifications = await repository.latest_asset_classifications( + product_ids, payload.ended_on + ) + qualities = await repository.latest_quality(product_ids, payload.ended_on) + rows = list( + await session.scalars( + select(AdvisorProductPriceHistory) + .where( + AdvisorProductPriceHistory.product_id.in_(product_ids), + AdvisorProductPriceHistory.trade_date >= payload.started_on, + AdvisorProductPriceHistory.trade_date <= payload.ended_on, + AdvisorProductPriceHistory.price_kind == "fund_nav", + ) + .order_by( + AdvisorProductPriceHistory.product_id, AdvisorProductPriceHistory.trade_date + ) + ) + ) + eligible = { + product_id: classification.asset_class + for product_id, classification in classifications.items() + if classification.asset_class in ASSET_CLASSES + and qualities.get(product_id) is not None + and qualities[product_id].status == "accepted" + } + return self._calculate_from_rows(rows, eligible, payload) + + @staticmethod + def _calculate_from_rows( + rows: Sequence[AdvisorProductPriceHistory], + eligible: dict[int, str], + payload: AllocationBacktestQuery, + ) -> BacktestResult: + per_product: dict[int, list[AdvisorProductPriceHistory]] = defaultdict(list) + for row in rows: + if row.product_id in eligible: + per_product[row.product_id].append(row) + daily_returns: dict[date, dict[str, list[Decimal]]] = defaultdict(lambda: defaultdict(list)) + daily_liquidity: dict[date, list[bool]] = defaultdict(list) + for product_id, history in per_product.items(): + for previous, current in zip(history, history[1:], strict=False): + if previous.close_price <= 0: + continue + daily_returns[current.trade_date][eligible[product_id]].append( + (current.close_price / previous.close_price - Decimal("1")) * Decimal("100") + ) + daily_liquidity[current.trade_date].append(current.turnover_amount is not None) + observations = [ + BacktestObservation( + trade_date=trade_date, + returns_pct={ + asset_class: sum(values, Decimal()) / len(values) + for asset_class, values in returns.items() + }, + liquidity_observed=all(daily_liquidity[trade_date]), + ) + for trade_date, returns in sorted(daily_returns.items()) + if returns + ] + static = AssetAllocationService._strategic_weights( + f"C{payload.profile_risk_level}", + max(13, (payload.ended_on - payload.started_on).days // 30), + payload.liquidity_requirement, + Decimal(payload.max_drawdown_pct), + ) + dynamic: dict[date, dict[str, int]] = {} + for index in range(119, len(observations), 20): + metrics = AllocationBacktestService._rolling_metrics( + observations[index - 119 : index + 1] + ) + optimized = DynamicAllocationOptimizer.optimize( + static, + metrics, + return_target_lower_pct=Decimal(payload.return_target_lower_pct), + max_drawdown_pct=Decimal(payload.max_drawdown_pct), + liquidity_requirement=payload.liquidity_requirement, + ) + dynamic[observations[index].trade_date] = optimized.weights + return AllocationBacktestEngine.run(observations, static, dynamic) + + @staticmethod + def _rolling_metrics( + observations: Sequence[BacktestObservation], + ) -> list[AssetClassMarketMetric]: + grouped: dict[str, list[Decimal]] = defaultdict(list) + liquidity: dict[str, list[Decimal]] = defaultdict(list) + for observation in observations: + for asset_class, value in observation.returns_pct.items(): + grouped[asset_class].append(value) + if observation.liquidity_observed: + liquidity[asset_class].append(Decimal("1")) + result: list[AssetClassMarketMetric] = [] + for asset_class, returns in grouped.items(): + value = Decimal("1") + peak = value + drawdown = Decimal() + for item in returns: + value *= Decimal("1") + item / Decimal("100") + peak = max(peak, value) + drawdown = min(drawdown, value / peak - Decimal("1")) + result.append( + AssetClassMarketMetric( + asset_class=asset_class, + trailing_120d_return_pct=(value - Decimal("1")) * Decimal("100"), + max_drawdown_pct=drawdown * Decimal("100"), + average_daily_turnover_amount=Decimal("10000000") + * (Decimal(len(liquidity[asset_class])) / Decimal(len(returns))), + product_count=len(returns), + ) + ) + return result + + @staticmethod + def _view(row: AdvisorAllocationBacktestRun) -> dict[str, object]: + return { + "backtest_no": row.backtest_no, + "started_on": row.started_on.isoformat(), + "ended_on": row.ended_on.isoformat(), + "profile_risk_level": row.profile_risk_level, + "return_target_lower_pct": str(row.return_target_lower_pct), + "max_drawdown_pct": str(row.max_drawdown_pct), + "liquidity_requirement": row.liquidity_requirement, + "status": row.status, + "observation_count": row.observation_count, + "static_total_return_pct": str(row.static_total_return_pct) + if row.static_total_return_pct is not None + else None, + "dynamic_total_return_pct": str(row.dynamic_total_return_pct) + if row.dynamic_total_return_pct is not None + else None, + "static_max_drawdown_pct": str(row.static_max_drawdown_pct) + if row.static_max_drawdown_pct is not None + else None, + "dynamic_max_drawdown_pct": str(row.dynamic_max_drawdown_pct) + if row.dynamic_max_drawdown_pct is not None + else None, + "dynamic_rebalance_count": row.dynamic_rebalance_count, + "liquidity_history_coverage_pct": str(row.liquidity_history_coverage_pct), + "limitations": row.limitations, + "strategy_version": row.strategy_version, + } diff --git a/app/service/asset_allocation_service.py b/app/service/asset_allocation_service.py new file mode 100644 index 0000000..b4c63b6 --- /dev/null +++ b/app/service/asset_allocation_service.py @@ -0,0 +1,194 @@ +"""Dynamic, analysis-only asset allocation from confirmed goals and history.""" + +from collections import defaultdict +from collections.abc import Callable +from datetime import UTC, date, datetime +from decimal import Decimal +from typing import Any + +from app.core.advisor_allocation_contracts import AssetAllocationQuery +from app.core.contracts import RequestContext +from app.infrastructure.db import SessionFactory +from app.repository.advisor_product_repository import AdvisorProductRepository +from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository +from app.service.authorization_service import AuthorizationService +from app.service.dynamic_allocation_optimizer import ( + ASSET_CLASSES, + AssetClassMarketMetric, + DynamicAllocationOptimizer, +) +from app.service.investment_goal_service import InvestmentGoalService +from app.service.product_governance_monitor_service import SALES_INSTITUTION +from app.service.profile_governance_service import ProfileGovernanceService +from app.service.suitability_service import SuitabilityService + +BASE_ALLOCATIONS = { + "C1": {"cash_management_etf": 50, "bond_etf": 40, "equity_etf": 10}, + "C2": {"cash_management_etf": 30, "bond_etf": 50, "equity_etf": 20}, + "C3": {"cash_management_etf": 15, "bond_etf": 45, "equity_etf": 40}, + "C4": {"cash_management_etf": 10, "bond_etf": 25, "equity_etf": 65}, + "C5": {"cash_management_etf": 5, "bond_etf": 15, "equity_etf": 80}, +} +ASSET_LABELS = { + "cash_management_etf": "现金管理类场内基金", + "bond_etf": "债券类场内基金", + "equity_etf": "权益类场内基金", +} + + +class AssetAllocationService: + def __init__( + self, + *, + session_factory: Callable[[], Any] = SessionFactory, + enforce_profile_governance: bool = False, + ) -> None: + self.session_factory = session_factory + self.enforce_profile_governance = enforce_profile_governance + + async def generate_for_agent( + self, _arguments: AssetAllocationQuery, context: RequestContext + ) -> dict[str, object]: + await AuthorizationService.require(context, "asset-allocation:generate:self") + if self.enforce_profile_governance: + await ProfileGovernanceService().require_operable(int(context.user_id)) + authority = await SuitabilityService().authority_for_customer(int(context.user_id)) + if authority.customer_risk_level is None: + return {"status": "profile_required"} + goal = await InvestmentGoalService().current_for_agent(context) + if goal is None: + return {"status": "investment_goal_required"} + horizon = goal.get("investment_horizon_months") + if not isinstance(horizon, int): + return {"status": "investment_goal_invalid"} + risk = f"C{authority.customer_risk_level}" + liquidity = str(goal["liquidity_requirement"]) + strategic = self._strategic_weights( + risk, horizon, liquidity, Decimal(str(goal["max_drawdown_pct"])) + ) + metrics = await self._market_metrics(authority.customer_risk_level) + optimized = DynamicAllocationOptimizer.optimize( + strategic, + metrics, + return_target_lower_pct=Decimal(str(goal["annualized_return_lower_pct"])), + max_drawdown_pct=Decimal(str(goal["max_drawdown_pct"])), + liquidity_requirement=liquidity, + ) + return { + "status": "ready", + "allocation": [ + {"asset_class": key, "label": ASSET_LABELS[key], "target_pct": value} + for key, value in optimized.weights.items() + ], + "optimization": { + "method": "constrained_historical_multi_factor_v1", + "dynamic": optimized.dynamic, + "metric_coverage_pct": str(optimized.metric_coverage_pct.quantize(Decimal("0.01"))), + "strategic_allocation": strategic, + "factor_evidence": optimized.factors, + }, + "constraints": { + "annualized_return_lower_pct": goal["annualized_return_lower_pct"], + "max_drawdown_pct": goal["max_drawdown_pct"], + "liquidity_requirement": liquidity, + "investment_horizon_months": horizon, + }, + "analysis_only": True, + } + + async def _market_metrics(self, customer_risk_level: int) -> list[AssetClassMarketMetric]: + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session_factory() as session: + candidates = await AdvisorProductRepository(session).authoritative_tradable_products( + now, sales_institution=SALES_INSTITUTION, limit=50 + ) + candidates, _excluded = AdvisorProductRepository.hard_suitability_filter( + candidates, customer_risk_level + ) + ids = tuple(item.product.id for item in candidates) + repository = PortfolioAnalysisRepository(session) + classifications = await repository.latest_asset_classifications(ids, date.today()) + snapshots = await repository.latest_metrics(ids, date.today()) + qualities = await repository.latest_quality(ids, date.today()) + grouped: dict[str, list[AssetClassMarketMetric]] = defaultdict(list) + for product_id in ids: + classification = classifications.get(product_id) + metric = snapshots.get(product_id) + quality = qualities.get(product_id) + if ( + classification is None + or classification.asset_class not in ASSET_CLASSES + or quality is None + or quality.status != "accepted" + or metric is None + or metric.trailing_120d_return_pct is None + or metric.max_drawdown_pct is None + or metric.average_daily_turnover_amount is None + or metric.observation_count < 20 + ): + continue + grouped[classification.asset_class].append( + AssetClassMarketMetric( + asset_class=classification.asset_class, + trailing_120d_return_pct=metric.trailing_120d_return_pct, + max_drawdown_pct=metric.max_drawdown_pct, + average_daily_turnover_amount=metric.average_daily_turnover_amount, + product_count=1, + ) + ) + return [ + AssetClassMarketMetric( + asset_class=key, + trailing_120d_return_pct=sum( + (item.trailing_120d_return_pct for item in rows), Decimal() + ) + / len(rows), + max_drawdown_pct=( + sum((item.max_drawdown_pct for item in rows), Decimal()) / len(rows) + ), + average_daily_turnover_amount=sum( + (item.average_daily_turnover_amount for item in rows), Decimal() + ) + / len(rows), + product_count=len(rows), + ) + for key, rows in grouped.items() + ] + + @staticmethod + def _strategic_weights( + risk: str, horizon: int, liquidity: str, max_drawdown: Decimal + ) -> dict[str, int]: + weights = dict(BASE_ALLOCATIONS[risk]) + if horizon <= 12: + AssetAllocationService._move(weights, "equity_etf", "cash_management_etf", 10) + elif horizon >= 60 and risk != "C1": + AssetAllocationService._move(weights, "bond_etf", "equity_etf", 5) + if liquidity == "daily": + AssetAllocationService._move(weights, "equity_etf", "cash_management_etf", 10) + AssetAllocationService._move(weights, "bond_etf", "cash_management_etf", 5) + if max_drawdown <= 10: + AssetAllocationService._cap_equity(weights, 20) + elif max_drawdown <= 20: + AssetAllocationService._cap_equity(weights, 40) + return weights + + @staticmethod + def _move(weights: dict[str, int], source: str, target: str, amount: int) -> None: + moved = min(weights[source], amount) + weights[source] -= moved + weights[target] += moved + + @staticmethod + def _cap_equity(weights: dict[str, int], cap: int) -> None: + excess = max(0, weights["equity_etf"] - cap) + weights["equity_etf"] -= excess + weights["bond_etf"] += excess + + +async def asset_allocation_tool( + arguments: AssetAllocationQuery, context: RequestContext +) -> dict[str, object]: + return await AssetAllocationService(enforce_profile_governance=True).generate_for_agent( + arguments, context + ) diff --git a/app/service/dynamic_allocation_optimizer.py b/app/service/dynamic_allocation_optimizer.py new file mode 100644 index 0000000..d99446c --- /dev/null +++ b/app/service/dynamic_allocation_optimizer.py @@ -0,0 +1,160 @@ +"""Explainable constraint-first allocation optimizer.""" + +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal + +HUNDRED = Decimal("100") +ASSET_CLASSES = ("cash_management_etf", "bond_etf", "equity_etf") + + +@dataclass(frozen=True) +class AssetClassMarketMetric: + asset_class: str + trailing_120d_return_pct: Decimal + max_drawdown_pct: Decimal + average_daily_turnover_amount: Decimal + product_count: int + + +@dataclass(frozen=True) +class DynamicAllocationResult: + weights: dict[str, int] + dynamic: bool + metric_coverage_pct: Decimal + factors: list[dict[str, object]] + + +class DynamicAllocationOptimizer: + @classmethod + def optimize( + cls, + strategic_weights: dict[str, int], + metrics: list[AssetClassMarketMetric], + *, + return_target_lower_pct: Decimal, + max_drawdown_pct: Decimal, + liquidity_requirement: str, + ) -> DynamicAllocationResult: + available = { + item.asset_class: item for item in metrics if item.asset_class in ASSET_CLASSES + } + coverage = Decimal(len(available)) / Decimal(len(ASSET_CLASSES)) * HUNDRED + if len(available) < 2: + return DynamicAllocationResult( + dict(strategic_weights), False, coverage, cls._factors(metrics, {}) + ) + scores = { + key: cls._score( + value, metrics, return_target_lower_pct, max_drawdown_pct, liquidity_requirement + ) + for key, value in available.items() + } + weights = {key: Decimal(value) for key, value in strategic_weights.items()} + mean = sum(scores.values(), Decimal()) / Decimal(len(scores)) + winners = {key: score - mean for key, score in scores.items() if score > mean} + losers = {key: mean - score for key, score in scores.items() if score < mean} + if winners and losers: + tilt = min(Decimal("15"), sum(weights.get(key, Decimal()) for key in losers)) + for key, value in winners.items(): + weights[key] = weights.get(key, Decimal()) + tilt * value / sum(winners.values()) + for key, value in losers.items(): + weights[key] = max( + Decimal(), weights.get(key, Decimal()) - tilt * value / sum(losers.values()) + ) + equity_cap = ( + Decimal("20") + if max_drawdown_pct <= 10 + else Decimal("40") + if max_drawdown_pct <= 20 + else Decimal("85") + ) + excess = max(Decimal(), weights["equity_etf"] - equity_cap) + weights["equity_etf"] -= excess + weights["bond_etf"] += excess + cash_min = { + "daily": Decimal("25"), + "within_7_days": Decimal("15"), + "within_30_days": Decimal("5"), + "over_30_days": Decimal(), + }[liquidity_requirement] + shortfall = max(Decimal(), cash_min - weights["cash_management_etf"]) + for key in ("bond_etf", "equity_etf"): + moved = min(shortfall, weights[key]) + weights[key] -= moved + weights["cash_management_etf"] += moved + shortfall -= moved + rounded = { + key: int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + for key, value in weights.items() + } + largest = max(rounded, key=lambda key: rounded[key]) + rounded[largest] += 100 - sum(rounded.values()) + return DynamicAllocationResult(rounded, True, coverage, cls._factors(metrics, scores)) + + @staticmethod + def _score( + metric: AssetClassMarketMetric, + peers: list[AssetClassMarketMetric], + target: Decimal, + drawdown_limit: Decimal, + liquidity_requirement: str, + ) -> Decimal: + returns = [item.trailing_120d_return_pct for item in peers] + liquidities = [item.average_daily_turnover_amount for item in peers] + return_fit = ( + min( + Decimal("1"), + max(Decimal(), metric.trailing_120d_return_pct * Decimal("2.1") / target), + ) + if target > 0 + else Decimal("0.5") + ) + return_rank = DynamicAllocationOptimizer._rank(metric.trailing_120d_return_pct, returns) + drawdown = abs(metric.max_drawdown_pct) + drawdown_fit = ( + min(Decimal("1"), drawdown_limit / drawdown) if drawdown > 0 else Decimal("1") + ) + liquidity_floor = { + "daily": Decimal("10000000"), + "within_7_days": Decimal("3000000"), + "within_30_days": Decimal("500000"), + "over_30_days": Decimal(), + }[liquidity_requirement] + liquidity_fit = ( + min(Decimal("1"), metric.average_daily_turnover_amount / liquidity_floor) + if liquidity_floor + else Decimal("0.5") + ) + liquidity_rank = DynamicAllocationOptimizer._rank( + metric.average_daily_turnover_amount, liquidities + ) + return ( + Decimal("0.45") * (return_fit + return_rank) / 2 + + Decimal("0.35") * drawdown_fit + + Decimal("0.20") * (liquidity_fit + liquidity_rank) / 2 + ) + + @staticmethod + def _rank(value: Decimal, peers: list[Decimal]) -> Decimal: + low, high = min(peers), max(peers) + return Decimal("0.5") if low == high else (value - low) / (high - low) + + @staticmethod + def _factors( + metrics: list[AssetClassMarketMetric], scores: dict[str, Decimal] + ) -> list[dict[str, object]]: + return [ + { + "asset_class": item.asset_class, + "trailing_120d_return_pct": str(item.trailing_120d_return_pct), + "max_drawdown_pct": str(item.max_drawdown_pct), + "average_daily_turnover_amount": str(item.average_daily_turnover_amount), + "product_count": item.product_count, + "composite_score": ( + str(scores[item.asset_class].quantize(Decimal("0.0001"))) + if item.asset_class in scores + else None + ), + } + for item in sorted(metrics, key=lambda value: value.asset_class) + ] diff --git a/app/service/goal_conversation_service.py b/app/service/goal_conversation_service.py new file mode 100644 index 0000000..ce7a644 --- /dev/null +++ b/app/service/goal_conversation_service.py @@ -0,0 +1,228 @@ +"""Conversation-level investment-goal entity extraction and gap handling.""" + +import re +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any, Protocol + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.db import SessionFactory +from app.model.audit import InteractionAudit +from app.model.conversation import ConversationMessage +from app.model.goal_conversation import AdvisorGoalConversationExtraction +from app.model.session import ConversationSession + +EXTRACTION_VERSION = "investment-goal-v1" +MAX_CLARIFICATION_ROUNDS = 10 +REQUIRED_FIELDS = ( + "annualized_return_lower_pct", + "annualized_return_upper_pct", + "max_drawdown_pct", + "liquidity_requirement", + "investment_horizon_months", + "benchmark_name", +) + +FIELD_LABELS = { + "annualized_return_lower_pct": "年化收益目标下限", + "annualized_return_upper_pct": "年化收益目标上限", + "max_drawdown_pct": "最大回撤容忍度", + "liquidity_requirement": "资金流动性要求", + "investment_horizon_months": "投资期限", + "benchmark_name": "业绩比较基准", +} + +_NUMBER = r"\d+(?:\.\d+)?" + + +class SessionFactoryLike(Protocol): + def __call__(self) -> AsyncSession: ... + + +def _number(value: str) -> float: + return float(Decimal(value)) + + +def extract_goal_entities(text: str) -> dict[str, Any]: + """Extract explicit goal facts from one user message. + + This parser intentionally accepts only explicit financial statements. It does not + infer a risk level, expected return, or benchmark from vague language. + """ + result: dict[str, Any] = {} + return_match = re.search( + rf"(?:年化)?(?:收益目标|收益率|收益)\s*(?:为|在|约为|目标为)?\s*({_NUMBER})\s*%?\s*" + rf"(?:至|到|[-~~])\s*({_NUMBER})\s*%?", + text, + re.IGNORECASE, + ) + if return_match: + lower, upper = map(_number, return_match.groups()) + result["annualized_return_lower_pct"] = min(lower, upper) + result["annualized_return_upper_pct"] = max(lower, upper) + else: + single_return = re.search( + rf"(?:年化)?(?:收益目标|收益率|收益)\s*" + rf"(?:是|为|约为|目标为)?\s*({_NUMBER})\s*%", + text, + re.IGNORECASE, + ) + if single_return: + value = _number(single_return.group(1)) + result["annualized_return_lower_pct"] = value + result["annualized_return_upper_pct"] = value + + drawdown = re.search(rf"(?:最大)?回撤\s*(?:容忍|控制|不超过|为|约为)?\s*({_NUMBER})\s*%?", text) + if drawdown: + result["max_drawdown_pct"] = _number(drawdown.group(1)) + + if re.search(r"随时|每天|每日|当天|日内", text): + result["liquidity_requirement"] = "daily" + else: + days = re.search( + r"(?:流动性|用钱|变现|取出).{0,12}?" + r"([一两两三四五六七八九十0-9]+)\s*天", + text, + ) + if days is None: + days = re.search( + r"([一两三四五六七八九十0-9]+)\s*天(?:内|可用|能用)", text + ) + if days: + count = _chinese_number(days.group(1)) + if count <= 7: + result["liquidity_requirement"] = "within_7_days" + elif count <= 30: + result["liquidity_requirement"] = "within_30_days" + else: + result["liquidity_requirement"] = "over_30_days" + + months = re.search(rf"({_NUMBER})\s*(?:年|岁)", text) + if months: + result["investment_horizon_months"] = int(_number(months.group(1)) * 12) + else: + months = re.search(rf"({_NUMBER})\s*个?月", text) + if months: + result["investment_horizon_months"] = int(_number(months.group(1))) + + benchmark = re.search( + r"(?:业绩比较基准|比较基准|基准)\s*(?:是|为|选)?\s*" + r"([\u4e00-\u9fffA-Za-z0-9_-]{2,32})", + text, + ) + if benchmark: + result["benchmark_name"] = benchmark.group(1).strip(",。;、") + else: + for candidate in ("沪深300指数", "沪深300", "中证500", "中证全指", "上证指数"): + if candidate in text: + result["benchmark_name"] = candidate + break + return result + + +def _chinese_number(value: str) -> int: + if value.isdigit(): + return int(value) + digits = {"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, + "六": 6, "七": 7, "八": 8, "九": 9, "十": 10} + if value == "十": + return 10 + if value.startswith("十"): + return 10 + digits.get(value[1:], 0) + if value.endswith("十"): + return digits.get(value[0], 0) * 10 + if "十" in value: + left, right = value.split("十", 1) + return digits.get(left, 0) * 10 + digits.get(right, 0) + return digits.get(value, 0) + + +class GoalConversationService: + """Merge explicit facts across a session and persist an internal gap snapshot.""" + + def __init__( + self, session_factory: SessionFactoryLike = SessionFactory, + ) -> None: + self.session_factory = session_factory + + async def process( + self, *, session_id: str, customer_id: int, trace_id: str, message: str + ) -> dict[str, Any]: + async with self.session_factory() as session, session.begin(): + session_row = await session.scalar(select(ConversationSession).where( + ConversationSession.session_id == session_id, + ConversationSession.user_id == customer_id, + ).with_for_update()) + if session_row is None: + return self._result({}, list(REQUIRED_FIELDS), "partial") + messages = list(await session.scalars(select(ConversationMessage).where( + ConversationMessage.session_id == session_id, + ConversationMessage.customer_id == customer_id, + ConversationMessage.role == "user", + ).order_by(ConversationMessage.created_at, ConversationMessage.id))) + merged: dict[str, Any] = {} + for row in messages: + merged.update(extract_goal_entities(row.content)) + merged.update(extract_goal_entities(message)) + missing = [field for field in REQUIRED_FIELDS if field not in merged] + status = "complete" if not missing else ( + "clarification_limit" + if session_row.clarification_round >= MAX_CLARIFICATION_ROUNDS + else "partial" + ) + if status == "partial": + session_row.clarification_round += 1 + now = datetime.now(UTC).replace(tzinfo=None) + message_row = await session.scalar(select(ConversationMessage).where( + ConversationMessage.session_id == session_id, + ConversationMessage.customer_id == customer_id, + ConversationMessage.trace_id == trace_id, + ConversationMessage.role == "user", + ).order_by(ConversationMessage.id.desc()).limit(1)) + if message_row is not None: + exists = await session.scalar(select(AdvisorGoalConversationExtraction).where( + AdvisorGoalConversationExtraction.message_id == message_row.id)) + if exists is None: + session.add(AdvisorGoalConversationExtraction( + message_id=message_row.id, session_id=session_id, + customer_id=customer_id, extraction_version=EXTRACTION_VERSION, + extracted_fields=merged, missing_fields=missing, status=status, + created_at=now, updated_at=now, + )) + session.add(InteractionAudit( + actor_type="agent", actor_id=customer_id, session_id=session_id, + portal="agent", action_type="advisor.goal_gap_evaluated", + detail={"trace_id": trace_id, "status": status, + "missing_count": len(missing)}, created_at=now, + )) + return self._result(merged, missing, status) + + @staticmethod + def _result(fields: dict[str, Any], missing: list[str], status: str) -> dict[str, Any]: + return {"fields": fields, "missing": missing, "status": status} + + @staticmethod + def customer_prompt(result: dict[str, Any]) -> str: + missing = result.get("missing", []) + if result.get("status") == "clarification_limit": + return "投资目标信息仍不完整,暂时无法继续采集,请转人工顾问协助确认。" + if missing: + labels = [FIELD_LABELS[field] for field in missing if field in FIELD_LABELS] + return "为了形成投资目标,还需要确认:" + "、".join(labels) + "。请一次补充这些信息。" + fields = result["fields"] + return ( + f"投资目标信息已收集完整:年化收益目标 {fields['annualized_return_lower_pct']}%-" + f"{fields['annualized_return_upper_pct']}%,最大回撤 {fields['max_drawdown_pct']}%," + f"流动性 {fields['liquidity_requirement']}," + f"期限 {fields['investment_horizon_months']} 个月," + f"业绩比较基准 {fields['benchmark_name']}。请确认后再生成目标书。" + ) + + @staticmethod + def should_collect(text: str) -> bool: + """Distinguish goal collection from a request to view an existing goal.""" + return bool(extract_goal_entities(text)) or bool(re.search( + r"制定|设定|创建|填写|采集|规划|目标书", text + )) diff --git a/app/service/investment_goal_service.py b/app/service/investment_goal_service.py new file mode 100644 index 0000000..09d145b --- /dev/null +++ b/app/service/investment_goal_service.py @@ -0,0 +1,373 @@ +"""Application service for investment-goal collection and goal-book workflow.""" + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.contracts import RequestContext +from app.core.errors import ( + GenericResourceNotFoundError, + InvalidStateError, + ResourceAlreadyExistsError, + ValidationAgentError, +) +from app.core.investment_goal_contracts import ( + InvestmentGoalBookPublish, + InvestmentGoalBookReview, + InvestmentGoalCreate, + InvestmentGoalQuery, +) +from app.infrastructure.db import SessionFactory +from app.model.audit import InteractionAudit +from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent +from app.repository.investment_goal_repository import InvestmentGoalRepository +from app.service.api_transaction_service import ApiTransactionService +from app.service.authorization_service import AuthorizationService + +_LIQUIDITY_LABELS = { + "daily": "可随时使用", + "within_7_days": "7 日内可使用", + "within_30_days": "30 日内可使用", + "over_30_days": "30 日后可使用", +} +_PROHIBITED_GOAL_PHRASES = ("保本", "保证收益", "稳赚", "无风险", "收益承诺") + + +class InvestmentGoalService: + async def create( + self, payload: InvestmentGoalCreate, context: RequestContext, key: str | None + ) -> dict[str, object]: + customer_id = await self._resolve_customer(payload.customer_id, context, "write") + self._validate_notes(payload.notes) + + async def operation(session: AsyncSession) -> dict[str, Any]: + now = _utc_now() + goal_no = f"IG-{uuid4().hex[:24]}" + repository = InvestmentGoalRepository(session) + content = ClientFacingContent( + customer_id=customer_id, + content_type="investment_goal_book", + draft_content=self._goal_book(goal_no, payload), + generated_by_portal=context.portal, + review_status="pending", + reviewer_user_id=None, + reviewed_at=None, + published_at=None, + created_at=now, + updated_at=now, + ) + repository.add_goal_book(content) + await session.flush() + goal = AdvisorInvestmentGoal( + goal_no=goal_no, + customer_id=customer_id, + status="pending_confirmation", + annualized_return_lower_pct=payload.annualized_return_lower_pct, + annualized_return_upper_pct=payload.annualized_return_upper_pct, + max_drawdown_pct=payload.max_drawdown_pct, + liquidity_requirement=payload.liquidity_requirement, + investment_horizon_months=payload.investment_horizon_months, + benchmark_name=payload.benchmark_name, + notes=payload.notes, + source="customer" if customer_id == int(context.user_id) else "advisor", + goal_book_content_id=content.id, + created_by=int(context.user_id), + confirmed_by=None, + confirmed_at=None, + created_at=now, + updated_at=now, + ) + repository.add_goal(goal) + self._audit(session, context, customer_id, "advisor.investment_goal_collected", { + "goal_no": goal_no, + "status": goal.status, + "goal_book_content_id": content.id, + }) + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + try: + return await ApiTransactionService().execute( + context, + f"advisor:investment-goals:{customer_id}", + key, + payload.model_dump(mode="json"), + operation, + ) + except IntegrityError as exc: + raise ResourceAlreadyExistsError("投资目标创建冲突") from exc + + async def confirm( + self, goal_no: str, context: RequestContext, key: str | None + ) -> dict[str, object]: + async def operation(session: AsyncSession) -> dict[str, Any]: + repository = InvestmentGoalRepository(session) + goal = await repository.goal(goal_no, lock=True) + if goal is None: + raise GenericResourceNotFoundError("投资目标不存在") + await self._assert_customer_access(goal.customer_id, context, "confirm") + if goal.status != "pending_confirmation": + raise InvalidStateError("当前投资目标不能确认") + now = _utc_now() + goal.status = "confirmed" + goal.confirmed_by = int(context.user_id) + goal.confirmed_at = now + goal.updated_at = now + await repository.supersede_confirmed(goal.customer_id, goal.goal_no, now) + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书草稿不存在") + self._audit(session, context, goal.customer_id, "advisor.investment_goal_confirmed", { + "goal_no": goal.goal_no, + "goal_book_content_id": goal.goal_book_content_id, + "review_status": content.review_status, + }) + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + return await ApiTransactionService().execute( + context, + f"advisor:investment-goals:{goal_no}:confirmation", + key, + {"goal_no": goal_no, "confirmed": True}, + operation, + ) + + async def current(self, customer_id: int, context: RequestContext) -> dict[str, object]: + await self._assert_customer_access(customer_id, context, "read") + async with SessionFactory() as session: + repository = InvestmentGoalRepository(session) + goal = await repository.latest_for_customer(customer_id) + if goal is None: + raise GenericResourceNotFoundError("当前投资目标不存在") + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书草稿不存在") + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + async def goal_book(self, goal_no: str, context: RequestContext) -> dict[str, object]: + async with SessionFactory() as session: + repository = InvestmentGoalRepository(session) + goal = await repository.goal(goal_no) + if goal is None: + raise GenericResourceNotFoundError("投资目标不存在") + await self._assert_customer_access(goal.customer_id, context, "read") + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书草稿不存在") + return { + "data": { + "goal_no": goal.goal_no, + "goal_status": goal.status, + "review_status": content.review_status, + "content": content.draft_content, + "published_at": _timestamp(content.published_at), + }, + "meta": {"trace_id": context.trace_id}, + } + + async def review_book( + self, goal_no: str, payload: InvestmentGoalBookReview, context: RequestContext, + key: str | None, + ) -> dict[str, object]: + await AuthorizationService.require(context, "investment-goal:review", admin=True) + + async def operation(session: AsyncSession) -> dict[str, Any]: + repository = InvestmentGoalRepository(session) + goal = await repository.goal(goal_no, lock=True) + if goal is None: + raise GenericResourceNotFoundError("投资目标不存在") + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书草稿不存在") + if content.review_status not in {"pending", "approved"}: + raise InvalidStateError("目标书当前不能审核") + now = _utc_now() + if payload.decision == "approved": + content.review_status = "approved" + content.reviewer_user_id = int(context.user_id) + content.reviewed_at = now + else: + content.review_status = "pending" + content.reviewer_user_id = None + content.reviewed_at = None + content.updated_at = now + self._audit( + session, context, goal.customer_id, "advisor.investment_goal_book_reviewed", + { + "goal_no": goal.goal_no, + "content_id": content.id, + "decision": payload.decision, + "comment": payload.comment, + }, + ) + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + return await ApiTransactionService().execute( + context, f"advisor:investment-goals:{goal_no}:book-review", key, + payload.model_dump(mode="json"), operation, + ) + + async def publish_book( + self, goal_no: str, payload: InvestmentGoalBookPublish, context: RequestContext, + key: str | None, + ) -> dict[str, object]: + await AuthorizationService.require(context, "investment-goal:publish", admin=True) + + async def operation(session: AsyncSession) -> dict[str, Any]: + repository = InvestmentGoalRepository(session) + goal = await repository.goal(goal_no, lock=True) + if goal is None: + raise GenericResourceNotFoundError("投资目标不存在") + content = await repository.goal_book(goal.goal_book_content_id) + if content is None: + raise GenericResourceNotFoundError("投资目标书不存在") + if content.review_status != "approved": + raise InvalidStateError("目标书审核通过后才能发布") + now = _utc_now() + content.review_status = "published" + content.published_at = now + content.updated_at = now + self._audit( + session, context, goal.customer_id, "advisor.investment_goal_book_published", + {"goal_no": goal.goal_no, "content_id": content.id}, + ) + return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}} + + return await ApiTransactionService().execute( + context, f"advisor:investment-goals:{goal_no}:book-publish", key, + payload.model_dump(mode="json"), operation, + ) + + async def current_for_agent(self, context: RequestContext) -> dict[str, object] | None: + await AuthorizationService.require(context, "investment-goal:read:self") + async with SessionFactory() as session: + repository = InvestmentGoalRepository(session) + goal = await repository.latest_for_customer(int(context.user_id)) + if goal is None or goal.status != "confirmed": + return None + content = await repository.goal_book(goal.goal_book_content_id) + return { + "goal_no": goal.goal_no, + "status": goal.status, + "annualized_return_lower_pct": str(goal.annualized_return_lower_pct), + "annualized_return_upper_pct": str(goal.annualized_return_upper_pct), + "max_drawdown_pct": str(goal.max_drawdown_pct), + "liquidity_requirement": goal.liquidity_requirement, + "investment_horizon_months": goal.investment_horizon_months, + "benchmark_name": goal.benchmark_name, + "goal_book_review_status": content.review_status if content else None, + } + + async def _resolve_customer( + self, requested_customer_id: int | None, context: RequestContext, action: str + ) -> int: + customer_id = requested_customer_id or int(context.user_id) + await self._assert_customer_access(customer_id, context, action) + return customer_id + + async def _assert_customer_access( + self, customer_id: int, context: RequestContext, action: str + ) -> None: + own = customer_id == int(context.user_id) + permission = ( + f"investment-goal:{action}:self" + if own + else f"investment-goal:{action}:customer" + ) + await AuthorizationService.require(context, permission) + if own: + return + scope = context.permission_scopes.get(permission, "self") + if scope != "all" and ( + scope != "own_customers" or str(customer_id) not in context.customer_ids + ): + raise GenericResourceNotFoundError("客户不可访问") + + @staticmethod + def _validate_notes(notes: str | None) -> None: + if notes and any(phrase in notes for phrase in _PROHIBITED_GOAL_PHRASES): + raise ValidationAgentError("投资目标说明不得包含收益承诺或保本表述") + + @staticmethod + def _goal_book(goal_no: str, payload: InvestmentGoalCreate) -> dict[str, object]: + return { + "document_type": "investment_goal_book", + "document_version": "1.0", + "goal_no": goal_no, + "sections": { + "investment_objective": { + "annualized_return_expectation_pct": { + "lower": str(payload.annualized_return_lower_pct), + "upper": str(payload.annualized_return_upper_pct), + }, + "benchmark_name": payload.benchmark_name, + }, + "risk_boundary": {"maximum_drawdown_pct": str(payload.max_drawdown_pct)}, + "liquidity": { + "requirement": payload.liquidity_requirement, + "description": _LIQUIDITY_LABELS[payload.liquidity_requirement], + }, + "investment_horizon": {"months": payload.investment_horizon_months}, + "notes": payload.notes, + }, + "disclosures": [ + "收益目标为客户期望与业绩比较基准口径,不构成收益承诺或保证。", + "基金投资有风险,过往业绩不预示未来表现。", + "本目标书为待审核草稿,仅作为后续场内基金模拟交易分析的输入,不构成交易指令。", + ], + } + + @classmethod + def _view(cls, goal: AdvisorInvestmentGoal, content: ClientFacingContent) -> dict[str, object]: + return { + "goal_no": goal.goal_no, + "customer_id": str(goal.customer_id), + "status": goal.status, + "goal_gap_status": ( + "awaiting_confirmation" if goal.status == "pending_confirmation" else "none" + ), + "annualized_return_lower_pct": str(goal.annualized_return_lower_pct), + "annualized_return_upper_pct": str(goal.annualized_return_upper_pct), + "max_drawdown_pct": str(goal.max_drawdown_pct), + "liquidity_requirement": goal.liquidity_requirement, + "investment_horizon_months": goal.investment_horizon_months, + "benchmark_name": goal.benchmark_name, + "notes": goal.notes, + "source": goal.source, + "goal_book": { + "content_id": str(content.id), + "review_status": content.review_status, + "published_at": _timestamp(content.published_at), + }, + "confirmed_at": _timestamp(goal.confirmed_at), + "created_at": _timestamp(goal.created_at), + "updated_at": _timestamp(goal.updated_at), + } + + @staticmethod + def _audit( + session: AsyncSession, context: RequestContext, customer_id: int, + action_type: str, detail: dict[str, object], + ) -> None: + session.add(InteractionAudit( + actor_type="user", actor_id=int(context.user_id), target_customer_id=customer_id, + portal=context.portal, action_type=action_type, + detail={**detail, "trace_id": context.trace_id}, created_at=_utc_now(), + )) + + +def _utc_now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) + + +def _timestamp(value: datetime | None) -> str | None: + return value.isoformat() + "Z" if value is not None else None + + +async def investment_goal_query_tool( + _arguments: InvestmentGoalQuery, context: RequestContext +) -> dict[str, object] | None: + """Read-only Agent entry point; only confirmed goals are exposed to the Agent.""" + return await InvestmentGoalService().current_for_agent(context) diff --git a/app/service/market_quote_sync_service.py b/app/service/market_quote_sync_service.py new file mode 100644 index 0000000..5926504 --- /dev/null +++ b/app/service/market_quote_sync_service.py @@ -0,0 +1,213 @@ +"""Dual-source exchange quote orchestration and health recording.""" + +import asyncio +from collections.abc import Callable +from datetime import UTC, datetime +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from typing import Any +from uuid import uuid4 + +from sqlalchemy import select + +from app.infrastructure.db import SessionFactory +from app.model.advisor_product import ( + AdvisorMarketQuoteAlert, + AdvisorMarketQuoteSourceHealth, + AdvisorMarketQuoteSourceRun, + AdvisorProductMarketQuoteSnapshot, +) +from app.model.fund import FundProduct + +QuoteLoader = Callable[[list[str]], dict[str, dict[str, str | None]]] + + +class MarketQuoteSyncService: + SOURCES = ("eastmoney_exchange", "tencent_exchange") + + def __init__( + self, + *, + session_factory: Callable[[], Any] = SessionFactory, + eastmoney_loader: QuoteLoader | None = None, + tencent_loader: QuoteLoader | None = None, + ) -> None: + self.session_factory = session_factory + self.loaders = ( + eastmoney_loader or self._eastmoney_loader, + tencent_loader or self._tencent_loader, + ) + + async def sync(self, *, product_codes: tuple[str, ...] | None = None) -> dict[str, object]: + async with self.session_factory() as session: + rows = list(await session.execute( + select(FundProduct.id, FundProduct.product_code).where( + FundProduct.fund_manager == "南方基金", + FundProduct.exchange_code.in_(("SSE", "SZSE")), + FundProduct.status == "上市", + ).order_by(FundProduct.id) + )) + if product_codes is not None: + allowed = set(product_codes) + rows = [row for row in rows if row.product_code in allowed] + product_ids = {str(code): int(product_id) for product_id, code in rows} + codes = list(product_ids) + sync_run_no = str(uuid4()) + selected: dict[str, tuple[str, dict[str, str | None]]] = {} + source_results: list[dict[str, object]] = [] + for priority, (source, loader) in enumerate( + zip(self.SOURCES, self.loaders, strict=True), 1 + ): + started = datetime.now(UTC).replace(tzinfo=None) + try: + quotes = await asyncio.to_thread(loader, codes) + error_type = None + except Exception as exc: + quotes = {} + error_type = type(exc).__name__ + usable = {code: value for code, value in quotes.items() if code in product_ids} + for code, quote in usable.items(): + selected.setdefault(code, (source, quote)) + status = ( + "failed" if not usable + else "succeeded" if len(usable) == len(codes) + else "degraded" + ) + completed = datetime.now(UTC).replace(tzinfo=None) + source_results.append({ + "source": source, "priority": priority, "status": status, + "requested_count": len(codes), "quote_count": len(usable), + "error_type": error_type, + "started_at": started, "completed_at": completed, + }) + if len(selected) == len(codes): + break + + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session_factory() as session, session.begin(): + for item in source_results: + await self._record_source_run(session, sync_run_no, item, now) + await self._record_health(session, item, now) + for item in source_results: + await self._record_alert(session, item, now) + for code, (source, quote) in selected.items(): + snapshot = self._snapshot(product_ids[code], source, quote, now) + if snapshot is not None: + session.add(snapshot) + return { + "sync_run_no": sync_run_no, + "requested_count": len(codes), + "quote_count": len(selected), + "sources": source_results, + "degraded": len(selected) < len(codes), + } + + @staticmethod + async def _record_source_run( + session: Any, run_no: str, item: dict[str, object], now: datetime + ) -> None: + session.add(AdvisorMarketQuoteSourceRun( + sync_run_no=run_no, source=item["source"], priority_order=item["priority"], + status=item["status"], requested_count=item["requested_count"], + quote_count=item["quote_count"], error_type=item["error_type"], + started_at=item["started_at"], completed_at=item["completed_at"], created_at=now, + )) + + @staticmethod + async def _record_health(session: Any, item: dict[str, object], now: datetime) -> None: + source = str(item["source"]) + health = await session.scalar(select(AdvisorMarketQuoteSourceHealth).where( + AdvisorMarketQuoteSourceHealth.source == source + ).with_for_update()) + source_status = str(item["status"]) + failed = source_status == "failed" + if health is None: + health = AdvisorMarketQuoteSourceHealth( + source=source, status="failed" if failed else source_status, + consecutive_failure_count=1 if failed else 0, + last_success_at=None if failed else now, + last_failure_at=now if failed else None, + last_error_type=item["error_type"], + last_requested_count=item["requested_count"], + last_quote_count=item["quote_count"], created_at=now, updated_at=now, + ) + session.add(health) + return + health.status = "failed" if failed else source_status + health.consecutive_failure_count = health.consecutive_failure_count + 1 if failed else 0 + health.last_success_at = None if failed else now + health.last_failure_at = now if failed else health.last_failure_at + health.last_error_type = item["error_type"] + health.last_requested_count = item["requested_count"] + health.last_quote_count = item["quote_count"] + health.updated_at = now + + @staticmethod + async def _record_alert(session: Any, item: dict[str, object], now: datetime) -> None: + source = str(item["source"]) + alert = await session.scalar(select(AdvisorMarketQuoteAlert).where( + AdvisorMarketQuoteAlert.source == source, + AdvisorMarketQuoteAlert.alert_type == "source_unavailable", + AdvisorMarketQuoteAlert.status == "open", + ).with_for_update()) + if item["status"] == "failed": + if alert is None: + session.add(AdvisorMarketQuoteAlert( + alert_no=str(uuid4()), source=source, alert_type="source_unavailable", + severity="high", status="open", occurrence_count=1, + detail={"error_type": item["error_type"], "quote_count": item["quote_count"]}, + first_observed_at=now, last_observed_at=now, + created_at=now, updated_at=now, + )) + else: + alert.occurrence_count += 1 + alert.last_observed_at = now + alert.updated_at = now + return + if alert is not None: + alert.status = "resolved" + alert.resolved_at = now + alert.last_observed_at = now + alert.updated_at = now + + @staticmethod + def _snapshot( + product_id: int, source: str, quote: dict[str, str | None], now: datetime + ) -> AdvisorProductMarketQuoteSnapshot | None: + try: + last_price = Decimal(str(quote.get("last_price"))).quantize( + Decimal("0.000001"), rounding=ROUND_HALF_UP + ) + except (InvalidOperation, TypeError): + return None + if last_price <= 0: + return None + def decimal(key: str, places: str) -> Decimal | None: + value = quote.get(key) + if value in (None, ""): + return None + try: + return Decimal(str(value)).quantize( + Decimal(places), rounding=ROUND_HALF_UP + ) + except InvalidOperation: + return None + return AdvisorProductMarketQuoteSnapshot( + product_id=product_id, observed_at=now, last_price=last_price, + previous_close=decimal("previous_close", "0.000001"), + change_pct=decimal("change_pct", "0.0001"), + volume=decimal("volume", "0.0001"), + turnover_amount=decimal("turnover_amount", "0.01"), + quote_status="active", source=source, created_at=now, + ) + + @staticmethod + def _eastmoney_loader(codes: list[str]) -> dict[str, dict[str, str | None]]: + from hq import fetch_southern_exchange_quotes_eastmoney + + return fetch_southern_exchange_quotes_eastmoney(codes) + + @staticmethod + def _tencent_loader(codes: list[str]) -> dict[str, dict[str, str | None]]: + from hq import fetch_southern_exchange_quotes_tencent + + return fetch_southern_exchange_quotes_tencent(codes) diff --git a/app/service/portfolio_analysis_service.py b/app/service/portfolio_analysis_service.py new file mode 100644 index 0000000..378dc3f --- /dev/null +++ b/app/service/portfolio_analysis_service.py @@ -0,0 +1,220 @@ +"""Authoritative, read-only portfolio concentration and risk analysis.""" + +from collections import defaultdict +from collections.abc import Callable +from datetime import date +from decimal import Decimal +from typing import Any + +from app.core.config import get_settings +from app.core.contracts import RequestContext +from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery +from app.infrastructure.db import SessionFactory +from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver +from app.model.advisor_product import AdvisorProductIndustryExposure, AdvisorProductMetricSnapshot +from app.model.fund import FundHolding, FundProduct +from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository +from app.service.authorization_service import AuthorizationService +from app.service.profile_governance_service import ProfileGovernanceService +from app.service.relationship_service import RelationshipService + +HUNDRED = Decimal("100") + + +class PortfolioAnalysisService: + def __init__( + self, + *, + session_factory: Callable[[], Any] = SessionFactory, + graph_service: RelationshipService | None = None, + enforce_profile_governance: bool = False, + ) -> None: + self.session_factory = session_factory + self.graph_service = graph_service or RelationshipService( + Neo4jGraphDriver(get_settings()) + ) + self.enforce_profile_governance = enforce_profile_governance + + async def analyze_for_agent( + self, _arguments: PortfolioAnalysisQuery, context: RequestContext + ) -> dict[str, object]: + await AuthorizationService.require(context, "portfolio-analysis:read:self") + if self.enforce_profile_governance: + await ProfileGovernanceService().require_operable(int(context.user_id)) + async with self.session_factory() as session: + repository = PortfolioAnalysisRepository(session) + positions = await repository.positions(int(context.user_id)) + ids = tuple(holding.product_id for holding, _product in positions) + exposures = await repository.latest_industry_exposures(ids, date.today()) + metrics = await repository.latest_metrics(ids, date.today()) + result = self._analyze(positions, exposures, metrics) + result["graph_context"] = await self._graph_context(int(context.user_id)) + return result + + async def _graph_context(self, customer_id: int) -> dict[str, object]: + try: + context = await self.graph_service.portfolio_industry_context(customer_id) + except Exception as exc: + return {"degraded": True, "reason": f"neo4j_unavailable:{type(exc).__name__}"} + if bool(context.get("degraded")): + return {"degraded": True, "reason": str(context.get("reason", "neo4j_unavailable"))} + raw = context.get("overlaps") + if not isinstance(raw, list): + return {"degraded": True, "reason": "neo4j_invalid_result"} + overlaps = [ + {"industry_name": item["industry_name"], "product_count": item["product_count"]} + for item in raw + if isinstance(item, dict) + and isinstance(item.get("industry_name"), str) + and isinstance(item.get("product_count"), int) + and item["product_count"] >= 2 + ] + return {"degraded": False, "overlaps": overlaps[:5]} + + @classmethod + def _analyze( + cls, + positions: list[tuple[FundHolding, FundProduct]], + exposures: dict[int, list[AdvisorProductIndustryExposure]], + metrics: dict[int, AdvisorProductMetricSnapshot], + ) -> dict[str, object]: + if not positions: + return {"status": "no_positions", "position_count": 0} + valued = [ + (holding, product, holding.market_value) + for holding, product in positions + if holding.market_value is not None and holding.market_value > 0 + ] + total = sum((value for _holding, _product, value in valued), Decimal()) + if total <= 0: + return { + "status": "valuation_required", + "position_count": len(positions), + "warnings": [{ + "code": "MARKET_VALUE_UNAVAILABLE", + "severity": "high", + "message": "当前持仓缺少可用市值,暂不能计算集中度。", + }], + } + product_rows = cls._product_rows(valued, total) + industry_rows, coverage, invalid = cls._industry_rows(valued, exposures, total) + coverage_pct = (coverage / total * HUNDRED).quantize(Decimal("0.01")) + warnings: list[dict[str, str]] = [] + if Decimal(str(product_rows[0]["share_pct"])) > Decimal("30"): + warnings.append({ + "code": "SINGLE_PRODUCT_CONCENTRATION", + "severity": "high", + "message": "单一产品持仓占比较高,存在集中度风险。", + }) + industry_hhi = None + if coverage_pct >= Decimal("80") and industry_rows: + industry_hhi = cls._hhi([row["share_pct"] for row in industry_rows]) + if Decimal(str(industry_rows[0]["share_pct"])) > Decimal("40"): + warnings.append({ + "code": "SINGLE_INDUSTRY_CONCENTRATION", + "severity": "high", + "message": "单一行业穿透占比较高,存在行业集中度风险。", + }) + else: + warnings.append({ + "code": "INDUSTRY_COVERAGE_INCOMPLETE", + "severity": "medium", + "message": "行业穿透参考数据覆盖不足,暂不输出确定性行业结论。", + }) + if invalid: + warnings.append({ + "code": "INDUSTRY_EXPOSURE_INVALID", + "severity": "medium", + "message": "部分产品行业暴露数据异常,未纳入行业穿透计算。", + }) + missing_metrics = len(valued) - len(metrics) + if missing_metrics: + warnings.append({ + "code": "HISTORICAL_METRICS_INCOMPLETE", + "severity": "medium", + "message": "部分持仓缺少历史行情指标,风险分析完整性受限。", + }) + if len(valued) < len(positions): + warnings.append({ + "code": "MARKET_VALUE_PARTIAL", + "severity": "medium", + "message": "部分持仓缺少可用市值,本次集中度基于已估值持仓计算。", + }) + return { + "status": "ready", + "summary": { + "position_count": len(positions), + "valued_position_count": len(valued), + "total_market_value": str(total.quantize(Decimal("0.01"))), + "industry_coverage_pct": str(coverage_pct), + "metrics_coverage_pct": str( + (Decimal(len(metrics)) / Decimal(len(valued)) * HUNDRED).quantize( + Decimal("0.01") + ) + ), + }, + "product_concentration": { + "hhi": cls._hhi([row["share_pct"] for row in product_rows]), + "rows": product_rows, + }, + "industry_concentration": { + "hhi": industry_hhi, + "rows": industry_rows, + "conclusion_available": industry_hhi is not None, + }, + "warnings": warnings, + "disclaimer": "分析结果仅供参考,不生成交易指令。", + } + + @staticmethod + def _product_rows( + valued: list[tuple[FundHolding, FundProduct, Decimal]], total: Decimal + ) -> list[dict[str, object]]: + rows = [{ + "product_id": str(holding.product_id), + "product_code": product.product_code, + "product_name": product.product_name, + "market_value": str(value.quantize(Decimal("0.01"))), + "share_pct": (value / total * HUNDRED).quantize(Decimal("0.01")), + } for holding, product, value in valued] + return sorted(rows, key=lambda row: -Decimal(str(row["share_pct"]))) + + @staticmethod + def _industry_rows( + valued: list[tuple[FundHolding, FundProduct, Decimal]], + exposures: dict[int, list[AdvisorProductIndustryExposure]], + total: Decimal, + ) -> tuple[list[dict[str, object]], Decimal, set[int]]: + amounts: dict[str, Decimal] = defaultdict(Decimal) + covered = Decimal() + invalid: set[int] = set() + for holding, _product, value in valued: + rows = exposures.get(holding.product_id, []) + weight = sum((row.exposure_weight_pct for row in rows), Decimal()) + if weight <= 0: + continue + if weight > HUNDRED: + invalid.add(holding.product_id) + continue + covered += value * weight / HUNDRED + for row in rows: + amounts[row.industry_name] += value * row.exposure_weight_pct / HUNDRED + result = [{ + "industry_name": name, + "market_value": str(value.quantize(Decimal("0.01"))), + "share_pct": (value / total * HUNDRED).quantize(Decimal("0.01")), + } for name, value in amounts.items()] + return sorted(result, key=lambda row: -Decimal(str(row["share_pct"]))), covered, invalid + + @staticmethod + def _hhi(shares: list[object]) -> str: + value = sum((Decimal(str(share)) ** 2 for share in shares), Decimal()) + return str(value.quantize(Decimal("0.01"))) + + +async def portfolio_analysis_tool( + arguments: PortfolioAnalysisQuery, context: RequestContext +) -> dict[str, object]: + return await PortfolioAnalysisService(enforce_profile_governance=True).analyze_for_agent( + arguments, context + ) diff --git a/app/service/product_comparison_service.py b/app/service/product_comparison_service.py new file mode 100644 index 0000000..4ce9f13 --- /dev/null +++ b/app/service/product_comparison_service.py @@ -0,0 +1,118 @@ +"""Evidence-based comparison for listed Southern Fund products.""" + +from collections.abc import Callable +from datetime import UTC, date, datetime +from typing import Any + +from app.core.contracts import RequestContext +from app.core.product_comparison_contracts import ProductComparisonQuery +from app.infrastructure.db import SessionFactory +from app.repository.advisor_product_repository import AdvisorProductRepository +from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository +from app.service.authorization_service import AuthorizationService +from app.service.product_governance_monitor_service import SALES_INSTITUTION + + +class ProductComparisonService: + def __init__(self, *, session_factory: Callable[[], Any] = SessionFactory) -> None: + self.session_factory = session_factory + + async def compare( + self, payload: ProductComparisonQuery, context: RequestContext + ) -> dict[str, object]: + await AuthorizationService.require(context, "product-comparison:read:self") + codes = tuple(dict.fromkeys(payload.product_codes)) + if len(codes) < 2: + return {"status": "products_required", "message": "至少需要两个不同的基金代码。"} + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session_factory() as session: + candidates = await AdvisorProductRepository(session).authoritative_tradable_products( + now, sales_institution=SALES_INSTITUTION, fund_manager="南方基金", limit=50 + ) + selected = [item for item in candidates if item.product.product_code in codes] + by_code = {item.product.product_code: item for item in selected} + missing = [code for code in codes if code not in by_code] + ids = tuple(item.product.id for item in selected) + exposures = await PortfolioAnalysisRepository(session).latest_industry_exposures( + ids, date.today() + ) + if missing: + return { + "status": "evidence_required", + "missing_product_codes": missing, + "message": "部分产品缺少当前有效的权威适当性或合同证据,无法完成对比。", + } + rows = [ + self._product_view(by_code[code], exposures.get(by_code[code].product.id, [])) + for code in codes + ] + industry_sets: list[set[str]] = [] + for row in rows: + raw_exposure = row.get("industry_exposure") + industry_sets.append({ + str(item["industry_name"]) + for item in raw_exposure + if isinstance(item, dict) and "industry_name" in item + } if isinstance(raw_exposure, list) else set()) + common_industries = sorted(set.intersection(*industry_sets)) if industry_sets else [] + return { + "status": "ready", + "products": rows, + "common_industries": common_industries, + "differences": self._differences(rows), + "analysis_only": True, + "disclaimer": "对比结果仅供分析参考,不构成交易指令。", + } + + @staticmethod + def _product_view(candidate: Any, exposures: list[Any]) -> dict[str, object]: + product = candidate.product + contract = candidate.contract + liquidity = candidate.liquidity + return { + "product_code": product.product_code, + "product_name": product.product_name, + "product_category": product.product_category, + "suitability_risk_level": candidate.suitability.risk_level, + "fund_type": contract.fund_type, + "investment_scope": contract.investment_scope, + "performance_benchmark": contract.performance_benchmark, + "management_fee_rate_pct": str(contract.management_fee_rate_pct) + if contract.management_fee_rate_pct is not None else None, + "custodian_fee_rate_pct": str(contract.custodian_fee_rate_pct) + if contract.custodian_fee_rate_pct is not None else None, + "asset_scale_billion": str(candidate.asset_scale_billion) + if candidate.asset_scale_billion is not None else None, + "liquidity": { + "status": liquidity.status if liquidity else "unknown", + "average_daily_turnover_amount": str(liquidity.average_daily_turnover_amount) + if liquidity and liquidity.average_daily_turnover_amount is not None else None, + }, + "industry_exposure": [ + {"industry_name": item.industry_name, + "exposure_weight_pct": str(item.exposure_weight_pct)} + for item in exposures + ], + "evidence": { + "suitability_source_url": candidate.suitability.source_url, + "contract_source_url": contract.source_url, + }, + } + + @staticmethod + def _differences(rows: list[dict[str, object]]) -> dict[str, list[object]]: + fields = ( + "product_category", "suitability_risk_level", "fund_type", + "performance_benchmark", "management_fee_rate_pct", "custodian_fee_rate_pct", + ) + return { + field: [row[field] for row in rows] + for field in fields + if len({str(row[field]) for row in rows}) > 1 + } + + +async def product_comparison_tool( + arguments: ProductComparisonQuery, context: RequestContext +) -> dict[str, object]: + return await ProductComparisonService().compare(arguments, context) diff --git a/app/service/product_governance_monitor_service.py b/app/service/product_governance_monitor_service.py new file mode 100644 index 0000000..e430407 --- /dev/null +++ b/app/service/product_governance_monitor_service.py @@ -0,0 +1,292 @@ +"""Monitor official product governance sources and enforce compliance review.""" + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, date, datetime +from typing import Any +from uuid import uuid4 + +from sqlalchemy import select + +from app.core.contracts import RequestContext +from app.core.errors import GenericResourceNotFoundError, InvalidStateError +from app.infrastructure.db import SessionFactory +from app.model.advisor_product import ( + AdvisorProductContractSnapshot, + AdvisorProductGovernanceCandidate, + AdvisorProductGovernanceSyncRun, + AdvisorProductSuitabilityReference, +) +from app.model.audit import InteractionAudit +from app.service.authorization_service import AuthorizationService + +SourceLoader = Callable[[float], tuple[list[dict[str, str]], list[dict[str, str]]]] +SALES_INSTITUTION = ( + "\u5357\u65b9\u57fa\u91d1\u7ba1\u7406\u80a1\u4efd\u6709\u9650\u516c\u53f8\u76f4\u9500" +) + + +@dataclass(frozen=True) +class GovernanceMonitorResult: + product_count: int + change_count: int + error_count: int + run_no: str + + +class ProductGovernanceMonitorService: + """Source changes are quarantined until an authorized reviewer approves them.""" + + def __init__( + self, + *, + session_factory: Callable[[], Any] = SessionFactory, + loader: SourceLoader | None = None, + timeout_seconds: float = 30.0, + ) -> None: + self.session_factory = session_factory + self.loader = loader or self._default_loader + self.timeout_seconds = timeout_seconds + + async def monitor(self) -> GovernanceMonitorResult: + now = datetime.now(UTC).replace(tzinfo=None) + run_no = str(uuid4()) + async with self.session_factory() as session, session.begin(): + session.add(AdvisorProductGovernanceSyncRun( + run_no=run_no, + source="nffund_official_direct_sales", + status="running", + product_count=0, + change_count=0, + error_count=0, + detail={}, + started_at=now, + completed_at=None, + created_at=now, + updated_at=now, + )) + try: + suitability_rows, contract_rows = await asyncio.to_thread( + self.loader, self.timeout_seconds + ) + result = await self._store_candidates(suitability_rows, contract_rows, now, run_no) + except Exception as exc: + async with self.session_factory() as session, session.begin(): + run = await session.scalar(select(AdvisorProductGovernanceSyncRun).where( + AdvisorProductGovernanceSyncRun.run_no == run_no + ).with_for_update()) + assert run is not None + run.status = "failed" + run.error_count = 1 + run.detail = {"error_type": type(exc).__name__} + run.completed_at = now + run.updated_at = now + return GovernanceMonitorResult(0, 0, 1, run_no) + return result + + async def review( + self, candidate_id: int, decision: str, comment: str, context: RequestContext + ) -> dict[str, object]: + await AuthorizationService.require(context, "product-governance:review", admin=True) + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session_factory() as session, session.begin(): + candidate = await session.scalar(select(AdvisorProductGovernanceCandidate).where( + AdvisorProductGovernanceCandidate.id == candidate_id + ).with_for_update()) + if candidate is None: + raise GenericResourceNotFoundError("product governance candidate not found") + if candidate.review_status != "pending_review": + raise InvalidStateError("PRODUCT_GOVERNANCE_CANDIDATE_NOT_PENDING") + candidate.review_status = "approved" if decision == "approved" else "rejected" + candidate.reviewed_by = int(context.user_id) + candidate.reviewed_at = now + candidate.review_comment = comment or None + candidate.updated_at = now + session.add(InteractionAudit( + actor_type="user", + actor_id=int(context.user_id), + portal="admin", + action_type=f"product_governance.candidate_{candidate.review_status}", + detail={ + "candidate_id": candidate.id, + "product_id": candidate.product_id, + "data_kind": candidate.data_kind, + "document_sha256": candidate.document_sha256, + "trace_id": context.trace_id, + }, + created_at=now, + )) + return self._candidate_view(candidate) + + async def pending( + self, context: RequestContext, *, limit: int = 100 + ) -> list[dict[str, object]]: + await AuthorizationService.require(context, "product-governance:review", admin=True) + async with self.session_factory() as session: + candidates = list(await session.scalars( + select(AdvisorProductGovernanceCandidate).where( + AdvisorProductGovernanceCandidate.review_status == "pending_review" + ).order_by(AdvisorProductGovernanceCandidate.observed_at.desc()).limit(limit) + )) + return [self._candidate_view(candidate) for candidate in candidates] + + async def runs(self, context: RequestContext, *, limit: int = 30) -> list[dict[str, object]]: + await AuthorizationService.require(context, "product-governance:read", admin=True) + async with self.session_factory() as session: + rows = list(await session.scalars(select(AdvisorProductGovernanceSyncRun).order_by( + AdvisorProductGovernanceSyncRun.started_at.desc() + ).limit(limit))) + return [ + { + "run_no": row.run_no, + "status": row.status, + "product_count": row.product_count, + "change_count": row.change_count, + "error_count": row.error_count, + "detail": row.detail, + "started_at": row.started_at.isoformat(), + "completed_at": row.completed_at.isoformat() if row.completed_at else None, + } + for row in rows + ] + + async def run_manually(self, context: RequestContext) -> GovernanceMonitorResult: + await AuthorizationService.require(context, "product-governance:sync", admin=True) + return await self.monitor() + + async def _store_candidates( + self, + suitability_rows: list[dict[str, str]], + contract_rows: list[dict[str, str]], + now: datetime, + run_no: str, + ) -> GovernanceMonitorResult: + product_codes = {row["product_code"] for row in [*suitability_rows, *contract_rows]} + async with self.session_factory() as session, session.begin(): + run = await session.scalar(select(AdvisorProductGovernanceSyncRun).where( + AdvisorProductGovernanceSyncRun.run_no == run_no + ).with_for_update()) + assert run is not None + from app.model.fund import FundProduct + + products = list(await session.execute(select( + FundProduct.id, FundProduct.product_code + ).where(FundProduct.product_code.in_(product_codes)))) + product_ids = {str(code): int(product_id) for product_id, code in products} + existing_suitability_rows = list(await session.execute(select( + AdvisorProductSuitabilityReference.product_id, + AdvisorProductSuitabilityReference.document_sha256, + AdvisorProductSuitabilityReference.effective_from, + ).where( + AdvisorProductSuitabilityReference.product_id.in_(product_ids.values()), + AdvisorProductSuitabilityReference.sales_institution == SALES_INSTITUTION, + ).order_by( + AdvisorProductSuitabilityReference.product_id, + AdvisorProductSuitabilityReference.effective_from.desc(), + ))) + existing_contract_rows = list(await session.execute(select( + AdvisorProductContractSnapshot.product_id, + AdvisorProductContractSnapshot.document_sha256, + AdvisorProductContractSnapshot.effective_from, + ).where(AdvisorProductContractSnapshot.product_id.in_(product_ids.values())).order_by( + AdvisorProductContractSnapshot.product_id, + AdvisorProductContractSnapshot.effective_from.desc(), + ))) + existing_candidate_rows = list(await session.execute(select( + AdvisorProductGovernanceCandidate.product_id, + AdvisorProductGovernanceCandidate.data_kind, + AdvisorProductGovernanceCandidate.document_sha256, + AdvisorProductGovernanceCandidate.review_status, + AdvisorProductGovernanceCandidate.observed_at, + ).where(AdvisorProductGovernanceCandidate.product_id.in_(product_ids.values())).order_by( + AdvisorProductGovernanceCandidate.product_id, + AdvisorProductGovernanceCandidate.data_kind, + AdvisorProductGovernanceCandidate.observed_at.desc(), + ))) + accepted_documents: dict[tuple[int, str], str] = {} + for product_id, document_hash, _effective_from in existing_suitability_rows: + accepted_documents.setdefault((int(product_id), "suitability"), str(document_hash)) + for product_id, document_hash, _effective_from in existing_contract_rows: + accepted_documents.setdefault((int(product_id), "contract"), str(document_hash)) + existing_candidates = { + (int(product_id), str(kind), str(document_hash)) + for product_id, kind, document_hash, _status, _observed_at + in existing_candidate_rows + } + for product_id, kind, document_hash, status, _observed_at in existing_candidate_rows: + if status == "approved": + accepted_documents.setdefault( + (int(product_id), str(kind)), str(document_hash) + ) + created = 0 + errors: list[str] = [] + for kind, rows in (("suitability", suitability_rows), ("contract", contract_rows)): + for row in rows: + product_id = product_ids.get(row["product_code"]) + document_hash = row["document_sha256"] + if product_id is None: + errors.append(f"missing_product:{row['product_code']}") + continue + if accepted_documents.get((product_id, kind)) == document_hash or ( + product_id, kind, document_hash + ) in existing_candidates: + continue + session.add(AdvisorProductGovernanceCandidate( + product_id=product_id, + data_kind=kind, + sales_institution=( + row.get("sales_institution") if kind == "suitability" else None + ), + observed_at=now, + effective_from=date.fromisoformat(row["effective_from"]), + risk_level=row.get("risk_level") if kind == "suitability" else None, + payload=dict(row), + source_url=row["source_url"], + document_title=row["document_title"], + document_published_at=self._optional_date(row.get("document_published_at")), + document_sha256=document_hash, + source=row["source"], + review_status="pending_review", + reviewed_by=None, + reviewed_at=None, + review_comment=None, + created_at=now, + updated_at=now, + )) + created += 1 + run.status = "succeeded" + run.product_count = len(product_codes) + run.change_count = created + run.error_count = len(errors) + run.detail = {"errors": errors[:20]} + run.completed_at = now + run.updated_at = now + return GovernanceMonitorResult(len(product_codes), created, len(errors), run_no) + + @staticmethod + def _optional_date(value: str | None) -> date | None: + return date.fromisoformat(value) if value else None + + @staticmethod + def _candidate_view(candidate: AdvisorProductGovernanceCandidate) -> dict[str, object]: + return { + "id": candidate.id, + "product_id": candidate.product_id, + "data_kind": candidate.data_kind, + "sales_institution": candidate.sales_institution, + "risk_level": candidate.risk_level, + "source_url": candidate.source_url, + "document_title": candidate.document_title, + "document_sha256": candidate.document_sha256, + "review_status": candidate.review_status, + "observed_at": candidate.observed_at.isoformat(), + "reviewed_at": candidate.reviewed_at.isoformat() if candidate.reviewed_at else None, + "review_comment": candidate.review_comment, + } + + @staticmethod + def _default_loader(timeout: float) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + from tools.sync_nanfang_official_product_governance import collect_rows + + return collect_rows(timeout=timeout) diff --git a/app/service/product_history_sync_service.py b/app/service/product_history_sync_service.py new file mode 100644 index 0000000..aae2dc4 --- /dev/null +++ b/app/service/product_history_sync_service.py @@ -0,0 +1,138 @@ +"""Synchronize public fund history into the additive advisory history store.""" + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal, InvalidOperation +from typing import Any + +from sqlalchemy import select +from sqlalchemy.dialects.mysql import insert + +from app.infrastructure.db import SessionFactory +from app.model.advisor_product import AdvisorProductPriceHistory +from app.model.fund import FundProduct + +NavHistoryLoader = Callable[[str, str, str], list[dict[str, str]]] + + +@dataclass(frozen=True) +class ProductHistorySyncResult: + product_count: int + observation_count: int + + +class ProductHistorySyncService: + """Write NAV observations and verified exchange turnover only.""" + + SOURCE = "eastmoney_hq_nav" + + def __init__( + self, + *, + session_factory: Callable[[], Any] = SessionFactory, + loader: NavHistoryLoader | None = None, + concurrency: int = 4, + ) -> None: + self.session_factory = session_factory + self.loader = loader or self._default_loader + self.concurrency = max(1, concurrency) + + async def sync( + self, + *, + days: int = 400, + limit: int = 100, + as_of_date: date | None = None, + product_codes: tuple[str, ...] | None = None, + ) -> ProductHistorySyncResult: + end_date = as_of_date or date.today() + start_date = end_date - timedelta(days=max(1, days)) + product_statement = select(FundProduct.id, FundProduct.product_code).where( + FundProduct.fund_manager == "南方基金", + FundProduct.status == "上市", + ) + if product_codes is not None: + product_statement = product_statement.where( + FundProduct.product_code.in_(product_codes) + ) + async with self.session_factory() as session: + products = list(await session.execute( + product_statement.order_by(FundProduct.id).limit(limit) + )) + + semaphore = asyncio.Semaphore(self.concurrency) + + async def fetch(product_id: int, product_code: str) -> tuple[int, list[dict[str, str]]]: + async with semaphore: + try: + rows = await asyncio.to_thread( + self.loader, product_code, start_date.isoformat(), end_date.isoformat() + ) + except Exception: + rows = [] + return product_id, rows + + fetched = await asyncio.gather( + *(fetch(int(product_id), str(product_code)) for product_id, product_code in products) + ) + now = datetime.now(UTC).replace(tzinfo=None) + observations = 0 + for product_id, rows in fetched: + payload = [] + for row in rows: + parsed = self._row(product_id, row, now) + if parsed is not None: + payload.append(parsed) + if not payload: + continue + async with self.session_factory() as session, session.begin(): + upsert_statement = insert(AdvisorProductPriceHistory).values(payload) + await session.execute(upsert_statement.on_duplicate_key_update( + close_price=upsert_statement.inserted.close_price, + turnover_amount=upsert_statement.inserted.turnover_amount, + source=upsert_statement.inserted.source, + source_updated_at=upsert_statement.inserted.source_updated_at, + updated_at=upsert_statement.inserted.updated_at, + )) + observations += len(payload) + return ProductHistorySyncResult(len(products), observations) + + @classmethod + def _row( + cls, product_id: int, raw: dict[str, str], now: datetime + ) -> dict[str, object] | None: + try: + trade_date = date.fromisoformat(raw["trade_date"]) + close_price = Decimal(raw["nav"]) + except (KeyError, InvalidOperation, ValueError): + return None + if close_price <= 0: + return None + turnover = raw.get("turnover_amount") + parsed_turnover: Decimal | None = None + if turnover not in (None, ""): + try: + parsed_turnover = Decimal(str(turnover)) + except InvalidOperation: + parsed_turnover = None + if parsed_turnover is not None and parsed_turnover < 0: + parsed_turnover = None + return { + "product_id": product_id, + "trade_date": trade_date, + "price_kind": "fund_nav", + "close_price": close_price, + "turnover_amount": parsed_turnover, + "source": cls.SOURCE, + "source_updated_at": now, + "created_at": now, + "updated_at": now, + } + + @staticmethod + def _default_loader(fund_code: str, start_date: str, end_date: str) -> list[dict[str, str]]: + from hq import get_southern_fund_nav_history + + return get_southern_fund_nav_history(fund_code, start_date, end_date) diff --git a/app/service/product_metric_service.py b/app/service/product_metric_service.py new file mode 100644 index 0000000..4dd9b24 --- /dev/null +++ b/app/service/product_metric_service.py @@ -0,0 +1,117 @@ +"""Reproducible historical product metrics for advisory analysis.""" + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, date, datetime +from decimal import ROUND_HALF_UP, Decimal +from math import sqrt +from typing import Protocol + +from app.model.advisor_product import AdvisorProductMetricSnapshot + + +class HistoricalPrice(Protocol): + @property + def trade_date(self) -> date: ... + + @property + def close_price(self) -> Decimal: ... + + @property + def turnover_amount(self) -> Decimal | None: ... + +_HUNDRED = Decimal("100") +_QUANTIZE = Decimal("0.0001") + + +@dataclass(frozen=True) +class CalculatedProductMetrics: + trailing_20d_return_pct: Decimal | None + trailing_120d_return_pct: Decimal | None + annualized_volatility_pct: Decimal | None + max_drawdown_pct: Decimal | None + average_daily_turnover_amount: Decimal | None + observation_count: int + + +class ProductMetricService: + CALCULATION_VERSION = "v1" + + @classmethod + def calculate(cls, prices: Sequence[HistoricalPrice]) -> CalculatedProductMetrics: + ordered = sorted(prices, key=lambda item: item.trade_date) + closes = [item.close_price for item in ordered if item.close_price > 0] + returns = [ + float(current / previous - 1) + for previous, current in zip(closes, closes[1:], strict=False) + if previous > 0 + ] + return CalculatedProductMetrics( + trailing_20d_return_pct=cls._period_return(closes, 20), + trailing_120d_return_pct=cls._period_return(closes, 120), + annualized_volatility_pct=cls._annualized_volatility(returns), + max_drawdown_pct=cls._max_drawdown(closes), + average_daily_turnover_amount=cls._average_turnover(ordered), + observation_count=len(closes), + ) + + @classmethod + def snapshot( + cls, product_id: int, prices: Sequence[HistoricalPrice] + ) -> AdvisorProductMetricSnapshot | None: + if not prices: + return None + as_of_date = max(item.trade_date for item in prices) + calculated = cls.calculate(prices) + now = datetime.now(UTC).replace(tzinfo=None) + return AdvisorProductMetricSnapshot( + product_id=product_id, + as_of_date=as_of_date, + trailing_20d_return_pct=calculated.trailing_20d_return_pct, + trailing_120d_return_pct=calculated.trailing_120d_return_pct, + annualized_volatility_pct=calculated.annualized_volatility_pct, + max_drawdown_pct=calculated.max_drawdown_pct, + average_daily_turnover_amount=calculated.average_daily_turnover_amount, + observation_count=calculated.observation_count, + source="advisor_product_price_history_dual_read", + calculation_version=cls.CALCULATION_VERSION, + created_at=now, + ) + + @staticmethod + def _period_return(closes: list[Decimal], days: int) -> Decimal | None: + if len(closes) <= days: + return None + return ((closes[-1] / closes[-days - 1] - 1) * _HUNDRED).quantize( + _QUANTIZE, rounding=ROUND_HALF_UP + ) + + @staticmethod + def _annualized_volatility(returns: list[float]) -> Decimal | None: + if len(returns) < 20: + return None + mean = sum(returns) / len(returns) + variance = sum((value - mean) ** 2 for value in returns) / (len(returns) - 1) + return Decimal(str(sqrt(variance * 252) * 100)).quantize( + _QUANTIZE, rounding=ROUND_HALF_UP + ) + + @staticmethod + def _max_drawdown(closes: list[Decimal]) -> Decimal | None: + if len(closes) < 2: + return None + peak = closes[0] + drawdown = Decimal() + for close in closes: + peak = max(peak, close) + drawdown = min(drawdown, close / peak - 1) + return (drawdown * _HUNDRED).quantize(_QUANTIZE, rounding=ROUND_HALF_UP) + + @staticmethod + def _average_turnover(prices: list[HistoricalPrice]) -> Decimal | None: + values = [item.turnover_amount for item in prices[-20:] if item.turnover_amount is not None] + if not values: + return None + return (sum(values, Decimal()) / len(values)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) diff --git a/app/service/product_recommendation_service.py b/app/service/product_recommendation_service.py new file mode 100644 index 0000000..41e9a3a --- /dev/null +++ b/app/service/product_recommendation_service.py @@ -0,0 +1,352 @@ +"""Constraint-first recommendations for the exchange-traded simulation domain.""" + +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select + +from app.core.config import get_settings +from app.core.contracts import RequestContext +from app.core.errors import GenericResourceNotFoundError, InvalidStateError +from app.core.product_recommendation_contracts import ProductRecommendationQuery +from app.infrastructure.db import SessionFactory +from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver +from app.model.audit import InteractionAudit +from app.model.investment_goal import ClientFacingContent +from app.repository.advisor_product_repository import ( + AdvisorProductRepository, + AuthoritativeProductCandidate, +) +from app.service.api_transaction_service import ApiTransactionService +from app.service.authorization_service import AuthorizationService +from app.service.investment_goal_service import InvestmentGoalService +from app.service.product_governance_monitor_service import SALES_INSTITUTION +from app.service.profile_governance_service import ProfileGovernanceService +from app.service.relationship_service import RelationshipService +from app.service.suitability_service import SuitabilityService + + +class ProductRecommendationService: + CONTENT_TYPE = "advisor_recommendation_plan" + + def __init__( + self, + *, + session_factory: Callable[[], Any] = SessionFactory, + relationship_service: RelationshipService | None = None, + enforce_profile_governance: bool = False, + ) -> None: + self.session_factory = session_factory + self.relationship_service = relationship_service or RelationshipService( + Neo4jGraphDriver(get_settings()) + ) + self.enforce_profile_governance = enforce_profile_governance + + async def generate( + self, payload: ProductRecommendationQuery, context: RequestContext, key: str | None + ) -> dict[str, object]: + await AuthorizationService.require(context, "product-recommendation:generate:self") + if self.enforce_profile_governance: + await ProfileGovernanceService().require_operable(int(context.user_id)) + authority = await SuitabilityService().authority_for_customer(int(context.user_id)) + if authority.customer_risk_level is None: + return {"status": "profile_required"} + goal = await InvestmentGoalService().current_for_agent(context) + if goal is None: + return {"status": "investment_goal_required"} + candidates, excluded = await self._candidates( + authority.customer_risk_level, str(goal["liquidity_requirement"]) + ) + horizon = goal.get("investment_horizon_months") + if not isinstance(horizon, int): + return {"status": "recommendation_input_invalid"} + ranked = self._rank( + candidates, authority.customer_risk_level, horizon + ) + selected = ranked[: payload.limit] + excluded.extend(self._ranking_exclusions(ranked[payload.limit :], payload.limit)) + graph_context = await self._graph_context(context) + products = [ + self._view(item, index, goal, graph_context) + for index, item in enumerate(selected, start=1) + ] + plan = { + "document_type": "advisor_recommendation_plan", + "document_version": "1.0", + "products": products, + "excluded_candidates": excluded, + "selection_summary": { + "candidate_count": len(candidates) + len(excluded), + "selected_count": len(products), + "excluded_count": len(excluded), + }, + "graph_context": graph_context, + "disclosures": [ + "推荐结果仅供场内基金模拟交易分析,不构成交易指令。", + "历史数据和风险等级不代表未来收益,收益目标不构成承诺。", + "推荐方案须经审核发布后方可对客户展示。", + ], + } + if key is None: + return {"status": "ready", **plan, "analysis_only": True} + + async def operation(session: Any) -> dict[str, object]: + now = datetime.now(UTC).replace(tzinfo=None) + content = ClientFacingContent( + customer_id=int(context.user_id), + content_type=self.CONTENT_TYPE, + draft_content=plan, + generated_by_portal=context.portal, + review_status="pending_review", + reviewer_user_id=None, + reviewed_at=None, + published_at=None, + created_at=now, + updated_at=now, + ) + session.add(content) + session.add( + InteractionAudit( + actor_type="user", + actor_id=int(context.user_id), + target_customer_id=int(context.user_id), + portal=context.portal, + action_type="advisor.recommendation_created", + detail={ + "content_type": self.CONTENT_TYPE, + "status": "pending_review", + "trace_id": context.trace_id, + }, + created_at=now, + ) + ) + await session.flush() + return { + "data": { + "content_id": str(content.id), + "status": content.review_status, + "plan": plan, + }, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, + f"advisor:recommendations:{context.user_id}", + key, + payload.model_dump(mode="json"), + operation, + ) + + async def _candidates( + self, customer_risk_level: int, liquidity_requirement: str + ) -> tuple[list[AuthoritativeProductCandidate], list[dict[str, object]]]: + async with self.session_factory() as session: + candidates = await AdvisorProductRepository(session).authoritative_tradable_products( + datetime.now(UTC).replace(tzinfo=None), + sales_institution=SALES_INSTITUTION, + liquidity_requirement=liquidity_requirement, + limit=50, + ) + return AdvisorProductRepository.hard_suitability_filter(candidates, customer_risk_level) + + @staticmethod + def _rank( + candidates: list[AuthoritativeProductCandidate], risk: int, horizon: int + ) -> list[tuple[AuthoritativeProductCandidate, float]]: + def score(candidate: AuthoritativeProductCandidate) -> float: + level = int(candidate.suitability.risk_level.removeprefix("R")) + risk_score = 1 - abs(risk - level) / 4 + liquidity = candidate.liquidity + if liquidity is None or liquidity.average_daily_turnover_amount is None: + liquidity_score = 0.5 + else: + liquidity_score = min( + 1.0, float(liquidity.average_daily_turnover_amount / 10_000_000) + ) + term_score = ( + 0.8 + if horizon >= 36 and candidate.product.product_category in {"ETF", "LOF"} + else 0.6 + ) + return 0.55 * risk_score + 0.25 * liquidity_score + 0.20 * term_score + + return sorted( + ((candidate, score(candidate)) for candidate in candidates), + key=lambda item: (-item[1], item[0].product.product_code), + ) + + @staticmethod + def _ranking_exclusions( + ranked: list[tuple[AuthoritativeProductCandidate, float]], limit: int + ) -> list[dict[str, object]]: + return [ + { + "product_code": candidate.product.product_code, + "product_name": candidate.product.product_name, + "stage": "ranking", + "reason_code": "RANKED_BELOW_SELECTION_LIMIT", + "reason": "产品通过硬性约束但排序低于本次选择数量。", + "ranking_score": round(score, 4), + "selection_limit": limit, + } + for candidate, score in ranked + ] + + @staticmethod + def _view( + item: tuple[AuthoritativeProductCandidate, float], + rank: int, + goal: dict[str, object], + graph_context: dict[str, object], + ) -> dict[str, object]: + candidate, score = item + product = candidate.product + contract = candidate.contract + return { + "rank": rank, + "product_code": product.product_code, + "product_name": product.product_name, + "product_category": product.product_category, + "reason": "该产品已通过场内可交易、权威适当性和合同证据校验," + "并与已确认投资目标的期限和流动性要求相匹配。", + "score": round(score, 4), + "recommendation_evidence_card": { + "card_version": "1.0", + "hard_constraints": [ + "exchange_traded", + "suitability_verified", + "contract_verified", + ], + "suitability": { + "risk_level": candidate.suitability.risk_level, + "source_url": candidate.suitability.source_url, + "document_title": candidate.suitability.document_title, + }, + "contract": { + "fund_type": contract.fund_type, + "source_url": contract.source_url, + "document_title": contract.document_title, + }, + "liquidity": { + "status": candidate.liquidity.status if candidate.liquidity else "unknown", + "average_daily_turnover_amount": str( + candidate.liquidity.average_daily_turnover_amount + ) + if candidate.liquidity + and candidate.liquidity.average_daily_turnover_amount is not None + else None, + }, + "goal_constraints": { + "liquidity_requirement": goal["liquidity_requirement"], + "investment_horizon_months": goal["investment_horizon_months"], + }, + "graph_status": "degraded" if graph_context.get("degraded") else "available", + }, + } + + async def _graph_context(self, context: RequestContext) -> dict[str, object]: + if self.relationship_service is None: + return {"degraded": True, "reason": "graph_not_configured"} + return await self.relationship_service.portfolio_industry_context(int(context.user_id)) + + async def review( + self, + content_id: int, + decision: str, + comment: str, + context: RequestContext, + key: str | None, + ) -> dict[str, object]: + await AuthorizationService.require(context, "product-recommendation:review", admin=True) + + async def operation(session: Any) -> dict[str, object]: + content = await session.get(ClientFacingContent, content_id, with_for_update=True) + if content is None or content.content_type != self.CONTENT_TYPE: + raise GenericResourceNotFoundError("推荐方案不存在") + if content.review_status != "pending_review": + raise InvalidStateError("推荐方案当前不能审核") + now = datetime.now(UTC).replace(tzinfo=None) + content.review_status = "approved" if decision == "approved" else "rejected" + content.reviewer_user_id = int(context.user_id) + content.reviewed_at = now + content.updated_at = now + content.draft_content = {**content.draft_content, "review_comment": comment} + await session.flush() + return { + "data": {"content_id": str(content.id), "status": content.review_status}, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, + f"advisor:recommendations:{content_id}:review", + key, + {"decision": decision, "comment": comment}, + operation, + ) + + async def publish( + self, content_id: int, context: RequestContext, key: str | None + ) -> dict[str, object]: + await AuthorizationService.require(context, "product-recommendation:publish", admin=True) + + async def operation(session: Any) -> dict[str, object]: + content = await session.get(ClientFacingContent, content_id, with_for_update=True) + if content is None or content.content_type != self.CONTENT_TYPE: + raise GenericResourceNotFoundError("推荐方案不存在") + if content.review_status != "approved": + raise InvalidStateError("推荐方案审核通过后才能发布") + content.review_status = "approved" + content.published_at = datetime.now(UTC).replace(tzinfo=None) + content.updated_at = content.published_at + await session.flush() + return { + "data": {"content_id": str(content.id), "status": "published"}, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, + f"advisor:recommendations:{content_id}:publish", + key, + {"publish": True}, + operation, + ) + + async def published(self, context: RequestContext) -> dict[str, object]: + await AuthorizationService.require(context, "product-recommendation:read:self") + async with self.session_factory() as session: + rows = list( + await session.scalars( + select(ClientFacingContent) + .where( + ClientFacingContent.customer_id == int(context.user_id), + ClientFacingContent.content_type == self.CONTENT_TYPE, + ClientFacingContent.review_status == "approved", + ClientFacingContent.published_at.is_not(None), + ) + .order_by(ClientFacingContent.published_at.desc()) + .limit(20) + ) + ) + return { + "data": [ + { + "content_id": str(row.id), + "plan": row.draft_content, + "published_at": row.published_at.isoformat() if row.published_at else None, + } + for row in rows + ], + "meta": {"trace_id": context.trace_id}, + } + + +async def product_recommendation_tool( + arguments: ProductRecommendationQuery, context: RequestContext +) -> dict[str, object]: + return await ProductRecommendationService(enforce_profile_governance=True).generate( + arguments, context, None + ) diff --git a/app/service/profile_governance_service.py b/app/service/profile_governance_service.py new file mode 100644 index 0000000..7f9c75a --- /dev/null +++ b/app/service/profile_governance_service.py @@ -0,0 +1,199 @@ +"""Profile-tag drift review and advisory-operation gating.""" + +from datetime import UTC, datetime +from typing import Any, Protocol +from uuid import uuid4 + +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.contracts import RequestContext +from app.core.errors import GenericResourceNotFoundError, InvalidStateError +from app.core.profile_governance_contracts import ProfileDriftReviewRequest +from app.infrastructure.db import SessionFactory +from app.model.audit import InteractionAudit +from app.model.memory import MemorySyncOutbox +from app.model.profile_tag import AdvisorProfileDriftReview, AdvisorProfileTag +from app.model.risk_questionnaire import ProfileSnapshot +from app.repository.risk_questionnaire_repository import RiskQuestionnaireRepository +from app.service.api_transaction_service import ApiTransactionService, digest +from app.service.authorization_service import AuthorizationService + + +class SessionFactoryLike(Protocol): + def __call__(self) -> AsyncSession: ... + + +class ProfileGovernanceService: + """Internal-only profile governance; no customer-facing projection is returned.""" + + def __init__(self, session_factory: SessionFactoryLike = SessionFactory) -> None: + self.session_factory = session_factory + + async def require_operable(self, customer_id: int) -> None: + """Block advisory decisions while a changed profile is awaiting review.""" + async with self.session_factory() as session: + pending = await RiskQuestionnaireRepository(session).pending_drift_review(customer_id) + if pending is not None: + raise InvalidStateError("画像标签漂移正在复核,暂不能执行投顾分析") + + async def tags(self, customer_id: int, context: RequestContext) -> dict[str, object]: + await AuthorizationService.require(context, "profile-governance:read", admin=True) + async with self.session_factory() as session: + rows = await RiskQuestionnaireRepository(session).tags(customer_id) + return { + "data": [self._tag_view(row) for row in rows], + "meta": {"trace_id": context.trace_id}, + } + + async def pending_reviews(self, context: RequestContext) -> dict[str, object]: + await AuthorizationService.require(context, "profile-governance:read", admin=True) + async with self.session_factory() as session: + rows = await RiskQuestionnaireRepository(session).pending_reviews(limit=100) + return { + "data": [self._review_view(row) for row in rows], + "meta": {"trace_id": context.trace_id}, + } + + async def review( + self, + review_id: int, + payload: ProfileDriftReviewRequest, + context: RequestContext, + key: str | None, + ) -> dict[str, object]: + await AuthorizationService.require(context, "profile-governance:review", admin=True) + + async def operation(session: AsyncSession) -> dict[str, Any]: + repository = RiskQuestionnaireRepository(session) + review = await repository.drift_review(review_id, lock=True) + if review is None: + raise GenericResourceNotFoundError("画像漂移复核记录不存在") + if review.status != "pending_review": + raise InvalidStateError("画像漂移复核记录当前不能审核") + now = datetime.now(UTC).replace(tzinfo=None) + tags = await repository.tags_for_review(review_id, lock=True) + if payload.decision == "approved": + await self._approve(repository, review, tags, now) + else: + await session.execute( + update(AdvisorProfileTag) + .where(AdvisorProfileTag.drift_review_id == review_id) + .values(status="rejected", active_customer_tag=None, updated_at=now) + ) + review.status = payload.decision + review.reviewer_user_id = int(context.user_id) + review.reviewed_at = now + review.review_comment = payload.comment + review.updated_at = now + session.add(InteractionAudit( + actor_type="user", actor_id=int(context.user_id), + target_customer_id=review.customer_id, portal="admin", + action_type="advisor.profile_drift_reviewed", + detail={"review_id": review_id, "decision": payload.decision, + "trace_id": context.trace_id}, created_at=now, + )) + await session.flush() + return { + "data": {"review_id": str(review.id), "status": review.status}, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, f"advisor:profile-drift-review:{review_id}", key, + payload.model_dump(mode="json"), operation, + ) + + async def _approve( + self, + repository: RiskQuestionnaireRepository, + review: AdvisorProfileDriftReview, + tags: list[AdvisorProfileTag], + now: datetime, + ) -> None: + await repository.deactivate_current_profile(review.customer_id, now) + active = await repository.active_tags(review.customer_id, lock=True) + await repository.supersede_active_tags( + review.customer_id, tuple(tag.tag_key for tag in active), now + ) + repository.add_profile(ProfileSnapshot( + profile_uuid=review.candidate_profile_uuid, + customer_id=review.customer_id, + version=review.candidate_profile_version, + snapshot=review.candidate_snapshot, + generation_basis=review.candidate_generation_basis, + snapshot_hash=digest(review.candidate_snapshot), + is_current=True, + generated_at=now, + created_at=now, + updated_at=now, + )) + tag_keys = {tag.tag_key for tag in tags} + if tag_keys: + await repository.session.execute( + update(AdvisorProfileTag) + .where( + AdvisorProfileTag.drift_review_id == review.id, + AdvisorProfileTag.status == "pending_review", + ) + .values( + status="active", + active_customer_tag=( + # A single SQL value cannot vary by tag; update individually below. + None + ), + updated_at=now, + ) + ) + for tag in tags: + tag.status = "active" + tag.active_customer_tag = f"{review.customer_id}:{tag.tag_key}" + tag.updated_at = now + self._add_sync_events(repository, review, now) + + @staticmethod + def _add_sync_events( + repository: RiskQuestionnaireRepository, + review: AdvisorProfileDriftReview, + now: datetime, + ) -> None: + # The same event UUID across targets is intentional; the baseline unique key is + # (event_uuid, target_store), so both projections share one logical change. + event_uuid = str(uuid4()) + payload = { + "customer_id": str(review.customer_id), + "profile_uuid": review.candidate_profile_uuid, + "version": review.candidate_profile_version, + "profile": review.candidate_snapshot, + } + for target_store in ("milvus", "neo4j"): + repository.add_sync_event(MemorySyncOutbox( + event_uuid=event_uuid, aggregate_type="profile", + aggregate_uuid=review.candidate_profile_uuid, + aggregate_version=review.candidate_profile_version, + target_store=target_store, operation="upsert", payload=payload, + status="pending", retry_count=0, next_retry_at=None, last_error=None, + created_at=now, processed_at=None, + )) + + @staticmethod + def _tag_view(row: AdvisorProfileTag) -> dict[str, object]: + return { + "tag_id": str(row.id), "customer_id": str(row.customer_id), + "tag_key": row.tag_key, "tag_value": row.tag_value, + "confidence": str(row.confidence), "source_type": row.source_type, + "source_reference": row.source_reference, + "source_confidence": str(row.source_confidence), + "profile_version": row.profile_version, "status": row.status, + "drift_review_id": str(row.drift_review_id) if row.drift_review_id else None, + } + + @staticmethod + def _review_view(row: AdvisorProfileDriftReview) -> dict[str, object]: + return { + "review_id": str(row.id), "drift_no": row.drift_no, + "customer_id": str(row.customer_id), + "candidate_profile_version": row.candidate_profile_version, + "changed_tags": row.changed_tags, "status": row.status, + "created_at": row.created_at.isoformat() + "Z", + } diff --git a/app/service/relationship_service.py b/app/service/relationship_service.py index 90962ec..2d1c841 100644 --- a/app/service/relationship_service.py +++ b/app/service/relationship_service.py @@ -49,3 +49,20 @@ class RelationshipService: return await self.driver.execute_query(query, customer_id=customer_id, limit=safe_limit) except Exception as exc: return [GraphDegradedResult(f"neo4j_unavailable:{type(exc).__name__}")] + + async def portfolio_industry_context(self, customer_id: int) -> dict[str, object]: + """Return only relationship enrichment; no position value comes from Neo4j.""" + query = ( + "MATCH (c:Customer {customer_id: $customer_id})-[:HOLDS]->" + "(h:Holding)-[:EXPOSED_TO_INDUSTRY]->(i:Industry) " + "RETURN i.industry_name AS industry_name, count(DISTINCT h) AS product_count " + "ORDER BY product_count DESC LIMIT $limit" + ) + try: + rows = await self.driver.execute_query(query, customer_id=customer_id, limit=20) + except Exception as exc: + return {"degraded": True, "reason": f"neo4j_unavailable:{type(exc).__name__}"} + if any(isinstance(row, GraphDegradedResult) for row in rows): + degraded = next(row for row in rows if isinstance(row, GraphDegradedResult)) + return {"degraded": True, "reason": degraded.reason} + return {"degraded": False, "overlaps": list(rows)} diff --git a/app/service/risk_questionnaire_service.py b/app/service/risk_questionnaire_service.py new file mode 100644 index 0000000..e0c212c --- /dev/null +++ b/app/service/risk_questionnaire_service.py @@ -0,0 +1,421 @@ +"""Opening risk-questionnaire application service with server-only scoring.""" + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Any +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.contracts import RequestContext +from app.core.errors import ForbiddenAgentError, InvalidStateError +from app.core.risk_questionnaire_contracts import ( + DECLARATION, + QUESTIONNAIRE_VERSION, + QUESTIONS, + RiskQuestionnaireSubmission, +) +from app.infrastructure.db import SessionFactory +from app.model.audit import InteractionAudit +from app.model.fund import FundRiskAssessment as RiskAssessment +from app.model.memory import MemorySyncOutbox +from app.model.profile_tag import AdvisorProfileDriftReview, AdvisorProfileTag +from app.model.risk_questionnaire import ProfileSnapshot +from app.repository.risk_questionnaire_repository import RiskQuestionnaireRepository +from app.service.api_transaction_service import ApiTransactionService, digest + +# This versioned mapping is deliberately server-side. It is never included in customer responses. +_SCORE_RULES: dict[str, dict[int, int]] = { + "q1": {1: 5, 2: 3, 3: 3, 4: 4, 5: 4, 6: 2, 7: 3, 8: 2, 9: 4, 10: 2, 11: 3, + 12: 1, 13: 1, 14: 3, 15: 3, 16: 2}, + "q2": {1: 4, 2: 3, 3: 2, 4: 1}, + "q3": {1: 3, 2: 4, 3: 2, 4: 5, 5: 1}, + "q4": {1: 4, 2: 3, 3: 2, 4: 1}, + "q5": {1: 4, 2: 3, 3: 2, 4: 1}, + "q6": {1: 5, 2: 4, 3: 3, 4: 2, 5: 1}, + "q7": {1: 5, 2: 4, 3: 3, 4: 2, 5: 1}, + "q8": {1: 4, 2: 3, 3: 2, 4: 1}, + "q9": {1: 4, 2: 3, 3: 2, 4: 1}, + "q10": {1: 1, 2: 2, 3: 3, 4: 4}, + "q11": {1: 5, 2: 4, 3: 3, 4: 2, 5: 1}, + "q12": {1: 4, 2: 3, 3: 2, 4: 1}, + "q13": {1: 4, 2: 3, 3: 2, 4: 1}, +} +_RISK_BANDS = ((22, "C1", "谨慎型"), (31, "C2", "稳健型"), (40, "C3", "平衡型"), + (49, "C4", "成长型"), (57, "C5", "进取型")) +_HORIZONS = {1: "5年以上", 2: "3至5年", 3: "1至3年", 4: "1年以下"} +_ASSET_PREFERENCES = { + 1: ["固定收益类"], + 2: ["固定收益类", "权益类"], + 3: ["固定收益类", "权益类", "衍生品"], + 4: ["固定收益类", "权益类", "衍生品", "其他"], +} + + +@dataclass(frozen=True) +class _ScoreResult: + total: int + risk_level: str + risk_profile: str + + +class RiskQuestionnaireService: + async def questionnaire(self, context: RequestContext) -> dict[str, object]: + self._require_customer(context) + required = await self.is_required(context) + return { + "data": { + "required": required, + "questionnaire": { + "version": QUESTIONNAIRE_VERSION, + "questions": QUESTIONS, + "declaration": DECLARATION, + } if required else None, + }, + "meta": {"trace_id": context.trace_id}, + } + + async def is_required(self, context: RequestContext) -> bool: + if "customer" not in context.roles: + return False + async with SessionFactory() as session: + return not await RiskQuestionnaireRepository(session).has_valid_assessment( + int(context.user_id), _utc_now() + ) + + async def submit( + self, payload: RiskQuestionnaireSubmission, context: RequestContext, key: str | None + ) -> dict[str, object]: + self._require_customer(context) + + async def operation(session: AsyncSession) -> dict[str, Any]: + now = _utc_now() + customer_id = int(context.user_id) + repository = RiskQuestionnaireRepository(session) + if await repository.pending_drift_review(customer_id, lock=True) is not None: + raise InvalidStateError("画像标签漂移正在复核,暂不能提交新的风险测评") + await self._validate_submission_rate(repository, customer_id, now) + score = self.score(payload.answers) + assessment_id = _identifier() + valid_until = _next_year(now) + assessment = RiskAssessment( + id=assessment_id, + customer_id=customer_id, + questionnaire_version=QUESTIONNAIRE_VERSION, + answers=dict(payload.answers), + total_score=score.total, + investor_type=score.risk_level, + assessed_at=now, + valid_until=valid_until, + created_at=now, + ) + version = await repository.next_profile_version(customer_id) + profile_uuid = str(uuid4()) + snapshot = self._profile_snapshot(score, payload.answers, valid_until) + repository.add_assessment(assessment) + basis = { + "source": "fin_risk_assessment", + "assessment_id": str(assessment_id), + "questionnaire_version": QUESTIONNAIRE_VERSION, + } + active_tags = await repository.active_tags(customer_id, lock=True) + candidates = self._profile_tag_candidates(score, payload.answers, assessment_id) + changes = self._drift_changes(active_tags, candidates) + audit_action: str + audit_detail: dict[str, object] + if active_tags and changes: + review = AdvisorProfileDriftReview( + drift_no=str(uuid4()), + customer_id=customer_id, + source_assessment_id=assessment_id, + candidate_profile_uuid=profile_uuid, + candidate_profile_version=version, + candidate_snapshot=snapshot, + candidate_generation_basis=basis, + changed_tags=changes, + status="pending_review", + reviewer_user_id=None, + reviewed_at=None, + review_comment=None, + created_at=now, + updated_at=now, + ) + repository.add_drift_review(review) + await session.flush() + self._add_tags( + repository, customer_id, version, candidates, active_tags, now, + status="pending_review", drift_review_id=review.id, + drift_reasons={ + str(item["tag_key"]): item["reason"] for item in changes + }, + ) + audit_action = "onboarding.profile_drift_detected" + audit_detail = { + "assessment_id": str(assessment_id), + "drift_no": review.drift_no, + "changed_tag_keys": [str(item["tag_key"]) for item in changes], + } + else: + await repository.deactivate_current_profile(customer_id, now) + if active_tags: + await repository.supersede_active_tags( + customer_id, tuple(tag.tag_key for tag in active_tags), now + ) + repository.add_profile(ProfileSnapshot( + profile_uuid=profile_uuid, + customer_id=customer_id, + version=version, + snapshot=snapshot, + generation_basis=basis, + snapshot_hash=digest(snapshot), + is_current=True, + generated_at=now, + created_at=now, + updated_at=now, + )) + self._add_tags( + repository, customer_id, version, candidates, active_tags, now, + status="active", drift_review_id=None, drift_reasons={}, + ) + self._add_profile_sync_events( + repository, profile_uuid, version, customer_id, snapshot, now + ) + audit_action = "onboarding.risk_questionnaire_completed" + audit_detail = {"assessment_id": str(assessment_id), "profile_version": version} + session.add(InteractionAudit( + actor_type="user", + actor_id=customer_id, + target_customer_id=customer_id, + portal=context.portal, + action_type=audit_action, + detail={ + "questionnaire_version": QUESTIONNAIRE_VERSION, + "trace_id": context.trace_id, + **audit_detail, + }, + created_at=now, + )) + return { + "data": { + "completed": True, + "questionnaire_version": QUESTIONNAIRE_VERSION, + "valid_until": valid_until.isoformat() + "Z", + }, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, + "onboarding:risk-questionnaire", + key, + payload.model_dump(mode="json"), + operation, + ) + + @staticmethod + def score(answers: dict[str, int]) -> _ScoreResult: + total = sum( + _SCORE_RULES[question_id][option_id] + for question_id, option_id in answers.items() + ) + for maximum, risk_level, risk_profile in _RISK_BANDS: + if total <= maximum: + return _ScoreResult(total, risk_level, risk_profile) + raise ValueError("questionnaire score exceeds configured range") + + @staticmethod + def _profile_snapshot( + score: _ScoreResult, answers: dict[str, int], valid_until: datetime + ) -> dict[str, object]: + return { + "profile_type": "opening_risk_assessment", + "risk_level": score.risk_level, + "risk_profile": score.risk_profile, + "investment_horizon": _HORIZONS[answers["q9"]], + "preferred_asset_classes": _ASSET_PREFERENCES[answers["q10"]], + "source": "formal_risk_assessment", + "valid_until": valid_until.isoformat() + "Z", + } + + @staticmethod + def _profile_tag_candidates( + score: _ScoreResult, answers: dict[str, int], assessment_id: int + ) -> dict[str, dict[str, object]]: + source_reference = f"fin_risk_assessment:{assessment_id}" + risk_confidence = RiskQuestionnaireService._risk_tag_confidence(score.total) + return { + "risk_level": { + "value": score.risk_level, + "confidence": risk_confidence, + "source_type": "formal_risk_assessment", + "source_reference": source_reference, + "source_confidence": Decimal("1.0000"), + }, + "risk_profile": { + "value": score.risk_profile, + "confidence": risk_confidence, + "source_type": "formal_risk_assessment", + "source_reference": source_reference, + "source_confidence": Decimal("1.0000"), + }, + "investment_horizon": { + "value": _HORIZONS[answers["q9"]], + "confidence": Decimal("1.0000"), + "source_type": "formal_risk_assessment", + "source_reference": source_reference, + "source_confidence": Decimal("1.0000"), + }, + "preferred_asset_classes": { + "value": _ASSET_PREFERENCES[answers["q10"]], + "confidence": Decimal("1.0000"), + "source_type": "formal_risk_assessment", + "source_reference": source_reference, + "source_confidence": Decimal("1.0000"), + }, + } + + @staticmethod + def _risk_tag_confidence(total: int) -> Decimal: + lower = 13 + for upper, _risk_level, _risk_profile in _RISK_BANDS: + if total <= upper: + distance = min(total - lower, upper - total) + return min( + Decimal("0.9500"), + Decimal("0.7000") + Decimal(distance) * Decimal("0.0500"), + ) + lower = upper + 1 + raise ValueError("questionnaire score exceeds configured range") + + @staticmethod + def _drift_changes( + active_tags: list[AdvisorProfileTag], candidates: dict[str, dict[str, object]] + ) -> list[dict[str, object]]: + active_by_key = {tag.tag_key: tag for tag in active_tags} + changes: list[dict[str, object]] = [] + for tag_key, candidate in candidates.items(): + previous = active_by_key.get(tag_key) + if previous is None: + continue + reason: str | None = None + if previous.tag_value != candidate["value"]: + reason = "value_changed" + elif previous.source_type != candidate["source_type"]: + reason = "source_changed" + elif ( + Decimal(str(candidate["confidence"])) + Decimal("0.1500") + < Decimal(previous.confidence) + ): + reason = "confidence_decreased" + if reason is not None: + changes.append({ + "tag_key": tag_key, + "reason": reason, + "previous_value": previous.tag_value, + "candidate_value": candidate["value"], + "previous_confidence": str(previous.confidence), + "candidate_confidence": str(candidate["confidence"]), + "previous_source_type": previous.source_type, + "candidate_source_type": candidate["source_type"], + }) + return changes + + @staticmethod + def _add_tags( + repository: RiskQuestionnaireRepository, + customer_id: int, + profile_version: int, + candidates: dict[str, dict[str, object]], + active_tags: list[AdvisorProfileTag], + now: datetime, + *, + status: str, + drift_review_id: int | None, + drift_reasons: dict[str, object], + ) -> None: + active_by_key = {tag.tag_key: tag for tag in active_tags} + for tag_key, candidate in candidates.items(): + previous = active_by_key.get(tag_key) + repository.add_tag(AdvisorProfileTag( + tag_uuid=str(uuid4()), + customer_id=customer_id, + tag_key=tag_key, + tag_value=candidate["value"], + tag_value_hash=digest(candidate["value"]), + confidence=Decimal(str(candidate["confidence"])), + source_type=str(candidate["source_type"]), + source_reference=str(candidate["source_reference"]), + source_confidence=Decimal(str(candidate["source_confidence"])), + profile_version=profile_version, + drift_review_id=drift_review_id, + previous_tag_id=previous.id if previous is not None else None, + drift_reason=str(drift_reasons[tag_key]) if tag_key in drift_reasons else None, + status=status, + active_customer_tag=(f"{customer_id}:{tag_key}" if status == "active" else None), + created_at=now, + updated_at=now, + )) + + @staticmethod + def _add_profile_sync_events( + repository: RiskQuestionnaireRepository, + profile_uuid: str, + version: int, + customer_id: int, + snapshot: dict[str, object], + now: datetime, + ) -> None: + event_uuid = str(uuid4()) + payload = { + "customer_id": str(customer_id), + "profile_uuid": profile_uuid, + "version": version, + "profile": snapshot, + } + for target_store in ("milvus", "neo4j"): + repository.add_sync_event(MemorySyncOutbox( + event_uuid=event_uuid, + aggregate_type="profile", + aggregate_uuid=profile_uuid, + aggregate_version=version, + target_store=target_store, + operation="upsert", + payload=payload, + status="pending", + retry_count=0, + next_retry_at=None, + last_error=None, + created_at=now, + processed_at=None, + )) + + @staticmethod + async def _validate_submission_rate( + repository: RiskQuestionnaireRepository, customer_id: int, now: datetime + ) -> None: + start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + if await repository.assessments_since(customer_id, start_of_day) >= 2: + raise InvalidStateError("风险测评在一个自然日内不能超过两次") + if await repository.assessments_since(customer_id, now - timedelta(days=365)) >= 8: + raise InvalidStateError("风险测评在一年内不能超过八次") + + @staticmethod + def _require_customer(context: RequestContext) -> None: + if "customer" not in context.roles: + raise ForbiddenAgentError("仅客户可以填写开户风险测评问卷") + + +def _identifier() -> int: + return (uuid4().int >> 64) or 1 + + +def _next_year(value: datetime) -> datetime: + try: + return value.replace(year=value.year + 1) + except ValueError: + return value.replace(year=value.year + 1, month=2, day=28) + + +def _utc_now() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) diff --git a/app/service/suitability_service.py b/app/service/suitability_service.py index a5287bc..117828e 100644 --- a/app/service/suitability_service.py +++ b/app/service/suitability_service.py @@ -159,6 +159,10 @@ class SuitabilityService: profile = await self._load_authority_profile(request.customer_id) return self._decide(request, profile, current) + async def authority_for_customer(self, customer_id: int) -> RiskAuthorityProfile: + """Expose the read-only risk authority for other advisory services.""" + return await self._load_authority_profile(str(customer_id)) + async def _load_authority_profile(self, customer_id: str) -> RiskAuthorityProfile: async with self._session_factory() as session: result = await session.execute(_AUTHORITY_SQL, {"customer_id": int(customer_id)}) diff --git a/docs/21-投顾Agent迁移TODO.md b/docs/21-投顾Agent迁移TODO.md new file mode 100644 index 0000000..cc297a9 --- /dev/null +++ b/docs/21-投顾Agent迁移TODO.md @@ -0,0 +1,602 @@ +# 投顾 Agent 迁移 TODO + +> 目标:以 `qyqy_develop` 为新底座,在不直接合并 `lzl` 的前提下,重新接入现有投顾 Agent 能力。 +> +> 使用规则:每完成一项,将 `[ ]` 改为 `[x]`,并在任务后补充提交号、测试结果或问题记录。 + +## 迁移进度记录 + +### 最新 qyqy_develop 基座合并(2026-09-11) + +已基于最新 `origin/qyqy_develop@76e87a33a7` 创建集成分支 +`lzl_qyqy_integration`,并在不修改 `lzl_develop` 的前提下合并投顾成果。 +平台、鉴权、风控、场外、推广、知识库、CORS、请求校验及既有 Agent 注册均以 +`qyqy_develop` 为准;开户问卷、投资目标、持仓分析、动态资产配置、产品推荐、 +会话闭环、画像治理、场内行情双源和投顾工具以 `lzl_develop` 为准,互不冲突的 +能力均已保留。 + +数据库基线 `docs/00-新数据库基线设计.md` 未修改。新增 +`20260911_merge_advisor_risk_heads` 仅收敛投顾链与基座风险索引链的 Alembic 图, +`upgrade`/`downgrade` 均无 DDL;当前唯一 head 为 +`20260911_merge_adv_risk_heads`。本机开发 JWT 密钥已按基座工具生成至被忽略的 +`config/jwt/dev/`,不进入版本控制。 + +合并验证结果:单元测试 `1186 passed, 2 skipped`,契约测试 `21 passed`,Ruff +通过,MyPy 对 `223` 个 app 源文件通过。测试仅出现既有第三方弃用警告和 Alembic +配置弃用警告,无失败项。 + +### 阶段一:迁移准备 + +已完成本地冻结和质量基线:提交 `5dcaf7c`,标签 `advisor-before-base-migration`。数据库备份保存于 `.migration-backups/jr_agent-before-base-migration.sql`,接口快照保存于 `.migration-backups/current-openapi.json`。 + +阶段一测试结果:单元/契约测试 `259 passed`,集成测试 `21 passed`,Ruff 通过,MyPy 通过,数据库结构审计通过,Alembic 当前版本为 `20260911_adv_profile_tags`。 + +当前阻塞:远程仓库 `47.106.207.27:3000` 暂时无法连接;`lzl_develop` 尚未确认存在。当前新底座已包含 `tools/audit_constraints.py`,旧库版本不兼容问题待切换独立测试库处理。 + +已有测试库只读复核(2026-09-11):`migration_state_check.py` 和 `alembic current` 均显示 +`20260911_adv_profile_tags (head)`,但结构审计发现 8 张历史场外表,约束审计发现 +`fin_holding`、`fin_market_price`、`fin_nav_history`、`sys_customer_assignment` 共 12 项唯一键 +漂移。因此拒绝在原 `jr_agent` 库执行 `upgrade` 或任何修复性 DDL;该库不能计入“已有测试库 +迁移成功”,仍以独立迁移库 `jr_agent_qyqy_migration` 作为新底座验收库。 + +### 阶段二:建立新开发分支 + +已基于本地缓存的 `origin/qyqy_develop@6516ccb` 创建 `lzl_develop`。新底座单元/契约测试 `447 passed`,Ruff 通过,MyPy(103 个源文件)通过。 + +注意:当前 MySQL 测试库仍记录旧投顾迁移版本 `20260911_adv_profile_tags`,新底座无法解析该版本;后续必须使用新底座基线重新初始化或准备独立迁移数据库,不能直接在该旧库上继续升级。 + +### 阶段三:公共底座适配 + +已新增最小投顾 Agent `advisor`,通过新底座的 `BaseAgent`、`AgentFactory`、`ToolExecutor`、意图声明和场内行情只读工具运行。当前只接入行情查询,产品、问卷、投资目标、持仓和推荐能力留待后续阶段逐项接入。 + +阶段三测试结果:专项测试 `9 passed`,全量单元/契约测试 `448 passed`,Ruff 通过,MyPy(104 个源文件)通过。适配提交:`5e8eecc`。 + +### 阶段四:数据库和迁移 + +已将旧投顾迁移按 `qyqy_develop` 的 `20260910_drop_review_separation` head +重新接入为一条连续链,新增产品治理、合同证据、行业/资产分类、历史行情、行情源健康、 +数据质量、动态配置回测、投资目标、会话目标抽取和画像标签治理表。未修改基线迁移, +未删除、重命名或改变基线表和字段;迁移契约测试对此有静态校验。 + +阶段四测试结果:迁移契约 `2 passed`,全量单元测试 `442 passed`,Ruff 通过,MyPy +(104 个源文件)通过;独立 MySQL 库 `jr_agent_qyqy_migration` 在线执行 +`alembic upgrade head` 成功,结构审计显示 `72` 张业务表无缺失或多余,约束/ORM 审计通过。 +原 `jr_agent` 库仍记录旧投顾迁移版本 `20260911_adv_profile_tags`,不能直接用新底座升级, +已保留不动,待数据库负责人按迁移方案另行切换。 + +### 阶段五:产品数据和适当性(进行中) + +已恢复 `hq.py` 公开数据适配器、场内产品导入工具、南方官网适当性/合同同步工具, +并新增投顾证据只读模型与 Repository。独立测试库已导入 19 个南方场内 ETF/LOF, +同步 19 条官网适当性披露和 19 条合同证据,实际权威风险分布为 R1=1、R2=4、R3=6、 +R4=7、R5=1;资产分类成功 18 个,1 个因合同证据不足跳过。每个等级至少四个产品 +不能由系统伪造,保留为未完成验收项。 + +阶段五已完成部分测试:产品证据 Repository `2 passed`,官网治理解析 `3 passed`, +治理导入/分类导入 `5 passed`,合计专项 `10 passed`;Ruff、MyPy、数据库结构审计和 +约束审计通过。阶段五后续补齐了历史平均成交额流动性证据、流动性门槛失败关闭、 +R1-R5 适当性硬过滤和排除原因。权威数据验收仍保留两个限制:R1/R5 尚不足四个 +测试产品,1 个产品因合同证据不足未完成资产分类;完整推荐侧联动留待阶段十。 +阶段五测试结果:专项 `7 passed`,全量单元测试 `473 passed, 3 warnings`,Ruff 和 +MyPy 通过。阶段五核心提交:`291cb7b`、`47a5ca4`。 + +### 阶段六:开户风险问卷 + +已完成开户问卷查询和提交接口、服务端 C1-C5 评分与风险等级生成、开户画像快照、 +问卷有效期和每日/年度提交次数限制。首次登录访问非 onboarding 接口时会被拦截, +问卷结果通过 `MemorySyncOutbox` 同步画像投影;客户响应不包含答案、总分、风险等级、 +画像或来源置信度。提交使用 `Idempotency-Key`,重复请求按请求摘要幂等处理。 + +阶段六测试结果:专项测试 `7 passed`,全量单元测试 `464 passed, 3 warnings`,Ruff +通过,MyPy(116 个源文件)通过。数据库审计工具已执行,但默认 `.env` 连接的是旧 +`jr_agent` 库,结构审计发现旧库的场外表和基线约束差异;阶段四已验证独立库 +`jr_agent_qyqy_migration` 的迁移结构,不能将旧库结果作为新底座验收结果。 +阶段六提交:`acb9175`。 + +### 阶段七:投资目标和目标书 + +已完成投资目标结构化采集、目标书草稿生成、客户确认、投顾审核和发布闭环。目标 +创建后状态为 `pending_confirmation`,目标书使用基线 `client_facing_content` 的 +`pending -> approved -> published` 审核状态;未经审核不能发布。目标查询工具只向 +Agent 暴露客户最新的 `confirmed` 目标,未确认目标不会进入后续推荐和配置输入。 +目标收益范围、最大回撤、流动性要求、期限、业绩比较基准和备注均有服务端校验, +目标书含收益非承诺、非交易指令披露;写操作统一使用 `Idempotency-Key` 并写入审计。 + +阶段七测试结果:专项测试 `5 passed`,全量单元测试 `468 passed, 3 warnings`,Ruff +通过,MyPy(122 个源文件)通过。阶段七提交:`60c4a49`。 + +### 阶段八:持仓分析 + +已基于现行 `fin_holding`、`fin_product` 和投顾行业/历史指标表完成只读持仓分析: +支持持仓查询、市值汇总、单产品集中度、行业穿透、产品/行业 HHI、行业与历史指标 +覆盖率,并在市值或行业数据不足时返回明确的降级状态和警告。MySQL 是数值分析的 +唯一事实来源,输出只生成分析结论,不生成交易指令;Neo4j 仅通过 +`RelationshipService` 的固定关系模板提供行业重叠辅助上下文,连接失败、超时或结果 +异常时明确降级,不影响 MySQL 数值分析。 + +阶段八测试结果:图谱与持仓专项测试 `8 passed`,全量单元测试 `475 passed, 3 warnings`,Ruff +通过,MyPy(127 个源文件)通过。阶段八核心提交:`281e76e`、`8ffd08b`。 + +阶段九已完成动态资产配置与真实历史回测:接入 C1-C5 基础权重、收益目标、最大回撤、 +流动性和投资期限约束,读取经适当性、合同证据、资产分类和数据质量门槛过滤的场内基金 +历史指标,输出动态权重、静态基线、数据覆盖率和因子证据;覆盖不足时静态降级。已接入 +`advisor` Agent 的 `asset_allocation` 意图、`generate_asset_allocation` 只读工具和 +`POST /api/v1/advisor/asset-allocation` 接口;管理员可通过回测接口生成静态/动态收益、 +最大回撤、再平衡次数和限制条件,并写入新增回测证据表。 + +阶段九测试结果:专项测试 `9 passed`,全量单元/契约测试 `491 passed, 3 warnings`,Ruff +通过,MyPy(135 个源文件)通过。阶段九提交:`5ea36e4`(动态配置核心提交:`ff71a1a`)。 + +阶段十已完成产品推荐与审核发布核心:推荐前读取有效风险测评和已确认投资目标,使用权威 +场内产品、销售机构适当性、合同证据和流动性数据做硬过滤,再结合风险匹配、流动性和期限 +进行排序;每个入选产品返回证据卡片,每个排除产品返回原因。推荐方案写入基线 +`client_facing_content` 并默认进入 `pending_review`,管理员审核通过后发布,客户只能读取已 +发布方案;Agent 和接口均明确不生成交易委托。 + +阶段十测试结果:推荐服务专项测试 `3 passed`,全量单元/契约测试 `493 passed, 3 warnings`, +Ruff 通过,MyPy(138 个源文件)通过。阶段十提交:`0d42779`;生产数据库和端到端联调待完成。 + +阶段十一已完成会话闭环核心:投顾 Agent 支持投资目标、产品推荐、持仓分析、资产配置、 +行情和对比意图路由;投资目标会话抽取只接受用户明确陈述,按会话合并多轮字段,写入内部 +`advisor_goal_conversation_extraction` 快照并根据缺口追问。缺口状态使用既有 +`svc_conversation_session.clarification_round` 持久化,达到 10 轮后转人工;Agent Run 完成 +时同步保存 `last_intent`。目标抽取记录、会话审计、Agent Run、Outbox、记忆召回和 Episode +聚合均沿用公共底座链路,客户响应不返回抽取字段、置信度、内部表名或治理信息。对比意图当前 +完成安全路由和参数提示,对比计算工具仍是后续待办。 + +阶段十一测试结果:专项 `4 passed`;单元/契约 `497 passed, 3 warnings`;Ruff 通过;MyPy +(140 个源文件)通过。集成测试 `25 passed, 3 failed, 1 skipped`:失败均为既有测试库状态 +问题(`chk_config_release_separation` 未按新底座迁移撤下、测试账号外键缺失,以及 UTC +测试依赖的数据库状态),不是本阶段代码回归;独立迁移库和端到端会话验收仍待执行。 + +阶段十二已完成画像标签治理闭环:开户问卷继续生成带置信度、来源类型、来源引用和画像版本 +的标签;标签值、来源变化和置信度明显下降会生成待复核候选画像。新增画像治理 Service 和 +管理员接口,可查看标签证据、查看待复核队列并审核通过/拒绝。审核通过在同一事务中切换当前 +画像、激活候选标签并写入 Milvus/Neo4j 两条 `memory_sync_outbox` 事件;审核拒绝保留旧画像。 +推荐、资产配置和持仓分析的 API 与 Agent 工具均接入漂移复核暂停闸门,客户接口不暴露内部 +标签、置信度、来源和审核信息。当前项目没有独立的调仓模拟入口,该项未虚报完成。 + +阶段十二测试结果:专项 `12 passed`;单元/契约 `499 passed, 3 warnings`;Ruff 通过;MyPy +(142 个源文件)通过;OpenAPI 已确认新增 3 个管理员画像治理路由。数据库结构未新增迁移, +复用阶段四已建立的画像标签、漂移复核和 Outbox 表;独立迁移库真实端到端审核已通过: +第二次问卷提交生成 1 条待复核记录,管理员审核通过后可查询 8 条内部标签。 + +阶段十三已完成全端验收(代码提交前):单元测试 `491 passed, 3 warnings`,契约测试 `8 passed`, +Ruff 和 MyPy 通过;独立迁移库 `jr_agent_qyqy_migration` 使用 `root` 账号执行 +`alembic upgrade head`、结构审计和约束审计均通过,集成测试 `28 passed, 1 skipped`。 +真实 HTTP + JWT + Worker 验收脚本已补齐首次登录问卷前置,HTTP 替身治理链路 `9/9` 通过, +覆盖问卷拦截/提交、Agent 授权、越权、未注册 Agent、SSE 内容事件、Worker 完成态和结果查询。 +Redis 不可用时实测按设计降级放行;生产装配模式因本机 Milvus/模型等依赖连接阻塞,已终止并保留为部署环境复验项。 +原 `jr_agent` 库的集成测试仍为 `25 passed, 3 failed, 1 skipped`,失败是旧约束、测试账号外键和 UTC +状态差异;其结构审计多出 8 张历史场外表,约束审计有 12 条历史唯一键差异,均未修改原库。 + +阶段十四完成行情增量管道修复:`hq.py` 新增场内日线 K 线成交额解析(Eastmoney f56),净值历史同步 +按交易日合并真实成交额;新增 `tools/sync_advisor_market_data.py`,可一次完成历史增量、指标重算和 +数据质量快照 upsert。历史接口失败时保留净值、成交额为空,质量状态继续 `rejected`,不绕过推荐和动态 +配置的失败关闭门槛。专项测试 `7 passed`,全量单元测试 `494 passed, 3 warnings`,Ruff 和 MyPy +通过。独立迁移库真实刷新结果:历史 `5240` 条、`19` 个产品,成交额非空 `0` 条;东方财富历史 K 线 +端点批量请求出现 `RemoteProtocolError`,因此质量 `rejected=19`,产品推荐和动态配置真实验收仍待 +行情源恢复后复验。实现提交:`930dd59`。 + +阶段十五完成对比分析工具:新增 `ProductComparisonQuery` 和只读 `ProductComparisonService`,通过 +公共 `ToolExecutor` 注册 `compare_products`,读取已审核的场内产品证据、合同字段、流动性和行业暴露, +输出共同行业、差异字段和证据来源;缺少权威证据时返回 `evidence_required`,不生成交易指令。客户侧 +对比意图已可从消息提取 2 至 4 个基金代码并返回摘要。专项测试 `4 passed`,全量单元测试 `497 passed, +3 warnings`,契约测试 `8 passed`,独立迁移库集成测试 `28 passed,1 skipped`,Ruff 和 MyPy 通过。 +真实验收:`159511` 与 `510500` 对比返回 `ready`、2 个产品和差异字段。实现提交:`b6429e0`。 + +阶段十六完成双源行情编排:新增 `MarketQuoteSyncService` 和 `tools/sync_advisor_market_quotes.py` 入口及三个行情健康/来源运行/告警表的 ORM +映射,东方财富主源失败或部分返回时按优先级切换腾讯备用源;记录来源状态、连续失败次数、开放告警 +及恢复关闭,成功行情按字段精度量化后写入场内行情快照。真实独立库同步验证两源均失败时返回 +`degraded=true`、不写入伪行情并进入失败告警链路;本次无可用行情,推荐和动态配置继续失败关闭。 +专项测试 `6 passed`,全量单元测试 `499 passed,3 warnings`,契约测试 `8 passed`,Ruff 和 MyPy +通过。实现提交:`2376585`;命令入口提交:`fbb1171`。 + +本次继续完成灰度闸门和回滚手册:新增 `AdvisorRolloutService`,由环境变量控制投顾灰度, +开启后管理员放行、客户按白名单放行,未命中返回 `403 AGENT_PERMISSION_DENIED` 并写入 +`advisor.rollout_denied` 审计;已接入投顾业务路由和 `advisor` Agent 运行入口。新增操作手册 +`docs/22-投顾Agent灰度与回滚操作手册.md`。专项测试 `6 passed`,全量单元测试 `505 passed, +3 warnings`,Ruff 和 MyPy(146 个源文件)通过。实现提交:`f5dd5b8`。生产/联调环境的 +实际灰度与回滚演练仍待执行。 + +## 一、迁移准备 + +- [ ] 确认远程仓库可访问。(当前失败:连接 `47.106.207.27:3000` 被拒绝) +- [x] 确认 `origin/qyqy_develop` 的最新提交。(已确认本地缓存引用为 `6516ccb`,待远程恢复后重新 fetch) +- [ ] 确认是否存在远程 `lzl_develop`。(远程暂不可访问) +- [x] 确认当前 `lzl` 工作区没有未解释的代码改动。(已提交 `5dcaf7c`;`.idea` 和 `.migration-backups` 已加入忽略) +- [x] 提交 `lzl` 当前所有投顾开发成果。(`5dcaf7c`) +- [x] 创建迁移前标签 `advisor-before-base-migration`。 +- [x] 备份当前测试库。(`.migration-backups/jr_agent-before-base-migration.sql`) +- [x] 导出当前数据库结构和迁移版本。(数据库迁移 head:`20260911_adv_profile_tags`) +- [ ] 导出当前产品、适当性、合同和行情数据。 +- [x] 保存当前接口清单和 Swagger 截图。(OpenAPI JSON:`.migration-backups/current-openapi.json`) +- [x] 保存当前单元、契约和集成测试结果。(259 个单元/契约测试,21 个集成测试) +- [ ] 保存当前投顾 Agent 端到端验收记录。 + +## 二、建立新开发分支 + +- [x] 基于 `origin/qyqy_develop` 创建 `lzl_develop`。(基于本地缓存 `6516ccb`,远程恢复后需重新 fetch) +- [x] 确认新分支工作区干净。(`619a5e6` 后无业务代码改动,迁移备份已忽略) +- [x] 建立迁移模块分支或提交规范。(按本 TODO 的模块提交名执行) +- [x] 确认 `qyqy_develop` 的 Python、数据库和中间件版本要求。(已核对 `pyproject.toml`、`.env.example` 和底座文档) +- [x] 确认新底座的启动命令和环境变量。(已核对 `uvicorn app.main:app`、Worker 入口和环境配置) +- [x] 确认新底座的数据库迁移 head。(代码 head:`20260910_drop_review_separation`;旧测试库版本不兼容) +- [x] 确认新底座的公共测试可以通过。(单元/契约 `447 passed`) + +建议命令: + +```powershell +git fetch origin qyqy_develop +git switch -c lzl_develop origin/qyqy_develop +python -m pytest tests/unit tests/contract -q +python -m ruff check app tests tools alembic +python -m mypy app +``` + +## 三、公共底座适配 + +- [x] 阅读并记录 `app/core/contracts.py` 的请求和响应契约。 +- [x] 阅读并记录 `app/core/errors.py` 的错误码和错误信封。 +- [x] 确认 JWT 鉴权和 RBAC 解析流程。 +- [x] 确认 `BaseAgent` 的标准执行流程。 +- [x] 确认 `AgentFactory` 的 Agent 注册方式。 +- [x] 确认 `ToolExecutor` 的工具鉴权和角色限制。 +- [x] 确认模型路由、模型降级和超时处理方式。 +- [x] 确认 Redis、Milvus、Neo4j 的接入方式。 +- [x] 确认记忆抽取、召回、生命周期和 Episode 聚合流程。 +- [x] 确认 Worker 注册和事件消费方式。 +- [x] 适配投顾 Agent 的公共请求和响应结构。(复用 `BaseAgent` 和 `AgentRequest/CoreResult`) +- [x] 适配投顾工具注册。(复用 `query_fund_quote` 只读工具) +- [x] 适配投顾 Agent 的 `AgentDefinition`。(新增 `advisor`,版本 `0.1.0`) +- [x] 适配 `app/main.py` 的路由注册。(复用已注册 Agent Run 入口,无新增旁路) +- [x] 确认所有写接口使用 `Idempotency-Key`。(底座契约测试通过) +- [x] 确认所有管理操作写入审计。(底座契约测试通过) +- [x] 确认所有跨存储同步使用 Outbox。(底座 Worker/事件测试通过) +- [x] 完成底座适配提交 `advisor/base-adaptation`。(`5e8eecc`) + +验收: + +- [x] 公共底座单元测试通过。(已包含于 `447 passed`) +- [x] 公共底座契约测试通过。(已包含于 `447 passed`) +- [x] AgentFactory 测试通过。(已包含于 `447 passed`) +- [x] ToolExecutor 测试通过。(已包含于 `447 passed`) +- [x] 鉴权和权限边界测试通过。(已包含于 `447 passed`) +- [x] Ruff 检查通过。 +- [x] MyPy 检查通过。(103 个源文件) + +## 四、数据库和迁移 + +- [x] 对比 `qyqy_develop` 数据库基线和 `docs/00-新数据库基线设计.md`。(迁移契约测试 + 在线结构审计) +- [x] 确认没有删除或重命名基线表。(14 个投顾迁移仅 `CREATE TABLE`) +- [x] 确认没有删除、重命名或复用基线字段。(迁移契约测试通过) +- [x] 确认没有改变基线字段类型和可空性。(未修改基线迁移) +- [x] 确认迁移链只有一个 head。(`20260911_adv_profile_tags`) +- [x] 迁移产品参考数据表。 +- [x] 迁移产品治理和适当性表。 +- [x] 迁移产品合同证据表。 +- [x] 迁移产品行业和资产类别表。 +- [x] 迁移历史行情表。 +- [x] 迁移产品指标快照表。 +- [x] 迁移行情源监控和失败记录表。 +- [x] 迁移行情质量表。 +- [x] 迁移资产配置回测表。 +- [x] 迁移投资组合图谱投影检查点表。 +- [x] 迁移投资目标表。 +- [x] 迁移会话目标抽取表。 +- [x] 迁移画像标签表 `advisor_profile_tag`。 +- [x] 迁移画像漂移复核表 `advisor_profile_drift_review`。 +- [x] 为历史当前画像回填标签证据。(迁移包含回填逻辑;独立空库无历史画像) +- [x] 在空库执行迁移。(`jr_agent_qyqy_migration`) +- [ ] 在已有测试库执行迁移。(2026-09-11 只读审计拒绝:80 张表、8 张历史场外表、12 项唯一键漂移;不得直接升级) +- [x] 执行数据库结构审计。(72 张业务表,无缺失或多余) +- [x] 执行数据库约束审计。(唯一键与 ORM 映射通过) +- [x] 完成数据库迁移提交 `advisor/database-migrations`。(`9ed536e`) + +验收命令: + +```powershell +python -m alembic upgrade head +python tools/audit_schema.py +python tools/audit_constraints.py +``` + +## 五、产品数据和适当性 + +- [x] 迁移场内基金产品模型。(复用 `FundProduct`,新增证据表只读映射) +- [x] 迁移产品查询 Repository。(`AdvisorProductRepository`) +- [x] 导入南方场内基金产品。(独立库 19 个 ETF/LOF) +- [x] 导入 R1-R5 产品适当性等级。(官网披露;五级均有,实际分布见阶段记录) +- [x] 导入基金合同字段和权威来源。(19 条官网合同证据) +- [x] 导入销售机构披露信息。(南方基金直销披露) +- [x] 导入产品状态和交易状态。(仅导入 `SSE/SZSE` 且 `上市` 产品) +- [x] 接入产品治理变更监控。(`ProductGovernanceMonitorService` + 官网同步工具) +- [x] 接入产品资产规模指标。(`advisor_product_reference_snapshot`) +- [x] 接入产品历史行情指标。(增量 NAV 同步 + `ProductMetricService`) +- [x] 接入产品流动性指标。(平均日成交额、最新行情时间、流动性状态) +- [x] 实现场内基金过滤。(Repository 强制 `SSE/SZSE`) +- [x] 实现适当性硬过滤。(R1-R5 不匹配失败关闭并返回排除原因) +- [x] 实现合同证据过滤。(verified + 有来源 URL/文档摘要) +- [x] 实现来源缺失时的失败关闭。(缺失权威证据不进入候选) +- [x] 完成产品数据提交 `advisor/product-data`。(专项 `7 passed`;完整推荐侧联动留待阶段十) + +验收: + +- [x] R1、R2、R3、R4、R5 均有可查询产品。(19 个南方场内产品,五级均有) +- [ ] 每个风险等级至少有四个测试产品。 +- [ ] 各基金类型分类正确。(1 个产品因合同证据不足跳过分类) +- [x] 场外基金不会进入场内交易表。 +- [x] 不适配产品会被排除并说明原因。(硬过滤返回 `reason_code`) +- [x] 产品来源和合同证据可追溯。 + +## 六、开户风险问卷 + +- [x] 迁移问卷查询接口。 +- [x] 迁移问卷提交接口。 +- [x] 迁移服务端评分规则。 +- [x] 迁移 C1-C5 风险等级生成。 +- [x] 迁移开户画像快照生成。 +- [x] 迁移问卷有效期控制。 +- [x] 迁移每日和年度提交次数限制。 +- [x] 迁移首次登录拦截。 +- [x] 迁移画像同步 Outbox 事件。 +- [x] 确认客户响应不返回答案、总分、风险等级和画像。 +- [x] 确认问卷重复提交具有幂等性。 +- [x] 完成风险问卷提交 `advisor/risk-questionnaire`。(专项 `7 passed`;全量单元 `464 passed`) + +验收: + +- [x] 新客户访问投顾业务会被拦截。 +- [x] 新客户可以查询问卷。 +- [x] 新客户可以成功提交问卷。 +- [x] 问卷结果正确写入后台表。 +- [x] 客户响应不包含内部评分信息。 +- [x] 投顾工具可以读取必要的内部画像投影。 + +## 七、投资目标和目标书 + +- [x] 迁移投资目标创建接口。 +- [x] 迁移收益目标下限和上限。 +- [x] 迁移最大回撤字段。 +- [x] 迁移流动性要求字段。 +- [x] 迁移投资期限字段。 +- [x] 迁移业绩比较基准字段。 +- [x] 迁移目标备注字段。 +- [x] 迁移目标书生成。 +- [x] 迁移客户确认流程。 +- [x] 迁移投顾审核流程。 +- [x] 迁移当前目标查询工具。 +- [x] 迁移目标缺口状态。 +- [x] 确认目标创建后为 `pending_confirmation`。 +- [x] 确认未确认目标不能用于推荐和配置。 +- [x] 确认目标书未审核不能对客发布。 +- [x] 确认收益目标不被表述为收益承诺。 +- [x] 完成投资目标提交 `advisor/investment-goal`。(专项 `5 passed`;全量单元 `468 passed`) + +## 八、持仓分析 + +- [x] 迁移持仓查询。 +- [x] 迁移持仓市值计算。 +- [x] 迁移单产品集中度计算。 +- [x] 迁移行业集中度计算。 +- [x] 迁移 HHI 指标计算。 +- [x] 迁移行业穿透计算。 +- [x] 迁移行情和行业数据覆盖率计算。 +- [x] 迁移 Neo4j 图谱增强。(固定关系模板、参数化查询、2 秒超时) +- [x] 确认 MySQL 是数值分析权威来源。 +- [x] 确认 Neo4j 只做关系增强。 +- [x] 实现图谱不可用时的降级。(连接失败、超时或结果异常均标记 degraded) +- [x] 实现关键数据不足时的降级。 +- [x] 确认分析结果不生成交易指令。 +- [x] 完成持仓分析提交 `advisor/portfolio-analysis`。(专项 `8 passed`;全量单元 `475 passed`) + +验收: + +- [x] 无持仓时返回明确状态。 +- [x] 缺少市值时不计算集中度。 +- [x] 行业覆盖不足时不输出确定性行业结论。 +- [x] 图谱不可用时仍可返回可靠的数值分析。(当前图谱未配置,数值分析仍可返回) +- [x] 客户看不到内部数据库查询细节。 + +## 九、动态资产配置 + +- [x] 迁移 C1-C5 基础配置。 +- [x] 迁移投资期限约束。 +- [x] 迁移流动性约束。 +- [x] 迁移最大回撤约束。 +- [x] 迁移收益目标约束。 +- [x] 迁移历史行情指标读取。 +- [x] 迁移动态权重优化器。 +- [x] 迁移流动性覆盖率。(以历史指标覆盖率和平均日成交额作为证据) +- [x] 迁移动态配置回测。(滚动 120 个交易日、每 20 个交易日动态再平衡) +- [x] 保存配置回测证据。(`advisor_allocation_backtest_run`) +- [x] 实现数据覆盖不足时的降级状态。(覆盖少于两个资产类别时返回静态配置) +- [x] 确认输出配置比例而不是买卖指令。 +- [x] 完成资产配置提交 `advisor/asset-allocation`。(专项 `9 passed`;全量 `491 passed`) + +验收: + +- [x] 收益目标参与优化。 +- [x] 最大回撤参与优化。 +- [x] 流动性要求参与优化。 +- [x] 投资期限参与优化。 +- [x] 动态配置与静态配置可以对比。(输出 `dynamic` 和 `strategic_allocation`) +- [x] 回测结果包含限制条件和数据覆盖率。 + +## 十、产品推荐和审核发布 + +- [x] 迁移产品推荐 Agent。 +- [x] 迁移客户画像读取。(使用服务端权威风险测评投影) +- [x] 迁移投资目标读取。(只读取已确认目标) +- [x] 迁移适当性硬过滤。 +- [x] 迁移合同证据过滤。 +- [x] 迁移行情和流动性校验。 +- [x] 迁移图谱增强。(组合行业关系作为辅助上下文,故障时降级) +- [x] 迁移多因子排序。(风险匹配、流动性和期限) +- [x] 迁移个性化推荐理由。 +- [x] 迁移推荐证据卡片。 +- [x] 迁移排除原因。 +- [x] 迁移推荐方案生成。 +- [x] 迁移 `pending_review` 状态。 +- [x] 迁移管理员审核接口。 +- [x] 迁移审核拒绝原因。 +- [x] 迁移审核通过后发布。 +- [x] 确认审核前不可对客发布。 +- [x] 确认推荐不生成交易委托。 +- [x] 完成产品推荐提交 `advisor/recommendation`。(专项 `3 passed`;全量 `493 passed`) + +验收: + +- [x] 画像未完成时不能推荐。 +- [x] 投资目标未确认时不能推荐。 +- [x] 不适配产品不会进入候选列表。 +- [x] 每个推荐产品都有证据。 +- [x] 每个排除产品都有原因。 +- [x] 推荐方案默认进入待审核。 +- [x] 审核通过后客户才能查看发布内容。 + +## 十一、会话闭环 + +- [x] 迁移意图分类。(沿用公共 `IntentClassifier`;新增对比意图声明) +- [x] 迁移产品推荐意图。 +- [x] 迁移持仓分析意图。 +- [x] 迁移资产配置意图。 +- [x] 迁移对比分析意图。(`compare_products` 只读工具已接入,支持 2 至 4 个场内基金代码) +- [x] 迁移投资目标意图。 +- [x] 迁移会话实体抽取。(`GoalConversationService`,只抽取明确目标字段) +- [x] 迁移投资目标缺口识别。(按当前会话所有用户轮次合并) +- [x] 迁移缺口追问状态。(使用 `clarification_round`,上限 10 轮) +- [x] 迁移会话状态持久化。(保存 `last_intent` 和缺口轮次) +- [x] 迁移记忆召回。(复用 `PlatformGovernance` 组合召回) +- [x] 迁移 Episode 聚合。(复用 `EpisodeWorker`) +- [x] 迁移 Agent Run 持久化。(复用 `AgentPersistenceService`) +- [x] 迁移 Outbox 事件。(复用 Agent Run 和记忆事件链路) +- [x] 迁移会话审计。(目标缺口评估和运行/工具均写统一审计) +- [x] 确认用户消息可以形成完整闭环。(专项 + 单元/契约测试通过) +- [x] 确认缺少字段时只追问必要信息。(专项测试覆盖) +- [x] 确认工具失败时返回可理解的降级结果。(既有各业务工具降级契约通过) +- [x] 完成会话闭环提交 `advisor/conversation`。(真实库端到端验收待完成) + +## 十二、画像标签和漂移复核 + +- [x] 迁移画像标签模型。(复用新增 `advisor_profile_tag`) +- [x] 迁移标签值保存。 +- [x] 迁移标签置信度保存。 +- [x] 迁移来源类型保存。 +- [x] 迁移来源引用保存。 +- [x] 迁移来源置信度保存。 +- [x] 迁移画像版本保存。 +- [x] 迁移标签生效状态。 +- [x] 迁移标签值变化检测。 +- [x] 迁移来源变化检测。 +- [x] 迁移置信度下降检测。 +- [x] 迁移候选画像保存。(复核记录保存候选快照) +- [x] 迁移漂移复核队列。 +- [x] 迁移管理员查看标签证据接口。 +- [x] 迁移管理员审核通过接口。 +- [x] 迁移管理员审核拒绝接口。 +- [x] 审核通过后切换当前画像。 +- [x] 审核通过后创建 Milvus 同步事件。 +- [x] 审核通过后创建 Neo4j 同步事件。 +- [x] 审核期间暂停产品推荐。 +- [x] 审核期间暂停资产配置。 +- [x] 审核期间暂停持仓分析。 +- [ ] 审核期间暂停调仓模拟。 +- [x] 确认客户不能读取内部标签、置信度和审核信息。(无客户侧标签路由) +- [x] 完成画像治理提交 `advisor/profile-governance`。(真实库端到端审核验收待完成) + +## 十三、全端测试 + +- [x] 执行全部单元测试。(`505 passed, 3 warnings`,Python 3.13) +- [x] 执行全部契约测试。(`8 passed`,Python 3.13) +- [x] 执行全部集成测试。(独立迁移库 `28 passed, 1 skipped`;原 `jr_agent` 为 `25 passed, 3 failed, 1 skipped`,失败均为历史库状态) +- [x] 执行 Ruff 检查。(通过) +- [x] 执行 MyPy 检查。(通过,`142` 个 app 源文件;验收工具单独检查也通过) +- [x] 执行数据库结构审计。(独立迁移库 `72` 张业务表通过) +- [x] 执行数据库约束审计。(独立迁移库通过) +- [x] 验证空库迁移。(独立迁移库按基线迁移链执行 `alembic upgrade head` 成功) +- [ ] 验证已有测试库迁移。(2026-09-11 只读审计拒绝,原因见阶段一记录) +- [x] 验证首次登录问卷流程。(真实 HTTP 验收:查询 `200`、首次提交 `201`、业务接口拦截解除) +- [x] 验证投资目标创建和确认流程。(真实 HTTP:创建 `201`、确认 `200`;目标书审核/发布均 `200`) +- [ ] 验证产品推荐流程。(真实 HTTP 可生成待审核方案,但 0 个候选;历史行情无成交额,流动性门槛按失败关闭) +- [x] 验证持仓分析流程。(真实 HTTP 返回 `ready`,2 条场内模拟持仓、集中度预警;行业数据按实际缺失降级) +- [ ] 验证动态资产配置流程。(真实 HTTP 返回 `ready`,但指标覆盖 `0.00`、`dynamic=false`,当前为静态降级) +- [x] 验证推荐审核发布流程。(真实 HTTP 审核和发布均 `200`;当前方案无产品,原因见推荐数据门槛) +- [x] 验证画像漂移复核流程。(真实 HTTP:问卷 `201`、发现 1 条待复核、审核 `200`、管理员标签查询 8 条) +- [x] 验证行情主源失败切换备用源。(专项测试通过) +- [x] 验证 Redis 不可用时的降级。(真实 HTTP 限流链路实测放行;专项测试通过) +- [x] 验证 Neo4j 不可用时的降级。(专项测试通过) +- [x] 验证 Milvus 不可用时的降级。(专项测试通过) +- [x] 验证模型端点失败时的降级。(专项测试通过) +- [x] 验证幂等键重复请求。(单元/集成测试通过) +- [x] 验证权限越权请求。(真实 HTTP 验收通过) +- [x] 验证客户数据隔离。(单元/集成测试通过) +- [x] 验证审计记录和 Outbox 事件。(独立迁移库集成测试通过) + +统一命令: + +```powershell +python -m pytest tests/unit -q +python -m pytest tests/contract -q +python -m pytest tests/integration -q +python -m ruff check app tests tools alembic +python -m mypy app +python -m alembic upgrade head +python tools/audit_schema.py +python tools/audit_constraints.py +``` + +## 十四、灰度发布 + +- [x] 在本地测试库完成迁移。(独立库 `jr_agent_qyqy_migration` 已验证) +- [ ] 在联调库完成迁移。 +- [x] 仅开放测试客户和管理员账号。(代码闸门已完成,环境实测待执行) +- [ ] 灰度产品查询和适当性过滤。 +- [ ] 灰度风险问卷。 +- [ ] 灰度投资目标。 +- [ ] 灰度持仓分析。 +- [ ] 灰度资产配置。 +- [ ] 灰度产品推荐。 +- [ ] 灰度推荐审核发布。 +- [ ] 灰度画像漂移复核。 +- [ ] 记录灰度期间错误、延迟和降级次数。 +- [ ] 确认客户数据未越权暴露。 +- [ ] 确认没有产生真实交易委托。 + +## 十五、回滚准备 + +- [x] 保存迁移前数据库备份。(`.migration-backups/jr_agent-before-base-migration.sql`) +- [x] 保存迁移前 Git 标签。(`advisor-before-base-migration`) +- [x] 保存每个模块的提交号。(已回填阶段记录) +- [x] 保存每个模块的测试结果。(已回填阶段记录) +- [x] 保存迁移后的结构审计结果。(独立迁移库 72 张业务表) +- [x] 准备关闭新投顾入口的配置开关。(`ADVISOR_ROLLOUT_ENABLED=false`) +- [x] 准备应用代码按提交回滚方案。(见 `docs/22-投顾Agent灰度与回滚操作手册.md`) +- [x] 确认数据库不执行破坏性 downgrade。(见回滚手册) +- [x] 确认新增表保留,不自动删除。(见回滚手册) +- [x] 确认失败 Outbox 可以重试或人工处理。(沿用公共 Outbox 重试/死信机制) +- [x] 确认原画像和原推荐结果可以保留。(回滚只关闭入口,不删除业务数据) +- [ ] 完成灰度回滚演练。 + +## 十六、最终完成标准 + +- [x] `qyqy_develop` 底座测试全部通过。(最新集成分支单元 `1186 passed, 2 skipped`;契约 `21 passed`) +- [x] 投顾业务测试全部通过。(已包含于最新集成分支全量单元与契约测试) +- [ ] 数据库基线审计通过。 +- [ ] 空库迁移成功。 +- [ ] 已有测试库迁移成功。 +- [ ] 鉴权和首次登录拦截有效。 +- [ ] 风险问卷后台评分有效且客户不可见。 +- [ ] 投资目标采集、确认和审核有效。 +- [ ] 持仓分析数值来源可靠。 +- [ ] 收益、回撤、流动性参与动态配置。 +- [ ] 产品推荐具备适当性过滤、证据卡片和排除原因。 +- [ ] 推荐方案审核发布闭环有效。 +- [ ] 会话实体抽取和目标缺口追问有效。 +- [ ] 画像标签具备置信度和来源。 +- [ ] 画像漂移复核闭环有效。 +- [ ] 行情双源和失败告警有效。 +- [ ] Redis、Neo4j、Milvus 和模型故障具备降级行为。 +- [ ] 所有关键写操作具备幂等和审计记录。 +- [ ] 每个迁移模块都有独立提交、测试结果和回滚点。 +- [ ] 完成迁移验收记录和交接文档。 diff --git a/docs/22-投顾Agent灰度与回滚操作手册.md b/docs/22-投顾Agent灰度与回滚操作手册.md new file mode 100644 index 0000000..0aed2f5 --- /dev/null +++ b/docs/22-投顾Agent灰度与回滚操作手册.md @@ -0,0 +1,42 @@ +# 投顾 Agent 灰度与回滚操作手册 + +## 灰度开关 + +投顾灰度使用进程环境变量,不修改冻结的数据库基线,也不需要数据库迁移: + +```text +ADVISOR_ROLLOUT_ENABLED=true +ADVISOR_ROLLOUT_CUSTOMER_IDS=9001,9002 +``` + +`ADVISOR_ROLLOUT_ENABLED=false` 时保持现有本地行为。开启后,`admin` 和 +`super_admin` 始终放行;客户只有在 `ADVISOR_ROLLOUT_CUSTOMER_IDS` 中才可以访问 +投顾 Agent、投资目标、持仓分析、资产配置和推荐接口。白名单为空时客户全部失败关闭, +不会误放量。灰度拒绝统一返回 `403 AGENT_PERMISSION_DENIED`,并写入 +`interaction_audit.action_type=advisor.rollout_denied`。 + +修改环境变量后必须重启全部 API 进程和 Worker,确保同一发布批次使用同一开关快照。 +上线前先用一名测试客户和一名管理员验证:客户白名单命中返回业务响应,未命中返回 403, +管理员可以完成审核/发布操作;检查审计记录中没有客户数据和内部画像字段泄露。 + +## 灰度检查 + +1. 在独立测试库执行 `python -m alembic upgrade head`、`python tools/audit_schema.py` 和 + `python tools/audit_constraints.py`。 +2. 启动 API 和 Worker,执行 `python tools/acceptance_check.py`,确认问卷前置、鉴权、 + SSE、Worker 完成态、越权和数据隔离均通过。 +3. 开启白名单后,用测试客户执行问卷、投资目标、持仓分析、资产配置、推荐及画像复核; + 推荐无有效行情时必须保持失败关闭,不得写入伪行情或产生模拟委托。 +4. 记录 API 错误数、延迟、降级次数、行情失败告警和 `advisor.rollout_denied` 审计数。 + +## 回滚 + +优先将 `ADVISOR_ROLLOUT_ENABLED=false` 并滚动重启 API/Worker,关闭投顾灰度入口;已落库 +的问卷、目标、画像、推荐审核记录和 Outbox 事件保留,不删除业务数据。 + +若需应用回滚,回到本分支上一个已验收提交,并重新执行单元、契约和结构审计。数据库不执行 +破坏性 `downgrade`,新增表保留;恢复后用 `alembic current` 和结构审计确认版本与结构一致。 +失败 Outbox 事件按原有重试/死信机制处理,必要时由管理员重放,不直接删除未完成事件。 + +回滚演练必须记录:开关关闭时间、API/Worker 重启结果、客户 403 验证、管理员可用性、 +审计记录、Outbox 未丢失以及没有产生真实交易委托。 diff --git a/hq.py b/hq.py index 5a15a1b..c8b82d8 100644 --- a/hq.py +++ b/hq.py @@ -1,11 +1,16 @@ -# -*- coding: utf-8 -*- -"""奶龙基金指定产品行情模块。""" +"""南方基金指定产品行情模块。""" +# Public-source adapter retains readable request expressions; line-length checks are not useful here. +# ruff: noqa: E501 from __future__ import annotations + +import logging import re import time -import logging -from datetime import date, datetime, time as clock_time +from datetime import date, datetime +from datetime import time as clock_time +from html import unescape from typing import Any + import httpx logger = logging.getLogger(__name__) @@ -13,23 +18,40 @@ logger = logging.getLogger(__name__) NAV_API = "https://api.fund.eastmoney.com/f10/lsjz" RETURN_API = "https://api.fund.eastmoney.com/pinzhong/LJSYLZS" QUOTE_API = "https://push2.eastmoney.com/api/qt/ulist.np/get" +EXCHANGE_HISTORY_API = "https://push2his.eastmoney.com/api/qt/stock/kline/get" +TENCENT_QUOTE_API = "https://qt.gtimg.cn/q=" DETAIL_API = "https://fund.eastmoney.com/pingzhongdata/{code}.js" +SOUTHERN_COMPANY_API = "https://fund.eastmoney.com/company/80000220.html" REQUEST_TIMEOUT = 12.0 +EXCHANGE_HISTORY_RETRIES = 2 MAX_FUNDS_PER_CALL = 1000 -SOUTHERN_FUND_CODES = tuple(dict.fromkeys("202308 020480 007161 003776 020281 018019 014189 018020 020553 016449 008854 008264 008736 010592 160127 588890 020839 589700 159382 159511 002900 021958 159948 009059 001421".split())) FUND_TYPE_GROUPS = { - "货币型": ("202308", "020480"), - "债券型": ("007161", "003776", "020281"), - "混合型": ("018019", "014189", "018020"), - "股票型": ("020553", "016449", "008854", "008264", "008736", "010592", "160127", "588890", "020839", "589700", "159382", "159511", "002900", "021958", "159948", "009059", "001421"), + "货币型": ("202308", "020480", "511810"), + "债券型": ("007161", "003776", "020281", "511070", "159700", "160128", "160129"), + "混合型": ("018019", "014189", "018020", "160105", "160142", "160143", "501062"), + "股票型": ( + "020553", "016449", "008854", "008264", "008736", "010592", "160127", "588890", + "020839", "589700", "159382", "159511", "002900", "021958", "159948", "009059", + "001421", "510500", + ), + "QDII": ("501018", "159329", "159615", "159687"), +} +SOUTHERN_FUND_CODES = tuple( + dict.fromkeys(code for codes in FUND_TYPE_GROUPS.values() for code in codes) +) +FUND_TYPE_BY_CODE = { + code: fund_type for fund_type, codes in FUND_TYPE_GROUPS.items() for code in codes } -FUND_TYPE_BY_CODE = {code: fund_type for fund_type, codes in FUND_TYPE_GROUPS.items() for code in codes} HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://fund.eastmoney.com/"} _history_date: str | None = None _history_cache: dict[str, dict[str, str | None]] = {} _name_cache: dict[str, str] = {} +class ExchangeQuoteSourceError(RuntimeError): + """A public exchange quote provider could not supply a usable response.""" + + def is_market_trading_time(now: datetime | None = None) -> bool: """判断中国大陆工作日盘中时段。""" current = now or datetime.now() @@ -40,8 +62,13 @@ def is_market_trading_time(now: datetime | None = None) -> bool: or clock_time(13, 0) <= current_time <= clock_time(15, 0)) -def get_southern_fund_market(target_date: str | None = None, limit: int | None = None, fund_type: str | None = None) -> list[dict[str, Any]]: - """获取指定奶龙基金的完整行情表。 +def get_southern_fund_market( + target_date: str | None = None, + limit: int | None = None, + fund_type: str | None = None, + fund_codes: list[str] | tuple[str, ...] | None = None, +) -> list[dict[str, Any]]: + """获取指定南方基金的完整行情表。 每次调用刷新整张表的实时行情;历史收益同一日期只请求一次并保存在进程缓存。 limit 不传时返回 SOUTHERN_FUND_CODES 中的全部产品。 @@ -49,8 +76,13 @@ def get_southern_fund_market(target_date: str | None = None, limit: int | None = query_date = target_date or date.today().isoformat() date.fromisoformat(query_date) if fund_type and fund_type not in FUND_TYPE_GROUPS: - raise ValueError("基金类型必须是货币型、债券型、混合型或股票型") + raise ValueError("基金类型不在南方基金白名单内") available_codes = FUND_TYPE_GROUPS[fund_type] if fund_type else SOUTHERN_FUND_CODES + if fund_codes is not None: + requested = tuple(dict.fromkeys(fund_codes)) + if any(code not in SOUTHERN_FUND_CODES for code in requested): + raise ValueError("基金代码不在南方基金白名单内") + available_codes = tuple(code for code in requested if code in available_codes) count = len(available_codes) if limit is None else min(max(limit, 1), MAX_FUNDS_PER_CALL) codes = list(available_codes[:count]) names = _get_names(codes) @@ -62,7 +94,7 @@ def get_southern_fund_market(target_date: str | None = None, limit: int | None = old = history.get(code, {}) live = quotes.get(code, {}) rows.append({ - "基金代码": code, "基金名称": names.get(code, f"奶龙基金 {code}"), + "基金代码": code, "基金名称": names.get(code, f"南方基金 {code}"), "基金类型": FUND_TYPE_BY_CODE.get(code, "未分类"), "基金净值": live.get("基金净值") or old.get("基金净值"), "日期": live.get("日期") or old.get("日期"), @@ -76,6 +108,186 @@ def get_southern_fund_market(target_date: str | None = None, limit: int | None = return rows +def get_southern_fund_nav_history( + fund_code: str, start_date: str, end_date: str +) -> list[dict[str, str]]: + """Return validated historical unit-NAV observations for an allowed fund.""" + if fund_code not in SOUTHERN_FUND_CODES: + raise ValueError("基金代码不在南方基金白名单内") + start = date.fromisoformat(start_date) + end = date.fromisoformat(end_date) + if start > end: + raise ValueError("开始日期不能晚于结束日期") + records = _fetch_nav_records( + fund_code, start_date=start.isoformat(), end_date=end.isoformat(), all_pages=True + ) + turnover_by_date: dict[str, str] = {} + try: + turnover_by_date = { + row["trade_date"]: row["turnover_amount"] + for row in get_southern_fund_exchange_history(fund_code, start_date, end_date) + if row.get("turnover_amount") + } + except (httpx.HTTPError, ValueError, TypeError) as exc: + logger.warning("历史成交额接口失败 code=%s error=%s", fund_code, type(exc).__name__) + observations: list[dict[str, str]] = [] + for record in records: + value_date = str(record.get("FSRQ") or "") + nav = str(record.get("DWJZ") or "").strip() + try: + parsed_date = date.fromisoformat(value_date) + if parsed_date < start or parsed_date > end or float(nav) <= 0: + continue + except ValueError: + continue + row = {"fund_code": fund_code, "trade_date": value_date, "nav": nav} + if value_date in turnover_by_date: + row["turnover_amount"] = turnover_by_date[value_date] + observations.append(row) + return sorted(observations, key=lambda item: item["trade_date"]) + + +def get_southern_fund_exchange_history( + fund_code: str, start_date: str, end_date: str +) -> list[dict[str, str]]: + """Return exchange daily close and turnover for a listed fund. + + The NAV endpoint has no trading amount. Eastmoney's exchange K-line + endpoint exposes amount as field f56, which is the only source accepted + for historical liquidity calculations here. + """ + if fund_code not in SOUTHERN_FUND_CODES: + raise ValueError("基金代码不在南方基金白名单内") + start = date.fromisoformat(start_date) + end = date.fromisoformat(end_date) + if start > end: + raise ValueError("开始日期不能晚于结束日期") + secid = ("1." if fund_code.startswith(("5", "6", "9")) else "0.") + fund_code + params: dict[str, str | int] = { + "secid": secid, + "klt": 101, + "fqt": 1, + "beg": start.strftime("%Y%m%d"), + "end": end.strftime("%Y%m%d"), + "fields1": "f1,f2,f3,f4,f5,f6", + "fields2": "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61", + } + for attempt in range(EXCHANGE_HISTORY_RETRIES): + try: + response = httpx.get( + EXCHANGE_HISTORY_API, + params=params, + headers=HEADERS, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + break + except httpx.HTTPError: + if attempt == EXCHANGE_HISTORY_RETRIES - 1: + raise + time.sleep(0.2 * (attempt + 1)) + payload = response.json() + records = ((payload.get("data") or {}).get("klines") or []) + result: list[dict[str, str]] = [] + for raw in records: + fields = str(raw).split(",") + if len(fields) < 7: + continue + trade_date, close_price, turnover_amount = fields[0], fields[2], fields[6] + try: + parsed_date = date.fromisoformat(trade_date) + if parsed_date < start or parsed_date > end: + continue + if float(close_price) <= 0 or float(turnover_amount) < 0: + continue + except ValueError: + continue + result.append({ + "fund_code": fund_code, + "trade_date": trade_date, + "close_price": close_price, + "turnover_amount": turnover_amount, + }) + return result + + +def get_southern_fund_catalog( + fund_codes: list[str] | tuple[str, ...], +) -> list[dict[str, Any]]: + """Return Southern Fund's public catalogue rows. + + The company directory supplies fund type and reported asset scale, which are + needed to select test products. It is reference data only: product risk is + intentionally not inferred here because the source does not publish a + channel-independent R1-R5 suitability rating. + """ + requested = tuple(dict.fromkeys(fund_codes)) + if not requested: + return [] + response = httpx.get(SOUTHERN_COMPANY_API, headers=HEADERS, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + content = response.content.decode("utf-8") + scale_as_of_date = _company_scale_as_of_date(content) + rows = [] + for code in requested: + row = _company_catalog_row(content, code) + if row is not None: + row["scale_as_of_date"] = scale_as_of_date + rows.append(row) + return rows + + +def get_southern_exchange_catalog( + fund_codes: list[str] | tuple[str, ...], +) -> list[dict[str, Any]]: + """Backward-compatible alias for callers that only request exchange codes.""" + return get_southern_fund_catalog(fund_codes) + + +def _company_catalog_row(content: str, fund_code: str) -> dict[str, Any] | None: + marker = f'class="code">{fund_code}' + position = content.find(marker) + if position < 0: + return None + start = content.rfind("