Italianhype commited on
Commit
6fbe974
·
verified ·
1 Parent(s): d2a93f4

Fix live-forward paper runtime deploy

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +25 -0
  2. .next/trace +0 -1
  3. backend/app/core/config.py +12 -1
  4. backend/app/main.py +30 -2
  5. backend/app/services/autonomous_engine.py +2 -0
  6. backend/app/services/blum_financial_model.py +6 -2
  7. backend/app/services/bootstrap.py +68 -20
  8. backend/app/services/dashboard.py +43 -0
  9. backend/app/services/dashboard_snapshots.py +26 -4
  10. backend/app/services/financial_chat.py +106 -2
  11. backend/app/services/learning_loop.py +705 -48
  12. backend/app/services/learning_summary.py +304 -3
  13. backend/app/services/performance.py +7 -1
  14. backend/app/services/trade_transparency.py +166 -41
  15. backend/app/services/trading_game.py +21 -0
  16. backend/tests/test_learning_performance_architecture.py +73 -2
  17. backend/tests/test_performance_diagnostics.py +6 -3
  18. frontend/.next/BUILD_ID +1 -0
  19. frontend/.next/app-build-manifest.json +199 -0
  20. frontend/.next/app-path-routes-manifest.json +1 -0
  21. frontend/.next/build-manifest.json +32 -0
  22. frontend/.next/cache/.tsbuildinfo +0 -0
  23. frontend/.next/cache/webpack/client-production/0.pack +3 -0
  24. frontend/.next/cache/webpack/client-production/1.pack +3 -0
  25. frontend/.next/cache/webpack/client-production/10.pack +3 -0
  26. frontend/.next/cache/webpack/client-production/11.pack +3 -0
  27. frontend/.next/cache/webpack/client-production/12.pack +3 -0
  28. frontend/.next/cache/webpack/client-production/13.pack +3 -0
  29. frontend/.next/cache/webpack/client-production/14.pack +0 -0
  30. frontend/.next/cache/webpack/client-production/15.pack +0 -0
  31. frontend/.next/cache/webpack/client-production/2.pack +3 -0
  32. frontend/.next/cache/webpack/client-production/3.pack +0 -0
  33. frontend/.next/cache/webpack/client-production/4.pack +0 -0
  34. frontend/.next/cache/webpack/client-production/5.pack +0 -0
  35. frontend/.next/cache/webpack/client-production/6.pack +3 -0
  36. frontend/.next/cache/webpack/client-production/7.pack +3 -0
  37. frontend/.next/cache/webpack/client-production/8.pack +3 -0
  38. frontend/.next/cache/webpack/client-production/9.pack +3 -0
  39. frontend/.next/cache/webpack/client-production/index.pack +3 -0
  40. frontend/.next/cache/webpack/client-production/index.pack.old +3 -0
  41. frontend/.next/cache/webpack/edge-server-production/0.pack +0 -0
  42. frontend/.next/cache/webpack/edge-server-production/index.pack +0 -0
  43. frontend/.next/cache/webpack/server-production/0.pack +3 -0
  44. frontend/.next/cache/webpack/server-production/1.pack +3 -0
  45. frontend/.next/cache/webpack/server-production/10.pack +3 -0
  46. frontend/.next/cache/webpack/server-production/2.pack +3 -0
  47. frontend/.next/cache/webpack/server-production/3.pack +3 -0
  48. frontend/.next/cache/webpack/server-production/4.pack +3 -0
  49. frontend/.next/cache/webpack/server-production/5.pack +3 -0
  50. frontend/.next/cache/webpack/server-production/6.pack +3 -0
.gitattributes CHANGED
@@ -39,3 +39,28 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
39
  .upload-venv/lib/python3.13/site-packages/pip/_vendor/distlib/w64-arm.exe filter=lfs diff=lfs merge=lfs -text
40
  .upload-venv/lib/python3.13/site-packages/pip/_vendor/distlib/w64.exe filter=lfs diff=lfs merge=lfs -text
41
  .upload-venv/lib/python3.13/site-packages/yaml/_yaml.cpython-313-darwin.so filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  .upload-venv/lib/python3.13/site-packages/pip/_vendor/distlib/w64-arm.exe filter=lfs diff=lfs merge=lfs -text
40
  .upload-venv/lib/python3.13/site-packages/pip/_vendor/distlib/w64.exe filter=lfs diff=lfs merge=lfs -text
41
  .upload-venv/lib/python3.13/site-packages/yaml/_yaml.cpython-313-darwin.so filter=lfs diff=lfs merge=lfs -text
42
+ frontend/.next/cache/webpack/client-production/0.pack filter=lfs diff=lfs merge=lfs -text
43
+ frontend/.next/cache/webpack/client-production/1.pack filter=lfs diff=lfs merge=lfs -text
44
+ frontend/.next/cache/webpack/client-production/10.pack filter=lfs diff=lfs merge=lfs -text
45
+ frontend/.next/cache/webpack/client-production/11.pack filter=lfs diff=lfs merge=lfs -text
46
+ frontend/.next/cache/webpack/client-production/12.pack filter=lfs diff=lfs merge=lfs -text
47
+ frontend/.next/cache/webpack/client-production/13.pack filter=lfs diff=lfs merge=lfs -text
48
+ frontend/.next/cache/webpack/client-production/2.pack filter=lfs diff=lfs merge=lfs -text
49
+ frontend/.next/cache/webpack/client-production/6.pack filter=lfs diff=lfs merge=lfs -text
50
+ frontend/.next/cache/webpack/client-production/7.pack filter=lfs diff=lfs merge=lfs -text
51
+ frontend/.next/cache/webpack/client-production/8.pack filter=lfs diff=lfs merge=lfs -text
52
+ frontend/.next/cache/webpack/client-production/9.pack filter=lfs diff=lfs merge=lfs -text
53
+ frontend/.next/cache/webpack/client-production/index.pack filter=lfs diff=lfs merge=lfs -text
54
+ frontend/.next/cache/webpack/client-production/index.pack.old filter=lfs diff=lfs merge=lfs -text
55
+ frontend/.next/cache/webpack/server-production/0.pack filter=lfs diff=lfs merge=lfs -text
56
+ frontend/.next/cache/webpack/server-production/1.pack filter=lfs diff=lfs merge=lfs -text
57
+ frontend/.next/cache/webpack/server-production/10.pack filter=lfs diff=lfs merge=lfs -text
58
+ frontend/.next/cache/webpack/server-production/2.pack filter=lfs diff=lfs merge=lfs -text
59
+ frontend/.next/cache/webpack/server-production/3.pack filter=lfs diff=lfs merge=lfs -text
60
+ frontend/.next/cache/webpack/server-production/4.pack filter=lfs diff=lfs merge=lfs -text
61
+ frontend/.next/cache/webpack/server-production/5.pack filter=lfs diff=lfs merge=lfs -text
62
+ frontend/.next/cache/webpack/server-production/6.pack filter=lfs diff=lfs merge=lfs -text
63
+ frontend/.next/cache/webpack/server-production/7.pack filter=lfs diff=lfs merge=lfs -text
64
+ frontend/.next/cache/webpack/server-production/8.pack filter=lfs diff=lfs merge=lfs -text
65
+ frontend/.next/cache/webpack/server-production/index.pack filter=lfs diff=lfs merge=lfs -text
66
+ frontend/.next/cache/webpack/server-production/index.pack.old filter=lfs diff=lfs merge=lfs -text
.next/trace CHANGED
@@ -1,2 +1 @@
1
  [{"name":"generate-buildid","duration":134,"timestamp":87872497011,"id":4,"parentId":1,"tags":{},"startTime":1782192543160,"traceId":"74c71a2e401cebbb"},{"name":"load-custom-routes","duration":113,"timestamp":87872497220,"id":5,"parentId":1,"tags":{},"startTime":1782192543160,"traceId":"74c71a2e401cebbb"},{"name":"next-build","duration":48980,"timestamp":87872449545,"id":1,"tags":{"buildMode":"default","isTurboBuild":"false","version":"14.2.35","isTurbopack":false},"startTime":1782192543112,"traceId":"74c71a2e401cebbb"}]
2
- [{"name":"generate-buildid","duration":139,"timestamp":107902785130,"id":4,"parentId":1,"tags":{},"startTime":1782212573555,"traceId":"5254ca5a348315b0"},{"name":"load-custom-routes","duration":121,"timestamp":107902785349,"id":5,"parentId":1,"tags":{},"startTime":1782212573555,"traceId":"5254ca5a348315b0"},{"name":"next-build","duration":41482,"timestamp":107902744802,"id":1,"tags":{"buildMode":"default","isTurboBuild":"false","version":"14.2.35","isTurbopack":false},"startTime":1782212573514,"traceId":"5254ca5a348315b0"}]
 
1
  [{"name":"generate-buildid","duration":134,"timestamp":87872497011,"id":4,"parentId":1,"tags":{},"startTime":1782192543160,"traceId":"74c71a2e401cebbb"},{"name":"load-custom-routes","duration":113,"timestamp":87872497220,"id":5,"parentId":1,"tags":{},"startTime":1782192543160,"traceId":"74c71a2e401cebbb"},{"name":"next-build","duration":48980,"timestamp":87872449545,"id":1,"tags":{"buildMode":"default","isTurboBuild":"false","version":"14.2.35","isTurbopack":false},"startTime":1782192543112,"traceId":"74c71a2e401cebbb"}]
 
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 = "0.20.0"
9
  environment: str = Field(default="demo", alias="ENVIRONMENT")
10
  database_url: str = Field(
11
  default="postgresql+psycopg2://postgres:postgres@127.0.0.1:5432/blum",
@@ -31,6 +31,9 @@ class Settings(BaseSettings):
31
  max_update_assets: int = Field(default=160, alias="BLUM_MAX_UPDATE_ASSETS")
32
  enable_live_startup: bool = Field(default=True, alias="BLUM_ENABLE_LIVE_STARTUP")
33
  enable_autonomous_engine: bool = Field(default=True, alias="BLUM_ENABLE_AUTONOMOUS_ENGINE")
 
 
 
34
  autonomous_cycle_minutes: int = Field(default=20, alias="BLUM_AUTONOMOUS_CYCLE_MINUTES")
35
  autonomous_repair_limit: int = Field(default=80, alias="BLUM_AUTONOMOUS_REPAIR_LIMIT")
36
  seed_historical_prices_on_startup: bool = Field(default=True, alias="BLUM_SEED_HISTORICAL_PRICES_ON_STARTUP")
@@ -49,6 +52,13 @@ class Settings(BaseSettings):
49
  learning_min_history_years: int = Field(default=3, alias="LEARNING_MIN_HISTORY_YEARS")
50
  learning_asset_universe: str = Field(default="stocks,etfs", alias="LEARNING_ASSET_UNIVERSE")
51
  learning_evaluation_mode: str = Field(default="walk_forward", alias="LEARNING_EVALUATION_MODE")
 
 
 
 
 
 
 
52
  blum_model_cycle_minutes: int = Field(default=5, alias="BLUM_MODEL_CYCLE_MINUTES")
53
  blum_model_cycle_limit: int = Field(default=160, alias="BLUM_MODEL_CYCLE_LIMIT")
54
  fundamentals_refresh_minutes: int = Field(default=720, alias="BLUM_FUNDAMENTALS_REFRESH_MINUTES")
@@ -62,6 +72,7 @@ class Settings(BaseSettings):
62
  refresh_price_period: str = Field(default="6mo", alias="BLUM_REFRESH_PRICE_PERIOD")
63
  sec_user_agent: str = Field(default="Blum-AI-Financial-Intelligence research demo", alias="BLUM_SEC_USER_AGENT")
64
  blum_model_repository: str = Field(default="Italianhype/Blum", alias="BLUM_MODEL_REPOSITORY")
 
65
  training_export_dir: str = Field(default="/tmp/blum_training_exports", alias="BLUM_TRAINING_EXPORT_DIR")
66
  enable_hf_dataset_catalog: bool = Field(default=True, alias="BLUM_ENABLE_HF_DATASET_CATALOG")
67
  hf_dataset_refresh_hours: int = Field(default=24, alias="BLUM_HF_DATASET_REFRESH_HOURS")
 
5
 
6
  class Settings(BaseSettings):
7
  app_name: str = "Blum AI Financial Intelligence"
8
+ app_version: str = "2.1.0"
9
  environment: str = Field(default="demo", alias="ENVIRONMENT")
10
  database_url: str = Field(
11
  default="postgresql+psycopg2://postgres:postgres@127.0.0.1:5432/blum",
 
31
  max_update_assets: int = Field(default=160, alias="BLUM_MAX_UPDATE_ASSETS")
32
  enable_live_startup: bool = Field(default=True, alias="BLUM_ENABLE_LIVE_STARTUP")
33
  enable_autonomous_engine: bool = Field(default=True, alias="BLUM_ENABLE_AUTONOMOUS_ENGINE")
34
+ startup_run_full_autonomous: bool = Field(default=False, alias="BLUM_STARTUP_RUN_FULL_AUTONOMOUS")
35
+ blum_autonomous_max_seconds_per_job: int = Field(default=120, alias="BLUM_AUTONOMOUS_MAX_SECONDS_PER_JOB")
36
+ blum_autonomous_max_items_per_job: int = Field(default=50, alias="BLUM_AUTONOMOUS_MAX_ITEMS_PER_JOB")
37
  autonomous_cycle_minutes: int = Field(default=20, alias="BLUM_AUTONOMOUS_CYCLE_MINUTES")
38
  autonomous_repair_limit: int = Field(default=80, alias="BLUM_AUTONOMOUS_REPAIR_LIMIT")
39
  seed_historical_prices_on_startup: bool = Field(default=True, alias="BLUM_SEED_HISTORICAL_PRICES_ON_STARTUP")
 
52
  learning_min_history_years: int = Field(default=3, alias="LEARNING_MIN_HISTORY_YEARS")
53
  learning_asset_universe: str = Field(default="stocks,etfs", alias="LEARNING_ASSET_UNIVERSE")
54
  learning_evaluation_mode: str = Field(default="walk_forward", alias="LEARNING_EVALUATION_MODE")
55
+ learning_random_sample_ratio: float = Field(default=0.40, alias="LEARNING_RANDOM_SAMPLE_RATIO")
56
+ learning_alpha_loss_sample_ratio: float = Field(default=0.30, alias="LEARNING_ALPHA_LOSS_SAMPLE_RATIO")
57
+ learning_factor_focus_sample_ratio: float = Field(default=0.20, alias="LEARNING_FACTOR_FOCUS_SAMPLE_RATIO")
58
+ learning_capital_preservation_sample_ratio: float = Field(default=0.10, alias="LEARNING_CAPITAL_PRESERVATION_SAMPLE_RATIO")
59
+ professional_learning_enabled: bool = Field(default=True, alias="BLUM_PROFESSIONAL_LEARNING_ENABLED")
60
+ professional_learning_minutes: int = Field(default=30, alias="BLUM_PROFESSIONAL_LEARNING_MINUTES")
61
+ professional_learning_batch_size: int = Field(default=20, alias="BLUM_PROFESSIONAL_LEARNING_BATCH_SIZE")
62
  blum_model_cycle_minutes: int = Field(default=5, alias="BLUM_MODEL_CYCLE_MINUTES")
63
  blum_model_cycle_limit: int = Field(default=160, alias="BLUM_MODEL_CYCLE_LIMIT")
64
  fundamentals_refresh_minutes: int = Field(default=720, alias="BLUM_FUNDAMENTALS_REFRESH_MINUTES")
 
72
  refresh_price_period: str = Field(default="6mo", alias="BLUM_REFRESH_PRICE_PERIOD")
73
  sec_user_agent: str = Field(default="Blum-AI-Financial-Intelligence research demo", alias="BLUM_SEC_USER_AGENT")
74
  blum_model_repository: str = Field(default="Italianhype/Blum", alias="BLUM_MODEL_REPOSITORY")
75
+ blum_analyst_repository: str = Field(default="Italianhype/Blum-Analyst", alias="BLUM_ANALYST_REPOSITORY")
76
  training_export_dir: str = Field(default="/tmp/blum_training_exports", alias="BLUM_TRAINING_EXPORT_DIR")
77
  enable_hf_dataset_catalog: bool = Field(default=True, alias="BLUM_ENABLE_HF_DATASET_CATALOG")
78
  hf_dataset_refresh_hours: int = Field(default=24, alias="BLUM_HF_DATASET_REFRESH_HOURS")
backend/app/main.py CHANGED
@@ -9,7 +9,15 @@ from fastapi.middleware.cors import CORSMiddleware
9
  from fastapi.responses import FileResponse
10
  from fastapi.staticfiles import StaticFiles
11
 
12
- from app.api.routes import router
 
 
 
 
 
 
 
 
13
  from app.core.config import get_settings
14
  from app.core.database import SessionLocal
15
  from app.services.bootstrap import bootstrap_database
@@ -38,7 +46,13 @@ app.add_middleware(
38
  allow_headers=["*"],
39
  )
40
 
41
- app.include_router(router)
 
 
 
 
 
 
42
 
43
 
44
  @app.middleware("http")
@@ -67,6 +81,13 @@ async def performance_timing_middleware(request: Request, call_next):
67
  duration_ms,
68
  {"method": request.method, "path": request.url.path, "referer": request.headers.get("referer", "")[:180]},
69
  )
 
 
 
 
 
 
 
70
  return response
71
  finally:
72
  duration_ms = (time.perf_counter() - started) * 1000
@@ -106,6 +127,13 @@ def is_heavy_recalculation_call(method: str, path: str) -> bool:
106
  "/business-quality/recalculate",
107
  "/decision-intelligence/superiority/recalculate",
108
  "/learning-intelligence/self-improvement/generate",
 
 
 
 
 
 
 
109
  )
110
  return any(fragment in path for fragment in heavy_fragments)
111
 
 
9
  from fastapi.responses import FileResponse
10
  from fastapi.staticfiles import StaticFiles
11
 
12
+ from app.api.routers import (
13
+ alpha_router,
14
+ analyst_router,
15
+ brain_router,
16
+ legacy_router,
17
+ paper_trading_router,
18
+ runtime_router,
19
+ training_router,
20
+ )
21
  from app.core.config import get_settings
22
  from app.core.database import SessionLocal
23
  from app.services.bootstrap import bootstrap_database
 
46
  allow_headers=["*"],
47
  )
48
 
49
+ app.include_router(brain_router)
50
+ app.include_router(training_router)
51
+ app.include_router(paper_trading_router)
52
+ app.include_router(alpha_router)
53
+ app.include_router(runtime_router)
54
+ app.include_router(analyst_router)
55
+ app.include_router(legacy_router)
56
 
57
 
58
  @app.middleware("http")
 
81
  duration_ms,
82
  {"method": request.method, "path": request.url.path, "referer": request.headers.get("referer", "")[:180]},
83
  )
84
+ if request.method.upper() == "GET" and "persist=true" in request.url.query.lower():
85
+ response.headers["X-BLUM-GET-SIDE-EFFECT-RISK"] = "true"
86
+ performance_recorder.record_dashboard_widget(
87
+ "performance.GET_ENDPOINT_SIDE_EFFECT_DETECTED",
88
+ duration_ms,
89
+ {"method": request.method, "path": request.url.path, "query": request.url.query[:180]},
90
+ )
91
  return response
92
  finally:
93
  duration_ms = (time.perf_counter() - started) * 1000
 
127
  "/business-quality/recalculate",
128
  "/decision-intelligence/superiority/recalculate",
129
  "/learning-intelligence/self-improvement/generate",
130
+ "/api/meta-cognition/recalculate",
131
+ "/api/meta-cognition/factor-importance/recalculate",
132
+ "/api/meta-cognition/evaluate",
133
+ "/api/meta-cognition/capital-preservation/evaluate",
134
+ "/api/meta-cognition/learning-focus/generate",
135
+ "/api/meta-cognition/noise/detect",
136
+ "/snapshots/produce",
137
  )
138
  return any(fragment in path for fragment in heavy_fragments)
139
 
backend/app/services/autonomous_engine.py CHANGED
@@ -34,6 +34,7 @@ from app.services.macro import update_macro_snapshots
34
  from app.services.market_data import MarketDataService
35
  from app.services.market_sniper import MarketSniperEngine
36
  from app.services.persistence import backup_embedded_postgres_if_configured
 
37
  from app.services.trading_game import TradingGameSimulator
38
  from app.signals.engine import SignalEngine
39
 
@@ -85,6 +86,7 @@ class AutonomousResearchEngine:
85
  stage("etf_intelligence", lambda: update_etf_trends(db))
86
  stage("ipo_radar", lambda: update_ipo_radar(db, limit_per_form=55))
87
  stage("accuracy_audit", lambda: run_accuracy_audit(db, limit=settings.max_update_assets))
 
88
  if settings.enable_learning_loop:
89
  stage("blum_financial_model", lambda: run_model_learning_cycle(db, limit=settings.blum_model_cycle_limit))
90
  stage("blum_learning_loop", lambda: LearningLoopService().run_batch(db, batch_size=settings.learning_batch_size, trigger="autonomous_engine"))
 
34
  from app.services.market_data import MarketDataService
35
  from app.services.market_sniper import MarketSniperEngine
36
  from app.services.persistence import backup_embedded_postgres_if_configured
37
+ from app.services.research_planner import AutonomousResearchPlanner
38
  from app.services.trading_game import TradingGameSimulator
39
  from app.signals.engine import SignalEngine
40
 
 
86
  stage("etf_intelligence", lambda: update_etf_trends(db))
87
  stage("ipo_radar", lambda: update_ipo_radar(db, limit_per_form=55))
88
  stage("accuracy_audit", lambda: run_accuracy_audit(db, limit=settings.max_update_assets))
89
+ stage("research_planner", lambda: AutonomousResearchPlanner().generate(db, persist=True))
90
  if settings.enable_learning_loop:
91
  stage("blum_financial_model", lambda: run_model_learning_cycle(db, limit=settings.blum_model_cycle_limit))
92
  stage("blum_learning_loop", lambda: LearningLoopService().run_batch(db, batch_size=settings.learning_batch_size, trigger="autonomous_engine"))
backend/app/services/blum_financial_model.py CHANGED
@@ -96,7 +96,7 @@ def model_status(db: Session) -> dict:
96
  }
97
 
98
 
99
- def run_model_learning_cycle(db: Session, limit: int = 120) -> dict:
100
  signals = db.scalars(select(SignalSnapshot).order_by(desc(SignalSnapshot.created_at)).limit(limit)).all()
101
  skipped = 0
102
  before_count = count(db, BlumKnowledgeRecord.id)
@@ -127,7 +127,11 @@ def run_model_learning_cycle(db: Session, limit: int = 120) -> dict:
127
  )
128
  db.add(event)
129
  db.commit()
130
- backup_result = backup_embedded_postgres_if_configured(reason="blum_model_autonomous_cycle")
 
 
 
 
131
  return {
132
  "status": "ok",
133
  "signals_seen": len(signals),
 
96
  }
97
 
98
 
99
+ def run_model_learning_cycle(db: Session, limit: int = 120, backup: bool = True) -> dict:
100
  signals = db.scalars(select(SignalSnapshot).order_by(desc(SignalSnapshot.created_at)).limit(limit)).all()
101
  skipped = 0
102
  before_count = count(db, BlumKnowledgeRecord.id)
 
127
  )
128
  db.add(event)
129
  db.commit()
130
+ backup_result = (
131
+ backup_embedded_postgres_if_configured(reason="blum_model_autonomous_cycle")
132
+ if backup
133
+ else {"status": "skipped", "reason": "frequent professional learning cycles do not run full embedded database backups"}
134
+ )
135
  return {
136
  "status": "ok",
137
  "signals_seen": len(signals),
backend/app/services/bootstrap.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import csv
4
  from datetime import date
5
  import gzip
 
6
  from pathlib import Path
7
 
8
  from sqlalchemy import func, select
@@ -67,31 +68,49 @@ def seed_historical_prices(db: Session) -> dict:
67
  "inserted_rows": 0,
68
  "message": "Historical price cache is not packaged in this build.",
69
  }
 
 
 
 
 
 
 
 
 
 
 
70
  asset_ids = dict(db.execute(select(Asset.ticker, Asset.id)).all())
71
  inserted = 0
72
  skipped = 0
73
  tickers: set[str] = set()
74
  rows: list[dict] = []
75
- with gzip.open(HISTORICAL_PRICE_CACHE, "rt", newline="", encoding="utf-8") as handle:
76
- reader = csv.DictReader(handle)
77
- for item in reader:
78
- ticker = (item.get("ticker") or "").upper()
79
- asset_id = asset_ids.get(ticker)
80
- if not asset_id:
81
- skipped += 1
82
- continue
83
- row = price_row_from_cache(asset_id, item)
84
- if not row:
85
- skipped += 1
86
- continue
87
- tickers.add(ticker)
88
- rows.append(row)
89
- if len(rows) >= 5000:
90
- inserted += insert_price_rows(db, rows)
91
- rows.clear()
92
- if rows:
93
- inserted += insert_price_rows(db, rows)
94
- db.commit()
 
 
 
 
 
 
 
95
  return {
96
  "enabled": True,
97
  "cache_status": "loaded",
@@ -121,6 +140,35 @@ def seed_startup_accuracy(db: Session) -> dict:
121
  return {"enabled": True, **run_accuracy_audit(db, limit=get_settings().max_update_assets)}
122
 
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  def price_row_from_cache(asset_id: int, item: dict) -> dict | None:
125
  try:
126
  close = float(item["close"])
 
3
  import csv
4
  from datetime import date
5
  import gzip
6
+ from gzip import BadGzipFile
7
  from pathlib import Path
8
 
9
  from sqlalchemy import func, select
 
68
  "inserted_rows": 0,
69
  "message": "Historical price cache is not packaged in this build.",
70
  }
71
+ pointer = git_lfs_pointer_summary(HISTORICAL_PRICE_CACHE)
72
+ if pointer:
73
+ return invalid_historical_cache_response(
74
+ "git_lfs_pointer",
75
+ (
76
+ "Historical price cache is a Git LFS pointer, not the resolved gzip payload. "
77
+ "Run `git lfs pull` in development or ensure the Space resolves LFS assets. "
78
+ "Startup continues without synthetic market data."
79
+ ),
80
+ pointer,
81
+ )
82
  asset_ids = dict(db.execute(select(Asset.ticker, Asset.id)).all())
83
  inserted = 0
84
  skipped = 0
85
  tickers: set[str] = set()
86
  rows: list[dict] = []
87
+ try:
88
+ with gzip.open(HISTORICAL_PRICE_CACHE, "rt", newline="", encoding="utf-8") as handle:
89
+ reader = csv.DictReader(handle)
90
+ for item in reader:
91
+ ticker = (item.get("ticker") or "").upper()
92
+ asset_id = asset_ids.get(ticker)
93
+ if not asset_id:
94
+ skipped += 1
95
+ continue
96
+ row = price_row_from_cache(asset_id, item)
97
+ if not row:
98
+ skipped += 1
99
+ continue
100
+ tickers.add(ticker)
101
+ rows.append(row)
102
+ if len(rows) >= 5000:
103
+ inserted += insert_price_rows(db, rows)
104
+ rows.clear()
105
+ if rows:
106
+ inserted += insert_price_rows(db, rows)
107
+ db.commit()
108
+ except (BadGzipFile, EOFError, OSError, UnicodeDecodeError, csv.Error) as exc:
109
+ db.rollback()
110
+ return invalid_historical_cache_response(
111
+ "invalid",
112
+ f"Historical price cache could not be loaded ({type(exc).__name__}). Startup continues without synthetic market data.",
113
+ )
114
  return {
115
  "enabled": True,
116
  "cache_status": "loaded",
 
140
  return {"enabled": True, **run_accuracy_audit(db, limit=get_settings().max_update_assets)}
141
 
142
 
143
+ def git_lfs_pointer_summary(path: Path) -> dict | None:
144
+ try:
145
+ head = path.read_bytes()[:220]
146
+ except OSError:
147
+ return None
148
+ if not head.startswith(b"version https://git-lfs.github.com/spec/v1"):
149
+ return None
150
+ text = head.decode("utf-8", errors="replace")
151
+ summary = {"pointer_detected": True}
152
+ for line in text.splitlines():
153
+ if line.startswith("oid "):
154
+ summary["oid"] = line.split(" ", 1)[1]
155
+ elif line.startswith("size "):
156
+ summary["expected_size"] = line.split(" ", 1)[1]
157
+ return summary
158
+
159
+
160
+ def invalid_historical_cache_response(cache_status: str, message: str, diagnostics: dict | None = None) -> dict:
161
+ return {
162
+ "enabled": True,
163
+ "cache_status": cache_status,
164
+ "cache_file": HISTORICAL_PRICE_CACHE.name,
165
+ "inserted_rows": 0,
166
+ "diagnostics": diagnostics or {},
167
+ "message": message,
168
+ "data_policy": "No synthetic prices are created when packaged OHLCV cache is unavailable.",
169
+ }
170
+
171
+
172
  def price_row_from_cache(asset_id: int, item: dict) -> dict | None:
173
  try:
174
  close = float(item["close"])
backend/app/services/dashboard.py CHANGED
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
6
  from app.models import Asset, ETFTrend, NewsArticle, PriceHistory, SentimentAnalysis, SignalSnapshot
7
  from app.services.accuracy import latest_accuracy_snapshot, market_accuracy_overview, signal_validation_report
8
  from app.services.data_continuity import data_coverage_report
 
9
  from app.services.macro import macro_overview
10
  from app.services.market_data import market_snapshot_for_asset
11
  from app.services.performance import performance_recorder
@@ -14,6 +15,19 @@ from app.services.realtime import realtime_status
14
 
15
 
16
  def dashboard_overview(db: Session) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  with performance_recorder.dashboard_widget("dashboard.load_signal_candidates"):
18
  signals = db.scalars(select(SignalSnapshot).order_by(desc(SignalSnapshot.created_at), desc(SignalSnapshot.blum_score)).limit(80)).all()
19
  with performance_recorder.dashboard_widget("dashboard.rank_latest_signals", signal_count=len(signals)):
@@ -80,6 +94,35 @@ def dashboard_overview(db: Session) -> dict:
80
  }
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def signal_payload(signal: SignalSnapshot, db: Session | None = None) -> dict:
84
  with performance_recorder.dashboard_widget("dashboard.signal_payload", ticker=signal.ticker):
85
  payload = {
 
6
  from app.models import Asset, ETFTrend, NewsArticle, PriceHistory, SentimentAnalysis, SignalSnapshot
7
  from app.services.accuracy import latest_accuracy_snapshot, market_accuracy_overview, signal_validation_report
8
  from app.services.data_continuity import data_coverage_report
9
+ from app.services.dashboard_snapshots import DashboardSnapshotService
10
  from app.services.macro import macro_overview
11
  from app.services.market_data import market_snapshot_for_asset
12
  from app.services.performance import performance_recorder
 
15
 
16
 
17
  def dashboard_overview(db: Session) -> dict:
18
+ with performance_recorder.dashboard_widget("dashboard.snapshot_lookup", snapshot_type="dashboard_overview_summary"):
19
+ snapshot = DashboardSnapshotService().latest(db, "dashboard_overview_summary")
20
+ if snapshot["payload"]:
21
+ payload = dict(snapshot["payload"])
22
+ payload["snapshot_status"] = snapshot["status"]
23
+ payload["snapshot_created_at"] = snapshot.get("created_at")
24
+ payload["snapshot_warnings"] = snapshot.get("warnings", [])
25
+ payload["runtime_policy"] = "snapshot_first_no_live_recalculation"
26
+ return payload
27
+ return empty_dashboard_overview(snapshot)
28
+
29
+
30
+ def build_dashboard_overview_live(db: Session) -> dict:
31
  with performance_recorder.dashboard_widget("dashboard.load_signal_candidates"):
32
  signals = db.scalars(select(SignalSnapshot).order_by(desc(SignalSnapshot.created_at), desc(SignalSnapshot.blum_score)).limit(80)).all()
33
  with performance_recorder.dashboard_widget("dashboard.rank_latest_signals", signal_count=len(signals)):
 
94
  }
95
 
96
 
97
+ def empty_dashboard_overview(snapshot: dict) -> dict:
98
+ return {
99
+ "snapshot_status": snapshot.get("status", "missing"),
100
+ "snapshot_created_at": snapshot.get("created_at"),
101
+ "snapshot_warnings": [snapshot.get("warning") or "dashboard_overview_summary snapshot is not ready"],
102
+ "runtime_policy": "snapshot_first_no_live_recalculation",
103
+ "market_pulse": {
104
+ "asset_count": 0,
105
+ "article_count": 0,
106
+ "average_sentiment": 0,
107
+ "signal_count": 0,
108
+ "classification_mix": {},
109
+ "price_row_count": 0,
110
+ },
111
+ "data_coverage": {"status": "missing_snapshot"},
112
+ "accuracy": {"status": "missing_snapshot"},
113
+ "macro": {"status": "missing_snapshot"},
114
+ "validation": {"status": "missing_snapshot"},
115
+ "readiness": {"status": "missing_snapshot"},
116
+ "realtime": {"status": "unknown"},
117
+ "todays_strongest_signals": [],
118
+ "narrative_breakouts": [],
119
+ "technical_breakouts": [],
120
+ "sentiment_divergence": [],
121
+ "watchlist_candidates": [],
122
+ "etf_rotation_leaders": [],
123
+ }
124
+
125
+
126
  def signal_payload(signal: SignalSnapshot, db: Session | None = None) -> dict:
127
  with performance_recorder.dashboard_widget("dashboard.signal_payload", ticker=signal.ticker):
128
  payload = {
backend/app/services/dashboard_snapshots.py CHANGED
@@ -1,6 +1,6 @@
1
  from __future__ import annotations
2
 
3
- from datetime import datetime, timedelta
4
  import time
5
 
6
  from sqlalchemy import desc, select
@@ -38,6 +38,7 @@ class DashboardSnapshotService:
38
  "is_stale": is_stale,
39
  "payload": row.payload_json or {},
40
  "source_modules": row.source_modules_json or {},
 
41
  "computation_duration_ms": row.computation_duration_ms,
42
  "warnings": row.warnings_json or [],
43
  }
@@ -51,6 +52,7 @@ class DashboardSnapshotService:
51
  source_modules: dict | None = None,
52
  ttl_seconds: int = 300,
53
  warnings: list[str] | None = None,
 
54
  computation_duration_ms: float | None = None,
55
  ) -> dict:
56
  started = time.perf_counter()
@@ -58,11 +60,12 @@ class DashboardSnapshotService:
58
  snapshot_type=snapshot_type,
59
  created_at=datetime.utcnow(),
60
  expires_at=datetime.utcnow() + timedelta(seconds=max(1, ttl_seconds)),
61
- payload_json=payload,
62
- source_modules_json=source_modules or {},
 
63
  is_stale=False,
64
  computation_duration_ms=computation_duration_ms,
65
- warnings_json=warnings or [],
66
  )
67
  if row.computation_duration_ms is None:
68
  row.computation_duration_ms = round((time.perf_counter() - started) * 1000, 3)
@@ -82,3 +85,22 @@ def record_snapshot_cache(snapshot_type: str, *, hit: bool, status: str) -> None
82
  )
83
  except Exception:
84
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from datetime import date, datetime, timedelta
4
  import time
5
 
6
  from sqlalchemy import desc, select
 
38
  "is_stale": is_stale,
39
  "payload": row.payload_json or {},
40
  "source_modules": row.source_modules_json or {},
41
+ "missing_sections": getattr(row, "missing_sections_json", None) or [],
42
  "computation_duration_ms": row.computation_duration_ms,
43
  "warnings": row.warnings_json or [],
44
  }
 
52
  source_modules: dict | None = None,
53
  ttl_seconds: int = 300,
54
  warnings: list[str] | None = None,
55
+ missing_sections: list[str] | None = None,
56
  computation_duration_ms: float | None = None,
57
  ) -> dict:
58
  started = time.perf_counter()
 
60
  snapshot_type=snapshot_type,
61
  created_at=datetime.utcnow(),
62
  expires_at=datetime.utcnow() + timedelta(seconds=max(1, ttl_seconds)),
63
+ payload_json=json_safe(payload),
64
+ source_modules_json=json_safe(source_modules or {}),
65
+ missing_sections_json=json_safe(missing_sections or []),
66
  is_stale=False,
67
  computation_duration_ms=computation_duration_ms,
68
+ warnings_json=json_safe(warnings or []),
69
  )
70
  if row.computation_duration_ms is None:
71
  row.computation_duration_ms = round((time.perf_counter() - started) * 1000, 3)
 
85
  )
86
  except Exception:
87
  pass
88
+
89
+
90
+ def json_safe(value):
91
+ if isinstance(value, (datetime, date)):
92
+ return value.isoformat()
93
+ if isinstance(value, dict):
94
+ return {str(key): json_safe(item) for key, item in value.items()}
95
+ if isinstance(value, list):
96
+ return [json_safe(item) for item in value]
97
+ if isinstance(value, tuple):
98
+ return [json_safe(item) for item in value]
99
+ try:
100
+ import numpy as np
101
+
102
+ if isinstance(value, np.generic):
103
+ return json_safe(value.item())
104
+ except Exception:
105
+ pass
106
+ return value
backend/app/services/financial_chat.py CHANGED
@@ -33,6 +33,8 @@ from app.services.fundamentals import fundamentals_for_asset
33
  from app.services.live import market_sentiment
34
  from app.services.learning_loop import LearningDashboardService
35
  from app.services.learning_intelligence import LearningIntelligenceDashboardService
 
 
36
  from app.services.capital_allocation import AdaptiveCapitalAllocationEngine
37
  from app.services.decision_intelligence import DecisionIntelligenceDashboardService
38
  from app.services.market_data import market_snapshot_for_asset
@@ -487,6 +489,8 @@ def trading_game_context_for_chat(db: Session) -> dict:
487
  "learning_intelligence": LearningIntelligenceDashboardService().dashboard(db),
488
  "decision_intelligence": DecisionIntelligenceDashboardService().dashboard(db),
489
  "capital_allocation": AdaptiveCapitalAllocationEngine().dashboard(db),
 
 
490
  "pnl_breakdown": PnLBreakdownService().game_breakdown(db),
491
  "reality_check": TradingGameRealityCheckService().evaluate(db),
492
  "failures": engine.failures(db, limit=12),
@@ -1477,6 +1481,8 @@ def build_trading_game_response(language: str, context: dict) -> dict:
1477
  learning_intelligence = context.get("learning_intelligence") or {}
1478
  decision_intelligence = context.get("decision_intelligence") or {}
1479
  capital_allocation = context.get("capital_allocation") or {}
 
 
1480
  if not game:
1481
  message = "BLUM non ha ancora un Trading Game persistito. Serve almeno un ciclo Sniper/Learning Loop per creare simulazioni P/L reali." if language == "it" else "BLUM does not have a persisted Trading Game yet. It needs at least one Sniper/Learning Loop cycle to create real P/L simulations."
1482
  return build_error_response(language, message, [])
@@ -1505,6 +1511,8 @@ def build_trading_game_response(language: str, context: dict) -> dict:
1505
  {"key": "learning_intelligence", "title": "Learning Intelligence", "bullets": learning_intelligence_lines(learning_intelligence, language)},
1506
  {"key": "decision_intelligence", "title": "Decision Intelligence", "bullets": decision_intelligence_lines(decision_intelligence, language)},
1507
  {"key": "capital_allocation", "title": "Capital Allocation Intelligence", "bullets": capital_allocation_lines(capital_allocation, language)},
 
 
1508
  {"key": "trade_ledger", "title": "Trade ledger", "bullets": trade_ledger_lines(ledger_rows, language)},
1509
  {"key": "intelligence_metrics", "title": "Trading intelligence", "bullets": intelligence_metric_lines(intelligence_metrics, rolling_metrics, metrics_by_setup, language)},
1510
  {"key": "live_forward", "title": "Storico vs live paper", "bullets": historical_live_lines(live_forward, historical_vs_live, language)},
@@ -1545,6 +1553,8 @@ def build_trading_game_response(language: str, context: dict) -> dict:
1545
  {"key": "learning_intelligence", "title": "Learning Intelligence", "bullets": learning_intelligence_lines(learning_intelligence, language)},
1546
  {"key": "decision_intelligence", "title": "Decision Intelligence", "bullets": decision_intelligence_lines(decision_intelligence, language)},
1547
  {"key": "capital_allocation", "title": "Capital Allocation Intelligence", "bullets": capital_allocation_lines(capital_allocation, language)},
 
 
1548
  {"key": "trade_ledger", "title": "Trade Ledger", "bullets": trade_ledger_lines(ledger_rows, language)},
1549
  {"key": "intelligence_metrics", "title": "Trading Intelligence", "bullets": intelligence_metric_lines(intelligence_metrics, rolling_metrics, metrics_by_setup, language)},
1550
  {"key": "live_forward", "title": "Historical vs Live Paper", "bullets": historical_live_lines(live_forward, historical_vs_live, language)},
@@ -1574,7 +1584,7 @@ def build_trading_game_response(language: str, context: dict) -> dict:
1574
  "executive_view": summary,
1575
  "risk_reward_view": f"Expectancy {format_number(game.get('expectancy_r'))}R, drawdown {format_signed(game.get('max_drawdown'))}%.",
1576
  "data_quality": {"sample_warning": sample_warning, "trades": game.get("trade_count"), "reproducibility": reproducibility, "cycles": cycle_stats, "live_sample_warning": (historical_vs_live.get("sample_warning") if isinstance(historical_vs_live, dict) else None)},
1577
- "learning_loop_memory": {"trading_game": game, "lessons": lessons[:6], "latest_trades": trades[:6], "ledger": ledger_rows[:8], "reality_check": reality_check, "cycles": cycle_stats, "intelligence_metrics": intelligence_metrics, "historical_vs_live": historical_vs_live, "learning_intelligence": learning_intelligence, "decision_intelligence": decision_intelligence, "capital_allocation": capital_allocation},
1578
  "answer_to_user": summary,
1579
  }
1580
 
@@ -1691,6 +1701,100 @@ def capital_allocation_lines(payload: dict, language: str) -> list[str]:
1691
  return dedupe_warnings(lines)
1692
 
1693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1694
  def trade_ledger_lines(rows: list[dict], language: str) -> list[str]:
1695
  if not rows:
1696
  return ["BLUM ha metriche di gioco, ma il trade ledger dettagliato non e ancora disponibile." if language == "it" else "BLUM has game-level metrics, but the detailed trade ledger is not available yet."]
@@ -2484,7 +2588,7 @@ def infer_intent(message: str, mode: str | None = None) -> str:
2484
  return "fundamental_analysis"
2485
  if any(term in normalized for term in ["tesi", "thesis", "convinzione", "conviction", "ancora valida", "still valid", "sopravviss", "survival", "decay", "decad", "bull bear neutral", "tesi bull", "tesi bear", "tesi neutral", "motore", "engine vote", "sta migliorando", "dove ha sbagliato", "reasoning core", "batte spy", "batte qqq", "vs spy", "vs qqq"]):
2486
  return "reasoning_memory_question"
2487
- if any(term in normalized for term in ["capitale virtuale", "trading game", "sta battendo", "batte il mercato", "benchmark", "drawdown", "profit factor", "expectancy", "p/l", "pl ", "peggior errore", "andato a zero", "rischio per trade", "riproducibil", "reproducib", "win rate", "quali trade", "trade hanno", "dove e entrato", "dove e uscito", "entrato blum", "uscito blum", "per azione", "fortuna", "profitto arriva", "ledger", "trade piu importante", "100 eur", "10,000", "10000", "target cycle", "ciclo capitale", "cicli capitale", "quante volte", "live paper", "forward paper", "storico vs live", "historical vs live", "sta migliorando", "intelligence growth", "missed entry", "stop hit", "target hit", "trading power", "power score", "dove e scarso", "piu scarso", "weakness", "self improvement", "auto miglior", "prossima azione", "baseline semplice", "stiamo battendo spy", "stiamo battendo qqq", "best opportunity", "migliore opportunita", "opportunita migliore", "ha scelto il migliore", "decision superiority", "superiorita decisionale", "alpha capture", "opportunita mancata", "missed opportunity", "business quality", "qualita business", "moat", "management quality", "portfolio quality", "qualita portafoglio", "contribuisce piu alpha", "contribution", "concentrazione portfolio", "capital allocation", "cash allocation", "cash reserve", "quanto capitale", "quanto allocare", "peso capitale", "sizing logic", "logica di sizing"]):
2488
  return "trading_game"
2489
  if any(term in normalized for term in ["sniper", "entrabile", "meglio aspettare", "ingresso", "entry", "risk/reward", "uscita", "exit", "target", "invalidazione", "invalidation", "profitto", "take profit"]):
2490
  return "market_sniper"
 
33
  from app.services.live import market_sentiment
34
  from app.services.learning_loop import LearningDashboardService
35
  from app.services.learning_intelligence import LearningIntelligenceDashboardService
36
+ from app.services.alpha_recovery import AlphaRecoveryDashboardService
37
+ from app.services.meta_cognition import MetaCognitionEngine
38
  from app.services.capital_allocation import AdaptiveCapitalAllocationEngine
39
  from app.services.decision_intelligence import DecisionIntelligenceDashboardService
40
  from app.services.market_data import market_snapshot_for_asset
 
489
  "learning_intelligence": LearningIntelligenceDashboardService().dashboard(db),
490
  "decision_intelligence": DecisionIntelligenceDashboardService().dashboard(db),
491
  "capital_allocation": AdaptiveCapitalAllocationEngine().dashboard(db),
492
+ "alpha_recovery": AlphaRecoveryDashboardService().dashboard(db),
493
+ "meta_cognition": MetaCognitionEngine().summary(db),
494
  "pnl_breakdown": PnLBreakdownService().game_breakdown(db),
495
  "reality_check": TradingGameRealityCheckService().evaluate(db),
496
  "failures": engine.failures(db, limit=12),
 
1481
  learning_intelligence = context.get("learning_intelligence") or {}
1482
  decision_intelligence = context.get("decision_intelligence") or {}
1483
  capital_allocation = context.get("capital_allocation") or {}
1484
+ alpha_recovery = context.get("alpha_recovery") or {}
1485
+ meta_cognition = context.get("meta_cognition") or {}
1486
  if not game:
1487
  message = "BLUM non ha ancora un Trading Game persistito. Serve almeno un ciclo Sniper/Learning Loop per creare simulazioni P/L reali." if language == "it" else "BLUM does not have a persisted Trading Game yet. It needs at least one Sniper/Learning Loop cycle to create real P/L simulations."
1488
  return build_error_response(language, message, [])
 
1511
  {"key": "learning_intelligence", "title": "Learning Intelligence", "bullets": learning_intelligence_lines(learning_intelligence, language)},
1512
  {"key": "decision_intelligence", "title": "Decision Intelligence", "bullets": decision_intelligence_lines(decision_intelligence, language)},
1513
  {"key": "capital_allocation", "title": "Capital Allocation Intelligence", "bullets": capital_allocation_lines(capital_allocation, language)},
1514
+ {"key": "alpha_recovery", "title": "Alpha Recovery", "bullets": alpha_recovery_lines(alpha_recovery, language)},
1515
+ {"key": "meta_cognition", "title": "Meta-Cognition", "bullets": meta_cognition_lines(meta_cognition, language)},
1516
  {"key": "trade_ledger", "title": "Trade ledger", "bullets": trade_ledger_lines(ledger_rows, language)},
1517
  {"key": "intelligence_metrics", "title": "Trading intelligence", "bullets": intelligence_metric_lines(intelligence_metrics, rolling_metrics, metrics_by_setup, language)},
1518
  {"key": "live_forward", "title": "Storico vs live paper", "bullets": historical_live_lines(live_forward, historical_vs_live, language)},
 
1553
  {"key": "learning_intelligence", "title": "Learning Intelligence", "bullets": learning_intelligence_lines(learning_intelligence, language)},
1554
  {"key": "decision_intelligence", "title": "Decision Intelligence", "bullets": decision_intelligence_lines(decision_intelligence, language)},
1555
  {"key": "capital_allocation", "title": "Capital Allocation Intelligence", "bullets": capital_allocation_lines(capital_allocation, language)},
1556
+ {"key": "alpha_recovery", "title": "Alpha Recovery", "bullets": alpha_recovery_lines(alpha_recovery, language)},
1557
+ {"key": "meta_cognition", "title": "Meta-Cognition", "bullets": meta_cognition_lines(meta_cognition, language)},
1558
  {"key": "trade_ledger", "title": "Trade Ledger", "bullets": trade_ledger_lines(ledger_rows, language)},
1559
  {"key": "intelligence_metrics", "title": "Trading Intelligence", "bullets": intelligence_metric_lines(intelligence_metrics, rolling_metrics, metrics_by_setup, language)},
1560
  {"key": "live_forward", "title": "Historical vs Live Paper", "bullets": historical_live_lines(live_forward, historical_vs_live, language)},
 
1584
  "executive_view": summary,
1585
  "risk_reward_view": f"Expectancy {format_number(game.get('expectancy_r'))}R, drawdown {format_signed(game.get('max_drawdown'))}%.",
1586
  "data_quality": {"sample_warning": sample_warning, "trades": game.get("trade_count"), "reproducibility": reproducibility, "cycles": cycle_stats, "live_sample_warning": (historical_vs_live.get("sample_warning") if isinstance(historical_vs_live, dict) else None)},
1587
+ "learning_loop_memory": {"trading_game": game, "lessons": lessons[:6], "latest_trades": trades[:6], "ledger": ledger_rows[:8], "reality_check": reality_check, "cycles": cycle_stats, "intelligence_metrics": intelligence_metrics, "historical_vs_live": historical_vs_live, "learning_intelligence": learning_intelligence, "decision_intelligence": decision_intelligence, "capital_allocation": capital_allocation, "alpha_recovery": alpha_recovery, "meta_cognition": meta_cognition},
1588
  "answer_to_user": summary,
1589
  }
1590
 
 
1701
  return dedupe_warnings(lines)
1702
 
1703
 
1704
+ def alpha_recovery_lines(payload: dict, language: str) -> list[str]:
1705
+ if not payload or payload.get("status") == "unavailable":
1706
+ return ["Alpha Recovery non disponibile: non invento perche BLUM perde o vince alpha." if language == "it" else "Alpha Recovery is unavailable; I will not invent why BLUM won or lost alpha."]
1707
+ truth = (payload.get("truth_layer") or {}).get("lines") or []
1708
+ attribution = payload.get("latest_attribution") or {}
1709
+ attribution_summary = attribution.get("summary") or {}
1710
+ top_category = attribution_summary.get("top_category")
1711
+ missed = ((payload.get("missed_winners") or {}).get("rows") or [])[:3]
1712
+ actions = ((payload.get("recovery_actions") or {}).get("rows") or [])[:3]
1713
+ methodology_rows = ((payload.get("latest_methodology") or {}).get("rows") or [])[:3]
1714
+ invalid = [row for row in methodology_rows if not row.get("methodology_valid")]
1715
+ if language == "it":
1716
+ lines = list(truth[:3]) or ["Evidenza insufficiente: servono benchmark validati e trade ledger prima di attribuire perdita alpha."]
1717
+ if invalid:
1718
+ lines.append("Alcuni benchmark non sono validi per apprendimento: BLUM blocca conclusioni di recovery su quei confronti.")
1719
+ if top_category:
1720
+ lines.append(f"Causa misurata principale: {str(top_category).replace('_', ' ')}.")
1721
+ if missed:
1722
+ lines.append("Missed winners da replay: " + ", ".join(f"{row.get('ticker')} ({format_signed(row.get('benchmark_relative_return'))}%)" for row in missed))
1723
+ if actions:
1724
+ lines.append(f"Azione recovery proposta: {actions[0].get('recommended_action')} | stato {actions[0].get('status')}.")
1725
+ return dedupe_warnings(lines)
1726
+ lines = list(truth[:3]) or ["Insufficient evidence: validated benchmarks and trade ledger data are required before attributing alpha loss."]
1727
+ if invalid:
1728
+ lines.append("Some benchmarks are not valid for learning; BLUM blocks recovery conclusions on those comparisons.")
1729
+ if top_category:
1730
+ lines.append(f"Main measured cause: {str(top_category).replace('_', ' ')}.")
1731
+ if missed:
1732
+ lines.append("Missed winners for replay: " + ", ".join(f"{row.get('ticker')} ({format_signed(row.get('benchmark_relative_return'))}%)" for row in missed))
1733
+ if actions:
1734
+ lines.append(f"Recovery action proposed: {actions[0].get('recommended_action')} | status {actions[0].get('status')}.")
1735
+ return dedupe_warnings(lines)
1736
+
1737
+
1738
+ def meta_cognition_lines(payload: dict, language: str) -> list[str]:
1739
+ if not payload or payload.get("status") == "unavailable":
1740
+ return ["Meta-Cognition non disponibile: non invento quali fattori creano o distruggono alpha." if language == "it" else "Meta-Cognition is unavailable; I will not invent which factors create or destroy alpha."]
1741
+ snapshot_payload = ((payload.get("snapshot") or {}).get("payload") or {}) if isinstance(payload.get("snapshot"), dict) else {}
1742
+ factor_summary = ((payload.get("factor_importance") or {}).get("summary") or {})
1743
+ focus_rows = ((payload.get("learning_focus") or {}).get("rows") or [])
1744
+ noise_rows = ((payload.get("noise") or {}).get("rows") or [])
1745
+ preservation_summary = ((payload.get("capital_preservation") or {}).get("summary") or {})
1746
+ events_summary = ((payload.get("events") or {}).get("summary") or {})
1747
+ conclusion = payload.get("conclusion") or snapshot_payload.get("meta_cognition_conclusion") or {}
1748
+
1749
+ top_alpha = snapshot_payload.get("top_alpha_factor") or first_payload(factor_summary.get("top_alpha_creators"))
1750
+ top_destroyer = snapshot_payload.get("top_alpha_destroyer") or first_payload(factor_summary.get("top_alpha_destroyers"))
1751
+ noisiest = snapshot_payload.get("noisiest_factor") or first_payload(factor_summary.get("noisiest_factors")) or first_payload(noise_rows)
1752
+ focus = snapshot_payload.get("next_learning_focus") or first_payload(focus_rows)
1753
+ preservation = snapshot_payload.get("strongest_capital_preservation_rule") or first_payload(preservation_summary.get("top_preservers"))
1754
+
1755
+ if language == "it":
1756
+ lines = []
1757
+ if top_alpha:
1758
+ lines.append(f"Fattore che sta creando piu alpha misurabile: {top_alpha.get('factor_name', 'n/a')} | contributo {format_number(top_alpha.get('alpha_contribution'))} | campione {top_alpha.get('sample_size', 'n/a')}.")
1759
+ if top_destroyer:
1760
+ lines.append(f"Fattore che sta distruggendo piu alpha: {top_destroyer.get('factor_name', 'n/a')} | perdita {format_number(top_destroyer.get('alpha_loss_contribution'))} | azione {top_destroyer.get('recommended_weight_action', 'n/a')}.")
1761
+ if noisiest:
1762
+ lines.append(f"Fattore/modulo piu rumoroso: {noisiest.get('factor_name') or noisiest.get('module_name', 'n/a')} | noise {format_number(noisiest.get('noise_score'))}/100 | severita {noisiest.get('severity', 'n/a')}.")
1763
+ if preservation:
1764
+ lines.append(f"Regola no-trade piu utile: {preservation.get('ticker', preservation.get('factor_name', 'n/a'))} | capitale preservato {format_money(preservation.get('capital_preserved'))} | quality {format_number(preservation.get('quality_score'))}/100.")
1765
+ if focus:
1766
+ lines.append(f"Prossimo focus Learning Loop: {focus.get('priority_type', 'focus')} su {focus.get('target', 'n/a')} | valore atteso {format_number(focus.get('expected_learning_value'))}/100.")
1767
+ if conclusion.get("summary"):
1768
+ lines.append(str(conclusion.get("summary")))
1769
+ if events_summary.get("events"):
1770
+ lines.append(f"Azioni di learning valutate: {events_summary.get('events')} | miglioramenti {events_summary.get('improvements', 0)} | peggioramenti {events_summary.get('degradations', 0)}.")
1771
+ return dedupe_warnings(lines or ["Evidenza meta-cognitiva insufficiente: serve ledger, benchmark validi e piu campioni prima di attribuire alpha ai fattori."])
1772
+
1773
+ lines = []
1774
+ if top_alpha:
1775
+ lines.append(f"Top measured alpha-creating factor: {top_alpha.get('factor_name', 'n/a')} | contribution {format_number(top_alpha.get('alpha_contribution'))} | sample {top_alpha.get('sample_size', 'n/a')}.")
1776
+ if top_destroyer:
1777
+ lines.append(f"Top measured alpha-destroying factor: {top_destroyer.get('factor_name', 'n/a')} | loss {format_number(top_destroyer.get('alpha_loss_contribution'))} | action {top_destroyer.get('recommended_weight_action', 'n/a')}.")
1778
+ if noisiest:
1779
+ lines.append(f"Noisiest factor/module: {noisiest.get('factor_name') or noisiest.get('module_name', 'n/a')} | noise {format_number(noisiest.get('noise_score'))}/100 | severity {noisiest.get('severity', 'n/a')}.")
1780
+ if preservation:
1781
+ lines.append(f"Strongest no-trade preservation rule: {preservation.get('ticker', preservation.get('factor_name', 'n/a'))} | preserved {format_money(preservation.get('capital_preserved'))} | quality {format_number(preservation.get('quality_score'))}/100.")
1782
+ if focus:
1783
+ lines.append(f"Next Learning Loop focus: {focus.get('priority_type', 'focus')} on {focus.get('target', 'n/a')} | expected value {format_number(focus.get('expected_learning_value'))}/100.")
1784
+ if conclusion.get("summary"):
1785
+ lines.append(str(conclusion.get("summary")))
1786
+ if events_summary.get("events"):
1787
+ lines.append(f"Learning actions evaluated: {events_summary.get('events')} | improvements {events_summary.get('improvements', 0)} | degradations {events_summary.get('degradations', 0)}.")
1788
+ return dedupe_warnings(lines or ["Insufficient meta-cognition evidence: BLUM needs a ledger, valid benchmarks and more samples before attributing alpha to factors."])
1789
+
1790
+
1791
+ def first_payload(value: object) -> dict:
1792
+ if isinstance(value, list) and value:
1793
+ first = value[0]
1794
+ return first if isinstance(first, dict) else {}
1795
+ return value if isinstance(value, dict) else {}
1796
+
1797
+
1798
  def trade_ledger_lines(rows: list[dict], language: str) -> list[str]:
1799
  if not rows:
1800
  return ["BLUM ha metriche di gioco, ma il trade ledger dettagliato non e ancora disponibile." if language == "it" else "BLUM has game-level metrics, but the detailed trade ledger is not available yet."]
 
2588
  return "fundamental_analysis"
2589
  if any(term in normalized for term in ["tesi", "thesis", "convinzione", "conviction", "ancora valida", "still valid", "sopravviss", "survival", "decay", "decad", "bull bear neutral", "tesi bull", "tesi bear", "tesi neutral", "motore", "engine vote", "sta migliorando", "dove ha sbagliato", "reasoning core", "batte spy", "batte qqq", "vs spy", "vs qqq"]):
2590
  return "reasoning_memory_question"
2591
+ if any(term in normalized for term in ["capitale virtuale", "trading game", "sta battendo", "batte il mercato", "benchmark", "drawdown", "profit factor", "expectancy", "p/l", "pl ", "peggior errore", "andato a zero", "rischio per trade", "riproducibil", "reproducib", "win rate", "quali trade", "trade hanno", "dove e entrato", "dove e uscito", "entrato blum", "uscito blum", "per azione", "fortuna", "profitto arriva", "ledger", "trade piu importante", "100 eur", "10,000", "10000", "target cycle", "ciclo capitale", "cicli capitale", "quante volte", "live paper", "forward paper", "storico vs live", "historical vs live", "sta migliorando", "intelligence growth", "missed entry", "stop hit", "target hit", "trading power", "power score", "dove e scarso", "piu scarso", "weakness", "self improvement", "auto miglior", "prossima azione", "baseline semplice", "stiamo battendo spy", "stiamo battendo qqq", "best opportunity", "migliore opportunita", "opportunita migliore", "ha scelto il migliore", "decision superiority", "superiorita decisionale", "alpha capture", "opportunita mancata", "missed opportunity", "business quality", "qualita business", "moat", "management quality", "portfolio quality", "qualita portafoglio", "contribuisce piu alpha", "contribution", "concentrazione portfolio", "capital allocation", "cash allocation", "cash reserve", "quanto capitale", "quanto allocare", "peso capitale", "sizing logic", "logica di sizing", "alpha loss", "alpha recovery", "perche perdiamo", "perché perdiamo", "perdiamo contro", "why are we losing", "why losing", "qqq", "spy", "missed winners", "winners mancati", "opportunita mancate", "opportunità mancate", "recovery action", "azioni recovery", "perdita alpha", "alpha perso", "lose alpha", "meta cognition", "meta-cognition", "meta cognizione", "meta-cognizione", "fattore alpha", "factor alpha", "quale fattore", "which factor", "fattore rumoroso", "noisy factor", "modulo rumoroso", "cosa deve imparare", "what should blum learn", "imparare adesso", "learn next", "trust technical", "fidarsi dei tecnici", "capital preservation", "preservazione capitale", "no-trade rule", "regola no trade"]):
2592
  return "trading_game"
2593
  if any(term in normalized for term in ["sniper", "entrabile", "meglio aspettare", "ingresso", "entry", "risk/reward", "uscita", "exit", "target", "invalidazione", "invalidation", "profitto", "take profit"]):
2594
  return "market_sniper"
backend/app/services/learning_loop.py CHANGED
@@ -17,11 +17,15 @@ from app.core.config import get_settings
17
  from app.models import (
18
  Asset,
19
  FundamentalSnapshot,
 
20
  HistoricalPrediction,
 
 
21
  LearningEvent,
22
  LearningMetric,
23
  LearningRun,
24
  MacroSnapshot,
 
25
  MistakeAnalysis,
26
  ModelVersion,
27
  NewsArticle,
@@ -30,6 +34,7 @@ from app.models import (
30
  PriceHistory,
31
  SignalPerformance,
32
  StrategyMemory,
 
33
  )
34
  from app.services.technical_analysis_engine import TechnicalAnalysisEngine
35
 
@@ -54,6 +59,10 @@ BASE_SIGNAL_WEIGHTS = {
54
  "regime": 0.05,
55
  }
56
 
 
 
 
 
57
  ERROR_TYPES = {
58
  "technical_false_breakout",
59
  "overbought_signal_ignored",
@@ -86,10 +95,17 @@ class LearningLoopService:
86
  self.memory = StrategyMemoryService()
87
  self.model_scores = ModelScoreService()
88
 
89
- def run_batch(self, db: Session, batch_size: int | None = None, trigger: str = "manual") -> dict:
 
 
 
 
 
 
90
  requested_batch = int(batch_size or settings.learning_batch_size)
91
- batch = max(1, min(requested_batch, settings.learning_batch_size, 500))
92
- daily_guard = self.daily_guard(db, batch)
 
93
  run_id = f"learn-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:8]}"
94
  run = LearningRun(
95
  run_id=run_id,
@@ -104,26 +120,26 @@ class LearningLoopService:
104
  db.flush()
105
 
106
  if not daily_guard["allowed"]:
107
- run.status = "skipped"
108
  run.completed_at = datetime.utcnow()
109
- run.summary = {"reason": daily_guard["reason"], "requested_batch_size": requested_batch}
110
  event = LearningEvent(
111
- event_type="blum_learning_loop_skipped",
112
  severity="Warning",
113
- title="BLUM Learning Loop skipped by daily guard",
114
  description=daily_guard["reason"],
115
  payload={"run_id": run_id, "guard": daily_guard},
116
  )
117
  db.add(event)
118
  db.commit()
119
- return {"status": "skipped", "run_id": run_id, "guard": daily_guard}
120
 
121
  reports: list[dict] = []
122
  errors: list[dict] = []
123
  seen_samples: set[tuple[str, str]] = set()
124
  for _ in range(batch):
125
  try:
126
- sample = self.sampler.random_sample(db, seen_samples=seen_samples)
127
  if not sample:
128
  errors.append({"stage": "sample", "error": "No eligible historical sample found."})
129
  continue
@@ -135,9 +151,16 @@ class LearningLoopService:
135
  sniper_learning = {"status": "skipped", "reason": "No reports created."}
136
  if reports:
137
  try:
138
- from app.services.market_sniper import MarketSniperEngine
139
-
140
- sniper_learning = MarketSniperEngine().simulate(db, limit=min(300, max(60, len(reports) * len(TIMEFRAMES) * 6)))
 
 
 
 
 
 
 
141
  except Exception as exc:
142
  sniper_learning = {"status": "degraded", "error": f"{type(exc).__name__}: {exc}"}
143
 
@@ -156,6 +179,7 @@ class LearningLoopService:
156
  "latest_reports": reports[-5:],
157
  "dashboard_metrics": metrics,
158
  "model_version": model_version,
 
159
  "market_sniper_learning": sniper_learning,
160
  }
161
  run.anti_overfitting_report = anti_overfitting
@@ -174,10 +198,13 @@ class LearningLoopService:
174
  "status": run.status,
175
  "run_id": run_id,
176
  "batch_size": batch,
 
 
177
  "reports_created": len(reports),
178
  "errors": errors[:8],
179
  "metrics": metrics,
180
  "model_version": model_version,
 
181
  "market_sniper_learning": sniper_learning,
182
  "anti_overfitting": anti_overfitting,
183
  "disclaimer": "Research learning loop only. It improves calibration and robustness; it does not create guaranteed market predictions.",
@@ -187,7 +214,12 @@ class LearningLoopService:
187
  context = self.point_in_time.context_for(db, sample["asset"], sample["analysis_date"])
188
  prediction_context = dict(context)
189
  prediction_context.pop("future_prices", None)
190
- prediction_payload = self.predictor.predict(prediction_context)
 
 
 
 
 
191
  prediction = HistoricalPrediction(
192
  learning_run_id=run.id,
193
  asset_id=sample["asset"].id,
@@ -203,7 +235,12 @@ class LearningLoopService:
203
  point_in_time_context=json_safe(context),
204
  expected_direction=prediction_payload["prediction"]["dominant_direction"],
205
  confidence=prediction_payload["prediction"]["aggregate_confidence"],
206
- model_version="blum-learning-loop-v1",
 
 
 
 
 
207
  data_quality_score=context["data_quality_score"],
208
  )
209
  db.add(prediction)
@@ -264,6 +301,7 @@ class LearningLoopService:
264
  memory_updates.extend(self.memory.update_from_outcome(db, context, frame_prediction, outcome_payload, analysis))
265
 
266
  self.memory.update_signal_performance(db, context, prediction_payload, outcomes)
 
267
  report = {
268
  "asset": prediction.ticker,
269
  "analysis_date": prediction.analysis_date.isoformat(),
@@ -280,20 +318,35 @@ class LearningLoopService:
280
  "mistakes": mistakes,
281
  "lessons_learned": [item["lesson"] for item in memory_updates],
282
  "memory_updates": memory_updates,
 
283
  }
284
  return json_safe(report)
285
 
286
  def daily_guard(self, db: Session, requested_batch: int) -> dict:
287
  start = datetime.combine(datetime.utcnow().date(), time.min)
288
  today_predictions = int(db.scalar(select(func.coalesce(func.sum(LearningRun.predictions_created), 0)).where(LearningRun.started_at >= start)) or 0)
289
- projected = today_predictions + requested_batch
290
- allowed = projected <= settings.learning_max_daily_runs
 
 
 
 
291
  return {
292
  "allowed": allowed,
293
  "today_predictions": today_predictions,
294
  "requested_batch": requested_batch,
295
- "max_daily_runs": settings.learning_max_daily_runs,
296
- "reason": "within daily limit" if allowed else "Learning max daily run guard prevents overfitting and excessive repeated sampling.",
 
 
 
 
 
 
 
 
 
 
297
  }
298
 
299
 
@@ -301,6 +354,92 @@ class HistoricalSamplerService:
301
  def __init__(self) -> None:
302
  self.rng = random.Random(self.seed())
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  def random_sample(self, db: Session, seen_samples: set[tuple[str, str]] | None = None) -> dict | None:
305
  universe = {item.strip().lower() for item in settings.learning_asset_universe.split(",") if item.strip()}
306
  asset_types = []
@@ -313,32 +452,37 @@ class HistoricalSamplerService:
313
 
314
  candidates = db.scalars(select(Asset).where(Asset.is_active.is_(True), Asset.asset_type.in_(asset_types))).all()
315
  self.rng.shuffle(candidates)
316
- min_rows = max(252, settings.learning_min_history_years * 252)
317
  for asset in candidates:
318
- stats = db.execute(
319
- select(func.count(PriceHistory.id), func.min(PriceHistory.date), func.max(PriceHistory.date)).where(PriceHistory.asset_id == asset.id)
320
- ).one()
321
- count, first_date, last_date = int(stats[0] or 0), as_date(stats[1]), as_date(stats[2])
322
- if count < min_rows or not first_date or not last_date:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  continue
324
- earliest = first_date + timedelta(days=max(365, settings.learning_min_history_years * 365))
325
- latest = last_date - timedelta(days=TIMEFRAMES["long"]["horizon_days"] * 2 + 30)
326
- if earliest >= latest:
327
  continue
328
- span = (latest - earliest).days
329
- for _ in range(8):
330
- analysis_date = nearest_trading_date(
331
- db,
332
- asset,
333
- earliest + timedelta(days=self.rng.randint(0, max(1, span))),
334
- latest,
335
- )
336
- if not analysis_date:
337
- continue
338
- key = (asset.ticker, analysis_date.isoformat())
339
- if seen_samples and key in seen_samples:
340
- continue
341
- return {"asset": asset, "analysis_date": analysis_date, "first_price_date": first_date, "last_price_date": last_date, "sample_rows": count}
342
  return None
343
 
344
  def seed(self) -> int:
@@ -480,12 +624,18 @@ class PointInTimeDataService:
480
 
481
 
482
  class PredictionEngine:
483
- def predict(self, context: dict) -> dict:
484
  technical = context["technical"]
485
  signal_scores = self.signal_scores(context)
486
- aggregate_score = weighted_score(signal_scores, BASE_SIGNAL_WEIGHTS)
 
 
 
487
  dominant_direction = direction_from_score(aggregate_score, technical)
488
- confidence = confidence_from_evidence(aggregate_score, context["data_quality_score"], context["market_context"], technical)
 
 
 
489
  timeframes = {
490
  timeframe: self.timeframe_prediction(timeframe, config, context, signal_scores, aggregate_score, dominant_direction, confidence)
491
  for timeframe, config in TIMEFRAMES.items()
@@ -502,9 +652,68 @@ class PredictionEngine:
502
  "reasoning": self.reasoning(context, signal_scores, dominant_direction),
503
  },
504
  "timeframes": timeframes,
 
 
 
 
 
 
 
505
  "anti_leakage": context["point_in_time_policy"],
506
  }
507
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
  def signal_scores(self, context: dict) -> dict:
509
  technical = context["technical"]
510
  indicators = technical.get("technical_indicators") or {}
@@ -775,19 +984,43 @@ class StrategyMemoryService:
775
  class ModelScoreService:
776
  def recalculate(self, db: Session) -> dict:
777
  rows = db.scalars(select(SignalPerformance).order_by(desc(SignalPerformance.updated_at)).limit(600)).all()
778
- if not rows:
779
- return {"status": "insufficient_sample", "version": None}
780
  previous = active_model_version(db)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  previous_weights = previous.weights if previous else BASE_SIGNAL_WEIGHTS
782
  new_weights = dict(BASE_SIGNAL_WEIGHTS)
783
  for signal_name in new_weights:
784
- matching = [row for row in rows if row.signal_name == signal_name and row.sample_count >= 3]
785
  if not matching:
786
  continue
787
  avg_reliability = mean(row.reliability_score for row in matching)
788
  new_weights[signal_name] = max(0.03, new_weights[signal_name] + (avg_reliability - 50) / 1000)
789
  new_weights = normalize_weights(new_weights)
790
- anti = self.anti_overfitting_report(db)
 
 
 
 
 
 
 
791
  version = f"learning-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
792
  row = ModelVersion(
793
  version=version,
@@ -844,6 +1077,181 @@ class ModelScoreService:
844
  }
845
 
846
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
847
  class LearningDashboardService:
848
  def dashboard(self, db: Session) -> dict:
849
  latest_run = db.scalar(select(LearningRun).order_by(desc(LearningRun.started_at)).limit(1))
@@ -866,6 +1274,7 @@ class LearningDashboardService:
866
  "strategy_memory": self.strategy_memory(db),
867
  "mistakes": self.mistake_summary(db),
868
  "model_versions": [serialize_model_version(row) for row in db.scalars(select(ModelVersion).order_by(desc(ModelVersion.created_at)).limit(8)).all()],
 
869
  "trading_game": trading_game,
870
  "policy": "BLUM Learning Loop optimizes calibration and robustness, not artificial 100% winrate.",
871
  }
@@ -1302,6 +1711,227 @@ def active_model_version(db: Session) -> ModelVersion | None:
1302
  return db.scalar(select(ModelVersion).where(ModelVersion.is_active.is_(True)).order_by(desc(ModelVersion.created_at)).limit(1))
1303
 
1304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1305
  def normalize_weights(weights: dict[str, float]) -> dict[str, float]:
1306
  total = sum(max(0.0, float(value)) for value in weights.values()) or 1.0
1307
  return {key: round(max(0.0, float(value)) / total, 4) for key, value in weights.items()}
@@ -1372,6 +2002,11 @@ def serialize_prediction(row: HistoricalPrediction) -> dict:
1372
  "market_regime": row.market_regime,
1373
  "volatility_regime": row.volatility_regime,
1374
  "data_quality_score": row.data_quality_score,
 
 
 
 
 
1375
  "prediction": row.prediction_payload.get("prediction", {}) if row.prediction_payload else {},
1376
  "timeframes": row.prediction_payload.get("timeframes", {}) if row.prediction_payload else {},
1377
  "created_at": iso(row.created_at),
@@ -1423,6 +2058,24 @@ def serialize_model_version(row: ModelVersion) -> dict:
1423
  }
1424
 
1425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1426
  def fundamental_reason(fundamentals: dict) -> str:
1427
  if fundamentals.get("status") == "ready":
1428
  return f"Point-in-time verified fundamentals available with quality score {fundamentals.get('quality_score')}."
@@ -1509,6 +2162,10 @@ def clamp(value: float, low: float = 0.0, high: float = 100.0) -> float:
1509
  return max(low, min(high, float(value)))
1510
 
1511
 
 
 
 
 
1512
  def round_float(value) -> float | None:
1513
  return round(safe_float(value), 4) if value is not None else None
1514
 
 
17
  from app.models import (
18
  Asset,
19
  FundamentalSnapshot,
20
+ FeedbackLoopAudit,
21
  HistoricalPrediction,
22
+ CapitalPreservationAlpha,
23
+ LearningFocusPriority,
24
  LearningEvent,
25
  LearningMetric,
26
  LearningRun,
27
  MacroSnapshot,
28
+ MissedWinner,
29
  MistakeAnalysis,
30
  ModelVersion,
31
  NewsArticle,
 
34
  PriceHistory,
35
  SignalPerformance,
36
  StrategyMemory,
37
+ TradingGameTrade,
38
  )
39
  from app.services.technical_analysis_engine import TechnicalAnalysisEngine
40
 
 
59
  "regime": 0.05,
60
  }
61
 
62
+ MIN_MODEL_VERSION_OUTCOMES = 30
63
+ MIN_MODEL_VERSION_SIGNAL_ROWS = 3
64
+ MIN_MODEL_VERSION_SIGNAL_SAMPLE = 3
65
+
66
  ERROR_TYPES = {
67
  "technical_false_breakout",
68
  "overbought_signal_ignored",
 
95
  self.memory = StrategyMemoryService()
96
  self.model_scores = ModelScoreService()
97
 
98
+ def run_batch(
99
+ self,
100
+ db: Session,
101
+ batch_size: int | None = None,
102
+ trigger: str = "manual",
103
+ sniper_simulation_limit: int | None = None,
104
+ ) -> dict:
105
  requested_batch = int(batch_size or settings.learning_batch_size)
106
+ configured_batch = max(1, min(requested_batch, settings.learning_batch_size, 500))
107
+ daily_guard = self.daily_guard(db, configured_batch)
108
+ batch = max(1, int(daily_guard.get("effective_batch", configured_batch) or configured_batch))
109
  run_id = f"learn-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:8]}"
110
  run = LearningRun(
111
  run_id=run_id,
 
120
  db.flush()
121
 
122
  if not daily_guard["allowed"]:
123
+ run.status = "budget_wait"
124
  run.completed_at = datetime.utcnow()
125
+ run.summary = {"reason": daily_guard["reason"], "requested_batch_size": requested_batch, "daily_guard": daily_guard}
126
  event = LearningEvent(
127
+ event_type="blum_learning_loop_budget_wait",
128
  severity="Warning",
129
+ title="BLUM Learning Loop waiting for daily budget",
130
  description=daily_guard["reason"],
131
  payload={"run_id": run_id, "guard": daily_guard},
132
  )
133
  db.add(event)
134
  db.commit()
135
+ return {"status": "budget_wait", "run_id": run_id, "guard": daily_guard}
136
 
137
  reports: list[dict] = []
138
  errors: list[dict] = []
139
  seen_samples: set[tuple[str, str]] = set()
140
  for _ in range(batch):
141
  try:
142
+ sample = self.sampler.blended_sample(db, seen_samples=seen_samples, trigger=trigger)
143
  if not sample:
144
  errors.append({"stage": "sample", "error": "No eligible historical sample found."})
145
  continue
 
151
  sniper_learning = {"status": "skipped", "reason": "No reports created."}
152
  if reports:
153
  try:
154
+ limit = sniper_simulation_limit if sniper_simulation_limit is not None else min(300, max(60, len(reports) * len(TIMEFRAMES) * 6))
155
+ if limit > 0:
156
+ from app.services.market_sniper import MarketSniperEngine
157
+
158
+ sniper_learning = MarketSniperEngine().simulate(db, limit=limit)
159
+ else:
160
+ sniper_learning = {
161
+ "status": "deferred",
162
+ "reason": "Sniper R-multiple simulation is deferred for this bounded learning lane.",
163
+ }
164
  except Exception as exc:
165
  sniper_learning = {"status": "degraded", "error": f"{type(exc).__name__}: {exc}"}
166
 
 
179
  "latest_reports": reports[-5:],
180
  "dashboard_metrics": metrics,
181
  "model_version": model_version,
182
+ "learning_mode": "alpha_loss_replay" if trigger == "alpha_loss_replay" else "random_point_in_time",
183
  "market_sniper_learning": sniper_learning,
184
  }
185
  run.anti_overfitting_report = anti_overfitting
 
198
  "status": run.status,
199
  "run_id": run_id,
200
  "batch_size": batch,
201
+ "requested_batch_size": requested_batch,
202
+ "daily_guard": daily_guard,
203
  "reports_created": len(reports),
204
  "errors": errors[:8],
205
  "metrics": metrics,
206
  "model_version": model_version,
207
+ "learning_mode": "alpha_loss_replay" if trigger == "alpha_loss_replay" else "random_point_in_time",
208
  "market_sniper_learning": sniper_learning,
209
  "anti_overfitting": anti_overfitting,
210
  "disclaimer": "Research learning loop only. It improves calibration and robustness; it does not create guaranteed market predictions.",
 
214
  context = self.point_in_time.context_for(db, sample["asset"], sample["analysis_date"])
215
  prediction_context = dict(context)
216
  prediction_context.pop("future_prices", None)
217
+ sample_metadata = dict(sample)
218
+ sample_metadata["run_trigger"] = run.trigger
219
+ sample_metadata["evaluation_mode"] = run.evaluation_mode
220
+ sample_metadata["learning_mode_metadata"] = learning_mode_metadata(run.trigger, sample_metadata)
221
+ prediction_payload = self.predictor.predict(prediction_context, db=db, sample_metadata=sample_metadata)
222
+ feedback_payload = prediction_payload.get("feedback_loop", {})
223
  prediction = HistoricalPrediction(
224
  learning_run_id=run.id,
225
  asset_id=sample["asset"].id,
 
235
  point_in_time_context=json_safe(context),
236
  expected_direction=prediction_payload["prediction"]["dominant_direction"],
237
  confidence=prediction_payload["prediction"]["aggregate_confidence"],
238
+ model_version=feedback_payload.get("model_version_used") or "base-static",
239
+ model_version_used=feedback_payload.get("model_version_used") or "base-static",
240
+ weights_used=json_safe(feedback_payload.get("weights_used") or {}),
241
+ learning_memory_used=json_safe(feedback_payload.get("learning_memory_used") or {}),
242
+ strategy_memory_used=json_safe(feedback_payload.get("strategy_memory_used") or {}),
243
+ research_priority_used=json_safe(feedback_payload.get("research_priority_used") or {}),
244
  data_quality_score=context["data_quality_score"],
245
  )
246
  db.add(prediction)
 
301
  memory_updates.extend(self.memory.update_from_outcome(db, context, frame_prediction, outcome_payload, analysis))
302
 
303
  self.memory.update_signal_performance(db, context, prediction_payload, outcomes)
304
+ feedback_audit = FeedbackLoopAuditService().record(db, prediction, prediction_payload, outcomes, memory_updates)
305
  report = {
306
  "asset": prediction.ticker,
307
  "analysis_date": prediction.analysis_date.isoformat(),
 
318
  "mistakes": mistakes,
319
  "lessons_learned": [item["lesson"] for item in memory_updates],
320
  "memory_updates": memory_updates,
321
+ "feedback_loop_audit": feedback_audit,
322
  }
323
  return json_safe(report)
324
 
325
  def daily_guard(self, db: Session, requested_batch: int) -> dict:
326
  start = datetime.combine(datetime.utcnow().date(), time.min)
327
  today_predictions = int(db.scalar(select(func.coalesce(func.sum(LearningRun.predictions_created), 0)).where(LearningRun.started_at >= start)) or 0)
328
+ max_daily = max(0, int(settings.learning_max_daily_runs))
329
+ remaining = max(0, max_daily - today_predictions)
330
+ effective_batch = min(max(1, requested_batch), remaining) if remaining else 0
331
+ projected = today_predictions + effective_batch
332
+ allowed = effective_batch > 0
333
+ partial = allowed and effective_batch < requested_batch
334
  return {
335
  "allowed": allowed,
336
  "today_predictions": today_predictions,
337
  "requested_batch": requested_batch,
338
+ "effective_batch": effective_batch,
339
+ "remaining_daily_budget": remaining,
340
+ "projected_predictions": projected,
341
+ "max_daily_runs": max_daily,
342
+ "partial_batch": partial,
343
+ "reason": (
344
+ "within daily limit"
345
+ if allowed and not partial
346
+ else "partial batch: using remaining daily learning budget to keep training without overfitting"
347
+ if partial
348
+ else "daily learning budget exhausted; waiting for next UTC window instead of oversampling the same day"
349
+ ),
350
  }
351
 
352
 
 
354
  def __init__(self) -> None:
355
  self.rng = random.Random(self.seed())
356
 
357
+ def blended_sample(self, db: Session, seen_samples: set[tuple[str, str]] | None = None, trigger: str = "manual") -> dict | None:
358
+ if trigger == "alpha_loss_replay":
359
+ return self.alpha_loss_sample(db, seen_samples=seen_samples) or self.random_sample(db, seen_samples=seen_samples)
360
+ roll = self.rng.random()
361
+ random_ratio = clamp_ratio(settings.learning_random_sample_ratio)
362
+ alpha_ratio = clamp_ratio(settings.learning_alpha_loss_sample_ratio)
363
+ factor_ratio = clamp_ratio(settings.learning_factor_focus_sample_ratio)
364
+ preservation_ratio = clamp_ratio(settings.learning_capital_preservation_sample_ratio)
365
+ total = max(0.01, random_ratio + alpha_ratio + factor_ratio + preservation_ratio)
366
+ random_cut = random_ratio / total
367
+ alpha_cut = random_cut + alpha_ratio / total
368
+ factor_cut = alpha_cut + factor_ratio / total
369
+ if roll < random_cut:
370
+ return self.random_sample(db, seen_samples=seen_samples)
371
+ if roll < alpha_cut:
372
+ return self.alpha_loss_sample(db, seen_samples=seen_samples) or self.random_sample(db, seen_samples=seen_samples)
373
+ if roll < factor_cut:
374
+ return self.focus_priority_sample(db, seen_samples=seen_samples) or self.random_sample(db, seen_samples=seen_samples)
375
+ return self.capital_preservation_sample(db, seen_samples=seen_samples) or self.random_sample(db, seen_samples=seen_samples)
376
+
377
+ def alpha_loss_sample(self, db: Session, seen_samples: set[tuple[str, str]] | None = None) -> dict | None:
378
+ missed = db.scalars(
379
+ select(MissedWinner)
380
+ .order_by(desc(MissedWinner.benchmark_relative_return), desc(MissedWinner.created_at))
381
+ .limit(120)
382
+ ).all()
383
+ for row in missed:
384
+ asset = db.scalar(select(Asset).where(Asset.ticker == row.ticker, Asset.is_active.is_(True)).limit(1))
385
+ if not asset:
386
+ continue
387
+ sample = self.sample_for_asset(db, asset, preferred_date=as_date(row.decision_date), seen_samples=seen_samples)
388
+ if sample:
389
+ sample["sampling_reason"] = "alpha_loss_replay"
390
+ sample["missed_winner_id"] = row.id
391
+ sample["benchmark_relative_return"] = row.benchmark_relative_return
392
+ return sample
393
+ return None
394
+
395
+ def focus_priority_sample(self, db: Session, seen_samples: set[tuple[str, str]] | None = None) -> dict | None:
396
+ priorities = db.scalars(
397
+ select(LearningFocusPriority)
398
+ .where(LearningFocusPriority.status.in_(["proposed", "active"]))
399
+ .order_by(desc(LearningFocusPriority.expected_learning_value), desc(LearningFocusPriority.created_at))
400
+ .limit(80)
401
+ ).all()
402
+ for priority in priorities:
403
+ target = str(priority.target or "").upper()
404
+ asset = db.scalar(select(Asset).where(Asset.ticker == target, Asset.is_active.is_(True)).limit(1))
405
+ if not asset:
406
+ asset = db.scalar(select(Asset).where(Asset.sector.ilike(priority.target), Asset.is_active.is_(True)).limit(1))
407
+ if not asset:
408
+ linked_trade = db.scalar(
409
+ select(TradingGameTrade)
410
+ .where(TradingGameTrade.setup_type == priority.target)
411
+ .order_by(desc(TradingGameTrade.created_at))
412
+ .limit(1)
413
+ )
414
+ asset = db.scalar(select(Asset).where(Asset.ticker == linked_trade.ticker, Asset.is_active.is_(True)).limit(1)) if linked_trade else None
415
+ if not asset:
416
+ continue
417
+ sample = self.sample_for_asset(db, asset, preferred_date=None, seen_samples=seen_samples)
418
+ if sample:
419
+ sample["sampling_reason"] = "learning_focus_priority"
420
+ sample["learning_focus_priority_id"] = priority.id
421
+ sample["priority_type"] = priority.priority_type
422
+ return sample
423
+ return None
424
+
425
+ def capital_preservation_sample(self, db: Session, seen_samples: set[tuple[str, str]] | None = None) -> dict | None:
426
+ rows = db.scalars(
427
+ select(CapitalPreservationAlpha)
428
+ .where(CapitalPreservationAlpha.missed_gain > CapitalPreservationAlpha.avoided_loss)
429
+ .order_by(desc(CapitalPreservationAlpha.missed_gain), desc(CapitalPreservationAlpha.created_at))
430
+ .limit(80)
431
+ ).all()
432
+ for row in rows:
433
+ asset = db.scalar(select(Asset).where(Asset.ticker == row.ticker, Asset.is_active.is_(True)).limit(1))
434
+ if not asset:
435
+ continue
436
+ sample = self.sample_for_asset(db, asset, preferred_date=as_date(row.decision_date), seen_samples=seen_samples)
437
+ if sample:
438
+ sample["sampling_reason"] = "capital_preservation_replay"
439
+ sample["capital_preservation_alpha_id"] = row.id
440
+ return sample
441
+ return None
442
+
443
  def random_sample(self, db: Session, seen_samples: set[tuple[str, str]] | None = None) -> dict | None:
444
  universe = {item.strip().lower() for item in settings.learning_asset_universe.split(",") if item.strip()}
445
  asset_types = []
 
452
 
453
  candidates = db.scalars(select(Asset).where(Asset.is_active.is_(True), Asset.asset_type.in_(asset_types))).all()
454
  self.rng.shuffle(candidates)
 
455
  for asset in candidates:
456
+ sample = self.sample_for_asset(db, asset, preferred_date=None, seen_samples=seen_samples)
457
+ if sample:
458
+ return sample
459
+ return None
460
+
461
+ def sample_for_asset(self, db: Session, asset: Asset, preferred_date: date | None, seen_samples: set[tuple[str, str]] | None = None) -> dict | None:
462
+ min_rows = max(252, settings.learning_min_history_years * 252)
463
+ stats = db.execute(
464
+ select(func.count(PriceHistory.id), func.min(PriceHistory.date), func.max(PriceHistory.date)).where(PriceHistory.asset_id == asset.id)
465
+ ).one()
466
+ count, first_date, last_date = int(stats[0] or 0), as_date(stats[1]), as_date(stats[2])
467
+ if count < min_rows or not first_date or not last_date:
468
+ return None
469
+ earliest = first_date + timedelta(days=max(365, settings.learning_min_history_years * 365))
470
+ latest = last_date - timedelta(days=TIMEFRAMES["long"]["horizon_days"] * 2 + 30)
471
+ if earliest >= latest:
472
+ return None
473
+ preferred_candidates: list[date] = []
474
+ if preferred_date and earliest <= preferred_date <= latest:
475
+ preferred_candidates.extend([preferred_date, preferred_date - timedelta(days=3), preferred_date + timedelta(days=3)])
476
+ span = (latest - earliest).days
477
+ preferred_candidates.extend(earliest + timedelta(days=self.rng.randint(0, max(1, span))) for _ in range(8))
478
+ for candidate_date in preferred_candidates:
479
+ analysis_date = nearest_trading_date(db, asset, max(earliest, min(candidate_date, latest)), latest)
480
+ if not analysis_date:
481
  continue
482
+ key = (asset.ticker, analysis_date.isoformat())
483
+ if seen_samples and key in seen_samples:
 
484
  continue
485
+ return {"asset": asset, "analysis_date": analysis_date, "first_price_date": first_date, "last_price_date": last_date, "sample_rows": count}
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  return None
487
 
488
  def seed(self) -> int:
 
624
 
625
 
626
  class PredictionEngine:
627
+ def predict(self, context: dict, db: Session | None = None, sample_metadata: dict | None = None) -> dict:
628
  technical = context["technical"]
629
  signal_scores = self.signal_scores(context)
630
+ sample_metadata = sample_metadata or {}
631
+ feedback = self.feedback_context(db, context, signal_scores, sample_metadata)
632
+ weights_used = feedback["weights_used"]
633
+ aggregate_score = weighted_score(signal_scores, weights_used)
634
  dominant_direction = direction_from_score(aggregate_score, technical)
635
+ base_confidence = confidence_from_evidence(aggregate_score, context["data_quality_score"], context["market_context"], technical)
636
+ confidence = round(clamp(base_confidence + feedback["confidence_adjustment"], 15, 88), 1)
637
+ feedback["base_confidence"] = base_confidence
638
+ feedback["final_confidence"] = confidence
639
  timeframes = {
640
  timeframe: self.timeframe_prediction(timeframe, config, context, signal_scores, aggregate_score, dominant_direction, confidence)
641
  for timeframe, config in TIMEFRAMES.items()
 
652
  "reasoning": self.reasoning(context, signal_scores, dominant_direction),
653
  },
654
  "timeframes": timeframes,
655
+ "feedback_loop": feedback,
656
+ "model_version_used": feedback["model_version_used"],
657
+ "weights_used": weights_used,
658
+ "learning_memory_used": feedback["learning_memory_used"],
659
+ "strategy_memory_used": feedback["strategy_memory_used"],
660
+ "research_priority_used": feedback["research_priority_used"],
661
+ "learning_mode_metadata": feedback["learning_mode_metadata"],
662
  "anti_leakage": context["point_in_time_policy"],
663
  }
664
 
665
+ def feedback_context(self, db: Session | None, context: dict, signal_scores: dict, sample_metadata: dict) -> dict:
666
+ model_version_used, weights_used, weight_source = active_weight_context(db)
667
+ signal_memory = signal_performance_context(db, context, signal_scores)
668
+ strategy_memory = strategy_memory_context(db, context)
669
+ research_priority = research_priority_context(db, sample_metadata)
670
+ mode_metadata = sample_metadata.get("learning_mode_metadata") or learning_mode_metadata(sample_metadata.get("run_trigger"), sample_metadata)
671
+ confidence_adjustment = round(
672
+ clamp(
673
+ signal_memory["confidence_delta"] + strategy_memory["confidence_delta"] + research_priority.get("confidence_delta", 0.0),
674
+ -14.0,
675
+ 14.0,
676
+ ),
677
+ 2,
678
+ )
679
+ return {
680
+ "model_version_used": model_version_used,
681
+ "weight_source": weight_source,
682
+ "weights_used": weights_used,
683
+ "learning_memory_used": {
684
+ "signal_performance": signal_memory["rows"],
685
+ "confidence_delta": signal_memory["confidence_delta"],
686
+ "policy": "SignalPerformance reliability changes confidence only when enough outcome evidence exists.",
687
+ },
688
+ "strategy_memory_used": {
689
+ "rows": strategy_memory["rows"],
690
+ "confidence_delta": strategy_memory["confidence_delta"],
691
+ "policy": "StrategyMemory lessons modify confidence when their stored conditions match the current point-in-time setup.",
692
+ },
693
+ "research_priority_used": research_priority,
694
+ "learning_mode_metadata": mode_metadata,
695
+ "confidence_adjustment": confidence_adjustment,
696
+ "policy": "PredictionEngine uses active learned weights when available; otherwise BASE_SIGNAL_WEIGHTS. Learning memory changes confidence, not source code.",
697
+ }
698
+
699
+ def baseline_prediction(self, context: dict) -> dict:
700
+ signal_scores = self.signal_scores(context)
701
+ weights = normalize_weights(BASE_SIGNAL_WEIGHTS)
702
+ aggregate_score = weighted_score(signal_scores, weights)
703
+ direction = direction_from_score(aggregate_score, context["technical"])
704
+ confidence = confidence_from_evidence(aggregate_score, context["data_quality_score"], context["market_context"], context["technical"])
705
+ return {
706
+ "model_version_used": "base-static",
707
+ "weights_used": weights,
708
+ "aggregate_score": round(aggregate_score, 2),
709
+ "aggregate_confidence": confidence,
710
+ "dominant_direction": direction,
711
+ "actionability": feedback_actionability(direction, confidence, aggregate_score),
712
+ "confidence_adjustment": 0.0,
713
+ "memory_adjustment_used": False,
714
+ "policy": "Counterfactual baseline uses BASE_SIGNAL_WEIGHTS and ignores learned memory/confidence adjustments.",
715
+ }
716
+
717
  def signal_scores(self, context: dict) -> dict:
718
  technical = context["technical"]
719
  indicators = technical.get("technical_indicators") or {}
 
984
  class ModelScoreService:
985
  def recalculate(self, db: Session) -> dict:
986
  rows = db.scalars(select(SignalPerformance).order_by(desc(SignalPerformance.updated_at)).limit(600)).all()
 
 
987
  previous = active_model_version(db)
988
+ anti = self.anti_overfitting_report(db)
989
+ eligible_rows = [row for row in rows if int(row.sample_count or 0) >= MIN_MODEL_VERSION_SIGNAL_SAMPLE]
990
+ if (
991
+ int(anti.get("sample_count", 0) or 0) < MIN_MODEL_VERSION_OUTCOMES
992
+ or len(eligible_rows) < MIN_MODEL_VERSION_SIGNAL_ROWS
993
+ ):
994
+ return {
995
+ "status": "insufficient_evidence",
996
+ "version": previous.version if previous else None,
997
+ "active_version": previous.version if previous else None,
998
+ "thresholds": {
999
+ "min_outcomes": MIN_MODEL_VERSION_OUTCOMES,
1000
+ "min_signal_rows": MIN_MODEL_VERSION_SIGNAL_ROWS,
1001
+ "min_signal_sample": MIN_MODEL_VERSION_SIGNAL_SAMPLE,
1002
+ },
1003
+ "evidence": {"outcomes": anti.get("sample_count", 0), "eligible_signal_rows": len(eligible_rows)},
1004
+ "anti_overfitting": anti,
1005
+ "policy": "No ModelVersion is created until enough outcome and signal reliability evidence exists.",
1006
+ }
1007
  previous_weights = previous.weights if previous else BASE_SIGNAL_WEIGHTS
1008
  new_weights = dict(BASE_SIGNAL_WEIGHTS)
1009
  for signal_name in new_weights:
1010
+ matching = [row for row in rows if row.signal_name == signal_name and row.sample_count >= MIN_MODEL_VERSION_SIGNAL_SAMPLE]
1011
  if not matching:
1012
  continue
1013
  avg_reliability = mean(row.reliability_score for row in matching)
1014
  new_weights[signal_name] = max(0.03, new_weights[signal_name] + (avg_reliability - 50) / 1000)
1015
  new_weights = normalize_weights(new_weights)
1016
+ if previous and max(abs(safe_float(new_weights.get(key)) - safe_float((previous.weights or {}).get(key))) for key in BASE_SIGNAL_WEIGHTS) < 0.001:
1017
+ return {
1018
+ "status": "stable",
1019
+ "version": previous.version,
1020
+ "weights": previous.weights,
1021
+ "anti_overfitting": anti,
1022
+ "policy": "No new ModelVersion created because learned weights did not materially change.",
1023
+ }
1024
  version = f"learning-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
1025
  row = ModelVersion(
1026
  version=version,
 
1077
  }
1078
 
1079
 
1080
+ class FeedbackLoopAuditService:
1081
+ def record(
1082
+ self,
1083
+ db: Session,
1084
+ prediction: HistoricalPrediction,
1085
+ prediction_payload: dict,
1086
+ outcomes: list[dict],
1087
+ memory_updates: list[dict],
1088
+ ) -> dict:
1089
+ feedback = prediction_payload.get("feedback_loop", {})
1090
+ usable = [row for row in outcomes if row.get("outcome_label") in {"correct", "wrong", "neutral"}]
1091
+ correct = sum(1 for row in usable if row.get("outcome_label") == "correct")
1092
+ wrong = sum(1 for row in usable if row.get("outcome_label") == "wrong")
1093
+ returns = [safe_float(row.get("realized_return")) for row in usable if row.get("realized_return") is not None]
1094
+ evidence_grade = "medium" if len(usable) >= 3 else "low" if usable else "insufficient"
1095
+ learned = {
1096
+ "memory_updates": memory_updates[:12],
1097
+ "signal_performance_used": (feedback.get("learning_memory_used") or {}).get("signal_performance", []),
1098
+ "strategy_memory_used": (feedback.get("strategy_memory_used") or {}).get("rows", []),
1099
+ }
1100
+ counterfactual = self.counterfactual_audit(prediction, prediction_payload, outcomes)
1101
+ comparison = counterfactual.get("outcome_comparison", {})
1102
+ changed_decision = bool(
1103
+ (counterfactual.get("differences") or {}).get("direction_changed")
1104
+ or (counterfactual.get("differences") or {}).get("actionability_changed")
1105
+ or safe_float((counterfactual.get("differences") or {}).get("score_delta")) != 0.0
1106
+ or safe_float((counterfactual.get("differences") or {}).get("confidence_delta")) != 0.0
1107
+ )
1108
+ improved = bool(comparison.get("improvement_detected"))
1109
+ changes = {
1110
+ "model_version_used": feedback.get("model_version_used"),
1111
+ "weights_used": feedback.get("weights_used"),
1112
+ "confidence_adjustment": feedback.get("confidence_adjustment"),
1113
+ "base_confidence": feedback.get("base_confidence"),
1114
+ "final_confidence": feedback.get("final_confidence"),
1115
+ "research_priority_used": feedback.get("research_priority_used"),
1116
+ "learning_mode_metadata": feedback.get("learning_mode_metadata"),
1117
+ "counterfactual_audit": counterfactual,
1118
+ }
1119
+ decision = {
1120
+ "prediction_id": prediction.id,
1121
+ "ticker": prediction.ticker,
1122
+ "analysis_date": iso(prediction.analysis_date),
1123
+ "direction": prediction.expected_direction,
1124
+ "confidence": prediction.confidence,
1125
+ "aggregate_score": (prediction_payload.get("prediction") or {}).get("aggregate_score"),
1126
+ "actionability": counterfactual.get("learned_prediction", {}).get("actionability"),
1127
+ }
1128
+ outcome_payload = {
1129
+ "correct": correct,
1130
+ "wrong": wrong,
1131
+ "neutral": sum(1 for row in usable if row.get("outcome_label") == "neutral"),
1132
+ "average_realized_return": round(mean(returns), 4) if returns else None,
1133
+ "outcomes": {row.get("timeframe"): row.get("outcome_label") for row in outcomes},
1134
+ "baseline_direction_correct": comparison.get("baseline_direction_correct"),
1135
+ "learned_direction_correct": comparison.get("learned_direction_correct"),
1136
+ "baseline_actionability": comparison.get("baseline_actionability"),
1137
+ "learned_actionability": comparison.get("learned_actionability"),
1138
+ "baseline_would_trade": comparison.get("baseline_would_trade"),
1139
+ "learned_would_trade": comparison.get("learned_would_trade"),
1140
+ "avoided_loss": comparison.get("avoided_loss"),
1141
+ "missed_gain": comparison.get("missed_gain"),
1142
+ "improvement_reason": comparison.get("improvement_reason"),
1143
+ }
1144
+ summary = (
1145
+ f"{prediction.ticker} used {feedback.get('model_version_used', 'base-static')} with "
1146
+ f"confidence adjustment {safe_float(feedback.get('confidence_adjustment')):.2f}; "
1147
+ f"changed_decision={changed_decision}, improved={improved}. "
1148
+ f"reason={comparison.get('improvement_reason', 'counterfactual_not_available')}."
1149
+ )
1150
+ row = FeedbackLoopAudit(
1151
+ prediction_id=prediction.id,
1152
+ ticker=prediction.ticker,
1153
+ model_version_used=feedback.get("model_version_used") or "base-static",
1154
+ learned_knowledge_json=json_safe(learned),
1155
+ changes_applied_json=json_safe(changes),
1156
+ future_decision_json=json_safe(decision),
1157
+ outcome_json=json_safe(outcome_payload),
1158
+ improvement_detected=improved,
1159
+ evidence_grade=evidence_grade,
1160
+ summary=summary,
1161
+ )
1162
+ db.add(row)
1163
+ return {
1164
+ "status": "recorded",
1165
+ "prediction_id": prediction.id,
1166
+ "model_version_used": row.model_version_used,
1167
+ "what_was_learned": learned,
1168
+ "what_changed": changes,
1169
+ "future_decision_used_change": decision,
1170
+ "counterfactual_audit": counterfactual,
1171
+ "outcome": outcome_payload,
1172
+ "improvement_detected": improved,
1173
+ "evidence_grade": evidence_grade,
1174
+ "summary": summary,
1175
+ }
1176
+
1177
+ def counterfactual_audit(self, prediction: HistoricalPrediction, prediction_payload: dict, outcomes: list[dict]) -> dict:
1178
+ context = dict(prediction.point_in_time_context or {})
1179
+ context.pop("future_prices", None)
1180
+ baseline = PredictionEngine().baseline_prediction(context) if context else {}
1181
+ learned_prediction = prediction_payload.get("prediction") or {}
1182
+ baseline_actionability = baseline.get("actionability")
1183
+ learned_actionability = feedback_actionability(
1184
+ learned_prediction.get("dominant_direction"),
1185
+ learned_prediction.get("aggregate_confidence"),
1186
+ learned_prediction.get("aggregate_score"),
1187
+ )
1188
+ learned = {
1189
+ "model_version_used": (prediction_payload.get("feedback_loop") or {}).get("model_version_used") or "base-static",
1190
+ "weights_used": prediction_payload.get("weights_used") or {},
1191
+ "aggregate_score": learned_prediction.get("aggregate_score"),
1192
+ "aggregate_confidence": learned_prediction.get("aggregate_confidence"),
1193
+ "dominant_direction": learned_prediction.get("dominant_direction"),
1194
+ "actionability": learned_actionability,
1195
+ "confidence_adjustment": (prediction_payload.get("feedback_loop") or {}).get("confidence_adjustment", 0.0),
1196
+ "memory_adjustment_used": bool((prediction_payload.get("feedback_loop") or {}).get("confidence_adjustment")),
1197
+ }
1198
+ baseline_correct = direction_correctness_summary(baseline.get("dominant_direction"), outcomes)
1199
+ learned_correct = direction_correctness_summary(learned.get("dominant_direction"), outcomes)
1200
+ baseline_would_trade = feedback_would_trade(baseline_actionability)
1201
+ learned_would_trade = feedback_would_trade(learned_actionability)
1202
+ returns = [safe_float(row.get("realized_return")) for row in outcomes if row.get("realized_return") is not None]
1203
+ average_return = mean(returns) if returns else 0.0
1204
+ avoided_loss = round(abs(average_return), 4) if baseline_would_trade and not learned_would_trade and average_return < 0 else 0.0
1205
+ missed_gain = round(average_return, 4) if baseline_would_trade and not learned_would_trade and average_return > 0 else 0.0
1206
+ improvement_detected, improvement_reason = counterfactual_improvement_reason(
1207
+ baseline_correct,
1208
+ learned_correct,
1209
+ baseline_would_trade,
1210
+ learned_would_trade,
1211
+ average_return,
1212
+ avoided_loss,
1213
+ missed_gain,
1214
+ )
1215
+ comparison = {
1216
+ "score_delta": round(safe_float(learned.get("aggregate_score")) - safe_float(baseline.get("aggregate_score")), 4),
1217
+ "confidence_delta": round(safe_float(learned.get("aggregate_confidence")) - safe_float(baseline.get("aggregate_confidence")), 4),
1218
+ "direction_changed": baseline.get("dominant_direction") != learned.get("dominant_direction"),
1219
+ "actionability_changed": baseline_actionability != learned_actionability,
1220
+ }
1221
+ return {
1222
+ "baseline_prediction": baseline,
1223
+ "learned_prediction": learned,
1224
+ "differences": comparison,
1225
+ "outcome_comparison": {
1226
+ "outcome_labels": {row.get("timeframe"): row.get("outcome_label") for row in outcomes},
1227
+ "realized_returns": {row.get("timeframe"): row.get("realized_return") for row in outcomes},
1228
+ "learned_direction": learned.get("dominant_direction"),
1229
+ "baseline_direction": baseline.get("dominant_direction"),
1230
+ "baseline_direction_correct": baseline_correct["direction_correct"],
1231
+ "learned_direction_correct": learned_correct["direction_correct"],
1232
+ "baseline_correct_count": baseline_correct["correct_count"],
1233
+ "learned_correct_count": learned_correct["correct_count"],
1234
+ "baseline_actionability": baseline_actionability,
1235
+ "learned_actionability": learned_actionability,
1236
+ "baseline_would_trade": baseline_would_trade,
1237
+ "learned_would_trade": learned_would_trade,
1238
+ "avoided_loss": avoided_loss,
1239
+ "missed_gain": missed_gain,
1240
+ "improvement_detected": improvement_detected,
1241
+ "improvement_reason": improvement_reason,
1242
+ "policy": "Outcome is observed after prediction persistence; baseline is recomputed only from point-in-time context.",
1243
+ },
1244
+ }
1245
+
1246
+ def report(self, db: Session, limit: int = 20) -> dict:
1247
+ rows = db.scalars(select(FeedbackLoopAudit).order_by(desc(FeedbackLoopAudit.created_at)).limit(limit)).all()
1248
+ return {
1249
+ "status": "ready" if rows else "insufficient_evidence",
1250
+ "rows": [serialize_feedback_audit(row) for row in rows],
1251
+ "policy": "FeedbackLoopAudit is persisted by background learning runs and never computed by GET page render.",
1252
+ }
1253
+
1254
+
1255
  class LearningDashboardService:
1256
  def dashboard(self, db: Session) -> dict:
1257
  latest_run = db.scalar(select(LearningRun).order_by(desc(LearningRun.started_at)).limit(1))
 
1274
  "strategy_memory": self.strategy_memory(db),
1275
  "mistakes": self.mistake_summary(db),
1276
  "model_versions": [serialize_model_version(row) for row in db.scalars(select(ModelVersion).order_by(desc(ModelVersion.created_at)).limit(8)).all()],
1277
+ "feedback_loop_audit": FeedbackLoopAuditService().report(db, limit=8),
1278
  "trading_game": trading_game,
1279
  "policy": "BLUM Learning Loop optimizes calibration and robustness, not artificial 100% winrate.",
1280
  }
 
1711
  return db.scalar(select(ModelVersion).where(ModelVersion.is_active.is_(True)).order_by(desc(ModelVersion.created_at)).limit(1))
1712
 
1713
 
1714
+ def active_weight_context(db: Session | None) -> tuple[str, dict, str]:
1715
+ if db is not None:
1716
+ row = active_model_version(db)
1717
+ if row and isinstance(row.weights, dict) and row.weights:
1718
+ return row.version, model_weights_with_fallback(row.weights), "active_model_version"
1719
+ return "base-static", normalize_weights(BASE_SIGNAL_WEIGHTS), "base_signal_weights"
1720
+
1721
+
1722
+ def learning_mode_metadata(trigger: str | None, sample_metadata: dict | None = None) -> dict:
1723
+ sample_metadata = sample_metadata or {}
1724
+ sampling_reason = sample_metadata.get("sampling_reason") or "random_point_in_time"
1725
+ mode = "training_replay" if sampling_reason in {"alpha_loss_replay", "learning_focus_priority", "capital_preservation_replay"} or trigger == "alpha_loss_replay" else "walk_forward_validation"
1726
+ if trigger == "paper_forward" or sample_metadata.get("mode") == "paper_forward":
1727
+ mode = "paper_forward"
1728
+ return {
1729
+ "mode": mode,
1730
+ "training_replay": mode == "training_replay",
1731
+ "walk_forward_validation": mode == "walk_forward_validation",
1732
+ "paper_forward": mode == "paper_forward",
1733
+ "trigger": trigger or sample_metadata.get("run_trigger") or "unknown",
1734
+ "sampling_reason": sampling_reason,
1735
+ "evaluation_mode": sample_metadata.get("evaluation_mode") or settings.learning_evaluation_mode,
1736
+ "policy": "Mode metadata is descriptive. It changes audit traceability, not source code or frontend execution.",
1737
+ }
1738
+
1739
+
1740
+ def feedback_actionability(direction: str | None, confidence: float | None, score: float | None = None) -> str:
1741
+ confidence_value = safe_float(confidence)
1742
+ score_value = safe_float(score)
1743
+ if direction == "neutral" or confidence_value < 35:
1744
+ return "watch"
1745
+ if confidence_value >= 68 and direction in {"bullish", "bearish"} and abs(score_value - 50) >= 10:
1746
+ return "active_setup"
1747
+ if confidence_value >= 54 and direction in {"bullish", "bearish"}:
1748
+ return "wait_for_trigger"
1749
+ return "watch"
1750
+
1751
+
1752
+ def feedback_would_trade(actionability: str | None) -> bool:
1753
+ return actionability in {"active_setup", "wait_for_trigger", "actionable_if_confirmed"}
1754
+
1755
+
1756
+ def direction_correctness_summary(direction: str | None, outcomes: list[dict]) -> dict:
1757
+ rows = [row for row in outcomes if row.get("realized_return") is not None]
1758
+ if not rows:
1759
+ return {"direction_correct": None, "correct_count": 0, "wrong_count": 0, "sample_count": 0}
1760
+ correct = sum(1 for row in rows if direction_matches_return(direction, safe_float(row.get("realized_return"))))
1761
+ wrong = len(rows) - correct
1762
+ return {
1763
+ "direction_correct": correct > wrong,
1764
+ "correct_count": correct,
1765
+ "wrong_count": wrong,
1766
+ "sample_count": len(rows),
1767
+ }
1768
+
1769
+
1770
+ def direction_matches_return(direction: str | None, realized_return: float) -> bool:
1771
+ if direction == "bullish":
1772
+ return realized_return > 0
1773
+ if direction == "bearish":
1774
+ return realized_return < 0
1775
+ if direction == "neutral":
1776
+ return abs(realized_return) <= 1.0
1777
+ return False
1778
+
1779
+
1780
+ def counterfactual_improvement_reason(
1781
+ baseline_correct: dict,
1782
+ learned_correct: dict,
1783
+ baseline_would_trade: bool,
1784
+ learned_would_trade: bool,
1785
+ average_return: float,
1786
+ avoided_loss: float,
1787
+ missed_gain: float,
1788
+ ) -> tuple[bool, str]:
1789
+ baseline_count = int(baseline_correct.get("correct_count") or 0)
1790
+ learned_count = int(learned_correct.get("correct_count") or 0)
1791
+ if learned_count > baseline_count:
1792
+ return True, "learned_direction_more_correct_than_baseline"
1793
+ if learned_count < baseline_count:
1794
+ return False, "learned_direction_less_correct_than_baseline"
1795
+ if avoided_loss > 0:
1796
+ return True, "learned_actionability_avoided_baseline_loss"
1797
+ if missed_gain > 0:
1798
+ return False, "learned_actionability_missed_baseline_gain"
1799
+ if learned_would_trade and not baseline_would_trade and average_return > 0:
1800
+ return True, "learned_actionability_captured_gain_baseline_would_skip"
1801
+ if learned_would_trade and not baseline_would_trade and average_return < 0:
1802
+ return False, "learned_actionability_entered_losing_trade_baseline_would_skip"
1803
+ return False, "no_counterfactual_improvement_detected"
1804
+
1805
+
1806
+ def model_weights_with_fallback(weights: dict) -> dict:
1807
+ merged = dict(BASE_SIGNAL_WEIGHTS)
1808
+ for key in BASE_SIGNAL_WEIGHTS:
1809
+ if key in weights:
1810
+ merged[key] = safe_float(weights.get(key)) if weights.get(key) is not None else BASE_SIGNAL_WEIGHTS[key]
1811
+ return normalize_weights(merged)
1812
+
1813
+
1814
+ def signal_performance_context(db: Session | None, context: dict, signal_scores: dict) -> dict:
1815
+ if db is None or not signal_scores:
1816
+ return {"rows": [], "confidence_delta": 0.0}
1817
+ regime = context.get("market_context", {}).get("market_regime", "Unknown")
1818
+ rows = db.scalars(
1819
+ select(SignalPerformance)
1820
+ .where(SignalPerformance.signal_name.in_(list(signal_scores.keys())), SignalPerformance.market_regime == regime)
1821
+ .order_by(desc(SignalPerformance.sample_count), desc(SignalPerformance.updated_at))
1822
+ .limit(80)
1823
+ ).all()
1824
+ grouped: dict[str, list[SignalPerformance]] = defaultdict(list)
1825
+ for row in rows:
1826
+ grouped[row.signal_name].append(row)
1827
+ payloads = []
1828
+ deltas = []
1829
+ for signal_name, signal_rows in grouped.items():
1830
+ sample_count = sum(int(row.sample_count or 0) for row in signal_rows)
1831
+ reliability = mean(float(row.reliability_score or 50.0) for row in signal_rows)
1832
+ false_positives = sum(int(row.false_positive_count or 0) for row in signal_rows)
1833
+ false_positive_rate = false_positives / max(1, sample_count)
1834
+ enough_evidence = sample_count >= MIN_MODEL_VERSION_SIGNAL_SAMPLE
1835
+ delta = clamp((reliability - 50.0) / 8.0 - false_positive_rate * 4.0, -5.0, 5.0) if enough_evidence else 0.0
1836
+ payloads.append(
1837
+ {
1838
+ "signal_name": signal_name,
1839
+ "market_regime": regime,
1840
+ "sample_count": sample_count,
1841
+ "reliability_score": round(reliability, 2),
1842
+ "false_positive_rate": round(false_positive_rate, 4),
1843
+ "signal_score": signal_scores.get(signal_name),
1844
+ "confidence_delta": round(delta, 3),
1845
+ "used": enough_evidence,
1846
+ }
1847
+ )
1848
+ if enough_evidence:
1849
+ deltas.append(delta)
1850
+ total_delta = clamp(sum(deltas) / max(1, len(deltas)) * 1.4, -8.0, 8.0) if deltas else 0.0
1851
+ return {"rows": payloads[:16], "confidence_delta": round(total_delta, 3)}
1852
+
1853
+
1854
+ def strategy_memory_context(db: Session | None, context: dict) -> dict:
1855
+ if db is None:
1856
+ return {"rows": [], "confidence_delta": 0.0}
1857
+ rows = db.scalars(select(StrategyMemory).order_by(desc(StrategyMemory.reliability_score), desc(StrategyMemory.updated_at)).limit(120)).all()
1858
+ applicable = []
1859
+ deltas = []
1860
+ for row in rows:
1861
+ if not strategy_memory_matches(row, context):
1862
+ continue
1863
+ sample_count = int(row.sample_count or 0)
1864
+ enough_evidence = sample_count >= 3
1865
+ positive = int(row.positive_count or 0)
1866
+ negative = int(row.negative_count or 0)
1867
+ reliability = safe_float(row.reliability_score) if row.reliability_score is not None else 50.0
1868
+ delta = clamp((reliability - 50.0) / 10.0, -4.0, 4.0) if enough_evidence else 0.0
1869
+ if negative > positive and enough_evidence:
1870
+ delta = min(delta, -1.5)
1871
+ applicable.append(
1872
+ {
1873
+ "memory_key": row.memory_key,
1874
+ "category": row.category,
1875
+ "lesson": row.lesson,
1876
+ "sample_count": sample_count,
1877
+ "positive_count": positive,
1878
+ "negative_count": negative,
1879
+ "reliability_score": reliability,
1880
+ "confidence_delta": round(delta, 3),
1881
+ "used": enough_evidence,
1882
+ }
1883
+ )
1884
+ if enough_evidence:
1885
+ deltas.append(delta)
1886
+ total_delta = clamp(sum(deltas), -6.0, 6.0) if deltas else 0.0
1887
+ return {"rows": applicable[:12], "confidence_delta": round(total_delta, 3)}
1888
+
1889
+
1890
+ def strategy_memory_matches(row: StrategyMemory, context: dict) -> bool:
1891
+ conditions = row.conditions or {}
1892
+ technical = context.get("technical") or {}
1893
+ indicators = technical.get("technical_indicators") or {}
1894
+ volume = technical.get("volume") or {}
1895
+ fundamentals = context.get("fundamentals") or {}
1896
+ if "rsi_gt" in conditions and safe_float(indicators.get("rsi")) > safe_float(conditions.get("rsi_gt")):
1897
+ return True
1898
+ if "relative_volume_gt" in conditions and safe_float(volume.get("relative_volume")) > safe_float(conditions.get("relative_volume_gt")):
1899
+ return True
1900
+ if conditions.get("fundamentals") == "not_point_in_time_verified" and fundamentals.get("status") != "ready":
1901
+ return True
1902
+ if row.category in {"general_calibration", "volume_confirmation"}:
1903
+ return True
1904
+ return False
1905
+
1906
+
1907
+ def research_priority_context(db: Session | None, sample_metadata: dict) -> dict:
1908
+ priority_id = sample_metadata.get("learning_focus_priority_id")
1909
+ if db is not None and priority_id:
1910
+ row = db.get(LearningFocusPriority, priority_id)
1911
+ if row:
1912
+ return {
1913
+ "status": "used",
1914
+ "id": row.id,
1915
+ "priority_type": row.priority_type,
1916
+ "target": row.target,
1917
+ "reason": row.reason,
1918
+ "expected_learning_value": row.expected_learning_value,
1919
+ "urgency": row.urgency,
1920
+ "sample_gap": row.sample_gap,
1921
+ "sampling_reason": sample_metadata.get("sampling_reason"),
1922
+ "confidence_delta": 0.0,
1923
+ }
1924
+ if sample_metadata.get("sampling_reason"):
1925
+ return {
1926
+ "status": "used",
1927
+ "sampling_reason": sample_metadata.get("sampling_reason"),
1928
+ "priority_type": sample_metadata.get("priority_type"),
1929
+ "missed_winner_id": sample_metadata.get("missed_winner_id"),
1930
+ "confidence_delta": 0.0,
1931
+ }
1932
+ return {"status": "not_applicable", "confidence_delta": 0.0}
1933
+
1934
+
1935
  def normalize_weights(weights: dict[str, float]) -> dict[str, float]:
1936
  total = sum(max(0.0, float(value)) for value in weights.values()) or 1.0
1937
  return {key: round(max(0.0, float(value)) / total, 4) for key, value in weights.items()}
 
2002
  "market_regime": row.market_regime,
2003
  "volatility_regime": row.volatility_regime,
2004
  "data_quality_score": row.data_quality_score,
2005
+ "model_version_used": row.model_version_used,
2006
+ "weights_used": row.weights_used,
2007
+ "learning_memory_used": row.learning_memory_used,
2008
+ "strategy_memory_used": row.strategy_memory_used,
2009
+ "research_priority_used": row.research_priority_used,
2010
  "prediction": row.prediction_payload.get("prediction", {}) if row.prediction_payload else {},
2011
  "timeframes": row.prediction_payload.get("timeframes", {}) if row.prediction_payload else {},
2012
  "created_at": iso(row.created_at),
 
2058
  }
2059
 
2060
 
2061
+ def serialize_feedback_audit(row: FeedbackLoopAudit) -> dict:
2062
+ return {
2063
+ "id": row.id,
2064
+ "prediction_id": row.prediction_id,
2065
+ "ticker": row.ticker,
2066
+ "model_version_used": row.model_version_used,
2067
+ "what_was_learned": row.learned_knowledge_json,
2068
+ "what_changed": row.changes_applied_json,
2069
+ "counterfactual_audit": (row.changes_applied_json or {}).get("counterfactual_audit"),
2070
+ "future_decision_used_change": row.future_decision_json,
2071
+ "outcome": row.outcome_json,
2072
+ "improvement_detected": row.improvement_detected,
2073
+ "evidence_grade": row.evidence_grade,
2074
+ "summary": row.summary,
2075
+ "created_at": iso(row.created_at),
2076
+ }
2077
+
2078
+
2079
  def fundamental_reason(fundamentals: dict) -> str:
2080
  if fundamentals.get("status") == "ready":
2081
  return f"Point-in-time verified fundamentals available with quality score {fundamentals.get('quality_score')}."
 
2162
  return max(low, min(high, float(value)))
2163
 
2164
 
2165
+ def clamp_ratio(value: float) -> float:
2166
+ return max(0.0, min(1.0, safe_float(value)))
2167
+
2168
+
2169
  def round_float(value) -> float | None:
2170
  return round(safe_float(value), 4) if value is not None else None
2171
 
backend/app/services/learning_summary.py CHANGED
@@ -3,16 +3,23 @@ from __future__ import annotations
3
  from datetime import datetime
4
  import time
5
 
6
- from sqlalchemy import desc, select
7
  from sqlalchemy.orm import Session
8
 
9
  from app.models import (
10
  BlumTradingPowerScore,
11
  DashboardSnapshot,
 
12
  LearningBenchmarkComparison,
 
 
13
  LearningRun,
 
 
14
  TradingCapitalCycle,
15
  TradingGame,
 
 
16
  )
17
  from app.services.dashboard_snapshots import DashboardSnapshotService
18
  from app.services.performance import performance_recorder
@@ -30,6 +37,21 @@ class LearningSummaryService:
30
  started = time.perf_counter()
31
  missing_sections: list[str] = []
32
  warnings: list[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  power = db.scalar(select(BlumTradingPowerScore).order_by(desc(BlumTradingPowerScore.calculated_at)).limit(1))
35
  if power is None:
@@ -54,35 +76,63 @@ class LearningSummaryService:
54
  if latest_run is None:
55
  missing_sections.append("latest_learning_run")
56
 
 
 
 
 
 
 
 
57
  benchmarks = list(db.scalars(select(LearningBenchmarkComparison).order_by(desc(LearningBenchmarkComparison.calculated_at)).limit(24)).all())
58
  benchmark_summary = summarize_benchmarks(benchmarks)
59
  if not benchmarks:
60
  missing_sections.append("benchmark_summary")
61
 
62
  live_snapshot = DashboardSnapshotService().latest(db, "live_vs_historical_summary")
 
63
  if live_snapshot["status"] == "missing":
64
  warnings.append("Live vs historical summary snapshot is missing.")
 
 
65
 
66
  current_capital = getattr(current_cycle, "final_capital", None) if current_cycle else getattr(game, "current_capital", None)
67
  target_capital = getattr(current_cycle, "target_capital", None) if current_cycle else getattr(game, "target_capital", None)
68
  target_progress = safe_progress(current_capital, target_capital)
 
69
  truth_panel_lines = build_truth_panel(power, benchmark_summary, warnings, benchmarks)
 
 
 
 
 
 
70
  payload = {
71
  "status": "initializing" if missing_sections else "ready",
72
  "generated_at": datetime.utcnow().isoformat(),
73
  "summary_duration_ms": round((time.perf_counter() - started) * 1000, 3),
 
74
  "trading_power_score": power.score if power else None,
75
  "trading_power_classification": power.classification if power else "initializing",
 
76
  "current_capital": current_capital,
77
  "target_capital": target_capital,
78
  "target_progress": target_progress,
 
 
79
  "completed_target_cycles": getattr(game, "target_cycles_completed", 0) if game else 0,
80
  "bankrupt_cycles": getattr(game, "bankrupt_cycles", 0) if game else 0,
81
  "latest_learning_run_status": getattr(latest_run, "status", None) or "not_started",
82
  "latest_learning_run_at": latest_run.started_at.isoformat() if latest_run and latest_run.started_at else None,
83
  "benchmark_summary": benchmark_summary,
84
  "live_vs_historical_summary": live_snapshot,
 
 
 
85
  "truth_panel": truth_panel_lines,
 
 
 
 
86
  "warnings": warnings,
87
  "missing_sections": missing_sections,
88
  "data_freshness": {
@@ -90,13 +140,15 @@ class LearningSummaryService:
90
  "game_updated_at": game.updated_at.isoformat() if game and game.updated_at else None,
91
  "learning_run_started_at": latest_run.started_at.isoformat() if latest_run and latest_run.started_at else None,
92
  "benchmark_calculated_at": max((row.calculated_at for row in benchmarks if row.calculated_at), default=None).isoformat() if benchmarks else None,
 
 
93
  },
94
- "is_recalculation_running": False,
95
  "suggested_next_step": "Use background workers or explicit recalculation buttons to refresh missing summaries." if missing_sections else "Summary is ready.",
96
  "performance": {
97
  "budget_ms": 300,
98
  "duration_ms": round((time.perf_counter() - started) * 1000, 3),
99
- "source": "latest_precomputed_rows_only",
100
  },
101
  }
102
  performance_recorder.record_dashboard_widget(
@@ -150,6 +202,160 @@ def build_truth_panel(power: BlumTradingPowerScore | None, benchmark_summary: di
150
  return output[:7]
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  def safe_progress(capital: float | None, target: float | None) -> float | None:
154
  if capital is None or target is None or target <= 0:
155
  return None
@@ -158,3 +364,98 @@ def safe_progress(capital: float | None, target: float | None) -> float | None:
158
 
159
  def format_number(value: float | None) -> str:
160
  return "n/a" if value is None else f"{value:.2f}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from datetime import datetime
4
  import time
5
 
6
+ from sqlalchemy import desc, inspect, select
7
  from sqlalchemy.orm import Session
8
 
9
  from app.models import (
10
  BlumTradingPowerScore,
11
  DashboardSnapshot,
12
+ CapitalPreservationAlpha,
13
  LearningBenchmarkComparison,
14
+ LearningFactorImportance,
15
+ LearningFocusPriority,
16
  LearningRun,
17
+ LearningStrengthWeaknessMap,
18
+ ReasoningNoiseFlag,
19
  TradingCapitalCycle,
20
  TradingGame,
21
+ TradingIntelligenceMetric,
22
+ TradeLearningEvidence,
23
  )
24
  from app.services.dashboard_snapshots import DashboardSnapshotService
25
  from app.services.performance import performance_recorder
 
37
  started = time.perf_counter()
38
  missing_sections: list[str] = []
39
  warnings: list[str] = []
40
+ snapshot_types = [
41
+ "learning_summary",
42
+ "trading_game_summary",
43
+ "benchmark_summary",
44
+ "intelligence_growth_summary",
45
+ "truth_panel_summary",
46
+ "meta_cognition_summary",
47
+ ]
48
+ snapshots = {snapshot_type: DashboardSnapshotService().latest(db, snapshot_type) for snapshot_type in snapshot_types}
49
+ stale_snapshots = [snapshot_type for snapshot_type, payload in snapshots.items() if payload.get("status") == "stale"]
50
+ missing_snapshots = [snapshot_type for snapshot_type, payload in snapshots.items() if payload.get("status") == "missing"]
51
+ if stale_snapshots:
52
+ warnings.append(f"Using stale dashboard snapshots for: {', '.join(stale_snapshots)}.")
53
+ if missing_snapshots:
54
+ warnings.append(f"Dashboard snapshots not available yet for: {', '.join(missing_snapshots)}.")
55
 
56
  power = db.scalar(select(BlumTradingPowerScore).order_by(desc(BlumTradingPowerScore.calculated_at)).limit(1))
57
  if power is None:
 
76
  if latest_run is None:
77
  missing_sections.append("latest_learning_run")
78
 
79
+ latest_metric = db.scalar(select(TradingIntelligenceMetric).order_by(desc(TradingIntelligenceMetric.calculated_at)).limit(1))
80
+ if latest_metric is None:
81
+ missing_sections.append("trading_intelligence_metrics")
82
+
83
+ top_weakness = db.scalar(select(LearningStrengthWeaknessMap).order_by(desc(LearningStrengthWeaknessMap.weakness_score), desc(LearningStrengthWeaknessMap.calculated_at)).limit(1))
84
+ latest_lesson = db.scalar(select(TradeLearningEvidence).order_by(desc(TradeLearningEvidence.created_at)).limit(1))
85
+
86
  benchmarks = list(db.scalars(select(LearningBenchmarkComparison).order_by(desc(LearningBenchmarkComparison.calculated_at)).limit(24)).all())
87
  benchmark_summary = summarize_benchmarks(benchmarks)
88
  if not benchmarks:
89
  missing_sections.append("benchmark_summary")
90
 
91
  live_snapshot = DashboardSnapshotService().latest(db, "live_vs_historical_summary")
92
+ meta_snapshot = snapshots.get("meta_cognition_summary") or {}
93
  if live_snapshot["status"] == "missing":
94
  warnings.append("Live vs historical summary snapshot is missing.")
95
+ if meta_snapshot.get("status") == "missing":
96
+ warnings.append("Meta-Cognition summary snapshot is missing.")
97
 
98
  current_capital = getattr(current_cycle, "final_capital", None) if current_cycle else getattr(game, "current_capital", None)
99
  target_capital = getattr(current_cycle, "target_capital", None) if current_cycle else getattr(game, "target_capital", None)
100
  target_progress = safe_progress(current_capital, target_capital)
101
+ current_cycle_payload = serialize_cycle(current_cycle)
102
  truth_panel_lines = build_truth_panel(power, benchmark_summary, warnings, benchmarks)
103
+ truth_snapshot = snapshots.get("truth_panel_summary", {})
104
+ truth_snapshot_payload = truth_snapshot.get("payload") or {}
105
+ if truth_snapshot_payload.get("truth_panel"):
106
+ truth_panel_lines = truth_snapshot_payload["truth_panel"]
107
+ last_snapshot_timestamp = latest_snapshot_timestamp(snapshots)
108
+ backend_training_status = training_status(latest_run, snapshots)
109
  payload = {
110
  "status": "initializing" if missing_sections else "ready",
111
  "generated_at": datetime.utcnow().isoformat(),
112
  "summary_duration_ms": round((time.perf_counter() - started) * 1000, 3),
113
+ "learning_loop_status": getattr(latest_run, "status", None) or "not_started",
114
  "trading_power_score": power.score if power else None,
115
  "trading_power_classification": power.classification if power else "initializing",
116
+ "current_capital_cycle": current_cycle_payload,
117
  "current_capital": current_capital,
118
  "target_capital": target_capital,
119
  "target_progress": target_progress,
120
+ "win_rate": first_not_none(getattr(latest_metric, "win_rate", None), getattr(game, "win_rate", None)),
121
+ "expectancy_r": first_not_none(getattr(latest_metric, "expectancy_r", None), getattr(current_cycle, "expectancy_r", None), getattr(game, "expectancy_r", None)),
122
  "completed_target_cycles": getattr(game, "target_cycles_completed", 0) if game else 0,
123
  "bankrupt_cycles": getattr(game, "bankrupt_cycles", 0) if game else 0,
124
  "latest_learning_run_status": getattr(latest_run, "status", None) or "not_started",
125
  "latest_learning_run_at": latest_run.started_at.isoformat() if latest_run and latest_run.started_at else None,
126
  "benchmark_summary": benchmark_summary,
127
  "live_vs_historical_summary": live_snapshot,
128
+ "live_vs_historical_status": live_snapshot.get("status", "missing"),
129
+ "top_weakness": serialize_weakness(top_weakness),
130
+ "latest_lesson_learned": serialize_lesson(latest_lesson),
131
  "truth_panel": truth_panel_lines,
132
+ "what_blum_should_learn_next": what_blum_should_learn_next(db, meta_snapshot),
133
+ "backend_training_status": backend_training_status,
134
+ "last_snapshot_timestamp": last_snapshot_timestamp,
135
+ "snapshots": summarize_snapshots(snapshots),
136
  "warnings": warnings,
137
  "missing_sections": missing_sections,
138
  "data_freshness": {
 
140
  "game_updated_at": game.updated_at.isoformat() if game and game.updated_at else None,
141
  "learning_run_started_at": latest_run.started_at.isoformat() if latest_run and latest_run.started_at else None,
142
  "benchmark_calculated_at": max((row.calculated_at for row in benchmarks if row.calculated_at), default=None).isoformat() if benchmarks else None,
143
+ "trading_intelligence_calculated_at": latest_metric.calculated_at.isoformat() if latest_metric and latest_metric.calculated_at else None,
144
+ "last_snapshot_timestamp": last_snapshot_timestamp,
145
  },
146
+ "is_recalculation_running": backend_training_status["is_recalculation_running"],
147
  "suggested_next_step": "Use background workers or explicit recalculation buttons to refresh missing summaries." if missing_sections else "Summary is ready.",
148
  "performance": {
149
  "budget_ms": 300,
150
  "duration_ms": round((time.perf_counter() - started) * 1000, 3),
151
+ "source": "snapshots_first_latest_precomputed_rows_only",
152
  },
153
  }
154
  performance_recorder.record_dashboard_widget(
 
202
  return output[:7]
203
 
204
 
205
+ def what_blum_should_learn_next(db: Session, meta_snapshot: dict) -> dict:
206
+ snapshot_payload = meta_snapshot.get("payload") or {}
207
+ if snapshot_payload:
208
+ return {
209
+ "status": meta_snapshot.get("status", "ready"),
210
+ "top_alpha_creating_factor": compact_factor(snapshot_payload.get("top_alpha_factor")),
211
+ "top_alpha_destroying_factor": compact_factor(snapshot_payload.get("top_alpha_destroyer")),
212
+ "noisiest_factor": compact_factor(snapshot_payload.get("noisiest_factor")),
213
+ "most_undervalued_factor": compact_factor(snapshot_payload.get("most_undervalued_factor")),
214
+ "strongest_capital_preservation_rule": compact_preservation(snapshot_payload.get("strongest_capital_preservation_rule")),
215
+ "weakest_current_module": compact_noise(snapshot_payload.get("weakest_current_module")),
216
+ "next_learning_focus": compact_focus(snapshot_payload.get("next_learning_focus")),
217
+ "conclusion": snapshot_payload.get("meta_cognition_conclusion", {}).get("summary") or "Meta-Cognition snapshot available.",
218
+ "source": "snapshot",
219
+ }
220
+ if not all(table_exists(db, model.__tablename__) for model in [LearningFactorImportance, LearningFocusPriority, ReasoningNoiseFlag, CapitalPreservationAlpha]):
221
+ return {
222
+ "status": "missing",
223
+ "top_alpha_creating_factor": None,
224
+ "top_alpha_destroying_factor": None,
225
+ "noisiest_factor": None,
226
+ "most_undervalued_factor": None,
227
+ "strongest_capital_preservation_rule": None,
228
+ "weakest_current_module": None,
229
+ "next_learning_focus": None,
230
+ "conclusion": "Meta-Cognition tables are not available yet; showing the rest of the Learning snapshot without recalculation.",
231
+ "source": "schema_not_ready",
232
+ }
233
+ factor_rows = db.scalars(select(LearningFactorImportance).order_by(desc(LearningFactorImportance.calculated_at)).limit(40)).all()
234
+ focus = db.scalar(
235
+ select(LearningFocusPriority)
236
+ .where(LearningFocusPriority.status.in_(["proposed", "active"]))
237
+ .order_by(desc(LearningFocusPriority.expected_learning_value), desc(LearningFocusPriority.created_at))
238
+ .limit(1)
239
+ )
240
+ noise = db.scalar(select(ReasoningNoiseFlag).where(ReasoningNoiseFlag.status == "open").order_by(desc(ReasoningNoiseFlag.created_at)).limit(1))
241
+ preservation = db.scalar(select(CapitalPreservationAlpha).order_by(desc(CapitalPreservationAlpha.capital_preserved), desc(CapitalPreservationAlpha.created_at)).limit(1))
242
+ top_alpha = max(factor_rows, key=lambda row: row.alpha_contribution, default=None)
243
+ top_loss = max(factor_rows, key=lambda row: row.alpha_loss_contribution, default=None)
244
+ noisiest = max(factor_rows, key=lambda row: row.noise_score, default=None)
245
+ undervalued = max(factor_rows, key=lambda row: row.undervaluation_score, default=None)
246
+ return {
247
+ "status": "ready" if factor_rows or focus or noise or preservation else "missing",
248
+ "top_alpha_creating_factor": compact_factor_obj(top_alpha),
249
+ "top_alpha_destroying_factor": compact_factor_obj(top_loss),
250
+ "noisiest_factor": compact_factor_obj(noisiest),
251
+ "most_undervalued_factor": compact_factor_obj(undervalued),
252
+ "strongest_capital_preservation_rule": compact_preservation_obj(preservation),
253
+ "weakest_current_module": compact_noise_obj(noise),
254
+ "next_learning_focus": compact_focus_obj(focus),
255
+ "conclusion": "Meta-Cognition snapshot missing; showing latest stored evidence only." if factor_rows else "Meta-Cognition evidence is not available yet.",
256
+ "source": "latest_rows_no_recalculation",
257
+ }
258
+
259
+
260
+ def table_exists(db: Session, table_name: str) -> bool:
261
+ try:
262
+ return inspect(db.get_bind()).has_table(table_name)
263
+ except Exception:
264
+ return False
265
+
266
+
267
+ def compact_factor(item: dict | None) -> dict | None:
268
+ if not item:
269
+ return None
270
+ return {
271
+ "factor_name": item.get("factor_name"),
272
+ "sample_size": item.get("sample_size"),
273
+ "confidence": item.get("confidence"),
274
+ "recommended_weight_action": item.get("recommended_weight_action"),
275
+ "alpha_contribution": item.get("alpha_contribution"),
276
+ "alpha_loss_contribution": item.get("alpha_loss_contribution"),
277
+ "noise_score": item.get("noise_score"),
278
+ }
279
+
280
+
281
+ def compact_factor_obj(row: LearningFactorImportance | None) -> dict | None:
282
+ return compact_factor({
283
+ "factor_name": row.factor_name,
284
+ "sample_size": row.sample_size,
285
+ "confidence": row.confidence,
286
+ "recommended_weight_action": row.recommended_weight_action,
287
+ "alpha_contribution": row.alpha_contribution,
288
+ "alpha_loss_contribution": row.alpha_loss_contribution,
289
+ "noise_score": row.noise_score,
290
+ }) if row else None
291
+
292
+
293
+ def compact_focus(item: dict | None) -> dict | None:
294
+ if not item:
295
+ return None
296
+ return {
297
+ "priority_type": item.get("priority_type"),
298
+ "target": item.get("target"),
299
+ "expected_learning_value": item.get("expected_learning_value"),
300
+ "urgency": item.get("urgency"),
301
+ "reason": item.get("reason"),
302
+ }
303
+
304
+
305
+ def compact_focus_obj(row: LearningFocusPriority | None) -> dict | None:
306
+ return compact_focus({
307
+ "priority_type": row.priority_type,
308
+ "target": row.target,
309
+ "expected_learning_value": row.expected_learning_value,
310
+ "urgency": row.urgency,
311
+ "reason": row.reason,
312
+ }) if row else None
313
+
314
+
315
+ def compact_preservation(item: dict | None) -> dict | None:
316
+ if not item:
317
+ return None
318
+ return {
319
+ "ticker": item.get("ticker"),
320
+ "setup_type": item.get("setup_type"),
321
+ "capital_preserved": item.get("capital_preserved"),
322
+ "opportunity_cost": item.get("opportunity_cost"),
323
+ "quality_score": item.get("quality_score"),
324
+ }
325
+
326
+
327
+ def compact_preservation_obj(row: CapitalPreservationAlpha | None) -> dict | None:
328
+ return compact_preservation({
329
+ "ticker": row.ticker,
330
+ "setup_type": row.setup_type,
331
+ "capital_preserved": row.capital_preserved,
332
+ "opportunity_cost": row.opportunity_cost,
333
+ "quality_score": row.quality_score,
334
+ }) if row else None
335
+
336
+
337
+ def compact_noise(item: dict | None) -> dict | None:
338
+ if not item:
339
+ return None
340
+ return {
341
+ "factor_name": item.get("factor_name"),
342
+ "module_name": item.get("module_name"),
343
+ "noise_type": item.get("noise_type"),
344
+ "severity": item.get("severity"),
345
+ "recommended_action": item.get("recommended_action"),
346
+ }
347
+
348
+
349
+ def compact_noise_obj(row: ReasoningNoiseFlag | None) -> dict | None:
350
+ return compact_noise({
351
+ "factor_name": row.factor_name,
352
+ "module_name": row.module_name,
353
+ "noise_type": row.noise_type,
354
+ "severity": row.severity,
355
+ "recommended_action": row.recommended_action,
356
+ }) if row else None
357
+
358
+
359
  def safe_progress(capital: float | None, target: float | None) -> float | None:
360
  if capital is None or target is None or target <= 0:
361
  return None
 
364
 
365
  def format_number(value: float | None) -> str:
366
  return "n/a" if value is None else f"{value:.2f}"
367
+
368
+
369
+ def first_not_none(*values):
370
+ for value in values:
371
+ if value is not None:
372
+ return value
373
+ return None
374
+
375
+
376
+ def serialize_cycle(row: TradingCapitalCycle | None) -> dict | None:
377
+ if row is None:
378
+ return None
379
+ return {
380
+ "id": row.id,
381
+ "cycle_number": row.cycle_number,
382
+ "status": row.status,
383
+ "started_at": row.started_at.isoformat() if row.started_at else None,
384
+ "ended_at": row.ended_at.isoformat() if row.ended_at else None,
385
+ "start_capital": row.start_capital,
386
+ "target_capital": row.target_capital,
387
+ "final_capital": row.final_capital,
388
+ "trades_count": row.trades_count,
389
+ "wins": row.wins,
390
+ "losses": row.losses,
391
+ "missed_entries": row.missed_entries,
392
+ "target_hits": row.target_hits,
393
+ "stop_hits": row.stop_hits,
394
+ "expectancy_r": row.expectancy_r,
395
+ "excess_return_vs_benchmark": row.excess_return_vs_benchmark,
396
+ "updated_at": row.updated_at.isoformat() if row.updated_at else None,
397
+ }
398
+
399
+
400
+ def serialize_weakness(row: LearningStrengthWeaknessMap | None) -> dict | None:
401
+ if row is None:
402
+ return None
403
+ return {
404
+ "dimension": row.dimension,
405
+ "entity": row.entity,
406
+ "weakness_score": row.weakness_score,
407
+ "strength_score": row.strength_score,
408
+ "sample_size": row.sample_size,
409
+ "main_problem": row.main_problem,
410
+ "recommended_action": row.recommended_action,
411
+ "priority": row.priority,
412
+ "calculated_at": row.calculated_at.isoformat() if row.calculated_at else None,
413
+ }
414
+
415
+
416
+ def serialize_lesson(row: TradeLearningEvidence | None) -> dict | None:
417
+ if row is None:
418
+ return None
419
+ return {
420
+ "ticker": row.ticker,
421
+ "setup_type": row.setup_type,
422
+ "lesson_type": row.lesson_type,
423
+ "observation": row.observation,
424
+ "sample_size": row.sample_size,
425
+ "affected_module": row.affected_module,
426
+ "confidence": row.confidence,
427
+ "created_at": row.created_at.isoformat() if row.created_at else None,
428
+ }
429
+
430
+
431
+ def summarize_snapshots(snapshots: dict[str, dict]) -> dict:
432
+ output = {}
433
+ for snapshot_type, payload in snapshots.items():
434
+ output[snapshot_type] = {
435
+ "status": payload.get("status"),
436
+ "created_at": payload.get("created_at"),
437
+ "expires_at": payload.get("expires_at"),
438
+ "is_stale": payload.get("is_stale"),
439
+ "warnings": payload.get("warnings") or ([payload.get("warning")] if payload.get("warning") else []),
440
+ }
441
+ return output
442
+
443
+
444
+ def latest_snapshot_timestamp(snapshots: dict[str, dict]) -> str | None:
445
+ timestamps = [payload.get("created_at") for payload in snapshots.values() if payload.get("created_at")]
446
+ return max(timestamps) if timestamps else None
447
+
448
+
449
+ def training_status(latest_run: LearningRun | None, snapshots: dict[str, dict]) -> dict:
450
+ running_statuses = {"running", "queued", "processing", "in_progress"}
451
+ status = getattr(latest_run, "status", None) or "not_started"
452
+ running = status in running_statuses
453
+ stale_count = sum(1 for payload in snapshots.values() if payload.get("status") == "stale")
454
+ return {
455
+ "mode": "backend_scheduler_independent",
456
+ "status": "running" if running else status,
457
+ "is_recalculation_running": running,
458
+ "frontend_policy": "read_only_snapshot_observer",
459
+ "stale_snapshot_count": stale_count,
460
+ "latest_run_id": getattr(latest_run, "run_id", None),
461
+ }
backend/app/services/performance.py CHANGED
@@ -424,7 +424,12 @@ def top_bottlenecks(api_events: list[dict[str, Any]], db_events: list[dict[str,
424
 
425
 
426
  def learning_page_load_summary(api_events: list[dict[str, Any]], widget_events: list[dict[str, Any]], frontend_widget_events: list[dict[str, Any]]) -> dict[str, Any]:
427
- frontend_api_events = [event for event in frontend_widget_events if str(event.get("name", "")).startswith("frontend.api.")]
 
 
 
 
 
428
  status_counts: dict[str, int] = defaultdict(int)
429
  for event in frontend_api_events:
430
  status_counts[str((event.get("metadata") or {}).get("status") or "unknown")] += 1
@@ -450,6 +455,7 @@ def learning_page_load_summary(api_events: list[dict[str, Any]], widget_events:
450
  ]
451
  return {
452
  "frontend_request_count": len(frontend_api_events),
 
453
  "duplicate_request_count": status_counts.get("deduped", 0),
454
  "cache_hit_count": status_counts.get("cache_hit", 0) + status_counts.get("cache", 0),
455
  "status_counts": dict(status_counts),
 
424
 
425
 
426
  def learning_page_load_summary(api_events: list[dict[str, Any]], widget_events: list[dict[str, Any]], frontend_widget_events: list[dict[str, Any]]) -> dict[str, Any]:
427
+ all_frontend_api_events = [event for event in frontend_widget_events if str(event.get("name", "")).startswith("frontend.api.")]
428
+ frontend_api_events = [
429
+ event for event in all_frontend_api_events
430
+ if str((event.get("metadata") or {}).get("route") or "").startswith("/learning")
431
+ and bool((event.get("metadata") or {}).get("initial_learning_window"))
432
+ ]
433
  status_counts: dict[str, int] = defaultdict(int)
434
  for event in frontend_api_events:
435
  status_counts[str((event.get("metadata") or {}).get("status") or "unknown")] += 1
 
455
  ]
456
  return {
457
  "frontend_request_count": len(frontend_api_events),
458
+ "unscoped_frontend_request_count": len(all_frontend_api_events),
459
  "duplicate_request_count": status_counts.get("deduped", 0),
460
  "cache_hit_count": status_counts.get("cache_hit", 0) + status_counts.get("cache", 0),
461
  "status_counts": dict(status_counts),
backend/app/services/trade_transparency.py CHANGED
@@ -51,41 +51,79 @@ class TradeLedgerService:
51
  limit: int = 200,
52
  offset: int = 0,
53
  refresh: bool = True,
 
 
54
  ) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  game = self.game(db, game_id)
56
  if not game:
57
  return {"status": "no_game", "rows": [], "summary": {}, "policy": TRANSPARENCY_POLICY}
 
 
 
58
  if refresh:
59
- self.refresh_game_transparency(db, game, commit=False, persist_reality=False)
60
- query = select(TradingGameTrade).where(TradingGameTrade.game_id == game.id)
61
- if ticker:
62
- query = query.where(TradingGameTrade.ticker == ticker.upper())
63
- if setup_type:
64
- query = query.where(TradingGameTrade.setup_type == setup_type)
65
- if outcome_label:
66
- query = query.where(TradingGameTrade.outcome_label == outcome_label)
67
- parsed_start = parse_date(start_date)
68
- parsed_end = parse_date(end_date)
69
- if parsed_start:
70
- query = query.where(TradingGameTrade.entry_date >= parsed_start)
71
- if parsed_end:
72
- query = query.where(TradingGameTrade.entry_date <= parsed_end)
73
- if min_r is not None:
74
- query = query.where(TradingGameTrade.realized_r_multiple >= min_r)
75
- if max_r is not None:
76
- query = query.where(TradingGameTrade.realized_r_multiple <= max_r)
77
- if only_open:
78
- query = query.where(TradingGameTrade.exit_date.is_(None))
79
- if only_closed:
80
- query = query.where(TradingGameTrade.exit_date.is_not(None))
81
- total = int(db.scalar(select(func.count()).select_from(query.subquery())) or 0)
82
- query = order_trade_query(query, sort_by).limit(limit).offset(offset)
83
- rows = db.scalars(query).all()
84
- return {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  "status": "ok",
 
86
  "game": serialize_game_header(game),
87
- "summary": self.summary_for_game(db, game),
88
- "rows": [self.serialize_trade(db, row) for row in rows],
89
  "total": total,
90
  "limit": limit,
91
  "offset": offset,
@@ -104,6 +142,27 @@ class TradeLedgerService:
104
  },
105
  "policy": TRANSPARENCY_POLICY,
106
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  def detail(self, db: Session, trade_id: int) -> dict:
109
  trade = db.get(TradingGameTrade, trade_id)
@@ -233,6 +292,33 @@ class TradeLedgerService:
233
  return db.get(TradingGame, game_id)
234
  return db.scalar(select(TradingGame).where(TradingGame.status == "active").order_by(desc(TradingGame.started_at)).limit(1)) or db.scalar(select(TradingGame).order_by(desc(TradingGame.started_at)).limit(1))
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  def serialize_trade(self, db: Session, trade: TradingGameTrade) -> dict:
237
  return {
238
  "trade_id": trade.id,
@@ -430,22 +516,61 @@ class TradeQualityEvaluator:
430
 
431
 
432
  class EquityCurveAnnotationService:
433
- def annotated_equity(self, db: Session, game_id: int | None = None, limit: int = 800) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  game = TradeLedgerService().game(db, game_id)
435
  if not game:
436
  return {"status": "no_game", "equity_curve_points": [], "benchmark_curve_points": [], "annotations": []}
437
- trades = db.scalars(select(TradingGameTrade).where(TradingGameTrade.game_id == game.id).order_by(TradingGameTrade.created_at)).all()
438
- self.refresh(db, game, trades)
439
- points = db.scalars(select(TradingGameEquityCurve).where(TradingGameEquityCurve.game_id == game.id).order_by(TradingGameEquityCurve.created_at).limit(limit)).all()
440
- annotations = db.scalars(select(EquityCurveAnnotation).where(EquityCurveAnnotation.game_id == game.id).order_by(EquityCurveAnnotation.timestamp).limit(limit)).all()
441
- return {
442
- "status": "ok",
443
- "game": serialize_game_header(game),
444
- "equity_curve_points": [serialize_equity_point(row) for row in points],
445
- "benchmark_curve_points": [{"timestamp": iso(row.equity_date or row.created_at), "value": row.benchmark_equity, "return": row.benchmark_return} for row in points],
446
- "annotations": [serialize_annotation(row) for row in annotations],
447
- "policy": "Markers connect equity movement to trade entries, exits, drawdowns, rule events and benchmark divergence when those events exist.",
448
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
 
450
  def refresh(self, db: Session, game: TradingGame, trades: list[TradingGameTrade]) -> None:
451
  for trade in trades:
 
51
  limit: int = 200,
52
  offset: int = 0,
53
  refresh: bool = True,
54
+ use_snapshot: bool = True,
55
+ include_trace: bool = False,
56
  ) -> dict:
57
+ if use_snapshot and self._can_use_ledger_snapshot(
58
+ ticker=ticker,
59
+ setup_type=setup_type,
60
+ outcome_label=outcome_label,
61
+ start_date=start_date,
62
+ end_date=end_date,
63
+ min_r=min_r,
64
+ max_r=max_r,
65
+ only_open=only_open,
66
+ only_closed=only_closed,
67
+ sort_by=sort_by,
68
+ ):
69
+ from app.services.trading_game_runtime import TradingGameRuntimeSnapshotService
70
+
71
+ snapshot_payload = TradingGameRuntimeSnapshotService().ledger_from_snapshot(db, game_id=game_id, limit=limit, offset=offset)
72
+ if snapshot_payload is not None:
73
+ return snapshot_payload
74
  game = self.game(db, game_id)
75
  if not game:
76
  return {"status": "no_game", "rows": [], "summary": {}, "policy": TRANSPARENCY_POLICY}
77
+ from app.services.trading_game_runtime import RuntimeTrace, payload_size_bytes
78
+
79
+ trace = RuntimeTrace("trading_game_ledger_live_read")
80
  if refresh:
81
+ with trace.phase("refresh_transparency"):
82
+ self.refresh_game_transparency(db, game, commit=False, persist_reality=False)
83
+ with trace.phase("base_trade_query"):
84
+ query = select(TradingGameTrade).where(TradingGameTrade.game_id == game.id)
85
+ if ticker:
86
+ query = query.where(TradingGameTrade.ticker == ticker.upper())
87
+ if setup_type:
88
+ query = query.where(TradingGameTrade.setup_type == setup_type)
89
+ if outcome_label:
90
+ query = query.where(TradingGameTrade.outcome_label == outcome_label)
91
+ parsed_start = parse_date(start_date)
92
+ parsed_end = parse_date(end_date)
93
+ if parsed_start:
94
+ query = query.where(TradingGameTrade.entry_date >= parsed_start)
95
+ if parsed_end:
96
+ query = query.where(TradingGameTrade.entry_date <= parsed_end)
97
+ if min_r is not None:
98
+ query = query.where(TradingGameTrade.realized_r_multiple >= min_r)
99
+ if max_r is not None:
100
+ query = query.where(TradingGameTrade.realized_r_multiple <= max_r)
101
+ if only_open:
102
+ query = query.where(TradingGameTrade.exit_date.is_(None))
103
+ if only_closed:
104
+ query = query.where(TradingGameTrade.exit_date.is_not(None))
105
+ total = int(db.scalar(select(func.count()).select_from(query.subquery())) or 0)
106
+ query = order_trade_query(query, sort_by).limit(limit).offset(offset)
107
+ rows = db.scalars(query).all()
108
+ with trace.phase("attribution_loading"):
109
+ attribution_count = 0
110
+ with trace.phase("evidence_loading"):
111
+ evidence_count = 0
112
+ with trace.phase("benchmark_loading"):
113
+ benchmark_count = sum(1 for row in rows if row.benchmark_return_same_period is not None or row.benchmark_return is not None)
114
+ with trace.phase("quality_loading"):
115
+ quality_count = sum(1 for row in rows if row.trade_quality_score is not None)
116
+ with trace.phase("prediction_loading"):
117
+ prediction_count = sum(1 for row in rows if row.thesis_id)
118
+ with trace.phase("serialization"):
119
+ serialized_rows = [self.serialize_trade(db, row) for row in rows]
120
+ summary = self.summary_for_game(db, game)
121
+ payload = {
122
  "status": "ok",
123
+ "snapshot_status": "miss",
124
  "game": serialize_game_header(game),
125
+ "summary": summary,
126
+ "rows": serialized_rows,
127
  "total": total,
128
  "limit": limit,
129
  "offset": offset,
 
142
  },
143
  "policy": TRANSPARENCY_POLICY,
144
  }
145
+ with trace.phase("json_generation"):
146
+ response_size = payload_size_bytes(payload)
147
+ trace.add(
148
+ snapshot_miss=True,
149
+ base_trade_query_count=2,
150
+ attribution_loading_queries=0,
151
+ evidence_loading_queries=0,
152
+ benchmark_loading_queries=0,
153
+ quality_loading_queries=0,
154
+ prediction_loading_queries=0,
155
+ attribution_rows=attribution_count,
156
+ evidence_rows=evidence_count,
157
+ benchmark_rows=benchmark_count,
158
+ quality_rows=quality_count,
159
+ prediction_links=prediction_count,
160
+ row_count=len(rows),
161
+ total_trades=total,
162
+ response_size_bytes=response_size,
163
+ )
164
+ payload["runtime_trace"] = trace.payload()
165
+ return payload
166
 
167
  def detail(self, db: Session, trade_id: int) -> dict:
168
  trade = db.get(TradingGameTrade, trade_id)
 
292
  return db.get(TradingGame, game_id)
293
  return db.scalar(select(TradingGame).where(TradingGame.status == "active").order_by(desc(TradingGame.started_at)).limit(1)) or db.scalar(select(TradingGame).order_by(desc(TradingGame.started_at)).limit(1))
294
 
295
+ def _can_use_ledger_snapshot(
296
+ self,
297
+ *,
298
+ ticker: str | None,
299
+ setup_type: str | None,
300
+ outcome_label: str | None,
301
+ start_date: str | None,
302
+ end_date: str | None,
303
+ min_r: float | None,
304
+ max_r: float | None,
305
+ only_open: bool,
306
+ only_closed: bool,
307
+ sort_by: str,
308
+ ) -> bool:
309
+ return (
310
+ ticker is None
311
+ and setup_type is None
312
+ and outcome_label is None
313
+ and start_date is None
314
+ and end_date is None
315
+ and min_r is None
316
+ and max_r is None
317
+ and not only_open
318
+ and not only_closed
319
+ and sort_by == "created_at_desc"
320
+ )
321
+
322
  def serialize_trade(self, db: Session, trade: TradingGameTrade) -> dict:
323
  return {
324
  "trade_id": trade.id,
 
516
 
517
 
518
  class EquityCurveAnnotationService:
519
+ def annotated_equity(
520
+ self,
521
+ db: Session,
522
+ game_id: int | None = None,
523
+ limit: int = 800,
524
+ *,
525
+ refresh: bool = False,
526
+ use_snapshot: bool = True,
527
+ include_trace: bool = False,
528
+ ) -> dict:
529
+ if use_snapshot:
530
+ from app.services.trading_game_runtime import TradingGameRuntimeSnapshotService
531
+
532
+ snapshot_payload = TradingGameRuntimeSnapshotService().equity_from_snapshot(db, game_id=game_id, limit=limit)
533
+ if snapshot_payload is not None:
534
+ return snapshot_payload
535
  game = TradeLedgerService().game(db, game_id)
536
  if not game:
537
  return {"status": "no_game", "equity_curve_points": [], "benchmark_curve_points": [], "annotations": []}
538
+ from app.services.trading_game_runtime import RuntimeTrace, payload_size_bytes
539
+
540
+ trace = RuntimeTrace("equity_curve_annotated_live_read")
541
+ if refresh:
542
+ with trace.phase("annotation_refresh"):
543
+ trades = db.scalars(select(TradingGameTrade).where(TradingGameTrade.game_id == game.id).order_by(TradingGameTrade.created_at)).all()
544
+ self.refresh(db, game, trades)
545
+ with trace.phase("equity_points_loading"):
546
+ points = db.scalars(select(TradingGameEquityCurve).where(TradingGameEquityCurve.game_id == game.id).order_by(TradingGameEquityCurve.created_at).limit(limit)).all()
547
+ with trace.phase("annotations_loading"):
548
+ annotations = db.scalars(select(EquityCurveAnnotation).where(EquityCurveAnnotation.game_id == game.id).order_by(EquityCurveAnnotation.timestamp).limit(limit)).all()
549
+ with trace.phase("benchmark_loading"):
550
+ benchmark_curve_points = [{"timestamp": iso(row.equity_date or row.created_at), "value": row.benchmark_equity, "return": row.benchmark_return} for row in points]
551
+ with trace.phase("serialization"):
552
+ payload = {
553
+ "status": "ok",
554
+ "snapshot_status": "miss",
555
+ "game": serialize_game_header(game),
556
+ "equity_curve_points": [serialize_equity_point(row) for row in points],
557
+ "benchmark_curve_points": benchmark_curve_points,
558
+ "annotations": [serialize_annotation(row) for row in annotations],
559
+ "policy": "Markers connect equity movement to trade entries, exits, drawdowns, rule events and benchmark divergence when those events exist.",
560
+ }
561
+ with trace.phase("json_generation"):
562
+ size = payload_size_bytes(payload)
563
+ trace.add(
564
+ snapshot_miss=True,
565
+ equity_points_queries=1,
566
+ annotations_queries=1,
567
+ benchmark_loading_queries=0,
568
+ point_count=len(points),
569
+ annotation_count=len(annotations),
570
+ response_size_bytes=size,
571
+ )
572
+ payload["runtime_trace"] = trace.payload()
573
+ return payload
574
 
575
  def refresh(self, db: Session, game: TradingGame, trades: list[TradingGameTrade]) -> None:
576
  for trade in trades:
backend/app/services/trading_game.py CHANGED
@@ -428,6 +428,7 @@ class TradingGameSimulator:
428
  "simulation": simulation.simulation_payload,
429
  "prediction_id": prediction.id if prediction else None,
430
  "market_regime": prediction.market_regime if prediction else "unknown",
 
431
  "guardrails": guardrails(),
432
  },
433
  )
@@ -624,6 +625,26 @@ def trade_reproducibility_score(simulation: ExecutionSimulation, prediction: His
624
  return round(clamp(score), 2)
625
 
626
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  def decision_state_for(simulation: ExecutionSimulation, reproducibility: float) -> str:
628
  if reproducibility < 42:
629
  return "avoid"
 
428
  "simulation": simulation.simulation_payload,
429
  "prediction_id": prediction.id if prediction else None,
430
  "market_regime": prediction.market_regime if prediction else "unknown",
431
+ "feedback_loop": prediction_feedback_payload(prediction),
432
  "guardrails": guardrails(),
433
  },
434
  )
 
625
  return round(clamp(score), 2)
626
 
627
 
628
+ def prediction_feedback_payload(prediction: HistoricalPrediction | None) -> dict:
629
+ if not prediction:
630
+ return {
631
+ "status": "missing_prediction",
632
+ "policy": "No HistoricalPrediction was linked to this simulated trade.",
633
+ }
634
+ feedback = (prediction.prediction_payload or {}).get("feedback_loop") or {}
635
+ return {
636
+ "status": "ready",
637
+ "model_version_used": prediction.model_version_used or prediction.model_version or "base-static",
638
+ "weights_used": prediction.weights_used or feedback.get("weights_used") or {},
639
+ "confidence_adjustment": feedback.get("confidence_adjustment", 0.0),
640
+ "learning_memory_used": prediction.learning_memory_used or feedback.get("learning_memory_used") or {},
641
+ "strategy_memory_used": prediction.strategy_memory_used or feedback.get("strategy_memory_used") or {},
642
+ "research_priority_used": prediction.research_priority_used or feedback.get("research_priority_used") or {},
643
+ "learning_mode_metadata": feedback.get("learning_mode_metadata") or (prediction.prediction_payload or {}).get("learning_mode_metadata") or {},
644
+ "policy": "Paper trade payload records the prediction feedback loop metadata; it does not recalculate learning during trade rendering.",
645
+ }
646
+
647
+
648
  def decision_state_for(simulation: ExecutionSimulation, reproducibility: float) -> str:
649
  if reproducibility < 42:
650
  return "avoid"
backend/tests/test_learning_performance_architecture.py CHANGED
@@ -9,8 +9,12 @@ from app.models import (
9
  DashboardSnapshot,
10
  LearningBenchmarkComparison,
11
  LearningRun,
 
12
  TradingCapitalCycle,
13
  TradingGame,
 
 
 
14
  )
15
  from app.services.dashboard_snapshots import DashboardSnapshotService
16
  from app.services.learning_summary import LearningSummaryService, build_truth_panel, safe_progress, summarize_benchmarks
@@ -41,8 +45,12 @@ def test_learning_summary_returns_partial_payload_without_precomputed_rows():
41
  DashboardSnapshot,
42
  LearningBenchmarkComparison,
43
  LearningRun,
 
44
  TradingCapitalCycle,
45
  TradingGame,
 
 
 
46
  ]:
47
  model.__table__.create(bind=engine)
48
 
@@ -51,6 +59,9 @@ def test_learning_summary_returns_partial_payload_without_precomputed_rows():
51
 
52
  assert payload["status"] == "initializing"
53
  assert "trading_power_score" in payload["missing_sections"]
 
 
 
54
  assert payload["truth_panel"][0].startswith("Not enough precomputed evidence")
55
 
56
 
@@ -74,9 +85,69 @@ def test_learning_page_keeps_heavy_work_out_of_initial_render():
74
  page = Path(__file__).resolve().parents[2] / "frontend" / "app" / "learning" / "page.tsx"
75
  text = page.read_text()
76
 
77
- assert "setTimeout(loadVisibleTables" not in text
78
- assert "IntersectionObserver" in text
 
 
 
 
 
 
 
 
 
79
  assert "recalculateLearningTradingPower" not in text
80
  assert "recalculateDecisionSuperiority" not in text
81
  assert "recalculateBusinessQuality" not in text
82
  assert "recalculatePortfolioIntelligence" not in text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  DashboardSnapshot,
10
  LearningBenchmarkComparison,
11
  LearningRun,
12
+ LearningStrengthWeaknessMap,
13
  TradingCapitalCycle,
14
  TradingGame,
15
+ TradingGameTrade,
16
+ TradingIntelligenceMetric,
17
+ TradeLearningEvidence,
18
  )
19
  from app.services.dashboard_snapshots import DashboardSnapshotService
20
  from app.services.learning_summary import LearningSummaryService, build_truth_panel, safe_progress, summarize_benchmarks
 
45
  DashboardSnapshot,
46
  LearningBenchmarkComparison,
47
  LearningRun,
48
+ LearningStrengthWeaknessMap,
49
  TradingCapitalCycle,
50
  TradingGame,
51
+ TradingGameTrade,
52
+ TradingIntelligenceMetric,
53
+ TradeLearningEvidence,
54
  ]:
55
  model.__table__.create(bind=engine)
56
 
 
59
 
60
  assert payload["status"] == "initializing"
61
  assert "trading_power_score" in payload["missing_sections"]
62
+ assert "trading_intelligence_metrics" in payload["missing_sections"]
63
+ assert payload["backend_training_status"]["frontend_policy"] == "read_only_snapshot_observer"
64
+ assert "snapshots" in payload
65
  assert payload["truth_panel"][0].startswith("Not enough precomputed evidence")
66
 
67
 
 
85
  page = Path(__file__).resolve().parents[2] / "frontend" / "app" / "learning" / "page.tsx"
86
  text = page.read_text()
87
 
88
+ assert text.strip() == 'export { default } from "../training-ground/page";'
89
+
90
+ training_page = Path(__file__).resolve().parents[2] / "frontend" / "app" / "training-ground" / "page.tsx"
91
+ training_text = training_page.read_text()
92
+ initial_effect = training_text.split("useEffect(() => {", 1)[1].split("}, []);", 1)[0]
93
+
94
+ assert "api.traderTrainingGround()" in initial_effect
95
+ assert initial_effect.count("api.") == 1
96
+ assert "api.tradingGameLedger" not in training_text
97
+ assert "api.alphaRecoveryDashboard()" not in training_text
98
+ assert "api.metaCognitionSummary()" not in training_text
99
  assert "recalculateLearningTradingPower" not in text
100
  assert "recalculateDecisionSuperiority" not in text
101
  assert "recalculateBusinessQuality" not in text
102
  assert "recalculatePortfolioIntelligence" not in text
103
+ assert "recalculateAlphaRecovery" not in text
104
+ assert "metaCognitionRecalculate" not in text
105
+
106
+
107
+ def test_deep_diagnostics_uses_human_readable_renderer_not_raw_json_dump():
108
+ page = Path(__file__).resolve().parents[2] / "frontend" / "components" / "DiagnosticPanelRenderer.tsx"
109
+ text = page.read_text()
110
+
111
+ assert "compactPreview" not in text
112
+ assert "JSON.stringify(compactPreview" not in text
113
+ assert "DiagnosticPanelRenderer" in text
114
+ assert "Show raw JSON" in text
115
+
116
+
117
+ def test_diagnostic_panel_renderer_hides_raw_json_by_default():
118
+ renderer = Path(__file__).resolve().parents[2] / "frontend" / "components" / "DiagnosticPanelRenderer.tsx"
119
+ text = renderer.read_text()
120
+
121
+ assert "export function DiagnosticPanelRenderer" in text
122
+ assert "function ReliabilityByRegimeRenderer" in text
123
+ assert "function EnsembleStatusRenderer" in text
124
+ assert "export function SampleSizeWarning" in text
125
+ assert "export function WeightDistributionTable" in text
126
+ assert "Show raw JSON" in text
127
+ assert "Hide raw JSON" in text
128
+ assert "open && <pre" in text
129
+
130
+
131
+ def test_diagnostic_renderer_exposes_evidence_warnings_and_tables():
132
+ renderer = Path(__file__).resolve().parents[2] / "frontend" / "components" / "DiagnosticPanelRenderer.tsx"
133
+ text = renderer.read_text()
134
+
135
+ assert "Small sample size: treat as weak evidence." in text
136
+ assert "Reliability is promising but not durable without more samples." in text
137
+ assert "Negative average return with positive excess return needs review." in text
138
+ assert "DiagnosticTable" in text
139
+ assert "Engine" in text
140
+ assert "Hit Rate" in text
141
+ assert "Excess vs Benchmark" in text
142
+ assert "Confidence Penalty" in text
143
+
144
+
145
+ def test_frontend_blocks_heavy_learning_post_during_initial_render():
146
+ api_file = Path(__file__).resolve().parents[2] / "frontend" / "lib" / "api.ts"
147
+ text = api_file.read_text()
148
+
149
+ assert "blocked_heavy_frontend_recalculation" in text
150
+ assert "LEARNING_INITIAL_RENDER_GUARD_MS" in text
151
+ assert '"/api/capital-allocation/recalculate"' in text
152
+ assert '"/api/alpha-recovery/recalculate"' in text
153
+ assert '"/api/meta-cognition/recalculate"' in text
backend/tests/test_performance_diagnostics.py CHANGED
@@ -35,9 +35,11 @@ def test_performance_recorder_builds_diagnostics():
35
  def test_performance_recorder_exposes_learning_page_load_diagnostics():
36
  recorder = PerformanceRecorder()
37
  now = datetime.utcnow()
38
- recorder.record_frontend_widget("frontend.api.GET./api/learning-intelligence/summary", 120.0, {"status": "ok", "source": "fetchBlum"})
39
- recorder.record_frontend_widget("frontend.api.GET./api/trading-game/status", 0.4, {"status": "cache_hit", "source": "fetchBlum"})
40
- recorder.record_frontend_widget("frontend.api.GET./api/trading-game/status", 0.2, {"status": "deduped", "source": "fetchBlum"})
 
 
41
  recorder.record_dashboard_widget(
42
  "performance.heavy_recalculation_triggered_during_page_load",
43
  3400.0,
@@ -49,6 +51,7 @@ def test_performance_recorder_exposes_learning_page_load_diagnostics():
49
  summary = payload["initial_learning_page_load"]
50
 
51
  assert summary["frontend_request_count"] == 3
 
52
  assert summary["cache_hit_count"] == 1
53
  assert summary["duplicate_request_count"] == 1
54
  assert summary["heavy_post_calls_during_page_load"][0]["path"] == "/api/business-quality/recalculate"
 
35
  def test_performance_recorder_exposes_learning_page_load_diagnostics():
36
  recorder = PerformanceRecorder()
37
  now = datetime.utcnow()
38
+ learning_meta = {"source": "fetchBlum", "route": "/learning", "initial_learning_window": True}
39
+ recorder.record_frontend_widget("frontend.api.GET./api/learning-intelligence/summary", 120.0, {"status": "ok", **learning_meta})
40
+ recorder.record_frontend_widget("frontend.api.GET./api/trading-game/status", 0.4, {"status": "cache_hit", **learning_meta})
41
+ recorder.record_frontend_widget("frontend.api.GET./api/trading-game/status", 0.2, {"status": "deduped", **learning_meta})
42
+ recorder.record_frontend_widget("frontend.api.GET./dashboard/overview", 800.0, {"status": "ok", "source": "fetchBlum", "route": "/", "initial_learning_window": False})
43
  recorder.record_dashboard_widget(
44
  "performance.heavy_recalculation_triggered_during_page_load",
45
  3400.0,
 
51
  summary = payload["initial_learning_page_load"]
52
 
53
  assert summary["frontend_request_count"] == 3
54
+ assert summary["unscoped_frontend_request_count"] == 4
55
  assert summary["cache_hit_count"] == 1
56
  assert summary["duplicate_request_count"] == 1
57
  assert summary["heavy_post_calls_during_page_load"][0]["path"] == "/api/business-quality/recalculate"
frontend/.next/BUILD_ID ADDED
@@ -0,0 +1 @@
 
 
1
+ wZUVLKXl_CiOxUlt_FdXC
frontend/.next/app-build-manifest.json ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "pages": {
3
+ "/_not-found/page": [
4
+ "static/chunks/webpack-aeb5c49f88887156.js",
5
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
6
+ "static/chunks/945-dfa5efbc7dfb2860.js",
7
+ "static/chunks/main-app-80b65e8c4f383000.js",
8
+ "static/chunks/app/_not-found/page-8194a34fe9d5ebe2.js"
9
+ ],
10
+ "/layout": [
11
+ "static/chunks/webpack-aeb5c49f88887156.js",
12
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
13
+ "static/chunks/945-dfa5efbc7dfb2860.js",
14
+ "static/chunks/main-app-80b65e8c4f383000.js",
15
+ "static/css/02bd12e03544ea91.css",
16
+ "static/chunks/183-66f1efcf12b29a02.js",
17
+ "static/chunks/651-5689242ae9b0ffc6.js",
18
+ "static/chunks/app/layout-c302ea2b37a37766.js"
19
+ ],
20
+ "/backtest/page": [
21
+ "static/chunks/webpack-aeb5c49f88887156.js",
22
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
23
+ "static/chunks/945-dfa5efbc7dfb2860.js",
24
+ "static/chunks/main-app-80b65e8c4f383000.js",
25
+ "static/chunks/651-5689242ae9b0ffc6.js",
26
+ "static/chunks/app/backtest/page-60b494c3f5caeab7.js"
27
+ ],
28
+ "/chart-analyst/page": [
29
+ "static/chunks/webpack-aeb5c49f88887156.js",
30
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
31
+ "static/chunks/945-dfa5efbc7dfb2860.js",
32
+ "static/chunks/main-app-80b65e8c4f383000.js",
33
+ "static/chunks/651-5689242ae9b0ffc6.js",
34
+ "static/chunks/75-8c48549bc3f12b00.js",
35
+ "static/chunks/app/chart-analyst/page-422122938e79d615.js"
36
+ ],
37
+ "/chat/page": [
38
+ "static/chunks/webpack-aeb5c49f88887156.js",
39
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
40
+ "static/chunks/945-dfa5efbc7dfb2860.js",
41
+ "static/chunks/main-app-80b65e8c4f383000.js",
42
+ "static/chunks/183-66f1efcf12b29a02.js",
43
+ "static/chunks/651-5689242ae9b0ffc6.js",
44
+ "static/chunks/908-fe3c8e03f00ace4e.js",
45
+ "static/chunks/145-04bdfe555d73caf0.js",
46
+ "static/chunks/app/chat/page-01bb723eea532598.js"
47
+ ],
48
+ "/chatbot/page": [
49
+ "static/chunks/webpack-aeb5c49f88887156.js",
50
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
51
+ "static/chunks/945-dfa5efbc7dfb2860.js",
52
+ "static/chunks/main-app-80b65e8c4f383000.js",
53
+ "static/chunks/183-66f1efcf12b29a02.js",
54
+ "static/chunks/651-5689242ae9b0ffc6.js",
55
+ "static/chunks/908-fe3c8e03f00ace4e.js",
56
+ "static/chunks/145-04bdfe555d73caf0.js",
57
+ "static/chunks/app/chatbot/page-ec86bf53c052af5b.js"
58
+ ],
59
+ "/learning/page": [
60
+ "static/chunks/webpack-aeb5c49f88887156.js",
61
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
62
+ "static/chunks/945-dfa5efbc7dfb2860.js",
63
+ "static/chunks/main-app-80b65e8c4f383000.js",
64
+ "static/chunks/651-5689242ae9b0ffc6.js",
65
+ "static/chunks/app/learning/page-fb9d9a3de58b413d.js"
66
+ ],
67
+ "/methodology/page": [
68
+ "static/chunks/webpack-aeb5c49f88887156.js",
69
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
70
+ "static/chunks/945-dfa5efbc7dfb2860.js",
71
+ "static/chunks/main-app-80b65e8c4f383000.js",
72
+ "static/chunks/651-5689242ae9b0ffc6.js",
73
+ "static/chunks/app/methodology/page-f0ef96f8ef7d3450.js"
74
+ ],
75
+ "/ipo-radar/page": [
76
+ "static/chunks/webpack-aeb5c49f88887156.js",
77
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
78
+ "static/chunks/945-dfa5efbc7dfb2860.js",
79
+ "static/chunks/main-app-80b65e8c4f383000.js",
80
+ "static/chunks/651-5689242ae9b0ffc6.js",
81
+ "static/chunks/app/ipo-radar/page-37bfbd97573d6050.js"
82
+ ],
83
+ "/market-brain/page": [
84
+ "static/chunks/webpack-aeb5c49f88887156.js",
85
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
86
+ "static/chunks/945-dfa5efbc7dfb2860.js",
87
+ "static/chunks/main-app-80b65e8c4f383000.js",
88
+ "static/chunks/183-66f1efcf12b29a02.js",
89
+ "static/chunks/651-5689242ae9b0ffc6.js",
90
+ "static/chunks/908-fe3c8e03f00ace4e.js",
91
+ "static/chunks/app/market-brain/page-a3c75d13701833bc.js"
92
+ ],
93
+ "/narratives/page": [
94
+ "static/chunks/webpack-aeb5c49f88887156.js",
95
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
96
+ "static/chunks/945-dfa5efbc7dfb2860.js",
97
+ "static/chunks/main-app-80b65e8c4f383000.js",
98
+ "static/chunks/183-66f1efcf12b29a02.js",
99
+ "static/chunks/651-5689242ae9b0ffc6.js",
100
+ "static/chunks/908-fe3c8e03f00ace4e.js",
101
+ "static/chunks/433-89ff72e62f774508.js",
102
+ "static/chunks/app/narratives/page-f858506c5d5d372d.js"
103
+ ],
104
+ "/page": [
105
+ "static/chunks/webpack-aeb5c49f88887156.js",
106
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
107
+ "static/chunks/945-dfa5efbc7dfb2860.js",
108
+ "static/chunks/main-app-80b65e8c4f383000.js",
109
+ "static/chunks/183-66f1efcf12b29a02.js",
110
+ "static/chunks/651-5689242ae9b0ffc6.js",
111
+ "static/chunks/908-fe3c8e03f00ace4e.js",
112
+ "static/chunks/96-ce8c57cd15d50941.js",
113
+ "static/chunks/app/page-d0db9a93fb4af9a5.js"
114
+ ],
115
+ "/etf-radar/page": [
116
+ "static/chunks/webpack-aeb5c49f88887156.js",
117
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
118
+ "static/chunks/945-dfa5efbc7dfb2860.js",
119
+ "static/chunks/main-app-80b65e8c4f383000.js",
120
+ "static/chunks/183-66f1efcf12b29a02.js",
121
+ "static/chunks/651-5689242ae9b0ffc6.js",
122
+ "static/chunks/908-fe3c8e03f00ace4e.js",
123
+ "static/chunks/app/etf-radar/page-cb504f6343d399db.js"
124
+ ],
125
+ "/performance/page": [
126
+ "static/chunks/webpack-aeb5c49f88887156.js",
127
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
128
+ "static/chunks/945-dfa5efbc7dfb2860.js",
129
+ "static/chunks/main-app-80b65e8c4f383000.js",
130
+ "static/chunks/183-66f1efcf12b29a02.js",
131
+ "static/chunks/651-5689242ae9b0ffc6.js",
132
+ "static/chunks/908-fe3c8e03f00ace4e.js",
133
+ "static/chunks/app/performance/page-72d89cf8fa4bd0b1.js"
134
+ ],
135
+ "/signal-lab/page": [
136
+ "static/chunks/webpack-aeb5c49f88887156.js",
137
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
138
+ "static/chunks/945-dfa5efbc7dfb2860.js",
139
+ "static/chunks/main-app-80b65e8c4f383000.js",
140
+ "static/chunks/183-66f1efcf12b29a02.js",
141
+ "static/chunks/651-5689242ae9b0ffc6.js",
142
+ "static/chunks/908-fe3c8e03f00ace4e.js",
143
+ "static/chunks/app/signal-lab/page-a2f85a5c0d0847cd.js"
144
+ ],
145
+ "/sniper/page": [
146
+ "static/chunks/webpack-aeb5c49f88887156.js",
147
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
148
+ "static/chunks/945-dfa5efbc7dfb2860.js",
149
+ "static/chunks/main-app-80b65e8c4f383000.js",
150
+ "static/chunks/183-66f1efcf12b29a02.js",
151
+ "static/chunks/651-5689242ae9b0ffc6.js",
152
+ "static/chunks/908-fe3c8e03f00ace4e.js",
153
+ "static/chunks/app/sniper/page-66e545665c88da6b.js"
154
+ ],
155
+ "/stock-radar/page": [
156
+ "static/chunks/webpack-aeb5c49f88887156.js",
157
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
158
+ "static/chunks/945-dfa5efbc7dfb2860.js",
159
+ "static/chunks/main-app-80b65e8c4f383000.js",
160
+ "static/chunks/183-66f1efcf12b29a02.js",
161
+ "static/chunks/651-5689242ae9b0ffc6.js",
162
+ "static/chunks/908-fe3c8e03f00ace4e.js",
163
+ "static/chunks/app/stock-radar/page-6e7f483858d9a39e.js"
164
+ ],
165
+ "/themes/page": [
166
+ "static/chunks/webpack-aeb5c49f88887156.js",
167
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
168
+ "static/chunks/945-dfa5efbc7dfb2860.js",
169
+ "static/chunks/main-app-80b65e8c4f383000.js",
170
+ "static/chunks/183-66f1efcf12b29a02.js",
171
+ "static/chunks/651-5689242ae9b0ffc6.js",
172
+ "static/chunks/908-fe3c8e03f00ace4e.js",
173
+ "static/chunks/433-89ff72e62f774508.js",
174
+ "static/chunks/app/themes/page-9af78316fbb208f8.js"
175
+ ],
176
+ "/dashboard/page": [
177
+ "static/chunks/webpack-aeb5c49f88887156.js",
178
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
179
+ "static/chunks/945-dfa5efbc7dfb2860.js",
180
+ "static/chunks/main-app-80b65e8c4f383000.js",
181
+ "static/chunks/183-66f1efcf12b29a02.js",
182
+ "static/chunks/651-5689242ae9b0ffc6.js",
183
+ "static/chunks/908-fe3c8e03f00ace4e.js",
184
+ "static/chunks/96-ce8c57cd15d50941.js",
185
+ "static/chunks/app/dashboard/page-d9577bde4b3301c9.js"
186
+ ],
187
+ "/assets/[ticker]/page": [
188
+ "static/chunks/webpack-aeb5c49f88887156.js",
189
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
190
+ "static/chunks/945-dfa5efbc7dfb2860.js",
191
+ "static/chunks/main-app-80b65e8c4f383000.js",
192
+ "static/chunks/183-66f1efcf12b29a02.js",
193
+ "static/chunks/651-5689242ae9b0ffc6.js",
194
+ "static/chunks/908-fe3c8e03f00ace4e.js",
195
+ "static/chunks/75-8c48549bc3f12b00.js",
196
+ "static/chunks/app/assets/[ticker]/page-3687f8c7fa1ef7c8.js"
197
+ ]
198
+ }
199
+ }
frontend/.next/app-path-routes-manifest.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"/_not-found/page":"/_not-found","/backtest/page":"/backtest","/chart-analyst/page":"/chart-analyst","/chat/page":"/chat","/chatbot/page":"/chatbot","/learning/page":"/learning","/methodology/page":"/methodology","/ipo-radar/page":"/ipo-radar","/market-brain/page":"/market-brain","/narratives/page":"/narratives","/page":"/","/etf-radar/page":"/etf-radar","/performance/page":"/performance","/signal-lab/page":"/signal-lab","/sniper/page":"/sniper","/stock-radar/page":"/stock-radar","/themes/page":"/themes","/dashboard/page":"/dashboard","/assets/[ticker]/page":"/assets/[ticker]"}
frontend/.next/build-manifest.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "polyfillFiles": [
3
+ "static/chunks/polyfills-42372ed130431b0a.js"
4
+ ],
5
+ "devFiles": [],
6
+ "ampDevFiles": [],
7
+ "lowPriorityFiles": [
8
+ "static/wZUVLKXl_CiOxUlt_FdXC/_buildManifest.js",
9
+ "static/wZUVLKXl_CiOxUlt_FdXC/_ssgManifest.js"
10
+ ],
11
+ "rootMainFiles": [
12
+ "static/chunks/webpack-aeb5c49f88887156.js",
13
+ "static/chunks/2200cc46-bb2e82a2a422da49.js",
14
+ "static/chunks/945-dfa5efbc7dfb2860.js",
15
+ "static/chunks/main-app-80b65e8c4f383000.js"
16
+ ],
17
+ "pages": {
18
+ "/_app": [
19
+ "static/chunks/webpack-aeb5c49f88887156.js",
20
+ "static/chunks/framework-6e06c675866dc992.js",
21
+ "static/chunks/main-d4dac7885e92ed7d.js",
22
+ "static/chunks/pages/_app-0c3037849002a4aa.js"
23
+ ],
24
+ "/_error": [
25
+ "static/chunks/webpack-aeb5c49f88887156.js",
26
+ "static/chunks/framework-6e06c675866dc992.js",
27
+ "static/chunks/main-d4dac7885e92ed7d.js",
28
+ "static/chunks/pages/_error-a647cd2c75dc4dc7.js"
29
+ ]
30
+ },
31
+ "ampFirstPages": []
32
+ }
frontend/.next/cache/.tsbuildinfo ADDED
The diff for this file is too large to render. See raw diff
 
frontend/.next/cache/webpack/client-production/0.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:64c20a49f6c3005f093ae1cf65d33529c0a59bbd35ba5ad023bca0d3cb434afa
3
+ size 118293334
frontend/.next/cache/webpack/client-production/1.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5e03aa28adc6a768648f515402a900fe14c6752847d7d964a1929cb3004a9b6c
3
+ size 868911
frontend/.next/cache/webpack/client-production/10.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:71fce4cef0b4b1f91d826885a2588f7734eb1eed4dd69e70ba1700cac0783b0a
3
+ size 8511085
frontend/.next/cache/webpack/client-production/11.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d59a7fd7a8c5256381e9719bc1e090967de13243d63add7c753a130867cc3927
3
+ size 1294746
frontend/.next/cache/webpack/client-production/12.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5d140048b49b2aa71f6dcca9123f6e4fde88898eef94c98d37246bf2188adc8e
3
+ size 3857837
frontend/.next/cache/webpack/client-production/13.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f6e722d889d1adf3361080549c4442a68aa9b575dbdb84465f319c8f06f86c74
3
+ size 9983794
frontend/.next/cache/webpack/client-production/14.pack ADDED
Binary file (64 kB). View file
 
frontend/.next/cache/webpack/client-production/15.pack ADDED
Binary file (79.3 kB). View file
 
frontend/.next/cache/webpack/client-production/2.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1bda4f125628c87fda9af6581649974e2231693f2fbb1a0989920194d4b3daa6
3
+ size 1249964
frontend/.next/cache/webpack/client-production/3.pack ADDED
Binary file (4.89 kB). View file
 
frontend/.next/cache/webpack/client-production/4.pack ADDED
Binary file (14.7 kB). View file
 
frontend/.next/cache/webpack/client-production/5.pack ADDED
Binary file (792 Bytes). View file
 
frontend/.next/cache/webpack/client-production/6.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:57c5d14b0f12b9dd3ec358cf64e42e813c43ef4e62aedae8f38b4e6372cbae52
3
+ size 6614985
frontend/.next/cache/webpack/client-production/7.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aaaa8c24f9ecde0e1428c5de84f15d7950323267d01fa36f5fc56859043395c2
3
+ size 108202
frontend/.next/cache/webpack/client-production/8.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2944bcbd152b577f7950f35c27cfbdd36f4d52134dff689a8b78f17ad4104cce
3
+ size 1112198
frontend/.next/cache/webpack/client-production/9.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c1e183c17cb5b8d257c1dbe1dcc04e84ce501d2d1a7ca7b66d2e0125327079e4
3
+ size 1062275
frontend/.next/cache/webpack/client-production/index.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:943c09107fb7bebabb49c02a0e84e94e938d4a94071f3c7746b3bab30ce503ba
3
+ size 5360825
frontend/.next/cache/webpack/client-production/index.pack.old ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b801d39452a502fce5ff9ea86dc9f12dd0c7acf45995880e368d15d8a2aa687b
3
+ size 5359015
frontend/.next/cache/webpack/edge-server-production/0.pack ADDED
Binary file (274 Bytes). View file
 
frontend/.next/cache/webpack/edge-server-production/index.pack ADDED
Binary file (28.6 kB). View file
 
frontend/.next/cache/webpack/server-production/0.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fbc928f3fe56a2511ce7ada8c42eabadf0939456e4e986640298317ff1701e12
3
+ size 19157446
frontend/.next/cache/webpack/server-production/1.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c6e9570f82bbe9b380b8f3fb99a5696f151db001f6f69084595c8f908f9918e5
3
+ size 1227512
frontend/.next/cache/webpack/server-production/10.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b562e5e3f5cb6d15b73a0ab664a9282aae91e6e728a8147a9ff433aca27c7aa7
3
+ size 484820
frontend/.next/cache/webpack/server-production/2.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:565d9a97b0e7643692ad87c36fe861e2bb92e0f9562b73008fdd7449c3d8d306
3
+ size 1378163
frontend/.next/cache/webpack/server-production/3.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:92873e01aee48539364ba76c413082cf9ac240cad900f375e6237cc1ff8ab900
3
+ size 1467753
frontend/.next/cache/webpack/server-production/4.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:80f2429d58fd2de77209e8d1d9a1411721a9a394bd4ea35456d453ecd82941b1
3
+ size 11907910
frontend/.next/cache/webpack/server-production/5.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ee5ee084edc5d07c06db017a493f328ff249612ec351a5aae1f25edcbcbe8aea
3
+ size 1497242
frontend/.next/cache/webpack/server-production/6.pack ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:09db955bc8f95822127420242b6a8baab0b2bb169b92c1d505db64cd1d770e66
3
+ size 6031704