Spaces:
Running
Running
Update Features.py
Browse files- Features.py +295 -67
Features.py
CHANGED
|
@@ -494,71 +494,243 @@ TIMEFRAMES = {
|
|
| 494 |
|
| 495 |
|
| 496 |
# ============================================================================
|
| 497 |
-
# FEATURE CONTRACT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
# ============================================================================
|
| 499 |
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
'
|
| 536 |
-
'
|
| 537 |
-
'
|
| 538 |
-
'
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 549 |
'price', 'close_scaled', 'close_price',
|
| 550 |
-
|
| 551 |
-
# Price-unit features (interpretability)
|
| 552 |
'ma10', 'ma20', 'bollinger_upper', 'bollinger_lower',
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
'price_vel', 'price_acc', 'price_jrk'
|
| 556 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 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(
|
| 1025 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1386 |
-
|
| 1387 |
-
|
|
|
|
|
|
|
| 1388 |
logger.error("=" * 80)
|
| 1389 |
-
logger.error(
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1412 |
resolved_tick = tick_index
|
| 1413 |
if resolved_tick is None:
|
| 1414 |
-
resolved_tick =
|
| 1415 |
-
|
| 1416 |
-
|
| 1417 |
-
|
| 1418 |
-
|
| 1419 |
-
|
| 1420 |
-
|
| 1421 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|