diff --git a/config/governance/governed_document_lock_manifest.json b/config/governance/governed_document_lock_manifest.json index a54d18fa..f07a8125 100644 --- a/config/governance/governed_document_lock_manifest.json +++ b/config/governance/governed_document_lock_manifest.json @@ -1,5 +1,22 @@ { "approval_records": [ + { + "approval_id": "AI4B-GOV-DOCLOCK-INTELLIGENCE-BOUNDARY-QUALITY-CLOSURE-20260926-001", + "approval_scope": "INTELLIGENCE_BOUNDARY_AND_FULL_QUALITY_CLOSURE_DOCUMENTATION_ALIGNMENT", + "approval_status": "APPROVED", + "approved_at_utc": "2026-09-26T17:45:26Z", + "approved_by": "Huseyin", + "approved_roles": [ + "GovernanceOwner" + ], + "approved_principal_ids": [ + "huseyin-governance-owner-principal" + ], + "written_owner_approval": true, + "approved_sha256": { + "docs/compliance/registry_compliance_matrix.md": "7286ee41aa0e4ab53c5c8b409e7e928089a68fd3c5ca6384cf91fac6c6e23f71" + } + }, { "approval_id": "AI4B-GOV-DOCLOCK-CODEX-FABRIC-ROUTING-20260918-001", "approval_scope": "CODEX_TERMINOLOGY_NAMING_TECHNOLOGY_LANGUAGE_FABRIC_ROUTING", @@ -2012,13 +2029,13 @@ "authority_scope": "compliance_matrix", "canonical_path": "docs/compliance/registry_compliance_matrix.md", "document_status": "ACTIVE", - "expected_hash": "925237186799fdd6b7fe4b98c271d4ed1be4c6cc02ad6a22394d50172a463477", + "expected_hash": "7286ee41aa0e4ab53c5c8b409e7e928089a68fd3c5ca6384cf91fac6c6e23f71", "lock_state": "LOCKED", "path": "docs/compliance/registry_compliance_matrix.md", - "sha256": "925237186799fdd6b7fe4b98c271d4ed1be4c6cc02ad6a22394d50172a463477", + "sha256": "7286ee41aa0e4ab53c5c8b409e7e928089a68fd3c5ca6384cf91fac6c6e23f71", "source_of_truth": true, "supersedes": [], - "version": "2.10.15" + "version": "2.10.16" }, { "allowed_change_process": "WRITTEN_OWNER_APPROVAL_REQUIRED", @@ -3498,6 +3515,23 @@ "status": "ACTIVE", "written_owner_approval_required": true, "written_owner_approvals": [ + { + "approval_id": "AI4B-GOV-DOCLOCK-INTELLIGENCE-BOUNDARY-QUALITY-CLOSURE-20260926-001", + "approval_scope": "INTELLIGENCE_BOUNDARY_AND_FULL_QUALITY_CLOSURE_DOCUMENTATION_ALIGNMENT", + "approval_status": "APPROVED", + "approved_at_utc": "2026-09-26T17:45:26Z", + "approved_by": "Huseyin", + "approved_roles": [ + "GovernanceOwner" + ], + "approved_principal_ids": [ + "huseyin-governance-owner-principal" + ], + "written_owner_approval": true, + "approved_sha256": { + "docs/compliance/registry_compliance_matrix.md": "7286ee41aa0e4ab53c5c8b409e7e928089a68fd3c5ca6384cf91fac6c6e23f71" + } + }, { "approval_id": "AI4B-GOV-DOCLOCK-CODEX-FABRIC-ROUTING-20260918-001", "approval_scope": "CODEX_TERMINOLOGY_NAMING_TECHNOLOGY_LANGUAGE_FABRIC_ROUTING", diff --git a/docs/compliance/registry_compliance_matrix.md b/docs/compliance/registry_compliance_matrix.md index 458c070d..94cf1441 100644 --- a/docs/compliance/registry_compliance_matrix.md +++ b/docs/compliance/registry_compliance_matrix.md @@ -2,7 +2,7 @@ document_id: AI4B-GOV-REG-001 title: AI4BINANCE Compliance Matrix document_type: REGISTRY -version: 2.10.15 +version: 2.10.16 status: ACTIVE owner: Enterprise Governance authority_level: NORMATIVE @@ -127,7 +127,7 @@ unverified requirement. | Virtual Market DGE application boundary | `docs/governance/framework_decision_governance_engine.md`, `src/ai4binance/core/contracts/virtual_governance.py`, `src/ai4binance/application/research.py`, `src/ai4binance/governance/adapters.py`, `src/ai4binance/governance/dge_engine.py`, `src/ai4binance/governance/rules.py`, `src/ai4binance/governance/replay.py`, `src/ai4binance/governance/shadow.py`, `src/ai4binance/research/virtual_runtime_request.py`, `src/ai4binance/research_runtime.py`, `tests/test_research_application.py`, `tests/test_dge_recovery_replay_shadow.py` | PARTIAL | `VirtualGovernanceResult` is the dependency-neutral result contract; the application layer owns only the evaluator port, `VirtualMarketDgeAdapter` owns deterministic candidate/context conversion and canonical DGE invocation, and `research_runtime.py` binds the concrete adapter at the composition boundary. The adapter derives the independent validation handoff from the validated analysis result; recovery input remains fail-closed when no independent validation result exists. Only `APPROVED_PAPER_ONLY` without DGE blockers can enter bounded Virtual Market simulation. This route reuses canonical virtual execution and accounting, remains fail-closed, and never grants live authority. Fresh FULL evidence and distinct C3 approval replay remain required; `RESEARCH_ONLY` and `LIVE_ORDER_BLOCKED` are mandatory. | | MCP Gateway policy route | `src/ai4binance/governance/mcp_gateway.py`, `src/ai4binance/governance/tool_policy.py`, `tests/test_governance_tool_gateway.py`, `docs/contracts/interface_contract_read_only_evidence_mcp.md` | COMPLETED | Agent -> MCP shortcut is disabled. Canonical route is `Agent -> Tool Policy -> Authorization -> MCP Gateway -> Tool`; Filesystem/GitHub/PostgreSQL/Research/future Binance READ-ONLY surfaces are consolidated under the default-deny contract. `filesystem.delete`, `shell.admin`, `exchange.place_order` remain denied; trading/live authority is not generated. | | Constitutional change control | `src/ai4binance/governance/framework.py`, `src/ai4binance/governance/gate.py`, `src/ai4binance/governance/lean.py`, `src/ai4binance/governance_primitives.py`, `tests/test_governance_framework_v2.py`, `tests/test_governance_gate.py`, `tests/test_governance_primitives.py`, `docs/governance/framework_core_vnext_governance.md`, `docs/governance/instruction_core_custom_instructions.md`, `AGENTS.md`, `docs/providers/instruction_codex_provider.md`, `docs/registries/registry_documentation_index.md`, `config/governance/governed_document_lock_manifest.json` | COMPLETED | Constitutional authority is split into three primitives: `DETERMINISTIC_QUALITY_GATE=TECHNICAL_TRUTH`, `DETERMINISTIC_GOVERNANCE_GATE=POLICY_ELIGIBILITY`, and `HUMAN_GOVERNANCE=CONSEQUENTIAL_AUTHORITY`. `risk-tiered human governance` applies only to consequential changes. `src/ai4binance/governance_primitives.py` is the canonical lifecycle and authority primitive surface, and `src/ai4binance/governance/gate.py` plus `src/ai4binance/governance/lean.py` consume that same first-class lifecycle primitive across deterministic quality evidence, governance eligibility, approval-verification payloads, and poka-yoke gate checks. `approval_verification` is a hard veto, not an informational check: if the approval packet is missing, unauthorized, expired, revoked, scope-mismatched, evidence-mismatched, lifecycle-definition-mismatched, authority-family-mismatched, or blocker-tainted, consequential transitions remain vetoed and `LIVE_ORDER_BLOCKED` stays true. `C2_BEHAVIORAL` changes require an ELI10-backed `Approval Packet`; `C3_GOVERNED` changes require explicit review of the relevant policy/instructions plus double approval and same-diff constitution sync; `C4_CONSEQUENTIAL` changes require explicit high-assurance approval. Code-constitution conflict is not considered complete. | -| Governance alignment / loose-code audit | `AGENTS.md`, `src/ai4binance/governance/audit.py`, `src/ai4binance/governance/constitution_sync.py`, `src/ai4binance/governance/gate.py`, `src/ai4binance/governance/lean.py`, `src/ai4binance/governance_primitives.py`, `src/ai4binance/ops/repository_cleanup_audit.py`, `src/ai4binance/storage/jsonl.py`, `tests/test_dge_recovery_replay_shadow.py`, `tests/test_governance_constitution_sync.py`, `tests/test_governance_gate.py`, `tests/test_governance_primitives.py`, `tests/test_repository_cleanup_audit.py`, `tests/test_docs_hygiene.py`, `tests/test_kaizen_quality.py`, `tests/test_quality_gate_profiles.py`, `tests/test_storage.py`, `scripts/quality.ps1`, `config/quality/gates.yaml`, `config/governance/governed_document_lock_manifest.json`, `runtime/artifacts/quality/gate/approval_record_latest.json`, `runtime/artifacts/quality/gate/latest.json` | COMPLETED | Core documentation, root `AGENTS.md`, Custom Instructions, Codex, and the compliance matrix must carry the same `TECHNICAL_TRUTH` / `POLICY_ELIGIBILITY` / `CONSEQUENTIAL_AUTHORITY` agreement. A mismatch across core/root/custom/codex/compliance appears as `CONSTITUTION_FAMILY_MISMATCH`. An empty-source-file change that does not carry test, compliance, or written rule evidence results in `RUNNING_WITH_BLOCKERS`. Governed cleanup audit registry records must jointly display source, test, compliance matrix, and core documentation evidence, plus written owner approval lineage when governed Markdown changes. The request "Expand the scope of governance" requires an aggressive yet realistic gap analysis using secure tools, current CPU/RAM/GPU capacity, extensive testing, and parallel read-only analysis. Capability OOS/operational evidence gaps remain visible as `RESEARCH_ONLY` / `PARTIAL` / `MISSING`. Full technical quality evidence does not produce the claim COMPLETE unless the current quality evidence carries `TECHNICAL_QUALITY_PASS`, `pytest_pass_count`, `coverage_percent`, and `coverage_source`, and the same evidence envelope reports `full_assurance_status=FULL_ASSURANCE_GREEN`. The approval loader fallback consumes `runtime/artifacts/quality/gate/approval_record_latest.json` as the first-class approval artifact for the same scope/evidence envelope, binds `evidence_hash`, `authority_family_sha256`, and `lifecycle_definition_sha256`, and never downgrades to lower-authority defaults; if approval verification fails, the same envelope must remain `RUNNING_WITH_BLOCKERS`, `consequential_change_allowed=false`, and `LIVE_ORDER_BLOCKED`. Governance and replay-ready DGE decision journals now use the same first-class tamper-evident JSONL audit primitive with durable write-through, read-back verification, and fail-closed chain validation before append; coverage rate cannot be inferred from outside full quality_gate evidence. Human governance is consequential authority, not generic bureaucracy; `execution_allowed=false`, `RESEARCH_ONLY`, and `LIVE_ORDER_BLOCKED` are constants. | +| Governance alignment / loose-code audit | `AGENTS.md`, `src/ai4binance/governance/audit.py`, `src/ai4binance/governance/constitution_sync.py`, `src/ai4binance/governance/gate.py`, `src/ai4binance/governance/lean.py`, `src/ai4binance/governance_primitives.py`, `src/ai4binance/ops/repository_cleanup_audit.py`, `src/ai4binance/storage/jsonl.py`, `src/ai4binance/intelligence/derivatives.py`, `src/ai4binance/intelligence/trading.py`, `tests/test_dge_recovery_replay_shadow.py`, `tests/test_governance_constitution_sync.py`, `tests/test_governance_gate.py`, `tests/test_governance_primitives.py`, `tests/test_repository_cleanup_audit.py`, `tests/test_docs_hygiene.py`, `tests/test_kaizen_quality.py`, `tests/test_quality_gate_profiles.py`, `tests/test_storage.py`, `tests/test_trading_intelligence.py`, `tests/test_virtual_runtime.py`, `tests/test_lowest_coverage_boundary_contracts.py`, `scripts/quality.ps1`, `config/quality/gates.yaml`, `config/governance/governed_document_lock_manifest.json`, `runtime/artifacts/quality/gate/approval_record_latest.json`, `runtime/artifacts/quality/gate/latest.json` | COMPLETED | Core documentation, root `AGENTS.md`, Custom Instructions, Codex, and the compliance matrix must carry the same `TECHNICAL_TRUTH` / `POLICY_ELIGIBILITY` / `CONSEQUENTIAL_AUTHORITY` agreement. A mismatch across core/root/custom/codex/compliance appears as `CONSTITUTION_FAMILY_MISMATCH`. An empty-source-file change that does not carry test, compliance, or written rule evidence results in `RUNNING_WITH_BLOCKERS`. `src/ai4binance/intelligence/derivatives.py` rejects malformed derivatives metrics with a fail-closed result, and `src/ai4binance/intelligence/trading.py` revalidates cost-model inputs before completing the intelligence result; neither path grants execution authority. The named intelligence tests cover those boundaries, including the virtual-runtime risk rejection path. Governed cleanup audit registry records must jointly display source, test, compliance matrix, and core documentation evidence, plus written owner approval lineage when governed Markdown changes. The request "Expand the scope of governance" requires an aggressive yet realistic gap analysis using secure tools, current CPU/RAM/GPU capacity, extensive testing, and parallel read-only analysis. Capability OOS/operational evidence gaps remain visible as `RESEARCH_ONLY` / `PARTIAL` / `MISSING`. Full technical quality evidence does not produce the claim COMPLETE unless the current quality evidence carries `TECHNICAL_QUALITY_PASS`, `pytest_pass_count`, `coverage_percent`, and `coverage_source`, and the same evidence envelope reports `full_assurance_status=FULL_ASSURANCE_GREEN`. The approval loader fallback consumes `runtime/artifacts/quality/gate/approval_record_latest.json` as the first-class approval artifact for the same scope/evidence envelope, binds `evidence_hash`, `authority_family_sha256`, and `lifecycle_definition_sha256`, and never downgrades to lower-authority defaults; if approval verification fails, the same envelope must remain `RUNNING_WITH_BLOCKERS`, `consequential_change_allowed=false`, and `LIVE_ORDER_BLOCKED`. Governance and replay-ready DGE decision journals now use the same first-class tamper-evident JSONL audit primitive with durable write-through, read-back verification, and fail-closed chain validation before append; coverage rate cannot be inferred from outside full quality_gate evidence. Human governance is consequential authority, not generic bureaucracy; `execution_allowed=false`, `RESEARCH_ONLY`, and `LIVE_ORDER_BLOCKED` are constants. | | Universal governed-object enforcement fabric | `docs/standards/standard_governed_object_enforcement.md`, `src/ai4binance/governance/enforcement/__init__.py`, `src/ai4binance/governance/enforcement/contracts.py`, `src/ai4binance/governance/enforcement/engine.py`, `src/ai4binance/governance/enforcement/registry.py`, `src/ai4binance/governance/enforcement/adapters.py`, `src/ai4binance/governance/enforcement/inventory.py`, `config/governance/enforcement_profiles.yaml`, `config/governance/enforcement_inventory.yaml`, `tests/test_governed_object_enforcement.py` | COMPLETED | The governed-object enforcement capability now exposes one shared `GovernedObjectEnvelope -> EnforcementRequest -> EnforcementDecision` contract, deterministic gate ordering, profile coverage checks, adapter reuse for repository/governed knowledge, and a machine-readable inventory of consequential entrypoints. Every consequential inventory entrypoint is now classified as either canonical `ROUTED` or explicit fail-closed `BLOCKED`, `ADAPTER_REQUIRED` and `REPORT_ONLY` exit gaps have been removed from the canonical inventory, and routed consequential paths do not retain a documented bypass route. Lower-level helper surfaces may still exist as implementation primitives, but they are non-authoritative for promotion or execution claims unless their inventory entrypoint is `ROUTED`. `execution_allowed=false`, `RESEARCH_ONLY`, and `LIVE_ORDER_BLOCKED` remain unchanged. | | Repository & File Governance Standard | `docs/standards/standard_repository_file_governance.md`, `src/ai4binance/governance/repository_validator.py`, `tests/test_repository_validator.py`, `tests/test_artifact_hygiene_scripts.py`, `scripts/quality.ps1` | COMPLETED | The `AI4B-GOV-REPO-001` standard has been transitioned to a permanent governance standard. The file/folder structure is not a one-time Codex cleanup decision; it is continuously enforced using the triple of `RepositoryPolicy + RepositoryArtifact schema + deterministic repository_validator`. The validator operates in report-only and fail-closed modes; it makes visible findings such as unknown top-level paths, unsafe Python naming, source filename versioning, source/runtime mixing, generated artifacts under src, and unapproved absolute paths. It cannot mitigate health score hard blockers; `execution_allowed=false`, `RESEARCH_ONLY`, and `LIVE_ORDER_BLOCKED` are constants. | | Documentation & Knowledge Governance Standard | `AGENTS.md`, `docs/providers/instruction_codex_provider.md`, `docs/governance/instruction_core_custom_instructions.md`, `docs/governance/framework_core_vnext_governance.md`, `docs/compliance/registry_compliance_matrix.md`, `docs/standards/standard_documentation_knowledge_governance.md`, `docs/controls/control_repository_validation_rules.md`, `src/ai4binance/governance/repository_validator.py`, `tests/test_repository_validator.py`, `tests/test_docs_hygiene.py`, `tests/test_governance_constitution_sync.py`, `scripts/quality.ps1` | COMPLETED | The `AI4B-GOV-DKG-001` standard has been transitioned to the active knowledge-governance standard. Critical system knowledge is not free Markdown; core/root/custom/codex/compliance and active standards carry `GovernedKnowledgeObject` metadata. The `RepositoryPolicy + RepositoryArtifact schema + deterministic repository_validator` chain enforces full quality gate within. Missing/broken document_id, semver, lifecycle, authority_level, content_role, owner, source_of_truth, duplicate knowledge_id, duplicate active source-of-truth concept, or lower-authority reuse of an active higher-authority `authority_scope` generates `RUNNING_WITH_BLOCKERS`. The validator now resolves concept ownership by `authority_scope`, emits `KNOWLEDGE_AUTHORITY_OVERRIDE_CONFLICT` when a lower layer attempts to weaken/contradict/override the higher owner scope, and allows operational specialization only through a narrower derived `authority_scope` plus matching derived `source_of_truth_scope`; governed repository/file standards and machine governance contracts are locked to professional English (`en-US`) and language drift in those controlled surfaces is `NON_CODE_CONTENT_LANGUAGE_VIOLATION`; `RESEARCH_ONLY` and `LIVE_ORDER_BLOCKED` remain constant. | diff --git a/src/ai4binance/agents/advanced.py b/src/ai4binance/agents/advanced.py index b0ea65e2..0e077a66 100644 --- a/src/ai4binance/agents/advanced.py +++ b/src/ai4binance/agents/advanced.py @@ -114,7 +114,15 @@ def analyze( detected_setups=feature.setups, warnings=("RESEARCH_ONLY_UNVALIDATED",), reason_codes=("ADVANCED_RULE_EVALUATED",), - calculation_metadata=feature.metadata, + calculation_metadata={ + **(feature.metadata or {}), + "source_timeframe": next( + timeframe + for timeframe in ("1d", "4h", "1h", "15m") + if timeframe in self.definition.supported_timeframes + and len(snapshot.ohlcv_by_timeframe.get(timeframe, ())) >= 55 + ), + }, ) def _candles(self, snapshot: MarketSnapshot) -> tuple[OHLCVCandle, ...] | None: @@ -427,13 +435,12 @@ def _external(self, snapshot: MarketSnapshot, field_name: str) -> AgentResult: vote = self._number(raw.get("directional_vote")) score = self._number(raw.get("score")) as_of = self._timestamp(raw.get("as_of")) - if ( - source_count is None - or source_count < 1 - or vote is None - or score is None - or as_of is None - ): + derivatives_context = self.definition.name in {"derivatives", "long_short"} + if source_count is None or source_count < 1 or as_of is None: + return self._insufficient( + snapshot, "EXTERNAL_EVIDENCE_MISSING_OR_UNSOURCED" + ) + if not derivatives_context and (vote is None or score is None): return self._insufficient( snapshot, "EXTERNAL_EVIDENCE_MISSING_OR_UNSOURCED" ) @@ -441,8 +448,13 @@ def _external(self, snapshot: MarketSnapshot, field_name: str) -> AgentResult: age = snapshot.created_at - as_of if age < timedelta(0) or age > maximum_age: return self._insufficient(snapshot, "EXTERNAL_EVIDENCE_STALE_OR_FUTURE") - bounded_vote = max(-1.0, min(1.0, vote)) - bounded_score = max(0.0, min(100.0, score)) + bounded_vote = max(-1.0, min(1.0, vote if vote is not None else 0.0)) + bounded_score = max(0.0, min(100.0, score if score is not None else 50.0)) + warning = ( + "SUPPLEMENTARY_FUTURES_CONTEXT_ONLY" + if derivatives_context + else "SUPPLEMENTARY_SPOT_EVIDENCE_ONLY" + ) return self.result( snapshot, status=AgentStatus.PARTIAL, @@ -452,7 +464,7 @@ def _external(self, snapshot: MarketSnapshot, field_name: str) -> AgentResult: score=bounded_score, confidence=min(0.5, source_count / 10), evidence=("SOURCED_EXTERNAL_SNAPSHOT",), - warnings=("SUPPLEMENTARY_SPOT_EVIDENCE_ONLY",), + warnings=(warning,), reason_codes=("EXTERNAL_CONTEXT_EVALUATED",), calculation_metadata={ "source_count": source_count, diff --git a/src/ai4binance/agents/orchestrator.py b/src/ai4binance/agents/orchestrator.py index 5aee66ec..cbce40ef 100644 --- a/src/ai4binance/agents/orchestrator.py +++ b/src/ai4binance/agents/orchestrator.py @@ -44,6 +44,8 @@ from ai4binance.agents.validation_gate import ValidationGate from ai4binance.core.contracts.memory import CompiledCycleContext from ai4binance.domain import TradeCandidate +from ai4binance.intelligence.contracts import TradingIntelligenceState +from ai4binance.intelligence.trading import TradingIntelligenceEngine from ai4binance.schemas import AgentResult, AgentStatus, AnalysisState, MarketSnapshot from ai4binance.strategies import StrategyEngine from ai4binance.strategies.arbitration import CandidateArbitrator @@ -69,6 +71,9 @@ class EnterpriseOrchestrator: candidate_arbitrator: CandidateArbitrator = field( default_factory=CandidateArbitrator ) + trading_intelligence_engine: TradingIntelligenceEngine = field( + default_factory=TradingIntelligenceEngine + ) telemetry_sink: AgentTelemetrySink | None = None agent_latency_budget_ms: float = 1_000.0 @@ -214,10 +219,18 @@ def analyze( snapshot, MappingProxyType(results), ) + trading_intelligence = self.trading_intelligence_engine.build( + snapshot, + MappingProxyType(results), + ) candidates = self.strategy_engine.generate( snapshot, MappingProxyType(results), ) + candidates = self.trading_intelligence_engine.bind_candidates( + candidates, + trading_intelligence, + ) selection = self.candidate_arbitrator.select(candidates) risk_candidates = selection.ranked[:5] if selection.selected is not None else () risk_gate = RiskGate(self.registry.get("risk"), candidates=risk_candidates) @@ -227,9 +240,12 @@ def analyze( return self._finalize_cycle( snapshot, results, - extra_blockers=selection.blockers, + extra_blockers=tuple( + dict.fromkeys((*selection.blockers, *trading_intelligence.blockers)) + ), candidates=candidates, compiled_cycle_context=compiled_cycle_context, + trading_intelligence=trading_intelligence, cycle_started_ns=cycle_started_ns, specialist_wall_ms=specialist_wall_ms, scheduler_overhead_ms=scheduler_overhead_ms, @@ -521,6 +537,7 @@ def _finalize_cycle( extra_blockers: tuple[str, ...] = (), candidates: tuple[TradeCandidate, ...] = (), compiled_cycle_context: CompiledCycleContext | None = None, + trading_intelligence: TradingIntelligenceState | None = None, *, cycle_started_ns: int, specialist_wall_ms: float, @@ -538,6 +555,7 @@ def _finalize_cycle( extra_blockers=extra_blockers, candidates=candidates, compiled_cycle_context=compiled_cycle_context, + trading_intelligence=trading_intelligence, ) self._record_cycle_metric( snapshot, @@ -593,8 +611,21 @@ def _finalize( extra_blockers: tuple[str, ...] = (), candidates: tuple[TradeCandidate, ...] = (), compiled_cycle_context: CompiledCycleContext | None = None, + trading_intelligence: TradingIntelligenceState | None = None, ) -> AnalysisState: """Build the immutable state and final fail-closed decision.""" + if trading_intelligence is None: + trading_intelligence = self.trading_intelligence_engine.blocked( + snapshot, + tuple( + dict.fromkeys( + ( + "TRADING_INTELLIGENCE_NOT_EVALUATED", + *extra_blockers, + ) + ) + ), + ) validation_blockers = tuple( dict.fromkeys( ( @@ -622,6 +653,7 @@ def _finalize( candidate_setups=candidates, final_decision=decision, compiled_cycle_context=compiled_cycle_context, + trading_intelligence=trading_intelligence, ) diff --git a/src/ai4binance/agents/risk_gate.py b/src/ai4binance/agents/risk_gate.py index 202dab68..07276754 100644 --- a/src/ai4binance/agents/risk_gate.py +++ b/src/ai4binance/agents/risk_gate.py @@ -169,6 +169,11 @@ def _evaluate_candidates(self, snapshot: MarketSnapshot) -> AgentResult: item.candidate_id, ), ) + selected_candidate = next( + item + for item in self.candidates + if item.candidate_id == assessment.candidate_id + ) return self._result( snapshot, status=AgentStatus.SUCCESS if assessment.approved else AgentStatus.BLOCKED, @@ -180,10 +185,30 @@ def _evaluate_candidates(self, snapshot: MarketSnapshot) -> AgentResult: reason_codes=("RISK_APPROVED" if assessment.approved else "RISK_REJECTED",), calculation_metadata={ "candidate_id": assessment.candidate_id, + "scenario_id": assessment.scenario_id, "approved": assessment.approved, "size_usdt": str(assessment.size_usdt), "quantity": str(assessment.quantity), "risk_amount_usdt": str(assessment.risk_amount_usdt), + "gross_risk_reward": str(selected_candidate.gross_risk_reward), + "structural_risk_reward": ( + str(selected_candidate.structural_risk_reward) + if selected_candidate.structural_risk_reward is not None + else None + ), + "net_risk_reward": ( + str(selected_candidate.net_risk_reward) + if selected_candidate.net_risk_reward is not None + else None + ), + "expected_r": ( + str(selected_candidate.expected_r) + if selected_candidate.expected_r is not None + else None + ), + "probability_calibration_state": ( + selected_candidate.probability_calibration_state + ), "evaluated_candidate_count": len(assessments), "unevaluated_candidate_count": max( 0, len(self.candidates) - len(assessments) @@ -191,6 +216,7 @@ def _evaluate_candidates(self, snapshot: MarketSnapshot) -> AgentResult: "assessments": tuple( { "candidate_id": item.candidate_id, + "scenario_id": item.scenario_id, "approved": item.approved, "blockers": item.blockers, } diff --git a/src/ai4binance/agents/technical.py b/src/ai4binance/agents/technical.py index 44a7ccb3..c7f466fa 100644 --- a/src/ai4binance/agents/technical.py +++ b/src/ai4binance/agents/technical.py @@ -1,13 +1,15 @@ """Deterministic core technical agents backed by transparent indicators.""" from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from decimal import Decimal from math import fsum from ai4binance.agents.base import BaseAgent from ai4binance.agents.registry import AgentDefinition from ai4binance.indicators import atr, clamp, closes, ema, relative_volume, rsi +from ai4binance.intelligence.contracts import StructureState +from ai4binance.intelligence.structure import MarketStructureEngine from ai4binance.opportunity_intelligence import ( CandlestickPattern, MultiTimeframeAlignmentState, @@ -286,7 +288,11 @@ def analyze( @dataclass(frozen=True, slots=True) class MarketStructureAgent(BaseAgent): - """Classify deterministic HH/HL or LH/LL structure over two windows.""" + """Project confirmed-swing structure while retaining legacy metadata.""" + + structure_engine: MarketStructureEngine = field( + default_factory=MarketStructureEngine + ) def analyze( self, @@ -298,6 +304,7 @@ def analyze( if not available: return _insufficient(self, snapshot) votes: list[float] = [] + confidences: list[float] = [] metrics: dict[str, object] = {} for timeframe, candles in available: sample = candles[-20:] @@ -306,21 +313,57 @@ def analyze( previous_low = min(item.low for item in previous) recent_high = max(item.high for item in recent) recent_low = min(item.low for item in recent) - if recent_high > previous_high and recent_low > previous_low: + structure = self.structure_engine.analyze(timeframe, candles) + if structure.state is StructureState.BULLISH: state, vote = "HH_HL", 1.0 - elif recent_high < previous_high and recent_low < previous_low: + elif structure.state is StructureState.BEARISH: state, vote = "LH_LL", -1.0 else: state, vote = "MIXED", 0.0 votes.append(vote) + confidences.append(structure.confidence) metrics[timeframe] = { "structure": state, "previous_high": str(previous_high), "previous_low": str(previous_low), "recent_high": str(recent_high), "recent_low": str(recent_low), + "structure_state": structure.state.value, + "structure_method": structure.method, + "range_low": str(structure.range_low), + "range_high": str(structure.range_high), + "invalidation_level": ( + str(structure.invalidation_level) + if structure.invalidation_level is not None + else None + ), + "structure_confidence": structure.confidence, + "structure_warnings": structure.warnings, + "swings": tuple( + { + "kind": swing.kind.value, + "candle_index": swing.candle_index, + "occurred_at": swing.occurred_at.isoformat(), + "available_at": swing.available_at.isoformat(), + "price": str(swing.price), + "label": swing.label, + "atr_significance": str(swing.atr_significance), + } + for swing in structure.swings + ), + "events": tuple( + { + "event_type": event.event_type, + "direction": event.direction.value, + "level": str(event.level), + "occurred_at": event.occurred_at.isoformat(), + "evidence_ref": event.evidence_ref, + } + for event in structure.events + ), } vote = fsum(votes) / len(votes) + confidence = fsum(confidences) / len(confidences) return self.result( snapshot, status=AgentStatus.SUCCESS, @@ -328,8 +371,8 @@ def analyze( applicable=True, directional_vote=round(vote, 6), score=75.0 if vote else 50.0, - confidence=round(abs(vote), 6), - evidence=("WINDOWED_SWING_STRUCTURE",), + confidence=round(confidence, 6), + evidence=("CONFIRMED_OR_FALLBACK_MARKET_STRUCTURE",), reason_codes=("MARKET_STRUCTURE_EVALUATED",), calculation_metadata={"timeframes": metrics}, ) diff --git a/src/ai4binance/agents/validation_gate.py b/src/ai4binance/agents/validation_gate.py index 2fd1f6f8..1b766b16 100644 --- a/src/ai4binance/agents/validation_gate.py +++ b/src/ai4binance/agents/validation_gate.py @@ -12,6 +12,11 @@ TradeCandidate, ValidationStatus, ) +from ai4binance.risk import ( + RiskAssessment, + candidate_risk_distance, + candidate_safety_blockers, +) from ai4binance.schemas import AgentResult, AgentStatus, MarketSnapshot from ai4binance.scoring import calculate_final_signal_score from ai4binance.validation.oos_maturity import ( @@ -88,6 +93,39 @@ def validate( and candidate.timeframe in snapshot.timeframes and candidate.market_type.upper() == snapshot.market_type.upper() ) + selected_scenario_id = selected[0].scenario_id if len(selected) == 1 else None + risk_scenario_id = ( + risk.calculation_metadata.get("scenario_id") if risk is not None else None + ) + scenario_binding_required = ( + selected_scenario_id is not None or risk_scenario_id is not None + ) + scenario_binding_valid = not scenario_binding_required or ( + isinstance(risk_scenario_id, str) + and risk_scenario_id == selected_scenario_id + ) + if scenario_binding_required and not scenario_binding_valid: + blockers.append("RISK_SCENARIO_BINDING_MISMATCH") + candidate_blockers = ( + candidate_safety_blockers(selected[0], snapshot) + if len(selected) == 1 + else () + ) + blockers.extend(candidate_blockers) + assessment = RiskAssessment.from_agent_result(risk) + sizing_valid = assessment is not None and assessment.approved + if sizing_valid and len(selected) == 1 and assessment is not None: + sizing_valid = ( + assessment.size_usdt == assessment.quantity * selected[0].entry_price + and assessment.risk_amount_usdt + >= assessment.quantity * candidate_risk_distance(selected[0]) + ) + if ( + risk is not None + and risk.calculation_metadata.get("approved") is True + and not sizing_valid + ): + blockers.append("RISK_SIZING_EVIDENCE_INVALID") risk_approved = ( len(selected) == 1 and risk is not None @@ -96,6 +134,9 @@ def validate( and risk.status is AgentStatus.SUCCESS and not risk.blockers and risk.calculation_metadata.get("approved") is True + and scenario_binding_valid + and not candidate_blockers + and sizing_valid ) required_gates_passed = all( (gate_result := agent_results.get(name)) is not None @@ -226,6 +267,11 @@ def maximum_score(*names: str) -> float: supporting_evidence=tuple( result.agent_name for result in evaluated if result.evidence ) + + ( + (f"SCENARIO:{selected_scenario_id}",) + if selected_scenario_id is not None + else () + ) + ((maturity_ref,) if maturity_ref is not None else ()), blockers=unique_blockers, warnings=unique_warnings, diff --git a/src/ai4binance/compatibility/opportunity_monitor.py b/src/ai4binance/compatibility/opportunity_monitor.py index 77d2e448..0d62352a 100644 --- a/src/ai4binance/compatibility/opportunity_monitor.py +++ b/src/ai4binance/compatibility/opportunity_monitor.py @@ -30,9 +30,7 @@ has_complete_measurable_opportunity, has_complete_measurable_trade_plan, ) -from ai4binance.integrations.research_market_universe import ( - RESEARCH_MARKET_UNIVERSE_SOURCE, -) +from ai4binance.domain.universe import RESEARCH_MARKET_UNIVERSE_SOURCE from ai4binance.opportunity_intelligence import ( TIMEFRAME_DURATIONS, ChartPatternLifecycleState, diff --git a/src/ai4binance/config.py b/src/ai4binance/config.py index 5ae3d287..2e5c39a8 100644 --- a/src/ai4binance/config.py +++ b/src/ai4binance/config.py @@ -59,7 +59,7 @@ class Settings(BaseSettings): market_history_max_workers: int = 8 market_history_opportunity_workers: int = 2 market_history_local_candles: bool = True - market_history_coin_m_enabled: bool = False + market_history_coin_m_enabled: bool = True market_history_wallet_minimum_value_usdt: Decimal = Decimal("1") market_history_market_cap_asset_limit: int = 20 market_depth_enabled: bool = True diff --git a/src/ai4binance/core/__init__.py b/src/ai4binance/core/__init__.py index 471e914d..a788c30a 100644 --- a/src/ai4binance/core/__init__.py +++ b/src/ai4binance/core/__init__.py @@ -1 +1,58 @@ """Core contracts and deterministic domain boundaries.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import BinaryIO + +_TAIL_READ_CHUNK_BYTES = 64 * 1024 +_DEFAULT_MAX_TAIL_BYTES = 16 * 1024 * 1024 + + +def read_bounded_jsonl_tail( + path: Path, + *, + max_lines: int = 200, + max_bytes: int = _DEFAULT_MAX_TAIL_BYTES, +) -> tuple[bytes, ...]: + """Read recent complete non-empty JSONL records without whole-file loading.""" + if max_lines < 1 or max_bytes < 1: + raise ValueError("JSONL tail limits must be positive") + with path.open("rb") as stream: + stream.seek(0, os.SEEK_END) + end = _trim_trailing_whitespace(stream, stream.tell()) + cursor = end + buffer = b"" + while cursor > 0: + start = max(0, cursor - _TAIL_READ_CHUNK_BYTES) + starts_at_record_boundary = start == 0 + if start > 0: + stream.seek(start - 1) + previous = stream.read(1) + stream.seek(start) + current = stream.read(1) + starts_at_record_boundary = previous in b"\r\n" or current in b"\r\n" + stream.seek(start) + buffer = stream.read(cursor - start) + buffer + if len(buffer) > max_bytes: + raise OSError("JSONL tail exceeds bounded read limit") + lines = tuple(line for line in buffer.splitlines() if line.strip()) + if lines and not starts_at_record_boundary: + lines = lines[1:] + if len(lines) >= max_lines or start == 0: + return lines[-max_lines:] + cursor = start + return () + + +def _trim_trailing_whitespace(stream: BinaryIO, end: int) -> int: + while end > 0: + start = max(0, end - _TAIL_READ_CHUNK_BYTES) + stream.seek(start) + block = stream.read(end - start) + stripped = block.rstrip(b" \t\r\n") + if stripped: + return start + len(stripped) + end = start + return 0 diff --git a/src/ai4binance/data/market_history_continuous.py b/src/ai4binance/data/market_history_continuous.py index 671d1eab..6858cdbb 100644 --- a/src/ai4binance/data/market_history_continuous.py +++ b/src/ai4binance/data/market_history_continuous.py @@ -1040,6 +1040,11 @@ def publish_progress( self._write_collection_progress( cycle_started_at=now, active_market=market, + universe_source=universe.source, + selected_assets=universe.selected_assets, + wallet_assets=universe.wallet_assets, + market_cap_assets=universe.market_cap_assets, + universe_retention=retention_result, completed_symbols=completed_symbols, total_symbols=total_symbols, completed_streams=completed_streams, @@ -1671,6 +1676,11 @@ def _write_collection_progress( *, cycle_started_at: datetime, active_market: str | None, + universe_source: str, + selected_assets: tuple[str, ...], + wallet_assets: tuple[str, ...], + market_cap_assets: tuple[str, ...], + universe_retention: Mapping[str, object] | None, completed_symbols: int, total_symbols: int, completed_streams: int, @@ -1692,6 +1702,11 @@ def _write_collection_progress( "cycle_started_at": cycle_started_at.isoformat(), "observed_at": datetime.now(UTC).isoformat(), "active_market": active_market, + "universe_source": universe_source, + "selected_assets": list(selected_assets), + "wallet_assets": list(wallet_assets), + "market_cap_assets": list(market_cap_assets), + "universe_retention": universe_retention, "completed_symbols": completed_symbols, "total_symbols": total_symbols, "completed_streams": completed_streams, diff --git a/src/ai4binance/data/market_universe_retention.py b/src/ai4binance/data/market_universe_retention.py index 343c45a5..952ebaa8 100644 --- a/src/ai4binance/data/market_universe_retention.py +++ b/src/ai4binance/data/market_universe_retention.py @@ -105,7 +105,12 @@ def _out_of_scope_children(root: Path, allowed: frozenset[str]) -> list[Path]: for child in root.iterdir() if child.is_dir() and not child.is_symlink() - and _SYMBOL.fullmatch(child.name) + # Symbol names from an exchange are untrusted. Do not retain an + # unknown directory merely because it is not an ASCII symbol: that + # would let a delisted or non-canonical market survive a universe + # reduction. ``metadata`` is the only non-symbol child owned by + # these archive roots. + and child.name != "metadata" and child.name not in allowed ] @@ -149,7 +154,14 @@ def _out_of_scope_flat_files(root: Path, allowed: frozenset[str]) -> list[Path]: if not child.is_file() or child.is_symlink(): continue symbol = child.name.split("-", maxsplit=1)[0] - if _SYMBOL.fullmatch(symbol) and symbol not in allowed: + # Replay datasets use ``--...`` names. Keeping + # a non-ASCII or otherwise malformed prefix would preserve data + # outside a verified universe, so only an exact allowed symbol is + # retained. Non-dataset files (without the separator) are left + # intact for service diagnostics. + if "-" in child.name and ( + not _SYMBOL.fullmatch(symbol) or symbol not in allowed + ): candidates.append(child) return candidates diff --git a/src/ai4binance/domain.py b/src/ai4binance/domain.py index 481f1905..b9d87778 100644 --- a/src/ai4binance/domain.py +++ b/src/ai4binance/domain.py @@ -169,8 +169,13 @@ class PriceZone: def __post_init__(self) -> None: """Reject negative or inverted zones.""" - if self.lower < ZERO or self.upper < ZERO: - raise ValueError("price zone values cannot be negative") + if ( + not self.lower.is_finite() + or not self.upper.is_finite() + or self.lower < ZERO + or self.upper < ZERO + ): + raise ValueError("price zone values must be finite and non-negative") if self.lower > self.upper: raise ValueError("price zone lower cannot exceed upper") @@ -202,23 +207,24 @@ class TradeCandidate: evidence: tuple[str, ...] = field(default_factory=tuple) blockers: tuple[str, ...] = field(default_factory=tuple) market_type: str = "SPOT" + scenario_id: str | None = None + scenario_type: str | None = None + scenario_state: str | None = None + scenario_invalidation: str | None = None + structural_risk_reward: Decimal | None = None + net_risk_reward: Decimal | None = None + estimated_round_trip_cost_ratio: Decimal | None = None + expected_r: Decimal | None = None + probability_calibration_state: str = "PROBABILITY_NOT_CALIBRATED" + entry_trigger: str = "NOT_SPECIFIED" + entry_state: str = "ENTRY_NOT_READY" + entry_expiry: datetime | None = None + target_sources: tuple[str, ...] = field(default_factory=tuple) def __post_init__(self) -> None: """Validate identity, geometry and market semantics.""" - for field_name in ( - "candidate_id", - "snapshot_id", - "symbol", - "timeframe", - "setup_name", - ): - if not getattr(self, field_name).strip(): - raise ValueError(f"{field_name} cannot be empty") - if self.timestamp.tzinfo is None or self.timestamp.utcoffset() is None: - raise ValueError("candidate timestamp must be timezone-aware") + self._validate_identity() object.__setattr__(self, "symbol", self.symbol.strip().upper()) - if self.action not in {Action.BUY, Action.SELL}: - raise ValueError("trade candidate action must be BUY or SELL") normalized_market_type = _normalize_market_type(self.market_type) object.__setattr__(self, "market_type", normalized_market_type) if ( @@ -234,10 +240,14 @@ def __post_init__(self) -> None: "atr", "risk_reward", ): - if getattr(self, field_name) <= ZERO: + if ( + not getattr(self, field_name).is_finite() + or getattr(self, field_name) <= ZERO + ): raise ValueError(f"{field_name} must be positive") if not self.take_profit_levels or any( - target <= ZERO for target in self.take_profit_levels + not target.is_finite() or target <= ZERO + for target in self.take_profit_levels ): raise ValueError("take_profit_levels must contain positive values") _validate_score("score", self.score) @@ -245,6 +255,74 @@ def __post_init__(self) -> None: raise ValueError("confidence must be finite and between 0 and 1") if self.ranking_score is not None: _validate_score("ranking_score", self.ranking_score) + self._validate_plan_metrics() + self._validate_entry_contract() + self._validate_scenario_contract() + self._validate_geometry() + if self.status is CandidateStatus.READY_FOR_RISK and self.blockers: + raise ValueError("READY_FOR_RISK candidate cannot contain blockers") + + def _validate_identity(self) -> None: + """Validate stable candidate identity and action semantics.""" + for field_name in ( + "candidate_id", + "snapshot_id", + "symbol", + "timeframe", + "setup_name", + ): + if not getattr(self, field_name).strip(): + raise ValueError(f"{field_name} cannot be empty") + if self.timestamp.tzinfo is None or self.timestamp.utcoffset() is None: + raise ValueError("candidate timestamp must be timezone-aware") + if self.action not in {Action.BUY, Action.SELL}: + raise ValueError("trade candidate action must be BUY or SELL") + + def _validate_entry_contract(self) -> None: + """Validate entry lifecycle, expiry, and target provenance.""" + if self.entry_expiry is not None: + if ( + self.entry_expiry.tzinfo is None + or self.entry_expiry.utcoffset() is None + ): + raise ValueError("entry_expiry must be timezone-aware") + if self.entry_expiry <= self.timestamp: + raise ValueError("entry_expiry must be after candidate timestamp") + if not self.entry_trigger.strip() or self.entry_state not in { + "ENTRY_NOT_READY", + "ENTRY_VALID", + "ENTRY_MISSED", + "ENTRY_INVALIDATED", + }: + raise ValueError("entry trigger/state is invalid") + if any(not source.strip() for source in self.target_sources): + raise ValueError("target_sources cannot contain blank values") + + def _validate_scenario_contract(self) -> None: + """Require complete scenario provenance when a candidate is bound.""" + scenario_fields = ( + self.scenario_type, + self.scenario_state, + self.scenario_invalidation, + ) + if self.scenario_id is None: + if any(value is not None for value in scenario_fields): + raise ValueError("scenario metadata requires scenario_id") + else: + if not self.scenario_id.strip(): + raise ValueError("scenario_id cannot be blank") + if self.scenario_type is None or not self.scenario_type.strip(): + raise ValueError("scenario_type is required for a bound scenario") + if self.scenario_state is None or not self.scenario_state.strip(): + raise ValueError("scenario_state is required for a bound scenario") + if ( + self.scenario_invalidation is not None + and not self.scenario_invalidation.strip() + ): + raise ValueError("scenario_invalidation cannot be blank") + + def _validate_geometry(self) -> None: + """Validate direction-specific entry, stop, and target geometry.""" entry = self.entry_price if self.action is Action.BUY: if self.stop_loss >= entry or any( @@ -255,14 +333,41 @@ def __post_init__(self) -> None: target >= entry for target in self.take_profit_levels ): raise ValueError("SELL candidate geometry is invalid") - if self.status is CandidateStatus.READY_FOR_RISK and self.blockers: - raise ValueError("READY_FOR_RISK candidate cannot contain blockers") + + def _validate_plan_metrics(self) -> None: + """Validate explicit structural, net, and calibrated R metrics.""" + for field_name in ("structural_risk_reward", "net_risk_reward"): + value = getattr(self, field_name) + if value is not None and (not value.is_finite() or value <= ZERO): + raise ValueError(f"{field_name} must be finite and positive") + if self.estimated_round_trip_cost_ratio is not None and ( + not self.estimated_round_trip_cost_ratio.is_finite() + or self.estimated_round_trip_cost_ratio < ZERO + ): + raise ValueError("estimated round-trip cost ratio is invalid") + if self.expected_r is not None and not self.expected_r.is_finite(): + raise ValueError("expected_r must be finite when available") + if self.probability_calibration_state not in { + "PROBABILITY_NOT_CALIBRATED", + "OOS_CALIBRATED", + }: + raise ValueError("probability calibration state is invalid") + if ( + self.expected_r is not None + and self.probability_calibration_state != "OOS_CALIBRATED" + ): + raise ValueError("expected_r requires OOS-calibrated probability") @property def entry_price(self) -> Decimal: """Return the deterministic midpoint used for risk calculations.""" return (self.entry_zone.lower + self.entry_zone.upper) / Decimal("2") + @property + def gross_risk_reward(self) -> Decimal: + """Retain the existing projected R/R as the gross metric.""" + return self.risk_reward + @dataclass(frozen=True, slots=True) class Signal: diff --git a/src/ai4binance/domain/universe.py b/src/ai4binance/domain/universe.py index e2280928..e425a670 100644 --- a/src/ai4binance/domain/universe.py +++ b/src/ai4binance/domain/universe.py @@ -5,6 +5,7 @@ from enum import StrEnum ZERO = Decimal("0") +RESEARCH_MARKET_UNIVERSE_SOURCE = "BINANCE_WALLET_AND_COINGECKO_MARKET_CAP" _STABLE_BASE_ASSETS = frozenset( { diff --git a/src/ai4binance/infrastructure/persistence/safe_json.py b/src/ai4binance/infrastructure/persistence/safe_json.py index b3636cbd..7a796d12 100644 --- a/src/ai4binance/infrastructure/persistence/safe_json.py +++ b/src/ai4binance/infrastructure/persistence/safe_json.py @@ -17,10 +17,13 @@ from typing import Any, BinaryIO, cast from uuid import uuid4 +from ai4binance.core import ( + read_bounded_jsonl_tail as _read_bounded_jsonl_tail, +) + _EVENT_TYPE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$") _TAIL_READ_CHUNK_BYTES = 64 * 1024 _DEFAULT_MAX_EVENT_BYTES = 8 * 1024 * 1024 -_DEFAULT_MAX_TAIL_BYTES = 16 * 1024 * 1024 _GENESIS_RECORD_HASH = "GENESIS" _SENSITIVE_KEY_FRAGMENTS = ( "api_key", @@ -402,33 +405,6 @@ def write_json_object_verified( ) -def read_bounded_jsonl_tail( - path: Path, - *, - max_lines: int = 200, - max_bytes: int = _DEFAULT_MAX_TAIL_BYTES, -) -> tuple[bytes, ...]: - """Read recent non-empty records without loading an entire JSONL file.""" - if max_lines < 1 or max_bytes < 1: - raise ValueError("JSONL tail limits must be positive") - with path.open("rb") as stream: - stream.seek(0, os.SEEK_END) - end = _trim_trailing_whitespace(stream, stream.tell()) - cursor = end - buffer = b"" - while cursor > 0: - start = max(0, cursor - _TAIL_READ_CHUNK_BYTES) - stream.seek(start) - buffer = stream.read(cursor - start) + buffer - if len(buffer) > max_bytes: - raise OSError("JSONL tail exceeds bounded read limit") - lines = tuple(line for line in buffer.splitlines() if line.strip()) - if len(lines) >= max_lines or start == 0: - return lines[-max_lines:] - cursor = start - return () - - def to_primitive(value: object) -> object: if is_dataclass(value) and not isinstance(value, type): return { @@ -453,6 +429,20 @@ def to_primitive(value: object) -> object: raise TypeError(f"{type(value).__name__} is not JSON serializable") +def read_bounded_jsonl_tail( + path: Path, + *, + max_lines: int = 200, + max_bytes: int = 16 * 1024 * 1024, +) -> tuple[bytes, ...]: + """Compatibility export for the canonical bounded JSONL reader.""" + return _read_bounded_jsonl_tail( + path, + max_lines=max_lines, + max_bytes=max_bytes, + ) + + def verified( destination: Path, subject_id: str, diff --git a/src/ai4binance/integrations/research_market_universe.py b/src/ai4binance/integrations/research_market_universe.py index 989b7972..edec35ce 100644 --- a/src/ai4binance/integrations/research_market_universe.py +++ b/src/ai4binance/integrations/research_market_universe.py @@ -14,21 +14,23 @@ from urllib.parse import urlencode from urllib.request import Request, urlopen +from ai4binance.core import read_bounded_jsonl_tail from ai4binance.core.errors import ( ExchangeHttpError, ExchangePayloadError, ExchangeRateLimitError, ExchangeTransportError, ) -from ai4binance.domain.universe import classify_asset_eligibility +from ai4binance.domain.universe import ( + RESEARCH_MARKET_UNIVERSE_SOURCE, + classify_asset_eligibility, +) from ai4binance.integrations.binance.market_universe_provider import ( BinanceEligibleMarketSnapshot, BinanceMarketUniverseProvider, ) -from ai4binance.storage import read_bounded_jsonl_tail _ZERO = Decimal("0") -RESEARCH_MARKET_UNIVERSE_SOURCE = "BINANCE_WALLET_AND_COINGECKO_MARKET_CAP" class _RetryableMarketCapError(Exception): @@ -263,8 +265,8 @@ def futures_transport(self) -> object: return self.binance.futures_transport @property - def coin_m_transport(self) -> None: - return None + def coin_m_transport(self) -> object | None: + return self.binance.coin_m_transport def eligible_market_snapshot(self) -> BinanceEligibleMarketSnapshot: return self.binance.eligible_market_snapshot() diff --git a/src/ai4binance/intelligence/__init__.py b/src/ai4binance/intelligence/__init__.py new file mode 100644 index 00000000..72b26d39 --- /dev/null +++ b/src/ai4binance/intelligence/__init__.py @@ -0,0 +1,5 @@ +"""Typed deterministic Trading Intelligence capability package. + +Consumers import contracts and engines from their owning modules. Keeping this +package initializer side-effect free prevents schema/engine import cycles. +""" diff --git a/src/ai4binance/intelligence/contracts.py b/src/ai4binance/intelligence/contracts.py new file mode 100644 index 00000000..d0ee342d --- /dev/null +++ b/src/ai4binance/intelligence/contracts.py @@ -0,0 +1,554 @@ +"""Immutable evidence contracts for deterministic Trading Intelligence.""" + +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal +from enum import StrEnum +from math import isfinite + +ZERO = Decimal("0") + + +class StructureState(StrEnum): + """Hierarchical market-structure states without order authority.""" + + BULLISH = "STRUCTURE_BULLISH" + BEARISH = "STRUCTURE_BEARISH" + RANGE = "STRUCTURE_RANGE" + TRANSITION = "STRUCTURE_TRANSITION" + UNCERTAIN = "STRUCTURE_UNCERTAIN" + + +class SwingKind(StrEnum): + """Confirmed swing geometry kind.""" + + HIGH = "HIGH" + LOW = "LOW" + + +class ScenarioDirection(StrEnum): + """Research direction that never implies execution permission.""" + + LONG = "LONG" + SHORT = "SHORT" + NEUTRAL = "NEUTRAL" + + +class ScenarioType(StrEnum): + """Bounded scenario families admitted to deterministic synthesis.""" + + LONG_CONTINUATION = "LONG_CONTINUATION" + LONG_REVERSAL = "LONG_REVERSAL" + SHORT_CONTINUATION = "SHORT_CONTINUATION" + SHORT_REVERSAL = "SHORT_REVERSAL" + RANGE_MEAN_REVERSION = "RANGE_MEAN_REVERSION" + BREAKOUT_PENDING = "BREAKOUT_PENDING" + NO_VALID_SETUP = "NO_VALID_SETUP" + + +class ScenarioState(StrEnum): + """Scenario lifecycle before risk and validation.""" + + FORMING = "FORMING" + CONFIRMED = "CONFIRMED" + BLOCKED = "BLOCKED" + INVALIDATED = "INVALIDATED" + NO_VALID_SETUP = "NO_VALID_SETUP" + + +class CalibrationState(StrEnum): + """Availability of subject-specific OOS probability calibration.""" + + NOT_CALIBRATED = "PROBABILITY_NOT_CALIBRATED" + CALIBRATED = "OOS_CALIBRATED" + + +class PatternLifecycleState(StrEnum): + """Normalized research lifecycle for every pattern family.""" + + FORMING = "FORMING" + CONFIRMED = "CONFIRMED" + FAILED = "FAILED" + INVALIDATED = "INVALIDATED" + CONTEXT_ONLY = "CONTEXT_ONLY" + ALTERNATIVE_UNRESOLVED = "ALTERNATIVE_UNRESOLVED" + + +class TrendGeometryState(StrEnum): + """Lifecycle of one deterministic dynamic trend zone.""" + + VALID = "TRENDLINE_VALID" + WEAKENING = "TRENDLINE_WEAKENING" + BREAK = "TRENDLINE_BREAK" + FALSE_BREAK = "TRENDLINE_FALSE_BREAK" + RETEST = "TRENDLINE_RETEST" + SIDEWAYS = "TRENDLINE_SIDEWAYS" + + +def _require_aware(name: str, value: datetime) -> None: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{name} must be timezone-aware") + + +def _require_nonblank(name: str, values: tuple[str, ...]) -> None: + if any(not value.strip() for value in values): + raise ValueError(f"{name} cannot contain blank values") + + +def _require_confidence(name: str, value: float) -> None: + if not isfinite(value) or not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be finite and between zero and one") + + +@dataclass(frozen=True, slots=True) +class ConfirmedSwing: + """A pivot visible only after its right-side confirmation bars close.""" + + timeframe: str + kind: SwingKind + candle_index: int + occurred_at: datetime + available_at: datetime + price: Decimal + label: str + atr_significance: Decimal + + def __post_init__(self) -> None: + if not self.timeframe.strip() or not self.label.strip(): + raise ValueError("confirmed swing identity cannot be empty") + if self.candle_index < 0: + raise ValueError("confirmed swing index cannot be negative") + _require_aware("confirmed swing occurred_at", self.occurred_at) + _require_aware("confirmed swing available_at", self.available_at) + if self.available_at < self.occurred_at: + raise ValueError("confirmed swing cannot be available before occurrence") + if ( + not self.price.is_finite() + or not self.atr_significance.is_finite() + or self.price <= ZERO + or self.atr_significance < ZERO + ): + raise ValueError("confirmed swing price/significance is invalid") + + +@dataclass(frozen=True, slots=True) +class StructureEvent: + """A deterministic BOS or CHoCH observation.""" + + event_type: str + direction: ScenarioDirection + level: Decimal + occurred_at: datetime + evidence_ref: str + + def __post_init__(self) -> None: + if not self.event_type.strip() or not self.evidence_ref.strip(): + raise ValueError("structure event identity cannot be empty") + if not self.level.is_finite() or self.level <= ZERO: + raise ValueError("structure event level must be positive") + _require_aware("structure event occurred_at", self.occurred_at) + + +@dataclass(frozen=True, slots=True) +class TimeframeStructureEvidence: + """One timeframe's structure, invalidation, and provenance.""" + + timeframe: str + state: StructureState + method: str + range_low: Decimal + range_high: Decimal + invalidation_level: Decimal | None + confidence: float + swings: tuple[ConfirmedSwing, ...] = field(default_factory=tuple) + events: tuple[StructureEvent, ...] = field(default_factory=tuple) + reason_codes: tuple[str, ...] = field(default_factory=tuple) + warnings: tuple[str, ...] = field(default_factory=tuple) + + def __post_init__(self) -> None: + if not self.timeframe.strip() or not self.method.strip(): + raise ValueError("timeframe structure identity cannot be empty") + if ( + not self.range_low.is_finite() + or not self.range_high.is_finite() + or self.range_low <= ZERO + or self.range_high < self.range_low + ): + raise ValueError("timeframe structure range is invalid") + if self.invalidation_level is not None and ( + not self.invalidation_level.is_finite() or self.invalidation_level <= ZERO + ): + raise ValueError("structure invalidation must be positive") + _require_confidence("structure confidence", self.confidence) + _require_nonblank("structure reason codes", self.reason_codes) + _require_nonblank("structure warnings", self.warnings) + + +@dataclass(frozen=True, slots=True) +class StructuralLevelEvidence: + """Shared structural zone consumed by downstream hypotheses.""" + + level_id: str + level_type: str + price_low: Decimal + price_high: Decimal + source_timeframe: str + touch_count: int + break_count: int + freshness: str + role_flip: bool + confidence: float + evidence_ref: str + rejection_strength: float = 0.0 + volume_context: str = "NOT_MEASURED" + age_bars: int = 0 + + def __post_init__(self) -> None: + required = ( + self.level_id, + self.level_type, + self.source_timeframe, + self.freshness, + self.evidence_ref, + ) + if any(not value.strip() for value in required): + raise ValueError("structural level identity cannot be empty") + if self.price_low <= ZERO or self.price_high < self.price_low: + raise ValueError("structural level price zone is invalid") + if self.touch_count < 0 or self.break_count < 0: + raise ValueError("structural level counts cannot be negative") + if self.age_bars < 0: + raise ValueError("structural level age cannot be negative") + _require_confidence("structural level confidence", self.confidence) + _require_confidence( + "structural level rejection strength", + self.rejection_strength, + ) + if not self.volume_context.strip(): + raise ValueError("structural level volume context cannot be empty") + + +@dataclass(frozen=True, slots=True) +class TrendGeometryEvidence: + """Dynamic trend-zone evidence projected from the existing trend owner.""" + + source_timeframe: str + slope: Decimal + channel_width: Decimal + state: str + touch_quality: str + evidence_ref: str + confidence: float + anchor_points: tuple[tuple[datetime, Decimal], ...] = field(default_factory=tuple) + intercept: Decimal = ZERO + touch_count: int = 0 + atr_normalized_error: Decimal = ZERO + age_bars: int = 0 + break_state: str = "UNBROKEN" + retest_state: str = "NOT_RETESTED" + compression_state: str = "NOT_MEASURED" + acceleration_state: str = "NOT_MEASURED" + blockers: tuple[str, ...] = field(default_factory=tuple) + + def __post_init__(self) -> None: + required = ( + self.source_timeframe, + self.state, + self.touch_quality, + self.evidence_ref, + ) + if any(not value.strip() for value in required): + raise ValueError("trend geometry identity cannot be empty") + if self.channel_width < ZERO: + raise ValueError("trend channel width cannot be negative") + if self.touch_count < 0 or self.age_bars < 0: + raise ValueError("trend geometry counts cannot be negative") + if self.atr_normalized_error < ZERO: + raise ValueError("trend geometry error cannot be negative") + for timestamp, price in self.anchor_points: + _require_aware("trend anchor timestamp", timestamp) + if price <= ZERO: + raise ValueError("trend anchor price must be positive") + if any( + not value.strip() + for value in ( + self.break_state, + self.retest_state, + self.compression_state, + self.acceleration_state, + ) + ): + raise ValueError("trend geometry lifecycle cannot be empty") + _require_confidence("trend geometry confidence", self.confidence) + _require_nonblank("trend geometry blockers", self.blockers) + + +@dataclass(frozen=True, slots=True) +class PatternHypothesisEvidence: + """Normalized pattern evidence; a hypothesis is never an entry.""" + + hypothesis_id: str + family: str + direction: ScenarioDirection + lifecycle_state: str + confidence: float + evidence_for: tuple[str, ...] + evidence_against: tuple[str, ...] = field(default_factory=tuple) + invalidation: str | None = None + source_timeframe: str = "UNKNOWN" + geometry_quality: float = 0.0 + completion_quality: float = 0.0 + attributes: tuple[tuple[str, str], ...] = field(default_factory=tuple) + primary_direction_signal: bool = False + execution_allowed: bool = False + + def __post_init__(self) -> None: + required = (self.hypothesis_id, self.family, self.lifecycle_state) + if any(not value.strip() for value in required): + raise ValueError("pattern hypothesis identity cannot be empty") + _require_confidence("pattern hypothesis confidence", self.confidence) + _require_confidence("pattern geometry quality", self.geometry_quality) + _require_confidence("pattern completion quality", self.completion_quality) + if not self.source_timeframe.strip(): + raise ValueError("pattern source timeframe cannot be empty") + if any(not key.strip() or not value.strip() for key, value in self.attributes): + raise ValueError("pattern attributes cannot contain blank values") + _require_nonblank("pattern evidence_for", self.evidence_for) + _require_nonblank("pattern evidence_against", self.evidence_against) + if self.primary_direction_signal or self.execution_allowed: + raise ValueError("pattern hypotheses cannot own direction or execution") + + +@dataclass(frozen=True, slots=True) +class DerivativesContextEvidence: + """Freshness-bound Futures context separate from OHLCV evidence.""" + + status: str + source_count: int + as_of: datetime | None + evidence_refs: tuple[str, ...] = field(default_factory=tuple) + blockers: tuple[str, ...] = field(default_factory=tuple) + age_seconds: int | None = None + funding_rate: Decimal | None = None + open_interest: Decimal | None = None + basis: Decimal | None = None + mark_price: Decimal | None = None + index_price: Decimal | None = None + mark_index_divergence: Decimal | None = None + taker_buy_sell_ratio: Decimal | None = None + crowding_state: str = "UNKNOWN" + confidence: float = 0.0 + execution_allowed: bool = False + + def __post_init__(self) -> None: + if not self.status.strip() or self.source_count < 0: + raise ValueError("derivatives context identity is invalid") + if self.as_of is not None: + _require_aware("derivatives context as_of", self.as_of) + if self.age_seconds is not None and self.age_seconds < 0: + raise ValueError("derivatives context age cannot be negative") + self._validate_metrics() + _require_confidence("derivatives context confidence", self.confidence) + if not self.crowding_state.strip(): + raise ValueError("derivatives crowding state cannot be empty") + _require_nonblank("derivatives evidence refs", self.evidence_refs) + _require_nonblank("derivatives blockers", self.blockers) + if self.execution_allowed: + raise ValueError("derivatives context cannot authorize execution") + + def _validate_metrics(self) -> None: + """Reject non-finite or impossible typed derivatives metrics.""" + for field_name in ( + "funding_rate", + "open_interest", + "basis", + "mark_price", + "index_price", + "mark_index_divergence", + "taker_buy_sell_ratio", + ): + value = getattr(self, field_name) + if value is not None and not value.is_finite(): + raise ValueError(f"{field_name} must be finite") + for field_name in ( + "open_interest", + "mark_price", + "index_price", + "taker_buy_sell_ratio", + ): + value = getattr(self, field_name) + if value is not None and value < ZERO: + raise ValueError(f"{field_name} cannot be negative") + if self.mark_index_divergence is not None and self.mark_index_divergence < ZERO: + raise ValueError("mark/index divergence cannot be negative") + + +@dataclass(frozen=True, slots=True) +class ConfidenceComponent: + """Named confidence component used by weakest-critical-layer reduction.""" + + name: str + value: float + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("confidence component name cannot be empty") + _require_confidence("confidence component", self.value) + + +@dataclass(frozen=True, slots=True) +class ScenarioHypothesis: + """Snapshot-bound scenario before candidate planning and risk.""" + + scenario_id: str + snapshot_id: str + scenario_type: ScenarioType + direction: ScenarioDirection + state: ScenarioState + structure_state: StructureState + regime: str + invalidation_level: Decimal | None + confidence: float + confidence_components: tuple[ConfidenceComponent, ...] + evidence_for: tuple[str, ...] + evidence_against: tuple[str, ...] = field(default_factory=tuple) + blockers: tuple[str, ...] = field(default_factory=tuple) + calibration_state: CalibrationState = CalibrationState.NOT_CALIBRATED + execution_allowed: bool = False + live_eligibility_status: str = "LIVE_ORDER_BLOCKED" + + def __post_init__(self) -> None: + if not self.scenario_id.strip() or not self.snapshot_id.strip(): + raise ValueError("scenario identity cannot be empty") + if not self.regime.strip(): + raise ValueError("scenario regime cannot be empty") + if self.invalidation_level is not None and ( + not self.invalidation_level.is_finite() or self.invalidation_level <= ZERO + ): + raise ValueError("scenario invalidation must be positive") + _require_confidence("scenario confidence", self.confidence) + if not self.confidence_components: + raise ValueError("scenario confidence components cannot be empty") + if self.confidence != min( + component.value for component in self.confidence_components + ): + raise ValueError("scenario confidence must equal the weakest component") + _require_nonblank("scenario evidence_for", self.evidence_for) + _require_nonblank("scenario evidence_against", self.evidence_against) + _require_nonblank("scenario blockers", self.blockers) + if ( + self.execution_allowed + or self.live_eligibility_status != "LIVE_ORDER_BLOCKED" + ): + raise ValueError("scenario hypotheses must remain live blocked") + + +@dataclass(frozen=True, slots=True) +class TradingIntelligenceState: + """One deterministic evidence chain shared by candidates, risk, and validation.""" + + snapshot_id: str + symbol: str + timestamp: datetime + structures: tuple[TimeframeStructureEvidence, ...] + levels: tuple[StructuralLevelEvidence, ...] + trend_geometry: tuple[TrendGeometryEvidence, ...] + pattern_hypotheses: tuple[PatternHypothesisEvidence, ...] + derivatives_context: DerivativesContextEvidence + scenarios: tuple[ScenarioHypothesis, ...] + selected_scenario_id: str | None + estimated_round_trip_cost_ratio: Decimal | None = None + cost_blockers: tuple[str, ...] = field(default_factory=tuple) + blockers: tuple[str, ...] = field(default_factory=tuple) + warnings: tuple[str, ...] = field(default_factory=tuple) + promotion_status: str = "RESEARCH_ONLY" + execution_allowed: bool = False + live_eligibility_status: str = "LIVE_ORDER_BLOCKED" + market_type: str = "SPOT" + + def __post_init__(self) -> None: + if not self.snapshot_id.strip() or not self.symbol.strip(): + raise ValueError("trading intelligence identity cannot be empty") + _require_aware("trading intelligence timestamp", self.timestamp) + if self.market_type not in {"SPOT", "USD_M_FUTURES"}: + raise ValueError("trading intelligence market type is invalid") + _require_nonblank("trading intelligence blockers", self.blockers) + _require_nonblank("trading intelligence warnings", self.warnings) + _require_nonblank("trading intelligence cost blockers", self.cost_blockers) + if self.estimated_round_trip_cost_ratio is not None and ( + not self.estimated_round_trip_cost_ratio.is_finite() + or self.estimated_round_trip_cost_ratio < ZERO + ): + raise ValueError("trading intelligence cost ratio is invalid") + self._validate_evidence_binding() + if ( + self.promotion_status != "RESEARCH_ONLY" + or self.execution_allowed + or self.live_eligibility_status != "LIVE_ORDER_BLOCKED" + ): + raise ValueError("trading intelligence must remain research-only") + + def _validate_evidence_binding(self) -> None: + """Reject cross-cycle evidence and contradictory scenario selection.""" + scenario_ids = tuple(item.scenario_id for item in self.scenarios) + if any(item.snapshot_id != self.snapshot_id for item in self.scenarios): + raise ValueError("scenario snapshot identity must match shared state") + if any( + swing.available_at > self.timestamp + for structure in self.structures + for swing in structure.swings + ) or any( + event.occurred_at > self.timestamp + for structure in self.structures + for event in structure.events + ): + raise ValueError("structure evidence cannot be available after snapshot") + if self.selected_scenario_id is not None and self.blockers: + raise ValueError("blocked intelligence cannot select a scenario") + if self.selected_scenario is not None and self.selected_scenario.state not in { + ScenarioState.FORMING, + ScenarioState.CONFIRMED, + }: + raise ValueError("selected scenario must be forming or confirmed") + if len(scenario_ids) != len(set(scenario_ids)): + raise ValueError("trading intelligence scenario IDs must be unique") + if ( + self.selected_scenario_id is not None + and self.selected_scenario_id not in scenario_ids + ): + raise ValueError("selected scenario must exist in the scenario set") + + @property + def selected_scenario(self) -> ScenarioHypothesis | None: + """Return the selected scenario without creating a second index.""" + return next( + ( + item + for item in self.scenarios + if item.scenario_id == self.selected_scenario_id + ), + None, + ) + + @property + def primary_confidence(self) -> float: + """Return selected confidence or zero when selection is blocked.""" + selected = self.selected_scenario + return selected.confidence if selected is not None else 0.0 + + @property + def runner_up_confidence(self) -> float: + """Return the strongest non-selected scenario confidence.""" + return max( + ( + item.confidence + for item in self.scenarios + if item.scenario_id != self.selected_scenario_id + ), + default=0.0, + ) + + @property + def scenario_separation(self) -> float: + """Expose confidence separation without hiding hard blockers.""" + return max(0.0, self.primary_confidence - self.runner_up_confidence) diff --git a/src/ai4binance/intelligence/derivatives.py b/src/ai4binance/intelligence/derivatives.py new file mode 100644 index 00000000..5f1e898b --- /dev/null +++ b/src/ai4binance/intelligence/derivatives.py @@ -0,0 +1,207 @@ +"""Typed, freshness-bound Futures context separate from OHLCV evidence.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta +from decimal import Decimal, InvalidOperation + +from ai4binance.intelligence.contracts import DerivativesContextEvidence +from ai4binance.schemas import ( + AgentResult, + MarketSnapshot, + is_futures_market_type, + is_usable_agent_result, +) + +ZERO = Decimal("0") + + +@dataclass(frozen=True, slots=True) +class FuturesContextEngine: + """Validate provenance, freshness, and critical derivatives metrics.""" + + maximum_age: timedelta = timedelta(hours=2) + + def __post_init__(self) -> None: + if self.maximum_age <= timedelta(0): + raise ValueError("Futures context maximum age must be positive") + + def build( + self, + snapshot: MarketSnapshot, + result: AgentResult | None, + ) -> DerivativesContextEvidence: + if not is_futures_market_type(snapshot.market_type): + return DerivativesContextEvidence( + status="NOT_APPLICABLE", + source_count=0, + as_of=None, + evidence_refs=("SPOT_MARKET",), + ) + if result is None or not is_usable_agent_result(result) or result.blockers: + return self._blocked("FUTURES_DERIVATIVES_CONTEXT_UNAVAILABLE") + if ( + result.agent_name != "derivatives" + or result.snapshot_id != snapshot.snapshot_id + or result.timestamp != snapshot.created_at + or result.symbol != snapshot.symbol + ): + return self._blocked("FUTURES_DERIVATIVES_IDENTITY_MISMATCH") + raw = snapshot.derivatives_snapshot + source_count = self._integer(raw.get("source_count")) + as_of = self._datetime(raw.get("as_of")) + if source_count is None or source_count < 1 or as_of is None: + return self._blocked("FUTURES_DERIVATIVES_PROVENANCE_INVALID") + age = snapshot.created_at - as_of + if age < timedelta(0) or age > self.maximum_age: + return self._blocked("FUTURES_DERIVATIVES_CONTEXT_STALE_OR_FUTURE") + metrics = self._metrics(raw) + blockers = self._metric_blockers(metrics) + if blockers: + return DerivativesContextEvidence( + status="BLOCKED", + source_count=source_count, + as_of=as_of, + evidence_refs=result.evidence or ("DERIVATIVES_AGENT",), + blockers=blockers, + age_seconds=int(age.total_seconds()), + funding_rate=metrics["funding_rate"], + open_interest=self._nonnegative(metrics["open_interest"]), + basis=metrics["basis"], + mark_price=self._positive(metrics["mark_price"]), + index_price=self._positive(metrics["index_price"]), + taker_buy_sell_ratio=self._nonnegative(metrics["taker_buy_sell_ratio"]), + ) + index_price = metrics["index_price"] + mark_price = metrics["mark_price"] + if not isinstance(index_price, Decimal) or not isinstance(mark_price, Decimal): + return self._blocked("FUTURES_DERIVATIVES_METRICS_INVALID") + divergence = abs(mark_price - index_price) / index_price + funding_rate = metrics["funding_rate"] + if not isinstance(funding_rate, Decimal): + return self._blocked("FUTURES_DERIVATIVES_METRICS_INVALID") + crowding = ( + "POSITIVE_FUNDING" + if funding_rate > ZERO + else "NEGATIVE_FUNDING" + if funding_rate < ZERO + else "NEUTRAL_FUNDING" + ) + return DerivativesContextEvidence( + status="AVAILABLE", + source_count=source_count, + as_of=as_of, + evidence_refs=result.evidence or ("DERIVATIVES_AGENT",), + age_seconds=int(age.total_seconds()), + mark_index_divergence=divergence, + crowding_state=crowding, + confidence=min(result.confidence, min(source_count, 10) / 10.0), + funding_rate=metrics["funding_rate"], + open_interest=metrics["open_interest"], + basis=metrics["basis"], + mark_price=metrics["mark_price"], + index_price=metrics["index_price"], + taker_buy_sell_ratio=metrics["taker_buy_sell_ratio"], + ) + + @staticmethod + def _metrics(raw: Mapping[str, object]) -> dict[str, Decimal | None]: + mark_price = FuturesContextEngine._decimal_alias(raw, "mark_price", "markPrice") + index_price = FuturesContextEngine._decimal_alias( + raw, "index_price", "indexPrice" + ) + basis = FuturesContextEngine._decimal_alias(raw, "basis") + if basis is None and mark_price is not None and index_price is not None: + basis = mark_price - index_price + return { + "funding_rate": FuturesContextEngine._decimal_alias( + raw, "funding_rate", "lastFundingRate" + ), + "open_interest": FuturesContextEngine._decimal_alias( + raw, "open_interest", "openInterest" + ), + "basis": basis, + "mark_price": mark_price, + "index_price": index_price, + "taker_buy_sell_ratio": FuturesContextEngine._decimal_alias( + raw, "taker_buy_sell_ratio", "takerBuySellRatio" + ), + } + + @staticmethod + def _metric_blockers( + metrics: Mapping[str, Decimal | None], + ) -> tuple[str, ...]: + blockers: list[str] = [] + for name in ("funding_rate", "open_interest", "mark_price", "index_price"): + value = metrics[name] + if value is None: + blockers.append(f"FUTURES_METRIC_MISSING:{name.upper()}") + for name in ("open_interest", "mark_price", "index_price"): + value = metrics[name] + if value is not None and value <= ZERO: + blockers.append(f"FUTURES_METRIC_INVALID:{name.upper()}") + taker = metrics["taker_buy_sell_ratio"] + if taker is not None and taker < ZERO: + blockers.append("FUTURES_METRIC_INVALID:TAKER_BUY_SELL_RATIO") + return tuple(dict.fromkeys(blockers)) + + @staticmethod + def _blocked(blocker: str) -> DerivativesContextEvidence: + return DerivativesContextEvidence( + status="BLOCKED", + source_count=0, + as_of=None, + blockers=(blocker,), + ) + + @staticmethod + def _nonnegative(value: Decimal | None) -> Decimal | None: + return value if value is not None and value >= ZERO else None + + @staticmethod + def _positive(value: Decimal | None) -> Decimal | None: + return value if value is not None and value > ZERO else None + + @staticmethod + def _decimal_alias( + raw: Mapping[str, object], + *names: str, + ) -> Decimal | None: + for name in names: + if name not in raw: + continue + value = raw.get(name) + if isinstance(value, (str, int, float, Decimal)) and not isinstance( + value, bool + ): + try: + parsed = Decimal(str(value)) + except InvalidOperation: + return None + if parsed.is_finite(): + return parsed + return None + return None + + @staticmethod + def _integer(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + @staticmethod + def _datetime(value: object) -> datetime | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is None or parsed.utcoffset() is None: + return None + return parsed diff --git a/src/ai4binance/intelligence/levels.py b/src/ai4binance/intelligence/levels.py new file mode 100644 index 00000000..c0ff6301 --- /dev/null +++ b/src/ai4binance/intelligence/levels.py @@ -0,0 +1,144 @@ +"""Canonical structural-level map projected from existing level evidence.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + +from ai4binance.indicators import atr +from ai4binance.intelligence.contracts import ( + StructuralLevelEvidence, + TimeframeStructureEvidence, +) +from ai4binance.schemas import ( + AgentResult, + MarketSnapshot, + OHLCVCandle, + is_usable_agent_result, +) + +ZERO = Decimal("0") + + +@dataclass(frozen=True, slots=True) +class StructuralLevelMapEngine: + """Enrich the existing support/resistance owner into shared zones.""" + + atr_zone_ratio: Decimal = Decimal("0.15") + touch_lookback: int = 50 + + def __post_init__(self) -> None: + if self.atr_zone_ratio <= ZERO: + raise ValueError("level-map ATR zone ratio must be positive") + if self.touch_lookback < 14: + raise ValueError("level-map touch lookback must be at least 14") + + def build( + self, + snapshot: MarketSnapshot, + structures: tuple[TimeframeStructureEvidence, ...], + result: AgentResult | None, + ) -> tuple[StructuralLevelEvidence, ...]: + """Build one reusable level map without owning primary direction.""" + if result is None or not is_usable_agent_result(result): + return () + raw_timeframes = self._mapping(result.calculation_metadata.get("timeframes")) + structure_by_timeframe = {item.timeframe: item for item in structures} + levels: list[StructuralLevelEvidence] = [] + for timeframe in snapshot.timeframes: + candles = tuple(snapshot.ohlcv_by_timeframe.get(timeframe, ())) + raw = self._mapping(raw_timeframes.get(timeframe)) + if len(candles) < 14 or not raw: + continue + volatility = atr(candles, 14) + half_width = volatility * self.atr_zone_ratio + structure = structure_by_timeframe.get(timeframe) + for level_type in ("support", "resistance"): + center = self._decimal(raw.get(level_type)) + if center is None or center <= ZERO: + continue + levels.append( + self._level( + snapshot, + timeframe, + level_type, + center, + half_width, + candles, + result.confidence, + structure, + ) + ) + return tuple(levels) + + def _level( + self, + snapshot: MarketSnapshot, + timeframe: str, + level_type: str, + center: Decimal, + half_width: Decimal, + candles: tuple[OHLCVCandle, ...], + source_confidence: float, + structure: TimeframeStructureEvidence | None, + ) -> StructuralLevelEvidence: + typed_candles = candles[-self.touch_lookback :] + price_low = max(Decimal("0.00000001"), center - half_width) + price_high = center + half_width + touches = tuple( + index + for index, candle in enumerate(typed_candles) + if candle.low <= price_high and candle.high >= price_low + ) + breaks = sum( + 1 + for candle in typed_candles + if ( + candle.close < price_low + if level_type == "support" + else candle.close > price_high + ) + ) + age_bars = ( + len(typed_candles) - 1 - touches[-1] if touches else len(typed_candles) + ) + freshness = "FRESH" if age_bars <= 5 else "AGING" if age_bars <= 20 else "STALE" + rejection_strength = min(1.0, len(touches) / 4.0) + structure_confidence = structure.confidence if structure is not None else 1.0 + confidence = min(source_confidence, structure_confidence) + latest_close = typed_candles[-1].close + role_flip = breaks > 0 and ( + (level_type == "support" and latest_close > price_high) + or (level_type == "resistance" and latest_close < price_low) + ) + return StructuralLevelEvidence( + level_id=f"{snapshot.snapshot_id}:{timeframe}:{level_type}", + level_type=level_type.upper(), + price_low=price_low, + price_high=price_high, + source_timeframe=timeframe, + touch_count=len(touches), + break_count=breaks, + freshness=freshness, + role_flip=role_flip, + confidence=confidence, + evidence_ref=f"support_resistance:{timeframe}:{level_type}", + rejection_strength=rejection_strength, + volume_context="NOT_MEASURED", + age_bars=age_bars, + ) + + @staticmethod + def _mapping(value: object) -> Mapping[str, object]: + if not isinstance(value, Mapping): + return {} + return {str(key): item for key, item in value.items()} + + @staticmethod + def _decimal(value: object) -> Decimal | None: + if not isinstance(value, (str, int, float, Decimal)) or isinstance(value, bool): + return None + try: + parsed = Decimal(str(value)) + except InvalidOperation: + return None + return parsed if parsed.is_finite() else None diff --git a/src/ai4binance/intelligence/patterns.py b/src/ai4binance/intelligence/patterns.py new file mode 100644 index 00000000..2ab6806e --- /dev/null +++ b/src/ai4binance/intelligence/patterns.py @@ -0,0 +1,192 @@ +"""Pattern-hypothesis normalization over existing deterministic analyzers.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from hashlib import sha256 + +from ai4binance.intelligence.contracts import ( + PatternHypothesisEvidence, + PatternLifecycleState, + ScenarioDirection, +) +from ai4binance.schemas import AgentResult, MarketSnapshot, is_usable_agent_result + +PATTERN_AGENTS = ( + "chart_pattern", + "fibonacci", + "harmonic_pattern", + "elliott_wave", + "candlestick", + "price_action", +) +TIMEFRAME_PRIORITY = ("1d", "4h", "1h", "15m", "5m") +ATTRIBUTE_KEYS = ( + "pattern_id", + "pattern_family", + "formation_progress", + "confirmation_condition", + "invalidation_condition", + "retracement_zone", + "nearest_level", + "ab_cd", + "theory", + "heuristic", + "method", +) + + +@dataclass(frozen=True, slots=True) +class PatternHypothesisFabric: + """Convert detectors into non-authoritative, lifecycle-bound hypotheses.""" + + def build( + self, + snapshot: MarketSnapshot, + agent_results: Mapping[str, AgentResult], + ) -> tuple[PatternHypothesisEvidence, ...]: + hypotheses = tuple( + hypothesis + for name in PATTERN_AGENTS + if (result := agent_results.get(name)) is not None + if (hypothesis := self._normalize(snapshot, name, result)) is not None + ) + return tuple( + sorted(hypotheses, key=lambda item: (item.family, item.hypothesis_id)) + ) + + def _normalize( + self, + snapshot: MarketSnapshot, + name: str, + result: AgentResult, + ) -> PatternHypothesisEvidence | None: + if not is_usable_agent_result(result) or result.blockers: + return None + if ( + result.agent_name != name + or result.snapshot_id != snapshot.snapshot_id + or result.symbol != snapshot.symbol + or result.timestamp != snapshot.created_at + ): + return None + direction = self._direction(result.directional_vote) + lifecycle = self._lifecycle(name, result.calculation_metadata) + source_timeframe = self._source_timeframe(snapshot, result) + attributes = self._attributes(result.calculation_metadata) + identity = result.calculation_metadata.get("pattern_id") + digest_source = f"{snapshot.snapshot_id}|{name}|" + ( + identity + if isinstance(identity, str) and identity.strip() + else ( + f"{snapshot.snapshot_id}|{name}|{direction.value}|" + f"{'|'.join(result.detected_setups)}" + ) + ) + digest = sha256(digest_source.encode()).hexdigest()[:16] + completion = self._completion_quality(lifecycle, result.calculation_metadata) + invalidation = result.invalidation or self._string( + result.calculation_metadata.get("invalidation_condition") + ) + return PatternHypothesisEvidence( + hypothesis_id=f"pattern:{digest}", + family=name.upper(), + direction=direction, + lifecycle_state=lifecycle.value, + confidence=result.confidence, + evidence_for=result.evidence or (f"{name}:RULE_EVALUATED",), + evidence_against=tuple( + dict.fromkeys( + ( + *result.counter_evidence, + *result.blockers, + *result.warnings, + ) + ) + ), + invalidation=invalidation, + source_timeframe=source_timeframe, + geometry_quality=0.0, + completion_quality=completion, + attributes=attributes, + ) + + @staticmethod + def _lifecycle( + name: str, + metadata: Mapping[str, object], + ) -> PatternLifecycleState: + explicit = metadata.get("lifecycle_state") + if isinstance(explicit, str): + try: + return PatternLifecycleState(explicit) + except ValueError: + return PatternLifecycleState.INVALIDATED + return { + "fibonacci": PatternLifecycleState.CONTEXT_ONLY, + "elliott_wave": PatternLifecycleState.ALTERNATIVE_UNRESOLVED, + "harmonic_pattern": PatternLifecycleState.FORMING, + "chart_pattern": PatternLifecycleState.FORMING, + "candlestick": PatternLifecycleState.CONFIRMED, + "price_action": PatternLifecycleState.CONFIRMED, + }[name] + + @staticmethod + def _source_timeframe( + snapshot: MarketSnapshot, + result: AgentResult, + ) -> str: + explicit = result.calculation_metadata.get("source_timeframe") + if isinstance(explicit, str) and explicit in snapshot.timeframes: + return explicit + evidence_timeframes = { + item.rsplit(":", 1)[-1] for item in result.evidence + } & set(snapshot.timeframes) + if len(evidence_timeframes) == 1: + return next(iter(evidence_timeframes)) + return ( + result.timeframes[0] + if len(result.timeframes) == 1 + and result.timeframes[0] in snapshot.timeframes + else "UNKNOWN" + ) + + @staticmethod + def _attributes(metadata: Mapping[str, object]) -> tuple[tuple[str, str], ...]: + attributes: list[tuple[str, str]] = [] + for key in ATTRIBUTE_KEYS: + value = metadata.get(key) + if isinstance(value, (str, int, float, Decimal)) and not isinstance( + value, bool + ): + text = str(value).strip() + if text: + attributes.append((key, text[:256])) + return tuple(attributes) + + @staticmethod + def _completion_quality( + lifecycle: PatternLifecycleState, + metadata: Mapping[str, object], + ) -> float: + raw = metadata.get("formation_progress") + if isinstance(raw, (str, int, float, Decimal)) and not isinstance(raw, bool): + try: + value = Decimal(str(raw)) + except InvalidOperation: + value = Decimal("0") + if value.is_finite(): + return float(max(Decimal("0"), min(Decimal("1"), value))) + return 1.0 if lifecycle is PatternLifecycleState.CONFIRMED else 0.5 + + @staticmethod + def _direction(vote: float) -> ScenarioDirection: + if vote > 0.0: + return ScenarioDirection.LONG + if vote < 0.0: + return ScenarioDirection.SHORT + return ScenarioDirection.NEUTRAL + + @staticmethod + def _string(value: object) -> str | None: + return value if isinstance(value, str) and value.strip() else None diff --git a/src/ai4binance/intelligence/structure.py b/src/ai4binance/intelligence/structure.py new file mode 100644 index 00000000..87b5e4e6 --- /dev/null +++ b/src/ai4binance/intelligence/structure.py @@ -0,0 +1,227 @@ +"""Confirmed-swing market-structure analysis with no look-ahead authority.""" + +from dataclasses import dataclass +from decimal import Decimal +from itertools import pairwise + +from ai4binance.indicators import atr +from ai4binance.intelligence.contracts import ( + ConfirmedSwing, + ScenarioDirection, + StructureEvent, + StructureState, + SwingKind, + TimeframeStructureEvidence, +) +from ai4binance.schemas import OHLCVCandle + +ZERO = Decimal("0") + + +@dataclass(frozen=True, slots=True) +class MarketStructureEngine: + """Build a deterministic confirmed-swing graph for one timeframe.""" + + pivot_left: int = 2 + pivot_right: int = 2 + minimum_candles: int = 20 + + def __post_init__(self) -> None: + if self.pivot_left < 1 or self.pivot_right < 1: + raise ValueError("pivot confirmation windows must be positive") + if self.minimum_candles < self.pivot_left + self.pivot_right + 3: + raise ValueError("minimum_candles is too small for pivot confirmation") + + def analyze( + self, + timeframe: str, + candles: tuple[OHLCVCandle, ...], + ) -> TimeframeStructureEvidence: + """Return confirmed structure or an explicit windowed fallback.""" + if not timeframe.strip(): + raise ValueError("structure timeframe cannot be empty") + if len(candles) < self.minimum_candles: + raise ValueError("market structure history is insufficient") + if any( + current.timestamp <= previous.timestamp + for previous, current in pairwise(candles) + ): + raise ValueError("market structure candles must be strictly chronological") + swings = self._confirmed_swings(timeframe, candles) + state, method, warnings = self._state(candles, swings) + events = self._events(timeframe, candles, swings, state) + invalidation = self._invalidation(swings, state) + sample = candles[-self.minimum_candles :] + confidence = self._confidence(state, method, swings) + return TimeframeStructureEvidence( + timeframe=timeframe, + state=state, + method=method, + range_low=min(item.low for item in sample), + range_high=max(item.high for item in sample), + invalidation_level=invalidation, + confidence=confidence, + swings=swings, + events=events, + reason_codes=("MARKET_STRUCTURE_EVALUATED",), + warnings=warnings, + ) + + def _confirmed_swings( + self, + timeframe: str, + candles: tuple[OHLCVCandle, ...], + ) -> tuple[ConfirmedSwing, ...]: + raw: list[tuple[int, SwingKind, Decimal]] = [] + stop = len(candles) - self.pivot_right + for index in range(self.pivot_left, stop): + current = candles[index] + left = candles[index - self.pivot_left : index] + right = candles[index + 1 : index + self.pivot_right + 1] + if current.high > max(item.high for item in left) and current.high >= max( + item.high for item in right + ): + raw.append((index, SwingKind.HIGH, current.high)) + if current.low < min(item.low for item in left) and current.low <= min( + item.low for item in right + ): + raw.append((index, SwingKind.LOW, current.low)) + previous: dict[SwingKind, Decimal] = {} + swings: list[ConfirmedSwing] = [] + ordered = sorted(raw, key=lambda item: (item[0], item[1].value)) + for index, kind, price in ordered: + confirmation = index + self.pivot_right + history = candles[max(0, confirmation - 14) : confirmation + 1] + volatility = atr(history, min(14, len(history) - 1)) + prior = previous.get(kind) + label = self._label(kind, price, prior) + significance = ( + abs(price - prior) / volatility + if prior is not None and volatility > ZERO + else ZERO + ) + swings.append( + ConfirmedSwing( + timeframe=timeframe, + kind=kind, + candle_index=index, + occurred_at=candles[index].timestamp, + available_at=candles[index + self.pivot_right].timestamp, + price=price, + label=label, + atr_significance=significance, + ) + ) + previous[kind] = price + return tuple(swings) + + def _state( + self, + candles: tuple[OHLCVCandle, ...], + swings: tuple[ConfirmedSwing, ...], + ) -> tuple[StructureState, str, tuple[str, ...]]: + highs = tuple(item for item in swings if item.kind is SwingKind.HIGH) + lows = tuple(item for item in swings if item.kind is SwingKind.LOW) + if len(highs) >= 2 and len(lows) >= 2: + high_label = highs[-1].label + low_label = lows[-1].label + if high_label == "HH" and low_label == "HL": + return StructureState.BULLISH, "CONFIRMED_SWING_GRAPH", () + if high_label == "LH" and low_label == "LL": + return StructureState.BEARISH, "CONFIRMED_SWING_GRAPH", () + if high_label == "EH" and low_label == "EL": + return StructureState.RANGE, "CONFIRMED_SWING_GRAPH", () + return StructureState.TRANSITION, "CONFIRMED_SWING_GRAPH", () + previous = candles[-20:-10] + recent = candles[-10:] + previous_high = max(item.high for item in previous) + previous_low = min(item.low for item in previous) + recent_high = max(item.high for item in recent) + recent_low = min(item.low for item in recent) + warning = ("CONFIRMED_SWING_PAIR_INCOMPLETE",) + if recent_high > previous_high and recent_low > previous_low: + return StructureState.BULLISH, "WINDOWED_STRUCTURE_FALLBACK", warning + if recent_high < previous_high and recent_low < previous_low: + return StructureState.BEARISH, "WINDOWED_STRUCTURE_FALLBACK", warning + if recent_high == previous_high and recent_low == previous_low: + return StructureState.RANGE, "WINDOWED_STRUCTURE_FALLBACK", warning + return StructureState.TRANSITION, "WINDOWED_STRUCTURE_FALLBACK", warning + + @staticmethod + def _events( + timeframe: str, + candles: tuple[OHLCVCandle, ...], + swings: tuple[ConfirmedSwing, ...], + state: StructureState, + ) -> tuple[StructureEvent, ...]: + latest = candles[-1] + highs = tuple(item for item in swings if item.kind is SwingKind.HIGH) + lows = tuple(item for item in swings if item.kind is SwingKind.LOW) + events: list[StructureEvent] = [] + if highs and latest.close > highs[-1].price: + event_type = "BOS_UP" if state is StructureState.BULLISH else "CHOCH_UP" + events.append( + StructureEvent( + event_type=event_type, + direction=ScenarioDirection.LONG, + level=highs[-1].price, + occurred_at=latest.timestamp, + evidence_ref=f"market_structure:{timeframe}:{event_type}", + ) + ) + if lows and latest.close < lows[-1].price: + event_type = "BOS_DOWN" if state is StructureState.BEARISH else "CHOCH_DOWN" + events.append( + StructureEvent( + event_type=event_type, + direction=ScenarioDirection.SHORT, + level=lows[-1].price, + occurred_at=latest.timestamp, + evidence_ref=f"market_structure:{timeframe}:{event_type}", + ) + ) + return tuple(events) + + @staticmethod + def _invalidation( + swings: tuple[ConfirmedSwing, ...], + state: StructureState, + ) -> Decimal | None: + if state is StructureState.BULLISH: + return next( + (item.price for item in reversed(swings) if item.kind is SwingKind.LOW), + None, + ) + if state is StructureState.BEARISH: + return next( + ( + item.price + for item in reversed(swings) + if item.kind is SwingKind.HIGH + ), + None, + ) + return None + + @staticmethod + def _label( + kind: SwingKind, + price: Decimal, + previous: Decimal | None, + ) -> str: + if previous is None: + return "INITIAL_HIGH" if kind is SwingKind.HIGH else "INITIAL_LOW" + if kind is SwingKind.HIGH: + return "HH" if price > previous else "LH" if price < previous else "EH" + return "HL" if price > previous else "LL" if price < previous else "EL" + + @staticmethod + def _confidence( + state: StructureState, + method: str, + swings: tuple[ConfirmedSwing, ...], + ) -> float: + if method == "WINDOWED_STRUCTURE_FALLBACK": + return 0.5 if state is not StructureState.TRANSITION else 0.35 + base = min(0.9, 0.55 + len(swings) * 0.025) + return round(base if state is not StructureState.TRANSITION else base * 0.75, 6) diff --git a/src/ai4binance/intelligence/trading.py b/src/ai4binance/intelligence/trading.py new file mode 100644 index 00000000..64fdb7d4 --- /dev/null +++ b/src/ai4binance/intelligence/trading.py @@ -0,0 +1,1164 @@ +"""Deterministic synthesis from existing evidence into bounded scenarios.""" + +from collections.abc import Mapping +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta +from decimal import Decimal, InvalidOperation +from hashlib import sha256 +from typing import cast + +from ai4binance.domain import Action, CandidateStatus, TradeCandidate +from ai4binance.intelligence.contracts import ( + CalibrationState, + ConfidenceComponent, + ConfirmedSwing, + DerivativesContextEvidence, + PatternHypothesisEvidence, + ScenarioDirection, + ScenarioHypothesis, + ScenarioState, + ScenarioType, + StructuralLevelEvidence, + StructureEvent, + StructureState, + SwingKind, + TimeframeStructureEvidence, + TradingIntelligenceState, + TrendGeometryEvidence, +) +from ai4binance.intelligence.derivatives import FuturesContextEngine +from ai4binance.intelligence.levels import StructuralLevelMapEngine +from ai4binance.intelligence.patterns import PatternHypothesisFabric +from ai4binance.intelligence.trend import TrendGeometryEngine +from ai4binance.schemas import ( + AgentResult, + DataQuality, + MarketSnapshot, + is_usable_agent_result, +) + +ZERO = Decimal("0") +STRUCTURE_PRIORITY = ("1d", "4h", "1h", "15m", "5m") + + +@dataclass(frozen=True, slots=True) +class TradingIntelligenceEngine: + """Normalize canonical observations and form hierarchical scenarios.""" + + level_map_engine: StructuralLevelMapEngine = field( + default_factory=StructuralLevelMapEngine + ) + trend_geometry_engine: TrendGeometryEngine = field( + default_factory=TrendGeometryEngine + ) + pattern_hypothesis_fabric: PatternHypothesisFabric = field( + default_factory=PatternHypothesisFabric + ) + futures_context_engine: FuturesContextEngine = field( + default_factory=FuturesContextEngine + ) + minimum_scenario_separation: float = 0.10 + + def __post_init__(self) -> None: + if not 0.0 <= self.minimum_scenario_separation <= 1.0: + raise ValueError("scenario separation must be between zero and one") + + def build( + self, + snapshot: MarketSnapshot, + agent_results: Mapping[str, AgentResult], + ) -> TradingIntelligenceState: + """Build one snapshot-bound state without recalculating indicators.""" + identity_blockers = self._identity_blockers(snapshot, agent_results) + if identity_blockers: + return self.blocked(snapshot, identity_blockers) + structures = self._structures(snapshot, agent_results) + levels = self._levels(snapshot, agent_results, structures) + trend_geometry = self._trend_geometry(snapshot, agent_results, structures) + pattern_hypotheses = self._patterns(snapshot, agent_results) + derivatives = self._derivatives(snapshot, agent_results) + cost_ratio, cost_blockers = self._cost_model(snapshot, derivatives) + scenarios, selected_id, scenario_blockers, warnings = self._scenarios( + snapshot, + agent_results, + structures, + levels, + trend_geometry, + pattern_hypotheses, + derivatives, + ) + blockers = tuple( + dict.fromkeys( + ( + *identity_blockers, + *scenario_blockers, + *derivatives.blockers, + ) + ) + ) + if identity_blockers or derivatives.blockers: + selected_id = None + return TradingIntelligenceState( + snapshot_id=snapshot.snapshot_id, + symbol=snapshot.symbol, + timestamp=snapshot.created_at, + structures=structures, + levels=levels, + trend_geometry=trend_geometry, + pattern_hypotheses=pattern_hypotheses, + derivatives_context=derivatives, + scenarios=scenarios, + selected_scenario_id=selected_id, + estimated_round_trip_cost_ratio=cost_ratio, + cost_blockers=cost_blockers, + blockers=blockers, + warnings=warnings, + market_type=snapshot.market_type.upper(), + ) + + def blocked( + self, + snapshot: MarketSnapshot, + blockers: tuple[str, ...], + ) -> TradingIntelligenceState: + """Return an explicit state when an upstream hard gate exits early.""" + unique = tuple(dict.fromkeys(blockers or ("TRADING_INTELLIGENCE_BLOCKED",))) + scenario = self._no_valid_scenario(snapshot, unique) + return TradingIntelligenceState( + snapshot_id=snapshot.snapshot_id, + symbol=snapshot.symbol, + timestamp=snapshot.created_at, + structures=(), + levels=(), + trend_geometry=(), + pattern_hypotheses=(), + derivatives_context=DerivativesContextEvidence( + status="BLOCKED", + source_count=0, + as_of=None, + blockers=unique, + ), + scenarios=(scenario,), + selected_scenario_id=None, + blockers=unique, + market_type=snapshot.market_type.upper(), + ) + + def bind_candidates( + self, + candidates: tuple[TradeCandidate, ...], + state: TradingIntelligenceState, + ) -> tuple[TradeCandidate, ...]: + """Bind candidates to one selected scenario before risk arbitration.""" + primary = state.selected_scenario + if primary is None or state.blockers: + blockers = tuple( + dict.fromkeys( + ( + "TRADING_INTELLIGENCE_NO_SELECTED_SCENARIO", + *state.blockers, + ) + ) + ) + return tuple( + replace( + candidate, + status=CandidateStatus.RESEARCH_ONLY, + blockers=tuple(dict.fromkeys((*candidate.blockers, *blockers))), + ) + for candidate in candidates + ) + bound: list[TradeCandidate] = [] + for candidate in candidates: + if ( + candidate.snapshot_id != state.snapshot_id + or candidate.symbol != state.symbol + or candidate.timestamp != state.timestamp + or candidate.market_type != state.market_type + ): + bound.append( + replace( + candidate, + status=CandidateStatus.RESEARCH_ONLY, + blockers=tuple( + dict.fromkeys( + ( + *candidate.blockers, + "SCENARIO_CANDIDATE_IDENTITY_MISMATCH", + ) + ) + ), + ) + ) + continue + direction = ( + ScenarioDirection.LONG + if candidate.action is Action.BUY + else ScenarioDirection.SHORT + ) + if direction is not primary.direction: + bound.append( + replace( + candidate, + status=CandidateStatus.RESEARCH_ONLY, + blockers=tuple( + dict.fromkeys( + ( + *candidate.blockers, + "SCENARIO_DIRECTION_NOT_SELECTED", + ) + ) + ), + ) + ) + continue + bound.append(self._bind_scenario(candidate, primary, state)) + return tuple(bound) + + def _bind_scenario( + self, + candidate: TradeCandidate, + primary: ScenarioHypothesis, + state: TradingIntelligenceState, + ) -> TradeCandidate: + """Bind scenario economics without changing strategy-owned target geometry.""" + status = candidate.status + candidate_blockers = list(candidate.blockers) + if ( + primary.state is ScenarioState.CONFIRMED + and f"ENTRY_TRIGGER_TIMEFRAME:{candidate.timeframe}" + not in primary.evidence_for + ): + status = CandidateStatus.RESEARCH_ONLY + candidate_blockers.append("ENTRY_TRIGGER_TIMEFRAME_MISMATCH") + if primary.state is ScenarioState.FORMING: + if status is CandidateStatus.READY_FOR_RISK: + status = CandidateStatus.WAIT_FOR_RETEST + candidate_blockers.append("SCENARIO_CONFIRMATION_PENDING") + elif primary.state is not ScenarioState.CONFIRMED: + status = CandidateStatus.RESEARCH_ONLY + candidate_blockers.extend(("SCENARIO_NOT_CONFIRMED", *primary.blockers)) + net_risk_reward = self._net_risk_reward( + candidate, + state.estimated_round_trip_cost_ratio, + ) + if net_risk_reward is None: + status = CandidateStatus.RESEARCH_ONLY + candidate_blockers.extend( + (*state.cost_blockers, "NET_RISK_REWARD_UNAVAILABLE") + ) + invalidation = primary.invalidation_level + valid_invalidation = invalidation is not None and ( + ZERO < invalidation < candidate.entry_zone.lower + if candidate.action is Action.BUY + else invalidation > candidate.entry_zone.upper + ) + structural_rr = None + if valid_invalidation and invalidation is not None: + structural_rr = abs( + candidate.take_profit_levels[0] - candidate.entry_price + ) / abs(candidate.entry_price - invalidation) + else: + status = CandidateStatus.RESEARCH_ONLY + candidate_blockers.append("SCENARIO_INVALIDATION_UNAVAILABLE_OR_INVALID") + if primary.blockers and primary.state is ScenarioState.CONFIRMED: + status = CandidateStatus.RESEARCH_ONLY + candidate_blockers.extend(primary.blockers) + if primary.state is ScenarioState.CONFIRMED and primary.confidence <= 0: + status = CandidateStatus.RESEARCH_ONLY + candidate_blockers.append("SCENARIO_CONFIDENCE_UNAVAILABLE") + expiry = self._entry_expiry(candidate.timestamp, candidate.timeframe) + if expiry is None: + status = CandidateStatus.RESEARCH_ONLY + candidate_blockers.append("ENTRY_EXPIRY_UNAVAILABLE") + return replace( + candidate, + status=status, + scenario_id=primary.scenario_id, + scenario_type=primary.scenario_type.value, + scenario_state=primary.state.value, + scenario_invalidation=( + str(primary.invalidation_level) + if primary.invalidation_level is not None + else None + ), + structural_risk_reward=structural_rr, + net_risk_reward=net_risk_reward, + estimated_round_trip_cost_ratio=(state.estimated_round_trip_cost_ratio), + expected_r=None, + probability_calibration_state=primary.calibration_state.value, + entry_trigger=self._entry_trigger(primary), + entry_state=( + "ENTRY_VALID" + if status is CandidateStatus.READY_FOR_RISK and not candidate_blockers + else "ENTRY_NOT_READY" + ), + entry_expiry=expiry, + target_sources=candidate.target_sources, + evidence=tuple( + dict.fromkeys( + ( + *candidate.evidence, + f"SCENARIO_ID:{primary.scenario_id}", + f"SCENARIO_TYPE:{primary.scenario_type.value}", + ) + ) + ), + blockers=tuple(dict.fromkeys(candidate_blockers)), + ) + + @classmethod + def _cost_model( + cls, + snapshot: MarketSnapshot, + derivatives: DerivativesContextEvidence, + ) -> tuple[Decimal | None, tuple[str, ...]]: + blockers: list[str] = [] + latest_price = snapshot.latest_price + if latest_price is None or latest_price <= ZERO or snapshot.spread is None: + blockers.append("COST_SPREAD_EVIDENCE_UNAVAILABLE") + spread_ratio = None + else: + spread_ratio = snapshot.spread / latest_price + fee_ratio = cls._decimal( + snapshot.market_metadata.get( + "estimated_fee_ratio", snapshot.market_metadata.get("fee_ratio") + ) + ) + slippage_ratio = cls._decimal( + snapshot.market_metadata.get("estimated_slippage_ratio") + ) + if fee_ratio is None or fee_ratio < ZERO: + blockers.append("COST_FEE_EVIDENCE_UNAVAILABLE") + if slippage_ratio is None or slippage_ratio < ZERO: + blockers.append("COST_SLIPPAGE_EVIDENCE_UNAVAILABLE") + funding_ratio = ZERO + if snapshot.market_type == "USD_M_FUTURES": + if derivatives.funding_rate is None: + blockers.append("COST_FUNDING_EVIDENCE_UNAVAILABLE") + else: + periods = cls._integer( + snapshot.market_metadata.get("estimated_funding_periods") + ) + if periods is None or periods < 0: + blockers.append("COST_FUNDING_HORIZON_UNAVAILABLE") + else: + funding_ratio = abs(derivatives.funding_rate) * periods + if blockers: + return None, tuple(dict.fromkeys(blockers)) + if spread_ratio is None or fee_ratio is None or slippage_ratio is None: + return None, ("COST_MODEL_INPUT_REVALIDATION_FAILED",) + round_trip = spread_ratio + (fee_ratio * 2) + (slippage_ratio * 2) + return round_trip + funding_ratio, () + + @staticmethod + def _net_risk_reward( + candidate: TradeCandidate, + cost_ratio: Decimal | None, + ) -> Decimal | None: + if cost_ratio is None: + return None + entry = candidate.entry_price + risk = ( + max( + abs(entry - candidate.invalidation_level), + abs(entry - candidate.stop_loss), + ) + + entry * cost_ratio + ) + gross_reward = abs(candidate.take_profit_levels[0] - entry) + net_reward = gross_reward - (entry * cost_ratio) + if risk <= ZERO or net_reward <= ZERO: + return None + return cast(Decimal, net_reward / risk) + + @staticmethod + def _identity_blockers( + snapshot: MarketSnapshot, + agent_results: Mapping[str, AgentResult], + ) -> tuple[str, ...]: + return tuple( + f"TRADING_INTELLIGENCE_IDENTITY_MISMATCH:{name}" + for name, result in sorted(agent_results.items()) + if result.snapshot_id != snapshot.snapshot_id + or result.symbol != snapshot.symbol + or result.timestamp != snapshot.created_at + or result.agent_name != name + ) + + def _structures( + self, + snapshot: MarketSnapshot, + agent_results: Mapping[str, AgentResult], + ) -> tuple[TimeframeStructureEvidence, ...]: + result = agent_results.get("market_structure") + if result is None or not is_usable_agent_result(result): + return () + raw_timeframes = self._mapping(result.calculation_metadata.get("timeframes")) + structures: list[TimeframeStructureEvidence] = [] + for timeframe in STRUCTURE_PRIORITY: + if timeframe not in snapshot.timeframes: + continue + raw = self._mapping(raw_timeframes.get(timeframe)) + if not raw: + continue + state = self._structure_state(raw.get("structure_state")) + if state is None: + state = self._legacy_structure_state(raw.get("structure")) + low = self._decimal(raw.get("range_low") or raw.get("recent_low")) + high = self._decimal(raw.get("range_high") or raw.get("recent_high")) + if ( + state is None + or low is None + or high is None + or low <= ZERO + or high < low + ): + continue + invalidation = self._decimal(raw.get("invalidation_level")) + confidence = self._float(raw.get("structure_confidence")) + swings = self._swings(raw.get("swings"), timeframe) + events = self._events(raw.get("events")) + if not self._structure_projection_is_valid(snapshot, raw, swings, events): + continue + structures.append( + TimeframeStructureEvidence( + timeframe=timeframe, + state=state, + method=str(raw.get("structure_method", "WINDOWED_STRUCTURE")), + range_low=low, + range_high=high, + invalidation_level=invalidation, + confidence=( + max(0.0, min(1.0, confidence)) + if confidence is not None + else result.confidence + ), + swings=swings, + events=events, + reason_codes=("MARKET_STRUCTURE_AGENT_PROJECTION",), + warnings=self._string_tuple(raw.get("structure_warnings")), + ) + ) + return tuple(structures) + + @classmethod + def _structure_projection_is_valid( + cls, + snapshot: MarketSnapshot, + raw: Mapping[str, object], + swings: tuple[ConfirmedSwing, ...], + events: tuple[StructureEvent, ...], + ) -> bool: + confidence = cls._float(raw.get("structure_confidence")) + invalidation = cls._decimal(raw.get("invalidation_level")) + raw_swings, raw_events = raw.get("swings", ()), raw.get("events", ()) + return ( + (confidence is None or 0.0 <= confidence <= 1.0) + and (invalidation is None or invalidation > ZERO) + and isinstance(raw_swings, (tuple, list)) + and len(swings) == len(raw_swings) + and isinstance(raw_events, (tuple, list)) + and len(events) == len(raw_events) + and all(swing.available_at <= snapshot.created_at for swing in swings) + and all(event.occurred_at <= snapshot.created_at for event in events) + ) + + def _levels( + self, + snapshot: MarketSnapshot, + agent_results: Mapping[str, AgentResult], + structures: tuple[TimeframeStructureEvidence, ...], + ) -> tuple[StructuralLevelEvidence, ...]: + result = agent_results.get("support_resistance") + return self.level_map_engine.build(snapshot, structures, result) + + def _trend_geometry( + self, + snapshot: MarketSnapshot, + agent_results: Mapping[str, AgentResult], + structures: tuple[TimeframeStructureEvidence, ...], + ) -> tuple[TrendGeometryEvidence, ...]: + result = agent_results.get("trend_channel") + return self.trend_geometry_engine.build(snapshot, structures, result) + + def _patterns( + self, + snapshot: MarketSnapshot, + agent_results: Mapping[str, AgentResult], + ) -> tuple[PatternHypothesisEvidence, ...]: + return self.pattern_hypothesis_fabric.build(snapshot, agent_results) + + def _derivatives( + self, + snapshot: MarketSnapshot, + agent_results: Mapping[str, AgentResult], + ) -> DerivativesContextEvidence: + result = agent_results.get("derivatives") + return self.futures_context_engine.build(snapshot, result) + + def _scenarios( + self, + snapshot: MarketSnapshot, + agent_results: Mapping[str, AgentResult], + structures: tuple[TimeframeStructureEvidence, ...], + levels: tuple[StructuralLevelEvidence, ...], + trend_geometry: tuple[TrendGeometryEvidence, ...], + patterns: tuple[PatternHypothesisEvidence, ...], + derivatives: DerivativesContextEvidence, + ) -> tuple[ + tuple[ScenarioHypothesis, ...], + str | None, + tuple[str, ...], + tuple[str, ...], + ]: + by_timeframe = {item.timeframe: item for item in structures} + macro = by_timeframe.get("1d") + directional = by_timeframe.get("4h") + setup = by_timeframe.get("1h") + mtf = agent_results.get("multi_timeframe") + data_quality = agent_results.get("data_quality") + regime_result = agent_results.get("market_regime") + regime = self._regime(regime_result) + if self._data_confidence(snapshot, data_quality) <= 0: + unavailable = ("SCENARIO_DATA_QUALITY_UNAVAILABLE",) + return ( + (self._no_valid_scenario(snapshot, unavailable, regime),), + None, + unavailable, + (), + ) + if directional is None: + unavailable = ("DIRECTION_STRUCTURE_UNAVAILABLE",) + scenario = self._no_valid_scenario(snapshot, unavailable, regime) + return (scenario,), None, unavailable, () + direction = self._structure_direction(directional.state) + if direction is ScenarioDirection.NEUTRAL: + scenario = self._neutral_scenario( + snapshot, + directional, + regime, + mtf, + data_quality, + derivatives, + ) + return (scenario,), None, scenario.blockers, () + blockers = list(self._hierarchy_blockers(direction, macro, setup, mtf)) + if self._data_confidence(snapshot, data_quality) <= 0: + blockers.append("SCENARIO_DATA_QUALITY_UNAVAILABLE") + if regime == "UNKNOWN": + blockers.append("SCENARIO_REGIME_UNAVAILABLE") + if ( + len( + { + self._direction(item.directional_vote) + for item in self._triggers(agent_results) + } + ) + > 1 + ): + blockers.append("ENTRY_TRIGGER_CONFLICT") + trigger = self._trigger(agent_results) + trigger_direction = ( + self._direction(trigger.directional_vote) + if trigger is not None + else ScenarioDirection.NEUTRAL + ) + scenario_state, scenario_blockers, trigger_blockers = self._trigger_state( + direction, + trigger_direction, + derivatives, + ) + blockers.extend(trigger_blockers) + if scenario_state is ScenarioState.CONFIRMED and ( + directional.invalidation_level is None or directional.confidence <= 0 + ): + blockers.append("SCENARIO_STRUCTURAL_EVIDENCE_INCOMPLETE") + if blockers: + scenario_state = ScenarioState.BLOCKED + scenario_blockers = tuple(dict.fromkeys((*scenario_blockers, *blockers))) + continuation_type = ( + ScenarioType.LONG_CONTINUATION + if direction is ScenarioDirection.LONG + else ScenarioType.SHORT_CONTINUATION + ) + evidence_for = self._continuation_evidence( + directional, + regime, + direction, + levels, + trend_geometry, + patterns, + derivatives, + mtf, + trigger, + trigger_direction, + ) + primary = self._scenario( + snapshot=snapshot, + scenario_type=continuation_type, + direction=direction, + state=scenario_state, + structure=directional, + regime=regime, + mtf_confidence=mtf.confidence if mtf is not None else 0.0, + trigger_confidence=trigger.confidence if trigger is not None else 0.0, + data_confidence=self._data_confidence(snapshot, data_quality), + context_confidence=( + derivatives.confidence + if derivatives.status == "AVAILABLE" + else 1.0 + if derivatives.status == "NOT_APPLICABLE" + else 0.0 + ), + evidence_for=evidence_for, + evidence_against=tuple(dict.fromkeys(blockers)), + blockers=scenario_blockers, + ) + reversal = self._reversal_scenario( + snapshot, + directional, + regime, + direction, + trigger, + trigger_direction, + ) + scenarios = (primary,) if reversal is None else (primary, reversal) + if ( + reversal is not None + and abs(primary.confidence - reversal.confidence) + < self.minimum_scenario_separation + ): + blockers.append("SCENARIO_SEPARATION_INSUFFICIENT") + selected_id = None if blockers else primary.scenario_id + return ( + scenarios, + selected_id, + tuple(dict.fromkeys(blockers)), + (), + ) + + def _neutral_scenario( + self, + snapshot: MarketSnapshot, + structure: TimeframeStructureEvidence, + regime: str, + mtf: AgentResult | None, + data_quality: AgentResult | None, + derivatives: DerivativesContextEvidence, + ) -> ScenarioHypothesis: + scenario_type = ( + ScenarioType.BREAKOUT_PENDING + if "COMPRESSION" in regime + else ScenarioType.RANGE_MEAN_REVERSION + ) + return self._scenario( + snapshot=snapshot, + scenario_type=scenario_type, + direction=ScenarioDirection.NEUTRAL, + state=ScenarioState.FORMING, + structure=structure, + regime=regime, + mtf_confidence=mtf.confidence if mtf is not None else 0.0, + trigger_confidence=0.0, + data_confidence=self._data_confidence(snapshot, data_quality), + context_confidence=( + derivatives.confidence + if derivatives.status == "AVAILABLE" + else 1.0 + if derivatives.status == "NOT_APPLICABLE" + else 0.0 + ), + evidence_for=( + f"STRUCTURE:{structure.timeframe}:{structure.state.value}", + f"REGIME:{regime}", + ), + blockers=("DIRECTIONAL_STRUCTURE_UNRESOLVED",), + ) + + @staticmethod + def _hierarchy_blockers( + direction: ScenarioDirection, + macro: TimeframeStructureEvidence | None, + setup: TimeframeStructureEvidence | None, + mtf: AgentResult | None, + ) -> tuple[str, ...]: + opposite = ( + StructureState.BEARISH + if direction is ScenarioDirection.LONG + else StructureState.BULLISH + ) + blockers: list[str] = [] + if macro is None or macro.state is StructureState.UNCERTAIN: + blockers.append("MACRO_STRUCTURE_UNAVAILABLE") + if setup is None or setup.state is StructureState.UNCERTAIN: + blockers.append("SETUP_STRUCTURE_UNAVAILABLE") + if macro is not None and macro.state is opposite: + blockers.append("MACRO_STRUCTURE_CONFLICT") + if setup is not None and setup.state is opposite: + blockers.append("SETUP_STRUCTURE_CONFLICT") + if mtf is None or not is_usable_agent_result(mtf): + blockers.append("MULTI_TIMEFRAME_EVIDENCE_UNAVAILABLE") + elif mtf.calculation_metadata.get("conflict") is True: + blockers.append("MULTI_TIMEFRAME_DIRECTION_CONFLICT") + return tuple(blockers) + + @staticmethod + def _trigger_state( + direction: ScenarioDirection, + trigger_direction: ScenarioDirection, + derivatives: DerivativesContextEvidence, + ) -> tuple[ScenarioState, tuple[str, ...], tuple[str, ...]]: + state = ScenarioState.FORMING + scenario_blockers: list[str] = [] + global_blockers: list[str] = [] + if trigger_direction is ScenarioDirection.NEUTRAL: + scenario_blockers.append("ENTRY_TRIGGER_MISSING") + elif trigger_direction is not direction: + global_blockers.append("TRIGGER_DIRECTION_CONFLICT") + scenario_blockers.append("TRIGGER_DIRECTION_CONFLICT") + state = ScenarioState.BLOCKED + else: + state = ScenarioState.CONFIRMED + if derivatives.status == "BLOCKED": + state = ScenarioState.BLOCKED + scenario_blockers.extend(derivatives.blockers) + return ( + state, + tuple(dict.fromkeys(scenario_blockers)), + tuple(global_blockers), + ) + + @staticmethod + def _continuation_evidence( + structure: TimeframeStructureEvidence, + regime: str, + direction: ScenarioDirection, + levels: tuple[StructuralLevelEvidence, ...], + trend_geometry: tuple[TrendGeometryEvidence, ...], + patterns: tuple[PatternHypothesisEvidence, ...], + derivatives: DerivativesContextEvidence, + mtf: AgentResult | None, + trigger: AgentResult | None, + trigger_direction: ScenarioDirection, + ) -> tuple[str, ...]: + evidence = [ + f"STRUCTURE:{structure.timeframe}:{structure.state.value}", + f"REGIME:{regime}", + ] + evidence.extend( + item.evidence_ref + for item in levels + if item.source_timeframe == structure.timeframe + and item.freshness != "STALE" + ) + if derivatives.status == "AVAILABLE": + evidence.extend(derivatives.evidence_refs) + evidence.extend( + item.evidence_ref + for item in trend_geometry + if item.source_timeframe == structure.timeframe and not item.blockers + ) + if mtf is not None: + evidence.extend(mtf.evidence or ("MULTI_TIMEFRAME_EVALUATED",)) + evidence.extend( + item.hypothesis_id + for item in patterns + if item.direction in {direction, ScenarioDirection.NEUTRAL} + and item.lifecycle_state in {"CONFIRMED", "CONTEXT_ONLY"} + ) + if trigger is not None and trigger_direction is direction: + evidence.extend(trigger.evidence or ("ENTRY_TRIGGER_ALIGNED",)) + evidence.extend( + f"ENTRY_TRIGGER_TIMEFRAME:{timeframe}" + for timeframe in trigger.timeframes + ) + return tuple(dict.fromkeys(evidence)) + + def _reversal_scenario( + self, + snapshot: MarketSnapshot, + structure: TimeframeStructureEvidence, + regime: str, + direction: ScenarioDirection, + trigger: AgentResult | None, + trigger_direction: ScenarioDirection, + ) -> ScenarioHypothesis | None: + if trigger is None or trigger_direction in { + direction, + ScenarioDirection.NEUTRAL, + }: + return None + reversal_type = ( + ScenarioType.LONG_REVERSAL + if trigger_direction is ScenarioDirection.LONG + else ScenarioType.SHORT_REVERSAL + ) + return self._scenario( + snapshot=snapshot, + scenario_type=reversal_type, + direction=trigger_direction, + state=ScenarioState.FORMING, + structure=structure, + regime=regime, + mtf_confidence=0.0, + trigger_confidence=trigger.confidence, + evidence_for=trigger.evidence or ("COUNTER_TREND_TRIGGER",), + evidence_against=("STRUCTURE_REVERSAL_NOT_CONFIRMED",), + blockers=("STRUCTURE_REVERSAL_NOT_CONFIRMED",), + ) + + def _scenario( + self, + *, + snapshot: MarketSnapshot, + scenario_type: ScenarioType, + direction: ScenarioDirection, + state: ScenarioState, + structure: TimeframeStructureEvidence, + regime: str, + mtf_confidence: float, + trigger_confidence: float, + evidence_for: tuple[str, ...], + data_confidence: float = 1.0, + context_confidence: float = 1.0, + evidence_against: tuple[str, ...] = (), + blockers: tuple[str, ...] = (), + ) -> ScenarioHypothesis: + components = ( + ConfidenceComponent("structure", structure.confidence), + ConfidenceComponent("multi_timeframe", max(0.0, mtf_confidence)), + ConfidenceComponent("entry_trigger", max(0.0, trigger_confidence)), + ConfidenceComponent("data_quality", max(0.0, data_confidence)), + ConfidenceComponent("futures_context", max(0.0, context_confidence)), + ) + payload = ( + f"{snapshot.snapshot_id}|{scenario_type.value}|{direction.value}|" + f"{structure.timeframe}|{structure.state.value}" + ) + digest = sha256(payload.encode("utf-8")).hexdigest()[:20] + return ScenarioHypothesis( + scenario_id=f"scenario:{digest}", + snapshot_id=snapshot.snapshot_id, + scenario_type=scenario_type, + direction=direction, + state=state, + structure_state=structure.state, + regime=regime, + invalidation_level=structure.invalidation_level, + confidence=min(item.value for item in components), + confidence_components=components, + evidence_for=evidence_for, + evidence_against=evidence_against, + blockers=blockers, + calibration_state=CalibrationState.NOT_CALIBRATED, + ) + + def _no_valid_scenario( + self, + snapshot: MarketSnapshot, + blockers: tuple[str, ...], + regime: str = "UNKNOWN", + ) -> ScenarioHypothesis: + components = ( + ConfidenceComponent("structure", 0.0), + ConfidenceComponent("multi_timeframe", 0.0), + ConfidenceComponent("entry_trigger", 0.0), + ConfidenceComponent("data_quality", 0.0), + ConfidenceComponent("futures_context", 0.0), + ) + digest = sha256(f"{snapshot.snapshot_id}|NO_VALID_SETUP".encode()).hexdigest()[ + :20 + ] + return ScenarioHypothesis( + scenario_id=f"scenario:{digest}", + snapshot_id=snapshot.snapshot_id, + scenario_type=ScenarioType.NO_VALID_SETUP, + direction=ScenarioDirection.NEUTRAL, + state=ScenarioState.NO_VALID_SETUP, + structure_state=StructureState.UNCERTAIN, + regime=regime, + invalidation_level=None, + confidence=0.0, + confidence_components=components, + evidence_for=("NO_DIRECTIONAL_STRUCTURE",), + evidence_against=blockers, + blockers=blockers, + ) + + @staticmethod + def _trigger( + agent_results: Mapping[str, AgentResult], + ) -> AgentResult | None: + return next(iter(TradingIntelligenceEngine._triggers(agent_results)), None) + + @staticmethod + def _triggers(agent_results: Mapping[str, AgentResult]) -> tuple[AgentResult, ...]: + triggers: list[AgentResult] = [] + for name in ("price_action", "candlestick", "breakout_retest"): + result = agent_results.get(name) + if ( + result is None + or not is_usable_agent_result(result) + or result.blockers + or result.confidence <= 0 + or result.calculation_metadata.get("lifecycle_state", "CONFIRMED") + != "CONFIRMED" + ): + continue + if name == "price_action": + for evidence in result.evidence: + pattern, _, timeframe = evidence.partition(":") + if timeframe in {"15m", "5m"} and pattern in { + "BULLISH_ENGULFING", + "BEARISH_ENGULFING", + }: + triggers.append( + replace( + result, + timeframes=(timeframe,), + directional_vote=1.0 + if pattern == "BULLISH_ENGULFING" + else -1.0, + evidence=(evidence,), + ) + ) + elif ( + result.calculation_metadata.get("source_timeframe") in {"15m", "5m"} + and abs(result.directional_vote) > 0 + ): + triggers.append( + replace( + result, + timeframes=( + str(result.calculation_metadata["source_timeframe"]), + ), + ) + ) + return tuple(triggers) + + @staticmethod + def _regime(result: AgentResult | None) -> str: + if result is None or not is_usable_agent_result(result): + return "UNKNOWN" + value = result.calculation_metadata.get("regime") + return value if isinstance(value, str) and value.strip() else "UNKNOWN" + + @staticmethod + def _data_confidence( + snapshot: MarketSnapshot, + result: AgentResult | None, + ) -> float: + if result is not None: + return ( + result.confidence + if is_usable_agent_result(result) and not result.blockers + else 0.0 + ) + if snapshot.data_quality is DataQuality.DATA_VALID: + return 1.0 + if snapshot.data_quality is DataQuality.DATA_DEGRADED: + return 0.5 + return 0.0 + + @staticmethod + def _entry_trigger(scenario: ScenarioHypothesis) -> str: + return next( + ( + item + for item in scenario.evidence_for + if any( + marker in item + for marker in ("ENGULFING", "BREAKOUT", "RETEST", "TRIGGER") + ) + ), + "SCENARIO_CONFIRMATION", + ) + + @staticmethod + def _entry_expiry(timestamp: datetime, timeframe: str) -> datetime | None: + unit = timeframe[-1:].lower() + raw_value = timeframe[:-1] + if not raw_value.isdigit(): + return None + if len(raw_value) > 6: + return None + value = int(raw_value) + if value < 1: + return None + scale = {"m": 60, "h": 3600, "d": 86400}.get(unit) + try: + return timestamp + timedelta(seconds=value * scale) if scale else None + except OverflowError: + return None + + @staticmethod + def _structure_direction(state: StructureState) -> ScenarioDirection: + if state is StructureState.BULLISH: + return ScenarioDirection.LONG + if state is StructureState.BEARISH: + return ScenarioDirection.SHORT + return ScenarioDirection.NEUTRAL + + @staticmethod + def _direction(vote: float) -> ScenarioDirection: + if vote > 0.0: + return ScenarioDirection.LONG + if vote < 0.0: + return ScenarioDirection.SHORT + return ScenarioDirection.NEUTRAL + + @staticmethod + def _structure_state(value: object) -> StructureState | None: + if not isinstance(value, str): + return None + try: + return StructureState(value) + except ValueError: + return None + + @staticmethod + def _legacy_structure_state(value: object) -> StructureState | None: + return ( + { + "HH_HL": StructureState.BULLISH, + "LH_LL": StructureState.BEARISH, + "MIXED": StructureState.TRANSITION, + }.get(value) + if isinstance(value, str) + else None + ) + + @classmethod + def _swings( + cls, + value: object, + timeframe: str, + ) -> tuple[ConfirmedSwing, ...]: + if not isinstance(value, (tuple, list)): + return () + swings: list[ConfirmedSwing] = [] + for item in value: + raw = cls._mapping(item) + kind_value = raw.get("kind") + index = cls._integer(raw.get("candle_index")) + occurred_at = cls._datetime(raw.get("occurred_at")) + available_at = cls._datetime(raw.get("available_at")) + price = cls._decimal(raw.get("price")) + label = raw.get("label") + significance = cls._decimal(raw.get("atr_significance")) + try: + kind = SwingKind(kind_value) if isinstance(kind_value, str) else None + except ValueError: + kind = None + if ( + kind is None + or index is None + or occurred_at is None + or available_at is None + or price is None + or not isinstance(label, str) + or significance is None + ): + continue + try: + swings.append( + ConfirmedSwing( + timeframe=timeframe, + kind=kind, + candle_index=index, + occurred_at=occurred_at, + available_at=available_at, + price=price, + label=label, + atr_significance=significance, + ) + ) + except ValueError: + continue + return tuple(swings) + + @classmethod + def _events(cls, value: object) -> tuple[StructureEvent, ...]: + if not isinstance(value, (tuple, list)): + return () + events: list[StructureEvent] = [] + for item in value: + raw = cls._mapping(item) + event_type = raw.get("event_type") + direction_value = raw.get("direction") + level = cls._decimal(raw.get("level")) + occurred_at = cls._datetime(raw.get("occurred_at")) + evidence_ref = raw.get("evidence_ref") + try: + direction = ( + ScenarioDirection(direction_value) + if isinstance(direction_value, str) + else None + ) + except ValueError: + direction = None + if ( + not isinstance(event_type, str) + or direction is None + or level is None + or occurred_at is None + or not isinstance(evidence_ref, str) + ): + continue + try: + events.append( + StructureEvent( + event_type=event_type, + direction=direction, + level=level, + occurred_at=occurred_at, + evidence_ref=evidence_ref, + ) + ) + except ValueError: + continue + return tuple(events) + + @staticmethod + def _mapping(value: object) -> Mapping[str, object]: + return value if isinstance(value, Mapping) else {} + + @staticmethod + def _decimal(value: object) -> Decimal | None: + if value is None or isinstance(value, bool): + return None + if not isinstance(value, (str, int, float, Decimal)): + return None + try: + parsed = Decimal(str(value)) + except InvalidOperation: + return None + return parsed if parsed.is_finite() else None + + @staticmethod + def _float(value: object) -> float | None: + decimal = TradingIntelligenceEngine._decimal(value) + return float(decimal) if decimal is not None else None + + @staticmethod + def _integer(value: object) -> int | None: + return value if isinstance(value, int) and not isinstance(value, bool) else None + + @staticmethod + def _datetime(value: object) -> datetime | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is None or parsed.utcoffset() is None: + return None + return parsed + + @staticmethod + def _string_tuple(value: object) -> tuple[str, ...]: + if not isinstance(value, (tuple, list)): + return () + return tuple(item for item in value if isinstance(item, str) and item.strip()) diff --git a/src/ai4binance/intelligence/trend.py b/src/ai4binance/intelligence/trend.py new file mode 100644 index 00000000..3235a110 --- /dev/null +++ b/src/ai4binance/intelligence/trend.py @@ -0,0 +1,205 @@ +"""Deterministic dynamic trend-zone projection from canonical evidence.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal, InvalidOperation + +from ai4binance.indicators import atr +from ai4binance.intelligence.contracts import ( + TimeframeStructureEvidence, + TrendGeometryEvidence, + TrendGeometryState, +) +from ai4binance.schemas import ( + AgentResult, + MarketSnapshot, + OHLCVCandle, + is_usable_agent_result, +) + +ZERO = Decimal("0") +TIMEFRAME_PRIORITY = ("1d", "4h", "1h", "15m", "5m") + + +@dataclass(frozen=True, slots=True) +class TrendGeometryEngine: + """Build one auditable dynamic zone without becoming direction authority.""" + + window_bars: int = 30 + + def __post_init__(self) -> None: + if self.window_bars < 20: + raise ValueError("trend geometry window must be at least 20 bars") + + def build( + self, + snapshot: MarketSnapshot, + structures: tuple[TimeframeStructureEvidence, ...], + result: AgentResult | None, + ) -> tuple[TrendGeometryEvidence, ...]: + if result is None or not is_usable_agent_result(result) or result.blockers: + return () + if ( + result.snapshot_id != snapshot.snapshot_id + or result.timestamp != snapshot.created_at + or result.symbol != snapshot.symbol + or result.agent_name != "trend_channel" + ): + return () + timeframe = self._source_timeframe(snapshot, result.calculation_metadata) + if timeframe is None: + return () + candles = tuple(snapshot.ohlcv_by_timeframe.get(timeframe, ())) + if len(candles) < self.window_bars: + return () + recent = candles[-self.window_bars :] + # Hold out both lifecycle bars so a break cannot refit its own baseline. + training = recent[:-2] + slope = (training[-1].close - training[0].close) / Decimal(len(training) - 1) + volatility = atr(training, 14) + width = volatility + if width <= ZERO: + return () + intercept = recent[0].close + errors = tuple( + abs(candle.close - (intercept + slope * Decimal(index))) + for index, candle in enumerate(training) + ) + mean_error = sum(errors, ZERO) / Decimal(len(errors)) + normalized_error = mean_error / volatility if volatility > ZERO else ZERO + touch_count = sum(1 for error in errors if error <= width) + state, break_state, retest_state = self._lifecycle( + recent[-2].close, + recent[-1].close, + intercept + slope * Decimal(len(recent) - 2), + intercept + slope * Decimal(len(recent) - 1), + width, + slope, + ) + anchors = self._anchors(training, None) + confidence = min(result.confidence, self._fit_confidence(normalized_error)) + return ( + TrendGeometryEvidence( + source_timeframe=timeframe, + slope=slope, + channel_width=width, + state=state.value, + touch_quality=self._touch_quality(touch_count, len(training)), + evidence_ref="trend_channel:DYNAMIC_ZONE", + confidence=confidence, + anchor_points=anchors, + intercept=intercept, + touch_count=touch_count, + atr_normalized_error=normalized_error, + age_bars=0, + break_state=break_state, + retest_state=retest_state, + compression_state=self._compression_state(recent), + acceleration_state=self._acceleration_state(recent), + ), + ) + + @staticmethod + def _source_timeframe( + snapshot: MarketSnapshot, + metadata: Mapping[str, object], + ) -> str | None: + explicit = metadata.get("source_timeframe") + if ( + isinstance(explicit, str) + and explicit in snapshot.timeframes + and len(snapshot.ohlcv_by_timeframe.get(explicit, ())) >= 30 + ): + return explicit + return next( + ( + timeframe + for timeframe in TIMEFRAME_PRIORITY + if len(snapshot.ohlcv_by_timeframe.get(timeframe, ())) >= 55 + ), + None, + ) + + @staticmethod + def _lifecycle( + previous_close: Decimal, + current_close: Decimal, + previous_center: Decimal, + current_center: Decimal, + width: Decimal, + slope: Decimal, + ) -> tuple[TrendGeometryState, str, str]: + previous_outside = abs(previous_close - previous_center) > width + current_outside = abs(current_close - current_center) > width + if previous_outside and not current_outside: + return TrendGeometryState.RETEST, "FALSE_BREAK", "RETEST_CONFIRMED" + if current_outside: + return TrendGeometryState.BREAK, "BROKEN", "NOT_RETESTED" + if slope == ZERO: + return TrendGeometryState.SIDEWAYS, "UNBROKEN", "NOT_RETESTED" + if abs(current_close - current_center) > width * Decimal("0.75"): + return TrendGeometryState.WEAKENING, "UNBROKEN", "NOT_RETESTED" + return TrendGeometryState.VALID, "UNBROKEN", "NOT_RETESTED" + + @staticmethod + def _anchors( + candles: tuple[OHLCVCandle, ...], + structure: TimeframeStructureEvidence | None, + ) -> tuple[tuple[datetime, Decimal], ...]: + if structure is not None and len(structure.swings) >= 2: + return tuple( + (swing.occurred_at, swing.price) for swing in structure.swings[-2:] + ) + return ( + (candles[0].timestamp, candles[0].close), + (candles[-1].timestamp, candles[-1].close), + ) + + @staticmethod + def _touch_quality(touch_count: int, total: int) -> str: + ratio = touch_count / total + return "HIGH" if ratio >= 0.7 else "MEDIUM" if ratio >= 0.4 else "LOW" + + @staticmethod + def _fit_confidence(normalized_error: Decimal) -> float: + return float(max(ZERO, min(Decimal("1"), Decimal("1") - normalized_error))) + + @staticmethod + def _compression_state(candles: tuple[OHLCVCandle, ...]) -> str: + midpoint = len(candles) // 2 + first = atr(candles[:midpoint], min(14, midpoint - 1)) + second = atr(candles[midpoint:], min(14, len(candles[midpoint:]) - 1)) + if first <= ZERO: + return "NOT_MEASURED" + ratio = second / first + if ratio < Decimal("0.8"): + return "COMPRESSING" + if ratio > Decimal("1.2"): + return "EXPANDING" + return "STABLE" + + @staticmethod + def _acceleration_state(candles: tuple[OHLCVCandle, ...]) -> str: + midpoint = len(candles) // 2 + first_slope = (candles[midpoint - 1].close - candles[0].close) / Decimal( + midpoint - 1 + ) + second_slope = (candles[-1].close - candles[midpoint].close) / Decimal( + len(candles) - midpoint - 1 + ) + if abs(second_slope) > abs(first_slope) * Decimal("1.5"): + return "ACCELERATING" + if abs(second_slope) * Decimal("1.5") < abs(first_slope): + return "DECELERATING" + return "STABLE" + + @staticmethod + def _decimal(value: object) -> Decimal | None: + if not isinstance(value, (str, int, float, Decimal)) or isinstance(value, bool): + return None + try: + parsed = Decimal(str(value)) + except InvalidOperation: + return None + return parsed if parsed.is_finite() else None diff --git a/src/ai4binance/ops/architecture_migration.py b/src/ai4binance/ops/architecture_migration.py index 1e9fe99c..4d8b3e53 100644 --- a/src/ai4binance/ops/architecture_migration.py +++ b/src/ai4binance/ops/architecture_migration.py @@ -503,6 +503,7 @@ def _legacy_package_migration_rule( "allocation": "domain/portfolio/allocation", "comparison": "domain/intelligence/comparison", "funding": "domain/portfolio/funding", + "intelligence": "domain/intelligence", "observability": "infrastructure/observability", "outlook": "domain/intelligence/outlook", "runtime_artifacts": "infrastructure/filesystem/runtime_artifacts", diff --git a/src/ai4binance/research/virtual_runtime.py b/src/ai4binance/research/virtual_runtime.py index cf352ff6..35408f0f 100644 --- a/src/ai4binance/research/virtual_runtime.py +++ b/src/ai4binance/research/virtual_runtime.py @@ -1298,18 +1298,60 @@ def _portfolio_governor_blockers( ): blockers.append("VIRTUAL_MAX_CONCURRENT_POSITIONS_EXCEEDED") if request.portfolio.market == "USD_M_FUTURES": - current_margin_utilization = ( - request.portfolio.margin_utilization_ratio or ZERO + blockers.extend( + VirtualMarketRuntime._futures_margin_blockers(request, fill_preview) ) - if current_margin_utilization > governor.maximum_margin_utilization_ratio: - blockers.append("FUTURES_MARGIN_UTILIZATION_LIMIT_EXCEEDED") - if ( - request.leverage is not None - and request.leverage > governor.maximum_futures_leverage - ): - blockers.append("FUTURES_LEVERAGE_LIMIT_EXCEEDED") return tuple(dict.fromkeys(blockers)) + @staticmethod + def _futures_margin_blockers( + request: VirtualRuntimeRequest, fill_preview: VirtualFillPreview + ) -> tuple[str, ...]: + portfolio = request.portfolio + current = portfolio.margin_utilization_ratio + if ( + current is None + and portfolio.open_position_count == 0 + and not portfolio.isolated_margin_usdt + ): + current = ZERO + projected = None + if ( + request.isolated_margin_usdt is not None + and request.funding_rate is not None + and request.mark_price is not None + ): + funding_cost = ( + abs(request.funding_rate) * fill_preview.gross_notional_usdt + if request.funding_payment_due + else ZERO + ) + mark_loss = ( + max( + ZERO, + (fill_preview.execution_price - request.mark_price) + * ( + ONE + if request.position_side is VirtualPositionSide.LONG + else -ONE + ), + ) + * fill_preview.filled_quantity + ) + equity_after_costs = ( + portfolio.equity_usdt - fill_preview.fee_usdt - funding_cost - mark_loss + ) + if equity_after_costs > ZERO: + committed = ( + portfolio.isolated_margin_usdt or ZERO + ) + request.isolated_margin_usdt + projected = committed / equity_after_costs + return request.portfolio_governor.futures_entry_blockers( + requested_leverage=request.leverage, + current_margin_utilization=current, + projected_margin_utilization=projected, + ) + def process_position( self, *, diff --git a/src/ai4binance/research/virtual_runtime_request.py b/src/ai4binance/research/virtual_runtime_request.py index 022088b7..fc26e939 100644 --- a/src/ai4binance/research/virtual_runtime_request.py +++ b/src/ai4binance/research/virtual_runtime_request.py @@ -81,6 +81,7 @@ class VirtualRuntimeRequest: entry_reason: tuple[str, ...] = ("VIRTUAL_MARKET_ENTRY",) def __post_init__(self) -> None: + self._validate_numeric_inputs() if not self.opportunity_id.strip(): object.__setattr__( self, @@ -195,6 +196,30 @@ def __post_init__(self) -> None: tuple(dict.fromkeys((*self.dge_blockers, DGE_SIMULATION_NOT_APPROVED))), ) + def _validate_numeric_inputs(self) -> None: + """Reject nonfinite values before arithmetic or comparisons.""" + numeric_values = ( + self.quantity, + self.entry_price, + self.stop_loss, + *self.take_profit_levels, + self.fee_ratio, + self.slippage_ratio, + self.half_spread_ratio, + self.tick_size, + self.step_size, + self.minimum_notional, + self.mark_price, + self.isolated_margin_usdt, + self.maintenance_margin_ratio, + ) + if any(value is not None and not value.is_finite() for value in numeric_values): + raise ValueError("virtual runtime request numeric values must be finite") + if self.leverage is not None and ( + not isinstance(self.leverage, int) or isinstance(self.leverage, bool) + ): + raise ValueError("virtual runtime leverage must be an integer") + def _require_unique_nonblank(name: str, values: tuple[str, ...]) -> None: if any(not value.strip() for value in values): diff --git a/src/ai4binance/research/virtual_runtime_risk.py b/src/ai4binance/research/virtual_runtime_risk.py index ca26fe6d..24a62dc1 100644 --- a/src/ai4binance/research/virtual_runtime_risk.py +++ b/src/ai4binance/research/virtual_runtime_risk.py @@ -3,7 +3,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from decimal import Decimal +from decimal import ROUND_CEILING, Decimal +from enum import StrEnum +from typing import cast from ai4binance.portfolio.risk_budget import PortfolioRiskPolicy @@ -11,6 +13,39 @@ ONE = Decimal("1") +class SimulatedLeverageState(StrEnum): + """Research-only leverage suitability outcomes.""" + + ELIGIBLE = "SIMULATED_LEVERAGE_ELIGIBLE" + REDUCED = "SIMULATED_LEVERAGE_REDUCED" + BLOCKED = "SIMULATED_LEVERAGE_BLOCKED" + + +@dataclass(frozen=True, slots=True) +class SimulatedLeverageAssessment: + """Non-executable leverage assessment without signal authority.""" + + state: SimulatedLeverageState + requested_leverage: int | None + permitted_leverage: int | None + blockers: tuple[str, ...] = () + execution_allowed: bool = False + + def __post_init__(self) -> None: + for field_name in ("requested_leverage", "permitted_leverage"): + value = getattr(self, field_name) + if value is not None and ( + not isinstance(value, int) or isinstance(value, bool) or value < 1 + ): + raise ValueError(f"{field_name} must be a positive integer") + if self.state is SimulatedLeverageState.BLOCKED and not self.blockers: + raise ValueError("blocked leverage assessment requires blockers") + if self.state is not SimulatedLeverageState.BLOCKED and self.blockers: + raise ValueError("eligible leverage assessment cannot contain blockers") + if self.execution_allowed: + raise ValueError("simulated leverage cannot authorize execution") + + @dataclass(frozen=True, slots=True) class VirtualPortfolioRiskGovernor: """Deterministic portfolio-level veto policy for virtual-market entries.""" @@ -31,6 +66,20 @@ class VirtualPortfolioRiskGovernor: maximum_futures_leverage: int = 5 def __post_init__(self) -> None: + if any( + not value.is_finite() + for value in ( + self.maximum_risk_per_trade_usdt, + self.maximum_open_risk_usdt, + self.maximum_drawdown_ratio, + self.maximum_margin_utilization_ratio, + ) + ): + raise ValueError("virtual portfolio governor limits must be finite") + if not isinstance(self.maximum_futures_leverage, int) or isinstance( + self.maximum_futures_leverage, bool + ): + raise ValueError("virtual portfolio leverage limit must be an integer") if ( min( self.maximum_risk_per_trade_usdt, @@ -57,3 +106,112 @@ def __post_init__(self) -> None: raise ValueError( "virtual portfolio futures leverage limit must be positive" ) + + def assess_simulated_leverage( + self, + *, + requested_leverage: int | None, + position_notional_usdt: Decimal | None, + available_margin_usdt: Decimal | None, + margin_utilization_ratio: Decimal | None, + strategy_oos_approved: bool, + upstream_blockers: tuple[str, ...] = (), + ) -> SimulatedLeverageAssessment: + """Assess bounded simulation suitability; confidence is never an input.""" + blockers = list(upstream_blockers) + valid_requested = ( + isinstance(requested_leverage, int) + and not isinstance(requested_leverage, bool) + and requested_leverage >= 1 + ) + if not valid_requested: + blockers.append("SIMULATED_LEVERAGE_REQUEST_INVALID") + if ( + position_notional_usdt is None + or not position_notional_usdt.is_finite() + or position_notional_usdt <= ZERO + ): + blockers.append("SIMULATED_POSITION_NOTIONAL_INVALID") + if ( + available_margin_usdt is None + or not available_margin_usdt.is_finite() + or available_margin_usdt <= ZERO + ): + blockers.append("SIMULATED_AVAILABLE_MARGIN_INVALID") + if ( + margin_utilization_ratio is None + or not margin_utilization_ratio.is_finite() + or not (ZERO <= margin_utilization_ratio <= ONE) + ): + blockers.append("SIMULATED_MARGIN_UTILIZATION_INVALID") + elif margin_utilization_ratio > self.maximum_margin_utilization_ratio: + blockers.append("FUTURES_MARGIN_UTILIZATION_LIMIT_EXCEEDED") + if strategy_oos_approved is not True: + blockers.append("SIMULATED_LEVERAGE_OOS_APPROVAL_MISSING") + if blockers: + return SimulatedLeverageAssessment( + state=SimulatedLeverageState.BLOCKED, + requested_leverage=requested_leverage if valid_requested else None, + permitted_leverage=None, + blockers=tuple(dict.fromkeys(blockers)), + ) + position_notional = cast(Decimal, position_notional_usdt) + available_margin = cast(Decimal, available_margin_usdt) + requested = cast(int, requested_leverage) + required = int( + (position_notional / available_margin).to_integral_value( + rounding=ROUND_CEILING + ) + ) + if required > self.maximum_futures_leverage: + return SimulatedLeverageAssessment( + state=SimulatedLeverageState.BLOCKED, + requested_leverage=requested_leverage, + permitted_leverage=None, + blockers=("SIMULATED_LEVERAGE_FEASIBILITY_EXCEEDED",), + ) + permitted = min(requested, self.maximum_futures_leverage) + if permitted < required: + return SimulatedLeverageAssessment( + state=SimulatedLeverageState.BLOCKED, + requested_leverage=requested_leverage, + permitted_leverage=None, + blockers=("SIMULATED_LEVERAGE_INSUFFICIENT_FOR_NOTIONAL",), + ) + state = ( + SimulatedLeverageState.REDUCED + if requested > self.maximum_futures_leverage + else SimulatedLeverageState.ELIGIBLE + ) + return SimulatedLeverageAssessment( + state=state, + requested_leverage=requested, + permitted_leverage=permitted, + ) + + def futures_entry_blockers( + self, + *, + requested_leverage: int | None, + current_margin_utilization: Decimal | None, + projected_margin_utilization: Decimal | None, + ) -> tuple[str, ...]: + """Enforce simulation limits without claiming OOS or execution authority.""" + blockers: list[str] = [] + if ( + not isinstance(requested_leverage, int) + or isinstance(requested_leverage, bool) + or requested_leverage < 1 + ): + blockers.append("FUTURES_LEVERAGE_UNAVAILABLE") + elif requested_leverage > self.maximum_futures_leverage: + blockers.append("FUTURES_LEVERAGE_LIMIT_EXCEEDED") + for name, utilization in ( + ("CURRENT", current_margin_utilization), + ("PROJECTED", projected_margin_utilization), + ): + if utilization is None or not utilization.is_finite() or utilization < ZERO: + blockers.append(f"FUTURES_{name}_MARGIN_UTILIZATION_UNAVAILABLE") + elif utilization > self.maximum_margin_utilization_ratio: + blockers.append("FUTURES_MARGIN_UTILIZATION_LIMIT_EXCEEDED") + return tuple(dict.fromkeys(blockers)) diff --git a/src/ai4binance/risk.py b/src/ai4binance/risk.py index 1f071f1c..3c96d790 100644 --- a/src/ai4binance/risk.py +++ b/src/ai4binance/risk.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field from decimal import Decimal, InvalidOperation +from typing import cast from ai4binance.core.contracts.risk import ( RiskConfig as RiskConfig, @@ -42,11 +43,18 @@ def __post_init__(self) -> None: "daily_loss_usdt", "estimated_slippage_ratio", ): - if getattr(self, field_name) < ZERO: - raise ValueError(f"{field_name} cannot be negative") - if self.equity_usdt is not None and self.equity_usdt <= ZERO: + if ( + not getattr(self, field_name).is_finite() + or getattr(self, field_name) < ZERO + ): + raise ValueError(f"{field_name} must be finite and non-negative") + if self.equity_usdt is not None and ( + not self.equity_usdt.is_finite() or self.equity_usdt <= ZERO + ): raise ValueError("equity_usdt must be positive when known") - if self.inventory_quantity is not None and self.inventory_quantity < ZERO: + if self.inventory_quantity is not None and ( + not self.inventory_quantity.is_finite() or self.inventory_quantity < ZERO + ): raise ValueError("inventory_quantity cannot be negative") if self.consecutive_losses < 0: raise ValueError("consecutive_losses cannot be negative") @@ -62,11 +70,20 @@ class RiskAssessment: quantity: Decimal = ZERO risk_amount_usdt: Decimal = ZERO blockers: tuple[str, ...] = () + scenario_id: str | None = None def __post_init__(self) -> None: if not self.candidate_id.strip(): raise ValueError("candidate_id cannot be empty") - if min(self.size_usdt, self.quantity, self.risk_amount_usdt) < ZERO: + if self.scenario_id is not None and not self.scenario_id.strip(): + raise ValueError("scenario_id cannot be blank") + if ( + any( + not value.is_finite() + for value in (self.size_usdt, self.quantity, self.risk_amount_usdt) + ) + or min(self.size_usdt, self.quantity, self.risk_amount_usdt) < ZERO + ): raise ValueError("risk assessment values cannot be negative") if self.approved and self.blockers: raise ValueError("approved risk assessment cannot contain blockers") @@ -94,6 +111,8 @@ def from_agent_result(cls, result: AgentResult | object) -> "RiskAssessment | No if not isinstance(candidate_id, str) or not candidate_id.strip(): return None approved = metadata.get("approved") is True + raw_scenario_id = metadata.get("scenario_id") + scenario_id = raw_scenario_id if isinstance(raw_scenario_id, str) else None size_usdt = _metadata_decimal(metadata.get("size_usdt")) quantity = _metadata_decimal(metadata.get("quantity")) risk_amount = _metadata_decimal(metadata.get("risk_amount_usdt")) @@ -106,6 +125,7 @@ def from_agent_result(cls, result: AgentResult | object) -> "RiskAssessment | No return cls( candidate_id=candidate_id, approved=approved, + scenario_id=scenario_id, size_usdt=size_usdt, quantity=quantity, risk_amount_usdt=risk_amount, @@ -153,9 +173,7 @@ def evaluate( execution_surface: ExecutionSurface = ExecutionSurface.BINANCE_MARKET, ) -> RiskAssessment: """Evaluate a candidate and calculate a safely rounded position preview.""" - blockers = list(candidate.blockers) - if candidate.status is not CandidateStatus.READY_FOR_RISK: - blockers.append("CANDIDATE_NOT_READY_FOR_RISK") + blockers = list(candidate_safety_blockers(candidate, snapshot)) if ( execution_surface is ExecutionSurface.BINANCE_MARKET and candidate.promotion_status @@ -187,14 +205,28 @@ def evaluate( blockers.append("SPREAD_EXCEEDS_LIMIT") if candidate.risk_reward < self.config.minimum_risk_reward: blockers.append("RISK_REWARD_BELOW_MINIMUM") + if ( + candidate.market_type == "USD_M_FUTURES" + and candidate.net_risk_reward is None + ): + blockers.append("FUTURES_NET_RISK_REWARD_UNAVAILABLE") + if ( + candidate.net_risk_reward is not None + and candidate.net_risk_reward < self.config.minimum_risk_reward + ): + blockers.append("NET_RISK_REWARD_BELOW_MINIMUM") if context.open_position_count >= self.config.virtual_market.maximum_positions: blockers.append("MAX_OPEN_POSITIONS_EXCEEDED") entry = candidate.entry_price - stop_distance = abs(entry - candidate.invalidation_level) + stop_distance = candidate_risk_distance(candidate) if stop_distance <= ZERO: blockers.append("STOP_DISTANCE_INVALID") - if candidate.action is Action.SELL and context.inventory_quantity is None: + if ( + candidate.market_type == "SPOT" + and candidate.action is Action.SELL + and context.inventory_quantity is None + ): blockers.append("INVENTORY_UNKNOWN_FOR_SPOT_SELL") quantity = ZERO @@ -223,6 +255,7 @@ def evaluate( quantity = size_usdt / entry if ( candidate.action is Action.SELL + and candidate.market_type == "SPOT" and context.inventory_quantity is not None ): quantity = min(quantity, context.inventory_quantity) @@ -248,6 +281,7 @@ def evaluate( return RiskAssessment( candidate_id=candidate.candidate_id, approved=not unique_blockers, + scenario_id=candidate.scenario_id, size_usdt=size_usdt, quantity=quantity, risk_amount_usdt=risk_amount, @@ -255,6 +289,81 @@ def evaluate( ) +def candidate_risk_distance(candidate: TradeCandidate) -> Decimal: + """Include the executable stop and known costs in scenario-bound sizing.""" + distance = abs(candidate.entry_price - candidate.invalidation_level) + if candidate.scenario_id is not None: + distance = max(distance, abs(candidate.entry_price - candidate.stop_loss)) + if candidate.estimated_round_trip_cost_ratio is not None: + distance += ( + candidate.entry_price * candidate.estimated_round_trip_cost_ratio + ) + return cast(Decimal, distance) + + +def candidate_safety_blockers( + candidate: TradeCandidate, snapshot: MarketSnapshot +) -> tuple[str, ...]: + """Shared admission checks for risk and downstream validation consumers.""" + blockers = list(candidate.blockers) + if ( + candidate.snapshot_id != snapshot.snapshot_id + or candidate.symbol != snapshot.symbol + or candidate.timestamp != snapshot.created_at + or candidate.market_type != snapshot.market_type.upper() + or candidate.timeframe not in snapshot.timeframes + ): + blockers.append("CANDIDATE_SNAPSHOT_IDENTITY_MISMATCH") + if candidate.status is not CandidateStatus.READY_FOR_RISK: + blockers.append("CANDIDATE_NOT_READY_FOR_RISK") + if ( + candidate.entry_expiry is not None + and snapshot.created_at >= candidate.entry_expiry + ): + blockers.append("CANDIDATE_ENTRY_EXPIRED") + if candidate.scenario_id is not None: + if ( + candidate.scenario_state != "CONFIRMED" + or candidate.entry_state != "ENTRY_VALID" + ): + blockers.append("SCENARIO_ENTRY_NOT_VALID") + if candidate.entry_expiry is None: + blockers.append("ENTRY_EXPIRY_UNAVAILABLE") + invalidation = _metadata_decimal(candidate.scenario_invalidation) + if invalidation is None or not ( + ZERO < invalidation < candidate.entry_zone.lower + if candidate.action is Action.BUY + else invalidation > candidate.entry_zone.upper + ): + blockers.append("SCENARIO_INVALIDATION_UNAVAILABLE_OR_INVALID") + elif candidate.action is Action.BUY and ( + candidate.stop_loss < invalidation + or candidate.invalidation_level < invalidation + ): + blockers.append("CANDIDATE_RISK_EXTENDS_BEYOND_SCENARIO") + elif candidate.action is Action.SELL and ( + candidate.stop_loss > invalidation + or candidate.invalidation_level > invalidation + ): + blockers.append("CANDIDATE_RISK_EXTENDS_BEYOND_SCENARIO") + blockers.extend(_candidate_economics_blockers(candidate)) + return tuple(dict.fromkeys(blockers)) + + +def _candidate_economics_blockers(candidate: TradeCandidate) -> tuple[str, ...]: + cost = candidate.estimated_round_trip_cost_ratio + if cost is None or candidate.net_risk_reward is None: + return ("NET_RISK_REWARD_UNAVAILABLE",) + distance = candidate_risk_distance(candidate) + reward = ( + abs(candidate.take_profit_levels[0] - candidate.entry_price) + - candidate.entry_price * cost + ) + if distance <= ZERO or candidate.net_risk_reward > reward / distance: + return ("NET_RISK_REWARD_INCONSISTENT",) + return () + + def _metadata_decimal(value: object) -> Decimal | None: if not isinstance(value, (str, int, float, Decimal)) or isinstance(value, bool): return None diff --git a/src/ai4binance/schemas.py b/src/ai4binance/schemas.py index 33bc6d50..f6e74009 100644 --- a/src/ai4binance/schemas.py +++ b/src/ai4binance/schemas.py @@ -11,6 +11,7 @@ from ai4binance.core.contracts.memory import CompiledCycleContext from ai4binance.domain import Signal, TradeCandidate +from ai4binance.intelligence.contracts import TradingIntelligenceState class AgentStatus(StrEnum): @@ -280,6 +281,7 @@ class AnalysisState: candidate_setups: tuple[TradeCandidate, ...] = field(default_factory=tuple) final_decision: Signal | None = None compiled_cycle_context: CompiledCycleContext | None = None + trading_intelligence: TradingIntelligenceState | None = None def __post_init__(self) -> None: """Block mixed-snapshot or mixed-symbol analysis state.""" @@ -315,5 +317,18 @@ def __post_init__(self) -> None: raise ValueError( "compiled cycle context must reference the shared symbol" ) + if self.trading_intelligence is not None: + if self.trading_intelligence.snapshot_id != self.snapshot_id: + raise ValueError( + "trading intelligence must reference the shared snapshot" + ) + if self.trading_intelligence.symbol != normalized_symbol: + raise ValueError( + "trading intelligence must reference the shared symbol" + ) + if self.trading_intelligence.timestamp != self.timestamp: + raise ValueError( + "trading intelligence must reference the shared timestamp" + ) object.__setattr__(self, "symbol", normalized_symbol) object.__setattr__(self, "agent_results", _freeze_mapping(self.agent_results)) diff --git a/src/ai4binance/storage/jsonl.py b/src/ai4binance/storage/jsonl.py index 9372b188..69cafee3 100644 --- a/src/ai4binance/storage/jsonl.py +++ b/src/ai4binance/storage/jsonl.py @@ -12,6 +12,9 @@ from threading import Lock from typing import Any, BinaryIO, cast +from ai4binance.core import ( + read_bounded_jsonl_tail as _read_bounded_jsonl_tail, +) from ai4binance.reporting import to_primitive from ai4binance.storage.destination_verification import ( VerifiedWriteResult, @@ -22,7 +25,6 @@ _EVENT_TYPE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$") _TAIL_READ_CHUNK_BYTES = 64 * 1024 _DEFAULT_MAX_EVENT_BYTES = 8 * 1024 * 1024 -_DEFAULT_MAX_TAIL_BYTES = 16 * 1024 * 1024 _GENESIS_RECORD_HASH = "GENESIS" _SENSITIVE_KEY_FRAGMENTS = ( "api_key", @@ -672,42 +674,6 @@ def _load_event_line(raw_line: bytes, *, label: str) -> dict[str, object]: return payload -def read_bounded_jsonl_tail( - path: Path, - *, - max_lines: int = 200, - max_bytes: int = _DEFAULT_MAX_TAIL_BYTES, -) -> tuple[bytes, ...]: - """Read recent non-empty records without loading an entire JSONL file.""" - if max_lines < 1 or max_bytes < 1: - raise ValueError("JSONL tail limits must be positive") - with path.open("rb") as stream: - stream.seek(0, os.SEEK_END) - end = JsonlAuditStore._trim_trailing_whitespace(stream, stream.tell()) - cursor = end - buffer = b"" - while cursor > 0: - start = max(0, cursor - _TAIL_READ_CHUNK_BYTES) - starts_at_record_boundary = start == 0 - if start > 0: - stream.seek(start - 1) - previous = stream.read(1) - stream.seek(start) - current = stream.read(1) - starts_at_record_boundary = previous in b"\r\n" or current in b"\r\n" - stream.seek(start) - buffer = stream.read(cursor - start) + buffer - if len(buffer) > max_bytes: - raise OSError("JSONL tail exceeds bounded read limit") - lines = tuple(line for line in buffer.splitlines() if line.strip()) - if lines and not starts_at_record_boundary: - lines = lines[1:] - if len(lines) >= max_lines or start == 0: - return lines[-max_lines:] - cursor = start - return () - - def _canonical_json_sha256(value: object) -> str: encoded = json.dumps( value, @@ -718,6 +684,20 @@ def _canonical_json_sha256(value: object) -> str: return hashlib.sha256(encoded).hexdigest() +def read_bounded_jsonl_tail( + path: Path, + *, + max_lines: int = 200, + max_bytes: int = 16 * 1024 * 1024, +) -> tuple[bytes, ...]: + """Compatibility export for the canonical bounded JSONL reader.""" + return _read_bounded_jsonl_tail( + path, + max_lines=max_lines, + max_bytes=max_bytes, + ) + + def _text_field(payload: Mapping[str, object], name: str, *, label: str) -> str: value = payload.get(name) if not isinstance(value, str) or not value.strip(): diff --git a/tests/test_advanced_agents.py b/tests/test_advanced_agents.py index 074916a6..da9c34c3 100644 --- a/tests/test_advanced_agents.py +++ b/tests/test_advanced_agents.py @@ -94,6 +94,32 @@ def test_external_agents_require_sourced_numeric_snapshots() -> None: assert stale.blockers == ("EXTERNAL_EVIDENCE_STALE_OR_FUTURE",) +def test_derivatives_context_does_not_require_a_directional_prediction() -> None: + registry = build_default_registry() + base = technical_snapshot() + snapshot = replace( + base, + market_type="USD_M_FUTURES", + derivatives_snapshot={ + "source_count": 4, + "as_of": base.created_at.isoformat(), + "funding_rate": "0.0001", + "open_interest": "500000", + "mark_price": "1.118", + "index_price": "1.117", + }, + ) + agent = build_advanced_agent(registry.get("derivatives")) + assert agent is not None + + result = agent.analyze(snapshot, {}) + + assert result.status is AgentStatus.PARTIAL + assert result.directional_vote == 0.0 + assert result.score == 50.0 + assert result.warnings == ("SUPPLEMENTARY_FUTURES_CONTEXT_ONLY",) + + def test_order_flow_uses_explicit_depth_and_remains_supplementary() -> None: registry = build_default_registry() snapshot = replace( diff --git a/tests/test_cli.py b/tests/test_cli.py index f67c591c..ab43e6dc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1832,7 +1832,9 @@ def test_virtual_market_scan_cursor_persists_and_reports_business_blockers( from ai4binance.data import market_history_sync monkeypatch.setattr( - market_history_sync, "read_cached_market_universe", lambda *_args: None + market_history_sync, + "read_cached_market_universe", + lambda *_args, **_kwargs: None, ) settings = Settings( symbol="BTCUSDT", @@ -1887,7 +1889,9 @@ def test_virtual_market_daemon_prioritizes_and_acknowledges_manual_refresh( from ai4binance.data import market_history_sync monkeypatch.setattr( - market_history_sync, "read_cached_market_universe", lambda *_: None + market_history_sync, + "read_cached_market_universe", + lambda *_args, **_kwargs: None, ) settings = Settings( symbol="BTCUSDT", @@ -1965,7 +1969,9 @@ def test_virtual_market_daemon_requests_canonical_refresh_for_stale_data( ) monkeypatch.setattr( - market_history_sync, "read_cached_market_universe", lambda *_args: None + market_history_sync, + "read_cached_market_universe", + lambda *_args, **_kwargs: None, ) settings = Settings( symbol="BTCUSDT", @@ -2065,7 +2071,9 @@ def test_virtual_market_priority_revisits_preserve_discovery_and_restart_cursor( excluded_assets=(), ) monkeypatch.setattr( - market_history_sync, "read_cached_market_universe", lambda *_: universe + market_history_sync, + "read_cached_market_universe", + lambda *_args, **_kwargs: universe, ) monkeypatch.setattr( runtime_cli, diff --git a/tests/test_config_reporting.py b/tests/test_config_reporting.py index 797f37ef..0427c5bc 100644 --- a/tests/test_config_reporting.py +++ b/tests/test_config_reporting.py @@ -146,7 +146,7 @@ def test_settings_defaults_to_bounded_research_history_horizons() -> None: settings = Settings() assert settings.market_history_initial_days == 90 assert settings.market_history_enrichment_days == 30 - assert settings.market_history_coin_m_enabled is False + assert settings.market_history_coin_m_enabled is True assert settings.market_history_market_cap_asset_limit == 20 assert settings.market_history_wallet_minimum_value_usdt == Decimal("1") assert settings.max_data_workers == 4 diff --git a/tests/test_domain.py b/tests/test_domain.py index 52d611c1..57f714da 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -247,8 +247,10 @@ def test_agent_score_rejects_empty_name_and_invalid_value() -> None: def test_price_zone_rejects_negative_values() -> None: - with pytest.raises(ValueError, match="cannot be negative"): + with pytest.raises(ValueError, match="finite and non-negative"): PriceZone(Decimal("-1"), Decimal("1")) + with pytest.raises(ValueError, match="finite and non-negative"): + PriceZone(Decimal("NaN"), Decimal("1")) def test_signal_rejects_empty_identity_and_reason_codes() -> None: diff --git a/tests/test_kaizen_quality.py b/tests/test_kaizen_quality.py index 860b68a6..89fcfe5b 100644 --- a/tests/test_kaizen_quality.py +++ b/tests/test_kaizen_quality.py @@ -773,6 +773,12 @@ def test_architecture_migration_ledger_classifies_every_repository_module() -> N assert ( by_path["src/ai4binance/core/contracts/memory.py"]["classification"] == "KEEP" ) + assert ( + by_path["src/ai4binance/intelligence/contracts.py"]["classification"] == "MOVE" + ) + assert by_path["src/ai4binance/intelligence/contracts.py"]["target_paths"] == [ + "src/ai4binance/domain/intelligence/contracts.py" + ] historical_evaluation = by_path["src/ai4binance/historical_replay_evaluation.py"] assert historical_evaluation["classification"] == "SPLIT" assert set(cast(list[str], historical_evaluation["target_paths"])) == { diff --git a/tests/test_oos_maturity.py b/tests/test_oos_maturity.py index ff46e9d2..b3cf956b 100644 --- a/tests/test_oos_maturity.py +++ b/tests/test_oos_maturity.py @@ -350,10 +350,12 @@ def test_runtime_validation_consumes_exact_maturity_and_keeps_all_vetoes( tmp_path: Path, bundle: OOSMaturityEvidenceBundle, case: str ) -> None: from datetime import timedelta + from decimal import Decimal from ai4binance.agents.validation_gate import ValidationGate + from ai4binance.risk import RiskContext, RiskEngine from ai4binance.schemas import AgentResult, AgentStatus, DataQuality - from tests.test_strategy_risk import approved_candidate, snapshot + from tests.test_strategy_risk import approved_candidate, snapshot, symbol_filters assert bundle.subject is not None subject = bundle.subject @@ -375,6 +377,10 @@ def test_runtime_validation_consumes_exact_maturity_and_keeps_all_vetoes( timeframe=subject.promotion.timeframe, setup_name=subject.setup_type, ) + assessment = RiskEngine().evaluate( + candidate, market, RiskContext(equity_usdt=Decimal("1000")), symbol_filters() + ) + assert assessment.approved risk = AgentResult( agent_name="risk", agent_version="1", @@ -389,7 +395,13 @@ def test_runtime_validation_consumes_exact_maturity_and_keeps_all_vetoes( score=100, confidence=1, reason_codes=("RISK_APPROVED",), - calculation_metadata={"approved": True, "candidate_id": candidate.candidate_id}, + calculation_metadata={ + "approved": assessment.approved, + "candidate_id": candidate.candidate_id, + "size_usdt": str(assessment.size_usdt), + "quantity": str(assessment.quantity), + "risk_amount_usdt": str(assessment.risk_amount_usdt), + }, ) if case == "wrong_hash": subject = replace(subject, feature_definition_sha256="9" * 64) diff --git a/tests/test_opportunity_monitor.py b/tests/test_opportunity_monitor.py index 1ac67123..62e6eb87 100644 --- a/tests/test_opportunity_monitor.py +++ b/tests/test_opportunity_monitor.py @@ -35,6 +35,7 @@ from ai4binance.data.archive import ParquetOHLCVArchive from ai4binance.data.market_history_sync import read_cached_market_universe from ai4binance.domain.opportunity_observation import estimate_measurable_trade_plan +from ai4binance.domain.universe import RESEARCH_MARKET_UNIVERSE_SOURCE from ai4binance.exchange.client import BinancePublicClient from ai4binance.exchange.models import MarketKline from ai4binance.opportunity_intelligence import TIMEFRAME_DURATIONS @@ -85,12 +86,15 @@ def test_monitor_helper_boundaries_and_research_estimates( universe = type( "Universe", (), {"spot_symbols": ("BTCUSDT",), "futures_symbols": ("ETHUSDT",)} )() - monkeypatch.setattr( - monitor_module, - "read_cached_market_universe", - lambda *_args, **_kwargs: universe, - ) + observed_sources: list[str] = [] + + def _read_cached(*_args: object, **kwargs: object) -> object: + observed_sources.append(str(kwargs["expected_source"])) + return universe + + monkeypatch.setattr(monitor_module, "read_cached_market_universe", _read_cached) assert monitor_module.market_symbols(tmp_path, "USD_M_FUTURES", NOW) == ("ETHUSDT",) + assert observed_sources == [RESEARCH_MARKET_UNIVERSE_SOURCE] monitor_path = monitor_directory(tmp_path, "SPOT", "BTCUSDT") monitor_path.mkdir(parents=True) diff --git a/tests/test_research_market_universe.py b/tests/test_research_market_universe.py index 4f93c5ec..db042b89 100644 --- a/tests/test_research_market_universe.py +++ b/tests/test_research_market_universe.py @@ -9,8 +9,12 @@ import pytest from ai4binance.data.market_universe_retention import MarketUniverseRetention +from ai4binance.domain.universe import ( + RESEARCH_MARKET_UNIVERSE_SOURCE as CANONICAL_SOURCE, +) from ai4binance.integrations.binance import BinanceEligibleMarketSnapshot from ai4binance.integrations.research_market_universe import ( + RESEARCH_MARKET_UNIVERSE_SOURCE, ResearchMarketUniverseProvider, read_wallet_assets_above_value, ) @@ -40,6 +44,10 @@ ) +def test_research_universe_source_uses_canonical_domain_contract() -> None: + assert RESEARCH_MARKET_UNIVERSE_SOURCE == CANONICAL_SOURCE + + class _MarketCapTransport: def __init__(self, rows: list[dict[str, object]]) -> None: self.rows = rows @@ -218,6 +226,7 @@ def test_retention_removes_only_out_of_scope_symbol_directories( validation = tmp_path / "validation" keep = archive / "spot" / "BTCUSDT" drop = archive / "spot" / "OLDUSDT" + non_ascii_drop = archive / "usd_m_futures" / "币安人生USDT" metadata = archive / "spot" / "metadata" coin_m_archive = archive / "coin_m_futures" / "metadata" source_keep = sources / "data/spot/daily/klines/BTCUSDT" @@ -235,6 +244,7 @@ def test_retention_removes_only_out_of_scope_symbol_directories( for directory in ( keep, drop, + non_ascii_drop, metadata, coin_m_archive, source_keep, @@ -269,6 +279,7 @@ def test_retention_removes_only_out_of_scope_symbol_directories( assert replay_keep.is_file() assert validation_keep.is_dir() assert not drop.exists() + assert not non_ascii_drop.exists() assert not source_drop.exists() assert not coin_m.exists() assert not monitor_drop.exists() diff --git a/tests/test_strategy_risk.py b/tests/test_strategy_risk.py index a57ac353..cc476283 100644 --- a/tests/test_strategy_risk.py +++ b/tests/test_strategy_risk.py @@ -24,6 +24,7 @@ RiskContext, RiskEngine, VirtualMarketPositionSizingPolicy, + candidate_safety_blockers, ) from ai4binance.schemas import ( AgentResult, @@ -843,6 +844,53 @@ def test_risk_context_rejects_invalid_optional_values() -> None: RiskContext(open_risk_usdt=Decimal("-1")) with pytest.raises(ValueError, match="open_position_count"): RiskContext(open_position_count=-1) + with pytest.raises(ValueError, match="finite and non-negative"): + RiskContext(daily_loss_usdt=Decimal("NaN")) + + +@pytest.mark.parametrize( + ( + "action", + "scenario_invalidation", + "stop_loss", + "candidate_invalidation", + "blocked", + ), + [ + (Action.BUY, "95", Decimal("95"), Decimal("95"), False), + (Action.BUY, "95", Decimal("94"), Decimal("95"), True), + (Action.SELL, "105", Decimal("105"), Decimal("105"), False), + (Action.SELL, "105", Decimal("106"), Decimal("105"), True), + ], +) +def test_candidate_safety_blockers_apply_explicit_scenario_bounds_by_action( + action: Action, + scenario_invalidation: str, + stop_loss: Decimal, + candidate_invalidation: Decimal, + blocked: bool, +) -> None: + candidate = replace( + approved_candidate(), + action=action, + stop_loss=stop_loss, + invalidation_level=candidate_invalidation, + take_profit_levels=(Decimal("110"),) + if action is Action.BUY + else (Decimal("90"),), + trailing_stop=stop_loss, + inventory_action="NONE" if action is Action.BUY else "SELL", + scenario_id="scenario-1", + scenario_type="TREND", + scenario_state="CONFIRMED", + scenario_invalidation=scenario_invalidation, + entry_state="ENTRY_VALID", + entry_expiry=NOW + timedelta(hours=1), + ) + + blockers = candidate_safety_blockers(candidate, snapshot()) + + assert ("CANDIDATE_RISK_EXTENDS_BEYOND_SCENARIO" in blockers) is blocked def test_risk_assessment_rejects_invalid_approved_state() -> None: diff --git a/tests/test_trading_intelligence.py b/tests/test_trading_intelligence.py new file mode 100644 index 00000000..801bb694 --- /dev/null +++ b/tests/test_trading_intelligence.py @@ -0,0 +1,807 @@ +"""Deterministic Trading Intelligence evidence-chain tests.""" + +from collections.abc import Mapping +from dataclasses import replace +from datetime import timedelta +from decimal import Decimal + +import pytest + +from ai4binance.agents.orchestrator import EnterpriseOrchestrator +from ai4binance.agents.validation_gate import ValidationGate +from ai4binance.domain import ( + Action, + CandidateStatus, + PriceZone, + TradeCandidate, +) +from ai4binance.intelligence.contracts import ( + CalibrationState, + PatternLifecycleState, + ScenarioDirection, + ScenarioState, + ScenarioType, + StructureState, +) +from ai4binance.intelligence.derivatives import FuturesContextEngine +from ai4binance.intelligence.patterns import PatternHypothesisFabric +from ai4binance.intelligence.structure import MarketStructureEngine +from ai4binance.intelligence.trading import TradingIntelligenceEngine +from ai4binance.intelligence.trend import TrendGeometryEngine +from ai4binance.research.virtual_runtime_risk import ( + SimulatedLeverageState, + VirtualPortfolioRiskGovernor, +) +from ai4binance.risk import ( + RiskAssessment, + RiskContext, + RiskEngine, + candidate_risk_distance, +) +from ai4binance.schemas import ( + AgentResult, + AgentStatus, + DataQuality, + MarketSnapshot, + OHLCVCandle, +) +from tests.test_strategy_risk import symbol_filters +from tests.test_technical_agents import NOW, technical_snapshot + + +def _result( + name: str, + *, + vote: float = 1.0, + confidence: float = 0.8, + evidence: tuple[str, ...] = ("DETERMINISTIC_EVIDENCE",), + metadata: dict[str, object] | None = None, +) -> AgentResult: + snapshot = technical_snapshot() + return AgentResult( + agent_name=name, + agent_version="test-v1", + snapshot_id=snapshot.snapshot_id, + timestamp=snapshot.created_at, + symbol=snapshot.symbol, + timeframes=snapshot.timeframes, + status=AgentStatus.SUCCESS, + data_quality=DataQuality.DATA_VALID, + applicable=True, + directional_vote=vote, + score=80.0, + confidence=confidence, + evidence=evidence, + reason_codes=("TEST_EVIDENCE_READY",), + calculation_metadata=metadata or {}, + ) + + +def _structure_result(*, macro_state: StructureState) -> AgentResult: + def projection(state: StructureState, invalidation: str) -> dict[str, object]: + return { + "structure_state": state.value, + "structure_method": "CONFIRMED_SWING_GRAPH", + "range_low": "0.90", + "range_high": "1.30", + "invalidation_level": invalidation, + "structure_confidence": 0.8, + "structure_warnings": (), + "swings": ( + { + "kind": "LOW", + "candle_index": 10, + "occurred_at": (NOW - timedelta(hours=4)).isoformat(), + "available_at": (NOW - timedelta(hours=2)).isoformat(), + "price": invalidation, + "label": "HL", + "atr_significance": "1.2", + }, + ), + "events": (), + } + + return _result( + "market_structure", + metadata={ + "timeframes": { + "1d": projection(macro_state, "1.25"), + "4h": projection(StructureState.BULLISH, "0.95"), + "1h": projection(StructureState.BULLISH, "0.98"), + "15m": projection(StructureState.BULLISH, "1.00"), + } + }, + ) + + +def _evidence_results( + *, + macro_state: StructureState = StructureState.BULLISH, + include_trigger: bool = True, +) -> dict[str, AgentResult]: + results = { + "market_structure": _structure_result(macro_state=macro_state), + "multi_timeframe": _result( + "multi_timeframe", + confidence=0.7, + evidence=("MTF_DIRECTION_ALIGNED",), + metadata={"conflict": False}, + ), + "market_regime": _result( + "market_regime", + metadata={"regime": "TRENDING"}, + ), + } + if include_trigger: + results["price_action"] = _result( + "price_action", + confidence=0.6, + evidence=("BULLISH_ENGULFING:15m",), + ) + return results + + +def _candidate() -> TradeCandidate: + snapshot = technical_snapshot() + return TradeCandidate( + candidate_id="candidate:long-continuation", + snapshot_id=snapshot.snapshot_id, + timestamp=snapshot.created_at, + symbol=snapshot.symbol, + timeframe="15m", + action=Action.BUY, + setup_name="LONG_CONTINUATION", + status=CandidateStatus.READY_FOR_RISK, + entry_zone=PriceZone(Decimal("1.10"), Decimal("1.12")), + invalidation_level=Decimal("1.00"), + stop_loss=Decimal("1.00"), + take_profit_levels=(Decimal("1.30"),), + trailing_stop=Decimal("0.05"), + atr=Decimal("0.02"), + risk_reward=Decimal("1.7"), + score=80.0, + confidence=0.7, + ) + + +def _costed_snapshot() -> MarketSnapshot: + snapshot = technical_snapshot() + return replace( + snapshot, + market_metadata={ + **snapshot.market_metadata, + "estimated_fee_ratio": "0.001", + "estimated_slippage_ratio": "0.001", + }, + ) + + +def test_confirmed_scenario_uses_weakest_confidence_and_binds_candidate() -> None: + engine = TradingIntelligenceEngine() + state = engine.build(_costed_snapshot(), _evidence_results()) + + assert state.selected_scenario is not None + assert state.selected_scenario.scenario_type is ScenarioType.LONG_CONTINUATION + assert state.selected_scenario.state is ScenarioState.CONFIRMED + assert state.selected_scenario.confidence == 0.6 + assert state.selected_scenario.calibration_state is CalibrationState.NOT_CALIBRATED + assert state.structures[1].swings + assert state.structures[1].swings[0].label == "HL" + assert state.scenario_separation == 0.6 + assert state.execution_allowed is False + assert state.live_eligibility_status == "LIVE_ORDER_BLOCKED" + + (candidate,) = engine.bind_candidates((_candidate(),), state) + assert candidate.status is CandidateStatus.READY_FOR_RISK + assert candidate.scenario_id == state.selected_scenario_id + assert candidate.scenario_type == ScenarioType.LONG_CONTINUATION.value + assert candidate.scenario_state == ScenarioState.CONFIRMED.value + assert candidate.scenario_invalidation == "0.95" + assert candidate.gross_risk_reward == Decimal("1.7") + assert candidate.structural_risk_reward == Decimal("1.1875") + assert candidate.net_risk_reward is not None + assert candidate.expected_r is None + assert candidate.probability_calibration_state == "PROBABILITY_NOT_CALIBRATED" + assert candidate.entry_trigger == "BULLISH_ENGULFING:15m" + assert candidate.entry_state == "ENTRY_VALID" + assert candidate.entry_expiry == candidate.timestamp + timedelta(minutes=15) + assert candidate.target_sources == () + + +def test_macro_conflict_and_missing_trigger_fail_closed() -> None: + engine = TradingIntelligenceEngine() + conflicted = engine.build( + technical_snapshot(), + _evidence_results(macro_state=StructureState.BEARISH), + ) + assert conflicted.selected_scenario is None + assert "MACRO_STRUCTURE_CONFLICT" in conflicted.blockers + + forming = engine.build( + _costed_snapshot(), + _evidence_results(include_trigger=False), + ) + assert forming.selected_scenario is not None + assert forming.selected_scenario.state is ScenarioState.FORMING + assert forming.selected_scenario.confidence == 0.0 + (candidate,) = engine.bind_candidates((_candidate(),), forming) + assert candidate.status is CandidateStatus.WAIT_FOR_RETEST + assert "SCENARIO_CONFIRMATION_PENDING" in candidate.blockers + + +def test_competing_scenarios_require_minimum_separation() -> None: + results = _evidence_results() + results["price_action"] = _result( + "price_action", + vote=-1.0, + confidence=0.6, + evidence=("BEARISH_ENGULFING:15m",), + ) + + state = TradingIntelligenceEngine(minimum_scenario_separation=0.7).build( + technical_snapshot(), results + ) + + assert len(state.scenarios) == 2 + assert state.selected_scenario is None + assert "SCENARIO_SEPARATION_INSUFFICIENT" in state.blockers + + +def test_futures_without_derivatives_context_has_no_selected_scenario() -> None: + snapshot = replace(technical_snapshot(), market_type="USD_M_FUTURES") + state = TradingIntelligenceEngine().build(snapshot, _evidence_results()) + + assert state.selected_scenario is None + assert state.derivatives_context.status == "BLOCKED" + assert "FUTURES_DERIVATIVES_CONTEXT_UNAVAILABLE" in state.blockers + + +def test_typed_futures_context_is_fresh_and_part_of_scenario_confidence() -> None: + raw = { + "source_count": 4, + "as_of": NOW.isoformat(), + "funding_rate": "0.0001", + "open_interest": "500000", + "mark_price": "1.118", + "index_price": "1.117", + "taker_buy_sell_ratio": "1.04", + } + snapshot = replace( + technical_snapshot(), + market_type="USD_M_FUTURES", + derivatives_snapshot=raw, + ) + derivatives_result = _result( + "derivatives", + confidence=0.4, + metadata={ + "source_count": 4, + "as_of": NOW.isoformat(), + }, + ) + results = {**_evidence_results(), "derivatives": derivatives_result} + + state = TradingIntelligenceEngine().build(snapshot, results) + + assert state.derivatives_context.status == "AVAILABLE" + assert state.derivatives_context.open_interest == Decimal("500000") + assert state.derivatives_context.mark_index_divergence is not None + assert state.derivatives_context.crowding_state == "POSITIVE_FUNDING" + assert state.selected_scenario is not None + assert state.selected_scenario.confidence == 0.4 + assert "SOURCED_EXTERNAL_SNAPSHOT" not in state.selected_scenario.evidence_for + assert "DETERMINISTIC_EVIDENCE" in state.selected_scenario.evidence_for + + +def test_futures_context_rejects_missing_critical_metric() -> None: + snapshot = replace( + technical_snapshot(), + market_type="USD_M_FUTURES", + derivatives_snapshot={ + "source_count": 2, + "as_of": NOW.isoformat(), + "funding_rate": "0.0001", + "mark_price": "1.1", + "index_price": "1.1", + }, + ) + result = _result( + "derivatives", + metadata={"source_count": 2, "as_of": NOW.isoformat()}, + ) + + context = FuturesContextEngine().build(snapshot, result) + + assert context.status == "BLOCKED" + assert "FUTURES_METRIC_MISSING:OPEN_INTEREST" in context.blockers + + +def test_pattern_fabric_normalizes_lifecycle_without_direction_authority() -> None: + hypotheses = PatternHypothesisFabric().build( + technical_snapshot(), + { + "fibonacci": _result( + "fibonacci", + metadata={"retracement_zone": "MID_RETRACEMENT_ZONE"}, + ), + "elliott_wave": _result( + "elliott_wave", + metadata={"heuristic": "ALTERNATING_SWINGS_PROXY"}, + ), + "price_action": _result("price_action"), + }, + ) + + lifecycle_by_family = {item.family: item.lifecycle_state for item in hypotheses} + assert lifecycle_by_family == { + "ELLIOTT_WAVE": PatternLifecycleState.ALTERNATIVE_UNRESOLVED.value, + "FIBONACCI": PatternLifecycleState.CONTEXT_ONLY.value, + "PRICE_ACTION": PatternLifecycleState.CONFIRMED.value, + } + assert all(item.primary_direction_signal is False for item in hypotheses) + assert all(item.execution_allowed is False for item in hypotheses) + + +def test_futures_risk_requires_net_rr_and_expected_r_requires_calibration() -> None: + candidate = replace(_candidate(), market_type="USD_M_FUTURES") + assessment = RiskEngine().evaluate( + candidate, + replace(technical_snapshot(), market_type="USD_M_FUTURES"), + RiskContext(equity_usdt=Decimal("1000")), + symbol_filters(), + ) + + assert "FUTURES_NET_RISK_REWARD_UNAVAILABLE" in assessment.blockers + with pytest.raises(ValueError, match="OOS-calibrated"): + replace(candidate, expected_r=Decimal("0.4")) + + +def test_cost_model_produces_net_rr_without_overwriting_structural_rr() -> None: + base = technical_snapshot() + snapshot = replace( + base, + market_metadata={ + **base.market_metadata, + "estimated_fee_ratio": "0.001", + "estimated_slippage_ratio": "0.001", + }, + ) + engine = TradingIntelligenceEngine() + state = engine.build(snapshot, _evidence_results()) + + (candidate,) = engine.bind_candidates((_candidate(),), state) + + assert state.estimated_round_trip_cost_ratio is not None + assert state.cost_blockers == () + assert candidate.structural_risk_reward == Decimal("1.1875") + assert candidate.net_risk_reward is not None + assert candidate.net_risk_reward < candidate.gross_risk_reward + assert candidate.estimated_round_trip_cost_ratio == ( + state.estimated_round_trip_cost_ratio + ) + + +def test_confirmed_swings_are_available_only_after_right_side_closes() -> None: + prices = ( + "10", + "11", + "12", + "11", + "10", + "9", + "10", + "11", + "13", + "12", + "11", + "10", + "11", + "12", + "14", + "13", + "12", + "11", + "12", + "13", + "15", + "14", + "13", + "12", + ) + candles = tuple( + OHLCVCandle( + timestamp=NOW - timedelta(hours=len(prices) - index), + open=Decimal(price), + high=Decimal(price) + Decimal("0.2"), + low=Decimal(price) - Decimal("0.2"), + close=Decimal(price), + volume=Decimal("100"), + ) + for index, price in enumerate(prices) + ) + + structure = MarketStructureEngine().analyze("1h", candles) + + assert structure.swings + for swing in structure.swings: + assert swing.available_at == candles[swing.candle_index + 2].timestamp + assert swing.available_at > swing.occurred_at + prefix = MarketStructureEngine().analyze("1h", candles[:20]) + assert prefix.swings == tuple( + swing + for swing in structure.swings + if swing.available_at <= candles[19].timestamp + ) + + +def test_simulated_leverage_governor_is_oos_and_margin_bound() -> None: + governor = VirtualPortfolioRiskGovernor(maximum_futures_leverage=5) + eligible = governor.assess_simulated_leverage( + requested_leverage=3, + position_notional_usdt=Decimal("2000"), + available_margin_usdt=Decimal("1000"), + margin_utilization_ratio=Decimal("0.2"), + strategy_oos_approved=True, + ) + reduced = governor.assess_simulated_leverage( + requested_leverage=10, + position_notional_usdt=Decimal("2000"), + available_margin_usdt=Decimal("1000"), + margin_utilization_ratio=Decimal("0.2"), + strategy_oos_approved=True, + ) + blocked = governor.assess_simulated_leverage( + requested_leverage=3, + position_notional_usdt=Decimal("2000"), + available_margin_usdt=Decimal("1000"), + margin_utilization_ratio=Decimal("0.2"), + strategy_oos_approved=False, + ) + + assert eligible.state is SimulatedLeverageState.ELIGIBLE + assert eligible.permitted_leverage == 3 + assert reduced.state is SimulatedLeverageState.REDUCED + assert reduced.permitted_leverage == 5 + assert blocked.state is SimulatedLeverageState.BLOCKED + assert blocked.execution_allowed is False + assert "SIMULATED_LEVERAGE_OOS_APPROVAL_MISSING" in blocked.blockers + + +def test_orchestrator_projects_one_shared_intelligence_state() -> None: + state = EnterpriseOrchestrator(minimum_candles=50).analyze(technical_snapshot()) + + assert state.trading_intelligence is not None + assert state.trading_intelligence.snapshot_id == state.snapshot_id + assert state.trading_intelligence.symbol == state.symbol + assert state.trading_intelligence.execution_allowed is False + assert state.trading_intelligence.levels + assert state.trading_intelligence.trend_geometry + geometry = state.trading_intelligence.trend_geometry[0] + assert geometry.anchor_points + assert geometry.touch_count > 0 + assert geometry.atr_normalized_error >= Decimal("0") + assert all( + level.price_low < level.price_high + for level in state.trading_intelligence.levels + ) + assert all(level.touch_count >= 1 for level in state.trading_intelligence.levels) + assert state.candidate_setups + assert all(candidate.scenario_id for candidate in state.candidate_setups) + assert all( + candidate.status is not CandidateStatus.READY_FOR_RISK + for candidate in state.candidate_setups + ) + selected = state.trading_intelligence.selected_scenario + assert selected is not None + assert selected.direction is ScenarioDirection.LONG + assert selected.state is ScenarioState.FORMING + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("snapshot_id", "foreign"), + ("symbol", "BTCUSDT"), + ("timestamp", NOW - timedelta(minutes=1)), + ("market_type", "USD_M_FUTURES"), + ], +) +def test_candidate_binding_rejects_cross_context_identity( + field: str, value: object +) -> None: + engine = TradingIntelligenceEngine() + state = engine.build(_costed_snapshot(), _evidence_results()) + (candidate,) = engine.bind_candidates( + (replace(_candidate(), **{field: value}),), state + ) + assert candidate.status is CandidateStatus.RESEARCH_ONLY + assert "SCENARIO_CANDIDATE_IDENTITY_MISMATCH" in candidate.blockers + assert candidate.scenario_id is None + + +def test_identity_failure_exits_before_interpreting_foreign_evidence() -> None: + results = _evidence_results() + results["market_structure"] = replace( + results["market_structure"], snapshot_id="foreign" + ) + state = TradingIntelligenceEngine().build(technical_snapshot(), results) + assert state.selected_scenario is None + assert state.structures == () + assert "TRADING_INTELLIGENCE_IDENTITY_MISMATCH:market_structure" in state.blockers + + +def test_failed_data_quality_cannot_fall_back_to_snapshot_quality() -> None: + results = _evidence_results() + results["data_quality"] = replace(_result("data_quality"), blockers=("STALE_DATA",)) + state = TradingIntelligenceEngine().build(technical_snapshot(), results) + assert state.selected_scenario is None + assert "SCENARIO_DATA_QUALITY_UNAVAILABLE" in state.blockers + + +@pytest.mark.parametrize("evidence", [("BULLISH_ENGULFING:1d",), ("UNKNOWN_TRIGGER",)]) +def test_non_execution_timeframe_cannot_confirm_entry( + evidence: tuple[str, ...], +) -> None: + results = _evidence_results() + results["price_action"] = _result("price_action", evidence=evidence) + state = TradingIntelligenceEngine().build(_costed_snapshot(), results) + assert state.selected_scenario is not None + assert state.selected_scenario.state is ScenarioState.FORMING + + +def test_conflicting_entry_timeframes_cannot_be_averaged_away() -> None: + results = _evidence_results() + results["price_action"] = _result( + "price_action", + vote=0, + evidence=("BULLISH_ENGULFING:15m", "BEARISH_ENGULFING:5m"), + ) + state = TradingIntelligenceEngine().build(_costed_snapshot(), results) + assert state.selected_scenario is None + assert "ENTRY_TRIGGER_CONFLICT" in state.blockers + + +@pytest.mark.parametrize("lifecycle", ["FAILED", "INVALIDATED", "FORMING", "UNKNOWN"]) +def test_unconfirmed_trigger_lifecycle_cannot_confirm_scenario(lifecycle: str) -> None: + results = _evidence_results() + results["price_action"] = replace( + results["price_action"], calculation_metadata={"lifecycle_state": lifecycle} + ) + state = TradingIntelligenceEngine().build(_costed_snapshot(), results) + assert state.selected_scenario is not None + assert state.selected_scenario.state is ScenarioState.FORMING + assert not any( + item.startswith("pattern:") for item in state.selected_scenario.evidence_for + ) + + +def test_missing_costs_deny_spot_candidate_and_preserve_missing_provenance() -> None: + engine = TradingIntelligenceEngine() + state = engine.build(technical_snapshot(), _evidence_results()) + (candidate,) = engine.bind_candidates((_candidate(),), state) + assert candidate.status is CandidateStatus.RESEARCH_ONLY + assert candidate.entry_state == "ENTRY_NOT_READY" + assert "NET_RISK_REWARD_UNAVAILABLE" in candidate.blockers + assert candidate.target_sources == () + + +def test_zero_fee_is_valid_and_net_rr_includes_loss_side_costs() -> None: + snapshot = replace( + _costed_snapshot(), + market_metadata={ + "estimated_fee_ratio": 0, + "fee_ratio": "0.05", + "estimated_slippage_ratio": 0, + }, + ) + engine = TradingIntelligenceEngine() + state = engine.build(snapshot, _evidence_results()) + assert snapshot.spread is not None + assert snapshot.latest_price is not None + assert ( + state.estimated_round_trip_cost_ratio == snapshot.spread / snapshot.latest_price + ) + (candidate,) = engine.bind_candidates((_candidate(),), state) + assert candidate.net_risk_reward == ( + Decimal("0.19") - candidate.entry_price * state.estimated_round_trip_cost_ratio + ) / candidate_risk_distance(candidate) + + +def test_validation_rechecks_candidate_and_requires_sizing_evidence() -> None: + engine = TradingIntelligenceEngine() + snapshot = _costed_snapshot() + (candidate,) = engine.bind_candidates( + (_candidate(),), engine.build(snapshot, _evidence_results()) + ) + candidate = replace( + candidate, status=CandidateStatus.RESEARCH_ONLY, blockers=("REVOKED_EVIDENCE",) + ) + results = { + "risk": _result( + "risk", + metadata={ + "candidate_id": candidate.candidate_id, + "scenario_id": candidate.scenario_id, + "approved": True, + }, + ) + } + signal = ValidationGate().validate(snapshot, results, candidates=(candidate,)) + assert "REVOKED_EVIDENCE" in signal.blockers + assert "RISK_SIZING_EVIDENCE_INVALID" in signal.blockers + assert "RISK_APPROVAL_MISSING" in signal.blockers + assert signal.execution_allowed is False + + +def test_risk_rejects_forged_scenario_net_rr() -> None: + engine = TradingIntelligenceEngine() + snapshot = _costed_snapshot() + (candidate,) = engine.bind_candidates( + (_candidate(),), engine.build(snapshot, _evidence_results()) + ) + assessment = RiskEngine().evaluate( + replace(candidate, net_risk_reward=Decimal("99")), + snapshot, + RiskContext(equity_usdt=Decimal("1000")), + symbol_filters(), + ) + assert assessment.approved is False + assert "NET_RISK_REWARD_INCONSISTENT" in assessment.blockers + + +def test_shared_state_rejects_cross_snapshot_scenarios() -> None: + state = TradingIntelligenceEngine().build(_costed_snapshot(), _evidence_results()) + with pytest.raises(ValueError, match="snapshot identity"): + replace(state, scenarios=(replace(state.scenarios[0], snapshot_id="foreign"),)) + with pytest.raises(ValueError, match="blocked intelligence"): + replace(state, blockers=("REVOKED",)) + + +@pytest.mark.parametrize( + "invalid", [Decimal("NaN"), Decimal("Infinity"), Decimal("-Infinity")] +) +def test_nonfinite_risk_contracts_fail_closed(invalid: Decimal) -> None: + with pytest.raises(ValueError, match="equity_usdt"): + RiskContext(equity_usdt=invalid) + with pytest.raises(ValueError, match="risk assessment"): + RiskAssessment("candidate", False, invalid) + with pytest.raises(ValueError, match="stop_loss"): + replace(_candidate(), stop_loss=invalid) + assessment = VirtualPortfolioRiskGovernor().assess_simulated_leverage( + requested_leverage=3, + position_notional_usdt=invalid, + available_margin_usdt=Decimal("1000"), + margin_utilization_ratio=Decimal("0"), + strategy_oos_approved=True, + ) + assert assessment.state is SimulatedLeverageState.BLOCKED + + +def test_risk_assessment_retains_positional_compatibility() -> None: + assessment = RiskAssessment( + "candidate", True, Decimal("100"), Decimal("1"), Decimal("5") + ) + assert assessment.size_usdt == Decimal("100") + assert assessment.scenario_id is None + + +def test_trend_break_is_evaluated_outside_its_fitted_window() -> None: + snapshot = technical_snapshot() + candles = tuple(snapshot.ohlcv_by_timeframe["1d"]) + last = replace(candles[-1], close=Decimal("2"), high=Decimal("2.1")) + snapshot = replace( + snapshot, + ohlcv_by_timeframe={**snapshot.ohlcv_by_timeframe, "1d": (*candles[:-1], last)}, + ) + (geometry,) = TrendGeometryEngine().build( + snapshot, (), _result("trend_channel", metadata={"source_timeframe": "1d"}) + ) + assert geometry.state == "TRENDLINE_BREAK" + assert geometry.break_state == "BROKEN" + assert geometry.anchor_points[-1][0] == candles[-3].timestamp + assert geometry.anchor_points[-1][1] == geometry.intercept + geometry.slope * 27 + + +def test_entry_expiry_overflow_returns_unavailable() -> None: + assert TradingIntelligenceEngine._entry_expiry(NOW, "999999999999999999d") is None + + +def test_futures_context_rejects_foreign_agent_identity() -> None: + context = FuturesContextEngine().build( + replace(technical_snapshot(), market_type="USD_M_FUTURES"), + replace(_result("derivatives"), snapshot_id="foreign"), + ) + assert context.blockers == ("FUTURES_DERIVATIVES_IDENTITY_MISMATCH",) + + +def test_futures_costs_require_explicit_funding_horizon() -> None: + snapshot = replace( + _costed_snapshot(), + market_type="USD_M_FUTURES", + derivatives_snapshot={ + "source_count": 4, + "as_of": NOW.isoformat(), + "funding_rate": "0.001", + "open_interest": "500", + "mark_price": "1.1", + "index_price": "1.1", + }, + ) + results = {**_evidence_results(), "derivatives": _result("derivatives")} + engine = TradingIntelligenceEngine() + missing = engine.build(snapshot, results) + assert missing.estimated_round_trip_cost_ratio is None + assert "COST_FUNDING_HORIZON_UNAVAILABLE" in missing.cost_blockers + zero = engine.build( + replace( + snapshot, + market_metadata={ + **snapshot.market_metadata, + "estimated_funding_periods": 0, + }, + ), + results, + ) + two = engine.build( + replace( + snapshot, + market_metadata={ + **snapshot.market_metadata, + "estimated_funding_periods": 2, + }, + ), + results, + ) + assert zero.estimated_round_trip_cost_ratio is not None + assert ( + two.estimated_round_trip_cost_ratio + == zero.estimated_round_trip_cost_ratio + Decimal("0.002") + ) + + +def test_invalid_derivatives_alias_does_not_override_canonical_metric() -> None: + snapshot = replace( + technical_snapshot(), + market_type="USD_M_FUTURES", + derivatives_snapshot={ + "source_count": 4, + "as_of": NOW.isoformat(), + "funding_rate": "0.001", + "open_interest": "NaN", + "openInterest": "500", + "mark_price": "1.1", + "index_price": "1.1", + "taker_buy_sell_ratio": "-1", + }, + ) + context = FuturesContextEngine().build(snapshot, _result("derivatives")) + assert context.status == "BLOCKED" + assert "FUTURES_METRIC_MISSING:OPEN_INTEREST" in context.blockers + assert "FUTURES_METRIC_INVALID:TAKER_BUY_SELL_RATIO" in context.blockers + + +def test_missing_and_future_directional_structure_cannot_confirm_scenario() -> None: + results = _evidence_results() + original = results["market_structure"] + timeframes = original.calculation_metadata["timeframes"] + assert isinstance(timeframes, Mapping) + raw = dict(timeframes) + raw.pop("4h") + results["market_structure"] = replace( + original, calculation_metadata={"timeframes": raw} + ) + state = TradingIntelligenceEngine().build(_costed_snapshot(), results) + assert "DIRECTION_STRUCTURE_UNAVAILABLE" in state.blockers + raw = dict(timeframes) + directional = dict(raw["4h"]) + swing = { + **directional["swings"][0], + "available_at": (NOW + timedelta(hours=1)).isoformat(), + } + raw["4h"] = {**directional, "swings": (swing,)} + results["market_structure"] = replace( + original, calculation_metadata={"timeframes": raw} + ) + state = TradingIntelligenceEngine().build(_costed_snapshot(), results) + assert state.selected_scenario is None + assert "DIRECTION_STRUCTURE_UNAVAILABLE" in state.blockers diff --git a/tests/test_virtual_runtime.py b/tests/test_virtual_runtime.py index abccd7b9..7fae5c8a 100644 --- a/tests/test_virtual_runtime.py +++ b/tests/test_virtual_runtime.py @@ -4324,6 +4324,49 @@ def test_virtual_market_runtime_opens_futures_long_with_margin_and_funding_evide assert decision.portfolio_after.liquidation_price == Decimal("77") +@pytest.mark.parametrize( + ("margin", "fee", "expected_block"), + [("600", "0", False), ("601", "0", True), ("600", "0.001", True)], +) +def test_futures_entry_checks_projected_margin_after_costs( + margin: str, fee: str, expected_block: bool +) -> None: + request = approved_virtual_runtime_request( + snapshot_id="snapshot:projected-margin", + decision_id="dge:projected-margin", + candidate_id="candidate:projected-margin", + symbol="BTCUSDT", + market="USD_M_FUTURES", + action=Action.BUY, + quantity=Decimal("2"), + entry_price=Decimal("100"), + stop_loss=Decimal("95"), + take_profit_levels=(Decimal("110"),), + position_side=VirtualPositionSide.LONG, + mark_price=Decimal("100"), + funding_rate=Decimal("0"), + leverage=5, + isolated_margin_usdt=Decimal(margin), + maintenance_margin_ratio=Decimal("0.02"), + fee_ratio=Decimal(fee), + portfolio=VirtualPortfolioState( + portfolio_id="virtual:projected-margin", + market="USD_M_FUTURES", + cash_usdt=Decimal("1000"), + equity_usdt=Decimal("1000"), + ), + ) + decision = VirtualMarketRuntime().evaluate(request) + assert ( + "FUTURES_MARGIN_UTILIZATION_LIMIT_EXCEEDED" in decision.eligibility.blockers + ) is expected_block + if expected_block: + assert decision.trade_intent is None + assert decision.portfolio_after == request.portfolio + else: + assert decision.status is VirtualRuntimeDecisionStatus.ORDER_READY + + def test_virtual_market_runtime_opens_futures_short_with_mirrored_geometry() -> None: runtime = VirtualMarketRuntime() decision = runtime.evaluate(