feat(nav): Enhance product API with historical NAV tracking and new charting features

- Updated `ready.py` to include a health check for the new `products_nav_history` endpoint, ensuring system readiness.
- Enhanced the `ProductNavChartPanel` component to visualize historical NAV data with various charting options, including line and column charts.
- Introduced new utility functions for filtering and aggregating NAV data, improving data handling in the frontend.
- Updated tests to verify the inclusion of the new `products_nav_history` check in the API response.
- Improved documentation to reflect recent changes and the new testing baseline of 833 passed tests, indicating enhanced stability.

This update significantly improves the product API by providing access to historical NAV data and enhancing user insights through visualizations.
This commit is contained in:
2026-09-11 21:38:54 +08:00
parent 2b4eaa9725
commit d4a2461ba4
16 changed files with 5975 additions and 5328 deletions
File diff suppressed because it is too large Load Diff
+143 -131
View File
@@ -1,131 +1,143 @@
"""为 core_product_nav 灌入近 365 个自然日历史(锚定 06-seed-nav 最新日)。␍
␍
仓库内 canonical 种子:`scripts/core/07-seed-nav-history.sql`(reset.ps1 在 06 之后执行)。␍
␍
python scripts/core/seed_nav_history.py # 直连 MySQL upsert(增量/修复)␍
python scripts/core/seed_nav_history.py --export-sql # 重新生成 07-seed-nav-history.sql␍
"""␍
from __future__ import annotations␍
␍
import argparse␍
import hashlib␍
import sys␍
from datetime import date, timedelta␍
from pathlib import Path␍
␍
ROOT = Path(__file__).resolve().parents[2]␍
if str(ROOT) not in sys.path:␍
sys.path.insert(0, str(ROOT))␍
␍
from sqlalchemy import text␍
␍
from app.config.database import get_core_engine␍
␍
ANCHOR_DATE = date(2026, 9, 4)␍
DAYS_BACK = 364 # 与锚定日合计约 365 个自然日␍
SQL_OUT = Path(__file__).resolve().parent / "07-seed-nav-history.sql"␍
␍
# 与 scripts/core/06-seed-nav.sql 一致(锚定日净值)␍
ANCHORS: list[tuple[str, float]] = [␍
("PROD-110022", 1.0300),␍
("PROD-110023", 1.0200),␍
("PROD-005827", 0.9500),␍
("PROD-005828", 1.0200),␍
("PROD-161725", 1.0500),␍
("PROD-161726", 1.0500),␍
("PROD-003095", 0.9500),␍
("PROD-510300", 1.0300),␍
("PROD-510500", 0.9800),␍
("PROD-XYZ999", 0.9000),␍
("PROD-000001", 1.0020),␍
("PROD-000002", 1.0050),␍
("PROD-WMG001", 1.0150),␍
("PROD-PRIV01", 1.0800),␍
]␍
␍
␍
def _daily_chg_pct(product_id: str, nav_date: date) -> float:␍
h = hashlib.sha256(f"{product_id}:{nav_date.isoformat()}".encode()).hexdigest()␍
n = int(h[:8], 16) / 0xFFFFFFFF␍
return round((n - 0.5) * 3.0, 4) # roughly -1.5% .. +1.5%␍
␍
␍
def build_history_rows() -> list[dict]:␍
rows: list[dict] = []␍
for product_id, anchor_nav in ANCHORS:␍
nav = anchor_nav␍
for offset in range(DAYS_BACK, 0, -1):␍
d = ANCHOR_DATE - timedelta(days=offset)␍
chg = _daily_chg_pct(product_id, d)␍
nav = round(nav / (1 + chg / 100.0), 4)␍
rows.append(␍
{␍
"product_id": product_id,␍
"nav_date": d.isoformat(),␍
"nav": nav,␍
"daily_chg_pct": chg,␍
}␍
)␍
return rows␍
␍
␍
def export_sql(path: Path = SQL_OUT, *, batch_size: int = 500) -> int:␍
rows = build_history_rows()␍
lines = [␍
"USE jinrong_core;",␍
"",␍
"-- 近 364 个自然日历史净值(锚定日 2026-09-04 由 06-seed-nav.sql 写入)",␍
f"-- 生成:python scripts/core/seed_nav_history.py --export-sql · {len(rows)} 行 · {len(ANCHORS)} 产品",␍
"",␍
]␍
for batch_start in range(0, len(rows), batch_size):␍
chunk = rows[batch_start : batch_start + batch_size]␍
lines.append(␍
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) VALUES"␍
)␍
value_lines = [␍
f"('{r['product_id']}', {r['nav']:.4f}, {r['daily_chg_pct']:.4f}, '{r['nav_date']}')"␍
for r in chunk␍
]␍
lines.append(",\n".join(value_lines) + ";")␍
lines.append("")␍
path.write_text("\n".join(lines), encoding="utf-8")␍
return len(rows)␍
␍
␍
def upsert_mysql() -> int:␍
rows = build_history_rows()␍
sql = text(␍
"""␍
INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date)␍
VALUES (:product_id, :nav, :daily_chg_pct, :nav_date)␍
ON DUPLICATE KEY UPDATE nav = VALUES(nav), daily_chg_pct = VALUES(daily_chg_pct)␍
"""␍
)␍
engine = get_core_engine()␍
with engine.begin() as conn:␍
for batch_start in range(0, len(rows), 500):␍
chunk = rows[batch_start : batch_start + 500]␍
conn.execute(sql, chunk)␍
return len(rows)␍
␍
␍
def main() -> None:␍
parser = argparse.ArgumentParser(description="Core NAV history seed (364 days × 14 products)")␍
parser.add_argument(␍
"--export-sql",␍
action="store_true",␍
help=f"Write {SQL_OUT.name} instead of upserting MySQL",␍
)␍
args = parser.parse_args()␍
if args.export_sql:␍
n = export_sql()␍
print(f"seed_nav_history: wrote {n} rows to {SQL_OUT}")␍
return␍
n = upsert_mysql()␍
print(f"seed_nav_history: upserted {n} rows for {len(ANCHORS)} products")␍
␍
␍
if __name__ == "__main__":␍
main()␍
␍
"""为 core_product_nav 灌入近 365 个自然日历史(锚定 06-seed-nav 最新日)。
仓库内 canonical 种子:`scripts/core/07-seed-nav-history.sql`(reset.ps1 在 06 之后执行)。
python scripts/core/seed_nav_history.py # 直连 MySQL upsert(增量/修复)
python scripts/core/seed_nav_history.py --export-sql # 重新生成 07-seed-nav-history.sql
"""
from __future__ import annotations
import argparse
import hashlib
import sys
from datetime import date, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from sqlalchemy import text
from app.config.database import get_core_engine
ANCHOR_DATE = date(2026, 9, 4)
DAYS_BACK = 364 # 与锚定日合计约 365 个自然日
SQL_OUT = Path(__file__).resolve().parent / "07-seed-nav-history.sql"
# 与 scripts/core/06-seed-nav.sql 一致(锚定日净值)
ANCHORS: list[tuple[str, float]] = [
("PROD-110022", 1.0300),
("PROD-110023", 1.0200),
("PROD-005827", 0.9500),
("PROD-005828", 1.0200),
("PROD-161725", 1.0500),
("PROD-161726", 1.0500),
("PROD-003095", 0.9500),
("PROD-510300", 1.0300),
("PROD-510500", 0.9800),
("PROD-XYZ999", 0.9000),
("PROD-000001", 1.0020),
("PROD-000002", 1.0050),
("PROD-WMG001", 1.0150),
("PROD-PRIV01", 1.0800),
]
def _daily_chg_pct(product_id: str, nav_date: date) -> float:
h = hashlib.sha256(f"{product_id}:{nav_date.isoformat()}".encode()).hexdigest()
n = int(h[:8], 16) / 0xFFFFFFFF
return round((n - 0.5) * 3.0, 4) # roughly -1.5% .. +1.5%
def build_history_rows() -> list[dict]:
"""从锚定日净值向前反推首日,再按日涨跌正向生成至锚定日(与 06-seed 锚点一致、无末日断崖)。"""
rows: list[dict] = []
for product_id, anchor_nav in ANCHORS:
days_list = [ANCHOR_DATE - timedelta(days=DAYS_BACK - i) for i in range(DAYS_BACK + 1)]
nav = anchor_nav
for d in reversed(days_list[1:]):
chg = _daily_chg_pct(product_id, d)
nav = nav / (1 + chg / 100.0)
running = round(nav, 4)
for i, d in enumerate(days_list):
if i > 0:
if d == ANCHOR_DATE:
chg = round(((anchor_nav - running) / running) * 100, 4)
running = anchor_nav
else:
chg = _daily_chg_pct(product_id, d)
running = round(running * (1 + chg / 100.0), 4)
else:
chg = _daily_chg_pct(product_id, d)
rows.append(
{
"product_id": product_id,
"nav_date": d.isoformat(),
"nav": running,
"daily_chg_pct": chg,
}
)
return rows
def export_sql(path: Path = SQL_OUT, *, batch_size: int = 500) -> int:
rows = build_history_rows()
lines = [
"USE jinrong_core;",
"",
"-- 近 365 个自然日(含锚定日 2026-09-04;与 06-seed-nav 锚点净值一致)",
f"-- 生成:python scripts/core/seed_nav_history.py --export-sql · {len(rows)} 行 · {len(ANCHORS)} 产品 · 含锚定日",
"",
]
for batch_start in range(0, len(rows), batch_size):
chunk = rows[batch_start : batch_start + batch_size]
lines.append(
"INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) VALUES"
)
value_lines = [
f"('{r['product_id']}', {r['nav']:.4f}, {r['daily_chg_pct']:.4f}, '{r['nav_date']}')"
for r in chunk
]
lines.append(",\n".join(value_lines) + ";")
lines.append("")
path.write_text("\n".join(lines), encoding="utf-8")
return len(rows)
def upsert_mysql() -> int:
rows = build_history_rows()
sql = text(
"""
INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date)
VALUES (:product_id, :nav, :daily_chg_pct, :nav_date)
ON DUPLICATE KEY UPDATE nav = VALUES(nav), daily_chg_pct = VALUES(daily_chg_pct)
"""
)
engine = get_core_engine()
with engine.begin() as conn:
for batch_start in range(0, len(rows), 500):
chunk = rows[batch_start : batch_start + 500]
conn.execute(sql, chunk)
return len(rows)
def main() -> None:
parser = argparse.ArgumentParser(description="Core NAV history seed (364 days × 14 products)")
parser.add_argument(
"--export-sql",
action="store_true",
help=f"Write {SQL_OUT.name} instead of upserting MySQL",
)
args = parser.parse_args()
if args.export_sql:
n = export_sql()
print(f"seed_nav_history: wrote {n} rows to {SQL_OUT}")
return
n = upsert_mysql()
print(f"seed_nav_history: upserted {n} rows for {len(ANCHORS)} products")
if __name__ == "__main__":
main()