KarlQuant commited on
Commit
74a6579
·
verified ·
1 Parent(s): 97b71c6

Update Features.py

Browse files
Files changed (1) hide show
  1. Features.py +295 -67
Features.py CHANGED
@@ -494,71 +494,243 @@ TIMEFRAMES = {
494
 
495
 
496
  # ============================================================================
497
- # FEATURE CONTRACT (EXACTLY 60 FEATURES)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  # ============================================================================
499
 
500
- REQUIRED_FEATURES = [
 
 
 
 
 
 
 
501
  # Core Technical (19)
502
  'log_return', 'rolling_mean_5', 'rolling_std_5', 'zscore_5',
503
  'rsi_14', 'macd', 'macd_signal', 'macd_hist', 'atr',
504
  'cdf_value', 'cdf_slope', 'cdf_diff',
505
  'volatility_quantile_90', 'volatility_ratio', 'entropy_50',
506
  'autocorr_3', 'momentum_10', 'volume_change_rate', 'volume_zscore',
507
-
508
  # Derivatives (15)
509
  'price_vel', 'price_acc', 'price_jrk',
510
  'price_vel_mean', 'price_vel_std', 'price_vel_skew', 'price_vel_kurtosis',
511
  'price_acc_mean', 'price_acc_std', 'price_acc_skew', 'price_acc_kurtosis',
512
  'price_jrk_mean', 'price_jrk_std', 'price_jrk_skew', 'price_jrk_kurtosis',
513
-
514
  # Additional Technical (7)
515
  'ma10', 'ma20', 'std20',
516
  'bollinger_upper', 'bollinger_lower', 'bollinger_width', 'bollinger_position',
517
-
518
  # Candlestick (9)
519
  'gravestone_doji', 'four_price_doji', 'doji', 'spinning_top',
520
  'bullish_candle', 'bearish_candle', 'dragonfly_candle',
521
  'spinning_top_bearish_followup', 'bullish_then_dragonfly',
522
-
523
- # Support/Resistance (7)
524
  'distance_to_nearest_support', 'distance_to_nearest_resistance',
525
  'near_support', 'near_resistance', 'distance_to_stop_loss',
526
  'support_strength', 'resistance_strength',
527
-
528
- # Price Variants (3)
529
- 'price', 'close_scaled', 'close_price'
530
- ]
531
-
532
- METADATA_FIELDS = {'timestamp', 'tick_count', 'tick_index', 'timeframe', 'agent', 'feature_count', 'price'}
533
-
534
- BINARY_FEATURES = {
535
- 'near_support', 'near_resistance',
536
- 'gravestone_doji', 'four_price_doji', 'doji', 'spinning_top',
537
- 'bullish_candle', 'bearish_candle', 'dragonfly_candle',
538
- 'spinning_top_bearish_followup', 'bullish_then_dragonfly'
539
- }
540
-
541
- NORMALIZATION_EXCLUSIONS = {
542
- # Binary flags
 
 
 
543
  'near_support', 'near_resistance',
544
  'gravestone_doji', 'four_price_doji', 'doji', 'spinning_top',
545
  'bullish_candle', 'bearish_candle', 'dragonfly_candle',
546
  'spinning_top_bearish_followup', 'bullish_then_dragonfly',
547
-
548
- # Raw price (keep absolute scale)
549
  'price', 'close_scaled', 'close_price',
550
-
551
- # Price-unit features (interpretability)
552
  'ma10', 'ma20', 'bollinger_upper', 'bollinger_lower',
553
-
554
- # Raw derivatives (normalize their stats only)
555
- 'price_vel', 'price_acc', 'price_jrk'
556
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
558
- PRICE_FEATURES = {
559
- 'price', 'close_scaled', 'close_price',
560
- 'ma10', 'ma20', 'bollinger_upper', 'bollinger_lower'
561
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
 
563
  # ============================================================================
564
  # REGIME DETECTION PARAMETERS
@@ -1021,8 +1193,19 @@ class IntegratedFeatureEnhancer:
1021
  self.features_lock = threading.Lock()
1022
 
1023
  logger.info(f"Regime-Adaptive Feature Enhancer initialized")
1024
- logger.info(f"Expected features: {len(REQUIRED_FEATURES)}")
1025
- assert len(REQUIRED_FEATURES) == 60, f"Feature count mismatch: {len(REQUIRED_FEATURES)}"
 
 
 
 
 
 
 
 
 
 
 
1026
 
1027
  def compute_core_technical_features(self, df):
1028
  """Compute 19 core technical indicators with robust edge case handling"""
@@ -1218,15 +1401,18 @@ class IntegratedFeatureEnhancer:
1218
  return df
1219
 
1220
  def _validate_feature_contract(self, features_dict):
1221
- """Enforce strict 60-feature contract"""
1222
- expected = set(REQUIRED_FEATURES)
1223
- actual = set(features_dict.keys()) - METADATA_FIELDS
1224
-
1225
- missing = expected - actual
1226
- extra = actual - expected
1227
-
1228
- is_valid = (len(missing) == 0 and len(extra) == 0)
1229
- return is_valid, missing, extra
 
 
 
1230
 
1231
  def compute_all_features(self, df):
1232
  """
@@ -1381,17 +1567,28 @@ class IntegratedFeatureEnhancer:
1381
  logger.warning(f"[{agent_name}] Missing feature: {feature}")
1382
  latest_features[feature] = 0.0
1383
 
1384
- # ENFORCE CONTRACT
1385
- is_valid, missing, extra = self._validate_feature_contract(latest_features)
1386
-
1387
- if not is_valid:
 
 
1388
  logger.error("=" * 80)
1389
- logger.error(f"❌ [{agent_name}] FEATURE CONTRACT VIOLATION")
1390
- if missing:
1391
- logger.error(f"Missing: {missing}")
1392
- if extra:
1393
- logger.error(f"Extra: {extra}")
 
1394
  logger.error("=" * 80)
 
 
 
 
 
 
 
 
1395
  return
1396
 
1397
  with self.features_lock:
@@ -1401,25 +1598,52 @@ class IntegratedFeatureEnhancer:
1401
  logger.error(f"[{agent_name}] Feature enhancement failed: {e}")
1402
 
1403
  async def publish_features(self, agent_name, features_dict, tick_index=None):
1404
- """Publish 60 features"""
 
 
 
 
 
 
1405
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1406
  clean_features = {
1407
  k: float(v) if isinstance(v, (np.floating, np.integer)) else v
1408
  for k, v in features_dict.items()
1409
  }
1410
 
1411
- # Resolve tick_index: caller may pass it, or it may be embedded in the dict
 
 
 
 
1412
  resolved_tick = tick_index
1413
  if resolved_tick is None:
1414
- resolved_tick = features_dict.get('tick_count') or features_dict.get('tick_index')
1415
-
1416
- payload = {
1417
- 'agent': agent_name,
1418
- 'features': clean_features,
1419
- 'timestamp': datetime.now(UTC).isoformat(),
1420
- 'feature_count': len(clean_features),
1421
- 'tick_index': resolved_tick
1422
- }
 
 
1423
 
1424
  await self.features_channel.publish("integrated-features", payload)
1425
 
@@ -1499,7 +1723,11 @@ class IntegratedFeatureEnhancer:
1499
  sample_agent = list(self.latest_computed_features.keys())[0]
1500
  features = self.latest_computed_features[sample_agent]
1501
 
1502
- actual_count = len([k for k in features.keys() if k not in METADATA_FIELDS])
 
 
 
 
1503
 
1504
  summary = f"REGIME-ADAPTIVE FEATURE ENHANCER\n"
1505
  summary += "=" * 60 + "\n\n"
 
494
 
495
 
496
  # ============================================================================
497
+ # FEATURE CONTRACT SINGLE SOURCE OF TRUTH (60 FEATURES)
498
+ # ----------------------------------------------------------------------------
499
+ #
500
+ # ENGINEERING NOTE — why this looks the way it does:
501
+ #
502
+ # Previously the contract was spread across three top-level sets
503
+ # (REQUIRED_FEATURES, METADATA_FIELDS, BINARY_FEATURES, ...), with no
504
+ # runtime check that they were mutually consistent. A drift where a
505
+ # single key ('price') landed in BOTH the "required features" list AND
506
+ # the "metadata to strip before validating" set caused the validator to
507
+ # report {'price'} missing on every tick, which silently shut down
508
+ # publishing for the entire pipeline.
509
+ #
510
+ # The fix is structural: ONE FeatureContract object owns the full schema
511
+ # and checks its own invariants at import time. Any future drift crashes
512
+ # the module on load with a named offender, instead of corrupting the
513
+ # wire format at 60Hz for hours.
514
+ #
515
+ # The old module-level names (REQUIRED_FEATURES, METADATA_FIELDS, etc.)
516
+ # are kept as PROJECTIONS of the contract for call-site back-compat — the
517
+ # rest of Features.py can import them exactly as before.
518
  # ============================================================================
519
 
520
+ from dataclasses import dataclass, field
521
+ from typing import FrozenSet, Mapping, Any
522
+
523
+ CONTRACT_VERSION = "feat-v1.0.0"
524
+ EXPECTED_FEATURE_COUNT = 60
525
+
526
+ # ---- Feature keys (60) — values fed into model inference ------------------
527
+ _FEATURES: FrozenSet[str] = frozenset({
528
  # Core Technical (19)
529
  'log_return', 'rolling_mean_5', 'rolling_std_5', 'zscore_5',
530
  'rsi_14', 'macd', 'macd_signal', 'macd_hist', 'atr',
531
  'cdf_value', 'cdf_slope', 'cdf_diff',
532
  'volatility_quantile_90', 'volatility_ratio', 'entropy_50',
533
  'autocorr_3', 'momentum_10', 'volume_change_rate', 'volume_zscore',
 
534
  # Derivatives (15)
535
  'price_vel', 'price_acc', 'price_jrk',
536
  'price_vel_mean', 'price_vel_std', 'price_vel_skew', 'price_vel_kurtosis',
537
  'price_acc_mean', 'price_acc_std', 'price_acc_skew', 'price_acc_kurtosis',
538
  'price_jrk_mean', 'price_jrk_std', 'price_jrk_skew', 'price_jrk_kurtosis',
 
539
  # Additional Technical (7)
540
  'ma10', 'ma20', 'std20',
541
  'bollinger_upper', 'bollinger_lower', 'bollinger_width', 'bollinger_position',
 
542
  # Candlestick (9)
543
  'gravestone_doji', 'four_price_doji', 'doji', 'spinning_top',
544
  'bullish_candle', 'bearish_candle', 'dragonfly_candle',
545
  'spinning_top_bearish_followup', 'bullish_then_dragonfly',
546
+ # Support / Resistance (7)
 
547
  'distance_to_nearest_support', 'distance_to_nearest_resistance',
548
  'near_support', 'near_resistance', 'distance_to_stop_loss',
549
  'support_strength', 'resistance_strength',
550
+ # Price Variants (3) — models consume these for absolute-scale context
551
+ 'price', 'close_scaled', 'close_price',
552
+ })
553
+
554
+ # ---- Envelope keys — wire metadata, NEVER fed to a model -------------------
555
+ # Disjoint from _FEATURES by invariant (checked below in __post_init__).
556
+ _ENVELOPE: FrozenSet[str] = frozenset({
557
+ 'agent', # routing
558
+ 'timeframe', # routing
559
+ 'timestamp', # wall-clock ISO-8601 at publish
560
+ 'tick_index', # monotonic producer tick counter
561
+ 'tick_count', # legacy alias, kept for back-compat
562
+ 'feature_count', # integrity check: len(features)
563
+ 'contract_version', # schema version string
564
+ 'features', # nested payload key
565
+ })
566
+
567
+ # ---- Typed subsets of _FEATURES (validated as subsets at import time) ------
568
+ _BINARY: FrozenSet[str] = frozenset({
569
  'near_support', 'near_resistance',
570
  'gravestone_doji', 'four_price_doji', 'doji', 'spinning_top',
571
  'bullish_candle', 'bearish_candle', 'dragonfly_candle',
572
  'spinning_top_bearish_followup', 'bullish_then_dragonfly',
573
+ })
574
+ _PRICE_SCALE: FrozenSet[str] = frozenset({
575
  'price', 'close_scaled', 'close_price',
 
 
576
  'ma10', 'ma20', 'bollinger_upper', 'bollinger_lower',
577
+ })
578
+ _NON_NORMALISED: FrozenSet[str] = _BINARY | _PRICE_SCALE | frozenset({
579
+ 'price_vel', 'price_acc', 'price_jrk',
580
+ })
581
+
582
+
583
+ @dataclass(frozen=True)
584
+ class ValidationResult:
585
+ """Structured validation outcome with three distinct failure modes."""
586
+ ok: bool
587
+ missing: FrozenSet[str] # required features absent from dict
588
+ leaked_envelope: FrozenSet[str] # envelope keys found inside features dict
589
+ unexpected: FrozenSet[str] # keys that belong to neither set
590
+
591
+ def as_error_lines(self):
592
+ lines = []
593
+ if self.missing:
594
+ lines.append(f"missing features: {sorted(self.missing)}")
595
+ if self.leaked_envelope:
596
+ lines.append(f"envelope keys inside features dict: "
597
+ f"{sorted(self.leaked_envelope)}")
598
+ if self.unexpected:
599
+ lines.append(f"unknown keys: {sorted(self.unexpected)}")
600
+ return lines
601
+
602
+
603
+ @dataclass(frozen=True)
604
+ class FeatureContract:
605
+ """
606
+ The schema for a single timeframe's feature payload.
607
 
608
+ Invariants (all checked in __post_init__ — module fails to import if
609
+ any are violated):
610
+
611
+ (1) features ∩ envelope = ∅
612
+ No key is allowed to be "both a feature and envelope". This
613
+ was the original bug — 'price' was in both sets, and the
614
+ validator silently rejected every tick.
615
+
616
+ (2) |features| == EXPECTED_FEATURE_COUNT
617
+ The contract declares an exact 60-feature shape. Drift here
618
+ would corrupt downstream tensor shapes.
619
+
620
+ (3) binary, price_scale, non_normalised are all ⊆ features
621
+ A typed subset cannot contain a key that isn't a feature at
622
+ all. This catches stale references after a feature rename.
623
+ """
624
+ version: str = CONTRACT_VERSION
625
+ features: FrozenSet[str] = field(default_factory=lambda: _FEATURES)
626
+ envelope: FrozenSet[str] = field(default_factory=lambda: _ENVELOPE)
627
+ binary: FrozenSet[str] = field(default_factory=lambda: _BINARY)
628
+ price_scale: FrozenSet[str] = field(default_factory=lambda: _PRICE_SCALE)
629
+ non_normalised: FrozenSet[str] = field(default_factory=lambda: _NON_NORMALISED)
630
+
631
+ def __post_init__(self):
632
+ # (1) disjointness
633
+ overlap = self.features & self.envelope
634
+ if overlap:
635
+ raise RuntimeError(
636
+ f"[FeatureContract] BROKEN INVARIANT: keys in BOTH features "
637
+ f"and envelope: {sorted(overlap)}. Remove from one set — the "
638
+ f"validator cannot distinguish feature-vs-envelope for these "
639
+ f"keys, so every tick will be rejected."
640
+ )
641
+ # (2) cardinality
642
+ if len(self.features) != EXPECTED_FEATURE_COUNT:
643
+ raise RuntimeError(
644
+ f"[FeatureContract] BROKEN INVARIANT: expected "
645
+ f"{EXPECTED_FEATURE_COUNT} features, got {len(self.features)}. "
646
+ f"Update EXPECTED_FEATURE_COUNT or fix the feature list."
647
+ )
648
+ # (3) subsets
649
+ for name, subset in (
650
+ ('binary', self.binary),
651
+ ('price_scale', self.price_scale),
652
+ ('non_normalised', self.non_normalised),
653
+ ):
654
+ stray = subset - self.features
655
+ if stray:
656
+ raise RuntimeError(
657
+ f"[FeatureContract] BROKEN INVARIANT: '{name}' contains "
658
+ f"non-feature keys: {sorted(stray)}"
659
+ )
660
+
661
+ # ---- public API -------------------------------------------------------
662
+
663
+ def validate(self, features_dict: Mapping[str, Any]) -> ValidationResult:
664
+ """
665
+ Validate the INNER features dict only — envelope keys should NOT
666
+ be present here; if they are, they're reported as leaked_envelope,
667
+ not stripped and hidden.
668
+ """
669
+ actual = set(features_dict.keys())
670
+ return ValidationResult(
671
+ ok = (actual == self.features),
672
+ missing = frozenset(self.features - actual),
673
+ leaked_envelope = frozenset(actual & self.envelope),
674
+ unexpected = frozenset(actual - self.features - self.envelope),
675
+ )
676
+
677
+ def build_payload(
678
+ self,
679
+ agent_name: str,
680
+ features_dict: Mapping[str, float],
681
+ tick_index,
682
+ timestamp_iso: str,
683
+ ) -> dict:
684
+ """
685
+ Construct the wire payload with envelope / feature separation
686
+ enforced structurally. Envelope fields live at the top level;
687
+ features live ONLY inside payload['features'].
688
+ """
689
+ return {
690
+ 'agent': agent_name,
691
+ 'timestamp': timestamp_iso,
692
+ 'tick_index': tick_index,
693
+ 'feature_count': len(features_dict),
694
+ 'contract_version': self.version,
695
+ 'features': dict(features_dict),
696
+ }
697
+
698
+ def extract_features(self, payload: Mapping[str, Any]) -> dict:
699
+ """
700
+ Consumer-side: pull the inner features dict and verify envelope
701
+ version. Raises ValueError on schema drift so the consumer can
702
+ log-and-drop rather than silently accept malformed payloads.
703
+ """
704
+ got_ver = payload.get('contract_version')
705
+ if got_ver is not None and got_ver != self.version:
706
+ raise ValueError(
707
+ f"contract version mismatch: payload={got_ver!r} "
708
+ f"expected={self.version!r}"
709
+ )
710
+ feats = payload.get('features')
711
+ if not isinstance(feats, dict):
712
+ raise ValueError(
713
+ f"payload.features missing or wrong type: {type(feats).__name__}"
714
+ )
715
+ return feats
716
+
717
+
718
+ # Singleton — import this, don't construct your own.
719
+ # Module import will FAIL LOUDLY here if any invariant is violated.
720
+ FEATURE_CONTRACT = FeatureContract()
721
+
722
+
723
+ # ---------------------------------------------------------------------------
724
+ # Back-compat aliases — projections of FEATURE_CONTRACT. Existing call sites
725
+ # keep working unchanged; only the source of truth moved. Deleting any of
726
+ # these will break older code paths that haven't been migrated to use
727
+ # FEATURE_CONTRACT directly.
728
+ # ---------------------------------------------------------------------------
729
+ REQUIRED_FEATURES = tuple(FEATURE_CONTRACT.features) # order-agnostic
730
+ METADATA_FIELDS = FEATURE_CONTRACT.envelope
731
+ BINARY_FEATURES = FEATURE_CONTRACT.binary
732
+ PRICE_FEATURES = FEATURE_CONTRACT.price_scale
733
+ NORMALIZATION_EXCLUSIONS = FEATURE_CONTRACT.non_normalised
734
 
735
  # ============================================================================
736
  # REGIME DETECTION PARAMETERS
 
1193
  self.features_lock = threading.Lock()
1194
 
1195
  logger.info(f"Regime-Adaptive Feature Enhancer initialized")
1196
+ logger.info(
1197
+ f"Contract: version={FEATURE_CONTRACT.version} "
1198
+ f"features={len(FEATURE_CONTRACT.features)} "
1199
+ f"envelope={len(FEATURE_CONTRACT.envelope)} "
1200
+ f"(invariants enforced at import time)"
1201
+ )
1202
+ # Defensive re-check at instantiation. The contract's __post_init__
1203
+ # already verified this at import, but a runtime assert catches
1204
+ # anyone monkey-patching FEATURE_CONTRACT.features before first use.
1205
+ assert len(FEATURE_CONTRACT.features) == EXPECTED_FEATURE_COUNT, (
1206
+ f"Feature count mismatch at runtime: "
1207
+ f"{len(FEATURE_CONTRACT.features)} != {EXPECTED_FEATURE_COUNT}"
1208
+ )
1209
 
1210
  def compute_core_technical_features(self, df):
1211
  """Compute 19 core technical indicators with robust edge case handling"""
 
1401
  return df
1402
 
1403
  def _validate_feature_contract(self, features_dict):
1404
+ """
1405
+ Delegate to FEATURE_CONTRACT.validate() and return a legacy
1406
+ 3-tuple (is_valid, missing, extra) for call-site back-compat.
1407
+
1408
+ `extra` in the legacy contract conflated two distinct failure
1409
+ modes envelope leakage and unknown keys. We preserve the
1410
+ 3-tuple shape but keep them merged; richer diagnostics are
1411
+ available by calling FEATURE_CONTRACT.validate() directly.
1412
+ """
1413
+ result = FEATURE_CONTRACT.validate(features_dict)
1414
+ extra = result.leaked_envelope | result.unexpected
1415
+ return result.ok, result.missing, extra
1416
 
1417
  def compute_all_features(self, df):
1418
  """
 
1567
  logger.warning(f"[{agent_name}] Missing feature: {feature}")
1568
  latest_features[feature] = 0.0
1569
 
1570
+ # ENFORCE CONTRACT — use the rich ValidationResult directly so we
1571
+ # log three distinct failure modes separately instead of collapsing
1572
+ # them into a single ambiguous "Missing / Extra" pair.
1573
+ validation = FEATURE_CONTRACT.validate(latest_features)
1574
+
1575
+ if not validation.ok:
1576
  logger.error("=" * 80)
1577
+ logger.error(
1578
+ f"❌ [{agent_name}] FEATURE CONTRACT VIOLATION "
1579
+ f"(contract={FEATURE_CONTRACT.version})"
1580
+ )
1581
+ for line in validation.as_error_lines():
1582
+ logger.error(f" {line}")
1583
  logger.error("=" * 80)
1584
+
1585
+ # Bookkeeping counter — lets ops tell the difference between
1586
+ # "feed is dry" and "feed is arriving but contract is broken".
1587
+ if not hasattr(self, '_contract_violation_counts'):
1588
+ self._contract_violation_counts = {}
1589
+ self._contract_violation_counts[agent_name] = (
1590
+ self._contract_violation_counts.get(agent_name, 0) + 1
1591
+ )
1592
  return
1593
 
1594
  with self.features_lock:
 
1598
  logger.error(f"[{agent_name}] Feature enhancement failed: {e}")
1599
 
1600
  async def publish_features(self, agent_name, features_dict, tick_index=None):
1601
+ """
1602
+ Publish 60 features on the wire. Payload shape is enforced by
1603
+ FEATURE_CONTRACT.build_payload() — envelope keys live at the
1604
+ top level, feature keys live ONLY inside payload['features'],
1605
+ and a contract_version string accompanies every message so the
1606
+ consumer can detect schema drift.
1607
+ """
1608
  try:
1609
+ # Defensive re-validation at the publish boundary. Zero cost on
1610
+ # the happy path; catches any mutation between compute and
1611
+ # publish (e.g. a caller accidentally injecting envelope keys
1612
+ # into the features dict).
1613
+ validation = FEATURE_CONTRACT.validate(features_dict)
1614
+ if not validation.ok:
1615
+ logger.error(
1616
+ f"[{agent_name}] publish BLOCKED — contract violation at "
1617
+ f"publish boundary: {validation.as_error_lines()}"
1618
+ )
1619
+ return
1620
+
1621
+ # Coerce numpy scalars to native floats so the JSON serialiser
1622
+ # doesn't choke. Done on the features-only dict, inside the
1623
+ # contract shape.
1624
  clean_features = {
1625
  k: float(v) if isinstance(v, (np.floating, np.integer)) else v
1626
  for k, v in features_dict.items()
1627
  }
1628
 
1629
+ # Resolve tick_index: caller may pass it explicitly, or it may
1630
+ # be embedded in the dict (legacy path). Envelope keys should
1631
+ # NOT be inside features_dict after the validation above, so
1632
+ # these .get() calls will normally return None — kept for
1633
+ # defensive back-compat.
1634
  resolved_tick = tick_index
1635
  if resolved_tick is None:
1636
+ resolved_tick = (
1637
+ features_dict.get('tick_count')
1638
+ or features_dict.get('tick_index')
1639
+ )
1640
+
1641
+ payload = FEATURE_CONTRACT.build_payload(
1642
+ agent_name = agent_name,
1643
+ features_dict = clean_features,
1644
+ tick_index = resolved_tick,
1645
+ timestamp_iso = datetime.now(UTC).isoformat(),
1646
+ )
1647
 
1648
  await self.features_channel.publish("integrated-features", payload)
1649
 
 
1723
  sample_agent = list(self.latest_computed_features.keys())[0]
1724
  features = self.latest_computed_features[sample_agent]
1725
 
1726
+ # Count only keys that are actually declared features in the
1727
+ # contract. This is set-intersection, not set-difference — so
1728
+ # it's correct regardless of whether envelope keys have leaked
1729
+ # into the features dict or not.
1730
+ actual_count = len(set(features.keys()) & FEATURE_CONTRACT.features)
1731
 
1732
  summary = f"REGIME-ADAPTIVE FEATURE ENHANCER\n"
1733
  summary += "=" * 60 + "\n\n"