diff --git a/Database_Integration/README.md b/Database_Integration/README.md index 7512699..da98f84 100644 --- a/Database_Integration/README.md +++ b/Database_Integration/README.md @@ -33,6 +33,16 @@ portfolios, multiple positions** and preserve their order, so the export is deterministic and faithful. SQLite makes the examples zero-setup; the same SQL runs on PostgreSQL (Oracle/SQL Server need only type tweaks — noted in the DDL). +### Numeric precision on export + +All four exporters render numbers at the **DDL scale** (`schema.sql`: +amounts `DECIMAL(20,2)`, `TotalPercentage` `DECIMAL(9,4)`, quantities / +`NavPrice` / `SharesOutstanding` `DECIMAL(28,6)`), then trim trailing zeros +down to two decimals (zero for `SharesOutstanding`). So `8.33` stays `8.33`, +`8.3333` stays `8.3333` and `50000.123456` units survive the round-trip; +values with *more* decimals than the DDL allows are rounded on export +(`xml_equiv.py` compares numerically and would report it). + ## Round-trip is proven by equivalence The user requirement: export the data the import wrote, then check the import diff --git a/Database_Integration/csharp/export/ExportFundsXml.cs b/Database_Integration/csharp/export/ExportFundsXml.cs index 19d4b72..641466c 100644 --- a/Database_Integration/csharp/export/ExportFundsXml.cs +++ b/Database_Integration/csharp/export/ExportFundsXml.cs @@ -47,7 +47,24 @@ internal static class ExportFundsXml ["ShareClass"] = "Shares", ["Option"] = "Contracts", ["Future"] = "Contracts" }; - static string Inv(double v) => v.ToString("0.00", CultureInfo.InvariantCulture); + /// Amounts: DDL scale 2 (see Num). + static string Inv(double v) => Num(v, 2, 2); + + /// + /// Number formatting follows the DDL scale (schema.sql): amounts + /// DECIMAL(20,2), TotalPercentage DECIMAL(9,4), quantities / NavPrice / + /// SharesOutstanding DECIMAL(28,6). Render at that scale, then drop + /// trailing zeros down to a floor of decimals + /// (custom format "0.00####"): 8.33 -> "8.33", 8.3333 -> "8.3333", + /// 550000 shares -> "550000". A fixed "0.00" would silently truncate what + /// the model stores (xml_equiv.py compares numerically and would flag it). + /// + static string Num(double v, int scale, int minDec) + { + decimal d = Math.Round((decimal)v, scale, MidpointRounding.AwayFromZero); + string fmt = "0" + (scale > 0 ? "." + new string('0', minDec) + new string('#', scale - minDec) : ""); + return d.ToString(fmt, CultureInfo.InvariantCulture); + } static XmlElement El(XmlDocument doc, XmlNode parent, string tag, string? text = null) @@ -198,12 +215,12 @@ static int Main(string[] args) var tv = El(doc, El(doc, pos, "TotalValue"), "Amount", Inv((double)p["value"]!)); tv.SetAttribute("ccy", ccy); - El(doc, pos, "TotalPercentage", Inv((double)p["pct"]!)); + El(doc, pos, "TotalPercentage", Num((double)p["pct"]!, 4, 2)); var kind = p["kind"] is string k && PositionKinds.Contains(k) ? k : "Generic"; var ke = El(doc, pos, kind); if (QtyElem.ContainsKey(kind) && p["qty"] != null) - El(doc, ke, QtyElem[kind], Inv((double)p["qty"]!)); + El(doc, ke, QtyElem[kind], Num((double)p["qty"]!, 6, 2)); } } @@ -245,7 +262,7 @@ static int Main(string[] args) (string?)sc["currency"]); El(doc, pr2, "PriceNature", "OFFICIAL"); El(doc, pr2, "NavPrice", - Inv((double)sc["nav_price"]!)); + Num((double)sc["nav_price"]!, 6, 2)); } if (sc["nav_fund_ccy"] != null) { @@ -258,8 +275,7 @@ static int Main(string[] args) a2.SetAttribute("ccy", ccy); if (sc["shares"] != null) El(doc, t2, "SharesOutstanding", - ((double)sc["shares"]!).ToString("0", - CultureInfo.InvariantCulture)); + Num((double)sc["shares"]!, 6, 0)); } } } diff --git a/Database_Integration/java/ExportFundsXml.java b/Database_Integration/java/ExportFundsXml.java index 1a7a784..fdda6bf 100644 --- a/Database_Integration/java/ExportFundsXml.java +++ b/Database_Integration/java/ExportFundsXml.java @@ -51,7 +51,24 @@ public class ExportFundsXml { "Bond", "Nominal", "ShareClass", "Shares", "Option", "Contracts", "Future", "Contracts"); - static String f2(double v) { return String.format(Locale.ROOT, "%.2f", v); } + /** Amounts: DDL scale 2 (see num). */ + static String f2(double v) { return num(v, 2, 2); } + + /** + * Number formatting follows the DDL scale (schema.sql): amounts + * DECIMAL(20,2), TotalPercentage DECIMAL(9,4), quantities / NavPrice / + * SharesOutstanding DECIMAL(28,6). Render at that scale, then drop trailing + * zeros down to a floor of {@code minDec} decimals: 8.33 -> "8.33", + * 8.3333 -> "8.3333", 550000 shares -> "550000". A fixed "%.2f" would + * silently truncate what the model can store (xml_equiv.py compares + * numerically and would flag the loss). + */ + static String num(double v, int scale, int minDec) { + java.math.BigDecimal d = java.math.BigDecimal.valueOf(v) + .setScale(scale, java.math.RoundingMode.HALF_UP).stripTrailingZeros(); + if (d.scale() < minDec) d = d.setScale(minDec); + return d.toPlainString(); + } /** Append text to parent (the one XML-build primitive). */ static Element el(Document doc, org.w3c.dom.Node parent, String tag, @@ -161,7 +178,7 @@ public static void main(String[] args) throws Exception { "Amount", f2(qr.getDouble("value_fund_ccy"))); tv.setAttribute("ccy", ccy); el(doc, pos, "TotalPercentage", - f2(qr.getDouble("percentage"))); + num(qr.getDouble("percentage"), 4, 2)); String kind = qr.getString("kind"); if (kind == null || !POSITION_KINDS.contains(kind)) kind = "Generic"; @@ -169,7 +186,7 @@ public static void main(String[] args) throws Exception { Object q = qr.getObject("kind_qty"); if (QTY_ELEM.containsKey(kind) && q != null) el(doc, ke, QTY_ELEM.get(kind), - f2(((Number) q).doubleValue())); + num(((Number) q).doubleValue(), 6, 2)); } } @@ -199,7 +216,7 @@ public static void main(String[] args) throws Exception { el(doc, pe2, "PriceCurrency", sr.getString("currency")); el(doc, pe2, "PriceNature", "OFFICIAL"); el(doc, pe2, "NavPrice", - f2(((Number) navp).doubleValue())); + num(((Number) navp).doubleValue(), 6, 2)); } Object navf = sr.getObject("nav_fund_ccy"); if (navf != null) { @@ -213,9 +230,8 @@ public static void main(String[] args) throws Exception { a2.setAttribute("ccy", ccy); Object so = sr.getObject("shares_outstanding"); if (so != null) - el(doc, t2, "SharesOutstanding", String.format( - Locale.ROOT, "%.0f", - ((Number) so).doubleValue())); + el(doc, t2, "SharesOutstanding", + num(((Number) so).doubleValue(), 6, 0)); } } } diff --git a/Database_Integration/javascript/export_fundsxml.mjs b/Database_Integration/javascript/export_fundsxml.mjs index 90635c4..feab9a7 100644 --- a/Database_Integration/javascript/export_fundsxml.mjs +++ b/Database_Integration/javascript/export_fundsxml.mjs @@ -34,7 +34,18 @@ const POSITION_KINDS = new Set(["Equity", "Bond", "ShareClass", "Warrant", const QTY_ELEM = { Equity: "Units", Warrant: "Units", Certificate: "Units", Bond: "Nominal", ShareClass: "Shares", Option: "Contracts", Future: "Contracts" }; -const f2 = (v) => Number(v).toFixed(2); +// Number formatting follows the DDL scale (schema.sql): amounts DECIMAL(20,2), +// TotalPercentage DECIMAL(9,4), quantities / NavPrice / SharesOutstanding +// DECIMAL(28,6). Render at that scale, then drop trailing zeros down to a floor +// of `minDec` decimals: 8.33 -> "8.33", 8.3333 -> "8.3333", 550000 shares -> +// "550000". A fixed toFixed(2) would silently truncate what the model stores +// (xml_equiv.py compares numerically and would flag the loss). +const num = (v, scale, minDec = 2) => { + const [whole, frac = ""] = Number(v).toFixed(scale).split("."); + const f = frac.replace(/0+$/, "").padEnd(minDec, "0"); + return f ? `${whole}.${f}` : whole; +}; +const f2 = (v) => num(v, 2); // amounts (scale 2) function el(doc, parent, tag, text) { // the one XML-build primitive const e = doc.createElement(tag); @@ -119,11 +130,11 @@ for (const f of rows(db, const tv = el(doc, el(doc, pos, "TotalValue"), "Amount", f2(p.value_fund_ccy)); tv.setAttribute("ccy", ccy); - el(doc, pos, "TotalPercentage", f2(p.percentage)); + el(doc, pos, "TotalPercentage", num(p.percentage, 4)); const kind = POSITION_KINDS.has(p.kind) ? p.kind : "Generic"; const ke = el(doc, pos, kind); if (QTY_ELEM[kind] && p.kind_qty != null) - el(doc, ke, QTY_ELEM[kind], f2(p.kind_qty)); + el(doc, ke, QTY_ELEM[kind], num(p.kind_qty, 6)); } } @@ -143,7 +154,7 @@ for (const f of rows(db, el(doc, pr, "NavDate", f.nav_date); el(doc, pr, "PriceCurrency", sc.currency); el(doc, pr, "PriceNature", "OFFICIAL"); - el(doc, pr, "NavPrice", f2(sc.nav_price)); + el(doc, pr, "NavPrice", num(sc.nav_price, 6)); } if (sc.nav_fund_ccy != null) { const t2 = el(doc, el(doc, x, "TotalAssetValues"), @@ -155,7 +166,7 @@ for (const f of rows(db, a2.setAttribute("ccy", ccy); if (sc.shares_outstanding != null) el(doc, t2, "SharesOutstanding", - Number(sc.shares_outstanding).toFixed(0)); + num(sc.shares_outstanding, 6, 0)); } } } diff --git a/Database_Integration/python/export_fundsxml.py b/Database_Integration/python/export_fundsxml.py index eff2186..9dcb45a 100644 --- a/Database_Integration/python/export_fundsxml.py +++ b/Database_Integration/python/export_fundsxml.py @@ -45,6 +45,20 @@ "Option": "Contracts", "Future": "Contracts"} +# Number formatting follows the DDL scale (schema.sql): amounts DECIMAL(20,2), +# TotalPercentage DECIMAL(9,4), quantities / NavPrice / SharesOutstanding +# DECIMAL(28,6). Render with that scale, then drop trailing zeros down to a +# floor of `min_dec` decimals, so 8.33 -> "8.33", 8.3333 -> "8.3333", +# 50000.123456 -> "50000.123456" and 550000 shares -> "550000". A fixed "%.2f" +# would silently truncate anything the model can store with more decimals +# (xml_equiv.py compares numerically, so it would flag the loss). +def _num(v, scale, min_dec=2): + s = f"{float(v):.{scale}f}" + whole, _, frac = s.partition(".") + frac = frac.rstrip("0") + frac = frac.ljust(min_dec, "0") + return f"{whole}.{frac}" if frac else whole + def _el(parent, tag, text=None, **attrs): """Append a child element — the single XML-building primitive.""" e = etree.SubElement(parent, tag) @@ -106,7 +120,7 @@ def main() -> int: "NavDate", f["nav_date"]).getparent() _el(tav, "TotalAssetNature", "OFFICIAL") _el(_el(tav, "TotalNetAssetValue"), "Amount", - f'{f["total_nav"]:.2f}', ccy=ccy) + _num(f["total_nav"], 2), ccy=ccy) ports = _el(fdd, "Portfolios") for pf in con.execute( @@ -126,12 +140,12 @@ def main() -> int: if p["currency"]: _el(pos, "Currency", p["currency"]) _el(_el(pos, "TotalValue"), "Amount", - f'{p["value_fund_ccy"]:.2f}', ccy=ccy) - _el(pos, "TotalPercentage", f'{p["percentage"]:.2f}') + _num(p["value_fund_ccy"], 2), ccy=ccy) + _el(pos, "TotalPercentage", _num(p["percentage"], 4)) kind = p["kind"] if p["kind"] in POSITION_KINDS else "Generic" ke = _el(pos, kind) if kind in QTY_ELEM and p["kind_qty"] is not None: - _el(ke, QTY_ELEM[kind], f'{p["kind_qty"]:.2f}') + _el(ke, QTY_ELEM[kind], _num(p["kind_qty"], 6)) scs = con.execute( "SELECT * FROM share_class WHERE document_id=? AND fund_seq=? " @@ -150,16 +164,16 @@ def main() -> int: _el(pr, "NavDate", f["nav_date"]) _el(pr, "PriceCurrency", sc["currency"]) _el(pr, "PriceNature", "OFFICIAL") - _el(pr, "NavPrice", f'{sc["nav_price"]:.2f}') + _el(pr, "NavPrice", _num(sc["nav_price"], 6)) if sc["nav_fund_ccy"] is not None: t = _el(_el(x, "TotalAssetValues"), "TotalAssetValue") _el(t, "NavDate", f["nav_date"]) _el(t, "TotalAssetNature", "OFFICIAL") _el(_el(t, "TotalNetAssetValue"), "Amount", - f'{sc["nav_fund_ccy"]:.2f}', ccy=ccy) + _num(sc["nav_fund_ccy"], 2), ccy=ccy) if sc["shares_outstanding"] is not None: _el(t, "SharesOutstanding", - f'{sc["shares_outstanding"]:.0f}') + _num(sc["shares_outstanding"], 6, 0)) assets = con.execute( "SELECT * FROM asset WHERE document_id=? ORDER BY unique_id",