Spaces:
Running
Running
Renato commited on
Commit ·
a136c48
1
Parent(s): e7afdfb
feat: register and audit prospective alpha calls by market
Browse files- .github/workflows/deploy.yml +2 -1
- CHANGELOG.md +10 -0
- README.md +3 -2
- backend/alembic/versions/0045_alpha_call_registry.py +31 -0
- backend/app/api/routers/alpha.py +5 -0
- backend/app/api/routers/runtime.py +2 -2
- backend/app/core/config.py +1 -1
- backend/app/core/release.py +23 -0
- backend/app/data/public_api_routes.json +1 -1
- backend/app/engine/brain/trader_brain.py +14 -4
- backend/app/engine/contracts.py +1 -1
- backend/app/engine/facade.py +6 -1
- backend/app/models.py +16 -0
- backend/app/services/alpha_desk.py +299 -0
- backend/app/services/forex_trader.py +6 -0
- backend/app/services/live_forward_paper_trading.py +16 -0
- backend/tests/test_alpha_desk.py +379 -0
- backend/tests/test_migration_chain.py +2 -1
- backend/tests/test_release_content_hash.py +30 -0
- backend/tests/test_restore_failure_guard.py +56 -0
- backend/tests/test_space_startup.py +1 -1
- backend/tests/test_trading_ml_persistence.py +1 -1
- cloudflare/package-lock.json +2 -2
- cloudflare/package.json +1 -1
- cloudflare/src/index.ts +4 -2
- cloudflare/tests/gateway.test.mjs +7 -2
- cloudflare/wrangler.jsonc +2 -1
- docs/ALPHA_DESK.md +41 -0
- docs/DEPLOYMENT.md +7 -2
- docs/superpowers/plans/2026-10-13-verified-alpha-desk.md +65 -0
- docs/superpowers/specs/2026-10-13-verified-alpha-desk-design.md +36 -0
- frontend/app/alpha/page.tsx +2 -0
- frontend/components/AlphaDesk.tsx +58 -0
- frontend/lib/alphaDeskView.d.mts +5 -0
- frontend/lib/alphaDeskView.mjs +18 -0
- frontend/package-lock.json +2 -2
- frontend/package.json +1 -1
- frontend/tests/alpha-desk-view.test.mjs +18 -0
- package.json +1 -1
- scripts/start.sh +36 -4
- 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 |
-
|
|
|
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
| 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":
|
|
|
|
| 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"
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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() == "
|
| 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() == "
|
|
|
|
| 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.
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
"name": "blum-cloudflare",
|
| 9 |
-
"version": "2.
|
| 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.
|
| 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.
|
| 111 |
-
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
|
|
|
| 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.
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
"name": "blum-ai-financial-intelligence-frontend",
|
| 9 |
-
"version": "2.
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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)
|