tesorotools-python 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl

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.
@@ -47,9 +47,21 @@ from matplotlib.dates import (
47
47
  from matplotlib.figure import Figure
48
48
  from matplotlib.font_manager import FontProperties
49
49
  from matplotlib.legend import Legend as MplLegend
50
- from matplotlib.ticker import FixedLocator, FuncFormatter, MaxNLocator
50
+ from matplotlib.text import Text
51
+ from matplotlib.ticker import (
52
+ FixedFormatter,
53
+ FixedLocator,
54
+ FuncFormatter,
55
+ MaxNLocator,
56
+ NullFormatter,
57
+ )
51
58
  from yaml.nodes import MappingNode
52
59
 
60
+ from tesorotools.artists._dates import (
61
+ DateLayout,
62
+ date_layouts,
63
+ period_label,
64
+ )
53
65
  from tesorotools.utils.matplotlib import (
54
66
  PLOT_CONFIG,
55
67
  apply_house_style,
@@ -110,6 +122,53 @@ def date_maxticks(density: str, *, compact: bool) -> int:
110
122
  return int(table.get(density, fallback[density]))
111
123
 
112
124
 
125
+ # Most date labels an axis shows per date_density level, read from
126
+ # ``compact.date_max_labels`` / ``line.date_max_labels`` in ``plots.yaml``:
127
+ # the finest calendar step (every year, every month...) whose labels fit in
128
+ # the axis width AND stay within this count wins.
129
+ _DATE_MAX_LABELS_FALLBACK_COMPACT: dict[str, int] = {
130
+ "few": 4,
131
+ "default": 8,
132
+ "many": 14,
133
+ }
134
+ _DATE_MAX_LABELS_FALLBACK_LINE: dict[str, int] = {
135
+ "few": 6,
136
+ "default": 15,
137
+ "many": 30,
138
+ }
139
+
140
+
141
+ def date_max_labels(density: str, *, compact: bool) -> int:
142
+ """Most date labels a ``date_density`` level allows on the x-axis.
143
+
144
+ Reads ``compact.date_max_labels`` / ``line.date_max_labels`` from
145
+ ``plots.yaml``, falling back to the built-in table when the config
146
+ (or a level) is absent.
147
+ """
148
+ cfg = COMPACT_CONFIG if compact else LINE_CONFIG
149
+ fallback = (
150
+ _DATE_MAX_LABELS_FALLBACK_COMPACT
151
+ if compact
152
+ else _DATE_MAX_LABELS_FALLBACK_LINE
153
+ )
154
+ table: dict[str, Any] = cfg.get("date_max_labels", fallback)
155
+ return int(table.get(density, fallback[density]))
156
+
157
+
158
+ def _date_axis_config(*, compact: bool) -> tuple[int, float, float]:
159
+ """Calendar date-axis knobs of a mode, from ``plots.yaml``.
160
+
161
+ ``(date_monthly_max_months, date_centered_min_fraction,
162
+ date_label_gap_em)``.
163
+ """
164
+ cfg = COMPACT_CONFIG if compact else LINE_CONFIG
165
+ return (
166
+ int(cfg.get("date_monthly_max_months", 24)),
167
+ float(cfg.get("date_centered_min_fraction", 0.5)),
168
+ float(cfg.get("date_label_gap_em", 0.3)),
169
+ )
170
+
171
+
113
172
  def dynamic_dpi(fig: Figure) -> int:
114
173
  """Save DPI adapted to the figure's physical size.
115
174
 
@@ -804,6 +863,188 @@ def _date_label_ha(
804
863
  return "right" if rotation > 0 else "left"
805
864
 
806
865
 
866
+ def _style_date_labels(ax: Axes, rot: float, *, minor: bool) -> None:
867
+ """Tilt the date labels *rot* degrees with the matching anchor.
868
+
869
+ Rotation goes in the axis tick kwargs; ``ha`` and the anchor rotation
870
+ mode are per-label Text properties, so they are reapplied after every
871
+ draw or locator swap.
872
+ """
873
+ ha = _date_label_ha(None if rot == 0 else rot)
874
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
875
+ axis="x", which="minor" if minor else "major", rotation=rot
876
+ )
877
+ for label in ax.get_xticklabels(minor=minor):
878
+ label.set_horizontalalignment(ha)
879
+ if rot != 0:
880
+ label.set_rotation_mode("anchor")
881
+
882
+
883
+ def _layout_texts(layout: DateLayout, *, pin_start: bool) -> list[str]:
884
+ """The label texts of *layout*, as the axis will print them."""
885
+ if layout.centered:
886
+ # centred: each label names its period
887
+ return [
888
+ period_label(p, layout.step, first=i == 0)
889
+ for i, p in enumerate(layout.periods)
890
+ ]
891
+ values = [float(cast(Any, date2num(t))) for t in layout.ticks]
892
+ formatter = _tick_formatter(FixedLocator(values), values[0], pin_start)
893
+ return list(formatter.format_ticks(values))
894
+
895
+
896
+ def _tick_formatter(
897
+ locator: FixedLocator, start: float, pin_start: bool
898
+ ) -> ConciseDateFormatter:
899
+ return (
900
+ _StartLabeledConcise(locator, start)
901
+ if pin_start
902
+ else ConciseDateFormatter(locator)
903
+ )
904
+
905
+
906
+ def calendar_date_axis(
907
+ ax: Axes,
908
+ layouts: list[DateLayout],
909
+ *,
910
+ pin_start: bool,
911
+ rotation: float | None = None,
912
+ auto_rotate: bool = False,
913
+ gap_em: float = 0.3,
914
+ ) -> DateLayout:
915
+ """Label every year (or month) the axis can hold.
916
+
917
+ *layouts* are the candidate calendar steps, finest first (see
918
+ :func:`tesorotools.artists._dates.date_layouts`). Each one's labels
919
+ are measured at their real size and position, and the first whose
920
+ labels do not overlap wins: a decade shows every year, thirty years
921
+ fall back to one every two or five. When none fits, the coarsest is
922
+ used. Returns the layout drawn.
923
+
924
+ With the start pinned, the first calendar tick gives way to the
925
+ (wider) start label when they collide and it lies under half a step
926
+ from the start: a series from October 2015 shows ``oct. 2015``,
927
+ 2017, 2018... Further away, dropping it would read as a skipped
928
+ year, so that step counts as not fitting.
929
+
930
+ Centred layouts (``date_labels: centered``) put the tick marks on the
931
+ period boundaries and the labels, as minor ticks without a mark, in
932
+ the middle of each period.
933
+
934
+ ``rotation`` tilts the labels. ``auto_rotate`` (ignored when a
935
+ rotation is given) tries 0, 30, 45, 60 and 90 degrees and keeps the
936
+ flattest tilt that reaches the finest step any of them reaches.
937
+
938
+ Neighbouring labels must stay *gap_em* ems apart
939
+ (``date_label_gap_em`` in ``plots.yaml``).
940
+ """
941
+ fig = ax.get_figure()
942
+ assert fig is not None
943
+ # settle the layout so dates map to their final pixels
944
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
945
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
946
+ font = ax.xaxis.get_major_ticks()[0].label1.get_fontproperties()
947
+ # a fixed share of an em between neighbouring labels, whatever the dpi
948
+ pad_px = (
949
+ float(
950
+ renderer.points_to_pixels(font.get_size_in_points()) # type: ignore[reportUnknownMemberType]
951
+ )
952
+ * gap_em
953
+ )
954
+
955
+ def _boxes(layout: DateLayout, rot: float) -> list[Any]:
956
+ texts = _layout_texts(layout, pin_start=pin_start)
957
+ ha = _date_label_ha(None if rot == 0 else rot)
958
+ boxes: list[Any] = []
959
+ for when, text in zip(layout.labels, texts):
960
+ x_px = float(
961
+ ax.transData.transform((float(cast(Any, date2num(when))), 0))[0]
962
+ )
963
+ probe = Text(
964
+ x_px,
965
+ 0.0,
966
+ text,
967
+ fontproperties=font,
968
+ rotation=rot,
969
+ horizontalalignment=ha,
970
+ rotation_mode="anchor" if rot != 0 else "default",
971
+ )
972
+ probe.set_figure(fig)
973
+ boxes.append(probe.get_window_extent(renderer)) # type: ignore[reportUnknownArgumentType]
974
+ return boxes
975
+
976
+ def _fit(layout: DateLayout, rot: float) -> list[int] | None:
977
+ boxes = _boxes(layout, rot)
978
+ kept = list(range(len(boxes)))
979
+ centered = layout.centered
980
+ if pin_start and not centered and len(kept) > 2:
981
+ # the first calendar tick may give way to the (wider) start
982
+ # label only when it sits under half a step from the start:
983
+ # further away, dropping it would open a gap that reads as a
984
+ # skipped year, so the step does not fit instead
985
+ when = [float(cast(Any, date2num(t))) for t in layout.ticks]
986
+ near = when[1] - when[0] < (when[2] - when[1]) / 2
987
+ if near and boxes[1].x0 <= boxes[0].x1 + pad_px:
988
+ kept.pop(1)
989
+ clear = all(
990
+ boxes[b].x0 > boxes[a].x1 + pad_px for a, b in zip(kept, kept[1:])
991
+ )
992
+ return kept if clear else None
993
+
994
+ tilts = (
995
+ (0.0, 30.0, 45.0, 60.0, 90.0)
996
+ if auto_rotate and rotation is None
997
+ else (0.0 if rotation is None else float(rotation),)
998
+ )
999
+ best: tuple[int, float, list[int]] | None = None
1000
+ for rot in tilts:
1001
+ for i, layout in enumerate(layouts):
1002
+ if best is not None and i >= best[0]:
1003
+ break
1004
+ kept = _fit(layout, rot)
1005
+ if kept is not None:
1006
+ best = (i, rot, kept)
1007
+ break
1008
+ if best is not None and best[0] == 0:
1009
+ break
1010
+ if best is None:
1011
+ coarsest = layouts[-1]
1012
+ best = (len(layouts) - 1, tilts[0], list(range(len(coarsest.labels))))
1013
+ index, rot, kept = best
1014
+ layout = layouts[index]
1015
+ centered = layout.centered
1016
+
1017
+ if centered:
1018
+ major = FixedLocator(
1019
+ [float(cast(Any, date2num(t))) for t in layout.ticks]
1020
+ )
1021
+ ax.xaxis.set_major_locator(major)
1022
+ ax.xaxis.set_major_formatter(NullFormatter())
1023
+ texts = _layout_texts(layout, pin_start=pin_start)
1024
+ ax.xaxis.set_minor_locator(
1025
+ FixedLocator(
1026
+ [float(cast(Any, date2num(layout.labels[k]))) for k in kept]
1027
+ )
1028
+ )
1029
+ ax.xaxis.set_minor_formatter(FixedFormatter([texts[k] for k in kept]))
1030
+ ax.tick_params( # type: ignore[reportUnknownMemberType]
1031
+ axis="x",
1032
+ which="minor",
1033
+ length=0,
1034
+ labelsize=ax.xaxis.get_major_ticks()[0].label1.get_fontsize(),
1035
+ )
1036
+ else:
1037
+ values = [float(cast(Any, date2num(layout.ticks[k]))) for k in kept]
1038
+ fixed = FixedLocator(values)
1039
+ ax.xaxis.set_major_locator(fixed)
1040
+ ax.xaxis.set_major_formatter(
1041
+ _tick_formatter(fixed, values[0], pin_start)
1042
+ )
1043
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
1044
+ _style_date_labels(ax, rot, minor=centered)
1045
+ return layout
1046
+
1047
+
807
1048
  def compact_date_axis(
808
1049
  ax: Axes,
809
1050
  data_min: Any,
@@ -813,8 +1054,22 @@ def compact_date_axis(
813
1054
  rotation: float | None = None,
814
1055
  pin_start: bool | None = None,
815
1056
  auto_rotate: bool = False,
1057
+ max_labels: int | None = None,
1058
+ step: str = "auto",
1059
+ labels: str = "tick",
816
1060
  ) -> None:
817
- """Compact date x-axis: few ticks, anchored to the data span.
1061
+ """Compact date x-axis: as many calendar dates as fit the narrow figure.
1062
+
1063
+ The axis labels every year (or, over two years at most, every month)
1064
+ that fits in the width without overlapping and within *max_labels*
1065
+ (``None`` reads ``compact.date_max_labels`` for the default density),
1066
+ stepping to one every 2, 5, 10... years only when they do not fit;
1067
+ ``step`` (``"1Y"``, ``"3M"``...) fixes the step instead, and
1068
+ ``labels="centered"`` centres each year over its stretch. See
1069
+ :func:`calendar_date_axis`.
1070
+
1071
+ Spans too short for a calendar grid (a few weeks of daily data) keep
1072
+ the historical behaviour described below, with *maxticks* ticks.
818
1073
 
819
1074
  Ticks are computed over the **data span** ``[data_min,
820
1075
  data_max]`` and pinned with a ``FixedLocator``, so none
@@ -853,6 +1108,31 @@ def compact_date_axis(
853
1108
  if pin_start is None
854
1109
  else pin_start
855
1110
  )
1111
+ monthly_max, min_fraction, gap_em = _date_axis_config(compact=True)
1112
+ layouts = date_layouts(
1113
+ data_min,
1114
+ data_max,
1115
+ step=step,
1116
+ max_labels=(
1117
+ date_max_labels("default", compact=True)
1118
+ if max_labels is None
1119
+ else max_labels
1120
+ ),
1121
+ monthly_max_months=monthly_max,
1122
+ pin_start=show_start,
1123
+ centered=labels == "centered",
1124
+ min_fraction=min_fraction,
1125
+ )
1126
+ if layouts:
1127
+ calendar_date_axis(
1128
+ ax,
1129
+ layouts,
1130
+ pin_start=show_start,
1131
+ rotation=rotation,
1132
+ auto_rotate=auto_rotate,
1133
+ gap_em=gap_em,
1134
+ )
1135
+ return
856
1136
  # same span-clipped ticks (and same pinned start) the non-compact axis
857
1137
  # uses, so both modes answer "when does this start?" identically
858
1138
  ticks: list[float] = date_span_ticks(
@@ -958,8 +1238,20 @@ def apply_date_axis(
958
1238
  data_min: Any = None,
959
1239
  data_max: Any = None,
960
1240
  pin_start: bool = False,
1241
+ max_labels: int | None = None,
1242
+ step: str = "auto",
1243
+ labels: str = "tick",
961
1244
  ) -> None:
962
- """Native matplotlib date x-axis with a soft tick count and optional slant.
1245
+ """Native matplotlib date x-axis with every year (or month) that fits.
1246
+
1247
+ Given the ``data_min`` / ``data_max`` span, the axis labels every year
1248
+ (or, over two years at most, every month) that fits in the width and
1249
+ within *max_labels* (``None`` reads ``line.date_max_labels`` for the
1250
+ default density), stepping to one every 2, 5, 10... years only when
1251
+ they do not; ``step`` fixes the step and ``labels="centered"`` centres
1252
+ each year over its stretch (see :func:`calendar_date_axis`). The rest
1253
+ of this docstring describes the fallback for spans too short for a
1254
+ calendar grid, or when no span is given.
963
1255
 
964
1256
  The non-compact counterpart to :func:`compact_date_axis`: it takes over the
965
1257
  x-axis (which must carry a **native** date axis, i.e. the series were
@@ -984,6 +1276,31 @@ def apply_date_axis(
984
1276
  ``rotation=None`` has to *actively* flatten them to keep the documented
985
1277
  "horizontal unless asked" behaviour.
986
1278
  """
1279
+ if data_min is not None and data_max is not None:
1280
+ monthly_max, min_fraction, gap_em = _date_axis_config(compact=False)
1281
+ layouts = date_layouts(
1282
+ data_min,
1283
+ data_max,
1284
+ step=step,
1285
+ max_labels=(
1286
+ date_max_labels("default", compact=False)
1287
+ if max_labels is None
1288
+ else max_labels
1289
+ ),
1290
+ monthly_max_months=monthly_max,
1291
+ pin_start=pin_start,
1292
+ centered=labels == "centered",
1293
+ min_fraction=min_fraction,
1294
+ )
1295
+ if layouts:
1296
+ calendar_date_axis(
1297
+ ax,
1298
+ layouts,
1299
+ pin_start=pin_start,
1300
+ rotation=rotation,
1301
+ gap_em=gap_em,
1302
+ )
1303
+ return
987
1304
  if pin_start and data_min is not None and data_max is not None:
988
1305
  ticks = date_span_ticks(
989
1306
  data_min, data_max, maxticks=maxticks, pin_start=True
@@ -1226,8 +1543,9 @@ def style_compact_axes(
1226
1543
  :func:`declutter_spines`.
1227
1544
  """
1228
1545
  if font_pt is not None:
1546
+ # which="both": centred date labels live on the minor ticks
1229
1547
  ax.tick_params( # type: ignore[reportUnknownMemberType]
1230
- axis="both", labelsize=font_pt
1548
+ axis="both", which="both", labelsize=font_pt
1231
1549
  )
1232
1550
  if side == "right":
1233
1551
  ax.yaxis.tick_right()
@@ -0,0 +1,238 @@
1
+ """Calendar grids for date x-axes: every year (or month) the axis can hold.
2
+
3
+ Pure date maths shared by the matplotlib date axes in ``_common`` and by
4
+ the Plotly backend: which calendar steps are worth trying over a span,
5
+ where their ticks fall and, for centred labels, where each period label
6
+ sits. Nothing here draws or measures text; picking the finest step whose
7
+ labels actually fit is the caller's job.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import pandas as pd
16
+
17
+ #: Calendar steps, finest first. ``M`` counts months, ``Y`` years; the
18
+ #: month steps divide a year, so January is always on the grid.
19
+ DATE_STEPS: tuple[str, ...] = (
20
+ "1M",
21
+ "2M",
22
+ "3M",
23
+ "6M",
24
+ "1Y",
25
+ "2Y",
26
+ "5Y",
27
+ "10Y",
28
+ "20Y",
29
+ "50Y",
30
+ )
31
+
32
+ #: How a date label relates to its tick: under it, or centred over the
33
+ #: period (year or month) that starts at the tick.
34
+ DATE_LABEL_MODES: tuple[str, ...] = ("tick", "centered")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DateLayout:
39
+ """Where one calendar step puts the tick marks and the labels.
40
+
41
+ ``ticks`` are the tick marks. ``labels`` are the label positions;
42
+ with ``tick`` labels they are the ticks themselves, with ``centered``
43
+ labels they sit mid-period. ``periods`` holds, per label, the date
44
+ that names it (the tick itself, or the start of its period).
45
+ """
46
+
47
+ step: str
48
+ ticks: list[pd.Timestamp]
49
+ labels: list[pd.Timestamp]
50
+ periods: list[pd.Timestamp]
51
+ centered: bool = False
52
+
53
+
54
+ def check_date_step(step: str) -> str:
55
+ """Return *step* when it is ``auto`` or one of :data:`DATE_STEPS`."""
56
+ if step != "auto" and step not in DATE_STEPS:
57
+ raise ValueError(
58
+ f"date_step must be 'auto' or one of {DATE_STEPS}: {step!r}"
59
+ )
60
+ return step
61
+
62
+
63
+ def check_date_labels(mode: str) -> str:
64
+ """Return *mode* when it is one of :data:`DATE_LABEL_MODES`."""
65
+ if mode not in DATE_LABEL_MODES:
66
+ raise ValueError(
67
+ f"date_labels must be one of {DATE_LABEL_MODES}: {mode!r}"
68
+ )
69
+ return mode
70
+
71
+
72
+ def _parse(step: str) -> tuple[str, int]:
73
+ return step[-1], int(step[:-1])
74
+
75
+
76
+ def _month_index(ts: pd.Timestamp) -> int:
77
+ return ts.year * 12 + ts.month - 1
78
+
79
+
80
+ def _month_start(index: int) -> pd.Timestamp:
81
+ return pd.Timestamp(year=index // 12, month=index % 12 + 1, day=1)
82
+
83
+
84
+ def _period_starts(
85
+ lo: pd.Timestamp, hi: pd.Timestamp, step: str, *, anchor_year: int
86
+ ) -> list[pd.Timestamp]:
87
+ """Calendar boundaries of *step* from the one at or before *lo* to *hi*.
88
+
89
+ Year steps run from ``anchor_year`` (so a two-year step over a series
90
+ starting in 2015 gives 2015, 2017, 2019 and not the calendar 2016,
91
+ 2018); month steps stay on the calendar grid (quarters start in
92
+ January, April, July and October), which keeps January, and so the
93
+ year, on the axis.
94
+ """
95
+ unit, n = _parse(step)
96
+ if unit == "Y":
97
+ return [
98
+ pd.Timestamp(year=y, month=1, day=1)
99
+ for y in range(anchor_year, hi.year + 1, n)
100
+ ]
101
+ first = _month_index(lo) - _month_index(lo) % n
102
+ return [_month_start(i) for i in range(first, _month_index(hi) + 1, n)]
103
+
104
+
105
+ def _tick_layout(
106
+ lo: pd.Timestamp, hi: pd.Timestamp, step: str, *, pin_start: bool
107
+ ) -> DateLayout:
108
+ # pinned, the start label stands for its own year, so the grid is
109
+ # anchored there; unpinned, at the first year boundary inside the span
110
+ on_new_year = lo == pd.Timestamp(year=lo.year, month=1, day=1)
111
+ anchor = lo.year if pin_start or on_new_year else lo.year + 1
112
+ grid = [
113
+ t
114
+ for t in _period_starts(lo, hi, step, anchor_year=anchor)
115
+ if lo < t <= hi or (not pin_start and t == lo)
116
+ ]
117
+ ticks = [lo, *grid] if pin_start else grid
118
+ return DateLayout(step, ticks, ticks, ticks)
119
+
120
+
121
+ def _centered_layout(
122
+ lo: pd.Timestamp, hi: pd.Timestamp, step: str, *, min_fraction: float
123
+ ) -> DateLayout:
124
+ unit, _n = _parse(step)
125
+ length = pd.DateOffset(years=1) if unit == "Y" else pd.DateOffset(months=1)
126
+ starts = _period_starts(lo, hi, step, anchor_year=lo.year)
127
+ labels: list[pd.Timestamp] = []
128
+ periods: list[pd.Timestamp] = []
129
+ for start in starts:
130
+ end = start + length
131
+ shown_lo = max(start, lo)
132
+ shown_hi = min(end, hi)
133
+ # a sliver of a year at either edge would push its label off the
134
+ # plot: label a period only when enough of it is drawn
135
+ if shown_hi - shown_lo >= (end - start) * min_fraction:
136
+ labels.append(shown_lo + (shown_hi - shown_lo) / 2)
137
+ periods.append(start)
138
+ ticks = [t for t in starts if lo <= t <= hi]
139
+ return DateLayout(step, ticks, labels, periods, centered=True)
140
+
141
+
142
+ def date_layout(
143
+ data_min: Any,
144
+ data_max: Any,
145
+ step: str,
146
+ *,
147
+ pin_start: bool,
148
+ centered: bool = False,
149
+ min_fraction: float = 0.5,
150
+ ) -> DateLayout:
151
+ """Ticks and labels that *step* puts over ``[data_min, data_max]``.
152
+
153
+ With ``tick`` labels (the default) every tick is labelled and, with
154
+ ``pin_start``, the first datum leads as its own tick. With
155
+ ``centered`` labels the ticks mark where each period starts and its
156
+ name sits in the middle of the part drawn; ``pin_start`` is ignored
157
+ there, since the first label already names the starting period.
158
+ """
159
+ lo = pd.Timestamp(data_min)
160
+ hi = pd.Timestamp(data_max)
161
+ if centered:
162
+ return _centered_layout(lo, hi, step, min_fraction=min_fraction)
163
+ return _tick_layout(lo, hi, step, pin_start=pin_start)
164
+
165
+
166
+ def date_layouts(
167
+ data_min: Any,
168
+ data_max: Any,
169
+ *,
170
+ step: str,
171
+ max_labels: int,
172
+ monthly_max_months: int,
173
+ pin_start: bool,
174
+ centered: bool = False,
175
+ min_fraction: float = 0.5,
176
+ ) -> list[DateLayout]:
177
+ """Candidate layouts for the span, finest first.
178
+
179
+ An explicit *step* is the only candidate. With ``auto``, month steps
180
+ are tried only over spans up to *monthly_max_months* (beyond that the
181
+ axis reads in years), and a step is kept when it gives between two
182
+ and *max_labels* labels. An empty list means no calendar step suits
183
+ the span (a few weeks of daily data, say) and the caller falls back to
184
+ matplotlib's own date locator.
185
+ """
186
+ lo = pd.Timestamp(data_min)
187
+ hi = pd.Timestamp(data_max)
188
+ if step != "auto":
189
+ pool: tuple[str, ...] = (step,)
190
+ limit = None
191
+ else:
192
+ months = _month_index(hi) - _month_index(lo)
193
+ pool = tuple(
194
+ s
195
+ for s in DATE_STEPS
196
+ if months <= monthly_max_months or s.endswith("Y")
197
+ )
198
+ limit = max_labels
199
+ layouts: list[DateLayout] = []
200
+ for candidate in pool:
201
+ layout = date_layout(
202
+ lo,
203
+ hi,
204
+ candidate,
205
+ pin_start=pin_start,
206
+ centered=centered,
207
+ min_fraction=min_fraction,
208
+ )
209
+ count = len(layout.labels)
210
+ if count >= 2 and (limit is None or count <= limit):
211
+ layouts.append(layout)
212
+ return layouts
213
+
214
+
215
+ def period_label(
216
+ period: pd.Timestamp, step: str, *, first: bool = False
217
+ ) -> str:
218
+ """Text of a centred label: the year, or the month (the year in January).
219
+
220
+ The *first* month label carries its year too (``may. 2024``) unless
221
+ it is January, so the reader knows which year the axis starts in.
222
+ """
223
+ unit, _n = _parse(step)
224
+ if unit == "Y" or period.month == 1:
225
+ return str(period.year)
226
+ if first:
227
+ # locale's %b may already end in a dot ("may."): avoid "may.. 2024"
228
+ month = period.strftime("%b").lower().rstrip(".")
229
+ return f"{month}. {period.year}"
230
+ return period.strftime("%b")
231
+
232
+
233
+ def plotly_dtick(data_min: Any, step: str) -> tuple[str, str]:
234
+ """Plotly ``(dtick, tick0)`` for *step*, anchored as the PNG axis is."""
235
+ unit, n = _parse(step)
236
+ if unit == "Y":
237
+ return f"M{12 * n}", f"{pd.Timestamp(data_min).year}-01-01"
238
+ return f"M{n}", "2000-01-01"
@@ -39,6 +39,7 @@ from tesorotools.artists._common import (
39
39
  compact_font_pt,
40
40
  compact_headroom_pad_pt,
41
41
  compact_y_axis,
42
+ date_max_labels,
42
43
  date_maxticks,
43
44
  date_show_start_default,
44
45
  draw_vlines,
@@ -50,6 +51,7 @@ from tesorotools.artists._common import (
50
51
  style_compact_axes,
51
52
  style_spines,
52
53
  )
54
+ from tesorotools.artists._dates import check_date_labels, check_date_step
53
55
  from tesorotools.utils.matplotlib import format_annotation
54
56
  from tesorotools.utils.template import TemplateLoader
55
57
 
@@ -128,12 +130,19 @@ class LinePlot(FigureLifecycle):
128
130
  historical behaviour). Set ``axis_units: false`` to keep the
129
131
  unit only on the value labels (a common house look: ``mM€``
130
132
  on the annotations, bare numbers on the axis), or the reverse.
131
- * ``date_density`` -- soft target for how many date ticks the
132
- x-axis shows: ``"few"`` / ``"default"`` / ``"many"``, mapped
133
- to a locator ``maxticks`` per mode in ``plots.yaml``
134
- (``compact.date_maxticks`` / ``line.date_maxticks``).
135
- ``"default"`` reproduces the current tick count. The surviving
136
- ticks are kept **equally spaced in time**. In compact mode a
133
+ * Date axis -- the x-axis labels **every year** that fits in
134
+ its width without overlapping (every month over spans of up to
135
+ ``date_monthly_max_months``, 24 by default), and only steps to
136
+ one every 2, 5, 10... years when they do not fit or would pass
137
+ the ``date_max_labels`` cap of the density level (15 at full
138
+ size, 8 in compact by default; both in ``plots.yaml``).
139
+ Spans too short for a calendar grid (a few weeks of daily data)
140
+ keep matplotlib's own date ticks, ``date_maxticks`` of them.
141
+ * ``date_density`` -- how many date labels the x-axis may show:
142
+ ``"few"`` / ``"default"`` / ``"many"``, mapped to the cap
143
+ ``date_max_labels`` per mode in ``plots.yaml``
144
+ (``compact.date_max_labels`` / ``line.date_max_labels``). In
145
+ compact mode a
137
146
  denser level with no explicit ``date_rotation`` **auto-tilts**
138
147
  the labels only as far as needed to actually fit the extra
139
148
  dates (a narrow chart cannot fit many horizontal 4-digit years),
@@ -160,6 +169,19 @@ class LinePlot(FigureLifecycle):
160
169
  blank), so in non-compact mode this switches the x-axis to
161
170
  the concise date labels -- a visible change of label *style*
162
171
  against the historical pandas one.
172
+ * ``date_step`` -- fix the date step instead of fitting it:
173
+ ``"1M"``, ``"2M"``, ``"3M"``, ``"6M"``, ``"1Y"``, ``"2Y"``,
174
+ ``"5Y"``, ``"10Y"``, ``"20Y"`` or ``"50Y"``. ``"auto"`` (the
175
+ default) picks the finest step that fits. A fixed step is drawn
176
+ even if its labels overlap.
177
+ * ``date_labels`` -- ``"tick"`` (the default) puts each label
178
+ under its tick; ``"centered"`` marks the start of each year (or
179
+ month) with a tick and centres its label over the stretch it
180
+ covers, the usual look for long monthly series. A year at
181
+ either edge is labelled only when at least
182
+ ``date_centered_min_fraction`` of it is drawn (half by default,
183
+ in ``plots.yaml``). The first label already names the starting
184
+ year, so ``date_show_start`` does not apply.
163
185
  * ``adapt_height`` -- when true (the default), grow the figure
164
186
  **height** (keeping the width) so the plot area is not
165
187
  squashed by a large legend or rotated date labels. Skipped
@@ -232,6 +254,8 @@ class LinePlot(FigureLifecycle):
232
254
  date_density: str = "default",
233
255
  date_rotation: float | None = None,
234
256
  date_show_start: bool | None = None,
257
+ date_step: str = "auto",
258
+ date_labels: str = "tick",
235
259
  adapt_height: bool = True,
236
260
  ref_lines: list[dict[str, Any]] | None = None,
237
261
  show_means: bool = False,
@@ -285,6 +309,8 @@ class LinePlot(FigureLifecycle):
285
309
  self.date_density = date_density
286
310
  self.date_rotation = date_rotation
287
311
  self.date_show_start = date_show_start
312
+ self.date_step = check_date_step(date_step)
313
+ self.date_labels = check_date_labels(date_labels)
288
314
  self.adapt_height = adapt_height
289
315
  self.ref_lines = ref_lines or []
290
316
  self.show_means = show_means
@@ -307,8 +333,9 @@ class LinePlot(FigureLifecycle):
307
333
  def _custom_date_axis(self) -> bool:
308
334
  """Whether the non-compact chart takes over the x-axis for dates.
309
335
 
310
- When the user asks for a non-default density or a rotation, or when
311
- the start date is pinned: all three need a **native** date axis,
336
+ When the user asks for a non-default density, step, label mode or
337
+ a rotation, or when the start date is pinned: all of them need a
338
+ **native** date axis,
312
339
  because pandas' own date plotting owns its locator and formatter (and
313
340
  labels a pinned tick blank). With none of them the historical pandas
314
341
  date plotting is kept untouched. Compact mode has its own date axis
@@ -317,6 +344,8 @@ class LinePlot(FigureLifecycle):
317
344
  return not self.compact and (
318
345
  self.date_density != "default"
319
346
  or self.date_rotation is not None
347
+ or self.date_step != "auto"
348
+ or self.date_labels != "tick"
320
349
  or self._pin_start_date
321
350
  )
322
351
 
@@ -345,6 +374,7 @@ class LinePlot(FigureLifecycle):
345
374
  ``plot_size``, ``vlines``, ``compact``, ``font_pt``,
346
375
  ``axis_decimals``, ``axis_units``, ``annotate_units``,
347
376
  ``date_density``, ``date_rotation``, ``date_show_start``,
377
+ ``date_step``, ``date_labels``,
348
378
  ``adapt_height``,
349
379
  ``ref_lines``, ``show_means``, ``means_window``,
350
380
  ``y_limits``, ``y_group``.
@@ -611,10 +641,19 @@ class LinePlot(FigureLifecycle):
611
641
  )
612
642
 
613
643
  if self.compact:
644
+ # y-axis side: keep the house RIGHT side (matching every other
645
+ # chart in a two-per-row row) by default, and move to the LEFT
646
+ # only when annotate is on -- there the right edge is needed for
647
+ # the end-of-series value labels. style_compact_axes also pins
648
+ # the tick label size -- BEFORE the date axis, which measures the
649
+ # labels at that size to fit as many years as it can -- and
650
+ # drops the now-tickless spines.
651
+ side = "left" if self.annotate else "right"
652
+ style_compact_axes(ax, font_pt, side=side)
614
653
  # pin the date ticks to the data span (after annotate widened the
615
654
  # x-limit) so the last tick sits next to the final data point and
616
655
  # none float in the value-label margin; match Word's body font.
617
- # date_density -> maxticks; date_rotation tilts the labels.
656
+ # date_density -> label cap; date_rotation tilts the labels.
618
657
  # The start tick is pinned whatever the density or rotation: an
619
658
  # even calendar cadence used to win here, but then asking for
620
659
  # more (or slanted) dates silently cost you the one date the
@@ -632,14 +671,10 @@ class LinePlot(FigureLifecycle):
632
671
  self.date_density != "default"
633
672
  and self.date_rotation is None
634
673
  ),
674
+ max_labels=date_max_labels(self.date_density, compact=True),
675
+ step=self.date_step,
676
+ labels=self.date_labels,
635
677
  )
636
- # y-axis side: keep the house RIGHT side (matching every other
637
- # chart in a two-per-row row) by default, and move to the LEFT
638
- # only when annotate is on -- there the right edge is needed for
639
- # the end-of-series value labels. style_compact_axes also pins
640
- # the tick label size and drops the now-tickless spines.
641
- side = "left" if self.annotate else "right"
642
- style_compact_axes(ax, font_pt, side=side)
643
678
  # denser y-numbers (e.g. every 20 instead of every 50) so the
644
679
  # small chart still has enough reference gridlines.
645
680
  compact_y_axis(ax)
@@ -655,6 +690,9 @@ class LinePlot(FigureLifecycle):
655
690
  data_min=plot_data.index.min(),
656
691
  data_max=plot_data.index.max(),
657
692
  pin_start=self._pin_start_date,
693
+ max_labels=date_max_labels(self.date_density, compact=False),
694
+ step=self.date_step,
695
+ labels=self.date_labels,
658
696
  )
659
697
 
660
698
  if self.legend is not None:
@@ -53,6 +53,7 @@ import numpy as np
53
53
  import plotly.graph_objects as go
54
54
 
55
55
  from tesorotools.artists._common import AX_CONFIG, Legend
56
+ from tesorotools.artists._dates import plotly_dtick
56
57
  from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
57
58
 
58
59
  if TYPE_CHECKING:
@@ -239,6 +240,20 @@ def _fit_bar_range(fig: go.Figure, values: Any, pad_frac: float = 0.25) -> None:
239
240
  )
240
241
 
241
242
 
243
+ def _date_axis(fig: go.Figure, start: Any, step: str, labels: str) -> None:
244
+ """Fixed date step and centred labels, as on the PNG axis.
245
+
246
+ With ``date_step: auto`` the interactive axis keeps Plotly's own
247
+ ticks, which adapt to zoom; a fixed step becomes ``dtick`` (anchored
248
+ like the PNG grid) and centred labels ``ticklabelmode="period"``.
249
+ """
250
+ if step != "auto":
251
+ dtick, tick0 = plotly_dtick(start, step)
252
+ fig.update_xaxes(dtick=dtick, tick0=tick0)
253
+ if labels == "centered":
254
+ fig.update_xaxes(ticklabelmode="period")
255
+
256
+
242
257
  def _padded_range(values: list[float], margin: float) -> list[float]:
243
258
  """Span *values* with *margin* padding (mirrors vector's ``_set_limits``)."""
244
259
  lo, hi = min(values), max(values)
@@ -310,6 +325,7 @@ def line_plot_to_figure(chart: LinePlot) -> go.Figure:
310
325
  # fixed or group-shared y range (see share_y_axes)
311
326
  if chart.y_limits is not None:
312
327
  fig.update_yaxes(range=list(chart.y_limits))
328
+ _date_axis(fig, plot_data.index.min(), chart.date_step, chart.date_labels)
313
329
  _legend_side(fig, chart.legend)
314
330
  return fig
315
331
 
@@ -447,6 +463,7 @@ def stacked_area_to_figure(chart: StackedAreaPlot) -> go.Figure:
447
463
  _baseline(fig, 0)
448
464
  if chart.fmt.units:
449
465
  fig.update_yaxes(ticksuffix=chart.fmt.units)
466
+ _date_axis(fig, plot_data.index.min(), chart.date_step, chart.date_labels)
450
467
  _legend_side(fig, chart.legend)
451
468
  return fig
452
469
 
@@ -29,6 +29,7 @@ from tesorotools.artists._common import (
29
29
  style_compact_axes,
30
30
  style_spines,
31
31
  )
32
+ from tesorotools.artists._dates import check_date_labels, check_date_step
32
33
  from tesorotools.utils.template import TemplateLoader
33
34
 
34
35
 
@@ -45,6 +46,11 @@ class StackedAreaPlot(FigureLifecycle):
45
46
  ``false`` leaves the plain calendar grid. Pinning takes
46
47
  over the x-axis, so at full size the date labels switch to
47
48
  the concise style.
49
+
50
+ The date axis labels every year (or month) that fits, like
51
+ ``LinePlot``'s; ``date_step`` fixes the step (``"1Y"``,
52
+ ``"3M"``...) and ``date_labels: centered`` centres each label
53
+ over its year or month.
48
54
  """
49
55
 
50
56
  #: Keys that change the PNG but never the ``to_plotly()`` figure
@@ -80,6 +86,8 @@ class StackedAreaPlot(FigureLifecycle):
80
86
  compact: bool = False,
81
87
  font_pt: float | None = None,
82
88
  date_show_start: bool | None = None,
89
+ date_step: str = "auto",
90
+ date_labels: str = "tick",
83
91
  ) -> None:
84
92
  if out_path.suffix != ".png":
85
93
  raise ValueError(f"out_path must be .png: {out_path}")
@@ -95,6 +103,8 @@ class StackedAreaPlot(FigureLifecycle):
95
103
  self.figsize = figsize
96
104
  self.plot_size = plot_size
97
105
  self.date_show_start = date_show_start
106
+ self.date_step = check_date_step(date_step)
107
+ self.date_labels = check_date_labels(date_labels)
98
108
  self.compact = compact
99
109
  self.font_pt = font_pt
100
110
 
@@ -117,7 +127,7 @@ class StackedAreaPlot(FigureLifecycle):
117
127
  -------------
118
128
  ``scale``, ``start_date``, ``end_date``, ``baseline``,
119
129
  ``fmt`` (``!format``), ``legend`` (``!legend``),
120
- ``date_show_start``,
130
+ ``date_show_start``, ``date_step``, ``date_labels``,
121
131
  ``figsize``, ``plot_size``, ``compact`` (fixed
122
132
  physical size, body-size text, legend in its own band
123
133
  and a sparse concise date axis, for two charts per row),
@@ -215,24 +225,29 @@ class StackedAreaPlot(FigureLifecycle):
215
225
  else self.date_show_start
216
226
  )
217
227
  if self.compact:
218
- # sparse concise date axis (a few ticks across the narrow figure)
219
- # plus body-size ticks and the house right-hand y-axis.
228
+ # body-size ticks and the house right-hand y-axis first, so the
229
+ # date axis measures its labels at their real size, then every
230
+ # year (or month) that fits across the narrow figure.
231
+ style_compact_axes(ax, font_pt, side="right")
220
232
  compact_date_axis(
221
233
  ax,
222
234
  plot_data.index.min(),
223
235
  plot_data.index.max(),
224
236
  pin_start=pin_start,
237
+ step=self.date_step,
238
+ labels=self.date_labels,
225
239
  )
226
- style_compact_axes(ax, font_pt, side="right")
227
- elif pin_start:
240
+ else:
228
241
  # stackplot already draws on a native date axis, so the ticks can
229
- # be pinned to the span with the first date spelled out
242
+ # be laid on the calendar grid with the first date spelled out
230
243
  apply_date_axis(
231
244
  ax,
232
245
  maxticks=date_maxticks("default", compact=False),
233
246
  data_min=plot_data.index.min(),
234
247
  data_max=plot_data.index.max(),
235
- pin_start=True,
248
+ pin_start=pin_start,
249
+ step=self.date_step,
250
+ labels=self.date_labels,
236
251
  )
237
252
 
238
253
  place_legend(
@@ -139,6 +139,22 @@ line:
139
139
  few: 3
140
140
  default: 6
141
141
  many: 14
142
+ # Eje de fechas por calendario: se etiqueta CADA año (o cada mes en
143
+ # periodos de hasta date_monthly_max_months meses) si las etiquetas caben
144
+ # sin solaparse y no pasan de date_max_labels; si no, se salta a uno cada
145
+ # 2, 5, 10... años. date_maxticks solo queda para periodos demasiado
146
+ # cortos para una rejilla de calendario (unas semanas de datos diarios).
147
+ date_max_labels:
148
+ few: 6
149
+ default: 15
150
+ many: 30
151
+ date_monthly_max_months: 24
152
+ # Con `date_labels: centered`, un año (o mes) de los extremos solo se
153
+ # etiqueta si se dibuja al menos esta fracción de él
154
+ date_centered_min_fraction: 0.5
155
+ # Hueco mínimo entre dos etiquetas de fecha vecinas, en ems (fracción del
156
+ # tamaño de letra): por debajo se considera que se pisan
157
+ date_label_gap_em: 0.3
142
158
  # Fija la fecha del PRIMER dato como primer tick del eje X, para que siempre
143
159
  # se vea desde cuando se dibuja la serie (el tick "bonito" de calendario cae
144
160
  # normalmente antes del primer dato, y entonces una serie que empieza en
@@ -227,6 +243,17 @@ compact:
227
243
  few: 3
228
244
  default: 4
229
245
  many: 12
246
+ # Eje de fechas por calendario (ver line.date_max_labels): todos los años
247
+ # que quepan en el ancho compact, con un máximo de etiquetas por nivel
248
+ date_max_labels:
249
+ few: 4
250
+ default: 8
251
+ many: 14
252
+ date_monthly_max_months: 24
253
+ date_centered_min_fraction: 0.5
254
+ # Hueco mínimo entre dos etiquetas de fecha vecinas, en ems (fracción del
255
+ # tamaño de letra): por debajo se considera que se pisan
256
+ date_label_gap_em: 0.3
230
257
  # Gráficos de barras horizontales (barh / grouped_barh): el alto fijo de
231
258
  # compact aplastaría las etiquetas de un gráfico con muchas categorías, así
232
259
  # que en compact se mantiene el ANCHO fijo y el ALTO crece con el nº de
tesorotools/note/lint.py CHANGED
@@ -598,6 +598,8 @@ _ABBREVIATION = re.compile(
598
598
  re.I,
599
599
  )
600
600
  _BOLD_RULE = "negrita solo en la entradilla, no en cifras ni frases sueltas"
601
+ #: A footnote in the text, ``^[...]`` (one level of brackets inside).
602
+ _NOTE_MARKUP = re.compile(r"\^\[(?:[^\[\]]|\[[^\]]*\])*\]")
601
603
 
602
604
 
603
605
  def _ends_sentence(text: str) -> bool:
@@ -652,7 +654,9 @@ def _bold_issues(line: int, text: str, *, lead: bool) -> Iterator[Issue]:
652
654
  yield Issue(line, "negrita", _BOLD_RULE)
653
655
  if not starts_bold:
654
656
  return
655
- if re.match(r"^\s*\*\*[^*]+\*\*[^\s*:;,.)]", text):
657
+ # without its footnotes: the «**» of a note's own lead
658
+ # («**Lead^[**Término**: ...] sigue.**») is not the end of the lead
659
+ if re.match(r"^\s*\*\*[^*]+\*\*[^\s*:;,.)]", _NOTE_MARKUP.sub("", text)):
656
660
  yield Issue(
657
661
  line,
658
662
  "entradilla",
@@ -650,6 +650,23 @@ class LSEGProvider(DataProvider):
650
650
  RIC.
651
651
  - **Flat columns labelled by RIC** — single-field call against
652
652
  multiple RICs (degenerate). Returned as-is.
653
+
654
+ Rows are only sorted by date, with a stable sort so that a date
655
+ returned twice keeps the response's order and the by-index
656
+ de-dup of :meth:`_download_batch` keeps the first copy the
657
+ source sent. They are **never** de-duplicated here, and
658
+ in particular never by value. Up to 0.1.7 every branch ended in
659
+ ``drop_duplicates()``, which compares the *values* of a row, not
660
+ its date: every session whose close had already appeared earlier
661
+ in the response was deleted, and :meth:`_download_batch`
662
+ reindexed the hole to NaN, so a caller that forward-fills (the
663
+ usual convention of a daily store) kept the previous session's
664
+ close in its place. A three-decimal yield revisits the same
665
+ figure hundreds of times in a 2 500-day window, so seeded
666
+ histories came out with up to half their sessions frozen. The
667
+ same close on two sessions is two observations; a date returned
668
+ twice is a different thing, and :meth:`_download_batch` drops it
669
+ by index.
653
670
  """
654
671
  if df.empty:
655
672
  return pd.DataFrame(columns=pd.Index(rics))
@@ -662,7 +679,7 @@ class LSEGProvider(DataProvider):
662
679
  if key in df.columns:
663
680
  picked[ric] = df[key]
664
681
  return (
665
- pd.DataFrame(picked).drop_duplicates().sort_index()
682
+ pd.DataFrame(picked).sort_index(kind="stable")
666
683
  if picked
667
684
  else pd.DataFrame(columns=pd.Index(rics))
668
685
  )
@@ -671,14 +688,10 @@ class LSEGProvider(DataProvider):
671
688
  ric = rics[0]
672
689
  field = field_per_ric[ric]
673
690
  if field in df.columns:
674
- return (
675
- pd.DataFrame({ric: df[field]})
676
- .drop_duplicates()
677
- .sort_index()
678
- )
691
+ return pd.DataFrame({ric: df[field]}).sort_index(kind="stable")
679
692
  return pd.DataFrame(columns=pd.Index([ric]))
680
693
 
681
- return df.drop_duplicates().sort_index()
694
+ return df.sort_index(kind="stable")
682
695
 
683
696
  # ------------------------------------------------------------------
684
697
  # Internal: per-RIC batched range download (close path)
@@ -748,6 +761,10 @@ class LSEGProvider(DataProvider):
748
761
  df.index = dates
749
762
  bdays_mask = ~dates.weekday.isin([5, 6])
750
763
  df = df[bdays_mask]
764
+ # By date, never by value: the same close on two
765
+ # sessions is two observations. A business day the
766
+ # source does not print stays NaN after the reindex;
767
+ # filling it is the caller's decision.
751
768
  df = df[~df.index.duplicated(keep="first")]
752
769
  full_range = pd.date_range(
753
770
  start=start_date, end=end_date, freq="B"
@@ -773,10 +790,23 @@ class LSEGProvider(DataProvider):
773
790
 
774
791
  @staticmethod
775
792
  def _unify_batches(csv_path: Path) -> pd.DataFrame:
793
+ """Merge every cached CSV partial into one column per RIC.
794
+
795
+ ``csv_path`` may hold partials of any range for the same RIC
796
+ (earlier runs, other ``start``/``end``), so the same date can
797
+ appear in several files. For each date the **newest file with a
798
+ figure** wins: files are read newest first (modification time,
799
+ then name for a stable tie-break) and the first non-NaN value
800
+ is kept. A NaN never hides a figure from another file, which
801
+ matters for partials written before 0.1.8, whose value-based
802
+ ``drop_duplicates()`` left holes; a date no file prints stays
803
+ NaN.
804
+ """
805
+ csv_files = [f for f in csv_path.iterdir() if f.suffix == ".csv"]
806
+ csv_files.sort(key=lambda f: (-f.stat().st_mtime_ns, f.name))
807
+
776
808
  same_col_groups: dict[str, list[pd.DataFrame]] = {}
777
- for csv_file in csv_path.iterdir():
778
- if csv_file.suffix != ".csv":
779
- continue
809
+ for csv_file in csv_files:
780
810
  df = pd.read_csv(csv_file, index_col=_CSV_DATE_TITLE)
781
811
  col_name = df.columns[0]
782
812
  same_col_groups.setdefault(col_name, []).append(df)
@@ -784,9 +814,7 @@ class LSEGProvider(DataProvider):
784
814
  vertical_dfs: list[pd.DataFrame] = []
785
815
  for dfs in same_col_groups.values():
786
816
  concatenated = pd.concat(dfs, axis=0)
787
- concatenated = concatenated[
788
- ~concatenated.index.duplicated(keep="first")
789
- ]
790
- vertical_dfs.append(concatenated)
817
+ # ``first`` skips NaN: the newest file that prints the date.
818
+ vertical_dfs.append(concatenated.groupby(level=0).first())
791
819
 
792
820
  return pd.concat(vertical_dfs, axis=1).sort_index()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tesorotools-python
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -8,27 +8,28 @@ tesorotools/palette.py,sha256=RHt_nSQXaIuGQzVVcgOaYzcvtQSEbK52ox1cOVcosyo,2122
8
8
  tesorotools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  tesorotools/scaffold.py,sha256=6mYNxLm_1CbEMjcYZ77m_2H1eTotbpatFJqhgyote0s,4578
10
10
  tesorotools/artists/__init__.py,sha256=wZTHZPB4Af6FVheWnTeuOmuzmCIG9mvziP3gHZW2nl0,2631
11
- tesorotools/artists/_common.py,sha256=YJtwXgt1atpoSUt_5siBQ81ry49_8srt2EzgxPuBktM,62628
11
+ tesorotools/artists/_common.py,sha256=jeciBAyc9414OLHOyy_Mhx_1g_sE6RvvmsK3kaeloJQ,74361
12
+ tesorotools/artists/_dates.py,sha256=bEEWJvlXHf9R3T0qN3Qpu8bue_o4DZP9WHZXyrrIeM0,7939
12
13
  tesorotools/artists/bar_line.py,sha256=8C-PuHQ8drXjTZeNsQ56R8UdQy2zvG6V270qrgFqetE,20319
13
14
  tesorotools/artists/barh_plot.py,sha256=n8DjIX1APCVS7HW4zzfh6ehggdgBqb6sezQHov1k7us,37520
14
15
  tesorotools/artists/box_plot.py,sha256=HfJ9gdAXE6wcMHG3sKMG0M2CcigkyPe2Wly7bBxq4nI,20413
15
16
  tesorotools/artists/compact.py,sha256=e_0tqkRSIgyiyMSwirJzj2TYSKojMxUHzug5tE_7toc,3003
16
17
  tesorotools/artists/intraday_plot.py,sha256=ucr5Riai0lfEgINOFm6qCuvZrGSQ4jfUmSSTxuSvIjM,13297
17
- tesorotools/artists/line_plot.py,sha256=GfOFhIlXIbUpKPD0STxUZCerogywhOMXl5PfiVf4xxE,31287
18
+ tesorotools/artists/line_plot.py,sha256=MBQ8S4P6xnGzs0nr2auGm_R4UMhLS-AbhJMel5-w70o,33378
18
19
  tesorotools/artists/matrix.py,sha256=cOpj9ZO5RR0-RlIP55UmD70i-738ck8wZIqRiDO22Hw,11785
19
- tesorotools/artists/plotly_backend.py,sha256=1FR-X3mz1KbFJCFVx2CPuuibPgVkYvq4EizIv9iW0v0,42979
20
+ tesorotools/artists/plotly_backend.py,sha256=YhkXJpuT5HRg8A7TvxqpKG-VSKW0H4OShd7QLB51GL8,43752
20
21
  tesorotools/artists/sankey.py,sha256=5rdWeih3AgIeqaQyUAhQMY8ZGAeVoEV4jHsi1HO3NTc,16269
21
22
  tesorotools/artists/scatter.py,sha256=CEkn_XDlX-u67O-sQxj5iPGIFewTC9J44gXTi1tYFx4,18117
22
23
  tesorotools/artists/sharing.py,sha256=Tg4AYtv0ileKQmzBSgmT1GzxA97tdU8ji_arE0oGEZ4,2236
23
24
  tesorotools/artists/shock_plot.py,sha256=fS8fi2OaIbWmG_I4FzaCF4U6i9a6tzKZSQH48l4ZobY,13473
24
- tesorotools/artists/stacked.py,sha256=MyItTb8NMOpR6jDclqjmofhAB-L9mD1vnzGQjXUTFBc,17439
25
+ tesorotools/artists/stacked.py,sha256=B3NF1NzfHhK-YFjTgwoTiCEI2teX6mVtMyd3cbnnfDQ,18170
25
26
  tesorotools/artists/tikz_backend.py,sha256=zqCFa9A_amwBvGjrFULzKf8comL2-1aQx7_yEDFxXFo,5832
26
27
  tesorotools/artists/type_curve.py,sha256=0j1LjfS4Zdz4_W7qp-nY8VP0H-fENV5adUg4syAOvig,12570
27
28
  tesorotools/artists/vector_plot.py,sha256=P7BtLCXRTWs3eepRFYvXzbJ9OkvB-Ya3kmXB6yJSkR0,12211
28
29
  tesorotools/artists/waterfall.py,sha256=U40HB3gVj3lyuM4rwfnpuxpq0XE7zPRGspgKAoq9T-g,12386
29
30
  tesorotools/assets/README.md,sha256=EjDuA4Xf-Omn0Khp8erNXdW-iyWBzSM2FQFexdrS6Eg,285
30
31
  tesorotools/assets/nota_template.dotx,sha256=LC3qcMz4F3SGDkJ12VNNvinlP3f5rJH5qGU5ZtJgJuc,146199
31
- tesorotools/assets/plots.yaml,sha256=CxvgXBCuJnpSszZFQ-cDObV7-1yQdADfklnN1Lt4mHE,13217
32
+ tesorotools/assets/plots.yaml,sha256=DU2O4eF2QORiLMF-uojYLOzXMan3azZ1ioJvqr7BeyM,14439
32
33
  tesorotools/assets/template.docx,sha256=QYilJmJ-ltGPH_GH22j6Kis7wmNaAgFQXr68d1KUSHY,94394
33
34
  tesorotools/assets/tesoro.mplstyle,sha256=9J3quXzu4Mhq0auHhU4RrKcHEnTCxTGgTP1GRGs8D-g,456
34
35
  tesorotools/assets/fonts/CabinetGrotesk-Black.otf,sha256=b7M_yFxz5IcGtmWWPMzLVkfx4RLhVALQDFeoq4T1YD0,37728
@@ -82,7 +83,7 @@ tesorotools/note/charts.py,sha256=7dUsCyURC39qOzLMU09mIm8Jh7B80ShBdRhpBNn6cUE,44
82
83
  tesorotools/note/cli.py,sha256=ZEzC4k7X6Go2LhVXqGAuBmqKrBdhxH4B7gWsUPabsJY,10685
83
84
  tesorotools/note/inline.py,sha256=FqpNKst1uLVvUtVHF9Q6ddhfFBKqeaOw1fXMLUvpHrw,12378
84
85
  tesorotools/note/issues.py,sha256=DZY6FblaInDyl0keen40nHt27w4mzpXz9VBMUjNdbu0,1456
85
- tesorotools/note/lint.py,sha256=PWGemiLoxOgp_ZrS4PoSkKYHLqPhJTBlAV04QfEH4oE,40390
86
+ tesorotools/note/lint.py,sha256=jUgdAt1Qheh0PZo39qap2V5aRFRi_m6IG_ZzKz96Kbc,40678
86
87
  tesorotools/note/markdown.py,sha256=fk8lnBtbcVj6SaOU__ZqKRnDR3xQ46_xiRa0HMjUm3E,52823
87
88
  tesorotools/note/pdf.py,sha256=wQWrfSsCYGYxatANAn8FXXfZbMQ-4tYOp3y8lYVs1B4,4142
88
89
  tesorotools/note/skeleton.md,sha256=lWvoiHdTqt6fEc5WLu87HRkR7I9vOULM4yE5UTtkff4,2924
@@ -114,7 +115,7 @@ tesorotools/providers/bde.py,sha256=xbTwxzb013VyjHkXN88UvS_IQxklPv8j5rslzL_RQv4,
114
115
  tesorotools/providers/ecb.py,sha256=8UIW-NFLlGVIM7E2Sd_9UEWryt5wwilY3Hv13dnjY68,6422
115
116
  tesorotools/providers/fred.py,sha256=5Jk1kCOSeH86wGqPOexKSVgcVJABUUea0dQ7k4eWYlc,13132
116
117
  tesorotools/providers/imf_irfcl.py,sha256=ywix759wki5AfSTZGRnolf4LGYKqxj-23Xm8bmi3iT4,8106
117
- tesorotools/providers/lseg.py,sha256=paKdSxb1tWv26YCwMD0dEUfF4GLFJWzHukKII9WPges,30183
118
+ tesorotools/providers/lseg.py,sha256=F8ePZfCEvykVXmP31FF_o931Up8EV1o_GokPXEy3WdI,32109
118
119
  tesorotools/render/__init__.py,sha256=wPbM3OMNx1R46K9KaIURxf7LNdcXIFj3eO0P7zkEqXo,662
119
120
  tesorotools/render/document.py,sha256=AiR909votacAWfZmeUekulRfZ-c5-bI6t5SnabibAd0,1136
120
121
  tesorotools/render/report.py,sha256=Gp7vAB-sfnnFRQyZXmlthlYMbtOxwQg_qh3ZWDweeNo,1385
@@ -159,7 +160,7 @@ tesorotools/web/templates/src/$package/main.py.tmpl,sha256=GXG3AA87WOjk5nTKA5Ywm
159
160
  tesorotools/web/templates/src/$package/sections.py.tmpl,sha256=4MNZ85AW5ERG1SrkVAXIwdmAMeQEBARCYdAMqUr37mg,2084
160
161
  tesorotools/web/templates/tests/__init__.py.tmpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
161
162
  tesorotools/web/templates/tests/test_app.py.tmpl,sha256=ZGfV5GPN6WsS3a2gCNLPWejkHTX2H_N9XpurIADuUxI,615
162
- tesorotools_python-0.1.7.dist-info/METADATA,sha256=1W3WDlBQRAKPRpED9lsjpuU3sMaafiA4lOXIn43nECc,1025
163
- tesorotools_python-0.1.7.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
164
- tesorotools_python-0.1.7.dist-info/entry_points.txt,sha256=X0LciYC_rEvc6X1KGTKzQ6GvdDmSxdUy20_vg1Mcl6g,214
165
- tesorotools_python-0.1.7.dist-info/RECORD,,
163
+ tesorotools_python-0.1.8.dist-info/METADATA,sha256=8Qjg10_TLhhQ9eadDIx9mrbpYsSpW2uIJHYp43dkB10,1025
164
+ tesorotools_python-0.1.8.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
165
+ tesorotools_python-0.1.8.dist-info/entry_points.txt,sha256=X0LciYC_rEvc6X1KGTKzQ6GvdDmSxdUy20_vg1Mcl6g,214
166
+ tesorotools_python-0.1.8.dist-info/RECORD,,