Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -751,10 +751,10 @@
"plugin_path": "plugins/stock-news",
"stars": 0,
"downloads": 0,
"last_updated": "2026-05-22",
"last_updated": "2026-07-08",
"verified": true,
"screenshot": "",
"latest_version": "2.4.1"
"latest_version": "2.4.4"
},
{
"id": "stocks",
Expand Down
58 changes: 56 additions & 2 deletions plugins/stock-news/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@
"default": false,
"description": "Show current market price (e.g. '$189.42') before the headline"
},
"price_color_mode": {
"type": "string",
"enum": ["fixed", "change"],
"default": "fixed",
"x-widget": "radio",
"description": "Price colour: 'fixed' always uses text_color; 'change' colours it price_up_color / price_down_color based on movement since market open"
},
"publisher_font_size": {
"type": "integer",
"default": 0,
"minimum": 0,
"maximum": 24,
"description": "Font size for the publisher segment (e.g. 'Reuters'). Set to 0 to match the main font_size."
},
"publisher_font_path": {
"type": "string",
"default": "",
"description": "Optional separate TTF for the publisher segment. Empty = use the main font_path."
},
"age_font_size": {
"type": "integer",
"default": 0,
"minimum": 0,
"maximum": 24,
"description": "Font size for the relative-age segment (e.g. '2h ago'). Set to 0 to match the main font_size."
},
"age_font_path": {
"type": "string",
"default": "",
"description": "Optional separate TTF for the relative-age segment. Empty = use the main font_path."
},
"eager_fetch_on_startup": {
"type": "boolean",
"default": true,
"description": "Fetch every configured symbol once immediately (ignoring the normal per-symbol spacing) so the ticker isn't left showing just 1-2 headlines for several minutes after a restart or config change. Falls back to the spread schedule once every symbol has data."
},
"sync_with_stocks_plugin": {
"type": "boolean",
"default": false,
Expand All @@ -61,7 +97,7 @@
},
"scroll_pixels_per_second": {
"type": "number",
"default": 25.0,
"default": 60.0,
"minimum": 5.0,
"maximum": 100.0,
"description": "Scroll speed in pixels per second"
Expand Down Expand Up @@ -287,7 +323,25 @@
"type": "string",
"x-widget": "color-picker",
"default": "#6e6e6e",
"description": "Color for publisher and age suffix (e.g. '• Reuters • 2h ago')"
"description": "Color for the publisher segment (e.g. '• Reuters')"
},
"age_color": {
"type": "string",
"x-widget": "color-picker",
"default": "#6e6e6e",
"description": "Color for the relative-age segment (e.g. '• 2h ago'), independent of publisher_color"
},
"price_up_color": {
"type": "string",
"x-widget": "color-picker",
"default": "#00ff00",
"description": "Price color when up since market open. Only used when price_color_mode is 'change'."
},
"price_down_color": {
"type": "string",
"x-widget": "color-picker",
"default": "#ff3b30",
"description": "Price color when down since market open. Only used when price_color_mode is 'change'."
}
},
"additionalProperties": false
Expand Down
138 changes: 117 additions & 21 deletions plugins/stock-news/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ def __init__(self, plugin_id: str, config: Dict[str, Any],
self._configure_scroll_settings()
self._register_fonts()

# Expose enable_scrolling so the core display loop uses its high-FPS
# (125 FPS) path instead of the 1 FPS default path used by static plugins
self.enable_scrolling = True

stock_symbols = self.feeds_config.get('stock_symbols', [])
custom_feeds = self._get_custom_feeds()
self.logger.info(
Expand Down Expand Up @@ -177,10 +181,34 @@ def _apply_config(self) -> None:
# Cross-plugin sync
self.sync_with_stocks_plugin = gc.get('sync_with_stocks_plugin', False)

# Eager fill: fetch every never-seen symbol at update() cadence until
# each has data at least once, instead of waiting for the steady-state
# per-symbol spacing. See update().
self.eager_fetch_on_startup = gc.get('eager_fetch_on_startup', True)

# Independent publisher / age styling — 0 or '' falls back to the main
# headline font_size / font_path so existing configs render unchanged.
raw_pub_fs = gc.get('publisher_font_size', 0)
self.publisher_font_size = int(raw_pub_fs) if raw_pub_fs and int(raw_pub_fs) > 0 else self.font_size
self.publisher_font_path = gc.get('publisher_font_path') or gc.get(
'font_path', 'assets/fonts/PressStart2P-Regular.ttf')

raw_age_fs = gc.get('age_font_size', 0)
self.age_font_size = int(raw_age_fs) if raw_age_fs and int(raw_age_fs) > 0 else self.font_size
self.age_font_path = gc.get('age_font_path') or gc.get(
'font_path', 'assets/fonts/PressStart2P-Regular.ttf')

# Price coloring: 'fixed' always uses text_color; 'change' colors the
# price green/red based on movement since market open.
self.price_color_mode = gc.get('price_color_mode', 'fixed')

# Colours
self.text_color: Tuple = self._parse_color(fc.get('text_color'), [0, 255, 0])
self.symbol_color: Tuple = self._parse_color(fc.get('symbol_color'), [255, 255, 0])
self.publisher_color: Tuple = self._parse_color(fc.get('publisher_color'), [110, 110, 110])
self.age_color: Tuple = self._parse_color(fc.get('age_color'), [110, 110, 110])
self.price_up_color: Tuple = self._parse_color(fc.get('price_up_color'), [0, 255, 0])
self.price_down_color: Tuple = self._parse_color(fc.get('price_down_color'), [255, 59, 48])

# Rate limiting
self.update_interval = gc.get('update_interval_seconds', 900)
Expand All @@ -203,18 +231,31 @@ def _apply_config(self) -> None:
# Font / scroll
# -------------------------------------------------------------------------

def _load_fonts(self) -> dict:
"""Three-level fallback: configured TTF → 4x6 bitmap → PIL default."""
configured = self.global_config.get('font_path', 'assets/fonts/PressStart2P-Regular.ttf')
for path in [configured, 'assets/fonts/4x6-font.ttf']:
def _load_font(self, path: str, size: int) -> Optional[ImageFont.FreeTypeFont]:
"""Two-level fallback: given TTF → 4x6 bitmap. Returns None if neither loads."""
for p in [path, 'assets/fonts/4x6-font.ttf']:
try:
font = ImageFont.truetype(path, self.font_size)
self.logger.debug("[Stock News] Font: %s @ %dpx", path, self.font_size)
return {'headline': font, 'symbol': font}
font = ImageFont.truetype(p, size)
self.logger.debug("[Stock News] Font: %s @ %dpx", p, size)
return font
except IOError:
continue
self.logger.warning("[Stock News] No TTF font found, using PIL default")
return {}
return None

def _load_fonts(self) -> dict:
"""Load the main headline/symbol font plus independently configurable
publisher and age fonts, each falling back to the main font on failure."""
main_path = self.global_config.get('font_path', 'assets/fonts/PressStart2P-Regular.ttf')
main_font = self._load_font(main_path, self.font_size)
if main_font is None:
self.logger.warning("[Stock News] No TTF font found, using PIL default")
return {}

publisher_font = self._load_font(self.publisher_font_path, self.publisher_font_size) or main_font
age_font = self._load_font(self.age_font_path, self.age_font_size) or main_font

return {'headline': main_font, 'symbol': main_font,
'publisher': publisher_font, 'age': age_font}

def _configure_scroll_settings(self) -> None:
"""Apply scroll speed / FPS to ScrollHelper using frame-based scrolling."""
Expand Down Expand Up @@ -274,8 +315,10 @@ def _register_fonts(self) -> None:
"press_start", self.font_size, self.text_color)
fm.register_manager_font(self.plugin_id, f"{self.plugin_id}.symbol",
"press_start", self.font_size, self.symbol_color)
fm.register_manager_font(self.plugin_id, f"{self.plugin_id}.info",
"four_by_six", 6, self.publisher_color)
fm.register_manager_font(self.plugin_id, f"{self.plugin_id}.publisher",
"press_start", self.publisher_font_size, self.publisher_color)
fm.register_manager_font(self.plugin_id, f"{self.plugin_id}.age",
"press_start", self.age_font_size, self.age_color)
except Exception as e:
self.logger.warning("[Stock News] Font registration error: %s", e)

Expand Down Expand Up @@ -312,8 +355,26 @@ def update(self) -> None:
if not self._is_market_hours():
per_sym = per_sym * self.off_hours_multiplier

# Eager fill: on a fresh start (or after symbols are added via config
# change) every symbol starts with zero cached headlines, and the
# steady-state schedule below only fetches one symbol every `per_sym`
# seconds — with 3 symbols on a 900s interval that's a 5-minute wait
# per symbol, leaving the ticker sparse for a long time. Prioritize
# any never-fetched symbol at plain update() cadence (ignoring
# per_sym spacing, still budget-checked) until every symbol has data
# at least once, then fall back to the normal spread schedule.
unseen = [s for s in stock_symbols if s not in self._symbol_data] if self.eager_fetch_on_startup else []

if unseen:
symbol = unseen[0]
max_h = self._get_symbol_max_headlines(symbol)
items = self._fetch_stock_news(symbol, max_h)
self._symbol_data[symbol] = items
self._fetch_index = (stock_symbols.index(symbol) + 1) % len(stock_symbols)
self._last_symbol_fetch = now
fetched = True
# Fetch exactly one symbol per call (rotating)
if stock_symbols and (now - self._last_symbol_fetch) >= per_sym:
elif stock_symbols and (now - self._last_symbol_fetch) >= per_sym:
idx = self._fetch_index % len(stock_symbols)
symbol = stock_symbols[idx]
max_h = self._get_symbol_max_headlines(symbol)
Expand Down Expand Up @@ -441,12 +502,14 @@ def _fetch_yf_api(self, symbol: str, max_h: int) -> List[Dict]:

data = resp.json()

# Extract price from quote if requested
# Extract price (and change since open, for up/down coloring) from quote if requested
price: Optional[float] = None
price_change: Optional[float] = None
if self.show_price:
quotes = data.get('quotes', [])
if quotes:
price = quotes[0].get('regularMarketPrice') or quotes[0].get('price')
price_change = quotes[0].get('regularMarketChange')

items: List[Dict] = []
for raw in data.get('news', [])[:max_h]:
Expand All @@ -462,6 +525,7 @@ def _fetch_yf_api(self, symbol: str, max_h: int) -> List[Dict]:
'publisher': raw.get('publisher', ''),
'published_ts': pub_ts, # unix float — cache-safe
'price': price,
'price_change': price_change,
})

self.cache_manager.set(cache_key, items, ttl=self.update_interval * 2)
Expand Down Expand Up @@ -744,16 +808,22 @@ def _render_news_item(self, news_item: dict) -> Optional[Image.Image]:

Segments drawn left-to-right:
logo (optional) | symbol: | $price | headline | • publisher | • age

Publisher and age are independent segments (own colour/font/size each),
not a single combined suffix string.
"""
try:
symbol = news_item.get('symbol', news_item.get('feed_name', '?'))
title = news_item.get('title', '')
publisher = news_item.get('publisher', '')
pub_ts = news_item.get('published_ts', 0)
price = news_item.get('price')
price_change = news_item.get('price_change')

font_sym = self._fonts.get('symbol')
font_hl = self._fonts.get('headline')
font_pub = self._fonts.get('publisher', font_hl)
font_age = self._fonts.get('age', font_hl)

logo = (self._get_symbol_logo(symbol)
if self.display_style in ('logo_and_ticker', 'logo_only') else None)
Expand All @@ -763,29 +833,38 @@ def _render_news_item(self, news_item: dict) -> Optional[Image.Image]:
sym_c = (80, 60, 0) if stale else self.symbol_color
txt_c = (0, 80, 0) if stale else self.text_color
pub_c = (60, 60, 60) if stale else self.publisher_color
age_c = (60, 60, 60) if stale else self.age_color
price_up_c = (0, 40, 0) if stale else self.price_up_color
price_down_c = (40, 0, 0) if stale else self.price_down_color

price_c = txt_c
if self.price_color_mode == 'change' and price_change is not None:
if price_change > 0:
price_c = price_up_c
elif price_change < 0:
price_c = price_down_c

# Build text segments: (text, colour, font)
segments: List[Tuple[str, tuple, Any]] = []

if self.display_style == 'logo_only':
segments.append((f" {symbol}", sym_c, font_sym))
if self.show_price and price is not None:
segments.append((f" ${price:.2f}", txt_c, font_sym))
segments.append((f" ${price:.2f}", price_c, font_sym))
else:
segments.append((f"{symbol}: ", sym_c, font_sym))
if self.show_price and price is not None:
segments.append((f"${price:.2f} ", txt_c, font_hl))
segments.append((f"${price:.2f} ", price_c, font_hl))
if title:
segments.append((title, txt_c, font_hl))
suffix: List[str] = []
# Publisher and age are independently styled (colour, font, size) —
# rendered as separate segments, each with its own bullet prefix.
if self.show_publisher and publisher:
suffix.append(publisher)
segments.append((" • " + publisher, pub_c, font_pub))
if self.show_age and pub_ts:
age = self._format_age(pub_ts)
if age:
suffix.append(age)
if suffix:
segments.append((" • " + " • ".join(suffix), pub_c, font_hl))
segments.append((" • " + age, age_c, font_age))

# Measure segments
draw_tmp = ImageDraw.Draw(Image.new('RGB', (1, 1)))
Expand Down Expand Up @@ -894,14 +973,22 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None:
old_custom_map = self._get_custom_feeds().copy()
old_font_size = self.font_size
old_font_path = self.global_config.get('font_path', '')
old_publisher_font_size = getattr(self, 'publisher_font_size', 0)
old_publisher_font_path = getattr(self, 'publisher_font_path', '')
old_age_font_size = getattr(self, 'age_font_size', 0)
old_age_font_path = getattr(self, 'age_font_path', '')

self.feeds_config = new_config.get('feeds', {})
self.global_config = new_config.get('global', {})
self._apply_config()
self._configure_scroll_settings()

font_changed = (self.font_size != old_font_size or
self.global_config.get('font_path', '') != old_font_path)
self.global_config.get('font_path', '') != old_font_path or
self.publisher_font_size != old_publisher_font_size or
self.publisher_font_path != old_publisher_font_path or
self.age_font_size != old_age_font_size or
self.age_font_path != old_age_font_path)
if font_changed:
self._fonts = self._load_fonts()
self._register_fonts()
Expand Down Expand Up @@ -956,10 +1043,16 @@ def get_info(self) -> Dict[str, Any]:
'dynamic_duration': self.dynamic_duration,
'display_style': self.display_style,
'font_size': self.font_size,
'publisher_font_size': self.publisher_font_size,
'publisher_font_path': self.publisher_font_path,
'age_font_size': self.age_font_size,
'age_font_path': self.age_font_path,
'item_gap': self.item_gap,
'show_publisher': self.show_publisher,
'show_age': self.show_age,
'show_price': self.show_price,
'price_color_mode': self.price_color_mode,
'eager_fetch_on_startup': self.eager_fetch_on_startup,
'shuffle_headlines': self.shuffle_headlines,
'rotation_enabled': self.rotation_enabled,
'rotation_threshold': self.rotation_threshold,
Expand All @@ -974,6 +1067,9 @@ def get_info(self) -> Dict[str, Any]:
'text_color': self.text_color,
'symbol_color': self.symbol_color,
'publisher_color': self.publisher_color,
'age_color': self.age_color,
'price_up_color': self.price_up_color,
'price_down_color': self.price_down_color,
})
return info

Expand Down
Loading
Loading