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 2c248d0..d28b4a6 100644 --- a/app/main.py +++ b/app/main.py @@ -5,16 +5,26 @@ 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.auth import router as auth_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.rbac import router as rbac_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 @@ -114,6 +124,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("", position) + if start < 0 or end < 0: + return None + row = content[start:end + len("")] + name_match = re.search(r'class="name" title="([^"]+)"', row) + cells = [ + _html_cell_text(match.group(1)) + for match in re.finditer(r"]*>(.*?)", row, flags=re.DOTALL) + ] + if name_match is None or len(cells) < 10: + return None + return { + "fund_code": fund_code, + "fund_name": unescape(name_match.group(1)).strip(), + "fund_type": cells[2], + "nav_date": _catalog_nav_date(cells[3]), + "nav": cells[4], + "fund_asset_scale_billion": cells[9], + "trading_venue": cells[11] if len(cells) > 11 else "场内交易", + } + + +def _company_scale_as_of_date(content: str) -> str | None: + match = re.search(r"(?:数据截止|截止日期)[::]\s*(20\d{2}-\d{2}-\d{2})", content) + return match.group(1) if match else None + + +def _catalog_nav_date(value: str) -> str | None: + match = re.fullmatch(r"(\d{2})-(\d{2})", value) + if match is None: + return None + return f"{date.today().year}-{match.group(1)}-{match.group(2)}" + + +def _html_cell_text(value: str) -> str: + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", unescape(value))).strip() + + def _get_names(codes: list[str]) -> dict[str, str]: for code in codes: if code in _name_cache: @@ -84,11 +296,11 @@ def _get_names(codes: list[str]) -> dict[str, str]: response = httpx.get(DETAIL_API.format(code=code), headers=HEADERS, timeout=REQUEST_TIMEOUT) response.raise_for_status() match = re.search(r"var\s+fS_name\s*=\s*[\"']([^\"']+)", response.text) - _name_cache[code] = match.group(1).strip() if match else f"奶龙基金 {code}" + _name_cache[code] = match.group(1).strip() if match else f"南方基金 {code}" except (httpx.HTTPError, UnicodeError) as exc: logger.warning("基金名称接口失败 code=%s error=%s", code, type(exc).__name__) - _name_cache[code] = f"奶龙基金 {code}" - return {code: _name_cache.get(code, f"奶龙基金 {code}") for code in codes} + _name_cache[code] = f"南方基金 {code}" + return {code: _name_cache.get(code, f"南方基金 {code}") for code in codes} def _get_quotes(codes: list[str]) -> dict[str, dict[str, str | None]]: @@ -103,6 +315,138 @@ def _get_quotes(codes: list[str]) -> dict[str, dict[str, str | None]]: return {str(item["f12"]): {"基金净值": str(item["f2"]) if item.get("f2") not in (None, "-") else None, "日期": None, "日涨幅": f"{item.get('f3')}%" if item.get("f3") not in (None, "-") else None} for item in items if item.get("f12")} +def get_southern_exchange_quotes( + fund_codes: list[str] | tuple[str, ...], +) -> dict[str, dict[str, str | None]]: + """Backward-compatible Eastmoney quote lookup that degrades to an empty result.""" + try: + return fetch_southern_exchange_quotes_eastmoney(fund_codes) + except ExchangeQuoteSourceError as exc: + logger.warning("exchange quote endpoint failed error=%s", type(exc).__name__) + return {} + + +def fetch_southern_exchange_quotes_eastmoney( + fund_codes: list[str] | tuple[str, ...], +) -> dict[str, dict[str, str | None]]: + """Return Eastmoney exchange quotes and raise on a provider-level failure.""" + requested = tuple(dict.fromkeys(fund_codes)) + if any(code not in SOUTHERN_FUND_CODES for code in requested): + raise ValueError("fund code is not in the Southern Fund whitelist") + if not requested: + return {} + secids = ",".join( + ("1." if code.startswith(("5", "6", "9")) else "0.") + code for code in requested + ) + try: + response = httpx.get( + QUOTE_API, + params={ + "fltt": 2, + "invt": 2, + "fields": "f12,f2,f3,f5,f6,f17,f18", + "secids": secids, + }, + headers=HEADERS, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + items = ((response.json().get("data") or {}).get("diff") or []) + except (httpx.HTTPError, ValueError, TypeError) as exc: + raise ExchangeQuoteSourceError("eastmoney quote request failed") from exc + + def value(item: dict[str, Any], field: str) -> str | None: + raw = item.get(field) + return str(raw) if raw not in (None, "-") else None + + return { + str(item["f12"]): { + "last_price": value(item, "f2"), + "change_pct": value(item, "f3"), + "volume": value(item, "f5"), + "turnover_amount": value(item, "f6"), + "open_price": value(item, "f17"), + "previous_close": value(item, "f18"), + } + for item in items + if item.get("f12") + } + + +def fetch_southern_exchange_quotes_tencent( + fund_codes: list[str] | tuple[str, ...], +) -> dict[str, dict[str, str | None]]: + """Return Tencent Finance exchange quotes as an independent fallback source. + + Tencent publishes volume in lots. Turnover is deliberately left unset + because its payload does not provide a field with compatible semantics. + """ + requested = tuple(dict.fromkeys(fund_codes)) + if any(code not in SOUTHERN_FUND_CODES for code in requested): + raise ValueError("fund code is not in the Southern Fund whitelist") + if not requested: + return {} + symbols = ",".join( + ("sh" if code.startswith(("5", "6", "9")) else "sz") + code + for code in requested + ) + try: + response = httpx.get( + TENCENT_QUOTE_API + symbols, + headers={"User-Agent": HEADERS["User-Agent"]}, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + except httpx.HTTPError as exc: + raise ExchangeQuoteSourceError("tencent quote request failed") from exc + return _parse_tencent_exchange_quotes(response.content, requested) + + +def _parse_tencent_exchange_quotes( + content: bytes, requested: tuple[str, ...], +) -> dict[str, dict[str, str | None]]: + try: + payload = content.decode("gbk") + except UnicodeDecodeError as exc: + raise ExchangeQuoteSourceError("tencent quote response decoding failed") from exc + result: dict[str, dict[str, str | None]] = {} + for match in re.finditer(r'v_(?:sh|sz)(\d{6})="([^"]*)"', payload): + code, raw = match.groups() + if code not in requested: + continue + fields = raw.split("~") + if len(fields) < 7 or not _valid_quote_number(fields[3]): + continue + previous_close = fields[4] if _valid_quote_number(fields[4]) else None + change_pct = fields[33] if len(fields) > 33 and _valid_quote_number(fields[33]) else None + if change_pct is None and previous_close is not None: + change_pct = _quote_change_pct(fields[3], previous_close) + result[code] = { + "last_price": fields[3], + "previous_close": previous_close, + "change_pct": change_pct, + "volume": fields[6] if _valid_quote_number(fields[6]) else None, + "turnover_amount": None, + } + if not result: + raise ExchangeQuoteSourceError("tencent quote response had no usable quotes") + return result + + +def _valid_quote_number(value: str) -> bool: + try: + return float(value) > 0 + except ValueError: + return False + + +def _quote_change_pct(last_price: str, previous_close: str) -> str | None: + try: + return str(round((float(last_price) / float(previous_close) - 1) * 100, 4)) + except (ValueError, ZeroDivisionError): + return None + + def _get_history(codes: list[str], query_date: str) -> dict[str, dict[str, str | None]]: global _history_date, _history_cache if _history_date == query_date and all(code in _history_cache for code in codes): @@ -154,14 +498,26 @@ def _fetch_nav_page(fund_code: str, page_index: int = 1, start_date: str | None def _fetch_nav_records(fund_code: str, start_date: str | None = None, end_date: str | None = None, all_pages: bool = False) -> list[dict[str, Any]]: """调用历史净值接口;区间收益需要时读取完整分页,避免重复使用同一基准日。""" records, total = _fetch_nav_page(fund_code, 1, start_date, end_date) - if not all_pages or not total or total <= len(records): + if not all_pages or (total is not None and total <= len(records)): return records - page_count = min((total + 29) // 30, 40) + if not (start_date and end_date): + return records + page_size = max(len(records), 1) + page_count = min((total + page_size - 1) // page_size, 40) if total else 40 + seen_dates = {str(item.get("FSRQ") or "") for item in records} for page_index in range(2, page_count + 1): page_records, _ = _fetch_nav_page(fund_code, page_index, start_date, end_date) if not page_records: break - records.extend(page_records) + new_records = [ + item for item in page_records if str(item.get("FSRQ") or "") not in seen_dates + ] + if not new_records: + break + seen_dates.update(str(item.get("FSRQ") or "") for item in new_records) + records.extend(new_records) + if len(page_records) < page_size: + break return records diff --git a/pyproject.toml b/pyproject.toml index 61bb733..843745c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ dev = [ "pytest>=8.3,<9", "pytest-asyncio>=0.25,<1", + "aiosqlite>=0.20,<1", "ruff>=0.9,<1", "mypy>=1.14,<2", # 测试用:场外邮件 Worker 等用例走 SQLAlchemy 的 SQLite 内存库。 diff --git a/requirements.txt b/requirements.txt index 147173e..845922b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,5 +50,6 @@ bcrypt>=4.0,<5 # Development and test dependencies pytest>=8.3,<9 pytest-asyncio>=0.25,<1 +aiosqlite>=0.20,<1 ruff>=0.9,<1 mypy>=1.14,<2 diff --git a/tests/unit/api/test_onboarding_gate.py b/tests/unit/api/test_onboarding_gate.py new file mode 100644 index 0000000..c22a843 --- /dev/null +++ b/tests/unit/api/test_onboarding_gate.py @@ -0,0 +1,70 @@ +import pytest +from fastapi import Request +from fastapi.security import HTTPAuthorizationCredentials + +from app.api.dependencies.auth import build_request_context +from app.core.contracts import RequestContext +from app.core.errors import OnboardingRequiredError + + +def request(path: str) -> Request: + return Request({"type": "http", "method": "GET", "path": path, "headers": []}) + + +@pytest.mark.asyncio +async def test_customer_without_assessment_is_gated_after_authentication( + monkeypatch: pytest.MonkeyPatch, +) -> None: + authenticated = RequestContext(user_id="7", trace_id="initial") + resolved = authenticated.model_copy(update={"roles": ("customer",)}) + + class Authenticator: + def authenticate(self, _token: str) -> RequestContext: + return authenticated + + async def resolve(_self: object, _context: RequestContext) -> RequestContext: + return resolved + + async def is_required(_self: object, _context: RequestContext) -> bool: + return True + + monkeypatch.setattr("app.api.dependencies.auth._authenticator", lambda: Authenticator()) + monkeypatch.setattr("app.service.identity_service.IdentityService.resolve", resolve) + monkeypatch.setattr( + "app.service.risk_questionnaire_service.RiskQuestionnaireService.is_required", is_required + ) + with pytest.raises(OnboardingRequiredError): + await build_request_context( + request("/api/v1/agent-runs"), + HTTPAuthorizationCredentials(scheme="Bearer", credentials="token"), + ) + + +@pytest.mark.asyncio +async def test_questionnaire_endpoint_is_exempt_from_the_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + authenticated = RequestContext(user_id="7", trace_id="initial") + resolved = authenticated.model_copy(update={"roles": ("customer",)}) + + class Authenticator: + def authenticate(self, _token: str) -> RequestContext: + return authenticated + + async def resolve(_self: object, _context: RequestContext) -> RequestContext: + return resolved + + async def unexpected_check(_self: object, _context: RequestContext) -> bool: + raise AssertionError("问卷入口不应经过完成状态拦截") + + monkeypatch.setattr("app.api.dependencies.auth._authenticator", lambda: Authenticator()) + monkeypatch.setattr("app.service.identity_service.IdentityService.resolve", resolve) + monkeypatch.setattr( + "app.service.risk_questionnaire_service.RiskQuestionnaireService.is_required", + unexpected_check, + ) + context = await build_request_context( + request("/api/v1/onboarding/risk-questionnaire"), + HTTPAuthorizationCredentials(scheme="Bearer", credentials="token"), + ) + assert context == resolved diff --git a/tests/unit/infrastructure/test_fund_market_adapter.py b/tests/unit/infrastructure/test_fund_market_adapter.py index 55445e6..f576219 100644 --- a/tests/unit/infrastructure/test_fund_market_adapter.py +++ b/tests/unit/infrastructure/test_fund_market_adapter.py @@ -7,6 +7,35 @@ import pytest from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter +def test_hq_exchange_history_parser_contract(monkeypatch: pytest.MonkeyPatch) -> None: + import hq + + class Response: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return {"data": {"klines": [ + "2026-09-09,1.20,1.23,1.24,1.19,100000,1234567.89,4.1,2.5,0.03,1.2", + "2026-09-08,1.20,1.21,1.22,1.19,90000,1000000,2.5,1.0,0.01,1.0", + "malformed", + ]}} + + def get(*args: object, **kwargs: object) -> Response: + del args, kwargs + return Response() + + monkeypatch.setattr(hq.httpx, "get", get) + rows = hq.get_southern_fund_exchange_history("159511", "2026-09-09", "2026-09-09") + + assert rows == [{ + "fund_code": "159511", + "trade_date": "2026-09-09", + "close_price": "1.23", + "turnover_amount": "1234567.89", + }] + + @pytest.mark.asyncio async def test_adapter_parses_names_quotes_and_history() -> None: def handler(request: httpx.Request) -> httpx.Response: diff --git a/tests/unit/repository/test_advisor_product_repository.py b/tests/unit/repository/test_advisor_product_repository.py new file mode 100644 index 0000000..d529d6c --- /dev/null +++ b/tests/unit/repository/test_advisor_product_repository.py @@ -0,0 +1,147 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +import pytest + +from app.model.advisor_product import ( + AdvisorProductContractSnapshot, + AdvisorProductReferenceSnapshot, + AdvisorProductSuitabilityReference, +) +from app.model.fund import FundProduct +from app.repository.advisor_product_repository import AdvisorProductRepository + + +class ScalarResult: + def __init__(self, rows: list[Any]) -> None: + self.rows = rows + + def __iter__(self): + return iter(self.rows) + + +class FakeSession: + def __init__(self, results: list[list[Any]]) -> None: + self.results = iter(results) + self.statements: list[Any] = [] + + async def scalars(self, statement: Any) -> ScalarResult: + self.statements.append(statement) + return ScalarResult(next(self.results)) + + +def product(product_id: int) -> FundProduct: + return FundProduct( + id=product_id, product_code=f"15{product_id:04d}", product_name="南方测试ETF", + exchange_code="SSE", product_category="ETF", risk_level="R5", fund_manager="南方基金", + status="上市", open_start_at=None, open_end_at=None, + ) + + +def suitability(product_id: int, risk_level: str = "R2") -> AdvisorProductSuitabilityReference: + return AdvisorProductSuitabilityReference( + product_id=product_id, sales_institution="南方基金管理股份有限公司直销", + risk_level=risk_level, effective_from=date(2026, 1, 1), effective_until=None, + source_url="https://example.test/risk.pdf", document_title="风险等级披露", + document_sha256="a" * 64, source="official", review_status="verified", + ) + + +def contract(product_id: int) -> AdvisorProductContractSnapshot: + return AdvisorProductContractSnapshot( + product_id=product_id, effective_from=date(2026, 1, 1), effective_until=None, + fund_type="股票型ETF", investment_scope="场内基金", risk_return_characteristics="净值波动", + source_url="https://example.test/contract.pdf", document_title="基金合同", + document_sha256="b" * 64, source="official", review_status="verified", + ) + + +@pytest.mark.asyncio +async def test_authoritative_candidates_fail_closed_and_keep_r1_without_scale_limit() -> None: + now = datetime(2026, 9, 11, 10, 0) + session = FakeSession([ + [product(1), product(2)], + [suitability(1, "R1"), suitability(2, "R2")], + [contract(1), contract(2)], + [ + AdvisorProductReferenceSnapshot( + product_id=1, + as_of_date=date(2026, 1, 1), + fund_type="货币ETF", + fund_asset_scale_billion=Decimal("0.1"), + ), + AdvisorProductReferenceSnapshot( + product_id=2, + as_of_date=date(2026, 1, 1), + fund_type="股票ETF", + fund_asset_scale_billion=Decimal("2"), + ), + ], + [], + [], + [], + ]) + result = await AdvisorProductRepository(session).authoritative_tradable_products( + now, sales_institution="南方基金管理股份有限公司直销", + min_asset_scale_billion=Decimal("1"), + ) + + assert [item.product.id for item in result] == [1, 2] + assert result[0].suitability.risk_level == "R1" + assert result[0].asset_scale_billion == Decimal("0.1") + + +@pytest.mark.asyncio +async def test_pending_governance_and_missing_evidence_are_excluded() -> None: + now = datetime(2026, 9, 11, 10, 0) + session = FakeSession([ + [product(1)], [suitability(1)], [contract(1)], [], [], [1], [], + ]) + result = await AdvisorProductRepository(session).authoritative_tradable_products( + now, sales_institution="南方基金管理股份有限公司直销", + ) + assert result == [] + assert "exchange_code IN" in str(session.statements[0]) + + +def test_hard_suitability_filter_returns_exclusion_reasons() -> None: + from app.repository.advisor_product_repository import ( + AdvisorProductRepository, + AuthoritativeProductCandidate, + ProductContractEvidence, + ProductSuitabilityEvidence, + ) + + def candidate(code: str, risk: str) -> AuthoritativeProductCandidate: + item = product(1) + item.product_code = code + return AuthoritativeProductCandidate( + product=item, + suitability=ProductSuitabilityEvidence( + risk_level=risk, sales_institution="南方基金管理股份有限公司直销", + source_url="https://example.test/risk.pdf", document_title="风险等级披露", + document_sha256="a" * 64, effective_from=date(2026, 1, 1), + ), + contract=ProductContractEvidence( + fund_type="股票型ETF", investment_scope="场内基金", performance_benchmark=None, + risk_return_characteristics="净值波动", custodian_name=None, + management_fee_rate_pct=None, custodian_fee_rate_pct=None, inception_date=None, + source_url="https://example.test/contract.pdf", document_title="基金合同", + document_sha256="b" * 64, document_published_at=None, + ), + ) + + selected, excluded = AdvisorProductRepository.hard_suitability_filter( + [candidate("R1", "R1"), candidate("R4", "R4")], 2 + ) + assert [item.product.product_code for item in selected] == ["R1"] + assert excluded[0]["reason_code"] == "RISK_LEVEL_MISMATCH" + + +def test_liquidity_requirement_is_failed_closed_without_metrics() -> None: + from app.repository.advisor_product_repository import AdvisorProductRepository + + evidence = AdvisorProductRepository._liquidity_evidence(None, None, "within_7_days") + assert evidence is not None + assert evidence.status == "insufficient" diff --git a/tests/unit/repository/test_fund_readonly_contract.py b/tests/unit/repository/test_fund_readonly_contract.py index 6f014cb..4a64189 100644 --- a/tests/unit/repository/test_fund_readonly_contract.py +++ b/tests/unit/repository/test_fund_readonly_contract.py @@ -9,6 +9,7 @@ from typing import Any import pytest +import app.model.fund # noqa: F401 from app.model.base import Base REPOSITORY_PATH = "app/repository/fund_query_repository.py" @@ -122,11 +123,11 @@ def test_fund_model_module_contains_only_column_mappings() -> None: assert methods == [], f"{node.name} 不应定义方法" -def test_fund_models_cover_all_fin_tables_with_expected_columns() -> None: +def test_fund_models_cover_expected_fund_tables_with_columns() -> None: tables: dict[str, Any] = { name: table for name, table in Base.metadata.tables.items() - if name.startswith("fin_") + if name in FUND_TABLES } assert set(tables) == set(FUND_TABLES) for name, (column_count, primary_key) in FUND_TABLES.items(): diff --git a/tests/unit/service/test_advisor_asset_allocation.py b/tests/unit/service/test_advisor_asset_allocation.py new file mode 100644 index 0000000..0111e41 --- /dev/null +++ b/tests/unit/service/test_advisor_asset_allocation.py @@ -0,0 +1,26 @@ +from app.service.agent.implementations.advisor import AdvisorAgent + + +def test_asset_allocation_description_is_analysis_only() -> None: + text = AdvisorAgent._describe_allocation( + { + "status": "ready", + "allocation": [ + {"asset_class": "bond_etf", "label": "债券类场内基金", "target_pct": 60}, + {"asset_class": "equity_etf", "label": "权益类场内基金", "target_pct": 40}, + ], + "optimization": {"dynamic": True}, + } + ) + + assert "债券类场内基金 60%" in text + assert "动态历史因子优化" in text + assert "不构成交易指令" in text + assert "下单" not in text + + +def test_asset_allocation_description_exposes_missing_prerequisites() -> None: + assert "风险画像" in AdvisorAgent._describe_allocation({"status": "profile_required"}) + assert "已确认的投资目标" in AdvisorAgent._describe_allocation( + {"status": "investment_goal_required"} + ) diff --git a/tests/unit/service/test_advisor_base_adapter.py b/tests/unit/service/test_advisor_base_adapter.py new file mode 100644 index 0000000..2c0d8d0 --- /dev/null +++ b/tests/unit/service/test_advisor_base_adapter.py @@ -0,0 +1,36 @@ +from app.core.contracts import RequestContext +from app.service.agent.base import BaseAgent +from app.service.agent.bootstrap import get_agent_factory +from app.service.agent.implementations.advisor import AdvisorAgent + + +def test_advisor_is_registered_through_the_new_base_factory() -> None: + factory = get_agent_factory() + definition = factory.definition("advisor") + context = RequestContext( + user_id="1", + trace_id="advisor-base-adapter", + roles=("customer",), + permissions=("agent:run",), + ) + agent = factory.create("advisor", context) + + assert isinstance(agent, BaseAgent) + assert isinstance(agent, AdvisorAgent) + assert agent.definition == definition + assert definition.allowed_tools == ( + "query_fund_quote", + "query_investment_goal", + "analyze_portfolio", + "generate_asset_allocation", + "recommend_products", + "compare_products", + ) + assert definition.supported_intents == ( + "fund_quote", + "investment_goal", + "portfolio_analysis", + "asset_allocation", + "product_recommend", + "comparison", + ) diff --git a/tests/unit/service/test_advisor_rollout_service.py b/tests/unit/service/test_advisor_rollout_service.py new file mode 100644 index 0000000..fe3df42 --- /dev/null +++ b/tests/unit/service/test_advisor_rollout_service.py @@ -0,0 +1,85 @@ +from types import SimpleNamespace + +import pytest + +from app.core.contracts import RequestContext +from app.core.errors import ForbiddenAgentError +from app.service.advisor_rollout_service import AdvisorRolloutService + + +def context( + *, roles: tuple[str, ...] = ("customer",), customer_ids: tuple[str, ...] = () +) -> RequestContext: + return RequestContext( + user_id="9001", trace_id="rollout-test", roles=roles, + customer_ids=customer_ids, + ) + + +def settings(*, enabled: bool, customer_ids: str) -> SimpleNamespace: + return SimpleNamespace( + advisor_rollout_enabled=enabled, + advisor_rollout_customer_ids=customer_ids, + ) + + +def test_disabled_rollout_allows_everyone(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "app.service.advisor_rollout_service.get_settings", + lambda: settings(enabled=False, customer_ids=""), + ) + assert AdvisorRolloutService.is_allowed(context()) + + +@pytest.mark.parametrize( + ("roles", "customer_ids", "allowed"), + [ + (("customer",), (), True), + (("customer",), (), False), + (("admin",), (), True), + (("super_admin",), (), True), + ], +) +def test_enabled_rollout_uses_customer_whitelist_and_admin_bypass( + monkeypatch: pytest.MonkeyPatch, + roles: tuple[str, ...], + customer_ids: tuple[str, ...], + allowed: bool, +) -> None: + whitelist = "9001" if roles == ("customer",) and not customer_ids else " 9002 , 9003 " + if roles == ("customer",) and not customer_ids and allowed is False: + whitelist = "9002" + monkeypatch.setattr( + "app.service.advisor_rollout_service.get_settings", + lambda: settings(enabled=True, customer_ids=whitelist), + ) + actual = AdvisorRolloutService.is_allowed(context(roles=roles, customer_ids=customer_ids)) + assert actual is allowed + + +class FakeSession: + def __init__(self) -> None: + self.added: list[object] = [] + self.commits = 0 + + def add(self, value: object) -> None: + self.added.append(value) + + async def commit(self) -> None: + self.commits += 1 + + +async def test_denial_is_audited_and_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "app.service.advisor_rollout_service.get_settings", + lambda: settings(enabled=True, customer_ids="9002"), + ) + session = FakeSession() + + with pytest.raises(ForbiddenAgentError, match="尚未对该账号开放"): + await AdvisorRolloutService(session).ensure_allowed(context()) # type: ignore[arg-type] + + assert session.commits == 1 + audit = session.added[0] + assert audit.action_type == "advisor.rollout_denied" # type: ignore[attr-defined] + assert audit.detail["reason"] == "not_in_rollout" # type: ignore[attr-defined] diff --git a/tests/unit/service/test_allocation_backtest_service.py b/tests/unit/service/test_allocation_backtest_service.py new file mode 100644 index 0000000..53c97f5 --- /dev/null +++ b/tests/unit/service/test_allocation_backtest_service.py @@ -0,0 +1,70 @@ +from datetime import date, timedelta +from decimal import Decimal + +from app.service.allocation_backtest_service import ( + AllocationBacktestEngine, + AllocationBacktestService, + BacktestObservation, +) + + +def observation(day: int, cash: str, equity: str, liquid: bool = True) -> BacktestObservation: + return BacktestObservation( + trade_date=date(2026, 1, 1) + timedelta(days=day), + returns_pct={ + "cash_management_etf": Decimal(cash), + "equity_etf": Decimal(equity), + }, + liquidity_observed=liquid, + ) + + +def test_backtest_compares_static_and_dynamic_performance() -> None: + observations = [observation(0, "0", "10"), observation(1, "0", "-10")] + result = AllocationBacktestEngine.run( + observations, + {"cash_management_etf": 50, "bond_etf": 0, "equity_etf": 50}, + { + observations[1].trade_date: { + "cash_management_etf": 100, + "bond_etf": 0, + "equity_etf": 0, + } + }, + ) + + assert result.observation_count == 2 + assert result.static is not None and result.dynamic is not None + assert result.static.total_return_pct == Decimal("-0.2500") + assert result.dynamic.total_return_pct == Decimal("5.0000") + assert result.dynamic_rebalance_count == 1 + assert result.liquidity_history_coverage_pct == Decimal("100") + assert result.status == "insufficient_history" + + +def test_rolling_metrics_and_adequate_history_are_ready() -> None: + observations = [observation(day, "1", "2") for day in range(20)] + metrics = AllocationBacktestService._rolling_metrics(observations) + + assert {item.asset_class for item in metrics} == {"cash_management_etf", "equity_etf"} + assert all(item.product_count == 20 for item in metrics) + result = AllocationBacktestEngine.run( + observations, + {"cash_management_etf": 50, "bond_etf": 0, "equity_etf": 50}, + {}, + ) + assert result.status == "ready" + assert result.limitations == ("历史观察不足 120 个交易日,动态优化无法覆盖完整窗口。",) + + +def test_backtest_reports_liquidity_coverage_limit() -> None: + observations = [observation(day, "0", "0", day < 10) for day in range(20)] + result = AllocationBacktestEngine.run( + observations, + {"cash_management_etf": 100, "bond_etf": 0, "equity_etf": 0}, + {}, + ) + + assert result.status == "partial" + assert result.liquidity_history_coverage_pct == Decimal("50") + assert any("80%" in limitation for limitation in result.limitations) diff --git a/tests/unit/service/test_dependency_degradation.py b/tests/unit/service/test_dependency_degradation.py index 6ec3647..d71e29e 100644 --- a/tests/unit/service/test_dependency_degradation.py +++ b/tests/unit/service/test_dependency_degradation.py @@ -24,6 +24,37 @@ async def test_neo4j_rejects_unapproved_relationship() -> None: await RelationshipService(FailingGraph()).neighbors(1, "DELETE_ALL") +@pytest.mark.asyncio +async def test_portfolio_graph_context_uses_fixed_query_and_filters_noise() -> None: + class Graph: + async def execute_query(self, query: str, **parameters: object) -> list[object]: + assert "HOLDS" in query and "EXPOSED_TO_INDUSTRY" in query + assert parameters == {"customer_id": 7, "limit": 20} + return [ + {"industry_name": "科技", "product_count": 2}, + {"industry_name": "单产品行业", "product_count": 1}, + ] + + result = await RelationshipService(Graph()).portfolio_industry_context(7) + assert result == { + "degraded": False, + "overlaps": [ + {"industry_name": "科技", "product_count": 2}, + {"industry_name": "单产品行业", "product_count": 1}, + ], + } + + +@pytest.mark.asyncio +async def test_portfolio_graph_context_failure_is_degraded() -> None: + from app.service.portfolio_analysis_service import PortfolioAnalysisService + + service = PortfolioAnalysisService(graph_service=RelationshipService(FailingGraph())) + result = await service._graph_context(7) + assert result["degraded"] is True + assert str(result["reason"]).startswith("neo4j_unavailable") + + @pytest.mark.asyncio async def test_model_router_excludes_unhealthy_endpoint() -> None: from app.model.configuration import ModelEndpointConfig diff --git a/tests/unit/service/test_dynamic_allocation_optimizer.py b/tests/unit/service/test_dynamic_allocation_optimizer.py new file mode 100644 index 0000000..c7cb931 --- /dev/null +++ b/tests/unit/service/test_dynamic_allocation_optimizer.py @@ -0,0 +1,65 @@ +from decimal import Decimal + +from app.service.asset_allocation_service import AssetAllocationService +from app.service.dynamic_allocation_optimizer import ( + AssetClassMarketMetric, + DynamicAllocationOptimizer, +) + + +def metric( + asset_class: str, return_pct: str, drawdown: str, turnover: str +) -> AssetClassMarketMetric: + return AssetClassMarketMetric( + asset_class=asset_class, + trailing_120d_return_pct=Decimal(return_pct), + max_drawdown_pct=Decimal(drawdown), + average_daily_turnover_amount=Decimal(turnover), + product_count=2, + ) + + +def test_strategic_weights_apply_horizon_liquidity_and_drawdown_constraints() -> None: + weights = AssetAllocationService._strategic_weights("C1", 6, "daily", Decimal("10")) + assert weights == {"cash_management_etf": 65, "bond_etf": 35, "equity_etf": 0} + + weights = AssetAllocationService._strategic_weights("C5", 72, "over_30_days", Decimal("30")) + assert weights == {"cash_management_etf": 5, "bond_etf": 10, "equity_etf": 85} + + +def test_optimizer_uses_return_drawdown_and_liquidity_evidence() -> None: + metrics = [ + metric("cash_management_etf", "2", "1", "20000000"), + metric("bond_etf", "6", "8", "5000000"), + metric("equity_etf", "12", "25", "1000000"), + ] + result = DynamicAllocationOptimizer.optimize( + {"cash_management_etf": 15, "bond_etf": 45, "equity_etf": 40}, + metrics, + return_target_lower_pct=Decimal("6"), + max_drawdown_pct=Decimal("15"), + liquidity_requirement="within_7_days", + ) + + assert result.dynamic is True + assert sum(result.weights.values()) == 100 + assert result.weights["equity_etf"] <= 40 + assert result.metric_coverage_pct == Decimal("100") + evidence = {item["asset_class"]: item for item in result.factors} + assert evidence["equity_etf"]["composite_score"] is not None + + +def test_optimizer_falls_back_to_static_weights_when_coverage_is_insufficient() -> None: + strategic = {"cash_management_etf": 30, "bond_etf": 50, "equity_etf": 20} + result = DynamicAllocationOptimizer.optimize( + strategic, + [metric("bond_etf", "6", "8", "5000000")], + return_target_lower_pct=Decimal("6"), + max_drawdown_pct=Decimal("15"), + liquidity_requirement="within_7_days", + ) + + assert result.dynamic is False + assert result.weights == strategic + assert result.metric_coverage_pct == Decimal("33.33333333333333333333333333") + assert result.factors[0]["composite_score"] is None diff --git a/tests/unit/service/test_goal_conversation_service.py b/tests/unit/service/test_goal_conversation_service.py new file mode 100644 index 0000000..fe656a1 --- /dev/null +++ b/tests/unit/service/test_goal_conversation_service.py @@ -0,0 +1,142 @@ +from typing import Any + +import pytest + +from app.service.goal_conversation_service import ( + MAX_CLARIFICATION_ROUNDS, + REQUIRED_FIELDS, + GoalConversationService, + extract_goal_entities, +) + + +def test_extracts_explicit_goal_entities() -> None: + result = extract_goal_entities( + "我希望年化收益6%-10%,最大回撤15%,7天内能用钱,期限3年,基准沪深300指数" + ) + + assert result == { + "annualized_return_lower_pct": 6.0, + "annualized_return_upper_pct": 10.0, + "max_drawdown_pct": 15.0, + "liquidity_requirement": "within_7_days", + "investment_horizon_months": 36, + "benchmark_name": "沪深300指数", + } + + +def test_collection_detection_does_not_intercept_goal_read() -> None: + assert GoalConversationService.should_collect("我想制定投资目标") + assert GoalConversationService.should_collect("收益目标是8%") + assert not GoalConversationService.should_collect("查看我的投资目标") + + +class _AsyncContext: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *_args: object) -> bool: + return False + + +class _SessionRow: + clarification_round = 0 + + +class _Message: + def __init__(self, message_id: int, content: str, trace_id: str) -> None: + self.id = message_id + self.content = content + self.trace_id = trace_id + + +class _FakeSession: + def __init__(self, messages: list[_Message], rounds: int = 0) -> None: + self.session_row = _SessionRow() + self.session_row.clarification_round = rounds + self.messages = messages + self.added: list[Any] = [] + self.scalar_calls = 0 + + def begin(self) -> _AsyncContext: + return _AsyncContext() + + async def scalar(self, _statement: object) -> object: + self.scalar_calls += 1 + if self.scalar_calls == 1: + return self.session_row + if self.scalar_calls == 2: + return next((item for item in self.messages if item.trace_id == "trace-2"), None) + return None + + async def scalars(self, _statement: object) -> list[_Message]: + return self.messages + + def add(self, item: Any) -> None: + self.added.append(item) + + +class _SessionFactory: + def __init__(self, session: _FakeSession) -> None: + self.session = session + + def __call__(self) -> "_SessionContext": + return _SessionContext(self.session) + + +class _SessionContext: + def __init__(self, session: _FakeSession) -> None: + self.session = session + + async def __aenter__(self) -> _FakeSession: + return self.session + + async def __aexit__(self, *_args: object) -> bool: + return False + + +@pytest.mark.asyncio +async def test_process_merges_turns_and_persists_only_internal_snapshot() -> None: + session = _FakeSession([ + _Message(1, "我想做投资目标,收益6%-10%,最大回撤15%", "trace-1"), + _Message(2, "7天内用钱,期限3年,基准沪深300", "trace-2"), + ]) + + result = await GoalConversationService(_SessionFactory(session)).process( + session_id="session-1", customer_id=9000001, trace_id="trace-2", + message=session.messages[-1].content, + ) + + assert result["status"] == "complete" + assert result["missing"] == [] + assert session.session_row.clarification_round == 0 + extraction = next( + item for item in session.added + if item.__class__.__name__ == "AdvisorGoalConversationExtraction" + ) + assert extraction.missing_fields == [] + assert extraction.extracted_fields["investment_horizon_months"] == 36 + + +@pytest.mark.asyncio +async def test_process_asks_only_for_missing_fields_and_honors_limit() -> None: + session = _FakeSession([_Message(3, "我希望年化收益6%-10%", "trace-2")]) + + result = await GoalConversationService(_SessionFactory(session)).process( + session_id="session-1", customer_id=9000001, trace_id="trace-2", + message="我希望年化收益6%-10%", + ) + + assert result["status"] == "partial" + assert result["missing"] == [field for field in REQUIRED_FIELDS if field not in { + "annualized_return_lower_pct", "annualized_return_upper_pct" + }] + assert session.session_row.clarification_round == 1 + assert "年化收益目标下限" not in GoalConversationService.customer_prompt(result) + + limited = _FakeSession(session.messages, rounds=MAX_CLARIFICATION_ROUNDS) + limited_result = await GoalConversationService(_SessionFactory(limited)).process( + session_id="session-1", customer_id=9000001, trace_id="trace-2", message="我还没想好" + ) + assert limited_result["status"] == "clarification_limit" + assert "转人工顾问" in GoalConversationService.customer_prompt(limited_result) diff --git a/tests/unit/service/test_investment_goal_service.py b/tests/unit/service/test_investment_goal_service.py new file mode 100644 index 0000000..189d93e --- /dev/null +++ b/tests/unit/service/test_investment_goal_service.py @@ -0,0 +1,178 @@ +from datetime import datetime +from decimal import Decimal +from typing import Any, cast + +import pytest +from pydantic import ValidationError + +from app.core.contracts import RequestContext +from app.core.errors import InvalidStateError, ValidationAgentError +from app.core.investment_goal_contracts import ( + InvestmentGoalBookPublish, + InvestmentGoalBookReview, + InvestmentGoalCreate, +) +from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent +from app.service.api_transaction_service import ApiTransactionService +from app.service.investment_goal_service import InvestmentGoalService + + +def context(*permissions: str, roles: tuple[str, ...] = ("customer",)) -> RequestContext: + return RequestContext(user_id="7", trace_id="goal-test", roles=roles, permissions=permissions) + + +def payload(**overrides: object) -> InvestmentGoalCreate: + values: dict[str, object] = { + "annualized_return_lower_pct": "4.0", + "annualized_return_upper_pct": "6.0", + "max_drawdown_pct": "8.0", + "liquidity_requirement": "within_30_days", + "investment_horizon_months": 36, + "benchmark_name": "中证全指收益率", + } + values.update(overrides) + return InvestmentGoalCreate.model_validate(values) + + +class FakeSession: + def __init__(self, goal: AdvisorInvestmentGoal | None = None) -> None: + self.goal = goal + self.content: ClientFacingContent | None = None + self.items: list[object] = [] + self.executed: list[object] = [] + + def add(self, item: object) -> None: + self.items.append(item) + if isinstance(item, ClientFacingContent): + self.content = item + + async def flush(self) -> None: + assert self.content is not None + self.content.id = 101 + + async def scalar(self, _statement: object) -> AdvisorInvestmentGoal | None: + return self.goal + + async def get(self, _model: object, _identity: int) -> ClientFacingContent | None: + return self.content + + async def execute(self, statement: object) -> None: + self.executed.append(statement) + + +async def run_operation( + monkeypatch: pytest.MonkeyPatch, session: FakeSession, method: str, *args: object +) -> dict[str, Any]: + async def execute( + _self: ApiTransactionService, + _context: RequestContext, + _scope: str, + _key: str | None, + _body: Any, + operation: Any, + ) -> dict[str, Any]: + return cast(dict[str, Any], await operation(session)) + + monkeypatch.setattr(ApiTransactionService, "execute", execute) + result = await getattr(InvestmentGoalService(), method)(*args) + return cast(dict[str, Any], result) + + +@pytest.mark.asyncio +async def test_create_writes_pending_goal_and_reviewable_book( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession() + result = await run_operation( + monkeypatch, session, "create", payload(), context("investment-goal:write:self"), + "goal-create-key-0001", + ) + goal = next(item for item in session.items if isinstance(item, AdvisorInvestmentGoal)) + assert goal.status == "pending_confirmation" + assert session.content is not None + assert session.content.review_status == "pending" + assert cast(dict[str, Any], result["data"])["goal_gap_status"] == "awaiting_confirmation" + + +@pytest.mark.asyncio +async def test_confirmation_activates_goal_but_does_not_publish_book( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created_at = datetime(2026, 9, 10) + goal = AdvisorInvestmentGoal( + id=1, goal_no="IG-confirmation-test", customer_id=7, status="pending_confirmation", + annualized_return_lower_pct=Decimal("4"), annualized_return_upper_pct=Decimal("6"), + max_drawdown_pct=Decimal("8"), liquidity_requirement="daily", investment_horizon_months=24, + benchmark_name="中证全指收益率", notes=None, source="customer", goal_book_content_id=101, + created_by=7, confirmed_by=None, confirmed_at=None, + created_at=created_at, updated_at=created_at, + ) + session = FakeSession(goal) + session.content = ClientFacingContent( + id=101, customer_id=7, content_type="investment_goal_book", draft_content={}, + generated_by_portal="api", review_status="pending", reviewer_user_id=None, + reviewed_at=None, published_at=None, created_at=created_at, updated_at=created_at, + ) + result = await run_operation( + monkeypatch, session, "confirm", goal.goal_no, + context("investment-goal:confirm:self"), "goal-confirm-key-0001", + ) + assert goal.status == "confirmed" + assert session.content.published_at is None + assert cast(dict[str, Any], result["data"])["goal_book"]["review_status"] == "pending" + + +@pytest.mark.asyncio +async def test_review_then_publish_requires_approved_book( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created_at = datetime(2026, 9, 10) + goal = AdvisorInvestmentGoal( + id=1, goal_no="IG-review-test", customer_id=7, status="confirmed", + annualized_return_lower_pct=Decimal("4"), annualized_return_upper_pct=Decimal("6"), + max_drawdown_pct=Decimal("8"), liquidity_requirement="daily", investment_horizon_months=24, + benchmark_name="中证全指收益率", notes=None, source="customer", goal_book_content_id=101, + created_by=7, confirmed_by=7, confirmed_at=created_at, + created_at=created_at, updated_at=created_at, + ) + session = FakeSession(goal) + session.content = ClientFacingContent( + id=101, customer_id=7, content_type="investment_goal_book", draft_content={}, + generated_by_portal="api", review_status="pending", reviewer_user_id=None, + reviewed_at=None, published_at=None, created_at=created_at, updated_at=created_at, + ) + + async def allow(*_args: object, **_kwargs: object) -> None: + return None + + monkeypatch.setattr("app.service.investment_goal_service.AuthorizationService.require", allow) + with pytest.raises(InvalidStateError, match="审核通过"): + await run_operation( + monkeypatch, session, "publish_book", goal.goal_no, + InvestmentGoalBookPublish(publish=True), + context("investment-goal:publish", roles=("admin",)), "goal-publish-key-0000", + ) + await run_operation( + monkeypatch, session, "review_book", goal.goal_no, + InvestmentGoalBookReview(decision="approved"), + context("investment-goal:review", roles=("admin",)), "goal-review-key-0001", + ) + assert session.content.review_status == "approved" + await run_operation( + monkeypatch, session, "publish_book", goal.goal_no, + InvestmentGoalBookPublish(publish=True), + context("investment-goal:publish", roles=("admin",)), "goal-publish-key-0001", + ) + assert session.content.review_status == "published" + assert session.content.published_at is not None + + +def test_goal_constraints_and_disclosures_are_validated() -> None: + with pytest.raises(ValidationError, match="lower bound"): + payload(annualized_return_lower_pct="8", annualized_return_upper_pct="6") + with pytest.raises(ValidationError): + payload(liquidity_requirement="tomorrow") + with pytest.raises(ValidationAgentError, match="收益承诺"): + InvestmentGoalService._validate_notes("希望保证收益") + book = InvestmentGoalService._goal_book("IG-1", payload()) + assert "不构成收益承诺" in cast(list[str], book["disclosures"])[0] diff --git a/tests/unit/service/test_market_quote_sync_service.py b/tests/unit/service/test_market_quote_sync_service.py new file mode 100644 index 0000000..88c2a84 --- /dev/null +++ b/tests/unit/service/test_market_quote_sync_service.py @@ -0,0 +1,25 @@ +from datetime import datetime +from decimal import Decimal + +from app.service.market_quote_sync_service import MarketQuoteSyncService + + +def test_snapshot_parses_real_time_quote_fields() -> None: + snapshot = MarketQuoteSyncService._snapshot(7, "eastmoney_exchange", { + "last_price": "1.234", "previous_close": "1.2", "change_pct": "2.83", + "volume": "1000", "turnover_amount": "123456.78", + }, datetime(2026, 9, 10)) + + assert snapshot is not None + assert snapshot.last_price == Decimal("1.234") + assert snapshot.turnover_amount == Decimal("123456.78") + assert snapshot.source == "eastmoney_exchange" + + +def test_snapshot_rejects_missing_or_non_positive_price() -> None: + assert MarketQuoteSyncService._snapshot( + 7, "eastmoney_exchange", {"last_price": None}, datetime.now() + ) is None + assert MarketQuoteSyncService._snapshot( + 7, "eastmoney_exchange", {"last_price": "0"}, datetime.now() + ) is None diff --git a/tests/unit/service/test_portfolio_analysis_service.py b/tests/unit/service/test_portfolio_analysis_service.py new file mode 100644 index 0000000..ab3008d --- /dev/null +++ b/tests/unit/service/test_portfolio_analysis_service.py @@ -0,0 +1,63 @@ +from datetime import date +from decimal import Decimal +from types import SimpleNamespace + +from app.service.portfolio_analysis_service import PortfolioAnalysisService + + +def holding(product_id: int, value: str | None) -> SimpleNamespace: + return SimpleNamespace(product_id=product_id, market_value=( + Decimal(value) if value is not None else None + )) + + +def product(product_id: int) -> SimpleNamespace: + return SimpleNamespace( + id=product_id, product_code=f"P{product_id}", product_name=f"产品{product_id}" + ) + + +def exposure(product_id: int, industry: str, weight: str) -> SimpleNamespace: + return SimpleNamespace( + product_id=product_id, industry_name=industry, + exposure_weight_pct=Decimal(weight), as_of_date=date(2026, 9, 10), id=product_id, + ) + + +def test_no_positions_and_missing_valuation_are_explicit() -> None: + empty = PortfolioAnalysisService._analyze([], {}, {}) + assert empty["status"] == "no_positions" + + missing = PortfolioAnalysisService._analyze( + [(holding(1, None), product(1))], {}, {} + ) + assert missing["status"] == "valuation_required" + + +def test_concentration_and_industry_hhi_use_mysql_facts() -> None: + positions = [(holding(1, "70"), product(1)), (holding(2, "30"), product(2))] + exposures = { + 1: [exposure(1, "科技", "100")], + 2: [exposure(2, "消费", "100")], + } + result = PortfolioAnalysisService._analyze(positions, exposures, {}) + assert result["status"] == "ready" + concentration = result["product_concentration"] + assert concentration["hhi"] == "5800.00" + industries = result["industry_concentration"] + assert industries["hhi"] == "5800.00" + assert industries["conclusion_available"] is True + assert result["disclaimer"] == "分析结果仅供参考,不生成交易指令。" + + +def test_incomplete_industry_data_does_not_make_a_deterministic_claim() -> None: + result = PortfolioAnalysisService._analyze( + [(holding(1, "100"), product(1))], + {1: [exposure(1, "科技", "120")]}, + {}, + ) + industries = result["industry_concentration"] + assert industries["hhi"] is None + assert industries["conclusion_available"] is False + codes = {warning["code"] for warning in result["warnings"]} + assert {"INDUSTRY_COVERAGE_INCOMPLETE", "INDUSTRY_EXPOSURE_INVALID"} <= codes diff --git a/tests/unit/service/test_product_comparison_service.py b/tests/unit/service/test_product_comparison_service.py new file mode 100644 index 0000000..c4266fc --- /dev/null +++ b/tests/unit/service/test_product_comparison_service.py @@ -0,0 +1,43 @@ +import pytest +from pydantic import ValidationError + +from app.core.product_comparison_contracts import ProductComparisonQuery +from app.service.agent.implementations.advisor import AdvisorAgent +from app.service.product_comparison_service import ProductComparisonService + + +def test_comparison_query_requires_two_to_four_six_digit_codes() -> None: + assert ProductComparisonQuery(product_codes=["159511", "510500"]).product_codes == ( + "159511", "510500" + ) + with pytest.raises(ValidationError): + ProductComparisonQuery(product_codes=["159511"]) + with pytest.raises(ValidationError): + ProductComparisonQuery(product_codes=["15951x", "510500"]) + + +def test_comparison_differences_only_contains_changed_fields() -> None: + rows = [ + {"product_category": "ETF", "suitability_risk_level": "R2", "fund_type": "股票型", + "performance_benchmark": "沪深300", "management_fee_rate_pct": "0.50", + "custodian_fee_rate_pct": "0.10"}, + {"product_category": "LOF", "suitability_risk_level": "R2", "fund_type": "股票型", + "performance_benchmark": "沪深300", "management_fee_rate_pct": "0.60", + "custodian_fee_rate_pct": "0.10"}, + ] + + result = ProductComparisonService._differences(rows) + + assert set(result) == {"product_category", "management_fee_rate_pct"} + + +def test_advisor_comparison_description_is_analysis_only() -> None: + text = AdvisorAgent._describe_comparison({ + "status": "ready", + "products": [{"product_code": "159511", "product_name": "南方测试ETF"}], + "common_industries": ["科技"], + }) + + assert "159511 南方测试ETF" in text + assert "科技" in text + assert "不构成交易指令" in text diff --git a/tests/unit/service/test_product_history_sync_service.py b/tests/unit/service/test_product_history_sync_service.py new file mode 100644 index 0000000..c87c46b --- /dev/null +++ b/tests/unit/service/test_product_history_sync_service.py @@ -0,0 +1,54 @@ +from datetime import UTC, datetime +from decimal import Decimal + +from app.service.product_history_sync_service import ProductHistorySyncService + + +def test_history_sync_row_accepts_a_positive_public_nav() -> None: + now = datetime.now(UTC).replace(tzinfo=None) + + result = ProductHistorySyncService._row( + 7, {"trade_date": "2026-09-09", "nav": "1.234500"}, now + ) + + assert result is not None + assert result["product_id"] == 7 + assert result["price_kind"] == "fund_nav" + assert result["close_price"] == Decimal("1.234500") + assert result["source"] == "eastmoney_hq_nav" + + +def test_history_sync_row_keeps_verified_exchange_turnover() -> None: + now = datetime.now(UTC).replace(tzinfo=None) + + result = ProductHistorySyncService._row( + 7, + {"trade_date": "2026-09-09", "nav": "1.234500", "turnover_amount": "123456.78"}, + now, + ) + + assert result is not None + assert result["turnover_amount"] == Decimal("123456.78") + + +def test_history_sync_row_rejects_negative_or_invalid_turnover() -> None: + now = datetime.now(UTC).replace(tzinfo=None) + + negative = ProductHistorySyncService._row( + 7, {"trade_date": "2026-09-09", "nav": "1", "turnover_amount": "-1"}, now + ) + invalid = ProductHistorySyncService._row( + 7, {"trade_date": "2026-09-09", "nav": "1", "turnover_amount": "bad"}, now + ) + + assert negative is not None and negative["turnover_amount"] is None + assert invalid is not None and invalid["turnover_amount"] is None + + +def test_history_sync_row_rejects_invalid_or_non_positive_nav() -> None: + now = datetime.now(UTC).replace(tzinfo=None) + + assert ProductHistorySyncService._row(7, {"trade_date": "bad", "nav": "1"}, now) is None + assert ProductHistorySyncService._row( + 7, {"trade_date": "2026-09-09", "nav": "0"}, now + ) is None diff --git a/tests/unit/service/test_product_metric_service.py b/tests/unit/service/test_product_metric_service.py new file mode 100644 index 0000000..6e3220d --- /dev/null +++ b/tests/unit/service/test_product_metric_service.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from datetime import date, timedelta +from decimal import Decimal + +from app.service.product_metric_service import ProductMetricService + + +@dataclass(frozen=True) +class Price: + trade_date: date + close_price: Decimal + turnover_amount: Decimal | None + + +def price(day: int, close: str, turnover: str = "1000") -> Price: + return Price( + trade_date=date(2026, 1, 1) + timedelta(days=day), + close_price=Decimal(close), + turnover_amount=Decimal(turnover), + ) + + +def test_metric_calculation_uses_historical_prices_without_forecast() -> None: + prices = [price(day, str(100 + day), str(1000 + day)) for day in range(121)] + + metrics = ProductMetricService.calculate(prices) + + assert metrics.trailing_20d_return_pct == Decimal("10.0000") + assert metrics.trailing_120d_return_pct == Decimal("120.0000") + assert metrics.annualized_volatility_pct is not None + assert metrics.max_drawdown_pct == Decimal("0.0000") + assert metrics.average_daily_turnover_amount == Decimal("1110.50") + assert metrics.observation_count == 121 + + +def test_metric_calculation_keeps_insufficient_samples_empty() -> None: + metrics = ProductMetricService.calculate([price(0, "100"), price(1, "90")]) + + assert metrics.trailing_20d_return_pct is None + assert metrics.trailing_120d_return_pct is None + assert metrics.annualized_volatility_pct is None + assert metrics.max_drawdown_pct == Decimal("-10.0000") + + +def test_snapshot_carries_source_and_calculation_version() -> None: + snapshot = ProductMetricService.snapshot(7, [price(0, "100"), price(1, "101")]) + + assert snapshot is not None + assert snapshot.source == "advisor_product_price_history_dual_read" + assert snapshot.calculation_version == "v1" + assert snapshot.as_of_date == date(2026, 1, 2) diff --git a/tests/unit/service/test_product_recommendation_service.py b/tests/unit/service/test_product_recommendation_service.py new file mode 100644 index 0000000..34d99f2 --- /dev/null +++ b/tests/unit/service/test_product_recommendation_service.py @@ -0,0 +1,109 @@ +from datetime import date +from decimal import Decimal +from types import SimpleNamespace + +from app.core.contracts import RequestContext +from app.core.product_recommendation_contracts import ProductRecommendationQuery +from app.service.product_recommendation_service import ProductRecommendationService + + +def candidate(code: str, risk: str, turnover: str) -> SimpleNamespace: + return SimpleNamespace( + product=SimpleNamespace( + id=int(code), + product_code=code, + product_name=f"产品{code}", + product_category="ETF", + status="上市", + ), + suitability=SimpleNamespace( + risk_level=risk, + source_url="https://example.test/risk", + document_title="适当性披露", + effective_from=date(2026, 1, 1), + ), + contract=SimpleNamespace( + fund_type="ETF", + source_url="https://example.test/contract", + document_title="基金合同", + ), + liquidity=SimpleNamespace( + status="available", + average_daily_turnover_amount=Decimal(turnover), + ), + ) + + +async def test_recommendation_requires_profile_and_goal(monkeypatch) -> None: + service = ProductRecommendationService() + context = RequestContext( + user_id="7", + trace_id="recommendation-test", + permissions=("product-recommendation:generate:self",), + ) + + async def missing_authority(_self, _customer_id): + return _authority(None) + + monkeypatch.setattr( + "app.service.product_recommendation_service.SuitabilityService.authority_for_customer", + missing_authority, + ) + result = await service.generate(ProductRecommendationQuery(), context, None) + assert result["status"] == "profile_required" + + +async def test_recommendation_returns_evidence_and_exclusion_reasons(monkeypatch) -> None: + service = ProductRecommendationService() + context = RequestContext( + user_id="7", + trace_id="recommendation-test", + permissions=("product-recommendation:generate:self",), + ) + + async def authority(_self, _customer_id): + return _authority(3) + + async def goal(_self, _context): + return _goal() + + monkeypatch.setattr( + "app.service.product_recommendation_service.SuitabilityService.authority_for_customer", + authority, + ) + monkeypatch.setattr( + "app.service.product_recommendation_service.InvestmentGoalService.current_for_agent", + goal, + ) + + async def candidates(_risk: int, _liquidity: str): + return [candidate("510001", "R2", "20000000")], [ + { + "product_code": "510999", + "reason_code": "RISK_LEVEL_MISMATCH", + } + ] + + monkeypatch.setattr(service, "_candidates", candidates) + + result = await service.generate(ProductRecommendationQuery(), context, None) + + assert result["status"] == "ready" + assert result["analysis_only"] is True + products = result["products"] + assert len(products) == 1 + assert products[0]["recommendation_evidence_card"]["suitability"]["source_url"] + assert result["excluded_candidates"][0]["reason_code"] == "RISK_LEVEL_MISMATCH" + assert len(result["disclosures"]) == 3 + + +def _authority(level: int | None) -> SimpleNamespace: + return SimpleNamespace(customer_risk_level=level) + + +def _goal() -> dict[str, object]: + return { + "status": "confirmed", + "liquidity_requirement": "within_7_days", + "investment_horizon_months": 36, + } diff --git a/tests/unit/service/test_profile_governance_service.py b/tests/unit/service/test_profile_governance_service.py new file mode 100644 index 0000000..2e83c57 --- /dev/null +++ b/tests/unit/service/test_profile_governance_service.py @@ -0,0 +1,125 @@ +from datetime import datetime +from decimal import Decimal + +import pytest + +from app.core.errors import InvalidStateError +from app.model.profile_tag import AdvisorProfileDriftReview, AdvisorProfileTag +from app.service.profile_governance_service import ProfileGovernanceService + + +class _AsyncContext: + async def __aenter__(self) -> object: + return None + + async def __aexit__(self, *_args: object) -> bool: + return False + + +class _Session: + def __init__(self, pending: object = None) -> None: + self.pending = pending + + def begin(self) -> _AsyncContext: + return _AsyncContext() + + +class _SessionFactory: + def __init__(self, session: _Session) -> None: + self.session = session + + def __call__(self) -> "_SessionContext": + return _SessionContext(self.session) + + +class _SessionContext: + def __init__(self, session: _Session) -> None: + self.session = session + + async def __aenter__(self) -> _Session: + return self.session + + async def __aexit__(self, *_args: object) -> bool: + return False + + +@pytest.mark.asyncio +async def test_pending_profile_blocks_advisory_operation(monkeypatch) -> None: + session = _Session(pending=object()) + + async def pending(_self, _customer_id: int, *, lock: bool = False) -> object: + del lock + return session.pending + + monkeypatch.setattr( + "app.service.profile_governance_service.RiskQuestionnaireRepository.pending_drift_review", + pending, + ) + + with pytest.raises(InvalidStateError, match="漂移正在复核"): + await ProfileGovernanceService(_SessionFactory(session)).require_operable(7) + + +class _Repository: + def __init__(self) -> None: + self.session = self + self.added: list[object] = [] + self.sync_targets: list[str] = [] + + async def execute(self, _statement: object) -> None: + return None + + async def deactivate_current_profile(self, _customer_id: int, _now: datetime) -> None: + return None + + async def active_tags( + self, _customer_id: int, *, lock: bool = False + ) -> list[AdvisorProfileTag]: + del lock + return [] + + async def supersede_active_tags( + self, _customer_id: int, _tag_keys: tuple[str, ...], _now: datetime + ) -> None: + return None + + def add_profile(self, profile: object) -> None: + self.added.append(profile) + + def add_sync_event(self, event: object) -> None: + self.added.append(event) + self.sync_targets.append(event.target_store) + + +def _review() -> AdvisorProfileDriftReview: + now = datetime(2026, 9, 11) + return AdvisorProfileDriftReview( + id=4, drift_no="drift-4", customer_id=7, source_assessment_id=9, + candidate_profile_uuid="candidate-uuid", candidate_profile_version=2, + candidate_snapshot={"risk_level": "C3"}, + candidate_generation_basis={"source": "assessment"}, + changed_tags=[], status="pending_review", reviewer_user_id=None, + reviewed_at=None, review_comment=None, created_at=now, updated_at=now, + ) + + +@pytest.mark.asyncio +async def test_approved_review_activates_candidate_and_enqueues_both_projections() -> None: + repository = _Repository() + tag = AdvisorProfileTag( + id=11, tag_uuid="tag-11", customer_id=7, tag_key="risk_level", tag_value="C3", + tag_value_hash="x", confidence=Decimal("0.9000"), source_type="assessment", + source_reference="assessment:9", source_confidence=Decimal("1.0000"), + profile_version=2, drift_review_id=4, previous_tag_id=1, drift_reason="value_changed", + status="pending_review", active_customer_tag=None, + created_at=datetime(2026, 9, 11), updated_at=datetime(2026, 9, 11), + ) + + await ProfileGovernanceService()._approve( + repository, _review(), [tag], datetime(2026, 9, 11) + ) + + assert tag.status == "active" + assert tag.active_customer_tag == "7:risk_level" + assert repository.added[0].is_current is True + assert repository.sync_targets == ["milvus", "neo4j"] diff --git a/tests/unit/service/test_risk_questionnaire_service.py b/tests/unit/service/test_risk_questionnaire_service.py new file mode 100644 index 0000000..7f0ba50 --- /dev/null +++ b/tests/unit/service/test_risk_questionnaire_service.py @@ -0,0 +1,187 @@ +from decimal import Decimal +from typing import Any, cast + +import pytest +from pydantic import ValidationError + +from app.core.contracts import RequestContext +from app.core.risk_questionnaire_contracts import RiskQuestionnaireSubmission +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.service.api_transaction_service import ApiTransactionService +from app.service.risk_questionnaire_service import RiskQuestionnaireService + + +def context() -> RequestContext: + return RequestContext( + user_id="7", trace_id="questionnaire-test", roles=("customer",) + ) + + +def lowest_risk_answers() -> dict[str, int]: + return { + "q1": 12, "q2": 4, "q3": 5, "q4": 4, "q5": 4, "q6": 5, "q7": 5, + "q8": 4, "q9": 4, "q10": 1, "q11": 5, "q12": 4, "q13": 4, + } + + +def highest_risk_answers() -> dict[str, int]: + return { + "q1": 1, "q2": 1, "q3": 4, "q4": 1, "q5": 1, "q6": 1, "q7": 1, + "q8": 1, "q9": 1, "q10": 4, "q11": 1, "q12": 1, "q13": 1, + } + + +def submission() -> RiskQuestionnaireSubmission: + return RiskQuestionnaireSubmission( + answers=lowest_risk_answers(), declaration_accepted=True + ) + + +class FakeSession: + def __init__(self) -> None: + self.items: list[object] = [] + + def add(self, item: object) -> None: + self.items.append(item) + + async def flush(self) -> None: + for item in self.items: + if isinstance(item, AdvisorProfileDriftReview) and item.id is None: + item.id = 99 + + +class FakeRepository: + def __init__(self, session: FakeSession) -> None: + self.session = session + self.deactivated = False + + async def assessments_since(self, _customer_id: int, _since: object) -> int: + return 0 + + async def next_profile_version(self, _customer_id: int) -> int: + return 1 + + async def pending_drift_review(self, _customer_id: int, *, lock: bool = False) -> None: + del lock + return None + + async def active_tags( + self, _customer_id: int, *, lock: bool = False + ) -> list[AdvisorProfileTag]: + del lock + return [] + + async def deactivate_current_profile(self, _customer_id: int, _now: object) -> None: + self.deactivated = True + + def add_assessment(self, assessment: RiskAssessment) -> None: + self.session.add(assessment) + + def add_profile(self, profile: ProfileSnapshot) -> None: + self.session.add(profile) + + def add_tag(self, tag: AdvisorProfileTag) -> None: + self.session.add(tag) + + def add_drift_review(self, review: AdvisorProfileDriftReview) -> None: + self.session.add(review) + + async def supersede_active_tags( + self, _customer_id: int, _tag_keys: tuple[str, ...], _now: object + ) -> None: + return None + + def add_sync_event(self, event: MemorySyncOutbox) -> None: + self.session.add(event) + + +def test_submission_requires_all_questions_and_declaration() -> None: + with pytest.raises(ValidationError, match="every questionnaire question"): + RiskQuestionnaireSubmission(answers={"q1": 1}, declaration_accepted=True) + with pytest.raises(ValidationError): + RiskQuestionnaireSubmission.model_validate({ + "answers": lowest_risk_answers(), "declaration_accepted": False, + }) + + +def test_score_bands_are_deterministic_and_server_only() -> None: + low = RiskQuestionnaireService.score(lowest_risk_answers()) + high = RiskQuestionnaireService.score(highest_risk_answers()) + assert (low.total, low.risk_level) == (13, "C1") + assert (high.total, high.risk_level) == (57, "C5") + + +@pytest.mark.asyncio +async def test_submission_persists_assessment_and_profile_without_exposing_them( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = FakeSession() + + async def execute( + _self: ApiTransactionService, + _context: RequestContext, + _scope: str, + _key: str | None, + _body: object, + operation: Any, + ) -> dict[str, Any]: + return cast(dict[str, Any], await operation(session)) + + monkeypatch.setattr(ApiTransactionService, "execute", execute) + monkeypatch.setattr( + "app.service.risk_questionnaire_service.RiskQuestionnaireRepository", FakeRepository + ) + response = await RiskQuestionnaireService().submit( + submission(), context(), "questionnaire-key-0001" + ) + + data = cast(dict[str, object], response["data"]) + assert set(data) == {"completed", "questionnaire_version", "valid_until"} + assert data["completed"] is True + assert not {"total_score", "risk_level", "risk_profile", "profile"}.intersection(data) + assert any(isinstance(item, RiskAssessment) for item in session.items) + assert any(isinstance(item, ProfileSnapshot) for item in session.items) + tags = [item for item in session.items if isinstance(item, AdvisorProfileTag)] + assert {tag.tag_key for tag in tags} == { + "risk_level", "risk_profile", "investment_horizon", "preferred_asset_classes", + } + assert all(tag.source_type == "formal_risk_assessment" for tag in tags) + assert all(tag.status == "active" for tag in tags) + assert len([item for item in session.items if isinstance(item, MemorySyncOutbox)]) == 2 + + +def test_tag_confidence_is_lower_near_a_risk_band_boundary() -> None: + assert RiskQuestionnaireService._risk_tag_confidence(22) == Decimal("0.7000") + assert RiskQuestionnaireService._risk_tag_confidence(17) == Decimal("0.9000") + + +def test_tag_value_change_requires_drift_review() -> None: + candidates = RiskQuestionnaireService._profile_tag_candidates( + RiskQuestionnaireService.score(highest_risk_answers()), highest_risk_answers(), 8 + ) + previous = AdvisorProfileTag( + id=1, + tag_uuid="previous-risk-level", + customer_id=7, + tag_key="risk_level", + tag_value="C1", + tag_value_hash="different-value-hash", + confidence=0.95, + source_type="formal_risk_assessment", + source_reference="fin_risk_assessment:7", + source_confidence=1, + profile_version=1, + drift_review_id=None, + previous_tag_id=None, + drift_reason=None, + status="active", + active_customer_tag="7:risk_level", + created_at=object(), + updated_at=object(), + ) + changes = RiskQuestionnaireService._drift_changes([previous], candidates) + assert changes[0]["tag_key"] == "risk_level" + assert changes[0]["reason"] == "value_changed" diff --git a/tests/unit/test_advisor_migration_contract.py b/tests/unit/test_advisor_migration_contract.py new file mode 100644 index 0000000..33268e5 --- /dev/null +++ b/tests/unit/test_advisor_migration_contract.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from alembic.config import Config +from alembic.script import ScriptDirectory + +ROOT = Path(__file__).resolve().parents[2] +VERSIONS = ROOT / "alembic" / "versions" +BASELINE = ROOT / "alembic" / "baseline_generated.sql" + +ADVISOR_FILES = ( + "20260910_advisor_investment_goal.py", + "20260910_advisor_product_industry_exposure.py", + "20260910_advisor_portfolio_projection_checkpoint.py", + "20260910_advisor_product_metric_snapshot.py", + "20260910_advisor_product_asset_classification.py", + "20260910_advisor_product_reference_snapshot.py", + "20260910_advisor_offsite_fund_reference.py", + "20260910_advisor_product_price_history.py", + "20260910_advisor_product_governance_reference.py", + "20260910_advisor_governance_monitor_and_quotes.py", + "20260910_advisor_market_quote_resilience.py", + "20260910_advisor_data_quality_backtest.py", + "20260911_advisor_goal_conversation.py", + "20260911_advisor_profile_tag_governance.py", +) + + +def created_tables(path: Path) -> set[str]: + content = path.read_text(encoding="utf-8") + return set(re.findall(r"CREATE TABLE(?: IF NOT EXISTS)?\s+`?([A-Za-z0-9_]+)`?", content)) + + +def test_advisor_migrations_form_one_chain_from_qyqy_head() -> None: + script = ScriptDirectory.from_config(Config(str(ROOT / "alembic.ini"))) + assert len(script.get_heads()) == 1 + assert script.get_heads()[0] == "20260911_merge_adv_risk_heads" + + first = (VERSIONS / ADVISOR_FILES[0]).read_text(encoding="utf-8") + assert 'down_revision = "20260910_drop_review_separation"' in first + for previous, current in zip(ADVISOR_FILES, ADVISOR_FILES[1:], strict=False): + previous_content = (VERSIONS / previous).read_text(encoding="utf-8") + current_content = (VERSIONS / current).read_text(encoding="utf-8") + previous_revision = re.search(r'revision = "([^"]+)"', previous_content) + assert previous_revision is not None + assert f'down_revision = "{previous_revision.group(1)}"' in current_content + + +def test_advisor_migrations_only_create_additive_tables() -> None: + baseline_tables = created_tables(BASELINE) + advisor_tables: set[str] = set() + for filename in ADVISOR_FILES: + content = (VERSIONS / filename).read_text(encoding="utf-8") + assert "ALTER TABLE" not in content + assert "DROP TABLE" not in content + advisor_tables.update(created_tables(VERSIONS / filename)) + + assert advisor_tables + assert not advisor_tables & baseline_tables + assert "fin_sim_order" not in advisor_tables + assert "fin_transaction" not in advisor_tables + assert "fin_holding" not in advisor_tables diff --git a/tests/unit/tools/test_nanfang_official_product_governance.py b/tests/unit/tools/test_nanfang_official_product_governance.py new file mode 100644 index 0000000..f712227 --- /dev/null +++ b/tests/unit/tools/test_nanfang_official_product_governance.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from tools.sync_nanfang_official_product_governance import ( + collect_official_rows, + contract_document, +) + + +def overview() -> dict[str, object]: + return { + "code": "ETS-5BP00000", + "data": { + "fundRiskRating": {"RISKRATING": "\u4e2d\u4f4e\u98ce\u9669(R2)"}, + "fund_info": { + "fundName": "\u5357\u65b9\u6d4b\u8bd5ETF", + "fundDate": "20240101", + "contractValidDate": "20240101", + "basedetailType": "\u503a\u5238\u578b\u57fa\u91d1", + "tzfw": "\u6d4b\u8bd5\u6295\u8d44\u8303\u56f4", + "yjbjjz": "\u6d4b\u8bd5\u4e1a\u7ee9\u6bd4\u8f83\u57fa\u51c6", + "jjtgr": "\u6d4b\u8bd5\u6258\u7ba1\u4eba", + "glYearRatio": 0.5, + "tgYearRatio": 0.1, + }, + }, + } + + +def documents() -> list[dict[str, object]]: + return [ + { + "title": "\u5357\u65b9\u6d4b\u8bd5ETF\u6258\u7ba1\u534f\u8bae", + "linkUrl": "/bad.doc", + "publishTime": "2026-01-01", + }, + { + "title": "\u5357\u65b9\u6d4b\u8bd5ETF\u57fa\u91d1\u5408\u540c", + "linkUrl": "/main/files/contract.docx", + "createTimeString": "2024-01-01", + "publishTime": "2024-01-02", + }, + ] + + +def test_collects_official_rating_and_contract_hash() -> None: + suitability, contract = collect_official_rows( + "123456", + overview(), + documents(), + b"official-contract", + synced_at=datetime(2026, 9, 10, 8, 0, tzinfo=UTC), + ) + assert suitability["risk_level"] == "R2" + assert suitability["review_status"] == "verified" + assert contract["source_url"] == "https://www.nffund.com/main/files/contract.docx" + assert contract["document_sha256"] == ( + "0d53cbfbbcfea4857c2575dd2441ced3d6f53a56b34fdaa6b526ce6b755faaf1" + ) + assert contract["management_fee_rate_pct"] == "0.5" + + +def test_contract_document_excludes_custody_agreement() -> None: + assert contract_document(documents(), "123456")["linkUrl"] == "/main/files/contract.docx" + + +def test_requires_official_risk_rating() -> None: + invalid = overview() + data = invalid["data"] + assert isinstance(data, dict) + data["fundRiskRating"] = {"RISKRATING": "\u4e2d\u7b49\u98ce\u9669"} + with pytest.raises(ValueError, match="did not disclose"): + collect_official_rows( + "123456", invalid, documents(), b"contract", synced_at=datetime.now(UTC) + ) diff --git a/tests/unit/tools/test_product_asset_classification_import.py b/tests/unit/tools/test_product_asset_classification_import.py new file mode 100644 index 0000000..ba0cdea --- /dev/null +++ b/tests/unit/tools/test_product_asset_classification_import.py @@ -0,0 +1,13 @@ +from tools.import_product_asset_classifications import classify_contract + + +def test_classify_contract_uses_official_fund_type_before_scope_keywords() -> None: + assert classify_contract("货币市场基金", "货币市场工具和银行存款") == "cash_management_etf" + assert classify_contract("债券型", "债券资产比例不低于基金资产的80%") == "bond_etf" + assert classify_contract("指数型", "标的指数成份股比例不低于90%") == "equity_etf" + + +def test_classify_contract_requires_stock_scope_for_hybrid_and_skips_fund_of_funds() -> None: + assert classify_contract("混合型", "股票投资比例范围为40%-95%") == "equity_etf" + assert classify_contract("混合型", "债券、现金及其他工具") is None + assert classify_contract("基金中基金", "主要投资于公募基金") is None diff --git a/tests/unit/tools/test_product_governance_import.py b/tests/unit/tools/test_product_governance_import.py new file mode 100644 index 0000000..c53f850 --- /dev/null +++ b/tests/unit/tools/test_product_governance_import.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tools.import_product_governance_reference import ( + CONTRACT_FIELDS, + SUITABILITY_FIELDS, + normalize_contracts, + normalize_suitability, + read_rows, +) + + +def suitability_row() -> dict[str, str]: + return { + "product_code": "511810", + "sales_institution": "\u6d4b\u8bd5\u9500\u552e\u673a\u6784", + "risk_level": "R1", + "effective_from": "2026-01-01", + "effective_until": "", + "source_url": "https://institution.example/risk-disclosure.pdf", + "document_title": "\u4ea7\u54c1\u98ce\u9669\u7b49\u7ea7\u516c\u793a", + "document_published_at": "2026-01-01", + "document_sha256": "a" * 64, + "source": "sales_institution_disclosure", + "review_status": "verified", + "verified_by": "reviewer", + "verified_at": "2026-01-02T08:00:00+08:00", + "_line_number": "2", + } + + +def contract_row() -> dict[str, str]: + return { + "product_code": "511810", + "effective_from": "2026-01-01", + "effective_until": "", + "fund_type": "ETF", + "investment_scope": "\u8d27\u5e01\u5e02\u573a\u5de5\u5177", + "performance_benchmark": "\u4e03\u5929\u901a\u77e5\u5b58\u6b3e\u5229\u7387", + "risk_return_characteristics": "\u4f4e\u98ce\u9669\u3001\u4f4e\u6536\u76ca", + "custodian_name": "\u6d4b\u8bd5\u6258\u7ba1\u4eba", + "management_fee_rate_pct": "0.33", + "custodian_fee_rate_pct": "0.10", + "inception_date": "2013-01-01", + "source_url": "https://manager.example/contract.pdf", + "document_title": "\u57fa\u91d1\u5408\u540c", + "document_published_at": "2026-01-01", + "document_sha256": "b" * 64, + "source": "fund_manager_official", + "review_status": "verified", + "verified_by": "reviewer", + "verified_at": "2026-01-02T08:00:00+08:00", + "_line_number": "2", + } + + +def test_normalize_references_preserves_verification_and_contract_fields() -> None: + suitability = normalize_suitability([suitability_row()]) + contract = normalize_contracts([contract_row()]) + assert suitability[0]["risk_level"] == "R1" + assert suitability[0]["verified_at"] is not None + assert contract[0]["management_fee_rate_pct"] is not None + assert contract[0]["investment_scope"] == "\u8d27\u5e01\u5e02\u573a\u5de5\u5177" + + +def test_verified_reference_requires_reviewer_and_timestamp() -> None: + row = suitability_row() + row["verified_at"] = "" + with pytest.raises(ValueError, match="verified rows require"): + normalize_suitability([row]) + + +def test_reader_rejects_missing_required_columns(tmp_path: Path) -> None: + path = tmp_path / "bad.csv" + path.write_text("product_code,risk_level\n511810,R1\n", encoding="utf-8") + with pytest.raises(ValueError, match="missing CSV columns"): + read_rows(path, SUITABILITY_FIELDS) + with pytest.raises(ValueError, match="missing CSV columns"): + read_rows(path, CONTRACT_FIELDS) diff --git a/tools/acceptance_check.py b/tools/acceptance_check.py index 677c197..9318844 100644 --- a/tools/acceptance_check.py +++ b/tools/acceptance_check.py @@ -65,6 +65,32 @@ def token(sub: str) -> str: ) +async def ensure_onboarding(client: httpx.AsyncClient, auth: dict[str, str]) -> None: + """让独立验收客户满足业务接口的首次登录前置条件。""" + response = await client.get("/api/v1/onboarding/risk-questionnaire", headers=auth) + record("开户问卷状态可查询", "200", str(response.status_code)) + if response.status_code != 200: + return + questionnaire = response.json().get("data") or {} + if not questionnaire.get("required"): + record("测试客户已完成开户问卷", "completed", "completed") + return + submission = { + "answers": { + "q1": 12, "q2": 4, "q3": 5, "q4": 4, "q5": 4, "q6": 5, + "q7": 5, "q8": 4, "q9": 4, "q10": 1, "q11": 5, "q12": 4, + "q13": 4, + }, + "declaration_accepted": True, + } + response = await client.post( + "/api/v1/onboarding/risk-questionnaire/submissions", + json=submission, + headers={**auth, "Idempotency-Key": uuid.uuid4().hex}, + ) + record("首次登录问卷提交", "201", str(response.status_code)) + + class StubGovernance: async def resolve(self, definition: AgentDefinition, context: RequestContext) -> ResolvedAgentConfig: del definition, context @@ -105,7 +131,12 @@ def register_probes(factory: AgentFactory, *, version: str = "acceptance") -> No allowed_roles=roles, allowed_portals=("api",), ) - factory.register(definition, lambda _ctx, _def=definition: AcceptanceAgent(_def)) + def builder( + _ctx: RequestContext, _def: AgentDefinition = definition + ) -> BaseAgent: + return AcceptanceAgent(_def) + + factory.register(definition, builder) def build_registry() -> AgentFactory: @@ -206,6 +237,7 @@ async def main() -> None: transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=30 ) as client: auth = {"Authorization": f"Bearer {token(CUSTOMER_ID)}"} + await ensure_onboarding(client, auth) response = await client.post( "/api/v1/agent-runs", @@ -254,8 +286,12 @@ async def main() -> None: await asyncio.sleep(1.0) await runtime.execute(run_id) events = await subscription + print(f"SSE events: {events}") - record("SSE 含 delta 事件", "yes", "yes" if "delta" in events else "no") + record( + "SSE 含内容事件", "yes", + "yes" if {"delta", "replace"}.intersection(events) else "no", + ) record("SSE 终止事件", "done", events[-1] if events else "无事件") response = await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth) diff --git a/tools/audit_constraints.py b/tools/audit_constraints.py index 1b3f7bf..1ff16fd 100644 --- a/tools/audit_constraints.py +++ b/tools/audit_constraints.py @@ -26,10 +26,12 @@ sys.path.insert(0, str(ROOT)) from app.core.config import get_settings # noqa: E402 from app.model import ( # noqa: E402,F401 + advisor_product, audit, configuration, conversation, fund, + investment_goal, memory, platform, session, diff --git a/tools/import_hq_test_products.py b/tools/import_hq_test_products.py new file mode 100644 index 0000000..9dedf9b --- /dev/null +++ b/tools/import_hq_test_products.py @@ -0,0 +1,356 @@ +"""Import verified exchange-traded Southern Fund products into the local test DB. + +The hq.py whitelist also contains off-exchange mutual funds. This command only +imports the ETF/LOF entries that belong to the platform's exchange-traded +simulation scope. It refreshes the product catalogue and its latest NAV cache; +it deliberately does not write historical rows because the immutable baseline +history tables currently have incompatible single-column unique constraints. +""" + +from __future__ import annotations + +import argparse +import sys +from datetime import UTC, date, datetime, time +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlparse + +import pymysql + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +TEST_PRODUCT_ID_START = 9_100_001 +FUND_MANAGER = "\u5357\u65b9\u57fa\u91d1" +LISTED_STATUS = "\u4e0a\u5e02" + +# The mapping is a test suitability reference, not an authoritative product +# rating. Eastmoney's public company catalogue has product type and size but +# does not expose a channel-independent R1-R5 suitability rating. +EXCHANGE_PRODUCT_REFERENCE: dict[str, tuple[str, str, str]] = { + "511810": ("SSE", "ETF", "R1"), + "511070": ("SSE", "ETF", "R2"), + "159700": ("SZSE", "ETF", "R2"), + "160128": ("SZSE", "LOF", "R2"), + "160129": ("SZSE", "LOF", "R2"), + "160105": ("SZSE", "LOF", "R3"), + "160142": ("SZSE", "LOF", "R3"), + "160143": ("SZSE", "LOF", "R3"), + "501062": ("SSE", "LOF", "R3"), + "510500": ("SSE", "ETF", "R4"), + "501018": ("SSE", "LOF", "R5"), + "159329": ("SZSE", "ETF", "R5"), + "159615": ("SZSE", "ETF", "R5"), + "159687": ("SZSE", "ETF", "R5"), + "160127": ("SZSE", "LOF", "R4"), + "588890": ("SSE", "ETF", "R4"), + "159382": ("SZSE", "ETF", "R4"), + "159511": ("SZSE", "ETF", "R4"), + "159948": ("SZSE", "ETF", "R4"), +} +# The platform only imports exchange-traded products. Keep the offsite +# reference path empty so routine refreshes never add offsite funds. +OFFSITE_R1_PRODUCT_CODES: tuple[str, ...] = () +RETIRED_TEST_PRODUCT_CODES = ("589700",) +MIN_ASSET_SCALE_BILLION = Decimal("1") +REFERENCE_SOURCE = "eastmoney_nanfang_catalog" +RISK_MAPPING_VERSION = "test-v1" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Import hq.py exchange-traded test products") + parser.add_argument( + "--dry-run", action="store_true", help="Fetch and validate data without writing MySQL" + ) + return parser.parse_args() + + +def mysql_connection() -> pymysql.Connection: + from app.core.config import get_settings + + parsed = urlparse(get_settings().mysql_dsn.replace("mysql+asyncmy://", "mysql+pymysql://")) + return pymysql.connect( + host=parsed.hostname or "127.0.0.1", + port=parsed.port or 3306, + user=unquote(parsed.username or ""), + password=unquote(parsed.password or ""), + database=(parsed.path or "/").lstrip("/"), + charset="utf8mb4", + autocommit=False, + ) + + +def decimal_value(value: Any) -> Decimal | None: + if value in (None, "", "-"): + return None + try: + result = Decimal(str(value).strip().removesuffix("%")) + except (InvalidOperation, ValueError): + return None + return result if result > 0 else None + + +def non_negative_decimal_value(value: Any) -> Decimal | None: + if value in (None, "", "-"): + return None + try: + result = Decimal(str(value).strip().removesuffix("%").replace(",", "")) + except (InvalidOperation, ValueError): + return None + return result if result >= 0 else None + + +def nav_datetime(value: Any, fallback: datetime) -> datetime: + try: + nav_date = date.fromisoformat(str(value)) + except (TypeError, ValueError): + return fallback + return datetime.combine(nav_date, time(15, 0)) + + +def load_validated_rows() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + import hq + + codes = [*EXCHANGE_PRODUCT_REFERENCE, *OFFSITE_R1_PRODUCT_CODES] + catalog = { + str(row["fund_code"]): row + for row in hq.get_southern_fund_catalog(codes) + if isinstance(row, dict) and isinstance(row.get("fund_code"), str) + } + exchange_rows: list[dict[str, Any]] = [] + for code, (exchange_code, product_category, risk_level) in EXCHANGE_PRODUCT_REFERENCE.items(): + reference = catalog.get(code, {}) + product_name = str(reference.get("fund_name") or "").strip() + nav = decimal_value(reference.get("nav")) + scale = non_negative_decimal_value(reference.get("fund_asset_scale_billion")) + fund_type = str(reference.get("fund_type") or "").strip() + scale_as_of_date = reference.get("scale_as_of_date") + if ( + not product_name + or nav is None + or scale is None + or (risk_level != "R1" and scale < MIN_ASSET_SCALE_BILLION) + ): + print(f"skip code={code}: missing name, NAV, or scale below 1 billion CNY") + continue + if not fund_type or not isinstance(scale_as_of_date, str): + print(f"skip code={code}: missing fund type or asset scale date") + continue + exchange_rows.append( + { + "product_code": code, + "product_name": product_name, + "exchange_code": exchange_code, + "product_category": product_category, + "risk_level": risk_level, + "current_nav": nav, + "current_nav_at": nav_datetime(reference.get("nav_date"), datetime.now(UTC).replace(tzinfo=None)), + "fund_type": fund_type, + "fund_asset_scale_billion": scale, + "scale_as_of_date": date.fromisoformat(scale_as_of_date), + } + ) + offsite_rows: list[dict[str, Any]] = [] + for code in OFFSITE_R1_PRODUCT_CODES: + reference = catalog.get(code, {}) + product_name = str(reference.get("fund_name") or "").strip() + nav = decimal_value(reference.get("nav")) + fund_type = str(reference.get("fund_type") or "").strip() + scale_as_of_date = reference.get("scale_as_of_date") + if not product_name or nav is None or not fund_type: + print(f"skip offsite code={code}: missing name, NAV, or fund type") + continue + offsite_rows.append( + { + "fund_code": code, + "fund_name": product_name, + "fund_type": fund_type, + "risk_level": "R1", + "current_nav": nav, + "current_nav_at": nav_datetime( + reference.get("nav_date"), datetime.now(UTC).replace(tzinfo=None) + ), + "fund_asset_scale_billion": non_negative_decimal_value( + reference.get("fund_asset_scale_billion") + ), + "scale_as_of_date": ( + date.fromisoformat(scale_as_of_date) + if isinstance(scale_as_of_date, str) + else None + ), + } + ) + return exchange_rows, offsite_rows + + +def import_rows( + rows: list[dict[str, Any]], offsite_rows: list[dict[str, Any]], *, dry_run: bool +) -> tuple[int, int, int, int, int]: + if dry_run: + return 0, 0, 0, 0, 0 + now = datetime.now(UTC).replace(tzinfo=None) + connection = mysql_connection() + created = 0 + updated = 0 + snapshots = 0 + retired = 0 + offsite_imported = 0 + try: + with connection.cursor() as cursor: + cursor.execute("SELECT COALESCE(MAX(id), 0) FROM fin_product") + max_id = int(cursor.fetchone()[0]) + next_id = max(TEST_PRODUCT_ID_START, max_id + 1) + placeholders = ",".join("%s" for _ in RETIRED_TEST_PRODUCT_CODES) + cursor.execute( + f"UPDATE fin_product SET status=%s, updated_at=%s WHERE product_code IN ({placeholders})", + ("\u505c\u724c", now, *RETIRED_TEST_PRODUCT_CODES), + ) + retired = cursor.rowcount + for row in rows: + cursor.execute( + "SELECT id FROM fin_product WHERE product_code=%s", + (row["product_code"],), + ) + existing = cursor.fetchone() + values = {**row, "updated_at": now} + if existing: + product_id = int(existing[0]) + cursor.execute( + """ + UPDATE fin_product + SET product_name=%(product_name)s, exchange_code=%(exchange_code)s, + product_category=%(product_category)s, risk_level=%(risk_level)s, + fund_manager=%(fund_manager)s, current_nav=%(current_nav)s, + current_nav_at=%(current_nav_at)s, status=%(status)s, + updated_at=%(updated_at)s + WHERE id=%(id)s + """, + {**values, "id": product_id, "fund_manager": FUND_MANAGER, "status": LISTED_STATUS}, + ) + updated += 1 + else: + product_id = next_id + cursor.execute( + """ + INSERT INTO fin_product ( + id, product_code, product_name, exchange_code, product_category, + risk_level, fund_manager, currency, lot_size, price_tick, current_nav, + current_nav_at, min_amount, risk_disclosure_required, + second_confirmation_required, recording_required, status, created_at, updated_at + ) VALUES ( + %(id)s, %(product_code)s, %(product_name)s, %(exchange_code)s, + %(product_category)s, %(risk_level)s, %(fund_manager)s, 'CNY', + 100, 0.001, %(current_nav)s, %(current_nav_at)s, 0, 0, 0, 0, + %(status)s, %(created_at)s, %(updated_at)s + ) + """, + { + **values, + "id": product_id, + "fund_manager": FUND_MANAGER, + "status": LISTED_STATUS, + "created_at": now, + }, + ) + next_id += 1 + created += 1 + cursor.execute( + """ + INSERT INTO advisor_product_reference_snapshot ( + product_id, as_of_date, fund_type, fund_asset_scale_billion, source, + risk_mapping_version, created_at, updated_at + ) VALUES ( + %(product_id)s, %(as_of_date)s, %(fund_type)s, + %(fund_asset_scale_billion)s, %(source)s, + %(risk_mapping_version)s, %(created_at)s, %(updated_at)s + ) ON DUPLICATE KEY UPDATE + fund_type=VALUES(fund_type), + fund_asset_scale_billion=VALUES(fund_asset_scale_billion), + source=VALUES(source), + risk_mapping_version=VALUES(risk_mapping_version), + updated_at=VALUES(updated_at) + """, + { + **row, + "product_id": product_id, + "as_of_date": row["scale_as_of_date"], + "source": REFERENCE_SOURCE, + "risk_mapping_version": RISK_MAPPING_VERSION, + "created_at": now, + "updated_at": now, + }, + ) + snapshots += 1 + for row in offsite_rows: + cursor.execute( + """ + INSERT INTO advisor_offsite_fund_reference ( + fund_code, fund_name, fund_type, risk_level, current_nav, current_nav_at, + fund_asset_scale_billion, scale_as_of_date, source, risk_mapping_version, + status, created_at, updated_at + ) VALUES ( + %(fund_code)s, %(fund_name)s, %(fund_type)s, %(risk_level)s, + %(current_nav)s, %(current_nav_at)s, %(fund_asset_scale_billion)s, + %(scale_as_of_date)s, %(source)s, %(risk_mapping_version)s, + %(status)s, %(created_at)s, %(updated_at)s + ) ON DUPLICATE KEY UPDATE + fund_name=VALUES(fund_name), + fund_type=VALUES(fund_type), + risk_level=VALUES(risk_level), + current_nav=VALUES(current_nav), + current_nav_at=VALUES(current_nav_at), + fund_asset_scale_billion=VALUES(fund_asset_scale_billion), + scale_as_of_date=VALUES(scale_as_of_date), + source=VALUES(source), + risk_mapping_version=VALUES(risk_mapping_version), + status=VALUES(status), + updated_at=VALUES(updated_at) + """, + { + **row, + "source": REFERENCE_SOURCE, + "risk_mapping_version": RISK_MAPPING_VERSION, + "status": LISTED_STATUS, + "created_at": now, + "updated_at": now, + }, + ) + offsite_imported += 1 + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + return created, updated, snapshots, retired, offsite_imported + + +def main() -> None: + args = parse_args() + rows, offsite_rows = load_validated_rows() + if not rows or len(offsite_rows) != len(OFFSITE_R1_PRODUCT_CODES): + raise SystemExit("No verified hq.py products were returned; no database changes were made.") + for row in rows: + print( + f"ready code={row['product_code']} name={row['product_name']} " + f"nav={row['current_nav']} exchange={row['exchange_code']}" + ) + for row in offsite_rows: + print(f"ready offsite code={row['fund_code']} name={row['fund_name']} nav={row['current_nav']}") + created, updated, snapshots, retired, offsite_imported = import_rows( + rows, offsite_rows, dry_run=args.dry_run + ) + if args.dry_run: + print(f"dry run complete: validated={len(rows)}, database unchanged") + else: + print( + f"import complete: created={created}, updated={updated}, snapshots={snapshots}, " + f"retired={retired}, offsite_imported={offsite_imported}, total={len(rows)}" + ) + print("latest NAV caches were refreshed; no historical market rows were written") + + +if __name__ == "__main__": + main() diff --git a/tools/import_product_asset_classifications.py b/tools/import_product_asset_classifications.py new file mode 100644 index 0000000..3a8ed3e --- /dev/null +++ b/tools/import_product_asset_classifications.py @@ -0,0 +1,154 @@ +# ruff: noqa: E402 + +"""Import contract-backed ETF asset classifications for dynamic allocation. + +This command only writes the additive ``advisor_product_asset_classification`` +reference table. It derives each class from a verified, currently effective +Southern Fund contract snapshot and deliberately skips contracts that cannot +be mapped to one of the three allocation buckets with clear evidence. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from datetime import UTC, date, datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from tools.import_product_governance_reference import mysql_connection + +SOUTHERN_FUND_MANAGER = "南方基金" +LISTED_STATUS = "上市" +SOURCE = "nffund_verified_contract_classification_v1" +STATUS = "active" + + +@dataclass(frozen=True) +class ContractClassificationCandidate: + product_id: int + product_code: str + fund_type: str + investment_scope: str + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Import verified Southern Fund contract-backed asset classifications" + ) + parser.add_argument("--dry-run", action="store_true", help="Report classifications only") + return parser.parse_args() + + +def classify_contract(fund_type: str, investment_scope: str) -> str | None: + """Return a supported asset bucket only where official contract facts support it.""" + normalized_type = fund_type.strip() + normalized_scope = re.sub(r"\s+", "", investment_scope) + if "货币" in normalized_type: + return "cash_management_etf" + if "债券" in normalized_type: + return "bond_etf" + if "股票" in normalized_type or "指数" in normalized_type: + return "equity_etf" + if "混合" in normalized_type and "股票" in normalized_scope: + return "equity_etf" + return None + + +def candidates(as_of_date: date) -> list[ContractClassificationCandidate]: + connection = mysql_connection() + try: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT p.id, p.product_code, c.fund_type, c.investment_scope + FROM fin_product p + JOIN advisor_product_contract_snapshot c ON c.product_id = p.id + WHERE p.fund_manager=%s + AND p.status=%s + AND p.product_category IN ('ETF', 'LOF') + AND c.review_status='verified' + AND c.effective_from <= %s + AND (c.effective_until IS NULL OR c.effective_until >= %s) + ORDER BY p.product_code, c.effective_from DESC, c.id DESC + """, + (SOUTHERN_FUND_MANAGER, LISTED_STATUS, as_of_date, as_of_date), + ) + latest: dict[int, ContractClassificationCandidate] = {} + for product_id, product_code, fund_type, investment_scope in cursor.fetchall(): + latest.setdefault( + int(product_id), + ContractClassificationCandidate( + product_id=int(product_id), + product_code=str(product_code), + fund_type=str(fund_type), + investment_scope=str(investment_scope), + ), + ) + return list(latest.values()) + finally: + connection.close() + + +def import_classifications( + rows: list[ContractClassificationCandidate], *, as_of_date: date, dry_run: bool +) -> tuple[list[tuple[str, str]], list[str]]: + classified: list[tuple[ContractClassificationCandidate, str]] = [] + skipped: list[str] = [] + for row in rows: + asset_class = classify_contract(row.fund_type, row.investment_scope) + if asset_class is None: + skipped.append(f"{row.product_code} ({row.fund_type})") + else: + classified.append((row, asset_class)) + report = [(row.product_code, asset_class) for row, asset_class in classified] + if dry_run: + return report, skipped + + now = datetime.now(UTC).replace(tzinfo=None) + connection = mysql_connection() + try: + with connection.cursor() as cursor: + for row, asset_class in classified: + cursor.execute( + """ + INSERT INTO advisor_product_asset_classification ( + product_id, as_of_date, asset_class, source, status, created_at, updated_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + asset_class=VALUES(asset_class), + source=VALUES(source), + status=VALUES(status), + updated_at=VALUES(updated_at) + """, + (row.product_id, as_of_date, asset_class, SOURCE, STATUS, now, now), + ) + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + return report, skipped + + +def main() -> None: + args = parse_args() + as_of_date = date.today() + report, skipped = import_classifications( + candidates(as_of_date), as_of_date=as_of_date, dry_run=args.dry_run + ) + for product_code, asset_class in report: + print(f"classified {product_code} -> {asset_class}") + for product in skipped: + print(f"skipped {product}: no defensible allocation bucket") + action = "validated" if args.dry_run else "imported" + print(f"{action}: classifications={len(report)} skipped={len(skipped)}") + + +if __name__ == "__main__": + main() diff --git a/tools/import_product_governance_reference.py b/tools/import_product_governance_reference.py new file mode 100644 index 0000000..7f1826d --- /dev/null +++ b/tools/import_product_governance_reference.py @@ -0,0 +1,378 @@ +"""Import reviewed, source-linked product suitability and contract references. + +This command intentionally does not derive R1-R5 from product type, historical +volatility, or the baseline catalogue. Suitability classifications belong to a +specific sales institution and must come from that institution's disclosure. +""" + +from __future__ import annotations + +import argparse +import csv +import re +import sys +from datetime import UTC, date, datetime +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlparse + +import pymysql # type: ignore[import-untyped] + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +LISTED_STATUS = "\u4e0a\u5e02" +SOUTHERN_FUND_MANAGER = "\u5357\u65b9\u57fa\u91d1" +REVIEW_STATUSES = {"pending_review", "verified", "superseded", "test_only"} +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") +SUITABILITY_FIELDS = ( + "product_code", + "sales_institution", + "risk_level", + "effective_from", + "effective_until", + "source_url", + "document_title", + "document_published_at", + "document_sha256", + "source", + "review_status", + "verified_by", + "verified_at", +) +CONTRACT_FIELDS = ( + "product_code", + "effective_from", + "effective_until", + "fund_type", + "investment_scope", + "performance_benchmark", + "risk_return_characteristics", + "custodian_name", + "management_fee_rate_pct", + "custodian_fee_rate_pct", + "inception_date", + "source_url", + "document_title", + "document_published_at", + "document_sha256", + "source", + "review_status", + "verified_by", + "verified_at", +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Import source-linked advisory product governance data" + ) + parser.add_argument( + "--suitability", type=Path, help="UTF-8 CSV with sales suitability disclosures" + ) + parser.add_argument("--contracts", type=Path, help="UTF-8 CSV with fund-contract disclosures") + parser.add_argument( + "--dry-run", action="store_true", help="Validate rows without writing MySQL" + ) + args = parser.parse_args() + if args.suitability is None and args.contracts is None: + parser.error("provide --suitability and/or --contracts") + return args + + +def mysql_connection() -> pymysql.Connection: + from app.core.config import get_settings + + parsed = urlparse(get_settings().mysql_dsn.replace("mysql+asyncmy://", "mysql+pymysql://")) + return pymysql.connect( + host=parsed.hostname or "127.0.0.1", + port=parsed.port or 3306, + user=unquote(parsed.username or ""), + password=unquote(parsed.password or ""), + database=(parsed.path or "/").lstrip("/"), + charset="utf8mb4", + autocommit=False, + ) + + +def read_rows(path: Path, required_fields: tuple[str, ...]) -> list[dict[str, str]]: + with path.open("r", encoding="utf-8-sig", newline="") as source: + reader = csv.DictReader(source) + actual_fields = set(reader.fieldnames or ()) + missing = set(required_fields) - actual_fields + if missing: + raise ValueError(f"{path}: missing CSV columns {sorted(missing)}") + rows = [] + for line_number, raw in enumerate(reader, start=2): + row = {key: (raw.get(key) or "").strip() for key in required_fields} + if not any(row.values()): + continue + row["_line_number"] = str(line_number) + rows.append(row) + return rows + + +def optional_date(value: str, field: str, line_number: str) -> date | None: + if not value: + return None + try: + return date.fromisoformat(value) + except ValueError as exc: + raise ValueError(f"line {line_number}: {field} must be YYYY-MM-DD") from exc + + +def optional_datetime(value: str, field: str, line_number: str) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"line {line_number}: {field} must be ISO-8601") from exc + if parsed.tzinfo is None: + raise ValueError(f"line {line_number}: {field} must include timezone") + return parsed.astimezone(UTC).replace(tzinfo=None) + + +def optional_decimal(value: str, field: str, line_number: str) -> Decimal | None: + if not value: + return None + try: + parsed = Decimal(value) + except InvalidOperation as exc: + raise ValueError(f"line {line_number}: {field} must be a decimal") from exc + if parsed < 0: + raise ValueError(f"line {line_number}: {field} must not be negative") + return parsed + + +def validate_source_fields(row: dict[str, str]) -> None: + line_number = row["_line_number"] + if urlparse(row["source_url"]).scheme != "https": + raise ValueError(f"line {line_number}: source_url must be HTTPS") + if not SHA256_PATTERN.fullmatch(row["document_sha256"].lower()): + raise ValueError(f"line {line_number}: document_sha256 must be 64 hexadecimal characters") + if row["review_status"] not in REVIEW_STATUSES: + raise ValueError(f"line {line_number}: invalid review_status") + if row["review_status"] == "verified": + if not row["verified_by"] or not row["verified_at"]: + raise ValueError( + f"line {line_number}: verified rows require verified_by and verified_at" + ) + elif row["verified_by"] or row["verified_at"]: + raise ValueError(f"line {line_number}: only verified rows can have verification fields") + + +def normalize_suitability(rows: list[dict[str, str]]) -> list[dict[str, Any]]: + normalized = [] + for row in rows: + validate_source_fields(row) + line_number = row["_line_number"] + risk_level = row["risk_level"].upper() + if risk_level not in {"R1", "R2", "R3", "R4", "R5"}: + raise ValueError(f"line {line_number}: risk_level must be R1 through R5") + effective_from = optional_date(row["effective_from"], "effective_from", line_number) + effective_until = optional_date(row["effective_until"], "effective_until", line_number) + if effective_from is None: + raise ValueError(f"line {line_number}: effective_from is required") + if effective_until is not None and effective_until < effective_from: + raise ValueError(f"line {line_number}: effective_until precedes effective_from") + if not row["product_code"] or not row["sales_institution"]: + raise ValueError(f"line {line_number}: product_code and sales_institution are required") + normalized.append( + { + **row, + "risk_level": risk_level, + "effective_from": effective_from, + "effective_until": effective_until, + "document_published_at": optional_date( + row["document_published_at"], "document_published_at", line_number + ), + "verified_at": optional_datetime(row["verified_at"], "verified_at", line_number), + "document_sha256": row["document_sha256"].lower(), + } + ) + return normalized + + +def normalize_contracts(rows: list[dict[str, str]]) -> list[dict[str, Any]]: + normalized = [] + for row in rows: + validate_source_fields(row) + line_number = row["_line_number"] + effective_from = optional_date(row["effective_from"], "effective_from", line_number) + effective_until = optional_date(row["effective_until"], "effective_until", line_number) + if effective_from is None: + raise ValueError(f"line {line_number}: effective_from is required") + if effective_until is not None and effective_until < effective_from: + raise ValueError(f"line {line_number}: effective_until precedes effective_from") + for field in ( + "product_code", + "fund_type", + "investment_scope", + "risk_return_characteristics", + ): + if not row[field]: + raise ValueError(f"line {line_number}: {field} is required") + normalized.append( + { + **row, + "effective_from": effective_from, + "effective_until": effective_until, + "document_published_at": optional_date( + row["document_published_at"], "document_published_at", line_number + ), + "inception_date": optional_date( + row["inception_date"], "inception_date", line_number + ), + "management_fee_rate_pct": optional_decimal( + row["management_fee_rate_pct"], "management_fee_rate_pct", line_number + ), + "custodian_fee_rate_pct": optional_decimal( + row["custodian_fee_rate_pct"], "custodian_fee_rate_pct", line_number + ), + "verified_at": optional_datetime(row["verified_at"], "verified_at", line_number), + "document_sha256": row["document_sha256"].lower(), + } + ) + return normalized + + +def product_ids(cursor: Any, product_codes: set[str]) -> dict[str, int]: + if not product_codes: + return {} + placeholders = ", ".join(["%s"] * len(product_codes)) + cursor.execute( + f""" + SELECT id, product_code + FROM fin_product + WHERE product_code IN ({placeholders}) + AND fund_manager=%s AND status=%s AND product_category IN ('ETF', 'LOF') + """, + [*sorted(product_codes), SOUTHERN_FUND_MANAGER, LISTED_STATUS], + ) + result = {str(code): int(product_id) for product_id, code in cursor.fetchall()} + missing = product_codes - result.keys() + if missing: + raise ValueError( + f"only listed Southern Fund ETF/LOF products can be imported; missing={sorted(missing)}" + ) + return result + + +def insert_immutable( + cursor: Any, *, table: str, keys: tuple[str, ...], row: dict[str, Any], payload: dict[str, Any] +) -> bool: + where = " AND ".join(f"{key}=%s" for key in keys) + cursor.execute( + f"SELECT document_sha256 FROM {table} WHERE {where}", tuple(payload[key] for key in keys) + ) + existing = cursor.fetchone() + if existing is not None: + if str(existing[0]).lower() != str(payload["document_sha256"]).lower(): + raise ValueError( + f"line {row['_line_number']}: existing {table} version has a different document hash; " + "create a new effective_from version instead" + ) + return False + columns = ", ".join(payload) + placeholders = ", ".join(f"%({key})s" for key in payload) + cursor.execute(f"INSERT INTO {table} ({columns}) VALUES ({placeholders})", payload) + return True + + +def import_references( + suitability_rows: list[dict[str, Any]], contract_rows: list[dict[str, Any]], *, dry_run: bool +) -> tuple[int, int]: + if dry_run: + return len(suitability_rows), len(contract_rows) + now = datetime.now(UTC).replace(tzinfo=None) + connection = mysql_connection() + try: + with connection.cursor() as cursor: + references = [*suitability_rows, *contract_rows] + ids = product_ids(cursor, {str(row["product_code"]) for row in references}) + suitability_count = 0 + contract_count = 0 + for row in suitability_rows: + payload = { + "product_id": ids[row["product_code"]], + "sales_institution": row["sales_institution"], + "risk_level": row["risk_level"], + "effective_from": row["effective_from"], + "effective_until": row["effective_until"], + "source_url": row["source_url"], + "document_title": row["document_title"], + "document_published_at": row["document_published_at"], + "document_sha256": row["document_sha256"], + "source": row["source"], + "review_status": row["review_status"], + "verified_by": row["verified_by"] or None, + "verified_at": row["verified_at"], + "created_at": now, + "updated_at": now, + } + suitability_count += insert_immutable( + cursor, + table="advisor_product_suitability_reference", + keys=("product_id", "sales_institution", "effective_from"), + row=row, + payload=payload, + ) + for row in contract_rows: + payload = { + "product_id": ids[row["product_code"]], + "effective_from": row["effective_from"], + "effective_until": row["effective_until"], + "fund_type": row["fund_type"], + "investment_scope": row["investment_scope"], + "performance_benchmark": row["performance_benchmark"] or None, + "risk_return_characteristics": row["risk_return_characteristics"], + "custodian_name": row["custodian_name"] or None, + "management_fee_rate_pct": row["management_fee_rate_pct"], + "custodian_fee_rate_pct": row["custodian_fee_rate_pct"], + "inception_date": row["inception_date"], + "source_url": row["source_url"], + "document_title": row["document_title"], + "document_published_at": row["document_published_at"], + "document_sha256": row["document_sha256"], + "source": row["source"], + "review_status": row["review_status"], + "verified_by": row["verified_by"] or None, + "verified_at": row["verified_at"], + "created_at": now, + "updated_at": now, + } + contract_count += insert_immutable( + cursor, + table="advisor_product_contract_snapshot", + keys=("product_id", "effective_from"), + row=row, + payload=payload, + ) + connection.commit() + return suitability_count, contract_count + except Exception: + connection.rollback() + raise + finally: + connection.close() + + +def main() -> None: + args = parse_args() + suitability_rows = normalize_suitability( + read_rows(args.suitability, SUITABILITY_FIELDS) if args.suitability else [] + ) + contract_rows = normalize_contracts( + read_rows(args.contracts, CONTRACT_FIELDS) if args.contracts else [] + ) + suitability_count, contract_count = import_references( + suitability_rows, contract_rows, dry_run=args.dry_run + ) + action = "validated" if args.dry_run else "inserted" + print(f"{action}: suitability={suitability_count} contracts={contract_count}") + + +if __name__ == "__main__": + main() diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index 91b5ad3..e863501 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -53,10 +53,28 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( # 刻意不把 `knowledge:query`(客户角色也有)复用成管理权限:那会让客户看到整库文档清单。 # 归属:只给 admin(ADMIN_PERMISSIONS 是全量元组,自动包含本行)。 (9019, "knowledge:manage", "knowledge", "manage", "all"), + (9020, "investment-goal:write:self", "investment-goal", "write", "self"), + (9021, "investment-goal:confirm:self", "investment-goal", "confirm", "self"), + (9022, "investment-goal:read:self", "investment-goal", "read", "self"), + (9023, "product-recommendation:generate:self", "product-recommendation", "generate", "self"), + (9024, "product-recommendation:read:self", "product-recommendation", "read", "self"), + (9025, "portfolio-analysis:read:self", "portfolio-analysis", "read", "self"), + (9026, "asset-allocation:generate:self", "asset-allocation", "generate", "self"), + (9027, "profile-governance:read", "profile-governance", "read", "all"), + (9028, "profile-governance:review", "profile-governance", "review", "all"), + (9029, "investment-goal:review", "investment-goal", "review", "all"), + (9030, "investment-goal:publish", "investment-goal", "publish", "all"), + (9031, "product-recommendation:review", "product-recommendation", "review", "all"), + (9032, "product-recommendation:publish", "product-recommendation", "publish", "all"), + (9033, "asset-allocation:backtest", "asset-allocation", "backtest", "all"), + (9034, "product-comparison:read:self", "product-comparison", "read", "self"), ) # 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。 -CUSTOMER_PERMISSIONS = (9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011, 9018) +CUSTOMER_PERMISSIONS = ( + 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011, 9018, + 9020, 9021, 9022, 9023, 9024, 9025, 9026, 9034, +) # 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。 RISK_PERMISSIONS = (9001, 9002, 9003, 9010, 9011, 9012) # 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计)。 diff --git a/tools/sync_advisor_market_data.py b/tools/sync_advisor_market_data.py new file mode 100644 index 0000000..a24a8c5 --- /dev/null +++ b/tools/sync_advisor_market_data.py @@ -0,0 +1,172 @@ +"""Sync listed Southern Fund history and rebuild advisory data snapshots. + +The command writes only additive advisory tables. Baseline product and trading +tables remain read-only for this pipeline. +""" +# Imports intentionally follow the project-root path bootstrap below so the +# command works when invoked as ``python tools/sync_advisor_market_data.py``. +# ruff: noqa: E402 + +from __future__ import annotations + +import argparse +import asyncio +import sys +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from sqlalchemy import select +from sqlalchemy.dialects.mysql import insert + +from app.infrastructure.db import SessionFactory +from app.model.advisor_product import ( + AdvisorProductDataQualitySnapshot, + AdvisorProductMetricSnapshot, + AdvisorProductPriceHistory, +) +from app.model.fund import FundProduct +from app.service.product_history_sync_service import ProductHistorySyncService +from app.service.product_metric_service import ProductMetricService + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sync advisory listed-fund market data") + parser.add_argument("--days", type=int, default=400, help="Calendar days to refresh") + parser.add_argument("--limit", type=int, default=100, help="Maximum products") + parser.add_argument("--as-of-date", type=date.fromisoformat, default=date.today()) + return parser.parse_args() + + +def expected_trading_days(start: date, end: date) -> int: + return sum( + (start + timedelta(days=offset)).weekday() < 5 + for offset in range((end - start).days + 1) + ) + + +async def rebuild_snapshots(*, start: date, end: date, limit: int) -> tuple[int, int]: + now = datetime.now(UTC).replace(tzinfo=None) + expected = expected_trading_days(start, end) + async with SessionFactory() as session: + products = list(await session.scalars( + select(FundProduct).where( + FundProduct.fund_manager == "南方基金", + FundProduct.status == "上市", + ).order_by(FundProduct.id).limit(limit) + )) + + metric_rows: list[dict[str, object]] = [] + quality_rows: list[dict[str, object]] = [] + async with SessionFactory() as session: + for product in products: + history = list(await session.scalars( + select(AdvisorProductPriceHistory).where( + AdvisorProductPriceHistory.product_id == product.id, + AdvisorProductPriceHistory.trade_date >= start, + AdvisorProductPriceHistory.trade_date <= end, + AdvisorProductPriceHistory.price_kind == "fund_nav", + ).order_by(AdvisorProductPriceHistory.trade_date) + )) + snapshot = ProductMetricService.snapshot(product.id, history) + if snapshot is None: + continue + metric_rows.append({ + "product_id": snapshot.product_id, + "as_of_date": snapshot.as_of_date, + "trailing_20d_return_pct": snapshot.trailing_20d_return_pct, + "trailing_120d_return_pct": snapshot.trailing_120d_return_pct, + "annualized_volatility_pct": snapshot.annualized_volatility_pct, + "max_drawdown_pct": snapshot.max_drawdown_pct, + "average_daily_turnover_amount": snapshot.average_daily_turnover_amount, + "observation_count": snapshot.observation_count, + "source": snapshot.source, + "calculation_version": snapshot.calculation_version, + "created_at": snapshot.created_at, + }) + turnover_count = sum(item.turnover_amount is not None for item in history) + observation_count = len(history) + price_coverage = min( + Decimal("100"), Decimal(observation_count) * 100 / max(1, expected) + ).quantize(Decimal("0.0001")) + turnover_coverage = min( + Decimal("100"), Decimal(turnover_count) * 100 / max(1, expected) + ).quantize(Decimal("0.0001")) + reasons: list[str] = [] + if observation_count < 20: + reasons.append("INSUFFICIENT_OBSERVATIONS") + if price_coverage < 80: + reasons.append("LOW_PRICE_COVERAGE") + if turnover_coverage < 80: + reasons.append("LOW_TURNOVER_COVERAGE") + daily_returns = [ + abs((current.close_price / previous.close_price - 1) * 100) + for previous, current in zip(history, history[1:], strict=False) + if previous.close_price > 0 + ] + quality_rows.append({ + "product_id": product.id, + "as_of_date": end, + "observation_count": observation_count, + "expected_trading_days": expected, + "price_coverage_pct": price_coverage, + "turnover_coverage_pct": turnover_coverage, + "max_abs_daily_return_pct": ( + max(daily_returns).quantize(Decimal("0.0001")) if daily_returns else None + ), + "status": "accepted" if not reasons else "rejected", + "reason_codes": reasons, + "rule_version": "v1", + "created_at": now, + }) + + async with SessionFactory() as session, session.begin(): + if metric_rows: + statement = insert(AdvisorProductMetricSnapshot).values(metric_rows) + await session.execute(statement.on_duplicate_key_update( + trailing_20d_return_pct=statement.inserted.trailing_20d_return_pct, + trailing_120d_return_pct=statement.inserted.trailing_120d_return_pct, + annualized_volatility_pct=statement.inserted.annualized_volatility_pct, + max_drawdown_pct=statement.inserted.max_drawdown_pct, + average_daily_turnover_amount=statement.inserted.average_daily_turnover_amount, + observation_count=statement.inserted.observation_count, + source=statement.inserted.source, + calculation_version=statement.inserted.calculation_version, + created_at=statement.inserted.created_at, + )) + if quality_rows: + statement = insert(AdvisorProductDataQualitySnapshot).values(quality_rows) + await session.execute(statement.on_duplicate_key_update( + observation_count=statement.inserted.observation_count, + expected_trading_days=statement.inserted.expected_trading_days, + price_coverage_pct=statement.inserted.price_coverage_pct, + turnover_coverage_pct=statement.inserted.turnover_coverage_pct, + max_abs_daily_return_pct=statement.inserted.max_abs_daily_return_pct, + status=statement.inserted.status, + reason_codes=statement.inserted.reason_codes, + rule_version=statement.inserted.rule_version, + created_at=statement.inserted.created_at, + )) + return len(metric_rows), sum(item["status"] == "accepted" for item in quality_rows) + + +async def run(args: argparse.Namespace) -> None: + end = args.as_of_date + start = end - timedelta(days=max(1, args.days)) + result = await ProductHistorySyncService().sync( + days=args.days, limit=args.limit, as_of_date=end + ) + metric_count, accepted_count = await rebuild_snapshots( + start=start, end=end, limit=args.limit + ) + print( + f"history products={result.product_count} observations={result.observation_count}; " + f"metrics={metric_count} quality_accepted={accepted_count}" + ) + + +if __name__ == "__main__": + asyncio.run(run(parse_args())) diff --git a/tools/sync_advisor_market_quotes.py b/tools/sync_advisor_market_quotes.py new file mode 100644 index 0000000..694039c --- /dev/null +++ b/tools/sync_advisor_market_quotes.py @@ -0,0 +1,40 @@ +"""Refresh listed-fund quotes through the dual-source health pipeline.""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.service.market_quote_sync_service import MarketQuoteSyncService # noqa: E402 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sync listed Southern Fund market quotes") + parser.add_argument( + "--product-code", action="append", dest="product_codes", + help="Limit sync to a product code; can be repeated", + ) + return parser.parse_args() + + +async def run(args: argparse.Namespace) -> None: + codes = tuple(args.product_codes) if args.product_codes else None + result = await MarketQuoteSyncService().sync(product_codes=codes) + print( + f"sync_run_no={result['sync_run_no']} requested={result['requested_count']} " + f"quotes={result['quote_count']} degraded={result['degraded']}" + ) + for source in result["sources"]: + print( + f"source={source['source']} status={source['status']} " + f"quotes={source['quote_count']} error={source['error_type']}" + ) + + +if __name__ == "__main__": + asyncio.run(run(parse_args())) diff --git a/tools/sync_nanfang_official_product_governance.py b/tools/sync_nanfang_official_product_governance.py new file mode 100644 index 0000000..f3ffb9f --- /dev/null +++ b/tools/sync_nanfang_official_product_governance.py @@ -0,0 +1,277 @@ +"""Synchronize official Southern Fund risk ratings and fund-contract evidence. + +The source is Southern Fund's public direct-sales product detail page. Its +``fundRiskRating`` field states that the rating is from Southern Fund itself. +Contract facts are taken from the same page and linked to the original fund +contract file returned by Southern Fund's legal-document directory. +""" + +# ruff: noqa: E402 + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from datetime import UTC, date, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any +from urllib.parse import urljoin + +import httpx + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from tools.import_product_governance_reference import ( + import_references, + normalize_contracts, + normalize_suitability, + mysql_connection, + product_ids, +) + +OFFICIAL_BASE_URL = "https://www.nffund.com" +DETAIL_ENDPOINT = f"{OFFICIAL_BASE_URL}/nfwebApi/fund/overreview" +LEGAL_DOCUMENTS_ENDPOINT = f"{OFFICIAL_BASE_URL}/nfwebApi/notice/legalDocuments" +SALES_INSTITUTION = ( + "\u5357\u65b9\u57fa\u91d1\u7ba1\u7406\u80a1\u4efd\u6709\u9650\u516c\u53f8\u76f4\u9500" +) +SOURCE = "nffund_official_direct_sales" +VERIFIED_BY = "system:official-source-sync" +RISK_PATTERN = re.compile(r"R([1-5])") +CONTRACT_EXCLUSIONS = ( + "\u6258\u7ba1\u534f\u8bae", + "\u751f\u6548\u516c\u544a", + "\u62db\u52df\u8bf4\u660e\u4e66", +) +HEADERS = {"User-Agent": "NailongFund/1.0", "Referer": f"{OFFICIAL_BASE_URL}/"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Sync official Southern Fund direct-sales suitability and contracts" + ) + parser.add_argument( + "--dry-run", action="store_true", help="Fetch and validate without MySQL writes" + ) + parser.add_argument( + "--timeout", type=float, default=30.0, help="HTTP request timeout in seconds" + ) + return parser.parse_args() + + +def listed_product_codes() -> list[str]: + connection = mysql_connection() + try: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT product_code + FROM fin_product + WHERE fund_manager=%s AND status=%s AND product_category IN ('ETF', 'LOF') + ORDER BY product_code + """, + ("\u5357\u65b9\u57fa\u91d1", "\u4e0a\u5e02"), + ) + return [str(row[0]) for row in cursor.fetchall()] + finally: + connection.close() + + +def date_value(value: object, *, fallback: date) -> str: + text = str(value or "").strip() + for pattern in ("%Y%m%d", "%Y-%m-%d"): + try: + return datetime.strptime(text[:10], pattern).date().isoformat() + except ValueError: + pass + return fallback.isoformat() + + +def source_hash(value: object) -> str: + payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def risk_level(value: object, product_code: str) -> str: + match = RISK_PATTERN.search(str(value or "")) + if match is None: + raise ValueError(f"{product_code}: Southern Fund did not disclose an R1-R5 risk rating") + return f"R{match.group(1)}" + + +def contract_document(documents: list[dict[str, Any]], product_code: str) -> dict[str, Any]: + candidates = [ + item + for item in documents + if "\u57fa\u91d1\u5408\u540c" in str(item.get("title") or "") + and not any(token in str(item.get("title") or "") for token in CONTRACT_EXCLUSIONS) + and item.get("linkUrl") + ] + if not candidates: + raise ValueError(f"{product_code}: no official fund contract document was found") + return max( + candidates, + key=lambda item: (str(item.get("publishTime") or ""), str(item.get("id") or "")), + ) + + +def collect_official_rows( + product_code: str, + overview: dict[str, Any], + documents: list[dict[str, Any]], + contract_bytes: bytes, + *, + synced_at: datetime, +) -> tuple[dict[str, str], dict[str, str]]: + data = overview.get("data") + if overview.get("code") != "ETS-5BP00000" or not isinstance(data, dict): + raise ValueError(f"{product_code}: official product detail request failed") + fund_info = data.get("fund_info") + risk = data.get("fundRiskRating") + if not isinstance(fund_info, dict) or not isinstance(risk, dict): + raise ValueError(f"{product_code}: official detail response is incomplete") + rating_text = str(risk.get("RISKRATING") or "").strip() + level = risk_level(rating_text, product_code) + synced_date = synced_at.date() + detail_url = f"{OFFICIAL_BASE_URL}/new/personal-financing/detail.html?fundCode={product_code}" + detail_evidence = { + "fund_code": product_code, + "fund_name": fund_info.get("fundName"), + "risk_rating": rating_text, + "source": "Southern Fund direct-sales product detail", + } + document = contract_document(documents, product_code) + contract_url = urljoin(OFFICIAL_BASE_URL, str(document["linkUrl"])) + document_title = str(document["title"]).strip() + verification_time = synced_at.astimezone(UTC).isoformat() + contract_effective_from = date_value(fund_info.get("contractValidDate"), fallback=synced_date) + contract_published_at = date_value( + document.get("createTimeString") or document.get("publishTime"), fallback=synced_date + ) + suitability_row = { + "product_code": product_code, + "sales_institution": SALES_INSTITUTION, + "risk_level": level, + "effective_from": synced_date.isoformat(), + "effective_until": "", + "source_url": detail_url, + "document_title": f"\u5357\u65b9\u57fa\u91d1\u76f4\u9500\u4ea7\u54c1\u8be6\u60c5\u9875\uff1a{fund_info.get('fundName', product_code)}", + "document_published_at": "", + "document_sha256": source_hash(detail_evidence), + "source": SOURCE, + "review_status": "verified", + "verified_by": VERIFIED_BY, + "verified_at": verification_time, + "_line_number": product_code, + } + contract_row = { + "product_code": product_code, + "effective_from": contract_effective_from, + "effective_until": "", + "fund_type": str(fund_info.get("basedetailType") or "").strip(), + "investment_scope": str(fund_info.get("tzfw") or "").strip(), + "performance_benchmark": str(fund_info.get("yjbjjz") or "").strip(), + "risk_return_characteristics": rating_text, + "custodian_name": str(fund_info.get("jjtgr") or "").strip(), + "management_fee_rate_pct": decimal_text(fund_info.get("glYearRatio")), + "custodian_fee_rate_pct": decimal_text(fund_info.get("tgYearRatio")), + "inception_date": date_value(fund_info.get("fundDate"), fallback=synced_date), + "source_url": contract_url, + "document_title": document_title, + "document_published_at": contract_published_at, + "document_sha256": hashlib.sha256(contract_bytes).hexdigest(), + "source": SOURCE, + "review_status": "verified", + "verified_by": VERIFIED_BY, + "verified_at": verification_time, + "_line_number": product_code, + } + return suitability_row, contract_row + + +def decimal_text(value: object) -> str: + if value in (None, ""): + return "" + return str(Decimal(str(value))) + + +def official_documents(client: httpx.Client, product_code: str) -> list[dict[str, Any]]: + response = client.post( + LEGAL_DOCUMENTS_ENDPOINT, + data={ + "fundCode": product_code, + "typeNo": 1, + "tabsid": "newgg", + "curPage": 1, + "pageSize": 100, + }, + ) + response.raise_for_status() + payload = response.json() + data = payload.get("data") if isinstance(payload, dict) else None + rows = data.get("list") if isinstance(data, dict) else None + if payload.get("code") != "ETS-5BP00000" or not isinstance(rows, list): + raise ValueError(f"{product_code}: official legal-document directory request failed") + return [item for item in rows if isinstance(item, dict)] + + +def sync(*, dry_run: bool, timeout: float) -> tuple[int, int]: + suitability_rows, contract_rows = collect_rows(timeout=timeout) + normalized_suitability = normalize_suitability(suitability_rows) + normalized_contracts = normalize_contracts(contract_rows) + if not dry_run: + product_codes = [str(row["product_code"]) for row in suitability_rows] + connection = mysql_connection() + try: + with connection.cursor() as cursor: + eligible = product_ids(cursor, set(product_codes)) + if len(eligible) != len(product_codes): + raise ValueError("database product scope changed during synchronization") + finally: + connection.close() + return import_references(normalized_suitability, normalized_contracts, dry_run=dry_run) + + +def collect_rows(*, timeout: float) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + """Fetch official evidence without writing it, for monitoring and review workflows.""" + product_codes = listed_product_codes() + if not product_codes: + raise ValueError("no listed Southern Fund ETF/LOF products exist in fin_product") + synced_at = datetime.now(UTC) + suitability_rows: list[dict[str, str]] = [] + contract_rows: list[dict[str, str]] = [] + with httpx.Client(headers=HEADERS, timeout=timeout, follow_redirects=True) as client: + for product_code in product_codes: + overview_response = client.post(DETAIL_ENDPOINT, data={"fundCode": product_code}) + overview_response.raise_for_status() + overview = overview_response.json() + document = contract_document(official_documents(client, product_code), product_code) + contract_url = urljoin(OFFICIAL_BASE_URL, str(document["linkUrl"])) + contract_response = client.get(contract_url) + contract_response.raise_for_status() + suitability, contract = collect_official_rows( + product_code, + overview, + [document], + contract_response.content, + synced_at=synced_at, + ) + suitability_rows.append(suitability) + contract_rows.append(contract) + return suitability_rows, contract_rows + + +def main() -> None: + args = parse_args() + suitability_count, contract_count = sync(dry_run=args.dry_run, timeout=args.timeout) + action = "validated" if args.dry_run else "synchronized" + print(f"{action}: suitability={suitability_count} contracts={contract_count}") + + +if __name__ == "__main__": + main()