Italianhype commited on
Commit
b878d47
·
verified ·
1 Parent(s): bec1915

Deepen Market Brain intelligence system

Browse files
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) => <IPORadarCard row={row} key={`${row.company.id}-${row.latest_filing?.accession_number ?? row.company.name}`} />)}
 
 
 
 
 
 
 
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
- {row.latest_filing?.url && <a className="button" href={row.latest_filing.url} target="_blank" rel="noreferrer">Open SEC filing</a>}
 
 
 
 
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
- setBrain(await api.marketBrain());
 
 
 
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
- setBrain(await api.runMarketBrain(refreshPipeline));
 
 
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 yfinance OHLCV history, collects public RSS news, deduplicates articles, links them to assets and persists all artifacts in PostgreSQL." },
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>Volume</span><strong>{formatVolume(row.market_snapshot.volume)}</strong></div>
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>Horizon</span><strong>{signal.time_horizon}</strong></div>
26
- <div><span>Exchange</span><strong>{signal.asset?.exchange ?? "n/a"}</strong></div>
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;