@@ -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