Skip to content

Commit 194127a

Browse files
authored
Implemented event-based alert system for both Low and High limits
1 parent 8cecb74 commit 194127a

File tree

1 file changed

+166
-74
lines changed

1 file changed

+166
-74
lines changed

custom_components/ghostfolio/sensor.py

Lines changed: 166 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,20 @@ def __init__(self, coordinator, config_entry, account_id, account_name, holding_
519519
"model": "Account Portfolio",
520520
"via_device": (DOMAIN, f"ghostfolio_portfolio_{config_entry.entry_id}"),
521521
}
522+
523+
# Track previous limit states for Event Firing
524+
self._prev_low_reached = False
525+
self._prev_high_reached = False
526+
527+
@callback
528+
def _handle_coordinator_update(self) -> None:
529+
"""Handle updated data from the coordinator."""
530+
self._check_and_fire_events()
531+
super()._handle_coordinator_update()
532+
533+
async def async_update(self) -> None:
534+
"""Manual update triggered by number entity."""
535+
self._check_and_fire_events()
522536

523537
@property
524538
def holding_data(self) -> dict[str, Any] | None:
@@ -545,6 +559,70 @@ def native_value(self) -> float | None:
545559

546560
return data.get("valueInBaseCurrency") or data.get("value")
547561

562+
def _get_limit_state(self, limit_type: str, current_value: float, compare_op):
563+
"""Helper to check limit status and return (limit_val_or_false, is_reached)."""
564+
registry = er.async_get(self.hass)
565+
safe_symbol = slugify(self.symbol)
566+
entry_id = self.config_entry.entry_id
567+
568+
# Reconstruct the Number's unique ID
569+
# Pattern: ghostfolio_limit_{limit_type}_{account_id}_{safe_symbol}_{entry_id}
570+
num_unique_id = f"ghostfolio_limit_{limit_type}_{self.account_id}_{safe_symbol}_{entry_id}"
571+
572+
entity_id = registry.async_get_entity_id("number", DOMAIN, num_unique_id)
573+
574+
limit_display = False
575+
is_reached = False
576+
limit_val = 0.0
577+
578+
if entity_id:
579+
state_obj = self.hass.states.get(entity_id)
580+
if state_obj and state_obj.state not in [None, "unknown", "unavailable"]:
581+
try:
582+
limit_val = float(state_obj.state)
583+
if limit_val > 0:
584+
limit_display = limit_val
585+
if current_value > 0:
586+
if compare_op(current_value, limit_val):
587+
is_reached = True
588+
except ValueError:
589+
pass
590+
return limit_display, is_reached, limit_val
591+
592+
def _check_and_fire_events(self):
593+
"""Check limits and fire events if transitions occur."""
594+
data = self.holding_data
595+
if not data:
596+
return
597+
598+
current_value_in_base = float(data.get("valueInBaseCurrency") or data.get("value") or 0)
599+
600+
# Low Limit Check
601+
low_disp, low_reached, low_val = self._get_limit_state("low", current_value_in_base, lambda val, limit: val <= limit)
602+
if low_reached and not self._prev_low_reached:
603+
self.hass.bus.async_fire("ghostfolio_limit_alert", {
604+
"ticker": self.symbol,
605+
"account": self.account_name,
606+
"limit_type": "low",
607+
"limit_value": low_val,
608+
"current_value": current_value_in_base,
609+
"currency": self.native_unit_of_measurement
610+
})
611+
self._prev_low_reached = low_reached
612+
613+
# High Limit Check
614+
high_disp, high_reached, high_val = self._get_limit_state("high", current_value_in_base, lambda val, limit: val >= limit)
615+
if high_reached and not self._prev_high_reached:
616+
self.hass.bus.async_fire("ghostfolio_limit_alert", {
617+
"ticker": self.symbol,
618+
"account": self.account_name,
619+
"limit_type": "high",
620+
"limit_value": high_val,
621+
"current_value": current_value_in_base,
622+
"currency": self.native_unit_of_measurement
623+
})
624+
self._prev_high_reached = high_reached
625+
548626
@property
549627
def extra_state_attributes(self) -> dict[str, Any] | None:
550628
data = self.holding_data
@@ -574,44 +652,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
574652
else:
575653
trend = "break_even"
576654

577-
# --- LIMIT CHECK LOGIC ---
578-
registry = er.async_get(self.hass)
579-
entry_id = self.config_entry.entry_id
580-
safe_symbol = slugify(self.symbol)
581-
582-
# Helper to check a limit against MARKET PRICE (Unit Price)
583-
def get_limit_status(limit_type, compare_op):
584-
# Reconstruct the Number's unique ID
585-
# Pattern from number.py: ghostfolio_limit_{limit_type}_{account_id}_{safe_symbol}_{entry_id}
586-
num_unique_id = f"ghostfolio_limit_{limit_type}_{self.account_id}_{safe_symbol}_{entry_id}"
587-
588-
# Lookup Entity ID
589-
entity_id = registry.async_get_entity_id("number", DOMAIN, num_unique_id)
590-
591-
# Change: Return actual value or False
592-
limit_display = False
593-
is_reached = False
594-
595-
if entity_id:
596-
state_obj = self.hass.states.get(entity_id)
597-
if state_obj and state_obj.state not in [None, "unknown", "unavailable"]:
598-
try:
599-
limit_val = float(state_obj.state)
600-
if limit_val > 0:
601-
limit_display = limit_val
602-
# CHANGED: Check against market_price_asset instead of current_value_in_base
603-
# This alerts on the STOCK PRICE, not the Total Value of the holding.
604-
if market_price_asset > 0:
605-
if compare_op(market_price_asset, limit_val):
606-
is_reached = True
607-
except ValueError:
608-
pass
609-
return limit_display, is_reached
610-
611-
# Standard Logic: Low Limit reached if val <= limit
612-
low_val, low_reached = get_limit_status("low", lambda val, limit: val <= limit)
613-
# Standard Logic: High Limit reached if val >= limit
614-
high_val, high_reached = get_limit_status("high", lambda val, limit: val >= limit)
655+
# --- LIMIT CHECK LOGIC (Reusing Helper) ---
656+
low_val, low_reached, _ = self._get_limit_state("low", current_value_in_base, lambda val, limit: val <= limit)
657+
high_val, high_reached, _ = self._get_limit_state("high", current_value_in_base, lambda val, limit: val >= limit)
615658
# -------------------------
616659

617660
return {
@@ -667,6 +710,20 @@ def __init__(self, coordinator, config_entry, item_data):
667710
"model": "Account Portfolio",
668711
"via_device": (DOMAIN, f"ghostfolio_portfolio_{config_entry.entry_id}"),
669712
}
713+
714+
# Track previous limit states for Event Firing
715+
self._prev_low_reached = False
716+
self._prev_high_reached = False
717+
718+
@callback
719+
def _handle_coordinator_update(self) -> None:
720+
"""Handle updated data from the coordinator."""
721+
self._check_and_fire_events()
722+
super()._handle_coordinator_update()
723+
724+
async def async_update(self) -> None:
725+
"""Manual update triggered by number entity."""
726+
self._check_and_fire_events()
670727

671728
@property
672729
def item_data(self) -> dict[str, Any] | None:
@@ -706,17 +763,80 @@ def native_unit_of_measurement(self) -> str | None:
706763
return "GBP"
707764
return data.get("currency")
708765

766+
def _get_limit_state(self, limit_type: str, current_value: float, compare_op):
767+
"""Helper to check limit status and return (limit_val_or_false, is_reached)."""
768+
registry = er.async_get(self.hass)
769+
safe_symbol = slugify(self.symbol)
770+
entry_id = self.config_entry.entry_id
771+
772+
# Reconstruct the Number's unique ID
773+
# Pattern: ghostfolio_watchlist_limit_{limit_type}_{slugify(symbol)}_{entry_id}
774+
num_unique_id = f"ghostfolio_watchlist_limit_{limit_type}_{safe_symbol}_{entry_id}"
775+
776+
entity_id = registry.async_get_entity_id("number", DOMAIN, num_unique_id)
777+
778+
limit_display = False
779+
is_reached = False
780+
limit_val = 0.0
781+
782+
if entity_id:
783+
state_obj = self.hass.states.get(entity_id)
784+
if state_obj and state_obj.state not in [None, "unknown", "unavailable"]:
785+
try:
786+
limit_val = float(state_obj.state)
787+
if limit_val > 0:
788+
limit_display = limit_val
789+
if current_value > 0:
790+
if compare_op(current_value, limit_val):
791+
is_reached = True
792+
except ValueError:
793+
pass
794+
return limit_display, is_reached, limit_val
795+
796+
def _check_and_fire_events(self):
797+
"""Check limits and fire events if transitions occur."""
798+
data = self.item_data
799+
if not data:
800+
return
801+
802+
# GBp Handling for attributes and limit check
803+
is_gbp_conversion = (data.get("currency") == "GBp")
804+
raw_price = data.get("marketPrice") or 0
805+
current_price = (raw_price / 100) if is_gbp_conversion else raw_price
806+
currency = "GBP" if is_gbp_conversion else data.get("currency")
807+
808+
# Low Limit Check
809+
low_disp, low_reached, low_val = self._get_limit_state("low", current_price, lambda val, limit: val <= limit)
810+
if low_reached and not self._prev_low_reached:
811+
self.hass.bus.async_fire("ghostfolio_limit_alert", {
812+
"ticker": self.symbol,
813+
"account": "Watchlist",
814+
"limit_type": "low",
815+
"limit_value": low_val,
816+
"current_value": current_price,
817+
"currency": currency
818+
})
819+
self._prev_low_reached = low_reached
820+
821+
# High Limit Check
822+
high_disp, high_reached, high_val = self._get_limit_state("high", current_price, lambda val, limit: val >= limit)
823+
if high_reached and not self._prev_high_reached:
824+
self.hass.bus.async_fire("ghostfolio_limit_alert", {
825+
"ticker": self.symbol,
826+
"account": "Watchlist",
827+
"limit_type": "high",
828+
"limit_value": high_val,
829+
"current_value": current_price,
830+
"currency": currency
831+
})
832+
self._prev_high_reached = high_reached
833+
709834
@property
710835
def extra_state_attributes(self) -> dict[str, Any] | None:
711836
data = self.item_data
712837
if not data:
713838
return None
714839

715-
# --- LIMIT CHECK LOGIC ---
716-
registry = er.async_get(self.hass)
717-
entry_id = self.config_entry.entry_id
718-
safe_symbol = slugify(self.symbol)
719-
720840
# GBp Handling for attributes and limit check
721841
is_gbp_conversion = (data.get("currency") == "GBp")
722842
raw_price = data.get("marketPrice") or 0
@@ -725,37 +845,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
725845
raw_change = data.get("marketChange")
726846
market_change = (raw_change / 100) if (is_gbp_conversion and raw_change is not None) else raw_change
727847

728-
# Helper to check a limit against CURRENT SENSOR VALUE (Price for Watchlist)
729-
def get_limit_status(limit_type, compare_op):
730-
# Reconstruct the Number's unique ID
731-
# Pattern from number.py: ghostfolio_watchlist_limit_{limit_type}_{slugify(symbol)}_{entry_id}
732-
num_unique_id = f"ghostfolio_watchlist_limit_{limit_type}_{safe_symbol}_{entry_id}"
733-
734-
# Lookup Entity ID
735-
entity_id = registry.async_get_entity_id("number", DOMAIN, num_unique_id)
736-
737-
# Change: Return actual value or False
738-
limit_display = False
739-
is_reached = False
740-
741-
if entity_id:
742-
state_obj = self.hass.states.get(entity_id)
743-
if state_obj and state_obj.state not in [None, "unknown", "unavailable"]:
744-
try:
745-
limit_val = float(state_obj.state)
746-
if limit_val > 0:
747-
limit_display = limit_val
748-
if current_price > 0:
749-
if compare_op(current_price, limit_val):
750-
is_reached = True
751-
except ValueError:
752-
pass
753-
return limit_display, is_reached
754-
755-
# Standard Logic: Low Limit reached if val <= limit
756-
low_val, low_reached = get_limit_status("low", lambda val, limit: val <= limit)
757-
# Standard Logic: High Limit reached if val >= limit
758-
high_val, high_reached = get_limit_status("high", lambda val, limit: val >= limit)
848+
# --- LIMIT CHECK LOGIC (Reusing Helper) ---
849+
low_val, low_reached, _ = self._get_limit_state("low", current_price, lambda val, limit: val <= limit)
850+
high_val, high_reached, _ = self._get_limit_state("high", current_price, lambda val, limit: val >= limit)
759851
# -------------------------
760852

761853
return {

0 commit comments

Comments
 (0)