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.
- {tradechart-2.0.1 → tradechart-2.1.0}/PKG-INFO +4 -4
- {tradechart-2.0.1 → tradechart-2.1.0}/README.md +3 -3
- {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/PKG-INFO +4 -4
- {tradechart-2.0.1 → tradechart-2.1.0}/pyproject.toml +1 -1
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/__init__.py +1 -1
- tradechart-2.1.0/tradechart/charts/heatmap.py +352 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/core/engine.py +28 -0
- tradechart-2.0.1/tradechart/charts/heatmap.py +0 -187
- {tradechart-2.0.1 → tradechart-2.1.0}/LICENSE +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/SOURCES.txt +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/dependency_links.txt +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/requires.txt +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/TradeChart.egg-info/top_level.txt +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/setup.cfg +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/__init__.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/indicators.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/renderer.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/themes.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/charts/watermark.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/config/__init__.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/config/logger.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/config/settings.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/core/__init__.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/__init__.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/fetcher.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/groups.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/models.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/data/provider_base.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/providers/__init__.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/providers/stooq_provider.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/providers/tradingview_provider.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/providers/yfinance_provider.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/utils/__init__.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/utils/exceptions.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/utils/formatting.py +0 -0
- {tradechart-2.0.1 → tradechart-2.1.0}/tradechart/utils/validation.py +0 -0
- {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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
# 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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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"}
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|