Spaces:
Running
Running
Add chart vision technical analyst
Browse files- README.md +57 -1
- ROADMAP.md +9 -1
- backend/alembic/versions/0007_chart_vision_technical_analyst.py +122 -0
- backend/app/api/routes.py +107 -3
- backend/app/core/config.py +7 -1
- backend/app/models.py +83 -0
- backend/app/services/chart_vision_engine.py +191 -0
- backend/app/services/hybrid_chart_intelligence.py +655 -0
- backend/app/services/technical_analysis_engine.py +560 -0
- frontend/app/assets/[ticker]/view.tsx +3 -0
- frontend/app/chart-analyst/page.tsx +65 -0
- frontend/app/globals.css +66 -1
- frontend/app/methodology/page.tsx +1 -0
- frontend/components/AppShell.tsx +2 -1
- frontend/components/ChartAnalystPanel.tsx +291 -0
- frontend/lib/api.ts +10 -1
- frontend/lib/types.ts +36 -0
- frontend/package.json +1 -1
- package.json +1 -1
- requirements.txt +1 -0
README.md
CHANGED
|
@@ -29,6 +29,7 @@ This is not a consumer trading app and not a simple dashboard. The project is a
|
|
| 29 |
| Accuracy layer | multi-provider checks, data quality, source credibility, macro/fundamental context and Blum Confidence Score |
|
| 30 |
| Strategic intelligence | Opportunity Radar, Market Narrative AI, Asset Intelligence Reports, watchlist, portfolio scenarios and community sentiment |
|
| 31 |
| Self-learning layer | signal outcome evaluation, accuracy memory, adaptive confidence, model weight versions and historical similarity cases |
|
|
|
|
| 32 |
| AI sentiment | FinBERT primary, VADER baseline |
|
| 33 |
| Semantic layer | sentence-transformers embeddings, semantic search, theme discovery |
|
| 34 |
| Reasoning | lightweight Qwen-compatible LLM evidence-only explanation layer |
|
|
@@ -45,6 +46,8 @@ Blum does not use one generic AI model for everything.
|
|
| 45 |
- sentence-transformers: embeddings for semantic search, narrative clustering, recurring themes and links between assets, sectors and macro trends.
|
| 46 |
- Qwen-compatible lightweight LLM: structured explanations from retrieved evidence only.
|
| 47 |
- Blum Financial Brain: finance-domain reasoning adapter for regime interpretation, opportunity hypotheses, risk hypotheses and monitoring plans.
|
|
|
|
|
|
|
| 48 |
- Statistical time-series module: anomalies, volatility regimes and scenario bands, ready for Chronos, TimesFM or PatchTST integration.
|
| 49 |
- Rule-based quantitative engine: scoring, ranking, risk controls and classifications.
|
| 50 |
|
|
@@ -69,7 +72,9 @@ Blum does not use one generic AI model for everything.
|
|
| 69 |
17. Evaluate matured signal outcomes after 1D, 3D, 7D, 14D and 30D.
|
| 70 |
18. Update signal accuracy memory, source reliability, ticker/sector profiles and adaptive confidence adjustments.
|
| 71 |
19. Version scoring weights in the database when real matured outcomes justify recalibration.
|
| 72 |
-
20.
|
|
|
|
|
|
|
| 73 |
|
| 74 |
## Live Runtime
|
| 75 |
|
|
@@ -151,6 +156,49 @@ The UI exposes:
|
|
| 151 |
|
| 152 |
Governance rules are enforced in product language and API output: no absolute certainty, no direct financial advice, no autonomous trading, no self-modifying code and clear separation between observed data, inference and hypothesis.
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
## Market Brain
|
| 155 |
|
| 156 |
The Market Brain is Blum's high-level reasoning orchestrator. It is not a single model that invents an answer. It combines:
|
|
@@ -252,6 +300,12 @@ FastAPI exposes clean JSON endpoints:
|
|
| 252 |
- `POST /brain/evaluate-signals`
|
| 253 |
- `POST /brain/recalculate-weights`
|
| 254 |
- `POST /brain/run-learning-cycle`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
- `POST /signals/run`
|
| 256 |
- `POST /pipeline/run`
|
| 257 |
- `GET /pipeline/status`
|
|
@@ -291,6 +345,7 @@ Interactive API docs are available at `/docs`.
|
|
| 291 |
- Asset Intelligence Report
|
| 292 |
- AI Portfolio Scenario
|
| 293 |
- Market Brain
|
|
|
|
| 294 |
- Asset Detail
|
| 295 |
- Blum Memory
|
| 296 |
- Stock Radar
|
|
@@ -387,6 +442,7 @@ Backtesting is included for research validation only. It reports historical hit
|
|
| 387 |
- SEC EDGAR current feeds are public filing evidence. They do not cover private-company rumor, expected listings without filings or licensed IPO calendars.
|
| 388 |
- The system does not generate synthetic prices. If public providers fail or rate-limit, the affected assets are reported as missing instead of being filled with fake data.
|
| 389 |
- FinBERT, embeddings and LLM model loading depend on runtime memory and Hugging Face model availability.
|
|
|
|
| 390 |
- The reasoning layer must not invent data; it is constrained to retrieved evidence.
|
| 391 |
- Signal classifications are research triage outputs, not investment recommendations.
|
| 392 |
- PostgreSQL is the database layer; the Docker demo can start an embedded PostgreSQL instance for Hugging Face convenience.
|
|
|
|
| 29 |
| Accuracy layer | multi-provider checks, data quality, source credibility, macro/fundamental context and Blum Confidence Score |
|
| 30 |
| Strategic intelligence | Opportunity Radar, Market Narrative AI, Asset Intelligence Reports, watchlist, portfolio scenarios and community sentiment |
|
| 31 |
| Self-learning layer | signal outcome evaluation, accuracy memory, adaptive confidence, model weight versions and historical similarity cases |
|
| 32 |
+
| Chart intelligence | Qwen3-VL/InternVL3-ready visual chart analyst plus deterministic OHLCV technical engine |
|
| 33 |
| AI sentiment | FinBERT primary, VADER baseline |
|
| 34 |
| Semantic layer | sentence-transformers embeddings, semantic search, theme discovery |
|
| 35 |
| Reasoning | lightweight Qwen-compatible LLM evidence-only explanation layer |
|
|
|
|
| 46 |
- sentence-transformers: embeddings for semantic search, narrative clustering, recurring themes and links between assets, sectors and macro trends.
|
| 47 |
- Qwen-compatible lightweight LLM: structured explanations from retrieved evidence only.
|
| 48 |
- Blum Financial Brain: finance-domain reasoning adapter for regime interpretation, opportunity hypotheses, risk hypotheses and monitoring plans.
|
| 49 |
+
- Chart Vision Technical Analyst: Qwen3-VL primary VLM for visual chart interpretation when configured, InternVL3 fallback for low-confidence or failed primary visual reads.
|
| 50 |
+
- Deterministic Technical Analysis Engine: OHLCV-derived trend structure, levels, moving averages, RSI, MACD, Bollinger Bands, ATR, volume, gaps, divergences, volatility compression and risk/reward.
|
| 51 |
- Statistical time-series module: anomalies, volatility regimes and scenario bands, ready for Chronos, TimesFM or PatchTST integration.
|
| 52 |
- Rule-based quantitative engine: scoring, ranking, risk controls and classifications.
|
| 53 |
|
|
|
|
| 72 |
17. Evaluate matured signal outcomes after 1D, 3D, 7D, 14D and 30D.
|
| 73 |
18. Update signal accuracy memory, source reliability, ticker/sector profiles and adaptive confidence adjustments.
|
| 74 |
19. Version scoring weights in the database when real matured outcomes justify recalibration.
|
| 75 |
+
20. Generate professional chart analysis from deterministic OHLCV evidence and optional Qwen3-VL/InternVL3 visual interpretation.
|
| 76 |
+
21. Persist chart analyses, technical levels, technical signals and chart pattern memory.
|
| 77 |
+
22. Produce AI explanations using only retrieved evidence.
|
| 78 |
|
| 79 |
## Live Runtime
|
| 80 |
|
|
|
|
| 156 |
|
| 157 |
Governance rules are enforced in product language and API output: no absolute certainty, no direct financial advice, no autonomous trading, no self-modifying code and clear separation between observed data, inference and hypothesis.
|
| 158 |
|
| 159 |
+
## Chart Vision Technical Analyst
|
| 160 |
+
|
| 161 |
+
Blum includes a dedicated technical chart intelligence module. It is designed to read financial chart images when a vision model is configured, but it never relies only on visual interpretation.
|
| 162 |
+
|
| 163 |
+
The module combines:
|
| 164 |
+
|
| 165 |
+
- **Qwen3-VL** as the primary configurable vision-language model for chart screenshots.
|
| 166 |
+
- **InternVL3** as a fallback model for low-confidence or failed visual interpretation.
|
| 167 |
+
- A deterministic OHLCV technical analysis engine for objective indicator and level calculation.
|
| 168 |
+
- Blum Financial Brain memory for historical context and similarity.
|
| 169 |
+
- Existing sentiment/news context for confirmation or contradiction checks.
|
| 170 |
+
|
| 171 |
+
The deterministic engine calculates:
|
| 172 |
+
|
| 173 |
+
- trend direction;
|
| 174 |
+
- higher highs, higher lows, lower highs and lower lows;
|
| 175 |
+
- support and resistance zones;
|
| 176 |
+
- EMA 9/21/50/200 and SMA 20/50/200;
|
| 177 |
+
- RSI, MACD, Bollinger Bands and ATR;
|
| 178 |
+
- relative volume and volume pressure;
|
| 179 |
+
- volatility regime and compression;
|
| 180 |
+
- gaps, consolidation zones and pullback quality;
|
| 181 |
+
- accumulation/distribution bias;
|
| 182 |
+
- price/RSI and price/MACD divergences;
|
| 183 |
+
- breakout probability;
|
| 184 |
+
- trend strength;
|
| 185 |
+
- risk/reward geometry.
|
| 186 |
+
|
| 187 |
+
The hybrid layer outputs trend summary, key levels, bullish/bearish/neutral evidence, confirmation signals, contradiction signals, invalidation level, risk zone, opportunity zone, confidence, scenarios, what to watch next and historical chart similarity.
|
| 188 |
+
|
| 189 |
+
Model serving is configurable:
|
| 190 |
+
|
| 191 |
+
```bash
|
| 192 |
+
export CHART_VISION_MODEL=Qwen/Qwen3-VL
|
| 193 |
+
export CHART_VISION_FALLBACK_MODEL=OpenGVLab/InternVL3
|
| 194 |
+
export CHART_VISION_MODE=disabled # local | remote | disabled
|
| 195 |
+
export CHART_VISION_MIN_CONFIDENCE=0.70
|
| 196 |
+
export CHART_VISION_REMOTE_URL=
|
| 197 |
+
export CHART_VISION_REMOTE_TOKEN=
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
Default Docker demo behavior is `CHART_VISION_MODE=disabled`, so the app remains reliable on CPU Spaces. The UI displays: `Vision model unavailable, deterministic analysis active`. Use `remote` mode for a production VLM endpoint.
|
| 201 |
+
|
| 202 |
## Market Brain
|
| 203 |
|
| 204 |
The Market Brain is Blum's high-level reasoning orchestrator. It is not a single model that invents an answer. It combines:
|
|
|
|
| 300 |
- `POST /brain/evaluate-signals`
|
| 301 |
- `POST /brain/recalculate-weights`
|
| 302 |
- `POST /brain/run-learning-cycle`
|
| 303 |
+
- `POST /chart/analyze-image`
|
| 304 |
+
- `POST /chart/analyze-ticker`
|
| 305 |
+
- `GET /chart/technical-report/{ticker}`
|
| 306 |
+
- `GET /chart/levels/{ticker}`
|
| 307 |
+
- `GET /chart/signals/{ticker}`
|
| 308 |
+
- `GET /chart/history/{ticker}`
|
| 309 |
- `POST /signals/run`
|
| 310 |
- `POST /pipeline/run`
|
| 311 |
- `GET /pipeline/status`
|
|
|
|
| 345 |
- Asset Intelligence Report
|
| 346 |
- AI Portfolio Scenario
|
| 347 |
- Market Brain
|
| 348 |
+
- Chart Analyst
|
| 349 |
- Asset Detail
|
| 350 |
- Blum Memory
|
| 351 |
- Stock Radar
|
|
|
|
| 442 |
- SEC EDGAR current feeds are public filing evidence. They do not cover private-company rumor, expected listings without filings or licensed IPO calendars.
|
| 443 |
- The system does not generate synthetic prices. If public providers fail or rate-limit, the affected assets are reported as missing instead of being filled with fake data.
|
| 444 |
- FinBERT, embeddings and LLM model loading depend on runtime memory and Hugging Face model availability.
|
| 445 |
+
- Qwen3-VL and InternVL3 chart vision are optional because multimodal models can exceed CPU Space resources. Deterministic OHLCV technical analysis remains active without them.
|
| 446 |
- The reasoning layer must not invent data; it is constrained to retrieved evidence.
|
| 447 |
- Signal classifications are research triage outputs, not investment recommendations.
|
| 448 |
- PostgreSQL is the database layer; the Docker demo can start an embedded PostgreSQL instance for Hugging Face convenience.
|
ROADMAP.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
This roadmap is the execution plan for turning Blum into a credible open-source AI financial intelligence platform. Each phase should ship with working code, API coverage, UI coverage, documentation and explicit limitations.
|
| 4 |
|
| 5 |
-
## Current Increment -
|
| 6 |
|
| 7 |
Shipped in the current architecture:
|
| 8 |
|
|
@@ -37,6 +37,14 @@ Shipped in the current architecture:
|
|
| 37 |
- Asset Detail now exposes Blum Memory: similar historical setups, confidence evolution, outcome history, confidence-change reasons and what Blum learned.
|
| 38 |
- Signal Lab now exposes pre-signal confidence, post-evaluation result, learning impact and invalidating conditions for stored horizon evaluations.
|
| 39 |
- Governance is explicit: no autonomous code modification, no trading, no certainty claims, no direct financial advice and every database-only weight change is reversible.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
## Phase 0 - Stabilize Docker Space Deployment
|
| 42 |
|
|
|
|
| 2 |
|
| 3 |
This roadmap is the execution plan for turning Blum into a credible open-source AI financial intelligence platform. Each phase should ship with working code, API coverage, UI coverage, documentation and explicit limitations.
|
| 4 |
|
| 5 |
+
## Current Increment - Chart Vision Technical Analyst
|
| 6 |
|
| 7 |
Shipped in the current architecture:
|
| 8 |
|
|
|
|
| 37 |
- Asset Detail now exposes Blum Memory: similar historical setups, confidence evolution, outcome history, confidence-change reasons and what Blum learned.
|
| 38 |
- Signal Lab now exposes pre-signal confidence, post-evaluation result, learning impact and invalidating conditions for stored horizon evaluations.
|
| 39 |
- Governance is explicit: no autonomous code modification, no trading, no certainty claims, no direct financial advice and every database-only weight change is reversible.
|
| 40 |
+
- Chart Vision Technical Analyst adds a Qwen3-VL primary / InternVL3 fallback architecture for chart image interpretation, with deterministic OHLCV analysis as the always-on backbone.
|
| 41 |
+
- New chart tables include `chart_analyses`, `technical_levels`, `technical_signals` and `chart_pattern_memory`.
|
| 42 |
+
- New chart APIs include `/chart/analyze-image`, `/chart/analyze-ticker`, `/chart/technical-report/{ticker}`, `/chart/levels/{ticker}`, `/chart/signals/{ticker}` and `/chart/history/{ticker}`.
|
| 43 |
+
- The deterministic engine calculates trend structure, moving averages, RSI, MACD, Bollinger Bands, ATR, support/resistance, volume pressure, gaps, consolidation, accumulation/distribution, divergences, breakout probability, pullback quality and risk/reward geometry.
|
| 44 |
+
- The hybrid chart layer compares visual observations, OHLCV indicators, sentiment/news, Blum memory and similar chart setups.
|
| 45 |
+
- Asset Detail now includes a Chart Analyst module with timeframe selector, key-level overlays, technical snapshot, signal evidence, analyst report and historical similarity.
|
| 46 |
+
- A dedicated Chart Analyst page allows fast ticker-level technical analysis without opening an asset detail first.
|
| 47 |
+
- Default Space behavior keeps `CHART_VISION_MODE=disabled` for CPU reliability; the UI explicitly reports deterministic analysis active when the VLM is unavailable.
|
| 48 |
|
| 49 |
## Phase 0 - Stabilize Docker Space Deployment
|
| 50 |
|
backend/alembic/versions/0007_chart_vision_technical_analyst.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from alembic import op
|
| 4 |
+
import sqlalchemy as sa
|
| 5 |
+
from sqlalchemy.dialects import postgresql
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
revision = "0007_chart_vision_technical_analyst"
|
| 9 |
+
down_revision = "0006_financial_brain_learning"
|
| 10 |
+
branch_labels = None
|
| 11 |
+
depends_on = None
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
json_type = postgresql.JSONB(astext_type=sa.Text())
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def upgrade() -> None:
|
| 18 |
+
op.create_table(
|
| 19 |
+
"chart_analyses",
|
| 20 |
+
sa.Column("id", sa.Integer(), nullable=False),
|
| 21 |
+
sa.Column("asset_id", sa.Integer(), nullable=True),
|
| 22 |
+
sa.Column("ticker", sa.String(length=32), nullable=True),
|
| 23 |
+
sa.Column("timeframe", sa.String(length=40), nullable=False),
|
| 24 |
+
sa.Column("period", sa.String(length=40), nullable=False),
|
| 25 |
+
sa.Column("image_hash", sa.String(length=128), nullable=True),
|
| 26 |
+
sa.Column("model_used", sa.String(length=180), nullable=False),
|
| 27 |
+
sa.Column("visual_analysis_json", json_type, nullable=False),
|
| 28 |
+
sa.Column("deterministic_analysis_json", json_type, nullable=False),
|
| 29 |
+
sa.Column("hybrid_analysis_json", json_type, nullable=False),
|
| 30 |
+
sa.Column("chart_image", sa.Text(), nullable=True),
|
| 31 |
+
sa.Column("confidence", sa.Float(), nullable=False),
|
| 32 |
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 33 |
+
sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"),
|
| 34 |
+
sa.PrimaryKeyConstraint("id"),
|
| 35 |
+
)
|
| 36 |
+
op.create_index("ix_chart_analyses_asset_id", "chart_analyses", ["asset_id"])
|
| 37 |
+
op.create_index("ix_chart_analyses_ticker", "chart_analyses", ["ticker"])
|
| 38 |
+
op.create_index("ix_chart_analyses_timeframe", "chart_analyses", ["timeframe"])
|
| 39 |
+
op.create_index("ix_chart_analyses_period", "chart_analyses", ["period"])
|
| 40 |
+
op.create_index("ix_chart_analyses_image_hash", "chart_analyses", ["image_hash"])
|
| 41 |
+
op.create_index("ix_chart_analyses_model_used", "chart_analyses", ["model_used"])
|
| 42 |
+
op.create_index("ix_chart_analyses_confidence", "chart_analyses", ["confidence"])
|
| 43 |
+
op.create_index("ix_chart_analyses_created_at", "chart_analyses", ["created_at"])
|
| 44 |
+
op.create_index("ix_chart_analyses_ticker_timeframe_created", "chart_analyses", ["ticker", "timeframe", "created_at"])
|
| 45 |
+
|
| 46 |
+
op.create_table(
|
| 47 |
+
"technical_levels",
|
| 48 |
+
sa.Column("id", sa.Integer(), nullable=False),
|
| 49 |
+
sa.Column("asset_id", sa.Integer(), nullable=True),
|
| 50 |
+
sa.Column("ticker", sa.String(length=32), nullable=False),
|
| 51 |
+
sa.Column("timeframe", sa.String(length=40), nullable=False),
|
| 52 |
+
sa.Column("support_levels_json", json_type, nullable=False),
|
| 53 |
+
sa.Column("resistance_levels_json", json_type, nullable=False),
|
| 54 |
+
sa.Column("breakout_level", sa.Float(), nullable=True),
|
| 55 |
+
sa.Column("breakdown_level", sa.Float(), nullable=True),
|
| 56 |
+
sa.Column("invalidation_level", sa.Float(), nullable=True),
|
| 57 |
+
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
| 58 |
+
sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"),
|
| 59 |
+
sa.PrimaryKeyConstraint("id"),
|
| 60 |
+
sa.UniqueConstraint("ticker", "timeframe", name="uq_technical_levels_ticker_timeframe"),
|
| 61 |
+
)
|
| 62 |
+
op.create_index("ix_technical_levels_asset_id", "technical_levels", ["asset_id"])
|
| 63 |
+
op.create_index("ix_technical_levels_ticker", "technical_levels", ["ticker"])
|
| 64 |
+
op.create_index("ix_technical_levels_timeframe", "technical_levels", ["timeframe"])
|
| 65 |
+
op.create_index("ix_technical_levels_updated_at", "technical_levels", ["updated_at"])
|
| 66 |
+
op.create_index("ix_technical_levels_ticker_updated", "technical_levels", ["ticker", "updated_at"])
|
| 67 |
+
|
| 68 |
+
op.create_table(
|
| 69 |
+
"technical_signals",
|
| 70 |
+
sa.Column("id", sa.Integer(), nullable=False),
|
| 71 |
+
sa.Column("asset_id", sa.Integer(), nullable=True),
|
| 72 |
+
sa.Column("ticker", sa.String(length=32), nullable=False),
|
| 73 |
+
sa.Column("timeframe", sa.String(length=40), nullable=False),
|
| 74 |
+
sa.Column("signal_type", sa.String(length=120), nullable=False),
|
| 75 |
+
sa.Column("direction", sa.String(length=40), nullable=False),
|
| 76 |
+
sa.Column("confidence", sa.Float(), nullable=False),
|
| 77 |
+
sa.Column("evidence_json", json_type, nullable=False),
|
| 78 |
+
sa.Column("invalidation_level", sa.Float(), nullable=True),
|
| 79 |
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 80 |
+
sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"),
|
| 81 |
+
sa.PrimaryKeyConstraint("id"),
|
| 82 |
+
)
|
| 83 |
+
op.create_index("ix_technical_signals_asset_id", "technical_signals", ["asset_id"])
|
| 84 |
+
op.create_index("ix_technical_signals_ticker", "technical_signals", ["ticker"])
|
| 85 |
+
op.create_index("ix_technical_signals_timeframe", "technical_signals", ["timeframe"])
|
| 86 |
+
op.create_index("ix_technical_signals_signal_type", "technical_signals", ["signal_type"])
|
| 87 |
+
op.create_index("ix_technical_signals_direction", "technical_signals", ["direction"])
|
| 88 |
+
op.create_index("ix_technical_signals_confidence", "technical_signals", ["confidence"])
|
| 89 |
+
op.create_index("ix_technical_signals_created_at", "technical_signals", ["created_at"])
|
| 90 |
+
op.create_index("ix_technical_signals_ticker_timeframe_created", "technical_signals", ["ticker", "timeframe", "created_at"])
|
| 91 |
+
|
| 92 |
+
op.create_table(
|
| 93 |
+
"chart_pattern_memory",
|
| 94 |
+
sa.Column("id", sa.Integer(), nullable=False),
|
| 95 |
+
sa.Column("asset_id", sa.Integer(), nullable=True),
|
| 96 |
+
sa.Column("ticker", sa.String(length=32), nullable=False),
|
| 97 |
+
sa.Column("timeframe", sa.String(length=40), nullable=False),
|
| 98 |
+
sa.Column("pattern_type", sa.String(length=120), nullable=False),
|
| 99 |
+
sa.Column("setup_embedding", json_type, nullable=False),
|
| 100 |
+
sa.Column("outcome_1d", sa.Float(), nullable=True),
|
| 101 |
+
sa.Column("outcome_7d", sa.Float(), nullable=True),
|
| 102 |
+
sa.Column("outcome_30d", sa.Float(), nullable=True),
|
| 103 |
+
sa.Column("max_drawdown", sa.Float(), nullable=True),
|
| 104 |
+
sa.Column("success", sa.Boolean(), nullable=True),
|
| 105 |
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
| 106 |
+
sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"),
|
| 107 |
+
sa.PrimaryKeyConstraint("id"),
|
| 108 |
+
)
|
| 109 |
+
op.create_index("ix_chart_pattern_memory_asset_id", "chart_pattern_memory", ["asset_id"])
|
| 110 |
+
op.create_index("ix_chart_pattern_memory_ticker", "chart_pattern_memory", ["ticker"])
|
| 111 |
+
op.create_index("ix_chart_pattern_memory_timeframe", "chart_pattern_memory", ["timeframe"])
|
| 112 |
+
op.create_index("ix_chart_pattern_memory_pattern_type", "chart_pattern_memory", ["pattern_type"])
|
| 113 |
+
op.create_index("ix_chart_pattern_memory_success", "chart_pattern_memory", ["success"])
|
| 114 |
+
op.create_index("ix_chart_pattern_memory_created_at", "chart_pattern_memory", ["created_at"])
|
| 115 |
+
op.create_index("ix_chart_pattern_memory_ticker_pattern_created", "chart_pattern_memory", ["ticker", "pattern_type", "created_at"])
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def downgrade() -> None:
|
| 119 |
+
op.drop_table("chart_pattern_memory")
|
| 120 |
+
op.drop_table("technical_signals")
|
| 121 |
+
op.drop_table("technical_levels")
|
| 122 |
+
op.drop_table("chart_analyses")
|
backend/app/api/routes.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from datetime import datetime
|
|
|
|
| 4 |
import os
|
| 5 |
|
| 6 |
from sqlalchemy import desc, func, select
|
| 7 |
from sqlalchemy.orm import Session
|
| 8 |
-
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 9 |
|
| 10 |
from app.ai.orchestrator import AIOrchestrator
|
| 11 |
from app.ai.financial_brain import FinancialBrainModel
|
|
@@ -16,6 +17,8 @@ from app.models import (
|
|
| 16 |
AIInsight,
|
| 17 |
AccuracySnapshot,
|
| 18 |
Asset,
|
|
|
|
|
|
|
| 19 |
ConfidenceAdjustment,
|
| 20 |
EmbeddingVector,
|
| 21 |
FundamentalSnapshot,
|
|
@@ -35,6 +38,8 @@ from app.models import (
|
|
| 35 |
SignalOutcome,
|
| 36 |
SignalSnapshot,
|
| 37 |
SourceReliabilityScore,
|
|
|
|
|
|
|
| 38 |
TickerAccuracyProfile,
|
| 39 |
WatchlistItem,
|
| 40 |
)
|
|
@@ -44,6 +49,7 @@ from app.services.dashboard import dashboard_overview, signal_payload
|
|
| 44 |
from app.services.data_continuity import data_coverage_report, repair_data_gaps
|
| 45 |
from app.services.etf import list_etf_trends, update_etf_trends
|
| 46 |
from app.services.fundamentals import fundamentals_for_asset, update_fundamentals
|
|
|
|
| 47 |
from app.services.financial_brain_learning import (
|
| 48 |
brain_accuracy,
|
| 49 |
brain_asset_memory,
|
|
@@ -55,6 +61,7 @@ from app.services.financial_brain_learning import (
|
|
| 55 |
recalculate_model_weights,
|
| 56 |
run_learning_cycle,
|
| 57 |
)
|
|
|
|
| 58 |
from app.services.ipo import ipo_radar, sec_company_submissions, update_ipo_radar
|
| 59 |
from app.services.live import live_news, market_sentiment
|
| 60 |
from app.services.macro import macro_overview, update_macro_snapshots
|
|
@@ -94,7 +101,7 @@ def system_status(db: Session = Depends(get_db)) -> dict:
|
|
| 94 |
return {
|
| 95 |
"service": "blum-ai-financial-intelligence",
|
| 96 |
"app_version": settings.app_version,
|
| 97 |
-
"feature_set": "
|
| 98 |
"environment": settings.environment,
|
| 99 |
"generated_at": datetime.utcnow().isoformat(),
|
| 100 |
"hugging_face": {
|
|
@@ -115,6 +122,8 @@ def system_status(db: Session = Depends(get_db)) -> dict:
|
|
| 115 |
"accuracy_audit_minutes": settings.accuracy_audit_minutes,
|
| 116 |
"learning_loop_enabled": settings.enable_learning_loop,
|
| 117 |
"learning_loop_minutes": settings.learning_loop_minutes,
|
|
|
|
|
|
|
| 118 |
"fundamentals_refresh_minutes": settings.fundamentals_refresh_minutes,
|
| 119 |
"macro_refresh_minutes": settings.macro_refresh_minutes,
|
| 120 |
},
|
|
@@ -124,6 +133,7 @@ def system_status(db: Session = Depends(get_db)) -> dict:
|
|
| 124 |
"reasoning_llm": settings.llm_model,
|
| 125 |
"financial_brain_configured": settings.financial_brain_model,
|
| 126 |
"financial_brain_runtime": FinancialBrainModel().status(),
|
|
|
|
| 127 |
},
|
| 128 |
"feature_visibility": {
|
| 129 |
"market_brain_page": True,
|
|
@@ -137,6 +147,7 @@ def system_status(db: Session = Depends(get_db)) -> dict:
|
|
| 137 |
"macro_fundamental_context": True,
|
| 138 |
"strategic_intelligence_layer": True,
|
| 139 |
"self_learning_financial_brain": True,
|
|
|
|
| 140 |
"portfolio_scenario": True,
|
| 141 |
"watchlist": True,
|
| 142 |
},
|
|
@@ -161,13 +172,17 @@ def system_status(db: Session = Depends(get_db)) -> dict:
|
|
| 161 |
"source_reliability_scores": int(db.scalar(select(func.count(SourceReliabilityScore.id))) or 0),
|
| 162 |
"ticker_accuracy_profiles": int(db.scalar(select(func.count(TickerAccuracyProfile.id))) or 0),
|
| 163 |
"sector_accuracy_profiles": int(db.scalar(select(func.count(SectorAccuracyProfile.id))) or 0),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
},
|
| 165 |
"latest_news_created_at": latest_brain,
|
| 166 |
"why_gui_can_look_unchanged": [
|
| 167 |
"Hugging Face serves the previous image until the Docker build finishes successfully.",
|
| 168 |
"The finance-domain 7B model is disabled by default unless BLUM_ENABLE_FINANCIAL_BRAIN_MODEL=true.",
|
| 169 |
"Existing snapshots must be regenerated with Run brain or full pipeline after a new deployment.",
|
| 170 |
-
"Browser cache can keep old static Next.js chunks; hard refresh if app_version is not 0.5.
|
| 171 |
],
|
| 172 |
}
|
| 173 |
|
|
@@ -223,6 +238,64 @@ def financial_brain_run_learning_cycle(limit: int = Query(default=240, ge=1, le=
|
|
| 223 |
return run_learning_cycle(db, limit=limit)
|
| 224 |
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
@router.get("/assets")
|
| 227 |
def list_assets(
|
| 228 |
asset_type: str | None = Query(default=None),
|
|
@@ -580,9 +653,12 @@ def ai_model_status(db: Session = Depends(get_db)):
|
|
| 580 |
"embeddings": settings.embedding_model,
|
| 581 |
"reasoning_llm": settings.llm_model,
|
| 582 |
"financial_brain": settings.financial_brain_model,
|
|
|
|
|
|
|
| 583 |
"time_series": "statistical-fallback with adapter-ready interface",
|
| 584 |
},
|
| 585 |
"financial_brain": FinancialBrainModel().status(),
|
|
|
|
| 586 |
"observed_models": {
|
| 587 |
"sentiment": [{"model_name": model, "records": int(count)} for model, count in sentiment_models],
|
| 588 |
"embeddings": [{"model_name": model, "records": int(count)} for model, count in embedding_models],
|
|
@@ -593,6 +669,7 @@ def ai_model_status(db: Session = Depends(get_db)):
|
|
| 593 |
"embeddings": "sentence-transformers primary when loadable; deterministic embedding fallback is explicit in code path.",
|
| 594 |
"reasoning": "Configured LLM when loadable; deterministic evidence reasoner fallback never invents data.",
|
| 595 |
"time_series": "Transparent statistical fallback until Chronos, TimesFM or PatchTST adapter is enabled.",
|
|
|
|
| 596 |
},
|
| 597 |
}
|
| 598 |
|
|
@@ -644,6 +721,33 @@ def backtest(ticker: str, db: Session = Depends(get_db)):
|
|
| 644 |
return run_simple_backtest(db, asset.id, asset.ticker)
|
| 645 |
|
| 646 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
def require_asset(db: Session, ticker: str) -> Asset:
|
| 648 |
asset = db.scalar(select(Asset).where(Asset.ticker == ticker.upper()))
|
| 649 |
if not asset:
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from datetime import datetime
|
| 4 |
+
import json
|
| 5 |
import os
|
| 6 |
|
| 7 |
from sqlalchemy import desc, func, select
|
| 8 |
from sqlalchemy.orm import Session
|
| 9 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
| 10 |
|
| 11 |
from app.ai.orchestrator import AIOrchestrator
|
| 12 |
from app.ai.financial_brain import FinancialBrainModel
|
|
|
|
| 17 |
AIInsight,
|
| 18 |
AccuracySnapshot,
|
| 19 |
Asset,
|
| 20 |
+
ChartAnalysis,
|
| 21 |
+
ChartPatternMemory,
|
| 22 |
ConfidenceAdjustment,
|
| 23 |
EmbeddingVector,
|
| 24 |
FundamentalSnapshot,
|
|
|
|
| 38 |
SignalOutcome,
|
| 39 |
SignalSnapshot,
|
| 40 |
SourceReliabilityScore,
|
| 41 |
+
TechnicalLevel,
|
| 42 |
+
TechnicalSignal,
|
| 43 |
TickerAccuracyProfile,
|
| 44 |
WatchlistItem,
|
| 45 |
)
|
|
|
|
| 49 |
from app.services.data_continuity import data_coverage_report, repair_data_gaps
|
| 50 |
from app.services.etf import list_etf_trends, update_etf_trends
|
| 51 |
from app.services.fundamentals import fundamentals_for_asset, update_fundamentals
|
| 52 |
+
from app.services.chart_vision_engine import ChartVisionEngine
|
| 53 |
from app.services.financial_brain_learning import (
|
| 54 |
brain_accuracy,
|
| 55 |
brain_asset_memory,
|
|
|
|
| 61 |
recalculate_model_weights,
|
| 62 |
run_learning_cycle,
|
| 63 |
)
|
| 64 |
+
from app.services.hybrid_chart_intelligence import HybridChartIntelligence
|
| 65 |
from app.services.ipo import ipo_radar, sec_company_submissions, update_ipo_radar
|
| 66 |
from app.services.live import live_news, market_sentiment
|
| 67 |
from app.services.macro import macro_overview, update_macro_snapshots
|
|
|
|
| 101 |
return {
|
| 102 |
"service": "blum-ai-financial-intelligence",
|
| 103 |
"app_version": settings.app_version,
|
| 104 |
+
"feature_set": "chart-vision-technical-analyst-v0.5.8",
|
| 105 |
"environment": settings.environment,
|
| 106 |
"generated_at": datetime.utcnow().isoformat(),
|
| 107 |
"hugging_face": {
|
|
|
|
| 122 |
"accuracy_audit_minutes": settings.accuracy_audit_minutes,
|
| 123 |
"learning_loop_enabled": settings.enable_learning_loop,
|
| 124 |
"learning_loop_minutes": settings.learning_loop_minutes,
|
| 125 |
+
"chart_vision_mode": settings.chart_vision_mode,
|
| 126 |
+
"chart_vision_min_confidence": settings.chart_vision_min_confidence,
|
| 127 |
"fundamentals_refresh_minutes": settings.fundamentals_refresh_minutes,
|
| 128 |
"macro_refresh_minutes": settings.macro_refresh_minutes,
|
| 129 |
},
|
|
|
|
| 133 |
"reasoning_llm": settings.llm_model,
|
| 134 |
"financial_brain_configured": settings.financial_brain_model,
|
| 135 |
"financial_brain_runtime": FinancialBrainModel().status(),
|
| 136 |
+
"chart_vision": ChartVisionEngine().status(),
|
| 137 |
},
|
| 138 |
"feature_visibility": {
|
| 139 |
"market_brain_page": True,
|
|
|
|
| 147 |
"macro_fundamental_context": True,
|
| 148 |
"strategic_intelligence_layer": True,
|
| 149 |
"self_learning_financial_brain": True,
|
| 150 |
+
"chart_vision_technical_analyst": True,
|
| 151 |
"portfolio_scenario": True,
|
| 152 |
"watchlist": True,
|
| 153 |
},
|
|
|
|
| 172 |
"source_reliability_scores": int(db.scalar(select(func.count(SourceReliabilityScore.id))) or 0),
|
| 173 |
"ticker_accuracy_profiles": int(db.scalar(select(func.count(TickerAccuracyProfile.id))) or 0),
|
| 174 |
"sector_accuracy_profiles": int(db.scalar(select(func.count(SectorAccuracyProfile.id))) or 0),
|
| 175 |
+
"chart_analyses": int(db.scalar(select(func.count(ChartAnalysis.id))) or 0),
|
| 176 |
+
"technical_levels": int(db.scalar(select(func.count(TechnicalLevel.id))) or 0),
|
| 177 |
+
"technical_signals": int(db.scalar(select(func.count(TechnicalSignal.id))) or 0),
|
| 178 |
+
"chart_pattern_memory": int(db.scalar(select(func.count(ChartPatternMemory.id))) or 0),
|
| 179 |
},
|
| 180 |
"latest_news_created_at": latest_brain,
|
| 181 |
"why_gui_can_look_unchanged": [
|
| 182 |
"Hugging Face serves the previous image until the Docker build finishes successfully.",
|
| 183 |
"The finance-domain 7B model is disabled by default unless BLUM_ENABLE_FINANCIAL_BRAIN_MODEL=true.",
|
| 184 |
"Existing snapshots must be regenerated with Run brain or full pipeline after a new deployment.",
|
| 185 |
+
"Browser cache can keep old static Next.js chunks; hard refresh if app_version is not 0.5.8.",
|
| 186 |
],
|
| 187 |
}
|
| 188 |
|
|
|
|
| 238 |
return run_learning_cycle(db, limit=limit)
|
| 239 |
|
| 240 |
|
| 241 |
+
@router.post("/chart/analyze-image")
|
| 242 |
+
async def chart_analyze_image(
|
| 243 |
+
image: UploadFile = File(...),
|
| 244 |
+
ticker: str | None = Form(default=None),
|
| 245 |
+
timeframe: str = Form(default="unknown"),
|
| 246 |
+
ohlcv_data: str | None = Form(default=None),
|
| 247 |
+
db: Session = Depends(get_db),
|
| 248 |
+
) -> dict:
|
| 249 |
+
image_bytes = await image.read()
|
| 250 |
+
if not image_bytes:
|
| 251 |
+
raise HTTPException(status_code=400, detail="Uploaded chart image is empty.")
|
| 252 |
+
parsed_ohlcv = parse_ohlcv_payload(ohlcv_data)
|
| 253 |
+
return HybridChartIntelligence().analyze_uploaded_image(
|
| 254 |
+
db,
|
| 255 |
+
image_bytes=image_bytes,
|
| 256 |
+
ticker=ticker.upper() if ticker else None,
|
| 257 |
+
timeframe=timeframe,
|
| 258 |
+
ohlcv_rows=parsed_ohlcv,
|
| 259 |
+
persist=bool(ticker),
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@router.post("/chart/analyze-ticker")
|
| 264 |
+
def chart_analyze_ticker(
|
| 265 |
+
ticker: str = Query(...),
|
| 266 |
+
timeframe: str = Query(default="6M"),
|
| 267 |
+
period: str = Query(default="1y"),
|
| 268 |
+
include_visual: bool = Query(default=False),
|
| 269 |
+
db: Session = Depends(get_db),
|
| 270 |
+
) -> dict:
|
| 271 |
+
asset = require_asset(db, ticker)
|
| 272 |
+
return HybridChartIntelligence().analyze_ticker(db, asset, timeframe=timeframe, period=period, include_visual=include_visual, persist=True)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@router.get("/chart/technical-report/{ticker}")
|
| 276 |
+
def chart_technical_report(ticker: str, timeframe: str = Query(default="6M"), db: Session = Depends(get_db)) -> dict:
|
| 277 |
+
asset = require_asset(db, ticker)
|
| 278 |
+
return HybridChartIntelligence().latest_report(db, asset, timeframe=timeframe)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@router.get("/chart/levels/{ticker}")
|
| 282 |
+
def chart_levels(ticker: str, timeframe: str = Query(default="6M"), db: Session = Depends(get_db)) -> dict:
|
| 283 |
+
asset = require_asset(db, ticker)
|
| 284 |
+
return HybridChartIntelligence().levels(db, asset, timeframe=timeframe)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@router.get("/chart/signals/{ticker}")
|
| 288 |
+
def chart_signals(ticker: str, timeframe: str = Query(default="6M"), limit: int = Query(default=30, ge=1, le=120), db: Session = Depends(get_db)) -> list[dict]:
|
| 289 |
+
asset = require_asset(db, ticker)
|
| 290 |
+
return HybridChartIntelligence().signals(db, asset, timeframe=timeframe, limit=limit)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@router.get("/chart/history/{ticker}")
|
| 294 |
+
def chart_history(ticker: str, limit: int = Query(default=30, ge=1, le=120), db: Session = Depends(get_db)) -> list[dict]:
|
| 295 |
+
asset = require_asset(db, ticker)
|
| 296 |
+
return HybridChartIntelligence().history(db, asset, limit=limit)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
@router.get("/assets")
|
| 300 |
def list_assets(
|
| 301 |
asset_type: str | None = Query(default=None),
|
|
|
|
| 653 |
"embeddings": settings.embedding_model,
|
| 654 |
"reasoning_llm": settings.llm_model,
|
| 655 |
"financial_brain": settings.financial_brain_model,
|
| 656 |
+
"chart_vision_primary": settings.chart_vision_model,
|
| 657 |
+
"chart_vision_fallback": settings.chart_vision_fallback_model,
|
| 658 |
"time_series": "statistical-fallback with adapter-ready interface",
|
| 659 |
},
|
| 660 |
"financial_brain": FinancialBrainModel().status(),
|
| 661 |
+
"chart_vision": ChartVisionEngine().status(),
|
| 662 |
"observed_models": {
|
| 663 |
"sentiment": [{"model_name": model, "records": int(count)} for model, count in sentiment_models],
|
| 664 |
"embeddings": [{"model_name": model, "records": int(count)} for model, count in embedding_models],
|
|
|
|
| 669 |
"embeddings": "sentence-transformers primary when loadable; deterministic embedding fallback is explicit in code path.",
|
| 670 |
"reasoning": "Configured LLM when loadable; deterministic evidence reasoner fallback never invents data.",
|
| 671 |
"time_series": "Transparent statistical fallback until Chronos, TimesFM or PatchTST adapter is enabled.",
|
| 672 |
+
"chart_vision": "Qwen3-VL primary when remote/local vision is configured; InternVL3 fallback; deterministic OHLCV analysis remains active if VLM is unavailable.",
|
| 673 |
},
|
| 674 |
}
|
| 675 |
|
|
|
|
| 721 |
return run_simple_backtest(db, asset.id, asset.ticker)
|
| 722 |
|
| 723 |
|
| 724 |
+
def parse_ohlcv_payload(raw: str | None) -> list[dict] | None:
|
| 725 |
+
if not raw:
|
| 726 |
+
return None
|
| 727 |
+
try:
|
| 728 |
+
payload = json.loads(raw)
|
| 729 |
+
except json.JSONDecodeError as exc:
|
| 730 |
+
raise HTTPException(status_code=400, detail=f"Invalid ohlcv_data JSON: {exc.msg}") from exc
|
| 731 |
+
rows = payload.get("rows") if isinstance(payload, dict) else payload
|
| 732 |
+
if not isinstance(rows, list):
|
| 733 |
+
raise HTTPException(status_code=400, detail="ohlcv_data must be a JSON array or an object with a rows array.")
|
| 734 |
+
normalized = []
|
| 735 |
+
for row in rows:
|
| 736 |
+
if not isinstance(row, dict) or "close" not in row:
|
| 737 |
+
continue
|
| 738 |
+
normalized.append(
|
| 739 |
+
{
|
| 740 |
+
"date": row.get("date"),
|
| 741 |
+
"open": row.get("open", row.get("close")),
|
| 742 |
+
"high": row.get("high", row.get("close")),
|
| 743 |
+
"low": row.get("low", row.get("close")),
|
| 744 |
+
"close": row.get("close"),
|
| 745 |
+
"volume": row.get("volume", 0),
|
| 746 |
+
}
|
| 747 |
+
)
|
| 748 |
+
return normalized
|
| 749 |
+
|
| 750 |
+
|
| 751 |
def require_asset(db: Session, ticker: str) -> Asset:
|
| 752 |
asset = db.scalar(select(Asset).where(Asset.ticker == ticker.upper()))
|
| 753 |
if not asset:
|
backend/app/core/config.py
CHANGED
|
@@ -5,7 +5,7 @@ from pydantic_settings import BaseSettings
|
|
| 5 |
|
| 6 |
class Settings(BaseSettings):
|
| 7 |
app_name: str = "Blum AI Financial Intelligence"
|
| 8 |
-
app_version: str = "0.5.
|
| 9 |
environment: str = Field(default="demo", alias="ENVIRONMENT")
|
| 10 |
database_url: str = Field(
|
| 11 |
default="postgresql+psycopg2://postgres:postgres@127.0.0.1:5432/blum",
|
|
@@ -20,6 +20,12 @@ class Settings(BaseSettings):
|
|
| 20 |
enable_financial_brain_model: bool = Field(default=False, alias="BLUM_ENABLE_FINANCIAL_BRAIN_MODEL")
|
| 21 |
financial_brain_model: str = Field(default="AdaptLLM/finance-chat", alias="BLUM_FINANCIAL_BRAIN_MODEL")
|
| 22 |
financial_brain_max_new_tokens: int = Field(default=420, alias="BLUM_FINANCIAL_BRAIN_MAX_NEW_TOKENS")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
default_benchmark: str = Field(default="SPY", alias="BLUM_DEFAULT_BENCHMARK")
|
| 24 |
enable_yfinance_fallback: bool = Field(default=False, alias="BLUM_ENABLE_YFINANCE_FALLBACK")
|
| 25 |
max_update_assets: int = Field(default=36, alias="BLUM_MAX_UPDATE_ASSETS")
|
|
|
|
| 5 |
|
| 6 |
class Settings(BaseSettings):
|
| 7 |
app_name: str = "Blum AI Financial Intelligence"
|
| 8 |
+
app_version: str = "0.5.8"
|
| 9 |
environment: str = Field(default="demo", alias="ENVIRONMENT")
|
| 10 |
database_url: str = Field(
|
| 11 |
default="postgresql+psycopg2://postgres:postgres@127.0.0.1:5432/blum",
|
|
|
|
| 20 |
enable_financial_brain_model: bool = Field(default=False, alias="BLUM_ENABLE_FINANCIAL_BRAIN_MODEL")
|
| 21 |
financial_brain_model: str = Field(default="AdaptLLM/finance-chat", alias="BLUM_FINANCIAL_BRAIN_MODEL")
|
| 22 |
financial_brain_max_new_tokens: int = Field(default=420, alias="BLUM_FINANCIAL_BRAIN_MAX_NEW_TOKENS")
|
| 23 |
+
chart_vision_model: str = Field(default="Qwen/Qwen3-VL", alias="CHART_VISION_MODEL")
|
| 24 |
+
chart_vision_fallback_model: str = Field(default="OpenGVLab/InternVL3", alias="CHART_VISION_FALLBACK_MODEL")
|
| 25 |
+
chart_vision_mode: str = Field(default="disabled", alias="CHART_VISION_MODE")
|
| 26 |
+
chart_vision_remote_url: str = Field(default="", alias="CHART_VISION_REMOTE_URL")
|
| 27 |
+
chart_vision_remote_token: str = Field(default="", alias="CHART_VISION_REMOTE_TOKEN")
|
| 28 |
+
chart_vision_min_confidence: float = Field(default=0.70, alias="CHART_VISION_MIN_CONFIDENCE")
|
| 29 |
default_benchmark: str = Field(default="SPY", alias="BLUM_DEFAULT_BENCHMARK")
|
| 30 |
enable_yfinance_fallback: bool = Field(default=False, alias="BLUM_ENABLE_YFINANCE_FALLBACK")
|
| 31 |
max_update_assets: int = Field(default=36, alias="BLUM_MAX_UPDATE_ASSETS")
|
backend/app/models.py
CHANGED
|
@@ -466,6 +466,89 @@ class SectorAccuracyProfile(Base):
|
|
| 466 |
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
|
| 467 |
|
| 468 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
class AccuracySnapshot(Base):
|
| 470 |
__tablename__ = "accuracy_snapshots"
|
| 471 |
__table_args__ = (Index("ix_accuracy_snapshots_scope_created", "scope", "created_at"),)
|
|
|
|
| 466 |
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
|
| 467 |
|
| 468 |
|
| 469 |
+
class ChartAnalysis(Base):
|
| 470 |
+
__tablename__ = "chart_analyses"
|
| 471 |
+
__table_args__ = (
|
| 472 |
+
Index("ix_chart_analyses_ticker_timeframe_created", "ticker", "timeframe", "created_at"),
|
| 473 |
+
Index("ix_chart_analyses_image_hash", "image_hash"),
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 477 |
+
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), index=True)
|
| 478 |
+
ticker: Mapped[str | None] = mapped_column(String(32), index=True)
|
| 479 |
+
timeframe: Mapped[str] = mapped_column(String(40), default="6M", index=True)
|
| 480 |
+
period: Mapped[str] = mapped_column(String(40), default="1y", index=True)
|
| 481 |
+
image_hash: Mapped[str | None] = mapped_column(String(128), index=True)
|
| 482 |
+
model_used: Mapped[str] = mapped_column(String(180), default="deterministic_technical_analysis", index=True)
|
| 483 |
+
visual_analysis_json: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 484 |
+
deterministic_analysis_json: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 485 |
+
hybrid_analysis_json: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 486 |
+
chart_image: Mapped[str | None] = mapped_column(Text)
|
| 487 |
+
confidence: Mapped[float] = mapped_column(Float, default=0.0, index=True)
|
| 488 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 489 |
+
|
| 490 |
+
asset = relationship("Asset")
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
class TechnicalLevel(Base):
|
| 494 |
+
__tablename__ = "technical_levels"
|
| 495 |
+
__table_args__ = (
|
| 496 |
+
UniqueConstraint("ticker", "timeframe", name="uq_technical_levels_ticker_timeframe"),
|
| 497 |
+
Index("ix_technical_levels_ticker_updated", "ticker", "updated_at"),
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 501 |
+
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), index=True)
|
| 502 |
+
ticker: Mapped[str] = mapped_column(String(32), index=True)
|
| 503 |
+
timeframe: Mapped[str] = mapped_column(String(40), default="6M", index=True)
|
| 504 |
+
support_levels_json: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 505 |
+
resistance_levels_json: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 506 |
+
breakout_level: Mapped[float | None] = mapped_column(Float)
|
| 507 |
+
breakdown_level: Mapped[float | None] = mapped_column(Float)
|
| 508 |
+
invalidation_level: Mapped[float | None] = mapped_column(Float)
|
| 509 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
|
| 510 |
+
|
| 511 |
+
asset = relationship("Asset")
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
class TechnicalSignal(Base):
|
| 515 |
+
__tablename__ = "technical_signals"
|
| 516 |
+
__table_args__ = (Index("ix_technical_signals_ticker_timeframe_created", "ticker", "timeframe", "created_at"),)
|
| 517 |
+
|
| 518 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 519 |
+
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), index=True)
|
| 520 |
+
ticker: Mapped[str] = mapped_column(String(32), index=True)
|
| 521 |
+
timeframe: Mapped[str] = mapped_column(String(40), default="6M", index=True)
|
| 522 |
+
signal_type: Mapped[str] = mapped_column(String(120), index=True)
|
| 523 |
+
direction: Mapped[str] = mapped_column(String(40), index=True)
|
| 524 |
+
confidence: Mapped[float] = mapped_column(Float, default=0.0, index=True)
|
| 525 |
+
evidence_json: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 526 |
+
invalidation_level: Mapped[float | None] = mapped_column(Float)
|
| 527 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 528 |
+
|
| 529 |
+
asset = relationship("Asset")
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
class ChartPatternMemory(Base):
|
| 533 |
+
__tablename__ = "chart_pattern_memory"
|
| 534 |
+
__table_args__ = (Index("ix_chart_pattern_memory_ticker_pattern_created", "ticker", "pattern_type", "created_at"),)
|
| 535 |
+
|
| 536 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 537 |
+
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), index=True)
|
| 538 |
+
ticker: Mapped[str] = mapped_column(String(32), index=True)
|
| 539 |
+
timeframe: Mapped[str] = mapped_column(String(40), default="6M", index=True)
|
| 540 |
+
pattern_type: Mapped[str] = mapped_column(String(120), index=True)
|
| 541 |
+
setup_embedding: Mapped[dict] = mapped_column(JsonType, default=dict)
|
| 542 |
+
outcome_1d: Mapped[float | None] = mapped_column(Float)
|
| 543 |
+
outcome_7d: Mapped[float | None] = mapped_column(Float)
|
| 544 |
+
outcome_30d: Mapped[float | None] = mapped_column(Float)
|
| 545 |
+
max_drawdown: Mapped[float | None] = mapped_column(Float)
|
| 546 |
+
success: Mapped[bool | None] = mapped_column(Boolean, index=True)
|
| 547 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 548 |
+
|
| 549 |
+
asset = relationship("Asset")
|
| 550 |
+
|
| 551 |
+
|
| 552 |
class AccuracySnapshot(Base):
|
| 553 |
__tablename__ = "accuracy_snapshots"
|
| 554 |
__table_args__ = (Index("ix_accuracy_snapshots_scope_created", "scope", "created_at"),)
|
backend/app/services/chart_vision_engine.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
from app.core.config import get_settings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
CHART_VISION_PROMPT = (
|
| 14 |
+
"You are an expert institutional technical analyst. Analyze this financial chart with maximum precision. "
|
| 15 |
+
"Identify trend, structure, support, resistance, breakout/breakdown, volume behavior, volatility compression, "
|
| 16 |
+
"candlestick signals, moving average behavior, momentum signals, divergences, continuation/reversal patterns, "
|
| 17 |
+
"and invalidation levels. Distinguish between what is visually observable and what is inferred. "
|
| 18 |
+
"Do not invent data not visible in the chart. Return structured JSON with: chart_type, timeframe_detected, "
|
| 19 |
+
"asset_detected, visible_indicators, trend_structure, support_levels, resistance_levels, chart_patterns, "
|
| 20 |
+
"candlestick_patterns, volume_analysis, momentum_interpretation, volatility_interpretation, bullish_evidence, "
|
| 21 |
+
"bearish_evidence, neutral_evidence, invalidation_levels, technical_summary, confidence, uncertainty_notes."
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ChartVisionEngine:
|
| 26 |
+
def __init__(self):
|
| 27 |
+
self.settings = get_settings()
|
| 28 |
+
|
| 29 |
+
def status(self) -> dict:
|
| 30 |
+
return {
|
| 31 |
+
"mode": self.settings.chart_vision_mode,
|
| 32 |
+
"primary_model": self.settings.chart_vision_model,
|
| 33 |
+
"fallback_model": self.settings.chart_vision_fallback_model,
|
| 34 |
+
"remote_configured": bool(self.settings.chart_vision_remote_url),
|
| 35 |
+
"min_confidence": self.settings.chart_vision_min_confidence,
|
| 36 |
+
"runtime_policy": "Vision is optional. If unavailable, deterministic OHLCV analysis remains active.",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
def analyze_image(self, image_bytes: bytes, ticker: str | None = None, timeframe: str | None = None, ohlcv_hint: dict | None = None) -> dict:
|
| 40 |
+
image_hash = hashlib.sha256(image_bytes).hexdigest()
|
| 41 |
+
if self.settings.chart_vision_mode == "remote" and self.settings.chart_vision_remote_url:
|
| 42 |
+
remote = self._remote_inference(image_bytes, ticker=ticker, timeframe=timeframe, ohlcv_hint=ohlcv_hint)
|
| 43 |
+
remote["image_hash"] = image_hash
|
| 44 |
+
if confidence_meets_threshold(remote.get("confidence"), self.settings.chart_vision_min_confidence):
|
| 45 |
+
return normalize_visual_analysis(remote, image_hash, self.settings.chart_vision_model)
|
| 46 |
+
fallback = self._remote_inference(image_bytes, ticker=ticker, timeframe=timeframe, ohlcv_hint=ohlcv_hint, fallback=True)
|
| 47 |
+
fallback["image_hash"] = image_hash
|
| 48 |
+
return normalize_visual_analysis(fallback, image_hash, self.settings.chart_vision_fallback_model)
|
| 49 |
+
if self.settings.chart_vision_mode == "local":
|
| 50 |
+
local = self._local_inference(image_bytes, ticker=ticker, timeframe=timeframe, ohlcv_hint=ohlcv_hint)
|
| 51 |
+
local["image_hash"] = image_hash
|
| 52 |
+
return normalize_visual_analysis(local, image_hash, local.get("model_used", self.settings.chart_vision_model))
|
| 53 |
+
return disabled_visual_analysis(image_hash, ticker, timeframe)
|
| 54 |
+
|
| 55 |
+
def _remote_inference(self, image_bytes: bytes, ticker: str | None, timeframe: str | None, ohlcv_hint: dict | None, fallback: bool = False) -> dict:
|
| 56 |
+
model = self.settings.chart_vision_fallback_model if fallback else self.settings.chart_vision_model
|
| 57 |
+
headers = {"Content-Type": "application/json"}
|
| 58 |
+
if self.settings.chart_vision_remote_token:
|
| 59 |
+
headers["Authorization"] = f"Bearer {self.settings.chart_vision_remote_token}"
|
| 60 |
+
payload = {
|
| 61 |
+
"model": model,
|
| 62 |
+
"prompt": CHART_VISION_PROMPT,
|
| 63 |
+
"image_base64": base64.b64encode(image_bytes).decode("ascii"),
|
| 64 |
+
"ticker": ticker,
|
| 65 |
+
"timeframe": timeframe,
|
| 66 |
+
"ohlcv_hint": ohlcv_hint or {},
|
| 67 |
+
"response_format": "json",
|
| 68 |
+
}
|
| 69 |
+
try:
|
| 70 |
+
response = requests.post(self.settings.chart_vision_remote_url, headers=headers, json=payload, timeout=90)
|
| 71 |
+
response.raise_for_status()
|
| 72 |
+
data = response.json()
|
| 73 |
+
if isinstance(data, dict) and "visual_analysis" in data:
|
| 74 |
+
data = data["visual_analysis"]
|
| 75 |
+
if isinstance(data, str):
|
| 76 |
+
data = json.loads(data)
|
| 77 |
+
data["model_used"] = model
|
| 78 |
+
data["mode"] = "remote"
|
| 79 |
+
return data
|
| 80 |
+
except Exception as exc:
|
| 81 |
+
return unavailable_visual_analysis(ticker, timeframe, model, f"Remote chart vision failed: {type(exc).__name__}: {str(exc)}")
|
| 82 |
+
|
| 83 |
+
def _local_inference(self, image_bytes: bytes, ticker: str | None, timeframe: str | None, ohlcv_hint: dict | None) -> dict:
|
| 84 |
+
try:
|
| 85 |
+
import torch
|
| 86 |
+
except Exception as exc:
|
| 87 |
+
return unavailable_visual_analysis(ticker, timeframe, self.settings.chart_vision_model, f"PyTorch unavailable: {type(exc).__name__}: {str(exc)}")
|
| 88 |
+
if not torch.cuda.is_available():
|
| 89 |
+
return unavailable_visual_analysis(ticker, timeframe, self.settings.chart_vision_model, "No GPU detected. Local Qwen3-VL inference is disabled to keep the Space responsive.")
|
| 90 |
+
return unavailable_visual_analysis(
|
| 91 |
+
ticker,
|
| 92 |
+
timeframe,
|
| 93 |
+
self.settings.chart_vision_model,
|
| 94 |
+
"Local VLM adapter is configured but not activated in this CPU demo. Use CHART_VISION_MODE=remote for production-grade VLM serving.",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def normalize_visual_analysis(payload: dict, image_hash: str, model_used: str) -> dict:
|
| 99 |
+
base = empty_visual_contract()
|
| 100 |
+
base.update({key: payload.get(key, base[key]) for key in base})
|
| 101 |
+
base["image_hash"] = image_hash
|
| 102 |
+
base["model_used"] = payload.get("model_used") or model_used
|
| 103 |
+
base["mode"] = payload.get("mode", "remote")
|
| 104 |
+
base["confidence"] = safe_confidence(base.get("confidence"))
|
| 105 |
+
base["uncertainty_notes"] = normalize_list(base.get("uncertainty_notes"))
|
| 106 |
+
return base
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def disabled_visual_analysis(image_hash: str, ticker: str | None, timeframe: str | None) -> dict:
|
| 110 |
+
payload = empty_visual_contract()
|
| 111 |
+
payload.update(
|
| 112 |
+
{
|
| 113 |
+
"image_hash": image_hash,
|
| 114 |
+
"model_used": "chart_vision_disabled",
|
| 115 |
+
"mode": "disabled",
|
| 116 |
+
"asset_detected": ticker or "",
|
| 117 |
+
"timeframe_detected": timeframe or "unknown",
|
| 118 |
+
"technical_summary": "Vision model unavailable, deterministic analysis active.",
|
| 119 |
+
"confidence": 0.0,
|
| 120 |
+
"uncertainty_notes": [
|
| 121 |
+
"CHART_VISION_MODE is disabled.",
|
| 122 |
+
"No visual levels are inferred from the uploaded chart.",
|
| 123 |
+
"Use deterministic OHLCV analysis for calculated technical evidence.",
|
| 124 |
+
],
|
| 125 |
+
}
|
| 126 |
+
)
|
| 127 |
+
return payload
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def unavailable_visual_analysis(ticker: str | None, timeframe: str | None, model: str, reason: str) -> dict:
|
| 131 |
+
payload = empty_visual_contract()
|
| 132 |
+
payload.update(
|
| 133 |
+
{
|
| 134 |
+
"model_used": model,
|
| 135 |
+
"mode": "unavailable",
|
| 136 |
+
"asset_detected": ticker or "",
|
| 137 |
+
"timeframe_detected": timeframe or "unknown",
|
| 138 |
+
"technical_summary": "Vision model unavailable, deterministic analysis active.",
|
| 139 |
+
"confidence": 0.0,
|
| 140 |
+
"uncertainty_notes": [reason],
|
| 141 |
+
}
|
| 142 |
+
)
|
| 143 |
+
return payload
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def empty_visual_contract() -> dict[str, Any]:
|
| 147 |
+
return {
|
| 148 |
+
"chart_type": "unknown",
|
| 149 |
+
"timeframe_detected": "unknown",
|
| 150 |
+
"asset_detected": "",
|
| 151 |
+
"visible_indicators": [],
|
| 152 |
+
"trend_structure": {},
|
| 153 |
+
"support_levels": [],
|
| 154 |
+
"resistance_levels": [],
|
| 155 |
+
"chart_patterns": [],
|
| 156 |
+
"candlestick_patterns": [],
|
| 157 |
+
"volume_analysis": {},
|
| 158 |
+
"momentum_interpretation": {},
|
| 159 |
+
"volatility_interpretation": {},
|
| 160 |
+
"bullish_evidence": [],
|
| 161 |
+
"bearish_evidence": [],
|
| 162 |
+
"neutral_evidence": [],
|
| 163 |
+
"invalidation_levels": [],
|
| 164 |
+
"technical_summary": "",
|
| 165 |
+
"confidence": 0.0,
|
| 166 |
+
"uncertainty_notes": [],
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def safe_confidence(value: Any) -> float:
|
| 171 |
+
try:
|
| 172 |
+
numeric = float(value)
|
| 173 |
+
if numeric <= 1:
|
| 174 |
+
numeric *= 100
|
| 175 |
+
return round(max(0, min(100, numeric)), 1)
|
| 176 |
+
except (TypeError, ValueError):
|
| 177 |
+
return 0.0
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def confidence_meets_threshold(value: Any, threshold: float) -> bool:
|
| 181 |
+
confidence = safe_confidence(value)
|
| 182 |
+
threshold_pct = threshold * 100 if threshold <= 1 else threshold
|
| 183 |
+
return confidence >= threshold_pct
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def normalize_list(value: Any) -> list:
|
| 187 |
+
if value is None:
|
| 188 |
+
return []
|
| 189 |
+
if isinstance(value, list):
|
| 190 |
+
return value
|
| 191 |
+
return [str(value)]
|
backend/app/services/hybrid_chart_intelligence.py
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
import hashlib
|
| 6 |
+
from statistics import mean
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from sqlalchemy import delete, desc, func, select
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
|
| 12 |
+
from app.models import (
|
| 13 |
+
Asset,
|
| 14 |
+
ChartAnalysis,
|
| 15 |
+
ChartPatternMemory,
|
| 16 |
+
NewsArticle,
|
| 17 |
+
NewsAssetLink,
|
| 18 |
+
SentimentAnalysis,
|
| 19 |
+
TechnicalLevel,
|
| 20 |
+
TechnicalSignal,
|
| 21 |
+
)
|
| 22 |
+
from app.services.chart_vision_engine import ChartVisionEngine
|
| 23 |
+
from app.services.financial_brain_learning import brain_asset_memory
|
| 24 |
+
from app.services.technical_analysis_engine import TechnicalAnalysisEngine, normalize_frame
|
| 25 |
+
from app.signals.engine import load_prices
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
DISCLAIMER = "This is technical analysis, not financial advice."
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class HybridChartIntelligence:
|
| 32 |
+
def __init__(self):
|
| 33 |
+
self.technical = TechnicalAnalysisEngine()
|
| 34 |
+
self.vision = ChartVisionEngine()
|
| 35 |
+
|
| 36 |
+
def analyze_ticker(self, db: Session, asset: Asset, timeframe: str = "6M", period: str = "1y", include_visual: bool = True, persist: bool = True) -> dict:
|
| 37 |
+
prices = load_prices(db, asset.id)
|
| 38 |
+
frame = normalize_frame(prices, timeframe)
|
| 39 |
+
deterministic = self.technical.analyze(frame, timeframe=timeframe)
|
| 40 |
+
chart_image = generate_chart_svg_data_uri(asset.ticker, frame, deterministic)
|
| 41 |
+
visual = self.vision.analyze_image(data_uri_payload_bytes(chart_image), ticker=asset.ticker, timeframe=timeframe, ohlcv_hint=compact_ohlcv_hint(deterministic)) if include_visual else disabled_visual(asset.ticker, timeframe)
|
| 42 |
+
memory = safe_memory(db, asset)
|
| 43 |
+
sentiment = sentiment_context(db, asset)
|
| 44 |
+
similar = pattern_similarity(db, asset, deterministic, timeframe)
|
| 45 |
+
hybrid = build_hybrid_report(asset, timeframe, visual, deterministic, memory, sentiment, similar)
|
| 46 |
+
if persist:
|
| 47 |
+
persist_chart_analysis(db, asset, timeframe, period, chart_image, visual, deterministic, hybrid)
|
| 48 |
+
return {
|
| 49 |
+
"ticker": asset.ticker,
|
| 50 |
+
"timeframe": timeframe,
|
| 51 |
+
"period": period,
|
| 52 |
+
"chart_image": chart_image,
|
| 53 |
+
"price_series": price_series(frame),
|
| 54 |
+
"visual_analysis": visual,
|
| 55 |
+
"deterministic_analysis": deterministic,
|
| 56 |
+
"hybrid_analysis": hybrid,
|
| 57 |
+
"confidence": hybrid["confidence_score"],
|
| 58 |
+
"warnings": hybrid["warnings"],
|
| 59 |
+
"disclaimer": DISCLAIMER,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
def analyze_uploaded_image(
|
| 63 |
+
self,
|
| 64 |
+
db: Session,
|
| 65 |
+
image_bytes: bytes,
|
| 66 |
+
ticker: str | None = None,
|
| 67 |
+
timeframe: str = "unknown",
|
| 68 |
+
ohlcv_rows: list[dict] | None = None,
|
| 69 |
+
persist: bool = True,
|
| 70 |
+
) -> dict:
|
| 71 |
+
asset = db.scalar(select(Asset).where(Asset.ticker == ticker.upper())) if ticker else None
|
| 72 |
+
frame = pd.DataFrame(ohlcv_rows or [])
|
| 73 |
+
deterministic = self.technical.analyze(frame, timeframe=timeframe) if not frame.empty else {"status": "no_ohlcv_payload", "technical_summary": "No OHLCV payload was supplied; deterministic analysis unavailable."}
|
| 74 |
+
visual = self.vision.analyze_image(image_bytes, ticker=ticker, timeframe=timeframe, ohlcv_hint=compact_ohlcv_hint(deterministic))
|
| 75 |
+
memory = safe_memory(db, asset) if asset else {}
|
| 76 |
+
sentiment = sentiment_context(db, asset) if asset else {}
|
| 77 |
+
similar = pattern_similarity(db, asset, deterministic, timeframe) if asset else empty_similarity()
|
| 78 |
+
hybrid = build_hybrid_report(asset, timeframe, visual, deterministic, memory, sentiment, similar)
|
| 79 |
+
chart_image = f"data:image/unknown;base64,{base64.b64encode(image_bytes).decode('ascii')}"
|
| 80 |
+
if persist and asset:
|
| 81 |
+
persist_chart_analysis(db, asset, timeframe, timeframe, chart_image, visual, deterministic, hybrid, image_hash=hashlib.sha256(image_bytes).hexdigest())
|
| 82 |
+
return {
|
| 83 |
+
"ticker": ticker,
|
| 84 |
+
"timeframe": timeframe,
|
| 85 |
+
"visual_analysis": visual,
|
| 86 |
+
"deterministic_analysis": deterministic,
|
| 87 |
+
"hybrid_analysis": hybrid,
|
| 88 |
+
"confidence": hybrid["confidence_score"],
|
| 89 |
+
"warnings": hybrid["warnings"],
|
| 90 |
+
"disclaimer": DISCLAIMER,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
def latest_report(self, db: Session, asset: Asset, timeframe: str = "6M") -> dict:
|
| 94 |
+
row = db.scalar(
|
| 95 |
+
select(ChartAnalysis)
|
| 96 |
+
.where(ChartAnalysis.ticker == asset.ticker, ChartAnalysis.timeframe == timeframe)
|
| 97 |
+
.order_by(desc(ChartAnalysis.created_at))
|
| 98 |
+
.limit(1)
|
| 99 |
+
)
|
| 100 |
+
if row:
|
| 101 |
+
return serialize_chart_analysis(row)
|
| 102 |
+
return self.analyze_ticker(db, asset, timeframe=timeframe, period=timeframe, include_visual=False, persist=True)
|
| 103 |
+
|
| 104 |
+
def levels(self, db: Session, asset: Asset, timeframe: str = "6M") -> dict:
|
| 105 |
+
row = db.scalar(select(TechnicalLevel).where(TechnicalLevel.ticker == asset.ticker, TechnicalLevel.timeframe == timeframe))
|
| 106 |
+
if not row:
|
| 107 |
+
self.analyze_ticker(db, asset, timeframe=timeframe, period=timeframe, include_visual=False, persist=True)
|
| 108 |
+
row = db.scalar(select(TechnicalLevel).where(TechnicalLevel.ticker == asset.ticker, TechnicalLevel.timeframe == timeframe))
|
| 109 |
+
return serialize_level(row) if row else {"ticker": asset.ticker, "timeframe": timeframe, "status": "unavailable"}
|
| 110 |
+
|
| 111 |
+
def signals(self, db: Session, asset: Asset, timeframe: str = "6M", limit: int = 30) -> list[dict]:
|
| 112 |
+
rows = db.scalars(
|
| 113 |
+
select(TechnicalSignal)
|
| 114 |
+
.where(TechnicalSignal.ticker == asset.ticker, TechnicalSignal.timeframe == timeframe)
|
| 115 |
+
.order_by(desc(TechnicalSignal.created_at))
|
| 116 |
+
.limit(limit)
|
| 117 |
+
).all()
|
| 118 |
+
if not rows:
|
| 119 |
+
self.analyze_ticker(db, asset, timeframe=timeframe, period=timeframe, include_visual=False, persist=True)
|
| 120 |
+
rows = db.scalars(
|
| 121 |
+
select(TechnicalSignal)
|
| 122 |
+
.where(TechnicalSignal.ticker == asset.ticker, TechnicalSignal.timeframe == timeframe)
|
| 123 |
+
.order_by(desc(TechnicalSignal.created_at))
|
| 124 |
+
.limit(limit)
|
| 125 |
+
).all()
|
| 126 |
+
return [serialize_signal(row) for row in rows]
|
| 127 |
+
|
| 128 |
+
def history(self, db: Session, asset: Asset, limit: int = 30) -> list[dict]:
|
| 129 |
+
rows = db.scalars(select(ChartAnalysis).where(ChartAnalysis.ticker == asset.ticker).order_by(desc(ChartAnalysis.created_at)).limit(limit)).all()
|
| 130 |
+
return [serialize_chart_analysis(row, compact=True) for row in rows]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def build_hybrid_report(asset: Asset | None, timeframe: str, visual: dict, deterministic: dict, memory: dict, sentiment: dict, similar: dict) -> dict:
|
| 134 |
+
bullish = []
|
| 135 |
+
bearish = []
|
| 136 |
+
neutral = []
|
| 137 |
+
if deterministic.get("status") == "ready":
|
| 138 |
+
for signal in deterministic.get("signals", []):
|
| 139 |
+
target = bullish if signal.get("direction") == "bullish" else bearish if signal.get("direction") == "bearish" else neutral
|
| 140 |
+
target.append(signal.get("evidence", signal.get("signal_type", "technical signal")))
|
| 141 |
+
if deterministic.get("trend_direction") in {"uptrend", "uptrend_attempt"}:
|
| 142 |
+
bullish.append(f"Calculated trend is {deterministic.get('trend_direction')}.")
|
| 143 |
+
if deterministic.get("trend_direction") in {"downtrend", "downtrend_attempt"}:
|
| 144 |
+
bearish.append(f"Calculated trend is {deterministic.get('trend_direction')}.")
|
| 145 |
+
bullish += visual.get("bullish_evidence", [])[:4]
|
| 146 |
+
bearish += visual.get("bearish_evidence", [])[:4]
|
| 147 |
+
neutral += visual.get("neutral_evidence", [])[:4]
|
| 148 |
+
if sentiment.get("average_sentiment") is not None:
|
| 149 |
+
if sentiment["average_sentiment"] > 0.15:
|
| 150 |
+
bullish.append("Recent linked news sentiment is constructive.")
|
| 151 |
+
elif sentiment["average_sentiment"] < -0.15:
|
| 152 |
+
bearish.append("Recent linked news sentiment is negative.")
|
| 153 |
+
else:
|
| 154 |
+
neutral.append("Recent linked news sentiment is mixed.")
|
| 155 |
+
contradictions = contradiction_checks(visual, deterministic, sentiment)
|
| 156 |
+
confirmations = confirmation_checks(visual, deterministic, sentiment, similar)
|
| 157 |
+
confidence = hybrid_confidence(visual, deterministic, sentiment, similar, contradictions)
|
| 158 |
+
levels = deterministic.get("levels", {})
|
| 159 |
+
return {
|
| 160 |
+
"trend_summary": deterministic.get("technical_summary") or visual.get("technical_summary") or "Technical evidence is still being collected.",
|
| 161 |
+
"key_levels": {
|
| 162 |
+
"support_1": first_level(levels.get("support_levels"), 0),
|
| 163 |
+
"support_2": first_level(levels.get("support_levels"), 1),
|
| 164 |
+
"resistance_1": first_level(levels.get("resistance_levels"), 0),
|
| 165 |
+
"resistance_2": first_level(levels.get("resistance_levels"), 1),
|
| 166 |
+
"breakout_level": levels.get("breakout_level"),
|
| 167 |
+
"breakdown_level": levels.get("breakdown_level"),
|
| 168 |
+
"invalidation_level": levels.get("invalidation_level"),
|
| 169 |
+
},
|
| 170 |
+
"technical_signals": deterministic.get("signals", []),
|
| 171 |
+
"confirmation_signals": confirmations,
|
| 172 |
+
"contradiction_signals": contradictions,
|
| 173 |
+
"invalidation_level": levels.get("invalidation_level"),
|
| 174 |
+
"risk_zone": risk_zone(deterministic),
|
| 175 |
+
"opportunity_zone": opportunity_zone(deterministic),
|
| 176 |
+
"confidence_score": confidence,
|
| 177 |
+
"timeframe_relevance": timeframe_relevance(timeframe, deterministic),
|
| 178 |
+
"possible_scenarios": scenarios(deterministic, sentiment, similar),
|
| 179 |
+
"what_to_watch_next": watch_next(deterministic, sentiment, contradictions),
|
| 180 |
+
"bullish_evidence": dedupe(bullish)[:8],
|
| 181 |
+
"bearish_evidence": dedupe(bearish)[:8],
|
| 182 |
+
"neutral_evidence": dedupe(neutral)[:8],
|
| 183 |
+
"analyst_report": analyst_report(asset, deterministic, sentiment, similar, confidence),
|
| 184 |
+
"historical_similarity": similar,
|
| 185 |
+
"memory_context": {
|
| 186 |
+
"blum_memory_summary": memory.get("blum_memory_summary"),
|
| 187 |
+
"confidence_evolution": memory.get("confidence_evolution", [])[:5],
|
| 188 |
+
},
|
| 189 |
+
"evidence_policy": {
|
| 190 |
+
"calculated_data": deterministic.get("evidence_policy", {}).get("calculated_data", []),
|
| 191 |
+
"visual_observations": ["Qwen3-VL/InternVL visual output"] if visual.get("mode") in {"remote", "local"} else [],
|
| 192 |
+
"inference": ["hybrid confidence", "scenario framing", "breakout probability", "risk/reward interpretation"],
|
| 193 |
+
"hypothesis": ["historical similarity is probabilistic and not a forecast"],
|
| 194 |
+
},
|
| 195 |
+
"warnings": warnings(visual, deterministic),
|
| 196 |
+
"disclaimer": DISCLAIMER,
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def persist_chart_analysis(db: Session, asset: Asset, timeframe: str, period: str, chart_image: str, visual: dict, deterministic: dict, hybrid: dict, image_hash: str | None = None) -> None:
|
| 201 |
+
image_hash = image_hash or hashlib.sha256(chart_image.encode("utf-8")).hexdigest()
|
| 202 |
+
analysis = ChartAnalysis(
|
| 203 |
+
asset_id=asset.id,
|
| 204 |
+
ticker=asset.ticker,
|
| 205 |
+
timeframe=timeframe,
|
| 206 |
+
period=period,
|
| 207 |
+
image_hash=image_hash,
|
| 208 |
+
model_used=visual.get("model_used", "deterministic_technical_analysis"),
|
| 209 |
+
visual_analysis_json=visual,
|
| 210 |
+
deterministic_analysis_json=deterministic,
|
| 211 |
+
hybrid_analysis_json=hybrid,
|
| 212 |
+
chart_image=chart_image,
|
| 213 |
+
confidence=float(hybrid.get("confidence_score") or 0),
|
| 214 |
+
)
|
| 215 |
+
db.add(analysis)
|
| 216 |
+
persist_levels(db, asset, timeframe, deterministic)
|
| 217 |
+
persist_signals(db, asset, timeframe, deterministic)
|
| 218 |
+
persist_pattern_memory(db, asset, timeframe, deterministic, hybrid)
|
| 219 |
+
db.commit()
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def persist_levels(db: Session, asset: Asset, timeframe: str, deterministic: dict) -> None:
|
| 223 |
+
levels = deterministic.get("levels", {})
|
| 224 |
+
row = db.scalar(select(TechnicalLevel).where(TechnicalLevel.ticker == asset.ticker, TechnicalLevel.timeframe == timeframe))
|
| 225 |
+
if row is None:
|
| 226 |
+
row = TechnicalLevel(asset_id=asset.id, ticker=asset.ticker, timeframe=timeframe)
|
| 227 |
+
db.add(row)
|
| 228 |
+
row.asset_id = asset.id
|
| 229 |
+
row.support_levels_json = levels.get("support_levels", [])
|
| 230 |
+
row.resistance_levels_json = levels.get("resistance_levels", [])
|
| 231 |
+
row.breakout_level = levels.get("breakout_level")
|
| 232 |
+
row.breakdown_level = levels.get("breakdown_level")
|
| 233 |
+
row.invalidation_level = levels.get("invalidation_level")
|
| 234 |
+
row.updated_at = datetime.utcnow()
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def persist_signals(db: Session, asset: Asset, timeframe: str, deterministic: dict) -> None:
|
| 238 |
+
db.execute(delete(TechnicalSignal).where(TechnicalSignal.ticker == asset.ticker, TechnicalSignal.timeframe == timeframe))
|
| 239 |
+
invalidation = deterministic.get("levels", {}).get("invalidation_level")
|
| 240 |
+
for item in deterministic.get("signals", [])[:12]:
|
| 241 |
+
db.add(
|
| 242 |
+
TechnicalSignal(
|
| 243 |
+
asset_id=asset.id,
|
| 244 |
+
ticker=asset.ticker,
|
| 245 |
+
timeframe=timeframe,
|
| 246 |
+
signal_type=item.get("signal_type", "technical_signal"),
|
| 247 |
+
direction=item.get("direction", "neutral"),
|
| 248 |
+
confidence=float(item.get("confidence") or 0),
|
| 249 |
+
evidence_json=item,
|
| 250 |
+
invalidation_level=invalidation,
|
| 251 |
+
)
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def persist_pattern_memory(db: Session, asset: Asset, timeframe: str, deterministic: dict, hybrid: dict) -> None:
|
| 256 |
+
pattern = primary_pattern(deterministic)
|
| 257 |
+
db.add(
|
| 258 |
+
ChartPatternMemory(
|
| 259 |
+
asset_id=asset.id,
|
| 260 |
+
ticker=asset.ticker,
|
| 261 |
+
timeframe=timeframe,
|
| 262 |
+
pattern_type=pattern,
|
| 263 |
+
setup_embedding=pattern_embedding(deterministic, hybrid),
|
| 264 |
+
outcome_1d=None,
|
| 265 |
+
outcome_7d=None,
|
| 266 |
+
outcome_30d=None,
|
| 267 |
+
max_drawdown=None,
|
| 268 |
+
success=None,
|
| 269 |
+
)
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def sentiment_context(db: Session, asset: Asset) -> dict:
|
| 274 |
+
linked_ids = [row[0] for row in db.execute(select(NewsAssetLink.article_id).where(NewsAssetLink.asset_id == asset.id)).all()]
|
| 275 |
+
if not linked_ids:
|
| 276 |
+
return {"article_count": 0, "average_sentiment": None, "latest_news": []}
|
| 277 |
+
rows = db.execute(
|
| 278 |
+
select(SentimentAnalysis.score, NewsArticle.title, NewsArticle.source, NewsArticle.published_at)
|
| 279 |
+
.join(NewsArticle, NewsArticle.id == SentimentAnalysis.article_id)
|
| 280 |
+
.where(SentimentAnalysis.article_id.in_(linked_ids))
|
| 281 |
+
.order_by(desc(SentimentAnalysis.created_at))
|
| 282 |
+
.limit(30)
|
| 283 |
+
).all()
|
| 284 |
+
scores = [float(score) for score, *_ in rows if score is not None]
|
| 285 |
+
return {
|
| 286 |
+
"article_count": len(rows),
|
| 287 |
+
"average_sentiment": round(mean(scores), 4) if scores else None,
|
| 288 |
+
"latest_news": [{"title": title, "source": source, "published_at": published_at.isoformat() if published_at else None} for _, title, source, published_at in rows[:6]],
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def pattern_similarity(db: Session, asset: Asset, deterministic: dict, timeframe: str) -> dict:
|
| 293 |
+
pattern = primary_pattern(deterministic)
|
| 294 |
+
rows = db.scalars(
|
| 295 |
+
select(ChartPatternMemory)
|
| 296 |
+
.where(ChartPatternMemory.pattern_type == pattern, ChartPatternMemory.ticker != asset.ticker)
|
| 297 |
+
.order_by(desc(ChartPatternMemory.created_at))
|
| 298 |
+
.limit(80)
|
| 299 |
+
).all()
|
| 300 |
+
mature = [row for row in rows if row.success is not None]
|
| 301 |
+
success_rate = sum(1 for row in mature if row.success) / len(mature) if mature else None
|
| 302 |
+
returns_7d = [row.outcome_7d for row in mature if row.outcome_7d is not None]
|
| 303 |
+
drawdowns = [row.max_drawdown for row in mature if row.max_drawdown is not None]
|
| 304 |
+
return {
|
| 305 |
+
"pattern_type": pattern,
|
| 306 |
+
"similar_chart_setups": len(rows),
|
| 307 |
+
"mature_cases": len(mature),
|
| 308 |
+
"average_forward_return_7d": round(mean(returns_7d), 3) if returns_7d else None,
|
| 309 |
+
"success_rate": round(success_rate, 4) if success_rate is not None else None,
|
| 310 |
+
"average_drawdown": round(mean(drawdowns), 3) if drawdowns else None,
|
| 311 |
+
"reliability_score": reliability_score(len(mature), success_rate),
|
| 312 |
+
"explanation": "Chart pattern memory is populated from stored Blum chart analyses and matures as future OHLCV outcomes become available.",
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def generate_chart_svg_data_uri(ticker: str, frame: pd.DataFrame, deterministic: dict) -> str:
|
| 317 |
+
if frame.empty:
|
| 318 |
+
svg = "<svg xmlns='http://www.w3.org/2000/svg' width='960' height='520'><rect width='100%' height='100%' fill='#07090d'/><text x='40' y='80' fill='#ffb000'>No price data</text></svg>"
|
| 319 |
+
return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
| 320 |
+
df = frame.tail(160).reset_index(drop=True)
|
| 321 |
+
close = df["close"].astype(float)
|
| 322 |
+
high = df["high"].astype(float)
|
| 323 |
+
low = df["low"].astype(float)
|
| 324 |
+
width = 960
|
| 325 |
+
height = 520
|
| 326 |
+
pad_x = 54
|
| 327 |
+
pad_y = 42
|
| 328 |
+
min_price = float(low.min())
|
| 329 |
+
max_price = float(high.max())
|
| 330 |
+
scale = (height - pad_y * 2) / max(max_price - min_price, 1e-9)
|
| 331 |
+
|
| 332 |
+
def x(index: int) -> float:
|
| 333 |
+
return pad_x + index * ((width - pad_x * 2) / max(len(df) - 1, 1))
|
| 334 |
+
|
| 335 |
+
def y(price: float) -> float:
|
| 336 |
+
return height - pad_y - ((price - min_price) * scale)
|
| 337 |
+
|
| 338 |
+
line_points = " ".join(f"{x(i):.2f},{y(float(price)):.2f}" for i, price in enumerate(close))
|
| 339 |
+
level_lines = []
|
| 340 |
+
levels = deterministic.get("levels", {})
|
| 341 |
+
for item in (levels.get("support_levels") or [])[:2]:
|
| 342 |
+
py = y(float(item["level"]))
|
| 343 |
+
level_lines.append(f"<line x1='{pad_x}' x2='{width-pad_x}' y1='{py:.2f}' y2='{py:.2f}' stroke='#20e070' stroke-width='1' stroke-dasharray='6 6'/>")
|
| 344 |
+
for item in (levels.get("resistance_levels") or [])[:2]:
|
| 345 |
+
py = y(float(item["level"]))
|
| 346 |
+
level_lines.append(f"<line x1='{pad_x}' x2='{width-pad_x}' y1='{py:.2f}' y2='{py:.2f}' stroke='#ff4d5e' stroke-width='1' stroke-dasharray='6 6'/>")
|
| 347 |
+
svg = f"""
|
| 348 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}">
|
| 349 |
+
<rect width="100%" height="100%" fill="#07090d"/>
|
| 350 |
+
<g stroke="rgba(255,255,255,.08)" stroke-width="1">
|
| 351 |
+
<line x1="{pad_x}" x2="{width-pad_x}" y1="{pad_y}" y2="{pad_y}"/>
|
| 352 |
+
<line x1="{pad_x}" x2="{width-pad_x}" y1="{height-pad_y}" y2="{height-pad_y}"/>
|
| 353 |
+
<line x1="{pad_x}" x2="{pad_x}" y1="{pad_y}" y2="{height-pad_y}"/>
|
| 354 |
+
<line x1="{width-pad_x}" x2="{width-pad_x}" y1="{pad_y}" y2="{height-pad_y}"/>
|
| 355 |
+
</g>
|
| 356 |
+
{''.join(level_lines)}
|
| 357 |
+
<polyline points="{line_points}" fill="none" stroke="#ffb000" stroke-width="2.4"/>
|
| 358 |
+
<text x="{pad_x}" y="28" fill="#eef3fa" font-family="Inter, system-ui" font-size="18" font-weight="800">{ticker} Technical Chart</text>
|
| 359 |
+
<text x="{width-pad_x-220}" y="28" fill="#8f9bad" font-family="Inter, system-ui" font-size="12">Generated by Blum Chart Analyst</text>
|
| 360 |
+
</svg>
|
| 361 |
+
"""
|
| 362 |
+
return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def data_uri_payload_bytes(data_uri: str) -> bytes:
|
| 366 |
+
if "," not in data_uri:
|
| 367 |
+
return data_uri.encode("utf-8")
|
| 368 |
+
return base64.b64decode(data_uri.split(",", 1)[1])
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def build_levels_overlay(deterministic: dict) -> dict:
|
| 372 |
+
levels = deterministic.get("levels", {})
|
| 373 |
+
return {
|
| 374 |
+
"support": levels.get("support_levels", [])[:3],
|
| 375 |
+
"resistance": levels.get("resistance_levels", [])[:3],
|
| 376 |
+
"breakout_level": levels.get("breakout_level"),
|
| 377 |
+
"breakdown_level": levels.get("breakdown_level"),
|
| 378 |
+
"invalidation_level": levels.get("invalidation_level"),
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def price_series(frame: pd.DataFrame) -> list[dict]:
|
| 383 |
+
if frame.empty:
|
| 384 |
+
return []
|
| 385 |
+
return [
|
| 386 |
+
{
|
| 387 |
+
"date": str(row.date),
|
| 388 |
+
"open": float(row.open) if row.open is not None else None,
|
| 389 |
+
"high": float(row.high) if row.high is not None else None,
|
| 390 |
+
"low": float(row.low) if row.low is not None else None,
|
| 391 |
+
"close": float(row.close),
|
| 392 |
+
"volume": float(row.volume) if row.volume is not None else None,
|
| 393 |
+
}
|
| 394 |
+
for row in frame.itertuples(index=False)
|
| 395 |
+
]
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def compact_ohlcv_hint(deterministic: dict) -> dict:
|
| 399 |
+
return {
|
| 400 |
+
"status": deterministic.get("status"),
|
| 401 |
+
"trend_direction": deterministic.get("trend_direction"),
|
| 402 |
+
"levels": deterministic.get("levels", {}),
|
| 403 |
+
"momentum": deterministic.get("momentum", {}),
|
| 404 |
+
"volatility": deterministic.get("volatility", {}),
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def disabled_visual(ticker: str, timeframe: str) -> dict:
|
| 409 |
+
return {
|
| 410 |
+
"mode": "not_requested",
|
| 411 |
+
"model_used": "not_requested",
|
| 412 |
+
"asset_detected": ticker,
|
| 413 |
+
"timeframe_detected": timeframe,
|
| 414 |
+
"confidence": 0,
|
| 415 |
+
"technical_summary": "Visual interpretation was not requested for this run.",
|
| 416 |
+
"bullish_evidence": [],
|
| 417 |
+
"bearish_evidence": [],
|
| 418 |
+
"neutral_evidence": [],
|
| 419 |
+
"uncertainty_notes": ["Deterministic technical analysis only."],
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def safe_memory(db: Session, asset: Asset | None) -> dict:
|
| 424 |
+
if not asset:
|
| 425 |
+
return {}
|
| 426 |
+
try:
|
| 427 |
+
return brain_asset_memory(db, asset)
|
| 428 |
+
except Exception:
|
| 429 |
+
return {}
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def first_level(levels: list | None, index: int) -> float | None:
|
| 433 |
+
if not levels or len(levels) <= index:
|
| 434 |
+
return None
|
| 435 |
+
item = levels[index]
|
| 436 |
+
return item.get("level") if isinstance(item, dict) else item
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def confirmation_checks(visual: dict, deterministic: dict, sentiment: dict, similar: dict) -> list[str]:
|
| 440 |
+
checks = []
|
| 441 |
+
if visual.get("mode") in {"remote", "local"} and deterministic.get("status") == "ready":
|
| 442 |
+
checks.append("Visual chart interpretation and OHLCV engine are both available for cross-check.")
|
| 443 |
+
if deterministic.get("trend_direction") in {"uptrend", "uptrend_attempt"} and sentiment.get("average_sentiment", 0) > 0:
|
| 444 |
+
checks.append("Technical trend and linked news sentiment are aligned constructively.")
|
| 445 |
+
if similar.get("success_rate") is not None and similar["success_rate"] >= 0.55:
|
| 446 |
+
checks.append("Historical chart pattern memory has positive follow-through in matured cases.")
|
| 447 |
+
return checks or ["No high-conviction cross-domain confirmation is available yet."]
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def contradiction_checks(visual: dict, deterministic: dict, sentiment: dict) -> list[str]:
|
| 451 |
+
checks = []
|
| 452 |
+
if deterministic.get("trend_direction") in {"uptrend", "uptrend_attempt"} and sentiment.get("average_sentiment", 0) < -0.2:
|
| 453 |
+
checks.append("Price structure is constructive while linked news sentiment is negative.")
|
| 454 |
+
if deterministic.get("trend_direction") in {"downtrend", "downtrend_attempt"} and sentiment.get("average_sentiment", 0) > 0.2:
|
| 455 |
+
checks.append("Price structure is deteriorating while linked news sentiment is positive.")
|
| 456 |
+
visual_summary = str(visual.get("technical_summary", "")).lower()
|
| 457 |
+
if "bearish" in visual_summary and deterministic.get("trend_direction") in {"uptrend", "uptrend_attempt"}:
|
| 458 |
+
checks.append("Vision output appears more bearish than deterministic OHLCV trend structure.")
|
| 459 |
+
return checks
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def hybrid_confidence(visual: dict, deterministic: dict, sentiment: dict, similar: dict, contradictions: list[str]) -> float:
|
| 463 |
+
confidence = 35.0
|
| 464 |
+
if deterministic.get("status") == "ready":
|
| 465 |
+
confidence += 35
|
| 466 |
+
confidence += min(15, float(deterministic.get("trend_strength_score") or 0) * 0.15)
|
| 467 |
+
if visual.get("mode") in {"remote", "local"}:
|
| 468 |
+
confidence += min(12, float(visual.get("confidence") or 0) * 0.12)
|
| 469 |
+
if sentiment.get("article_count", 0) > 0:
|
| 470 |
+
confidence += 5
|
| 471 |
+
if similar.get("mature_cases", 0) >= 5:
|
| 472 |
+
confidence += min(8, similar["mature_cases"])
|
| 473 |
+
confidence -= len(contradictions) * 8
|
| 474 |
+
return round(max(0, min(100, confidence)), 1)
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def risk_zone(deterministic: dict) -> dict:
|
| 478 |
+
rr = deterministic.get("risk_reward_estimate", {})
|
| 479 |
+
return {
|
| 480 |
+
"zone": rr.get("nearest_support"),
|
| 481 |
+
"description": "Risk increases below nearest support or invalidation level.",
|
| 482 |
+
"downside_to_support_pct": rr.get("downside_to_support_pct"),
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def opportunity_zone(deterministic: dict) -> dict:
|
| 487 |
+
rr = deterministic.get("risk_reward_estimate", {})
|
| 488 |
+
return {
|
| 489 |
+
"zone": rr.get("nearest_resistance"),
|
| 490 |
+
"description": "Opportunity improves if price confirms above resistance with volume expansion.",
|
| 491 |
+
"upside_to_resistance_pct": rr.get("upside_to_resistance_pct"),
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def timeframe_relevance(timeframe: str, deterministic: dict) -> str:
|
| 496 |
+
if timeframe in {"1D", "5D", "1M"}:
|
| 497 |
+
return "short_term_execution_context"
|
| 498 |
+
if timeframe in {"3M", "6M", "YTD"}:
|
| 499 |
+
return "swing_to_position_context"
|
| 500 |
+
return "strategic_trend_context"
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def scenarios(deterministic: dict, sentiment: dict, similar: dict) -> list[dict]:
|
| 504 |
+
levels = deterministic.get("levels", {})
|
| 505 |
+
return [
|
| 506 |
+
{
|
| 507 |
+
"name": "Bullish confirmation",
|
| 508 |
+
"condition": f"Confirmed breakout above {levels.get('breakout_level')} with relative volume expansion.",
|
| 509 |
+
"impact": "Confidence rises if momentum and volume confirm the move.",
|
| 510 |
+
},
|
| 511 |
+
{
|
| 512 |
+
"name": "Bearish invalidation",
|
| 513 |
+
"condition": f"Failure below {levels.get('invalidation_level')} or breakdown level {levels.get('breakdown_level')}.",
|
| 514 |
+
"impact": "Setup weakens and risk controls become more important.",
|
| 515 |
+
},
|
| 516 |
+
{
|
| 517 |
+
"name": "Base-building continuation",
|
| 518 |
+
"condition": "Price remains above support while volatility compresses and news flow stays neutral-to-positive.",
|
| 519 |
+
"impact": "Setup remains on watch without requiring immediate directional conclusion.",
|
| 520 |
+
},
|
| 521 |
+
]
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def watch_next(deterministic: dict, sentiment: dict, contradictions: list[str]) -> list[str]:
|
| 525 |
+
levels = deterministic.get("levels", {})
|
| 526 |
+
watch = [
|
| 527 |
+
f"Breakout level near {levels.get('breakout_level')}.",
|
| 528 |
+
f"Support and invalidation near {levels.get('invalidation_level')}.",
|
| 529 |
+
"Relative volume expansion on any breakout attempt.",
|
| 530 |
+
"RSI/MACD confirmation or divergence resolution.",
|
| 531 |
+
]
|
| 532 |
+
if sentiment.get("article_count", 0) > 0:
|
| 533 |
+
watch.append("Whether news sentiment confirms or contradicts price action.")
|
| 534 |
+
if contradictions:
|
| 535 |
+
watch.append("Resolve cross-domain contradictions before increasing confidence.")
|
| 536 |
+
return watch
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def analyst_report(asset: Asset | None, deterministic: dict, sentiment: dict, similar: dict, confidence: float) -> str:
|
| 540 |
+
ticker = asset.ticker if asset else "the instrument"
|
| 541 |
+
levels = deterministic.get("levels", {})
|
| 542 |
+
trend = deterministic.get("trend_direction", "unknown")
|
| 543 |
+
momentum = deterministic.get("momentum", {}).get("state", "unknown")
|
| 544 |
+
volume = deterministic.get("volume", {}).get("volume_state", "unknown")
|
| 545 |
+
return (
|
| 546 |
+
f"Blum detects {trend.replace('_', ' ')} on {ticker}, with {momentum.replace('_', ' ')} momentum and {volume} volume. "
|
| 547 |
+
f"Resistance is near {levels.get('breakout_level')} and support is near {levels.get('invalidation_level')}. "
|
| 548 |
+
f"A confirmed breakout above resistance with relative volume expansion would increase technical confidence; failure below support would invalidate the setup. "
|
| 549 |
+
f"Current hybrid technical confidence is {confidence:.1f}/100. Historical chart memory found {similar.get('similar_chart_setups', 0)} similar stored setups."
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
def warnings(visual: dict, deterministic: dict) -> list[str]:
|
| 554 |
+
output = [DISCLAIMER]
|
| 555 |
+
if visual.get("mode") not in {"remote", "local"}:
|
| 556 |
+
output.append("Vision model unavailable, deterministic analysis active.")
|
| 557 |
+
if deterministic.get("status") != "ready":
|
| 558 |
+
output.append("Deterministic technical analysis is limited because stored OHLCV data is insufficient.")
|
| 559 |
+
return output
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def primary_pattern(deterministic: dict) -> str:
|
| 563 |
+
patterns = deterministic.get("patterns", [])
|
| 564 |
+
if not patterns:
|
| 565 |
+
return deterministic.get("trend_direction", "unknown_pattern")
|
| 566 |
+
return patterns[0].get("pattern", "unknown_pattern")
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def pattern_embedding(deterministic: dict, hybrid: dict) -> dict:
|
| 570 |
+
return {
|
| 571 |
+
"trend": deterministic.get("trend_direction"),
|
| 572 |
+
"momentum": deterministic.get("momentum", {}).get("state"),
|
| 573 |
+
"volatility": deterministic.get("volatility", {}).get("regime"),
|
| 574 |
+
"breakout_probability": deterministic.get("breakout_probability", {}).get("score"),
|
| 575 |
+
"risk_reward": deterministic.get("risk_reward_estimate", {}).get("reward_to_risk"),
|
| 576 |
+
"confidence": hybrid.get("confidence_score"),
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def reliability_score(mature_cases: int, success_rate: float | None) -> float:
|
| 581 |
+
if not mature_cases or success_rate is None:
|
| 582 |
+
return 0.0
|
| 583 |
+
depth = min(35, mature_cases * 4)
|
| 584 |
+
return round(depth + success_rate * 65, 1)
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
def empty_similarity() -> dict:
|
| 588 |
+
return {
|
| 589 |
+
"pattern_type": "unknown",
|
| 590 |
+
"similar_chart_setups": 0,
|
| 591 |
+
"mature_cases": 0,
|
| 592 |
+
"average_forward_return_7d": None,
|
| 593 |
+
"success_rate": None,
|
| 594 |
+
"average_drawdown": None,
|
| 595 |
+
"reliability_score": 0,
|
| 596 |
+
"explanation": "No ticker-linked chart pattern memory is available.",
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def dedupe(items: list) -> list:
|
| 601 |
+
seen = set()
|
| 602 |
+
out = []
|
| 603 |
+
for item in items:
|
| 604 |
+
text = str(item)
|
| 605 |
+
if text and text not in seen:
|
| 606 |
+
seen.add(text)
|
| 607 |
+
out.append(text)
|
| 608 |
+
return out
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def serialize_chart_analysis(row: ChartAnalysis, compact: bool = False) -> dict:
|
| 612 |
+
payload = {
|
| 613 |
+
"id": row.id,
|
| 614 |
+
"ticker": row.ticker,
|
| 615 |
+
"timeframe": row.timeframe,
|
| 616 |
+
"period": row.period,
|
| 617 |
+
"image_hash": row.image_hash,
|
| 618 |
+
"model_used": row.model_used,
|
| 619 |
+
"confidence": row.confidence,
|
| 620 |
+
"created_at": row.created_at.isoformat(),
|
| 621 |
+
"hybrid_analysis": row.hybrid_analysis_json,
|
| 622 |
+
"visual_analysis": row.visual_analysis_json,
|
| 623 |
+
"deterministic_analysis": row.deterministic_analysis_json,
|
| 624 |
+
"disclaimer": DISCLAIMER,
|
| 625 |
+
}
|
| 626 |
+
if not compact:
|
| 627 |
+
payload["chart_image"] = row.chart_image
|
| 628 |
+
payload["price_series"] = []
|
| 629 |
+
return payload
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def serialize_level(row: TechnicalLevel) -> dict:
|
| 633 |
+
return {
|
| 634 |
+
"ticker": row.ticker,
|
| 635 |
+
"timeframe": row.timeframe,
|
| 636 |
+
"support_levels": row.support_levels_json,
|
| 637 |
+
"resistance_levels": row.resistance_levels_json,
|
| 638 |
+
"breakout_level": row.breakout_level,
|
| 639 |
+
"breakdown_level": row.breakdown_level,
|
| 640 |
+
"invalidation_level": row.invalidation_level,
|
| 641 |
+
"updated_at": row.updated_at.isoformat(),
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def serialize_signal(row: TechnicalSignal) -> dict:
|
| 646 |
+
return {
|
| 647 |
+
"ticker": row.ticker,
|
| 648 |
+
"timeframe": row.timeframe,
|
| 649 |
+
"signal_type": row.signal_type,
|
| 650 |
+
"direction": row.direction,
|
| 651 |
+
"confidence": row.confidence,
|
| 652 |
+
"evidence": row.evidence_json,
|
| 653 |
+
"invalidation_level": row.invalidation_level,
|
| 654 |
+
"created_at": row.created_at.isoformat(),
|
| 655 |
+
}
|
backend/app/services/technical_analysis_engine.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import math
|
| 5 |
+
from statistics import mean
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
from app.signals.indicators import compute_indicators
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
TIMEFRAME_LIMITS = {
|
| 14 |
+
"1D": 96,
|
| 15 |
+
"5D": 120,
|
| 16 |
+
"1M": 42,
|
| 17 |
+
"3M": 84,
|
| 18 |
+
"6M": 150,
|
| 19 |
+
"YTD": 260,
|
| 20 |
+
"1Y": 260,
|
| 21 |
+
"5Y": 1260,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TechnicalAnalysisEngine:
|
| 26 |
+
def analyze(self, price_frame: pd.DataFrame, timeframe: str = "6M", benchmark_frame: pd.DataFrame | None = None) -> dict:
|
| 27 |
+
df = normalize_frame(price_frame, timeframe)
|
| 28 |
+
if df.empty or len(df) < 20:
|
| 29 |
+
return insufficient_analysis(timeframe, len(df))
|
| 30 |
+
close = df["close"].astype(float)
|
| 31 |
+
high = df["high"].astype(float)
|
| 32 |
+
low = df["low"].astype(float)
|
| 33 |
+
open_ = df["open"].astype(float)
|
| 34 |
+
volume = df["volume"].fillna(0).astype(float)
|
| 35 |
+
indicators = compute_indicators(df, benchmark_frame)
|
| 36 |
+
pivots = pivot_points(df)
|
| 37 |
+
levels = key_levels(df, pivots)
|
| 38 |
+
ma = moving_averages(close)
|
| 39 |
+
momentum = momentum_block(close, indicators)
|
| 40 |
+
volume_block = volume_analysis(volume, close)
|
| 41 |
+
volatility = volatility_block(df, indicators)
|
| 42 |
+
structure = trend_structure(df, pivots, ma, indicators)
|
| 43 |
+
patterns = pattern_detection(df, pivots, ma, levels, volatility, volume_block)
|
| 44 |
+
divergences = divergence_detection(df, indicators)
|
| 45 |
+
gaps = gap_detection(open_, close, high, low)
|
| 46 |
+
consolidation = consolidation_zones(df, volatility)
|
| 47 |
+
accumulation = accumulation_distribution(df)
|
| 48 |
+
risk_reward = risk_reward_estimate(close.iloc[-1], levels, volatility, structure)
|
| 49 |
+
breakout_probability = breakout_probability_score(structure, momentum, volume_block, volatility, levels, close.iloc[-1])
|
| 50 |
+
trend_strength = trend_strength_score(structure, momentum, volatility, volume_block, ma)
|
| 51 |
+
pullback = pullback_quality(close, ma, levels, indicators)
|
| 52 |
+
signals = technical_signals(structure, momentum, volume_block, volatility, patterns, divergences, breakout_probability, risk_reward)
|
| 53 |
+
report = analyst_summary(structure, momentum, volume_block, volatility, levels, signals, breakout_probability, risk_reward)
|
| 54 |
+
return {
|
| 55 |
+
"status": "ready",
|
| 56 |
+
"generated_at": datetime.utcnow().isoformat(),
|
| 57 |
+
"timeframe": timeframe,
|
| 58 |
+
"observed_rows": int(len(df)),
|
| 59 |
+
"last_price": round(float(close.iloc[-1]), 4),
|
| 60 |
+
"trend_direction": structure["trend_direction"],
|
| 61 |
+
"trend_structure": structure,
|
| 62 |
+
"moving_averages": ma,
|
| 63 |
+
"momentum": momentum,
|
| 64 |
+
"volatility": volatility,
|
| 65 |
+
"volume": volume_block,
|
| 66 |
+
"levels": levels,
|
| 67 |
+
"support_levels": levels["support_levels"],
|
| 68 |
+
"resistance_levels": levels["resistance_levels"],
|
| 69 |
+
"breakout_level": levels["breakout_level"],
|
| 70 |
+
"breakdown_level": levels["breakdown_level"],
|
| 71 |
+
"invalidation_level": levels["invalidation_level"],
|
| 72 |
+
"technical_indicators": {
|
| 73 |
+
"rsi": indicators.get("rsi"),
|
| 74 |
+
"macd": indicators.get("macd"),
|
| 75 |
+
"macd_signal": indicators.get("macd_signal"),
|
| 76 |
+
"macd_hist": indicators.get("macd_hist"),
|
| 77 |
+
"bollinger_upper": indicators.get("bollinger_upper"),
|
| 78 |
+
"bollinger_mid": indicators.get("bollinger_mid"),
|
| 79 |
+
"bollinger_lower": indicators.get("bollinger_lower"),
|
| 80 |
+
"atr": indicators.get("atr"),
|
| 81 |
+
"atr_percent": indicators.get("atr_percent"),
|
| 82 |
+
"adx": indicators.get("adx"),
|
| 83 |
+
},
|
| 84 |
+
"patterns": patterns,
|
| 85 |
+
"gap_detection": gaps,
|
| 86 |
+
"consolidation_zones": consolidation,
|
| 87 |
+
"accumulation_distribution": accumulation,
|
| 88 |
+
"divergences": divergences,
|
| 89 |
+
"breakout_probability": breakout_probability,
|
| 90 |
+
"pullback_quality": pullback,
|
| 91 |
+
"trend_strength_score": trend_strength,
|
| 92 |
+
"risk_reward_estimate": risk_reward,
|
| 93 |
+
"signals": signals,
|
| 94 |
+
"technical_summary": report,
|
| 95 |
+
"evidence_policy": {
|
| 96 |
+
"calculated_data": ["OHLCV-derived indicators", "support/resistance", "volatility", "volume", "risk/reward"],
|
| 97 |
+
"inference": ["trend quality", "breakout probability", "pullback quality", "pattern labels"],
|
| 98 |
+
"uncertainty": ["technical analysis is probabilistic", "levels are zones, not exact guarantees"],
|
| 99 |
+
},
|
| 100 |
+
"warning": "This is technical analysis, not financial advice.",
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def normalize_frame(price_frame: pd.DataFrame, timeframe: str) -> pd.DataFrame:
|
| 105 |
+
if price_frame is None or price_frame.empty:
|
| 106 |
+
return pd.DataFrame()
|
| 107 |
+
df = price_frame.sort_values("date").copy()
|
| 108 |
+
for column in ["open", "high", "low", "close"]:
|
| 109 |
+
if column not in df:
|
| 110 |
+
df[column] = df["close"]
|
| 111 |
+
if "volume" not in df:
|
| 112 |
+
df["volume"] = 0.0
|
| 113 |
+
df = df.dropna(subset=["close"])
|
| 114 |
+
limit = TIMEFRAME_LIMITS.get(timeframe.upper(), 150)
|
| 115 |
+
if timeframe.upper() == "YTD":
|
| 116 |
+
dates = pd.to_datetime(df["date"])
|
| 117 |
+
current_year = dates.iloc[-1].year
|
| 118 |
+
ytd = df[dates.dt.year == current_year]
|
| 119 |
+
return ytd.tail(limit) if len(ytd) >= 20 else df.tail(limit)
|
| 120 |
+
return df.tail(limit)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def insufficient_analysis(timeframe: str, rows: int) -> dict:
|
| 124 |
+
return {
|
| 125 |
+
"status": "insufficient_price_history",
|
| 126 |
+
"generated_at": datetime.utcnow().isoformat(),
|
| 127 |
+
"timeframe": timeframe,
|
| 128 |
+
"observed_rows": rows,
|
| 129 |
+
"confidence": 0,
|
| 130 |
+
"technical_summary": "Not enough stored OHLCV rows to produce a professional technical analysis.",
|
| 131 |
+
"warning": "This is technical analysis, not financial advice.",
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def moving_averages(close: pd.Series) -> dict:
|
| 136 |
+
def ema(span: int) -> float | None:
|
| 137 |
+
return round(float(close.ewm(span=span, adjust=False).mean().iloc[-1]), 4) if len(close) >= max(3, span // 3) else None
|
| 138 |
+
|
| 139 |
+
def sma(window: int) -> float | None:
|
| 140 |
+
if len(close) < window:
|
| 141 |
+
return round(float(close.mean()), 4)
|
| 142 |
+
return round(float(close.rolling(window).mean().iloc[-1]), 4)
|
| 143 |
+
|
| 144 |
+
last = float(close.iloc[-1])
|
| 145 |
+
values = {
|
| 146 |
+
"ema9": ema(9),
|
| 147 |
+
"ema21": ema(21),
|
| 148 |
+
"ema50": ema(50),
|
| 149 |
+
"ema200": ema(200),
|
| 150 |
+
"sma20": sma(20),
|
| 151 |
+
"sma50": sma(50),
|
| 152 |
+
"sma200": sma(200),
|
| 153 |
+
}
|
| 154 |
+
values["price_vs_ema21_pct"] = pct(values["ema21"], last) if values["ema21"] else None
|
| 155 |
+
values["price_vs_sma50_pct"] = pct(values["sma50"], last) if values["sma50"] else None
|
| 156 |
+
values["alignment"] = ma_alignment(last, values)
|
| 157 |
+
return values
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def ma_alignment(last: float, ma: dict) -> str:
|
| 161 |
+
ema9 = ma.get("ema9") or last
|
| 162 |
+
ema21 = ma.get("ema21") or last
|
| 163 |
+
ema50 = ma.get("ema50") or last
|
| 164 |
+
sma200 = ma.get("sma200") or last
|
| 165 |
+
if last > ema9 > ema21 > ema50 > sma200:
|
| 166 |
+
return "bullish_stack"
|
| 167 |
+
if last < ema9 < ema21 < ema50 < sma200:
|
| 168 |
+
return "bearish_stack"
|
| 169 |
+
if last > ema21 and ema21 > ema50:
|
| 170 |
+
return "constructive"
|
| 171 |
+
if last < ema21 and ema21 < ema50:
|
| 172 |
+
return "deteriorating"
|
| 173 |
+
return "mixed"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def pivot_points(df: pd.DataFrame, window: int = 3) -> dict:
|
| 177 |
+
highs = []
|
| 178 |
+
lows = []
|
| 179 |
+
high = df["high"].astype(float).reset_index(drop=True)
|
| 180 |
+
low = df["low"].astype(float).reset_index(drop=True)
|
| 181 |
+
dates = df["date"].reset_index(drop=True)
|
| 182 |
+
for index in range(window, len(df) - window):
|
| 183 |
+
h = high.iloc[index]
|
| 184 |
+
l = low.iloc[index]
|
| 185 |
+
if h >= high.iloc[index - window : index + window + 1].max():
|
| 186 |
+
highs.append({"index": index, "date": str(dates.iloc[index]), "price": round(float(h), 4)})
|
| 187 |
+
if l <= low.iloc[index - window : index + window + 1].min():
|
| 188 |
+
lows.append({"index": index, "date": str(dates.iloc[index]), "price": round(float(l), 4)})
|
| 189 |
+
return {"highs": highs[-10:], "lows": lows[-10:]}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def key_levels(df: pd.DataFrame, pivots: dict) -> dict:
|
| 193 |
+
close = df["close"].astype(float)
|
| 194 |
+
high = df["high"].astype(float)
|
| 195 |
+
low = df["low"].astype(float)
|
| 196 |
+
last = float(close.iloc[-1])
|
| 197 |
+
pivot_supports = [item["price"] for item in pivots["lows"] if item["price"] <= last * 1.03]
|
| 198 |
+
pivot_resistances = [item["price"] for item in pivots["highs"] if item["price"] >= last * 0.97]
|
| 199 |
+
supports = sorted(set(round(float(value), 4) for value in pivot_supports + [low.tail(20).min(), close.tail(60).quantile(0.18)] if value < last), reverse=True)[:4]
|
| 200 |
+
resistances = sorted(set(round(float(value), 4) for value in pivot_resistances + [high.tail(20).max(), close.tail(60).quantile(0.82)] if value > last))[:4]
|
| 201 |
+
if not supports:
|
| 202 |
+
supports = [round(float(low.tail(30).min()), 4)]
|
| 203 |
+
if not resistances:
|
| 204 |
+
resistances = [round(float(high.tail(30).max()), 4)]
|
| 205 |
+
return {
|
| 206 |
+
"support_levels": [{"level": value, "distance_pct": round(pct(last, value), 2), "source": "pivot_or_distribution"} for value in supports],
|
| 207 |
+
"resistance_levels": [{"level": value, "distance_pct": round(pct(last, value), 2), "source": "pivot_or_distribution"} for value in resistances],
|
| 208 |
+
"breakout_level": resistances[0] if resistances else None,
|
| 209 |
+
"breakdown_level": supports[0] if supports else None,
|
| 210 |
+
"invalidation_level": supports[0] if supports else None,
|
| 211 |
+
"nearest_support": supports[0] if supports else None,
|
| 212 |
+
"nearest_resistance": resistances[0] if resistances else None,
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def trend_structure(df: pd.DataFrame, pivots: dict, ma: dict, indicators: dict) -> dict:
|
| 217 |
+
highs = pivots["highs"]
|
| 218 |
+
lows = pivots["lows"]
|
| 219 |
+
higher_highs = count_progression(highs, "up")
|
| 220 |
+
lower_highs = count_progression(highs, "down")
|
| 221 |
+
higher_lows = count_progression(lows, "up")
|
| 222 |
+
lower_lows = count_progression(lows, "down")
|
| 223 |
+
close = df["close"].astype(float)
|
| 224 |
+
slope_20 = series_slope(close.tail(min(40, len(close))))
|
| 225 |
+
alignment = ma.get("alignment", "mixed")
|
| 226 |
+
if higher_highs >= 2 and higher_lows >= 2 and alignment in {"bullish_stack", "constructive"}:
|
| 227 |
+
direction = "uptrend"
|
| 228 |
+
elif lower_highs >= 2 and lower_lows >= 2 and alignment in {"bearish_stack", "deteriorating"}:
|
| 229 |
+
direction = "downtrend"
|
| 230 |
+
elif abs(slope_20) < 0.08:
|
| 231 |
+
direction = "range_or_consolidation"
|
| 232 |
+
elif slope_20 > 0:
|
| 233 |
+
direction = "uptrend_attempt"
|
| 234 |
+
else:
|
| 235 |
+
direction = "downtrend_attempt"
|
| 236 |
+
return {
|
| 237 |
+
"trend_direction": direction,
|
| 238 |
+
"higher_highs": higher_highs,
|
| 239 |
+
"higher_lows": higher_lows,
|
| 240 |
+
"lower_highs": lower_highs,
|
| 241 |
+
"lower_lows": lower_lows,
|
| 242 |
+
"slope_20": round(slope_20, 4),
|
| 243 |
+
"ma_alignment": alignment,
|
| 244 |
+
"adx": indicators.get("adx"),
|
| 245 |
+
"structure_comment": structure_comment(direction, higher_highs, higher_lows, lower_highs, lower_lows),
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def momentum_block(close: pd.Series, indicators: dict) -> dict:
|
| 250 |
+
rsi = float(indicators.get("rsi") or 50)
|
| 251 |
+
macd_hist = float(indicators.get("macd_hist") or 0)
|
| 252 |
+
perf_5d = float(indicators.get("perf_5d") or 0)
|
| 253 |
+
perf_1m = float(indicators.get("perf_1m") or 0)
|
| 254 |
+
state = "neutral"
|
| 255 |
+
if rsi >= 58 and macd_hist > 0 and perf_5d > 0:
|
| 256 |
+
state = "improving"
|
| 257 |
+
if rsi >= 68:
|
| 258 |
+
state = "extended_positive"
|
| 259 |
+
if rsi <= 42 and macd_hist < 0:
|
| 260 |
+
state = "weakening"
|
| 261 |
+
if rsi <= 32:
|
| 262 |
+
state = "oversold"
|
| 263 |
+
return {
|
| 264 |
+
"state": state,
|
| 265 |
+
"rsi": round(rsi, 2),
|
| 266 |
+
"macd_hist": round(macd_hist, 4),
|
| 267 |
+
"perf_5d": round(perf_5d, 3),
|
| 268 |
+
"perf_1m": round(perf_1m, 3),
|
| 269 |
+
"rate_of_change_20": round(return_pct(close, min(20, len(close) - 1)), 3),
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def volume_analysis(volume: pd.Series, close: pd.Series) -> dict:
|
| 274 |
+
recent = volume.tail(10)
|
| 275 |
+
baseline = volume.tail(60).iloc[:-10] if len(volume) > 20 else volume
|
| 276 |
+
baseline_mean = float(baseline.mean()) if len(baseline) else 0.0
|
| 277 |
+
rel = float(recent.mean() / baseline_mean) if baseline_mean else 0.0
|
| 278 |
+
up_volume = volume[close.diff() > 0].tail(30).sum()
|
| 279 |
+
down_volume = volume[close.diff() < 0].tail(30).sum()
|
| 280 |
+
pressure = "balanced"
|
| 281 |
+
if up_volume > down_volume * 1.2:
|
| 282 |
+
pressure = "accumulation_bias"
|
| 283 |
+
if down_volume > up_volume * 1.2:
|
| 284 |
+
pressure = "distribution_bias"
|
| 285 |
+
return {
|
| 286 |
+
"relative_volume": round(rel, 3),
|
| 287 |
+
"volume_state": "expanding" if rel >= 1.25 else "contracting" if rel <= 0.75 else "normal",
|
| 288 |
+
"up_down_volume_pressure": pressure,
|
| 289 |
+
"latest_volume": round(float(volume.iloc[-1]), 2) if len(volume) else 0,
|
| 290 |
+
"average_volume_50": round(float(volume.tail(50).mean()), 2) if len(volume) else 0,
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def volatility_block(df: pd.DataFrame, indicators: dict) -> dict:
|
| 295 |
+
close = df["close"].astype(float)
|
| 296 |
+
returns = close.pct_change().dropna()
|
| 297 |
+
realized = float(returns.tail(30).std() * math.sqrt(252) * 100) if len(returns) > 5 else 0.0
|
| 298 |
+
atr_pct = float(indicators.get("atr_percent") or 0)
|
| 299 |
+
bb_upper = float(indicators.get("bollinger_upper") or 0)
|
| 300 |
+
bb_lower = float(indicators.get("bollinger_lower") or 0)
|
| 301 |
+
last = float(close.iloc[-1])
|
| 302 |
+
bb_width = ((bb_upper - bb_lower) / last * 100) if last and bb_upper and bb_lower else 0.0
|
| 303 |
+
historical_width = (close.rolling(20).std() / close.rolling(20).mean() * 100).dropna()
|
| 304 |
+
compression = bool(len(historical_width) and bb_width <= historical_width.tail(120).quantile(0.25))
|
| 305 |
+
regime = "normal"
|
| 306 |
+
if realized > 45 or atr_pct > 5:
|
| 307 |
+
regime = "high"
|
| 308 |
+
elif realized < 18 and atr_pct < 2.5:
|
| 309 |
+
regime = "low"
|
| 310 |
+
return {
|
| 311 |
+
"regime": regime,
|
| 312 |
+
"realized_volatility_30d": round(realized, 3),
|
| 313 |
+
"atr_percent": round(atr_pct, 3),
|
| 314 |
+
"bollinger_width_pct": round(bb_width, 3),
|
| 315 |
+
"volatility_compression": compression,
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def pattern_detection(df: pd.DataFrame, pivots: dict, ma: dict, levels: dict, volatility: dict, volume: dict) -> list[dict]:
|
| 320 |
+
close = df["close"].astype(float)
|
| 321 |
+
last = float(close.iloc[-1])
|
| 322 |
+
patterns = []
|
| 323 |
+
if volatility["volatility_compression"]:
|
| 324 |
+
patterns.append({"pattern": "volatility_compression", "direction": "neutral", "confidence": 66, "evidence": "Bollinger width is in the lower historical quartile."})
|
| 325 |
+
if levels.get("breakout_level") and last > float(levels["breakout_level"]) * 0.995 and volume["relative_volume"] >= 1.1:
|
| 326 |
+
patterns.append({"pattern": "breakout_test", "direction": "bullish", "confidence": 62, "evidence": "Price is pressing against nearest resistance with volume support."})
|
| 327 |
+
if levels.get("breakdown_level") and last < float(levels["breakdown_level"]) * 1.005:
|
| 328 |
+
patterns.append({"pattern": "support_test", "direction": "bearish", "confidence": 58, "evidence": "Price is testing nearest support; failure would weaken structure."})
|
| 329 |
+
if ma.get("alignment") == "bullish_stack":
|
| 330 |
+
patterns.append({"pattern": "moving_average_bullish_stack", "direction": "bullish", "confidence": 72, "evidence": "Price and moving averages are stacked in constructive order."})
|
| 331 |
+
if ma.get("alignment") == "bearish_stack":
|
| 332 |
+
patterns.append({"pattern": "moving_average_bearish_stack", "direction": "bearish", "confidence": 72, "evidence": "Price and moving averages are stacked in deteriorating order."})
|
| 333 |
+
if not patterns:
|
| 334 |
+
patterns.append({"pattern": "mixed_structure", "direction": "neutral", "confidence": 50, "evidence": "No dominant continuation or reversal pattern is statistically clear."})
|
| 335 |
+
return patterns
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def divergence_detection(df: pd.DataFrame, indicators: dict) -> dict:
|
| 339 |
+
close = df["close"].astype(float)
|
| 340 |
+
rsi_series = rsi_series_calc(close)
|
| 341 |
+
macd_hist = macd_hist_series(close)
|
| 342 |
+
price_lookback = close.tail(40)
|
| 343 |
+
bullish_rsi = bool(price_lookback.iloc[-1] <= price_lookback.quantile(0.35) and rsi_series.tail(40).iloc[-1] > rsi_series.tail(40).quantile(0.45))
|
| 344 |
+
bearish_rsi = bool(price_lookback.iloc[-1] >= price_lookback.quantile(0.65) and rsi_series.tail(40).iloc[-1] < rsi_series.tail(40).quantile(0.55))
|
| 345 |
+
bullish_macd = bool(price_lookback.iloc[-1] <= price_lookback.quantile(0.35) and macd_hist.tail(40).iloc[-1] > macd_hist.tail(40).quantile(0.45))
|
| 346 |
+
bearish_macd = bool(price_lookback.iloc[-1] >= price_lookback.quantile(0.65) and macd_hist.tail(40).iloc[-1] < macd_hist.tail(40).quantile(0.55))
|
| 347 |
+
return {
|
| 348 |
+
"price_rsi": "bullish_divergence" if bullish_rsi else "bearish_divergence" if bearish_rsi else "none_detected",
|
| 349 |
+
"price_macd": "bullish_divergence" if bullish_macd else "bearish_divergence" if bearish_macd else "none_detected",
|
| 350 |
+
"notes": "Divergence is inferred from recent quantile behavior and should be confirmed with price action.",
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def gap_detection(open_: pd.Series, close: pd.Series, high: pd.Series, low: pd.Series) -> dict:
|
| 355 |
+
if len(open_) < 3:
|
| 356 |
+
return {"latest_gap_pct": 0, "gap_type": "insufficient_history"}
|
| 357 |
+
latest_gap = (float(open_.iloc[-1]) / float(close.iloc[-2]) - 1) * 100 if close.iloc[-2] else 0
|
| 358 |
+
gap_type = "none"
|
| 359 |
+
if latest_gap > 1.5:
|
| 360 |
+
gap_type = "gap_up"
|
| 361 |
+
elif latest_gap < -1.5:
|
| 362 |
+
gap_type = "gap_down"
|
| 363 |
+
filled = bool(low.iloc[-1] <= close.iloc[-2] <= high.iloc[-1])
|
| 364 |
+
return {"latest_gap_pct": round(latest_gap, 3), "gap_type": gap_type, "gap_filled_intraday": filled}
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def consolidation_zones(df: pd.DataFrame, volatility: dict) -> list[dict]:
|
| 368 |
+
close = df["close"].astype(float)
|
| 369 |
+
zones = []
|
| 370 |
+
for window in [20, 40, 60]:
|
| 371 |
+
if len(close) < window:
|
| 372 |
+
continue
|
| 373 |
+
sample = close.tail(window)
|
| 374 |
+
width = (sample.max() / sample.min() - 1) * 100 if sample.min() else 0
|
| 375 |
+
if width <= max(8, volatility.get("atr_percent", 2) * 4):
|
| 376 |
+
zones.append({"window": window, "low": round(float(sample.min()), 4), "high": round(float(sample.max()), 4), "width_pct": round(width, 3)})
|
| 377 |
+
return zones[:3]
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def accumulation_distribution(df: pd.DataFrame) -> dict:
|
| 381 |
+
close = df["close"].astype(float)
|
| 382 |
+
volume = df["volume"].fillna(0).astype(float)
|
| 383 |
+
money_flow = ((close - close.shift()) * volume).tail(30).sum()
|
| 384 |
+
total_volume = volume.tail(30).sum() or 1
|
| 385 |
+
score = float(money_flow / total_volume)
|
| 386 |
+
state = "balanced"
|
| 387 |
+
if score > 0:
|
| 388 |
+
state = "accumulation_bias"
|
| 389 |
+
if score < 0:
|
| 390 |
+
state = "distribution_bias"
|
| 391 |
+
return {"state": state, "pressure_score": round(score, 4)}
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def risk_reward_estimate(last: float, levels: dict, volatility: dict, structure: dict) -> dict:
|
| 395 |
+
support = levels.get("nearest_support")
|
| 396 |
+
resistance = levels.get("nearest_resistance")
|
| 397 |
+
downside = abs(pct(last, support)) if support else None
|
| 398 |
+
upside = abs(pct(last, resistance)) if resistance else None
|
| 399 |
+
ratio = round((upside / downside), 2) if upside is not None and downside and downside > 0 else None
|
| 400 |
+
label = "unbalanced"
|
| 401 |
+
if ratio is not None and ratio >= 1.8:
|
| 402 |
+
label = "favorable"
|
| 403 |
+
elif ratio is not None and ratio >= 1.0:
|
| 404 |
+
label = "balanced"
|
| 405 |
+
if volatility["regime"] == "high":
|
| 406 |
+
label = f"{label}_high_volatility"
|
| 407 |
+
return {
|
| 408 |
+
"nearest_support": support,
|
| 409 |
+
"nearest_resistance": resistance,
|
| 410 |
+
"downside_to_support_pct": round(downside, 3) if downside is not None else None,
|
| 411 |
+
"upside_to_resistance_pct": round(upside, 3) if upside is not None else None,
|
| 412 |
+
"reward_to_risk": ratio,
|
| 413 |
+
"label": label,
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def breakout_probability_score(structure: dict, momentum: dict, volume: dict, volatility: dict, levels: dict, last: float) -> dict:
|
| 418 |
+
score = 45.0
|
| 419 |
+
if structure["trend_direction"] in {"uptrend", "uptrend_attempt"}:
|
| 420 |
+
score += 14
|
| 421 |
+
if momentum["state"] in {"improving", "extended_positive"}:
|
| 422 |
+
score += 12
|
| 423 |
+
if volume["volume_state"] == "expanding":
|
| 424 |
+
score += 10
|
| 425 |
+
if volatility["volatility_compression"]:
|
| 426 |
+
score += 8
|
| 427 |
+
resistance = levels.get("nearest_resistance")
|
| 428 |
+
if resistance:
|
| 429 |
+
distance = abs(pct(last, resistance))
|
| 430 |
+
if distance <= 3:
|
| 431 |
+
score += 6
|
| 432 |
+
elif distance > 12:
|
| 433 |
+
score -= 8
|
| 434 |
+
if momentum["state"] == "extended_positive":
|
| 435 |
+
score -= 5
|
| 436 |
+
return {"score": round(max(0, min(100, score)), 1), "method": "weighted trend, momentum, volume, volatility and distance-to-resistance model"}
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def pullback_quality(close: pd.Series, ma: dict, levels: dict, indicators: dict) -> dict:
|
| 440 |
+
last = float(close.iloc[-1])
|
| 441 |
+
ema21 = ma.get("ema21")
|
| 442 |
+
support = levels.get("nearest_support")
|
| 443 |
+
rsi = float(indicators.get("rsi") or 50)
|
| 444 |
+
quality = 50.0
|
| 445 |
+
if ema21 and abs(pct(last, ema21)) <= 3:
|
| 446 |
+
quality += 18
|
| 447 |
+
if support and abs(pct(last, support)) <= 4:
|
| 448 |
+
quality += 14
|
| 449 |
+
if 38 <= rsi <= 58:
|
| 450 |
+
quality += 12
|
| 451 |
+
if rsi > 70:
|
| 452 |
+
quality -= 18
|
| 453 |
+
return {"score": round(max(0, min(100, quality)), 1), "comment": "Higher score means pullback is closer to logical support without excessive momentum damage."}
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def trend_strength_score(structure: dict, momentum: dict, volatility: dict, volume: dict, ma: dict) -> float:
|
| 457 |
+
score = 45.0
|
| 458 |
+
if structure["trend_direction"] == "uptrend":
|
| 459 |
+
score += 22
|
| 460 |
+
elif structure["trend_direction"] == "downtrend":
|
| 461 |
+
score -= 18
|
| 462 |
+
elif structure["trend_direction"] in {"uptrend_attempt", "downtrend_attempt"}:
|
| 463 |
+
score += 4
|
| 464 |
+
if ma.get("alignment") == "bullish_stack":
|
| 465 |
+
score += 16
|
| 466 |
+
if ma.get("alignment") == "bearish_stack":
|
| 467 |
+
score -= 16
|
| 468 |
+
if momentum["state"] == "improving":
|
| 469 |
+
score += 10
|
| 470 |
+
if volume["volume_state"] == "expanding":
|
| 471 |
+
score += 5
|
| 472 |
+
if volatility["regime"] == "high":
|
| 473 |
+
score -= 8
|
| 474 |
+
return round(max(0, min(100, score)), 1)
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def technical_signals(structure: dict, momentum: dict, volume: dict, volatility: dict, patterns: list[dict], divergences: dict, breakout_probability: dict, risk_reward: dict) -> list[dict]:
|
| 478 |
+
signals = []
|
| 479 |
+
if breakout_probability["score"] >= 70:
|
| 480 |
+
signals.append({"signal_type": "breakout_watch", "direction": "bullish", "confidence": breakout_probability["score"], "evidence": "Trend, momentum, volume or compression support a breakout watch."})
|
| 481 |
+
if structure["trend_direction"] in {"downtrend", "downtrend_attempt"} and momentum["state"] in {"weakening", "oversold"}:
|
| 482 |
+
signals.append({"signal_type": "downtrend_pressure", "direction": "bearish", "confidence": 68, "evidence": "Structure and momentum remain weak."})
|
| 483 |
+
if volatility["volatility_compression"]:
|
| 484 |
+
signals.append({"signal_type": "volatility_compression", "direction": "neutral", "confidence": 64, "evidence": "Compressed volatility can precede directional expansion."})
|
| 485 |
+
if divergences["price_rsi"] != "none_detected" or divergences["price_macd"] != "none_detected":
|
| 486 |
+
direction = "bullish" if "bullish" in (divergences["price_rsi"] + divergences["price_macd"]) else "bearish"
|
| 487 |
+
signals.append({"signal_type": "momentum_divergence", "direction": direction, "confidence": 58, "evidence": f"RSI/MACD divergence state: {divergences['price_rsi']} / {divergences['price_macd']}."})
|
| 488 |
+
if risk_reward.get("label", "").startswith("favorable"):
|
| 489 |
+
signals.append({"signal_type": "favorable_risk_reward_zone", "direction": "neutral", "confidence": 60, "evidence": "Nearest support/resistance geometry is favorable, pending confirmation."})
|
| 490 |
+
if not signals:
|
| 491 |
+
strongest = max(patterns, key=lambda item: item.get("confidence", 0))
|
| 492 |
+
signals.append({"signal_type": strongest["pattern"], "direction": strongest["direction"], "confidence": strongest["confidence"], "evidence": strongest["evidence"]})
|
| 493 |
+
return signals
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def analyst_summary(structure: dict, momentum: dict, volume: dict, volatility: dict, levels: dict, signals: list[dict], breakout_probability: dict, risk_reward: dict) -> str:
|
| 497 |
+
support = levels.get("nearest_support")
|
| 498 |
+
resistance = levels.get("nearest_resistance")
|
| 499 |
+
return (
|
| 500 |
+
f"Blum detects a {structure['trend_direction'].replace('_', ' ')} with {momentum['state'].replace('_', ' ')} momentum. "
|
| 501 |
+
f"Nearest support is near {support if support is not None else 'n/a'} and resistance is near {resistance if resistance is not None else 'n/a'}. "
|
| 502 |
+
f"Volume is {volume['volume_state']} with {volume['up_down_volume_pressure'].replace('_', ' ')}. "
|
| 503 |
+
f"Volatility regime is {volatility['regime']} and breakout probability is {breakout_probability['score']:.1f}/100. "
|
| 504 |
+
f"Risk/reward is {risk_reward.get('label', 'not rated').replace('_', ' ')}. "
|
| 505 |
+
f"Primary signal: {signals[0]['signal_type'].replace('_', ' ')}."
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def structure_comment(direction: str, higher_highs: int, higher_lows: int, lower_highs: int, lower_lows: int) -> str:
|
| 510 |
+
return (
|
| 511 |
+
f"Structure classified as {direction}. Recent pivots show {higher_highs} higher highs, "
|
| 512 |
+
f"{higher_lows} higher lows, {lower_highs} lower highs and {lower_lows} lower lows."
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def count_progression(items: list[dict], direction: str) -> int:
|
| 517 |
+
count = 0
|
| 518 |
+
for previous, current in zip(items[-5:], items[-4:]):
|
| 519 |
+
if direction == "up" and current["price"] > previous["price"]:
|
| 520 |
+
count += 1
|
| 521 |
+
if direction == "down" and current["price"] < previous["price"]:
|
| 522 |
+
count += 1
|
| 523 |
+
return count
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def rsi_series_calc(close: pd.Series, window: int = 14) -> pd.Series:
|
| 527 |
+
delta = close.diff()
|
| 528 |
+
gain = delta.clip(lower=0).rolling(window).mean()
|
| 529 |
+
loss = -delta.clip(upper=0).rolling(window).mean()
|
| 530 |
+
rs = gain / loss.replace(0, np.nan)
|
| 531 |
+
return (100 - (100 / (1 + rs))).fillna(50)
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def macd_hist_series(close: pd.Series) -> pd.Series:
|
| 535 |
+
fast = close.ewm(span=12, adjust=False).mean()
|
| 536 |
+
slow = close.ewm(span=26, adjust=False).mean()
|
| 537 |
+
line = fast - slow
|
| 538 |
+
signal = line.ewm(span=9, adjust=False).mean()
|
| 539 |
+
return (line - signal).fillna(0)
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def series_slope(series: pd.Series) -> float:
|
| 543 |
+
if len(series) < 3:
|
| 544 |
+
return 0.0
|
| 545 |
+
y = series.astype(float).values
|
| 546 |
+
x = np.arange(len(y))
|
| 547 |
+
return float(np.polyfit(x, y, 1)[0] / max(abs(y[-1]), 1e-9) * 100)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def return_pct(close: pd.Series, periods: int) -> float:
|
| 551 |
+
if len(close) <= periods or periods <= 0:
|
| 552 |
+
return 0.0
|
| 553 |
+
base = float(close.iloc[-periods - 1])
|
| 554 |
+
return (float(close.iloc[-1]) / base - 1) * 100 if base else 0.0
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def pct(start: float | None, end: float | None) -> float:
|
| 558 |
+
if start is None or end is None or not start:
|
| 559 |
+
return 0.0
|
| 560 |
+
return (float(end) / float(start) - 1) * 100
|
frontend/app/assets/[ticker]/view.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { AccuracyProfile, PricePoint, RelatedNews, Signal } from "@/lib/types";
|
|
| 6 |
import { LoadingState } from "@/components/LoadingState";
|
| 7 |
import { BlumMemoryPanel } from "@/components/BlumMemoryPanel";
|
| 8 |
import { BreakdownBars } from "@/components/BreakdownBars";
|
|
|
|
| 9 |
import { formatPercent, formatPrice, formatVolume, MarketSnapshotStrip } from "@/components/MarketSnapshotStrip";
|
| 10 |
import { PlotPanel } from "@/components/PlotPanel";
|
| 11 |
import { StatusBadge } from "@/components/StatusBadge";
|
|
@@ -107,6 +108,8 @@ export function AssetDetailClient({ ticker }: { ticker: string }) {
|
|
| 107 |
|
| 108 |
<BlumMemoryPanel memory={brainMemory} />
|
| 109 |
|
|
|
|
|
|
|
| 110 |
<section className="grid-2" style={{ marginTop: 12 }}>
|
| 111 |
<PlotPanel
|
| 112 |
title="Historical Price"
|
|
|
|
| 6 |
import { LoadingState } from "@/components/LoadingState";
|
| 7 |
import { BlumMemoryPanel } from "@/components/BlumMemoryPanel";
|
| 8 |
import { BreakdownBars } from "@/components/BreakdownBars";
|
| 9 |
+
import { ChartAnalystPanel } from "@/components/ChartAnalystPanel";
|
| 10 |
import { formatPercent, formatPrice, formatVolume, MarketSnapshotStrip } from "@/components/MarketSnapshotStrip";
|
| 11 |
import { PlotPanel } from "@/components/PlotPanel";
|
| 12 |
import { StatusBadge } from "@/components/StatusBadge";
|
|
|
|
| 108 |
|
| 109 |
<BlumMemoryPanel memory={brainMemory} />
|
| 110 |
|
| 111 |
+
<ChartAnalystPanel ticker={data.asset.ticker} prices={prices} />
|
| 112 |
+
|
| 113 |
<section className="grid-2" style={{ marginTop: 12 }}>
|
| 114 |
<PlotPanel
|
| 115 |
title="Historical Price"
|
frontend/app/chart-analyst/page.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useMemo, useState } from "react";
|
| 4 |
+
import { api } from "@/lib/api";
|
| 5 |
+
import { Asset, PricePoint } from "@/lib/types";
|
| 6 |
+
import { ChartAnalystPanel } from "@/components/ChartAnalystPanel";
|
| 7 |
+
import { LoadingState } from "@/components/LoadingState";
|
| 8 |
+
|
| 9 |
+
export default function ChartAnalystPage() {
|
| 10 |
+
const [assets, setAssets] = useState<Asset[] | null>(null);
|
| 11 |
+
const [ticker, setTicker] = useState("NVDA");
|
| 12 |
+
const [prices, setPrices] = useState<PricePoint[]>([]);
|
| 13 |
+
const [error, setError] = useState("");
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
api.assets()
|
| 17 |
+
.then((rows) => {
|
| 18 |
+
setAssets(rows);
|
| 19 |
+
if (!rows.some((asset) => asset.ticker === ticker) && rows[0]) setTicker(rows[0].ticker);
|
| 20 |
+
})
|
| 21 |
+
.catch((err) => setError((err as Error).message));
|
| 22 |
+
}, []);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
if (!ticker) return;
|
| 26 |
+
api.asset(ticker)
|
| 27 |
+
.then((payload) => setPrices(payload.prices ?? []))
|
| 28 |
+
.catch(() => setPrices([]));
|
| 29 |
+
}, [ticker]);
|
| 30 |
+
|
| 31 |
+
const selected = useMemo(() => assets?.find((asset) => asset.ticker === ticker), [assets, ticker]);
|
| 32 |
+
|
| 33 |
+
if (error) return <div className="empty-state">API error: {error}</div>;
|
| 34 |
+
if (!assets) return <LoadingState label="Loading chart analyst" />;
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<>
|
| 38 |
+
<div className="page-header">
|
| 39 |
+
<div>
|
| 40 |
+
<div className="kicker">Chart Analyst</div>
|
| 41 |
+
<h1>Institutional technical chart intelligence.</h1>
|
| 42 |
+
<p>Hybrid analysis from deterministic OHLCV indicators, optional Qwen3-VL chart vision, sentiment context and Blum memory.</p>
|
| 43 |
+
</div>
|
| 44 |
+
<select className="input" value={ticker} onChange={(event) => setTicker(event.target.value)}>
|
| 45 |
+
{assets.map((asset) => <option key={asset.ticker} value={asset.ticker}>{asset.ticker} | {asset.name}</option>)}
|
| 46 |
+
</select>
|
| 47 |
+
</div>
|
| 48 |
+
{selected && (
|
| 49 |
+
<section className="instrument-card" style={{ marginBottom: 12 }}>
|
| 50 |
+
<div>
|
| 51 |
+
<span>Instrument</span>
|
| 52 |
+
<strong>{selected.ticker} | {selected.sector}</strong>
|
| 53 |
+
<p>{selected.name} | {selected.asset_type} | {selected.exchange} | {selected.country}</p>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="market-strip compact">
|
| 56 |
+
<div><span>Price</span><strong>{selected.market_snapshot?.price ?? "n/a"}</strong></div>
|
| 57 |
+
<div><span>1D</span><strong>{selected.market_snapshot?.perf_1d ?? "n/a"}%</strong></div>
|
| 58 |
+
<div><span>Provider</span><strong>{selected.market_snapshot?.provider ?? "pending"}</strong></div>
|
| 59 |
+
</div>
|
| 60 |
+
</section>
|
| 61 |
+
)}
|
| 62 |
+
<ChartAnalystPanel ticker={ticker} prices={prices} />
|
| 63 |
+
</>
|
| 64 |
+
);
|
| 65 |
+
}
|
frontend/app/globals.css
CHANGED
|
@@ -956,6 +956,71 @@ p { color: var(--muted); line-height: 1.55; }
|
|
| 956 |
padding: 10px;
|
| 957 |
font-size: 12px;
|
| 958 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 959 |
|
| 960 |
.control-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
| 961 |
.button { border: 1px solid var(--line); background: #101722; color: var(--text); border-radius: 6px; padding: 9px 12px; font-weight: 900; cursor: pointer; }
|
|
@@ -993,7 +1058,7 @@ p { color: var(--muted); line-height: 1.55; }
|
|
| 993 |
.system-card { position: static; margin-top: 14px; }
|
| 994 |
.page-header { align-items: flex-start; flex-direction: column; }
|
| 995 |
.header-actions { justify-content: flex-start; }
|
| 996 |
-
.hero, .grid-2, .grid-3, .grid-4, .live-grid, .realtime-strip, .deployment-banner, .diagnostic-grid, .instrument-card, .market-strip, .market-strip.compact, .brain-hero, .scenario-grid, .evidence-grid, .method-grid, .event-graph, .financial-brain-grid, .quality-lists, .macro-list, .executive-hero, .executive-command, .executive-grid, .narrative-grid, .sentiment-columns, .opportunity-card, .score-stack, .allocation-list div, .brain-status-grid, .brain-two-col { grid-template-columns: 1fr; }
|
| 997 |
.tape-row { grid-template-columns: 1fr; }
|
| 998 |
.tape-meta { justify-content: flex-start; max-width: none; }
|
| 999 |
}
|
|
|
|
| 956 |
padding: 10px;
|
| 957 |
font-size: 12px;
|
| 958 |
}
|
| 959 |
+
.chart-analyst-shell {
|
| 960 |
+
margin-top: 12px;
|
| 961 |
+
}
|
| 962 |
+
.compact-header {
|
| 963 |
+
margin-bottom: 12px;
|
| 964 |
+
}
|
| 965 |
+
.chart-snapshot-panel .brain-status-grid {
|
| 966 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 967 |
+
}
|
| 968 |
+
.key-level-grid {
|
| 969 |
+
display: grid;
|
| 970 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 971 |
+
gap: 8px;
|
| 972 |
+
}
|
| 973 |
+
.key-level-grid div {
|
| 974 |
+
min-height: 66px;
|
| 975 |
+
border: 1px solid var(--line);
|
| 976 |
+
border-radius: 6px;
|
| 977 |
+
padding: 9px;
|
| 978 |
+
background: #070a0f;
|
| 979 |
+
}
|
| 980 |
+
.key-level-grid span {
|
| 981 |
+
display: block;
|
| 982 |
+
color: var(--muted);
|
| 983 |
+
font-size: 10px;
|
| 984 |
+
text-transform: uppercase;
|
| 985 |
+
letter-spacing: .08em;
|
| 986 |
+
font-weight: 900;
|
| 987 |
+
}
|
| 988 |
+
.key-level-grid strong {
|
| 989 |
+
display: block;
|
| 990 |
+
margin-top: 7px;
|
| 991 |
+
color: var(--amber);
|
| 992 |
+
font-size: 20px;
|
| 993 |
+
}
|
| 994 |
+
.evidence-columns {
|
| 995 |
+
display: grid;
|
| 996 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 997 |
+
gap: 10px;
|
| 998 |
+
}
|
| 999 |
+
.evidence-columns h3 {
|
| 1000 |
+
margin-bottom: 8px;
|
| 1001 |
+
font-size: 13px;
|
| 1002 |
+
}
|
| 1003 |
+
.scenario-list {
|
| 1004 |
+
display: grid;
|
| 1005 |
+
gap: 8px;
|
| 1006 |
+
}
|
| 1007 |
+
.scenario-list div {
|
| 1008 |
+
border: 1px solid var(--line);
|
| 1009 |
+
border-radius: 6px;
|
| 1010 |
+
padding: 10px;
|
| 1011 |
+
background: #070a0f;
|
| 1012 |
+
}
|
| 1013 |
+
.scenario-list strong {
|
| 1014 |
+
display: block;
|
| 1015 |
+
margin-bottom: 5px;
|
| 1016 |
+
color: var(--text);
|
| 1017 |
+
}
|
| 1018 |
+
.scenario-list span {
|
| 1019 |
+
display: block;
|
| 1020 |
+
color: var(--muted);
|
| 1021 |
+
font-size: 12px;
|
| 1022 |
+
line-height: 1.4;
|
| 1023 |
+
}
|
| 1024 |
|
| 1025 |
.control-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
| 1026 |
.button { border: 1px solid var(--line); background: #101722; color: var(--text); border-radius: 6px; padding: 9px 12px; font-weight: 900; cursor: pointer; }
|
|
|
|
| 1058 |
.system-card { position: static; margin-top: 14px; }
|
| 1059 |
.page-header { align-items: flex-start; flex-direction: column; }
|
| 1060 |
.header-actions { justify-content: flex-start; }
|
| 1061 |
+
.hero, .grid-2, .grid-3, .grid-4, .live-grid, .realtime-strip, .deployment-banner, .diagnostic-grid, .instrument-card, .market-strip, .market-strip.compact, .brain-hero, .scenario-grid, .evidence-grid, .method-grid, .event-graph, .financial-brain-grid, .quality-lists, .macro-list, .executive-hero, .executive-command, .executive-grid, .narrative-grid, .sentiment-columns, .opportunity-card, .score-stack, .allocation-list div, .brain-status-grid, .brain-two-col, .chart-snapshot-panel .brain-status-grid, .key-level-grid, .evidence-columns { grid-template-columns: 1fr; }
|
| 1062 |
.tape-row { grid-template-columns: 1fr; }
|
| 1063 |
.tape-meta { justify-content: flex-start; max-width: none; }
|
| 1064 |
}
|
frontend/app/methodology/page.tsx
CHANGED
|
@@ -78,6 +78,7 @@ const sections = [
|
|
| 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: "Self-learning financial memory", body: "Blum evaluates signal outcomes after 1D, 3D, 7D, 14D and 30D, then stores accuracy memory, confidence adjustments, source reliability and reversible model weight versions. It updates parameters, not source code, and never executes trades." },
|
|
|
|
| 81 |
{ 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." },
|
| 82 |
{ 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." },
|
| 83 |
{ 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." },
|
|
|
|
| 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: "Self-learning financial memory", body: "Blum evaluates signal outcomes after 1D, 3D, 7D, 14D and 30D, then stores accuracy memory, confidence adjustments, source reliability and reversible model weight versions. It updates parameters, not source code, and never executes trades." },
|
| 81 |
+
{ title: "Chart Vision Technical Analyst", body: "Chart analysis combines deterministic OHLCV calculations with optional Qwen3-VL visual interpretation and InternVL3 fallback. If vision is unavailable, Blum clearly switches to deterministic technical analysis and does not invent visual levels." },
|
| 82 |
{ 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." },
|
| 83 |
{ 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." },
|
| 84 |
{ 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." },
|
frontend/components/AppShell.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import Link from "next/link";
|
| 4 |
import { usePathname } from "next/navigation";
|
| 5 |
import { useEffect, useState } from "react";
|
| 6 |
-
import { BarChart3, Brain, Cpu, Database, Gauge, Network, Radar, Search, ShieldAlert, type LucideIcon } from "lucide-react";
|
| 7 |
import clsx from "clsx";
|
| 8 |
import { api } from "@/lib/api";
|
| 9 |
import { SystemStatus } from "@/lib/types";
|
|
@@ -19,6 +19,7 @@ const nav: NavItem[] = [
|
|
| 19 |
{ href: "/", label: "Dashboard", icon: Gauge, aliases: ["/dashboard"] },
|
| 20 |
{ href: "/market-brain", label: "Market Brain", icon: Cpu },
|
| 21 |
{ href: "/stock-radar", label: "Radar", icon: Radar, aliases: ["/etf-radar", "/ipo-radar", "/assets"] },
|
|
|
|
| 22 |
{ href: "/themes", label: "Themes", icon: Network },
|
| 23 |
{ href: "/signal-lab", label: "Signal Lab", icon: Search },
|
| 24 |
{ href: "/methodology", label: "Methodology", icon: Brain }
|
|
|
|
| 3 |
import Link from "next/link";
|
| 4 |
import { usePathname } from "next/navigation";
|
| 5 |
import { useEffect, useState } from "react";
|
| 6 |
+
import { BarChart3, Brain, Cpu, Database, Gauge, LineChart, Network, Radar, Search, ShieldAlert, type LucideIcon } from "lucide-react";
|
| 7 |
import clsx from "clsx";
|
| 8 |
import { api } from "@/lib/api";
|
| 9 |
import { SystemStatus } from "@/lib/types";
|
|
|
|
| 19 |
{ href: "/", label: "Dashboard", icon: Gauge, aliases: ["/dashboard"] },
|
| 20 |
{ href: "/market-brain", label: "Market Brain", icon: Cpu },
|
| 21 |
{ href: "/stock-radar", label: "Radar", icon: Radar, aliases: ["/etf-radar", "/ipo-radar", "/assets"] },
|
| 22 |
+
{ href: "/chart-analyst", label: "Chart Analyst", icon: LineChart },
|
| 23 |
{ href: "/themes", label: "Themes", icon: Network },
|
| 24 |
{ href: "/signal-lab", label: "Signal Lab", icon: Search },
|
| 25 |
{ href: "/methodology", label: "Methodology", icon: Brain }
|
frontend/components/ChartAnalystPanel.tsx
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useMemo, useState } from "react";
|
| 4 |
+
import { api } from "@/lib/api";
|
| 5 |
+
import { ChartReport, PricePoint } from "@/lib/types";
|
| 6 |
+
import { PlotPanel } from "./PlotPanel";
|
| 7 |
+
|
| 8 |
+
const TIMEFRAMES = ["1D", "5D", "1M", "3M", "6M", "YTD", "1Y", "5Y"];
|
| 9 |
+
|
| 10 |
+
export function ChartAnalystPanel({ ticker, prices = [] }: { ticker: string; prices?: PricePoint[] }) {
|
| 11 |
+
const [timeframe, setTimeframe] = useState("6M");
|
| 12 |
+
const [report, setReport] = useState<ChartReport | null>(null);
|
| 13 |
+
const [busy, setBusy] = useState(false);
|
| 14 |
+
const [error, setError] = useState("");
|
| 15 |
+
|
| 16 |
+
const loadReport = async (tf = timeframe) => {
|
| 17 |
+
setError("");
|
| 18 |
+
try {
|
| 19 |
+
setReport(await api.chartTechnicalReport(ticker, tf));
|
| 20 |
+
} catch (err) {
|
| 21 |
+
setError((err as Error).message);
|
| 22 |
+
}
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
loadReport(timeframe);
|
| 27 |
+
}, [ticker, timeframe]);
|
| 28 |
+
|
| 29 |
+
const analyze = async () => {
|
| 30 |
+
setBusy(true);
|
| 31 |
+
setError("");
|
| 32 |
+
try {
|
| 33 |
+
setReport(await api.chartAnalyzeTicker(ticker, timeframe, periodFor(timeframe), false));
|
| 34 |
+
} catch (err) {
|
| 35 |
+
setError((err as Error).message);
|
| 36 |
+
} finally {
|
| 37 |
+
setBusy(false);
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const chartPrices = useMemo(() => trimPrices(report?.price_series?.length ? report.price_series : prices, timeframe), [report, prices, timeframe]);
|
| 42 |
+
const hybrid = (report?.hybrid_analysis ?? {}) as ChartReport["hybrid_analysis"];
|
| 43 |
+
const deterministic = (report?.deterministic_analysis ?? {}) as Record<string, any>;
|
| 44 |
+
const levels = hybrid.key_levels ?? {};
|
| 45 |
+
const visual = report?.visual_analysis ?? {};
|
| 46 |
+
const plot = chartPlot(chartPrices, levels);
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<section className="chart-analyst-shell">
|
| 50 |
+
<div className="page-header compact-header">
|
| 51 |
+
<div>
|
| 52 |
+
<div className="kicker">Chart Vision Technical Analyst</div>
|
| 53 |
+
<h2>{ticker} technical chart intelligence.</h2>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="header-actions">
|
| 56 |
+
<select className="input" value={timeframe} onChange={(event) => setTimeframe(event.target.value)}>
|
| 57 |
+
{TIMEFRAMES.map((item) => <option key={item}>{item}</option>)}
|
| 58 |
+
</select>
|
| 59 |
+
<button className="button primary" onClick={analyze} disabled={busy}>{busy ? "Analyzing..." : "Analyze chart"}</button>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
{error && <div className="empty-state" style={{ marginBottom: 12 }}>Chart API warning: {error}</div>}
|
| 64 |
+
|
| 65 |
+
<section className="grid-2">
|
| 66 |
+
<PlotPanel
|
| 67 |
+
title="Price Structure With Key Levels"
|
| 68 |
+
data={plot.data}
|
| 69 |
+
layout={plot.layout}
|
| 70 |
+
height={430}
|
| 71 |
+
emptyMessage="Waiting for stored OHLCV data to render the chart."
|
| 72 |
+
/>
|
| 73 |
+
<TechnicalSnapshot report={report} />
|
| 74 |
+
</section>
|
| 75 |
+
|
| 76 |
+
<section className="grid-2" style={{ marginTop: 12 }}>
|
| 77 |
+
<KeyLevels levels={levels} />
|
| 78 |
+
<SignalEvidence hybrid={hybrid} />
|
| 79 |
+
</section>
|
| 80 |
+
|
| 81 |
+
<section className="grid-2" style={{ marginTop: 12 }}>
|
| 82 |
+
<AnalystReport hybrid={hybrid} visual={visual} deterministic={deterministic} />
|
| 83 |
+
<HistoricalSimilarity similarity={hybrid.historical_similarity ?? {}} />
|
| 84 |
+
</section>
|
| 85 |
+
</section>
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function TechnicalSnapshot({ report }: { report: ChartReport | null }) {
|
| 90 |
+
const d = report?.deterministic_analysis ?? {};
|
| 91 |
+
const h = (report?.hybrid_analysis ?? {}) as ChartReport["hybrid_analysis"];
|
| 92 |
+
const momentum = d.momentum ?? {};
|
| 93 |
+
const volatility = d.volatility ?? {};
|
| 94 |
+
const volume = d.volume ?? {};
|
| 95 |
+
const pattern = d.patterns?.[0]?.pattern ?? "pending";
|
| 96 |
+
return (
|
| 97 |
+
<div className="panel chart-snapshot-panel">
|
| 98 |
+
<div className="panel-head">
|
| 99 |
+
<span>Technical Snapshot</span>
|
| 100 |
+
<strong>{Number(h.confidence_score ?? report?.confidence ?? 0).toFixed(1)} confidence</strong>
|
| 101 |
+
</div>
|
| 102 |
+
<div className="brain-status-grid">
|
| 103 |
+
<SnapshotMetric label="Trend" value={String(d.trend_direction ?? "pending").replaceAll("_", " ")} />
|
| 104 |
+
<SnapshotMetric label="Momentum" value={String(momentum.state ?? "pending").replaceAll("_", " ")} />
|
| 105 |
+
<SnapshotMetric label="Volatility" value={String(volatility.regime ?? "pending").replaceAll("_", " ")} />
|
| 106 |
+
<SnapshotMetric label="Volume" value={String(volume.volume_state ?? "pending").replaceAll("_", " ")} />
|
| 107 |
+
<SnapshotMetric label="Pattern" value={String(pattern).replaceAll("_", " ")} />
|
| 108 |
+
<SnapshotMetric label="Breakout" value={`${Number(d.breakout_probability?.score ?? 0).toFixed(1)}/100`} />
|
| 109 |
+
</div>
|
| 110 |
+
<div className="brain-drift info">
|
| 111 |
+
<strong>{String(h.timeframe_relevance ?? "timeframe pending").replaceAll("_", " ")}</strong>
|
| 112 |
+
<span>{h.trend_summary ?? "Run chart analysis to produce a hybrid technical summary."}</span>
|
| 113 |
+
</div>
|
| 114 |
+
<p>{(report?.warnings ?? h.warnings)?.join(" ") ?? "Vision model status and deterministic analysis warnings will appear here."}</p>
|
| 115 |
+
</div>
|
| 116 |
+
);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function KeyLevels({ levels }: { levels: Record<string, any> }) {
|
| 120 |
+
return (
|
| 121 |
+
<div className="panel">
|
| 122 |
+
<div className="panel-head"><span>Key Levels</span><strong>zones, not guarantees</strong></div>
|
| 123 |
+
<div className="key-level-grid">
|
| 124 |
+
<Level label="Support 1" value={levels.support_1} />
|
| 125 |
+
<Level label="Support 2" value={levels.support_2} />
|
| 126 |
+
<Level label="Resistance 1" value={levels.resistance_1} />
|
| 127 |
+
<Level label="Resistance 2" value={levels.resistance_2} />
|
| 128 |
+
<Level label="Breakout" value={levels.breakout_level} />
|
| 129 |
+
<Level label="Breakdown" value={levels.breakdown_level} />
|
| 130 |
+
<Level label="Invalidation" value={levels.invalidation_level} />
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function SignalEvidence({ hybrid }: { hybrid: any }) {
|
| 137 |
+
return (
|
| 138 |
+
<div className="panel">
|
| 139 |
+
<div className="panel-head"><span>Signal Evidence</span><strong>{hybrid.technical_signals?.length ?? 0} signals</strong></div>
|
| 140 |
+
<div className="evidence-columns">
|
| 141 |
+
<EvidenceList title="Bullish Evidence" rows={hybrid.bullish_evidence ?? []} />
|
| 142 |
+
<EvidenceList title="Bearish Evidence" rows={hybrid.bearish_evidence ?? []} />
|
| 143 |
+
<EvidenceList title="Neutral / Uncertain" rows={hybrid.neutral_evidence ?? []} />
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function AnalystReport({ hybrid, visual, deterministic }: { hybrid: any; visual: Record<string, any>; deterministic: Record<string, any> }) {
|
| 150 |
+
return (
|
| 151 |
+
<div className="panel">
|
| 152 |
+
<div className="panel-head">
|
| 153 |
+
<span>Analyst Report</span>
|
| 154 |
+
<strong>{String(visual.mode ?? "deterministic").replaceAll("_", " ")}</strong>
|
| 155 |
+
</div>
|
| 156 |
+
<p>{hybrid.analyst_report ?? deterministic.technical_summary ?? "Run chart analysis to produce the analyst report."}</p>
|
| 157 |
+
<div className="scenario-list">
|
| 158 |
+
{(hybrid.possible_scenarios ?? []).map((scenario: any) => (
|
| 159 |
+
<div key={scenario.name}>
|
| 160 |
+
<strong>{scenario.name}</strong>
|
| 161 |
+
<span>{scenario.condition}</span>
|
| 162 |
+
<span>{scenario.impact}</span>
|
| 163 |
+
</div>
|
| 164 |
+
))}
|
| 165 |
+
</div>
|
| 166 |
+
<div className="learning-event-list" style={{ marginTop: 10 }}>
|
| 167 |
+
{(hybrid.what_to_watch_next ?? []).slice(0, 6).map((item: string) => <span key={item}>{item}</span>)}
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function HistoricalSimilarity({ similarity }: { similarity: Record<string, any> }) {
|
| 174 |
+
return (
|
| 175 |
+
<div className="panel">
|
| 176 |
+
<div className="panel-head">
|
| 177 |
+
<span>Historical Similarity</span>
|
| 178 |
+
<strong>{similarity.reliability_score ? `${Number(similarity.reliability_score).toFixed(1)} reliability` : "maturing"}</strong>
|
| 179 |
+
</div>
|
| 180 |
+
<div className="brain-status-grid">
|
| 181 |
+
<SnapshotMetric label="Similar Setups" value={similarity.similar_chart_setups ?? 0} />
|
| 182 |
+
<SnapshotMetric label="Mature Cases" value={similarity.mature_cases ?? 0} />
|
| 183 |
+
<SnapshotMetric label="7D Avg Return" value={formatPercent(similarity.average_forward_return_7d)} />
|
| 184 |
+
<SnapshotMetric label="Success Rate" value={ratio(similarity.success_rate)} />
|
| 185 |
+
</div>
|
| 186 |
+
<p>{similarity.explanation ?? "Chart pattern memory will mature as future OHLCV outcomes are observed."}</p>
|
| 187 |
+
</div>
|
| 188 |
+
);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
function SnapshotMetric({ label, value }: { label: string; value: string | number }) {
|
| 192 |
+
return <div className="brain-metric"><span>{label}</span><strong>{value}</strong></div>;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function Level({ label, value }: { label: string; value: any }) {
|
| 196 |
+
return <div><span>{label}</span><strong>{formatLevel(value)}</strong></div>;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function EvidenceList({ title, rows }: { title: string; rows: string[] }) {
|
| 200 |
+
return (
|
| 201 |
+
<div>
|
| 202 |
+
<h3>{title}</h3>
|
| 203 |
+
<div className="learning-event-list">
|
| 204 |
+
{rows.length ? rows.slice(0, 6).map((item) => <span key={item}>{item}</span>) : <span>No confirmed evidence yet.</span>}
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function chartPlot(prices: PricePoint[], levels: Record<string, any>) {
|
| 211 |
+
const x = prices.map((point) => point.date);
|
| 212 |
+
const shapes = levelShapes(levels, x);
|
| 213 |
+
return {
|
| 214 |
+
data: [
|
| 215 |
+
{
|
| 216 |
+
type: "candlestick",
|
| 217 |
+
x,
|
| 218 |
+
open: prices.map((point) => point.open ?? point.close),
|
| 219 |
+
high: prices.map((point) => point.high ?? point.close),
|
| 220 |
+
low: prices.map((point) => point.low ?? point.close),
|
| 221 |
+
close: prices.map((point) => point.close),
|
| 222 |
+
increasing: { line: { color: "#20e070" } },
|
| 223 |
+
decreasing: { line: { color: "#ff4d5e" } },
|
| 224 |
+
name: "OHLC"
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
type: "bar",
|
| 228 |
+
x,
|
| 229 |
+
y: prices.map((point) => point.volume ?? 0),
|
| 230 |
+
yaxis: "y2",
|
| 231 |
+
marker: { color: "rgba(77,216,255,.24)" },
|
| 232 |
+
name: "Volume"
|
| 233 |
+
}
|
| 234 |
+
],
|
| 235 |
+
layout: {
|
| 236 |
+
shapes,
|
| 237 |
+
xaxis: { rangeslider: { visible: false }, gridcolor: "rgba(255,255,255,.06)" },
|
| 238 |
+
yaxis: { domain: [0.24, 1], gridcolor: "rgba(255,255,255,.06)" },
|
| 239 |
+
yaxis2: { domain: [0, 0.17], gridcolor: "rgba(255,255,255,.04)" },
|
| 240 |
+
showlegend: false
|
| 241 |
+
}
|
| 242 |
+
};
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
function levelShapes(levels: Record<string, any>, x: string[]) {
|
| 246 |
+
if (!x.length) return [];
|
| 247 |
+
const x0 = x[0];
|
| 248 |
+
const x1 = x[x.length - 1];
|
| 249 |
+
const candidates = [
|
| 250 |
+
{ value: levels.support_1, color: "#20e070" },
|
| 251 |
+
{ value: levels.support_2, color: "#20e070" },
|
| 252 |
+
{ value: levels.resistance_1, color: "#ff4d5e" },
|
| 253 |
+
{ value: levels.resistance_2, color: "#ff4d5e" },
|
| 254 |
+
{ value: levels.invalidation_level, color: "#ffb000" }
|
| 255 |
+
];
|
| 256 |
+
return candidates.filter((item) => item.value).map((item) => ({
|
| 257 |
+
type: "line",
|
| 258 |
+
xref: "x",
|
| 259 |
+
yref: "y",
|
| 260 |
+
x0,
|
| 261 |
+
x1,
|
| 262 |
+
y0: Number(item.value),
|
| 263 |
+
y1: Number(item.value),
|
| 264 |
+
line: { color: item.color, width: 1, dash: "dot" }
|
| 265 |
+
}));
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
function trimPrices(prices: PricePoint[], timeframe: string) {
|
| 269 |
+
const limits: Record<string, number> = { "1D": 96, "5D": 120, "1M": 42, "3M": 84, "6M": 150, "YTD": 260, "1Y": 260, "5Y": 1260 };
|
| 270 |
+
return prices.slice(-(limits[timeframe] ?? 150));
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
function periodFor(timeframe: string) {
|
| 274 |
+
const periods: Record<string, string> = { "1D": "5d", "5D": "1mo", "1M": "3mo", "3M": "6mo", "6M": "1y", "YTD": "1y", "1Y": "2y", "5Y": "5y" };
|
| 275 |
+
return periods[timeframe] ?? "1y";
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function formatLevel(value: any) {
|
| 279 |
+
if (value === null || value === undefined || Number.isNaN(Number(value))) return "n/a";
|
| 280 |
+
return Number(value).toFixed(2);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
function formatPercent(value: any) {
|
| 284 |
+
if (value === null || value === undefined || Number.isNaN(Number(value))) return "Pending";
|
| 285 |
+
return `${Number(value).toFixed(2)}%`;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
function ratio(value: any) {
|
| 289 |
+
if (value === null || value === undefined || Number.isNaN(Number(value))) return "Pending";
|
| 290 |
+
return `${Math.round(Number(value) * 100)}%`;
|
| 291 |
+
}
|
frontend/lib/api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { AccuracyOverview, AccuracyProfile, Asset, BrainAccuracy, BrainAssetMemory, BrainEvaluation, BrainStatus, CommunitySentimentPayload, DashboardOverview, DataCoverage, ExecutiveDashboardPayload, IPORadar, LiveNewsArticle, MacroOverview, MarketBrain, MarketBrainHistoryRow, MarketNarrativePayload, MarketSentiment, OpportunityRadarPayload, PipelineStatus, PortfolioScenarioPayload, RelatedNews, Signal, SignalValidationReport, StockRadar, SystemStatus, WatchlistPayload } from "./types";
|
| 2 |
|
| 3 |
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
|
| 4 |
|
|
@@ -43,6 +43,11 @@ export const api = {
|
|
| 43 |
evaluateBrainSignals: (limit = 240) => postJson<any>(`/brain/evaluate-signals?limit=${limit}`, {}),
|
| 44 |
recalculateBrainWeights: () => postJson<any>("/brain/recalculate-weights", {}),
|
| 45 |
runLearningCycle: (limit = 240) => postJson<any>(`/brain/run-learning-cycle?limit=${limit}`, {}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
assets: () => getJson<Asset[]>("/assets"),
|
| 47 |
asset: (ticker: string) => getJson<{ asset: Asset; market_snapshot: Asset["market_snapshot"]; prices: any[]; latest_signal: Signal | null; related_news: RelatedNews[] }>(`/assets/${encodeURIComponent(ticker)}`),
|
| 48 |
overview: () => getJson<DashboardOverview>("/dashboard/overview"),
|
|
@@ -92,3 +97,7 @@ export const api = {
|
|
| 92 |
runPipeline: () => postJson<any>("/pipeline/run", { refresh_prices: false, limit: 36 }),
|
| 93 |
backtest: (ticker: string) => postJson<any>(`/backtest/${ticker}`, {})
|
| 94 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AccuracyOverview, AccuracyProfile, Asset, BrainAccuracy, BrainAssetMemory, BrainEvaluation, BrainStatus, ChartReport, CommunitySentimentPayload, DashboardOverview, DataCoverage, ExecutiveDashboardPayload, IPORadar, LiveNewsArticle, MacroOverview, MarketBrain, MarketBrainHistoryRow, MarketNarrativePayload, MarketSentiment, OpportunityRadarPayload, PipelineStatus, PortfolioScenarioPayload, RelatedNews, Signal, SignalValidationReport, StockRadar, SystemStatus, WatchlistPayload } from "./types";
|
| 2 |
|
| 3 |
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
|
| 4 |
|
|
|
|
| 43 |
evaluateBrainSignals: (limit = 240) => postJson<any>(`/brain/evaluate-signals?limit=${limit}`, {}),
|
| 44 |
recalculateBrainWeights: () => postJson<any>("/brain/recalculate-weights", {}),
|
| 45 |
runLearningCycle: (limit = 240) => postJson<any>(`/brain/run-learning-cycle?limit=${limit}`, {}),
|
| 46 |
+
chartAnalyzeTicker: (ticker: string, timeframe = "6M", period = "1y", includeVisual = false) => getPostChart<ChartReport>(`/chart/analyze-ticker?ticker=${encodeURIComponent(ticker)}&timeframe=${encodeURIComponent(timeframe)}&period=${encodeURIComponent(period)}&include_visual=${includeVisual ? "true" : "false"}`),
|
| 47 |
+
chartTechnicalReport: (ticker: string, timeframe = "6M") => getJson<ChartReport>(`/chart/technical-report/${encodeURIComponent(ticker)}?timeframe=${encodeURIComponent(timeframe)}`),
|
| 48 |
+
chartLevels: (ticker: string, timeframe = "6M") => getJson<any>(`/chart/levels/${encodeURIComponent(ticker)}?timeframe=${encodeURIComponent(timeframe)}`),
|
| 49 |
+
chartSignals: (ticker: string, timeframe = "6M") => getJson<any[]>(`/chart/signals/${encodeURIComponent(ticker)}?timeframe=${encodeURIComponent(timeframe)}`),
|
| 50 |
+
chartHistory: (ticker: string, limit = 30) => getJson<any[]>(`/chart/history/${encodeURIComponent(ticker)}?limit=${limit}`),
|
| 51 |
assets: () => getJson<Asset[]>("/assets"),
|
| 52 |
asset: (ticker: string) => getJson<{ asset: Asset; market_snapshot: Asset["market_snapshot"]; prices: any[]; latest_signal: Signal | null; related_news: RelatedNews[] }>(`/assets/${encodeURIComponent(ticker)}`),
|
| 53 |
overview: () => getJson<DashboardOverview>("/dashboard/overview"),
|
|
|
|
| 97 |
runPipeline: () => postJson<any>("/pipeline/run", { refresh_prices: false, limit: 36 }),
|
| 98 |
backtest: (ticker: string) => postJson<any>(`/backtest/${ticker}`, {})
|
| 99 |
};
|
| 100 |
+
|
| 101 |
+
function getPostChart<T>(path: string): Promise<T> {
|
| 102 |
+
return postJson<T>(path, {});
|
| 103 |
+
}
|
frontend/lib/types.ts
CHANGED
|
@@ -261,6 +261,42 @@ export type BrainAccuracy = {
|
|
| 261 |
disclaimer: string;
|
| 262 |
};
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
export type Signal = {
|
| 265 |
ticker: string;
|
| 266 |
classification: string;
|
|
|
|
| 261 |
disclaimer: string;
|
| 262 |
};
|
| 263 |
|
| 264 |
+
export type ChartReport = {
|
| 265 |
+
id?: number;
|
| 266 |
+
ticker: string;
|
| 267 |
+
timeframe: string;
|
| 268 |
+
period?: string;
|
| 269 |
+
chart_image?: string | null;
|
| 270 |
+
price_series?: PricePoint[];
|
| 271 |
+
visual_analysis: Record<string, any>;
|
| 272 |
+
deterministic_analysis: Record<string, any>;
|
| 273 |
+
hybrid_analysis: {
|
| 274 |
+
trend_summary?: string;
|
| 275 |
+
key_levels?: Record<string, number | null>;
|
| 276 |
+
technical_signals?: Array<Record<string, any>>;
|
| 277 |
+
confirmation_signals?: string[];
|
| 278 |
+
contradiction_signals?: string[];
|
| 279 |
+
invalidation_level?: number | null;
|
| 280 |
+
risk_zone?: Record<string, any>;
|
| 281 |
+
opportunity_zone?: Record<string, any>;
|
| 282 |
+
confidence_score?: number;
|
| 283 |
+
timeframe_relevance?: string;
|
| 284 |
+
possible_scenarios?: Array<Record<string, any>>;
|
| 285 |
+
what_to_watch_next?: string[];
|
| 286 |
+
bullish_evidence?: string[];
|
| 287 |
+
bearish_evidence?: string[];
|
| 288 |
+
neutral_evidence?: string[];
|
| 289 |
+
analyst_report?: string;
|
| 290 |
+
historical_similarity?: Record<string, any>;
|
| 291 |
+
warnings?: string[];
|
| 292 |
+
disclaimer?: string;
|
| 293 |
+
};
|
| 294 |
+
confidence: number;
|
| 295 |
+
warnings?: string[];
|
| 296 |
+
created_at?: string;
|
| 297 |
+
disclaimer: string;
|
| 298 |
+
};
|
| 299 |
+
|
| 300 |
export type Signal = {
|
| 301 |
ticker: string;
|
| 302 |
classification: string;
|
frontend/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "blum-ai-financial-intelligence-frontend",
|
| 3 |
-
"version": "0.5.
|
| 4 |
"private": true,
|
| 5 |
"scripts": {
|
| 6 |
"dev": "next dev -p 3000",
|
|
|
|
| 1 |
{
|
| 2 |
"name": "blum-ai-financial-intelligence-frontend",
|
| 3 |
+
"version": "0.5.8",
|
| 4 |
"private": true,
|
| 5 |
"scripts": {
|
| 6 |
"dev": "next dev -p 3000",
|
package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "blum-ai-financial-intelligence",
|
| 3 |
-
"version": "0.5.
|
| 4 |
"private": true,
|
| 5 |
"scripts": {
|
| 6 |
"frontend:dev": "npm --prefix frontend run dev",
|
|
|
|
| 1 |
{
|
| 2 |
"name": "blum-ai-financial-intelligence",
|
| 3 |
+
"version": "0.5.8",
|
| 4 |
"private": true,
|
| 5 |
"scripts": {
|
| 6 |
"frontend:dev": "npm --prefix frontend run dev",
|
requirements.txt
CHANGED
|
@@ -25,5 +25,6 @@ scikit-learn==1.6.0
|
|
| 25 |
statsmodels==0.14.4
|
| 26 |
pydantic==2.10.4
|
| 27 |
pydantic-settings==2.7.0
|
|
|
|
| 28 |
apscheduler==3.11.0
|
| 29 |
redis==5.2.1
|
|
|
|
| 25 |
statsmodels==0.14.4
|
| 26 |
pydantic==2.10.4
|
| 27 |
pydantic-settings==2.7.0
|
| 28 |
+
python-multipart==0.0.20
|
| 29 |
apscheduler==3.11.0
|
| 30 |
redis==5.2.1
|