diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 833faab..cd5ddca 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -8,7 +8,10 @@ **2026-09-12 · 基金转换 T+1**(分支 **`integrate/risk-convert`** · `risk-control-agent` → `merger` 方案 A · 待合入 `merger`) - [x] merge 冲突解完(15 文件)· `main.py` 保留平台/问数/顾问 + convert 路由 · `core_ro` 并集 + 顾问行情别名 -- [ ] 本机 `reset.ps1` 灌 Core convert 种子 · 全量 pytest 回归 · fast-forward **`merger`** +- [x] fast-forward **`merger`** → `03fce88`(`integrate/risk-convert`) +- [x] 本机 Core convert 种子(`verify_convert_seed.py` + nav-history 可选)· `verify_convert_api.py` 9/9 +- [x] 前端:`/app/risk/convert` + `web/src/api/convert.ts` +- [ ] 全量 pytest 回归 **2026-09-12 · 投资顾问 Agent 合并**(分支 **`integrate/advisor-agent`** · 目标合入 **`merger`**) diff --git a/scripts/agent/migrate-risk-convert-detail.sql b/scripts/agent/migrate-risk-convert-detail.sql new file mode 100644 index 0000000..5d189c8 --- /dev/null +++ b/scripts/agent/migrate-risk-convert-detail.sql @@ -0,0 +1,24 @@ +USE jinrong_agent; + +CREATE TABLE IF NOT EXISTS risk_convert_detail ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + convert_group_id VARCHAR(64) NOT NULL, + status ENUM('pending','completed','failed','cancelled','expired') NOT NULL DEFAULT 'pending', + out_trade_id VARCHAR(64) NULL, + in_trade_id VARCHAR(64) NULL, + related_trade_id VARCHAR(64) NULL, + nav DECIMAL(10,4) NULL, + nav_date DATE NULL, + fee_amount DECIMAL(18,2) NULL, + hold_days_min INT NULL, + hold_days_max INT NULL, + estimated TINYINT(1) NOT NULL DEFAULT 1, + nav_stale TINYINT(1) NOT NULL DEFAULT 0, + client_request_id VARCHAR(64) NULL, + cancelled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_group (convert_group_id), + UNIQUE KEY uk_idem (client_request_id), + KEY idx_out (out_trade_id), + KEY idx_in (in_trade_id) +) ENGINE=InnoDB COMMENT='基金转换详情(Agent 侧)'; diff --git a/scripts/core/06-seed-nav.sql b/scripts/core/06-seed-nav.sql index 50d0d67..6e994d7 100644 --- a/scripts/core/06-seed-nav.sql +++ b/scripts/core/06-seed-nav.sql @@ -1,18 +1,30 @@ -USE jinrong_core; +USE jinrong_core; + +-- 基金净值种子:**真实历史净值,非人造数值** +-- 生成方式:python scripts/core/fetch_nav.py --days 60 +-- 数据来源:天天基金(东方财富)公开接口 https://fund.eastmoney.com/pingzhongdata/{代码}.js +-- 抓取时间:2026-09-12 14:56:55 +-- 区间:2026-06-22 ~ 2026-09-11(共 60 个净值发布日) +-- +-- 产品 ↔ 净值来源映射(产品 ID 为虚构,数值借用下列真实基金,按类型/风险等级配对): +-- PROD-000002 <- 014437 鹏华中证同业存单AAA指数7天持有(bond/R1:同业存单,日波动 0.01%) +-- PROD-003095 <- 003095 中欧医疗健康混合A(stock/R4:医药主题,代码即真身) +-- PROD-005827 <- 005827 易方达蓝筹精选混合(mixed/R3:代码即真身,股债平衡) +-- PROD-005828 <- 001182 易方达安心回馈混合A(mixed/R2:低波混合,日波动 0.73%) +-- PROD-110022 <- 000191 富国信用债债券A/B(bond/R1:纯债,日波动 0.02%) +-- PROD-110023 <- 000032 易方达信用债债券A(bond/R2:信用债,日波动 0.09%) +-- PROD-161725 <- 161631 融通人工智能指数(LOF)A(stock/R4:科技主题,日波动 2.57%) +-- PROD-161726 <- 110022 易方达消费行业股票(stock/R4:消费主题,代码即真身) +-- PROD-510300 <- 510300 沪深300ETF华泰柏瑞(index/R3:宽基指数,代码即真身) +-- PROD-510500 <- 510500 中证500ETF南方(index/R4:宽基指数,代码即真身) +-- PROD-PRIV01 <- 519062 海富通阿尔法对冲混合A(private_fund/R4:量化对冲,日波动 0.33%) +-- PROD-WMG001 <- 004155 中信保诚至泰中短债A(wealth_mgmt/R2:中短债,日波动 0.02%) +-- PROD-XYZ999 <- 320007 诺安成长混合A(stock/R5:高波动成长,日波动 1.76%) +-- PROD-000001 <- 恒定 1.0000(现金宝货币(money/R1:货基单位净值恒为 1 元,收益以份额结转)) +-- +-- ⚠️ 真实净值只到抓取当日。之后运行需重跑 fetch_nav.py 增量补拉, +-- 否则 T+1 确认会取不到 T 日净值(真实业务同样是「净值 T+1 才公告」)。 --- 鍩洪噾鍑€鍊肩瀛愶細**鐪熷疄鍘嗗彶鍑€鍊硷紝闈炰汉閫犳暟鍊?* --- 鐢熸垚鏂瑰紡锛歱ython scripts/core/fetch_nav.py --days 60 --- 鏁版嵁鏉ユ簮锛氬ぉ澶╁熀閲戯紙涓滄柟璐㈠瘜锛夊叕寮€鎺ュ彛 https://fund.eastmoney.com/pingzhongdata/{浠g爜}.js --- 鎶撳彇鏃堕棿锛?026-09-12 14:56:55 --- 鍖洪棿锛?026-06-22 ~ 2026-09-11锛堝叡 60 涓噣鍊煎彂甯冩棩锛?-- --- 浜у搧 鈫?鍑€鍊兼潵婧愭槧灏勶紙浜у搧 ID 涓鸿櫄鏋勶紝鏁板€煎€熺敤涓嬪垪鐪熷疄鍩洪噾锛屾寜绫诲瀷/椋庨櫓绛夌骇閰嶅锛夛細 --- PROD-000002 <- 014437 楣忓崕涓瘉鍚屼笟瀛樺崟AAA鎸囨暟7澶╂寔鏈夛紙bond/R1锛氬悓涓氬瓨鍗曪紝鏃ユ尝鍔?0.01%锛?-- PROD-003095 <- 003095 涓鍖荤枟鍋ュ悍娣峰悎A锛坰tock/R4锛氬尰鑽富棰橈紝浠g爜鍗崇湡韬級 --- PROD-005827 <- 005827 鏄撴柟杈捐摑绛圭簿閫夋贩鍚堬紙mixed/R3锛氫唬鐮佸嵆鐪熻韩锛岃偂鍊哄钩琛★級 --- PROD-005828 <- 001182 鏄撴柟杈惧畨蹇冨洖棣堟贩鍚圓锛坢ixed/R2锛氫綆娉㈡贩鍚堬紝鏃ユ尝鍔?0.73%锛?-- PROD-110022 <- 000191 瀵屽浗淇$敤鍊哄€哄埜A/B锛坆ond/R1锛氱函鍊猴紝鏃ユ尝鍔?0.02%锛?-- PROD-110023 <- 000032 鏄撴柟杈句俊鐢ㄥ€哄€哄埜A锛坆ond/R2锛氫俊鐢ㄥ€猴紝鏃ユ尝鍔?0.09%锛?-- PROD-161725 <- 161631 铻嶉€氫汉宸ユ櫤鑳芥寚鏁?LOF)A锛坰tock/R4锛氱鎶€涓婚锛屾棩娉㈠姩 2.57%锛?-- PROD-161726 <- 110022 鏄撴柟杈炬秷璐硅涓氳偂绁紙stock/R4锛氭秷璐逛富棰橈紝浠g爜鍗崇湡韬級 --- PROD-510300 <- 510300 娌繁300ETF鍗庢嘲鏌忕憺锛坕ndex/R3锛氬鍩烘寚鏁帮紝浠g爜鍗崇湡韬級 --- PROD-510500 <- 510500 涓瘉500ETF鍗楁柟锛坕ndex/R4锛氬鍩烘寚鏁帮紝浠g爜鍗崇湡韬級 --- PROD-PRIV01 <- 519062 娴峰瘜閫氶樋灏旀硶瀵瑰啿娣峰悎A锛坧rivate_fund/R4锛氶噺鍖栧鍐诧紝鏃ユ尝鍔?0.33%锛?-- PROD-WMG001 <- 004155 涓俊淇濊瘹鑷虫嘲涓煭鍊篈锛坵ealth_mgmt/R2锛氫腑鐭€猴紝鏃ユ尝鍔?0.02%锛?-- PROD-XYZ999 <- 320007 璇哄畨鎴愰暱娣峰悎A锛坰tock/R5锛氶珮娉㈠姩鎴愰暱锛屾棩娉㈠姩 1.76%锛?-- PROD-000001 <- 鎭掑畾 1.0000锛堢幇閲戝疂璐у竵锛坢oney/R1锛氳揣鍩哄崟浣嶅噣鍊兼亽涓?1 鍏冿紝鏀剁泭浠ヤ唤棰濈粨杞級锛?-- --- 鈿狅笍 鐪熷疄鍑€鍊煎彧鍒版姄鍙栧綋鏃ャ€備箣鍚庤繍琛岄渶閲嶈窇 fetch_nav.py 澧為噺琛ユ媺锛?-- 鍚﹀垯 T+1 纭浼氬彇涓嶅埌 T 鏃ュ噣鍊硷紙鐪熷疄涓氬姟鍚屾牱鏄€屽噣鍊?T+1 鎵嶅叕鍛娿€嶏級銆? INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) VALUES ('PROD-000001', 1.0000, 0.0000, '2026-06-22'), ('PROD-000001', 1.0000, 0.0000, '2026-06-23'), diff --git a/web/src/App.tsx b/web/src/App.tsx index e3e392b..6cad92d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,6 +23,7 @@ import { ProductNavDetailPage } from './pages/market/ProductNavDetailPage' import { RiskAlertsPage } from './pages/risk/RiskAlertsPage' import { RiskChatPage } from './pages/risk/RiskChatPage' import { RiskSimulatePage } from './pages/risk/RiskSimulatePage' +import { RiskConvertPage } from './pages/risk/RiskConvertPage' import { RiskSuitabilityPage } from './pages/risk/RiskSuitabilityPage' import { RiskAmlScanPage } from './pages/risk/RiskAmlScanPage' import { ChatPanel } from './components/chat' @@ -109,6 +110,7 @@ export default function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/api/convert.ts b/web/src/api/convert.ts new file mode 100644 index 0000000..1d8946f --- /dev/null +++ b/web/src/api/convert.ts @@ -0,0 +1,72 @@ +import { apiFetch } from './client' +import type { AgentType } from './chat' + +function platformHeaders(token: string, agentType: AgentType = 'risk'): HeadersInit { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'X-Agent-Type': agentType, + } +} + +export type ConvertAcceptPayload = { + customer_id: string + trade_type: 'convert' + from_product_id: string + to_product_id: string + qty: number + client_request_id?: string +} + +export type ConvertAcceptResponse = { + accepted?: boolean + convert_group_id?: string + status?: string + blocked?: boolean + message?: string + [key: string]: unknown +} + +/** POST /api/simulate/trade — trade_type=convert,成功受理为 HTTP 202。 */ +export async function submitConvertAccept(token: string, payload: ConvertAcceptPayload) { + const { data } = await apiFetch('/api/simulate/trade', { + method: 'POST', + token, + headers: platformHeaders(token, 'risk'), + body: JSON.stringify(payload), + }) + return data +} + +export type ConvertQueryResponse = Record + +/** GET /api/simulate/trade/convert/{convert_group_id} */ +export async function getConvertRequest(token: string, convertGroupId: string) { + const { data } = await apiFetch( + `/api/simulate/trade/convert/${encodeURIComponent(convertGroupId)}`, + { token, headers: platformHeaders(token, 'risk') }, + ) + return data +} + +/** POST /api/simulate/trade/convert/{convert_group_id}/cancel */ +export async function cancelConvertRequest(token: string, convertGroupId: string) { + const { data } = await apiFetch>( + `/api/simulate/trade/convert/${encodeURIComponent(convertGroupId)}/cancel`, + { method: 'POST', token, headers: platformHeaders(token, 'risk'), body: JSON.stringify({}) }, + ) + return data +} + +/** POST /api/admin/convert/confirm?accept_date=YYYY-MM-DD — 需 risk_officer。 */ +export async function confirmConvertBatch(token: string, acceptDate: string) { + const q = new URLSearchParams({ accept_date: acceptDate }) + const { data } = await apiFetch>(`/api/admin/convert/confirm?${q}`, { + method: 'POST', + token, + headers: platformHeaders(token, 'risk'), + body: JSON.stringify({}), + }) + return data +} diff --git a/web/src/pages/risk/RiskConvertPage.tsx b/web/src/pages/risk/RiskConvertPage.tsx new file mode 100644 index 0000000..cf6d9a2 --- /dev/null +++ b/web/src/pages/risk/RiskConvertPage.tsx @@ -0,0 +1,212 @@ +import { Alert, Form, Input, InputNumber, Tabs, Typography } from 'antd' +import { useState } from 'react' +import { ApiErrorResult } from '../../components/ApiErrorResult' +import { PageShell } from '../../components/PageShell' +import { Button } from '../../components/ui' +import { + cancelConvertRequest, + confirmConvertBatch, + getConvertRequest, + submitConvertAccept, +} from '../../api/convert' +import { ApiError } from '../../api/client' +import { RiskJsonResult } from '../../components/risk/RiskJsonResult' +import { useAppAuth } from '../../layouts/AppLayout' + +const ACCEPT_PRESETS = [ + { + key: 'demo', + label: '演示全转(CUST-1001 · 110022→161725 · 30000 份)', + values: { + customer_id: 'CUST-1001', + from_product_id: 'PROD-110022', + to_product_id: 'PROD-161725', + qty: 30000, + }, + }, +] + +export function RiskConvertPage() { + const auth = useAppAuth() + const [acceptForm] = Form.useForm() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [acceptResult, setAcceptResult] = useState | null>(null) + const [queryId, setQueryId] = useState('') + const [queryResult, setQueryResult] = useState | null>(null) + const [confirmDate, setConfirmDate] = useState('2026-09-15') + const [confirmResult, setConfirmResult] = useState | null>(null) + + const onAccept = async () => { + const values = await acceptForm.validateFields() + setLoading(true) + setError(null) + setAcceptResult(null) + try { + const data = await submitConvertAccept(auth.accessToken, { + customer_id: values.customer_id, + trade_type: 'convert', + from_product_id: values.from_product_id, + to_product_id: values.to_product_id, + qty: values.qty, + client_request_id: values.client_request_id || undefined, + }) + setAcceptResult(data as Record) + if (data.convert_group_id) setQueryId(String(data.convert_group_id)) + } catch (e) { + setError(e instanceof Error ? e : new Error('accept failed')) + } finally { + setLoading(false) + } + } + + const onQuery = async () => { + if (!queryId.trim()) return + setLoading(true) + setError(null) + try { + const data = await getConvertRequest(auth.accessToken, queryId.trim()) + setQueryResult(data) + } catch (e) { + setError(e instanceof Error ? e : new Error('query failed')) + } finally { + setLoading(false) + } + } + + const onCancel = async () => { + if (!queryId.trim()) return + setLoading(true) + setError(null) + try { + const data = await cancelConvertRequest(auth.accessToken, queryId.trim()) + setQueryResult(data) + await onQuery() + } catch (e) { + setError(e instanceof Error ? e : new Error('cancel failed')) + } finally { + setLoading(false) + } + } + + const onConfirmBatch = async () => { + setLoading(true) + setError(null) + setConfirmResult(null) + try { + const data = await confirmConvertBatch(auth.accessToken, confirmDate) + setConfirmResult(data) + } catch (e) { + setError(e instanceof Error ? e : new Error('confirm failed')) + } finally { + setLoading(false) + } + } + + return ( + + + 受理 POST /api/simulate/trade(convert)· 查询/撤单{' '} + /api/simulate/trade/convert/… · 确认批处理{' '} + POST /api/admin/convert/confirm(需 risk_officer,如 STAFF-30001)。 + Core 需已灌 convert 种子(verify_convert_seed.py)。 + + } + /> + + {error && } + + +
+ + acceptForm.setFieldsValue(ACCEPT_PRESETS[0].values)} + /> + + + + + + + + + + + + + + + + +
+ + + + ), + }, + { + key: 'query', + label: '查询 / 撤单', + children: ( +
+ setQueryId(e.target.value)} + placeholder="convert_group_id" + /> +
+ + +
+ +
+ ), + }, + { + key: 'confirm', + label: '确认批处理', + children: ( +
+ setConfirmDate(e.target.value)} + placeholder="业务确认日 YYYY-MM-DD" + /> + + +
+ ), + }, + ]} + /> +
+ ) +} diff --git a/web/src/routes/menus.tsx b/web/src/routes/menus.tsx index 2a50805..6b31bd8 100644 --- a/web/src/routes/menus.tsx +++ b/web/src/routes/menus.tsx @@ -129,6 +129,7 @@ export function buildMenuGroups(roleLabel: string): RouteMenuGroup[] { { key: '/app/risk/suitability', icon: , label: '适当性校验', path: '/app/risk/suitability' }, { key: '/app/risk/aml-scan', icon: , label: 'AML 扫描', path: '/app/risk/aml-scan' }, { key: '/app/risk/simulate', icon: , label: '模拟交易', path: '/app/risk/simulate' }, + { key: '/app/risk/convert', icon: , label: '基金转换', path: '/app/risk/convert' }, ], }, {