fpml-convert 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fpml_convert-0.6.0/PKG-INFO +75 -0
- fpml_convert-0.6.0/README.md +64 -0
- fpml_convert-0.6.0/fpml_convert/__init__.py +40 -0
- fpml_convert-0.6.0/fpml_convert/__main__.py +49 -0
- fpml_convert-0.6.0/fpml_convert/_project.py +225 -0
- fpml_convert-0.6.0/fpml_convert/_rules.py +40 -0
- fpml_convert-0.6.0/fpml_convert/_serialize.py +124 -0
- fpml_convert-0.6.0/fpml_convert/rules.json +97 -0
- fpml_convert-0.6.0/fpml_convert.egg-info/PKG-INFO +75 -0
- fpml_convert-0.6.0/fpml_convert.egg-info/SOURCES.txt +14 -0
- fpml_convert-0.6.0/fpml_convert.egg-info/dependency_links.txt +1 -0
- fpml_convert-0.6.0/fpml_convert.egg-info/entry_points.txt +2 -0
- fpml_convert-0.6.0/fpml_convert.egg-info/top_level.txt +1 -0
- fpml_convert-0.6.0/pyproject.toml +36 -0
- fpml_convert-0.6.0/setup.cfg +4 -0
- fpml_convert-0.6.0/tests/test_parity.py +136 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fpml-convert
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: FpML interest-rate-swap XML <-> JSON, standard library only. One projection mapping, also shipped as a JS interpreter and a C++17 converter.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://danieltuzes.github.io/fpml-convert/
|
|
7
|
+
Project-URL: Source, https://github.com/danieltuzes/fpml-convert
|
|
8
|
+
Keywords: fpml,xml,json,structile,derivatives,swap
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# fpml-convert (Python)
|
|
13
|
+
|
|
14
|
+
Standard-library-only conversion between **FpML interest-rate-swap XML and
|
|
15
|
+
plain JSON**, in both directions. No `lxml`, no third-party JSON — just
|
|
16
|
+
`xml.etree.ElementTree` and `json`.
|
|
17
|
+
|
|
18
|
+
One projection mapping, also implemented as a JavaScript interpreter (for
|
|
19
|
+
the [Structile](https://danieltuzes.github.io/structile/) viewer) and a
|
|
20
|
+
pure-STL C++17 converter; the three agree byte-for-byte on a shared test
|
|
21
|
+
corpus. Downloads, the full version archive, and the mapping's precise
|
|
22
|
+
rules: <https://danieltuzes.github.io/fpml-convert/>.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install fpml-convert
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
No runtime dependencies. Python 3.8+.
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from fpml_convert import fpml_to_json, json_to_fpml
|
|
36
|
+
|
|
37
|
+
value = fpml_to_json(xml_text) # -> dict
|
|
38
|
+
xml_text2 = json_to_fpml(value) # -> schema-ordered FpML, round-trips at the value level
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
# CLI (direction inferred from the input, or forced with --to)
|
|
43
|
+
python -m fpml_convert swap.xml -o swap.json
|
|
44
|
+
python -m fpml_convert swap.json -o swap.xml
|
|
45
|
+
cat swap.xml | python -m fpml_convert - > swap.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## The mapping
|
|
49
|
+
|
|
50
|
+
- Element local name → object key; a tag repeated under one parent → an
|
|
51
|
+
array in document order.
|
|
52
|
+
- Namespace prefixes and ElementTree Clark notation (`{uri}local`) are both
|
|
53
|
+
reduced to the local name, so the result is identical across a browser
|
|
54
|
+
`DOMParser` and a server-side parser.
|
|
55
|
+
- Attributes → `@`-prefixed keys, before any child keys, ordered `@id`,
|
|
56
|
+
`@xsi:type`, `@href`, then the rest. `xsi:type` is kept verbatim.
|
|
57
|
+
- A pure reference element (`href`, no children) → the scalar `«id»`;
|
|
58
|
+
Structile renders that as a link to the element whose `@id` matches.
|
|
59
|
+
- Typed lexical forms are preserved as strings, never coerced
|
|
60
|
+
(`1000000.00` stays exactly that).
|
|
61
|
+
- Every element the projection descends into is checked against a
|
|
62
|
+
supported-subset allowlist — an unknown element raises
|
|
63
|
+
`FpmlUnsupportedError` rather than projecting partially.
|
|
64
|
+
- Serialization re-emits children in schema (`xs:sequence`) order.
|
|
65
|
+
|
|
66
|
+
## Scope
|
|
67
|
+
|
|
68
|
+
FpML 5.10 confirmation view, interest-rate **swap** `dataDocument` only. A
|
|
69
|
+
non-IR-swap document raises `ValueError`. FRA and other products widen the
|
|
70
|
+
allowlist in later releases. An FpML-AWG interchange-shape mode is planned.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
Apache-2.0. The FpML example files used for testing are redistributed
|
|
75
|
+
under the [FpML Public License](https://www.fpml.org/the_standard/fpml-public-license/).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# fpml-convert (Python)
|
|
2
|
+
|
|
3
|
+
Standard-library-only conversion between **FpML interest-rate-swap XML and
|
|
4
|
+
plain JSON**, in both directions. No `lxml`, no third-party JSON — just
|
|
5
|
+
`xml.etree.ElementTree` and `json`.
|
|
6
|
+
|
|
7
|
+
One projection mapping, also implemented as a JavaScript interpreter (for
|
|
8
|
+
the [Structile](https://danieltuzes.github.io/structile/) viewer) and a
|
|
9
|
+
pure-STL C++17 converter; the three agree byte-for-byte on a shared test
|
|
10
|
+
corpus. Downloads, the full version archive, and the mapping's precise
|
|
11
|
+
rules: <https://danieltuzes.github.io/fpml-convert/>.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install fpml-convert
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
No runtime dependencies. Python 3.8+.
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from fpml_convert import fpml_to_json, json_to_fpml
|
|
25
|
+
|
|
26
|
+
value = fpml_to_json(xml_text) # -> dict
|
|
27
|
+
xml_text2 = json_to_fpml(value) # -> schema-ordered FpML, round-trips at the value level
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
# CLI (direction inferred from the input, or forced with --to)
|
|
32
|
+
python -m fpml_convert swap.xml -o swap.json
|
|
33
|
+
python -m fpml_convert swap.json -o swap.xml
|
|
34
|
+
cat swap.xml | python -m fpml_convert - > swap.json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## The mapping
|
|
38
|
+
|
|
39
|
+
- Element local name → object key; a tag repeated under one parent → an
|
|
40
|
+
array in document order.
|
|
41
|
+
- Namespace prefixes and ElementTree Clark notation (`{uri}local`) are both
|
|
42
|
+
reduced to the local name, so the result is identical across a browser
|
|
43
|
+
`DOMParser` and a server-side parser.
|
|
44
|
+
- Attributes → `@`-prefixed keys, before any child keys, ordered `@id`,
|
|
45
|
+
`@xsi:type`, `@href`, then the rest. `xsi:type` is kept verbatim.
|
|
46
|
+
- A pure reference element (`href`, no children) → the scalar `«id»`;
|
|
47
|
+
Structile renders that as a link to the element whose `@id` matches.
|
|
48
|
+
- Typed lexical forms are preserved as strings, never coerced
|
|
49
|
+
(`1000000.00` stays exactly that).
|
|
50
|
+
- Every element the projection descends into is checked against a
|
|
51
|
+
supported-subset allowlist — an unknown element raises
|
|
52
|
+
`FpmlUnsupportedError` rather than projecting partially.
|
|
53
|
+
- Serialization re-emits children in schema (`xs:sequence`) order.
|
|
54
|
+
|
|
55
|
+
## Scope
|
|
56
|
+
|
|
57
|
+
FpML 5.10 confirmation view, interest-rate **swap** `dataDocument` only. A
|
|
58
|
+
non-IR-swap document raises `ValueError`. FRA and other products widen the
|
|
59
|
+
allowlist in later releases. An FpML-AWG interchange-shape mode is planned.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
Apache-2.0. The FpML example files used for testing are redistributed
|
|
64
|
+
under the [FpML Public License](https://www.fpml.org/the_standard/fpml-public-license/).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""fpml_convert — FpML interest-rate-swap XML <-> JSON, standard library only.
|
|
2
|
+
|
|
3
|
+
The Python sibling of ``structile-fpml``'s JavaScript interpreter bundle.
|
|
4
|
+
Same projection mapping (the ``@``-prefixed, ``«id»``-reference,
|
|
5
|
+
local-name-reduced one that round-trips without the schema), same
|
|
6
|
+
supported-element subset and ``xs:sequence`` child-order binding — both
|
|
7
|
+
read from the shared ``src/rules.json``.
|
|
8
|
+
|
|
9
|
+
>>> from fpml_convert import fpml_to_json, json_to_fpml
|
|
10
|
+
>>> value = fpml_to_json(xml_text) # -> dict, == the JS bundle's output
|
|
11
|
+
>>> xml_text2 = json_to_fpml(value) # -> schema-ordered FpML
|
|
12
|
+
|
|
13
|
+
A ``--mapping fpml-awg`` interchange mode (the ISDA FpML AWG "Guidelines on
|
|
14
|
+
FpML to JSON Conversion" shape) is planned; this module ships only the
|
|
15
|
+
structile projection for now.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ._project import (
|
|
20
|
+
FpmlUnsupportedError,
|
|
21
|
+
LINK_CLOSE,
|
|
22
|
+
LINK_ID_FIELD,
|
|
23
|
+
LINK_OPEN,
|
|
24
|
+
fpml_to_json,
|
|
25
|
+
)
|
|
26
|
+
from ._serialize import json_to_fpml
|
|
27
|
+
|
|
28
|
+
#: FpML schema version this converter targets. Mirrors the JS bundle's
|
|
29
|
+
#: ``INTERPRETER_VERSION`` / ``package.json`` ``fpmlVersion``.
|
|
30
|
+
FPML_VERSION = "5.10"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"fpml_to_json",
|
|
34
|
+
"json_to_fpml",
|
|
35
|
+
"FpmlUnsupportedError",
|
|
36
|
+
"FPML_VERSION",
|
|
37
|
+
"LINK_OPEN",
|
|
38
|
+
"LINK_CLOSE",
|
|
39
|
+
"LINK_ID_FIELD",
|
|
40
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``python -m fpml_convert IN [-o OUT]`` — convert an FpML IR-swap
|
|
2
|
+
``dataDocument`` between XML and JSON, direction inferred from the input
|
|
3
|
+
(``<`` first non-space char -> XML -> JSON, else JSON -> XML) or forced
|
|
4
|
+
with ``--to xml|json``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import FPML_VERSION, fpml_to_json, json_to_fpml
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _looks_like_xml(text: str) -> bool:
|
|
16
|
+
stripped = text.lstrip()
|
|
17
|
+
return stripped.startswith("<")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv=None) -> int:
|
|
21
|
+
p = argparse.ArgumentParser(
|
|
22
|
+
prog="python -m fpml_convert",
|
|
23
|
+
description="FpML interest-rate-swap XML <-> JSON (structile projection).",
|
|
24
|
+
)
|
|
25
|
+
p.add_argument("input", help="path to an FpML .xml or a projected .json file, or - for stdin")
|
|
26
|
+
p.add_argument("-o", "--output", help="write here instead of stdout")
|
|
27
|
+
p.add_argument("--to", choices=("xml", "json"), help="force the output direction")
|
|
28
|
+
p.add_argument("--indent", type=int, default=2, help="JSON indent (default 2)")
|
|
29
|
+
p.add_argument("--version", action="version", version=f"fpml_convert (FpML {FPML_VERSION})")
|
|
30
|
+
args = p.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
|
33
|
+
|
|
34
|
+
to = args.to or ("json" if _looks_like_xml(text) else "xml")
|
|
35
|
+
if to == "json":
|
|
36
|
+
out_text = json.dumps(fpml_to_json(text), indent=args.indent, ensure_ascii=False) + "\n"
|
|
37
|
+
else:
|
|
38
|
+
out_text = json_to_fpml(json.loads(text))
|
|
39
|
+
|
|
40
|
+
if args.output:
|
|
41
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
42
|
+
fh.write(out_text)
|
|
43
|
+
else:
|
|
44
|
+
sys.stdout.write(out_text)
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
sys.exit(main())
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""FpML XML -> plain value projection — a faithful port of the JS bundle's
|
|
2
|
+
``src/20-ns.js`` + ``src/30-project.js`` + ``src/40-support.js`` +
|
|
3
|
+
``src/50-swap.js``. The rule tables come from the shared ``src/rules.json``
|
|
4
|
+
(see :mod:`fpml_convert._rules`); only this traversal logic is duplicated
|
|
5
|
+
between the two implementations, and the committed JS snapshots
|
|
6
|
+
(``test/__snapshots__/*.json``) are the cross-language contract.
|
|
7
|
+
|
|
8
|
+
Projection rules (identical to the JS interpreter):
|
|
9
|
+
|
|
10
|
+
* element local name -> key; a repeated tag under one parent -> a list,
|
|
11
|
+
in document order.
|
|
12
|
+
* attributes -> ``@``-prefixed keys: ``@id`` first, then ``@xsi:type``,
|
|
13
|
+
then ``@href``, then any other plain attribute, all BEFORE the child
|
|
14
|
+
keys. ``xmlns*`` and other ``xsi:*`` attributes are dropped.
|
|
15
|
+
* a pure reference element (``href``, nothing else) -> the link scalar
|
|
16
|
+
``«href»``. One that also carries content stays a dict but still gets
|
|
17
|
+
``@href = «href»``.
|
|
18
|
+
* ``xsi:type`` -> ``@xsi:type`` verbatim. Typed lexical forms are NEVER
|
|
19
|
+
coerced — every leaf value stays the exact source string.
|
|
20
|
+
* every element descended into is checked against ``FPML_SUPPORTED``
|
|
21
|
+
first; an unknown element is a loud error, not a best-effort guess.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import xml.etree.ElementTree as ET
|
|
26
|
+
|
|
27
|
+
from ._rules import FPML_SUPPORTED
|
|
28
|
+
|
|
29
|
+
LINK_OPEN = "«" # «
|
|
30
|
+
LINK_CLOSE = "»" # »
|
|
31
|
+
LINK_ID_FIELD = "@id"
|
|
32
|
+
|
|
33
|
+
_XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FpmlUnsupportedError(Exception):
|
|
37
|
+
"""Raised for an element outside the supported IR-swap subset. A
|
|
38
|
+
structural "I don't recognise this" — distinct from the soft rule
|
|
39
|
+
violations Structile's ``__structileDiagnostic`` is for."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fpml_local_name(name) -> str:
|
|
43
|
+
"""``{uri}local`` -> ``local``; ``prefix:local`` -> ``local``;
|
|
44
|
+
``local`` -> ``local``. Reduces both DOM providers' spellings."""
|
|
45
|
+
t = "" if name is None else str(name)
|
|
46
|
+
brace = t.rfind("}")
|
|
47
|
+
if brace != -1:
|
|
48
|
+
return t[brace + 1:]
|
|
49
|
+
colon = t.rfind(":")
|
|
50
|
+
return t[colon + 1:] if colon != -1 else t
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fpml_element_local_name(el) -> str:
|
|
54
|
+
return fpml_local_name(getattr(el, "tag", None))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _attr_items(el):
|
|
58
|
+
"""``el.attrib`` items in source order (ElementTree preserves it since
|
|
59
|
+
Python 3.8)."""
|
|
60
|
+
return list(el.attrib.items())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fpml_xsi_type(el):
|
|
64
|
+
for raw, val in _attr_items(el):
|
|
65
|
+
if raw == "xsi:type":
|
|
66
|
+
return val
|
|
67
|
+
if raw.startswith("{") and "}" in raw:
|
|
68
|
+
uri = raw[1:raw.index("}")]
|
|
69
|
+
if uri == _XSI_NS and fpml_local_name(raw) == "type":
|
|
70
|
+
return val
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fpml_plain_attributes(el):
|
|
75
|
+
"""Non-namespaced "business" attributes as an ordered ``dict``, with the
|
|
76
|
+
structural ones removed: ``xmlns*``, ``xsi:*`` (``xsi:type`` / ``xsi:nil``
|
|
77
|
+
are surfaced separately), and ``id`` / ``href`` (projected explicitly as
|
|
78
|
+
``@id`` / ``@href``)."""
|
|
79
|
+
out = {}
|
|
80
|
+
for raw, val in _attr_items(el):
|
|
81
|
+
if raw == "xmlns" or raw.startswith("xmlns:"):
|
|
82
|
+
continue
|
|
83
|
+
if raw in ("id", "href"):
|
|
84
|
+
continue
|
|
85
|
+
if raw.startswith("{"):
|
|
86
|
+
uri = raw[1:raw.index("}")]
|
|
87
|
+
if uri == _XSI_NS:
|
|
88
|
+
continue
|
|
89
|
+
elif raw.startswith("xsi:"):
|
|
90
|
+
continue
|
|
91
|
+
out[fpml_local_name(raw)] = val
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fpml_is_xsi_nil(el) -> bool:
|
|
96
|
+
for raw, val in _attr_items(el):
|
|
97
|
+
is_nil_attr = raw == "xsi:nil" or (
|
|
98
|
+
raw.startswith("{") and "}" in raw
|
|
99
|
+
and fpml_local_name(raw) == "nil" and raw[1:raw.index("}")] == _XSI_NS
|
|
100
|
+
)
|
|
101
|
+
if is_nil_attr:
|
|
102
|
+
return str(val).strip() == "true"
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def fpml_own_text(el) -> str:
|
|
107
|
+
"""A leaf element's own trimmed text, or ``""``. Only ever called on a
|
|
108
|
+
childless element, so ``el.text`` IS its whole text."""
|
|
109
|
+
return (el.text or "").strip()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _wrap_ref(id_: str) -> str:
|
|
113
|
+
return LINK_OPEN + id_ + LINK_CLOSE
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def fpml_assert_supported(el) -> None:
|
|
117
|
+
name = fpml_element_local_name(el)
|
|
118
|
+
if name not in FPML_SUPPORTED:
|
|
119
|
+
raise FpmlUnsupportedError(
|
|
120
|
+
"fpml converter (read-only IR-swap subset): element <" + name + "> "
|
|
121
|
+
"is outside the supported subset. This is by design — the subset "
|
|
122
|
+
"is widened only as real FpML example files are added to the test "
|
|
123
|
+
"corpus."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fpml_project_element(el):
|
|
128
|
+
fpml_assert_supported(el)
|
|
129
|
+
|
|
130
|
+
id_ = el.get("id")
|
|
131
|
+
href = el.get("href")
|
|
132
|
+
xsi_type = fpml_xsi_type(el)
|
|
133
|
+
is_nil = fpml_is_xsi_nil(el)
|
|
134
|
+
plain = fpml_plain_attributes(el)
|
|
135
|
+
kids = list(el)
|
|
136
|
+
has_plain = len(plain) > 0
|
|
137
|
+
|
|
138
|
+
# Pure reference: href, nothing else of substance.
|
|
139
|
+
if href is not None and not kids and not has_plain and xsi_type is None and fpml_own_text(el) == "":
|
|
140
|
+
return _wrap_ref(href)
|
|
141
|
+
|
|
142
|
+
# Childless: a scalar (or a small dict when it has attributes to keep).
|
|
143
|
+
if not kids:
|
|
144
|
+
text = fpml_own_text(el)
|
|
145
|
+
bare = None if is_nil else (None if text == "" else text)
|
|
146
|
+
if id_ is None and href is None and xsi_type is None and not has_plain:
|
|
147
|
+
return bare
|
|
148
|
+
leaf = {}
|
|
149
|
+
if id_ is not None:
|
|
150
|
+
leaf["@id"] = str(id_)
|
|
151
|
+
if xsi_type is not None:
|
|
152
|
+
leaf["@xsi:type"] = str(xsi_type)
|
|
153
|
+
if href is not None:
|
|
154
|
+
leaf["@href"] = _wrap_ref(href)
|
|
155
|
+
for k, v in plain.items():
|
|
156
|
+
leaf["@" + k] = v
|
|
157
|
+
if is_nil:
|
|
158
|
+
leaf["@xsi:nil"] = "true"
|
|
159
|
+
if text != "":
|
|
160
|
+
leaf["#text"] = text
|
|
161
|
+
return leaf
|
|
162
|
+
|
|
163
|
+
# Container: attributes first (fixed order), then grouped children.
|
|
164
|
+
out = {}
|
|
165
|
+
if id_ is not None:
|
|
166
|
+
out["@id"] = str(id_)
|
|
167
|
+
if xsi_type is not None:
|
|
168
|
+
out["@xsi:type"] = str(xsi_type)
|
|
169
|
+
if href is not None:
|
|
170
|
+
out["@href"] = _wrap_ref(href)
|
|
171
|
+
for k, v in plain.items():
|
|
172
|
+
out["@" + k] = v
|
|
173
|
+
if is_nil:
|
|
174
|
+
out["@xsi:nil"] = "true"
|
|
175
|
+
|
|
176
|
+
order = []
|
|
177
|
+
groups = {}
|
|
178
|
+
for kid in kids:
|
|
179
|
+
key = fpml_element_local_name(kid)
|
|
180
|
+
if key not in groups:
|
|
181
|
+
groups[key] = []
|
|
182
|
+
order.append(key)
|
|
183
|
+
groups[key].append(kid)
|
|
184
|
+
for key in order:
|
|
185
|
+
g = groups[key]
|
|
186
|
+
out[key] = fpml_project_element(g[0]) if len(g) == 1 else [fpml_project_element(x) for x in g]
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _has_descendant(root, local_name: str) -> bool:
|
|
191
|
+
return any(fpml_element_local_name(e) == local_name for e in root.iter())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def fpml_assert_is_ir_swap_data_document(root) -> None:
|
|
195
|
+
"""Root gate — the throw that tells interpreter selection FpML apart
|
|
196
|
+
from every other candidate. Rejects anything that isn't an FpML
|
|
197
|
+
interest-rate-swap ``<dataDocument>``."""
|
|
198
|
+
if root is None or fpml_element_local_name(root) != "dataDocument":
|
|
199
|
+
got = fpml_element_local_name(root) if root is not None else "?"
|
|
200
|
+
raise ValueError(
|
|
201
|
+
"fpml converter: expected a root <dataDocument> element, got <" + got + "> "
|
|
202
|
+
"— this doesn't look like an FpML dataDocument."
|
|
203
|
+
)
|
|
204
|
+
ver = root.get("fpmlVersion")
|
|
205
|
+
if ver is not None and not str(ver).startswith("5-"):
|
|
206
|
+
raise ValueError(
|
|
207
|
+
'fpml converter: fpmlVersion="' + str(ver) + '" is not an FpML 5.x document.'
|
|
208
|
+
)
|
|
209
|
+
if not _has_descendant(root, "swap"):
|
|
210
|
+
raise ValueError(
|
|
211
|
+
"fpml converter: this <dataDocument> contains no <swap> — only the "
|
|
212
|
+
"interest-rate-swap product family is supported in this read-only build."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def fpml_to_json(xml_text: str):
|
|
217
|
+
"""FpML interest-rate-swap XML -> the nested value Structile renders.
|
|
218
|
+
|
|
219
|
+
Raises :class:`ValueError` if the document isn't an FpML IR-swap
|
|
220
|
+
``dataDocument``, or :class:`FpmlUnsupportedError` for an element
|
|
221
|
+
outside the supported subset.
|
|
222
|
+
"""
|
|
223
|
+
root = ET.fromstring(xml_text)
|
|
224
|
+
fpml_assert_is_ir_swap_data_document(root)
|
|
225
|
+
return fpml_project_element(root)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Loads the language-neutral projection rule tables from ``src/rules.json``
|
|
2
|
+
— the SAME file ``scripts/build.mjs`` injects into the JS bundle as
|
|
3
|
+
``const FPML_RULES``. One source of truth for the supported-element set and
|
|
4
|
+
the ``xs:sequence`` child-order binding across both implementations.
|
|
5
|
+
|
|
6
|
+
Lookup order:
|
|
7
|
+
1. ``rules.json`` sitting next to this package (a packaging step copies
|
|
8
|
+
it there for the wheel — see the FpML interpreter plan Phase 8);
|
|
9
|
+
2. ``../../src/rules.json`` relative to this file (a repo checkout).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_HERE = Path(__file__).resolve().parent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _locate() -> Path:
|
|
20
|
+
packaged = _HERE / "rules.json"
|
|
21
|
+
if packaged.is_file():
|
|
22
|
+
return packaged
|
|
23
|
+
repo_copy = _HERE.parents[1] / "src" / "rules.json"
|
|
24
|
+
if repo_copy.is_file():
|
|
25
|
+
return repo_copy
|
|
26
|
+
raise FileNotFoundError(
|
|
27
|
+
"fpml_convert: rules.json not found next to the package or at "
|
|
28
|
+
f"{repo_copy} — a repo checkout or a packaged copy is required"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_RAW = json.loads(_locate().read_text(encoding="utf-8"))
|
|
33
|
+
|
|
34
|
+
#: ``set`` of every element local-name the projection is allowed to descend
|
|
35
|
+
#: into; anything else is a :class:`~fpml_convert.FpmlUnsupportedError`.
|
|
36
|
+
FPML_SUPPORTED: frozenset = frozenset(_RAW["supported"])
|
|
37
|
+
|
|
38
|
+
#: ``{parent_local_name: [child_local_name, ...]}`` — serialize re-emits a
|
|
39
|
+
#: dict's children in this order, not the (user-editable) key order.
|
|
40
|
+
FPML_CHILD_ORDER: dict = dict(_RAW["childOrder"])
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Value -> FpML XML — the inverse of :func:`fpml_convert._project.fpml_to_json`,
|
|
2
|
+
a faithful port of the JS bundle's ``src/70-serialize.js`` (which uses the
|
|
3
|
+
shared ``FPML_CHILD_ORDER`` from ``src/rules.json``). Round-trips at the
|
|
4
|
+
VALUE level: ``fpml_to_json(json_to_fpml(fpml_to_json(x))) == fpml_to_json(x)``.
|
|
5
|
+
|
|
6
|
+
Policies, matching the JS side exactly:
|
|
7
|
+
|
|
8
|
+
* typed lexical forms are written back verbatim (no coercion — ``1000000``
|
|
9
|
+
vs ``1000000.00`` survives);
|
|
10
|
+
* the confirmation-view default namespace + ``xsi:`` are re-declared on the
|
|
11
|
+
root; ``xsi:schemaLocation`` is not reproduced;
|
|
12
|
+
* children are re-emitted in ``FPML_CHILD_ORDER`` order, so a value whose
|
|
13
|
+
keys were reordered still serialises as schema-ordered FpML.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from ._project import LINK_CLOSE, LINK_OPEN
|
|
18
|
+
from ._rules import FPML_CHILD_ORDER
|
|
19
|
+
|
|
20
|
+
_FPML_CONFIRMATION_NS = "http://www.fpml.org/FpML-5/confirmation"
|
|
21
|
+
_FPML_XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _esc_attr(s) -> str:
|
|
25
|
+
return (str(s).replace("&", "&").replace("<", "<")
|
|
26
|
+
.replace(">", ">").replace('"', """))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _esc_text(s) -> str:
|
|
30
|
+
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_ref(v) -> bool:
|
|
34
|
+
return isinstance(v, str) and v.startswith(LINK_OPEN) and v.endswith(LINK_CLOSE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _unwrap_ref(v: str) -> str:
|
|
38
|
+
return v[len(LINK_OPEN):len(v) - len(LINK_CLOSE)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _order_child_pairs(parent_local_name, pairs):
|
|
42
|
+
order = FPML_CHILD_ORDER.get(parent_local_name)
|
|
43
|
+
if not order:
|
|
44
|
+
return pairs
|
|
45
|
+
rank = {name: i for i, name in enumerate(order)}
|
|
46
|
+
decorated = [
|
|
47
|
+
(rank[p[0]] if p[0] in rank else len(order) + i, i, p)
|
|
48
|
+
for i, p in enumerate(pairs)
|
|
49
|
+
]
|
|
50
|
+
decorated.sort(key=lambda t: (t[0], t[1]))
|
|
51
|
+
return [t[2] for t in decorated]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _serialize_value(key, val, indent, out):
|
|
55
|
+
if isinstance(val, list):
|
|
56
|
+
for item in val:
|
|
57
|
+
_serialize_value(key, item, indent, out)
|
|
58
|
+
return
|
|
59
|
+
if val is None:
|
|
60
|
+
out.append(indent + "<" + key + "/>")
|
|
61
|
+
return
|
|
62
|
+
if not isinstance(val, dict):
|
|
63
|
+
if _is_ref(val):
|
|
64
|
+
out.append(indent + "<" + key + ' href="' + _esc_attr(_unwrap_ref(val)) + '"/>')
|
|
65
|
+
else:
|
|
66
|
+
out.append(indent + "<" + key + ">" + _esc_text(val) + "</" + key + ">")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
attrs = []
|
|
70
|
+
nil = False
|
|
71
|
+
text = None
|
|
72
|
+
child_pairs = []
|
|
73
|
+
for k in val:
|
|
74
|
+
if k == "@xsi:nil":
|
|
75
|
+
nil = str(val[k]) == "true"
|
|
76
|
+
elif k == "@id":
|
|
77
|
+
attrs.append(("id", val[k]))
|
|
78
|
+
elif k == "@xsi:type":
|
|
79
|
+
attrs.append(("xsi:type", val[k]))
|
|
80
|
+
elif k == "@href":
|
|
81
|
+
attrs.append(("href", _unwrap_ref(val[k]) if _is_ref(val[k]) else val[k]))
|
|
82
|
+
elif k.startswith("@"):
|
|
83
|
+
attrs.append((k[1:], val[k]))
|
|
84
|
+
elif k == "#text":
|
|
85
|
+
text = val[k]
|
|
86
|
+
else:
|
|
87
|
+
child_pairs.append((k, val[k]))
|
|
88
|
+
|
|
89
|
+
attr_str = "".join(' %s="%s"' % (name, _esc_attr(v)) for name, v in attrs)
|
|
90
|
+
if nil:
|
|
91
|
+
attr_str += ' xsi:nil="true"'
|
|
92
|
+
open_tag = "<" + key + attr_str
|
|
93
|
+
|
|
94
|
+
if nil or (not child_pairs and text is None):
|
|
95
|
+
out.append(indent + open_tag + "/>")
|
|
96
|
+
return
|
|
97
|
+
if not child_pairs:
|
|
98
|
+
out.append(indent + open_tag + ">" + _esc_text(text) + "</" + key + ">")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
out.append(indent + open_tag + ">")
|
|
102
|
+
ci = indent + " "
|
|
103
|
+
if text is not None:
|
|
104
|
+
out.append(ci + _esc_text(text)) # mixed content (not seen in the subset)
|
|
105
|
+
for cp in _order_child_pairs(key, child_pairs):
|
|
106
|
+
_serialize_value(cp[0], cp[1], ci, out)
|
|
107
|
+
out.append(indent + "</" + key + ">")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def json_to_fpml(value) -> str:
|
|
111
|
+
"""The projected ``dataDocument`` value -> FpML text."""
|
|
112
|
+
if not isinstance(value, dict):
|
|
113
|
+
raise ValueError("fpml json_to_fpml: expected the projected <dataDocument> object")
|
|
114
|
+
ver = value["@fpmlVersion"] if isinstance(value.get("@fpmlVersion"), str) else "5-10"
|
|
115
|
+
out = [
|
|
116
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
117
|
+
'<dataDocument xmlns="%s" xmlns:xsi="%s" fpmlVersion="%s">'
|
|
118
|
+
% (_FPML_CONFIRMATION_NS, _FPML_XSI_NS, _esc_attr(ver)),
|
|
119
|
+
]
|
|
120
|
+
child_pairs = [(k, value[k]) for k in value if not k.startswith("@")]
|
|
121
|
+
for cp in _order_child_pairs("dataDocument", child_pairs):
|
|
122
|
+
_serialize_value(cp[0], cp[1], " ", out)
|
|
123
|
+
out.append("</dataDocument>")
|
|
124
|
+
return "\n".join(out) + "\n"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Language-neutral FpML projection rule tables, the single source of truth shared by the JS bundle (injected by scripts/build.mjs as `const FPML_RULES`, consumed by src/40-support.js and src/60-binding.js) and the Python converter (python/fpml_convert/_rules.py). Widen the same way as before: against a real corpus file or the FpML 5.10 XSD, never speculatively. See src/40-support.js and src/60-binding.js headers for the rationale behind each table.",
|
|
3
|
+
|
|
4
|
+
"supported": [
|
|
5
|
+
"dataDocument", "trade", "tradeHeader", "partyTradeIdentifier",
|
|
6
|
+
"partyReference", "tradeId", "tradeDate", "party", "partyId", "partyName",
|
|
7
|
+
"calculationAgent", "calculationAgentPartyReference",
|
|
8
|
+
|
|
9
|
+
"swap", "swapStream",
|
|
10
|
+
"payerPartyReference", "receiverPartyReference",
|
|
11
|
+
|
|
12
|
+
"calculationPeriodDates", "effectiveDate", "terminationDate",
|
|
13
|
+
"unadjustedDate", "adjustedStartDate", "adjustedEndDate",
|
|
14
|
+
"dateAdjustments", "businessDayConvention", "businessCenters",
|
|
15
|
+
"businessCenter", "businessCentersReference",
|
|
16
|
+
"calculationPeriodDatesAdjustments", "calculationPeriodFrequency",
|
|
17
|
+
"periodMultiplier", "period", "rollConvention",
|
|
18
|
+
"firstPeriodStartDate", "firstRegularPeriodStartDate",
|
|
19
|
+
"lastRegularPeriodEndDate", "firstPaymentDate",
|
|
20
|
+
"stubCalculationPeriodAmount", "initialStub", "finalStub", "stubRate",
|
|
21
|
+
|
|
22
|
+
"paymentDates", "calculationPeriodDatesReference", "paymentFrequency",
|
|
23
|
+
"payRelativeTo", "paymentDatesAdjustments", "paymentDaysOffset",
|
|
24
|
+
"adjustedPaymentDate", "paymentDate",
|
|
25
|
+
|
|
26
|
+
"resetDates", "resetRelativeTo", "fixingDates", "dayType", "dateRelativeTo",
|
|
27
|
+
"resetFrequency", "resetDatesAdjustments", "adjustedFixingDate",
|
|
28
|
+
|
|
29
|
+
"calculationPeriodAmount", "calculation", "compoundingMethod",
|
|
30
|
+
"notionalSchedule", "notionalStepSchedule", "notionalAmount",
|
|
31
|
+
"initialValue", "currency", "step", "stepDate", "stepValue",
|
|
32
|
+
"fixedRateSchedule", "fixedRate", "spreadSchedule",
|
|
33
|
+
"floatingRateCalculation", "floatingRateDefinition", "floatingRate",
|
|
34
|
+
"floatingRateIndex", "indexTenor", "finalRateRounding",
|
|
35
|
+
"roundingDirection", "precision", "rateObservation", "observationWeight",
|
|
36
|
+
"dayCountFraction",
|
|
37
|
+
|
|
38
|
+
"calculationPeriod", "paymentCalculationPeriod",
|
|
39
|
+
"cashflows", "cashflowsMatchParameters", "additionalPayment",
|
|
40
|
+
"paymentAmount", "amount",
|
|
41
|
+
|
|
42
|
+
"principalExchanges", "initialExchange", "intermediateExchange",
|
|
43
|
+
"finalExchange", "principalExchange", "principalExchangeAmount",
|
|
44
|
+
"principalExchangeDate", "adjustedPrincipalExchangeDate"
|
|
45
|
+
],
|
|
46
|
+
|
|
47
|
+
"childOrder": {
|
|
48
|
+
"additionalPayment": ["payerPartyReference", "receiverPartyReference", "paymentAmount", "paymentDate"],
|
|
49
|
+
"businessCenters": ["businessCenter"],
|
|
50
|
+
"calculation": ["notionalSchedule", "fixedRateSchedule", "floatingRateCalculation", "dayCountFraction", "compoundingMethod"],
|
|
51
|
+
"calculationAgent": ["calculationAgentPartyReference"],
|
|
52
|
+
"calculationPeriod": ["adjustedStartDate", "adjustedEndDate", "notionalAmount", "floatingRateDefinition", "fixedRate"],
|
|
53
|
+
"calculationPeriodAmount": ["calculation"],
|
|
54
|
+
"calculationPeriodDates": ["effectiveDate", "terminationDate", "calculationPeriodDatesAdjustments", "firstPeriodStartDate", "firstRegularPeriodStartDate", "lastRegularPeriodEndDate", "calculationPeriodFrequency"],
|
|
55
|
+
"calculationPeriodDatesAdjustments": ["businessDayConvention", "businessCentersReference", "businessCenters"],
|
|
56
|
+
"calculationPeriodFrequency": ["periodMultiplier", "period", "rollConvention"],
|
|
57
|
+
"cashflows": ["cashflowsMatchParameters", "principalExchange", "paymentCalculationPeriod"],
|
|
58
|
+
"dataDocument": ["trade", "party"],
|
|
59
|
+
"dateAdjustments": ["businessDayConvention", "businessCenters", "businessCentersReference"],
|
|
60
|
+
"effectiveDate": ["unadjustedDate", "dateAdjustments"],
|
|
61
|
+
"finalRateRounding": ["roundingDirection", "precision"],
|
|
62
|
+
"finalStub": ["floatingRate"],
|
|
63
|
+
"firstPeriodStartDate": ["unadjustedDate", "dateAdjustments"],
|
|
64
|
+
"fixedRateSchedule": ["initialValue", "step"],
|
|
65
|
+
"fixingDates": ["periodMultiplier", "period", "dayType", "businessDayConvention", "businessCenters", "dateRelativeTo"],
|
|
66
|
+
"floatingRate": ["floatingRateIndex", "indexTenor"],
|
|
67
|
+
"floatingRateCalculation": ["floatingRateIndex", "indexTenor", "finalRateRounding", "spreadSchedule"],
|
|
68
|
+
"floatingRateDefinition": ["rateObservation"],
|
|
69
|
+
"indexTenor": ["periodMultiplier", "period"],
|
|
70
|
+
"initialStub": ["floatingRate", "stubRate"],
|
|
71
|
+
"notionalSchedule": ["notionalStepSchedule"],
|
|
72
|
+
"notionalStepSchedule": ["initialValue", "step", "currency"],
|
|
73
|
+
"party": ["partyId", "partyName"],
|
|
74
|
+
"partyTradeIdentifier": ["partyReference", "tradeId"],
|
|
75
|
+
"paymentAmount": ["currency", "amount"],
|
|
76
|
+
"paymentCalculationPeriod": ["adjustedPaymentDate", "calculationPeriod"],
|
|
77
|
+
"paymentDate": ["unadjustedDate", "dateAdjustments"],
|
|
78
|
+
"paymentDates": ["calculationPeriodDatesReference", "paymentFrequency", "firstPaymentDate", "payRelativeTo", "paymentDaysOffset", "paymentDatesAdjustments"],
|
|
79
|
+
"paymentDatesAdjustments": ["businessDayConvention", "businessCentersReference", "businessCenters"],
|
|
80
|
+
"paymentDaysOffset": ["periodMultiplier", "period", "dayType"],
|
|
81
|
+
"paymentFrequency": ["periodMultiplier", "period"],
|
|
82
|
+
"principalExchange": ["adjustedPrincipalExchangeDate", "principalExchangeAmount"],
|
|
83
|
+
"principalExchanges": ["initialExchange", "finalExchange", "intermediateExchange"],
|
|
84
|
+
"rateObservation": ["adjustedFixingDate", "observationWeight"],
|
|
85
|
+
"resetDates": ["calculationPeriodDatesReference", "resetRelativeTo", "fixingDates", "resetFrequency", "resetDatesAdjustments"],
|
|
86
|
+
"resetDatesAdjustments": ["businessDayConvention", "businessCentersReference", "businessCenters"],
|
|
87
|
+
"resetFrequency": ["periodMultiplier", "period"],
|
|
88
|
+
"spreadSchedule": ["initialValue"],
|
|
89
|
+
"step": ["stepDate", "stepValue"],
|
|
90
|
+
"stubCalculationPeriodAmount": ["calculationPeriodDatesReference", "initialStub", "finalStub"],
|
|
91
|
+
"swap": ["swapStream", "additionalPayment"],
|
|
92
|
+
"swapStream": ["payerPartyReference", "receiverPartyReference", "calculationPeriodDates", "paymentDates", "resetDates", "calculationPeriodAmount", "stubCalculationPeriodAmount", "principalExchanges", "cashflows"],
|
|
93
|
+
"terminationDate": ["unadjustedDate", "dateAdjustments"],
|
|
94
|
+
"trade": ["tradeHeader", "swap", "calculationAgent", "fra"],
|
|
95
|
+
"tradeHeader": ["partyTradeIdentifier", "tradeDate"]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fpml-convert
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: FpML interest-rate-swap XML <-> JSON, standard library only. One projection mapping, also shipped as a JS interpreter and a C++17 converter.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://danieltuzes.github.io/fpml-convert/
|
|
7
|
+
Project-URL: Source, https://github.com/danieltuzes/fpml-convert
|
|
8
|
+
Keywords: fpml,xml,json,structile,derivatives,swap
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# fpml-convert (Python)
|
|
13
|
+
|
|
14
|
+
Standard-library-only conversion between **FpML interest-rate-swap XML and
|
|
15
|
+
plain JSON**, in both directions. No `lxml`, no third-party JSON — just
|
|
16
|
+
`xml.etree.ElementTree` and `json`.
|
|
17
|
+
|
|
18
|
+
One projection mapping, also implemented as a JavaScript interpreter (for
|
|
19
|
+
the [Structile](https://danieltuzes.github.io/structile/) viewer) and a
|
|
20
|
+
pure-STL C++17 converter; the three agree byte-for-byte on a shared test
|
|
21
|
+
corpus. Downloads, the full version archive, and the mapping's precise
|
|
22
|
+
rules: <https://danieltuzes.github.io/fpml-convert/>.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install fpml-convert
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
No runtime dependencies. Python 3.8+.
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from fpml_convert import fpml_to_json, json_to_fpml
|
|
36
|
+
|
|
37
|
+
value = fpml_to_json(xml_text) # -> dict
|
|
38
|
+
xml_text2 = json_to_fpml(value) # -> schema-ordered FpML, round-trips at the value level
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
# CLI (direction inferred from the input, or forced with --to)
|
|
43
|
+
python -m fpml_convert swap.xml -o swap.json
|
|
44
|
+
python -m fpml_convert swap.json -o swap.xml
|
|
45
|
+
cat swap.xml | python -m fpml_convert - > swap.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## The mapping
|
|
49
|
+
|
|
50
|
+
- Element local name → object key; a tag repeated under one parent → an
|
|
51
|
+
array in document order.
|
|
52
|
+
- Namespace prefixes and ElementTree Clark notation (`{uri}local`) are both
|
|
53
|
+
reduced to the local name, so the result is identical across a browser
|
|
54
|
+
`DOMParser` and a server-side parser.
|
|
55
|
+
- Attributes → `@`-prefixed keys, before any child keys, ordered `@id`,
|
|
56
|
+
`@xsi:type`, `@href`, then the rest. `xsi:type` is kept verbatim.
|
|
57
|
+
- A pure reference element (`href`, no children) → the scalar `«id»`;
|
|
58
|
+
Structile renders that as a link to the element whose `@id` matches.
|
|
59
|
+
- Typed lexical forms are preserved as strings, never coerced
|
|
60
|
+
(`1000000.00` stays exactly that).
|
|
61
|
+
- Every element the projection descends into is checked against a
|
|
62
|
+
supported-subset allowlist — an unknown element raises
|
|
63
|
+
`FpmlUnsupportedError` rather than projecting partially.
|
|
64
|
+
- Serialization re-emits children in schema (`xs:sequence`) order.
|
|
65
|
+
|
|
66
|
+
## Scope
|
|
67
|
+
|
|
68
|
+
FpML 5.10 confirmation view, interest-rate **swap** `dataDocument` only. A
|
|
69
|
+
non-IR-swap document raises `ValueError`. FRA and other products widen the
|
|
70
|
+
allowlist in later releases. An FpML-AWG interchange-shape mode is planned.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
Apache-2.0. The FpML example files used for testing are redistributed
|
|
75
|
+
under the [FpML Public License](https://www.fpml.org/the_standard/fpml-public-license/).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
fpml_convert/__init__.py
|
|
4
|
+
fpml_convert/__main__.py
|
|
5
|
+
fpml_convert/_project.py
|
|
6
|
+
fpml_convert/_rules.py
|
|
7
|
+
fpml_convert/_serialize.py
|
|
8
|
+
fpml_convert/rules.json
|
|
9
|
+
fpml_convert.egg-info/PKG-INFO
|
|
10
|
+
fpml_convert.egg-info/SOURCES.txt
|
|
11
|
+
fpml_convert.egg-info/dependency_links.txt
|
|
12
|
+
fpml_convert.egg-info/entry_points.txt
|
|
13
|
+
fpml_convert.egg-info/top_level.txt
|
|
14
|
+
tests/test_parity.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fpml_convert
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# setuptools ships with every Python — so `python -m build --no-isolation`
|
|
3
|
+
# needs no network (FpML interpreter plan Phase 8: the local artifact
|
|
4
|
+
# assembly must run offline). rules.json is shipped as package data; a
|
|
5
|
+
# repo checkout without a packaged copy falls back to ../../src/rules.json
|
|
6
|
+
# (see fpml_convert/_rules.py).
|
|
7
|
+
requires = ["setuptools>=61.0"]
|
|
8
|
+
build-backend = "setuptools.build_meta"
|
|
9
|
+
|
|
10
|
+
[project]
|
|
11
|
+
name = "fpml-convert"
|
|
12
|
+
# Tracks structile-fpml's package.json `version` (this repo's code semver),
|
|
13
|
+
# NOT the FpML schema version (that's fpml_convert.FPML_VERSION / "5.10").
|
|
14
|
+
version = "0.6.0"
|
|
15
|
+
description = "FpML interest-rate-swap XML <-> JSON, standard library only. One projection mapping, also shipped as a JS interpreter and a C++17 converter."
|
|
16
|
+
readme = "README.md"
|
|
17
|
+
requires-python = ">=3.8"
|
|
18
|
+
license = "Apache-2.0"
|
|
19
|
+
keywords = ["fpml", "xml", "json", "structile", "derivatives", "swap"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://danieltuzes.github.io/fpml-convert/"
|
|
23
|
+
# The projection source lives in a private repo; this public one hosts the
|
|
24
|
+
# built artifacts, the version archive, and a pointer to the source.
|
|
25
|
+
Source = "https://github.com/danieltuzes/fpml-convert"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
fpml-convert = "fpml_convert.__main__:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
packages = ["fpml_convert"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-data]
|
|
34
|
+
# scripts/build_artifacts.mjs copies src/rules.json here before building
|
|
35
|
+
# the wheel/sdist; it is gitignored (a checkout reads ../../src/rules.json).
|
|
36
|
+
fpml_convert = ["rules.json"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Cross-language parity + round-trip test for the Python converter.
|
|
2
|
+
|
|
3
|
+
The JS bundle's committed snapshots (``test/__snapshots__/*.json``) are the
|
|
4
|
+
contract: ``fpml_to_json`` here must reproduce them BYTE-FOR-BYTE (same key
|
|
5
|
+
order, same ``JSON.stringify(v, null, 2) + "\\n"`` formatting), so the two
|
|
6
|
+
implementations can never silently diverge on the shared corpus.
|
|
7
|
+
|
|
8
|
+
Runs three ways:
|
|
9
|
+
* ``python python/tests/test_parity.py`` (plain script, exits non-zero on failure)
|
|
10
|
+
* ``python -m pytest python/tests`` (each check is a ``test_*``)
|
|
11
|
+
* ``npm run test:py`` (wired in package.json)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_REPO = Path(__file__).resolve().parents[2]
|
|
20
|
+
sys.path.insert(0, str(_REPO / "python"))
|
|
21
|
+
|
|
22
|
+
from fpml_convert import FpmlUnsupportedError, fpml_to_json, json_to_fpml # noqa: E402
|
|
23
|
+
|
|
24
|
+
_CORPUS = _REPO / "test" / "corpus"
|
|
25
|
+
_FIXTURES = _REPO / "test" / "fixtures"
|
|
26
|
+
_SNAPS = _REPO / "test" / "__snapshots__"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _samples():
|
|
30
|
+
"""(snapshot-name, xml-path) for every file the JS suite snapshots."""
|
|
31
|
+
out = [("ir-swap-minimal", _FIXTURES / "ir-swap-minimal.xml")]
|
|
32
|
+
for x in sorted(_CORPUS.glob("*.xml")):
|
|
33
|
+
out.append(("corpus-" + x.stem, x))
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_projection_matches_the_js_snapshots_byte_for_byte():
|
|
38
|
+
for name, xml_path in _samples():
|
|
39
|
+
snap = _SNAPS / (name + ".json")
|
|
40
|
+
assert snap.is_file(), f"missing JS snapshot {snap.name} — run `npm run snapshots` in structile-fpml"
|
|
41
|
+
got = json.dumps(fpml_to_json(xml_path.read_text(encoding="utf-8")), indent=2, ensure_ascii=False) + "\n"
|
|
42
|
+
assert got == snap.read_text(encoding="utf-8"), (
|
|
43
|
+
f"{xml_path.name}: Python projection differs from the JS snapshot {snap.name}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_round_trips_every_sample_at_the_value_level():
|
|
48
|
+
for name, xml_path in _samples():
|
|
49
|
+
v1 = fpml_to_json(xml_path.read_text(encoding="utf-8"))
|
|
50
|
+
v2 = fpml_to_json(json_to_fpml(v1))
|
|
51
|
+
assert v2 == v1, f"{xml_path.name}: value changed across json_to_fpml -> fpml_to_json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_serialize_is_independent_of_key_order():
|
|
55
|
+
def scramble(node):
|
|
56
|
+
if isinstance(node, list):
|
|
57
|
+
return [scramble(n) for n in node]
|
|
58
|
+
if isinstance(node, dict):
|
|
59
|
+
return {k: scramble(node[k]) for k in reversed(list(node))}
|
|
60
|
+
return node
|
|
61
|
+
|
|
62
|
+
checked = 0
|
|
63
|
+
for name, xml_path in _samples():
|
|
64
|
+
original = fpml_to_json(xml_path.read_text(encoding="utf-8"))
|
|
65
|
+
scrambled = scramble(original)
|
|
66
|
+
assert json_to_fpml(scrambled) == json_to_fpml(original), (
|
|
67
|
+
f"{xml_path.name}: serialize output depends on key order — binding not applied"
|
|
68
|
+
)
|
|
69
|
+
assert fpml_to_json(json_to_fpml(scrambled)) == original, f"{xml_path.name}: lost data"
|
|
70
|
+
checked += 1
|
|
71
|
+
assert checked >= 8
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_rejects_documents_outside_the_subset():
|
|
75
|
+
# non-dataDocument root
|
|
76
|
+
try:
|
|
77
|
+
fpml_to_json("<FpML><foo/></FpML>")
|
|
78
|
+
assert False, "expected a ValueError for a non-dataDocument root"
|
|
79
|
+
except ValueError as e:
|
|
80
|
+
assert "root <dataDocument>" in str(e)
|
|
81
|
+
|
|
82
|
+
# dataDocument with no <swap>
|
|
83
|
+
no_swap = ('<dataDocument xmlns="http://www.fpml.org/FpML-5/confirmation" '
|
|
84
|
+
'fpmlVersion="5-10"><party id="P"><partyName>X</partyName></party></dataDocument>')
|
|
85
|
+
try:
|
|
86
|
+
fpml_to_json(no_swap)
|
|
87
|
+
assert False, "expected a ValueError for a <dataDocument> with no <swap>"
|
|
88
|
+
except ValueError as e:
|
|
89
|
+
assert "no <swap>" in str(e)
|
|
90
|
+
|
|
91
|
+
# a real FpML FRA example — <fra>, not <swap>, so the root gate rejects it
|
|
92
|
+
fra = _FIXTURES / "ird-ex08-fra.xml"
|
|
93
|
+
if fra.is_file():
|
|
94
|
+
try:
|
|
95
|
+
fpml_to_json(fra.read_text(encoding="utf-8"))
|
|
96
|
+
assert False, "expected the FRA fixture to be rejected"
|
|
97
|
+
except ValueError as e:
|
|
98
|
+
assert "no <swap>" in str(e)
|
|
99
|
+
|
|
100
|
+
# an element outside FPML_SUPPORTED
|
|
101
|
+
alien = _FIXTURES.joinpath("ir-swap-minimal.xml").read_text(encoding="utf-8").replace(
|
|
102
|
+
"<tradeDate>2024-01-15</tradeDate>",
|
|
103
|
+
"<tradeDate>2024-01-15</tradeDate><novationEvent/>",
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
fpml_to_json(alien)
|
|
107
|
+
assert False, "expected FpmlUnsupportedError for <novationEvent>"
|
|
108
|
+
except FpmlUnsupportedError as e:
|
|
109
|
+
assert "<novationEvent>" in str(e)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_json_to_fpml_rejects_a_non_datadocument_value():
|
|
113
|
+
for bad in (None, [1, 2, 3], "nope"):
|
|
114
|
+
try:
|
|
115
|
+
json_to_fpml(bad)
|
|
116
|
+
assert False, f"expected ValueError for {bad!r}"
|
|
117
|
+
except ValueError as e:
|
|
118
|
+
assert "expected the projected" in str(e)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _run():
|
|
122
|
+
checks = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
|
123
|
+
failed = 0
|
|
124
|
+
for fn in checks:
|
|
125
|
+
try:
|
|
126
|
+
fn()
|
|
127
|
+
print(f"ok {fn.__name__}")
|
|
128
|
+
except Exception as e: # noqa: BLE001
|
|
129
|
+
failed += 1
|
|
130
|
+
print(f"FAIL {fn.__name__}: {e}")
|
|
131
|
+
print(f"\n{len(checks) - failed}/{len(checks)} passed")
|
|
132
|
+
return 1 if failed else 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
sys.exit(_run())
|