Spaces:
Running
Running
Deepen Market Brain intelligence system
Browse files- README.md +10 -0
- ROADMAP.md +5 -0
- backend/alembic/versions/0003_signal_metadata.py +31 -0
- backend/app/api/routes.py +57 -3
- backend/app/models.py +3 -0
- backend/app/services/dashboard.py +3 -0
- backend/app/services/ipo.py +146 -0
- backend/app/services/market_brain.py +198 -0
- backend/app/services/semantic.py +74 -1
- backend/app/services/stock.py +14 -1
- backend/app/signals/engine.py +43 -0
- frontend/app/globals.css +42 -1
- frontend/app/ipo-radar/page.tsx +64 -3
- frontend/app/market-brain/page.tsx +68 -3
- frontend/app/methodology/page.tsx +52 -2
- frontend/app/stock-radar/page.tsx +3 -2
- frontend/app/themes/page.tsx +43 -1
- frontend/components/ScoreCard.tsx +2 -2
- frontend/components/SignalTable.tsx +6 -1
- frontend/lib/api.ts +5 -1
- frontend/lib/types.ts +25 -0
README.md
CHANGED
|
@@ -88,10 +88,14 @@ The Market Brain is Blum's high-level reasoning orchestrator. It is not a single
|
|
| 88 |
|
| 89 |
The output includes current regime, forward scenarios, opportunity stack, risk alerts, model stack and an evidence ledger. It is a research-priority engine, not a recommendation system.
|
| 90 |
|
|
|
|
|
|
|
| 91 |
## IPO And Pre-Listing Intelligence
|
| 92 |
|
| 93 |
IPO Radar scans SEC EDGAR current filing feeds for `S-1`, `S-1/A`, `F-1`, `F-1/A`, `424B1` and `424B4` forms. It also surfaces stored public news narratives that mention IPOs, listings, prospectuses, market debuts and SPACs.
|
| 94 |
|
|
|
|
|
|
|
| 95 |
The IPO score separates:
|
| 96 |
|
| 97 |
- readiness score;
|
|
@@ -146,14 +150,18 @@ FastAPI exposes clean JSON endpoints:
|
|
| 146 |
- `POST /semantic-search`
|
| 147 |
- `GET /related-news?ticker=NVDA`
|
| 148 |
- `GET /themes`
|
|
|
|
| 149 |
- `GET /etf-trends`
|
| 150 |
- `GET /stock-radar`
|
| 151 |
- `POST /stock-radar/update`
|
| 152 |
- `GET /ipo-radar`
|
| 153 |
- `POST /ipo-radar/update`
|
|
|
|
| 154 |
- `GET /market-brain`
|
| 155 |
- `GET /market-brain/latest`
|
|
|
|
| 156 |
- `POST /market-brain/run`
|
|
|
|
| 157 |
- `GET /dashboard/overview`
|
| 158 |
- `GET /ai/explain/{ticker}`
|
| 159 |
- `POST /backtest/{ticker}`
|
|
@@ -178,6 +186,8 @@ Interactive API docs are available at `/docs`.
|
|
| 178 |
|
| 179 |
The UI is intentionally dense, dark and technical: Bloomberg-style information density, Linear/Vercel-style cleanliness, TradingView-style chart clarity and OpenBB-style open-source posture.
|
| 180 |
|
|
|
|
|
|
|
| 181 |
## Local Setup
|
| 182 |
|
| 183 |
```bash
|
|
|
|
| 88 |
|
| 89 |
The output includes current regime, forward scenarios, opportunity stack, risk alerts, model stack and an evidence ledger. It is a research-priority engine, not a recommendation system.
|
| 90 |
|
| 91 |
+
The Brain also persists snapshot history and compares each run with the previous one. The change log highlights regime changes, score movement, top stock/ETF/IPO leader changes and risk-count shifts. A contradiction engine flags price/sentiment conflicts, overbought high-risk setups and market-wide narrative conflicts. An event graph links themes, stocks, ETFs, IPO candidates and live news into a compact intelligence map.
|
| 92 |
+
|
| 93 |
## IPO And Pre-Listing Intelligence
|
| 94 |
|
| 95 |
IPO Radar scans SEC EDGAR current filing feeds for `S-1`, `S-1/A`, `F-1`, `F-1/A`, `424B1` and `424B4` forms. It also surfaces stored public news narratives that mention IPOs, listings, prospectuses, market debuts and SPACs.
|
| 96 |
|
| 97 |
+
For deeper issuer history, the backend can query the official SEC company submissions API at `data.sec.gov`. The IPO Radar UI exposes SEC filing history and can persist additional IPO-related filings into PostgreSQL.
|
| 98 |
+
|
| 99 |
The IPO score separates:
|
| 100 |
|
| 101 |
- readiness score;
|
|
|
|
| 150 |
- `POST /semantic-search`
|
| 151 |
- `GET /related-news?ticker=NVDA`
|
| 152 |
- `GET /themes`
|
| 153 |
+
- `GET /themes/{label}`
|
| 154 |
- `GET /etf-trends`
|
| 155 |
- `GET /stock-radar`
|
| 156 |
- `POST /stock-radar/update`
|
| 157 |
- `GET /ipo-radar`
|
| 158 |
- `POST /ipo-radar/update`
|
| 159 |
+
- `GET /ipo-radar/sec-submissions/{cik}`
|
| 160 |
- `GET /market-brain`
|
| 161 |
- `GET /market-brain/latest`
|
| 162 |
+
- `GET /market-brain/history`
|
| 163 |
- `POST /market-brain/run`
|
| 164 |
+
- `GET /ai/models/status`
|
| 165 |
- `GET /dashboard/overview`
|
| 166 |
- `GET /ai/explain/{ticker}`
|
| 167 |
- `POST /backtest/{ticker}`
|
|
|
|
| 186 |
|
| 187 |
The UI is intentionally dense, dark and technical: Bloomberg-style information density, Linear/Vercel-style cleanliness, TradingView-style chart clarity and OpenBB-style open-source posture.
|
| 188 |
|
| 189 |
+
Signal surfaces include score version, confidence score and lifecycle state (`new`, `confirmed`, `strengthening`, `active`, `faded`, `invalidated`) so the platform can show whether a signal is emerging, durable or deteriorating.
|
| 190 |
+
|
| 191 |
## Local Setup
|
| 192 |
|
| 193 |
```bash
|
ROADMAP.md
CHANGED
|
@@ -16,6 +16,11 @@ Shipped in the current architecture:
|
|
| 16 |
- Stock Radar is now a first-class research surface with dedicated API, on-demand stock hydration, sector leadership, factor views, research priorities and real market snapshots.
|
| 17 |
- IPO Radar is now a first-class primary-market research surface using SEC EDGAR current filing feeds for S-1, F-1 and 424B prospectus forms.
|
| 18 |
- Market Brain is now the top-level orchestration layer combining stock signals, ETF rotation, market sentiment, public news, IPO evidence, forward scenarios, risk alerts and an evidence ledger.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- `/market-brain`, `/market-brain/run`, `/ipo-radar` and `/ipo-radar/update` expose the new intelligence layer through documented JSON APIs.
|
| 20 |
|
| 21 |
## Phase 0 - Stabilize Docker Space Deployment
|
|
|
|
| 16 |
- Stock Radar is now a first-class research surface with dedicated API, on-demand stock hydration, sector leadership, factor views, research priorities and real market snapshots.
|
| 17 |
- IPO Radar is now a first-class primary-market research surface using SEC EDGAR current filing feeds for S-1, F-1 and 424B prospectus forms.
|
| 18 |
- Market Brain is now the top-level orchestration layer combining stock signals, ETF rotation, market sentiment, public news, IPO evidence, forward scenarios, risk alerts and an evidence ledger.
|
| 19 |
+
- Market Brain now persists snapshot history, produces a changelog against the prior run, detects price/news/risk contradictions and emits an event graph linking themes, assets, ETFs, IPO candidates and live news.
|
| 20 |
+
- IPO Radar now supports official `data.sec.gov` company submissions enrichment by CIK, with optional persistence of additional IPO-related filings.
|
| 21 |
+
- Theme Explorer now supports theme detail with article list, source mix, linked assets and sentiment.
|
| 22 |
+
- The signal engine now records score version, confidence score and lifecycle state for every signal snapshot.
|
| 23 |
+
- `/ai/models/status` exposes configured models, observed model records and fallback policy.
|
| 24 |
- `/market-brain`, `/market-brain/run`, `/ipo-radar` and `/ipo-radar/update` expose the new intelligence layer through documented JSON APIs.
|
| 25 |
|
| 26 |
## Phase 0 - Stabilize Docker Space Deployment
|
backend/alembic/versions/0003_signal_metadata.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from alembic import op
|
| 4 |
+
import sqlalchemy as sa
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
revision = "0003_signal_metadata"
|
| 8 |
+
down_revision = "0002_market_brain_ipo"
|
| 9 |
+
branch_labels = None
|
| 10 |
+
depends_on = None
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def upgrade() -> None:
|
| 14 |
+
op.add_column("signal_snapshots", sa.Column("score_version", sa.String(length=40), nullable=False, server_default="blum-score-v0.4"))
|
| 15 |
+
op.add_column("signal_snapshots", sa.Column("confidence_score", sa.Float(), nullable=False, server_default="0"))
|
| 16 |
+
op.add_column("signal_snapshots", sa.Column("lifecycle_state", sa.String(length=40), nullable=False, server_default="new"))
|
| 17 |
+
op.create_index("ix_signal_snapshots_score_version", "signal_snapshots", ["score_version"])
|
| 18 |
+
op.create_index("ix_signal_snapshots_confidence_score", "signal_snapshots", ["confidence_score"])
|
| 19 |
+
op.create_index("ix_signal_snapshots_lifecycle_state", "signal_snapshots", ["lifecycle_state"])
|
| 20 |
+
op.alter_column("signal_snapshots", "score_version", server_default=None)
|
| 21 |
+
op.alter_column("signal_snapshots", "confidence_score", server_default=None)
|
| 22 |
+
op.alter_column("signal_snapshots", "lifecycle_state", server_default=None)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def downgrade() -> None:
|
| 26 |
+
op.drop_index("ix_signal_snapshots_lifecycle_state", table_name="signal_snapshots")
|
| 27 |
+
op.drop_index("ix_signal_snapshots_confidence_score", table_name="signal_snapshots")
|
| 28 |
+
op.drop_index("ix_signal_snapshots_score_version", table_name="signal_snapshots")
|
| 29 |
+
op.drop_column("signal_snapshots", "lifecycle_state")
|
| 30 |
+
op.drop_column("signal_snapshots", "confidence_score")
|
| 31 |
+
op.drop_column("signal_snapshots", "score_version")
|
backend/app/api/routes.py
CHANGED
|
@@ -8,13 +8,13 @@ from app.ai.orchestrator import AIOrchestrator
|
|
| 8 |
from app.core.config import get_settings
|
| 9 |
from app.core.database import get_db
|
| 10 |
from app.ingestion.news_ingestor import NewsIngestor
|
| 11 |
-
from app.models import AIInsight, Asset, NewsArticle, NewsAssetLink, PriceHistory, SentimentAnalysis, SignalSnapshot
|
| 12 |
from app.schemas import AssetOut, MarketUpdateRequest, NewsOut, NewsUpdateRequest, SemanticSearchRequest, SignalRunRequest
|
| 13 |
from app.services.dashboard import dashboard_overview, signal_payload
|
| 14 |
from app.services.etf import list_etf_trends, update_etf_trends
|
| 15 |
-
from app.services.ipo import ipo_radar, update_ipo_radar
|
| 16 |
from app.services.live import live_news, market_sentiment
|
| 17 |
-
from app.services.market_brain import build_market_brain, latest_market_brain
|
| 18 |
from app.services.market_data import MarketDataService, market_snapshot_for_asset
|
| 19 |
from app.services.pipeline import PipelineService
|
| 20 |
from app.services.realtime import realtime_status
|
|
@@ -192,6 +192,11 @@ def themes(db: Session = Depends(get_db)):
|
|
| 192 |
return SemanticService().themes(db)
|
| 193 |
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
@router.get("/etf-trends")
|
| 196 |
def etf_trends(db: Session = Depends(get_db)):
|
| 197 |
return list_etf_trends(db)
|
|
@@ -217,6 +222,11 @@ def ipo_radar_update(limit_per_form: int = Query(default=50, ge=10, le=120), db:
|
|
| 217 |
return update_ipo_radar(db, limit_per_form=limit_per_form)
|
| 218 |
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
@router.get("/market-brain")
|
| 221 |
def market_brain_endpoint(db: Session = Depends(get_db)):
|
| 222 |
return build_market_brain(db, persist=False)
|
|
@@ -227,6 +237,11 @@ def market_brain_latest_endpoint(db: Session = Depends(get_db)):
|
|
| 227 |
return latest_market_brain(db)
|
| 228 |
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
@router.post("/market-brain/run")
|
| 231 |
def market_brain_run(
|
| 232 |
refresh_pipeline: bool = Query(default=False),
|
|
@@ -243,6 +258,45 @@ def market_brain_run(
|
|
| 243 |
return brain
|
| 244 |
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
@router.get("/dashboard/overview")
|
| 247 |
def overview(db: Session = Depends(get_db)):
|
| 248 |
return dashboard_overview(db)
|
|
|
|
| 8 |
from app.core.config import get_settings
|
| 9 |
from app.core.database import get_db
|
| 10 |
from app.ingestion.news_ingestor import NewsIngestor
|
| 11 |
+
from app.models import AIInsight, Asset, EmbeddingVector, NewsArticle, NewsAssetLink, PriceHistory, SentimentAnalysis, SignalSnapshot
|
| 12 |
from app.schemas import AssetOut, MarketUpdateRequest, NewsOut, NewsUpdateRequest, SemanticSearchRequest, SignalRunRequest
|
| 13 |
from app.services.dashboard import dashboard_overview, signal_payload
|
| 14 |
from app.services.etf import list_etf_trends, update_etf_trends
|
| 15 |
+
from app.services.ipo import ipo_radar, sec_company_submissions, update_ipo_radar
|
| 16 |
from app.services.live import live_news, market_sentiment
|
| 17 |
+
from app.services.market_brain import build_market_brain, latest_market_brain, market_brain_history
|
| 18 |
from app.services.market_data import MarketDataService, market_snapshot_for_asset
|
| 19 |
from app.services.pipeline import PipelineService
|
| 20 |
from app.services.realtime import realtime_status
|
|
|
|
| 192 |
return SemanticService().themes(db)
|
| 193 |
|
| 194 |
|
| 195 |
+
@router.get("/themes/{label}")
|
| 196 |
+
def theme_detail(label: str, limit: int = Query(default=60, ge=1, le=160), db: Session = Depends(get_db)):
|
| 197 |
+
return SemanticService().theme_detail(db, label=label, limit=limit)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
@router.get("/etf-trends")
|
| 201 |
def etf_trends(db: Session = Depends(get_db)):
|
| 202 |
return list_etf_trends(db)
|
|
|
|
| 222 |
return update_ipo_radar(db, limit_per_form=limit_per_form)
|
| 223 |
|
| 224 |
|
| 225 |
+
@router.get("/ipo-radar/sec-submissions/{cik}")
|
| 226 |
+
def ipo_sec_submissions(cik: str, persist: bool = Query(default=False), db: Session = Depends(get_db)):
|
| 227 |
+
return sec_company_submissions(db, cik=cik, persist=persist)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
@router.get("/market-brain")
|
| 231 |
def market_brain_endpoint(db: Session = Depends(get_db)):
|
| 232 |
return build_market_brain(db, persist=False)
|
|
|
|
| 237 |
return latest_market_brain(db)
|
| 238 |
|
| 239 |
|
| 240 |
+
@router.get("/market-brain/history")
|
| 241 |
+
def market_brain_history_endpoint(limit: int = Query(default=20, ge=1, le=100), db: Session = Depends(get_db)):
|
| 242 |
+
return market_brain_history(db, limit=limit)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
@router.post("/market-brain/run")
|
| 246 |
def market_brain_run(
|
| 247 |
refresh_pipeline: bool = Query(default=False),
|
|
|
|
| 258 |
return brain
|
| 259 |
|
| 260 |
|
| 261 |
+
@router.get("/ai/models/status")
|
| 262 |
+
def ai_model_status(db: Session = Depends(get_db)):
|
| 263 |
+
sentiment_models = db.execute(
|
| 264 |
+
select(SentimentAnalysis.model_name, func.count(SentimentAnalysis.id))
|
| 265 |
+
.group_by(SentimentAnalysis.model_name)
|
| 266 |
+
.order_by(func.count(SentimentAnalysis.id).desc())
|
| 267 |
+
).all()
|
| 268 |
+
insight_models = db.execute(
|
| 269 |
+
select(AIInsight.model_name, func.count(AIInsight.id))
|
| 270 |
+
.group_by(AIInsight.model_name)
|
| 271 |
+
.order_by(func.count(AIInsight.id).desc())
|
| 272 |
+
).all()
|
| 273 |
+
embedding_models = db.execute(
|
| 274 |
+
select(EmbeddingVector.model_name, func.count(EmbeddingVector.id))
|
| 275 |
+
.group_by(EmbeddingVector.model_name)
|
| 276 |
+
.order_by(func.count(EmbeddingVector.id).desc())
|
| 277 |
+
).all()
|
| 278 |
+
return {
|
| 279 |
+
"model_loading_enabled": settings.enable_model_loading,
|
| 280 |
+
"configured_models": {
|
| 281 |
+
"financial_sentiment": settings.finbert_model,
|
| 282 |
+
"embeddings": settings.embedding_model,
|
| 283 |
+
"reasoning_llm": settings.llm_model,
|
| 284 |
+
"time_series": "statistical-fallback with adapter-ready interface",
|
| 285 |
+
},
|
| 286 |
+
"observed_models": {
|
| 287 |
+
"sentiment": [{"model_name": model, "records": int(count)} for model, count in sentiment_models],
|
| 288 |
+
"embeddings": [{"model_name": model, "records": int(count)} for model, count in embedding_models],
|
| 289 |
+
"insights": [{"model_name": model, "records": int(count)} for model, count in insight_models],
|
| 290 |
+
},
|
| 291 |
+
"fallback_policy": {
|
| 292 |
+
"sentiment": "FinBERT primary when loadable; VADER baseline/fallback is labeled in stored records.",
|
| 293 |
+
"embeddings": "sentence-transformers primary when loadable; deterministic embedding fallback is explicit in code path.",
|
| 294 |
+
"reasoning": "Configured LLM when loadable; deterministic evidence reasoner fallback never invents data.",
|
| 295 |
+
"time_series": "Transparent statistical fallback until Chronos, TimesFM or PatchTST adapter is enabled.",
|
| 296 |
+
},
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
@router.get("/dashboard/overview")
|
| 301 |
def overview(db: Session = Depends(get_db)):
|
| 302 |
return dashboard_overview(db)
|
backend/app/models.py
CHANGED
|
@@ -127,6 +127,9 @@ class SignalSnapshot(Base):
|
|
| 127 |
blum_score: Mapped[float] = mapped_column(Float, index=True)
|
| 128 |
risk_level: Mapped[str] = mapped_column(String(40), index=True)
|
| 129 |
time_horizon: Mapped[str] = mapped_column(String(80), default="Short/Medium term")
|
|
|
|
|
|
|
|
|
|
| 130 |
score_breakdown: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 131 |
technical_summary: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 132 |
narrative_summary: Mapped[dict] = mapped_column(JsonType, default=dict)
|
|
|
|
| 127 |
blum_score: Mapped[float] = mapped_column(Float, index=True)
|
| 128 |
risk_level: Mapped[str] = mapped_column(String(40), index=True)
|
| 129 |
time_horizon: Mapped[str] = mapped_column(String(80), default="Short/Medium term")
|
| 130 |
+
score_version: Mapped[str] = mapped_column(String(40), default="blum-score-v0.4", index=True)
|
| 131 |
+
confidence_score: Mapped[float] = mapped_column(Float, default=0.0, index=True)
|
| 132 |
+
lifecycle_state: Mapped[str] = mapped_column(String(40), default="new", index=True)
|
| 133 |
score_breakdown: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 134 |
technical_summary: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 135 |
narrative_summary: Mapped[dict] = mapped_column(JsonType, default=dict)
|
backend/app/services/dashboard.py
CHANGED
|
@@ -54,6 +54,9 @@ def signal_payload(signal: SignalSnapshot, db: Session | None = None) -> dict:
|
|
| 54 |
"blum_score": signal.blum_score,
|
| 55 |
"risk_level": signal.risk_level,
|
| 56 |
"time_horizon": signal.time_horizon,
|
|
|
|
|
|
|
|
|
|
| 57 |
"score_breakdown": signal.score_breakdown,
|
| 58 |
"explanation": signal.explanation,
|
| 59 |
"watch_points": signal.watch_points,
|
|
|
|
| 54 |
"blum_score": signal.blum_score,
|
| 55 |
"risk_level": signal.risk_level,
|
| 56 |
"time_horizon": signal.time_horizon,
|
| 57 |
+
"score_version": signal.score_version,
|
| 58 |
+
"confidence_score": signal.confidence_score,
|
| 59 |
+
"lifecycle_state": signal.lifecycle_state,
|
| 60 |
"score_breakdown": signal.score_breakdown,
|
| 61 |
"explanation": signal.explanation,
|
| 62 |
"watch_points": signal.watch_points,
|
backend/app/services/ipo.py
CHANGED
|
@@ -19,7 +19,9 @@ settings = get_settings()
|
|
| 19 |
|
| 20 |
SEC_CURRENT_FORMS = ["S-1", "S-1/A", "F-1", "F-1/A", "424B1", "424B4"]
|
| 21 |
SEC_CURRENT_URL = "https://www.sec.gov/cgi-bin/browse-edgar"
|
|
|
|
| 22 |
SEC_SOURCE = "SEC EDGAR current filings"
|
|
|
|
| 23 |
IPO_NEWS_KEYWORDS = [
|
| 24 |
"ipo",
|
| 25 |
"initial public offering",
|
|
@@ -172,6 +174,150 @@ def ipo_radar(db: Session, limit: int = 80) -> dict:
|
|
| 172 |
}
|
| 173 |
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
def fetch_sec_current_filings(form_type: str, count: int) -> list[dict]:
|
| 176 |
response = requests.get(
|
| 177 |
SEC_CURRENT_URL,
|
|
|
|
| 19 |
|
| 20 |
SEC_CURRENT_FORMS = ["S-1", "S-1/A", "F-1", "F-1/A", "424B1", "424B4"]
|
| 21 |
SEC_CURRENT_URL = "https://www.sec.gov/cgi-bin/browse-edgar"
|
| 22 |
+
SEC_SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik}.json"
|
| 23 |
SEC_SOURCE = "SEC EDGAR current filings"
|
| 24 |
+
SEC_SUBMISSIONS_SOURCE = "SEC EDGAR company submissions API"
|
| 25 |
IPO_NEWS_KEYWORDS = [
|
| 26 |
"ipo",
|
| 27 |
"initial public offering",
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
|
| 177 |
+
def sec_company_submissions(db: Session, cik: str, persist: bool = False) -> dict:
|
| 178 |
+
normalized_cik = normalize_cik(cik)
|
| 179 |
+
payload = fetch_sec_company_submissions(normalized_cik)
|
| 180 |
+
recent = payload.get("filings", {}).get("recent", {})
|
| 181 |
+
filings = normalize_company_submission_filings(payload, recent)
|
| 182 |
+
ipo_related = [filing for filing in filings if filing["form_type"] in set(SEC_CURRENT_FORMS + ["424B2", "424B3", "424B5"])]
|
| 183 |
+
|
| 184 |
+
company = db.scalar(select(IPOCompany).where(IPOCompany.cik == normalized_cik))
|
| 185 |
+
if company is not None:
|
| 186 |
+
metadata = dict(company.company_metadata or {})
|
| 187 |
+
metadata["sec_submissions"] = {
|
| 188 |
+
"entity_type": payload.get("entityType"),
|
| 189 |
+
"sic": payload.get("sic"),
|
| 190 |
+
"sic_description": payload.get("sicDescription"),
|
| 191 |
+
"exchanges": payload.get("exchanges", []),
|
| 192 |
+
"tickers": payload.get("tickers", []),
|
| 193 |
+
"filing_count": len(filings),
|
| 194 |
+
"ipo_related_filing_count": len(ipo_related),
|
| 195 |
+
"last_enriched_at": datetime.utcnow().isoformat(),
|
| 196 |
+
"source": SEC_SUBMISSIONS_SOURCE,
|
| 197 |
+
}
|
| 198 |
+
company.company_metadata = metadata
|
| 199 |
+
if payload.get("tickers") and not company.ticker:
|
| 200 |
+
company.ticker = payload["tickers"][0]
|
| 201 |
+
if payload.get("exchanges") and not company.exchange:
|
| 202 |
+
company.exchange = payload["exchanges"][0]
|
| 203 |
+
if payload.get("sicDescription") and company.industry == "Unknown":
|
| 204 |
+
company.industry = payload["sicDescription"]
|
| 205 |
+
company.last_seen_at = datetime.utcnow()
|
| 206 |
+
|
| 207 |
+
inserted = 0
|
| 208 |
+
if persist and company is not None:
|
| 209 |
+
for filing in ipo_related:
|
| 210 |
+
accession = filing["accession_number"]
|
| 211 |
+
if db.scalar(select(IPOFiling).where(IPOFiling.accession_number == accession)):
|
| 212 |
+
continue
|
| 213 |
+
db.add(
|
| 214 |
+
IPOFiling(
|
| 215 |
+
company_id=company.id,
|
| 216 |
+
cik=normalized_cik,
|
| 217 |
+
company_name=company.name,
|
| 218 |
+
form_type=filing["form_type"],
|
| 219 |
+
filing_date=filing["filing_date"],
|
| 220 |
+
title=f"{filing['form_type']} - {company.name}",
|
| 221 |
+
url=filing["url"],
|
| 222 |
+
accession_number=accession,
|
| 223 |
+
source=SEC_SUBMISSIONS_SOURCE,
|
| 224 |
+
raw_payload={**filing, "filing_date": filing["filing_date"].isoformat() if filing["filing_date"] else None},
|
| 225 |
+
)
|
| 226 |
+
)
|
| 227 |
+
inserted += 1
|
| 228 |
+
if inserted:
|
| 229 |
+
db.flush()
|
| 230 |
+
score = score_ipo_company(db, company)
|
| 231 |
+
if score:
|
| 232 |
+
db.add(score)
|
| 233 |
+
db.commit()
|
| 234 |
+
elif company is not None:
|
| 235 |
+
db.commit()
|
| 236 |
+
|
| 237 |
+
return {
|
| 238 |
+
"cik": normalized_cik,
|
| 239 |
+
"name": payload.get("name"),
|
| 240 |
+
"entity_type": payload.get("entityType"),
|
| 241 |
+
"sic": payload.get("sic"),
|
| 242 |
+
"sic_description": payload.get("sicDescription"),
|
| 243 |
+
"tickers": payload.get("tickers", []),
|
| 244 |
+
"exchanges": payload.get("exchanges", []),
|
| 245 |
+
"fiscal_year_end": payload.get("fiscalYearEnd"),
|
| 246 |
+
"filing_count": len(filings),
|
| 247 |
+
"ipo_related_filing_count": len(ipo_related),
|
| 248 |
+
"recent_filings": filings[:80],
|
| 249 |
+
"ipo_related_filings": ipo_related[:80],
|
| 250 |
+
"persisted_new_ipo_filings": inserted,
|
| 251 |
+
"source": SEC_SUBMISSIONS_SOURCE,
|
| 252 |
+
"data_policy": "Official SEC company submissions data. No unavailable listing dates, valuations or tickers are inferred.",
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def fetch_sec_company_submissions(cik: str) -> dict:
|
| 257 |
+
response = requests.get(
|
| 258 |
+
SEC_SUBMISSIONS_URL.format(cik=normalize_cik(cik)),
|
| 259 |
+
headers={"User-Agent": settings.sec_user_agent, "Accept-Encoding": "gzip, deflate", "Host": "data.sec.gov"},
|
| 260 |
+
timeout=24,
|
| 261 |
+
)
|
| 262 |
+
response.raise_for_status()
|
| 263 |
+
return response.json()
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def normalize_company_submission_filings(payload: dict, recent: dict) -> list[dict]:
|
| 267 |
+
forms = recent.get("form", []) or []
|
| 268 |
+
accession_numbers = recent.get("accessionNumber", []) or []
|
| 269 |
+
filing_dates = recent.get("filingDate", []) or []
|
| 270 |
+
report_dates = recent.get("reportDate", []) or []
|
| 271 |
+
documents = recent.get("primaryDocument", []) or []
|
| 272 |
+
descriptions = recent.get("primaryDocDescription", []) or []
|
| 273 |
+
cik = normalize_cik(str(payload.get("cik", "")))
|
| 274 |
+
filings = []
|
| 275 |
+
for index, form_type in enumerate(forms):
|
| 276 |
+
accession = safe_list_value(accession_numbers, index)
|
| 277 |
+
document = safe_list_value(documents, index)
|
| 278 |
+
filing_date = parse_sec_date(safe_list_value(filing_dates, index))
|
| 279 |
+
filings.append(
|
| 280 |
+
{
|
| 281 |
+
"form_type": form_type,
|
| 282 |
+
"accession_number": accession,
|
| 283 |
+
"filing_date": filing_date,
|
| 284 |
+
"report_date": safe_list_value(report_dates, index),
|
| 285 |
+
"primary_document": document,
|
| 286 |
+
"description": safe_list_value(descriptions, index),
|
| 287 |
+
"url": sec_document_url(cik, accession, document),
|
| 288 |
+
"source": SEC_SUBMISSIONS_SOURCE,
|
| 289 |
+
}
|
| 290 |
+
)
|
| 291 |
+
return filings
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def normalize_cik(cik: str) -> str:
|
| 295 |
+
digits = re.sub(r"\D", "", cik or "")
|
| 296 |
+
return digits.zfill(10)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def parse_sec_date(value: str | None) -> datetime | None:
|
| 300 |
+
if not value:
|
| 301 |
+
return None
|
| 302 |
+
try:
|
| 303 |
+
return datetime.strptime(value, "%Y-%m-%d")
|
| 304 |
+
except Exception:
|
| 305 |
+
return None
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def safe_list_value(values: list, index: int):
|
| 309 |
+
if index >= len(values):
|
| 310 |
+
return None
|
| 311 |
+
return values[index]
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def sec_document_url(cik: str, accession: str | None, document: str | None) -> str:
|
| 315 |
+
if not cik or not accession or not document:
|
| 316 |
+
return ""
|
| 317 |
+
compact_accession = accession.replace("-", "")
|
| 318 |
+
return f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{compact_accession}/{document}"
|
| 319 |
+
|
| 320 |
+
|
| 321 |
def fetch_sec_current_filings(form_type: str, count: int) -> list[dict]:
|
| 322 |
response = requests.get(
|
| 323 |
SEC_CURRENT_URL,
|
backend/app/services/market_brain.py
CHANGED
|
@@ -16,6 +16,7 @@ from app.services.stock import stock_radar
|
|
| 16 |
|
| 17 |
|
| 18 |
def build_market_brain(db: Session, persist: bool = True) -> dict:
|
|
|
|
| 19 |
overview = dashboard_overview(db)
|
| 20 |
sentiment = market_sentiment(db, hours=48)
|
| 21 |
stocks = stock_radar(db, limit=100)
|
|
@@ -31,6 +32,8 @@ def build_market_brain(db: Session, persist: bool = True) -> dict:
|
|
| 31 |
risks = build_risk_alerts(stocks, latest_signals, sentiment, ipo, overview)
|
| 32 |
evidence = evidence_ledger(overview, sentiment, stocks, etfs, ipo, news, latest_signals)
|
| 33 |
summary = brain_summary(regime, brain_score, opportunity_stack, risks)
|
|
|
|
|
|
|
| 34 |
|
| 35 |
payload = {
|
| 36 |
"run_id": f"brain-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}",
|
|
@@ -52,7 +55,10 @@ def build_market_brain(db: Session, persist: bool = True) -> dict:
|
|
| 52 |
"opportunity_stack": opportunity_stack,
|
| 53 |
"forward_scenarios": scenarios,
|
| 54 |
"risk_alerts": risks,
|
|
|
|
|
|
|
| 55 |
"evidence_ledger": evidence,
|
|
|
|
| 56 |
"model_stack": {
|
| 57 |
"sentiment": "FinBERT-led sentiment records when model loading is available; VADER remains a baseline comparator.",
|
| 58 |
"semantic": "Sentence-transformer embeddings and theme clusters where stored news embeddings exist.",
|
|
@@ -62,6 +68,7 @@ def build_market_brain(db: Session, persist: bool = True) -> dict:
|
|
| 62 |
},
|
| 63 |
"disclaimer": "Educational research case study only. Not financial advice, not a recommendation and not an operational trading signal.",
|
| 64 |
}
|
|
|
|
| 65 |
|
| 66 |
if persist:
|
| 67 |
db.add(
|
|
@@ -79,6 +86,25 @@ def build_market_brain(db: Session, persist: bool = True) -> dict:
|
|
| 79 |
return payload
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
def latest_market_brain(db: Session) -> dict:
|
| 83 |
snapshot = db.scalar(select(MarketBrainSnapshot).order_by(desc(MarketBrainSnapshot.created_at)).limit(1))
|
| 84 |
if snapshot is None:
|
|
@@ -245,6 +271,159 @@ def build_risk_alerts(stocks: dict, signals: list[SignalSnapshot], sentiment: di
|
|
| 245 |
return alerts
|
| 246 |
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
def evidence_ledger(overview: dict, sentiment: dict, stocks: dict, etfs: list[dict], ipo: dict, news: list[dict], signals: list[SignalSnapshot]) -> dict:
|
| 249 |
return {
|
| 250 |
"stored_assets": overview["market_pulse"]["asset_count"],
|
|
@@ -262,6 +441,16 @@ def evidence_ledger(overview: dict, sentiment: dict, stocks: dict, etfs: list[di
|
|
| 262 |
}
|
| 263 |
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
def brain_summary(regime: str, brain_score: float, opportunity_stack: dict, risks: list[dict]) -> str:
|
| 266 |
stocks = len(opportunity_stack.get("stock_research_priorities", []))
|
| 267 |
etfs = len(opportunity_stack.get("etf_rotation_leaders", []))
|
|
@@ -365,3 +554,12 @@ def probability_from_regime(regime: str, scenario: str) -> int:
|
|
| 365 |
|
| 366 |
def clamp(value: float, low: float = 0, high: float = 100) -> float:
|
| 367 |
return max(low, min(high, float(value)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def build_market_brain(db: Session, persist: bool = True) -> dict:
|
| 19 |
+
previous = db.scalar(select(MarketBrainSnapshot).order_by(desc(MarketBrainSnapshot.created_at)).limit(1))
|
| 20 |
overview = dashboard_overview(db)
|
| 21 |
sentiment = market_sentiment(db, hours=48)
|
| 22 |
stocks = stock_radar(db, limit=100)
|
|
|
|
| 32 |
risks = build_risk_alerts(stocks, latest_signals, sentiment, ipo, overview)
|
| 33 |
evidence = evidence_ledger(overview, sentiment, stocks, etfs, ipo, news, latest_signals)
|
| 34 |
summary = brain_summary(regime, brain_score, opportunity_stack, risks)
|
| 35 |
+
contradictions = detect_contradictions(stocks, latest_signals, sentiment)
|
| 36 |
+
event_graph = build_event_graph(opportunity_stack, sentiment, news)
|
| 37 |
|
| 38 |
payload = {
|
| 39 |
"run_id": f"brain-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}",
|
|
|
|
| 55 |
"opportunity_stack": opportunity_stack,
|
| 56 |
"forward_scenarios": scenarios,
|
| 57 |
"risk_alerts": risks,
|
| 58 |
+
"contradictions": contradictions,
|
| 59 |
+
"event_graph": event_graph,
|
| 60 |
"evidence_ledger": evidence,
|
| 61 |
+
"change_log": [],
|
| 62 |
"model_stack": {
|
| 63 |
"sentiment": "FinBERT-led sentiment records when model loading is available; VADER remains a baseline comparator.",
|
| 64 |
"semantic": "Sentence-transformer embeddings and theme clusters where stored news embeddings exist.",
|
|
|
|
| 68 |
},
|
| 69 |
"disclaimer": "Educational research case study only. Not financial advice, not a recommendation and not an operational trading signal.",
|
| 70 |
}
|
| 71 |
+
payload["change_log"] = compare_with_previous(previous.structured_output if previous else None, payload)
|
| 72 |
|
| 73 |
if persist:
|
| 74 |
db.add(
|
|
|
|
| 86 |
return payload
|
| 87 |
|
| 88 |
|
| 89 |
+
def market_brain_history(db: Session, limit: int = 20) -> list[dict]:
|
| 90 |
+
snapshots = db.scalars(select(MarketBrainSnapshot).order_by(desc(MarketBrainSnapshot.created_at)).limit(limit)).all()
|
| 91 |
+
return [
|
| 92 |
+
{
|
| 93 |
+
"run_id": snapshot.run_id,
|
| 94 |
+
"created_at": snapshot.created_at,
|
| 95 |
+
"brain_score": snapshot.brain_score,
|
| 96 |
+
"regime": snapshot.regime,
|
| 97 |
+
"summary": snapshot.summary,
|
| 98 |
+
"risk_alert_count": len((snapshot.structured_output or {}).get("risk_alerts", [])),
|
| 99 |
+
"contradiction_count": len((snapshot.structured_output or {}).get("contradictions", [])),
|
| 100 |
+
"top_stock": first_stack_name(snapshot.structured_output, "stock_research_priorities"),
|
| 101 |
+
"top_etf": first_stack_name(snapshot.structured_output, "etf_rotation_leaders"),
|
| 102 |
+
"top_ipo": first_stack_name(snapshot.structured_output, "ipo_watch"),
|
| 103 |
+
}
|
| 104 |
+
for snapshot in snapshots
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
def latest_market_brain(db: Session) -> dict:
|
| 109 |
snapshot = db.scalar(select(MarketBrainSnapshot).order_by(desc(MarketBrainSnapshot.created_at)).limit(1))
|
| 110 |
if snapshot is None:
|
|
|
|
| 271 |
return alerts
|
| 272 |
|
| 273 |
|
| 274 |
+
def detect_contradictions(stocks: dict, signals: list[SignalSnapshot], sentiment: dict) -> list[dict]:
|
| 275 |
+
contradictions: list[dict] = []
|
| 276 |
+
for row in stocks.get("rows", []):
|
| 277 |
+
snapshot = row.get("market_snapshot") or {}
|
| 278 |
+
narrative = row.get("narrative_flags") or {}
|
| 279 |
+
technical = row.get("technical_flags") or {}
|
| 280 |
+
signal = row.get("signal") or {}
|
| 281 |
+
perf_5d = numeric(snapshot.get("perf_5d"))
|
| 282 |
+
sentiment_7d = numeric(narrative.get("sentiment_7d"))
|
| 283 |
+
rsi = numeric(technical.get("rsi"))
|
| 284 |
+
if perf_5d >= 4 and sentiment_7d <= -0.15:
|
| 285 |
+
contradictions.append(
|
| 286 |
+
{
|
| 287 |
+
"type": "price_up_sentiment_down",
|
| 288 |
+
"severity": "High" if perf_5d >= 8 else "Medium",
|
| 289 |
+
"ticker": row.get("ticker"),
|
| 290 |
+
"title": f"{row.get('ticker')} price strength conflicts with negative 7D sentiment.",
|
| 291 |
+
"evidence": {"perf_5d": perf_5d, "sentiment_7d": sentiment_7d, "classification": signal.get("classification")},
|
| 292 |
+
}
|
| 293 |
+
)
|
| 294 |
+
if perf_5d <= -4 and sentiment_7d >= 0.18:
|
| 295 |
+
contradictions.append(
|
| 296 |
+
{
|
| 297 |
+
"type": "price_down_sentiment_up",
|
| 298 |
+
"severity": "Medium",
|
| 299 |
+
"ticker": row.get("ticker"),
|
| 300 |
+
"title": f"{row.get('ticker')} price weakness conflicts with positive 7D sentiment.",
|
| 301 |
+
"evidence": {"perf_5d": perf_5d, "sentiment_7d": sentiment_7d, "classification": signal.get("classification")},
|
| 302 |
+
}
|
| 303 |
+
)
|
| 304 |
+
if rsi >= 72 and signal.get("risk_level") == "High":
|
| 305 |
+
contradictions.append(
|
| 306 |
+
{
|
| 307 |
+
"type": "overbought_high_risk",
|
| 308 |
+
"severity": "Medium",
|
| 309 |
+
"ticker": row.get("ticker"),
|
| 310 |
+
"title": f"{row.get('ticker')} combines elevated RSI with high risk classification.",
|
| 311 |
+
"evidence": {"rsi": rsi, "risk_level": signal.get("risk_level"), "score": signal.get("blum_score")},
|
| 312 |
+
}
|
| 313 |
+
)
|
| 314 |
+
market_sentiment = numeric(sentiment.get("average_sentiment"))
|
| 315 |
+
if market_sentiment < -0.12:
|
| 316 |
+
bullish = [signal for signal in signals if signal.blum_score >= 72]
|
| 317 |
+
if bullish:
|
| 318 |
+
contradictions.append(
|
| 319 |
+
{
|
| 320 |
+
"type": "bullish_signals_negative_tape",
|
| 321 |
+
"severity": "Medium",
|
| 322 |
+
"ticker": "MARKET",
|
| 323 |
+
"title": "High-scoring signals exist while market-wide sentiment is negative.",
|
| 324 |
+
"evidence": {"average_sentiment": market_sentiment, "bullish_signal_count": len(bullish)},
|
| 325 |
+
}
|
| 326 |
+
)
|
| 327 |
+
return contradictions[:24]
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def build_event_graph(opportunity_stack: dict, sentiment: dict, news: list[dict]) -> dict:
|
| 331 |
+
nodes: list[dict] = []
|
| 332 |
+
edges: list[dict] = []
|
| 333 |
+
|
| 334 |
+
def add_node(node_id: str, label: str, node_type: str, score: float | None = None) -> None:
|
| 335 |
+
if any(node["id"] == node_id for node in nodes):
|
| 336 |
+
return
|
| 337 |
+
nodes.append({"id": node_id, "label": label, "type": node_type, "score": score})
|
| 338 |
+
|
| 339 |
+
add_node("market", "Market Brain", "system", None)
|
| 340 |
+
for theme in sentiment.get("themes", [])[:8]:
|
| 341 |
+
node_id = f"theme:{theme['theme']}"
|
| 342 |
+
add_node(node_id, theme["theme"], "theme", theme.get("avg_sentiment"))
|
| 343 |
+
edges.append({"source": "market", "target": node_id, "relationship": "theme_detected", "weight": theme.get("headline_count", 0)})
|
| 344 |
+
|
| 345 |
+
for item in opportunity_stack.get("stock_research_priorities", [])[:8]:
|
| 346 |
+
node_id = f"stock:{item.get('ticker')}"
|
| 347 |
+
add_node(node_id, item.get("ticker") or "Stock", "stock", item.get("score"))
|
| 348 |
+
edges.append({"source": "market", "target": node_id, "relationship": "research_priority", "weight": item.get("score") or 0})
|
| 349 |
+
for tag in (item.get("tags") or [])[:3]:
|
| 350 |
+
theme_id = f"theme:{tag}"
|
| 351 |
+
add_node(theme_id, tag, "theme", None)
|
| 352 |
+
edges.append({"source": theme_id, "target": node_id, "relationship": "tag_link", "weight": 1})
|
| 353 |
+
|
| 354 |
+
for item in opportunity_stack.get("etf_rotation_leaders", [])[:6]:
|
| 355 |
+
node_id = f"etf:{item.get('ticker')}"
|
| 356 |
+
add_node(node_id, item.get("ticker") or "ETF", "etf", item.get("confirmation_score"))
|
| 357 |
+
edges.append({"source": "market", "target": node_id, "relationship": "rotation_confirmation", "weight": item.get("confirmation_score") or 0})
|
| 358 |
+
|
| 359 |
+
for item in opportunity_stack.get("ipo_watch", [])[:6]:
|
| 360 |
+
node_id = f"ipo:{item.get('name')}"
|
| 361 |
+
add_node(node_id, item.get("name") or "IPO candidate", "ipo", item.get("opportunity_score"))
|
| 362 |
+
edges.append({"source": "market", "target": node_id, "relationship": "primary_market_watch", "weight": item.get("opportunity_score") or 0})
|
| 363 |
+
|
| 364 |
+
for article in news[:8]:
|
| 365 |
+
node_id = f"news:{article.get('id')}"
|
| 366 |
+
add_node(node_id, article.get("title", "News")[:80], "news", article.get("quality_score"))
|
| 367 |
+
edges.append({"source": "market", "target": node_id, "relationship": "live_news", "weight": article.get("quality_score") or 0})
|
| 368 |
+
|
| 369 |
+
return {"nodes": nodes[:48], "edges": edges[:80]}
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def compare_with_previous(previous: dict | None, current: dict) -> list[dict]:
|
| 373 |
+
if not previous:
|
| 374 |
+
return [{"type": "initial_snapshot", "severity": "Info", "message": "No previous Market Brain snapshot exists yet."}]
|
| 375 |
+
changes: list[dict] = []
|
| 376 |
+
if previous.get("regime") != current.get("regime"):
|
| 377 |
+
changes.append(
|
| 378 |
+
{
|
| 379 |
+
"type": "regime_change",
|
| 380 |
+
"severity": "High",
|
| 381 |
+
"message": f"Regime changed from {previous.get('regime')} to {current.get('regime')}.",
|
| 382 |
+
}
|
| 383 |
+
)
|
| 384 |
+
previous_score = numeric(previous.get("brain_score"))
|
| 385 |
+
current_score = numeric(current.get("brain_score"))
|
| 386 |
+
delta = round(current_score - previous_score, 2)
|
| 387 |
+
if abs(delta) >= 5:
|
| 388 |
+
changes.append(
|
| 389 |
+
{
|
| 390 |
+
"type": "brain_score_change",
|
| 391 |
+
"severity": "Medium",
|
| 392 |
+
"message": f"Brain score moved {delta:+.2f} points.",
|
| 393 |
+
"previous": previous_score,
|
| 394 |
+
"current": current_score,
|
| 395 |
+
}
|
| 396 |
+
)
|
| 397 |
+
for key, label in [
|
| 398 |
+
("stock_research_priorities", "top stock"),
|
| 399 |
+
("etf_rotation_leaders", "top ETF"),
|
| 400 |
+
("ipo_watch", "top IPO watch"),
|
| 401 |
+
]:
|
| 402 |
+
old = first_stack_name(previous, key)
|
| 403 |
+
new = first_stack_name(current, key)
|
| 404 |
+
if old and new and old != new:
|
| 405 |
+
changes.append(
|
| 406 |
+
{
|
| 407 |
+
"type": f"{key}_leader_change",
|
| 408 |
+
"severity": "Info",
|
| 409 |
+
"message": f"{label.title()} changed from {old} to {new}.",
|
| 410 |
+
"previous": old,
|
| 411 |
+
"current": new,
|
| 412 |
+
}
|
| 413 |
+
)
|
| 414 |
+
previous_risk_count = len(previous.get("risk_alerts", []))
|
| 415 |
+
current_risk_count = len(current.get("risk_alerts", []))
|
| 416 |
+
if previous_risk_count != current_risk_count:
|
| 417 |
+
changes.append(
|
| 418 |
+
{
|
| 419 |
+
"type": "risk_alert_count_change",
|
| 420 |
+
"severity": "Info",
|
| 421 |
+
"message": f"Risk alerts changed from {previous_risk_count} to {current_risk_count}.",
|
| 422 |
+
}
|
| 423 |
+
)
|
| 424 |
+
return changes or [{"type": "stable_snapshot", "severity": "Info", "message": "No material Market Brain change versus the previous snapshot."}]
|
| 425 |
+
|
| 426 |
+
|
| 427 |
def evidence_ledger(overview: dict, sentiment: dict, stocks: dict, etfs: list[dict], ipo: dict, news: list[dict], signals: list[SignalSnapshot]) -> dict:
|
| 428 |
return {
|
| 429 |
"stored_assets": overview["market_pulse"]["asset_count"],
|
|
|
|
| 441 |
}
|
| 442 |
|
| 443 |
|
| 444 |
+
def first_stack_name(payload: dict | None, key: str) -> str | None:
|
| 445 |
+
if not payload:
|
| 446 |
+
return None
|
| 447 |
+
rows = (payload.get("opportunity_stack") or {}).get(key, [])
|
| 448 |
+
if not rows:
|
| 449 |
+
return None
|
| 450 |
+
first = rows[0]
|
| 451 |
+
return first.get("ticker") or first.get("name")
|
| 452 |
+
|
| 453 |
+
|
| 454 |
def brain_summary(regime: str, brain_score: float, opportunity_stack: dict, risks: list[dict]) -> str:
|
| 455 |
stocks = len(opportunity_stack.get("stock_research_priorities", []))
|
| 456 |
etfs = len(opportunity_stack.get("etf_rotation_leaders", []))
|
|
|
|
| 554 |
|
| 555 |
def clamp(value: float, low: float = 0, high: float = 100) -> float:
|
| 556 |
return max(low, min(high, float(value)))
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def numeric(value) -> float:
|
| 560 |
+
try:
|
| 561 |
+
if value is None:
|
| 562 |
+
return 0.0
|
| 563 |
+
return float(value)
|
| 564 |
+
except Exception:
|
| 565 |
+
return 0.0
|
backend/app/services/semantic.py
CHANGED
|
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
|
|
| 5 |
from sklearn.cluster import KMeans
|
| 6 |
|
| 7 |
from app.ai.orchestrator import AIOrchestrator
|
| 8 |
-
from app.models import EmbeddingVector, NewsArticle, ThemeCluster
|
| 9 |
|
| 10 |
|
| 11 |
class SemanticService:
|
|
@@ -78,6 +78,67 @@ class SemanticService:
|
|
| 78 |
for label, value in sorted(clusters.items(), key=lambda item: item[1]["articles"], reverse=True)
|
| 79 |
]
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
def semantic_clusters(rows, vectors: list[list[float]]) -> list[dict]:
|
| 83 |
import numpy as np
|
|
@@ -116,3 +177,15 @@ def semantic_clusters(rows, vectors: list[list[float]]) -> list[dict]:
|
|
| 116 |
}
|
| 117 |
)
|
| 118 |
return sorted(output, key=lambda item: item["article_count"], reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from sklearn.cluster import KMeans
|
| 6 |
|
| 7 |
from app.ai.orchestrator import AIOrchestrator
|
| 8 |
+
from app.models import Asset, EmbeddingVector, NewsArticle, NewsAssetLink, SentimentAnalysis, ThemeCluster
|
| 9 |
|
| 10 |
|
| 11 |
class SemanticService:
|
|
|
|
| 78 |
for label, value in sorted(clusters.items(), key=lambda item: item[1]["articles"], reverse=True)
|
| 79 |
]
|
| 80 |
|
| 81 |
+
def theme_detail(self, db: Session, label: str, limit: int = 60) -> dict:
|
| 82 |
+
normalized = label.strip().lower()
|
| 83 |
+
articles = db.scalars(select(NewsArticle).order_by(desc(NewsArticle.published_at), desc(NewsArticle.created_at)).limit(700)).all()
|
| 84 |
+
matched = [
|
| 85 |
+
article for article in articles
|
| 86 |
+
if normalized in [str(theme).lower() for theme in article.theme_tags.get("themes", [])]
|
| 87 |
+
or normalized in article.title.lower()
|
| 88 |
+
or normalized in article.summary.lower()
|
| 89 |
+
][:limit]
|
| 90 |
+
article_ids = [article.id for article in matched]
|
| 91 |
+
sentiments = []
|
| 92 |
+
links_by_article: dict[int, list[dict]] = {}
|
| 93 |
+
if article_ids:
|
| 94 |
+
sentiments = db.scalars(
|
| 95 |
+
select(SentimentAnalysis)
|
| 96 |
+
.where(SentimentAnalysis.article_id.in_(article_ids))
|
| 97 |
+
.order_by(desc(SentimentAnalysis.created_at))
|
| 98 |
+
).all()
|
| 99 |
+
link_rows = db.execute(
|
| 100 |
+
select(NewsAssetLink.article_id, Asset.ticker, Asset.name, Asset.sector, NewsAssetLink.relevance_score)
|
| 101 |
+
.join(Asset, Asset.id == NewsAssetLink.asset_id)
|
| 102 |
+
.where(NewsAssetLink.article_id.in_(article_ids))
|
| 103 |
+
).all()
|
| 104 |
+
for article_id, ticker, name, sector, relevance in link_rows:
|
| 105 |
+
links_by_article.setdefault(article_id, []).append(
|
| 106 |
+
{"ticker": ticker, "name": name, "sector": sector, "relevance_score": relevance}
|
| 107 |
+
)
|
| 108 |
+
sentiment_by_article = {}
|
| 109 |
+
for sentiment in sentiments:
|
| 110 |
+
sentiment_by_article.setdefault(sentiment.article_id, sentiment)
|
| 111 |
+
scores = [float(sentiment.score) for sentiment in sentiments]
|
| 112 |
+
source_counts: dict[str, int] = {}
|
| 113 |
+
asset_counts: dict[str, dict] = {}
|
| 114 |
+
for article in matched:
|
| 115 |
+
source_counts[article.source] = source_counts.get(article.source, 0) + 1
|
| 116 |
+
for asset in links_by_article.get(article.id, []):
|
| 117 |
+
item = asset_counts.setdefault(asset["ticker"], {"ticker": asset["ticker"], "name": asset["name"], "sector": asset["sector"], "mentions": 0})
|
| 118 |
+
item["mentions"] += 1
|
| 119 |
+
return {
|
| 120 |
+
"label": label,
|
| 121 |
+
"article_count": len(matched),
|
| 122 |
+
"average_sentiment": round(sum(scores) / len(scores), 4) if scores else 0,
|
| 123 |
+
"source_mix": sorted([{"source": source, "count": count} for source, count in source_counts.items()], key=lambda item: item["count"], reverse=True),
|
| 124 |
+
"linked_assets": sorted(asset_counts.values(), key=lambda item: item["mentions"], reverse=True)[:20],
|
| 125 |
+
"articles": [
|
| 126 |
+
{
|
| 127 |
+
"id": article.id,
|
| 128 |
+
"title": article.title,
|
| 129 |
+
"summary": article.summary,
|
| 130 |
+
"source": article.source,
|
| 131 |
+
"url": article.url,
|
| 132 |
+
"published_at": article.published_at,
|
| 133 |
+
"quality_score": article.quality_score,
|
| 134 |
+
"theme_tags": article.theme_tags,
|
| 135 |
+
"sentiment": sentiment_payload(sentiment_by_article.get(article.id)),
|
| 136 |
+
"linked_assets": links_by_article.get(article.id, [])[:8],
|
| 137 |
+
}
|
| 138 |
+
for article in matched
|
| 139 |
+
],
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
|
| 143 |
def semantic_clusters(rows, vectors: list[list[float]]) -> list[dict]:
|
| 144 |
import numpy as np
|
|
|
|
| 177 |
}
|
| 178 |
)
|
| 179 |
return sorted(output, key=lambda item: item["article_count"], reverse=True)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def sentiment_payload(row: SentimentAnalysis | None) -> dict | None:
|
| 183 |
+
if row is None:
|
| 184 |
+
return None
|
| 185 |
+
return {
|
| 186 |
+
"model_name": row.model_name,
|
| 187 |
+
"label": row.label,
|
| 188 |
+
"score": row.score,
|
| 189 |
+
"confidence": row.confidence,
|
| 190 |
+
"baseline_vader": row.baseline_vader,
|
| 191 |
+
}
|
backend/app/services/stock.py
CHANGED
|
@@ -103,6 +103,9 @@ def stock_row(db: Session, asset: Asset, signal: SignalSnapshot | None) -> dict:
|
|
| 103 |
"blum_score": signal.blum_score,
|
| 104 |
"risk_level": signal.risk_level,
|
| 105 |
"time_horizon": signal.time_horizon,
|
|
|
|
|
|
|
|
|
|
| 106 |
"score_breakdown": breakdown,
|
| 107 |
"created_at": signal.created_at,
|
| 108 |
},
|
|
@@ -217,6 +220,12 @@ def research_priority(signal: SignalSnapshot, snapshot: dict) -> str:
|
|
| 217 |
return "Data Watch"
|
| 218 |
if signal.risk_level == "High" and score >= 70:
|
| 219 |
return "Risk Review"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
if score >= 78:
|
| 221 |
return "Priority A"
|
| 222 |
if score >= 65:
|
|
@@ -227,7 +236,11 @@ def research_priority(signal: SignalSnapshot, snapshot: dict) -> str:
|
|
| 227 |
|
| 228 |
|
| 229 |
def radar_tags(signal: SignalSnapshot, technical: dict, narrative: dict) -> list[str]:
|
| 230 |
-
tags = [signal.classification, signal.risk_level]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
if technical.get("above_sma20") and technical.get("above_sma50"):
|
| 232 |
tags.append("Trend Confirmed")
|
| 233 |
if technical.get("above_sma200"):
|
|
|
|
| 103 |
"blum_score": signal.blum_score,
|
| 104 |
"risk_level": signal.risk_level,
|
| 105 |
"time_horizon": signal.time_horizon,
|
| 106 |
+
"score_version": signal.score_version,
|
| 107 |
+
"confidence_score": signal.confidence_score,
|
| 108 |
+
"lifecycle_state": signal.lifecycle_state,
|
| 109 |
"score_breakdown": breakdown,
|
| 110 |
"created_at": signal.created_at,
|
| 111 |
},
|
|
|
|
| 220 |
return "Data Watch"
|
| 221 |
if signal.risk_level == "High" and score >= 70:
|
| 222 |
return "Risk Review"
|
| 223 |
+
if signal.lifecycle_state == "confirmed" and score >= 70:
|
| 224 |
+
return "Confirmed Priority"
|
| 225 |
+
if signal.lifecycle_state == "faded":
|
| 226 |
+
return "Fade Watch"
|
| 227 |
+
if signal.lifecycle_state == "invalidated":
|
| 228 |
+
return "Invalidated"
|
| 229 |
if score >= 78:
|
| 230 |
return "Priority A"
|
| 231 |
if score >= 65:
|
|
|
|
| 236 |
|
| 237 |
|
| 238 |
def radar_tags(signal: SignalSnapshot, technical: dict, narrative: dict) -> list[str]:
|
| 239 |
+
tags = [signal.classification, signal.risk_level, signal.lifecycle_state]
|
| 240 |
+
if signal.confidence_score >= 70:
|
| 241 |
+
tags.append("High Confidence")
|
| 242 |
+
if signal.confidence_score < 45:
|
| 243 |
+
tags.append("Low Confidence")
|
| 244 |
if technical.get("above_sma20") and technical.get("above_sma50"):
|
| 245 |
tags.append("Trend Confirmed")
|
| 246 |
if technical.get("above_sma200"):
|
backend/app/signals/engine.py
CHANGED
|
@@ -10,6 +10,9 @@ from app.models import Asset, NewsAssetLink, PriceHistory, SentimentAnalysis, Si
|
|
| 10 |
from app.signals.indicators import compute_indicators
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
class SignalEngine:
|
| 14 |
def __init__(self, ai: AIOrchestrator | None = None):
|
| 15 |
self.ai = ai or AIOrchestrator()
|
|
@@ -30,6 +33,9 @@ class SignalEngine:
|
|
| 30 |
ts = self.ai.time_series.analyze(frame)
|
| 31 |
narrative = self.narrative_features(db, asset)
|
| 32 |
score = build_score(indicators, narrative, ts, asset)
|
|
|
|
|
|
|
|
|
|
| 33 |
explanation_stub = build_rule_explanation(asset, score, indicators, narrative, ts)
|
| 34 |
snapshot = SignalSnapshot(
|
| 35 |
asset_id=asset.id,
|
|
@@ -38,6 +44,9 @@ class SignalEngine:
|
|
| 38 |
blum_score=score["blum_score"],
|
| 39 |
risk_level=score["risk_level"],
|
| 40 |
time_horizon=score["time_horizon"],
|
|
|
|
|
|
|
|
|
|
| 41 |
score_breakdown=score["score_breakdown"],
|
| 42 |
technical_summary={**indicators, "time_series": ts},
|
| 43 |
narrative_summary=narrative,
|
|
@@ -224,6 +233,40 @@ def build_rule_explanation(asset: Asset, score: dict, indicators: dict, narrativ
|
|
| 224 |
)
|
| 225 |
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
def etf_confirmation_proxy(asset: Asset, indicators: dict) -> float:
|
| 228 |
base = scale(indicators.get("relative_strength_vs_benchmark"), -12, 12)
|
| 229 |
if asset.asset_type == "ETF":
|
|
|
|
| 10 |
from app.signals.indicators import compute_indicators
|
| 11 |
|
| 12 |
|
| 13 |
+
SCORE_VERSION = "blum-score-v0.4"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
class SignalEngine:
|
| 17 |
def __init__(self, ai: AIOrchestrator | None = None):
|
| 18 |
self.ai = ai or AIOrchestrator()
|
|
|
|
| 33 |
ts = self.ai.time_series.analyze(frame)
|
| 34 |
narrative = self.narrative_features(db, asset)
|
| 35 |
score = build_score(indicators, narrative, ts, asset)
|
| 36 |
+
previous = latest_signal_for_asset(db, asset.id)
|
| 37 |
+
confidence = confidence_score(frame, indicators, narrative, ts)
|
| 38 |
+
lifecycle = lifecycle_state(previous, score, confidence)
|
| 39 |
explanation_stub = build_rule_explanation(asset, score, indicators, narrative, ts)
|
| 40 |
snapshot = SignalSnapshot(
|
| 41 |
asset_id=asset.id,
|
|
|
|
| 44 |
blum_score=score["blum_score"],
|
| 45 |
risk_level=score["risk_level"],
|
| 46 |
time_horizon=score["time_horizon"],
|
| 47 |
+
score_version=SCORE_VERSION,
|
| 48 |
+
confidence_score=confidence,
|
| 49 |
+
lifecycle_state=lifecycle,
|
| 50 |
score_breakdown=score["score_breakdown"],
|
| 51 |
technical_summary={**indicators, "time_series": ts},
|
| 52 |
narrative_summary=narrative,
|
|
|
|
| 233 |
)
|
| 234 |
|
| 235 |
|
| 236 |
+
def latest_signal_for_asset(db: Session, asset_id: int) -> SignalSnapshot | None:
|
| 237 |
+
return db.scalar(
|
| 238 |
+
select(SignalSnapshot)
|
| 239 |
+
.where(SignalSnapshot.asset_id == asset_id)
|
| 240 |
+
.order_by(desc(SignalSnapshot.created_at))
|
| 241 |
+
.limit(1)
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def confidence_score(frame: pd.DataFrame, indicators: dict, narrative: dict, ts: dict) -> float:
|
| 246 |
+
history_depth = scale(len(frame), 60, 900)
|
| 247 |
+
indicator_completeness = sum(1 for key in ["sma20", "sma50", "sma200", "rsi", "macd_hist", "atr_percent", "support", "resistance"] if indicators.get(key) is not None) / 8 * 100
|
| 248 |
+
news_support = scale(narrative.get("news_count_30d", 0), 0, 12)
|
| 249 |
+
sentiment_quality = scale(abs(narrative.get("sentiment_30d", 0)), 0, 0.55)
|
| 250 |
+
time_series_depth = 80 if ts.get("regime") not in {"unknown", "insufficient_history"} else 35
|
| 251 |
+
return round(avg(history_depth, indicator_completeness, news_support, sentiment_quality, time_series_depth), 1)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def lifecycle_state(previous: SignalSnapshot | None, score: dict, confidence: float) -> str:
|
| 255 |
+
current_score = float(score.get("blum_score", 0))
|
| 256 |
+
if previous is None:
|
| 257 |
+
return "new"
|
| 258 |
+
previous_score = float(previous.blum_score)
|
| 259 |
+
if previous_score >= 65 and current_score < 42:
|
| 260 |
+
return "invalidated"
|
| 261 |
+
if previous_score - current_score >= 12:
|
| 262 |
+
return "faded"
|
| 263 |
+
if previous.classification == score.get("classification") and current_score >= previous_score - 5 and confidence >= 50:
|
| 264 |
+
return "confirmed"
|
| 265 |
+
if current_score - previous_score >= 10:
|
| 266 |
+
return "strengthening"
|
| 267 |
+
return "active"
|
| 268 |
+
|
| 269 |
+
|
| 270 |
def etf_confirmation_proxy(asset: Asset, indicators: dict) -> float:
|
| 271 |
base = scale(indicators.get("relative_strength_vs_benchmark"), -12, 12)
|
| 272 |
if asset.asset_type == "ETF":
|
frontend/app/globals.css
CHANGED
|
@@ -287,9 +287,50 @@ p { color: var(--muted); line-height: 1.55; }
|
|
| 287 |
}
|
| 288 |
.evidence-grid strong, .method-grid strong { display: block; margin-top: 6px; line-height: 1.35; }
|
| 289 |
.method-grid strong { color: #d8e5f2; font-size: 12px; font-weight: 700; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
.opportunity-panel { min-height: 520px; }
|
| 291 |
.ipo-card { min-height: 410px; }
|
| 292 |
.ipo-card .button { display: inline-flex; width: fit-content; margin-top: 12px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
.realtime-strip {
|
| 295 |
display: grid;
|
|
@@ -485,7 +526,7 @@ p { color: var(--muted); line-height: 1.55; }
|
|
| 485 |
.app-shell { grid-template-columns: 1fr; }
|
| 486 |
.sidebar { position: static; height: auto; }
|
| 487 |
.system-card { position: static; margin-top: 14px; }
|
| 488 |
-
.hero, .grid-2, .grid-3, .grid-4, .live-grid, .realtime-strip, .diagnostic-grid, .instrument-card, .market-strip, .market-strip.compact, .brain-hero, .scenario-grid, .evidence-grid, .method-grid { grid-template-columns: 1fr; }
|
| 489 |
.tape-row { grid-template-columns: 1fr; }
|
| 490 |
.tape-meta { justify-content: flex-start; max-width: none; }
|
| 491 |
}
|
|
|
|
| 287 |
}
|
| 288 |
.evidence-grid strong, .method-grid strong { display: block; margin-top: 6px; line-height: 1.35; }
|
| 289 |
.method-grid strong { color: #d8e5f2; font-size: 12px; font-weight: 700; }
|
| 290 |
+
.observed-model-panel {
|
| 291 |
+
border: 1px solid var(--line);
|
| 292 |
+
border-radius: 6px;
|
| 293 |
+
padding: 12px;
|
| 294 |
+
background: #070a0f;
|
| 295 |
+
}
|
| 296 |
+
.theme-card { cursor: pointer; transition: border-color .15s ease, transform .15s ease; }
|
| 297 |
+
.theme-card:hover { border-color: rgba(255,176,0,.45); transform: translateY(-1px); }
|
| 298 |
.opportunity-panel { min-height: 520px; }
|
| 299 |
.ipo-card { min-height: 410px; }
|
| 300 |
.ipo-card .button { display: inline-flex; width: fit-content; margin-top: 12px; }
|
| 301 |
+
.event-graph {
|
| 302 |
+
display: grid;
|
| 303 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 304 |
+
gap: 8px;
|
| 305 |
+
}
|
| 306 |
+
.event-node {
|
| 307 |
+
min-height: 78px;
|
| 308 |
+
border: 1px solid var(--line);
|
| 309 |
+
border-radius: 6px;
|
| 310 |
+
padding: 9px;
|
| 311 |
+
background: #070a0f;
|
| 312 |
+
}
|
| 313 |
+
.event-node span, .event-node em {
|
| 314 |
+
display: block;
|
| 315 |
+
color: var(--muted);
|
| 316 |
+
font-size: 10px;
|
| 317 |
+
text-transform: uppercase;
|
| 318 |
+
letter-spacing: .06em;
|
| 319 |
+
font-style: normal;
|
| 320 |
+
font-weight: 900;
|
| 321 |
+
}
|
| 322 |
+
.event-node strong {
|
| 323 |
+
display: block;
|
| 324 |
+
margin: 6px 0;
|
| 325 |
+
color: #eef3fa;
|
| 326 |
+
font-size: 12px;
|
| 327 |
+
line-height: 1.25;
|
| 328 |
+
}
|
| 329 |
+
.event-node.stock { border-left: 3px solid var(--green); }
|
| 330 |
+
.event-node.etf { border-left: 3px solid var(--cyan); }
|
| 331 |
+
.event-node.ipo { border-left: 3px solid var(--amber); }
|
| 332 |
+
.event-node.news { border-left: 3px solid var(--blue); }
|
| 333 |
+
.event-node.theme { border-left: 3px solid #b58cff; }
|
| 334 |
|
| 335 |
.realtime-strip {
|
| 336 |
display: grid;
|
|
|
|
| 526 |
.app-shell { grid-template-columns: 1fr; }
|
| 527 |
.sidebar { position: static; height: auto; }
|
| 528 |
.system-card { position: static; margin-top: 14px; }
|
| 529 |
+
.hero, .grid-2, .grid-3, .grid-4, .live-grid, .realtime-strip, .diagnostic-grid, .instrument-card, .market-strip, .market-strip.compact, .brain-hero, .scenario-grid, .evidence-grid, .method-grid, .event-graph { grid-template-columns: 1fr; }
|
| 530 |
.tape-row { grid-template-columns: 1fr; }
|
| 531 |
.tape-meta { justify-content: flex-start; max-width: none; }
|
| 532 |
}
|
frontend/app/ipo-radar/page.tsx
CHANGED
|
@@ -21,8 +21,10 @@ export default function IPORadarPage() {
|
|
| 21 |
const [search, setSearch] = useState("");
|
| 22 |
const [classification, setClassification] = useState("");
|
| 23 |
const [busy, setBusy] = useState(false);
|
|
|
|
| 24 |
const [error, setError] = useState("");
|
| 25 |
const [updateResult, setUpdateResult] = useState<any>(null);
|
|
|
|
| 26 |
|
| 27 |
const load = async () => {
|
| 28 |
setError("");
|
|
@@ -49,6 +51,21 @@ export default function IPORadarPage() {
|
|
| 49 |
}
|
| 50 |
};
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
const rows = useMemo(() => {
|
| 53 |
const query = search.trim().toLowerCase();
|
| 54 |
const base = radar?.sections?.[selectedSection] ?? radar?.rows ?? [];
|
|
@@ -134,9 +151,49 @@ export default function IPORadarPage() {
|
|
| 134 |
</section>
|
| 135 |
|
| 136 |
<section className="grid-3" style={{ marginTop: 12 }}>
|
| 137 |
-
{rows.slice(0, 6).map((row) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
</section>
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
<section className="panel" style={{ marginTop: 12 }}>
|
| 141 |
<div className="panel-head"><span>IPO radar table</span><strong>{rows.length} companies</strong></div>
|
| 142 |
<div className="control-row">
|
|
@@ -171,7 +228,7 @@ export default function IPORadarPage() {
|
|
| 171 |
);
|
| 172 |
}
|
| 173 |
|
| 174 |
-
function IPORadarCard({ row }: { row: IPORadarRow }) {
|
| 175 |
return (
|
| 176 |
<article className="score-card ipo-card">
|
| 177 |
<div className="score-card-top">
|
|
@@ -192,7 +249,11 @@ function IPORadarCard({ row }: { row: IPORadarRow }) {
|
|
| 192 |
<div><span>Narrative</span><strong>{row.score.narrative_heat_score.toFixed(0)}</strong></div>
|
| 193 |
<div><span>Risk terms</span><strong>{row.score.valuation_risk_score.toFixed(0)}</strong></div>
|
| 194 |
</div>
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
</article>
|
| 197 |
);
|
| 198 |
}
|
|
|
|
| 21 |
const [search, setSearch] = useState("");
|
| 22 |
const [classification, setClassification] = useState("");
|
| 23 |
const [busy, setBusy] = useState(false);
|
| 24 |
+
const [secBusy, setSecBusy] = useState("");
|
| 25 |
const [error, setError] = useState("");
|
| 26 |
const [updateResult, setUpdateResult] = useState<any>(null);
|
| 27 |
+
const [secResult, setSecResult] = useState<any>(null);
|
| 28 |
|
| 29 |
const load = async () => {
|
| 30 |
setError("");
|
|
|
|
| 51 |
}
|
| 52 |
};
|
| 53 |
|
| 54 |
+
const loadSecSubmissions = async (row: IPORadarRow, persist: boolean) => {
|
| 55 |
+
if (!row.company.cik) return;
|
| 56 |
+
setSecBusy(row.company.cik);
|
| 57 |
+
setError("");
|
| 58 |
+
try {
|
| 59 |
+
const result = await api.secSubmissions(row.company.cik, persist);
|
| 60 |
+
setSecResult(result);
|
| 61 |
+
if (persist) await load();
|
| 62 |
+
} catch (err) {
|
| 63 |
+
setError((err as Error).message);
|
| 64 |
+
} finally {
|
| 65 |
+
setSecBusy("");
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
const rows = useMemo(() => {
|
| 70 |
const query = search.trim().toLowerCase();
|
| 71 |
const base = radar?.sections?.[selectedSection] ?? radar?.rows ?? [];
|
|
|
|
| 151 |
</section>
|
| 152 |
|
| 153 |
<section className="grid-3" style={{ marginTop: 12 }}>
|
| 154 |
+
{rows.slice(0, 6).map((row) => (
|
| 155 |
+
<IPORadarCard
|
| 156 |
+
row={row}
|
| 157 |
+
key={`${row.company.id}-${row.latest_filing?.accession_number ?? row.company.name}`}
|
| 158 |
+
onLoadSec={(persist) => loadSecSubmissions(row, persist)}
|
| 159 |
+
secBusy={secBusy === row.company.cik}
|
| 160 |
+
/>
|
| 161 |
+
))}
|
| 162 |
</section>
|
| 163 |
|
| 164 |
+
{secResult && (
|
| 165 |
+
<section className="panel" style={{ marginTop: 12 }}>
|
| 166 |
+
<div className="panel-head"><span>SEC company submissions</span><strong>{secResult.name ?? secResult.cik}</strong></div>
|
| 167 |
+
<div className="diagnostic-grid">
|
| 168 |
+
<div>
|
| 169 |
+
<span>Official filing history</span>
|
| 170 |
+
<strong>{secResult.filing_count} filings | {secResult.ipo_related_filing_count} IPO-related</strong>
|
| 171 |
+
<p>{(secResult.tickers ?? []).join(" | ") || "No public ticker in SEC payload"} | {(secResult.exchanges ?? []).join(" | ") || "No exchange in SEC payload"}</p>
|
| 172 |
+
</div>
|
| 173 |
+
<div>
|
| 174 |
+
<span>Persistence</span>
|
| 175 |
+
<strong>{secResult.persisted_new_ipo_filings ?? 0} new filings stored</strong>
|
| 176 |
+
<p>{secResult.data_policy}</p>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
<div className="table-shell" style={{ marginTop: 12 }}>
|
| 180 |
+
<table className="intel-table">
|
| 181 |
+
<thead><tr><th>Form</th><th>Date</th><th>Description</th><th>Document</th></tr></thead>
|
| 182 |
+
<tbody>
|
| 183 |
+
{(secResult.ipo_related_filings ?? []).slice(0, 20).map((filing: any) => (
|
| 184 |
+
<tr key={filing.accession_number}>
|
| 185 |
+
<td><strong>{filing.form_type}</strong></td>
|
| 186 |
+
<td><span>{formatTime(filing.filing_date)}</span></td>
|
| 187 |
+
<td><span>{filing.description ?? "n/a"}</span></td>
|
| 188 |
+
<td>{filing.url ? <a className="asset-link" href={filing.url} target="_blank" rel="noreferrer">Open</a> : <span>n/a</span>}</td>
|
| 189 |
+
</tr>
|
| 190 |
+
))}
|
| 191 |
+
</tbody>
|
| 192 |
+
</table>
|
| 193 |
+
</div>
|
| 194 |
+
</section>
|
| 195 |
+
)}
|
| 196 |
+
|
| 197 |
<section className="panel" style={{ marginTop: 12 }}>
|
| 198 |
<div className="panel-head"><span>IPO radar table</span><strong>{rows.length} companies</strong></div>
|
| 199 |
<div className="control-row">
|
|
|
|
| 228 |
);
|
| 229 |
}
|
| 230 |
|
| 231 |
+
function IPORadarCard({ row, onLoadSec, secBusy }: { row: IPORadarRow; onLoadSec: (persist: boolean) => void; secBusy: boolean }) {
|
| 232 |
return (
|
| 233 |
<article className="score-card ipo-card">
|
| 234 |
<div className="score-card-top">
|
|
|
|
| 249 |
<div><span>Narrative</span><strong>{row.score.narrative_heat_score.toFixed(0)}</strong></div>
|
| 250 |
<div><span>Risk terms</span><strong>{row.score.valuation_risk_score.toFixed(0)}</strong></div>
|
| 251 |
</div>
|
| 252 |
+
<div className="control-row" style={{ marginTop: 12, marginBottom: 0 }}>
|
| 253 |
+
{row.latest_filing?.url && <a className="button" href={row.latest_filing.url} target="_blank" rel="noreferrer">Open SEC filing</a>}
|
| 254 |
+
{row.company.cik && <button className="button" onClick={() => onLoadSec(false)} disabled={secBusy}>{secBusy ? "Loading SEC..." : "SEC history"}</button>}
|
| 255 |
+
{row.company.cik && <button className="button" onClick={() => onLoadSec(true)} disabled={secBusy}>{secBusy ? "Syncing..." : "Sync filings"}</button>}
|
| 256 |
+
</div>
|
| 257 |
</article>
|
| 258 |
);
|
| 259 |
}
|
frontend/app/market-brain/page.tsx
CHANGED
|
@@ -3,20 +3,24 @@
|
|
| 3 |
import Link from "next/link";
|
| 4 |
import { useEffect, useState } from "react";
|
| 5 |
import { api } from "@/lib/api";
|
| 6 |
-
import { MarketBrain } from "@/lib/types";
|
| 7 |
import { LoadingState } from "@/components/LoadingState";
|
| 8 |
import { PlotPanel } from "@/components/PlotPanel";
|
| 9 |
import { StatusBadge } from "@/components/StatusBadge";
|
| 10 |
|
| 11 |
export default function MarketBrainPage() {
|
| 12 |
const [brain, setBrain] = useState<MarketBrain | null>(null);
|
|
|
|
| 13 |
const [busy, setBusy] = useState(false);
|
| 14 |
const [error, setError] = useState("");
|
| 15 |
|
| 16 |
const load = async () => {
|
| 17 |
setError("");
|
| 18 |
try {
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
} catch (err) {
|
| 21 |
setError((err as Error).message);
|
| 22 |
}
|
|
@@ -28,7 +32,9 @@ export default function MarketBrainPage() {
|
|
| 28 |
setBusy(true);
|
| 29 |
setError("");
|
| 30 |
try {
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
} catch (err) {
|
| 33 |
setError((err as Error).message);
|
| 34 |
} finally {
|
|
@@ -125,12 +131,71 @@ export default function MarketBrainPage() {
|
|
| 125 |
))}
|
| 126 |
</section>
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
<section className="grid-3" style={{ marginTop: 12 }}>
|
| 129 |
<OpportunityPanel title="Stock research priorities" rows={stack.stock_research_priorities} kind="stock" />
|
| 130 |
<OpportunityPanel title="ETF rotation leaders" rows={stack.etf_rotation_leaders} kind="etf" />
|
| 131 |
<OpportunityPanel title="IPO / pre-listing watch" rows={stack.ipo_watch} kind="ipo" />
|
| 132 |
</section>
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
<section className="grid-2" style={{ marginTop: 12 }}>
|
| 135 |
<div className="panel">
|
| 136 |
<div className="panel-head"><span>Risk alerts</span><strong>{brain.risk_alerts.length}</strong></div>
|
|
|
|
| 3 |
import Link from "next/link";
|
| 4 |
import { useEffect, useState } from "react";
|
| 5 |
import { api } from "@/lib/api";
|
| 6 |
+
import { MarketBrain, MarketBrainHistoryRow } from "@/lib/types";
|
| 7 |
import { LoadingState } from "@/components/LoadingState";
|
| 8 |
import { PlotPanel } from "@/components/PlotPanel";
|
| 9 |
import { StatusBadge } from "@/components/StatusBadge";
|
| 10 |
|
| 11 |
export default function MarketBrainPage() {
|
| 12 |
const [brain, setBrain] = useState<MarketBrain | null>(null);
|
| 13 |
+
const [history, setHistory] = useState<MarketBrainHistoryRow[]>([]);
|
| 14 |
const [busy, setBusy] = useState(false);
|
| 15 |
const [error, setError] = useState("");
|
| 16 |
|
| 17 |
const load = async () => {
|
| 18 |
setError("");
|
| 19 |
try {
|
| 20 |
+
const [brainResult, historyResult] = await Promise.allSettled([api.marketBrain(), api.marketBrainHistory(12)] as const);
|
| 21 |
+
if (brainResult.status === "fulfilled") setBrain(brainResult.value);
|
| 22 |
+
if (historyResult.status === "fulfilled") setHistory(historyResult.value);
|
| 23 |
+
if (brainResult.status === "rejected") throw brainResult.reason;
|
| 24 |
} catch (err) {
|
| 25 |
setError((err as Error).message);
|
| 26 |
}
|
|
|
|
| 32 |
setBusy(true);
|
| 33 |
setError("");
|
| 34 |
try {
|
| 35 |
+
const result = await api.runMarketBrain(refreshPipeline);
|
| 36 |
+
setBrain(result);
|
| 37 |
+
setHistory(await api.marketBrainHistory(12));
|
| 38 |
} catch (err) {
|
| 39 |
setError((err as Error).message);
|
| 40 |
} finally {
|
|
|
|
| 131 |
))}
|
| 132 |
</section>
|
| 133 |
|
| 134 |
+
<section className="grid-2" style={{ marginTop: 12 }}>
|
| 135 |
+
<div className="panel">
|
| 136 |
+
<div className="panel-head"><span>Brain changelog</span><strong>{brain.change_log.length}</strong></div>
|
| 137 |
+
<div className="brain-list">
|
| 138 |
+
{brain.change_log.map((item) => (
|
| 139 |
+
<div key={`${item.type}-${item.message}`}>
|
| 140 |
+
<StatusBadge label={item.severity} />
|
| 141 |
+
<strong>{item.type.replaceAll("_", " ")}</strong>
|
| 142 |
+
<p>{item.message}</p>
|
| 143 |
+
</div>
|
| 144 |
+
))}
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="panel">
|
| 148 |
+
<div className="panel-head"><span>Contradiction engine</span><strong>{brain.contradictions.length}</strong></div>
|
| 149 |
+
<div className="brain-list">
|
| 150 |
+
{brain.contradictions.length === 0 && <div className="empty-state">No material price, sentiment or risk contradictions detected.</div>}
|
| 151 |
+
{brain.contradictions.slice(0, 8).map((item) => (
|
| 152 |
+
<div key={`${item.type}-${item.ticker}-${item.title}`}>
|
| 153 |
+
<StatusBadge label={item.severity} />
|
| 154 |
+
<strong>{item.title}</strong>
|
| 155 |
+
<span>{Object.entries(item.evidence).map(([key, value]) => `${key} ${value}`).join(" | ")}</span>
|
| 156 |
+
</div>
|
| 157 |
+
))}
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</section>
|
| 161 |
+
|
| 162 |
<section className="grid-3" style={{ marginTop: 12 }}>
|
| 163 |
<OpportunityPanel title="Stock research priorities" rows={stack.stock_research_priorities} kind="stock" />
|
| 164 |
<OpportunityPanel title="ETF rotation leaders" rows={stack.etf_rotation_leaders} kind="etf" />
|
| 165 |
<OpportunityPanel title="IPO / pre-listing watch" rows={stack.ipo_watch} kind="ipo" />
|
| 166 |
</section>
|
| 167 |
|
| 168 |
+
<section className="grid-2" style={{ marginTop: 12 }}>
|
| 169 |
+
<div className="panel">
|
| 170 |
+
<div className="panel-head"><span>Event graph</span><strong>{brain.event_graph.nodes.length} nodes</strong></div>
|
| 171 |
+
<div className="event-graph">
|
| 172 |
+
{brain.event_graph.nodes.slice(0, 28).map((node) => (
|
| 173 |
+
<div className={`event-node ${node.type}`} key={node.id}>
|
| 174 |
+
<span>{node.type}</span>
|
| 175 |
+
<strong>{node.label}</strong>
|
| 176 |
+
{node.score !== undefined && node.score !== null && <em>{Number(node.score).toFixed(1)}</em>}
|
| 177 |
+
</div>
|
| 178 |
+
))}
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
<div className="panel">
|
| 182 |
+
<div className="panel-head"><span>Snapshot history</span><strong>{history.length}</strong></div>
|
| 183 |
+
<div className="brain-list dense">
|
| 184 |
+
{history.length === 0 && <div className="empty-state">No persisted Market Brain snapshots yet. Run brain to create the first snapshot.</div>}
|
| 185 |
+
{history.map((item) => (
|
| 186 |
+
<div key={item.run_id}>
|
| 187 |
+
<div className="opportunity-line">
|
| 188 |
+
<strong>{item.regime}</strong>
|
| 189 |
+
<span>{Number(item.brain_score).toFixed(1)}</span>
|
| 190 |
+
</div>
|
| 191 |
+
<p>{formatTime(item.created_at)} | risk {item.risk_alert_count} | contradictions {item.contradiction_count}</p>
|
| 192 |
+
<span>Top: {item.top_stock ?? "n/a"} | ETF {item.top_etf ?? "n/a"} | IPO {item.top_ipo ?? "n/a"}</span>
|
| 193 |
+
</div>
|
| 194 |
+
))}
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</section>
|
| 198 |
+
|
| 199 |
<section className="grid-2" style={{ marginTop: 12 }}>
|
| 200 |
<div className="panel">
|
| 201 |
<div className="panel-head"><span>Risk alerts</span><strong>{brain.risk_alerts.length}</strong></div>
|
frontend/app/methodology/page.tsx
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export default function MethodologyPage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
return (
|
| 3 |
<>
|
| 4 |
<div className="page-header">
|
|
@@ -12,6 +23,27 @@ export default function MethodologyPage() {
|
|
| 12 |
</article>
|
| 13 |
))}
|
| 14 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
<section className="panel" style={{ marginTop: 12 }}>
|
| 16 |
<div className="panel-head"><span>Financial disclaimer</span></div>
|
| 17 |
<p>
|
|
@@ -24,12 +56,30 @@ export default function MethodologyPage() {
|
|
| 24 |
);
|
| 25 |
}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const sections = [
|
| 28 |
-
{ title: "Data workflow", body: "The backend seeds an asset universe, ingests
|
| 29 |
{ title: "AI orchestration", body: "FinBERT handles financial sentiment, sentence-transformers handles embeddings and semantic search, a lightweight LLM creates evidence-only explanations, and the time-series layer computes anomalies, volatility regimes and scenarios." },
|
|
|
|
|
|
|
| 30 |
{ title: "Signal scoring", body: "The Blum Intelligence Score combines momentum, trend quality, technical indicators, volatility/risk, sentiment trend, semantic intensity, ETF confirmation and anomaly pressure." },
|
| 31 |
{ title: "Explainability", body: "Each surfaced asset must expose why it appeared, which technical and narrative data support it, what contradicts it, what to monitor, and which risks limit confidence." },
|
| 32 |
{ title: "Backtesting", body: "Historical validation reports hit rate, forward returns, max adverse excursion, max favorable excursion, false positives and methodology limits. It does not promise future performance." },
|
| 33 |
{ title: "Provider architecture", body: "The first provider is yfinance. The backend isolates providers so future integrations can add licensed market data, estimates, filings, transcripts, ownership and portfolio systems." }
|
| 34 |
];
|
| 35 |
-
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react";
|
| 4 |
+
import { api } from "@/lib/api";
|
| 5 |
+
|
| 6 |
export default function MethodologyPage() {
|
| 7 |
+
const [modelStatus, setModelStatus] = useState<any>(null);
|
| 8 |
+
|
| 9 |
+
useEffect(() => {
|
| 10 |
+
api.modelStatus().then(setModelStatus).catch(() => setModelStatus(null));
|
| 11 |
+
}, []);
|
| 12 |
+
|
| 13 |
return (
|
| 14 |
<>
|
| 15 |
<div className="page-header">
|
|
|
|
| 23 |
</article>
|
| 24 |
))}
|
| 25 |
</section>
|
| 26 |
+
<section className="panel" style={{ marginTop: 12 }}>
|
| 27 |
+
<div className="panel-head"><span>AI model status</span><strong>{modelStatus?.model_loading_enabled ? "model loading enabled" : "fallback-ready"}</strong></div>
|
| 28 |
+
{!modelStatus && <div className="empty-state">Model status is not available yet.</div>}
|
| 29 |
+
{modelStatus && (
|
| 30 |
+
<>
|
| 31 |
+
<div className="method-grid">
|
| 32 |
+
{Object.entries(modelStatus.configured_models).map(([key, value]) => (
|
| 33 |
+
<div key={key}>
|
| 34 |
+
<span>{key.replaceAll("_", " ")}</span>
|
| 35 |
+
<strong>{String(value)}</strong>
|
| 36 |
+
</div>
|
| 37 |
+
))}
|
| 38 |
+
</div>
|
| 39 |
+
<div className="grid-3" style={{ marginTop: 10 }}>
|
| 40 |
+
<ObservedModelPanel title="Sentiment records" rows={modelStatus.observed_models.sentiment} />
|
| 41 |
+
<ObservedModelPanel title="Embedding records" rows={modelStatus.observed_models.embeddings} />
|
| 42 |
+
<ObservedModelPanel title="Insight records" rows={modelStatus.observed_models.insights} />
|
| 43 |
+
</div>
|
| 44 |
+
</>
|
| 45 |
+
)}
|
| 46 |
+
</section>
|
| 47 |
<section className="panel" style={{ marginTop: 12 }}>
|
| 48 |
<div className="panel-head"><span>Financial disclaimer</span></div>
|
| 49 |
<p>
|
|
|
|
| 56 |
);
|
| 57 |
}
|
| 58 |
|
| 59 |
+
function ObservedModelPanel({ title, rows }: { title: string; rows: any[] }) {
|
| 60 |
+
return (
|
| 61 |
+
<div className="observed-model-panel">
|
| 62 |
+
<div className="panel-head"><span>{title}</span><strong>{rows?.length ?? 0}</strong></div>
|
| 63 |
+
<div className="brain-list dense">
|
| 64 |
+
{!rows?.length && <div className="empty-state">No records observed yet.</div>}
|
| 65 |
+
{rows?.map((row) => (
|
| 66 |
+
<div key={row.model_name}>
|
| 67 |
+
<strong>{row.model_name}</strong>
|
| 68 |
+
<span>{row.records} records</span>
|
| 69 |
+
</div>
|
| 70 |
+
))}
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
const sections = [
|
| 77 |
+
{ title: "Data workflow", body: "The backend seeds an asset universe, ingests public OHLCV history, collects public RSS news, deduplicates articles, links them to assets and persists all artifacts in PostgreSQL." },
|
| 78 |
{ title: "AI orchestration", body: "FinBERT handles financial sentiment, sentence-transformers handles embeddings and semantic search, a lightweight LLM creates evidence-only explanations, and the time-series layer computes anomalies, volatility regimes and scenarios." },
|
| 79 |
+
{ title: "Market Brain", body: "The Market Brain combines stock signals, ETF rotation, live news, sentiment, SEC filing evidence, forward scenarios, contradictions, risk alerts and change logs into one evidence-bound operating view." },
|
| 80 |
+
{ title: "IPO intelligence", body: "IPO Radar uses SEC EDGAR current filings and company submissions data for S-1, F-1 and 424B prospectus evidence. It never fabricates listing dates, valuations or tickers." },
|
| 81 |
{ title: "Signal scoring", body: "The Blum Intelligence Score combines momentum, trend quality, technical indicators, volatility/risk, sentiment trend, semantic intensity, ETF confirmation and anomaly pressure." },
|
| 82 |
{ title: "Explainability", body: "Each surfaced asset must expose why it appeared, which technical and narrative data support it, what contradicts it, what to monitor, and which risks limit confidence." },
|
| 83 |
{ title: "Backtesting", body: "Historical validation reports hit rate, forward returns, max adverse excursion, max favorable excursion, false positives and methodology limits. It does not promise future performance." },
|
| 84 |
{ title: "Provider architecture", body: "The first provider is yfinance. The backend isolates providers so future integrations can add licensed market data, estimates, filings, transcripts, ownership and portfolio systems." }
|
| 85 |
];
|
|
|
frontend/app/stock-radar/page.tsx
CHANGED
|
@@ -190,7 +190,7 @@ function StockRadarCard({ row }: { row: StockRadarRow }) {
|
|
| 190 |
<div><span>Trend</span><strong>{factor(row, "trend")}</strong></div>
|
| 191 |
<div><span>Momentum</span><strong>{factor(row, "momentum")}</strong></div>
|
| 192 |
<div><span>Sentiment</span><strong>{factor(row, "sentiment")}</strong></div>
|
| 193 |
-
<div><span>
|
| 194 |
</div>
|
| 195 |
</article>
|
| 196 |
);
|
|
@@ -229,7 +229,8 @@ function StockRadarTable({ rows }: { rows: StockRadarRow[] }) {
|
|
| 229 |
<td><span className="metric-pill">{row.research_priority}</span></td>
|
| 230 |
<td>
|
| 231 |
{row.signal ? <StatusBadge label={row.signal.classification} /> : <StatusBadge label="Insufficient Evidence" />}
|
| 232 |
-
<span>Score {row.signal?.blum_score?.toFixed(1) ?? "n/a"} | {row.signal?.risk_level ?? "Not rated"}</span>
|
|
|
|
| 233 |
</td>
|
| 234 |
<td>
|
| 235 |
<span>Mom {factor(row, "momentum")} | Trend {factor(row, "trend")}</span>
|
|
|
|
| 190 |
<div><span>Trend</span><strong>{factor(row, "trend")}</strong></div>
|
| 191 |
<div><span>Momentum</span><strong>{factor(row, "momentum")}</strong></div>
|
| 192 |
<div><span>Sentiment</span><strong>{factor(row, "sentiment")}</strong></div>
|
| 193 |
+
<div><span>Confidence</span><strong>{row.signal?.confidence_score?.toFixed(0) ?? "n/a"}</strong></div>
|
| 194 |
</div>
|
| 195 |
</article>
|
| 196 |
);
|
|
|
|
| 229 |
<td><span className="metric-pill">{row.research_priority}</span></td>
|
| 230 |
<td>
|
| 231 |
{row.signal ? <StatusBadge label={row.signal.classification} /> : <StatusBadge label="Insufficient Evidence" />}
|
| 232 |
+
<span>Score {row.signal?.blum_score?.toFixed(1) ?? "n/a"} | conf {row.signal?.confidence_score?.toFixed(0) ?? "n/a"} | {row.signal?.risk_level ?? "Not rated"}</span>
|
| 233 |
+
<span>{row.signal?.lifecycle_state ?? "no lifecycle"} | {row.signal?.score_version ?? "no score version"}</span>
|
| 234 |
</td>
|
| 235 |
<td>
|
| 236 |
<span>Mom {factor(row, "momentum")} | Trend {factor(row, "trend")}</span>
|
frontend/app/themes/page.tsx
CHANGED
|
@@ -6,10 +6,12 @@ import { LoadingState } from "@/components/LoadingState";
|
|
| 6 |
|
| 7 |
export default function ThemeExplorerPage() {
|
| 8 |
const [themes, setThemes] = useState<any[] | null>(null);
|
|
|
|
| 9 |
const [query, setQuery] = useState("AI infrastructure guidance");
|
| 10 |
const [results, setResults] = useState<any[]>([]);
|
| 11 |
useEffect(() => { api.themes().then(setThemes); }, []);
|
| 12 |
const search = async () => setResults(await api.semanticSearch(query));
|
|
|
|
| 13 |
if (!themes) return <LoadingState label="Loading semantic themes" />;
|
| 14 |
return (
|
| 15 |
<>
|
|
@@ -22,12 +24,49 @@ export default function ThemeExplorerPage() {
|
|
| 22 |
</div>
|
| 23 |
<section className="grid-3">
|
| 24 |
{themes.slice(0, 12).map((theme) => (
|
| 25 |
-
<article className="panel" key={theme.label}>
|
| 26 |
<div className="panel-head"><span>{theme.label}</span><strong>{theme.article_count ?? 0} articles</strong></div>
|
| 27 |
<p>Keywords: {(theme.keywords ?? []).join(", ") || "semantic cluster"}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</article>
|
| 29 |
))}
|
| 30 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
<section className="panel" style={{ marginTop: 12 }}>
|
| 32 |
<div className="panel-head"><span>Semantic Search Results</span></div>
|
| 33 |
<div className="news-list">
|
|
@@ -43,3 +82,6 @@ export default function ThemeExplorerPage() {
|
|
| 43 |
);
|
| 44 |
}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
export default function ThemeExplorerPage() {
|
| 8 |
const [themes, setThemes] = useState<any[] | null>(null);
|
| 9 |
+
const [themeDetail, setThemeDetail] = useState<any | null>(null);
|
| 10 |
const [query, setQuery] = useState("AI infrastructure guidance");
|
| 11 |
const [results, setResults] = useState<any[]>([]);
|
| 12 |
useEffect(() => { api.themes().then(setThemes); }, []);
|
| 13 |
const search = async () => setResults(await api.semanticSearch(query));
|
| 14 |
+
const openTheme = async (label: string) => setThemeDetail(await api.themeDetail(label));
|
| 15 |
if (!themes) return <LoadingState label="Loading semantic themes" />;
|
| 16 |
return (
|
| 17 |
<>
|
|
|
|
| 24 |
</div>
|
| 25 |
<section className="grid-3">
|
| 26 |
{themes.slice(0, 12).map((theme) => (
|
| 27 |
+
<article className="panel theme-card" key={theme.label} onClick={() => openTheme(theme.label)}>
|
| 28 |
<div className="panel-head"><span>{theme.label}</span><strong>{theme.article_count ?? 0} articles</strong></div>
|
| 29 |
<p>Keywords: {(theme.keywords ?? []).join(", ") || "semantic cluster"}</p>
|
| 30 |
+
<div className="tag-row">
|
| 31 |
+
<span>sentiment {Number(theme.sentiment_score ?? 0).toFixed(2)}</span>
|
| 32 |
+
<span>{theme.cluster_method ?? "theme aggregation"}</span>
|
| 33 |
+
</div>
|
| 34 |
</article>
|
| 35 |
))}
|
| 36 |
</section>
|
| 37 |
+
{themeDetail && (
|
| 38 |
+
<section className="panel" style={{ marginTop: 12 }}>
|
| 39 |
+
<div className="panel-head"><span>Theme detail</span><strong>{themeDetail.label}</strong></div>
|
| 40 |
+
<div className="grid-4">
|
| 41 |
+
<Metric label="Articles" value={themeDetail.article_count} />
|
| 42 |
+
<Metric label="Avg Sentiment" value={Number(themeDetail.average_sentiment).toFixed(2)} />
|
| 43 |
+
<Metric label="Sources" value={themeDetail.source_mix.length} />
|
| 44 |
+
<Metric label="Assets" value={themeDetail.linked_assets.length} />
|
| 45 |
+
</div>
|
| 46 |
+
<div className="grid-2" style={{ marginTop: 12 }}>
|
| 47 |
+
<div className="observed-model-panel">
|
| 48 |
+
<div className="panel-head"><span>Linked assets</span></div>
|
| 49 |
+
<div className="tag-row">
|
| 50 |
+
{themeDetail.linked_assets.slice(0, 20).map((asset: any) => <span key={asset.ticker}>{asset.ticker} {asset.mentions}</span>)}
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
<div className="observed-model-panel">
|
| 54 |
+
<div className="panel-head"><span>Source mix</span></div>
|
| 55 |
+
<div className="tag-row">
|
| 56 |
+
{themeDetail.source_mix.slice(0, 16).map((source: any) => <span key={source.source}>{source.source} {source.count}</span>)}
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
<div className="news-list" style={{ marginTop: 12 }}>
|
| 61 |
+
{themeDetail.articles.slice(0, 16).map((article: any) => (
|
| 62 |
+
<a className="news-item" href={article.url} target="_blank" rel="noreferrer" key={article.id}>
|
| 63 |
+
<strong>{article.title}</strong>
|
| 64 |
+
<span>{article.source} | sentiment {article.sentiment?.score?.toFixed?.(2) ?? "n/a"} | assets {(article.linked_assets ?? []).map((asset: any) => asset.ticker).join(" | ")}</span>
|
| 65 |
+
</a>
|
| 66 |
+
))}
|
| 67 |
+
</div>
|
| 68 |
+
</section>
|
| 69 |
+
)}
|
| 70 |
<section className="panel" style={{ marginTop: 12 }}>
|
| 71 |
<div className="panel-head"><span>Semantic Search Results</span></div>
|
| 72 |
<div className="news-list">
|
|
|
|
| 82 |
);
|
| 83 |
}
|
| 84 |
|
| 85 |
+
function Metric({ label, value }: { label: string; value: number | string }) {
|
| 86 |
+
return <div className="metric-card"><span>{label}</span><strong>{value}</strong></div>;
|
| 87 |
+
}
|
frontend/components/ScoreCard.tsx
CHANGED
|
@@ -22,8 +22,8 @@ export function ScoreCard({ signal }: { signal: Signal }) {
|
|
| 22 |
<p>{signal.explanation}</p>
|
| 23 |
<div className="mini-metrics">
|
| 24 |
<div><span>Risk</span><strong>{signal.risk_level}</strong></div>
|
| 25 |
-
<div><span>
|
| 26 |
-
<div><span>
|
| 27 |
<div><span>Volume</span><strong>{formatVolume(signal.market_snapshot?.volume)}</strong></div>
|
| 28 |
</div>
|
| 29 |
</article>
|
|
|
|
| 22 |
<p>{signal.explanation}</p>
|
| 23 |
<div className="mini-metrics">
|
| 24 |
<div><span>Risk</span><strong>{signal.risk_level}</strong></div>
|
| 25 |
+
<div><span>Confidence</span><strong>{Number(signal.confidence_score ?? 0).toFixed(0)}</strong></div>
|
| 26 |
+
<div><span>Lifecycle</span><strong>{signal.lifecycle_state ?? "active"}</strong></div>
|
| 27 |
<div><span>Volume</span><strong>{formatVolume(signal.market_snapshot?.volume)}</strong></div>
|
| 28 |
</div>
|
| 29 |
</article>
|
frontend/components/SignalTable.tsx
CHANGED
|
@@ -13,6 +13,7 @@ export function SignalTable({ signals }: { signals: Signal[] }) {
|
|
| 13 |
<th>Asset</th>
|
| 14 |
<th>Market</th>
|
| 15 |
<th>Score</th>
|
|
|
|
| 16 |
<th>Classification</th>
|
| 17 |
<th>Risk</th>
|
| 18 |
<th>Momentum</th>
|
|
@@ -35,8 +36,12 @@ export function SignalTable({ signals }: { signals: Signal[] }) {
|
|
| 35 |
<span>{signal.market_snapshot?.provider ?? "provider n/a"} | vol {formatVolume(signal.market_snapshot?.volume)}</span>
|
| 36 |
</td>
|
| 37 |
<td><strong className="score-number">{signal.blum_score.toFixed(1)}</strong></td>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
<td><StatusBadge label={signal.classification} /></td>
|
| 39 |
-
<td>{signal.risk_level}</td>
|
| 40 |
<td>{metric(signal, "momentum_score")}</td>
|
| 41 |
<td>{metric(signal, "trend_score")}</td>
|
| 42 |
<td>{metric(signal, "sentiment_score")}</td>
|
|
|
|
| 13 |
<th>Asset</th>
|
| 14 |
<th>Market</th>
|
| 15 |
<th>Score</th>
|
| 16 |
+
<th>Confidence</th>
|
| 17 |
<th>Classification</th>
|
| 18 |
<th>Risk</th>
|
| 19 |
<th>Momentum</th>
|
|
|
|
| 36 |
<span>{signal.market_snapshot?.provider ?? "provider n/a"} | vol {formatVolume(signal.market_snapshot?.volume)}</span>
|
| 37 |
</td>
|
| 38 |
<td><strong className="score-number">{signal.blum_score.toFixed(1)}</strong></td>
|
| 39 |
+
<td>
|
| 40 |
+
<span className="metric-pill">{Number(signal.confidence_score ?? 0).toFixed(0)}</span>
|
| 41 |
+
<span>{signal.lifecycle_state ?? "active"}</span>
|
| 42 |
+
</td>
|
| 43 |
<td><StatusBadge label={signal.classification} /></td>
|
| 44 |
+
<td>{signal.risk_level}<span>{signal.score_version ?? "blum-score"}</span></td>
|
| 45 |
<td>{metric(signal, "momentum_score")}</td>
|
| 46 |
<td>{metric(signal, "trend_score")}</td>
|
| 47 |
<td>{metric(signal, "sentiment_score")}</td>
|
frontend/lib/api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { Asset, DashboardOverview, IPORadar, LiveNewsArticle, MarketBrain, MarketSentiment, PipelineStatus, RelatedNews, Signal, StockRadar } from "./types";
|
| 2 |
|
| 3 |
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
|
| 4 |
|
|
@@ -35,13 +35,17 @@ export const api = {
|
|
| 35 |
explain: (ticker: string) => getJson<any>(`/ai/explain/${ticker}`),
|
| 36 |
relatedNews: (ticker: string) => getJson<RelatedNews[]>(`/related-news?ticker=${ticker}`),
|
| 37 |
themes: () => getJson<any[]>("/themes"),
|
|
|
|
| 38 |
etfTrends: () => getJson<any[]>("/etf-trends"),
|
| 39 |
stockRadar: (limit = 80) => getJson<StockRadar>(`/stock-radar?limit=${limit}`),
|
| 40 |
updateStockRadar: (limit = 36) => postJson<any>(`/stock-radar/update?limit=${limit}`, {}),
|
| 41 |
ipoRadar: (limit = 80) => getJson<IPORadar>(`/ipo-radar?limit=${limit}`),
|
| 42 |
updateIpoRadar: (limitPerForm = 50) => postJson<any>(`/ipo-radar/update?limit_per_form=${limitPerForm}`, {}),
|
|
|
|
| 43 |
marketBrain: () => getJson<MarketBrain>("/market-brain"),
|
|
|
|
| 44 |
runMarketBrain: (refreshPipeline = false) => postJson<MarketBrain>(`/market-brain/run?refresh_pipeline=${refreshPipeline ? "true" : "false"}&refresh_sec=true`, {}),
|
|
|
|
| 45 |
semanticSearch: (query: string) => postJson<any[]>("/semantic-search", { query, limit: 12 }),
|
| 46 |
marketUpdate: () => postJson("/market/update", { period: "max", limit: 36 }),
|
| 47 |
newsUpdate: () => postJson("/news/update", { lookback_hours: 72, limit_per_feed: 35 }),
|
|
|
|
| 1 |
+
import { Asset, DashboardOverview, IPORadar, LiveNewsArticle, MarketBrain, MarketBrainHistoryRow, MarketSentiment, PipelineStatus, RelatedNews, Signal, StockRadar } from "./types";
|
| 2 |
|
| 3 |
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
|
| 4 |
|
|
|
|
| 35 |
explain: (ticker: string) => getJson<any>(`/ai/explain/${ticker}`),
|
| 36 |
relatedNews: (ticker: string) => getJson<RelatedNews[]>(`/related-news?ticker=${ticker}`),
|
| 37 |
themes: () => getJson<any[]>("/themes"),
|
| 38 |
+
themeDetail: (label: string) => getJson<any>(`/themes/${encodeURIComponent(label)}`),
|
| 39 |
etfTrends: () => getJson<any[]>("/etf-trends"),
|
| 40 |
stockRadar: (limit = 80) => getJson<StockRadar>(`/stock-radar?limit=${limit}`),
|
| 41 |
updateStockRadar: (limit = 36) => postJson<any>(`/stock-radar/update?limit=${limit}`, {}),
|
| 42 |
ipoRadar: (limit = 80) => getJson<IPORadar>(`/ipo-radar?limit=${limit}`),
|
| 43 |
updateIpoRadar: (limitPerForm = 50) => postJson<any>(`/ipo-radar/update?limit_per_form=${limitPerForm}`, {}),
|
| 44 |
+
secSubmissions: (cik: string, persist = false) => getJson<any>(`/ipo-radar/sec-submissions/${cik}?persist=${persist ? "true" : "false"}`),
|
| 45 |
marketBrain: () => getJson<MarketBrain>("/market-brain"),
|
| 46 |
+
marketBrainHistory: (limit = 20) => getJson<MarketBrainHistoryRow[]>(`/market-brain/history?limit=${limit}`),
|
| 47 |
runMarketBrain: (refreshPipeline = false) => postJson<MarketBrain>(`/market-brain/run?refresh_pipeline=${refreshPipeline ? "true" : "false"}&refresh_sec=true`, {}),
|
| 48 |
+
modelStatus: () => getJson<any>("/ai/models/status"),
|
| 49 |
semanticSearch: (query: string) => postJson<any[]>("/semantic-search", { query, limit: 12 }),
|
| 50 |
marketUpdate: () => postJson("/market/update", { period: "max", limit: 36 }),
|
| 51 |
newsUpdate: () => postJson("/news/update", { lookback_hours: 72, limit_per_feed: 35 }),
|
frontend/lib/types.ts
CHANGED
|
@@ -31,6 +31,9 @@ export type Signal = {
|
|
| 31 |
blum_score: number;
|
| 32 |
risk_level: string;
|
| 33 |
time_horizon: string;
|
|
|
|
|
|
|
|
|
|
| 34 |
score_breakdown: Record<string, number>;
|
| 35 |
technical_summary?: Record<string, number | string | boolean | null>;
|
| 36 |
narrative_summary?: Record<string, number | string | boolean | null>;
|
|
@@ -119,6 +122,9 @@ export type StockRadarSignal = {
|
|
| 119 |
blum_score: number;
|
| 120 |
risk_level: string;
|
| 121 |
time_horizon: string;
|
|
|
|
|
|
|
|
|
|
| 122 |
score_breakdown: Record<string, number>;
|
| 123 |
created_at: string;
|
| 124 |
};
|
|
@@ -253,12 +259,31 @@ export type MarketBrain = {
|
|
| 253 |
evidence: Record<string, any>;
|
| 254 |
}>;
|
| 255 |
risk_alerts: Array<{ severity: string; title: string; detail: string; tickers: string[] }>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
evidence_ledger: Record<string, number | string>;
|
|
|
|
| 257 |
model_stack: Record<string, string>;
|
| 258 |
disclaimer: string;
|
| 259 |
update_diagnostics?: Record<string, any>;
|
| 260 |
};
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
export type PricePoint = {
|
| 263 |
date: string;
|
| 264 |
open?: number;
|
|
|
|
| 31 |
blum_score: number;
|
| 32 |
risk_level: string;
|
| 33 |
time_horizon: string;
|
| 34 |
+
score_version?: string;
|
| 35 |
+
confidence_score?: number;
|
| 36 |
+
lifecycle_state?: string;
|
| 37 |
score_breakdown: Record<string, number>;
|
| 38 |
technical_summary?: Record<string, number | string | boolean | null>;
|
| 39 |
narrative_summary?: Record<string, number | string | boolean | null>;
|
|
|
|
| 122 |
blum_score: number;
|
| 123 |
risk_level: string;
|
| 124 |
time_horizon: string;
|
| 125 |
+
score_version?: string;
|
| 126 |
+
confidence_score?: number;
|
| 127 |
+
lifecycle_state?: string;
|
| 128 |
score_breakdown: Record<string, number>;
|
| 129 |
created_at: string;
|
| 130 |
};
|
|
|
|
| 259 |
evidence: Record<string, any>;
|
| 260 |
}>;
|
| 261 |
risk_alerts: Array<{ severity: string; title: string; detail: string; tickers: string[] }>;
|
| 262 |
+
contradictions: Array<{ type: string; severity: string; ticker: string; title: string; evidence: Record<string, any> }>;
|
| 263 |
+
event_graph: {
|
| 264 |
+
nodes: Array<{ id: string; label: string; type: string; score?: number | null }>;
|
| 265 |
+
edges: Array<{ source: string; target: string; relationship: string; weight: number }>;
|
| 266 |
+
};
|
| 267 |
evidence_ledger: Record<string, number | string>;
|
| 268 |
+
change_log: Array<{ type: string; severity: string; message: string; previous?: any; current?: any }>;
|
| 269 |
model_stack: Record<string, string>;
|
| 270 |
disclaimer: string;
|
| 271 |
update_diagnostics?: Record<string, any>;
|
| 272 |
};
|
| 273 |
|
| 274 |
+
export type MarketBrainHistoryRow = {
|
| 275 |
+
run_id: string;
|
| 276 |
+
created_at: string;
|
| 277 |
+
brain_score: number;
|
| 278 |
+
regime: string;
|
| 279 |
+
summary: string;
|
| 280 |
+
risk_alert_count: number;
|
| 281 |
+
contradiction_count: number;
|
| 282 |
+
top_stock?: string | null;
|
| 283 |
+
top_etf?: string | null;
|
| 284 |
+
top_ipo?: string | null;
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
export type PricePoint = {
|
| 288 |
date: string;
|
| 289 |
open?: number;
|