Renato commited on
Commit
a136c48
·
1 Parent(s): e7afdfb

feat: register and audit prospective alpha calls by market

Browse files
Files changed (41) hide show
  1. .github/workflows/deploy.yml +2 -1
  2. CHANGELOG.md +10 -0
  3. README.md +3 -2
  4. backend/alembic/versions/0045_alpha_call_registry.py +31 -0
  5. backend/app/api/routers/alpha.py +5 -0
  6. backend/app/api/routers/runtime.py +2 -2
  7. backend/app/core/config.py +1 -1
  8. backend/app/core/release.py +23 -0
  9. backend/app/data/public_api_routes.json +1 -1
  10. backend/app/engine/brain/trader_brain.py +14 -4
  11. backend/app/engine/contracts.py +1 -1
  12. backend/app/engine/facade.py +6 -1
  13. backend/app/models.py +16 -0
  14. backend/app/services/alpha_desk.py +299 -0
  15. backend/app/services/forex_trader.py +6 -0
  16. backend/app/services/live_forward_paper_trading.py +16 -0
  17. backend/tests/test_alpha_desk.py +379 -0
  18. backend/tests/test_migration_chain.py +2 -1
  19. backend/tests/test_release_content_hash.py +30 -0
  20. backend/tests/test_restore_failure_guard.py +56 -0
  21. backend/tests/test_space_startup.py +1 -1
  22. backend/tests/test_trading_ml_persistence.py +1 -1
  23. cloudflare/package-lock.json +2 -2
  24. cloudflare/package.json +1 -1
  25. cloudflare/src/index.ts +4 -2
  26. cloudflare/tests/gateway.test.mjs +7 -2
  27. cloudflare/wrangler.jsonc +2 -1
  28. docs/ALPHA_DESK.md +41 -0
  29. docs/DEPLOYMENT.md +7 -2
  30. docs/superpowers/plans/2026-10-13-verified-alpha-desk.md +65 -0
  31. docs/superpowers/specs/2026-10-13-verified-alpha-desk-design.md +36 -0
  32. frontend/app/alpha/page.tsx +2 -0
  33. frontend/components/AlphaDesk.tsx +58 -0
  34. frontend/lib/alphaDeskView.d.mts +5 -0
  35. frontend/lib/alphaDeskView.mjs +18 -0
  36. frontend/package-lock.json +2 -2
  37. frontend/package.json +1 -1
  38. frontend/tests/alpha-desk-view.test.mjs +18 -0
  39. package.json +1 -1
  40. scripts/start.sh +36 -4
  41. scripts/verify_deployment.py +11 -4
.github/workflows/deploy.yml CHANGED
@@ -62,6 +62,7 @@ jobs:
62
  WRANGLER_SEND_METRICS: 'false'
63
  run: |
64
  test -n "$CLOUDFLARE_API_TOKEN" || { echo "Configure the production Cloudflare deployment secret."; exit 1; }
65
- npm --prefix cloudflare run deploy -- --var "SOURCE_REVISION:$(git rev-parse HEAD)"
 
66
  - name: Verify public release
67
  run: python scripts/verify_deployment.py --timeout 120
 
62
  WRANGLER_SEND_METRICS: 'false'
63
  run: |
64
  test -n "$CLOUDFLARE_API_TOKEN" || { echo "Configure the production Cloudflare deployment secret."; exit 1; }
65
+ source_content_hash=$(PYTHONPATH=backend python -c 'from app.core.release import source_content_hash; print(source_content_hash())')
66
+ npm --prefix cloudflare run deploy -- --var "SOURCE_REVISION:$(git rev-parse HEAD)" --var "SOURCE_CONTENT_HASH:$source_content_hash"
67
  - name: Verify public release
68
  run: python scripts/verify_deployment.py --timeout 120
CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
  # Changelog
2
 
 
 
 
 
 
 
 
 
 
 
3
  ## v2.2.0 | ASCENT and canonical release
4
 
5
  - Added bounded ASCENT calibration source experiments, frozen temporal holdouts, independent gates, persistent lineage and Evolution.
 
1
  # Changelog
2
 
3
+ ## 2.3.0 — 2026-10-14
4
+
5
+ - Register new equity and Forex calls prospectively, preserving source identity, frozen inputs, timestamps and protected-code fingerprints.
6
+ - Add a read-only Alpha Desk with separate markets, explicit exclusions, net-cost reconciliation and closure-date confidence intervals.
7
+ - Include older closed trades in Alpha and exclude expired unfilled orders; remove duplicate live-forward sample counts.
8
+ - Mark unverified historical positive grades as insufficient evidence.
9
+ - Stop startup after failed database restores. Periodic embedded backups still do not guarantee zero-loss recovery.
10
+ - Verify actual protected backend/script content during automated deployment, in addition to source revision and version.
11
+
12
+
13
  ## v2.2.0 | ASCENT and canonical release
14
 
15
  - Added bounded ASCENT calibration source experiments, frozen temporal holdouts, independent gates, persistent lineage and Evolution.
README.md CHANGED
@@ -46,14 +46,15 @@ The supported configuration is paper trading and research. There is no broker ex
46
 
47
  These pages read snapshots; opening a page does not launch training or research. The public gateway permits only reviewed GET/HEAD APIs. Legacy diagnostic operations remain private. Administrative research and deployment use private credentials and trusted maintainer tools.
48
 
49
- ## What ships in 2.2
50
 
 
51
  - **Bounded source evolution.** ASCENT generates four actual calibration source candidates per generation, tests them, freezes selection and evaluates the selected candidate against untouched evidence. Candidates can alter confidence calibration only.
52
  - **Capability-free evaluation.** A restricted arithmetic interpreter permits finite constants, the scalar confidence input, arithmetic and min/max. Candidate code receives no filesystem, environment, network or host execution.
53
  - **Independent validation.** Purged chronological development folds, a frozen holdout, baseline comparisons, asset/regime coverage, calibration regression checks and conservative time-block uncertainty gates determine retention.
54
  - **Persistent experiments.** SQL checkpoints, source/evaluator/data hashes, consumed-holdout records and private artifacts preserve lineage and failures.
55
  - **Data rejection at the boundary.** Invalid price candles and conflicting Forex records are excluded. Old external Forex projections and advice are rejected by the current policy.
56
- - **Reproducible releases.** Locked web dependencies, backend/migration checks, gateway tests, reviewed data hashes and one-way GitHub deployment.
57
 
58
  ASCENT retains research artifacts. It does not automatically merge code, replace a production champion, promote an HF model or place orders. Its budget is at most 2,000 observations, four candidates and 90 cooperative seconds per cycle, with at most eight generations per day. It uses no external inference or paid GPU job. Database operations are not forcibly preempted.
59
 
 
46
 
47
  These pages read snapshots; opening a page does not launch training or research. The public gateway permits only reviewed GET/HEAD APIs. Legacy diagnostic operations remain private. Administrative research and deployment use private credentials and trusted maintainer tools.
48
 
49
+ ## What ships in 2.3
50
 
51
+ - **Prospective Alpha Desk.** New equity and Forex decisions are registered before outcomes with frozen input hashes, provider/availability metadata, an actual registration timestamp and a protected-code fingerprint. Markets are evaluated separately; stale inputs, unfilled orders and unreconciled costs cannot create a qualified track record. [Read the evaluation contract](docs/ALPHA_DESK.md).
52
  - **Bounded source evolution.** ASCENT generates four actual calibration source candidates per generation, tests them, freezes selection and evaluates the selected candidate against untouched evidence. Candidates can alter confidence calibration only.
53
  - **Capability-free evaluation.** A restricted arithmetic interpreter permits finite constants, the scalar confidence input, arithmetic and min/max. Candidate code receives no filesystem, environment, network or host execution.
54
  - **Independent validation.** Purged chronological development folds, a frozen holdout, baseline comparisons, asset/regime coverage, calibration regression checks and conservative time-block uncertainty gates determine retention.
55
  - **Persistent experiments.** SQL checkpoints, source/evaluator/data hashes, consumed-holdout records and private artifacts preserve lineage and failures.
56
  - **Data rejection at the boundary.** Invalid price candles and conflicting Forex records are excluded. Old external Forex projections and advice are rejected by the current policy.
57
+ - **Reproducible releases.** Locked web dependencies, backend/migration checks, gateway tests, reviewed data hashes and one-way GitHub deployment. The backend reports a fingerprint computed from its actual protected runtime files; deployment verification checks this against the verified checkout.
58
 
59
  ASCENT retains research artifacts. It does not automatically merge code, replace a production champion, promote an HF model or place orders. Its budget is at most 2,000 observations, four candidates and 90 cooperative seconds per cycle, with at most eight generations per day. It uses no external inference or paid GPU job. Database operations are not forcibly preempted.
60
 
backend/alembic/versions/0045_alpha_call_registry.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Register prospective alpha calls without modifying legacy evidence."""
2
+ from alembic import op
3
+ import sqlalchemy as sa
4
+ from sqlalchemy.dialects.postgresql import JSONB
5
+
6
+ revision = '0045_alpha_call_registry'
7
+ down_revision = '0044_ascent_evolution'
8
+ branch_labels = None
9
+ depends_on = None
10
+
11
+
12
+ def upgrade():
13
+ op.create_table('alpha_call_registrations',
14
+ sa.Column('id',sa.Integer(),primary_key=True),
15
+ sa.Column('source_uid',sa.String(220),nullable=False,unique=True),
16
+ sa.Column('source_type',sa.String(32),nullable=False),
17
+ sa.Column('source_id',sa.Integer(),nullable=False),
18
+ sa.Column('market',sa.String(16),nullable=False),
19
+ sa.Column('asset',sa.String(32),nullable=False),
20
+ sa.Column('decision_at',sa.DateTime(),nullable=False),
21
+ sa.Column('registered_at',sa.DateTime(),nullable=False),
22
+ sa.Column('source_revision',sa.String(40),nullable=False),
23
+ sa.Column('source_content_hash',sa.String(64),nullable=False),
24
+ sa.Column('manifest_json',sa.JSON().with_variant(JSONB,'postgresql'),nullable=False),
25
+ sa.Column('manifest_sha256',sa.String(64),nullable=False))
26
+ op.create_index('ix_alpha_call_registrations_market','alpha_call_registrations',['market'])
27
+ op.create_index('ix_alpha_call_registrations_registered_at','alpha_call_registrations',['registered_at'])
28
+
29
+
30
+ def downgrade():
31
+ op.drop_table('alpha_call_registrations')
backend/app/api/routers/alpha.py CHANGED
@@ -10,6 +10,11 @@ from app.engine.facade import BlumEngineFacade
10
  router = APIRouter(tags=["Alpha"])
11
 
12
 
 
 
 
 
 
13
  @router.get("/trader-brain/alpha")
14
  @router.get("/api/trader-brain/alpha")
15
  def alpha(db: Session = Depends(get_db)) -> dict:
 
10
  router = APIRouter(tags=["Alpha"])
11
 
12
 
13
+ @router.get("/api/alpha/desk")
14
+ def alpha_desk(db: Session = Depends(get_db)) -> dict:
15
+ return BlumEngineFacade().alpha_desk_snapshot(db)
16
+
17
+
18
  @router.get("/trader-brain/alpha")
19
  @router.get("/api/trader-brain/alpha")
20
  def alpha(db: Session = Depends(get_db)) -> dict:
backend/app/api/routers/runtime.py CHANGED
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, Query
4
  from sqlalchemy.orm import Session
5
 
6
  from app.core.config import get_settings
7
- from app.core.release import source_revision
8
  from app.core.database import get_db
9
  from app.runtime.facade import BlumRuntimeFacade
10
  from app.services.central_brain_runtime import CentralBrainRuntime, LearningHealthService, SnapshotProducerService, SnapshotWatchdogService
@@ -18,7 +18,7 @@ router = APIRouter(tags=["Runtime"])
18
 
19
  @router.get("/api/runtime/release")
20
  def runtime_release() -> dict:
21
- return {"version": settings.app_version, "source_revision": source_revision(),
22
  "mode": "paper_research", "github": "https://github.com/SignalLayerLabs/Blum"}
23
 
24
 
 
4
  from sqlalchemy.orm import Session
5
 
6
  from app.core.config import get_settings
7
+ from app.core.release import source_revision, source_content_hash
8
  from app.core.database import get_db
9
  from app.runtime.facade import BlumRuntimeFacade
10
  from app.services.central_brain_runtime import CentralBrainRuntime, LearningHealthService, SnapshotProducerService, SnapshotWatchdogService
 
18
 
19
  @router.get("/api/runtime/release")
20
  def runtime_release() -> dict:
21
+ return {"version": settings.app_version, "source_revision": source_revision(), "source_content_hash": source_content_hash(),
22
  "mode": "paper_research", "github": "https://github.com/SignalLayerLabs/Blum"}
23
 
24
 
backend/app/core/config.py CHANGED
@@ -5,7 +5,7 @@ from pydantic_settings import BaseSettings
5
 
6
  class Settings(BaseSettings):
7
  app_name: str = "Blum AI Financial Intelligence"
8
- app_version: str = "2.2.0"
9
  public_observatory_enabled: bool = Field(default=False, alias="BLUM_PUBLIC_OBSERVATORY_ENABLED")
10
  environment: str = Field(default="demo", alias="ENVIRONMENT")
11
  database_url: str = Field(
 
5
 
6
  class Settings(BaseSettings):
7
  app_name: str = "Blum AI Financial Intelligence"
8
+ app_version: str = "2.3.0"
9
  public_observatory_enabled: bool = Field(default=False, alias="BLUM_PUBLIC_OBSERVATORY_ENABLED")
10
  environment: str = Field(default="demo", alias="ENVIRONMENT")
11
  database_url: str = Field(
backend/app/core/release.py CHANGED
@@ -2,6 +2,8 @@
2
  from __future__ import annotations
3
 
4
  import json
 
 
5
  import os
6
  from pathlib import Path
7
  import re
@@ -10,6 +12,27 @@ import subprocess
10
  BUILD_REVISION = Path(__file__).parents[1] / 'data' / 'build_revision.json'
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def source_revision() -> str:
14
  if BUILD_REVISION.is_file():
15
  try:
 
2
  from __future__ import annotations
3
 
4
  import json
5
+ import hashlib
6
+ from functools import lru_cache
7
  import os
8
  from pathlib import Path
9
  import re
 
12
  BUILD_REVISION = Path(__file__).parents[1] / 'data' / 'build_revision.json'
13
 
14
 
15
+ def content_hash_for_root(root: Path) -> str:
16
+ """Fingerprint protected runtime code, independently of declared revision variables."""
17
+ files = [*root.joinpath('backend/app').rglob('*.py'),
18
+ *root.joinpath('scripts').rglob('*.py'),
19
+ *root.joinpath('scripts').rglob('*.sh')]
20
+ result = hashlib.sha256()
21
+ for file in sorted(set(files), key=lambda path: path.relative_to(root).as_posix()):
22
+ relative = file.relative_to(root).as_posix().encode()
23
+ content = file.read_bytes()
24
+ result.update(len(relative).to_bytes(8, 'big'))
25
+ result.update(relative)
26
+ result.update(len(content).to_bytes(8, 'big'))
27
+ result.update(content)
28
+ return result.hexdigest()
29
+
30
+
31
+ @lru_cache(maxsize=1)
32
+ def source_content_hash() -> str:
33
+ return content_hash_for_root(Path(__file__).parents[3])
34
+
35
+
36
  def source_revision() -> str:
37
  if BUILD_REVISION.is_file():
38
  try:
backend/app/data/public_api_routes.json CHANGED
@@ -2,7 +2,7 @@
2
  "/health", "/startup/status", "/system/status",
3
  "/api/brain/snapshot", "/api/training/snapshot", "/api/paper-trading/snapshot",
4
  "/api/paper-trading/trades/{source_engine}/{source_trade_id}",
5
- "/api/alpha/snapshot", "/api/ascent/status",
6
  "/api/runtime/release", "/api/runtime/status", "/api/runtime/contracts",
7
  "/api/engine/status", "/api/engine/contracts", "/api/engine/agents",
8
  "/api/analyst/status", "/api/analyst/contracts", "/api/architecture/contracts",
 
2
  "/health", "/startup/status", "/system/status",
3
  "/api/brain/snapshot", "/api/training/snapshot", "/api/paper-trading/snapshot",
4
  "/api/paper-trading/trades/{source_engine}/{source_trade_id}",
5
+ "/api/alpha/snapshot", "/api/alpha/desk", "/api/ascent/status",
6
  "/api/runtime/release", "/api/runtime/status", "/api/runtime/contracts",
7
  "/api/engine/status", "/api/engine/contracts", "/api/engine/agents",
8
  "/api/analyst/status", "/api/analyst/contracts", "/api/architecture/contracts",
backend/app/engine/brain/trader_brain.py CHANGED
@@ -291,6 +291,10 @@ class TraderBrainService:
291
  live_game = latest_row(db, LiveForwardPaperGame)
292
  benchmarks = latest_benchmarks(db)
293
  paper_rows = db.scalars(select(LiveForwardPaperTrade).order_by(desc(LiveForwardPaperTrade.created_at)).limit(500)).all()
 
 
 
 
294
  excluded_evidence_types = {
295
  "PAPER_FORWARD_INTRADAY_EXPERIMENTAL",
296
  "PAPER_FORWARD_INVALID_ENTRY_GEOMETRY",
@@ -334,7 +338,8 @@ class TraderBrainService:
334
  verdict = alpha_verdict_from_splits(evidence_split, evidence_grade)
335
  latest_update = latest_alpha_update_from_splits(evidence_split) or latest_paper_forward_update(eligible_paper_rows, benchmarks)
336
  primary_evidence = primary_alpha_evidence(evidence_split)
337
- total_evidence_sample = sum(int(split.get("sample_size") or 0) for split in evidence_split.values() if isinstance(split, dict))
 
338
  unified_payload = unified_paper.get("payload") or {}
339
  unified_counts = (unified_payload.get("counts") or {}).get("aggregate") or {}
340
  unified_metrics = (unified_payload.get("metrics") or {}).get("aggregate") or {}
@@ -1569,8 +1574,9 @@ def trading_game_evidence_split(rows: list[TradingGameTrade], *, label: str, dat
1569
  "win_rate": round(wins / len(rows), 4) if rows else None,
1570
  "profit_factor": profit_factor,
1571
  "max_drawdown": average_present(drawdown_values),
1572
- "evidence_grade": grade,
1573
- "evidence_reason": reason,
 
1574
  "data_source": data_source,
1575
  "last_updated_at": latest_iso([row.created_at for row in rows if row.created_at]),
1576
  "results": [historical_trade_result(row) for row in rows[:6]],
@@ -1888,11 +1894,15 @@ def latest_iso(values: list[Any]) -> str | None:
1888
 
1889
 
1890
  def paper_forward_trade_is_closed(row: LiveForwardPaperTrade) -> bool:
 
 
 
 
1891
  return bool(
1892
  row.closed_at
1893
  or row.exit_price is not None
1894
  or row.close_reason
1895
- or str(row.status or "").upper() in {"CLOSED", "EXITED", "EXPIRED", "INVALIDATED"}
1896
  )
1897
 
1898
 
 
291
  live_game = latest_row(db, LiveForwardPaperGame)
292
  benchmarks = latest_benchmarks(db)
293
  paper_rows = db.scalars(select(LiveForwardPaperTrade).order_by(desc(LiveForwardPaperTrade.created_at)).limit(500)).all()
294
+ closed_history = db.scalars(select(LiveForwardPaperTrade)
295
+ .where(LiveForwardPaperTrade.status == "CLOSED")
296
+ .order_by(desc(LiveForwardPaperTrade.closed_at), desc(LiveForwardPaperTrade.id)).limit(500)).all()
297
+ paper_rows = list({row.id: row for row in [*paper_rows, *closed_history]}.values())
298
  excluded_evidence_types = {
299
  "PAPER_FORWARD_INTRADAY_EXPERIMENTAL",
300
  "PAPER_FORWARD_INVALID_ENTRY_GEOMETRY",
 
338
  verdict = alpha_verdict_from_splits(evidence_split, evidence_grade)
339
  latest_update = latest_alpha_update_from_splits(evidence_split) or latest_paper_forward_update(eligible_paper_rows, benchmarks)
340
  primary_evidence = primary_alpha_evidence(evidence_split)
341
+ # live_forward is a second view of paper_forward, not independent observations.
342
+ total_evidence_sample = sum(int(split.get("sample_size") or 0) for key, split in evidence_split.items() if isinstance(split, dict) and key != "live_forward")
343
  unified_payload = unified_paper.get("payload") or {}
344
  unified_counts = (unified_payload.get("counts") or {}).get("aggregate") or {}
345
  unified_metrics = (unified_payload.get("metrics") or {}).get("aggregate") or {}
 
1574
  "win_rate": round(wins / len(rows), 4) if rows else None,
1575
  "profit_factor": profit_factor,
1576
  "max_drawdown": average_present(drawdown_values),
1577
+ "evidence_grade": grade if grade in {"NO_DATA", "WEAK"} else "INSUFFICIENT_EVIDENCE",
1578
+ "evidence_reason": "Legacy historical results lack independently verified input and evaluation provenance; descriptive returns are not a validated alpha claim.",
1579
+ "provenance_status": "UNVERIFIED_LEGACY",
1580
  "data_source": data_source,
1581
  "last_updated_at": latest_iso([row.created_at for row in rows if row.created_at]),
1582
  "results": [historical_trade_result(row) for row in rows[:6]],
 
1894
 
1895
 
1896
  def paper_forward_trade_is_closed(row: LiveForwardPaperTrade) -> bool:
1897
+ if str(row.status or "").upper() in {"EXPIRED", "INVALIDATED", "SKIPPED", "REJECTED", "DATA_BLOCKED"}:
1898
+ return False
1899
+ if str(row.outcome_label or row.close_reason or "").upper() in {"ORDER_NOT_FILLED", "SIGNAL_DECAY_BEFORE_ENTRY"}:
1900
+ return False
1901
  return bool(
1902
  row.closed_at
1903
  or row.exit_price is not None
1904
  or row.close_reason
1905
+ or str(row.status or "").upper() in {"CLOSED", "EXITED"}
1906
  )
1907
 
1908
 
backend/app/engine/contracts.py CHANGED
@@ -4,7 +4,7 @@ from dataclasses import asdict, dataclass, field
4
  from typing import Any, Literal
5
 
6
 
7
- ENGINE_VERSION = "2.2.0"
8
  PROJECT_FEATURE_SET = "clean-core"
9
  BLUM_ANALYST_REPOSITORY = "Italianhype/Blum-Analyst"
10
 
 
4
  from typing import Any, Literal
5
 
6
 
7
+ ENGINE_VERSION = "2.3.0"
8
  PROJECT_FEATURE_SET = "clean-core"
9
  BLUM_ANALYST_REPOSITORY = "Italianhype/Blum-Analyst"
10
 
backend/app/engine/facade.py CHANGED
@@ -127,7 +127,12 @@ class BlumEngineFacade:
127
  return UnifiedPaperTradingProjectionService().detail(db, source_engine, source_trade_id)
128
 
129
  def alpha_snapshot(self, db: Session) -> dict:
130
- return TraderBrainService().alpha(db)
 
 
 
 
 
131
 
132
  def agent_evidence(self, db: Session, *, agents: list[str] | None = None, limit: int = 8) -> dict:
133
  return collect_agent_evidence(db, names=agents, limit=limit)
 
127
  return UnifiedPaperTradingProjectionService().detail(db, source_engine, source_trade_id)
128
 
129
  def alpha_snapshot(self, db: Session) -> dict:
130
+ from app.services.alpha_desk import AlphaDeskService
131
+ return {**TraderBrainService().alpha(db), "registered_alpha_desk": AlphaDeskService().snapshot(db)}
132
+
133
+ def alpha_desk_snapshot(self, db: Session) -> dict:
134
+ from app.services.alpha_desk import AlphaDeskService
135
+ return AlphaDeskService().snapshot(db)
136
 
137
  def agent_evidence(self, db: Session, *, agents: list[str] | None = None, limit: int = 8) -> dict:
138
  return collect_agent_evidence(db, names=agents, limit=limit)
backend/app/models.py CHANGED
@@ -10,6 +10,22 @@ from app.core.database import Base
10
  JsonType = JSON().with_variant(JSONB, "postgresql")
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  class AscentGeneration(Base):
14
  __tablename__ = "ascent_generations"
15
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
 
10
  JsonType = JSON().with_variant(JSONB, "postgresql")
11
 
12
 
13
+ class AlphaCallRegistration(Base):
14
+ __tablename__ = "alpha_call_registrations"
15
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
16
+ source_uid: Mapped[str] = mapped_column(String(220), unique=True)
17
+ source_type: Mapped[str] = mapped_column(String(32))
18
+ source_id: Mapped[int] = mapped_column(Integer)
19
+ market: Mapped[str] = mapped_column(String(16), index=True)
20
+ asset: Mapped[str] = mapped_column(String(32))
21
+ decision_at: Mapped[datetime] = mapped_column(DateTime)
22
+ registered_at: Mapped[datetime] = mapped_column(DateTime, index=True)
23
+ source_revision: Mapped[str] = mapped_column(String(40))
24
+ source_content_hash: Mapped[str] = mapped_column(String(64))
25
+ manifest_json: Mapped[dict] = mapped_column(JsonType)
26
+ manifest_sha256: Mapped[str] = mapped_column(String(64))
27
+
28
+
29
  class AscentGeneration(Base):
30
  __tablename__ = "ascent_generations"
31
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
backend/app/services/alpha_desk.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prospective call registration and bounded, market-separated evidence reads.
2
+
3
+ The registry proves what this deployment recorded, not external data correctness
4
+ or competitor superiority. It never upgrades legacy records into forward calls.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from collections import Counter, defaultdict
9
+ from datetime import date, datetime, timezone
10
+ import hashlib
11
+ import json
12
+ import math
13
+ import random
14
+
15
+ from sqlalchemy import desc, func, select
16
+
17
+ from app.core.release import source_revision, source_content_hash
18
+ from app.models import AlphaCallRegistration, ForexDecision, ForexPosition, LiveForwardPaperTrade
19
+ from app.services.paper_forward_direction import is_forex_identity, normalize_side, paper_trade_evidence_is_eligible, validate_trade_plan
20
+ from app.services.persistence import database_persistence_status
21
+
22
+ WINDOW = 2000
23
+ METHODOLOGY = 'prospective-alpha-desk-v1'
24
+
25
+
26
+ def utc(value):
27
+ if isinstance(value,str):
28
+ try:
29
+ value=datetime.fromisoformat(value.replace('Z','+00:00'))
30
+ except ValueError:
31
+ return None
32
+ if isinstance(value,date) and not isinstance(value,datetime):
33
+ value=datetime.combine(value,datetime.min.time())
34
+ if not isinstance(value,datetime):
35
+ return None
36
+ return value.astimezone(timezone.utc).replace(tzinfo=None) if value.tzinfo else value
37
+
38
+
39
+ def number(value):
40
+ if isinstance(value,bool) or not isinstance(value,(int,float)):
41
+ return None
42
+ return float(value) if math.isfinite(value) else None
43
+
44
+
45
+ def mapping(value):
46
+ return value if isinstance(value,dict) else {}
47
+
48
+
49
+ def provider_name(value):
50
+ return isinstance(value,str) and bool(value.strip())
51
+
52
+
53
+ def safe_json(value):
54
+ if isinstance(value,dict): return {str(k):safe_json(v) for k,v in value.items()}
55
+ if isinstance(value,(list,tuple)): return [safe_json(v) for v in value]
56
+ if isinstance(value,(datetime,date)): return value.isoformat()
57
+ if isinstance(value,float) and not math.isfinite(value): return {'invalid_number':str(value)}
58
+ return value
59
+
60
+
61
+ def digest(value):
62
+ return hashlib.sha256(json.dumps(safe_json(value),sort_keys=True,separators=(',',':'),allow_nan=False).encode()).hexdigest()
63
+
64
+
65
+ def manifest(source):
66
+ if isinstance(source,LiveForwardPaperTrade):
67
+ return safe_json({'source_uid':'paper:'+source.trade_uid,'asset':source.ticker,
68
+ 'direction':source.side,'decision_at':source.decision_timestamp,
69
+ 'market':source.market,'asset_type':source.asset_type,'benchmark':source.benchmark_ticker,
70
+ 'expected_net_edge':source.net_expectancy_bps,
71
+ 'model':source.model_version_used,'inputs':source.frozen_decision_payload or {}})
72
+ if isinstance(source,ForexDecision):
73
+ return safe_json({'source_uid':'forex:'+source.decision_uid,'asset':source.pair,
74
+ 'direction':source.direction,'decision_at':source.decision_timestamp,
75
+ 'model':source.strategy_id,'inputs':source.input_snapshot or {},
76
+ 'proposal':source.proposal_json or {},'risk':source.risk_json or {}})
77
+ raise ValueError('UNSUPPORTED_ALPHA_SOURCE')
78
+
79
+
80
+ class AlphaCallRegistry:
81
+ def register(self,db,source,*,registered_at=None):
82
+ db.flush()
83
+ frozen=manifest(source)
84
+ uid=frozen['source_uid']
85
+ sha=digest(frozen)
86
+ existing=db.scalar(select(AlphaCallRegistration).where(AlphaCallRegistration.source_uid==uid))
87
+ if existing:
88
+ if existing.manifest_sha256!=sha:
89
+ raise ValueError('FROZEN_CALL_CHANGED')
90
+ return existing
91
+ now=utc(registered_at or datetime.now(timezone.utc))
92
+ decision=utc(source.decision_timestamp)
93
+ if decision is None or decision>now:
94
+ raise ValueError('INVALID_REGISTRATION_TIME')
95
+ if isinstance(source,LiveForwardPaperTrade) and (source.closed_at is not None or source.status in {'CLOSED','EXPIRED','INVALIDATED'}):
96
+ raise ValueError('RETROSPECTIVE_REGISTRATION_FORBIDDEN')
97
+ if isinstance(source,ForexDecision) and db.scalar(select(ForexPosition.id).where(
98
+ ForexPosition.decision_id==source.id,ForexPosition.closed_at.is_not(None)).limit(1)):
99
+ raise ValueError('RETROSPECTIVE_REGISTRATION_FORBIDDEN')
100
+ forex=isinstance(source,ForexDecision) or is_forex_identity(ticker=source.ticker,market=source.market,asset_type=source.asset_type)
101
+ record=AlphaCallRegistration(source_uid=uid,source_type='forex' if isinstance(source,ForexDecision) else 'paper',
102
+ source_id=source.id,market='forex' if forex else 'equity',asset=frozen['asset'],decision_at=decision,
103
+ registered_at=now,source_revision=source_revision(),source_content_hash=source_content_hash(),manifest_json=frozen,manifest_sha256=sha)
104
+ db.add(record)
105
+ db.flush()
106
+ return record
107
+
108
+
109
+ def provenance(record):
110
+ frozen=mapping(record.manifest_json)
111
+ inputs=mapping(frozen.get('inputs'))
112
+ decision=utc(record.decision_at)
113
+ blockers=[]
114
+ if not isinstance(frozen.get('inputs'),dict): blockers.append('INVALID_INPUT_SHAPE')
115
+ if (utc(record.registered_at)-decision).total_seconds()>300:
116
+ blockers.append('REGISTRATION_DELAY')
117
+ if record.source_type=='forex':
118
+ quote=mapping(inputs.get('quote'))
119
+ stamp=utc(quote.get('timestamp'))
120
+ provider=quote.get('source')
121
+ bid,ask=number(quote.get('bid')),number(quote.get('ask'))
122
+ if bid is None or ask is None or bid<=0 or ask<bid:
123
+ blockers.append('INVALID_QUOTE')
124
+ availability=[utc(inputs.get('as_of'))]
125
+ frames=mapping(inputs.get('timeframes'))
126
+ if not frames: blockers.append('MISSING_PROVENANCE')
127
+ availability += [utc(frame.get('acquired_at')) for frame in frames.values() if isinstance(frame,dict)]
128
+ limit=180
129
+ else:
130
+ context=mapping(inputs.get('price_context'))
131
+ stamp=utc(context.get('market_timestamp') or context.get('latest_date'))
132
+ provider=context.get('provider')
133
+ availability=[utc(context.get('available_at'))]
134
+ limit=180 if record.market=='forex' else 96*3600
135
+ price=number(context.get('latest_price'))
136
+ if price is None or price<=0: blockers.append('INVALID_QUOTE')
137
+ quality=number(context.get('data_quality_score'))
138
+ if quality is None or not 70<=quality<=100: blockers.append('LOW_DATA_QUALITY')
139
+ if not provider_name(provider) or stamp is None or not availability or any(value is None for value in availability):
140
+ blockers.append('MISSING_PROVENANCE')
141
+ if stamp is not None and decision is not None:
142
+ if stamp>decision: blockers.append('FUTURE_INPUT')
143
+ elif (decision-stamp).total_seconds()>limit: blockers.append('STALE_AT_DECISION')
144
+ if any(value is not None and value>decision for value in availability): blockers.append('FUTURE_INPUT')
145
+ if record.source_type=='forex':
146
+ for frame in frames.values():
147
+ if not isinstance(frame,dict):
148
+ blockers.append('MISSING_PROVENANCE')
149
+ continue
150
+ timestamp=utc(frame.get('market_timestamp'))
151
+ if timestamp is None or not provider_name(frame.get('provider')): blockers.append('MISSING_PROVENANCE')
152
+ elif timestamp>decision: blockers.append('FUTURE_INPUT')
153
+ quality=number(frame.get('quality_score'))
154
+ if quality is None or not 70<=quality<=100: blockers.append('LOW_DATA_QUALITY')
155
+ return list(dict.fromkeys(blockers)),stamp,limit
156
+
157
+
158
+ def paired_interval(rows):
159
+ groups=defaultdict(list)
160
+ for row in rows:
161
+ if number(row.get('excess')) is not None:
162
+ groups[str(row['date'])].append(float(row['excess']))
163
+ values=[v for group in groups.values() for v in group]
164
+ result={'observations':len(values),'dates':len(groups),'assets':len({r['asset'] for r in rows}),
165
+ 'mean':sum(values)/len(values) if values else None,'lower_95':None,'upper_95':None,
166
+ 'positive_sample':False,'method':'closure-date cluster bootstrap; no multiple-testing correction'}
167
+ if len(groups)<2: return result
168
+ blocks=[(sum(v),len(v)) for v in groups.values()]
169
+ rng=random.Random(3407)
170
+ draws=[]
171
+ for _ in range(800):
172
+ selected=rng.choices(blocks,k=len(blocks))
173
+ draws.append(sum(v for v,n in selected)/sum(n for v,n in selected))
174
+ draws.sort()
175
+ result['lower_95'],result['upper_95']=draws[19],draws[779]
176
+ result['positive_sample']=len(values)>=100 and len(groups)>=30 and result['assets']>=3 and result['lower_95']>0
177
+ return result
178
+
179
+
180
+ class AlphaDeskService:
181
+ def snapshot(self,db,*,now=None):
182
+ now=utc(now or datetime.now(timezone.utc))
183
+ records=list(db.scalars(select(AlphaCallRegistration).order_by(desc(AlphaCallRegistration.registered_at),desc(AlphaCallRegistration.id)).limit(WINDOW)))
184
+ paper_ids=[r.source_id for r in records if r.source_type=='paper']
185
+ forex_ids=[r.source_id for r in records if r.source_type=='forex']
186
+ papers={r.id:r for r in db.scalars(select(LiveForwardPaperTrade).where(LiveForwardPaperTrade.id.in_(paper_ids)))} if paper_ids else {}
187
+ decisions={r.id:r for r in db.scalars(select(ForexDecision).where(ForexDecision.id.in_(forex_ids)))} if forex_ids else {}
188
+ positions=defaultdict(list)
189
+ if forex_ids:
190
+ for row in db.scalars(select(ForexPosition).where(ForexPosition.decision_id.in_(forex_ids))): positions[row.decision_id].append(row)
191
+ journal=[]
192
+ for record in records:
193
+ source=papers.get(record.source_id) if record.source_type=='paper' else decisions.get(record.source_id)
194
+ rows=positions[record.source_id] if record.source_type=='forex' else []
195
+ journal.append(self._call(record,source,rows,now))
196
+ markets={key:self._market([r for r in journal if r['market']==key]) for key in ('equity','forex')}
197
+ total=int(db.scalar(select(func.count(AlphaCallRegistration.id))) or 0)
198
+ persistence=database_persistence_status()
199
+ return {'status':'READY','methodology_version':METHODOLOGY,'generated_at':now.isoformat(),
200
+ 'registered_count':total,'window':{'limit':WINDOW,'evaluated':len(records),'truncated':total>WINDOW},
201
+ 'markets':markets,'journal':journal[:40],
202
+ 'durability':{'mode':persistence['mode'],
203
+ 'strict_no_reset_mode':persistence['strict_no_reset_mode'],
204
+ 'zero_loss_restart_guaranteed':False,
205
+ 'note':'Durability depends on the configured database and recovery policy; this read model does not certify zero-loss restarts.'},
206
+ 'policy':'Prospective registration only. Markets remain separate. Sample evidence has no production-promotion authority. Confidence is heuristic. Competitor superiority is unverified.'}
207
+
208
+ def _call(self,record,source,positions,now):
209
+ blockers,stamp,ttl=provenance(record)
210
+ if utc(record.registered_at)>now: blockers.append('FUTURE_REGISTRATION')
211
+ frozen=mapping(record.manifest_json)
212
+ if digest(frozen)!=record.manifest_sha256: blockers.append('REGISTRY_INTEGRITY_FAILED')
213
+ if source is None: blockers.append('SOURCE_MISSING')
214
+ elif digest(manifest(source))!=record.manifest_sha256: blockers.append('SOURCE_CHANGED')
215
+ position=positions[0] if len(positions)==1 else None
216
+ if len(positions)>1: blockers.append('AMBIGUOUS_POSITION')
217
+ trade=source if record.source_type=='paper' else position
218
+ status=str(getattr(trade,'status',None) or getattr(source,'status','MISSING')).upper()
219
+ closed_at=utc(getattr(trade,'closed_at',None))
220
+ entry=number(getattr(trade,'entry_price',None))
221
+ exit_price=number(getattr(trade,'exit_price',None))
222
+ opened=utc(getattr(trade,'opened_at',None))
223
+ side=normalize_side(frozen.get('direction'))
224
+ inputs=mapping(frozen.get('inputs'))
225
+ plan=mapping(inputs.get('trade_plan')) or mapping(frozen.get('proposal'))
226
+ stop=number(plan.get('stop_price') or plan.get('stop_loss') or plan.get('invalidation_level') or plan.get('stop'))
227
+ target=number(plan.get('target_1') or plan.get('target'))
228
+ planned_entry=number(plan.get('entry_price') or plan.get('entry') or mapping(inputs.get('price_context')).get('latest_price'))
229
+ try:
230
+ validate_trade_plan(side,entry=planned_entry,stop=stop,targets=[target])
231
+ except (ValueError,TypeError):
232
+ blockers.append('INVALID_PLAN')
233
+ net_r=number(getattr(trade,'r_multiple',None) if record.source_type=='paper' else getattr(trade,'realized_r',None))
234
+ contract=mapping(getattr(trade,'contract_json',None)) if record.source_type=='forex' else {}
235
+ notional=number(getattr(trade,'notional_value',None) if record.source_type=='paper' else contract.get('notional_value'))
236
+ net=number(getattr(trade,'net_pnl_eur',None) if record.source_type=='paper' else getattr(trade,'net_pnl',None))
237
+ gross=number(getattr(trade,'gross_pnl_eur',None) if record.source_type=='paper' else getattr(trade,'gross_pnl',None))
238
+ cost_fields=('spread_cost','slippage_cost','commission_cost') if record.source_type=='paper' else ('spread_cost','slippage_cost','commission','swap_accrued')
239
+ parts=[number(getattr(trade,k,None)) for k in cost_fields]
240
+ costs=(sum(parts) if record.source_type=='paper' else sum(parts[:3])-parts[3]) if all(v is not None for v in parts) else None
241
+ stored_cost=number(getattr(trade,'costs_paid',None)) if record.source_type=='paper' else costs
242
+ benchmark=number(getattr(trade,'benchmark_return_same_period',None)) if record.source_type=='paper' else None
243
+ outcome='PENDING'
244
+ realized=False
245
+ paired=None
246
+ net_return=None
247
+ if status=='CLOSED':
248
+ outcome='CLOSED'
249
+ quantity=number(getattr(trade,'position_size',None) if record.source_type=='paper' else getattr(trade,'quantity_lots',None))
250
+ if quantity is None or quantity<=0: blockers.append('MISSING_FILL')
251
+ if closed_at is None or closed_at>now or opened is None or opened<utc(record.decision_at) or closed_at<=opened or closed_at<=utc(record.registered_at): blockers.append('NON_CAUSAL_OUTCOME')
252
+ if entry is None or exit_price is None or entry<=0 or exit_price<=0 or side is None or net is None or gross is None or costs is None or stored_cost is None:
253
+ blockers.append('INVALID_ACCOUNTING')
254
+ elif any(value<0 for value in parts[:3]): blockers.append('INVALID_ACCOUNTING')
255
+ elif not math.isclose(gross-net,costs,rel_tol=1e-6,abs_tol=1e-5) or not math.isclose(costs,stored_cost,rel_tol=1e-6,abs_tol=1e-5): blockers.append('COST_RECONCILIATION_FAILED')
256
+ if record.source_type=='paper' and source is not None and not paper_trade_evidence_is_eligible(source): blockers.append('INVALID_ACCOUNTING')
257
+ realized=not blockers
258
+ if realized and notional is not None and notional>0:
259
+ holding=(exit_price/entry-1)*(1 if side=='LONG' else -1)
260
+ if math.isclose(gross,holding*notional,rel_tol=1e-4,abs_tol=1e-5):
261
+ net_return=net/notional*100
262
+ paired=net_return-benchmark if benchmark is not None else None
263
+ else: blockers.append('NOTIONAL_RETURN_MISMATCH')
264
+ if realized and paired is None: blockers.append('MISSING_PAIRED_BENCHMARK')
265
+ elif status in {'EXPIRED','INVALIDATED','REJECTED','SKIPPED','BLOCKED','NO_TRADE'}:
266
+ outcome='NO_FILL'
267
+ else:
268
+ if stamp is not None and (now-stamp).total_seconds()>ttl: blockers.append('QUOTE_STALE_NOW')
269
+ expected=number(frozen.get('expected_net_edge')) if record.source_type=='paper' else number(mapping(frozen.get('proposal')).get('expected_net_pips'))
270
+ if outcome=='PENDING' and expected is None: blockers.append('NO_STORED_EDGE_ESTIMATE')
271
+ return {'source_uid':record.source_uid,'market':record.market,'asset':record.asset,
272
+ 'status':status,'outcome_status':outcome,'direction':side,'decision_at':record.decision_at.isoformat(),
273
+ 'registered_at':record.registered_at.isoformat(),'source_revision':record.source_revision,
274
+ 'source_content_hash':record.source_content_hash,
275
+ 'input_sha256':record.manifest_sha256,'provider':mapping(inputs.get('quote')).get('source') if record.source_type=='forex' else mapping(inputs.get('price_context')).get('provider'),
276
+ 'quote_at':stamp.isoformat() if stamp else None,'quote_age_seconds':max(0,(now-stamp).total_seconds()) if stamp else None,
277
+ 'entry':entry,'stop':stop,'target':target,'model':frozen.get('model'),
278
+ 'confidence':number((mapping(frozen.get('proposal')) or inputs).get('confidence')),
279
+ 'confidence_semantics':'HEURISTIC_NOT_PROBABILITY','expected_net_edge':expected,
280
+ 'edge_units':'bps' if record.source_type=='paper' else 'pips',
281
+ 'qualified_closed':realized,'net_pnl':net if realized else None,'net_r':net_r if realized else None,
282
+ 'net_return_percent':net_return,'paired_excess_percent':paired,'closed_at':closed_at.isoformat() if closed_at else None,
283
+ 'active_eligible':outcome=='PENDING' and not blockers and expected is not None and expected>0,
284
+ 'blockers':list(dict.fromkeys(blockers))}
285
+
286
+ @staticmethod
287
+ def _market(rows):
288
+ closed=[r for r in rows if r['qualified_closed']]
289
+ paired=[{'asset':r['asset'],'date':r['closed_at'][:10],'excess':r['paired_excess_percent']} for r in closed if r['paired_excess_percent'] is not None]
290
+ interval=paired_interval(paired)
291
+ active=sorted([r for r in rows if r['active_eligible']],key=lambda r:r['expected_net_edge'],reverse=True)[:10]
292
+ exclusions=Counter(b for r in rows for b in r['blockers'])
293
+ returns=[r['net_return_percent'] for r in closed if r['net_return_percent'] is not None]
294
+ return {'registered':len(rows),'qualified_closed':len(closed),'paired_sample':len(paired),
295
+ 'observed_net_pnl':sum(r['net_pnl'] for r in closed) if closed else None,
296
+ 'mean_net_return_percent':sum(returns)/len(returns) if returns else None,
297
+ 'mean_paired_excess_percent':interval['mean'],'paired_interval':interval,
298
+ 'verdict':'POSITIVE_PAIRED_SAMPLE' if interval['positive_sample'] else 'INSUFFICIENT_EVIDENCE',
299
+ 'active_calls':active,'exclusions':dict(exclusions),'closed_coverage':{'assets':interval['assets'],'dates':interval['dates']}}
backend/app/services/forex_trader.py CHANGED
@@ -523,6 +523,8 @@ class BlumForexTraderCore:
523
  "finrlx_quant": quant_payload,
524
  }
525
  if not evaluation.approved:
 
 
526
  rejected.append({"pair": market.pair, "blockers": list(evaluation.blockers)})
527
  blockers.extend(evaluation.blockers)
528
  continue
@@ -532,6 +534,8 @@ class BlumForexTraderCore:
532
  if not risk.decision.startswith("APPROVE"):
533
  decision.status = "REJECTED"
534
  decision.blockers = list(risk.blockers)
 
 
535
  rejected.append({"pair": market.pair, "blockers": list(risk.blockers)})
536
  blockers.extend(risk.blockers)
537
  evidence = self.learning.record_outcome(db, decision_id=decision.id, outcome="RISK_BLOCK_CORRECT", payload={
@@ -544,6 +548,8 @@ class BlumForexTraderCore:
544
  if evidence:
545
  learning_events.append(evidence.outcome)
546
  continue
 
 
547
  request = ForexOrderRequest.from_proposal(
548
  evaluation.proposal,
549
  quantity_lots=risk.quantity_lots,
 
523
  "finrlx_quant": quant_payload,
524
  }
525
  if not evaluation.approved:
526
+ from app.services.alpha_desk import AlphaCallRegistry
527
+ AlphaCallRegistry().register(db, decision)
528
  rejected.append({"pair": market.pair, "blockers": list(evaluation.blockers)})
529
  blockers.extend(evaluation.blockers)
530
  continue
 
534
  if not risk.decision.startswith("APPROVE"):
535
  decision.status = "REJECTED"
536
  decision.blockers = list(risk.blockers)
537
+ from app.services.alpha_desk import AlphaCallRegistry
538
+ AlphaCallRegistry().register(db, decision)
539
  rejected.append({"pair": market.pair, "blockers": list(risk.blockers)})
540
  blockers.extend(risk.blockers)
541
  evidence = self.learning.record_outcome(db, decision_id=decision.id, outcome="RISK_BLOCK_CORRECT", payload={
 
548
  if evidence:
549
  learning_events.append(evidence.outcome)
550
  continue
551
+ from app.services.alpha_desk import AlphaCallRegistry
552
+ AlphaCallRegistry().register(db, decision)
553
  request = ForexOrderRequest.from_proposal(
554
  evaluation.proposal,
555
  quantity_lots=risk.quantity_lots,
backend/app/services/live_forward_paper_trading.py CHANGED
@@ -113,6 +113,17 @@ class LiveForwardPaperTradingService(_TradingLabLiveForwardService):
113
  game = self.active_or_create_live_game(db)
114
  now = datetime.utcnow()
115
  ticker = normalize_text(decision_payload.get("ticker")).upper()
 
 
 
 
 
 
 
 
 
 
 
116
  plan = decision_payload.get("trade_plan") or {}
117
  setup_type = normalize_text((decision_payload.get("setup") or {}).get("setup_type")) or "unknown_setup"
118
  entry_trigger = normalize_text(plan.get("entry_trigger") or plan.get("confirmation_condition")) or "live_forward_candidate"
@@ -357,6 +368,8 @@ class LiveForwardPaperTradingService(_TradingLabLiveForwardService):
357
  self.append_event(db, trade.id, "DATA_BLOCKED", block_reason, payload={"price_context": decision_payload.get("price_context"), "actionability_diagnosis": diagnosis_payload})
358
  elif status in {"SKIPPED", "WAITING_FOR_TRIGGER"}:
359
  self.append_event(db, trade.id, "ACTIONABILITY_REJECTED", block_reason, payload={**actionability_event_payload(decision_payload, diagnosis, feedback), "classification": decision_payload_with_diagnosis["paper_forward_classification"]}, price_used=price if price > 0 else None)
 
 
360
  return trade
361
 
362
  def append_event(
@@ -2485,6 +2498,9 @@ def latest_price_context_for_asset(db: Session, asset: Asset, *, data_quality_sc
2485
  return {
2486
  "latest_price": safe_float(latest.close, None) if latest else None,
2487
  "latest_date": latest.date.isoformat() if latest and latest.date else None,
 
 
 
2488
  "latest_volume": safe_float(latest.volume, None) if latest else None,
2489
  "rows": rows,
2490
  "data_quality_score": safe_float(data_quality_score, 0.0),
 
113
  game = self.active_or_create_live_game(db)
114
  now = datetime.utcnow()
115
  ticker = normalize_text(decision_payload.get("ticker")).upper()
116
+ context = dict(decision_payload.get("price_context") or {})
117
+ if not context.get("provider") or not context.get("available_at"):
118
+ asset = db.scalar(select(Asset).where(Asset.ticker == ticker).limit(1))
119
+ if asset is not None:
120
+ latest = db.scalar(select(PriceHistory).where(PriceHistory.asset_id == asset.id)
121
+ .order_by(desc(PriceHistory.date)).limit(1))
122
+ if (latest is not None and latest.created_at is not None and latest.created_at <= now
123
+ and context.get("latest_date") == latest.date.isoformat()
124
+ and safe_float(context.get("latest_price"), None) == float(latest.close)):
125
+ context.update(provider=latest.provider, available_at=latest.created_at.isoformat(), source_price_id=latest.id)
126
+ decision_payload = {**decision_payload, "price_context": context}
127
  plan = decision_payload.get("trade_plan") or {}
128
  setup_type = normalize_text((decision_payload.get("setup") or {}).get("setup_type")) or "unknown_setup"
129
  entry_trigger = normalize_text(plan.get("entry_trigger") or plan.get("confirmation_condition")) or "live_forward_candidate"
 
368
  self.append_event(db, trade.id, "DATA_BLOCKED", block_reason, payload={"price_context": decision_payload.get("price_context"), "actionability_diagnosis": diagnosis_payload})
369
  elif status in {"SKIPPED", "WAITING_FOR_TRIGGER"}:
370
  self.append_event(db, trade.id, "ACTIONABILITY_REJECTED", block_reason, payload={**actionability_event_payload(decision_payload, diagnosis, feedback), "classification": decision_payload_with_diagnosis["paper_forward_classification"]}, price_used=price if price > 0 else None)
371
+ from app.services.alpha_desk import AlphaCallRegistry
372
+ AlphaCallRegistry().register(db, trade)
373
  return trade
374
 
375
  def append_event(
 
2498
  return {
2499
  "latest_price": safe_float(latest.close, None) if latest else None,
2500
  "latest_date": latest.date.isoformat() if latest and latest.date else None,
2501
+ "provider": latest.provider if latest else None,
2502
+ "available_at": latest.created_at.isoformat() if latest and latest.created_at else None,
2503
+ "source_price_id": latest.id if latest else None,
2504
  "latest_volume": safe_float(latest.volume, None) if latest else None,
2505
  "rows": rows,
2506
  "data_quality_score": safe_float(data_quality_score, 0.0),
backend/tests/test_alpha_desk.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+
3
+ import pytest
4
+ from sqlalchemy import create_engine, select
5
+ from sqlalchemy.orm import Session
6
+ from sqlalchemy.pool import StaticPool
7
+
8
+ from app.core.database import Base
9
+ from app.models import LiveForwardPaperGame, LiveForwardPaperTrade
10
+
11
+ NOW = datetime(2026, 10, 13, 12)
12
+
13
+
14
+ @pytest.fixture
15
+ def db():
16
+ engine = create_engine('sqlite:///:memory:',connect_args={'check_same_thread':False},poolclass=StaticPool)
17
+ Base.metadata.create_all(engine)
18
+ with Session(engine) as session:
19
+ yield session
20
+ engine.dispose()
21
+
22
+
23
+ def paper(db, *, uid='one', ticker='SPY', market='equity'):
24
+ game = db.scalar(select(LiveForwardPaperGame))
25
+ if game is None:
26
+ game = LiveForwardPaperGame(game_id='desk-test')
27
+ db.add(game)
28
+ db.flush()
29
+ row = LiveForwardPaperTrade(
30
+ game_id=game.id, trade_uid=uid, duplicate_key=uid, ticker=ticker,
31
+ setup_type='trend', model_version_used='baseline', side='LONG',
32
+ market=market, status='OPEN', decision_timestamp=NOW,
33
+ opened_at=NOW + timedelta(minutes=1), entry_price=100, position_size=1,
34
+ stop_loss=95, target_1=110, confidence=65, notional_value=100,
35
+ accounting_status='VALID', accounting_version='paper-forward-direction-v2',
36
+ evidence_type='PAPER_FORWARD', benchmark_ticker='SPY',
37
+ frozen_decision_payload={'confidence':65, 'side':'LONG',
38
+ 'trade_plan':{'entry_price':100, 'stop_price':95, 'target_1':110},
39
+ 'price_context':{'provider':'test-market-feed', 'latest_date':NOW.date().isoformat(),
40
+ 'available_at':(NOW-timedelta(minutes=1)).isoformat(),
41
+ 'latest_price':100, 'data_quality_score':90}},
42
+ )
43
+ db.add(row)
44
+ db.flush()
45
+ return row
46
+
47
+
48
+ def close(row, *, benchmark=3, net=9):
49
+ row.status='CLOSED'
50
+ row.closed_at=NOW+timedelta(days=1)
51
+ row.exit_price=110
52
+ row.gross_pnl_eur=10
53
+ row.net_pnl_eur=net
54
+ row.costs_paid=1
55
+ row.commission_cost=1
56
+ row.r_multiple=1.8
57
+ row.benchmark_return_same_period=benchmark
58
+
59
+
60
+ def register(db, row):
61
+ from app.services.alpha_desk import AlphaCallRegistry
62
+ return AlphaCallRegistry().register(db,row,registered_at=NOW+timedelta(seconds=1))
63
+
64
+
65
+ def snapshot(db):
66
+ from app.services.alpha_desk import AlphaDeskService
67
+ db.flush()
68
+ return AlphaDeskService().snapshot(db,now=NOW+timedelta(days=2))
69
+
70
+
71
+ def test_registration_is_idempotent_and_refuses_changed_frozen_inputs(db):
72
+ row=paper(db)
73
+ original=register(db,row)
74
+ assert register(db,row).id==original.id
75
+ row.frozen_decision_payload={**row.frozen_decision_payload,'confidence':99}
76
+ with pytest.raises(ValueError,match='CHANGED'):
77
+ register(db,row)
78
+
79
+
80
+ def test_closed_legacy_call_cannot_be_registered_prospectively(db):
81
+ from app.services.alpha_desk import AlphaCallRegistry
82
+ row=paper(db)
83
+ close(row)
84
+ with pytest.raises(ValueError,match='RETROSPECTIVE'):
85
+ AlphaCallRegistry().register(db,row,registered_at=NOW+timedelta(days=2))
86
+ assert snapshot(db)['markets']['equity']['qualified_closed']==0
87
+
88
+
89
+ def test_paired_alpha_uses_cost_adjusted_holding_return(db):
90
+ row=paper(db)
91
+ register(db,row)
92
+ close(row)
93
+ payload=snapshot(db)
94
+ equity=payload['markets']['equity']
95
+ assert equity['qualified_closed']==1
96
+ assert equity['paired_sample']==1
97
+ assert equity['mean_net_return_percent']==9
98
+ assert equity['mean_paired_excess_percent']==6
99
+ assert equity['verdict']=='INSUFFICIENT_EVIDENCE'
100
+ assert payload['journal'][0]['confidence_semantics']=='HEURISTIC_NOT_PROBABILITY'
101
+
102
+
103
+ @pytest.mark.parametrize('failure,expected',[
104
+ ('future','FUTURE_INPUT'),('stale','STALE_AT_DECISION'),
105
+ ('provider','MISSING_PROVENANCE'),('mutated','SOURCE_CHANGED'),
106
+ ('cost','COST_RECONCILIATION_FAILED'),('nan','INVALID_ACCOUNTING'),
107
+ ])
108
+ def test_invalid_evidence_stays_visible_and_cannot_raise_sample(db,failure,expected):
109
+ row=paper(db)
110
+ context=row.frozen_decision_payload['price_context']
111
+ if failure=='future': context['available_at']=(NOW+timedelta(hours=1)).isoformat()
112
+ if failure=='stale': context['latest_date']=(NOW-timedelta(days=10)).date().isoformat()
113
+ if failure=='provider': context.pop('provider')
114
+ register(db,row)
115
+ close(row)
116
+ if failure=='mutated': row.frozen_decision_payload={**row.frozen_decision_payload,'confidence':99}
117
+ if failure=='cost': row.net_pnl_eur=20
118
+ if failure=='nan': row.costs_paid=float('inf')
119
+ payload=snapshot(db)
120
+ assert payload['markets']['equity']['qualified_closed']==0
121
+ assert expected in payload['journal'][0]['blockers']
122
+
123
+
124
+ def test_missing_benchmark_and_inconsistent_notional_do_not_become_zero_alpha(db):
125
+ row=paper(db)
126
+ register(db,row)
127
+ close(row,benchmark=None)
128
+ payload=snapshot(db)
129
+ assert payload['markets']['equity']['qualified_closed']==1
130
+ assert payload['markets']['equity']['paired_sample']==0
131
+ assert payload['markets']['equity']['mean_paired_excess_percent'] is None
132
+ row.benchmark_return_same_period=3
133
+ row.notional_value=1000
134
+ assert snapshot(db)['markets']['equity']['paired_sample']==0
135
+
136
+
137
+ def test_unfilled_orders_never_contribute_realized_pnl(db):
138
+ row=paper(db)
139
+ register(db,row)
140
+ row.status='EXPIRED'
141
+ row.net_pnl_eur=500
142
+ row.closed_at=NOW+timedelta(days=1)
143
+ payload=snapshot(db)
144
+ assert payload['markets']['equity']['qualified_closed']==0
145
+ assert payload['markets']['equity']['observed_net_pnl'] is None
146
+ assert payload['journal'][0]['outcome_status']=='NO_FILL'
147
+
148
+
149
+ def test_forex_and_equity_samples_are_not_pooled(db):
150
+ equity=paper(db,uid='eq')
151
+ forex=paper(db,uid='fx',ticker='EURUSD=X',market='forex')
152
+ register(db,equity)
153
+ register(db,forex)
154
+ close(equity)
155
+ close(forex,net=-10)
156
+ payload=snapshot(db)
157
+ assert payload['markets']['equity']['qualified_closed']==1
158
+ assert payload['markets']['forex']['qualified_closed']==0
159
+ assert 'pooled_alpha' not in payload
160
+
161
+
162
+ def test_reading_desk_does_not_register_legacy_calls_or_write_rows(db):
163
+ row=paper(db)
164
+ db.commit()
165
+ first=snapshot(db)
166
+ second=snapshot(db)
167
+ assert first['registered_count']==second['registered_count']==0
168
+ assert db.get(LiveForwardPaperTrade,row.id).status=='OPEN'
169
+ assert not db.new and not db.dirty and not db.deleted
170
+
171
+
172
+ def test_date_cluster_interval_requires_breadth_and_reports_literal_positive_mean():
173
+ from app.services.alpha_desk import paired_interval
174
+ rows=[{'date':f'2026-01-{i%30+1:02d}','asset':str(i%3),'excess':2.0} for i in range(120)]
175
+ result=paired_interval(rows)
176
+ assert result['mean']==result['lower_95']==result['upper_95']==2
177
+ assert result['observations']==120 and result['dates']==30 and result['assets']==3
178
+ assert result['positive_sample'] is True
179
+ assert paired_interval(rows[:20])['positive_sample'] is False
180
+ assert paired_interval([{**row,'date':'2026-01-01'} for row in rows])['positive_sample'] is False
181
+
182
+
183
+ def test_active_call_with_invalid_geometry_is_not_ranked(db):
184
+ row=paper(db)
185
+ row.net_expectancy_bps=50
186
+ row.frozen_decision_payload['trade_plan']['stop_price']=105
187
+ register(db,row)
188
+ from app.services.alpha_desk import AlphaDeskService
189
+ result=AlphaDeskService().snapshot(db,now=NOW+timedelta(seconds=2))
190
+ assert result['markets']['equity']['active_calls']==[]
191
+ assert 'INVALID_PLAN' in result['journal'][0]['blockers']
192
+
193
+
194
+ def test_paper_candidate_creator_registers_original_inputs_before_any_outcome(db):
195
+ from app.models import AlphaCallRegistration
196
+ from app.services.live_forward_paper_trading import LiveForwardPaperTradingService
197
+ candidate={'ticker':'SPY','side':'LONG','asset':{'asset_type':'etf'},
198
+ 'setup':{'setup_type':'test'},'confidence':70,'trade_plan':{'stop_price':95,'target_1':110},
199
+ 'price_context':{'latest_price':100,'latest_date':NOW.date().isoformat(),'rows':100,'data_quality_score':90}}
200
+ row=LiveForwardPaperTradingService().create_candidate(db,candidate)
201
+ record=db.scalar(select(AlphaCallRegistration).where(AlphaCallRegistration.source_id==row.id))
202
+ assert record is not None
203
+ assert record.manifest_json['inputs']['price_context']['latest_price']==100
204
+ assert record.registered_at>=record.decision_at
205
+ assert row.closed_at is None
206
+
207
+
208
+ def test_alpha_includes_older_closed_trades_and_deduplicates_live_evidence(db):
209
+ from app.services.trader_brain import TraderBrainService
210
+ row=paper(db,uid='older')
211
+ close(row)
212
+ row.created_at=NOW-timedelta(days=20)
213
+ for i in range(501):
214
+ newer=paper(db,uid=f'no-fill-{i}')
215
+ newer.created_at=NOW+timedelta(seconds=i)
216
+ newer.status='EXPIRED'
217
+ db.commit()
218
+ payload=TraderBrainService().alpha(db)
219
+ assert payload['evidence_split']['paper_forward']['closed_trade_count']==1
220
+ assert payload['total_evidence_sample_size']==1
221
+
222
+
223
+ def test_positive_historical_rows_without_provenance_are_not_strong_evidence():
224
+ from app.engine.brain.trader_brain import trading_game_evidence_split
225
+ from app.models import TradingGameTrade
226
+ rows=[TradingGameTrade(ticker='SPY',net_pnl_eur=10,realized_r_multiple=2,
227
+ pnl_percent=10,benchmark_return_same_period=2,outcome_label='win') for _ in range(120)]
228
+ result=trading_game_evidence_split(rows,label='Historical Replay',data_source='trading_game_trades')
229
+ assert result['evidence_grade']=='INSUFFICIENT_EVIDENCE'
230
+ assert result['provenance_status']=='UNVERIFIED_LEGACY'
231
+
232
+
233
+ def test_desk_api_is_read_only_and_does_not_register_a_legacy_row(db):
234
+ from fastapi.testclient import TestClient
235
+ from app.main import app
236
+ from app.core.database import get_db
237
+ row=paper(db)
238
+ db.commit()
239
+ def dependency():
240
+ yield db
241
+ app.dependency_overrides[get_db]=dependency
242
+ try:
243
+ client=TestClient(app)
244
+ response=client.get('/api/alpha/desk')
245
+ assert response.status_code==200
246
+ assert response.json()['registered_count']==0
247
+ assert client.post('/api/alpha/desk',json={}).status_code==405
248
+ assert db.get(LiveForwardPaperTrade,row.id).status=='OPEN'
249
+ finally:
250
+ app.dependency_overrides.clear()
251
+
252
+
253
+ def forex_source(db):
254
+ from app.models import ForexDecision,ForexTraderCycle,ForexPosition
255
+ cycle=ForexTraderCycle(cycle_uid='fx-test',cycle_key='fx-test',configuration_hash='test-config',
256
+ data_coverage_hash='test-input',code_commit='test-revision')
257
+ db.add(cycle)
258
+ db.flush()
259
+ row=ForexDecision(cycle_id=cycle.id,decision_uid='fx-test-call',pair='EURUSD=X',
260
+ strategy_id='frozen-baseline',status='APPROVED',direction='LONG',decision_timestamp=NOW,
261
+ input_snapshot={'as_of':NOW.isoformat(),'quote':{'bid':1.1,'ask':1.101,
262
+ 'timestamp':(NOW-timedelta(seconds=10)).isoformat(),'source':'market-feed'},
263
+ 'timeframes':{'1m':{'market_timestamp':(NOW-timedelta(seconds=20)).isoformat(),
264
+ 'acquired_at':(NOW-timedelta(seconds=10)).isoformat(),'provider':'market-feed','quality_score':90}}},
265
+ proposal_json={'entry':1.1,'stop':1.09,'target':1.12,'confidence':0.6,'expected_net_pips':30})
266
+ db.add(row)
267
+ db.flush()
268
+ position=ForexPosition(position_uid='fx-test-position',decision_id=row.id,pair=row.pair,
269
+ strategy_id=row.strategy_id,direction='LONG',quantity_lots=0.001,entry_price=1.1,
270
+ stop_price=1.09,target_price=1.12,current_price=1.1,opened_at=NOW+timedelta(seconds=2),
271
+ contract_json={'account_fx_rate':1.0,'fx_rate_source':'IDENTITY'})
272
+ db.add(position)
273
+ db.flush()
274
+ return row,position
275
+
276
+
277
+ def test_native_forex_swap_credit_is_income_and_never_pooled_with_equity(db):
278
+ row,position=forex_source(db)
279
+ register(db,row)
280
+ position.status='CLOSED'
281
+ position.closed_at=NOW+timedelta(hours=1)
282
+ position.exit_price=1.12
283
+ position.gross_pnl=2
284
+ position.spread_cost=0.4
285
+ position.slippage_cost=0.3
286
+ position.commission=0.3
287
+ position.swap_accrued=0.5
288
+ position.net_pnl=1.5
289
+ result=snapshot(db)
290
+ assert result['markets']['forex']['qualified_closed']==1
291
+ assert result['markets']['forex']['observed_net_pnl']==1.5
292
+ assert result['markets']['forex']['paired_sample']==0
293
+ assert result['markets']['equity']['qualified_closed']==0
294
+
295
+
296
+ @pytest.mark.parametrize('native_forex', [False, True])
297
+ def test_closed_record_without_positive_fill_is_unqualified(db,native_forex):
298
+ if native_forex:
299
+ row,position=forex_source(db)
300
+ register(db,row)
301
+ position.status='CLOSED'
302
+ position.closed_at=NOW+timedelta(hours=1)
303
+ position.exit_price=1.12
304
+ position.quantity_lots=0
305
+ else:
306
+ row=paper(db)
307
+ register(db,row)
308
+ close(row)
309
+ row.position_size=0
310
+ payload=snapshot(db)
311
+ market='forex' if native_forex else 'equity'
312
+ assert payload['markets'][market]['qualified_closed']==0
313
+ assert 'MISSING_FILL' in payload['journal'][0]['blockers']
314
+
315
+
316
+ def test_completed_forex_position_cannot_be_retroactively_registered(db):
317
+ from app.services.alpha_desk import AlphaCallRegistry
318
+ row,position=forex_source(db)
319
+ position.status='CLOSED'
320
+ position.closed_at=NOW+timedelta(hours=1)
321
+ db.flush()
322
+ with pytest.raises(ValueError,match='RETROSPECTIVE'):
323
+ AlphaCallRegistry().register(db,row,registered_at=NOW+timedelta(days=1))
324
+
325
+
326
+ def test_unavailable_quality_and_delayed_registration_block_forward_qualification(db):
327
+ row=paper(db)
328
+ row.frozen_decision_payload['price_context']['data_quality_score']=10
329
+ from app.services.alpha_desk import AlphaCallRegistry
330
+ AlphaCallRegistry().register(db,row,registered_at=NOW+timedelta(hours=1))
331
+ close(row)
332
+ result=snapshot(db)
333
+ assert result['markets']['equity']['qualified_closed']==0
334
+ assert 'LOW_DATA_QUALITY' in result['journal'][0]['blockers']
335
+ assert 'REGISTRATION_DELAY' in result['journal'][0]['blockers']
336
+
337
+
338
+ def test_candidate_freezes_matching_database_price_provenance(db):
339
+ from app.models import Asset,PriceHistory
340
+ from app.services.live_forward_paper_trading import LiveForwardPaperTradingService
341
+ now=datetime.utcnow()
342
+ asset=Asset(ticker='SPY',name='SPY',category='ETF',sector='equity',country='US',asset_type='ETF')
343
+ db.add(asset)
344
+ db.flush()
345
+ price=PriceHistory(asset_id=asset.id,date=now.date()-timedelta(days=1),open=99,high=101,low=98,
346
+ close=100,volume=1000000,provider='stooq',created_at=now-timedelta(hours=1))
347
+ db.add(price)
348
+ db.flush()
349
+ candidate={'ticker':'SPY','side':'LONG','asset':{'asset_type':'ETF'},'setup':{'setup_type':'trend'},
350
+ 'confidence':70,'trade_plan':{'stop_price':95,'target_1':110},
351
+ 'price_context':{'latest_price':100,'latest_date':price.date.isoformat(),'data_quality_score':90,'rows':100}}
352
+ row=LiveForwardPaperTradingService().create_candidate(db,candidate)
353
+ context=row.frozen_decision_payload['price_context']
354
+ assert context['provider']=='stooq'
355
+ assert context['source_price_id']==price.id
356
+ assert context['available_at']==price.created_at.isoformat()
357
+
358
+
359
+ @pytest.mark.parametrize('failure',['inputs','context','provider','quality','frames','empty_frames'])
360
+ def test_invalid_source_shapes_stay_visible_without_qualifying_or_breaking_reads(db,failure):
361
+ native=failure in {'frames','empty_frames'}
362
+ if native:
363
+ row,position=forex_source(db)
364
+ row.input_snapshot['timeframes']=['malformed'] if failure=='frames' else {}
365
+ register(db,row)
366
+ position.status='CLOSED'
367
+ position.closed_at=NOW+timedelta(hours=1)
368
+ position.exit_price=1.12
369
+ else:
370
+ row=paper(db)
371
+ if failure=='inputs': row.frozen_decision_payload=['malformed']
372
+ if failure=='context': row.frozen_decision_payload['price_context']=['malformed']
373
+ if failure=='provider': row.frozen_decision_payload['price_context']['provider']={'not':'a name'}
374
+ if failure=='quality': row.frozen_decision_payload['price_context']['data_quality_score']=1000
375
+ register(db,row)
376
+ close(row)
377
+ result=snapshot(db)
378
+ assert result['markets']['forex' if native else 'equity']['qualified_closed']==0
379
+ assert result['journal'][0]['blockers']
backend/tests/test_migration_chain.py CHANGED
@@ -88,6 +88,7 @@ def test_full_migration_chain_reaches_head_on_sqlite(tmp_path):
88
  "ascent_generations",
89
  "ascent_experiments",
90
  "ascent_evidence_use",
 
91
  }
92
 
93
  assert required_tables.issubset(tables)
@@ -102,7 +103,7 @@ def test_full_migration_chain_reaches_head_on_sqlite(tmp_path):
102
  "accounting_recomputed_at",
103
  }.issubset(paper_trade_columns)
104
  with engine.connect() as connection:
105
- assert connection.execute(text("select version_num from alembic_version")).scalar_one() == "0044_ascent_evolution"
106
 
107
  downgrade = subprocess.run(
108
  [sys.executable, "-m", "alembic", "downgrade", "base"],
 
88
  "ascent_generations",
89
  "ascent_experiments",
90
  "ascent_evidence_use",
91
+ "alpha_call_registrations",
92
  }
93
 
94
  assert required_tables.issubset(tables)
 
103
  "accounting_recomputed_at",
104
  }.issubset(paper_trade_columns)
105
  with engine.connect() as connection:
106
+ assert connection.execute(text("select version_num from alembic_version")).scalar_one() == "0045_alpha_call_registry"
107
 
108
  downgrade = subprocess.run(
109
  [sys.executable, "-m", "alembic", "downgrade", "base"],
backend/tests/test_release_content_hash.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+
4
+ def test_release_hash_detects_protected_code_changes_and_ignores_generated_revision(tmp_path):
5
+ from app.core.release import content_hash_for_root
6
+ source=tmp_path/'backend/app/core'
7
+ source.mkdir(parents=True)
8
+ file=source/'example.py'
9
+ file.write_text('value = 1\n')
10
+ scripts=tmp_path/'scripts'
11
+ scripts.mkdir()
12
+ script=scripts/'start.sh'
13
+ script.write_text('exit 0\n')
14
+ original=content_hash_for_root(tmp_path)
15
+ (source/'build_revision.json').write_text('{"source_revision":"changed"}')
16
+ assert content_hash_for_root(tmp_path)==original
17
+ file.write_text('value = 2\n')
18
+ changed=content_hash_for_root(tmp_path)
19
+ assert changed!=original
20
+ script.write_text('exit 1\n')
21
+ assert content_hash_for_root(tmp_path)!=changed
22
+
23
+
24
+ def test_identical_release_code_has_same_hash_across_checkout_paths(tmp_path):
25
+ from app.core.release import content_hash_for_root
26
+ for name in ['first','second']:
27
+ file=tmp_path/name/'backend/app/example.py'
28
+ file.parent.mkdir(parents=True)
29
+ file.write_text('identical protected source\n')
30
+ assert content_hash_for_root(tmp_path/'first')==content_hash_for_root(tmp_path/'second')
backend/tests/test_restore_failure_guard.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the actual startup script against failed restore tools and an isolated backup."""
2
+ import os
3
+ from pathlib import Path
4
+ import subprocess
5
+
6
+
7
+ def test_failed_logical_restore_does_not_run_migrations_or_destroy_the_backup(tmp_path):
8
+ fake=tmp_path/'bin'
9
+ fake.mkdir()
10
+ tools={'service':'exit 0', 'python3':'exit 0',
11
+ 'su':'case "$*" in *pg_restore*) exit 21;; *information_schema.tables*) echo 0;; *) exit 0;; esac'}
12
+ for name,body in tools.items():
13
+ path=fake/name
14
+ path.write_text('#!/bin/sh\n'+body+'\n')
15
+ path.chmod(0o700)
16
+ persistence=tmp_path/'data'
17
+ persistence.mkdir()
18
+ backup=persistence/'embedded_postgres_blum.dump'
19
+ backup.write_bytes(b'failed-test-backup-kept-for-recovery')
20
+ env={**os.environ,'PATH':str(fake)+':/usr/bin:/bin','DATABASE_URL':'',
21
+ 'BLUM_PERSIST_DIR':str(persistence),'BLUM_LOCAL_POSTGRES_DATA_DIR':str(tmp_path/'local-postgres')}
22
+ script=Path(__file__).parents[2]/'scripts/start.sh'
23
+ result=subprocess.run(['/bin/bash',str(script)],env=env,capture_output=True,text=True,timeout=15)
24
+ assert result.returncode!=0
25
+ assert 'Applying Alembic migrations' not in result.stdout
26
+ assert 'Refusing startup' in result.stdout
27
+ assert backup.read_bytes()==b'failed-test-backup-kept-for-recovery'
28
+ # A partial restore may have created tables. A container retry must still fail.
29
+ (fake/'su').write_text('#!/bin/sh\ncase "$*" in *information_schema.tables*) echo 45;; *) exit 0;; esac\n')
30
+ retry=subprocess.run(['/bin/bash',str(script)],env=env,capture_output=True,text=True,timeout=15)
31
+ assert 'Applying Alembic migrations' not in retry.stdout
32
+ assert 'incomplete restore' in retry.stdout
33
+
34
+
35
+ def test_unusable_physical_backup_without_logical_recovery_refuses_empty_startup(tmp_path):
36
+ fake=tmp_path/'bin'
37
+ fake.mkdir()
38
+ for name,body in {'service':'exit 0','python3':'exit 0','pg_dropcluster':'exit 0',
39
+ 'pg_createcluster':'exit 0','su':'case "$*" in *information_schema.tables*) echo 0;; *) exit 0;; esac'}.items():
40
+ path=fake/name
41
+ path.write_text('#!/bin/sh\n'+body+'\n')
42
+ path.chmod(0o700)
43
+ persistence=tmp_path/'data'
44
+ physical=persistence/'embedded_postgres_physical'
45
+ physical.mkdir(parents=True)
46
+ (physical/'ready').touch()
47
+ (physical/'base.tar.gz').write_bytes(b'invalid recovery archive')
48
+ local=tmp_path/'local-postgres'
49
+ local.mkdir()
50
+ env={**os.environ,'PATH':str(fake)+':/usr/bin:/bin','DATABASE_URL':'',
51
+ 'BLUM_PERSIST_DIR':str(persistence),'BLUM_LOCAL_POSTGRES_DATA_DIR':str(local)}
52
+ script=Path(__file__).parents[2]/'scripts/start.sh'
53
+ result=subprocess.run(['/bin/bash',str(script)],env=env,capture_output=True,text=True,timeout=15)
54
+ assert result.returncode!=0
55
+ assert 'Applying Alembic migrations' not in result.stdout
56
+ assert 'Refusing startup' in result.stdout
backend/tests/test_space_startup.py CHANGED
@@ -28,7 +28,7 @@ def test_embedded_postgres_restore_supports_custom_and_bootstrap_fallbacks() ->
28
  assert 'embedded_postgres_blum.sql"' in source
29
  assert 'Restoring legacy PostgreSQL backup' in source
30
  assert "BLUM_LEGACY_POSTGRES_BACKUP_FILE" in source
31
- assert 'su postgres -c "psql -d blum" < "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}"' in source
32
  assert 'psql -d blum < \\"${BLUM_LEGACY_POSTGRES_BACKUP_FILE}\\"' not in source
33
 
34
 
 
28
  assert 'embedded_postgres_blum.sql"' in source
29
  assert 'Restoring legacy PostgreSQL backup' in source
30
  assert "BLUM_LEGACY_POSTGRES_BACKUP_FILE" in source
31
+ assert 'su postgres -c "psql -v ON_ERROR_STOP=1 -d blum" < "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}"' in source
32
  assert 'psql -d blum < \\"${BLUM_LEGACY_POSTGRES_BACKUP_FILE}\\"' not in source
33
 
34
 
backend/tests/test_trading_ml_persistence.py CHANGED
@@ -174,4 +174,4 @@ def test_trading_ml_settings_have_evidence_bound_defaults():
174
 
175
 
176
  def test_migration_head_includes_ascent_after_evidence_decision_council():
177
- assert migration_head() == "0044_ascent_evolution"
 
174
 
175
 
176
  def test_migration_head_includes_ascent_after_evidence_decision_council():
177
+ assert migration_head() == "0045_alpha_call_registry"
cloudflare/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
  "name": "blum-cloudflare",
3
- "version": "2.2.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "blum-cloudflare",
9
- "version": "2.2.0",
10
  "devDependencies": {
11
  "typescript": "7.0.2",
12
  "wrangler": "4.149.0"
 
1
  {
2
  "name": "blum-cloudflare",
3
+ "version": "2.3.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "blum-cloudflare",
9
+ "version": "2.3.0",
10
  "devDependencies": {
11
  "typescript": "7.0.2",
12
  "wrangler": "4.149.0"
cloudflare/package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "blum-cloudflare",
3
- "version": "2.2.0",
4
  "private": true,
5
  "type": "module",
6
  "scripts": {
 
1
  {
2
  "name": "blum-cloudflare",
3
+ "version": "2.3.0",
4
  "private": true,
5
  "type": "module",
6
  "scripts": {
cloudflare/src/index.ts CHANGED
@@ -107,8 +107,10 @@ async function releaseStatus(request: Request, env: Env): Promise<Response> {
107
  proxy(probe, env, '/api/runtime/release').then(boundedJSON)
108
  ]);
109
  const ready = startup?.api_ready === true && startup?.ui_ready === true && !startup?.last_error
110
- && release?.source_revision === env.SOURCE_REVISION && release?.version === '2.2.0';
111
- return json(env, {version: '2.2.0', source_revision: env.SOURCE_REVISION,
 
 
112
  worker_version: env.CF_VERSION_METADATA.id, backend_ready: ready,
113
  backend_source_revision:release?.source_revision ?? null,
114
  backend_version:release?.version ?? null,
 
107
  proxy(probe, env, '/api/runtime/release').then(boundedJSON)
108
  ]);
109
  const ready = startup?.api_ready === true && startup?.ui_ready === true && !startup?.last_error
110
+ && release?.source_revision === env.SOURCE_REVISION && release?.version === '2.3.0'
111
+ && /^[0-9a-f]{64}$/.test(env.SOURCE_CONTENT_HASH) && release?.source_content_hash === env.SOURCE_CONTENT_HASH;
112
+ return json(env, {version: '2.3.0', source_revision: env.SOURCE_REVISION,
113
+ source_content_hash: release?.source_content_hash ?? null,
114
  worker_version: env.CF_VERSION_METADATA.id, backend_ready: ready,
115
  backend_source_revision:release?.source_revision ?? null,
116
  backend_version:release?.version ?? null,
cloudflare/tests/gateway.test.mjs CHANGED
@@ -4,6 +4,7 @@ import worker from '../src/index.ts';
4
 
5
  const env = {
6
  BACKEND_ORIGIN: 'https://italianhype-blum.hf.space', SOURCE_REVISION: 'a'.repeat(40),
 
7
  CF_VERSION_METADATA: {id:'version'},
8
  ASSETS: {fetch: async () => new Response('<html>BLUM</html>', {headers:{'Content-Type':'text/html'}})}
9
  };
@@ -86,13 +87,17 @@ test('every backend truthy persistence alias is denied', async t => {
86
  assert.equal(r.status,400);
87
  }
88
  });
89
- test('release GET and HEAD require the exact backend revision', async t => {
90
  let revision = 'a'.repeat(40);
 
91
  t.mock.method(globalThis,'fetch',async url => new Response(JSON.stringify(String(url).endsWith('/startup/status')
92
- ? {api_ready:true,ui_ready:true,last_error:''} : {version:'2.2.0',source_revision:revision}), {headers:{'Content-Type':'application/json'}}));
93
  assert.equal((await worker.fetch(new Request('https://blum.example/api/release'),env)).status,200);
94
  const head = await worker.fetch(new Request('https://blum.example/api/release',{method:'HEAD'}),env);
95
  assert.equal(head.status,200); assert.equal(await head.text(),'');
 
 
 
96
  revision = 'b'.repeat(40);
97
  assert.equal((await worker.fetch(new Request('https://blum.example/api/release'),env)).status,503);
98
  });
 
4
 
5
  const env = {
6
  BACKEND_ORIGIN: 'https://italianhype-blum.hf.space', SOURCE_REVISION: 'a'.repeat(40),
7
+ SOURCE_CONTENT_HASH: 'c'.repeat(64),
8
  CF_VERSION_METADATA: {id:'version'},
9
  ASSETS: {fetch: async () => new Response('<html>BLUM</html>', {headers:{'Content-Type':'text/html'}})}
10
  };
 
87
  assert.equal(r.status,400);
88
  }
89
  });
90
+ test('release GET and HEAD require the exact backend revision and source content', async t => {
91
  let revision = 'a'.repeat(40);
92
+ let content = 'c'.repeat(64);
93
  t.mock.method(globalThis,'fetch',async url => new Response(JSON.stringify(String(url).endsWith('/startup/status')
94
+ ? {api_ready:true,ui_ready:true,last_error:''} : {version:'2.3.0',source_revision:revision,source_content_hash:content}), {headers:{'Content-Type':'application/json'}}));
95
  assert.equal((await worker.fetch(new Request('https://blum.example/api/release'),env)).status,200);
96
  const head = await worker.fetch(new Request('https://blum.example/api/release',{method:'HEAD'}),env);
97
  assert.equal(head.status,200); assert.equal(await head.text(),'');
98
+ content = 'd'.repeat(64);
99
+ assert.equal((await worker.fetch(new Request('https://blum.example/api/release'),env)).status,503);
100
+ content = 'c'.repeat(64);
101
  revision = 'b'.repeat(40);
102
  assert.equal((await worker.fetch(new Request('https://blum.example/api/release'),env)).status,503);
103
  });
cloudflare/wrangler.jsonc CHANGED
@@ -15,7 +15,8 @@
15
  },
16
  "vars": {
17
  "BACKEND_ORIGIN": "https://italianhype-blum.hf.space",
18
- "SOURCE_REVISION": "unavailable"
 
19
  },
20
  "version_metadata": {"binding": "CF_VERSION_METADATA"},
21
  "observability": {"enabled": true, "head_sampling_rate": 0.1}
 
15
  },
16
  "vars": {
17
  "BACKEND_ORIGIN": "https://italianhype-blum.hf.space",
18
+ "SOURCE_REVISION": "unavailable",
19
+ "SOURCE_CONTENT_HASH": "unavailable"
20
  },
21
  "version_metadata": {"binding": "CF_VERSION_METADATA"},
22
  "observability": {"enabled": true, "head_sampling_rate": 0.1}
docs/ALPHA_DESK.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Prospective Alpha Desk
2
+
3
+ BLUM 2.3 records what its decision engines actually call before observing the outcome. The objective is a reproducible, cost-aware track record across equities/ETFs and Forex. Bloomberg product superiority and market-leading alpha are ambitions, not established results.
4
+
5
+ ## Registration
6
+
7
+ New paper candidates and final Forex decisions register in the same transaction as their authoritative source records. A unique source identity prevents duplicate registration. The manifest freezes the decision, model/strategy, confidence and price inputs. Its SHA256, source revision and protected-code fingerprint preserve lineage. Changes to a registered manifest are rejected or shown as integrity blockers. Completed historical decisions cannot be retrospectively registered.
8
+
9
+ The source engines retain order, risk and accounting authority. The registry is an audit record, not a second execution engine. It does not merge code or promote a model. Confidence is explicitly heuristic and is not advertised as a calibrated probability.
10
+
11
+ ## Qualification
12
+
13
+ - Equity daily inputs may be at most 96 hours old at decision time. Forex inputs may be at most 180 seconds old.
14
+ - A named provider and source/availability timestamps are required; future input timestamps fail. Available quality scores below 70 fail.
15
+ - Registration delayed more than five minutes from the decision is unqualified. Current calls also need fresh quotes and an explicitly stored positive expected net edge.
16
+ - Side and entry/stop/target geometry must be valid. A closed call requires actual opening and closing timestamps and registration before closure.
17
+ - Net P/L must reconcile with gross P/L and recorded execution costs. Forex swap credits are income; debits are costs. Unfilled, rejected and expired orders never enter realized-performance samples.
18
+ - Paired percentage alpha additionally needs a positive notional whose gross holding return matches recorded gross P/L, plus a finite same-period stored benchmark return. Missing or inconsistent values remain missing. Native Forex calls lacking the required notional/benchmark contract cannot produce paired alpha.
19
+
20
+ These gates verify internal record consistency and declared source availability. They do not independently certify a provider's entire history, every model input, or all underlying database rows. A hash alone does not establish semantic validity. The prior data quarantines remain active.
21
+
22
+ ## Evaluation
23
+
24
+ The service reads at most 2,000 recent registrations and reports truncation. Equity and Forex samples, exclusions and rankings are separate. Costs-qualified observations and paired benchmark observations have different counts. An absent P/L or benchmark displays as unavailable, not zero.
25
+
26
+ A deterministic bootstrap resamples complete closure-date groups, with 800 draws and seed 3407. The interval is a two-sided 95% empirical interval. A positive paired-sample label requires 100 paired observations, 30 closure dates, three assets and a lower bound above zero. These are conservative application gates, not a universal statistical certification standard. The interval does not correct selection bias, repeated monitoring or every overlapping holding period; it never authorizes promotion.
27
+
28
+ Irregular trade returns are not annualized into Sharpe ratios. Mean trade excursion is not represented as a portfolio equity-curve drawdown in this Desk. Legacy panels remain descriptive; positive historical grades without independently verified provenance are marked insufficient.
29
+
30
+ ## Operation and limits
31
+
32
+ `GET /api/alpha/desk` and the existing Alpha snapshot read stored evidence. Page load cannot register calls, train weights or run orders. Public mutations remain blocked at Cloudflare and origin. Existing paid training stays disabled and all execution remains paper-only.
33
+
34
+ The maintained Space uses embedded PostgreSQL and periodic object-storage backups. Registrations inherit that recovery policy; they are not a guarantee of zero-loss restart durability. Failed logical restores stop startup. External PostgreSQL with a verified durability/recovery configuration is required for stricter guarantees.
35
+
36
+ The 2.3 registry starts empty. Legacy outcomes remain available, but cannot be counted as newly registered prospective calls. Qualification and independent forward performance must accumulate over real time.
37
+
38
+ ## Research references
39
+
40
+ - [Bloomberg BQuant](https://professional.bloomberg.com/products/bloomberg-terminal/research/bquant/) describes Bloomberg's quantitative research platform; it supplies no public common test set establishing relative BLUM alpha.
41
+ - [Bailey and López de Prado: The Deflated Sharpe Ratio](https://www.davidhbailey.com/dhbpapers/deflated-sharpe.pdf) explains selection bias and non-normality. BLUM does not label the Desk's bootstrap as DSR or as a multiple-testing correction.
docs/DEPLOYMENT.md CHANGED
@@ -40,7 +40,8 @@ npm --prefix frontend run build
40
  npm ci --prefix cloudflare
41
  npm --prefix cloudflare run types
42
  npm --prefix cloudflare run typecheck
43
- npm --prefix cloudflare run deploy -- --var SOURCE_REVISION:$(git rev-parse HEAD)
 
44
  ```
45
 
46
  Wrangler authentication is required for publication. A custom domain can be added later. This release does not alter unrelated DNS. Worker observability is enabled. Financial state is not stored in edge cache or a parallel Worker database.
@@ -55,7 +56,7 @@ curl -fsS https://italianhype-blum.hf.space/api/runtime/release
55
  curl -fsS https://blum.signallayerlabs.workers.dev/api/release
56
  ```
57
 
58
- Require api_ready=true, ui_ready=true, no startup last_error, and the exact GitHub source revision. HTML with HTTP200 is not API readiness. `scripts/verify_deployment.py` enforces this contract.
59
 
60
  The existing bucket stays attached. PostgreSQL remains on local disk; object storage holds physical recovery files and atomic logical backups. Embedded PostgreSQL supports one replica. Multiple replicas require an external database. Preserve recovery files and verify startup before declaring data recovery complete.
61
 
@@ -84,3 +85,7 @@ Keep BLUM_FOREX_HISTORY_ENABLED=false, BLUM_HF_TRAINING_ENABLED=false, BLUM_HF_T
84
  5. Additive migration 0044_ascent_evolution preserves existing data. Code rollback does not require dropping its tables or erasing evidence.
85
 
86
  Report outages and missing evidence explicitly. Do not fill unavailable charts or APIs with fabricated results.
 
 
 
 
 
40
  npm ci --prefix cloudflare
41
  npm --prefix cloudflare run types
42
  npm --prefix cloudflare run typecheck
43
+ source_content_hash=$(PYTHONPATH=backend python -c 'from app.core.release import source_content_hash; print(source_content_hash())')
44
+ npm --prefix cloudflare run deploy -- --var "SOURCE_REVISION:$(git rev-parse HEAD)" --var "SOURCE_CONTENT_HASH:$source_content_hash"
45
  ```
46
 
47
  Wrangler authentication is required for publication. A custom domain can be added later. This release does not alter unrelated DNS. Worker observability is enabled. Financial state is not stored in edge cache or a parallel Worker database.
 
56
  curl -fsS https://blum.signallayerlabs.workers.dev/api/release
57
  ```
58
 
59
+ Require api_ready=true, ui_ready=true, no startup last_error, and the exact GitHub source revision. The release API also reports source_content_hash, computed from protected backend and script files. The verifier compares it with the verified checkout, so changing a revision variable cannot make an older code image pass. HTML with HTTP200 is not API readiness. `scripts/verify_deployment.py` enforces this contract.
60
 
61
  The existing bucket stays attached. PostgreSQL remains on local disk; object storage holds physical recovery files and atomic logical backups. Embedded PostgreSQL supports one replica. Multiple replicas require an external database. Preserve recovery files and verify startup before declaring data recovery complete.
62
 
 
85
  5. Additive migration 0044_ascent_evolution preserves existing data. Code rollback does not require dropping its tables or erasing evidence.
86
 
87
  Report outages and missing evidence explicitly. Do not fill unavailable charts or APIs with fabricated results.
88
+
89
+ ## Prospective call evidence
90
+
91
+ New decisions register within their original database transactions. The read-only `/api/alpha/desk` never backfills legacy records or starts financial execution. Migration 0045 is additive. Source fingerprints establish recorded code content, not semantic data validity or model outperformance. See [Alpha Desk](ALPHA_DESK.md).
docs/superpowers/plans/2026-10-13-verified-alpha-desk.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Verified Alpha Desk Implementation Plan
2
+
3
+ > For agentic workers: use superpowers:executing-plans for native implementation and request an independent whole-branch review before merge.
4
+
5
+ **Goal:** Release prospectively registered, provenance-aware alpha calls with independent equity and Forex evidence.
6
+
7
+ **Architecture:** Existing decision engines retain authority. An additive registration table freezes a manifest before outcomes, and a bounded read service verifies it against source accounting. The existing Alpha page renders two market panels and the call journal.
8
+
9
+ **Tech Stack:** Python/FastAPI, SQLAlchemy/Alembic/PostgreSQL, SQLite integration tests, Next.js/TypeScript, Cloudflare Workers.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-10-13-verified-alpha-desk-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - Paper-only; no paid infrastructure or weight-training jobs.
16
+ - At most 2,000 registered records per read; explicit window and exclusions.
17
+ - Separate equity and Forex evaluations; no retrospective prospective registration.
18
+ - Freshness: equities 96 hours, Forex 180 seconds. All sources available no later than decision.
19
+ - Positive paired-sample verdict: 100 observations, 30 closure dates, three assets and positive 95% lower bound.
20
+ - Confidence remains heuristic; no annualized Sharpe from irregular trades.
21
+
22
+ ## Review Focus
23
+
24
+ - Repeated registration must preserve the original manifest even if source data later changes.
25
+ - An unfilled order or non-finite cost must never become a winning realized trade.
26
+ - A new source record with stale/future timestamps must remain visible but unqualified.
27
+ - A missing benchmark or inconsistent notional must remain missing, not a zero-return baseline.
28
+ - Startup must not continue after a failed logical database restore.
29
+
30
+ ### Task 1: Registration and audited read model
31
+
32
+ Files: `backend/app/models.py`, migration `0045_alpha_call_registry.py`, `backend/app/services/alpha_desk.py`, `backend/tests/test_alpha_desk.py`.
33
+
34
+ Interfaces: `AlphaCallRegistry.register(db, source, *, registered_at=None)` returns the immutable registration; `AlphaDeskService.snapshot(db, *, now=None)` returns the two market panels, bounded journal and exclusions. Pure `paired_interval(rows)` returns sample/date/asset coverage and bootstrap bounds.
35
+
36
+ - [x] Write integration tests for idempotent registration and mutation rejection; observe missing-feature failure.
37
+ - [x] Add the model, migration and registry; verify those tests.
38
+ - [x] Write tests for stale/future provenance, late registration, absent benchmark, cost mismatch, rejected/unfilled calls and market separation; observe failure.
39
+ - [x] Implement bounded source adapters, verified accounting and paired-date interval; verify focused tests.
40
+
41
+ ### Task 2: Capture new decisions and correct existing evidence
42
+
43
+ Files: paper creator, Forex orchestrator, paper price-context builder, `backend/app/engine/brain/trader_brain.py`, associated integration tests.
44
+
45
+ - [x] Test new registrations through the actual source engines and an older closed trade hidden by 500 newer non-fills.
46
+ - [x] Capture final frozen inputs in the original transaction; include existing provider/availability metadata.
47
+ - [x] Query closed trades independently, deduplicate IDs, remove double-counted live rows and mark unverified historical grades.
48
+ - [x] Run affected source-engine and Brain tests.
49
+
50
+ ### Task 3: API, two-market product and recovery guard
51
+
52
+ Files: Alpha router, facade, shared route JSON, Alpha page/components, `scripts/start.sh`, API and restore tests.
53
+
54
+ - [x] Test read-only Desk GET and executable failed logical restore; observe failure.
55
+ - [x] Expose the bounded Desk, render market panels and journal with source/freshness/coverage, and terminate failed restore startup.
56
+ - [x] Verify API boundary and actual frontend rendering.
57
+
58
+ ### Task 4: Review and production release
59
+
60
+ Files: version references, release documentation and deployment verifier.
61
+
62
+ - [x] Set version 2.3.0 consistently and add Desk smoke verification.
63
+ - [ ] Run full backend, frontend and gateway checks; obtain independent review and resolve concrete blockers.
64
+ - [ ] Commit, push PR, attach it, merge after required CI and verify exact-revision deployment.
65
+ - [ ] Capture public runtime, data/persistence limitations and live screenshots; publish GitHub 2.3.0 release.
docs/superpowers/specs/2026-10-13-verified-alpha-desk-design.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Verified Alpha Desk
2
+
3
+ The user asks for development and release of the improvements we judge necessary to pursue a financial research platform superior to Bloomberg and a leading alpha caller. They explicitly chose both equities/ETFs and Forex, evaluated separately. Success in this release means verifiable prospective calls and honest comparable evidence; it does not mean an unsupported claim of superiority to proprietary products. Implementation and publication are delegated by the user. Work uses the existing task-owned clone and native execution, with independent review before merge.
4
+
5
+ ## Approach
6
+
7
+ Reuse BLUM's authoritative paper and Forex decision engines. Add a prospective registration ledger and a read-only Alpha Desk to the existing Alpha page. A Bloomberg-scale data terminal needs licensed datasets and infrastructure; weight training requires valid datasets and a budget. Neither substitutes for a prospective, cost-aware track record. This release concentrates on that track record and fixes known read-model errors.
8
+
9
+ ## Registration and provenance
10
+
11
+ One additive SQL table records source identity, market, actual registration time, source decision time, the frozen decision manifest and its SHA256. The paper candidate creator and Forex orchestrator register new decisions within their existing transactions. Rejected and unfilled calls remain visible. Registration is idempotent and refuses changes to an existing manifest. Historical rows are never retrospectively registered as prospective evidence. A source row missing a registration remains legacy evidence.
12
+
13
+ Price provenance must identify a provider, market timestamp and availability timestamp. All timestamps must be at or before the decision. Equity daily data older than 96 hours and Forex quotes older than 180 seconds are unavailable for active calls. A Forex quote needs positive finite bid/ask, ask >= bid and named source; timeframe acquisition dates must not exceed the decision. Missing provenance, invalid side/geometry, non-finite values and source mutations block qualification. Confidence is an explicitly labelled heuristic, not a calibrated probability.
14
+
15
+ ## Evidence and ranking
16
+
17
+ The Desk reads at most 2,000 registered calls, reports its window, and keeps equities and Forex separate. A closed call counts as qualified forward evidence only if registration preceded closure, input integrity remains intact, timestamps are causal, fill/accounting are valid and net P/L reconciles with explicit execution costs. Benchmark-relative percentage returns additionally require positive account-currency notional and gross-return/notional reconciliation. Missing benchmarks remain missing. Unfilled and rejected calls never enter realized-performance samples.
18
+
19
+ Report observed P/L and R separately from qualified paired alpha. Do not annualize irregular per-trade returns or treat average trade excursion as portfolio drawdown. Cluster paired excess returns by closure date for a deterministic 95% bootstrap interval. A positive-sample verdict requires at least 100 paired observations, 30 closure dates and three assets, with a lower confidence bound above zero. This is sample evidence, never a production-promotion or competitor-superiority certificate. All failures and exclusions are counted. Existing positive historical grades are marked uncertified when provenance is not independently validated.
20
+
21
+ Open eligible calls are shown with direction, frozen entry/stop/target, model, quote age, registration hash and source status. Rankings remain within each market and use stored expected net edge only; absent edge estimates are not invented. Prices are never fetched or financial decisions generated by page load.
22
+
23
+ ## Integration and persistence
24
+
25
+ Expose a reviewed GET/HEAD `/api/alpha/desk` through the shared route policy and embed it in the existing Alpha snapshot. Keep mutations private and paper-only. Preserve existing source tables and additive migrations. Correct Alpha's newest-500 sampling to include a separate bounded query for older closed trades, deduplicate source identities and avoid counting the same live-forward row twice.
26
+
27
+ Embedded PostgreSQL still uses periodic backups and cannot guarantee zero-loss restart durability. Make restore failures terminate startup rather than continue into a partially restored or empty database. Expose the actual durability mode in the Desk; do not claim this fixes recovery-point loss. Strict durability requires external PostgreSQL. No new paid infrastructure, GPU training or broker execution is enabled.
28
+
29
+ ## Verification and release
30
+
31
+ Use real SQLite integration tests for registration, immutability, causal/stale inputs, net-cost reconciliation, unfilled outcomes, separate markets, read-only access and older closed trades. Test bootstrap evidence with hand-derived groups and insufficient samples. Test failed restore via executable shell fixtures. Run the full backend suite, frontend tests/build and gateway tests/types. Review, merge only after required CI passes, wait for exact-revision HF and Cloudflare readiness, smoke-test the Desk and five product surfaces, and publish BLUM 2.3.0 with reproducible reports and explicit limitations.
32
+
33
+ ## Sources
34
+
35
+ - [Bloomberg BQuant](https://professional.bloomberg.com/products/bloomberg-terminal/research/bquant/): product comparison context, not an alpha-performance benchmark.
36
+ - [Bailey and López de Prado, The Deflated Sharpe Ratio](https://www.davidhbailey.com/dhbpapers/deflated-sharpe.pdf): selection bias and non-normal returns require care. This release does not claim to implement DSR or turn a bootstrap interval into a multiple-testing correction.
frontend/app/alpha/page.tsx CHANGED
@@ -6,6 +6,7 @@ import { api } from "@/lib/api";
6
  import { BloombergPanel, MetricCard, MiniSparkline, TerminalHeader } from "@/components/FinancialTerminal";
7
  import { LoadingState } from "@/components/LoadingState";
8
  import { StatusBadge } from "@/components/StatusBadge";
 
9
 
10
  const EVIDENCE_GRADE_ORDER = ["NO_DATA", "INSUFFICIENT_EVIDENCE", "WEAK", "MIXED", "PROMISING", "STRONG"];
11
 
@@ -69,6 +70,7 @@ export default function AlphaPage() {
69
  ]}
70
  />
71
 
 
72
  <section className="alpha-verdict-panel">
73
  <div>
74
  <span>Alpha Verdict</span>
 
6
  import { BloombergPanel, MetricCard, MiniSparkline, TerminalHeader } from "@/components/FinancialTerminal";
7
  import { LoadingState } from "@/components/LoadingState";
8
  import { StatusBadge } from "@/components/StatusBadge";
9
+ import { AlphaDesk } from "@/components/AlphaDesk";
10
 
11
  const EVIDENCE_GRADE_ORDER = ["NO_DATA", "INSUFFICIENT_EVIDENCE", "WEAK", "MIXED", "PROMISING", "STRONG"];
12
 
 
70
  ]}
71
  />
72
 
73
+ <AlphaDesk data={data.registered_alpha_desk} />
74
  <section className="alpha-verdict-panel">
75
  <div>
76
  <span>Alpha Verdict</span>
frontend/components/AlphaDesk.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { BloombergPanel } from "@/components/FinancialTerminal";
4
+ import { StatusBadge } from "@/components/StatusBadge";
5
+ import { alphaMarketView, alphaMetric } from "@/lib/alphaDeskView.mjs";
6
+
7
+ export function AlphaDesk({ data }: { data: any }) {
8
+ if (!data) return <BloombergPanel title="Prospective Alpha Desk" value="unavailable"><p>The call registry is unavailable. Performance is not inferred from legacy records.</p></BloombergPanel>;
9
+ return (
10
+ <section aria-label="Prospective Alpha Desk" style={{ marginBottom: 16 }}>
11
+ <BloombergPanel title="Prospective Alpha Desk" value={`${data.registered_count ?? 0} registered`} subtitle="Calls recorded before outcomes. Equities and Forex are evaluated separately.">
12
+ <p style={{ color: "var(--text-secondary)", marginBottom: 12 }}>Only registered calls with causal inputs, valid fills and reconciled costs qualify. Imported history cannot create a forward track record. Confidence remains a heuristic.</p>
13
+ <div className="grid-2">
14
+ <Market data={data} market="equity" title="Equities / ETFs" />
15
+ <Market data={data} market="forex" title="Forex" />
16
+ </div>
17
+ <p style={{ color: "var(--text-secondary)", marginTop: 12 }}>Window: {data.window?.evaluated ?? 0} of {data.registered_count ?? 0} registrations, maximum {data.window?.limit ?? "—"}. {data.window?.truncated ? "Earlier registrations are outside this bounded read." : "All current registrations are included."} A positive paired sample requires 100 observations, 30 closure dates and three assets. The interval does not certify strategy promotion or competitor superiority.</p>
18
+ <p style={{ color: "var(--text-secondary)" }}>Recovery: {data.durability?.mode === "embedded_postgres" ? "periodic backups; the latest updates may be absent after a restart" : "external database; guarantees depend on its recovery configuration"}.</p>
19
+ </BloombergPanel>
20
+ <BloombergPanel title="Registered Call Journal" value={`${data.journal?.length ?? 0} shown`} subtitle="Original decisions, source integrity and explicit exclusions." dense>
21
+ <div style={{ overflowX: "auto" }}>
22
+ <table className="terminal-table" aria-label="Registered alpha calls">
23
+ <thead><tr><th>Asset / market</th><th>Recorded UTC</th><th>Direction / state</th><th>Source</th><th>Net account P/L</th><th>Paired excess</th><th>Qualification</th></tr></thead>
24
+ <tbody>{(data.journal ?? []).map((call: any) => <tr key={call.source_uid}>
25
+ <td>{call.asset}<br /><small>{call.market}</small></td>
26
+ <td>{call.registered_at?.replace("T", " ")}<br /><small title={call.input_sha256}>Input {call.input_sha256?.slice(0, 12)}</small></td>
27
+ <td>{call.direction ?? "unknown"}<br /><small>{call.status}</small></td>
28
+ <td>{call.provider ?? "unattributed"}<br /><small>{call.quote_at?.replace("T", " ") ?? "timestamp missing"}</small></td>
29
+ <td>{alphaMetric(call.net_pnl)}</td><td>{alphaMetric(call.paired_excess_percent, "%")}</td>
30
+ <td>{call.qualified_closed ? "Cost-qualified close" : call.outcome_status}<br /><small>{call.blockers?.join(" · ") || "No current blocker"}</small></td>
31
+ </tr>)}</tbody>
32
+ </table>
33
+ {!data.journal?.length && <p className="terminal-empty">No prospective calls registered yet. Existing historical and paper records remain descriptive evidence below.</p>}
34
+ </div>
35
+ </BloombergPanel>
36
+ </section>
37
+ );
38
+ }
39
+
40
+ function Market({ data, market, title }: { data: any; market: string; title: string }) {
41
+ const view = alphaMarketView(data, market);
42
+ return <BloombergPanel title={title} value={view.verdict.replaceAll("_", " ")} subtitle="No samples or performance pooled across markets.">
43
+ <StatusBadge label={view.verdict} />
44
+ <div className="brain-list dense" style={{ marginTop: 10 }}>
45
+ <div><span>Registered / cost-qualified / paired</span><strong>{view.registered} / {view.qualifiedClosed} / {view.paired}</strong></div>
46
+ <div><span>Mean paired excess</span><strong>{alphaMetric(view.alpha, "%")}</strong></div>
47
+ <div><span>95% closure-date interval</span><strong>{alphaMetric(view.interval.lower_95, "%")} to {alphaMetric(view.interval.upper_95, "%")}</strong></div>
48
+ <div><span>Closure dates / assets</span><strong>{view.interval.dates ?? 0} / {view.interval.assets ?? 0}</strong></div>
49
+ <div><span>Observed net account P/L</span><strong>{alphaMetric(view.pnl)}</strong></div>
50
+ </div>
51
+ <h3 style={{ marginTop: 14 }}>Current qualified calls</h3>
52
+ {view.active.map((call: any) => <div className="brain-list dense" key={call.source_uid}>
53
+ <div><strong>{call.asset} · {call.direction}</strong><p>Entry {alphaMetric(call.entry)} · Stop {alphaMetric(call.stop)} · Target {alphaMetric(call.target)}</p><span>Stored expected net edge: {alphaMetric(call.expected_net_edge)} {call.edge_units}. {call.model}</span></div>
54
+ </div>)}
55
+ {!view.active.length && <p>No current call passes the source, freshness and stored-edge gates.</p>}
56
+ <details style={{ marginTop: 10 }}><summary>Excluded evidence</summary>{Object.entries(view.exclusions).map(([reason, count]) => <p key={reason}>{reason.replaceAll("_", " ")}: {count}</p>)}{!Object.keys(view.exclusions).length && <p>No recorded exclusions in this window.</p>}</details>
57
+ </BloombergPanel>;
58
+ }
frontend/lib/alphaDeskView.d.mts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export function alphaMetric(value: unknown, unit?: string): string;
2
+ export function alphaMarketView(data: any, market: string): {
3
+ registered: number; qualifiedClosed: number; paired: number; alpha: number | null;
4
+ pnl: number | null; interval: any; verdict: string; active: any[]; exclusions: Record<string, number>;
5
+ };
frontend/lib/alphaDeskView.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function alphaMetric(value, unit = '') {
2
+ return typeof value === 'number' && Number.isFinite(value) ? `${value.toFixed(3)}${unit}` : '—';
3
+ }
4
+
5
+ export function alphaMarketView(data, market) {
6
+ const source = data?.markets?.[market] ?? {};
7
+ return {
8
+ registered: source.registered ?? 0,
9
+ qualifiedClosed: source.qualified_closed ?? 0,
10
+ paired: source.paired_sample ?? 0,
11
+ alpha: source.mean_paired_excess_percent ?? null,
12
+ pnl: source.observed_net_pnl ?? null,
13
+ interval: source.paired_interval ?? {},
14
+ verdict: source.verdict ?? 'INSUFFICIENT_EVIDENCE',
15
+ active: source.active_calls ?? [],
16
+ exclusions: source.exclusions ?? {},
17
+ };
18
+ }
frontend/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
  "name": "blum-ai-financial-intelligence-frontend",
3
- "version": "2.2.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "blum-ai-financial-intelligence-frontend",
9
- "version": "2.2.0",
10
  "dependencies": {
11
  "@types/node": "^20.12.12",
12
  "@types/react": "^18.3.3",
 
1
  {
2
  "name": "blum-ai-financial-intelligence-frontend",
3
+ "version": "2.3.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "blum-ai-financial-intelligence-frontend",
9
+ "version": "2.3.0",
10
  "dependencies": {
11
  "@types/node": "^20.12.12",
12
  "@types/react": "^18.3.3",
frontend/package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "blum-ai-financial-intelligence-frontend",
3
- "version": "2.2.0",
4
  "private": true,
5
  "scripts": {
6
  "dev": "next dev -p 3000",
 
1
  {
2
  "name": "blum-ai-financial-intelligence-frontend",
3
+ "version": "2.3.0",
4
  "private": true,
5
  "scripts": {
6
  "dev": "next dev -p 3000",
frontend/tests/alpha-desk-view.test.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ test('unknown or non-finite alpha never renders as zero',async()=>{
5
+ const {alphaMetric}=await import('../lib/alphaDeskView.mjs');
6
+ assert.equal(alphaMetric(null,'%'),'—');
7
+ assert.equal(alphaMetric(Infinity,'%'),'—');
8
+ assert.equal(alphaMetric(-0.01,'%'),'-0.010%');
9
+ });
10
+
11
+ test('market view reads only the chosen market and preserves an absent benchmark',async()=>{
12
+ const {alphaMarketView}=await import('../lib/alphaDeskView.mjs');
13
+ const data={markets:{equity:{qualified_closed:100,mean_paired_excess_percent:6},forex:{qualified_closed:2,mean_paired_excess_percent:null}}};
14
+ const fx=alphaMarketView(data,'forex');
15
+ assert.equal(fx.qualifiedClosed,2);
16
+ assert.equal(fx.alpha,null);
17
+ assert.equal(fx.verdict,'INSUFFICIENT_EVIDENCE');
18
+ });
package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "blum-ai-financial-intelligence",
3
- "version": "2.2.0",
4
  "private": true,
5
  "scripts": {
6
  "frontend:dev": "npm --prefix frontend run dev",
 
1
  {
2
  "name": "blum-ai-financial-intelligence",
3
+ "version": "2.3.0",
4
  "private": true,
5
  "scripts": {
6
  "frontend:dev": "npm --prefix frontend run dev",
scripts/start.sh CHANGED
@@ -7,6 +7,7 @@ export BLUM_DB_BACKUP_SECONDS="${BLUM_DB_BACKUP_SECONDS:-1800}"
7
  export BLUM_DB_RESTORE_JOBS="${BLUM_DB_RESTORE_JOBS:-2}"
8
  export BLUM_DB_INITIAL_BACKUP_DELAY_SECONDS="${BLUM_DB_INITIAL_BACKUP_DELAY_SECONDS:-60}"
9
  export BLUM_LOCAL_POSTGRES_DATA_DIR="${BLUM_LOCAL_POSTGRES_DATA_DIR:-/var/lib/postgresql/15/main}"
 
10
  export BLUM_PHYSICAL_POSTGRES_BACKUP_DIR="${BLUM_PERSIST_DIR}/embedded_postgres_physical"
11
  export BLUM_PHYSICAL_POSTGRES_BASE_BACKUP="${BLUM_PERSIST_DIR}/embedded_postgres_physical/base.tar.gz"
12
  export BLUM_PHYSICAL_POSTGRES_WAL_BACKUP="${BLUM_PERSIST_DIR}/embedded_postgres_physical/pg_wal.tar.gz"
@@ -134,11 +135,16 @@ cleanup_unsupported_persistent_postgres() {
134
 
135
  if [[ -z "${DATABASE_URL:-}" ]]; then
136
  echo "No DATABASE_URL provided. Starting embedded PostgreSQL for the Hugging Face Docker demo."
 
 
 
 
137
  mkdir -p "${BLUM_PERSIST_DIR}" || true
138
  cleanup_unsupported_persistent_postgres
139
  PHYSICAL_RESTORE_ATTEMPTED=false
140
  if [[ -f "${BLUM_PHYSICAL_POSTGRES_READY_FILE}" ]]; then
141
  PHYSICAL_RESTORE_ATTEMPTED=true
 
142
  start_restore_status_server
143
  if restore_embedded_postgres_physical_backup; then
144
  echo "Physical PostgreSQL recovery image restored to local disk."
@@ -166,27 +172,53 @@ if [[ -z "${DATABASE_URL:-}" ]]; then
166
  export BLUM_COMPRESSED_POSTGRES_PARTS_READY_FILE="${BLUM_PERSIST_DIR}/embedded_postgres_blum.sql.gz.parts-ready"
167
  export BLUM_LEGACY_POSTGRES_BACKUP_FILE="${BLUM_PERSIST_DIR}/embedded_postgres_blum.sql"
168
  TABLE_COUNT="$(su postgres -c "psql -d blum -tAc \"SELECT count(*) FROM information_schema.tables WHERE table_schema='public';\"" | tr -d '[:space:]')"
 
 
 
 
 
169
  if [[ "${TABLE_COUNT:-0}" == "0" ]] && { [[ -s "${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}" ]] || [[ -s "${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}" ]] || [[ -s "${BLUM_COMPRESSED_POSTGRES_PARTS_READY_FILE}" ]] || [[ -s "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}" ]]; }; then
170
  start_restore_status_server
171
  fi
172
  if [[ "${TABLE_COUNT:-0}" == "0" && -s "${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}" ]]; then
173
  echo "Restoring embedded PostgreSQL backup from ${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}."
174
  chmod a+r "${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}" || true
175
- su postgres -c "pg_restore --jobs=${BLUM_DB_RESTORE_JOBS} --exit-on-error -d blum '${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}'" || echo "Custom backup restore failed; continuing with migrations."
 
 
 
 
 
176
  elif [[ "${TABLE_COUNT:-0}" == "0" && -s "${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}" ]]; then
177
  echo "Restoring compressed PostgreSQL bootstrap from ${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}."
178
- gzip -dc "${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}" | su postgres -c "psql -d blum" || echo "Compressed backup restore failed; continuing with migrations."
 
 
 
 
 
179
  elif [[ "${TABLE_COUNT:-0}" == "0" && -s "${BLUM_COMPRESSED_POSTGRES_PARTS_READY_FILE}" ]]; then
180
  echo "Restoring segmented PostgreSQL bootstrap from ${BLUM_COMPRESSED_POSTGRES_PART_PREFIX}.part-* ."
181
- cat "${BLUM_COMPRESSED_POSTGRES_PART_PREFIX}".part-* | gzip -dc | su postgres -c "psql -d blum" || echo "Segmented backup restore failed; continuing with migrations."
 
 
 
 
 
182
  elif [[ "${TABLE_COUNT:-0}" == "0" && -s "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}" ]]; then
183
  echo "Restoring legacy PostgreSQL backup from ${BLUM_LEGACY_POSTGRES_BACKUP_FILE}."
184
- su postgres -c "psql -d blum" < "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}" || echo "Legacy backup restore failed; continuing with migrations."
 
 
 
 
 
185
  elif [[ ! -s "${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}" && ! -s "${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}" && ! -s "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}" ]]; then
186
  echo "No embedded PostgreSQL backup found yet. A new backup will be written periodically."
187
  else
188
  echo "Embedded PostgreSQL already contains tables; skipping restore."
189
  fi
 
190
  stop_restore_status_server
191
  export DATABASE_URL="postgresql+psycopg2://postgres:postgres@127.0.0.1:5432/blum"
192
  START_EMBEDDED_BACKUP_LOOP=true
 
7
  export BLUM_DB_RESTORE_JOBS="${BLUM_DB_RESTORE_JOBS:-2}"
8
  export BLUM_DB_INITIAL_BACKUP_DELAY_SECONDS="${BLUM_DB_INITIAL_BACKUP_DELAY_SECONDS:-60}"
9
  export BLUM_LOCAL_POSTGRES_DATA_DIR="${BLUM_LOCAL_POSTGRES_DATA_DIR:-/var/lib/postgresql/15/main}"
10
+ BLUM_LOCAL_RESTORE_INCOMPLETE_FILE="${BLUM_LOCAL_POSTGRES_DATA_DIR}.restore-incomplete"
11
  export BLUM_PHYSICAL_POSTGRES_BACKUP_DIR="${BLUM_PERSIST_DIR}/embedded_postgres_physical"
12
  export BLUM_PHYSICAL_POSTGRES_BASE_BACKUP="${BLUM_PERSIST_DIR}/embedded_postgres_physical/base.tar.gz"
13
  export BLUM_PHYSICAL_POSTGRES_WAL_BACKUP="${BLUM_PERSIST_DIR}/embedded_postgres_physical/pg_wal.tar.gz"
 
135
 
136
  if [[ -z "${DATABASE_URL:-}" ]]; then
137
  echo "No DATABASE_URL provided. Starting embedded PostgreSQL for the Hugging Face Docker demo."
138
+ if [[ -f "${BLUM_LOCAL_RESTORE_INCOMPLETE_FILE}" ]]; then
139
+ echo "Refusing startup after an incomplete restore. Recover the local database before clearing its failure marker."
140
+ exit 1
141
+ fi
142
  mkdir -p "${BLUM_PERSIST_DIR}" || true
143
  cleanup_unsupported_persistent_postgres
144
  PHYSICAL_RESTORE_ATTEMPTED=false
145
  if [[ -f "${BLUM_PHYSICAL_POSTGRES_READY_FILE}" ]]; then
146
  PHYSICAL_RESTORE_ATTEMPTED=true
147
+ touch "${BLUM_LOCAL_RESTORE_INCOMPLETE_FILE}"
148
  start_restore_status_server
149
  if restore_embedded_postgres_physical_backup; then
150
  echo "Physical PostgreSQL recovery image restored to local disk."
 
172
  export BLUM_COMPRESSED_POSTGRES_PARTS_READY_FILE="${BLUM_PERSIST_DIR}/embedded_postgres_blum.sql.gz.parts-ready"
173
  export BLUM_LEGACY_POSTGRES_BACKUP_FILE="${BLUM_PERSIST_DIR}/embedded_postgres_blum.sql"
174
  TABLE_COUNT="$(su postgres -c "psql -d blum -tAc \"SELECT count(*) FROM information_schema.tables WHERE table_schema='public';\"" | tr -d '[:space:]')"
175
+ if [[ "${TABLE_COUNT:-0}" == "0" && "${PHYSICAL_RESTORE_ATTEMPTED}" == "true" ]] && ! { [[ -s "${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}" ]] || [[ -s "${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}" ]] || [[ -s "${BLUM_COMPRESSED_POSTGRES_PARTS_READY_FILE}" ]] || [[ -s "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}" ]]; }; then
176
+ echo "Refusing startup: physical recovery left no financial tables and no logical backup is available."
177
+ stop_restore_status_server
178
+ exit 1
179
+ fi
180
  if [[ "${TABLE_COUNT:-0}" == "0" ]] && { [[ -s "${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}" ]] || [[ -s "${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}" ]] || [[ -s "${BLUM_COMPRESSED_POSTGRES_PARTS_READY_FILE}" ]] || [[ -s "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}" ]]; }; then
181
  start_restore_status_server
182
  fi
183
  if [[ "${TABLE_COUNT:-0}" == "0" && -s "${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}" ]]; then
184
  echo "Restoring embedded PostgreSQL backup from ${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}."
185
  chmod a+r "${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}" || true
186
+ touch "${BLUM_LOCAL_RESTORE_INCOMPLETE_FILE}"
187
+ if ! su postgres -c "pg_restore --jobs=${BLUM_DB_RESTORE_JOBS} --exit-on-error -d blum '${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}'"; then
188
+ echo "Custom backup restore failed. Refusing startup with incomplete financial evidence."
189
+ stop_restore_status_server
190
+ exit 1
191
+ fi
192
  elif [[ "${TABLE_COUNT:-0}" == "0" && -s "${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}" ]]; then
193
  echo "Restoring compressed PostgreSQL bootstrap from ${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}."
194
+ touch "${BLUM_LOCAL_RESTORE_INCOMPLETE_FILE}"
195
+ if ! gzip -dc "${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}" | su postgres -c "psql -v ON_ERROR_STOP=1 -d blum"; then
196
+ echo "Compressed backup restore failed. Refusing startup with incomplete financial evidence."
197
+ stop_restore_status_server
198
+ exit 1
199
+ fi
200
  elif [[ "${TABLE_COUNT:-0}" == "0" && -s "${BLUM_COMPRESSED_POSTGRES_PARTS_READY_FILE}" ]]; then
201
  echo "Restoring segmented PostgreSQL bootstrap from ${BLUM_COMPRESSED_POSTGRES_PART_PREFIX}.part-* ."
202
+ touch "${BLUM_LOCAL_RESTORE_INCOMPLETE_FILE}"
203
+ if ! cat "${BLUM_COMPRESSED_POSTGRES_PART_PREFIX}".part-* | gzip -dc | su postgres -c "psql -v ON_ERROR_STOP=1 -d blum"; then
204
+ echo "Segmented backup restore failed. Refusing startup with incomplete financial evidence."
205
+ stop_restore_status_server
206
+ exit 1
207
+ fi
208
  elif [[ "${TABLE_COUNT:-0}" == "0" && -s "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}" ]]; then
209
  echo "Restoring legacy PostgreSQL backup from ${BLUM_LEGACY_POSTGRES_BACKUP_FILE}."
210
+ touch "${BLUM_LOCAL_RESTORE_INCOMPLETE_FILE}"
211
+ if ! su postgres -c "psql -v ON_ERROR_STOP=1 -d blum" < "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}"; then
212
+ echo "Legacy backup restore failed. Refusing startup with incomplete financial evidence."
213
+ stop_restore_status_server
214
+ exit 1
215
+ fi
216
  elif [[ ! -s "${BLUM_EMBEDDED_POSTGRES_BACKUP_FILE}" && ! -s "${BLUM_COMPRESSED_POSTGRES_BACKUP_FILE}" && ! -s "${BLUM_LEGACY_POSTGRES_BACKUP_FILE}" ]]; then
217
  echo "No embedded PostgreSQL backup found yet. A new backup will be written periodically."
218
  else
219
  echo "Embedded PostgreSQL already contains tables; skipping restore."
220
  fi
221
+ rm -f "${BLUM_LOCAL_RESTORE_INCOMPLETE_FILE}"
222
  stop_restore_status_server
223
  export DATABASE_URL="postgresql+psycopg2://postgres:postgres@127.0.0.1:5432/blum"
224
  START_EMBEDDED_BACKUP_LOOP=true
scripts/verify_deployment.py CHANGED
@@ -2,6 +2,8 @@
2
  import argparse
3
  import json
4
  import subprocess
 
 
5
  import time
6
  import urllib.error
7
  import urllib.request
@@ -10,11 +12,14 @@ parser = argparse.ArgumentParser(description=__doc__)
10
  parser.add_argument('--backend-only', action='store_true')
11
  parser.add_argument('--timeout', type=int, default=120)
12
  args = parser.parse_args()
 
 
 
13
  revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
14
  backend = 'https://italianhype-blum.hf.space'
15
  site = 'https://blum.signallayerlabs.workers.dev'
16
  verification_headers = {
17
- 'User-Agent': 'BLUM-Deployment-Verification/2.2.0 (+https://github.com/SignalLayerLabs/Blum)',
18
  }
19
 
20
  def request(url, **kwargs):
@@ -35,8 +40,10 @@ while time.monotonic() < deadline:
35
  deployed = get_json(backend, '/api/runtime/release')
36
  if not startup.get('api_ready') or not startup.get('ui_ready') or startup.get('last_error'):
37
  raise ValueError('Backend is restoring or unavailable.')
38
- if deployed.get('source_revision') != revision or deployed.get('version') != '2.2.0':
39
  raise ValueError('Backend revision has not finished deploying.')
 
 
40
  break
41
  except (OSError, ValueError) as exc:
42
  last_error = str(exc)
@@ -46,10 +53,10 @@ else:
46
  print('Backend ready at', revision, flush=True)
47
  if not args.backend_only:
48
  public = get_json(site, '/api/release')
49
- if public.get('source_revision') != revision or not public.get('backend_ready'):
50
  raise SystemExit('Cloudflare revision or backend readiness mismatch.')
51
  for path in ['/api/brain/snapshot', '/api/training/snapshot', '/api/paper-trading/snapshot?limit=1',
52
- '/api/alpha/snapshot', '/api/ascent/status']:
53
  payload = get_json(site, path)
54
  if not isinstance(payload, dict): raise SystemExit('Invalid snapshot: '+path)
55
  print('Verified JSON snapshot', path, flush=True)
 
2
  import argparse
3
  import json
4
  import subprocess
5
+ import sys
6
+ from pathlib import Path
7
  import time
8
  import urllib.error
9
  import urllib.request
 
12
  parser.add_argument('--backend-only', action='store_true')
13
  parser.add_argument('--timeout', type=int, default=120)
14
  args = parser.parse_args()
15
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'backend'))
16
+ from app.core.release import source_content_hash
17
+ expected_content_hash = source_content_hash()
18
  revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
19
  backend = 'https://italianhype-blum.hf.space'
20
  site = 'https://blum.signallayerlabs.workers.dev'
21
  verification_headers = {
22
+ 'User-Agent': 'BLUM-Deployment-Verification/2.3.0 (+https://github.com/SignalLayerLabs/Blum)',
23
  }
24
 
25
  def request(url, **kwargs):
 
40
  deployed = get_json(backend, '/api/runtime/release')
41
  if not startup.get('api_ready') or not startup.get('ui_ready') or startup.get('last_error'):
42
  raise ValueError('Backend is restoring or unavailable.')
43
+ if deployed.get('source_revision') != revision or deployed.get('version') != '2.3.0':
44
  raise ValueError('Backend revision has not finished deploying.')
45
+ if deployed.get('source_content_hash') != expected_content_hash:
46
+ raise ValueError('Backend protected source content does not match the verified checkout.')
47
  break
48
  except (OSError, ValueError) as exc:
49
  last_error = str(exc)
 
53
  print('Backend ready at', revision, flush=True)
54
  if not args.backend_only:
55
  public = get_json(site, '/api/release')
56
+ if public.get('source_revision') != revision or public.get('source_content_hash') != expected_content_hash or not public.get('backend_ready'):
57
  raise SystemExit('Cloudflare revision or backend readiness mismatch.')
58
  for path in ['/api/brain/snapshot', '/api/training/snapshot', '/api/paper-trading/snapshot?limit=1',
59
+ '/api/alpha/snapshot', '/api/alpha/desk', '/api/ascent/status']:
60
  payload = get_json(site, path)
61
  if not isinstance(payload, dict): raise SystemExit('Invalid snapshot: '+path)
62
  print('Verified JSON snapshot', path, flush=True)