TradeChart 2.0.1__tar.gz → 2.1.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.
Files changed (37) hide show
  1. {tradechart-2.0.1 → tradechart-2.1.0}/PKG-INFO +4 -4
  2. {tradechart-2.0.1 → tradechart-2.1.0}/README.md +3 -3
  3. {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/PKG-INFO +4 -4
  4. {tradechart-2.0.1 → tradechart-2.1.0}/pyproject.toml +1 -1
  5. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/__init__.py +1 -1
  6. tradechart-2.1.0/tradechart/charts/heatmap.py +352 -0
  7. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/core/engine.py +28 -0
  8. tradechart-2.0.1/tradechart/charts/heatmap.py +0 -187
  9. {tradechart-2.0.1 → tradechart-2.1.0}/LICENSE +0 -0
  10. {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/SOURCES.txt +0 -0
  11. {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/dependency_links.txt +0 -0
  12. {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/requires.txt +0 -0
  13. {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/top_level.txt +0 -0
  14. {tradechart-2.0.1 → tradechart-2.1.0}/setup.cfg +0 -0
  15. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/__init__.py +0 -0
  16. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/indicators.py +0 -0
  17. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/renderer.py +0 -0
  18. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/themes.py +0 -0
  19. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/watermark.py +0 -0
  20. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/config/__init__.py +0 -0
  21. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/config/logger.py +0 -0
  22. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/config/settings.py +0 -0
  23. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/core/__init__.py +0 -0
  24. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/__init__.py +0 -0
  25. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/fetcher.py +0 -0
  26. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/groups.py +0 -0
  27. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/models.py +0 -0
  28. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/provider_base.py +0 -0
  29. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/providers/__init__.py +0 -0
  30. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/providers/stooq_provider.py +0 -0
  31. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/providers/tradingview_provider.py +0 -0
  32. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/providers/yfinance_provider.py +0 -0
  33. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/utils/__init__.py +0 -0
  34. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/utils/exceptions.py +0 -0
  35. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/utils/formatting.py +0 -0
  36. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/utils/validation.py +0 -0
  37. {tradechart-2.0.1 → tradechart-2.1.0}/tradechart_examples/discord_demo_bot/discord_bot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TradeChart
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: Production-quality financial chart generator — candlestick, line, area, OHLC, Heikin-Ashi, performance heatmaps, and sector group charts.
5
5
  Author-email: "TRADELY.DEV" <dev@tradely.dev>
6
6
  License: Apache-2.0
@@ -45,7 +45,7 @@ Requires-Dist: build>=1.0; extra == "dev"
45
45
  Requires-Dist: twine>=5.0; extra == "dev"
46
46
  Dynamic: license-file
47
47
 
48
- # TradeChart — Library Edition v2.0.1
48
+ # TradeChart — Library Edition v2.1.0
49
49
 
50
50
  **Python → Financial Charts**
51
51
  Generate production-quality candlestick, line, area, OHLC, Heikin-Ashi, and performance heatmap charts from code.
@@ -216,7 +216,7 @@ path = tc.compare(
216
216
 
217
217
  ## `tc.heatmap()` — Performance Heatmap
218
218
 
219
- Renders a grid of coloured tiles — one per ticker — where each tile's hue encodes the **percentage change** over the requested duration. Red = loss, green = gain. Designed for sector snapshots and portfolio overviews.
219
+ Renders a **market-cap weighted treemap** — one tile per ticker — where tile **size** is proportional to market capitalisation and tile **colour** encodes percentage change over the requested duration. Red = loss, green = gain. Designed for sector snapshots and portfolio overviews, matching the style of professional tools like Finviz and Bloomberg.
220
220
 
221
221
  ```python
222
222
  import tradechart as tc
@@ -236,7 +236,7 @@ Each tile shows:
236
236
  - **± % change** over the duration
237
237
  - **Last closing price**
238
238
 
239
- The colour scale is automatically centred at zero and symmetrically ranged to the largest move in the group. A colourbar legend is included at the bottom of the chart.
239
+ Tile sizes use a **squarified treemap** algorithm to minimise wasted space while keeping aspect ratios close to square. Market-cap data is fetched automatically via yfinance. When market-cap data is unavailable (indices, futures, commodities), the renderer falls back to an equal-area grid automatically. The colour scale is symmetrically ranged to the largest move in the group. A colourbar legend is included.
240
240
 
241
241
  | Parameter | Type | Required | Default | Description |
242
242
  |---|---|---|---|---|
@@ -1,4 +1,4 @@
1
- # TradeChart — Library Edition v2.0.1
1
+ # TradeChart — Library Edition v2.1.0
2
2
 
3
3
  **Python → Financial Charts**
4
4
  Generate production-quality candlestick, line, area, OHLC, Heikin-Ashi, and performance heatmap charts from code.
@@ -169,7 +169,7 @@ path = tc.compare(
169
169
 
170
170
  ## `tc.heatmap()` — Performance Heatmap
171
171
 
172
- Renders a grid of coloured tiles — one per ticker — where each tile's hue encodes the **percentage change** over the requested duration. Red = loss, green = gain. Designed for sector snapshots and portfolio overviews.
172
+ Renders a **market-cap weighted treemap** — one tile per ticker — where tile **size** is proportional to market capitalisation and tile **colour** encodes percentage change over the requested duration. Red = loss, green = gain. Designed for sector snapshots and portfolio overviews, matching the style of professional tools like Finviz and Bloomberg.
173
173
 
174
174
  ```python
175
175
  import tradechart as tc
@@ -189,7 +189,7 @@ Each tile shows:
189
189
  - **± % change** over the duration
190
190
  - **Last closing price**
191
191
 
192
- The colour scale is automatically centred at zero and symmetrically ranged to the largest move in the group. A colourbar legend is included at the bottom of the chart.
192
+ Tile sizes use a **squarified treemap** algorithm to minimise wasted space while keeping aspect ratios close to square. Market-cap data is fetched automatically via yfinance. When market-cap data is unavailable (indices, futures, commodities), the renderer falls back to an equal-area grid automatically. The colour scale is symmetrically ranged to the largest move in the group. A colourbar legend is included.
193
193
 
194
194
  | Parameter | Type | Required | Default | Description |
195
195
  |---|---|---|---|---|
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TradeChart
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: Production-quality financial chart generator — candlestick, line, area, OHLC, Heikin-Ashi, performance heatmaps, and sector group charts.
5
5
  Author-email: "TRADELY.DEV" <dev@tradely.dev>
6
6
  License: Apache-2.0
@@ -45,7 +45,7 @@ Requires-Dist: build>=1.0; extra == "dev"
45
45
  Requires-Dist: twine>=5.0; extra == "dev"
46
46
  Dynamic: license-file
47
47
 
48
- # TradeChart — Library Edition v2.0.1
48
+ # TradeChart — Library Edition v2.1.0
49
49
 
50
50
  **Python → Financial Charts**
51
51
  Generate production-quality candlestick, line, area, OHLC, Heikin-Ashi, and performance heatmap charts from code.
@@ -216,7 +216,7 @@ path = tc.compare(
216
216
 
217
217
  ## `tc.heatmap()` — Performance Heatmap
218
218
 
219
- Renders a grid of coloured tiles — one per ticker — where each tile's hue encodes the **percentage change** over the requested duration. Red = loss, green = gain. Designed for sector snapshots and portfolio overviews.
219
+ Renders a **market-cap weighted treemap** — one tile per ticker — where tile **size** is proportional to market capitalisation and tile **colour** encodes percentage change over the requested duration. Red = loss, green = gain. Designed for sector snapshots and portfolio overviews, matching the style of professional tools like Finviz and Bloomberg.
220
220
 
221
221
  ```python
222
222
  import tradechart as tc
@@ -236,7 +236,7 @@ Each tile shows:
236
236
  - **± % change** over the duration
237
237
  - **Last closing price**
238
238
 
239
- The colour scale is automatically centred at zero and symmetrically ranged to the largest move in the group. A colourbar legend is included at the bottom of the chart.
239
+ Tile sizes use a **squarified treemap** algorithm to minimise wasted space while keeping aspect ratios close to square. Market-cap data is fetched automatically via yfinance. When market-cap data is unavailable (indices, futures, commodities), the renderer falls back to an equal-area grid automatically. The colour scale is symmetrically ranged to the largest move in the group. A colourbar legend is included.
240
240
 
241
241
  | Parameter | Type | Required | Default | Description |
242
242
  |---|---|---|---|---|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "TradeChart"
7
- version = "2.0.1"
7
+ version = "2.1.0"
8
8
  description = "Production-quality financial chart generator — candlestick, line, area, OHLC, Heikin-Ashi, performance heatmaps, and sector group charts."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -72,7 +72,7 @@ from tradechart.utils.exceptions import (
72
72
  ConfigError,
73
73
  )
74
74
 
75
- __version__ = "2.0.1"
75
+ __version__ = "2.1.0"
76
76
  __all__ = [
77
77
  "terminal", "theme", "watermark", "config",
78
78
  "chart", "compare", "heatmap", "data", "export", "clear_cache",
@@ -0,0 +1,352 @@
1
+ """Financial performance heatmap renderer.
2
+
3
+ Tiles are sized by market capitalisation using a squarified treemap algorithm —
4
+ large-cap stocks occupy more space, exactly like professional tools (Finviz,
5
+ Bloomberg). Tile colour encodes percentage change over the requested duration.
6
+
7
+ When market-cap data is unavailable for all tickers (e.g. commodities futures
8
+ or indices) the renderer falls back to an equal-area grid.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from pathlib import Path
15
+
16
+ import matplotlib
17
+ matplotlib.use("Agg")
18
+ import matplotlib.pyplot as plt
19
+ import matplotlib.colors as mcolors
20
+ import matplotlib.patches as mpatches
21
+
22
+ from tradechart.config.logger import get_logger
23
+ from tradechart.config.settings import get_settings
24
+ from tradechart.charts.themes import get_theme
25
+ from tradechart.charts.watermark import stamp_logo
26
+ from tradechart.utils.exceptions import RenderError
27
+
28
+
29
+ # ── Colour map: deep-red → slate-neutral → deep-green ───────────────────────
30
+
31
+ _HEATMAP_CMAP = mcolors.LinearSegmentedColormap.from_list(
32
+ "tradechart_heatmap",
33
+ [
34
+ "#8b1a1a", # deep red (large loss)
35
+ "#c62828",
36
+ "#ef5350", # light red
37
+ "#546e7a", # slate-grey (near zero)
38
+ "#26a69a", # teal-green
39
+ "#00897b",
40
+ "#004d40", # deep green (large gain)
41
+ ],
42
+ N=256,
43
+ )
44
+
45
+ _NORM_CACHE: dict[float, mcolors.Normalize] = {}
46
+
47
+
48
+ def _tile_color(pct: float, vrange: float) -> str:
49
+ key = round(vrange, 4)
50
+ if key not in _NORM_CACHE:
51
+ _NORM_CACHE[key] = mcolors.Normalize(vmin=-vrange, vmax=vrange)
52
+ return mcolors.to_hex(_HEATMAP_CMAP(_NORM_CACHE[key](pct)))
53
+
54
+
55
+ # ── Squarified treemap ───────────────────────────────────────────────────────
56
+
57
+ def _worst_aspect(areas: list[float], strip_dim: float) -> float:
58
+ """Worst tile aspect ratio for a candidate row.
59
+
60
+ For a strip of length *strip_dim*, items are arranged along the perpendicular
61
+ axis. The strip width = sum(areas) / strip_dim; each item's other dimension
62
+ = area / strip_width. Aspect ratio = max(w/h, h/w).
63
+ """
64
+ s = sum(areas)
65
+ if s == 0 or strip_dim == 0:
66
+ return float("inf")
67
+ return max(
68
+ max(strip_dim ** 2 * a / s ** 2, s ** 2 / (strip_dim ** 2 * a))
69
+ for a in areas
70
+ )
71
+
72
+
73
+ def _squarify_layout(
74
+ items: list[str],
75
+ weights: list[float],
76
+ x0: float, y0: float,
77
+ x1: float, y1: float,
78
+ ) -> list[tuple[str, float, float, float, float]]:
79
+ """Squarified treemap layout.
80
+
81
+ Returns a list of ``(item, x0, y0, x1, y1)`` in the coordinate space
82
+ [x0..x1] × [y0..y1]. Weights are normalised internally.
83
+ """
84
+ results: list[tuple[str, float, float, float, float]] = []
85
+ _layout(list(items), list(weights), x0, y0, x1, y1, results)
86
+ return results
87
+
88
+
89
+ def _layout(
90
+ items: list[str],
91
+ weights: list[float],
92
+ x0: float, y0: float,
93
+ x1: float, y1: float,
94
+ results: list,
95
+ ) -> None:
96
+ if not items:
97
+ return
98
+
99
+ W = x1 - x0
100
+ H = y1 - y0
101
+
102
+ if W <= 0 or H <= 0:
103
+ return
104
+
105
+ if len(items) == 1:
106
+ results.append((items[0], x0, y0, x1, y1))
107
+ return
108
+
109
+ # Normalise weights to fill W×H
110
+ total_w = sum(weights)
111
+ area = W * H
112
+ normed = [w * area / total_w for w in weights]
113
+
114
+ # Strip runs along the shorter dimension to minimise aspect ratios
115
+ landscape = W >= H
116
+ strip_dim = W if landscape else H
117
+
118
+ # Greedily grow the current row while aspect ratio improves
119
+ row_items: list[str] = [items[0]]
120
+ row_normed: list[float] = [normed[0]]
121
+
122
+ for i in range(1, len(items)):
123
+ candidate = row_normed + [normed[i]]
124
+ if _worst_aspect(candidate, strip_dim) <= _worst_aspect(row_normed, strip_dim):
125
+ row_items.append(items[i])
126
+ row_normed.append(normed[i])
127
+ else:
128
+ break
129
+
130
+ n_row = len(row_items)
131
+ remaining_items = items[n_row:]
132
+ remaining_weights = weights[n_row:]
133
+
134
+ s = sum(row_normed)
135
+
136
+ if landscape:
137
+ # Horizontal strip: full width W, height h_strip = s / W
138
+ h_strip = s / W
139
+ cx = x0
140
+ for item, a in zip(row_items, row_normed):
141
+ tile_w = a / h_strip
142
+ results.append((item, cx, y0, cx + tile_w, y0 + h_strip))
143
+ cx += tile_w
144
+ _layout(remaining_items, remaining_weights, x0, y0 + h_strip, x1, y1, results)
145
+ else:
146
+ # Vertical strip: full height H, width w_strip = s / H
147
+ w_strip = s / H
148
+ cy = y0
149
+ for item, a in zip(row_items, row_normed):
150
+ tile_h = a / w_strip
151
+ results.append((item, x0, cy, x0 + w_strip, cy + tile_h))
152
+ cy += tile_h
153
+ _layout(remaining_items, remaining_weights, x0 + w_strip, y0, x1, y1, results)
154
+
155
+
156
+ def _equal_grid_layout(
157
+ items: list[str],
158
+ x0: float, y0: float,
159
+ x1: float, y1: float,
160
+ ) -> list[tuple[str, float, float, float, float]]:
161
+ """Fallback equal-area grid when no market-cap data is available."""
162
+ n = len(items)
163
+ W = x1 - x0
164
+ H = y1 - y0
165
+ # Choose grid proportional to the canvas aspect ratio
166
+ aspect = W / H if H > 0 else 1.0
167
+ ncols = max(1, round(math.sqrt(n * aspect)))
168
+ nrows = math.ceil(n / ncols)
169
+ cell_w = W / ncols
170
+ cell_h = H / nrows
171
+ results = []
172
+ for i, item in enumerate(items):
173
+ col = i % ncols
174
+ row = nrows - 1 - (i // ncols)
175
+ results.append((
176
+ item,
177
+ x0 + col * cell_w, y0 + row * cell_h,
178
+ x0 + (col + 1) * cell_w, y0 + (row + 1) * cell_h,
179
+ ))
180
+ return results
181
+
182
+
183
+ # ── Renderer ─────────────────────────────────────────────────────────────────
184
+
185
+ class HeatmapRenderer:
186
+ """Stateless renderer — call :meth:`render` to produce a heatmap image."""
187
+
188
+ def render(
189
+ self,
190
+ tickers: list[str],
191
+ perf: dict[str, float], # ticker → % change over duration
192
+ prices: dict[str, float], # ticker → last close price
193
+ market_caps: dict[str, float], # ticker → market cap (0 = unknown)
194
+ label: str,
195
+ duration: str,
196
+ output_path: Path,
197
+ fmt: str = "png",
198
+ provider: str | None = None,
199
+ ) -> Path:
200
+ log = get_logger()
201
+ settings = get_settings()
202
+ theme = get_theme(settings.theme)
203
+
204
+ if not tickers:
205
+ raise RenderError("Cannot render heatmap with no tickers.")
206
+
207
+ # ── Colour range ──────────────────────────────────────────────────────
208
+ values = [perf.get(t, 0.0) for t in tickers]
209
+ abs_max = max((abs(v) for v in values), default=5.0)
210
+ vrange = max(round(abs_max * 1.15, 1), 2.0)
211
+
212
+ # ── Layout ───────────────────────────────────────────────────────────
213
+ # Use figure aspect ratio as the root rectangle so tile aspect ratios
214
+ # computed in data coordinates match what the viewer sees.
215
+ fw, fh = settings.fig_size
216
+ fig_aspect = fw / fh
217
+
218
+ weights = [market_caps.get(t, 0.0) for t in tickers]
219
+ has_caps = sum(weights) > 0
220
+
221
+ if has_caps:
222
+ # Tickers with unknown cap get the smallest known cap as a floor
223
+ known = [w for w in weights if w > 0]
224
+ floor = min(known) * 0.5
225
+ weights = [w if w > 0 else floor for w in weights]
226
+ layout = _squarify_layout(tickers, weights, 0.0, 0.0, fig_aspect, 1.0)
227
+ weight_note = "market-cap weighted"
228
+ else:
229
+ layout = _equal_grid_layout(tickers, 0.0, 0.0, fig_aspect, 1.0)
230
+ weight_note = "equal weight"
231
+
232
+ log.detail("Heatmap layout: %s (%d tiles)", weight_note, len(layout))
233
+
234
+ # ── Figure ───────────────────────────────────────────────────────────
235
+ fig, ax = plt.subplots(figsize=(fw, fh))
236
+ fig.patch.set_facecolor(theme.bg_color)
237
+ ax.set_facecolor(theme.bg_color)
238
+ ax.set_xlim(0, fig_aspect)
239
+ ax.set_ylim(0, 1.0)
240
+ ax.set_aspect("auto")
241
+ ax.axis("off")
242
+
243
+ # Pre-compute figure height in typographic points for font sizing
244
+ fig_h_pt = fh * 72.0
245
+
246
+ for ticker, tx0, ty0, tx1, ty1 in layout:
247
+ pct = perf.get(ticker, 0.0)
248
+ price = prices.get(ticker)
249
+ color = _tile_color(pct, vrange)
250
+
251
+ # Gap between tiles: 1.5 % of cell dimensions
252
+ gap_x = (tx1 - tx0) * 0.015
253
+ gap_y = (ty1 - ty0) * 0.015
254
+
255
+ rect = mpatches.FancyBboxPatch(
256
+ (tx0 + gap_x, ty0 + gap_y),
257
+ (tx1 - tx0) - 2 * gap_x,
258
+ (ty1 - ty0) - 2 * gap_y,
259
+ boxstyle="round,pad=0.005",
260
+ facecolor=color,
261
+ edgecolor=theme.bg_color,
262
+ linewidth=1.5,
263
+ zorder=2,
264
+ transform=ax.transData,
265
+ )
266
+ ax.add_patch(rect)
267
+
268
+ # ── Font sizes — scale with tile height in points ─────────────────
269
+ tile_h_pt = (ty1 - ty0) * fig_h_pt
270
+ tile_w_pt = (tx1 - tx0) / fig_aspect * fw * 72.0
271
+ min_dim_pt = min(tile_h_pt, tile_w_pt)
272
+
273
+ fs_ticker = max(5, min(15, int(min_dim_pt / 7)))
274
+ fs_pct = max(4, min(13, int(min_dim_pt / 9)))
275
+ fs_price = max(4, min(11, int(min_dim_pt / 12)))
276
+
277
+ show_ticker = tile_h_pt >= 18
278
+ show_pct = tile_h_pt >= 32
279
+ show_price = tile_h_pt >= 55 and price is not None
280
+
281
+ if not show_ticker:
282
+ continue
283
+
284
+ cx = (tx0 + tx1) / 2
285
+ cy = (ty0 + ty1) / 2
286
+
287
+ # Vertically distribute text within the tile
288
+ if show_price and show_pct:
289
+ ty_t, ty_p, ty_pr = cy + 0.34 * (ty1 - ty0) * 0.5, cy, cy - 0.34 * (ty1 - ty0) * 0.5
290
+ elif show_pct:
291
+ ty_t, ty_p = cy + 0.22 * (ty1 - ty0) * 0.5, cy - 0.18 * (ty1 - ty0) * 0.5
292
+ ty_pr = None
293
+ else:
294
+ ty_t = cy
295
+ ty_p = ty_pr = None
296
+
297
+ ax.text(cx, ty_t, ticker,
298
+ ha="center", va="center",
299
+ fontsize=fs_ticker, fontweight="bold",
300
+ color="white", zorder=3, clip_on=True)
301
+
302
+ if show_pct and ty_p is not None:
303
+ sign = "+" if pct >= 0 else ""
304
+ ax.text(cx, ty_p, f"{sign}{pct:.2f}%",
305
+ ha="center", va="center",
306
+ fontsize=fs_pct, color="white", alpha=0.92,
307
+ zorder=3, clip_on=True)
308
+
309
+ if show_price and ty_pr is not None:
310
+ price_str = (
311
+ f"${price:,.0f}" if price >= 1_000
312
+ else f"${price:.2f}" if price >= 1
313
+ else f"${price:.4f}"
314
+ )
315
+ ax.text(cx, ty_pr, price_str,
316
+ ha="center", va="center",
317
+ fontsize=fs_price, color="white", alpha=0.72,
318
+ zorder=3, clip_on=True)
319
+
320
+ # ── Title ─────────────────────────────────────────────────────────────
321
+ subtitle = f"market-cap weighted" if has_caps else "equal weight"
322
+ ax.set_title(
323
+ f"{label} • {duration} • Performance Heatmap ({subtitle})",
324
+ color=theme.text_color, fontsize=12, fontweight="bold", pad=12,
325
+ )
326
+
327
+ # ── Colour-bar legend ──────────────────────────────────────────────────
328
+ sm = plt.cm.ScalarMappable(
329
+ cmap=_HEATMAP_CMAP,
330
+ norm=mcolors.Normalize(vmin=-vrange, vmax=vrange),
331
+ )
332
+ sm.set_array([])
333
+ cbar = fig.colorbar(
334
+ sm, ax=ax, orientation="horizontal",
335
+ fraction=0.025, pad=0.03, aspect=50,
336
+ )
337
+ cbar.set_label("% Change", color=theme.text_color, fontsize=9)
338
+ cbar.ax.tick_params(colors=theme.text_color, labelsize=8)
339
+ for spine in cbar.ax.spines.values():
340
+ spine.set_visible(False)
341
+
342
+ fig.tight_layout(pad=1.5)
343
+
344
+ if settings.watermark_enabled:
345
+ stamp_logo(fig, provider=provider)
346
+
347
+ out_file = output_path.with_suffix(f".{fmt}")
348
+ fig.savefig(out_file, dpi=settings.dpi,
349
+ facecolor=fig.get_facecolor(), bbox_inches="tight")
350
+ plt.close(fig)
351
+ log.detail("Saved heatmap → %s", out_file)
352
+ return out_file
@@ -244,10 +244,14 @@ class Engine:
244
244
  valid_tickers = [t for t in tickers if t in perf]
245
245
  provider_str = ", ".join(sorted(set(providers))) if providers else None
246
246
 
247
+ self._log.section("Fetching market caps")
248
+ market_caps = self._fetch_market_caps(valid_tickers)
249
+
247
250
  result_path = HeatmapRenderer().render(
248
251
  tickers=valid_tickers,
249
252
  perf=perf,
250
253
  prices=prices,
254
+ market_caps=market_caps,
251
255
  label=label,
252
256
  duration=duration,
253
257
  output_path=out_path,
@@ -386,6 +390,30 @@ class Engine:
386
390
  )
387
391
  return MarketData(ticker=label, duration=duration, provider="averaged", df=avg_df)
388
392
 
393
+ def _fetch_market_caps(self, tickers: list[str]) -> dict[str, float]:
394
+ """Return market-cap values for *tickers* via yfinance.
395
+
396
+ Returns a dict mapping ticker → market cap in USD. Tickers for which
397
+ the data is unavailable (indices, futures, etc.) map to ``0``, which
398
+ tells the heatmap renderer to fall back to the minimum-cap floor.
399
+ """
400
+ import yfinance as yf
401
+
402
+ caps: dict[str, float] = {}
403
+ for ticker in tickers:
404
+ try:
405
+ info = yf.Ticker(ticker).info
406
+ cap = info.get("marketCap") or info.get("market_cap") or 0
407
+ caps[ticker] = float(cap) if cap else 0.0
408
+ if caps[ticker] > 0:
409
+ self._log.detail("Market cap %s → $%,.0f", ticker, caps[ticker])
410
+ else:
411
+ self._log.detail("Market cap %s → unavailable", ticker)
412
+ except Exception as exc:
413
+ self._log.detail("Market cap %s — failed: %s", ticker, exc)
414
+ caps[ticker] = 0.0
415
+ return caps
416
+
389
417
  def _safe_path(self, out_dir: Path, filename: str) -> Path:
390
418
  """Return a non-colliding output path unless overwrite is enabled."""
391
419
  out_path = out_dir / filename
@@ -1,187 +0,0 @@
1
- """Financial performance heatmap renderer.
2
-
3
- Produces a grid of coloured tiles — one per ticker — where the tile colour
4
- encodes the percentage change over the requested duration. Designed to work
5
- with ticker groups (``tc.SECTOR_GROUPS``) so an entire sector can be visualised
6
- in a single call.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import math
12
- from pathlib import Path
13
-
14
- import matplotlib
15
- matplotlib.use("Agg")
16
- import matplotlib.pyplot as plt
17
- import matplotlib.colors as mcolors
18
- import matplotlib.patches as mpatches
19
-
20
- from tradechart.config.logger import get_logger
21
- from tradechart.config.settings import get_settings
22
- from tradechart.charts.themes import get_theme
23
- from tradechart.charts.watermark import stamp_logo
24
- from tradechart.utils.exceptions import RenderError
25
-
26
-
27
- # ── Colour map: deep-red → slate-neutral → deep-green ───────────────────────
28
-
29
- _HEATMAP_CMAP = mcolors.LinearSegmentedColormap.from_list(
30
- "tradechart_heatmap",
31
- [
32
- "#8b1a1a", # deep red (large loss)
33
- "#c62828", # red
34
- "#ef5350", # light red
35
- "#546e7a", # slate-grey (neutral / near zero)
36
- "#26a69a", # teal-green
37
- "#00897b", # green
38
- "#004d40", # deep green (large gain)
39
- ],
40
- N=256,
41
- )
42
-
43
- _NORM_CACHE: dict[float, mcolors.Normalize] = {}
44
-
45
-
46
- def _tile_color(pct: float, vrange: float) -> str:
47
- """Map *pct* to a hex colour centred at zero with ±*vrange* bounds."""
48
- key = round(vrange, 4)
49
- if key not in _NORM_CACHE:
50
- _NORM_CACHE[key] = mcolors.Normalize(vmin=-vrange, vmax=vrange)
51
- norm = _NORM_CACHE[key]
52
- return mcolors.to_hex(_HEATMAP_CMAP(norm(pct)))
53
-
54
-
55
- def _font_sizes(ncols: int) -> tuple[int, int, int]:
56
- """Return (ticker_fs, pct_fs, price_fs) scaled for the grid width."""
57
- if ncols <= 2:
58
- return 13, 10, 8
59
- if ncols <= 3:
60
- return 11, 9, 7
61
- if ncols <= 4:
62
- return 9, 7, 6
63
- if ncols <= 5:
64
- return 8, 6, 5
65
- return 7, 5, 4
66
-
67
-
68
- class HeatmapRenderer:
69
- """Stateless renderer — call :meth:`render` to produce a heatmap image."""
70
-
71
- def render(
72
- self,
73
- tickers: list[str],
74
- perf: dict[str, float], # ticker → % change over duration
75
- prices: dict[str, float], # ticker → last close price
76
- label: str,
77
- duration: str,
78
- output_path: Path,
79
- fmt: str = "png",
80
- provider: str | None = None,
81
- ) -> Path:
82
- log = get_logger()
83
- settings = get_settings()
84
- theme = get_theme(settings.theme)
85
-
86
- if not tickers:
87
- raise RenderError("Cannot render heatmap with no tickers.")
88
-
89
- n = len(tickers)
90
- ncols = math.ceil(math.sqrt(n))
91
- nrows = math.ceil(n / ncols)
92
-
93
- # Dynamic colour range — symmetrical, at least ±2 %
94
- values = [perf.get(t, 0.0) for t in tickers]
95
- abs_max = max((abs(v) for v in values), default=5.0)
96
- vrange = max(round(abs_max * 1.15, 1), 2.0)
97
-
98
- fs_ticker, fs_pct, fs_price = _font_sizes(ncols)
99
-
100
- fig, ax = plt.subplots(figsize=settings.fig_size)
101
- fig.patch.set_facecolor(theme.bg_color)
102
- ax.set_facecolor(theme.bg_color)
103
- ax.set_xlim(0, ncols)
104
- ax.set_ylim(0, nrows)
105
- ax.axis("off")
106
-
107
- for i, ticker in enumerate(tickers):
108
- row = nrows - 1 - (i // ncols)
109
- col = i % ncols
110
- pct = perf.get(ticker, 0.0)
111
- price = prices.get(ticker)
112
- color = _tile_color(pct, vrange)
113
-
114
- # ── Rounded-rectangle tile ──────────────────────────────────────
115
- rect = mpatches.FancyBboxPatch(
116
- (col + 0.04, row + 0.04), 0.92, 0.92,
117
- boxstyle="round,pad=0.03",
118
- facecolor=color,
119
- edgecolor=theme.bg_color,
120
- linewidth=2.5,
121
- zorder=2,
122
- )
123
- ax.add_patch(rect)
124
-
125
- cx = col + 0.50
126
- cy = row + 0.50
127
-
128
- # ── Text layout (3 rows: ticker / pct / price) ──────────────────
129
- has_price = price is not None
130
- if has_price:
131
- ty, py, pry = cy + 0.17, cy - 0.03, cy - 0.22
132
- else:
133
- ty, py = cy + 0.08, cy - 0.11
134
- pry = None # unused
135
-
136
- ax.text(cx, ty, ticker,
137
- ha="center", va="center",
138
- fontsize=fs_ticker, fontweight="bold",
139
- color="white", zorder=3)
140
-
141
- sign = "+" if pct >= 0 else ""
142
- ax.text(cx, py, f"{sign}{pct:.2f}%",
143
- ha="center", va="center",
144
- fontsize=fs_pct, color="white", alpha=0.95, zorder=3)
145
-
146
- if has_price and pry is not None:
147
- price_str = (
148
- f"${price:,.0f}" if price >= 1_000
149
- else f"${price:.2f}" if price >= 1
150
- else f"${price:.4f}"
151
- )
152
- ax.text(cx, pry, price_str,
153
- ha="center", va="center",
154
- fontsize=fs_price, color="white", alpha=0.75, zorder=3)
155
-
156
- # ── Title ────────────────────────────────────────────────────────────
157
- ax.set_title(
158
- f"{label} • {duration} • Performance Heatmap",
159
- color=theme.text_color, fontsize=13, fontweight="bold", pad=14,
160
- )
161
-
162
- # ── Colour-bar legend ────────────────────────────────────────────────
163
- sm = plt.cm.ScalarMappable(
164
- cmap=_HEATMAP_CMAP,
165
- norm=mcolors.Normalize(vmin=-vrange, vmax=vrange),
166
- )
167
- sm.set_array([])
168
- cbar = fig.colorbar(
169
- sm, ax=ax, orientation="horizontal",
170
- fraction=0.025, pad=0.03, aspect=45,
171
- )
172
- cbar.set_label("% Change", color=theme.text_color, fontsize=9)
173
- cbar.ax.tick_params(colors=theme.text_color, labelsize=8)
174
- for spine in cbar.ax.spines.values():
175
- spine.set_visible(False)
176
-
177
- fig.tight_layout(pad=1.5)
178
-
179
- if settings.watermark_enabled:
180
- stamp_logo(fig, provider=provider)
181
-
182
- out_file = output_path.with_suffix(f".{fmt}")
183
- fig.savefig(out_file, dpi=settings.dpi,
184
- facecolor=fig.get_facecolor(), bbox_inches="tight")
185
- plt.close(fig)
186
- log.detail("Saved heatmap → %s", out_file)
187
- return out_file
File without changes
File without changes