From 248b50c8fd057c87a852f8306fc4585905880abc Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 20:42:27 +0800 Subject: Fetch latest fund NAV via ETF endpoint so T-day subscriptions can confirm (3d-38) --- src/FundLab.Api/akshare_collector.py | 61 ++++++++++++++++------ tests/FundLab.Api.Tests/CashReconciliationTests.fs | 44 ++++++++++++++++ 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py index aed053a..6534511 100644 --- a/src/FundLab.Api/akshare_collector.py +++ b/src/FundLab.Api/akshare_collector.py @@ -2,7 +2,9 @@ """Small raw-data adapter for the AKShare endpoints used by Fund Lab.""" import argparse +import contextlib import datetime as dt +import io import json import re import sys @@ -113,28 +115,53 @@ def nav(code): if not re.fullmatch(r"\d{6}", code): raise ValueError("fund code must contain exactly six digits") - frame = ak.fund_open_fund_info_em( + observations = [] + seen = set() + + def append_observations(frame): + for _, row in frame.iterrows(): + nav_date = date_text(row.get("净值日期")) + nav_value = decimal_text(row.get("单位净值")) + if nav_date is None or nav_value is None or nav_date in seen: + continue + + seen.add(nav_date) + observations.append( + { + "nav_date": nav_date, + "published_at": None, + "nav": nav_value, + "accumulated_nav": None, + "daily_return": decimal_text(row.get("日增长率")), + } + ) + + # Long history via the unit-NAV trend endpoint. It can lag the latest + # trading day, so the recent window below is merged in on top of it. + history = ak.fund_open_fund_info_em( symbol=code, indicator="单位净值走势", period="成立来", ) - observations = [] - - for _, row in frame.iterrows(): - nav_date = date_text(row.get("净值日期")) - nav_value = decimal_text(row.get("单位净值")) - if nav_date is None or nav_value is None: - continue + append_observations(history) - observations.append( - { - "nav_date": nav_date, - "published_at": None, - "nav": nav_value, - "accumulated_nav": None, - "daily_return": decimal_text(row.get("日增长率")), - } - ) + # Recent window from the ETF/LOF NAV endpoint, which publishes the newest + # trading day earlier than the trend endpoint (this is what makes T-day + # subscription confirmations possible). Only missing dates are appended, so + # historical values are never overwritten. + try: + today = dt.date.today() + with contextlib.redirect_stderr(io.StringIO()): + recent = ak.fund_etf_fund_info_em( + fund=code, + start_date=(today - dt.timedelta(days=30)).strftime("%Y%m%d"), + end_date=today.strftime("%Y%m%d"), + ) + if recent is not None and not recent.empty: + append_observations(recent.sort_values("净值日期")) + except Exception: + # Best-effort: a failure here leaves the trend history untouched. + pass if not observations: raise ValueError(f"AKShare returned no usable NAV observations for {code}") diff --git a/tests/FundLab.Api.Tests/CashReconciliationTests.fs b/tests/FundLab.Api.Tests/CashReconciliationTests.fs index b9b024a..fa14bff 100644 --- a/tests/FundLab.Api.Tests/CashReconciliationTests.fs +++ b/tests/FundLab.Api.Tests/CashReconciliationTests.fs @@ -542,3 +542,47 @@ type CashReconciliationTests(fixture: PostgresFixture) = Assert.Equal(-520.00m, internalTotal) Assert.Equal(3880.00m, externalTotal + internalTotal) Assert.Equal(0.00m, (repository().GetCashReconciliation fundId |> Result.defaultWith failwith).Difference) + + [] + member _.``a subscription confirmed at the 2026-09-22 nav date reconciles to zero``() = + let fundId = createFund 10000.00m + let code = seedInstrument () + let tradeDate = DateOnly(2026, 9, 22) + insertQuoteOnDate code 1.327m tradeDate + + let order = + match + repository().CreateSubscriptionOrder( + fixture.Key "cash-tday-order", + fundId, + { FundCode = code; Amount = 1000.00m; FeeAmount = 0m }, + tradeDate + ) + with + | SubscriptionOrderWriteResult.OrderCreated order -> order + | other -> failwithf "unexpected subscription order result: %A" other + + match + repository().ConfirmSubscriptionOrder( + fixture.Key "cash-tday-confirm", + fundId, + order.Id + ) + with + | SubscriptionConfirmResult.OrderConfirmed confirmed -> + match confirmed.ConfirmedQuote with + | Some quote -> + Assert.Equal(tradeDate, quote.NavDate) + Assert.Equal(1.327m, quote.Nav) + | None -> failwith "confirmed quote evidence missing" + | other -> failwithf "unexpected subscription confirm: %A" other + + let status, body = reconciliation fundId + Assert.Equal(200, status) + Assert.Contains("\"openingCash\":\"10000.00\"", body) + Assert.Contains("\"difference\":\"0.00\"", body) + Assert.Equal(1, eventCount body "subscription") + + // the confirmed subscription is an external outflow that fully explains cash + let subscriptionNet = sourceNet body "subscription" + Assert.True(subscriptionNet < 0m, sprintf "expected subscription outflow, got %M" subscriptionNet) -- cgit v1.2.3