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",