Skip to content

Commit 6835216

Browse files
feat: add sovereign_debt_py plotting subpackage with yield curve, timeseries, spread, fan chart, and PNG export
Co-authored-by: zachessesjohnson <168567202+zachessesjohnson@users.noreply.github.com>
1 parent b573a0a commit 6835216

File tree

10 files changed

+1116
-3
lines changed

10 files changed

+1116
-3
lines changed

README.md

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ The library covers the full workflow of a sovereign debt analyst: from descripti
3232
- [Event Studies & Early Warning](#event-studies--early-warning)
3333
5. [Error Handling](#error-handling)
3434
6. [Running the Tests](#running-the-tests)
35-
7. [Extracting sovereign\_debt\_py](#extracting-sovereign_debt_py)
35+
7. [sovereign\_debt\_py plotting](#sovereign_debt_py-plotting)
36+
8. [Extracting sovereign\_debt\_py](#extracting-sovereign_debt_py)
3637

3738
---
3839

@@ -1713,7 +1714,99 @@ pip install pytest
17131714
python -m pytest
17141715
```
17151716

1716-
All 154 tests should pass in under 5 seconds.
1717+
All 184 tests should pass in under 5 seconds.
1718+
1719+
---
1720+
1721+
## sovereign\_debt\_py plotting
1722+
1723+
`sovereign_debt_py` is a pure-Python analytics library (no PyXLL dependency)
1724+
included in this repository. Its `plotting` subpackage provides Matplotlib-
1725+
based charting functions for all common sovereign-debt visualisations.
1726+
1727+
### Quick start
1728+
1729+
```python
1730+
from sovereign_debt_py.plotting import (
1731+
plot_yield_curve,
1732+
plot_timeseries,
1733+
plot_rolling_average,
1734+
plot_spread,
1735+
plot_fan_chart,
1736+
fig_to_png_bytes,
1737+
)
1738+
```
1739+
1740+
### Yield curve
1741+
1742+
```python
1743+
fig, ax = plot_yield_curve(
1744+
[1, 2, 5, 10], # tenors (years)
1745+
[0.04, 0.045, 0.05, 0.052], # yields (decimal)
1746+
title="Sovereign Yield Curve",
1747+
style="line+markers", # "line" | "markers" | "line+markers"
1748+
as_percent=True, # format y-axis as 4.00%, 4.50%, …
1749+
)
1750+
fig.show()
1751+
```
1752+
1753+
### Time series
1754+
1755+
```python
1756+
import datetime
1757+
1758+
dates = [datetime.date(2023, m, 1) for m in range(1, 13)]
1759+
yields = [0.04 + 0.001 * m for m in range(12)]
1760+
1761+
fig, ax = plot_timeseries(dates, yields, title="10Y Yield – 2023")
1762+
```
1763+
1764+
### Rolling average overlay
1765+
1766+
```python
1767+
fig, ax = plot_rolling_average(
1768+
dates, yields,
1769+
window=3,
1770+
base_label="Raw yield",
1771+
roll_label="3-month MA",
1772+
)
1773+
```
1774+
1775+
### Spread chart
1776+
1777+
```python
1778+
em_yields = [0.06 + 0.001 * i for i in range(6)]
1779+
us_yields = [0.04 + 0.001 * i for i in range(6)]
1780+
1781+
fig, ax = plot_spread(
1782+
dates[:6], em_yields, us_yields,
1783+
label_a="EM", label_b="US",
1784+
spread_label="EM–US Spread",
1785+
)
1786+
```
1787+
1788+
### DSA fan chart
1789+
1790+
```python
1791+
x = list(range(2024, 2031))
1792+
p50 = [60, 62, 64, 63, 61, 60, 59]
1793+
1794+
bands = {
1795+
(0.10, 0.90): ([55] * 7, [70] * 7),
1796+
(0.25, 0.75): ([58] * 7, [66] * 7),
1797+
}
1798+
1799+
fig, ax = plot_fan_chart(x, p50, bands, title="Debt/GDP Fan Chart")
1800+
```
1801+
1802+
### Export to PNG bytes (for embedding / reports)
1803+
1804+
```python
1805+
png: bytes = fig_to_png_bytes(fig, width_px=800, height_px=450, dpi=120)
1806+
# png starts with b'\x89PNG' and can be written to a file or embedded in Excel:
1807+
with open("chart.png", "wb") as f:
1808+
f.write(png)
1809+
```
17171810

17181811
---
17191812

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ dependencies = [
1313
"scipy",
1414
"statsmodels",
1515
"scikit-learn",
16+
"matplotlib",
1617
]
1718

1819
[project.optional-dependencies]
1920
dev = ["pytest"]
2021

2122
[tool.setuptools.packages.find]
2223
where = ["."]
23-
include = ["sovereign_debt_xl*"]
24+
include = ["sovereign_debt_xl*", "sovereign_debt_py*"]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ pandas
33
scipy
44
statsmodels
55
scikit-learn
6+
matplotlib
67
pytest

sovereign_debt_py/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""sovereign_debt_py – pure-Python sovereign debt analytics library."""
2+
3+
from .plotting import ( # noqa: F401
4+
plot_yield_curve,
5+
plot_timeseries,
6+
plot_rolling_average,
7+
plot_spread,
8+
plot_fan_chart,
9+
fig_to_png_bytes,
10+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Public API for sovereign_debt_py.plotting."""
2+
3+
from .yield_curve import plot_yield_curve # noqa: F401
4+
from .timeseries import plot_timeseries, plot_rolling_average, plot_spread # noqa: F401
5+
from .dsa import plot_fan_chart # noqa: F401
6+
from .core import fig_to_png_bytes # noqa: F401

sovereign_debt_py/plotting/core.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""sovereign_debt_py.plotting.core
2+
3+
Internal helpers: validation, styling, figure creation, and the
4+
fig_to_png_bytes rendering utility.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import io
10+
import datetime
11+
from typing import Sequence, Any
12+
13+
import numpy as np
14+
import matplotlib
15+
import matplotlib.pyplot as plt
16+
import matplotlib.ticker as mticker
17+
from matplotlib.figure import Figure
18+
from matplotlib.axes import Axes
19+
20+
21+
# ---------------------------------------------------------------------------
22+
# Input validation
23+
# ---------------------------------------------------------------------------
24+
25+
def to_1d_array(x: Any) -> np.ndarray:
26+
"""Convert list, tuple, numpy array, or pandas Series to a 1-D ndarray.
27+
28+
Raises
29+
------
30+
ValueError
31+
If *x* cannot be converted or is not 1-D after conversion.
32+
"""
33+
try:
34+
# pandas Series / Index
35+
arr = np.asarray(x, dtype=float)
36+
except (TypeError, ValueError) as exc:
37+
raise ValueError(
38+
f"Cannot convert input to a numeric 1-D array: {exc}"
39+
) from exc
40+
41+
if arr.ndim != 1:
42+
raise ValueError(
43+
f"Expected a 1-D array-like, got shape {arr.shape}."
44+
)
45+
return arr
46+
47+
48+
def validate_same_length(*arrays: np.ndarray) -> None:
49+
"""Raise ValueError if any two arrays differ in length."""
50+
lengths = [len(a) for a in arrays]
51+
if len(set(lengths)) > 1:
52+
raise ValueError(
53+
f"All inputs must have the same length; got lengths: {lengths}."
54+
)
55+
56+
57+
def coerce_dates(dates: Sequence[Any]) -> list[datetime.datetime]:
58+
"""Convert a sequence of date-like objects to :class:`datetime.datetime`.
59+
60+
Accepts ``datetime.date``, ``datetime.datetime``, ISO-format strings,
61+
and numpy / pandas timestamp-like objects.
62+
63+
Raises
64+
------
65+
ValueError
66+
If any element cannot be parsed.
67+
"""
68+
result: list[datetime.datetime] = []
69+
for item in dates:
70+
if isinstance(item, datetime.datetime):
71+
result.append(item)
72+
elif isinstance(item, datetime.date):
73+
result.append(datetime.datetime(item.year, item.month, item.day))
74+
elif isinstance(item, str):
75+
try:
76+
result.append(datetime.datetime.fromisoformat(item))
77+
except ValueError as exc:
78+
raise ValueError(
79+
f"Cannot parse date string {item!r}: {exc}"
80+
) from exc
81+
else:
82+
# numpy datetime64, pandas Timestamp, etc.
83+
try:
84+
import pandas as pd # optional
85+
ts = pd.Timestamp(item)
86+
result.append(ts.to_pydatetime())
87+
except Exception:
88+
# Last resort: try numpy datetime64 → Python datetime
89+
try:
90+
ts_ms = np.datetime64(item, "ms")
91+
epoch = np.datetime64(0, "ms")
92+
ms = int((ts_ms - epoch) / np.timedelta64(1, "ms"))
93+
result.append(
94+
datetime.datetime(1970, 1, 1)
95+
+ datetime.timedelta(milliseconds=ms)
96+
)
97+
except Exception as exc2:
98+
raise ValueError(
99+
f"Cannot coerce {item!r} to datetime: {exc2}"
100+
) from exc2
101+
return result
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# Styling
106+
# ---------------------------------------------------------------------------
107+
108+
def apply_sdpy_style(ax: Axes, *, grid: bool = True) -> None:
109+
"""Apply consistent sovereign_debt_py chart styling to *ax*.
110+
111+
Adjusts spines, font sizes, line widths, and optionally adds a grid.
112+
Does **not** call ``plt.style.use`` globally.
113+
"""
114+
ax.spines["top"].set_visible(False)
115+
ax.spines["right"].set_visible(False)
116+
ax.spines["left"].set_linewidth(0.8)
117+
ax.spines["bottom"].set_linewidth(0.8)
118+
119+
ax.tick_params(axis="both", labelsize=9, length=4, width=0.8)
120+
ax.xaxis.label.set_size(10)
121+
ax.yaxis.label.set_size(10)
122+
if ax.get_title():
123+
ax.title.set_size(11)
124+
ax.title.set_fontweight("bold")
125+
126+
if grid:
127+
ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.6)
128+
ax.set_axisbelow(True)
129+
else:
130+
ax.grid(False)
131+
132+
133+
# ---------------------------------------------------------------------------
134+
# Figure factory
135+
# ---------------------------------------------------------------------------
136+
137+
def _make_fig_ax(
138+
fig: Figure | None,
139+
ax: Axes | None,
140+
) -> tuple[Figure, Axes]:
141+
"""Return *(fig, ax)*, creating new ones if not supplied."""
142+
if fig is None and ax is None:
143+
fig, ax = plt.subplots()
144+
elif fig is None:
145+
fig = ax.get_figure()
146+
elif ax is None:
147+
ax = fig.gca()
148+
return fig, ax # type: ignore[return-value]
149+
150+
151+
# ---------------------------------------------------------------------------
152+
# Rendering helper
153+
# ---------------------------------------------------------------------------
154+
155+
def fig_to_png_bytes(
156+
fig: Figure,
157+
*,
158+
width_px: int = 800,
159+
height_px: int = 450,
160+
dpi: int = 120,
161+
tight: bool = True,
162+
close: bool = False,
163+
) -> bytes:
164+
"""Render *fig* to PNG and return the raw bytes.
165+
166+
The figure's size is set (in inches) based on ``width_px / dpi`` and
167+
``height_px / dpi`` before saving, so the output is deterministic.
168+
169+
Parameters
170+
----------
171+
fig:
172+
A Matplotlib :class:`~matplotlib.figure.Figure`.
173+
width_px:
174+
Output width in pixels.
175+
height_px:
176+
Output height in pixels.
177+
dpi:
178+
Dots per inch for the PNG encoder.
179+
tight:
180+
If *True*, call ``tight_layout()`` before saving.
181+
close:
182+
If *True*, close *fig* after saving (useful for embedding workflows).
183+
184+
Returns
185+
-------
186+
bytes
187+
Raw PNG bytes (starts with ``b'\\x89PNG'``).
188+
"""
189+
fig.set_size_inches(width_px / dpi, height_px / dpi)
190+
if tight:
191+
fig.tight_layout()
192+
193+
buf = io.BytesIO()
194+
# Use the Agg backend for headless rendering without touching the global
195+
# backend. canvas.print_figure works regardless of the current backend.
196+
agg_canvas = matplotlib.backends.backend_agg.FigureCanvasAgg(fig)
197+
agg_canvas.print_figure(buf, format="png", dpi=dpi)
198+
png_bytes = buf.getvalue()
199+
200+
if close:
201+
plt.close(fig)
202+
203+
return png_bytes

0 commit comments

Comments
 (0)