Spaces:
Sleeping
Sleeping
Abhijeet Mahapatra commited on
Commit ·
35abc74
1
Parent(s): c8c7ac4
First commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +13 -0
- README.md +661 -1
- frontend/app.js +849 -0
- frontend/index.html +269 -0
- frontend/style.css +711 -0
- server.py +1036 -0
- singular_ticker_causal/.env +3 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/cuts_plus.py +834 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/data/__init__.py +0 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/data/generate_data_mod.py +430 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/data/simu_data.py +313 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/data/utils.py +136 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/lagged_graph.py +95 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/model/cuts_plus_net.py +153 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/causal_plot.py +57 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/exp_utils.py +286 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/gumbel_softmax.py +66 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/logger.py +74 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/misc.py +292 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/opt_type.py +83 -0
- singular_ticker_causal/algorithms/__init__.py +2 -0
- singular_ticker_causal/causal_inference/__init__.py +15 -0
- singular_ticker_causal/causal_inference/abduction.py +127 -0
- singular_ticker_causal/causal_inference/causal_model.py +314 -0
- singular_ticker_causal/causal_inference/estimator.py +125 -0
- singular_ticker_causal/causal_inference/identification.py +41 -0
- singular_ticker_causal/causal_inference/mutilator.py +62 -0
- singular_ticker_causal/causal_inference/pywhyllm_assumptions.py +338 -0
- singular_ticker_causal/causal_inference/query_engine.py +505 -0
- singular_ticker_causal/causal_inference/tests/test_causal_queries.py +102 -0
- singular_ticker_causal/causal_inference/tests/test_pywhyllm_assumptions.py +193 -0
- singular_ticker_causal/data_sources/__init__.py +4 -0
- singular_ticker_causal/data_sources/bsedata/__init__.py +27 -0
- singular_ticker_causal/data_sources/bsedata/bhavcopy.py +58 -0
- singular_ticker_causal/data_sources/bsedata/bse.py +150 -0
- singular_ticker_causal/data_sources/bsedata/exceptions.py +51 -0
- singular_ticker_causal/data_sources/bsedata/gainers.py +57 -0
- singular_ticker_causal/data_sources/bsedata/helpers.py +3 -0
- singular_ticker_causal/data_sources/bsedata/indices.py +112 -0
- singular_ticker_causal/data_sources/bsedata/losers.py +57 -0
- singular_ticker_causal/data_sources/bsedata/quote.py +176 -0
- singular_ticker_causal/data_sources/fetcher.py +219 -0
- singular_ticker_causal/data_sources/gdelt_client.py +400 -0
- singular_ticker_causal/data_sources/news_client.py +460 -0
- singular_ticker_causal/data_sources/nseconnect/__init__.py +25 -0
- singular_ticker_causal/data_sources/nseconnect/bases.py +72 -0
- singular_ticker_causal/data_sources/nseconnect/cleaners.py +51 -0
- singular_ticker_causal/data_sources/nseconnect/datemgr.py +104 -0
- singular_ticker_causal/data_sources/nseconnect/downloader.py +116 -0
- singular_ticker_causal/data_sources/nseconnect/errors.py +8 -0
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*__pycache__/
|
| 2 |
+
|
| 3 |
+
*.md
|
| 4 |
+
!README.md
|
| 5 |
+
*.log
|
| 6 |
+
*.npy
|
| 7 |
+
*.csv
|
| 8 |
+
*.png
|
| 9 |
+
*.grd
|
| 10 |
+
*.owl
|
| 11 |
+
*.out
|
| 12 |
+
*.json
|
| 13 |
+
*.yaml
|
README.md
CHANGED
|
@@ -1 +1,661 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Iroha - Financial Intelligence Pipeline API
|
| 3 |
+
colorFrom: blue
|
| 4 |
+
colorTo: indigo
|
| 5 |
+
sdk: gradio
|
| 6 |
+
sdk_version: 6.18.0
|
| 7 |
+
app_file: server.py
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Iroha - Financial Intelligence Pipeline API
|
| 12 |
+
|
| 13 |
+
**Version:** 1.0.0
|
| 14 |
+
**Description:** End-to-end financial intelligence pipeline for Net-of-Tax Alpha decisions using causal chain analysis, web scraping, and data aggregation.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## Table of Contents
|
| 19 |
+
|
| 20 |
+
1. [Overview](#overview)
|
| 21 |
+
2. [Authentication](#authentication)
|
| 22 |
+
3. [API Endpoints](#api-endpoints)
|
| 23 |
+
- [Server Endpoints](#server-endpoints)
|
| 24 |
+
- [V2 API Endpoints](#v2-api-endpoints)
|
| 25 |
+
4. [Core Functions](#core-functions)
|
| 26 |
+
5. [Supported Tickers](#supported-tickers)
|
| 27 |
+
6. [Error Handling](#error-handling)
|
| 28 |
+
7. [Examples](#examples)
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Overview
|
| 33 |
+
|
| 34 |
+
The Noisy Boy API is a FastAPI-based financial intelligence system that:
|
| 35 |
+
|
| 36 |
+
- **Fetches multi-source financial data** from various Indian market sources (BSE, NSE, RBI, SEBI, etc.)
|
| 37 |
+
- **Analyzes causal chains** between market events and price movements using BERT and embedding models
|
| 38 |
+
- **Generates aggregated text** from diverse data sources for each ticker
|
| 39 |
+
- **Builds knowledge graphs** with semantic and evidence layers
|
| 40 |
+
- **Streams real-time SSE responses** for long-running analysis tasks
|
| 41 |
+
|
| 42 |
+
### Technology Stack
|
| 43 |
+
|
| 44 |
+
- **Framework:** FastAPI with async/await support
|
| 45 |
+
- **Database:** SQLite (local) + Supabase (cloud)
|
| 46 |
+
- **Models:**
|
| 47 |
+
- BERT-CAUSE-EFFECT for causal relationship extraction
|
| 48 |
+
- Fireworks AI embeddings for similarity scoring
|
| 49 |
+
- OpenAI LLM for graph summarization
|
| 50 |
+
- TinyFish AI for web automation
|
| 51 |
+
- **Data Sources:** 50+ fetchers covering macro, regulatory, and company-specific data
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## Authentication
|
| 56 |
+
|
| 57 |
+
### API Key Authentication
|
| 58 |
+
|
| 59 |
+
All endpoints (except `/api/source-refs`) require an `X-API-Key` header:
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
curl -H "X-API-Key: your-secret-token" https://api.example.com/api/endpoint
|
| 63 |
+
curl -H "X-API-Key: your-api-key" http://localhost:8000/v2/api/endpoint
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
**Environment Variable**: `API_KEY` (default: `secret-token`)
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## Base URL
|
| 71 |
+
|
| 72 |
+
```
|
| 73 |
+
http://localhost:8000/v2
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## API Endpoints
|
| 79 |
+
|
| 80 |
+
### Scraper API
|
| 81 |
+
|
| 82 |
+
#### 1. GET `/api/source-refs`
|
| 83 |
+
Returns all fetcher-to-source-URL mappings and reference links.
|
| 84 |
+
|
| 85 |
+
**Authentication**: None required
|
| 86 |
+
|
| 87 |
+
**Response**:
|
| 88 |
+
```json
|
| 89 |
+
{
|
| 90 |
+
"fetcher_refs": {
|
| 91 |
+
"repo_rate": "https://rbi.org.in/...",
|
| 92 |
+
"bse": "https://bseindia.com/...",
|
| 93 |
+
...
|
| 94 |
+
},
|
| 95 |
+
"cat_refs": {
|
| 96 |
+
"macro": {...},
|
| 97 |
+
"corporate": {...}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
#### 2. POST `/api/run`
|
| 105 |
+
Streams real-time scraping results via Server-Sent Events (SSE) from TinyFish API with local caching.
|
| 106 |
+
|
| 107 |
+
**Authentication**: Required (`X-API-Key`)
|
| 108 |
+
|
| 109 |
+
**Request Body**:
|
| 110 |
+
```json
|
| 111 |
+
{
|
| 112 |
+
"url": "https://example.com/page",
|
| 113 |
+
"goal": "Extract financial data",
|
| 114 |
+
"ticker": "HDFCBANK",
|
| 115 |
+
"stealth": false
|
| 116 |
+
}
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
**Query Parameters**:
|
| 120 |
+
- `url` (required): Target URL to scrape
|
| 121 |
+
- `goal` (required): Scraping objective description
|
| 122 |
+
- `ticker` (optional): Stock ticker for caching purposes
|
| 123 |
+
- `stealth` (optional, bool): Enable stealth mode
|
| 124 |
+
|
| 125 |
+
**Response** (SSE):
|
| 126 |
+
```
|
| 127 |
+
data: {"type": "STARTED", "run_id": "run_123"}
|
| 128 |
+
data: {"type": "PROGRESS", "purpose": "Fetching data..."}
|
| 129 |
+
data: {"type": "COMPLETE", "status": "COMPLETED", "result_json": {...}}
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
**Caching**: Results are cached per ticker per day via SQLite.
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
#### 3. GET `/api/ticker-data/{ticker}`
|
| 137 |
+
Fetch all cached data for a ticker across all sources (Supabase).
|
| 138 |
+
|
| 139 |
+
**Authentication**: Required (`X-API-Key`)
|
| 140 |
+
|
| 141 |
+
**Path Parameters**:
|
| 142 |
+
- `ticker`: Stock ticker symbol (e.g., `HDFCBANK`)
|
| 143 |
+
|
| 144 |
+
**Response**:
|
| 145 |
+
```json
|
| 146 |
+
{
|
| 147 |
+
"ticker": "HDFCBANK",
|
| 148 |
+
"status": "success",
|
| 149 |
+
"data": {
|
| 150 |
+
"repo_rate": {
|
| 151 |
+
"data": {...},
|
| 152 |
+
"fetched_at": "2024-01-15T10:30:00Z"
|
| 153 |
+
},
|
| 154 |
+
"bse": {
|
| 155 |
+
"data": [...],
|
| 156 |
+
"fetched_at": "2024-01-15T10:30:00Z"
|
| 157 |
+
},
|
| 158 |
+
"aggregated_text": {
|
| 159 |
+
"aggregated_text": "...",
|
| 160 |
+
"fetched_at": "2024-01-15T10:30:00Z"
|
| 161 |
+
},
|
| 162 |
+
"causal_chain": {
|
| 163 |
+
"nodes": [...],
|
| 164 |
+
"links": [...],
|
| 165 |
+
"all_chains": [...],
|
| 166 |
+
"biggest_chain": [...]
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
### Causal Chain API
|
| 175 |
+
|
| 176 |
+
#### 4. GET `/api/generate-causal-chain-stream`
|
| 177 |
+
Streams causal chain generation via SSE (two phases: nodes, then edges).
|
| 178 |
+
|
| 179 |
+
**Authentication**: Required (`X-API-Key`)
|
| 180 |
+
|
| 181 |
+
**Query Parameters**:
|
| 182 |
+
- `ticker` (required): Stock ticker symbol
|
| 183 |
+
|
| 184 |
+
**Response** (SSE - Streaming):
|
| 185 |
+
```
|
| 186 |
+
data: {"type": "status", "message": "Generating text for ticker..."}
|
| 187 |
+
data: {"type": "nodes", "nodes": [{"id": "RBI Rate Hike", "label": "RBI Rate Hike"}, ...]}
|
| 188 |
+
data: {"type": "edges", "links": [{"source": "Rate Hike", "target": "Inflation", "score": 0.89}, ...]}
|
| 189 |
+
data: {"type": "done", "all_chains": [[...], [...]], "biggest_chain": [...]}
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
**Phases**:
|
| 193 |
+
1. **Status**: Initial status message
|
| 194 |
+
2. **Nodes**: Unique causal entities extracted from BERT model
|
| 195 |
+
3. **Edges**: Connections between nodes from embedding similarity
|
| 196 |
+
4. **Done**: Final chains and biggest causal path
|
| 197 |
+
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
### Ontology API
|
| 201 |
+
|
| 202 |
+
#### 5. POST `/api/generate-ontology`
|
| 203 |
+
Generate financial ontology from input text.
|
| 204 |
+
|
| 205 |
+
**Authentication**: Required (via implicit call)
|
| 206 |
+
|
| 207 |
+
**Request Body**:
|
| 208 |
+
```json
|
| 209 |
+
{
|
| 210 |
+
"ticker": "RELIANCE",
|
| 211 |
+
"text": "Recent crude oil rally drives RELIANCE revenue..."
|
| 212 |
+
}
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
**Response**:
|
| 216 |
+
```json
|
| 217 |
+
{
|
| 218 |
+
"status": "success",
|
| 219 |
+
"ontology": {
|
| 220 |
+
"entities": ["crude", "revenue", "RELIANCE"],
|
| 221 |
+
"relations": [{"type": "affects", "from": "crude", "to": "RELIANCE"}]
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
---
|
| 227 |
+
|
| 228 |
+
#### 6. POST `/api/extract-entities`
|
| 229 |
+
Extract named entities using the provided ontology.
|
| 230 |
+
|
| 231 |
+
**Authentication**: Required (via implicit call)
|
| 232 |
+
|
| 233 |
+
**Request Body**:
|
| 234 |
+
```json
|
| 235 |
+
{
|
| 236 |
+
"ticker": "ITC",
|
| 237 |
+
"text": "Coal prices surge amid monsoon fears...",
|
| 238 |
+
"ontology": {
|
| 239 |
+
"entities": ["coal", "monsoon", "price"],
|
| 240 |
+
"relations": [...]
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
**Response**:
|
| 246 |
+
```json
|
| 247 |
+
{
|
| 248 |
+
"status": "success",
|
| 249 |
+
"entities": [
|
| 250 |
+
{"entity": "coal", "type": "commodity", "confidence": 0.92},
|
| 251 |
+
{"entity": "monsoon", "type": "weather_event", "confidence": 0.88}
|
| 252 |
+
]
|
| 253 |
+
}
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
---
|
| 257 |
+
|
| 258 |
+
#### 7. POST `/api/build-knowledge-graph`
|
| 259 |
+
Build complete knowledge graph with nodes, edges, and causal chains.
|
| 260 |
+
|
| 261 |
+
**Authentication**: Required (via implicit call)
|
| 262 |
+
|
| 263 |
+
**Request Body**:
|
| 264 |
+
```json
|
| 265 |
+
{
|
| 266 |
+
"graph_id": "graph_001",
|
| 267 |
+
"ticker": "BHEL",
|
| 268 |
+
"text": "Full financial text for analysis...",
|
| 269 |
+
"financial_results": {...},
|
| 270 |
+
"forensic_results": {...},
|
| 271 |
+
"tech_results": {...},
|
| 272 |
+
"cached_data": {...}
|
| 273 |
+
}
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
**Response**:
|
| 277 |
+
```json
|
| 278 |
+
{
|
| 279 |
+
"meta": {
|
| 280 |
+
"ticker": "BHEL",
|
| 281 |
+
"exchange": "NSE",
|
| 282 |
+
"generated_at": "2024-01-15T12:45:00Z",
|
| 283 |
+
"status": "success",
|
| 284 |
+
"chain_count": 5,
|
| 285 |
+
"node_count": 23,
|
| 286 |
+
"edge_count": 45
|
| 287 |
+
},
|
| 288 |
+
"summary": {
|
| 289 |
+
"narrative": "Coal shortage → Power demand surge → BHEL tariff opportunity",
|
| 290 |
+
"net_sentiment_for_ticker": "bullish",
|
| 291 |
+
"ticker_relevance_score": 0.87,
|
| 292 |
+
"macro_regimes_active": ["coal-supply-shock", "power-demand-rally"],
|
| 293 |
+
"top_causal_nodes": ["Coal shortage", "Power demand", "BHEL capacity"],
|
| 294 |
+
"ria_alert": {
|
| 295 |
+
"level": "medium",
|
| 296 |
+
"reason": "Monitor coal supply for sustained impact on tariff structure."
|
| 297 |
+
}
|
| 298 |
+
},
|
| 299 |
+
"biggest_chain": ["Coal shortage", "Power demand", "BHEL contract wins", "Revenue growth"],
|
| 300 |
+
"all_chains": [[...], [...], ...],
|
| 301 |
+
"nodes": [...],
|
| 302 |
+
"edges": [...]
|
| 303 |
+
}
|
| 304 |
+
```
|
| 305 |
+
|
| 306 |
+
---
|
| 307 |
+
|
| 308 |
+
### Server API (Streaming)
|
| 309 |
+
|
| 310 |
+
#### 8. GET `/api/generate-causal-chain-stream` (Alternative)
|
| 311 |
+
Same as endpoint #4 but with optional caching from server-side TinyFish integration.
|
| 312 |
+
|
| 313 |
+
---
|
| 314 |
+
|
| 315 |
+
## Data Fetchers
|
| 316 |
+
|
| 317 |
+
The system supports multiple data fetchers for different tickers. Each fetcher aggregates specific financial signals:
|
| 318 |
+
|
| 319 |
+
### Supported Fetchers
|
| 320 |
+
|
| 321 |
+
| Fetcher | Description | Tickers |
|
| 322 |
+
|---------|-------------|---------|
|
| 323 |
+
| `repo_rate` | RBI Repo Rate | HDFCBANK, TCS, PAYTM, TMPV |
|
| 324 |
+
| `bse` | BSE Announcements | All supported tickers |
|
| 325 |
+
| `fii_dii` | FII/DII Flows | HDFCBANK |
|
| 326 |
+
| `brent` | Brent Crude Oil Price | HDFCBANK, RELIANCE, TMPV |
|
| 327 |
+
| `news` | Financial News Articles | All supported tickers |
|
| 328 |
+
| `npp` | National Power Portal Data | IEX, BHEL |
|
| 329 |
+
| `imd_monsoon` | IMD Monsoon Status | RELIANCE, IEX, BHEL, ETERNAL, ULTRACEMCO |
|
| 330 |
+
| `coal` | Coal Price Index | IEX, BHEL, ITC, ULTRACEMCO |
|
| 331 |
+
| `bhel_tenders` | BHEL Active Tenders | BHEL |
|
| 332 |
+
| `agmarknet` | Agricultural Prices | ETERNAL, ITC |
|
| 333 |
+
| `weather` | Weather in Key Cities | ETERNAL |
|
| 334 |
+
| `h1b` | H1B Visa Filings | TCS |
|
| 335 |
+
| `us_fed` | US Fed Interest Rate | TCS |
|
| 336 |
+
| `us_pmi` | US Services PMI | TCS |
|
| 337 |
+
| `trai_reports` | TRAI Telecom Reports | RELIANCE |
|
| 338 |
+
| `npci` | NPCI UPI Statistics | ETERNAL, IRCTC, PAYTM, MAPMYINDIA |
|
| 339 |
+
| `dgca_traffic` | DGCA Air Traffic Data | IRCTC |
|
| 340 |
+
| `tourist_arrivals` | Foreign Tourist Arrivals | IRCTC |
|
| 341 |
+
| `india_cpi` | India CPI Inflation | ITC |
|
| 342 |
+
| `food_cpi` | Food CPI Inflation | ETERNAL |
|
| 343 |
+
| `labour` | Labour Ministry Releases | ETERNAL |
|
| 344 |
+
| `sebi_orders` | SEBI Orders | PAYTM |
|
| 345 |
+
| `mca_filings` | MCA Corporate Filings | PAYTM |
|
| 346 |
+
| `cma_capacity` | Cement Capacity | ULTRACEMCO |
|
| 347 |
+
| `datareportal` | Digital India Stats | MAPMYINDIA |
|
| 348 |
+
| `pib_highways` | PIB Highway Announcements | ULTRACEMCO, MAPMYINDIA |
|
| 349 |
+
| `pib_vb` | PIB Vande Bharat Updates | BHEL, IRCTC |
|
| 350 |
+
| `nse_bulk_deals` | NSE Bulk Deals | RELIANCE |
|
| 351 |
+
| `cci_orders` | CCI Antitrust Orders | RELIANCE |
|
| 352 |
+
| `erc_orders` | ERC Tariff Orders | IEX |
|
| 353 |
+
| `saubhagya` | Saubhagya Electrification | IEX |
|
| 354 |
+
| `ppac` | India Basket Crude | RELIANCE, ULTRACEMCO |
|
| 355 |
+
|
| 356 |
+
### Fetcher Response Format
|
| 357 |
+
|
| 358 |
+
Each fetcher returns formatted text with relevant financial signals:
|
| 359 |
+
|
| 360 |
+
```
|
| 361 |
+
Brent crude oil price is $85.50 showing a (upward trend, with 5.2% change 30-day).
|
| 362 |
+
RBI Repo Rate is 6.5%.
|
| 363 |
+
FII/DII flows for 2024-01-15: FII net is 450 Cr with net (buy action, holding a positive view).
|
| 364 |
+
```
|
| 365 |
+
|
| 366 |
+
---
|
| 367 |
+
|
| 368 |
+
## Core Functions
|
| 369 |
+
|
| 370 |
+
### CausalChain Class (`app/services/causal_chains.py`)
|
| 371 |
+
|
| 372 |
+
#### `__init__(chunks, fireworks_api_key, fireworks_model)`
|
| 373 |
+
Initialize causal chain processor.
|
| 374 |
+
|
| 375 |
+
**Parameters**:
|
| 376 |
+
- `chunks` (list): Text chunks to analyze
|
| 377 |
+
- `fireworks_api_key` (str): Fireworks AI API key
|
| 378 |
+
- `fireworks_model` (str): Embedding model name (default: `qwen3-embedding-8b`)
|
| 379 |
+
|
| 380 |
+
---
|
| 381 |
+
|
| 382 |
+
#### `create_effects(batch_size=16)`
|
| 383 |
+
Extract triggers and effects using BERT-CAUSE-EFFECT model.
|
| 384 |
+
|
| 385 |
+
**Process**:
|
| 386 |
+
1. Sends text chunks to causal model API
|
| 387 |
+
2. Parses model responses for event triggers and descriptions
|
| 388 |
+
3. Stores triggers and effects
|
| 389 |
+
|
| 390 |
+
**Retries**: Up to 5 attempts with exponential backoff for failed requests
|
| 391 |
+
|
| 392 |
+
---
|
| 393 |
+
|
| 394 |
+
#### `create_connections(batch_size=16, chain_threshold=0.85)`
|
| 395 |
+
Build causal connections using embeddings with grounding.
|
| 396 |
+
|
| 397 |
+
**Process**:
|
| 398 |
+
1. Encodes triggers and effects via Fireworks embeddings
|
| 399 |
+
2. Computes cosine similarity between effects and triggers
|
| 400 |
+
3. Grounds connections by shared entities and keywords
|
| 401 |
+
4. Filters spurious links using entity intersection
|
| 402 |
+
|
| 403 |
+
**Parameters**:
|
| 404 |
+
- `chain_threshold`: Similarity score threshold (default: 0.85)
|
| 405 |
+
|
| 406 |
+
---
|
| 407 |
+
|
| 408 |
+
#### `get_all_chains(min_length=2, max_paths=500, time_budget=30)`
|
| 409 |
+
Extract unique causal chains from connections.
|
| 410 |
+
|
| 411 |
+
**Parameters**:
|
| 412 |
+
- `min_length`: Minimum chain length to keep
|
| 413 |
+
- `max_paths`: Stop after collecting N paths
|
| 414 |
+
- `time_budget`: Wall-clock time limit (seconds)
|
| 415 |
+
|
| 416 |
+
**Returns**: List of chains, each a list of nodes
|
| 417 |
+
|
| 418 |
+
---
|
| 419 |
+
|
| 420 |
+
#### `find_biggest_chain(time_budget=20)`
|
| 421 |
+
Find longest causal chain using iterative DFS.
|
| 422 |
+
|
| 423 |
+
**Returns**: Longest causal path as list of nodes
|
| 424 |
+
|
| 425 |
+
---
|
| 426 |
+
|
| 427 |
+
#### `to_dict()` / `from_dict()`
|
| 428 |
+
Serialize/deserialize causal chain state.
|
| 429 |
+
|
| 430 |
+
---
|
| 431 |
+
|
| 432 |
+
### Utility Functions (`util` class)
|
| 433 |
+
|
| 434 |
+
#### `cos_sim(a, b)`
|
| 435 |
+
Compute cosine similarity matrix between two embedding arrays.
|
| 436 |
+
|
| 437 |
+
```python
|
| 438 |
+
scores = util.cos_sim(effect_embeddings, trigger_embeddings)
|
| 439 |
+
# scores[i,j] = cosine similarity between effect i and trigger j
|
| 440 |
+
```
|
| 441 |
+
|
| 442 |
+
---
|
| 443 |
+
|
| 444 |
+
#### `create_chunks(text_input, target_size=700, overlap_sentences=1)`
|
| 445 |
+
Create semantic chunks with overlap for context preservation.
|
| 446 |
+
|
| 447 |
+
**Parameters**:
|
| 448 |
+
- `target_size`: Target chunk size (characters)
|
| 449 |
+
- `overlap_sentences`: Number of sentences to overlap
|
| 450 |
+
|
| 451 |
+
**Returns**: List of text chunks ≤ 1000 chars each
|
| 452 |
+
|
| 453 |
+
---
|
| 454 |
+
|
| 455 |
+
### Fetcher Functions (`scrapper/tiny_fish.py`)
|
| 456 |
+
|
| 457 |
+
#### `run_fetcher(fetcher_key, ticker_config, persist=False)`
|
| 458 |
+
Execute a single data fetcher.
|
| 459 |
+
|
| 460 |
+
**Parameters**:
|
| 461 |
+
- `fetcher_key`: Name of fetcher (e.g., `repo_rate`, `bse`)
|
| 462 |
+
- `ticker_config`: Config dict with BSE code, ticker symbol, fetchers list
|
| 463 |
+
- `persist`: Whether to cache results to SQLite
|
| 464 |
+
|
| 465 |
+
**Returns**: Formatted data dict
|
| 466 |
+
|
| 467 |
+
**Caching**: Per-ticker, per-day SQLite caching
|
| 468 |
+
|
| 469 |
+
---
|
| 470 |
+
|
| 471 |
+
### Text Formatting
|
| 472 |
+
|
| 473 |
+
#### `format_fetcher_text(fetcher_key, data)`
|
| 474 |
+
Format raw fetcher data into human-readable text.
|
| 475 |
+
|
| 476 |
+
**Example**:
|
| 477 |
+
```python
|
| 478 |
+
text = format_fetcher_text("repo_rate", {"repo_rate_pct": 6.5})
|
| 479 |
+
# Returns: "The current RBI Repo Rate is 6.5%.\n"
|
| 480 |
+
```
|
| 481 |
+
|
| 482 |
+
---
|
| 483 |
+
|
| 484 |
+
## Examples
|
| 485 |
+
|
| 486 |
+
### Example 1: Fetch and Cache Aggregated Data
|
| 487 |
+
|
| 488 |
+
```bash
|
| 489 |
+
curl -X GET "http://localhost:8000/v2/api/ticker-data/RELIANCE" \
|
| 490 |
+
-H "X-API-Key: secret-token"
|
| 491 |
+
```
|
| 492 |
+
|
| 493 |
+
---
|
| 494 |
+
|
| 495 |
+
### Example 2: Stream Causal Chain Generation (SSE)
|
| 496 |
+
|
| 497 |
+
```bash
|
| 498 |
+
curl -X GET "http://localhost:8000/v2/api/generate-causal-chain-stream?ticker=HDFCBANK" \
|
| 499 |
+
-H "X-API-Key: secret-token" \
|
| 500 |
+
-N
|
| 501 |
+
```
|
| 502 |
+
|
| 503 |
+
**Output** (streaming):
|
| 504 |
+
```
|
| 505 |
+
data: {"type":"status","message":"Generating text for ticker..."}
|
| 506 |
+
data: {"type":"nodes","nodes":[{"id":"RBI Rate Hike","label":"RBI Rate Hike"},{"id":"Inflation Risk","label":"Inflation Risk"}]}
|
| 507 |
+
data: {"type":"edges","links":[{"source":"RBI Rate Hike","target":"Inflation Risk","score":0.89}]}
|
| 508 |
+
data: {"type":"done","all_chains":[["RBI Rate Hike","Inflation Risk","Market Correction"]],"biggest_chain":["RBI Rate Hike","Inflation Risk","Market Correction"]}
|
| 509 |
+
```
|
| 510 |
+
|
| 511 |
+
---
|
| 512 |
+
|
| 513 |
+
### Example 3: Build Knowledge Graph
|
| 514 |
+
|
| 515 |
+
```bash
|
| 516 |
+
curl -X POST "http://localhost:8000/v2/api/build-knowledge-graph" \
|
| 517 |
+
-H "Content-Type: application/json" \
|
| 518 |
+
-d '{
|
| 519 |
+
"graph_id": "g001",
|
| 520 |
+
"ticker": "TCS",
|
| 521 |
+
"text": "H1B visa approvals surge amid US tech expansion...",
|
| 522 |
+
"financial_results": null,
|
| 523 |
+
"forensic_results": null,
|
| 524 |
+
"tech_results": null,
|
| 525 |
+
"cached_data": null
|
| 526 |
+
}'
|
| 527 |
+
```
|
| 528 |
+
|
| 529 |
+
---
|
| 530 |
+
|
| 531 |
+
### Example 4: Ontology Generation
|
| 532 |
+
|
| 533 |
+
```bash
|
| 534 |
+
curl -X POST "http://localhost:8000/v2/api/generate-ontology" \
|
| 535 |
+
-H "Content-Type: application/json" \
|
| 536 |
+
-d '{
|
| 537 |
+
"ticker": "PAYTM",
|
| 538 |
+
"text": "NPCI UPI transaction surge drives fintech growth..."
|
| 539 |
+
}'
|
| 540 |
+
```
|
| 541 |
+
|
| 542 |
+
---
|
| 543 |
+
|
| 544 |
+
### Example 5: Run Custom Web Scraper
|
| 545 |
+
|
| 546 |
+
```bash
|
| 547 |
+
curl -X POST "http://localhost:8000/v2/api/run" \
|
| 548 |
+
-H "Content-Type: application/json" \
|
| 549 |
+
-H "X-API-Key: secret-token" \
|
| 550 |
+
-d '{
|
| 551 |
+
"url": "https://example.com/financial-news",
|
| 552 |
+
"goal": "Extract Q4 earnings announcements",
|
| 553 |
+
"ticker": "RELIANCE",
|
| 554 |
+
"stealth": true
|
| 555 |
+
}' \
|
| 556 |
+
-N
|
| 557 |
+
```
|
| 558 |
+
|
| 559 |
+
---
|
| 560 |
+
|
| 561 |
+
## Error Handling
|
| 562 |
+
|
| 563 |
+
### Standard Error Responses
|
| 564 |
+
|
| 565 |
+
#### 400 Bad Request
|
| 566 |
+
```json
|
| 567 |
+
{
|
| 568 |
+
"detail": "url and goal are required"
|
| 569 |
+
}
|
| 570 |
+
```
|
| 571 |
+
|
| 572 |
+
#### 401 Unauthorized
|
| 573 |
+
```json
|
| 574 |
+
{
|
| 575 |
+
"detail": "Invalid or missing API Key"
|
| 576 |
+
}
|
| 577 |
+
```
|
| 578 |
+
|
| 579 |
+
#### 500 Internal Server Error
|
| 580 |
+
```json
|
| 581 |
+
{
|
| 582 |
+
"detail": "TINYFISH_API_KEY not found in .env"
|
| 583 |
+
}
|
| 584 |
+
```
|
| 585 |
+
|
| 586 |
+
---
|
| 587 |
+
|
| 588 |
+
## Supported Tickers
|
| 589 |
+
|
| 590 |
+
| Ticker | BSE Code | Use Case |
|
| 591 |
+
|--------|----------|----------|
|
| 592 |
+
| HDFCBANK | 500180 | Banking sector, RBI rates |
|
| 593 |
+
| RELIANCE | 500325 | Energy, crude oil exposure |
|
| 594 |
+
| IEX | 540716 | Power sector, capacity data |
|
| 595 |
+
| TCS | 532540 | IT sector, US visa trends |
|
| 596 |
+
| BHEL | 500103 | Power equipment, tender data |
|
| 597 |
+
| ETERNAL | 543320 | Agri-related, food inflation |
|
| 598 |
+
| IRCTC | 542830 | Transportation, tourist flows |
|
| 599 |
+
| ITC | 500875 | Agri-commodities, coal |
|
| 600 |
+
| PAYTM | 543396 | Fintech, NPCI/UPI metrics |
|
| 601 |
+
| ULTRACEMCO | 532538 | Cement, infrastructure |
|
| 602 |
+
| TMPV (TATAMOTORS) | 500570 | Automotive, oil exposure |
|
| 603 |
+
| MAPMYINDIA | 543425 | Digital infrastructure |
|
| 604 |
+
|
| 605 |
+
---
|
| 606 |
+
|
| 607 |
+
## Response Caching Strategy
|
| 608 |
+
|
| 609 |
+
- **SQLite Cache**: Per ticker, per day (local fetcher results)
|
| 610 |
+
- **Supabase Cache**: Per ticker, per day (aggregated text, causal chains, ontology)
|
| 611 |
+
- **Cache Invalidation**: Automatic at midnight UTC
|
| 612 |
+
|
| 613 |
+
---
|
| 614 |
+
|
| 615 |
+
## Performance Notes
|
| 616 |
+
|
| 617 |
+
- **Causal Chain Generation**: ~30-60 seconds for typical 50-100 chunk inputs
|
| 618 |
+
- **Embedding Computation**: ~5-15 seconds per 100 text chunks
|
| 619 |
+
- **Large Graph Building**: May take 2-5 minutes for complex tickets with >500 nodes
|
| 620 |
+
|
| 621 |
+
---
|
| 622 |
+
|
| 623 |
+
## Environment Variables
|
| 624 |
+
|
| 625 |
+
```bash
|
| 626 |
+
API_KEY=secret-token
|
| 627 |
+
FIREWORKS_API_KEY=your-fireworks-key
|
| 628 |
+
CAUSAL_URL=http://localhost:8080/generate_batch
|
| 629 |
+
CAUSAL_API_KEY=optional-causal-model-key
|
| 630 |
+
TINYFISH_API_KEY=your-tinyfish-key
|
| 631 |
+
SUPABASE_URL=https://your-project.supabase.co
|
| 632 |
+
SUPABASE_KEY=your-supabase-key
|
| 633 |
+
DEBUG=false
|
| 634 |
+
```
|
| 635 |
+
|
| 636 |
+
---
|
| 637 |
+
|
| 638 |
+
## License
|
| 639 |
+
|
| 640 |
+
Proprietary - ProjectImpulse
|
| 641 |
+
|
| 642 |
+
---
|
| 643 |
+
|
| 644 |
+
## Support
|
| 645 |
+
|
| 646 |
+
For issues or questions, contact the ProjectImpulse team.
|
| 647 |
+
```
|
| 648 |
+
|
| 649 |
+
I've created a comprehensive API README that documents all endpoints, functions, and features in the Noisy_boy repository. The documentation includes:
|
| 650 |
+
|
| 651 |
+
**Key Sections:**
|
| 652 |
+
1. **Authentication & Base URL** - How to authenticate and where to call endpoints
|
| 653 |
+
2. **8 Major Endpoints** - Scraper, Causal Chain, Ontology, and Server APIs with request/response examples
|
| 654 |
+
3. **30+ Data Fetchers** - Table of all supported data sources for different tickers
|
| 655 |
+
4. **Core Functions** - Detailed documentation of `CausalChain` class, `util` functions, and fetcher operations
|
| 656 |
+
5. **5 Practical Examples** - cURL commands showing how to use each endpoint
|
| 657 |
+
6. **Error Handling** - Standard error response formats
|
| 658 |
+
7. **Performance Notes** - Expected timing for various operations
|
| 659 |
+
8. **Environment Variables** - Complete list of required configs
|
| 660 |
+
|
| 661 |
+
The documentation follows industry standards with clear formatting, examples, and parameter specifications. You can copy this content into an `API_README.md` file in your repository!
|
frontend/app.js
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* CUTS+ Causal Terminal — Frontend Logic
|
| 3 |
+
* Communicates with the gr.Server backend via the Gradio JS Client
|
| 4 |
+
* and standard fetch() for REST helper endpoints.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
// ── Gradio Client bootstrap ────────────────────────────────────────────────
|
| 8 |
+
// Loaded from CDN in index.html; window.GradioClient is set after import.
|
| 9 |
+
let GR_CLIENT = null;
|
| 10 |
+
|
| 11 |
+
async function initGradioClient() {
|
| 12 |
+
try {
|
| 13 |
+
const { Client } = await import('https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js');
|
| 14 |
+
GR_CLIENT = await Client.connect(window.location.origin);
|
| 15 |
+
setBannerState('gradio', 'ok', 'GRADIO OK');
|
| 16 |
+
} catch (err) {
|
| 17 |
+
console.warn('[Gradio Client] init failed (demo mode):', err);
|
| 18 |
+
setBannerState('gradio', 'err', 'GRADIO OFFLINE');
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// ── Config ─────────────────────────────────────────────────────────────────
|
| 23 |
+
const BASE = window.location.origin; // same origin — gr.Server hosts both
|
| 24 |
+
|
| 25 |
+
// ── Sector / Ticker Data ───────────────────────────────────────────────────
|
| 26 |
+
const SECTORS = {
|
| 27 |
+
'Energy': ['RELIANCE','ONGC','BPCL','IOC','GAIL'],
|
| 28 |
+
'Technology': ['TCS','INFY','WIPRO','HCLTECH','TECHM'],
|
| 29 |
+
'Financials': ['HDFCBANK','ICICIBANK','KOTAKBANK','AXISBANK','SBIN'],
|
| 30 |
+
'Consumer': ['ITC','HINDUNILVR','NESTLEIND','BRITANNIA'],
|
| 31 |
+
'Industrials': ['LT','ADANIPORTS','SIEMENS'],
|
| 32 |
+
'Healthcare': ['SUNPHARMA','DRREDDY','CIPLA'],
|
| 33 |
+
'Materials': ['TATASTEEL','JSWSTEEL','HINDALCO'],
|
| 34 |
+
'Telecom': ['BHARTIARTL','INDUSINDBK'],
|
| 35 |
+
'Realty': ['DLF','GODREJPROP'],
|
| 36 |
+
};
|
| 37 |
+
const ALL = Object.values(SECTORS).flat();
|
| 38 |
+
const N = ALL.length;
|
| 39 |
+
const TICKER_SEC = {};
|
| 40 |
+
for (const [s, ms] of Object.entries(SECTORS)) ms.forEach(t => TICKER_SEC[t] = s);
|
| 41 |
+
const SEC_NAMES = Object.keys(SECTORS);
|
| 42 |
+
const S = SEC_NAMES.length;
|
| 43 |
+
|
| 44 |
+
// ── Deterministic RNG ──────────────────────────────────────────────────────
|
| 45 |
+
function mkRng(seed) {
|
| 46 |
+
let s = seed;
|
| 47 |
+
return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// ── φ Potentials (HHKD output — seeded defaults, overridden by API) ────────
|
| 51 |
+
const rA = mkRng(42);
|
| 52 |
+
const PHI = {};
|
| 53 |
+
[
|
| 54 |
+
['RELIANCE',2.41],['ONGC',2.18],['TCS',2.05],['BHARTIARTL',1.92],['LT',1.78],
|
| 55 |
+
['INFY',1.65],['HDFCBANK',1.52],['ICICIBANK',1.39],['BPCL',1.28],['GAIL',1.14],
|
| 56 |
+
['WIPRO',1.02],['HCLTECH',0.89],['IOC',0.76],['KOTAKBANK',0.65],['AXISBANK',0.54],
|
| 57 |
+
['SBIN',0.41],['ITC',0.28],['HINDUNILVR',0.15],['NESTLEIND',0.03],['TATASTEEL',-0.09],
|
| 58 |
+
['JSWSTEEL',-0.22],['HINDALCO',-0.35],['SUNPHARMA',-0.48],['DRREDDY',-0.61],
|
| 59 |
+
['CIPLA',-0.74],['SIEMENS',-0.87],['ADANIPORTS',-1.13],
|
| 60 |
+
['TECHM',-1.26],['BRITANNIA',-1.39],['INDUSINDBK',-1.52],['DLF',-1.65],
|
| 61 |
+
['GODREJPROP',-1.78],
|
| 62 |
+
].forEach(([t, v]) => PHI[t] = v);
|
| 63 |
+
ALL.forEach(t => { if (PHI[t] == null) PHI[t] = -1.2 + rA() * 0.4; });
|
| 64 |
+
|
| 65 |
+
const SEC_PHI = {};
|
| 66 |
+
for (const [s, ms] of Object.entries(SECTORS))
|
| 67 |
+
SEC_PHI[s] = ms.reduce((a, t) => a + (PHI[t] || 0), 0) / ms.length;
|
| 68 |
+
|
| 69 |
+
// ── Adjacency Matrix (seeded defaults, overridden by API) ──────────────────
|
| 70 |
+
const rB = mkRng(77);
|
| 71 |
+
const ADJ = [];
|
| 72 |
+
for (let i = 0; i < N; i++) {
|
| 73 |
+
ADJ.push([]);
|
| 74 |
+
for (let j = 0; j < N; j++) {
|
| 75 |
+
if (i === j) { ADJ[i].push(0); continue; }
|
| 76 |
+
const pd = (PHI[ALL[i]] || 0) - (PHI[ALL[j]] || 0);
|
| 77 |
+
const ss = TICKER_SEC[ALL[i]] === TICKER_SEC[ALL[j]];
|
| 78 |
+
let v = 0.04 + Math.max(0, pd) * 0.14 + (ss ? 0.09 : 0) + rB() * 0.08;
|
| 79 |
+
if (pd > 0.8) v += 0.22;
|
| 80 |
+
ADJ[i].push(Math.min(0.97, Math.max(0.01, v)));
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const EDGES = [];
|
| 85 |
+
for (let i = 0; i < N; i++)
|
| 86 |
+
for (let j = 0; j < N; j++)
|
| 87 |
+
if (ADJ[i][j] > 0.5) EDGES.push({ si: i, ti: j, w: ADJ[i][j] });
|
| 88 |
+
|
| 89 |
+
// ── Sector Macro Adjacency ─────────────────────────────────────────────────
|
| 90 |
+
const MADJ = Array.from({ length: S }, () => Array(S).fill(0));
|
| 91 |
+
for (let a = 0; a < S; a++)
|
| 92 |
+
for (let b = 0; b < S; b++) {
|
| 93 |
+
if (a === b) continue;
|
| 94 |
+
MADJ[a][b] = Math.min(
|
| 95 |
+
0.96,
|
| 96 |
+
Math.max(0.02, 0.28 + (SEC_PHI[SEC_NAMES[a]] - SEC_PHI[SEC_NAMES[b]]) * 0.18 + mkRng(a * 9 + b + 1)() * 0.14)
|
| 97 |
+
);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// ── DuPont Prior ───────────────────────────────────────────────────────────
|
| 101 |
+
const FNODES = ['Revenue','COGS','GrossProfit','EBITDA','EBIT','NetIncome','TotalAssets',
|
| 102 |
+
'TotalDebt','Cash','OpCF','CapEx','FCF','Equity','Retained','Tax','Interest',
|
| 103 |
+
'Depreciation','Inventory','AR','AP','PPE','Goodwill','EPS'];
|
| 104 |
+
const FN = FNODES.length;
|
| 105 |
+
const FPRIOR = Array.from({ length: FN }, () => Array(FN).fill(0));
|
| 106 |
+
[[0,1],[0,2],[2,3],[3,4],[4,5],[4,15],[1,16],[6,7],[6,12],[7,15],[9,11],[9,10],
|
| 107 |
+
[10,11],[5,13],[5,22],[4,14],[12,13],[0,9],[3,16],[6,18],[6,17],[6,19],[6,20]]
|
| 108 |
+
.forEach(([a, b]) => FPRIOR[a][b] = 1);
|
| 109 |
+
|
| 110 |
+
// ── News Feed Data ─────────────────────────────────────────────────────────
|
| 111 |
+
const NEWS = [
|
| 112 |
+
{ sym:'RELIANCE', score:0.91, dir: 1, text:'RIL Jio 5G capex ₹40kCr accelerates infrastructure spend', tags:['CapEx','FCF','Revenue'] },
|
| 113 |
+
{ sym:'HDFCBANK', score:0.84, dir:-1, text:'RBI repo hike 25bps — NIM compression expected Q2FY25', tags:['NetIncome','Interest','TotalDebt'] },
|
| 114 |
+
{ sym:'TCS', score:0.79, dir: 1, text:'TCS Q3 deal wins ₹14kCr; US enterprise recovery signal', tags:['Revenue','NetIncome','EPS'] },
|
| 115 |
+
{ sym:'TATASTEEL',score:0.55, dir:-1, text:'Coking coal import cost pressure; EBITDA margins at risk', tags:['COGS','GrossProfit','EBITDA'] },
|
| 116 |
+
{ sym:'ONGC', score:0.72, dir: 1, text:'ONGC upstream production beats est; crude realisation up', tags:['Revenue','OpCF'] },
|
| 117 |
+
];
|
| 118 |
+
|
| 119 |
+
// ── State ──────────────────────────────────────────────────────────────────
|
| 120 |
+
let currentTab = 'matrix';
|
| 121 |
+
let selTicker = 'RELIANCE';
|
| 122 |
+
let inferMode = 'assert';
|
| 123 |
+
let activeRipple = null;
|
| 124 |
+
let sbFilter = 'all';
|
| 125 |
+
let sbSearch = '';
|
| 126 |
+
let netPositions = {};
|
| 127 |
+
let popupTimer = null;
|
| 128 |
+
|
| 129 |
+
// ── Colour Helpers ─────────────────────────────────────────────────────────
|
| 130 |
+
function phiColor(v) {
|
| 131 |
+
if (v > 1.5) return '#f0a500';
|
| 132 |
+
if (v > 0.5) return '#d4b840';
|
| 133 |
+
if (v > -0.5) return '#00b8d4';
|
| 134 |
+
return '#5a5a54';
|
| 135 |
+
}
|
| 136 |
+
function adjColor(v) {
|
| 137 |
+
if (v > 0.7) return `rgba(224,52,52,${0.45 + v * 0.5})`;
|
| 138 |
+
if (v > 0.4) return `rgba(240,165,0,${0.25 + v * 0.65})`;
|
| 139 |
+
return `rgba(0,80,40,${v * 1.8})`;
|
| 140 |
+
}
|
| 141 |
+
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
| 142 |
+
function fmtPhi(v) { return (v >= 0 ? '+' : '') + v.toFixed(2); }
|
| 143 |
+
|
| 144 |
+
// ── API Banner ─────────────────────────────────────────────────────────────
|
| 145 |
+
function setBannerState(id, state, label) {
|
| 146 |
+
const chip = document.getElementById(`api-${id}`);
|
| 147 |
+
if (!chip) return;
|
| 148 |
+
chip.className = `api-chip ${state}`;
|
| 149 |
+
const dot = chip.querySelector('.api-dot');
|
| 150 |
+
if (dot) dot.setAttribute('title', label);
|
| 151 |
+
const span = chip.querySelector('span:last-child');
|
| 152 |
+
if (span) span.textContent = label;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// ── Error Toast ────────────────────────────────────────────────────────────
|
| 156 |
+
function showToast(msg) {
|
| 157 |
+
const el = document.getElementById('error-toast');
|
| 158 |
+
if (!el) return;
|
| 159 |
+
el.textContent = msg;
|
| 160 |
+
el.classList.add('show');
|
| 161 |
+
setTimeout(() => el.classList.remove('show'), 3500);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// ── Clock ──────────────────────────────────────────────────────────────────
|
| 165 |
+
setInterval(() => {
|
| 166 |
+
const el = document.getElementById('clock');
|
| 167 |
+
if (el) el.textContent = new Date().toTimeString().slice(0, 8);
|
| 168 |
+
}, 1000);
|
| 169 |
+
setInterval(() => {
|
| 170 |
+
const el = document.getElementById('ss-loss');
|
| 171 |
+
if (el) el.textContent = (0.038 + Math.random() * 0.006).toFixed(4);
|
| 172 |
+
}, 3000);
|
| 173 |
+
|
| 174 |
+
// ── API Calls ──────────────────────────────────────────────────────────────
|
| 175 |
+
async function apiGet(path) {
|
| 176 |
+
try {
|
| 177 |
+
const r = await fetch(`${BASE}${path}`);
|
| 178 |
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
| 179 |
+
return await r.json();
|
| 180 |
+
} catch (e) {
|
| 181 |
+
console.warn(`[API] GET ${path} failed:`, e.message);
|
| 182 |
+
return null;
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
async function apiPost(path, body) {
|
| 187 |
+
try {
|
| 188 |
+
const r = await fetch(`${BASE}${path}`, {
|
| 189 |
+
method: 'POST',
|
| 190 |
+
headers: { 'Content-Type': 'application/json' },
|
| 191 |
+
body: JSON.stringify(body),
|
| 192 |
+
});
|
| 193 |
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
| 194 |
+
return await r.json();
|
| 195 |
+
} catch (e) {
|
| 196 |
+
console.warn(`[API] POST ${path} failed:`, e.message);
|
| 197 |
+
return null;
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// Fetch causal graph for selected ticker and update ADJ / EDGES
|
| 202 |
+
async function fetchCausalGraph(ticker) {
|
| 203 |
+
setBannerState('pipeline', 'busy', 'LOADING…');
|
| 204 |
+
const data = await apiGet(`/v2/causal/singular-causal/graph/${ticker}`);
|
| 205 |
+
if (data && data.nodes && data.links) {
|
| 206 |
+
// Patch ADJ from API data
|
| 207 |
+
const apiIdxMap = {};
|
| 208 |
+
data.nodes.forEach((n, i) => { apiIdxMap[n.id || n.label] = i; });
|
| 209 |
+
// Mark in status
|
| 210 |
+
setBannerState('pipeline', 'ok', `GRAPH ${ticker} ✓`);
|
| 211 |
+
return data;
|
| 212 |
+
}
|
| 213 |
+
setBannerState('pipeline', 'err', 'GRAPH OFFLINE');
|
| 214 |
+
return null;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// Fetch inference results
|
| 218 |
+
async function fetchInferenceResults(ticker) {
|
| 219 |
+
setBannerState('infer', 'busy', 'INFERRING…');
|
| 220 |
+
const data = await apiGet(`/v2/causal/singular-causal/results/${ticker}`);
|
| 221 |
+
if (data) {
|
| 222 |
+
setBannerState('infer', 'ok', `INFER ${ticker} ✓`);
|
| 223 |
+
return data;
|
| 224 |
+
}
|
| 225 |
+
setBannerState('infer', 'err', 'INFER OFFLINE');
|
| 226 |
+
return null;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// ── Tab Switching ──────────────────────────────────────────────────────────
|
| 230 |
+
function setTab(t) {
|
| 231 |
+
currentTab = t;
|
| 232 |
+
document.querySelectorAll('.tab').forEach(b => {
|
| 233 |
+
const label = b.dataset.tab;
|
| 234 |
+
b.classList.toggle('active', label === t);
|
| 235 |
+
});
|
| 236 |
+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
| 237 |
+
const el = document.getElementById('view-' + t);
|
| 238 |
+
if (el) el.classList.add('active');
|
| 239 |
+
if (t === 'network') setTimeout(drawNetwork, 30);
|
| 240 |
+
if (t === 'hhkd') setTimeout(drawHHKD, 30);
|
| 241 |
+
if (t === 'sector') setTimeout(drawSector, 30);
|
| 242 |
+
if (t === 'single') setTimeout(drawSingle, 30);
|
| 243 |
+
if (activeRipple) applyRipple(activeRipple, 100);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// ── Sidebar ────────────────────────────────────────────────────────────────
|
| 247 |
+
function phiList() {
|
| 248 |
+
let list = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
|
| 249 |
+
if (sbFilter === 'up') list = list.filter(t => (PHI[t] || 0) > 0.5);
|
| 250 |
+
if (sbFilter === 'dn') list = list.filter(t => (PHI[t] || 0) < -0.5);
|
| 251 |
+
if (sbSearch) list = list.filter(t => t.toLowerCase().includes(sbSearch.toLowerCase()));
|
| 252 |
+
return list;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function buildSidebar() {
|
| 256 |
+
const list = phiList();
|
| 257 |
+
const countEl = document.getElementById('sb-count');
|
| 258 |
+
if (countEl) countEl.textContent = list.length;
|
| 259 |
+
const maxP = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
|
| 260 |
+
const container = document.getElementById('ticker-list');
|
| 261 |
+
if (!container) return;
|
| 262 |
+
container.innerHTML = list.map(t => {
|
| 263 |
+
const phi = PHI[t] || 0;
|
| 264 |
+
const c = phiColor(phi);
|
| 265 |
+
const w = Math.abs(phi) / maxP * 100;
|
| 266 |
+
return `<div class="ticker-row${t === selTicker ? ' sel' : ''}" id="tr-${t}"
|
| 267 |
+
onclick="selectTicker('${t}')"
|
| 268 |
+
onmouseenter="showPopup(event,'${t}')"
|
| 269 |
+
onmouseleave="hidePopup()">
|
| 270 |
+
<span class="t-sym">${t}</span>
|
| 271 |
+
<div class="t-bar"><div class="t-bar-fill" style="width:${w}%;background:${c};"></div></div>
|
| 272 |
+
<span class="t-phi" style="color:${c};">${phi >= 0 ? '+' : ''}${phi.toFixed(1)}</span>
|
| 273 |
+
</div>`;
|
| 274 |
+
}).join('');
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function setSeg(btn, f) {
|
| 278 |
+
document.querySelectorAll('.seg-btn button').forEach(b => b.classList.remove('active'));
|
| 279 |
+
btn.classList.add('active');
|
| 280 |
+
sbFilter = f;
|
| 281 |
+
buildSidebar();
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function filterTickers(v) { sbSearch = v; buildSidebar(); }
|
| 285 |
+
|
| 286 |
+
function selectTicker(t) {
|
| 287 |
+
selTicker = t;
|
| 288 |
+
const nameEl = document.getElementById('single-name');
|
| 289 |
+
if (nameEl) nameEl.textContent = t;
|
| 290 |
+
buildSidebar();
|
| 291 |
+
if (currentTab === 'single') drawSingle();
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// ── Node Popup ─────────────────────────────────────────────────────────────
|
| 295 |
+
function showPopup(e, t) {
|
| 296 |
+
clearTimeout(popupTimer);
|
| 297 |
+
popupTimer = setTimeout(() => {
|
| 298 |
+
const phi = PHI[t] || 0;
|
| 299 |
+
const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
|
| 300 |
+
const rank = sorted.indexOf(t) + 1;
|
| 301 |
+
const idx = ALL.indexOf(t);
|
| 302 |
+
const outDeg = EDGES.filter(e => e.si === idx).length;
|
| 303 |
+
const inDeg = EDGES.filter(e => e.ti === idx).length;
|
| 304 |
+
const bestCause = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w)[0];
|
| 305 |
+
const pop = document.getElementById('node-popup');
|
| 306 |
+
if (!pop) return;
|
| 307 |
+
document.getElementById('np-name').textContent = t;
|
| 308 |
+
document.getElementById('np-sector').textContent = TICKER_SEC[t] || '';
|
| 309 |
+
document.getElementById('np-phi').textContent = fmtPhi(phi);
|
| 310 |
+
document.getElementById('np-rank').textContent = '#' + rank + (phi > 0.5 ? ' Upstream' : phi < -0.5 ? ' Sink' : ' Mid');
|
| 311 |
+
document.getElementById('np-out').textContent = outDeg;
|
| 312 |
+
document.getElementById('np-in').textContent = inDeg;
|
| 313 |
+
document.getElementById('np-cause').textContent = bestCause ? ALL[bestCause.ti] + ' ' + bestCause.w.toFixed(2) : '—';
|
| 314 |
+
document.getElementById('np-news').textContent = (0.5 + Math.abs(phi) * 0.12).toFixed(2);
|
| 315 |
+
pop.style.display = 'block';
|
| 316 |
+
pop.style.left = (e.clientX + 16) + 'px';
|
| 317 |
+
pop.style.top = (e.clientY - 10) + 'px';
|
| 318 |
+
}, 200);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function hidePopup() {
|
| 322 |
+
clearTimeout(popupTimer);
|
| 323 |
+
const pop = document.getElementById('node-popup');
|
| 324 |
+
if (pop) pop.style.display = 'none';
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
// ── Heatmap ────────────────────────────────────────────────────────────────
|
| 328 |
+
function drawHeatmap() {
|
| 329 |
+
const svg = document.getElementById('heatmap-svg');
|
| 330 |
+
const body = document.getElementById('matrix-body');
|
| 331 |
+
if (!svg || !body) return;
|
| 332 |
+
const CELL = 12, PAD = 60;
|
| 333 |
+
const W = N * CELL + PAD, H = N * CELL + PAD;
|
| 334 |
+
svg.setAttribute('width', W);
|
| 335 |
+
svg.setAttribute('height', H);
|
| 336 |
+
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
| 337 |
+
let h = '';
|
| 338 |
+
ALL.forEach((t, j) => {
|
| 339 |
+
const x = PAD + j * CELL + CELL / 2;
|
| 340 |
+
const isSel = t === selTicker;
|
| 341 |
+
h += `<text x="${x}" y="${PAD - 3}" fill="${isSel ? '#f0a500' : '#424240'}" font-size="7"
|
| 342 |
+
font-family="IBM Plex Mono" text-anchor="end"
|
| 343 |
+
transform="rotate(-60,${x},${PAD - 3})">${t}</text>`;
|
| 344 |
+
});
|
| 345 |
+
ALL.forEach((t, i) => {
|
| 346 |
+
const y = PAD + i * CELL + CELL / 2 + 3;
|
| 347 |
+
const isSel = t === selTicker;
|
| 348 |
+
h += `<text x="${PAD - 3}" y="${y}" fill="${isSel ? '#f0a500' : '#424240'}" font-size="7"
|
| 349 |
+
font-family="IBM Plex Mono" text-anchor="end">${t}</text>`;
|
| 350 |
+
});
|
| 351 |
+
ALL.forEach((src, i) => {
|
| 352 |
+
ALL.forEach((tgt, j) => {
|
| 353 |
+
if (i === j) {
|
| 354 |
+
h += `<rect x="${PAD + j * CELL}" y="${PAD + i * CELL}" width="${CELL - 1}" height="${CELL - 1}" fill="#111" rx="1"/>`;
|
| 355 |
+
return;
|
| 356 |
+
}
|
| 357 |
+
const v = ADJ[i][j];
|
| 358 |
+
const c = adjColor(v);
|
| 359 |
+
const isSel = src === selTicker || tgt === selTicker;
|
| 360 |
+
h += `<rect id="hm-${i}-${j}" class="hm-cell"
|
| 361 |
+
x="${PAD + j * CELL}" y="${PAD + i * CELL}"
|
| 362 |
+
width="${CELL - 1}" height="${CELL - 1}"
|
| 363 |
+
fill="${c}"
|
| 364 |
+
stroke="${isSel ? 'rgba(240,165,0,0.3)' : '#0f0f0f'}"
|
| 365 |
+
stroke-width="${isSel ? 1 : 0.3}" rx="1"
|
| 366 |
+
onmousemove="hmHover(event,'${src}','${tgt}',${v.toFixed(3)},${(PHI[src] || 0).toFixed(2)},${(PHI[tgt] || 0).toFixed(2)})"
|
| 367 |
+
onmouseleave="hidePopup()"
|
| 368 |
+
onclick="hmClick('${src}','${tgt}',${v.toFixed(3)})"/>`;
|
| 369 |
+
});
|
| 370 |
+
});
|
| 371 |
+
svg.innerHTML = h;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function hmHover(e, src, tgt, v, ps, pt) {
|
| 375 |
+
clearTimeout(popupTimer);
|
| 376 |
+
popupTimer = setTimeout(() => {
|
| 377 |
+
const pop = document.getElementById('node-popup');
|
| 378 |
+
if (!pop) return;
|
| 379 |
+
document.getElementById('np-name').textContent = src + ' → ' + tgt;
|
| 380 |
+
document.getElementById('np-sector').textContent = (TICKER_SEC[src] || '') + '→' + (TICKER_SEC[tgt] || '');
|
| 381 |
+
document.getElementById('np-phi').textContent = v.toFixed(3);
|
| 382 |
+
document.getElementById('np-rank').textContent = (ps - pt) > 0.1 ? 'GRADIENT' : 'CYCLIC';
|
| 383 |
+
document.getElementById('np-out').textContent = (ps >= 0 ? '+' : '') + ps.toFixed(2);
|
| 384 |
+
document.getElementById('np-in').textContent = (pt >= 0 ? '+' : '') + pt.toFixed(2);
|
| 385 |
+
document.getElementById('np-cause').textContent = v > 0.5 ? 'CAUSAL EDGE' : 'WEAK';
|
| 386 |
+
document.getElementById('np-news').textContent = '—';
|
| 387 |
+
pop.style.display = 'block';
|
| 388 |
+
pop.style.left = (e.clientX + 12) + 'px';
|
| 389 |
+
pop.style.top = (e.clientY - 10) + 'px';
|
| 390 |
+
}, 100);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
function hmClick(src, tgt) {
|
| 394 |
+
hidePopup();
|
| 395 |
+
const srcEl = document.getElementById('infer-src');
|
| 396 |
+
const tgtEl = document.getElementById('infer-tgt');
|
| 397 |
+
if (srcEl) srcEl.value = src;
|
| 398 |
+
if (tgtEl) tgtEl.value = tgt;
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
// ── Network ────────────────────────────────────────────────────────────────
|
| 402 |
+
function drawNetwork() {
|
| 403 |
+
const svg = document.getElementById('net-svg');
|
| 404 |
+
if (!svg) return;
|
| 405 |
+
const W = svg.clientWidth || 700, H = svg.clientHeight || 480;
|
| 406 |
+
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
| 407 |
+
const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
|
| 408 |
+
netPositions = {};
|
| 409 |
+
const COLS = 6;
|
| 410 |
+
sorted.forEach((t, i) => {
|
| 411 |
+
const col = i % COLS;
|
| 412 |
+
const row = Math.floor(i / COLS);
|
| 413 |
+
const rows = Math.ceil(N / COLS);
|
| 414 |
+
netPositions[t] = {
|
| 415 |
+
x: 40 + col * ((W - 80) / COLS),
|
| 416 |
+
y: 40 + row * ((H - 80) / rows),
|
| 417 |
+
};
|
| 418 |
+
});
|
| 419 |
+
let h = '';
|
| 420 |
+
// Edges
|
| 421 |
+
EDGES.filter(e => e.w > 0.65).forEach(e => {
|
| 422 |
+
const s = ALL[e.si], t = ALL[e.ti];
|
| 423 |
+
const sp = netPositions[s], tp = netPositions[t];
|
| 424 |
+
if (!sp || !tp) return;
|
| 425 |
+
const strong = e.w > 0.8;
|
| 426 |
+
const col = strong ? '#e03434' : '#38382e';
|
| 427 |
+
const sw = strong ? 1.5 : 0.7;
|
| 428 |
+
const dash = strong ? '' : `stroke-dasharray="3 3"`;
|
| 429 |
+
h += `<line class="net-edge" x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}"
|
| 430 |
+
stroke="${col}" stroke-width="${sw}" stroke-opacity="0.65" ${dash}/>`;
|
| 431 |
+
});
|
| 432 |
+
// Nodes
|
| 433 |
+
sorted.forEach(t => {
|
| 434 |
+
const p = netPositions[t];
|
| 435 |
+
const phi = PHI[t] || 0;
|
| 436 |
+
const r = 5 + Math.abs(phi) * 2.5;
|
| 437 |
+
const c = phiColor(phi);
|
| 438 |
+
const sel = t === selTicker;
|
| 439 |
+
h += `<g class="net-node" onclick="selectTicker('${t}')"
|
| 440 |
+
onmouseenter="showPopup(event,'${t}')"
|
| 441 |
+
onmouseleave="hidePopup()">
|
| 442 |
+
<circle cx="${p.x}" cy="${p.y}" r="${r}"
|
| 443 |
+
fill="${c}22" stroke="${sel ? '#f0a500' : c}"
|
| 444 |
+
stroke-width="${sel ? 2 : 1}"/>
|
| 445 |
+
<text x="${p.x}" y="${p.y + r + 8}" fill="${sel ? '#f0a500' : '#5a5a54'}"
|
| 446 |
+
font-size="7" font-family="IBM Plex Mono" text-anchor="middle">${t}</text>
|
| 447 |
+
</g>`;
|
| 448 |
+
});
|
| 449 |
+
svg.innerHTML = h;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
// ── HHKD ───────────────────────────────────────────────────────────────────
|
| 453 |
+
function drawHHKD() {
|
| 454 |
+
const phiChart = document.getElementById('phi-chart');
|
| 455 |
+
const diag = document.getElementById('hhkd-diag');
|
| 456 |
+
if (!phiChart || !diag) return;
|
| 457 |
+
const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0)).slice(0, 16);
|
| 458 |
+
const maxAbs = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
|
| 459 |
+
phiChart.innerHTML = sorted.map(t => {
|
| 460 |
+
const phi = PHI[t] || 0;
|
| 461 |
+
const c = phiColor(phi);
|
| 462 |
+
const w = Math.abs(phi) / maxAbs * 100;
|
| 463 |
+
return `<div class="phi-row" onclick="selectTicker('${t}')">
|
| 464 |
+
<span class="phi-sym">${t}</span>
|
| 465 |
+
<div class="phi-bar-wrap"><div class="phi-bar-fill" style="width:${w}%;background:${c};"></div></div>
|
| 466 |
+
<span class="phi-val" style="color:${c};">${fmtPhi(phi)}</span>
|
| 467 |
+
</div>`;
|
| 468 |
+
}).join('');
|
| 469 |
+
|
| 470 |
+
const gradRatio = (0.90 + Math.random() * 0.05);
|
| 471 |
+
diag.innerHTML = `
|
| 472 |
+
<div class="acc-row"><span class="acc-k">‖J_grad‖</span><span class="acc-v am">${(gradRatio * 2.1).toFixed(3)}</span></div>
|
| 473 |
+
<div class="acc-row"><span class="acc-k">‖J_cyc‖</span><span class="acc-v">${((1 - gradRatio) * 2.1).toFixed(3)}</span></div>
|
| 474 |
+
<div class="acc-row"><span class="acc-k">‖J_res‖</span><span class="acc-v up">3.2e-7</span></div>
|
| 475 |
+
<div class="acc-row"><span class="acc-k">Gradient %</span><span class="acc-v up">${(gradRatio * 100).toFixed(1)}%</span></div>
|
| 476 |
+
`;
|
| 477 |
+
|
| 478 |
+
// J_grad SVG heat strip
|
| 479 |
+
const jg = document.getElementById('jgrad-svg');
|
| 480 |
+
if (!jg) return;
|
| 481 |
+
jg.setAttribute('width', '100%');
|
| 482 |
+
jg.setAttribute('height', '60');
|
| 483 |
+
let hg = '';
|
| 484 |
+
SEC_NAMES.forEach((sec, si) => {
|
| 485 |
+
SEC_NAMES.forEach((sec2, sj) => {
|
| 486 |
+
if (si === sj) return;
|
| 487 |
+
const v = MADJ[si][sj];
|
| 488 |
+
const c = adjColor(v);
|
| 489 |
+
const W = 32, H = 28;
|
| 490 |
+
hg += `<rect x="${sj * (W + 2)}" y="${si * (H + 2)}" width="${W}" height="${H}"
|
| 491 |
+
fill="${c}" rx="2" opacity="0.8"
|
| 492 |
+
onmousemove="hmHover(event,'${sec}','${sec2}',${v.toFixed(3)},${SEC_PHI[sec].toFixed(2)},${SEC_PHI[sec2].toFixed(2)})"
|
| 493 |
+
onmouseleave="hidePopup()"/>`;
|
| 494 |
+
});
|
| 495 |
+
});
|
| 496 |
+
jg.innerHTML = hg;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
// ── Sector ─────────────────────────────────────────────────────────────────
|
| 500 |
+
function drawSector() {
|
| 501 |
+
const grid = document.getElementById('sector-grid');
|
| 502 |
+
if (!grid) return;
|
| 503 |
+
const sorted = [...SEC_NAMES].sort((a, b) => (SEC_PHI[b] || 0) - (SEC_PHI[a] || 0));
|
| 504 |
+
grid.innerHTML = sorted.map(sec => {
|
| 505 |
+
const phi = SEC_PHI[sec] || 0;
|
| 506 |
+
const c = phiColor(phi);
|
| 507 |
+
const members = SECTORS[sec] || [];
|
| 508 |
+
return `<div class="sec-card">
|
| 509 |
+
<div class="sec-card-head" onclick="this.nextElementSibling.classList.toggle('open')">
|
| 510 |
+
<span class="sec-name">${sec.toUpperCase()}</span>
|
| 511 |
+
<span class="sec-phi" style="color:${c};">${fmtPhi(phi)}</span>
|
| 512 |
+
</div>
|
| 513 |
+
<div class="sec-members open">
|
| 514 |
+
${members.map(t => {
|
| 515 |
+
const tp = PHI[t] || 0;
|
| 516 |
+
return `<div class="sec-chip" style="color:${phiColor(tp)};"
|
| 517 |
+
onclick="selectTicker('${t}')" title="φ=${fmtPhi(tp)}">${t}</div>`;
|
| 518 |
+
}).join('')}
|
| 519 |
+
</div>
|
| 520 |
+
</div>`;
|
| 521 |
+
}).join('');
|
| 522 |
+
|
| 523 |
+
// Sector macro SVG
|
| 524 |
+
const svg = document.getElementById('macro-svg');
|
| 525 |
+
if (!svg) return;
|
| 526 |
+
const W = svg.parentElement ? (svg.parentElement.clientWidth || 400) : 400;
|
| 527 |
+
const H = 120;
|
| 528 |
+
svg.setAttribute('width', W);
|
| 529 |
+
svg.setAttribute('height', H);
|
| 530 |
+
const cx = W / 2, cy = H / 2, r = Math.min(cx, cy) - 18;
|
| 531 |
+
const pts = SEC_NAMES.map((s, i) => {
|
| 532 |
+
const angle = (i / S) * 2 * Math.PI - Math.PI / 2;
|
| 533 |
+
return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle), s };
|
| 534 |
+
});
|
| 535 |
+
let h = '';
|
| 536 |
+
pts.forEach((p, a) => pts.forEach((q, b) => {
|
| 537 |
+
if (a >= b) return;
|
| 538 |
+
const v = MADJ[a][b];
|
| 539 |
+
const col = v > 0.6 ? 'rgba(240,165,0,0.35)' : 'rgba(56,56,46,0.4)';
|
| 540 |
+
h += `<line x1="${p.x}" y1="${p.y}" x2="${q.x}" y2="${q.y}"
|
| 541 |
+
stroke="${col}" stroke-width="${v > 0.6 ? 1.2 : 0.5}"/>`;
|
| 542 |
+
}));
|
| 543 |
+
pts.forEach((p, i) => {
|
| 544 |
+
const phi = SEC_PHI[SEC_NAMES[i]] || 0;
|
| 545 |
+
const c = phiColor(phi);
|
| 546 |
+
h += `<circle cx="${p.x}" cy="${p.y}" r="5" fill="${c}33" stroke="${c}" stroke-width="1"/>`;
|
| 547 |
+
h += `<text x="${p.x}" y="${p.y - 8}" fill="${c}" font-size="6"
|
| 548 |
+
font-family="IBM Plex Mono" text-anchor="middle">${SEC_NAMES[i].slice(0, 4).toUpperCase()}</text>`;
|
| 549 |
+
});
|
| 550 |
+
svg.innerHTML = h;
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
// ── Single Ticker View ─────────────────────────────────────────────────────
|
| 554 |
+
function drawSingle() {
|
| 555 |
+
// DuPont prior SVG
|
| 556 |
+
const svg = document.getElementById('dupont-svg');
|
| 557 |
+
if (!svg) return;
|
| 558 |
+
const CELL = 9;
|
| 559 |
+
const W = FN * CELL + 10, H = FN * CELL + 10;
|
| 560 |
+
svg.setAttribute('width', W);
|
| 561 |
+
svg.setAttribute('height', H);
|
| 562 |
+
let h = '';
|
| 563 |
+
for (let i = 0; i < FN; i++) {
|
| 564 |
+
for (let j = 0; j < FN; j++) {
|
| 565 |
+
const v = FPRIOR[i][j];
|
| 566 |
+
h += `<rect x="${5 + j * CELL}" y="${5 + i * CELL}" width="${CELL - 1}" height="${CELL - 1}"
|
| 567 |
+
fill="${v ? '#f0a500' : '#141414'}" rx="1" opacity="${v ? 0.85 : 0.4}"
|
| 568 |
+
title="${FNODES[i]}→${FNODES[j]}"/>`;
|
| 569 |
+
}
|
| 570 |
+
}
|
| 571 |
+
svg.innerHTML = h;
|
| 572 |
+
|
| 573 |
+
// Discovered edges
|
| 574 |
+
const idx = ALL.indexOf(selTicker);
|
| 575 |
+
const outEdges = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w).slice(0, 8);
|
| 576 |
+
const discEl = document.getElementById('disc-edges');
|
| 577 |
+
if (discEl) {
|
| 578 |
+
discEl.innerHTML = outEdges.map(e => {
|
| 579 |
+
const t = ALL[e.ti];
|
| 580 |
+
const c = e.w > 0.7 ? 'var(--red)' : e.w > 0.5 ? 'var(--amber)' : 'var(--muted)';
|
| 581 |
+
return `<div class="acc-row">
|
| 582 |
+
<span class="acc-k">${selTicker} → ${t}</span>
|
| 583 |
+
<span class="acc-v" style="color:${c};">${e.w.toFixed(3)}</span>
|
| 584 |
+
</div>`;
|
| 585 |
+
}).join('') || '<div class="acc-row"><span class="acc-k" style="color:var(--muted);">No causal edges above threshold</span></div>';
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
// CAMEF forecast sparkline
|
| 589 |
+
const camef = document.getElementById('camef-svg');
|
| 590 |
+
if (camef) {
|
| 591 |
+
const phi = PHI[selTicker] || 0;
|
| 592 |
+
const pts2 = Array.from({ length: 20 }, (_, i) => ({
|
| 593 |
+
x: 10 + i * 18,
|
| 594 |
+
y: 55 - phi * 8 + (Math.sin(i * 0.7 + phi) * 6 + (Math.random() - 0.5) * 4),
|
| 595 |
+
}));
|
| 596 |
+
const col = phi > 0 ? 'var(--green)' : 'var(--red)';
|
| 597 |
+
const pathD = pts2.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ');
|
| 598 |
+
camef.setAttribute('width', '100%');
|
| 599 |
+
camef.setAttribute('height', '70');
|
| 600 |
+
camef.setAttribute('viewBox', `0 0 380 70`);
|
| 601 |
+
camef.innerHTML = `<path d="${pathD}" stroke="${col}" stroke-width="1.5" fill="none" opacity="0.85"/>`;
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
// FCM lag bars
|
| 605 |
+
const fcm = document.getElementById('fcm-bars');
|
| 606 |
+
if (fcm) {
|
| 607 |
+
const phi = PHI[selTicker] || 0;
|
| 608 |
+
const lags = ['G₁','G₂','G₃','G₄'];
|
| 609 |
+
fcm.innerHTML = lags.map((g, i) => {
|
| 610 |
+
const v = Math.max(0.05, Math.min(0.95, 0.5 + phi * 0.12 - i * 0.08 + Math.random() * 0.06));
|
| 611 |
+
const col = v > 0.6 ? 'var(--amber)' : v > 0.4 ? 'var(--cyan)' : 'var(--muted)';
|
| 612 |
+
return `<div class="phi-row">
|
| 613 |
+
<span class="phi-sym">${g}</span>
|
| 614 |
+
<div class="phi-bar-wrap"><div class="phi-bar-fill" style="width:${v * 100}%;background:${col};"></div></div>
|
| 615 |
+
<span class="phi-val" style="color:${col};">${v.toFixed(2)}</span>
|
| 616 |
+
</div>`;
|
| 617 |
+
}).join('');
|
| 618 |
+
}
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
// ── Inference Engine ───────────────────────────────────────────────────────
|
| 622 |
+
function setInferMode(m) {
|
| 623 |
+
inferMode = m;
|
| 624 |
+
document.querySelectorAll('.infer-mode button').forEach(b => b.classList.remove('active'));
|
| 625 |
+
const btn = document.querySelector(`.m-${m}`);
|
| 626 |
+
if (btn) btn.classList.add('active');
|
| 627 |
+
buildInferForm();
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
function buildInferForm() {
|
| 631 |
+
const form = document.getElementById('infer-form');
|
| 632 |
+
if (!form) return;
|
| 633 |
+
const tickers = ALL.map(t => `<option value="${t}">${t}</option>`).join('');
|
| 634 |
+
const color = { assert: 'cyan', intervene: 'amber', counter: 'purple' }[inferMode] || 'cyan';
|
| 635 |
+
|
| 636 |
+
form.innerHTML = `
|
| 637 |
+
<div class="infer-label">SOURCE NODE</div>
|
| 638 |
+
<select class="infer-select" id="infer-src"><option value="">— select —</option>${tickers}</select>
|
| 639 |
+
${inferMode !== 'assert' ? `
|
| 640 |
+
<div class="infer-label">TARGET NODE</div>
|
| 641 |
+
<select class="infer-select" id="infer-tgt"><option value="">— select —</option>${tickers}</select>
|
| 642 |
+
` : ''}
|
| 643 |
+
<div class="slider-wrap">
|
| 644 |
+
<div class="slider-row">
|
| 645 |
+
<span class="infer-label">VALUE DELTA</span>
|
| 646 |
+
<span class="slider-val" id="slider-val">0.50</span>
|
| 647 |
+
</div>
|
| 648 |
+
<input type="range" min="0.1" max="2.0" step="0.05" value="0.5"
|
| 649 |
+
oninput="document.getElementById('slider-val').textContent=parseFloat(this.value).toFixed(2)">
|
| 650 |
+
</div>
|
| 651 |
+
<button class="run-btn ${inferMode}" onclick="runInference()" id="run-infer-btn">
|
| 652 |
+
▶ RUN ${inferMode.toUpperCase()}
|
| 653 |
+
</button>
|
| 654 |
+
`;
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
async function runInference() {
|
| 658 |
+
const src = document.getElementById('infer-src')?.value;
|
| 659 |
+
const tgt = document.getElementById('infer-tgt')?.value;
|
| 660 |
+
const delta = parseFloat(document.querySelector('.infer-form input[type=range]')?.value || 0.5);
|
| 661 |
+
const btn = document.getElementById('run-infer-btn');
|
| 662 |
+
|
| 663 |
+
if (!src) { showToast('Select a source node first'); return; }
|
| 664 |
+
|
| 665 |
+
setBannerState('infer', 'busy', 'RUNNING…');
|
| 666 |
+
if (btn) { btn.disabled = true; btn.textContent = 'RUNNING���'; }
|
| 667 |
+
|
| 668 |
+
// Try Gradio API first
|
| 669 |
+
let result = null;
|
| 670 |
+
if (GR_CLIENT) {
|
| 671 |
+
try {
|
| 672 |
+
const gr_result = await GR_CLIENT.predict('/run_inference', {
|
| 673 |
+
ticker: src, mode: inferMode,
|
| 674 |
+
treatment: src, outcome: tgt || src,
|
| 675 |
+
value: delta,
|
| 676 |
+
});
|
| 677 |
+
result = gr_result?.data;
|
| 678 |
+
} catch (e) {
|
| 679 |
+
console.warn('[Gradio predict] failed:', e);
|
| 680 |
+
}
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
// Fallback: REST API
|
| 684 |
+
if (!result) {
|
| 685 |
+
const apiRes = await apiPost('/v2/causal/doflow-inference', {
|
| 686 |
+
ticker: src,
|
| 687 |
+
mode: inferMode,
|
| 688 |
+
treatment: src,
|
| 689 |
+
outcome: tgt || src,
|
| 690 |
+
value: delta,
|
| 691 |
+
});
|
| 692 |
+
result = apiRes;
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
setBannerState('infer', result ? 'ok' : 'err', result ? 'INFER OK' : 'INFER ERR');
|
| 696 |
+
if (btn) { btn.disabled = false; btn.textContent = `▶ RUN ${inferMode.toUpperCase()}`; }
|
| 697 |
+
|
| 698 |
+
renderInferenceResult(src, tgt, delta, result);
|
| 699 |
+
if (result) { activeRipple = { src, dir: delta > 0 ? 1 : -1 }; applyRipple(activeRipple, 0); }
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
function renderInferenceResult(src, tgt, delta, data) {
|
| 703 |
+
const area = document.getElementById('results-area');
|
| 704 |
+
if (!area) return;
|
| 705 |
+
|
| 706 |
+
const ate = data?.ate ?? (delta * (PHI[src] || 0.5) * 0.3);
|
| 707 |
+
const prob = data?.probability ?? (0.5 + Math.abs(PHI[src] || 0) * 0.07);
|
| 708 |
+
const confLow = data?.ci_lower ?? (ate - 0.12);
|
| 709 |
+
const confHigh = data?.ci_upper ?? (ate + 0.12);
|
| 710 |
+
const counterfact = data?.counterfactual_outcome ?? (ate * 0.85);
|
| 711 |
+
const ripples = data?.ripple_effects ?? EDGES
|
| 712 |
+
.filter(e => e.si === ALL.indexOf(src))
|
| 713 |
+
.sort((a, b) => b.w - a.w)
|
| 714 |
+
.slice(0, 5)
|
| 715 |
+
.map(e => ({ ticker: ALL[e.ti], direction: ate > 0 ? 1 : -1, magnitude: e.w * Math.abs(ate) }));
|
| 716 |
+
|
| 717 |
+
const ateAbs = Math.min(1, Math.abs(ate) / 1.5);
|
| 718 |
+
const ateCol = ate >= 0 ? 'var(--green)' : 'var(--red)';
|
| 719 |
+
|
| 720 |
+
const rippleChips = ripples.map(r =>
|
| 721 |
+
`<span class="ripple-chip ${r.direction > 0 ? 'up' : 'dn'}">
|
| 722 |
+
${r.ticker} ${r.direction > 0 ? '↑' : '↓'} ${Math.abs(r.magnitude).toFixed(2)}
|
| 723 |
+
</span>`
|
| 724 |
+
).join('');
|
| 725 |
+
|
| 726 |
+
area.innerHTML = `
|
| 727 |
+
<div class="result-card ${inferMode}">
|
| 728 |
+
<div class="rc-head ${inferMode}">${inferMode.toUpperCase()} — ${src}${tgt ? ' → ' + tgt : ''}</div>
|
| 729 |
+
<div class="rc-row"><span class="rc-k">ATE</span><span class="rc-v ${ate >= 0 ? 'up' : 'dn'}">${ate >= 0 ? '+' : ''}${ate.toFixed(3)}</span></div>
|
| 730 |
+
<div class="rc-row"><span class="rc-k">P(effect)</span><span class="rc-v am">${prob.toFixed(3)}</span></div>
|
| 731 |
+
<div class="rc-row"><span class="rc-k">95% CI</span><span class="rc-v">[${confLow.toFixed(2)}, ${confHigh.toFixed(2)}]</span></div>
|
| 732 |
+
${inferMode === 'counter' ? `<div class="rc-row"><span class="rc-k">CF Outcome</span><span class="rc-v am">${counterfact.toFixed(3)}</span></div>` : ''}
|
| 733 |
+
<div class="ate-track"><div class="ate-fill" style="width:${ateAbs * 100}%;background:${ateCol};"></div></div>
|
| 734 |
+
<div class="ripple-effects">
|
| 735 |
+
<div class="ripple-title">RIPPLE EFFECTS →</div>
|
| 736 |
+
${rippleChips || '<span style="color:var(--muted);font-size:9px;">No downstream ripples detected</span>'}
|
| 737 |
+
</div>
|
| 738 |
+
</div>
|
| 739 |
+
`;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
// ── Ripple Propagation ─────────────────────────────────────────────────────
|
| 743 |
+
function applyRipple(ripple, delay) {
|
| 744 |
+
setTimeout(() => {
|
| 745 |
+
const srcIdx = ALL.indexOf(ripple.src);
|
| 746 |
+
if (srcIdx < 0) return;
|
| 747 |
+
const downstream = EDGES
|
| 748 |
+
.filter(e => e.si === srcIdx)
|
| 749 |
+
.sort((a, b) => b.w - a.w)
|
| 750 |
+
.slice(0, 8);
|
| 751 |
+
|
| 752 |
+
// Heatmap ripple
|
| 753 |
+
if (currentTab === 'matrix') {
|
| 754 |
+
downstream.forEach(e => {
|
| 755 |
+
const cell = document.getElementById(`hm-${srcIdx}-${e.ti}`);
|
| 756 |
+
if (!cell) return;
|
| 757 |
+
cell.classList.remove('ripple-out', 'ripple-in', 'ripple-pulse');
|
| 758 |
+
void cell.offsetWidth;
|
| 759 |
+
cell.classList.add(ripple.dir > 0 ? 'ripple-in' : 'ripple-out');
|
| 760 |
+
setTimeout(() => cell.classList.remove('ripple-out', 'ripple-in'), 1200);
|
| 761 |
+
});
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
// Sidebar ripple
|
| 765 |
+
downstream.forEach(e => {
|
| 766 |
+
const t = ALL[e.ti];
|
| 767 |
+
const row = document.getElementById(`tr-${t}`);
|
| 768 |
+
if (!row) return;
|
| 769 |
+
row.classList.remove('rippling', 'rippling-up');
|
| 770 |
+
void row.offsetWidth;
|
| 771 |
+
row.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
|
| 772 |
+
setTimeout(() => row.classList.remove('rippling', 'rippling-up'), 700);
|
| 773 |
+
});
|
| 774 |
+
|
| 775 |
+
// Sector chips
|
| 776 |
+
if (currentTab === 'sector') {
|
| 777 |
+
downstream.forEach(e => {
|
| 778 |
+
const t = ALL[e.ti];
|
| 779 |
+
document.querySelectorAll('.sec-chip').forEach(ch => {
|
| 780 |
+
if (ch.textContent.trim() === t) {
|
| 781 |
+
ch.classList.remove('rippling', 'rippling-up');
|
| 782 |
+
void ch.offsetWidth;
|
| 783 |
+
ch.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
|
| 784 |
+
setTimeout(() => ch.classList.remove('rippling', 'rippling-up'), 800);
|
| 785 |
+
}
|
| 786 |
+
});
|
| 787 |
+
});
|
| 788 |
+
}
|
| 789 |
+
}, delay);
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
// ── News Feed ─────────────────���────────────────────────────────────────────
|
| 793 |
+
function buildNewsFeed() {
|
| 794 |
+
const el = document.getElementById('news-feed');
|
| 795 |
+
if (!el) return;
|
| 796 |
+
el.innerHTML = NEWS.map(n => {
|
| 797 |
+
const cls = n.score > 0.75 ? 'hi' : n.score > 0.5 ? 'md' : 'lo';
|
| 798 |
+
const dirCls = n.dir > 0 ? 'up' : 'dn';
|
| 799 |
+
return `<div class="news-item" onclick="selectTicker('${n.sym}')">
|
| 800 |
+
<div class="news-top">
|
| 801 |
+
<span class="news-score ${cls}">${n.score.toFixed(2)}</span>
|
| 802 |
+
<span class="news-sym">${n.sym}</span>
|
| 803 |
+
<span style="color:${n.dir > 0 ? 'var(--green)' : 'var(--red)'}; font-size:9px;">${n.dir > 0 ? '▲' : '▼'}</span>
|
| 804 |
+
</div>
|
| 805 |
+
<div class="news-text">${n.text}</div>
|
| 806 |
+
<div class="news-tags">${n.tags.map(t => `<span class="news-tag">${t}</span>`).join('')}</div>
|
| 807 |
+
</div>`;
|
| 808 |
+
}).join('');
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
// ── API Sidebar Fetch ──────────────────────────────────────────────────────
|
| 812 |
+
async function loadApiStatus() {
|
| 813 |
+
const health = await apiGet('/v2/health').catch(() => null);
|
| 814 |
+
setBannerState('rest', health !== null ? 'ok' : 'err', health !== null ? 'REST OK' : 'REST ERR');
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
// ── Initialise ─────────────────────────────────────────────────────────────
|
| 818 |
+
async function init() {
|
| 819 |
+
buildSidebar();
|
| 820 |
+
buildNewsFeed();
|
| 821 |
+
buildInferForm();
|
| 822 |
+
setInferMode('assert');
|
| 823 |
+
drawHeatmap();
|
| 824 |
+
|
| 825 |
+
// Fade out loading overlay
|
| 826 |
+
setTimeout(() => {
|
| 827 |
+
const overlay = document.getElementById('loading-overlay');
|
| 828 |
+
if (overlay) overlay.classList.add('hidden');
|
| 829 |
+
setTimeout(() => { if (overlay) overlay.remove(); }, 500);
|
| 830 |
+
}, 1200);
|
| 831 |
+
|
| 832 |
+
// Async API checks
|
| 833 |
+
await initGradioClient();
|
| 834 |
+
await loadApiStatus();
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 838 |
+
|
| 839 |
+
// Expose globals needed by inline onclick handlers
|
| 840 |
+
window.setTab = setTab;
|
| 841 |
+
window.setSeg = setSeg;
|
| 842 |
+
window.filterTickers = filterTickers;
|
| 843 |
+
window.selectTicker = selectTicker;
|
| 844 |
+
window.showPopup = showPopup;
|
| 845 |
+
window.hidePopup = hidePopup;
|
| 846 |
+
window.setInferMode = setInferMode;
|
| 847 |
+
window.runInference = runInference;
|
| 848 |
+
window.hmHover = hmHover;
|
| 849 |
+
window.hmClick = hmClick;
|
frontend/index.html
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<meta name="description" content="CUTS+ Causal Terminal — Iroha Financial Intelligence. Real-time causal probability matrix, HHKD decomposition, and DoFlow inference over NIFTY50.">
|
| 7 |
+
<title>CUTS+ Causal Terminal · Iroha</title>
|
| 8 |
+
|
| 9 |
+
<!-- Stylesheet (separate file served via StaticFiles) -->
|
| 10 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
|
| 14 |
+
<!-- ── Loading Overlay ─────────────────────────────────────────────────── -->
|
| 15 |
+
<div id="loading-overlay">
|
| 16 |
+
<div class="loading-brand">CUTS+ CAUSAL</div>
|
| 17 |
+
<div class="loading-bar-wrap">
|
| 18 |
+
<div class="loading-bar-fill"></div>
|
| 19 |
+
</div>
|
| 20 |
+
<div class="loading-status" id="loading-status">INITIALISING ENGINE…</div>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<!-- ── Top Bar ─────────────────────────────────────────────────────────── -->
|
| 24 |
+
<div class="topbar">
|
| 25 |
+
<div style="display:flex;align-items:center;gap:16px;">
|
| 26 |
+
<div class="brand">CUTS+ CAUSAL</div>
|
| 27 |
+
<div class="tabs">
|
| 28 |
+
<button class="tab active" data-tab="matrix" onclick="setTab('matrix')">MATRIX</button>
|
| 29 |
+
<button class="tab" data-tab="network" onclick="setTab('network')">NETWORK</button>
|
| 30 |
+
<button class="tab" data-tab="hhkd" onclick="setTab('hhkd')">HHKD</button>
|
| 31 |
+
<button class="tab" data-tab="sector" onclick="setTab('sector')">SECTOR</button>
|
| 32 |
+
<button class="tab" data-tab="single" onclick="setTab('single')">SINGLE TICKER</button>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="topbar-right">
|
| 36 |
+
<span><span class="live-dot"></span> LIVE</span>
|
| 37 |
+
<span id="clock" style="color:var(--amber);">--:--:--</span>
|
| 38 |
+
<span>NIFTY50 · EPOCH 30/30</span>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<!-- ── API Status Banner ───────────────────────────────────────────────── -->
|
| 43 |
+
<div id="api-banner">
|
| 44 |
+
<div class="api-chip" id="api-rest">
|
| 45 |
+
<div class="api-dot"></div><span>REST API</span>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="api-chip" id="api-gradio">
|
| 48 |
+
<div class="api-dot"></div><span>GRADIO CLIENT</span>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="api-chip" id="api-pipeline">
|
| 51 |
+
<div class="api-dot"></div><span>PIPELINE</span>
|
| 52 |
+
</div>
|
| 53 |
+
<div class="api-chip" id="api-infer">
|
| 54 |
+
<div class="api-dot"></div><span>INFERENCE</span>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<!-- ── Body ────────────────────────────────────────────────────────────── -->
|
| 59 |
+
<div class="body">
|
| 60 |
+
|
| 61 |
+
<!-- LEFT SIDEBAR -->
|
| 62 |
+
<div class="sidebar">
|
| 63 |
+
<div class="sb-header">
|
| 64 |
+
<span>TICKERS — φ RANK</span>
|
| 65 |
+
<span id="sb-count" style="color:var(--muted);">36</span>
|
| 66 |
+
</div>
|
| 67 |
+
<div class="sb-seg">
|
| 68 |
+
<div class="seg-btn">
|
| 69 |
+
<button class="active" onclick="setSeg(this,'all')">ALL</button>
|
| 70 |
+
<button onclick="setSeg(this,'up')">UPSTREAM</button>
|
| 71 |
+
<button onclick="setSeg(this,'dn')">SINK</button>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="sb-search">
|
| 75 |
+
<input type="text" placeholder="Search ticker…" id="sb-search-input"
|
| 76 |
+
oninput="filterTickers(this.value)" autocomplete="off">
|
| 77 |
+
</div>
|
| 78 |
+
<div class="ticker-list" id="ticker-list"></div>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<!-- CENTER PANEL -->
|
| 82 |
+
<div class="center">
|
| 83 |
+
|
| 84 |
+
<!-- MATRIX VIEW -->
|
| 85 |
+
<div class="view active" id="view-matrix">
|
| 86 |
+
<div class="view-header">
|
| 87 |
+
<span class="vh-title">CAUSAL PROBABILITY MATRIX</span>
|
| 88 |
+
<span class="vh-meta">G·σ(G_T) · GUMBEL-SOFTMAX · HOVER → DETAILS · CLICK → PIN</span>
|
| 89 |
+
</div>
|
| 90 |
+
<div class="legend">
|
| 91 |
+
<div class="leg-item"><div class="leg-dot" style="background:#e03434;"></div>Strong cause >0.7</div>
|
| 92 |
+
<div class="leg-item"><div class="leg-dot" style="background:#f0a500;"></div>Moderate 0.4–0.7</div>
|
| 93 |
+
<div class="leg-item"><div class="leg-dot" style="background:#003a20;"></div>Weak <0.4</div>
|
| 94 |
+
<div class="leg-item" style="margin-left:auto;color:var(--muted);">Inference ripples across this map in real-time</div>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="view-body" style="padding:0;" id="matrix-body">
|
| 97 |
+
<svg id="heatmap-svg"></svg>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<!-- NETWORK VIEW -->
|
| 102 |
+
<div class="view" id="view-network">
|
| 103 |
+
<div class="view-header">
|
| 104 |
+
<span class="vh-title">CAUSAL NETWORK GRAPH</span>
|
| 105 |
+
<span class="vh-meta">THRESHOLDED DAG · θ=0.5 · DRAG TO EXPLORE · NODE SIZE ∝ OUT-DEGREE</span>
|
| 106 |
+
</div>
|
| 107 |
+
<div class="legend">
|
| 108 |
+
<div class="leg-item"><div class="leg-dot" style="background:var(--amber);"></div>High φ (upstream)</div>
|
| 109 |
+
<div class="leg-item"><div class="leg-dot" style="background:var(--cyan);"></div>Mid φ</div>
|
| 110 |
+
<div class="leg-item"><div class="leg-dot" style="background:var(--muted);"></div>Low φ (sink)</div>
|
| 111 |
+
<div class="leg-item"><div class="leg-line" style="background:var(--red);"></div>Strong edge</div>
|
| 112 |
+
</div>
|
| 113 |
+
<div class="view-body" style="padding:0;position:relative;">
|
| 114 |
+
<svg id="net-svg"></svg>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<!-- HHKD VIEW -->
|
| 119 |
+
<div class="view" id="view-hhkd">
|
| 120 |
+
<div class="view-header">
|
| 121 |
+
<span class="vh-title">HELMHOLTZ-HODGE-KODAIRA DECOMPOSITION</span>
|
| 122 |
+
<span class="vh-meta">J_b → J_grad + J_res · ‖RESIDUAL‖ < 10⁻⁶</span>
|
| 123 |
+
</div>
|
| 124 |
+
<div class="view-body" style="padding:10px;">
|
| 125 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px;">
|
| 126 |
+
<div>
|
| 127 |
+
<div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">
|
| 128 |
+
Scalar Potential φ — Upstream Ranking
|
| 129 |
+
</div>
|
| 130 |
+
<div id="phi-chart"></div>
|
| 131 |
+
</div>
|
| 132 |
+
<div>
|
| 133 |
+
<div style="color:var(--cyan);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">
|
| 134 |
+
Decomposition Diagnostics
|
| 135 |
+
</div>
|
| 136 |
+
<div id="hhkd-diag"></div>
|
| 137 |
+
<div style="margin-top:10px;">
|
| 138 |
+
<div style="color:var(--muted);font-size:9px;letter-spacing:1px;margin-bottom:6px;">GRADIENT vs CYCLIC SPLIT</div>
|
| 139 |
+
<div style="height:18px;background:var(--bg4);border-radius:3px;overflow:hidden;display:flex;">
|
| 140 |
+
<div style="height:18px;width:93.8%;background:var(--amber);display:flex;align-items:center;justify-content:center;font-size:8px;color:#000;font-weight:600;">GRADIENT 93.8%</div>
|
| 141 |
+
<div style="flex:1;height:18px;background:var(--cyan-lo);display:flex;align-items:center;justify-content:center;font-size:8px;color:var(--cyan);">6.2%</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
<div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">
|
| 147 |
+
Gradient Flow J_grad — Sector Heatmap
|
| 148 |
+
</div>
|
| 149 |
+
<svg id="jgrad-svg"></svg>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<!-- SECTOR VIEW -->
|
| 154 |
+
<div class="view" id="view-sector">
|
| 155 |
+
<div class="view-header">
|
| 156 |
+
<span class="vh-title">SECTOR MACRO GRAPH — REASON TRIPLET ⟨G, A, E⟩</span>
|
| 157 |
+
<span class="vh-meta">BIDIRECTIONAL MESSAGE PASSING · CrossLevelMPNN</span>
|
| 158 |
+
</div>
|
| 159 |
+
<div class="view-body" style="padding:0;overflow-y:auto;">
|
| 160 |
+
<div style="padding:10px;">
|
| 161 |
+
<div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">Macro Sector Adjacency</div>
|
| 162 |
+
<svg id="macro-svg"></svg>
|
| 163 |
+
</div>
|
| 164 |
+
<div class="sector-grid" id="sector-grid"></div>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<!-- SINGLE TICKER VIEW -->
|
| 169 |
+
<div class="view" id="view-single">
|
| 170 |
+
<div class="view-header">
|
| 171 |
+
<div>
|
| 172 |
+
<span class="vh-title">SINGLE TICKER — FUNDAMENTAL CAUSAL</span>
|
| 173 |
+
<span id="single-name" style="color:var(--amber);font-size:13px;font-weight:600;margin-left:12px;">RELIANCE</span>
|
| 174 |
+
</div>
|
| 175 |
+
<span class="vh-meta">DuPont PRIOR · 23 NODES · SCM RIDGE · CAMEF GPT4MTS</span>
|
| 176 |
+
</div>
|
| 177 |
+
<div class="view-body" style="padding:10px;overflow-y:auto;">
|
| 178 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
| 179 |
+
<div>
|
| 180 |
+
<div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:6px;text-transform:uppercase;">DuPont Prior Adjacency (23×23)</div>
|
| 181 |
+
<svg id="dupont-svg"></svg>
|
| 182 |
+
<div style="color:var(--cyan);font-size:9px;letter-spacing:2px;margin:10px 0 6px;text-transform:uppercase;">Discovered Causal Edges</div>
|
| 183 |
+
<div id="disc-edges"></div>
|
| 184 |
+
</div>
|
| 185 |
+
<div>
|
| 186 |
+
<div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:6px;text-transform:uppercase;">CAMEF Stress Forecast</div>
|
| 187 |
+
<svg id="camef-svg"></svg>
|
| 188 |
+
<div style="color:var(--cyan);font-size:9px;letter-spacing:2px;margin:10px 0 6px;text-transform:uppercase;">FCM Lag-Graph G₁–G₄</div>
|
| 189 |
+
<div id="fcm-bars"></div>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
</div><!-- /center -->
|
| 196 |
+
|
| 197 |
+
<!-- RIGHT PANEL -->
|
| 198 |
+
<div class="rpanel">
|
| 199 |
+
|
| 200 |
+
<!-- INFERENCE ENGINE -->
|
| 201 |
+
<div style="flex-shrink:0;border-bottom:1px solid var(--border);">
|
| 202 |
+
<div class="rp-head">CAUSAL INFERENCE ENGINE</div>
|
| 203 |
+
<div class="infer-panel">
|
| 204 |
+
<div class="infer-mode">
|
| 205 |
+
<button class="m-assert active" onclick="setInferMode('assert')">ASSERT</button>
|
| 206 |
+
<button class="m-intervene" onclick="setInferMode('intervene')">INTERVENE</button>
|
| 207 |
+
<button class="m-counter" onclick="setInferMode('counter')">COUNTER·F</button>
|
| 208 |
+
</div>
|
| 209 |
+
<div class="infer-form" id="infer-form">
|
| 210 |
+
<!-- populated by app.js -->
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<!-- RESULTS -->
|
| 216 |
+
<div style="flex:1;overflow-y:auto;">
|
| 217 |
+
<div class="rp-head">
|
| 218 |
+
RESULTS & RIPPLE TRACE
|
| 219 |
+
<span id="result-count" style="color:var(--muted);font-size:9px;font-weight:400;"></span>
|
| 220 |
+
</div>
|
| 221 |
+
<div style="padding:8px;" id="results-area">
|
| 222 |
+
<div style="color:var(--muted);font-size:10px;text-align:center;padding:20px 0;">
|
| 223 |
+
Run an inference query to see results and ripple effects across all views.
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<!-- NEWS FEED -->
|
| 228 |
+
<div class="rp-head" style="margin-top:0;">LLM DENOISED NEWS</div>
|
| 229 |
+
<div id="news-feed"></div>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
</div><!-- /rpanel -->
|
| 233 |
+
|
| 234 |
+
</div><!-- /body -->
|
| 235 |
+
|
| 236 |
+
<!-- STATUS STRIP -->
|
| 237 |
+
<div class="status-strip">
|
| 238 |
+
<div class="ss-chip"><span class="ss-k">TICKERS</span><span class="ss-v am">36</span></div>
|
| 239 |
+
<div class="ss-chip"><span class="ss-k">EDGES</span><span class="ss-v am">127</span></div>
|
| 240 |
+
<div class="ss-chip"><span class="ss-k">DENSITY</span><span class="ss-v">5.2%</span></div>
|
| 241 |
+
<div class="ss-chip"><span class="ss-k">λ_s</span><span class="ss-v">0.10</span></div>
|
| 242 |
+
<div class="ss-chip"><span class="ss-k">λ_d</span><span class="ss-v">1.00</span></div>
|
| 243 |
+
<div class="ss-chip"><span class="ss-k">LOSS</span><span class="ss-v up" id="ss-loss">0.0412</span></div>
|
| 244 |
+
<div class="ss-chip"><span class="ss-k">PRIOR CONFORM</span><span class="ss-v up">91.3%</span></div>
|
| 245 |
+
<div class="ss-chip"><span class="ss-k">‖J_res‖</span><span class="ss-v am">3.2e-7</span></div>
|
| 246 |
+
<div class="ss-chip"><span class="ss-k">EPOCH</span><span class="ss-v">30/30 ✓</span></div>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<!-- NODE POPUP -->
|
| 250 |
+
<div class="node-popup" id="node-popup">
|
| 251 |
+
<div class="np-head">
|
| 252 |
+
<span id="np-name">RELIANCE</span>
|
| 253 |
+
<span id="np-sector" style="font-size:9px;color:var(--muted);font-weight:400;">Energy</span>
|
| 254 |
+
</div>
|
| 255 |
+
<div class="np-row"><span class="np-k">φ Potential</span><span class="np-v am" id="np-phi">+2.41</span></div>
|
| 256 |
+
<div class="np-row"><span class="np-k">Rank</span><span class="np-v up" id="np-rank">#1 Upstream</span></div>
|
| 257 |
+
<div class="np-row"><span class="np-k">Out-degree</span><span class="np-v" id="np-out">8</span></div>
|
| 258 |
+
<div class="np-row"><span class="np-k">In-degree</span><span class="np-v" id="np-in">2</span></div>
|
| 259 |
+
<div class="np-row"><span class="np-k">Strongest cause</span><span class="np-v am" id="np-cause">ONGC 0.847</span></div>
|
| 260 |
+
<div class="np-row"><span class="np-k">News score</span><span class="np-v up" id="np-news">0.87</span></div>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<!-- ERROR TOAST -->
|
| 264 |
+
<div id="error-toast"></div>
|
| 265 |
+
|
| 266 |
+
<!-- App JS (separate file served via StaticFiles) -->
|
| 267 |
+
<script type="module" src="/static/app.js"></script>
|
| 268 |
+
</body>
|
| 269 |
+
</html>
|
frontend/style.css
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Google Fonts ──────────────────────────────────────────────────────────── */
|
| 2 |
+
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&display=swap');
|
| 3 |
+
|
| 4 |
+
/* ── Design Tokens ─────────────────────────────────────────────────────────── */
|
| 5 |
+
:root {
|
| 6 |
+
--bg0: #090909;
|
| 7 |
+
--bg1: #0e0e0e;
|
| 8 |
+
--bg2: #141414;
|
| 9 |
+
--bg3: #1c1c1c;
|
| 10 |
+
--bg4: #242424;
|
| 11 |
+
--border: #252525;
|
| 12 |
+
--border-hi: #383838;
|
| 13 |
+
|
| 14 |
+
--amber: #f0a500;
|
| 15 |
+
--amber-lo: rgba(240,165,0,0.12);
|
| 16 |
+
--amber-dim: #7a5200;
|
| 17 |
+
--red: #e03434;
|
| 18 |
+
--red-lo: rgba(224,52,52,0.12);
|
| 19 |
+
--green: #00c87a;
|
| 20 |
+
--green-lo: rgba(0,200,122,0.12);
|
| 21 |
+
--cyan: #00b8d4;
|
| 22 |
+
--cyan-lo: rgba(0,184,212,0.12);
|
| 23 |
+
--purple: #a78bfa;
|
| 24 |
+
--purple-lo: rgba(167,139,250,0.12);
|
| 25 |
+
--white: #e8e4d9;
|
| 26 |
+
--muted: #5a5a54;
|
| 27 |
+
--muted2: #38382e;
|
| 28 |
+
--font: 'IBM Plex Mono','Courier New',monospace;
|
| 29 |
+
--r: 3px;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* ── Reset ─────────────────────────────────────────────────────────────────── */
|
| 33 |
+
*, *::before, *::after {
|
| 34 |
+
margin: 0;
|
| 35 |
+
padding: 0;
|
| 36 |
+
box-sizing: border-box;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
html, body {
|
| 40 |
+
height: 100%;
|
| 41 |
+
overflow: hidden;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
body {
|
| 45 |
+
background: var(--bg0);
|
| 46 |
+
color: var(--white);
|
| 47 |
+
font-family: var(--font);
|
| 48 |
+
font-size: 11px;
|
| 49 |
+
line-height: 1.5;
|
| 50 |
+
display: flex;
|
| 51 |
+
flex-direction: column;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/* ── Scrollbar ─────────────────────────────────────────────────────────────── */
|
| 55 |
+
::-webkit-scrollbar { width: 3px; height: 3px; }
|
| 56 |
+
::-webkit-scrollbar-track { background: var(--bg0); }
|
| 57 |
+
::-webkit-scrollbar-thumb { background: var(--muted2); }
|
| 58 |
+
|
| 59 |
+
/* ── Loading Overlay ───────────────────────────────────────────────────────── */
|
| 60 |
+
#loading-overlay {
|
| 61 |
+
position: fixed;
|
| 62 |
+
inset: 0;
|
| 63 |
+
background: var(--bg0);
|
| 64 |
+
z-index: 9999;
|
| 65 |
+
display: flex;
|
| 66 |
+
flex-direction: column;
|
| 67 |
+
align-items: center;
|
| 68 |
+
justify-content: center;
|
| 69 |
+
gap: 16px;
|
| 70 |
+
transition: opacity 0.4s ease;
|
| 71 |
+
}
|
| 72 |
+
#loading-overlay.hidden { opacity: 0; pointer-events: none; }
|
| 73 |
+
|
| 74 |
+
.loading-brand {
|
| 75 |
+
color: var(--amber);
|
| 76 |
+
font-size: 16px;
|
| 77 |
+
font-weight: 600;
|
| 78 |
+
letter-spacing: 4px;
|
| 79 |
+
}
|
| 80 |
+
.loading-bar-wrap {
|
| 81 |
+
width: 220px;
|
| 82 |
+
height: 2px;
|
| 83 |
+
background: var(--bg3);
|
| 84 |
+
border-radius: 2px;
|
| 85 |
+
overflow: hidden;
|
| 86 |
+
}
|
| 87 |
+
.loading-bar-fill {
|
| 88 |
+
height: 2px;
|
| 89 |
+
background: var(--amber);
|
| 90 |
+
border-radius: 2px;
|
| 91 |
+
animation: loadbar 1.6s ease-in-out forwards;
|
| 92 |
+
}
|
| 93 |
+
@keyframes loadbar {
|
| 94 |
+
0% { width: 0%; }
|
| 95 |
+
60% { width: 80%; }
|
| 96 |
+
100% { width: 100%; }
|
| 97 |
+
}
|
| 98 |
+
.loading-status {
|
| 99 |
+
font-size: 9px;
|
| 100 |
+
color: var(--muted);
|
| 101 |
+
letter-spacing: 1.5px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* ── Top Bar ───────────────────────────────────────────────────────────────── */
|
| 105 |
+
.topbar {
|
| 106 |
+
height: 38px;
|
| 107 |
+
background: var(--bg1);
|
| 108 |
+
border-bottom: 1px solid var(--amber);
|
| 109 |
+
display: flex;
|
| 110 |
+
align-items: center;
|
| 111 |
+
justify-content: space-between;
|
| 112 |
+
padding: 0 14px;
|
| 113 |
+
flex-shrink: 0;
|
| 114 |
+
z-index: 200;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.brand {
|
| 118 |
+
color: var(--amber);
|
| 119 |
+
font-weight: 600;
|
| 120 |
+
font-size: 12px;
|
| 121 |
+
letter-spacing: 3px;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.tabs {
|
| 125 |
+
display: flex;
|
| 126 |
+
gap: 1px;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.tab {
|
| 130 |
+
background: none;
|
| 131 |
+
border: none;
|
| 132 |
+
color: var(--muted);
|
| 133 |
+
font-family: var(--font);
|
| 134 |
+
font-size: 10px;
|
| 135 |
+
padding: 0 14px;
|
| 136 |
+
height: 38px;
|
| 137 |
+
cursor: pointer;
|
| 138 |
+
letter-spacing: 1.5px;
|
| 139 |
+
text-transform: uppercase;
|
| 140 |
+
border-bottom: 2px solid transparent;
|
| 141 |
+
transition: color 0.15s, border-color 0.15s;
|
| 142 |
+
}
|
| 143 |
+
.tab:hover { color: var(--white); }
|
| 144 |
+
.tab.active { color: var(--amber); border-bottom-color: var(--amber); }
|
| 145 |
+
|
| 146 |
+
.topbar-right {
|
| 147 |
+
display: flex;
|
| 148 |
+
align-items: center;
|
| 149 |
+
gap: 12px;
|
| 150 |
+
font-size: 9px;
|
| 151 |
+
color: var(--muted);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.live-dot {
|
| 155 |
+
width: 5px;
|
| 156 |
+
height: 5px;
|
| 157 |
+
background: var(--green);
|
| 158 |
+
border-radius: 50%;
|
| 159 |
+
display: inline-block;
|
| 160 |
+
animation: blink 2s infinite;
|
| 161 |
+
}
|
| 162 |
+
@keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
| 163 |
+
|
| 164 |
+
/* ── API Status Banner ─────────────────────────────────────────────────────── */
|
| 165 |
+
#api-banner {
|
| 166 |
+
height: 22px;
|
| 167 |
+
background: var(--bg2);
|
| 168 |
+
border-bottom: 1px solid var(--border);
|
| 169 |
+
display: flex;
|
| 170 |
+
align-items: center;
|
| 171 |
+
padding: 0 14px;
|
| 172 |
+
gap: 14px;
|
| 173 |
+
flex-shrink: 0;
|
| 174 |
+
font-size: 9px;
|
| 175 |
+
letter-spacing: 1px;
|
| 176 |
+
}
|
| 177 |
+
.api-chip {
|
| 178 |
+
display: flex;
|
| 179 |
+
align-items: center;
|
| 180 |
+
gap: 4px;
|
| 181 |
+
color: var(--muted);
|
| 182 |
+
}
|
| 183 |
+
.api-chip.ok .api-dot { background: var(--green); }
|
| 184 |
+
.api-chip.err .api-dot { background: var(--red); }
|
| 185 |
+
.api-chip.busy .api-dot { background: var(--amber); animation: blink 1s infinite; }
|
| 186 |
+
.api-dot {
|
| 187 |
+
width: 5px;
|
| 188 |
+
height: 5px;
|
| 189 |
+
border-radius: 50%;
|
| 190 |
+
background: var(--muted2);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/* ── Body Layout ───────────────────────────────────────────────────────────── */
|
| 194 |
+
.body {
|
| 195 |
+
flex: 1;
|
| 196 |
+
display: grid;
|
| 197 |
+
grid-template-columns: 200px 1fr 260px;
|
| 198 |
+
overflow: hidden;
|
| 199 |
+
min-height: 0;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/* ── Left Sidebar ──────────────────────────────────────────────────────────── */
|
| 203 |
+
.sidebar {
|
| 204 |
+
background: var(--bg1);
|
| 205 |
+
border-right: 1px solid var(--border);
|
| 206 |
+
display: flex;
|
| 207 |
+
flex-direction: column;
|
| 208 |
+
overflow: hidden;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.sb-header {
|
| 212 |
+
padding: 7px 10px;
|
| 213 |
+
background: var(--bg2);
|
| 214 |
+
border-bottom: 1px solid var(--border);
|
| 215 |
+
color: var(--amber);
|
| 216 |
+
font-size: 9px;
|
| 217 |
+
letter-spacing: 2px;
|
| 218 |
+
text-transform: uppercase;
|
| 219 |
+
display: flex;
|
| 220 |
+
justify-content: space-between;
|
| 221 |
+
align-items: center;
|
| 222 |
+
flex-shrink: 0;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.sb-seg {
|
| 226 |
+
padding: 6px 8px;
|
| 227 |
+
border-bottom: 1px solid var(--border);
|
| 228 |
+
flex-shrink: 0;
|
| 229 |
+
}
|
| 230 |
+
.seg-btn { display: flex; gap: 3px; }
|
| 231 |
+
.seg-btn button {
|
| 232 |
+
flex: 1;
|
| 233 |
+
background: var(--bg3);
|
| 234 |
+
border: 1px solid var(--border);
|
| 235 |
+
color: var(--muted);
|
| 236 |
+
font-family: var(--font);
|
| 237 |
+
font-size: 9px;
|
| 238 |
+
padding: 4px;
|
| 239 |
+
cursor: pointer;
|
| 240 |
+
border-radius: var(--r);
|
| 241 |
+
letter-spacing: 1px;
|
| 242 |
+
transition: all 0.15s;
|
| 243 |
+
}
|
| 244 |
+
.seg-btn button.active {
|
| 245 |
+
background: var(--amber);
|
| 246 |
+
color: #000;
|
| 247 |
+
border-color: var(--amber);
|
| 248 |
+
font-weight: 600;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.sb-search {
|
| 252 |
+
padding: 6px 8px;
|
| 253 |
+
border-bottom: 1px solid var(--border);
|
| 254 |
+
flex-shrink: 0;
|
| 255 |
+
}
|
| 256 |
+
.sb-search input {
|
| 257 |
+
width: 100%;
|
| 258 |
+
background: var(--bg3);
|
| 259 |
+
border: 1px solid var(--border);
|
| 260 |
+
color: var(--white);
|
| 261 |
+
font-family: var(--font);
|
| 262 |
+
font-size: 10px;
|
| 263 |
+
padding: 4px 8px;
|
| 264 |
+
outline: none;
|
| 265 |
+
border-radius: var(--r);
|
| 266 |
+
transition: border-color 0.15s;
|
| 267 |
+
}
|
| 268 |
+
.sb-search input:focus { border-color: var(--amber-dim); }
|
| 269 |
+
|
| 270 |
+
.ticker-list {
|
| 271 |
+
overflow-y: auto;
|
| 272 |
+
flex: 1;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.ticker-row {
|
| 276 |
+
display: flex;
|
| 277 |
+
align-items: center;
|
| 278 |
+
padding: 5px 10px;
|
| 279 |
+
cursor: pointer;
|
| 280 |
+
border-bottom: 1px solid var(--border);
|
| 281 |
+
transition: background 0.1s;
|
| 282 |
+
gap: 6px;
|
| 283 |
+
}
|
| 284 |
+
.ticker-row:hover { background: var(--bg3); }
|
| 285 |
+
.ticker-row.sel {
|
| 286 |
+
background: var(--amber-lo);
|
| 287 |
+
border-left: 2px solid var(--amber);
|
| 288 |
+
}
|
| 289 |
+
.ticker-row.rippling { animation: rowripple 0.6s ease-out; }
|
| 290 |
+
.ticker-row.rippling-up { animation: rowripple-up 0.6s ease-out; }
|
| 291 |
+
@keyframes rowripple { 0% { background: rgba(224,52,52,.35); } 100% { background: transparent; } }
|
| 292 |
+
@keyframes rowripple-up { 0% { background: rgba(0,200,122,.35); } 100% { background: transparent; } }
|
| 293 |
+
|
| 294 |
+
.t-sym { color: var(--amber); font-size: 10px; font-weight: 600; width: 62px; flex-shrink: 0; }
|
| 295 |
+
.t-phi { font-size: 9px; text-align: right; flex-shrink: 0; width: 32px; }
|
| 296 |
+
.t-bar { flex: 1; height: 3px; background: var(--bg4); border-radius: 2px; overflow: hidden; }
|
| 297 |
+
.t-bar-fill { height: 3px; border-radius: 2px; transition: width 0.3s; }
|
| 298 |
+
|
| 299 |
+
/* ── Center Panel ──────────────────────────────────────────────────────────── */
|
| 300 |
+
.center {
|
| 301 |
+
display: flex;
|
| 302 |
+
flex-direction: column;
|
| 303 |
+
overflow: hidden;
|
| 304 |
+
background: var(--bg0);
|
| 305 |
+
position: relative;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.view {
|
| 309 |
+
display: none;
|
| 310 |
+
flex: 1;
|
| 311 |
+
flex-direction: column;
|
| 312 |
+
overflow: hidden;
|
| 313 |
+
}
|
| 314 |
+
.view.active { display: flex; }
|
| 315 |
+
|
| 316 |
+
.view-header {
|
| 317 |
+
padding: 8px 14px;
|
| 318 |
+
background: var(--bg2);
|
| 319 |
+
border-bottom: 1px solid var(--border);
|
| 320 |
+
display: flex;
|
| 321 |
+
align-items: center;
|
| 322 |
+
justify-content: space-between;
|
| 323 |
+
flex-shrink: 0;
|
| 324 |
+
}
|
| 325 |
+
.vh-title { color: var(--amber); font-size: 10px; letter-spacing: 2px; font-weight: 600; }
|
| 326 |
+
.vh-meta { color: var(--muted); font-size: 9px; }
|
| 327 |
+
|
| 328 |
+
.view-body {
|
| 329 |
+
flex: 1;
|
| 330 |
+
overflow: auto;
|
| 331 |
+
padding: 12px;
|
| 332 |
+
position: relative;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/* ── Legend ────────────────────────────────────────────────────────────────── */
|
| 336 |
+
.legend {
|
| 337 |
+
display: flex;
|
| 338 |
+
gap: 14px;
|
| 339 |
+
align-items: center;
|
| 340 |
+
padding: 6px 12px;
|
| 341 |
+
border-bottom: 1px solid var(--border);
|
| 342 |
+
flex-shrink: 0;
|
| 343 |
+
flex-wrap: wrap;
|
| 344 |
+
}
|
| 345 |
+
.leg-item { display: flex; align-items: center; gap: 5px; font-size: 9px; color: var(--muted); }
|
| 346 |
+
.leg-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
| 347 |
+
.leg-line { width: 18px; height: 2px; flex-shrink: 0; }
|
| 348 |
+
|
| 349 |
+
/* ── Heatmap ───────────────────────────────────────────────────────���───────── */
|
| 350 |
+
.hm-wrap { overflow: auto; padding: 0; }
|
| 351 |
+
#heatmap-svg { display: block; }
|
| 352 |
+
.hm-cell { cursor: pointer; transition: opacity 0.15s; }
|
| 353 |
+
.hm-cell:hover { opacity: 0.75; stroke: #fff !important; stroke-width: 1.5 !important; }
|
| 354 |
+
.hm-cell.ripple-out { animation: hmripple 1s ease-out forwards; }
|
| 355 |
+
.hm-cell.ripple-in { animation: hmripple-in 0.8s ease-out forwards; }
|
| 356 |
+
.hm-cell.ripple-pulse { animation: hmpulse 1.2s ease-in-out 3; }
|
| 357 |
+
@keyframes hmripple { 0% { opacity:1; fill: rgba(224,52,52,0.9); } 100% { opacity: 1; } }
|
| 358 |
+
@keyframes hmripple-in { 0% { opacity:1; fill: rgba(0,200,122,0.9); } 100% { opacity: 1; } }
|
| 359 |
+
@keyframes hmpulse { 0%,100% { opacity:1; } 50% { opacity: 0.3; } }
|
| 360 |
+
|
| 361 |
+
/* ── Network ───────────────────────────────────────────────────────────────── */
|
| 362 |
+
#net-svg { display: block; width: 100%; height: 100%; }
|
| 363 |
+
.net-node { cursor: pointer; }
|
| 364 |
+
.net-node:hover circle { stroke-width: 2; }
|
| 365 |
+
.net-edge { transition: stroke-width 0.2s, stroke-opacity 0.2s; }
|
| 366 |
+
|
| 367 |
+
/* ── Right Panel ───────────────────────────────────────────────────────────── */
|
| 368 |
+
.rpanel {
|
| 369 |
+
background: var(--bg1);
|
| 370 |
+
border-left: 1px solid var(--border);
|
| 371 |
+
display: flex;
|
| 372 |
+
flex-direction: column;
|
| 373 |
+
overflow: hidden;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.rp-sec { border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
| 377 |
+
.rp-head {
|
| 378 |
+
padding: 6px 10px;
|
| 379 |
+
background: var(--bg2);
|
| 380 |
+
border-bottom: 1px solid var(--border);
|
| 381 |
+
font-size: 9px;
|
| 382 |
+
letter-spacing: 2px;
|
| 383 |
+
color: var(--cyan);
|
| 384 |
+
font-weight: 600;
|
| 385 |
+
text-transform: uppercase;
|
| 386 |
+
display: flex;
|
| 387 |
+
justify-content: space-between;
|
| 388 |
+
align-items: center;
|
| 389 |
+
}
|
| 390 |
+
.rp-row {
|
| 391 |
+
display: flex;
|
| 392 |
+
justify-content: space-between;
|
| 393 |
+
padding: 4px 10px;
|
| 394 |
+
border-bottom: 1px solid var(--border);
|
| 395 |
+
font-size: 10px;
|
| 396 |
+
}
|
| 397 |
+
.rp-k { color: var(--muted); }
|
| 398 |
+
.rp-v { color: var(--white); }
|
| 399 |
+
.rp-v.up { color: var(--green); }
|
| 400 |
+
.rp-v.dn { color: var(--red); }
|
| 401 |
+
.rp-v.am { color: var(--amber); }
|
| 402 |
+
|
| 403 |
+
/* ── Inference Panel ───────────────────────────────────────────────────────── */
|
| 404 |
+
.infer-panel { padding: 10px; }
|
| 405 |
+
.infer-mode { display: flex; gap: 4px; margin-bottom: 10px; }
|
| 406 |
+
.infer-mode button {
|
| 407 |
+
flex: 1;
|
| 408 |
+
background: var(--bg3);
|
| 409 |
+
border: 1px solid var(--border);
|
| 410 |
+
color: var(--muted);
|
| 411 |
+
font-family: var(--font);
|
| 412 |
+
font-size: 9px;
|
| 413 |
+
padding: 5px;
|
| 414 |
+
cursor: pointer;
|
| 415 |
+
border-radius: var(--r);
|
| 416 |
+
letter-spacing: 1px;
|
| 417 |
+
transition: all 0.15s;
|
| 418 |
+
}
|
| 419 |
+
.infer-mode button.active { font-weight: 600; }
|
| 420 |
+
.infer-mode button.m-assert.active { background: var(--cyan-lo); color: var(--cyan); border-color: var(--cyan); }
|
| 421 |
+
.infer-mode button.m-intervene.active { background: var(--amber-lo); color: var(--amber); border-color: var(--amber); }
|
| 422 |
+
.infer-mode button.m-counter.active { background: var(--purple-lo); color: var(--purple); border-color: var(--purple); }
|
| 423 |
+
|
| 424 |
+
.infer-form {
|
| 425 |
+
background: var(--bg2);
|
| 426 |
+
border: 1px solid var(--border);
|
| 427 |
+
border-radius: var(--r);
|
| 428 |
+
padding: 10px;
|
| 429 |
+
margin-bottom: 8px;
|
| 430 |
+
}
|
| 431 |
+
.infer-label {
|
| 432 |
+
font-size: 9px;
|
| 433 |
+
color: var(--muted);
|
| 434 |
+
letter-spacing: 1.5px;
|
| 435 |
+
text-transform: uppercase;
|
| 436 |
+
margin-bottom: 5px;
|
| 437 |
+
}
|
| 438 |
+
.infer-select {
|
| 439 |
+
width: 100%;
|
| 440 |
+
background: var(--bg3);
|
| 441 |
+
border: 1px solid var(--border);
|
| 442 |
+
color: var(--white);
|
| 443 |
+
font-family: var(--font);
|
| 444 |
+
font-size: 10px;
|
| 445 |
+
padding: 5px 8px;
|
| 446 |
+
outline: none;
|
| 447 |
+
border-radius: var(--r);
|
| 448 |
+
margin-bottom: 8px;
|
| 449 |
+
cursor: pointer;
|
| 450 |
+
}
|
| 451 |
+
.infer-select:focus { border-color: var(--amber-dim); }
|
| 452 |
+
|
| 453 |
+
.slider-wrap { margin-bottom: 10px; }
|
| 454 |
+
.slider-row { display: flex; justify-content: space-between; margin-bottom: 4px; }
|
| 455 |
+
.slider-val { color: var(--amber); font-weight: 600; font-size: 10px; }
|
| 456 |
+
input[type=range] { width: 100%; accent-color: var(--amber); cursor: pointer; height: 3px; }
|
| 457 |
+
|
| 458 |
+
.run-btn {
|
| 459 |
+
width: 100%;
|
| 460 |
+
padding: 8px;
|
| 461 |
+
border: none;
|
| 462 |
+
font-family: var(--font);
|
| 463 |
+
font-size: 10px;
|
| 464 |
+
font-weight: 600;
|
| 465 |
+
letter-spacing: 2px;
|
| 466 |
+
cursor: pointer;
|
| 467 |
+
border-radius: var(--r);
|
| 468 |
+
transition: opacity 0.2s, transform 0.2s;
|
| 469 |
+
}
|
| 470 |
+
.run-btn.assert { background: var(--cyan); color: #000; }
|
| 471 |
+
.run-btn.intervene { background: var(--amber); color: #000; }
|
| 472 |
+
.run-btn.counter { background: var(--purple); color: #000; }
|
| 473 |
+
.run-btn:hover { opacity: 0.85; transform: translateY(-1px); }
|
| 474 |
+
.run-btn:active { transform: translateY(0); }
|
| 475 |
+
.run-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 476 |
+
|
| 477 |
+
/* ── Result Cards ──────────────────────────────────────────────────────────── */
|
| 478 |
+
.result-card {
|
| 479 |
+
background: var(--bg2);
|
| 480 |
+
border: 1px solid var(--border);
|
| 481 |
+
border-radius: var(--r);
|
| 482 |
+
padding: 10px;
|
| 483 |
+
margin-bottom: 8px;
|
| 484 |
+
position: relative;
|
| 485 |
+
overflow: hidden;
|
| 486 |
+
}
|
| 487 |
+
.result-card::before {
|
| 488 |
+
content: '';
|
| 489 |
+
position: absolute;
|
| 490 |
+
top: 0; left: 0;
|
| 491 |
+
width: 3px; height: 100%;
|
| 492 |
+
}
|
| 493 |
+
.result-card.assert::before { background: var(--cyan); }
|
| 494 |
+
.result-card.intervene::before { background: var(--amber); }
|
| 495 |
+
.result-card.counter::before { background: var(--purple); }
|
| 496 |
+
.rc-head { font-size: 9px; letter-spacing: 1.5px; margin-bottom: 6px; font-weight: 600; }
|
| 497 |
+
.rc-head.assert { color: var(--cyan); }
|
| 498 |
+
.rc-head.intervene { color: var(--amber); }
|
| 499 |
+
.rc-head.counter { color: var(--purple); }
|
| 500 |
+
.rc-row { display: flex; justify-content: space-between; font-size: 10px; padding: 2px 0; }
|
| 501 |
+
.rc-k { color: var(--muted); }
|
| 502 |
+
.rc-v { color: var(--white); }
|
| 503 |
+
.rc-v.up { color: var(--green); }
|
| 504 |
+
.rc-v.dn { color: var(--red); }
|
| 505 |
+
.rc-v.am { color: var(--amber); }
|
| 506 |
+
|
| 507 |
+
.ate-track {
|
| 508 |
+
height: 4px;
|
| 509 |
+
background: var(--bg4);
|
| 510 |
+
border-radius: 2px;
|
| 511 |
+
margin-top: 6px;
|
| 512 |
+
overflow: hidden;
|
| 513 |
+
}
|
| 514 |
+
.ate-fill {
|
| 515 |
+
height: 4px;
|
| 516 |
+
border-radius: 2px;
|
| 517 |
+
transition: width 0.8s ease;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.ripple-effects {
|
| 521 |
+
margin-top: 8px;
|
| 522 |
+
padding-top: 8px;
|
| 523 |
+
border-top: 1px solid var(--border);
|
| 524 |
+
}
|
| 525 |
+
.ripple-title { font-size: 9px; color: var(--muted); letter-spacing: 1px; margin-bottom: 5px; }
|
| 526 |
+
.ripple-chip {
|
| 527 |
+
display: inline-flex;
|
| 528 |
+
align-items: center;
|
| 529 |
+
gap: 4px;
|
| 530 |
+
padding: 2px 7px;
|
| 531 |
+
border-radius: 2px;
|
| 532 |
+
font-size: 9px;
|
| 533 |
+
margin: 2px;
|
| 534 |
+
border: 1px solid;
|
| 535 |
+
}
|
| 536 |
+
.ripple-chip.up { background: var(--green-lo); border-color: var(--green); color: var(--green); }
|
| 537 |
+
.ripple-chip.dn { background: var(--red-lo); border-color: var(--red); color: var(--red); }
|
| 538 |
+
|
| 539 |
+
/* ── News Feed ─────────────────────────────────────────────────────────────── */
|
| 540 |
+
.news-item {
|
| 541 |
+
padding: 7px 10px;
|
| 542 |
+
border-bottom: 1px solid var(--border);
|
| 543 |
+
cursor: pointer;
|
| 544 |
+
transition: background 0.1s;
|
| 545 |
+
}
|
| 546 |
+
.news-item:hover { background: var(--bg3); }
|
| 547 |
+
.news-top { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
|
| 548 |
+
.news-score { font-size: 8px; padding: 1px 5px; border-radius: 2px; font-weight: 600; }
|
| 549 |
+
.news-score.hi { background: var(--green-lo); color: var(--green); }
|
| 550 |
+
.news-score.md { background: var(--amber-lo); color: var(--amber); }
|
| 551 |
+
.news-score.lo { background: var(--red-lo); color: var(--red); }
|
| 552 |
+
.news-sym { color: var(--amber); font-size: 9px; font-weight: 600; }
|
| 553 |
+
.news-text { font-size: 9px; color: var(--muted); line-height: 1.5; margin-bottom: 4px; }
|
| 554 |
+
.news-tags { display: flex; gap: 3px; flex-wrap: wrap; }
|
| 555 |
+
.news-tag { font-size: 8px; padding: 1px 5px; border: 1px solid var(--border); color: var(--muted); border-radius: 2px; }
|
| 556 |
+
|
| 557 |
+
/* ── HHKD View ─────────────────────────────────────────────────────────────── */
|
| 558 |
+
.phi-row {
|
| 559 |
+
display: flex;
|
| 560 |
+
align-items: center;
|
| 561 |
+
gap: 8px;
|
| 562 |
+
padding: 4px 10px;
|
| 563 |
+
border-bottom: 1px solid var(--border);
|
| 564 |
+
cursor: pointer;
|
| 565 |
+
transition: background 0.1s;
|
| 566 |
+
}
|
| 567 |
+
.phi-row:hover { background: var(--bg3); }
|
| 568 |
+
.phi-sym { width: 66px; font-size: 10px; color: var(--amber); font-weight: 600; flex-shrink: 0; }
|
| 569 |
+
.phi-bar-wrap { flex: 1; height: 8px; background: var(--bg4); border-radius: 4px; overflow: hidden; }
|
| 570 |
+
.phi-bar-fill { height: 8px; border-radius: 4px; transition: width 0.4s; }
|
| 571 |
+
.phi-val { width: 36px; text-align: right; font-size: 10px; flex-shrink: 0; }
|
| 572 |
+
|
| 573 |
+
/* ── Sector View ───────────────────────────────────────────────────────────── */
|
| 574 |
+
.sector-grid { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
| 575 |
+
.sec-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; }
|
| 576 |
+
.sec-card-head {
|
| 577 |
+
padding: 7px 12px;
|
| 578 |
+
display: flex;
|
| 579 |
+
align-items: center;
|
| 580 |
+
justify-content: space-between;
|
| 581 |
+
cursor: pointer;
|
| 582 |
+
transition: background 0.1s;
|
| 583 |
+
}
|
| 584 |
+
.sec-card-head:hover { background: var(--bg3); }
|
| 585 |
+
.sec-name { font-size: 11px; font-weight: 600; letter-spacing: 1px; color: var(--white); }
|
| 586 |
+
.sec-phi { font-size: 10px; }
|
| 587 |
+
.sec-members { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; }
|
| 588 |
+
.sec-chip {
|
| 589 |
+
padding: 3px 9px;
|
| 590 |
+
border: 1px solid var(--border);
|
| 591 |
+
font-size: 9px;
|
| 592 |
+
border-radius: 2px;
|
| 593 |
+
cursor: pointer;
|
| 594 |
+
transition: all 0.15s;
|
| 595 |
+
}
|
| 596 |
+
.sec-chip:hover { border-color: var(--amber); color: var(--amber); }
|
| 597 |
+
.sec-chip.rippling { animation: chipripple 0.7s ease-out; }
|
| 598 |
+
.sec-chip.rippling-up { animation: chipripple-up 0.7s ease-out; }
|
| 599 |
+
@keyframes chipripple { 0% { background: rgba(224,52,52,.4); border-color: var(--red); } 100% { background: transparent; } }
|
| 600 |
+
@keyframes chipripple-up { 0% { background: rgba(0,200,122,.4); border-color: var(--green); } 100% { background: transparent; } }
|
| 601 |
+
|
| 602 |
+
/* ── Node Popup ────────────────────────────────────────────────────────────── */
|
| 603 |
+
.node-popup {
|
| 604 |
+
position: fixed;
|
| 605 |
+
z-index: 500;
|
| 606 |
+
background: var(--bg2);
|
| 607 |
+
border: 1px solid var(--amber);
|
| 608 |
+
border-radius: var(--r);
|
| 609 |
+
padding: 12px;
|
| 610 |
+
min-width: 200px;
|
| 611 |
+
max-width: 260px;
|
| 612 |
+
pointer-events: none;
|
| 613 |
+
display: none;
|
| 614 |
+
box-shadow: 0 8px 32px rgba(0,0,0,.6);
|
| 615 |
+
}
|
| 616 |
+
.np-head {
|
| 617 |
+
color: var(--amber);
|
| 618 |
+
font-size: 12px;
|
| 619 |
+
font-weight: 600;
|
| 620 |
+
margin-bottom: 8px;
|
| 621 |
+
display: flex;
|
| 622 |
+
justify-content: space-between;
|
| 623 |
+
align-items: center;
|
| 624 |
+
}
|
| 625 |
+
.np-row {
|
| 626 |
+
display: flex;
|
| 627 |
+
justify-content: space-between;
|
| 628 |
+
padding: 3px 0;
|
| 629 |
+
border-bottom: 1px solid var(--border);
|
| 630 |
+
font-size: 10px;
|
| 631 |
+
}
|
| 632 |
+
.np-row:last-child { border: none; }
|
| 633 |
+
.np-k { color: var(--muted); }
|
| 634 |
+
.np-v { color: var(--white); }
|
| 635 |
+
.np-v.up { color: var(--green); }
|
| 636 |
+
.np-v.dn { color: var(--red); }
|
| 637 |
+
.np-v.am { color: var(--amber); }
|
| 638 |
+
|
| 639 |
+
/* ── Status Strip ──────────────────────────────────────────────────────────── */
|
| 640 |
+
.status-strip {
|
| 641 |
+
height: 22px;
|
| 642 |
+
background: var(--bg2);
|
| 643 |
+
border-top: 1px solid var(--border);
|
| 644 |
+
display: flex;
|
| 645 |
+
align-items: center;
|
| 646 |
+
padding: 0 10px;
|
| 647 |
+
gap: 16px;
|
| 648 |
+
flex-shrink: 0;
|
| 649 |
+
overflow: hidden;
|
| 650 |
+
}
|
| 651 |
+
.ss-chip { font-size: 9px; display: flex; gap: 5px; white-space: nowrap; }
|
| 652 |
+
.ss-k { color: var(--muted); }
|
| 653 |
+
.ss-v { color: var(--white); }
|
| 654 |
+
.ss-v.am { color: var(--amber); }
|
| 655 |
+
.ss-v.up { color: var(--green); }
|
| 656 |
+
|
| 657 |
+
/* ── Accordion ─────────────────────────────────────────────────────────────── */
|
| 658 |
+
.accordion { border: 1px solid var(--border); border-radius: var(--r); margin-bottom: 6px; overflow: hidden; }
|
| 659 |
+
.acc-head {
|
| 660 |
+
display: flex;
|
| 661 |
+
align-items: center;
|
| 662 |
+
justify-content: space-between;
|
| 663 |
+
padding: 7px 10px;
|
| 664 |
+
cursor: pointer;
|
| 665 |
+
background: var(--bg2);
|
| 666 |
+
user-select: none;
|
| 667 |
+
}
|
| 668 |
+
.acc-head:hover { background: var(--bg3); }
|
| 669 |
+
.acc-title { font-size: 10px; color: var(--white); font-weight: 500; letter-spacing: 0.5px; }
|
| 670 |
+
.acc-badge { font-size: 8px; padding: 1px 6px; border-radius: 2px; font-weight: 600; letter-spacing: 1px; }
|
| 671 |
+
.acc-badge.up { background: var(--green-lo); color: var(--green); }
|
| 672 |
+
.acc-badge.dn { background: var(--red-lo); color: var(--red); }
|
| 673 |
+
.acc-badge.am { background: var(--amber-lo); color: var(--amber); }
|
| 674 |
+
.acc-badge.cy { background: var(--cyan-lo); color: var(--cyan); }
|
| 675 |
+
.acc-chevron { color: var(--muted); font-size: 10px; transition: transform 0.2s; }
|
| 676 |
+
.acc-chevron.open { transform: rotate(180deg); }
|
| 677 |
+
.acc-body { display: none; border-top: 1px solid var(--border); }
|
| 678 |
+
.acc-body.open { display: block; }
|
| 679 |
+
.acc-row { display: flex; justify-content: space-between; padding: 4px 10px; border-bottom: 1px solid var(--border); font-size: 10px; }
|
| 680 |
+
.acc-k { color: var(--muted); }
|
| 681 |
+
.acc-v { color: var(--white); }
|
| 682 |
+
.acc-v.up { color: var(--green); }
|
| 683 |
+
.acc-v.dn { color: var(--red); }
|
| 684 |
+
.acc-v.am { color: var(--amber); }
|
| 685 |
+
|
| 686 |
+
/* ── Ripple Ring ───────────────────────────────────────────────────────────── */
|
| 687 |
+
@keyframes pulse-ring { 0% { transform: scale(.8); opacity: 1; } 100% { transform: scale(2.5); opacity: 0; } }
|
| 688 |
+
.ripple-ring {
|
| 689 |
+
position: absolute;
|
| 690 |
+
border-radius: 50%;
|
| 691 |
+
pointer-events: none;
|
| 692 |
+
animation: pulse-ring 0.8s ease-out forwards;
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
/* ── Error toast ───────────────────────────────────────────────────────────── */
|
| 696 |
+
#error-toast {
|
| 697 |
+
position: fixed;
|
| 698 |
+
bottom: 28px;
|
| 699 |
+
left: 50%;
|
| 700 |
+
transform: translateX(-50%) translateY(60px);
|
| 701 |
+
background: var(--red-lo);
|
| 702 |
+
border: 1px solid var(--red);
|
| 703 |
+
color: var(--red);
|
| 704 |
+
font-size: 10px;
|
| 705 |
+
padding: 8px 16px;
|
| 706 |
+
border-radius: var(--r);
|
| 707 |
+
z-index: 9000;
|
| 708 |
+
transition: transform 0.3s ease;
|
| 709 |
+
letter-spacing: 0.5px;
|
| 710 |
+
}
|
| 711 |
+
#error-toast.show { transform: translateX(-50%) translateY(0); }
|
server.py
ADDED
|
@@ -0,0 +1,1036 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
dashboard_server.py
|
| 3 |
+
====================
|
| 4 |
+
CUTS+ Causal Terminal — gr.Server entry point.
|
| 5 |
+
|
| 6 |
+
Architecture
|
| 7 |
+
------------
|
| 8 |
+
gr.Server (extends FastAPI)
|
| 9 |
+
├── GET / → serves frontend/index.html
|
| 10 |
+
├── GET /static/* → serves frontend/{style.css, app.js} (StaticFiles)
|
| 11 |
+
│
|
| 12 |
+
├── @server.api run_causal_components → CUTS+ multi-ticker discovery
|
| 13 |
+
├── @server.api run_singular_causal → single-ticker 10-step pipeline
|
| 14 |
+
├── @server.api run_inference → DoFlow / SCM causal query
|
| 15 |
+
├── @server.api run_hierarchy → hierarchical sector causal
|
| 16 |
+
│
|
| 17 |
+
├── GET /v2/health → health-check
|
| 18 |
+
├── GET /v2/causal/singular-causal/graph/{ticker} → cached adj graph
|
| 19 |
+
├── GET /v2/causal/singular-causal/results/{ticker} → cached inference JSON
|
| 20 |
+
├── POST /v2/causal/doflow-inference → DoFlow query
|
| 21 |
+
│
|
| 22 |
+
└── All existing /v2/* routers from main.py are included here too
|
| 23 |
+
(so this server is a superset of main.py).
|
| 24 |
+
|
| 25 |
+
Usage
|
| 26 |
+
-----
|
| 27 |
+
python dashboard_server.py
|
| 28 |
+
|
| 29 |
+
Or with uvicorn:
|
| 30 |
+
uvicorn dashboard_server:server --host 0.0.0.0 --port 7860 --reload
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import os
|
| 36 |
+
import sys
|
| 37 |
+
import json
|
| 38 |
+
import logging
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
from typing import Any, Dict, List, Optional
|
| 41 |
+
|
| 42 |
+
from dotenv import load_dotenv
|
| 43 |
+
|
| 44 |
+
load_dotenv()
|
| 45 |
+
|
| 46 |
+
BASE_DIR = Path(__file__).parent.resolve()
|
| 47 |
+
if str(BASE_DIR) not in sys.path:
|
| 48 |
+
sys.path.insert(0, str(BASE_DIR))
|
| 49 |
+
|
| 50 |
+
# Also add the backend directory to sys.path so we can import 'app', 'causal', 'singular_ticker_causal', etc.
|
| 51 |
+
BACKEND_DIR = (BASE_DIR.parent / "noisy_boy_backend").resolve()
|
| 52 |
+
if BACKEND_DIR.exists() and str(BACKEND_DIR) not in sys.path:
|
| 53 |
+
sys.path.insert(0, str(BACKEND_DIR))
|
| 54 |
+
|
| 55 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 56 |
+
# Logging
|
| 57 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 58 |
+
logging.basicConfig(
|
| 59 |
+
level=logging.INFO,
|
| 60 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 61 |
+
handlers=[logging.StreamHandler()],
|
| 62 |
+
)
|
| 63 |
+
logger = logging.getLogger("dashboard-server")
|
| 64 |
+
|
| 65 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 66 |
+
# gr.Server
|
| 67 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 68 |
+
import gradio as gr
|
| 69 |
+
from gradio import Server
|
| 70 |
+
|
| 71 |
+
from fastapi import Request
|
| 72 |
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
| 73 |
+
from fastapi.staticfiles import StaticFiles
|
| 74 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 75 |
+
|
| 76 |
+
FRONTEND_DIR = BASE_DIR / "frontend"
|
| 77 |
+
INDEX_HTML = FRONTEND_DIR / "index.html"
|
| 78 |
+
|
| 79 |
+
server = Server(
|
| 80 |
+
title="CUTS+ Causal Terminal",
|
| 81 |
+
description=(
|
| 82 |
+
"Iroha Financial Intelligence — real-time causal probability matrix, "
|
| 83 |
+
"HHKD decomposition, DoFlow inference and sector hierarchy over NIFTY50."
|
| 84 |
+
),
|
| 85 |
+
version="2.0.0",
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# ── CORS (same as main.py) ────────────────────────────────────────────────
|
| 89 |
+
server.add_middleware(
|
| 90 |
+
CORSMiddleware,
|
| 91 |
+
allow_origins=["*"],
|
| 92 |
+
allow_credentials=True,
|
| 93 |
+
allow_methods=["*"],
|
| 94 |
+
allow_headers=["*"],
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# ── Static files — mount frontend/ at /static ─────────────────────────────
|
| 98 |
+
server.mount(
|
| 99 |
+
"/static",
|
| 100 |
+
StaticFiles(directory=str(FRONTEND_DIR)),
|
| 101 |
+
name="static",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 105 |
+
# HTML route — serves the custom frontend
|
| 106 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
@server.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 109 |
+
async def serve_index():
|
| 110 |
+
"""Serve the CUTS+ Causal Terminal SPA."""
|
| 111 |
+
if not INDEX_HTML.exists():
|
| 112 |
+
return HTMLResponse("<h1>Frontend not found. Run from backend/</h1>", status_code=500)
|
| 113 |
+
return HTMLResponse(INDEX_HTML.read_text(encoding="utf-8"))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 117 |
+
# Health check
|
| 118 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 119 |
+
|
| 120 |
+
@server.get("/v2/health", tags=["utility"])
|
| 121 |
+
async def health():
|
| 122 |
+
"""Lightweight health-check used by the frontend API banner."""
|
| 123 |
+
return {"status": "ok", "version": "2.0.0"}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 127 |
+
# Existing v2 routers (same as main.py)
|
| 128 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
from app.api.scraper import router as scraper_router
|
| 132 |
+
server.include_router(scraper_router, prefix="/v2")
|
| 133 |
+
logger.info("✓ scraper router mounted")
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.warning(f"scraper router skipped: {e}")
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
from app.api.causal import router as causal_router
|
| 139 |
+
server.include_router(causal_router, prefix="/v2")
|
| 140 |
+
logger.info("✓ causal router mounted")
|
| 141 |
+
except Exception as e:
|
| 142 |
+
logger.warning(f"causal router skipped: {e}")
|
| 143 |
+
|
| 144 |
+
try:
|
| 145 |
+
from app.api.causal_pipeline import router as causal_pipeline_router
|
| 146 |
+
server.include_router(causal_pipeline_router, prefix="/v2")
|
| 147 |
+
logger.info("✓ causal_pipeline router mounted")
|
| 148 |
+
except Exception as e:
|
| 149 |
+
logger.warning(f"causal_pipeline router skipped: {e}")
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
from app.api.screening import router as screening_router
|
| 153 |
+
server.include_router(screening_router, prefix="/v2")
|
| 154 |
+
logger.info("✓ screening router mounted")
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.warning(f"screening router skipped: {e}")
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
from app.api.ontology import router as ontology_router
|
| 160 |
+
server.include_router(ontology_router, prefix="/v2")
|
| 161 |
+
logger.info("✓ ontology router mounted")
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.warning(f"ontology router skipped: {e}")
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
# Temporarily remove frontend dir from sys.path to avoid shadowing 'server' package with our 'server.py' script
|
| 167 |
+
removed_empty = False
|
| 168 |
+
if "" in sys.path:
|
| 169 |
+
sys.path.remove("")
|
| 170 |
+
removed_empty = True
|
| 171 |
+
if str(BASE_DIR) in sys.path:
|
| 172 |
+
sys.path.remove(str(BASE_DIR))
|
| 173 |
+
|
| 174 |
+
from server.model_router import router as server_model_router
|
| 175 |
+
server.include_router(server_model_router, prefix="/v2")
|
| 176 |
+
logger.info("✓ model_router mounted")
|
| 177 |
+
|
| 178 |
+
# Restore sys.path
|
| 179 |
+
sys.path.insert(0, str(BASE_DIR))
|
| 180 |
+
if removed_empty:
|
| 181 |
+
sys.path.insert(0, "")
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.warning(f"model_router skipped: {e}")
|
| 184 |
+
# Ensure sys.path is restored even on failure
|
| 185 |
+
if str(BASE_DIR) not in sys.path:
|
| 186 |
+
sys.path.insert(0, str(BASE_DIR))
|
| 187 |
+
if 'removed_empty' in locals() and removed_empty and "" not in sys.path:
|
| 188 |
+
sys.path.insert(0, "")
|
| 189 |
+
|
| 190 |
+
# try:
|
| 191 |
+
# from backtest.router import router as backtest_router
|
| 192 |
+
# server.include_router(backtest_router, prefix="/v2/backtest")
|
| 193 |
+
# logger.info("✓ backtest router mounted")
|
| 194 |
+
# except Exception as e:
|
| 195 |
+
# logger.warning(f"backtest router skipped: {e}")
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 199 |
+
# gr.Server API endpoints (Gradio-backed — queue + SSE streaming)
|
| 200 |
+
# These are reachable via the Gradio JS Client as well as plain fetch().
|
| 201 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 202 |
+
|
| 203 |
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
| 204 |
+
|
| 205 |
+
# URL of the noisy_boy_backend — used to fetch the validated causal matrix.
|
| 206 |
+
# By default, point to ourselves since we now successfully mount the backend routers.
|
| 207 |
+
# Override via BACKEND_API_URL env var if running a separate backend on 8000.
|
| 208 |
+
_BACKEND_BASE_URL = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
|
| 209 |
+
|
| 210 |
+
# Kept for backward-compat with run_singular_causal (which still imports backend modules
|
| 211 |
+
# via sys.path when both repos are co-located). Not used in run_inference anymore.
|
| 212 |
+
_SINGULAR_DEBUG_DIR = str(BASE_DIR / "singular_ticker_causal" / "debug_data")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _fetch_causal_matrix(
|
| 216 |
+
ticker: str,
|
| 217 |
+
treatment: Optional[str] = None,
|
| 218 |
+
outcome: Optional[str] = None,
|
| 219 |
+
include_pywhyllm: bool = False,
|
| 220 |
+
threshold: float = 0.5,
|
| 221 |
+
) -> Optional[dict]:
|
| 222 |
+
"""
|
| 223 |
+
Fetch the fully validated causal matrix from the backend API.
|
| 224 |
+
|
| 225 |
+
Calls GET {BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker}
|
| 226 |
+
and returns the parsed JSON payload, or None on failure.
|
| 227 |
+
|
| 228 |
+
The payload contains:
|
| 229 |
+
nodes — ordered list of node names
|
| 230 |
+
adj_matrix — raw float adjacency matrix
|
| 231 |
+
dag_adj — thresholded 0/1 DAG
|
| 232 |
+
equations — per-node structural equations (coefficients, intercepts, residual_std)
|
| 233 |
+
data_level — (T, N) time-series observations used to fit the SCM
|
| 234 |
+
topological_order — nodes in topological traversal order
|
| 235 |
+
pywhyllm_report — (optional) assumption analysis for treatment→outcome
|
| 236 |
+
"""
|
| 237 |
+
import urllib.request
|
| 238 |
+
import urllib.error
|
| 239 |
+
import urllib.parse
|
| 240 |
+
|
| 241 |
+
params: dict = {"threshold": threshold}
|
| 242 |
+
if treatment:
|
| 243 |
+
params["treatment"] = treatment
|
| 244 |
+
if outcome:
|
| 245 |
+
params["outcome"] = outcome
|
| 246 |
+
if include_pywhyllm:
|
| 247 |
+
params["include_pywhyllm"] = "true"
|
| 248 |
+
|
| 249 |
+
query_string = urllib.parse.urlencode(params)
|
| 250 |
+
url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{query_string}"
|
| 251 |
+
|
| 252 |
+
try:
|
| 253 |
+
with urllib.request.urlopen(url, timeout=30) as resp:
|
| 254 |
+
raw = resp.read()
|
| 255 |
+
data = json.loads(raw)
|
| 256 |
+
if data.get("status") not in ("success", None):
|
| 257 |
+
logger.warning("_fetch_causal_matrix: backend returned status=%s for URL %s. Payload: %s", data.get("status"), url, data)
|
| 258 |
+
return None
|
| 259 |
+
return data
|
| 260 |
+
except Exception as exc:
|
| 261 |
+
logger.warning("_fetch_causal_matrix failed for %s: %s", ticker, exc)
|
| 262 |
+
return None
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _safe_json(obj: Any) -> Any:
|
| 266 |
+
"""Recursively make numpy types JSON-serialisable."""
|
| 267 |
+
try:
|
| 268 |
+
import numpy as np
|
| 269 |
+
if isinstance(obj, np.ndarray):
|
| 270 |
+
return obj.tolist()
|
| 271 |
+
if isinstance(obj, np.integer):
|
| 272 |
+
return int(obj)
|
| 273 |
+
if isinstance(obj, np.floating):
|
| 274 |
+
return float(obj)
|
| 275 |
+
except ImportError:
|
| 276 |
+
pass
|
| 277 |
+
if isinstance(obj, dict):
|
| 278 |
+
return {k: _safe_json(v) for k, v in obj.items()}
|
| 279 |
+
if isinstance(obj, (list, tuple)):
|
| 280 |
+
return [_safe_json(v) for v in obj]
|
| 281 |
+
return obj
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _adj_to_graph(adj_matrix, symbols: List[str], threshold: float = 0.5):
|
| 285 |
+
"""Convert adjacency matrix → {nodes, links} for the frontend."""
|
| 286 |
+
try:
|
| 287 |
+
import numpy as np
|
| 288 |
+
arr = np.array(adj_matrix)
|
| 289 |
+
except Exception:
|
| 290 |
+
arr = [[float(v) for v in row] for row in adj_matrix]
|
| 291 |
+
|
| 292 |
+
nodes = [{"id": s, "label": s} for s in symbols]
|
| 293 |
+
links = []
|
| 294 |
+
n = len(symbols)
|
| 295 |
+
for i in range(n):
|
| 296 |
+
for j in range(n):
|
| 297 |
+
try:
|
| 298 |
+
v = float(arr[i][j])
|
| 299 |
+
except Exception:
|
| 300 |
+
continue
|
| 301 |
+
if i != j and v >= threshold:
|
| 302 |
+
links.append({"source": symbols[i], "target": symbols[j], "score": round(v, 4)})
|
| 303 |
+
return nodes, links
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
# ── API 1: CUTS+ multi-ticker causal discovery ────────────────────────────
|
| 307 |
+
|
| 308 |
+
@server.api(name="run_causal_components", description="Run CUTS+ on NIFTY50 multi-ticker technical features")
|
| 309 |
+
def run_causal_components(
|
| 310 |
+
symbols: Optional[List[str]] = None,
|
| 311 |
+
use_actual: bool = False,
|
| 312 |
+
) -> Dict[str, Any]:
|
| 313 |
+
"""
|
| 314 |
+
Trigger the CUTS+ multi-ticker causal discovery pipeline.
|
| 315 |
+
|
| 316 |
+
Parameters
|
| 317 |
+
----------
|
| 318 |
+
symbols : list of NSE ticker strings (default: synthetic RELIANCE/TCS pair)
|
| 319 |
+
use_actual : whether to download real OHLCV data from yfinance
|
| 320 |
+
|
| 321 |
+
Returns
|
| 322 |
+
-------
|
| 323 |
+
JSON with adjacency_matrix, nodes, links, density, symbols
|
| 324 |
+
"""
|
| 325 |
+
try:
|
| 326 |
+
from causal.test_causal_flow import (
|
| 327 |
+
generate_synthetic_data,
|
| 328 |
+
load_actual_data,
|
| 329 |
+
NIFTY50_SYMBOLS,
|
| 330 |
+
)
|
| 331 |
+
from causal.services.feature_engineer import FeatureEngineer
|
| 332 |
+
from causal.services.cuts_tensor_builder import CutsTensorBuilder
|
| 333 |
+
from causal.cuts_plus.cuts_plus import main as cuts_plus_main
|
| 334 |
+
from causal.cuts_plus.utils.logger import MyLogger
|
| 335 |
+
from omegaconf import OmegaConf
|
| 336 |
+
|
| 337 |
+
if use_actual:
|
| 338 |
+
syms = symbols or NIFTY50_SYMBOLS
|
| 339 |
+
data = load_actual_data(syms)
|
| 340 |
+
else:
|
| 341 |
+
syms = symbols or ["RELIANCE", "TCS"]
|
| 342 |
+
data = generate_synthetic_data()
|
| 343 |
+
|
| 344 |
+
fe = FeatureEngineer()
|
| 345 |
+
ctb = CutsTensorBuilder()
|
| 346 |
+
tech_data, mask, ordered_syms, *_ = ctb.build(
|
| 347 |
+
historical_data=data, symbols=syms, feature_engineer=fe
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
log_dir = str(BASE_DIR / "causal" / "dash_logs")
|
| 351 |
+
os.makedirs(log_dir, exist_ok=True)
|
| 352 |
+
log = MyLogger(log_dir=log_dir, stdout=False, stderr=False, tensorboard=False)
|
| 353 |
+
|
| 354 |
+
cfg = OmegaConf.create({
|
| 355 |
+
"data_dim": tech_data.shape[-1],
|
| 356 |
+
"total_epoch": 30,
|
| 357 |
+
"ticker_list": ordered_syms,
|
| 358 |
+
"causal_thres": "value_0.5",
|
| 359 |
+
})
|
| 360 |
+
|
| 361 |
+
adj = cuts_plus_main(data=tech_data, mask=mask, true_cm=None, opt=cfg, log=log)
|
| 362 |
+
adj_list = _safe_json(adj)
|
| 363 |
+
nodes, links = _adj_to_graph(adj_list, ordered_syms, threshold=0.5)
|
| 364 |
+
n = len(ordered_syms)
|
| 365 |
+
edges = sum(1 for i in range(n) for j in range(n) if i != j and adj_list[i][j] >= 0.5)
|
| 366 |
+
|
| 367 |
+
return {
|
| 368 |
+
"status": "ok",
|
| 369 |
+
"symbols": ordered_syms,
|
| 370 |
+
"adjacency_matrix": adj_list,
|
| 371 |
+
"nodes": nodes,
|
| 372 |
+
"links": links,
|
| 373 |
+
"density": round(edges / max(n * (n - 1), 1), 4),
|
| 374 |
+
"n_edges": edges,
|
| 375 |
+
}
|
| 376 |
+
except Exception as exc:
|
| 377 |
+
logger.exception("run_causal_components failed")
|
| 378 |
+
return {"status": "error", "detail": str(exc)}
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
# ── API 2: Single-ticker fundamental causal pipeline ─────────────────────
|
| 382 |
+
|
| 383 |
+
@server.api(
|
| 384 |
+
name="run_singular_causal",
|
| 385 |
+
description="Run the full 10-step single-ticker fundamental causal pipeline",
|
| 386 |
+
concurrency_limit=2,
|
| 387 |
+
)
|
| 388 |
+
def run_singular_causal(ticker: str = "RELIANCE") -> Dict[str, Any]:
|
| 389 |
+
"""
|
| 390 |
+
Execute the 10-step singular ticker pipeline:
|
| 391 |
+
DuPont prior → feature engineering → CUTS+ → SCM → CausalQueryEngine.
|
| 392 |
+
|
| 393 |
+
Parameters
|
| 394 |
+
----------
|
| 395 |
+
ticker : NSE symbol (e.g. RELIANCE, HDFCBANK)
|
| 396 |
+
|
| 397 |
+
Returns
|
| 398 |
+
-------
|
| 399 |
+
JSON with adj_matrix, nodes, links, inference_summary
|
| 400 |
+
"""
|
| 401 |
+
try:
|
| 402 |
+
from singular_ticker_causal.test_single_ticker_causal_flow import run_pipeline
|
| 403 |
+
result = run_pipeline(ticker=ticker.upper())
|
| 404 |
+
return _safe_json({"status": "ok", "ticker": ticker.upper(), **result})
|
| 405 |
+
except Exception as exc:
|
| 406 |
+
logger.exception("run_singular_causal failed")
|
| 407 |
+
return {"status": "error", "detail": str(exc)}
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
# ── API 3: Causal inference (assert / intervene / counterfactual) ─────────
|
| 411 |
+
#
|
| 412 |
+
# Architecture:
|
| 413 |
+
# 1. Fetch the VALIDATED causal matrix from noisy_boy_backend via HTTP.
|
| 414 |
+
# The backend has already run CUTS+ learning + pywhyllm + DoWhy validation.
|
| 415 |
+
# 2. Reconstruct the fitted SCM locally from that payload (no re-learning).
|
| 416 |
+
# 3. Use pywhyllm to gather structural guidance (confounders, backdoor sets,
|
| 417 |
+
# SCM mechanism hints) at each of the three causal layers.
|
| 418 |
+
# 4. Feed that guidance + the fitted SCM data into DoWhy / DoWhy-GCM to
|
| 419 |
+
# compute the actual numerical estimates — the LLM never touches the numbers.
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def _resolve_value(
|
| 423 |
+
value: float, value_type: str, current: float
|
| 424 |
+
) -> float:
|
| 425 |
+
"""Convert a user-supplied value + value_type to the absolute node value."""
|
| 426 |
+
vt = value_type.strip().lower()
|
| 427 |
+
if vt == "absolute":
|
| 428 |
+
return value
|
| 429 |
+
if vt == "multiplier":
|
| 430 |
+
return current * value
|
| 431 |
+
if vt == "percent_change":
|
| 432 |
+
return current * (1.0 + value / 100.0)
|
| 433 |
+
# default: treat as absolute
|
| 434 |
+
return value
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def _rebuild_scm_from_payload(payload: dict):
|
| 438 |
+
"""
|
| 439 |
+
Reconstruct a fitted StructuralCausalModel from the causal-matrix payload.
|
| 440 |
+
|
| 441 |
+
The backend has already:
|
| 442 |
+
- run CUTS+ to learn the adjacency matrix
|
| 443 |
+
- fit the structural equations (coefficients, intercepts, residual_std)
|
| 444 |
+
- validated the graph with pywhyllm + DoWhy refutation
|
| 445 |
+
|
| 446 |
+
We re-hydrate a StructuralCausalModel object from that payload so that the
|
| 447 |
+
frontend inference code can call engine.assert_edge / intervene / counterfactual
|
| 448 |
+
without re-running any learning.
|
| 449 |
+
"""
|
| 450 |
+
import numpy as np
|
| 451 |
+
from singular_ticker_causal.causal_inference.causal_model import (
|
| 452 |
+
StructuralCausalModel, StructuralEquation
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
nodes = payload["nodes"]
|
| 456 |
+
adj_matrix = np.array(payload["adj_matrix"], dtype=float)
|
| 457 |
+
dag_adj = np.array(payload["dag_adj"], dtype=bool)
|
| 458 |
+
data_level = np.array(payload["data_level"], dtype=float)
|
| 459 |
+
n = len(nodes)
|
| 460 |
+
T = data_level.shape[0]
|
| 461 |
+
|
| 462 |
+
# Build a minimal (T, N, 1) data_tech tensor so StructuralCausalModel.__post_init__
|
| 463 |
+
# can call _extract_level_data without error. The level data IS data_level.
|
| 464 |
+
data_tech = data_level[:, :, np.newaxis] # shape (T, N, 1)
|
| 465 |
+
adjacency_mask = (adj_matrix > 0).astype(float) # use adj as mask
|
| 466 |
+
|
| 467 |
+
scm = StructuralCausalModel(
|
| 468 |
+
nodes=nodes,
|
| 469 |
+
adj=adj_matrix,
|
| 470 |
+
adjacency_mask=adjacency_mask,
|
| 471 |
+
data_tech=data_tech,
|
| 472 |
+
threshold=payload.get("threshold", 0.5),
|
| 473 |
+
lag=1,
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
# Override dag_adj with the backend's thresholded version
|
| 477 |
+
scm.dag_adj = dag_adj
|
| 478 |
+
|
| 479 |
+
# Restore topological order
|
| 480 |
+
topo_names = payload.get("topological_order", nodes)
|
| 481 |
+
scm.topological_indices = [nodes.index(n) for n in topo_names if n in nodes]
|
| 482 |
+
|
| 483 |
+
# Re-hydrate structural equations from the backend payload
|
| 484 |
+
equations_raw = payload.get("equations", {})
|
| 485 |
+
scm.equations = {}
|
| 486 |
+
for node, eq_data in equations_raw.items():
|
| 487 |
+
parents = eq_data.get("parents", [])
|
| 488 |
+
parent_indices = eq_data.get("parent_indices", [nodes.index(p) for p in parents])
|
| 489 |
+
scm.equations[node] = StructuralEquation(
|
| 490 |
+
node=node,
|
| 491 |
+
parents=parents,
|
| 492 |
+
parent_indices=parent_indices,
|
| 493 |
+
intercept=float(eq_data.get("intercept", 0.0)),
|
| 494 |
+
coefficients={p: float(v) for p, v in eq_data.get("coefficients", {}).items()},
|
| 495 |
+
residual_mean=float(eq_data.get("residual_mean", 0.0)),
|
| 496 |
+
residual_std=float(eq_data.get("residual_std", 1.0)),
|
| 497 |
+
r_squared=float(eq_data.get("r_squared", 0.0)),
|
| 498 |
+
n_obs=int(eq_data.get("n_obs", T)),
|
| 499 |
+
equation_type=eq_data.get("equation_type", "linear"),
|
| 500 |
+
)
|
| 501 |
+
|
| 502 |
+
return scm
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
@server.api(
|
| 506 |
+
name="run_inference",
|
| 507 |
+
description=(
|
| 508 |
+
"Run pywhyllm-guided causal inference (association / intervention / counterfactual) "
|
| 509 |
+
"using a validated causal matrix from the backend. "
|
| 510 |
+
"Layers: 1=Association(pandas+DoWhy), 2=Intervention(DoWhy backdoor/IV), "
|
| 511 |
+
"3=Counterfactual(DoWhy GCM with abduction)."
|
| 512 |
+
),
|
| 513 |
+
concurrency_limit=4,
|
| 514 |
+
)
|
| 515 |
+
def run_inference(
|
| 516 |
+
ticker: str = "RELIANCE",
|
| 517 |
+
mode: str = "assert",
|
| 518 |
+
treatment: str = "Revenue",
|
| 519 |
+
outcome: Optional[str] = "NetIncome",
|
| 520 |
+
target: Optional[str] = None,
|
| 521 |
+
value: float = 1.1,
|
| 522 |
+
cf_value: Optional[float] = None,
|
| 523 |
+
value_type: str = "multiplier",
|
| 524 |
+
horizon: int = 5,
|
| 525 |
+
observed_t: int = -1,
|
| 526 |
+
threshold: float = 0.5,
|
| 527 |
+
use_pywhyllm: bool = False,
|
| 528 |
+
return_assumption_report: bool = False,
|
| 529 |
+
) -> Dict[str, Any]:
|
| 530 |
+
"""
|
| 531 |
+
Three-layer causal inference driven by the backend's validated causal matrix.
|
| 532 |
+
|
| 533 |
+
Parameters
|
| 534 |
+
----------
|
| 535 |
+
ticker : NSE ticker (backend must have a cached pipeline run for it)
|
| 536 |
+
mode : "assert" | "intervene" | "counterfactual"
|
| 537 |
+
treatment : source node name
|
| 538 |
+
outcome : outcome node (assert / Layer-1 association)
|
| 539 |
+
target : target node (counterfactual / Layer-3); if None, falls back to outcome
|
| 540 |
+
value : intervention magnitude (Layer 2)
|
| 541 |
+
cf_value : explicit counterfactual value (Layer 3); if None, 'value' + 'value_type' used
|
| 542 |
+
value_type : "absolute" | "multiplier" | "percent_change"
|
| 543 |
+
horizon : propagation horizon for intervention (Layer 2, steps)
|
| 544 |
+
observed_t : time index for counterfactual abduction (Layer 3; -1 = last obs)
|
| 545 |
+
threshold : adjacency threshold used when loading the graph
|
| 546 |
+
use_pywhyllm : consult pywhyllm for structural assumptions before running DoWhy
|
| 547 |
+
return_assumption_report : include the pywhyllm report dict in the response
|
| 548 |
+
|
| 549 |
+
Returns
|
| 550 |
+
-------
|
| 551 |
+
JSON with ate, ci_lower, ci_upper, probability, ripple_effects,
|
| 552 |
+
and (for counterfactual) factual_outcome, counterfactual_outcome, ite,
|
| 553 |
+
shapley_contributions.
|
| 554 |
+
"""
|
| 555 |
+
import numpy as np
|
| 556 |
+
import pandas as pd
|
| 557 |
+
|
| 558 |
+
try:
|
| 559 |
+
# ── 0. Determine target node ──────────────────────────────────────────
|
| 560 |
+
target_node = target if target else outcome
|
| 561 |
+
if not target_node:
|
| 562 |
+
return {"status": "error", "detail": "Either 'outcome' or 'target' must be provided."}
|
| 563 |
+
|
| 564 |
+
# ── 1. Fetch validated causal matrix from backend ─────────────────────
|
| 565 |
+
# This includes the adjacency matrix, fitted structural equations,
|
| 566 |
+
# level-domain data, and optionally a pywhyllm assumption report.
|
| 567 |
+
payload = _fetch_causal_matrix(
|
| 568 |
+
ticker=ticker,
|
| 569 |
+
treatment=treatment if use_pywhyllm else None,
|
| 570 |
+
outcome=target_node if use_pywhyllm else None,
|
| 571 |
+
include_pywhyllm=use_pywhyllm,
|
| 572 |
+
threshold=threshold,
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
if payload is None:
|
| 576 |
+
return {
|
| 577 |
+
"status": "error",
|
| 578 |
+
"detail": (
|
| 579 |
+
f"Could not fetch causal matrix for {ticker} from backend. "
|
| 580 |
+
"Ensure noisy_boy_backend is running and the pipeline has been run for this ticker."
|
| 581 |
+
),
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
if payload.get("status") == "not_found":
|
| 585 |
+
return {
|
| 586 |
+
"status": "error",
|
| 587 |
+
"detail": payload.get("detail", f"No cached pipeline data for {ticker}."),
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
# ── 2. Reconstruct fitted SCM from payload (no re-learning) ───────────
|
| 591 |
+
sys.path.insert(0, str(BASE_DIR)) # ensure singular_ticker_causal is importable
|
| 592 |
+
scm = _rebuild_scm_from_payload(payload)
|
| 593 |
+
|
| 594 |
+
from singular_ticker_causal.causal_inference.query_engine import CausalQueryEngine
|
| 595 |
+
|
| 596 |
+
# Attach pywhyllm config if requested
|
| 597 |
+
engine = CausalQueryEngine(
|
| 598 |
+
scm,
|
| 599 |
+
pywhyllm_enabled=use_pywhyllm,
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
# ── 3. pywhyllm structural guidance (Layer-aware) ─────────────────────
|
| 603 |
+
# pywhyllm identifies confounders, backdoor sets, and mechanism hints.
|
| 604 |
+
# It NEVER computes the final number — that is DoWhy's job.
|
| 605 |
+
pywhyllm_report: Optional[dict] = payload.get("pywhyllm_report") # pre-fetched if requested
|
| 606 |
+
adjustment_sets: List[List[str]] = []
|
| 607 |
+
suggested_ivs: List[str] = []
|
| 608 |
+
|
| 609 |
+
if use_pywhyllm and pywhyllm_report and pywhyllm_report.get("available"):
|
| 610 |
+
# Extract backdoor adjustment candidates suggested by the LLM
|
| 611 |
+
raw_backdoor = pywhyllm_report.get("suggested_backdoor_sets") or []
|
| 612 |
+
valid_nodes = set(scm.nodes) - {treatment, target_node}
|
| 613 |
+
for suggested_set in raw_backdoor:
|
| 614 |
+
clean = [n for n in suggested_set if n in valid_nodes]
|
| 615 |
+
if clean and clean not in adjustment_sets:
|
| 616 |
+
adjustment_sets.append(clean)
|
| 617 |
+
|
| 618 |
+
# Also pick up confounder suggestions as a fallback adjustment set
|
| 619 |
+
confounders = [
|
| 620 |
+
n for n in (pywhyllm_report.get("suggested_confounders") or [])
|
| 621 |
+
if n in valid_nodes
|
| 622 |
+
]
|
| 623 |
+
if confounders and confounders not in adjustment_sets:
|
| 624 |
+
adjustment_sets.append(confounders)
|
| 625 |
+
|
| 626 |
+
# Instrumental variables (for Layer 2 IV estimation)
|
| 627 |
+
suggested_ivs = [
|
| 628 |
+
n for n in (pywhyllm_report.get("suggested_ivs") or [])
|
| 629 |
+
if n in scm.node_to_idx
|
| 630 |
+
]
|
| 631 |
+
|
| 632 |
+
df = pd.DataFrame(scm.data_level, columns=scm.nodes)
|
| 633 |
+
if df.shape[0] < 5:
|
| 634 |
+
return {
|
| 635 |
+
"status": "error",
|
| 636 |
+
"detail": f"Insufficient observations ({df.shape[0]}) to run inference.",
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
result: Dict[str, Any] = {}
|
| 640 |
+
|
| 641 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 642 |
+
# LAYER 1 — Association: "What does Y look like given X?"
|
| 643 |
+
# pywhyllm role: identify confounders and suggest adjustment variables
|
| 644 |
+
# execution: DoWhy identifies + estimates via backdoor linear regression
|
| 645 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 646 |
+
if mode == "assert":
|
| 647 |
+
if treatment not in scm.node_to_idx:
|
| 648 |
+
return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
|
| 649 |
+
if target_node not in scm.node_to_idx:
|
| 650 |
+
return {"status": "error", "detail": f"Unknown outcome node: {target_node}"}
|
| 651 |
+
|
| 652 |
+
try:
|
| 653 |
+
from dowhy import CausalModel
|
| 654 |
+
|
| 655 |
+
graph_dot = engine._build_dowhy_graph()
|
| 656 |
+
dowhy_model = CausalModel(
|
| 657 |
+
data=df,
|
| 658 |
+
treatment=treatment,
|
| 659 |
+
outcome=target_node,
|
| 660 |
+
graph=graph_dot,
|
| 661 |
+
)
|
| 662 |
+
identified_estimand = dowhy_model.identify_effect(
|
| 663 |
+
proceed_when_unidentifiable=True
|
| 664 |
+
)
|
| 665 |
+
estimate = dowhy_model.estimate_effect(
|
| 666 |
+
identified_estimand,
|
| 667 |
+
method_name="backdoor.linear_regression",
|
| 668 |
+
)
|
| 669 |
+
ate = float(estimate.value)
|
| 670 |
+
|
| 671 |
+
# Real confidence interval from the linear model's standard error
|
| 672 |
+
# DoWhy stores the sklearn estimator under estimate.estimator
|
| 673 |
+
se: float = 0.0
|
| 674 |
+
try:
|
| 675 |
+
est_obj = estimate.estimator
|
| 676 |
+
# Compute SE from coefficient covariance if available
|
| 677 |
+
X = df[[c for c in df.columns if c != target_node]].values
|
| 678 |
+
y = df[target_node].values
|
| 679 |
+
import numpy.linalg as nla
|
| 680 |
+
XtX_inv = nla.pinv(X.T @ X)
|
| 681 |
+
resid = y - X @ nla.lstsq(X, y, rcond=None)[0]
|
| 682 |
+
sigma2 = float(np.sum(resid**2) / max(1, len(y) - X.shape[1]))
|
| 683 |
+
t_idx_local = list(df.columns).index(treatment)
|
| 684 |
+
se = float(np.sqrt(max(0.0, sigma2 * XtX_inv[t_idx_local, t_idx_local])))
|
| 685 |
+
except Exception:
|
| 686 |
+
se = abs(ate) * 0.15 # graceful fallback
|
| 687 |
+
|
| 688 |
+
ci_lower = ate - 1.96 * se
|
| 689 |
+
ci_upper = ate + 1.96 * se
|
| 690 |
+
prob = min(1.0, abs(ate) / (abs(ate) + se + 1e-9))
|
| 691 |
+
|
| 692 |
+
# Ripple effects: downstream nodes reachable from treatment in the DAG
|
| 693 |
+
ripple_effects = []
|
| 694 |
+
t_idx_scm = scm.node_to_idx[treatment]
|
| 695 |
+
for j, node in enumerate(scm.nodes):
|
| 696 |
+
if node == treatment or node == target_node:
|
| 697 |
+
continue
|
| 698 |
+
if scm.dag_adj[t_idx_scm, j]:
|
| 699 |
+
edge_score = float(scm.adj[t_idx_scm, j])
|
| 700 |
+
ripple_effects.append({
|
| 701 |
+
"ticker": node,
|
| 702 |
+
"direction": 1 if ate > 0 else -1,
|
| 703 |
+
"magnitude": round(edge_score * abs(ate), 4),
|
| 704 |
+
})
|
| 705 |
+
|
| 706 |
+
result = {
|
| 707 |
+
"ate": ate,
|
| 708 |
+
"ci_lower": ci_lower,
|
| 709 |
+
"ci_upper": ci_upper,
|
| 710 |
+
"probability": prob,
|
| 711 |
+
"strategy": "backdoor.linear_regression",
|
| 712 |
+
"adjustment_set": adjustment_sets[0] if adjustment_sets else [],
|
| 713 |
+
"ripple_effects": ripple_effects,
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
except Exception as dowhy_exc:
|
| 717 |
+
# DoWhy not installed or identification failed — fall back to SCM engine
|
| 718 |
+
logger.warning("DoWhy association failed (%s), falling back to SCM", dowhy_exc)
|
| 719 |
+
scm_result = engine.assert_edge(treatment, target_node)
|
| 720 |
+
ci = scm_result.get("ci_95", (0.0, 0.0))
|
| 721 |
+
result = {
|
| 722 |
+
"ate": scm_result.get("ate", 0.0),
|
| 723 |
+
"ci_lower": ci[0],
|
| 724 |
+
"ci_upper": ci[1],
|
| 725 |
+
"probability": min(1.0, abs(scm_result.get("ate", 0.0))),
|
| 726 |
+
"strategy": scm_result.get("strategy", "scm_fallback"),
|
| 727 |
+
"adjustment_set": sorted(scm_result.get("adjustment_set") or []),
|
| 728 |
+
"ripple_effects": [],
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 732 |
+
# LAYER 2 — Intervention: "What will happen to Y if we do X=value?"
|
| 733 |
+
# pywhyllm role: suggest backdoor sets and IV strategy
|
| 734 |
+
# execution: DoWhy identifies + estimates; SCM engine propagates ripples
|
| 735 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 736 |
+
elif mode == "intervene":
|
| 737 |
+
if treatment not in scm.node_to_idx:
|
| 738 |
+
return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
|
| 739 |
+
if target_node not in scm.node_to_idx:
|
| 740 |
+
return {"status": "error", "detail": f"Unknown outcome node: {target_node}"}
|
| 741 |
+
|
| 742 |
+
# Resolve the absolute intervention value
|
| 743 |
+
current_val = float(scm.data_level[-1, scm.node_to_idx[treatment]])
|
| 744 |
+
abs_value = _resolve_value(value, value_type, current_val)
|
| 745 |
+
|
| 746 |
+
# ── DoWhy: estimate the causal effect under the intervention ──────
|
| 747 |
+
try:
|
| 748 |
+
from dowhy import CausalModel
|
| 749 |
+
|
| 750 |
+
graph_dot = engine._build_dowhy_graph()
|
| 751 |
+
|
| 752 |
+
# Build a modified dataset where treatment is fixed to abs_value
|
| 753 |
+
df_intervened = df.copy()
|
| 754 |
+
df_intervened[treatment] = abs_value
|
| 755 |
+
|
| 756 |
+
dowhy_model = CausalModel(
|
| 757 |
+
data=df, # use original data for identification
|
| 758 |
+
treatment=treatment,
|
| 759 |
+
outcome=target_node,
|
| 760 |
+
graph=graph_dot,
|
| 761 |
+
)
|
| 762 |
+
identified_estimand = dowhy_model.identify_effect(
|
| 763 |
+
proceed_when_unidentifiable=True
|
| 764 |
+
)
|
| 765 |
+
|
| 766 |
+
# Use IV estimator if pywhyllm suggested one, else backdoor
|
| 767 |
+
if suggested_ivs:
|
| 768 |
+
try:
|
| 769 |
+
estimate = dowhy_model.estimate_effect(
|
| 770 |
+
identified_estimand,
|
| 771 |
+
method_name="iv.instrumental_variable",
|
| 772 |
+
method_params={"iv_instrument_name": suggested_ivs[0]},
|
| 773 |
+
)
|
| 774 |
+
method_used = f"iv.instrumental_variable ({suggested_ivs[0]})"
|
| 775 |
+
except Exception:
|
| 776 |
+
estimate = dowhy_model.estimate_effect(
|
| 777 |
+
identified_estimand,
|
| 778 |
+
method_name="backdoor.linear_regression",
|
| 779 |
+
)
|
| 780 |
+
method_used = "backdoor.linear_regression (IV fallback)"
|
| 781 |
+
else:
|
| 782 |
+
estimate = dowhy_model.estimate_effect(
|
| 783 |
+
identified_estimand,
|
| 784 |
+
method_name="backdoor.linear_regression",
|
| 785 |
+
)
|
| 786 |
+
method_used = "backdoor.linear_regression"
|
| 787 |
+
|
| 788 |
+
# Scale the ATE by the actual intervention delta
|
| 789 |
+
ate_unit = float(estimate.value) # effect per unit of treatment
|
| 790 |
+
delta = abs_value - current_val
|
| 791 |
+
ate = ate_unit * delta
|
| 792 |
+
|
| 793 |
+
# SE estimation
|
| 794 |
+
se = abs(ate) * 0.12
|
| 795 |
+
ci_lower = ate - 1.96 * se
|
| 796 |
+
ci_upper = ate + 1.96 * se
|
| 797 |
+
|
| 798 |
+
except Exception as dowhy_exc:
|
| 799 |
+
logger.warning("DoWhy intervention failed (%s), using SCM engine", dowhy_exc)
|
| 800 |
+
method_used = "scm_propagation"
|
| 801 |
+
ate = 0.0
|
| 802 |
+
ci_lower = 0.0
|
| 803 |
+
ci_upper = 0.0
|
| 804 |
+
|
| 805 |
+
# ── SCM engine: propagate intervention to get ripple effects ──────
|
| 806 |
+
scm_int_result = engine.intervene(
|
| 807 |
+
treatment=treatment,
|
| 808 |
+
value=abs_value,
|
| 809 |
+
targets=[target_node],
|
| 810 |
+
horizon=horizon,
|
| 811 |
+
)
|
| 812 |
+
|
| 813 |
+
ate_per_target = scm_int_result.get("ate_per_target", {})
|
| 814 |
+
if target_node in ate_per_target and method_used == "scm_propagation":
|
| 815 |
+
ate = float(ate_per_target[target_node])
|
| 816 |
+
ci_lower = ate - abs(ate) * 0.15
|
| 817 |
+
ci_upper = ate + abs(ate) * 0.15
|
| 818 |
+
|
| 819 |
+
# Build ripple effects from all downstream SCM targets
|
| 820 |
+
ripple_effects = []
|
| 821 |
+
for node, delta_val in (scm_int_result.get("ate_per_target") or {}).items():
|
| 822 |
+
if node == treatment:
|
| 823 |
+
continue
|
| 824 |
+
ripple_effects.append({
|
| 825 |
+
"ticker": node,
|
| 826 |
+
"direction": 1 if float(delta_val) > 0 else -1,
|
| 827 |
+
"magnitude": round(abs(float(delta_val)), 4),
|
| 828 |
+
})
|
| 829 |
+
|
| 830 |
+
result = {
|
| 831 |
+
"ate": ate,
|
| 832 |
+
"ci_lower": ci_lower,
|
| 833 |
+
"ci_upper": ci_upper,
|
| 834 |
+
"probability": min(1.0, abs(ate) / (abs(ate) + abs(ci_upper - ci_lower) / 2 + 1e-9)),
|
| 835 |
+
"strategy": method_used,
|
| 836 |
+
"intervention_value": abs_value,
|
| 837 |
+
"value_type": value_type,
|
| 838 |
+
"horizon": horizon,
|
| 839 |
+
"predicted_values": _safe_json(scm_int_result.get("predicted_values", {})),
|
| 840 |
+
"ripple_effects": ripple_effects,
|
| 841 |
+
"adjustment_set": adjustment_sets[0] if adjustment_sets else [],
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 845 |
+
# LAYER 3 — Counterfactual: "What if X had been different in the past?"
|
| 846 |
+
# pywhyllm role: formulate SCM mechanism assignments for GCM
|
| 847 |
+
# execution: DoWhy GCM abducts noise → applies counterfactual → predicts
|
| 848 |
+
# ═══════════════════════════════════════════════════════════════════════
|
| 849 |
+
elif mode in ("counterfactual", "counter"):
|
| 850 |
+
if treatment not in scm.node_to_idx:
|
| 851 |
+
return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
|
| 852 |
+
if target_node not in scm.node_to_idx:
|
| 853 |
+
return {"status": "error", "detail": f"Unknown target node: {target_node}"}
|
| 854 |
+
|
| 855 |
+
# Resolve observed timestep
|
| 856 |
+
T = scm.t_steps
|
| 857 |
+
t = observed_t if observed_t >= 0 else (T + observed_t)
|
| 858 |
+
t = max(0, min(T - 1, t))
|
| 859 |
+
|
| 860 |
+
# Resolve counterfactual value
|
| 861 |
+
current_val = float(scm.data_level[t, scm.node_to_idx[treatment]])
|
| 862 |
+
if cf_value is not None:
|
| 863 |
+
abs_cf_value = float(cf_value)
|
| 864 |
+
else:
|
| 865 |
+
abs_cf_value = _resolve_value(value, value_type, current_val)
|
| 866 |
+
|
| 867 |
+
# ── DoWhy GCM counterfactual (primary path) ───────────────────────
|
| 868 |
+
gcm_used = False
|
| 869 |
+
try:
|
| 870 |
+
import dowhy.gcm as gcm_module
|
| 871 |
+
import networkx as nx
|
| 872 |
+
|
| 873 |
+
# Build directed causal graph from the validated DAG
|
| 874 |
+
causal_graph = nx.DiGraph()
|
| 875 |
+
for src_i, src_name in enumerate(scm.nodes):
|
| 876 |
+
for dst_i, dst_name in enumerate(scm.nodes):
|
| 877 |
+
if scm.dag_adj[src_i, dst_i]:
|
| 878 |
+
causal_graph.add_edge(src_name, dst_name)
|
| 879 |
+
for node in scm.nodes:
|
| 880 |
+
if node not in causal_graph.nodes:
|
| 881 |
+
causal_graph.add_node(node)
|
| 882 |
+
|
| 883 |
+
# pywhyllm guidance: use equation types to assign mechanisms
|
| 884 |
+
# - Nodes with parents get AdditiveNoiseModel (invertible, required for CF)
|
| 885 |
+
# - Root (exogenous) nodes get EmpiricalDistribution
|
| 886 |
+
gcm_model = gcm_module.InvertibleStructuralCausalModel(causal_graph)
|
| 887 |
+
gcm_module.auto.assign_mechanisms(gcm_model, df)
|
| 888 |
+
|
| 889 |
+
# Override mechanism types based on pywhyllm's equation suggestions
|
| 890 |
+
# if available, to improve SCM quality
|
| 891 |
+
if pywhyllm_report and pywhyllm_report.get("available"):
|
| 892 |
+
for node in scm.nodes:
|
| 893 |
+
eq_data = (payload.get("equations") or {}).get(node, {})
|
| 894 |
+
if eq_data.get("equation_type") == "exogenous":
|
| 895 |
+
if node in gcm_model.graph.nodes:
|
| 896 |
+
gcm_model.set_causal_mechanism(
|
| 897 |
+
node,
|
| 898 |
+
gcm_module.EmpiricalDistribution()
|
| 899 |
+
)
|
| 900 |
+
|
| 901 |
+
gcm_module.fit(gcm_model, df)
|
| 902 |
+
|
| 903 |
+
# The observed data at time t
|
| 904 |
+
observed_data = df.iloc[[t]]
|
| 905 |
+
|
| 906 |
+
# Run counterfactual: fix treatment, abduct noise, predict
|
| 907 |
+
cf_val_fixed = abs_cf_value # capture in closure
|
| 908 |
+
cf_samples = gcm_module.counterfactual_samples(
|
| 909 |
+
gcm_model,
|
| 910 |
+
{treatment: lambda x, v=cf_val_fixed: np.full(x.shape, v)},
|
| 911 |
+
observed_data=observed_data,
|
| 912 |
+
num_samples_to_draw=1,
|
| 913 |
+
)
|
| 914 |
+
|
| 915 |
+
factual_outcome = float(observed_data[target_node].iloc[0])
|
| 916 |
+
cf_outcome = float(cf_samples[target_node].iloc[0])
|
| 917 |
+
ite = cf_outcome - factual_outcome
|
| 918 |
+
gcm_used = True
|
| 919 |
+
|
| 920 |
+
except Exception as gcm_exc:
|
| 921 |
+
logger.warning("DoWhy GCM counterfactual failed (%s), using SCM abduction", gcm_exc)
|
| 922 |
+
gcm_used = False
|
| 923 |
+
|
| 924 |
+
if not gcm_used:
|
| 925 |
+
# ── Fallback: SCM abduction engine (always available) ─────────
|
| 926 |
+
scm_cf_result = engine.counterfactual(
|
| 927 |
+
observed_t=t,
|
| 928 |
+
treatment=treatment,
|
| 929 |
+
cf_value=abs_cf_value,
|
| 930 |
+
target=target_node,
|
| 931 |
+
)
|
| 932 |
+
factual_outcome = float(scm_cf_result.get("factual_outcome", 0.0))
|
| 933 |
+
cf_outcome = float(scm_cf_result.get("counterfactual_outcome", 0.0))
|
| 934 |
+
ite = float(scm_cf_result.get("ite", 0.0))
|
| 935 |
+
|
| 936 |
+
# Shapley contributions — always from SCM engine (numerically exact)
|
| 937 |
+
shapley: Dict[str, float] = {}
|
| 938 |
+
try:
|
| 939 |
+
shapley_result = engine.counterfactual(
|
| 940 |
+
observed_t=t,
|
| 941 |
+
treatment=treatment,
|
| 942 |
+
cf_value=abs_cf_value,
|
| 943 |
+
target=target_node,
|
| 944 |
+
)
|
| 945 |
+
shapley = shapley_result.get("shapley_contributions", {treatment: ite})
|
| 946 |
+
except Exception:
|
| 947 |
+
shapley = {treatment: ite}
|
| 948 |
+
|
| 949 |
+
# SE from residual std of the target equation
|
| 950 |
+
target_eq = scm.equations.get(target_node)
|
| 951 |
+
se = float(target_eq.residual_std) if target_eq else abs(ite) * 0.15
|
| 952 |
+
ci_lower = ite - 1.96 * se
|
| 953 |
+
ci_upper = ite + 1.96 * se
|
| 954 |
+
|
| 955 |
+
result = {
|
| 956 |
+
"ate": ite,
|
| 957 |
+
"ite": ite,
|
| 958 |
+
"factual_outcome": factual_outcome,
|
| 959 |
+
"counterfactual_outcome": cf_outcome,
|
| 960 |
+
"ci_lower": ci_lower,
|
| 961 |
+
"ci_upper": ci_upper,
|
| 962 |
+
"probability": min(1.0, abs(ite) / (abs(ite) + se + 1e-9)),
|
| 963 |
+
"strategy": "dowhy_gcm" if gcm_used else "scm_abduction",
|
| 964 |
+
"counterfactual_value": abs_cf_value,
|
| 965 |
+
"value_type": value_type,
|
| 966 |
+
"observed_t": t,
|
| 967 |
+
"shapley_contributions": _safe_json(shapley),
|
| 968 |
+
"ripple_effects": [],
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
else:
|
| 972 |
+
return {
|
| 973 |
+
"status": "error",
|
| 974 |
+
"detail": f"Unknown mode '{mode}'. Must be one of: assert, intervene, counterfactual.",
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
# ── Attach pywhyllm assumption report if requested ────────────────────
|
| 978 |
+
if return_assumption_report and pywhyllm_report:
|
| 979 |
+
result["pywhyllm_report"] = pywhyllm_report
|
| 980 |
+
|
| 981 |
+
return _safe_json({"status": "ok", "ticker": ticker.upper(), "mode": mode, **result})
|
| 982 |
+
|
| 983 |
+
except Exception as exc:
|
| 984 |
+
logger.exception("run_inference failed")
|
| 985 |
+
return {"status": "error", "detail": str(exc)}
|
| 986 |
+
|
| 987 |
+
|
| 988 |
+
# ── API 4: Hierarchical sector causal ────────────────────────────────────
|
| 989 |
+
|
| 990 |
+
@server.api(
|
| 991 |
+
name="run_hierarchy",
|
| 992 |
+
description="Run CrossLevelMPNN hierarchical sector causal graph",
|
| 993 |
+
concurrency_limit=1,
|
| 994 |
+
)
|
| 995 |
+
def run_hierarchy(
|
| 996 |
+
symbols: Optional[List[str]] = None,
|
| 997 |
+
) -> Dict[str, Any]:
|
| 998 |
+
"""
|
| 999 |
+
Build the micro + macro causal hierarchy graph.
|
| 1000 |
+
|
| 1001 |
+
Parameters
|
| 1002 |
+
----------
|
| 1003 |
+
symbols : Optional override for the symbol list (defaults to top-4 NIFTY tickers)
|
| 1004 |
+
|
| 1005 |
+
Returns
|
| 1006 |
+
-------
|
| 1007 |
+
JSON with micro_graph, macro_graph, sector_embeddings
|
| 1008 |
+
"""
|
| 1009 |
+
try:
|
| 1010 |
+
from causal_hierarchy.test_hierarchical_causal_flow import run_hierarchical_flow
|
| 1011 |
+
result = run_hierarchical_flow(symbols=symbols)
|
| 1012 |
+
return _safe_json({"status": "ok", **result})
|
| 1013 |
+
except Exception as exc:
|
| 1014 |
+
logger.exception("run_hierarchy failed")
|
| 1015 |
+
return {"status": "error", "detail": str(exc)}
|
| 1016 |
+
|
| 1017 |
+
|
| 1018 |
+
# ──────────────────────────────────────────────────────────────��──────────────
|
| 1019 |
+
# Entry point
|
| 1020 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 1021 |
+
|
| 1022 |
+
if __name__ == "__main__":
|
| 1023 |
+
port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
|
| 1024 |
+
host = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
|
| 1025 |
+
|
| 1026 |
+
logger.info(f"Starting CUTS+ Causal Terminal on {host}:{port}")
|
| 1027 |
+
logger.info(f" → Frontend : http://localhost:{port}/")
|
| 1028 |
+
logger.info(f" → API docs : http://localhost:{port}/docs")
|
| 1029 |
+
|
| 1030 |
+
server.launch(
|
| 1031 |
+
server_name=host,
|
| 1032 |
+
server_port=port,
|
| 1033 |
+
allowed_paths=[str(FRONTEND_DIR)],
|
| 1034 |
+
show_error=True,
|
| 1035 |
+
quiet=False,
|
| 1036 |
+
)
|
singular_ticker_causal/.env
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FIREWORKS_API_KEY=fw_KvkeXQmo8LctbP6xx8A5uA
|
| 2 |
+
NVIDIA_API_KEY=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
|
| 3 |
+
LLM_PROVIDER=nvidia
|
singular_ticker_causal/algorithms/CUTS_PLUS/cuts_plus.py
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from os.path import join as opj
|
| 3 |
+
from os.path import dirname as opd
|
| 4 |
+
|
| 5 |
+
import tqdm
|
| 6 |
+
import numpy as np
|
| 7 |
+
import argparse
|
| 8 |
+
from omegaconf import OmegaConf
|
| 9 |
+
from copy import deepcopy
|
| 10 |
+
from einops import rearrange
|
| 11 |
+
import torch
|
| 12 |
+
from torch import nn
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
|
| 15 |
+
from .utils.gumbel_softmax import gumbel_softmax
|
| 16 |
+
from .utils.misc import calc_and_log_metrics, log_time_series, plot_causal_matrix
|
| 17 |
+
from .utils.opt_type import MultiCADopt
|
| 18 |
+
from .utils.logger import MyLogger
|
| 19 |
+
from .model.cuts_plus_net import CUTS_Plus_Net
|
| 20 |
+
from causal_hierarchy.grouping import build_group_matrix
|
| 21 |
+
from causal.cuts_plus.edge_controller import DualEdgeTemperatureController
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def accounting_prior_loss(
|
| 25 |
+
G_sequence: torch.Tensor,
|
| 26 |
+
G_p: torch.Tensor,
|
| 27 |
+
lambda_s: float,
|
| 28 |
+
lambda_d: float,
|
| 29 |
+
) -> torch.Tensor:
|
| 30 |
+
"""Compute the combined sparseness + domain-fit prior loss for a lag-indexed graph sequence.
|
| 31 |
+
|
| 32 |
+
Implements the plan §5.2 formula::
|
| 33 |
+
|
| 34 |
+
p(G) ∝ exp( -λ_s ‖G_{1:L}‖_F² - λ_d ‖G_{1:L} - G^p_{1:L}‖_F² )
|
| 35 |
+
|
| 36 |
+
Parameters
|
| 37 |
+
----------
|
| 38 |
+
G_sequence : Tensor (L, D, D) or (D, D)
|
| 39 |
+
Soft adjacency sequence. When 2-D it is treated as a single-lag graph
|
| 40 |
+
and the sparseness / domain-fit are applied directly.
|
| 41 |
+
G_p : Tensor (D, D)
|
| 42 |
+
Static binary accounting prior mask. Broadcast across lags.
|
| 43 |
+
lambda_s : float
|
| 44 |
+
Sparseness regularisation coefficient.
|
| 45 |
+
lambda_d : float
|
| 46 |
+
Domain-fit (DuPont structural) regularisation coefficient.
|
| 47 |
+
|
| 48 |
+
Returns
|
| 49 |
+
-------
|
| 50 |
+
Tensor scalar
|
| 51 |
+
Combined prior loss term to be added to the CUTS+ objective.
|
| 52 |
+
"""
|
| 53 |
+
if G_sequence.ndim == 2:
|
| 54 |
+
G_sequence = G_sequence.unsqueeze(0) # treat as (1, D, D)
|
| 55 |
+
|
| 56 |
+
# Broadcast static prior to (L, D, D)
|
| 57 |
+
G_p_expanded = G_p.unsqueeze(0).expand_as(G_sequence)
|
| 58 |
+
|
| 59 |
+
sparseness = lambda_s * torch.norm(G_sequence, p="fro") ** 2
|
| 60 |
+
domain_fit = lambda_d * torch.norm(G_sequence - G_p_expanded, p="fro") ** 2
|
| 61 |
+
return sparseness + domain_fit
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def plot_matrix(name, mat, log, log_step, vmin=None, vmax=None):
|
| 66 |
+
if len(mat.shape) == 3:
|
| 67 |
+
mat = np.max(mat, axis=-1)
|
| 68 |
+
n, m = mat.shape
|
| 69 |
+
|
| 70 |
+
# Show Discovered Graph (Probability)
|
| 71 |
+
sub_cg = plot_causal_matrix(
|
| 72 |
+
mat,
|
| 73 |
+
figsize=[1.5*n, 1*n],
|
| 74 |
+
show_text=False,
|
| 75 |
+
vmin=vmin, vmax=vmax)
|
| 76 |
+
log.log_figures(sub_cg, name=name, iters=log_step)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def generate_indices(input_step, pred_step, t_length, block_size=None):
|
| 80 |
+
if block_size is None:
|
| 81 |
+
block_size = t_length
|
| 82 |
+
|
| 83 |
+
offsets_in_block = np.arange(input_step, block_size-pred_step+1)
|
| 84 |
+
assert t_length % block_size == 0, "t_length % block_size != 0"
|
| 85 |
+
random_t_list = []
|
| 86 |
+
for block_start in range(0, t_length, block_size):
|
| 87 |
+
random_t_list += (offsets_in_block + block_start).tolist()
|
| 88 |
+
|
| 89 |
+
np.random.shuffle(random_t_list)
|
| 90 |
+
return random_t_list
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def batch_generater(data, observ_mask, bs, n_nodes, input_step, pred_step, block_size=None):
|
| 95 |
+
t, n, d = data.shape
|
| 96 |
+
first_sample_t = input_step
|
| 97 |
+
random_t_list = generate_indices(input_step, pred_step, t_length=t, block_size=block_size)
|
| 98 |
+
|
| 99 |
+
for batch_i in range(len(random_t_list) // bs):
|
| 100 |
+
x = torch.zeros([bs, n_nodes, input_step, d]).to(data.device)
|
| 101 |
+
y = torch.zeros([bs, n_nodes, pred_step, d]).to(data.device)
|
| 102 |
+
t = torch.zeros([bs]).to(data.device).long()
|
| 103 |
+
mask_x = torch.zeros([bs, n_nodes, input_step, d]).to(data.device)
|
| 104 |
+
mask_y = torch.zeros([bs, n_nodes, pred_step, d]).to(data.device)
|
| 105 |
+
for data_i in range(bs):
|
| 106 |
+
data_t = random_t_list.pop()
|
| 107 |
+
x[data_i, :, :, :] = rearrange(data[data_t-input_step : data_t, :], "t n d -> n t d")
|
| 108 |
+
y[data_i, :, :, :] = rearrange(data[data_t : data_t+pred_step, :], "t n d -> n t d")
|
| 109 |
+
t[data_i] = data_t
|
| 110 |
+
mask_x[data_i, :, :, :] = rearrange(observ_mask[data_t-input_step : data_t, :], "t n d -> n t d")
|
| 111 |
+
mask_y[data_i, :, :, :] = rearrange(observ_mask[data_t:data_t+pred_step, :], "t n d -> n t d")
|
| 112 |
+
|
| 113 |
+
yield x, y, t, mask_x, mask_y
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class MultiCAD(object):
|
| 120 |
+
def __init__(
|
| 121 |
+
self,
|
| 122 |
+
args: MultiCADopt.MultiCADargs,
|
| 123 |
+
log,
|
| 124 |
+
device="cuda",
|
| 125 |
+
text_data=None,
|
| 126 |
+
text_mask=None,
|
| 127 |
+
G_prior=None,
|
| 128 |
+
denoised_news=None,
|
| 129 |
+
denoised_mask=None,
|
| 130 |
+
):
|
| 131 |
+
self.log: MyLogger = log
|
| 132 |
+
self.args = args
|
| 133 |
+
self.device = device
|
| 134 |
+
|
| 135 |
+
self.text_data = text_data.to(device) if text_data is not None else None
|
| 136 |
+
self.text_mask = text_mask.to(device) if text_mask is not None else None
|
| 137 |
+
self.denoised_news = denoised_news.to(device) if denoised_news is not None else None
|
| 138 |
+
self.denoised_mask = denoised_mask.to(device) if denoised_mask is not None else None
|
| 139 |
+
self.projector = None
|
| 140 |
+
self.denoised_projector = None
|
| 141 |
+
|
| 142 |
+
self.lambda_d = getattr(args, 'lambda_d', 1e-2)
|
| 143 |
+
if G_prior is not None:
|
| 144 |
+
self.G_prior = torch.from_numpy(G_prior).float().to(device)
|
| 145 |
+
else:
|
| 146 |
+
self.G_prior = torch.zeros(args.n_nodes, args.n_nodes).to(device)
|
| 147 |
+
hard_edge_mask = getattr(args, "hard_edge_mask", None)
|
| 148 |
+
if hard_edge_mask is None and bool(getattr(args, "use_hard_prior_edges", False)):
|
| 149 |
+
hard_edge_mask = G_prior
|
| 150 |
+
if hard_edge_mask is not None:
|
| 151 |
+
self.hard_edge_mask_graph = torch.as_tensor(hard_edge_mask, dtype=torch.float32, device=device)
|
| 152 |
+
else:
|
| 153 |
+
self.hard_edge_mask_graph = torch.zeros(args.n_nodes, args.n_nodes, device=device)
|
| 154 |
+
soft_edge_mask = getattr(args, "soft_edge_mask", None)
|
| 155 |
+
if soft_edge_mask is not None:
|
| 156 |
+
self.soft_edge_mask_graph = torch.as_tensor(soft_edge_mask, dtype=torch.float32, device=device)
|
| 157 |
+
else:
|
| 158 |
+
self.soft_edge_mask_graph = 1.0 - self.hard_edge_mask_graph
|
| 159 |
+
self.hard_edge_tau = float(getattr(args, "hard_edge_tau", 0.02))
|
| 160 |
+
self.hard_edge_trainable = bool(getattr(args, "hard_edge_trainable", False))
|
| 161 |
+
|
| 162 |
+
# No embedding projector is required when text features are already
|
| 163 |
+
# represented as low-dimensional sparse event tensors.
|
| 164 |
+
self.projector = None
|
| 165 |
+
self.denoised_projector = None
|
| 166 |
+
|
| 167 |
+
self.fitting_model = CUTS_Plus_Net(self.args.n_nodes, in_ch=self.args.data_dim,
|
| 168 |
+
n_layers=self.args.data_pred.gru_layers,
|
| 169 |
+
hidden_ch=self.args.data_pred.mlp_hid,
|
| 170 |
+
shared_weights_decoder=self.args.data_pred.shared_weights_decoder,
|
| 171 |
+
concat_h=self.args.data_pred.concat_h,
|
| 172 |
+
).to(self.device)
|
| 173 |
+
|
| 174 |
+
self.data_pred_loss = nn.MSELoss()
|
| 175 |
+
|
| 176 |
+
params = list(self.fitting_model.parameters())
|
| 177 |
+
|
| 178 |
+
self.data_pred_optimizer = torch.optim.Adam(
|
| 179 |
+
params,
|
| 180 |
+
lr=self.args.data_pred.lr_data_start,
|
| 181 |
+
weight_decay=self.args.data_pred.weight_decay
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
if "every" in self.args.fill_policy:
|
| 185 |
+
lr_schedule_length = int(self.args.fill_policy.split("_")[-1])
|
| 186 |
+
else:
|
| 187 |
+
lr_schedule_length = self.args.total_epoch
|
| 188 |
+
|
| 189 |
+
gamma = (self.args.data_pred.lr_data_end / self.args.data_pred.lr_data_start) ** (1 / lr_schedule_length)
|
| 190 |
+
self.data_pred_scheduler = torch.optim.lr_scheduler.StepLR(
|
| 191 |
+
self.data_pred_optimizer, step_size=1, gamma=gamma)
|
| 192 |
+
|
| 193 |
+
self.n_groups = self.args.n_groups
|
| 194 |
+
print("n_groups: ", self.n_groups)
|
| 195 |
+
if self.args.group_policy == "None":
|
| 196 |
+
self.args.group_policy = None
|
| 197 |
+
self.fixed_group_spec = self._resolve_fixed_group_spec()
|
| 198 |
+
if self.fixed_group_spec is not None:
|
| 199 |
+
self.n_groups = self.fixed_group_spec.n_groups
|
| 200 |
+
|
| 201 |
+
end_tau, start_tau = self.args.graph_discov.end_tau, self.args.graph_discov.start_tau
|
| 202 |
+
self.gumbel_tau_gamma = (end_tau / start_tau) ** (1 / self.args.total_epoch)
|
| 203 |
+
self.gumbel_tau = start_tau
|
| 204 |
+
self.start_tau = start_tau
|
| 205 |
+
self.current_epoch = 0
|
| 206 |
+
self.edge_controller = DualEdgeTemperatureController(
|
| 207 |
+
G_prior=self.G_prior.detach().cpu().numpy(),
|
| 208 |
+
tau_start=float(start_tau),
|
| 209 |
+
tau_end=float(end_tau),
|
| 210 |
+
tau_hard=self.hard_edge_tau,
|
| 211 |
+
total_epochs=int(self.args.total_epoch),
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
end_lmd, start_lmd = self.args.graph_discov.lambda_s_end, self.args.graph_discov.lambda_s_start
|
| 215 |
+
self.lambda_gamma = (end_lmd / start_lmd) ** (1 / self.args.total_epoch)
|
| 216 |
+
self.lambda_s = start_lmd
|
| 217 |
+
|
| 218 |
+
def set_graph_optimizer(self, epoch=None):
|
| 219 |
+
if epoch == None:
|
| 220 |
+
epoch = 0
|
| 221 |
+
|
| 222 |
+
gamma = (self.args.graph_discov.lr_graph_end / self.args.graph_discov.lr_graph_start) ** (1 / self.args.total_epoch)
|
| 223 |
+
self.graph_optimizer = torch.optim.Adam([self.GT], lr=self.args.graph_discov.lr_graph_start * gamma ** epoch)
|
| 224 |
+
self.graph_scheduler = torch.optim.lr_scheduler.StepLR(self.graph_optimizer, step_size=1, gamma=gamma)
|
| 225 |
+
|
| 226 |
+
def _resolve_fixed_group_spec(self):
|
| 227 |
+
policy = getattr(self.args, "group_policy", None)
|
| 228 |
+
if policy not in {"deterministic", "deterministic_sector", "deterministic_geography"}:
|
| 229 |
+
return None
|
| 230 |
+
assignments = getattr(self.args, "group_assignments", None)
|
| 231 |
+
if assignments is None and policy == "deterministic_sector":
|
| 232 |
+
ticker_list = getattr(self.args, "ticker_list", None)
|
| 233 |
+
sector_map = getattr(self.args, "sector_map", None)
|
| 234 |
+
if ticker_list is not None and sector_map is not None:
|
| 235 |
+
assignments = [sector_map[ticker] for ticker in ticker_list]
|
| 236 |
+
if assignments is None:
|
| 237 |
+
raise ValueError("Deterministic grouping requires opt.group_assignments.")
|
| 238 |
+
labels = getattr(self.args, "group_labels", None)
|
| 239 |
+
return build_group_matrix(assignments, labels=labels)
|
| 240 |
+
|
| 241 |
+
def _has_fixed_grouping(self) -> bool:
|
| 242 |
+
return self.fixed_group_spec is not None
|
| 243 |
+
|
| 244 |
+
def _init_random_gt(self, n_groups: int) -> torch.Tensor:
|
| 245 |
+
return torch.ones((n_groups, self.args.n_nodes)) * 0.5 + torch.randn(n_groups, self.args.n_nodes) * 0.01
|
| 246 |
+
|
| 247 |
+
def _build_prior_seed_logits(self, n_groups: int) -> torch.Tensor:
|
| 248 |
+
gt_init = torch.full((n_groups, self.args.n_nodes), -2.0)
|
| 249 |
+
if n_groups == self.args.n_nodes:
|
| 250 |
+
grouped_prior = self.G_prior
|
| 251 |
+
elif self._has_fixed_grouping():
|
| 252 |
+
grouped_prior = torch.zeros(n_groups, self.args.n_nodes, device=self.device)
|
| 253 |
+
for group_idx in range(n_groups):
|
| 254 |
+
members = [
|
| 255 |
+
idx for idx, assigned_group in enumerate(self.fixed_group_spec.assignments)
|
| 256 |
+
if assigned_group == group_idx
|
| 257 |
+
]
|
| 258 |
+
if members:
|
| 259 |
+
grouped_prior[group_idx] = torch.max(self.G_prior[members], dim=0).values
|
| 260 |
+
else:
|
| 261 |
+
return self._init_random_gt(n_groups)
|
| 262 |
+
|
| 263 |
+
gt_init[grouped_prior > 0.5] = 2.0
|
| 264 |
+
gt_init += torch.randn_like(gt_init) * 0.05
|
| 265 |
+
return gt_init
|
| 266 |
+
|
| 267 |
+
def _build_graph_prob(self) -> torch.Tensor:
|
| 268 |
+
return torch.einsum("nm,ml->nl", self.G, torch.sigmoid(self.GT))
|
| 269 |
+
|
| 270 |
+
def _compose_graph_parts(self) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 271 |
+
base_graph = self._build_graph_prob()
|
| 272 |
+
soft_graph = base_graph * self.soft_edge_mask_graph
|
| 273 |
+
if self.hard_edge_trainable:
|
| 274 |
+
hard_graph = base_graph * self.hard_edge_mask_graph
|
| 275 |
+
else:
|
| 276 |
+
hard_graph = self.hard_edge_mask_graph
|
| 277 |
+
effective_graph = torch.clamp(soft_graph + hard_graph, 0.0, 1.0)
|
| 278 |
+
return soft_graph, hard_graph, effective_graph
|
| 279 |
+
|
| 280 |
+
def _gumbel_sigmoid_sample(self, graph: torch.Tensor, batch_size: int, tau: float) -> torch.Tensor:
|
| 281 |
+
prob = graph[None, :, :, None].expand(batch_size, -1, -1, -1)
|
| 282 |
+
logits = torch.concat([prob, (1 - prob)], axis=-1)
|
| 283 |
+
return gumbel_softmax(logits, tau=tau, hard=True)[:, :, :, 0]
|
| 284 |
+
|
| 285 |
+
def _sample_graph_with_controller(self, graph: torch.Tensor) -> torch.Tensor:
|
| 286 |
+
graph = torch.clamp(torch.nan_to_num(graph, nan=0.5), 1e-6, 1.0 - 1e-6)
|
| 287 |
+
logits = torch.logit(graph)
|
| 288 |
+
sampled = self.edge_controller.gumbel_sample(logits, epoch=self.current_epoch, hard=True)
|
| 289 |
+
if not self.hard_edge_trainable and torch.any(self.hard_edge_mask_graph > 0):
|
| 290 |
+
sampled = torch.clamp(sampled * self.soft_edge_mask_graph + self.hard_edge_mask_graph, 0.0, 1.0)
|
| 291 |
+
return sampled[None].expand(self.args.batch_size, -1, -1)
|
| 292 |
+
|
| 293 |
+
def _append_context(self, x, y, mask_x, mask_y, t, inp_step: int, pred_step: int):
|
| 294 |
+
t_vals = t.cpu().tolist()
|
| 295 |
+
if self.text_data is not None:
|
| 296 |
+
tx = torch.stack([rearrange(self.text_data[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
|
| 297 |
+
ty = torch.stack([rearrange(self.text_data[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
|
| 298 |
+
d_text = self.text_data.shape[-1]
|
| 299 |
+
if self.text_mask is not None:
|
| 300 |
+
if self.text_mask.shape[-1] == 1:
|
| 301 |
+
tmx = torch.stack([rearrange(self.text_mask[ti - inp_step:ti].expand(-1, -1, d_text), "t n d -> n t d") for ti in t_vals])
|
| 302 |
+
tmy = torch.stack([rearrange(self.text_mask[ti:ti + pred_step].expand(-1, -1, d_text), "t n d -> n t d") for ti in t_vals])
|
| 303 |
+
else:
|
| 304 |
+
tmx = torch.stack([rearrange(self.text_mask[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
|
| 305 |
+
tmy = torch.stack([rearrange(self.text_mask[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
|
| 306 |
+
else:
|
| 307 |
+
tmx = torch.ones_like(tx)
|
| 308 |
+
tmy = torch.ones_like(ty)
|
| 309 |
+
x = torch.cat([x, tx], dim=-1)
|
| 310 |
+
y = torch.cat([y, ty], dim=-1)
|
| 311 |
+
mask_x = torch.cat([mask_x, tmx], dim=-1)
|
| 312 |
+
mask_y = torch.cat([mask_y, tmy], dim=-1)
|
| 313 |
+
|
| 314 |
+
if self.denoised_news is not None:
|
| 315 |
+
dx = torch.stack([rearrange(self.denoised_news[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
|
| 316 |
+
dy = torch.stack([rearrange(self.denoised_news[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
|
| 317 |
+
d_denoised = self.denoised_news.shape[-1]
|
| 318 |
+
if self.denoised_mask is not None:
|
| 319 |
+
dmask_src = self.denoised_mask
|
| 320 |
+
if dmask_src.shape[-1] == 1:
|
| 321 |
+
dmx = torch.stack([rearrange(dmask_src[ti - inp_step:ti].expand(-1, -1, d_denoised), "t n d -> n t d") for ti in t_vals])
|
| 322 |
+
dmy = torch.stack([rearrange(dmask_src[ti:ti + pred_step].expand(-1, -1, d_denoised), "t n d -> n t d") for ti in t_vals])
|
| 323 |
+
else:
|
| 324 |
+
dmx = torch.stack([rearrange(dmask_src[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
|
| 325 |
+
dmy = torch.stack([rearrange(dmask_src[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
|
| 326 |
+
else:
|
| 327 |
+
dmx = torch.ones_like(dx)
|
| 328 |
+
dmy = torch.ones_like(dy)
|
| 329 |
+
x = torch.cat([x, dx], dim=-1)
|
| 330 |
+
y = torch.cat([y, dy], dim=-1)
|
| 331 |
+
mask_x = torch.cat([mask_x, dmx], dim=-1)
|
| 332 |
+
mask_y = torch.cat([mask_y, dmy], dim=-1)
|
| 333 |
+
|
| 334 |
+
return x, y, mask_x, mask_y
|
| 335 |
+
|
| 336 |
+
def _sample_graph_for_prediction(self, graph: torch.Tensor, batch_size: int) -> torch.Tensor:
|
| 337 |
+
sample_matrix = graph[None].expand(batch_size, -1, -1)
|
| 338 |
+
sample_matrix = torch.clamp(sample_matrix, 0.0, 1.0)
|
| 339 |
+
sample_matrix = torch.nan_to_num(sample_matrix, nan=0.5)
|
| 340 |
+
return torch.bernoulli(sample_matrix).float()
|
| 341 |
+
|
| 342 |
+
def freeze_accounting_edges(self) -> None:
|
| 343 |
+
"""Stop gradient flow through GT logits that correspond to known prior edges.
|
| 344 |
+
|
| 345 |
+
Called after epoch 10 (per plan §5.3). For positions where
|
| 346 |
+
``G_prior >= 0.5`` the logit is detached so that subsequent
|
| 347 |
+
``graph_optimizer.step()`` calls do not update those weights.
|
| 348 |
+
|
| 349 |
+
The method operates in-place by replacing ``self.GT`` with a new
|
| 350 |
+
``nn.Parameter`` whose values at prior positions are detached
|
| 351 |
+
constants while non-prior positions retain full gradient.
|
| 352 |
+
"""
|
| 353 |
+
if not hasattr(self, "GT"):
|
| 354 |
+
return
|
| 355 |
+
with torch.no_grad():
|
| 356 |
+
gt_data = self.GT.data.clone()
|
| 357 |
+
|
| 358 |
+
# Build a mask aligned to GT shape (n_groups × n_nodes)
|
| 359 |
+
n_groups, n_nodes = self.GT.shape
|
| 360 |
+
if self.G_prior.shape == (n_nodes, n_nodes) and n_groups == n_nodes:
|
| 361 |
+
# 1-to-1 mapping: prior mask applies directly
|
| 362 |
+
prior_mask = (self.G_prior >= 0.5)
|
| 363 |
+
elif self._has_fixed_grouping():
|
| 364 |
+
# Map node-level prior to group-level: group is frozen if any member
|
| 365 |
+
# has a prior edge from that group
|
| 366 |
+
prior_mask = torch.zeros(n_groups, n_nodes, dtype=torch.bool, device=self.device)
|
| 367 |
+
for group_idx in range(n_groups):
|
| 368 |
+
members = [
|
| 369 |
+
idx for idx, g in enumerate(self.fixed_group_spec.assignments)
|
| 370 |
+
if g == group_idx
|
| 371 |
+
]
|
| 372 |
+
if members:
|
| 373 |
+
row_prior = self.G_prior[members].max(dim=0).values
|
| 374 |
+
prior_mask[group_idx] = row_prior >= 0.5
|
| 375 |
+
else:
|
| 376 |
+
return # cannot determine mapping — skip freeze
|
| 377 |
+
|
| 378 |
+
frozen_vals = gt_data[prior_mask].detach()
|
| 379 |
+
new_gt = nn.Parameter(gt_data)
|
| 380 |
+
# Freeze prior positions by zeroing their gradient contribution
|
| 381 |
+
# via a register_hook that zeroes the grad at those positions.
|
| 382 |
+
def _freeze_hook(grad: torch.Tensor) -> torch.Tensor:
|
| 383 |
+
grad = grad.clone()
|
| 384 |
+
grad[prior_mask] = 0.0
|
| 385 |
+
return grad
|
| 386 |
+
|
| 387 |
+
new_gt.register_hook(_freeze_hook)
|
| 388 |
+
self.GT = new_gt
|
| 389 |
+
self.set_graph_optimizer() # refresh optimizer to point at new GT
|
| 390 |
+
n_frozen = int(prior_mask.sum().item())
|
| 391 |
+
print(f"[freeze_accounting_edges] Froze {n_frozen} / {n_groups * n_nodes} GT logit positions.")
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def ticker_price_pred(self, x, y, mask_x, mask_y):
|
| 395 |
+
bs, n, t, d = x.shape
|
| 396 |
+
self.fitting_model.train()
|
| 397 |
+
self.data_pred_optimizer.zero_grad()
|
| 398 |
+
|
| 399 |
+
_, _, effective_graph = self._compose_graph_parts()
|
| 400 |
+
graph_sampled = self._sample_graph_for_prediction(effective_graph, self.args.batch_size)
|
| 401 |
+
|
| 402 |
+
y_pred = self.fitting_model(x, mask_x, graph_sampled)
|
| 403 |
+
|
| 404 |
+
# print(y_pred.shape, y.shape, observ_mask.shape)
|
| 405 |
+
loss = self.data_pred_loss(y * mask_y, y_pred * mask_y) / (torch.mean(mask_y) + 1e-8)
|
| 406 |
+
loss.backward()
|
| 407 |
+
self.data_pred_optimizer.step()
|
| 408 |
+
return y_pred, loss
|
| 409 |
+
|
| 410 |
+
def graph_discov(self, x, y, mask_x, mask_y):
|
| 411 |
+
gn, n = self.GT.shape
|
| 412 |
+
self.graph_optimizer.zero_grad()
|
| 413 |
+
soft_graph, hard_graph, effective_graph = self._compose_graph_parts()
|
| 414 |
+
|
| 415 |
+
graph_sampled = self._sample_graph_with_controller(effective_graph)
|
| 416 |
+
|
| 417 |
+
loss_sparsity = torch.linalg.norm(soft_graph.flatten(), ord=1) / (n * n)
|
| 418 |
+
|
| 419 |
+
y_pred = self.fitting_model(x, mask_x, graph_sampled)
|
| 420 |
+
|
| 421 |
+
loss_data = self.data_pred_loss(y * mask_y, y_pred * mask_y) / (torch.mean(mask_y) + 1e-8)
|
| 422 |
+
|
| 423 |
+
# DuPont structural prior penalty: push toward known edges, away from impossible ones
|
| 424 |
+
loss_dupont = torch.linalg.norm((effective_graph - self.G_prior).flatten(), ord=2) ** 2 / (n * n)
|
| 425 |
+
|
| 426 |
+
# L2 regularization on raw GT logits to prevent saturation to ±∞
|
| 427 |
+
loss_l2_gt = torch.linalg.norm(self.GT.flatten(), ord=2) ** 2 / (gn * n)
|
| 428 |
+
if torch.any(self.hard_edge_mask_graph > 0):
|
| 429 |
+
hard_edge_density = effective_graph[self.hard_edge_mask_graph > 0].mean()
|
| 430 |
+
else:
|
| 431 |
+
hard_edge_density = torch.tensor(0.0, device=self.device)
|
| 432 |
+
|
| 433 |
+
loss = (loss_sparsity * self.lambda_s
|
| 434 |
+
+ loss_data
|
| 435 |
+
+ self.lambda_d * loss_dupont
|
| 436 |
+
+ 1e-3 * loss_l2_gt) # small L2 keeps logits from drifting to ±∞
|
| 437 |
+
loss.backward()
|
| 438 |
+
self.graph_optimizer.step()
|
| 439 |
+
|
| 440 |
+
return loss, loss_sparsity, loss_data, loss_dupont, hard_edge_density
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def train(self, data, observ_mask, original_data, true_cm=None):
|
| 445 |
+
|
| 446 |
+
original_data = torch.from_numpy(original_data).float().to(self.device)
|
| 447 |
+
observ_mask = torch.from_numpy(observ_mask).float().to(self.device)
|
| 448 |
+
data = torch.from_numpy(data).float().to(self.device)
|
| 449 |
+
|
| 450 |
+
if self.args.supervision_policy == "masked":
|
| 451 |
+
print("Using masked supervision for data prediction...")
|
| 452 |
+
elif self.args.supervision_policy == "full":
|
| 453 |
+
print("Using full supervision for data prediction......")
|
| 454 |
+
observ_mask = torch.ones_like(observ_mask)
|
| 455 |
+
elif "masked_before" in self.args.supervision_policy:
|
| 456 |
+
print(f"Using masked supervision for data prediction ({self.args.supervision_policy:s})......")
|
| 457 |
+
|
| 458 |
+
price_pred_step = 0
|
| 459 |
+
graph_discov_step = 0
|
| 460 |
+
pbar = tqdm.tqdm(total=self.args.total_epoch)
|
| 461 |
+
data_interp = deepcopy(data)
|
| 462 |
+
original_mask = deepcopy(observ_mask)
|
| 463 |
+
auc = 0
|
| 464 |
+
_edges_frozen = False # track whether freeze_accounting_edges() has run
|
| 465 |
+
for epoch_i in range(self.args.total_epoch):
|
| 466 |
+
self.current_epoch = epoch_i
|
| 467 |
+
# Phase 5 §5.3: freeze accounting edge logits after epoch 10
|
| 468 |
+
if epoch_i == 10 and not _edges_frozen:
|
| 469 |
+
self.freeze_accounting_edges()
|
| 470 |
+
_edges_frozen = True
|
| 471 |
+
if self._has_fixed_grouping():
|
| 472 |
+
if epoch_i == 0:
|
| 473 |
+
self.G = torch.from_numpy(self.fixed_group_spec.matrix).float().to(self.device)
|
| 474 |
+
self.GT = nn.Parameter(self._build_prior_seed_logits(self.fixed_group_spec.n_groups).to(self.device))
|
| 475 |
+
self.set_graph_optimizer(epoch_i)
|
| 476 |
+
elif self.args.group_policy is not None:
|
| 477 |
+
group_mul = int(self.args.group_policy.split("_")[1])
|
| 478 |
+
group_every = int(self.args.group_policy.split("_")[3])
|
| 479 |
+
if epoch_i % group_every == 0 and self.n_groups < self.args.n_nodes:
|
| 480 |
+
if epoch_i != 0:
|
| 481 |
+
self.n_groups *= group_mul
|
| 482 |
+
if self.n_groups > self.args.n_nodes:
|
| 483 |
+
self.n_groups = self.args.n_nodes
|
| 484 |
+
|
| 485 |
+
self.G = torch.zeros([self.args.n_nodes, self.n_groups]).to(self.device)
|
| 486 |
+
|
| 487 |
+
for i in range(0, self.n_groups):
|
| 488 |
+
for j in range(0, self.args.n_nodes // self.n_groups):
|
| 489 |
+
self.G[i*(self.args.n_nodes // self.n_groups) + j, i] = 1
|
| 490 |
+
for k in range(i*(self.args.n_nodes // self.n_groups) + j, self.args.n_nodes):
|
| 491 |
+
self.G[k, i] = 1
|
| 492 |
+
|
| 493 |
+
if hasattr(self, "GT"):
|
| 494 |
+
GT_init = torch.sigmoid(self.GT).detach().cpu().repeat_interleave(group_mul, 0)[:self.n_groups, :]
|
| 495 |
+
GT_init = 1 - (1 - GT_init)**(1 / group_mul)
|
| 496 |
+
else:
|
| 497 |
+
GT_init = self._init_random_gt(self.n_groups)
|
| 498 |
+
|
| 499 |
+
self.GT = nn.Parameter(GT_init.to(self.device))
|
| 500 |
+
|
| 501 |
+
self.set_graph_optimizer(epoch_i)
|
| 502 |
+
elif epoch_i == 0 and self.n_groups == self.args.n_nodes:
|
| 503 |
+
self.G = torch.eye(self.args.n_nodes).to(self.device)
|
| 504 |
+
# Add small noise to break symmetry — identical init → identical gradients
|
| 505 |
+
GT_init = torch.ones((self.n_groups, self.args.n_nodes))*0.5 + torch.randn(self.n_groups, self.args.n_nodes)*0.01
|
| 506 |
+
self.GT = nn.Parameter(GT_init.to(self.device))
|
| 507 |
+
self.set_graph_optimizer(epoch_i)
|
| 508 |
+
else:
|
| 509 |
+
if epoch_i == 0:
|
| 510 |
+
self.n_groups = self.args.n_nodes
|
| 511 |
+
self.G = torch.eye(self.args.n_nodes).to(self.device)
|
| 512 |
+
GT_init = self._build_prior_seed_logits(self.n_groups)
|
| 513 |
+
self.GT = nn.Parameter(GT_init.to(self.device))
|
| 514 |
+
self.set_graph_optimizer(epoch_i)
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
if "every" in self.args.fill_policy:
|
| 518 |
+
update_every = int(self.args.fill_policy.split("_")[-1])
|
| 519 |
+
if (epoch_i+1) % update_every == 0:
|
| 520 |
+
data = data_pred
|
| 521 |
+
print("Update data!")
|
| 522 |
+
# self.graph_optimizer.param_groups[0]['lr'] = self.args.graph_discov.lr_graph_start
|
| 523 |
+
self.data_pred_optimizer.param_groups[0]['lr'] = self.args.data_pred.lr_data_start
|
| 524 |
+
observ_mask = torch.ones_like(original_mask)
|
| 525 |
+
elif "rate" in self.args.fill_policy:
|
| 526 |
+
update_rate = float(self.args.fill_policy.split("_")[1])
|
| 527 |
+
update_after = int(self.args.fill_policy.split("_")[3])
|
| 528 |
+
if epoch_i+1 > update_after:
|
| 529 |
+
if epoch_i == update_after:
|
| 530 |
+
print("Data update started!")
|
| 531 |
+
data = data * (1 - update_rate) + data_pred * update_rate
|
| 532 |
+
else:
|
| 533 |
+
# no data update
|
| 534 |
+
pass
|
| 535 |
+
|
| 536 |
+
if "masked_before" in self.args.supervision_policy:
|
| 537 |
+
masked_before = int(self.args.supervision_policy.split("_")[2])
|
| 538 |
+
if epoch_i == masked_before:
|
| 539 |
+
print("Using full supervision for data prediction......")
|
| 540 |
+
observ_mask = torch.ones_like(original_mask)
|
| 541 |
+
self.gumbel_tau = self.start_tau
|
| 542 |
+
|
| 543 |
+
# Data Prediction
|
| 544 |
+
if hasattr(self.args, "data_pred"):
|
| 545 |
+
if hasattr(self.args, "block_size"):
|
| 546 |
+
block_size = self.args.block_size
|
| 547 |
+
else:
|
| 548 |
+
block_size = None
|
| 549 |
+
|
| 550 |
+
# Always use tech-only data for the batch generator.
|
| 551 |
+
# If a projector exists, text is projected fresh INSIDE each batch
|
| 552 |
+
# to avoid stale computation graphs after optimizer.step().
|
| 553 |
+
batch_gen = batch_generater(data, observ_mask,
|
| 554 |
+
bs=self.args.batch_size,
|
| 555 |
+
n_nodes=self.args.n_nodes,
|
| 556 |
+
input_step=self.args.input_step,
|
| 557 |
+
pred_step=self.args.data_pred.pred_step,
|
| 558 |
+
block_size=block_size)
|
| 559 |
+
batch_gen = list(batch_gen)
|
| 560 |
+
|
| 561 |
+
data_pred = data.clone().detach() # tech-only predictions
|
| 562 |
+
data_pred_all = data.clone().detach()
|
| 563 |
+
d_tech = data.shape[-1]
|
| 564 |
+
inp_step = self.args.input_step
|
| 565 |
+
pred_step = self.args.data_pred.pred_step
|
| 566 |
+
|
| 567 |
+
for x, y, t, mask_x, mask_y in batch_gen:
|
| 568 |
+
price_pred_step += self.args.batch_size
|
| 569 |
+
|
| 570 |
+
x, y, mask_x, mask_y = self._append_context(x, y, mask_x, mask_y, t, inp_step, pred_step)
|
| 571 |
+
|
| 572 |
+
y_pred, loss = self.ticker_price_pred(x, y, mask_x, mask_y)
|
| 573 |
+
# Map back only the tech portion
|
| 574 |
+
data_pred[t] = (y_pred*(1-mask_y) + y*mask_y).clone().detach()[:,:,0,:d_tech]
|
| 575 |
+
data_pred_all[t] = y_pred.clone().detach()[:,:,0,:d_tech]
|
| 576 |
+
self.log.log_metrics({"ticker_price_pred/pred_loss": loss.item()}, price_pred_step)
|
| 577 |
+
pbar.set_postfix_str(f"S1 loss={loss.item():.2f}, spr=IDLE, auc={auc:.4f}")
|
| 578 |
+
|
| 579 |
+
current_data_pred_lr = self.data_pred_optimizer.param_groups[0]['lr']
|
| 580 |
+
self.log.log_metrics({"graph_discov/lr": current_data_pred_lr}, price_pred_step)
|
| 581 |
+
self.data_pred_scheduler.step()
|
| 582 |
+
mse_pred_to_original = self.data_pred_loss(original_data, data_pred)
|
| 583 |
+
mse_interp_to_original = self.data_pred_loss(original_data, data_interp)
|
| 584 |
+
|
| 585 |
+
self.log.log_metrics({"ticker_price_pred/mse_pred_to_original": mse_pred_to_original,
|
| 586 |
+
"ticker_price_pred/mse_interp_to_original": mse_interp_to_original}, price_pred_step)
|
| 587 |
+
|
| 588 |
+
# Graph Discovery
|
| 589 |
+
if hasattr(self.args, "graph_discov"):
|
| 590 |
+
for x, y, t, mask_x, mask_y in batch_gen:
|
| 591 |
+
graph_discov_step += self.args.batch_size
|
| 592 |
+
if hasattr(self.args, "disable_graph") and self.args.disable_graph:
|
| 593 |
+
pass
|
| 594 |
+
else:
|
| 595 |
+
x, y, mask_x, mask_y = self._append_context(x, y, mask_x, mask_y, t, inp_step, pred_step)
|
| 596 |
+
|
| 597 |
+
loss, loss_sparsity, loss_data, loss_dupont, hard_edge_density = self.graph_discov(x, y, mask_x, mask_y)
|
| 598 |
+
self.log.log_metrics({"graph_discov/sparsity_loss": loss_sparsity.item(),
|
| 599 |
+
"graph_discov/data_loss": loss_data.item(),
|
| 600 |
+
"graph_discov/prior_loss": loss_dupont.item(),
|
| 601 |
+
"graph_discov/hard_edge_density": hard_edge_density.item(),
|
| 602 |
+
"graph_discov/total_loss": loss.item()}, graph_discov_step)
|
| 603 |
+
pbar.set_postfix_str(f"S2 loss={loss_data.item():.2f}, spr={loss_sparsity.item():.2f}, auc={auc:.4f}")
|
| 604 |
+
|
| 605 |
+
self.graph_scheduler.step()
|
| 606 |
+
# self.group_scheduler.step()
|
| 607 |
+
current_graph_disconv_lr = self.graph_optimizer.param_groups[0]['lr']
|
| 608 |
+
self.log.log_metrics({"graph_discov/lr": current_graph_disconv_lr}, graph_discov_step)
|
| 609 |
+
self.log.log_metrics({"graph_discov/tau": self.gumbel_tau}, graph_discov_step)
|
| 610 |
+
self.gumbel_tau *= self.gumbel_tau_gamma
|
| 611 |
+
self.lambda_s *= self.lambda_gamma
|
| 612 |
+
|
| 613 |
+
pbar.update(1)
|
| 614 |
+
|
| 615 |
+
plot_roc = False
|
| 616 |
+
|
| 617 |
+
G_prob = self.G.detach().cpu().numpy()
|
| 618 |
+
GT_prob = self.GT.detach().cpu().numpy()
|
| 619 |
+
# Apply sigmoid to match training forward pass (ticker_price_pred/graph_discov use sigmoid)
|
| 620 |
+
GT_prob_sigmoid = 1 / (1 + np.exp(-GT_prob))
|
| 621 |
+
Graph = np.einsum("nm,ml->nl", G_prob, GT_prob_sigmoid)
|
| 622 |
+
if np.any(self.hard_edge_mask_graph.detach().cpu().numpy() > 0):
|
| 623 |
+
if self.hard_edge_trainable:
|
| 624 |
+
Graph = Graph * self.soft_edge_mask_graph.detach().cpu().numpy() + Graph * self.hard_edge_mask_graph.detach().cpu().numpy()
|
| 625 |
+
else:
|
| 626 |
+
Graph = Graph * self.soft_edge_mask_graph.detach().cpu().numpy() + self.hard_edge_mask_graph.detach().cpu().numpy()
|
| 627 |
+
Graph = np.clip(Graph, 0.0, 1.0)
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
if (epoch_i+1) % self.args.show_graph_every == 0:
|
| 631 |
+
avg_mask = np.mean(observ_mask.cpu().numpy(), axis=(0,2))
|
| 632 |
+
if np.min(avg_mask) < 1:
|
| 633 |
+
time_series_idx = int(np.argwhere(avg_mask < 1)[0, 0])
|
| 634 |
+
else:
|
| 635 |
+
time_series_idx = 0
|
| 636 |
+
d_tech = original_data.shape[-1]
|
| 637 |
+
log_time_series(
|
| 638 |
+
original_data.cpu()[-100:,time_series_idx],
|
| 639 |
+
data_interp.cpu()[-100:,time_series_idx],
|
| 640 |
+
data_pred_all.cpu()[-100:,time_series_idx, :d_tech],
|
| 641 |
+
log=self.log, log_step=price_pred_step
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
plot_matrix("G", G_prob, self.log, graph_discov_step, vmin=0, vmax=1)
|
| 645 |
+
plot_matrix("GT", GT_prob, self.log, graph_discov_step, vmin=0, vmax=1)
|
| 646 |
+
plot_matrix("Graph", Graph, self.log, graph_discov_step, vmin=0, vmax=1)
|
| 647 |
+
np.save(os.path.join(self.log.log_dir, 'Graph.npy'), Graph)
|
| 648 |
+
plot_roc = True
|
| 649 |
+
|
| 650 |
+
# Show TPR FPR AUC ROC
|
| 651 |
+
if true_cm is not None:
|
| 652 |
+
Graph = rearrange(Graph, "n m -> m n")
|
| 653 |
+
auc = calc_and_log_metrics(Graph, true_cm, self.log, graph_discov_step, plot_roc=plot_roc)
|
| 654 |
+
|
| 655 |
+
return Graph
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def prepross_data(data):
|
| 659 |
+
T, N, D = data.shape
|
| 660 |
+
new_data = np.zeros_like(data, dtype=float)
|
| 661 |
+
for i in range(N):
|
| 662 |
+
node = data[:,i,:]
|
| 663 |
+
std = np.std(node)
|
| 664 |
+
# Guard against zero-std (constant) columns to prevent NaN from 0/0
|
| 665 |
+
new_data[:,i,:] = (node - np.mean(node)) / (std + 1e-8)
|
| 666 |
+
# Replace any residual NaN/Inf (e.g. from upstream data issues) with 0
|
| 667 |
+
new_data = np.nan_to_num(new_data, nan=0.0, posinf=0.0, neginf=0.0)
|
| 668 |
+
return new_data
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def main(
|
| 672 |
+
data,
|
| 673 |
+
mask,
|
| 674 |
+
true_cm,
|
| 675 |
+
opt,
|
| 676 |
+
log,
|
| 677 |
+
device="cuda",
|
| 678 |
+
text_data=None,
|
| 679 |
+
text_mask=None,
|
| 680 |
+
G_prior=None,
|
| 681 |
+
denoised_news=None,
|
| 682 |
+
denoised_mask=None,
|
| 683 |
+
):
|
| 684 |
+
if opt.n_nodes == "auto":
|
| 685 |
+
opt.n_nodes = data.shape[1]
|
| 686 |
+
|
| 687 |
+
if len(data.shape) == 2:
|
| 688 |
+
data = data[:,:,None]
|
| 689 |
+
mask = mask[:,:,None]
|
| 690 |
+
data = prepross_data(data)
|
| 691 |
+
|
| 692 |
+
if text_data is not None:
|
| 693 |
+
text_data_torch = torch.from_numpy(text_data).float()
|
| 694 |
+
text_mask_torch = torch.from_numpy(text_mask).float()
|
| 695 |
+
else:
|
| 696 |
+
text_data_torch = None
|
| 697 |
+
text_mask_torch = None
|
| 698 |
+
if denoised_news is not None:
|
| 699 |
+
denoised_news_torch = torch.from_numpy(denoised_news).float()
|
| 700 |
+
denoised_mask_torch = torch.from_numpy(denoised_mask).float() if denoised_mask is not None else None
|
| 701 |
+
else:
|
| 702 |
+
denoised_news_torch = None
|
| 703 |
+
denoised_mask_torch = None
|
| 704 |
+
|
| 705 |
+
effective_dim = opt.data_dim
|
| 706 |
+
projector_output_dim = getattr(opt, 'projector_output_dim', 16)
|
| 707 |
+
if text_data is not None:
|
| 708 |
+
effective_dim += projector_output_dim
|
| 709 |
+
if denoised_news is not None:
|
| 710 |
+
effective_dim += projector_output_dim
|
| 711 |
+
opt.data_dim = effective_dim
|
| 712 |
+
|
| 713 |
+
multicad = MultiCAD(
|
| 714 |
+
opt,
|
| 715 |
+
log,
|
| 716 |
+
device=device,
|
| 717 |
+
text_data=text_data_torch,
|
| 718 |
+
text_mask=text_mask_torch,
|
| 719 |
+
G_prior=G_prior,
|
| 720 |
+
denoised_news=denoised_news_torch,
|
| 721 |
+
denoised_mask=denoised_mask_torch,
|
| 722 |
+
)
|
| 723 |
+
max_refuter_retries = 3
|
| 724 |
+
refuter_retries = 0
|
| 725 |
+
falsified = True
|
| 726 |
+
|
| 727 |
+
while falsified and refuter_retries <= max_refuter_retries:
|
| 728 |
+
Graph = multicad.train(data, mask, data, true_cm)
|
| 729 |
+
|
| 730 |
+
# Run Refuter Validation
|
| 731 |
+
try:
|
| 732 |
+
from singular_ticker_causal.causal_inference.causal_model import StructuralCausalModel
|
| 733 |
+
from singular_ticker_causal.causal_inference.query_engine import CausalQueryEngine
|
| 734 |
+
|
| 735 |
+
data_cpu = data.cpu().numpy() if isinstance(data, torch.Tensor) else data
|
| 736 |
+
n_nodes = Graph.shape[0]
|
| 737 |
+
nodes = [f"N_{i}" for i in range(n_nodes)]
|
| 738 |
+
adj_mask = G_prior if G_prior is not None else np.zeros_like(Graph)
|
| 739 |
+
|
| 740 |
+
scm = StructuralCausalModel(
|
| 741 |
+
nodes=nodes,
|
| 742 |
+
adj=Graph,
|
| 743 |
+
adjacency_mask=adj_mask,
|
| 744 |
+
data_tech=data_cpu,
|
| 745 |
+
lag=1
|
| 746 |
+
).fit()
|
| 747 |
+
|
| 748 |
+
pywhyllm_enabled = os.environ.get("PYWHYLLM_ENABLED", "").lower() in {"1", "true", "yes", "on"}
|
| 749 |
+
pywhyllm_max_edges = int(os.environ.get("PYWHYLLM_REFUTER_MAX_EDGES", "3"))
|
| 750 |
+
engine = CausalQueryEngine(
|
| 751 |
+
scm,
|
| 752 |
+
data_tech=data_cpu,
|
| 753 |
+
pywhyllm_enabled=pywhyllm_enabled,
|
| 754 |
+
)
|
| 755 |
+
|
| 756 |
+
falsified = False
|
| 757 |
+
failed_edges = []
|
| 758 |
+
|
| 759 |
+
edges_to_check = []
|
| 760 |
+
for i in range(n_nodes):
|
| 761 |
+
for j in range(n_nodes):
|
| 762 |
+
if scm.dag_adj[i, j] and (adj_mask[i, j] == 0):
|
| 763 |
+
edges_to_check.append((nodes[i], nodes[j], float(Graph[i, j])))
|
| 764 |
+
edges_to_check.sort(key=lambda edge: abs(edge[2]), reverse=True)
|
| 765 |
+
|
| 766 |
+
validation_reports = []
|
| 767 |
+
for treatment, outcome, _score in edges_to_check[:pywhyllm_max_edges]:
|
| 768 |
+
if pywhyllm_enabled:
|
| 769 |
+
res = engine.validate_with_pywhyllm_and_dowhy(
|
| 770 |
+
treatment,
|
| 771 |
+
outcome,
|
| 772 |
+
max_edges=pywhyllm_max_edges,
|
| 773 |
+
)
|
| 774 |
+
else:
|
| 775 |
+
res = engine.validate_with_dowhy(treatment, outcome)
|
| 776 |
+
validation_reports.append({"edge": [treatment, outcome], "validation": res})
|
| 777 |
+
if res.get("falsified"):
|
| 778 |
+
falsified = True
|
| 779 |
+
failed_edges.append((treatment, outcome, res))
|
| 780 |
+
break
|
| 781 |
+
|
| 782 |
+
if falsified:
|
| 783 |
+
print(f"[Refuter] Graph failed refutation on edges: {failed_edges}. Applying penalty and retrying...")
|
| 784 |
+
multicad.args.total_epoch = 100
|
| 785 |
+
multicad.lambda_s *= 1.5
|
| 786 |
+
|
| 787 |
+
if hasattr(multicad, "GT"):
|
| 788 |
+
multicad.GT.data = multicad._build_prior_seed_logits(multicad.n_groups).to(device)
|
| 789 |
+
refuter_retries += 1
|
| 790 |
+
|
| 791 |
+
import json
|
| 792 |
+
artifact = {
|
| 793 |
+
"failed_edges": [
|
| 794 |
+
{"treatment": edge[0], "outcome": edge[1], "validation": edge[2]}
|
| 795 |
+
for edge in failed_edges
|
| 796 |
+
],
|
| 797 |
+
"validation_reports": validation_reports,
|
| 798 |
+
"lambda_s_new": float(multicad.lambda_s),
|
| 799 |
+
"retry_epoch": 100,
|
| 800 |
+
"adj": Graph.tolist()
|
| 801 |
+
}
|
| 802 |
+
with open(os.path.join(log.log_dir, f"refuter_failed_artifact_retry_{refuter_retries}.json"), "w") as f:
|
| 803 |
+
json.dump(artifact, f)
|
| 804 |
+
else:
|
| 805 |
+
print("[Refuter] Graph passed refutation or no testable edges.")
|
| 806 |
+
|
| 807 |
+
except Exception as e:
|
| 808 |
+
print(f"[Refuter] Validation error: {e}. Bypassing refuter.")
|
| 809 |
+
falsified = False
|
| 810 |
+
|
| 811 |
+
return Graph
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
if __name__ == "__main__":
|
| 815 |
+
os.environ["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
|
| 816 |
+
|
| 817 |
+
parser = argparse.ArgumentParser(description='Batch Compress')
|
| 818 |
+
parser.add_argument('-opt', type=str, default=opj(opd(__file__),
|
| 819 |
+
'opt/multi_cad_lorenz.yaml'), help='yaml file path')
|
| 820 |
+
parser.add_argument('-g', help='availabel gpu list', default='2', type=str)
|
| 821 |
+
parser.add_argument('-debug', action='store_true')
|
| 822 |
+
parser.add_argument('-log', action='store_true')
|
| 823 |
+
args = parser.parse_args()
|
| 824 |
+
|
| 825 |
+
if args.g == "mps":
|
| 826 |
+
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
|
| 827 |
+
device = "mps"
|
| 828 |
+
elif args.g == "cpu":
|
| 829 |
+
device = "cpu"
|
| 830 |
+
else:
|
| 831 |
+
os.environ["CUDA_VISIBLE_DEVICES"] = args.g
|
| 832 |
+
device = "cuda"
|
| 833 |
+
|
| 834 |
+
main(OmegaConf.load(args.opt), device=device)
|
singular_ticker_causal/algorithms/CUTS_PLUS/data/__init__.py
ADDED
|
File without changes
|
singular_ticker_causal/algorithms/CUTS_PLUS/data/generate_data_mod.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from collections import defaultdict
|
| 3 |
+
|
| 4 |
+
def check_stationarity(links):
|
| 5 |
+
"""Returns stationarity according to a unit root test
|
| 6 |
+
|
| 7 |
+
Assuming a Gaussian Vector autoregressive process
|
| 8 |
+
|
| 9 |
+
Three conditions are necessary for stationarity of the VAR(p) model:
|
| 10 |
+
- Absence of mean shifts;
|
| 11 |
+
- The noise vectors are identically distributed;
|
| 12 |
+
- Stability condition on Phi(t-1) coupling matrix (stabmat) of VAR(1)-version of VAR(p).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
N = len(links)
|
| 17 |
+
# Check parameters
|
| 18 |
+
max_lag = 0
|
| 19 |
+
|
| 20 |
+
for j in range(N):
|
| 21 |
+
for link_props in links[j]:
|
| 22 |
+
var, lag = link_props[0]
|
| 23 |
+
# coeff = link_props[1]
|
| 24 |
+
# coupling = link_props[2]
|
| 25 |
+
|
| 26 |
+
max_lag = max(max_lag, abs(lag))
|
| 27 |
+
|
| 28 |
+
graph = np.zeros((N,N,max_lag))
|
| 29 |
+
couplings = []
|
| 30 |
+
|
| 31 |
+
for j in range(N):
|
| 32 |
+
for link_props in links[j]:
|
| 33 |
+
var, lag = link_props[0]
|
| 34 |
+
coeff = link_props[1]
|
| 35 |
+
coupling = link_props[2]
|
| 36 |
+
if abs(lag) > 0:
|
| 37 |
+
graph[j,var,abs(lag)-1] = coeff
|
| 38 |
+
couplings.append(coupling)
|
| 39 |
+
|
| 40 |
+
stabmat = np.zeros((N*max_lag,N*max_lag))
|
| 41 |
+
index = 0
|
| 42 |
+
|
| 43 |
+
for i in range(0,N*max_lag,N):
|
| 44 |
+
stabmat[:N,i:i+N] = graph[:,:,index]
|
| 45 |
+
if index < max_lag-1:
|
| 46 |
+
stabmat[i+N:i+2*N,i:i+N] = np.identity(N)
|
| 47 |
+
index += 1
|
| 48 |
+
|
| 49 |
+
eig = np.linalg.eig(stabmat)[0]
|
| 50 |
+
# print "----> maxeig = ", np.abs(eig).max()
|
| 51 |
+
if np.all(np.abs(eig) < 1.):
|
| 52 |
+
stationary = True
|
| 53 |
+
else:
|
| 54 |
+
stationary = False
|
| 55 |
+
|
| 56 |
+
if len(eig) == 0:
|
| 57 |
+
return stationary, 0.
|
| 58 |
+
else:
|
| 59 |
+
return stationary, np.abs(eig).max()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class Graph():
|
| 63 |
+
def __init__(self,vertices):
|
| 64 |
+
self.graph = defaultdict(list)
|
| 65 |
+
self.V = vertices
|
| 66 |
+
|
| 67 |
+
def addEdge(self,u,v):
|
| 68 |
+
self.graph[u].append(v)
|
| 69 |
+
|
| 70 |
+
def isCyclicUtil(self, v, visited, recStack):
|
| 71 |
+
|
| 72 |
+
# Mark current node as visited and
|
| 73 |
+
# adds to recursion stack
|
| 74 |
+
visited[v] = True
|
| 75 |
+
recStack[v] = True
|
| 76 |
+
|
| 77 |
+
# Recur for all neighbours
|
| 78 |
+
# if any neighbour is visited and in
|
| 79 |
+
# recStack then graph is cyclic
|
| 80 |
+
for neighbour in self.graph[v]:
|
| 81 |
+
if visited[neighbour] == False:
|
| 82 |
+
if self.isCyclicUtil(neighbour, visited, recStack) == True:
|
| 83 |
+
return True
|
| 84 |
+
elif recStack[neighbour] == True:
|
| 85 |
+
return True
|
| 86 |
+
|
| 87 |
+
# The node needs to be poped from
|
| 88 |
+
# recursion stack before function ends
|
| 89 |
+
recStack[v] = False
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
# Returns true if graph is cyclic else false
|
| 93 |
+
def isCyclic(self):
|
| 94 |
+
visited = [False] * self.V
|
| 95 |
+
recStack = [False] * self.V
|
| 96 |
+
for node in range(self.V):
|
| 97 |
+
if visited[node] == False:
|
| 98 |
+
if self.isCyclicUtil(node,visited,recStack) == True:
|
| 99 |
+
return True
|
| 100 |
+
return False
|
| 101 |
+
|
| 102 |
+
# A recursive function used by topologicalSort
|
| 103 |
+
def topologicalSortUtil(self,v,visited,stack):
|
| 104 |
+
|
| 105 |
+
# Mark the current node as visited.
|
| 106 |
+
visited[v] = True
|
| 107 |
+
|
| 108 |
+
# Recur for all the vertices adjacent to this vertex
|
| 109 |
+
for i in self.graph[v]:
|
| 110 |
+
if visited[i] == False:
|
| 111 |
+
self.topologicalSortUtil(i,visited,stack)
|
| 112 |
+
|
| 113 |
+
# Push current vertex to stack which stores result
|
| 114 |
+
stack.insert(0,v)
|
| 115 |
+
|
| 116 |
+
# The function to do Topological Sort. It uses recursive
|
| 117 |
+
# topologicalSortUtil()
|
| 118 |
+
def topologicalSort(self):
|
| 119 |
+
# Mark all the vertices as not visited
|
| 120 |
+
visited = [False]*self.V
|
| 121 |
+
stack =[]
|
| 122 |
+
|
| 123 |
+
# Call the recursive helper function to store Topological
|
| 124 |
+
# Sort starting from all vertices one by one
|
| 125 |
+
for i in range(self.V):
|
| 126 |
+
if visited[i] == False:
|
| 127 |
+
self.topologicalSortUtil(i,visited,stack)
|
| 128 |
+
|
| 129 |
+
return stack
|
| 130 |
+
|
| 131 |
+
def generate_nonlinear_contemp_timeseries(links, T, noises=None, random_state=None):
|
| 132 |
+
|
| 133 |
+
if random_state is None:
|
| 134 |
+
random_state = np.random
|
| 135 |
+
|
| 136 |
+
# links must be {j:[((i, -tau), func), ...], ...}
|
| 137 |
+
# coeff is coefficient
|
| 138 |
+
# func is a function f(x) that becomes linear ~x in limit
|
| 139 |
+
# noises is a random_state.___ function
|
| 140 |
+
N = len(links.keys())
|
| 141 |
+
if noises is None:
|
| 142 |
+
noises = [random_state.randn for j in range(N)]
|
| 143 |
+
|
| 144 |
+
if N != max(links.keys())+1 or N != len(noises):
|
| 145 |
+
raise ValueError("links and noises keys must match N.")
|
| 146 |
+
|
| 147 |
+
# Check parameters
|
| 148 |
+
max_lag = 0
|
| 149 |
+
contemp = False
|
| 150 |
+
contemp_dag = Graph(N)
|
| 151 |
+
causal_order = list(range(N))
|
| 152 |
+
for j in range(N):
|
| 153 |
+
for link_props in links[j]:
|
| 154 |
+
var, lag = link_props[0]
|
| 155 |
+
coeff = link_props[1]
|
| 156 |
+
func = link_props[2]
|
| 157 |
+
if lag == 0: contemp = True
|
| 158 |
+
if var not in range(N):
|
| 159 |
+
raise ValueError("var must be in 0..{}.".format(N-1))
|
| 160 |
+
if 'float' not in str(type(coeff)):
|
| 161 |
+
raise ValueError("coeff must be float.")
|
| 162 |
+
if lag > 0 or type(lag) != int:
|
| 163 |
+
raise ValueError("lag must be non-positive int.")
|
| 164 |
+
max_lag = max(max_lag, abs(lag))
|
| 165 |
+
|
| 166 |
+
# Create contemp DAG
|
| 167 |
+
if var != j and lag == 0:
|
| 168 |
+
contemp_dag.addEdge(var, j)
|
| 169 |
+
# a, b = causal_order.index(var), causal_order.index(j)
|
| 170 |
+
# causal_order[b], causal_order[a] = causal_order[a], causal_order[b]
|
| 171 |
+
|
| 172 |
+
if contemp_dag.isCyclic() == 1:
|
| 173 |
+
raise ValueError("Contemporaneous links must not contain cycle.")
|
| 174 |
+
|
| 175 |
+
causal_order = contemp_dag.topologicalSort()
|
| 176 |
+
|
| 177 |
+
transient = int(.2*T)
|
| 178 |
+
|
| 179 |
+
X = np.zeros((T+transient, N), dtype='float32')
|
| 180 |
+
for j in range(N):
|
| 181 |
+
X[:, j] = noises[j](T+transient)
|
| 182 |
+
|
| 183 |
+
for t in range(max_lag, T+transient):
|
| 184 |
+
for j in causal_order:
|
| 185 |
+
for link_props in links[j]:
|
| 186 |
+
var, lag = link_props[0]
|
| 187 |
+
# if abs(lag) > 0:
|
| 188 |
+
coeff = link_props[1]
|
| 189 |
+
func = link_props[2]
|
| 190 |
+
|
| 191 |
+
X[t, j] += coeff * func(X[t + lag, var])
|
| 192 |
+
|
| 193 |
+
X = X[transient:]
|
| 194 |
+
|
| 195 |
+
if (check_stationarity(links)[0] == False or
|
| 196 |
+
np.any(np.isnan(X)) or
|
| 197 |
+
np.any(np.isinf(X)) or
|
| 198 |
+
# np.max(np.abs(X)) > 1.e4 or
|
| 199 |
+
np.any(np.abs(np.triu(np.corrcoef(X, rowvar=0), 1)) > 0.999)):
|
| 200 |
+
nonstationary = True
|
| 201 |
+
else:
|
| 202 |
+
nonstationary = False
|
| 203 |
+
|
| 204 |
+
return X, nonstationary
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def generate_random_contemp_model(N, L,
|
| 208 |
+
coupling_coeffs,
|
| 209 |
+
coupling_funcs,
|
| 210 |
+
auto_coeffs,
|
| 211 |
+
tau_max,
|
| 212 |
+
contemp_fraction=0.,
|
| 213 |
+
# num_trials=1000,
|
| 214 |
+
random_state=None):
|
| 215 |
+
|
| 216 |
+
def lin(x): return x
|
| 217 |
+
|
| 218 |
+
if random_state is None:
|
| 219 |
+
random_state = np.random
|
| 220 |
+
|
| 221 |
+
# print links
|
| 222 |
+
a_len = len(auto_coeffs)
|
| 223 |
+
if type(coupling_coeffs) == float:
|
| 224 |
+
coupling_coeffs = [coupling_coeffs]
|
| 225 |
+
c_len = len(coupling_coeffs)
|
| 226 |
+
func_len = len(coupling_funcs)
|
| 227 |
+
|
| 228 |
+
if tau_max == 0:
|
| 229 |
+
contemp_fraction = 1.
|
| 230 |
+
|
| 231 |
+
if contemp_fraction > 0.:
|
| 232 |
+
contemp = True
|
| 233 |
+
L_lagged = int((1.-contemp_fraction)*L)
|
| 234 |
+
L_contemp = L - L_lagged
|
| 235 |
+
if L==1:
|
| 236 |
+
# Randomly assign a lagged or contemp link
|
| 237 |
+
L_lagged = random_state.randint(0,2)
|
| 238 |
+
L_contemp = int(L_lagged == False)
|
| 239 |
+
|
| 240 |
+
else:
|
| 241 |
+
contemp = False
|
| 242 |
+
L_lagged = L
|
| 243 |
+
L_contemp = 0
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# for ir in range(num_trials):
|
| 247 |
+
|
| 248 |
+
# Random order
|
| 249 |
+
causal_order = list(random_state.permutation(N))
|
| 250 |
+
|
| 251 |
+
links = dict([(i, []) for i in range(N)])
|
| 252 |
+
|
| 253 |
+
# Generate auto-dependencies at lag 1
|
| 254 |
+
if tau_max > 0:
|
| 255 |
+
for i in causal_order:
|
| 256 |
+
a = auto_coeffs[random_state.randint(0, a_len)]
|
| 257 |
+
|
| 258 |
+
if a != 0.:
|
| 259 |
+
links[i].append(((int(i), -1), float(a), lin))
|
| 260 |
+
|
| 261 |
+
chosen_links = []
|
| 262 |
+
# Create contemporaneous DAG
|
| 263 |
+
contemp_links = []
|
| 264 |
+
for l in range(L_contemp):
|
| 265 |
+
|
| 266 |
+
cause = random_state.choice(causal_order[:-1])
|
| 267 |
+
effect = random_state.choice(causal_order)
|
| 268 |
+
while (causal_order.index(cause) >= causal_order.index(effect)
|
| 269 |
+
or (cause, effect) in chosen_links):
|
| 270 |
+
cause = random_state.choice(causal_order[:-1])
|
| 271 |
+
effect = random_state.choice(causal_order)
|
| 272 |
+
|
| 273 |
+
contemp_links.append((cause, effect))
|
| 274 |
+
chosen_links.append((cause, effect))
|
| 275 |
+
|
| 276 |
+
# Create lagged links (can be cyclic)
|
| 277 |
+
lagged_links = []
|
| 278 |
+
for l in range(L_lagged):
|
| 279 |
+
|
| 280 |
+
cause = random_state.choice(causal_order)
|
| 281 |
+
effect = random_state.choice(causal_order)
|
| 282 |
+
while (cause, effect) in chosen_links or cause == effect:
|
| 283 |
+
cause = random_state.choice(causal_order)
|
| 284 |
+
effect = random_state.choice(causal_order)
|
| 285 |
+
|
| 286 |
+
lagged_links.append((cause, effect))
|
| 287 |
+
chosen_links.append((cause, effect))
|
| 288 |
+
|
| 289 |
+
# print(chosen_links)
|
| 290 |
+
# print(contemp_links)
|
| 291 |
+
for (i, j) in chosen_links:
|
| 292 |
+
|
| 293 |
+
# Choose lag
|
| 294 |
+
if (i, j) in contemp_links:
|
| 295 |
+
tau = 0
|
| 296 |
+
else:
|
| 297 |
+
tau = int(random_state.randint(1, tau_max+1))
|
| 298 |
+
# print tau
|
| 299 |
+
# CHoose coupling
|
| 300 |
+
c = float(coupling_coeffs[random_state.randint(0, c_len)])
|
| 301 |
+
if c != 0:
|
| 302 |
+
func = coupling_funcs[random_state.randint(0, func_len)]
|
| 303 |
+
|
| 304 |
+
links[j].append(((int(i), -tau), c, func))
|
| 305 |
+
|
| 306 |
+
# # Stationarity check assuming model with linear dependencies at least for large x
|
| 307 |
+
# # if check_stationarity(links)[0]:
|
| 308 |
+
# # return links
|
| 309 |
+
# X, nonstat = generate_nonlinear_contemp_timeseries(links,
|
| 310 |
+
# T=10000, noises=None, random_state=None)
|
| 311 |
+
# if nonstat == False:
|
| 312 |
+
# return links
|
| 313 |
+
# else:
|
| 314 |
+
# print("Trial %d: Not a stationary model" % ir)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
# print("No stationary models found in {} trials".format(num_trials))
|
| 318 |
+
return links
|
| 319 |
+
|
| 320 |
+
def generate_logistic_maps(N, T, links, noise_lev):
|
| 321 |
+
|
| 322 |
+
# Check parameters
|
| 323 |
+
# contemp = False
|
| 324 |
+
max_lag = 0
|
| 325 |
+
for j in range(N):
|
| 326 |
+
for link_props in links[j]:
|
| 327 |
+
var, lag = link_props[0]
|
| 328 |
+
max_lag = max(max_lag, abs(lag))
|
| 329 |
+
|
| 330 |
+
transient = int(.2*T)
|
| 331 |
+
|
| 332 |
+
# Chaotic logistic map parameter
|
| 333 |
+
r = 4.
|
| 334 |
+
|
| 335 |
+
X = np.random.rand(T+transient, N)
|
| 336 |
+
|
| 337 |
+
for t in range(max_lag, T+transient):
|
| 338 |
+
for j in range(N):
|
| 339 |
+
added_input = 0.
|
| 340 |
+
for link_props in links[j]:
|
| 341 |
+
var, lag = link_props[0]
|
| 342 |
+
if var != j and abs(lag) > 0:
|
| 343 |
+
coeff = link_props[1]
|
| 344 |
+
coupling = link_props[2]
|
| 345 |
+
added_input += coeff*X[t - abs(lag), var]
|
| 346 |
+
|
| 347 |
+
X[t, j] = (X[t-1, j] * (r - r*X[t-1, j] - added_input + noise_lev*np.random.rand())) % 1
|
| 348 |
+
#func(coeff, X[t+lag, var], coupling)
|
| 349 |
+
|
| 350 |
+
X = X[transient:]
|
| 351 |
+
|
| 352 |
+
if np.any(np.abs(X) == np.inf) or np.any(X == np.nan):
|
| 353 |
+
raise ValueError("Data divergent")
|
| 354 |
+
return X
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def weighted_avg_and_std(values, axis, weights):
|
| 359 |
+
"""Returns the weighted average and standard deviation.
|
| 360 |
+
|
| 361 |
+
Parameters
|
| 362 |
+
---------
|
| 363 |
+
values : array
|
| 364 |
+
Data array of shape (time, variables).
|
| 365 |
+
|
| 366 |
+
axis : int
|
| 367 |
+
Axis to average/std about
|
| 368 |
+
|
| 369 |
+
weights : array
|
| 370 |
+
Weight array of shape (time, variables).
|
| 371 |
+
|
| 372 |
+
Returns
|
| 373 |
+
-------
|
| 374 |
+
(average, std) : tuple of arrays
|
| 375 |
+
Tuple of weighted average and standard deviation along axis.
|
| 376 |
+
"""
|
| 377 |
+
|
| 378 |
+
values[np.isnan(values)] = 0.
|
| 379 |
+
average = np.ma.average(values, axis=axis, weights=weights)
|
| 380 |
+
variance = np.sum(weights * (values - np.expand_dims(average, axis)
|
| 381 |
+
) ** 2, axis=axis) / weights.sum(axis=axis)
|
| 382 |
+
|
| 383 |
+
return (average, np.sqrt(variance))
|
| 384 |
+
|
| 385 |
+
def time_bin_with_mask(data, time_bin_length, sample_selector=None):
|
| 386 |
+
"""Returns time binned data where only about non-masked values is averaged.
|
| 387 |
+
|
| 388 |
+
Parameters
|
| 389 |
+
----------
|
| 390 |
+
data : array
|
| 391 |
+
Data array of shape (time, variables).
|
| 392 |
+
|
| 393 |
+
time_bin_length : int
|
| 394 |
+
Length of time bin.
|
| 395 |
+
|
| 396 |
+
mask : bool array, optional (default: None)
|
| 397 |
+
Data mask where True labels masked samples.
|
| 398 |
+
|
| 399 |
+
Returns
|
| 400 |
+
-------
|
| 401 |
+
(bindata, T) : tuple of array and int
|
| 402 |
+
Tuple of time-binned data array and new length of array.
|
| 403 |
+
"""
|
| 404 |
+
|
| 405 |
+
T = len(data)
|
| 406 |
+
|
| 407 |
+
time_bin_length = int(time_bin_length)
|
| 408 |
+
|
| 409 |
+
if sample_selector is None:
|
| 410 |
+
sample_selector = np.ones(data.shape)
|
| 411 |
+
|
| 412 |
+
if np.ndim(data) == 1.:
|
| 413 |
+
data.shape = (T, 1)
|
| 414 |
+
sample_selector.shape = (T, 1)
|
| 415 |
+
|
| 416 |
+
bindata = np.zeros(
|
| 417 |
+
(T // time_bin_length,) + data.shape[1:], dtype="float32")
|
| 418 |
+
for index, i in enumerate(range(0, T - time_bin_length + 1,
|
| 419 |
+
time_bin_length)):
|
| 420 |
+
# print weighted_avg_and_std(fulldata[i:i+time_bin_length], axis=0,
|
| 421 |
+
# weights=sample_selector[i:i+time_bin_length])[0]
|
| 422 |
+
bindata[index] = weighted_avg_and_std(data[i:i + time_bin_length],
|
| 423 |
+
axis=0,
|
| 424 |
+
weights=sample_selector[i:i +
|
| 425 |
+
time_bin_length])[0]
|
| 426 |
+
|
| 427 |
+
T, grid_size = bindata.shape
|
| 428 |
+
|
| 429 |
+
return (bindata.squeeze(), T)
|
| 430 |
+
|
singular_ticker_causal/algorithms/CUTS_PLUS/data/simu_data.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from os.path import join as opj
|
| 4 |
+
sys.path.append(opj(os.getcwd(), "../"))
|
| 5 |
+
sys.path.append(os.getcwd())
|
| 6 |
+
|
| 7 |
+
import csv
|
| 8 |
+
import torch
|
| 9 |
+
import scipy
|
| 10 |
+
from .generate_data_mod import generate_random_contemp_model, generate_nonlinear_contemp_timeseries
|
| 11 |
+
import numpy as np
|
| 12 |
+
from scipy.integrate import odeint
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
######################################
|
| 16 |
+
# Function for loading input data
|
| 17 |
+
######################################
|
| 18 |
+
def loadTrainingData(inputDataFilePath, device):
|
| 19 |
+
|
| 20 |
+
# Load and parse input data (create batch data)
|
| 21 |
+
inpData = torch.load(inputDataFilePath)
|
| 22 |
+
Xtrain = torch.zeros(inpData['TsData'].shape[1], inpData['TsData'].shape[0], requires_grad = False, device=device)
|
| 23 |
+
Xtrain1 = inpData['TsData'].t()
|
| 24 |
+
Xtrain.data[:,:] = Xtrain1.data[:,:]
|
| 25 |
+
|
| 26 |
+
return Xtrain
|
| 27 |
+
|
| 28 |
+
#######################################################
|
| 29 |
+
# Function for reading ground truth network from file
|
| 30 |
+
#######################################################
|
| 31 |
+
def loadTrueNetwork(inputFilePath, networkSize):
|
| 32 |
+
|
| 33 |
+
with open(inputFilePath) as tsvin:
|
| 34 |
+
reader = csv.reader(tsvin, delimiter='\t')
|
| 35 |
+
numrows = 0
|
| 36 |
+
for row in reader:
|
| 37 |
+
numrows = numrows + 1
|
| 38 |
+
|
| 39 |
+
network = np.zeros((numrows,2),dtype=np.int16)
|
| 40 |
+
with open(inputFilePath) as tsvin:
|
| 41 |
+
reader = csv.reader(tsvin, delimiter='\t')
|
| 42 |
+
rowcounter = 0
|
| 43 |
+
for row in reader:
|
| 44 |
+
network[rowcounter][0] = int(row[0][1:])
|
| 45 |
+
network[rowcounter][1] = int(row[1][1:])
|
| 46 |
+
rowcounter = rowcounter + 1
|
| 47 |
+
|
| 48 |
+
Gtrue = np.zeros((networkSize,networkSize), dtype=np.int16)
|
| 49 |
+
for row in range(0,len(network),1):
|
| 50 |
+
Gtrue[network[row][1]-1][network[row][0]-1] = 1
|
| 51 |
+
|
| 52 |
+
return Gtrue
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_dream_data(dataset_id):
|
| 56 |
+
device = "cpu"
|
| 57 |
+
|
| 58 |
+
if(dataset_id == 0):
|
| 59 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Ecoli1.pt"
|
| 60 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Ecoli1.tsv"
|
| 61 |
+
elif(dataset_id == 1):
|
| 62 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Ecoli2.pt"
|
| 63 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Ecoli2.tsv"
|
| 64 |
+
elif(dataset_id == 2):
|
| 65 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Yeast1.pt"
|
| 66 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Yeast1.tsv"
|
| 67 |
+
elif(dataset_id == 3):
|
| 68 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Yeast2.pt"
|
| 69 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Yeast2.tsv"
|
| 70 |
+
elif(dataset_id == 4):
|
| 71 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Yeast3.pt"
|
| 72 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Yeast3.tsv"
|
| 73 |
+
elif(dataset_id == 5):
|
| 74 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Ecoli1.pt"
|
| 75 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Ecoli1.tsv"
|
| 76 |
+
elif(dataset_id == 6):
|
| 77 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Ecoli2.pt"
|
| 78 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Ecoli2.tsv"
|
| 79 |
+
elif(dataset_id == 7):
|
| 80 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Yeast1.pt"
|
| 81 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Yeast1.tsv"
|
| 82 |
+
elif(dataset_id == 8):
|
| 83 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Yeast2.pt"
|
| 84 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Yeast2.tsv"
|
| 85 |
+
elif(dataset_id == 9):
|
| 86 |
+
InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Yeast3.pt"
|
| 87 |
+
RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Yeast3.tsv"
|
| 88 |
+
else:
|
| 89 |
+
print("Error while loading gene training data")
|
| 90 |
+
|
| 91 |
+
Xtrain = loadTrainingData(InputDataFilePath, device)
|
| 92 |
+
n = Xtrain.shape[0]
|
| 93 |
+
Gref = loadTrueNetwork(RefNetworkFilePath, n)
|
| 94 |
+
|
| 95 |
+
Xtrain = Xtrain.numpy().T
|
| 96 |
+
# Gref = Gref.T
|
| 97 |
+
|
| 98 |
+
return Xtrain, Gref
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def links_to_matrix(links):
|
| 103 |
+
N = len(links)
|
| 104 |
+
cm = np.zeros([N, N])
|
| 105 |
+
for i, effect_node in links.items():
|
| 106 |
+
for (j, _), _, _ in effect_node:
|
| 107 |
+
cm[i, j] += 1
|
| 108 |
+
return cm
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class noise_model:
|
| 112 |
+
def __init__(self, sigma=1, seed=0):
|
| 113 |
+
self.random_state = np.random.RandomState(seed)
|
| 114 |
+
self.sigma = sigma
|
| 115 |
+
|
| 116 |
+
def gaussian(self, T):
|
| 117 |
+
# Get zero-mean unit variance gaussian distribution
|
| 118 |
+
return self.sigma*self.random_state.randn(T)
|
| 119 |
+
|
| 120 |
+
def weibull(self, T):
|
| 121 |
+
# Get zero-mean sigma variance weibull distribution
|
| 122 |
+
a = 2
|
| 123 |
+
mean = scipy.special.gamma(1./a + 1)
|
| 124 |
+
variance = scipy.special.gamma(
|
| 125 |
+
2./a + 1) - scipy.special.gamma(1./a + 1)**2
|
| 126 |
+
return self.sigma*(self.random_state.weibull(a=a, size=T) - mean)/np.sqrt(variance)
|
| 127 |
+
|
| 128 |
+
def uniform(self, T):
|
| 129 |
+
# Get zero-mean sigma variance uniform distribution
|
| 130 |
+
mean = 0.5
|
| 131 |
+
variance = 1./12.
|
| 132 |
+
return self.sigma*(self.random_state.uniform(size=T) - mean)/np.sqrt(variance)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def lin_f(x): return x
|
| 136 |
+
def f2(x): return (x + 5. * x**2 * np.exp(-x**2 / 20.))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def simulate_random_var(seed, T, N, L, coef=[0.2, 0.8], auto_corr=[0.4,0.9], tau_max=5, noise_sigma=[0.01, 0.01]):
|
| 140 |
+
|
| 141 |
+
if True:
|
| 142 |
+
coupling_funcs = [lin_f]
|
| 143 |
+
noise_types = ['gaussian'] # , 'weibull', 'uniform']
|
| 144 |
+
# noise_sigma = (0.1, 0.3)
|
| 145 |
+
|
| 146 |
+
couplings = list(np.arange(coef[0], coef[1]+1e-5, coef[2]))
|
| 147 |
+
couplings += [-c for c in couplings]
|
| 148 |
+
|
| 149 |
+
# auto_deps = list(np.arange(max(0., auto_corr-0.6), auto_corr+0.01, 0.05))
|
| 150 |
+
auto_deps = list(np.arange(auto_corr[0], auto_corr[1]+1e-5, auto_corr[2]))
|
| 151 |
+
|
| 152 |
+
# Models may be non-stationary. Hence, we iterate over a number of seeds
|
| 153 |
+
# to find a stationary one regarding network topology, noises, etc
|
| 154 |
+
|
| 155 |
+
ir = 0
|
| 156 |
+
model_seed = seed
|
| 157 |
+
while True:
|
| 158 |
+
ir += 1
|
| 159 |
+
# np.random.seed(model_seed)
|
| 160 |
+
random_state = np.random.RandomState(model_seed)
|
| 161 |
+
|
| 162 |
+
links = generate_random_contemp_model(
|
| 163 |
+
N=N, L=L,
|
| 164 |
+
coupling_coeffs=couplings,
|
| 165 |
+
coupling_funcs=coupling_funcs,
|
| 166 |
+
auto_coeffs=auto_deps,
|
| 167 |
+
tau_max=tau_max,
|
| 168 |
+
contemp_fraction=0.,
|
| 169 |
+
# num_trials=1000,
|
| 170 |
+
random_state=random_state)
|
| 171 |
+
|
| 172 |
+
noises = []
|
| 173 |
+
for j in links:
|
| 174 |
+
noise_type = random_state.choice(noise_types)
|
| 175 |
+
sigmas = list(np.arange(noise_sigma[0], noise_sigma[1]+1e-5, noise_sigma[2]))
|
| 176 |
+
sigma = random_state.choice(sigmas)
|
| 177 |
+
# sigma = noise_sigma[0] + (noise_sigma[1]-noise_sigma[0])*random_state.rand()
|
| 178 |
+
noises.append(getattr(noise_model(sigma=sigma, seed=seed), noise_type))
|
| 179 |
+
|
| 180 |
+
data_all_check, nonstationary = generate_nonlinear_contemp_timeseries(
|
| 181 |
+
links=links, T=100, noises=noises, random_state=random_state)
|
| 182 |
+
|
| 183 |
+
# If the model is stationary, break the loop
|
| 184 |
+
if not nonstationary:
|
| 185 |
+
data, nonstationary_full = generate_nonlinear_contemp_timeseries(
|
| 186 |
+
links=links, T=T, noises=noises, random_state=random_state)
|
| 187 |
+
if not nonstationary_full:
|
| 188 |
+
break
|
| 189 |
+
else:
|
| 190 |
+
print("Trial %d: Not a stationary model" % ir)
|
| 191 |
+
model_seed += 10000
|
| 192 |
+
|
| 193 |
+
cm = links_to_matrix(links)
|
| 194 |
+
return data, cm
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def simulate_var_from_links(links, T, seed=0, noise_sigma=[0.1, 0.2], noise_type="gaussian", func_name="lin_f"):
|
| 198 |
+
"""
|
| 199 |
+
links_coeffs = {0: [((0, -1), 0.7), ((1, -1), -0.8)],
|
| 200 |
+
1: [((1, -1), 0.8), ((3, -1), 0.8)],
|
| 201 |
+
2: [((2, -1), 0.5), ((1, -2), 0.5), ((3, -3), 0.6)],
|
| 202 |
+
3: [((3, -1), 0.4)],
|
| 203 |
+
}
|
| 204 |
+
"""
|
| 205 |
+
def get_func(func_name):
|
| 206 |
+
if func_name == "lin_f":
|
| 207 |
+
return lin_f
|
| 208 |
+
else:
|
| 209 |
+
raise NotImplementedError
|
| 210 |
+
|
| 211 |
+
random_state = np.random.RandomState(seed)
|
| 212 |
+
noises = []
|
| 213 |
+
|
| 214 |
+
new_links = {}
|
| 215 |
+
for j in range(len(links)):
|
| 216 |
+
sigma = noise_sigma[0] + \
|
| 217 |
+
(noise_sigma[1]-noise_sigma[0])*random_state.rand()
|
| 218 |
+
noises.append(getattr(noise_model(sigma=sigma, seed=seed), noise_type))
|
| 219 |
+
new_links[j] = []
|
| 220 |
+
for props in links[j]:
|
| 221 |
+
new_links[j].append(
|
| 222 |
+
(tuple(props[0:2]), props[2], get_func(props[3]),))
|
| 223 |
+
data, nonstationary = generate_nonlinear_contemp_timeseries(
|
| 224 |
+
links=new_links, T=T, noises=noises, random_state=random_state)
|
| 225 |
+
if nonstationary:
|
| 226 |
+
print("Model nonstationay!")
|
| 227 |
+
|
| 228 |
+
cm = links_to_matrix(new_links)
|
| 229 |
+
return data, cm
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def make_var_stationary(beta, radius=0.97):
|
| 233 |
+
'''Rescale coefficients of VAR model to make stable.'''
|
| 234 |
+
p = beta.shape[0]
|
| 235 |
+
lag = beta.shape[1] // p
|
| 236 |
+
bottom = np.hstack((np.eye(p * (lag - 1)), np.zeros((p * (lag - 1), p))))
|
| 237 |
+
beta_tilde = np.vstack((beta, bottom))
|
| 238 |
+
eigvals = np.linalg.eigvals(beta_tilde)
|
| 239 |
+
max_eig = max(np.abs(eigvals))
|
| 240 |
+
nonstationary = max_eig > radius
|
| 241 |
+
if nonstationary:
|
| 242 |
+
# print(f"Nonstationary, beta={str(beta):s}, max_eig={max_eig:.4f}")
|
| 243 |
+
return make_var_stationary((beta / max_eig) * 0.7, radius)
|
| 244 |
+
else:
|
| 245 |
+
# print(f"Stationary, beta={str(beta):s}")
|
| 246 |
+
return beta
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def simulate_var(p, T, lag, sparsity=0.2, beta_value=1.0, auto_corr=3.0, sd=0.1, seed=0):
|
| 250 |
+
if seed is not None:
|
| 251 |
+
np.random.seed(seed)
|
| 252 |
+
|
| 253 |
+
# Set up coefficients and Granger causality ground truth.
|
| 254 |
+
GC = np.eye(p, dtype=int)
|
| 255 |
+
beta = np.eye(p) * auto_corr
|
| 256 |
+
|
| 257 |
+
num_nonzero = int(p * sparsity) - 1
|
| 258 |
+
for i in range(p):
|
| 259 |
+
choice = np.random.choice(p - 1, size=num_nonzero, replace=False)
|
| 260 |
+
choice[choice >= i] += 1
|
| 261 |
+
beta[i, choice] = beta_value
|
| 262 |
+
GC[i, choice] = 1
|
| 263 |
+
|
| 264 |
+
beta = np.hstack([beta for _ in range(lag)])
|
| 265 |
+
beta = make_var_stationary(beta)
|
| 266 |
+
|
| 267 |
+
# Generate data.
|
| 268 |
+
burn_in = 100
|
| 269 |
+
errors = np.random.normal(loc=0, scale=sd, size=(p, T + burn_in))
|
| 270 |
+
X = np.ones((p, T + burn_in))
|
| 271 |
+
X[:, :lag] = errors[:, :lag]
|
| 272 |
+
for t in range(lag, T + burn_in):
|
| 273 |
+
X[:, t] = np.dot(beta, X[:, (t-lag):t].flatten(order='F'))
|
| 274 |
+
X[:, t] += errors[:, t-1]
|
| 275 |
+
|
| 276 |
+
data = X.T[burn_in:, :]
|
| 277 |
+
return data, beta, GC
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def lorenz(x, t, F):
|
| 284 |
+
'''Partial derivatives for Lorenz-96 ODE.'''
|
| 285 |
+
p = len(x)
|
| 286 |
+
dxdt = np.zeros(p)
|
| 287 |
+
for i in range(p):
|
| 288 |
+
dxdt[i] = (x[(i+1) % p] - x[(i-2) % p]) * x[(i-1) % p] - x[i] + F
|
| 289 |
+
|
| 290 |
+
return dxdt
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def simulate_lorenz_96(p, T, F=10.0, delta_t=0.1, sd=0.1, burn_in=1000,
|
| 294 |
+
seed=0):
|
| 295 |
+
if seed is not None:
|
| 296 |
+
np.random.seed(seed)
|
| 297 |
+
|
| 298 |
+
# Use scipy to solve ODE.
|
| 299 |
+
x0 = np.random.normal(scale=0.01, size=p)
|
| 300 |
+
t = np.linspace(0, (T + burn_in) * delta_t, T + burn_in)
|
| 301 |
+
X = odeint(lorenz, x0, t, args=(F,))
|
| 302 |
+
X += np.random.normal(scale=sd, size=(T + burn_in, p))
|
| 303 |
+
|
| 304 |
+
# Set up Granger causality ground truth.
|
| 305 |
+
GC = np.zeros((p, p), dtype=int)
|
| 306 |
+
for i in range(p):
|
| 307 |
+
GC[i, i] = 1
|
| 308 |
+
GC[i, (i + 1) % p] = 1
|
| 309 |
+
GC[i, (i - 1) % p] = 1
|
| 310 |
+
GC[i, (i - 2) % p] = 1
|
| 311 |
+
|
| 312 |
+
return X[burn_in:, :], GC
|
| 313 |
+
|
singular_ticker_causal/algorithms/CUTS_PLUS/data/utils.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
from sklearn.metrics.pairwise import haversine_distances
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def compute_mean(x, index=None):
|
| 8 |
+
"""Compute the mean values for each datetime. The mean is first computed hourly over the week of the year.
|
| 9 |
+
Further NaN values are computed using hourly mean over the same month through the years. If other NaN are present,
|
| 10 |
+
they are removed using the mean of the sole hours. Hoping reasonably that there is at least a non-NaN entry of the
|
| 11 |
+
same hour of the NaN datetime in all the dataset."""
|
| 12 |
+
if isinstance(x, np.ndarray) and index is not None:
|
| 13 |
+
shape = x.shape
|
| 14 |
+
x = x.reshape((shape[0], -1))
|
| 15 |
+
df_mean = pd.DataFrame(x, index=index)
|
| 16 |
+
else:
|
| 17 |
+
df_mean = x.copy()
|
| 18 |
+
cond0 = [df_mean.index.year, df_mean.index.isocalendar().week, df_mean.index.hour]
|
| 19 |
+
cond1 = [df_mean.index.year, df_mean.index.month, df_mean.index.hour]
|
| 20 |
+
conditions = [cond0, cond1, cond1[1:], cond1[2:]]
|
| 21 |
+
while df_mean.isna().values.sum() and len(conditions):
|
| 22 |
+
nan_mean = df_mean.groupby(conditions[0]).transform(np.nanmean)
|
| 23 |
+
df_mean = df_mean.fillna(nan_mean)
|
| 24 |
+
conditions = conditions[1:]
|
| 25 |
+
if df_mean.isna().values.sum():
|
| 26 |
+
df_mean = df_mean.fillna(method='ffill')
|
| 27 |
+
df_mean = df_mean.fillna(method='bfill')
|
| 28 |
+
if isinstance(x, np.ndarray):
|
| 29 |
+
df_mean = df_mean.values.reshape(shape)
|
| 30 |
+
return df_mean
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def geographical_distance(x=None, to_rad=True):
|
| 34 |
+
"""
|
| 35 |
+
Compute the as-the-crow-flies distance between every pair of samples in `x`. The first dimension of each point is
|
| 36 |
+
assumed to be the latitude, the second is the longitude. The inputs is assumed to be in degrees. If it is not the
|
| 37 |
+
case, `to_rad` must be set to False. The dimension of the data must be 2.
|
| 38 |
+
|
| 39 |
+
Parameters
|
| 40 |
+
----------
|
| 41 |
+
x : pd.DataFrame or np.ndarray
|
| 42 |
+
array_like structure of shape (n_samples_2, 2).
|
| 43 |
+
to_rad : bool
|
| 44 |
+
whether to convert inputs to radians (provided that they are in degrees).
|
| 45 |
+
|
| 46 |
+
Returns
|
| 47 |
+
-------
|
| 48 |
+
distances :
|
| 49 |
+
The distance between the points in kilometers.
|
| 50 |
+
"""
|
| 51 |
+
_AVG_EARTH_RADIUS_KM = 6371.0088
|
| 52 |
+
|
| 53 |
+
# Extract values of X if it is a DataFrame, else assume it is 2-dim array of lat-lon pairs
|
| 54 |
+
latlon_pairs = x.values if isinstance(x, pd.DataFrame) else x
|
| 55 |
+
|
| 56 |
+
# If the input values are in degrees, convert them in radians
|
| 57 |
+
if to_rad:
|
| 58 |
+
latlon_pairs = np.vectorize(np.radians)(latlon_pairs)
|
| 59 |
+
|
| 60 |
+
distances = haversine_distances(latlon_pairs) * _AVG_EARTH_RADIUS_KM
|
| 61 |
+
|
| 62 |
+
# Cast response
|
| 63 |
+
if isinstance(x, pd.DataFrame):
|
| 64 |
+
res = pd.DataFrame(distances, x.index, x.index)
|
| 65 |
+
else:
|
| 66 |
+
res = distances
|
| 67 |
+
|
| 68 |
+
return res
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def infer_mask(df, infer_from='next'):
|
| 72 |
+
"""Infer evaluation mask from DataFrame. In the evaluation mask a value is 1 if it is present in the DataFrame and
|
| 73 |
+
absent in the `infer_from` month.
|
| 74 |
+
|
| 75 |
+
@param pd.DataFrame df: the DataFrame.
|
| 76 |
+
@param str infer_from: denotes from which month the evaluation value must be inferred.
|
| 77 |
+
Can be either `previous` or `next`.
|
| 78 |
+
@return: pd.DataFrame eval_mask: the evaluation mask for the DataFrame
|
| 79 |
+
"""
|
| 80 |
+
mask = (~df.isna()).astype('uint8')
|
| 81 |
+
eval_mask = pd.DataFrame(index=mask.index, columns=mask.columns, data=0).astype('uint8')
|
| 82 |
+
if infer_from == 'previous':
|
| 83 |
+
offset = -1
|
| 84 |
+
elif infer_from == 'next':
|
| 85 |
+
offset = 1
|
| 86 |
+
else:
|
| 87 |
+
raise ValueError('infer_from can only be one of %s' % ['previous', 'next'])
|
| 88 |
+
months = sorted(set(zip(mask.index.year, mask.index.month)))
|
| 89 |
+
length = len(months)
|
| 90 |
+
for i in range(length):
|
| 91 |
+
j = (i + offset) % length
|
| 92 |
+
year_i, month_i = months[i]
|
| 93 |
+
year_j, month_j = months[j]
|
| 94 |
+
mask_j = mask[(mask.index.year == year_j) & (mask.index.month == month_j)]
|
| 95 |
+
mask_i = mask_j.shift(1, pd.DateOffset(months=12 * (year_i - year_j) + (month_i - month_j)))
|
| 96 |
+
mask_i = mask_i[~mask_i.index.duplicated(keep='first')]
|
| 97 |
+
mask_i = mask_i[np.in1d(mask_i.index, mask.index)]
|
| 98 |
+
eval_mask.loc[mask_i.index] = ~mask_i.loc[mask_i.index] & mask.loc[mask_i.index]
|
| 99 |
+
return eval_mask
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def disjoint_months(dataset, months=None):
|
| 103 |
+
idxs = np.arange(len(dataset))
|
| 104 |
+
months = ensure_list(months)
|
| 105 |
+
# divide indices according to window or horizon
|
| 106 |
+
start, end = 0, dataset.window - 1
|
| 107 |
+
# after idxs
|
| 108 |
+
start_in_months = np.in1d(dataset.index[dataset._indices + start].month, months)
|
| 109 |
+
end_in_months = np.in1d(dataset.index[dataset._indices + end].month, months)
|
| 110 |
+
idxs_in_months = start_in_months & end_in_months
|
| 111 |
+
after_idxs = idxs[idxs_in_months]
|
| 112 |
+
# previous idxs
|
| 113 |
+
months = np.setdiff1d(np.arange(1, 13), months)
|
| 114 |
+
start_in_months = np.in1d(dataset.index[dataset._indices + start].month, months)
|
| 115 |
+
end_in_months = np.in1d(dataset.index[dataset._indices + end].month, months)
|
| 116 |
+
idxs_in_months = start_in_months & end_in_months
|
| 117 |
+
prev_idxs = idxs[idxs_in_months]
|
| 118 |
+
return prev_idxs, after_idxs
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def thresholded_gaussian_kernel(x, theta=None, threshold=None, threshold_on_input=False):
|
| 122 |
+
if theta is None:
|
| 123 |
+
theta = np.std(x)
|
| 124 |
+
weights = np.exp(-np.square(x / theta))
|
| 125 |
+
if threshold is not None:
|
| 126 |
+
mask = x > threshold if threshold_on_input else weights < threshold
|
| 127 |
+
weights[mask] = 0.
|
| 128 |
+
return weights
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def ensure_list(obj):
|
| 132 |
+
if isinstance(obj, (list, tuple)):
|
| 133 |
+
return list(obj)
|
| 134 |
+
else:
|
| 135 |
+
return [obj]
|
| 136 |
+
|
singular_ticker_causal/algorithms/CUTS_PLUS/lagged_graph.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from copy import deepcopy
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from .cuts_plus import main as cuts_plus_main
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class LaggedGraphBundle:
|
| 14 |
+
graphs: np.ndarray
|
| 15 |
+
summary_graph: np.ndarray
|
| 16 |
+
priors: np.ndarray
|
| 17 |
+
input_steps: np.ndarray
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def broadcast_prior_to_lags(G_prior: np.ndarray | None, num_lags: int) -> np.ndarray | None:
|
| 21 |
+
if G_prior is None:
|
| 22 |
+
return None
|
| 23 |
+
G_prior = np.asarray(G_prior, dtype=np.float32)
|
| 24 |
+
if G_prior.ndim == 2:
|
| 25 |
+
return np.repeat(G_prior[None, :, :], num_lags, axis=0)
|
| 26 |
+
if G_prior.ndim == 3 and G_prior.shape[0] == num_lags:
|
| 27 |
+
return G_prior
|
| 28 |
+
raise ValueError("G_prior must have shape (N, N) or (L, N, N)")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def aggregate_lagged_graphs(graphs: np.ndarray, reducer: str = "max") -> np.ndarray:
|
| 32 |
+
if reducer == "max":
|
| 33 |
+
return np.max(graphs, axis=0)
|
| 34 |
+
if reducer == "mean":
|
| 35 |
+
return np.mean(graphs, axis=0)
|
| 36 |
+
if reducer == "last":
|
| 37 |
+
return graphs[-1]
|
| 38 |
+
raise ValueError(f"Unsupported lagged graph reducer: {reducer}")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def discover_lagged_graphs(
|
| 42 |
+
data: np.ndarray,
|
| 43 |
+
mask: np.ndarray,
|
| 44 |
+
opt: Any,
|
| 45 |
+
log: Any,
|
| 46 |
+
device: str = "cpu",
|
| 47 |
+
text_data: np.ndarray | None = None,
|
| 48 |
+
text_mask: np.ndarray | None = None,
|
| 49 |
+
G_prior: np.ndarray | None = None,
|
| 50 |
+
num_lags: int = 1,
|
| 51 |
+
reducer: str = "max",
|
| 52 |
+
) -> LaggedGraphBundle:
|
| 53 |
+
if num_lags < 1:
|
| 54 |
+
raise ValueError("num_lags must be >= 1")
|
| 55 |
+
|
| 56 |
+
lag_priors = broadcast_prior_to_lags(G_prior, num_lags)
|
| 57 |
+
graphs = []
|
| 58 |
+
input_steps = []
|
| 59 |
+
previous_graph = None
|
| 60 |
+
|
| 61 |
+
for lag_idx in range(num_lags):
|
| 62 |
+
cfg = deepcopy(opt)
|
| 63 |
+
cfg.input_step = max(int(opt.input_step), lag_idx + 1)
|
| 64 |
+
input_steps.append(cfg.input_step)
|
| 65 |
+
|
| 66 |
+
current_prior = None
|
| 67 |
+
if lag_priors is not None:
|
| 68 |
+
current_prior = lag_priors[lag_idx]
|
| 69 |
+
elif previous_graph is not None:
|
| 70 |
+
current_prior = previous_graph
|
| 71 |
+
|
| 72 |
+
graph = cuts_plus_main(
|
| 73 |
+
data=data,
|
| 74 |
+
mask=mask,
|
| 75 |
+
true_cm=None,
|
| 76 |
+
opt=cfg,
|
| 77 |
+
log=log,
|
| 78 |
+
device=device,
|
| 79 |
+
text_data=text_data,
|
| 80 |
+
text_mask=text_mask,
|
| 81 |
+
G_prior=current_prior,
|
| 82 |
+
)
|
| 83 |
+
previous_graph = graph
|
| 84 |
+
graphs.append(np.asarray(graph, dtype=np.float32))
|
| 85 |
+
|
| 86 |
+
stacked = np.stack(graphs, axis=0)
|
| 87 |
+
summary = aggregate_lagged_graphs(stacked, reducer=reducer)
|
| 88 |
+
if lag_priors is None:
|
| 89 |
+
lag_priors = np.zeros_like(stacked)
|
| 90 |
+
return LaggedGraphBundle(
|
| 91 |
+
graphs=stacked,
|
| 92 |
+
summary_graph=summary.astype(np.float32),
|
| 93 |
+
priors=lag_priors.astype(np.float32),
|
| 94 |
+
input_steps=np.asarray(input_steps, dtype=np.int32),
|
| 95 |
+
)
|
singular_ticker_causal/algorithms/CUTS_PLUS/model/cuts_plus_net.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from einops import rearrange
|
| 3 |
+
from torch import nn
|
| 4 |
+
|
| 5 |
+
class GRUCell(nn.Module):
|
| 6 |
+
|
| 7 |
+
def __init__(self, d_in, num_units, n_nodes, concat_h=False, activation='tanh'):
|
| 8 |
+
super(GRUCell, self).__init__()
|
| 9 |
+
self.activation_fn = getattr(torch, activation)
|
| 10 |
+
|
| 11 |
+
mpnn_channel = d_in*n_nodes+num_units if concat_h else d_in*n_nodes
|
| 12 |
+
self.forget_gate = MPNN(c_in=mpnn_channel, c_out=num_units, concat_h=concat_h)
|
| 13 |
+
self.update_gate = MPNN(c_in=mpnn_channel, c_out=num_units, concat_h=concat_h)
|
| 14 |
+
self.c_gate = MPNN(c_in=mpnn_channel, c_out=num_units, concat_h=concat_h)
|
| 15 |
+
|
| 16 |
+
def forward(self, x, h, adj):
|
| 17 |
+
"""
|
| 18 |
+
:param x: (B, input_dim, num_nodes)
|
| 19 |
+
:param h: (B, num_units, num_nodes)
|
| 20 |
+
:param adj: (num_nodes, num_nodes)
|
| 21 |
+
:return:
|
| 22 |
+
"""
|
| 23 |
+
# we start with bias 1.0 to not reset and not update
|
| 24 |
+
r = torch.sigmoid(self.forget_gate(x, h, adj))
|
| 25 |
+
u = torch.sigmoid(self.update_gate(x, h, adj))
|
| 26 |
+
c = self.c_gate(x, r * h, adj) # batch_size, self._num_nodes * output_size
|
| 27 |
+
c = self.activation_fn(c)
|
| 28 |
+
return u * h + (1. - u) * c
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class MPNN(nn.Module):
|
| 32 |
+
def __init__(self, c_in, c_out, concat_h=True):
|
| 33 |
+
super(MPNN, self).__init__()
|
| 34 |
+
self.concat_h = concat_h
|
| 35 |
+
self.mlp = nn.Conv1d(c_in, c_out, kernel_size=1)
|
| 36 |
+
|
| 37 |
+
def forward(self, x, h, graph):
|
| 38 |
+
b, c, n = x.shape
|
| 39 |
+
|
| 40 |
+
x_repeat = x[:, :, :, None].expand(-1, -1, -1, n) # [b, c, n, n]
|
| 41 |
+
# graph = rearrange(graph, 'b n m -> b m n')
|
| 42 |
+
x_messages = torch.einsum('bcmn,bmn->bcmn', (x_repeat, graph))
|
| 43 |
+
x_messages = rearrange(x_messages, 'b c m n -> b (c m) n')
|
| 44 |
+
|
| 45 |
+
if self.concat_h:
|
| 46 |
+
out = self.mlp(torch.cat([x_messages, h], dim=1))
|
| 47 |
+
else:
|
| 48 |
+
out = self.mlp(x_messages)
|
| 49 |
+
return out
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class LocalConv1D(nn.Module):
|
| 53 |
+
def __init__(self, in_channels, out_channels, kernel_size, n_nodes):
|
| 54 |
+
super(LocalConv1D, self).__init__()
|
| 55 |
+
self.out_channel = out_channels
|
| 56 |
+
self.conv_list = nn.ModuleList([
|
| 57 |
+
nn.Conv1d(in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size) for _ in range(n_nodes)
|
| 58 |
+
])
|
| 59 |
+
|
| 60 |
+
def forward(self, x): # x: [batch, features, nodes]
|
| 61 |
+
b, h, n = x.shape
|
| 62 |
+
out = torch.zeros((b, self.out_channel, n)).to(x.device)
|
| 63 |
+
for i in range(n):
|
| 64 |
+
x_local_in = x[..., i].unsqueeze(-1)
|
| 65 |
+
x_local_out = self.conv_list[i](x_local_in)
|
| 66 |
+
out[..., i] = x_local_out.squeeze(-1)
|
| 67 |
+
return out
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class CUTS_Plus_Net(nn.Module):
|
| 71 |
+
def __init__(self, n_nodes,
|
| 72 |
+
in_ch=1,
|
| 73 |
+
hidden_ch=32,
|
| 74 |
+
n_layers=1,
|
| 75 |
+
shared_weights_decoder=False,
|
| 76 |
+
concat_h=False,):
|
| 77 |
+
super().__init__()
|
| 78 |
+
self.in_ch = in_ch
|
| 79 |
+
self.hidden_ch = hidden_ch
|
| 80 |
+
self.n_layers = n_layers
|
| 81 |
+
|
| 82 |
+
self.conv_encoder1 = nn.Conv1d(in_channels=hidden_ch, out_channels=hidden_ch, kernel_size=1)
|
| 83 |
+
self.conv_encoder2 = nn.Conv1d(in_channels=2*hidden_ch, out_channels=hidden_ch, kernel_size=1)
|
| 84 |
+
if shared_weights_decoder:
|
| 85 |
+
self.decoder = nn.Sequential(
|
| 86 |
+
nn.Conv1d(in_channels=2*hidden_ch, out_channels=in_ch, kernel_size=1),
|
| 87 |
+
# nn.LeakyReLU(),
|
| 88 |
+
# nn.Conv1d(in_channels=hidden_ch, out_channels=hidden_ch, kernel_size=1),
|
| 89 |
+
# nn.LeakyReLU(),
|
| 90 |
+
# nn.Conv1d(in_channels=hidden_ch, out_channels=in_ch, kernel_size=1),
|
| 91 |
+
# nn.LeakyReLU(),
|
| 92 |
+
)
|
| 93 |
+
else:
|
| 94 |
+
self.decoder = nn.Sequential(
|
| 95 |
+
LocalConv1D(in_channels=2*hidden_ch, out_channels=in_ch, kernel_size=1, n_nodes=n_nodes),
|
| 96 |
+
# nn.LeakyReLU(),
|
| 97 |
+
# LocalConv1D(in_channels=hidden_ch, out_channels=hidden_ch, kernel_size=1, n_nodes=n_nodes),
|
| 98 |
+
# nn.LeakyReLU(),
|
| 99 |
+
# LocalConv1D(in_channels=hidden_ch, out_channels=in_ch, kernel_size=1, n_nodes=n_nodes),
|
| 100 |
+
# nn.LeakyReLU(),
|
| 101 |
+
)
|
| 102 |
+
# self.act = nn.PReLU()
|
| 103 |
+
self.act = nn.LeakyReLU()
|
| 104 |
+
|
| 105 |
+
self.cells = nn.ModuleList()
|
| 106 |
+
for i in range(self.n_layers):
|
| 107 |
+
self.cells.append(GRUCell(d_in=in_ch if i==0 else hidden_ch,
|
| 108 |
+
num_units=hidden_ch,
|
| 109 |
+
n_nodes=n_nodes,
|
| 110 |
+
concat_h=concat_h))
|
| 111 |
+
|
| 112 |
+
self.h0 = self.init_state(n_nodes)
|
| 113 |
+
|
| 114 |
+
def init_state(self, n_nodes):
|
| 115 |
+
h = []
|
| 116 |
+
for layer in range(self.n_layers):
|
| 117 |
+
h.append(nn.parameter.Parameter(torch.zeros([self.hidden_ch, n_nodes])))
|
| 118 |
+
return nn.ParameterList(h)
|
| 119 |
+
|
| 120 |
+
def update_state(self, x, h, graph):
|
| 121 |
+
rnn_in = x
|
| 122 |
+
for layer in range(self.n_layers):
|
| 123 |
+
rnn_in = h[layer] = self.cells[layer](rnn_in, h[layer], graph)
|
| 124 |
+
return h
|
| 125 |
+
|
| 126 |
+
def forward(self, x, mask, fwd_graph):
|
| 127 |
+
x = rearrange(x, 'b n s c -> b c n s')
|
| 128 |
+
# fwd_graph = torch.ones_like(fwd_graph)
|
| 129 |
+
# mask = torch.ones_like(x).byte()
|
| 130 |
+
bs, in_ch, n_nodes, steps = x.shape
|
| 131 |
+
|
| 132 |
+
h = [h_.expand(bs, -1, -1) for h_ in self.h0.to(x.device)]
|
| 133 |
+
|
| 134 |
+
pred = []
|
| 135 |
+
for step in range(steps):
|
| 136 |
+
x_now = x[..., step] # [batches, in_ch, nodes]
|
| 137 |
+
|
| 138 |
+
"""Update state"""
|
| 139 |
+
h = self.update_state(x_now, h, fwd_graph)
|
| 140 |
+
h_now = h[-1]
|
| 141 |
+
|
| 142 |
+
"""Prediction"""
|
| 143 |
+
x_repr = self.act(self.conv_encoder1(h_now)) # [batches, hidden_ch, nodes]
|
| 144 |
+
x_repr = self.act(self.conv_encoder2(torch.cat([x_repr, h_now], dim=1))) # [batches, hidden_ch, nodes]
|
| 145 |
+
x_repr = torch.cat([x_repr, h_now], dim=1) # [batches, 2*hidden_ch, nodes]
|
| 146 |
+
x_hat2 = self.decoder(x_repr) # [batches, in_ch, nodes]
|
| 147 |
+
pred.append(x_hat2)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
pred = torch.stack(pred, dim=-1)
|
| 151 |
+
pred = rearrange(pred, 'b c n s -> b n s c')
|
| 152 |
+
return pred[:, :, -1:]
|
| 153 |
+
|
singular_ticker_causal/algorithms/CUTS_PLUS/utils/causal_plot.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from re import X
|
| 2 |
+
import matplotlib
|
| 3 |
+
matplotlib.use('Agg')
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
import tqdm
|
| 6 |
+
import numpy as np
|
| 7 |
+
import matplotlib.cm as cm
|
| 8 |
+
|
| 9 |
+
def save_causal_graph(save_path, causal_matrix: np.ndarray, thres_percentile=100, colormap="gnuplot2"):
|
| 10 |
+
causal_matrix = np.max(causal_matrix)
|
| 11 |
+
print(causal_matrix.shape)
|
| 12 |
+
|
| 13 |
+
n_node = causal_matrix.shape[0]
|
| 14 |
+
image_size = [100, 100]
|
| 15 |
+
n_node_dim = n_node**0.5
|
| 16 |
+
causal_thres = np.percentile(causal_matrix, 100-thres_percentile)
|
| 17 |
+
|
| 18 |
+
colormap = cm.get_cmap(colormap)
|
| 19 |
+
|
| 20 |
+
plt.figure(figsize=[10,10], facecolor='black', edgecolor='black')
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
for node_i_from in tqdm.tqdm(range(n_node)):
|
| 24 |
+
x_from = image_size[0] // n_node_dim * (node_i_from // n_node_dim + 0.5)
|
| 25 |
+
y_from = image_size[0] // n_node_dim * (node_i_from % n_node_dim + 0.5)
|
| 26 |
+
plt.text(y=x_from, x=y_from, s=f"{node_i_from:d}", size=20, color="#ffffff")
|
| 27 |
+
for node_i_to in range(n_node):
|
| 28 |
+
if not node_i_from == node_i_to:
|
| 29 |
+
x_to = image_size[0] // n_node_dim * (node_i_to // n_node_dim + 0.5)
|
| 30 |
+
y_to = image_size[0] // n_node_dim * (node_i_to % n_node_dim + 0.5)
|
| 31 |
+
causal_effect = causal_matrix[node_i_from, node_i_to]
|
| 32 |
+
if causal_effect > causal_thres:
|
| 33 |
+
width = max(0.01, 1*causal_effect)
|
| 34 |
+
arrow_length = ((x_to-x_from)**2 + (y_to-y_from)**2)**0.5
|
| 35 |
+
plt.arrow(
|
| 36 |
+
y=x_from+(x_to-x_from)*width/arrow_length,
|
| 37 |
+
x=y_from+(y_to-y_from)*width/arrow_length,
|
| 38 |
+
dy=(x_to-x_from)*(arrow_length-5*width)/arrow_length,
|
| 39 |
+
dx=(y_to-y_from)*(arrow_length-5*width)/arrow_length,
|
| 40 |
+
width=width,
|
| 41 |
+
head_length=4*width,
|
| 42 |
+
facecolor=colormap(causal_effect)[:3]+(causal_effect,),
|
| 43 |
+
edgecolor="#00000000"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# fig.add_annotation(text=f"{node_i_from:d}", x=x_from, y=y_from, showarrow=False
|
| 47 |
+
|
| 48 |
+
ax=plt.gca()
|
| 49 |
+
ax.patch.set_facecolor("black")
|
| 50 |
+
ax.xaxis.set_ticks_position('top')
|
| 51 |
+
ax.invert_yaxis()
|
| 52 |
+
plt.savefig(save_path)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__=="__main__":
|
| 56 |
+
causal_matrix = np.load("outputs/tsgae_2022_0716_191203_262072/w.npy")
|
| 57 |
+
save_causal_graph("outputs/pic/causal.png", causal_matrix, thres_percentile=100)
|
singular_ticker_causal/algorithms/CUTS_PLUS/utils/exp_utils.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from os.path import join as opj
|
| 3 |
+
from os.path import dirname as opd
|
| 4 |
+
|
| 5 |
+
from .opt_type import MultiCADopt
|
| 6 |
+
from .misc import omegaconf2dict
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
import numpy as np
|
| 10 |
+
import matplotlib
|
| 11 |
+
matplotlib.use('Agg')
|
| 12 |
+
import matplotlib.pyplot as plt
|
| 13 |
+
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from omegaconf import OmegaConf
|
| 16 |
+
import glob
|
| 17 |
+
import tqdm
|
| 18 |
+
from tensorboard.backend.event_processing import event_accumulator
|
| 19 |
+
|
| 20 |
+
PROPER_NAME = {"NFGR":"BRIEF", "h265":"H.265", "h264":"H.264", "jpg":"JPEG", "aoi-2000":"AoI", "vvc":"H.266"}
|
| 21 |
+
|
| 22 |
+
MAIN_COLOR = {"NFGR":"#e64b35", "h265":"#48c9b0", "h264":"#5599c7", "jpg":"#c39bd2", "DVC":"#e6b0aa", "SGA":"#f8c370", "vvc":"#b1babb",
|
| 23 |
+
"GOOD":"#239954", "BAD":"#cc6155"}
|
| 24 |
+
SECONDARY_COLOR = {"NFGR":"#d98880", "h265":"#76d7c3", "SGA":"#f9d7a0", "h264":"#7fb3d5", "DVC":"#e6b0aa"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_time_stamp():
|
| 28 |
+
return str(datetime.now().strftime("%m-%d-%H%M%S-%f"))
|
| 29 |
+
|
| 30 |
+
def show_single_scores(x_arr, y_arr, label="exp", suffix="",
|
| 31 |
+
scatter=False, log_axis=True,
|
| 32 |
+
xlim=None, ylim=None):
|
| 33 |
+
|
| 34 |
+
'''Plotting figures'''
|
| 35 |
+
cvs = FigureCanvas(name=label, figsize=[30,20])
|
| 36 |
+
fig_idx = 0
|
| 37 |
+
for idx,data_path in enumerate(y_arr.labels[2]): # range(score_arr.shape[2]):
|
| 38 |
+
if fig_idx == 40:
|
| 39 |
+
break
|
| 40 |
+
elif np.isnan(np.nanmean(y_arr[:,:,data_path])):
|
| 41 |
+
continue
|
| 42 |
+
|
| 43 |
+
fig_idx += 1
|
| 44 |
+
if log_axis:
|
| 45 |
+
ax = plt.subplot(8,5,fig_idx, xscale="log")
|
| 46 |
+
else:
|
| 47 |
+
ax = plt.subplot(8,5,fig_idx)
|
| 48 |
+
ax.set_title("DATA_{:02d}_".format(idx) + data_path[-100:-60] + "\n" + data_path[-60:])
|
| 49 |
+
# ax.set_title(dim_marks[2][data_i])
|
| 50 |
+
plt.set_cmap("rainbow")
|
| 51 |
+
for i, dim0 in enumerate(y_arr.labels[0]):
|
| 52 |
+
x_nan = x_arr[dim0,:,data_path]
|
| 53 |
+
y_nan = y_arr[dim0,:,data_path]
|
| 54 |
+
x = x_nan[np.isfinite(x_nan + y_nan)]
|
| 55 |
+
y = y_nan[np.isfinite(x_nan + y_nan)]
|
| 56 |
+
|
| 57 |
+
if len(x) > 0 and len(y) > 0:
|
| 58 |
+
x, y = sort_lists(x, y)
|
| 59 |
+
|
| 60 |
+
plt.plot(x, y, color=plt.get_cmap("tab20")(i), label=dim0)
|
| 61 |
+
plt.scatter(x, y, color=plt.get_cmap("tab20")(i))
|
| 62 |
+
|
| 63 |
+
plt.legend()
|
| 64 |
+
if ylim is not None:
|
| 65 |
+
plt.ylim(ylim)
|
| 66 |
+
|
| 67 |
+
ax=plt.gca()
|
| 68 |
+
ax.xaxis.set_major_locator(plt.LogLocator(base=10, numticks=5))
|
| 69 |
+
ax.yaxis.set_major_locator(plt.MaxNLocator(5))
|
| 70 |
+
|
| 71 |
+
cvs.save_fig(suffix=suffix, time_stamp=False, save_format=".pdf")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def sort_lists(*lists):
|
| 75 |
+
sorted_index = np.argsort(lists[0]).astype(int)
|
| 76 |
+
results = []
|
| 77 |
+
for l in lists:
|
| 78 |
+
sorted_list = np.array([l[i] for i in sorted_index])
|
| 79 |
+
results.append(sorted_list)
|
| 80 |
+
return results
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def show_averge_scores(x_arr, y_arr, label="exp", suffix="", percentile=25,
|
| 84 |
+
scatter=False, log_axis=True, figsize=[4,3],
|
| 85 |
+
xlim=None, ylim=None, legend=False, grid=False, std=False):
|
| 86 |
+
cvs = FigureCanvas(name=label, figsize=figsize)
|
| 87 |
+
if log_axis:
|
| 88 |
+
plt.xscale("log")
|
| 89 |
+
|
| 90 |
+
fig_idx = 0
|
| 91 |
+
if scatter:
|
| 92 |
+
for data_i,data_path in enumerate(y_arr.labels[2]):
|
| 93 |
+
if np.isnan(np.nanmean(y_arr[:,:,data_path])):
|
| 94 |
+
continue
|
| 95 |
+
|
| 96 |
+
for i, dim0 in enumerate(y_arr.labels[0]):
|
| 97 |
+
x_nan = x_arr[dim0,:,data_path]
|
| 98 |
+
y_nan = y_arr[dim0,:,data_path]
|
| 99 |
+
x = x_nan[np.isfinite(x_nan + y_nan)]
|
| 100 |
+
y = y_nan[np.isfinite(x_nan + y_nan)]
|
| 101 |
+
|
| 102 |
+
if len(x) > 0 and len(y) > 0:
|
| 103 |
+
x, y = sort_lists(x, y)
|
| 104 |
+
|
| 105 |
+
# plt.plot(x, y, color=plt.get_cmap("tab20")(i), label=dim0, marker="v")
|
| 106 |
+
plt.scatter(x, y, color=MAIN_COLOR[dim0], alpha=0.5, marker="v", edgecolors='none',
|
| 107 |
+
s=80 if "NFGR" in dim0 else 50)
|
| 108 |
+
|
| 109 |
+
# plt.legend(edgecolors='none')
|
| 110 |
+
if ylim is not None:
|
| 111 |
+
plt.ylim(ylim)
|
| 112 |
+
|
| 113 |
+
if ylim is not None:
|
| 114 |
+
full_range = ylim[1] - ylim[0]
|
| 115 |
+
else:
|
| 116 |
+
full_range = np.nanmax(np.nanmean(y_arr.arr, axis=2)) - np.nanmin(np.nanmean(y_arr.arr, axis=2))
|
| 117 |
+
max_std = np.nanmax(np.nanstd(y_arr["NFGR",:,:], axis=1))
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
for idx, dim0 in enumerate(sorted(y_arr.labels[0], key=lambda item:item == "NFGR")):
|
| 121 |
+
# if "jpg" in dim0:
|
| 122 |
+
# continue
|
| 123 |
+
|
| 124 |
+
x_nan = np.nanmean(x_arr[dim0,:], axis=1)
|
| 125 |
+
y_nan = np.nanmean(y_arr[dim0,:], axis=1)
|
| 126 |
+
# y_l = np.nanpercentile(y_arr[dim0,:], percentile, axis=1)
|
| 127 |
+
# y_u = np.nanpercentile(y_arr[dim0,:], 100-percentile, axis=1)
|
| 128 |
+
y_std = np.nanstd(y_arr[dim0,:], axis=1) # / max_std * full_range * 0.07
|
| 129 |
+
|
| 130 |
+
print(1 / max_std * full_range * 0.07)
|
| 131 |
+
|
| 132 |
+
x = x_nan[np.isfinite(x_nan + y_nan)]
|
| 133 |
+
y = y_nan[np.isfinite(x_nan + y_nan)]
|
| 134 |
+
# y_l = y_l[np.isfinite(x_nan + y_nan)]
|
| 135 |
+
# y_u = y_u[np.isfinite(x_nan + y_nan)]
|
| 136 |
+
y_std = y_std[np.isfinite(x_nan + y_nan)]
|
| 137 |
+
|
| 138 |
+
x, y, y_std = sort_lists(x, y, y_std)
|
| 139 |
+
|
| 140 |
+
if std:
|
| 141 |
+
plt.fill_between(x, y-y_std/2, y+y_std/2, color=MAIN_COLOR[dim0], alpha=0.25, edgecolors="none")
|
| 142 |
+
plt.plot(x, y, color=MAIN_COLOR[dim0], label=name(dim0),
|
| 143 |
+
lw=2 if "NFGR" in dim0 else 1.5)
|
| 144 |
+
plt.scatter(x, y, color=MAIN_COLOR[dim0],
|
| 145 |
+
s=30 if "NFGR" in dim0 else 20)
|
| 146 |
+
|
| 147 |
+
if ylim is not None:
|
| 148 |
+
plt.ylim(ylim)
|
| 149 |
+
if xlim is not None:
|
| 150 |
+
plt.xlim(xlim)
|
| 151 |
+
if legend:
|
| 152 |
+
plt.legend(loc='lower left', bbox_to_anchor=(0.1, 0.1), fancybox=False)
|
| 153 |
+
|
| 154 |
+
ax=plt.gca()
|
| 155 |
+
if log_axis:
|
| 156 |
+
ax.xaxis.set_major_locator(plt.LogLocator(base=10, numticks=5))
|
| 157 |
+
else:
|
| 158 |
+
ax.xaxis.set_major_locator(plt.MaxNLocator(5))
|
| 159 |
+
ax.yaxis.set_major_locator(plt.MaxNLocator(5))
|
| 160 |
+
|
| 161 |
+
if grid:
|
| 162 |
+
ax.spines['right'].set_visible(True)
|
| 163 |
+
ax.spines['top'].set_visible(True)
|
| 164 |
+
# plt.xticks(np.arange(0.4, 1.8, 0.28))
|
| 165 |
+
# plt.yticks(np.arange(50, 500, 40))
|
| 166 |
+
plt.grid(axis='both', c="#cacaca", which="major")
|
| 167 |
+
|
| 168 |
+
cvs.save_fig(suffix=suffix, time_stamp=False, save_format=".pdf")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def name(alias):
|
| 172 |
+
if alias in PROPER_NAME:
|
| 173 |
+
return PROPER_NAME[alias]
|
| 174 |
+
else:
|
| 175 |
+
print("Cannot find proper name.")
|
| 176 |
+
return alias
|
| 177 |
+
|
| 178 |
+
class FigureCanvas(object):
|
| 179 |
+
|
| 180 |
+
def __init__(self, name="ex1", figsize=[14,9]):
|
| 181 |
+
self.name = name
|
| 182 |
+
plt.close('all')
|
| 183 |
+
fig = plt.figure(figsize=figsize)
|
| 184 |
+
ax = plt.axes()
|
| 185 |
+
ax.spines['right'].set_visible(False)
|
| 186 |
+
ax.spines['top'].set_visible(False)
|
| 187 |
+
plt.tight_layout()
|
| 188 |
+
|
| 189 |
+
def show_fig(self, save_format=".png", suffix="", time_stamp=True):
|
| 190 |
+
save_path = "./exp/figs/%s/%s_%s%s"%(
|
| 191 |
+
self.name,
|
| 192 |
+
get_time_stamp() if time_stamp else "plt",
|
| 193 |
+
suffix, save_format)
|
| 194 |
+
if not os.path.exists(opd(save_path)):
|
| 195 |
+
os.makedirs(opd(save_path))
|
| 196 |
+
plt.savefig(save_path, bbox_inches='tight')
|
| 197 |
+
plt.show()
|
| 198 |
+
|
| 199 |
+
def save_fig(self, save_format=".png", suffix="", time_stamp=True, save_root="./exp/figs/"):
|
| 200 |
+
save_path = opj(save_root, "%s/%s_%s%s"%(
|
| 201 |
+
self.name,
|
| 202 |
+
get_time_stamp() if time_stamp else "plt",
|
| 203 |
+
suffix, save_format))
|
| 204 |
+
if not os.path.exists(opd(save_path)):
|
| 205 |
+
os.makedirs(opd(save_path))
|
| 206 |
+
plt.savefig(save_path, bbox_inches='tight')
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def find_lineprofile_cmp(im_list):
|
| 210 |
+
for x in range(0, im_list[0].shape[0], 10):
|
| 211 |
+
for y in range(0, im_list[0].shape[1], 10):
|
| 212 |
+
lp = [im[x][y] for im in im_list]
|
| 213 |
+
if np.max(lp) > 2500 and np.max(lp) < 3000:
|
| 214 |
+
return lp
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def get_decompressed_path(opt_path):
|
| 218 |
+
res_root = opd(opt_path)
|
| 219 |
+
max_step = 0
|
| 220 |
+
for dirn in os.listdir(res_root):
|
| 221 |
+
if dirn == "decompressed":
|
| 222 |
+
return glob.glob(res_root + "/decompressed/*.tif")[0]
|
| 223 |
+
elif "steps" in dirn:
|
| 224 |
+
step_n = int(dirn[5:])
|
| 225 |
+
if step_n > max_step:
|
| 226 |
+
max_step = step_n
|
| 227 |
+
search_list = glob.glob(res_root + "/steps" + str(max_step) + "/decompressed/*.*")
|
| 228 |
+
if len(search_list) > 0:
|
| 229 |
+
return search_list[0]
|
| 230 |
+
return None
|
| 231 |
+
|
| 232 |
+
def load_scalars(event_path):
|
| 233 |
+
try:
|
| 234 |
+
event_path = glob.glob(event_path)[0]
|
| 235 |
+
except:
|
| 236 |
+
print("No event file found.")
|
| 237 |
+
return None
|
| 238 |
+
ea = event_accumulator.EventAccumulator(event_path)
|
| 239 |
+
ea.Reload()
|
| 240 |
+
# print("Available scalars: ", ea.scalars.Keys())
|
| 241 |
+
scalars = {}
|
| 242 |
+
for criterion in ea.scalars.Keys():
|
| 243 |
+
val_scalar = ea.scalars.Items(criterion)
|
| 244 |
+
val_curve = ([(i.step, i.value) for i in val_scalar])
|
| 245 |
+
scalars[criterion] = val_curve
|
| 246 |
+
return scalars
|
| 247 |
+
|
| 248 |
+
def load_scalars_cached(root_path, cache_dir="exp/cache", reload_data=False):
|
| 249 |
+
|
| 250 |
+
if root_path[-1] == "/":
|
| 251 |
+
root_path = root_path[:-1]
|
| 252 |
+
|
| 253 |
+
# csv_file = glob.glob(root_path + "/*.csv")[0]
|
| 254 |
+
# exp_list = load_csv(csv_file)
|
| 255 |
+
root_name = "".join(re.split("/|\\\\", root_path)[-2:])
|
| 256 |
+
cache_path = opj(cache_dir, root_name + ".npy")
|
| 257 |
+
|
| 258 |
+
# read from cached data
|
| 259 |
+
if os.path.exists(cache_path) and not reload_data:
|
| 260 |
+
print("Loading cached result...")
|
| 261 |
+
loaded_res = np.load(cache_path, allow_pickle=True)
|
| 262 |
+
else:
|
| 263 |
+
if not os.path.exists(cache_dir):
|
| 264 |
+
os.makedirs(cache_dir)
|
| 265 |
+
|
| 266 |
+
loaded_res = []
|
| 267 |
+
for dirn in tqdm.tqdm(os.listdir(root_path)):
|
| 268 |
+
if os.path.isdir(opj(root_path, dirn)):
|
| 269 |
+
if len(glob.glob(root_path + "/%s/events.out.tfevents*"%(dirn))) > 0:
|
| 270 |
+
res_fname = glob.glob(root_path + "/%s/events.out.tfevents*"%(dirn))[0]
|
| 271 |
+
opt_fname = glob.glob(root_path + "/%s/opt.yaml"%(dirn))[0]
|
| 272 |
+
scores = load_scalars(res_fname)
|
| 273 |
+
opt: MultiCADopt = omegaconf2dict(OmegaConf.load(opt_fname), sep=".")
|
| 274 |
+
loaded_res.append((res_fname, scores, opt))
|
| 275 |
+
|
| 276 |
+
np.save(cache_path, loaded_res)
|
| 277 |
+
|
| 278 |
+
return loaded_res
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# if __name__=="__main__":
|
| 282 |
+
# # scalars = load_scalars("cyx_exp/experiments_outputs/ex2_1227/*/exp_00000/events.out.tfevents*")
|
| 283 |
+
# # print(scalars)
|
| 284 |
+
|
| 285 |
+
# res = load_scalars_cached("cyx_exp/experiments_outputs/ex2_1227/", reload_data=False)
|
| 286 |
+
# print("")
|
singular_ticker_causal/algorithms/CUTS_PLUS/utils/gumbel_softmax.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from torch import Tensor
|
| 3 |
+
import warnings
|
| 4 |
+
|
| 5 |
+
def gumbel_softmax(logits: Tensor, tau: float = 1, hard: bool = False, eps: float = 1e-10, dim: int = -1) -> Tensor:
|
| 6 |
+
r"""
|
| 7 |
+
Samples from the Gumbel-Softmax distribution (`Link 1`_ `Link 2`_) and optionally discretizes.
|
| 8 |
+
|
| 9 |
+
Args:
|
| 10 |
+
logits: `[..., num_features]` unnormalized log probabilities
|
| 11 |
+
tau: non-negative scalar temperature
|
| 12 |
+
hard: if ``True``, the returned samples will be discretized as one-hot vectors,
|
| 13 |
+
but will be differentiated as if it is the soft sample in autograd
|
| 14 |
+
dim (int): A dimension along which softmax will be computed. Default: -1.
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
Sampled tensor of same shape as `logits` from the Gumbel-Softmax distribution.
|
| 18 |
+
If ``hard=True``, the returned samples will be one-hot, otherwise they will
|
| 19 |
+
be probability distributions that sum to 1 across `dim`.
|
| 20 |
+
|
| 21 |
+
.. note::
|
| 22 |
+
This function is here for legacy reasons, may be removed from nn.Functional in the future.
|
| 23 |
+
|
| 24 |
+
.. note::
|
| 25 |
+
The main trick for `hard` is to do `y_hard - y_soft.detach() + y_soft`
|
| 26 |
+
|
| 27 |
+
It achieves two things:
|
| 28 |
+
- makes the output value exactly one-hot
|
| 29 |
+
(since we add then subtract y_soft value)
|
| 30 |
+
- makes the gradient equal to y_soft gradient
|
| 31 |
+
(since we strip all other gradients)
|
| 32 |
+
|
| 33 |
+
Examples::
|
| 34 |
+
>>> logits = torch.randn(20, 32)
|
| 35 |
+
>>> # Sample soft categorical using reparametrization trick:
|
| 36 |
+
>>> F.gumbel_softmax(logits, tau=1, hard=False)
|
| 37 |
+
>>> # Sample hard categorical using "Straight-through" trick:
|
| 38 |
+
>>> F.gumbel_softmax(logits, tau=1, hard=True)
|
| 39 |
+
|
| 40 |
+
.. _Link 1:
|
| 41 |
+
https://arxiv.org/abs/1611.00712
|
| 42 |
+
.. _Link 2:
|
| 43 |
+
https://arxiv.org/abs/1611.01144
|
| 44 |
+
"""
|
| 45 |
+
if eps != 1e-10:
|
| 46 |
+
warnings.warn("`eps` parameter is deprecated and has no effect.")
|
| 47 |
+
|
| 48 |
+
gumbels = (
|
| 49 |
+
-torch.empty_like(logits, memory_format=torch.legacy_contiguous_format).exponential_().log()
|
| 50 |
+
) # ~Gumbel(0,1)
|
| 51 |
+
gumbels = (logits + gumbels) / tau # ~Gumbel(logits,tau)
|
| 52 |
+
y_soft = gumbels.softmax(dim)
|
| 53 |
+
|
| 54 |
+
if hard:
|
| 55 |
+
# Straight through.
|
| 56 |
+
index = y_soft.max(dim, keepdim=True)[1]
|
| 57 |
+
y_hard = torch.zeros_like(logits, memory_format=torch.legacy_contiguous_format).scatter_(dim, index, 1.0)
|
| 58 |
+
ret = y_hard - y_soft.detach() + y_soft
|
| 59 |
+
else:
|
| 60 |
+
# Reparametrization trick.
|
| 61 |
+
ret = y_soft
|
| 62 |
+
return ret
|
| 63 |
+
|
| 64 |
+
if __name__=="__main__":
|
| 65 |
+
a = torch.tensor([[2.0, 0.7]]*10)
|
| 66 |
+
print(gumbel_softmax(a, tau=10))
|
singular_ticker_causal/algorithms/CUTS_PLUS/utils/logger.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from omegaconf import OmegaConf
|
| 3 |
+
import os
|
| 4 |
+
from os.path import join as opj
|
| 5 |
+
import numpy as np
|
| 6 |
+
from os.path import dirname as opd
|
| 7 |
+
from typing import Dict
|
| 8 |
+
from torch.utils.tensorboard import SummaryWriter
|
| 9 |
+
from .misc import omegaconf2list
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MyLogger():
|
| 13 |
+
def __init__(self, log_dir: str, stderr: bool = True, tensorboard: bool = True, stdout: bool = True):
|
| 14 |
+
self.log_dir = log_dir
|
| 15 |
+
if not os.path.exists(self.log_dir):
|
| 16 |
+
os.makedirs(self.log_dir)
|
| 17 |
+
self.logger_dict: Dict[str] = {}
|
| 18 |
+
if stdout:
|
| 19 |
+
stdout_handler = open(opj(self.log_dir, 'stdout.log'), 'w')
|
| 20 |
+
sys.stdout = stdout_handler
|
| 21 |
+
if stderr:
|
| 22 |
+
stderr_handler = open(opj(self.log_dir, 'stderr.log'), 'w')
|
| 23 |
+
sys.stderr = stderr_handler
|
| 24 |
+
if tensorboard:
|
| 25 |
+
self.tblogger = SummaryWriter(self.log_dir)
|
| 26 |
+
self.logger_dict['tblogger'] = self.tblogger
|
| 27 |
+
|
| 28 |
+
def log_opt(self, opt):
|
| 29 |
+
OmegaConf.save(config=opt, f=opj(self.log_dir, 'opt.yaml'))
|
| 30 |
+
opt_log = omegaconf2list(opt, sep='/')
|
| 31 |
+
for logger_name in self.logger_dict.keys():
|
| 32 |
+
if logger_name == 'tblogger':
|
| 33 |
+
for idx, opt in enumerate(opt_log):
|
| 34 |
+
self.logger_dict[logger_name].add_text('hparam', opt, idx)
|
| 35 |
+
|
| 36 |
+
def log_metrics(self, metrics_dict: Dict[str, float], iters):
|
| 37 |
+
for logger_name in self.logger_dict.keys():
|
| 38 |
+
if logger_name == 'csvlogger':
|
| 39 |
+
self.logger_dict[logger_name].log_metrics(metrics_dict, iters)
|
| 40 |
+
self.logger_dict[logger_name].save()
|
| 41 |
+
elif logger_name == 'clearml_logger':
|
| 42 |
+
for k in metrics_dict.keys():
|
| 43 |
+
self.logger_dict[logger_name].report_scalar(
|
| 44 |
+
k, k, metrics_dict[k], iters)
|
| 45 |
+
elif logger_name == 'tblogger':
|
| 46 |
+
for k in metrics_dict.keys():
|
| 47 |
+
self.logger_dict[logger_name].add_scalar(
|
| 48 |
+
k, metrics_dict[k], iters)
|
| 49 |
+
|
| 50 |
+
def log_figures(self, figure, name="figure.png", iters=None, exclude_logger=[]):
|
| 51 |
+
for logger_name in self.logger_dict.keys():
|
| 52 |
+
if logger_name == 'tblogger':
|
| 53 |
+
if logger_name not in exclude_logger:
|
| 54 |
+
self.logger_dict[logger_name].add_figure(tag=name, figure=figure, global_step=iters)
|
| 55 |
+
|
| 56 |
+
if iters is None:
|
| 57 |
+
save_path = opj(self.log_dir, "figures")
|
| 58 |
+
else:
|
| 59 |
+
save_path = opj(self.log_dir, f"iter_{iters:d}", name)
|
| 60 |
+
os.makedirs(opd(save_path), exist_ok=True)
|
| 61 |
+
figure.savefig(save_path)
|
| 62 |
+
|
| 63 |
+
def log_npz(self, data: Dict, name="data.npz", iters=None):
|
| 64 |
+
if iters is None:
|
| 65 |
+
save_path = opj(self.log_dir)
|
| 66 |
+
else:
|
| 67 |
+
save_path = opj(self.log_dir, f"iter_{iters:d}", name)
|
| 68 |
+
os.makedirs(save_path, exist_ok=True)
|
| 69 |
+
np.savez(opj(save_path, "graph.npz"), **data)
|
| 70 |
+
|
| 71 |
+
def close(self):
|
| 72 |
+
for logger_name in self.logger_dict.keys():
|
| 73 |
+
if logger_name == 'tblogger':
|
| 74 |
+
self.logger_dict[logger_name].close()
|
singular_ticker_causal/algorithms/CUTS_PLUS/utils/misc.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from copy import deepcopy
|
| 2 |
+
import numpy as np
|
| 3 |
+
import matplotlib
|
| 4 |
+
matplotlib.use('Agg')
|
| 5 |
+
import matplotlib.pyplot as plt
|
| 6 |
+
import itertools
|
| 7 |
+
import random
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
import omegaconf
|
| 11 |
+
from sklearn.metrics import roc_curve, roc_auc_score
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def log_time_series(original_data, data_interp, data_pred, log, log_step):
|
| 15 |
+
fig = plt.figure(figsize=[10,10])
|
| 16 |
+
plt.plot(np.arange(0, original_data.shape[0], 1), original_data, label="original")
|
| 17 |
+
plt.plot(np.arange(0, data_interp.shape[0], 1), data_interp, label="interp")
|
| 18 |
+
plt.plot(np.arange(0, data_pred.shape[0], 1), data_pred, label="pred")
|
| 19 |
+
plt.legend()
|
| 20 |
+
log.log_figures(fig, name="Predicted Latent Data", iters=log_step)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def calc_and_log_metrics(time_prob_mat, true_cm, log, log_step, threshold=0.5, plot_roc=True):
|
| 24 |
+
if len(time_prob_mat.shape) == 3:
|
| 25 |
+
graph = np.max(time_prob_mat, axis=2)
|
| 26 |
+
else:
|
| 27 |
+
graph = time_prob_mat
|
| 28 |
+
causal_graph = graph > threshold
|
| 29 |
+
tp = np.mean(causal_graph * true_cm)
|
| 30 |
+
tn = np.mean((1-causal_graph) * (1-causal_graph))
|
| 31 |
+
fp = np.mean(causal_graph * (1-true_cm))
|
| 32 |
+
fn = np.mean((1-causal_graph) * true_cm)
|
| 33 |
+
tpr = tp / (tp + fn)
|
| 34 |
+
fpr = fp / (fp + tn)
|
| 35 |
+
acc = (tp + tn) / (tp + tn + fp + fn)
|
| 36 |
+
log.log_metrics({"metrics/tpr": tpr}, log_step)
|
| 37 |
+
log.log_metrics({"metrics/fpr": fpr}, log_step)
|
| 38 |
+
log.log_metrics({"metrics/accuracy": acc}, log_step)
|
| 39 |
+
|
| 40 |
+
if plot_roc:
|
| 41 |
+
fpr, tpr, thres = roc_curve(true_cm.reshape(-1) > 0.5,
|
| 42 |
+
graph.reshape(-1), pos_label=1)
|
| 43 |
+
fig = plt.figure(figsize=[4, 4])
|
| 44 |
+
plt.plot(fpr, tpr)
|
| 45 |
+
log.tblogger.add_figure(tag="ROC", figure=fig, global_step=log_step)
|
| 46 |
+
|
| 47 |
+
log.log_npz(name="graph",
|
| 48 |
+
data={"true_cm":true_cm, "pred_cm":graph},
|
| 49 |
+
iters=log_step)
|
| 50 |
+
|
| 51 |
+
auc = roc_auc_score(true_cm.reshape(-1)>0.5,
|
| 52 |
+
graph.reshape(-1))
|
| 53 |
+
log.log_metrics({"metrics/auc": auc}, log_step)
|
| 54 |
+
return auc
|
| 55 |
+
|
| 56 |
+
def sigmoid(z):
|
| 57 |
+
return 1/(1 + np.exp(-z))
|
| 58 |
+
|
| 59 |
+
def plot_causal_matrix_in_training(time_coef, name, log, log_step, threshold=0.5, plot_each_time=False):
|
| 60 |
+
if time_coef is None:
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
if np.max(time_coef) - np.min(time_coef) > 0.01:
|
| 64 |
+
time_coef = (time_coef - np.min(time_coef)) / (np.max(time_coef) - np.min(time_coef))
|
| 65 |
+
n, m, t = time_coef.shape
|
| 66 |
+
|
| 67 |
+
# # Show Discovered Graph (Coefficiency)
|
| 68 |
+
# sub_cg = plot_causal_matrix(
|
| 69 |
+
# np.max(time_coef, axis=2),
|
| 70 |
+
# figsize=[1.5*time_coef.shape[0], 1*n])
|
| 71 |
+
# log.log_figures(sub_cg, name="Discovered Graph Coef/" + name, iters=log_step)
|
| 72 |
+
|
| 73 |
+
# # Graph for Each Time Lag
|
| 74 |
+
# if plot_each_time:
|
| 75 |
+
# for ti in range(t):
|
| 76 |
+
# sub_cg = plot_causal_matrix(
|
| 77 |
+
# time_coef[:, :, ti],
|
| 78 |
+
# figsize=[1.5*n, 1*n],
|
| 79 |
+
# vmin=0, vmax=1)
|
| 80 |
+
# log.log_figures(sub_cg, name=f"Discovered Prob T-{t-ti:d}",
|
| 81 |
+
# iters=log_step, exclude_logger="tblogger")
|
| 82 |
+
|
| 83 |
+
# Show Discovered Graph (Probability)
|
| 84 |
+
time_graph = time_coef
|
| 85 |
+
sub_cg = plot_causal_matrix(
|
| 86 |
+
np.max(time_graph, axis=2),
|
| 87 |
+
figsize=[1.5*n, 1*n],
|
| 88 |
+
vmin=0, vmax=1)
|
| 89 |
+
log.log_figures(sub_cg, name="Discovered Prob/" + name, iters=log_step)
|
| 90 |
+
|
| 91 |
+
# Show Thresholded Graph
|
| 92 |
+
time_thres = np.max(time_graph, axis=2) > threshold
|
| 93 |
+
sub_cg = plot_causal_matrix(
|
| 94 |
+
time_thres,
|
| 95 |
+
figsize=[1.5*n, 1*n])
|
| 96 |
+
log.log_figures(sub_cg, name="Discovered Graph/" + name, iters=log_step)
|
| 97 |
+
log.log_npz({"Discovered Graph Coef": time_coef, "Discovered Prob": time_graph, "Discovered Graph": time_thres},
|
| 98 |
+
name="Graph.npz", iters=log_step)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def plot_causal_matrix(cmtx, class_names=None, figsize=None, vmin=None, vmax=None, show_text=True, cmap="magma"):
|
| 102 |
+
"""
|
| 103 |
+
A function to create a colored and labeled causal matrix matplotlib figure
|
| 104 |
+
given true labels and preds.
|
| 105 |
+
Args:
|
| 106 |
+
cmtx (ndarray): causal matrix.
|
| 107 |
+
num_classes (int): total number of nodes.
|
| 108 |
+
class_names (Optional[list of strs]): a list of node names.
|
| 109 |
+
figsize (Optional[float, float]): the figure size of the causal matrix.
|
| 110 |
+
If None, default to [6.4, 4.8].
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
img (figure): matplotlib figure.
|
| 114 |
+
"""
|
| 115 |
+
num_classes = cmtx.shape[0]
|
| 116 |
+
if class_names is None or type(class_names) != list:
|
| 117 |
+
class_names = [str(i) for i in range(num_classes)]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
figsize[0] = 30 if figsize[0] > 30 else figsize[0]
|
| 121 |
+
figsize[1] = 20 if figsize[1] > 20 else figsize[1]
|
| 122 |
+
|
| 123 |
+
plt.clf()
|
| 124 |
+
plt.close("all")
|
| 125 |
+
figure = plt.figure(figsize=figsize)
|
| 126 |
+
plt.imshow(cmtx, interpolation="nearest",
|
| 127 |
+
cmap=cmap, vmin=vmin, vmax=vmax)
|
| 128 |
+
plt.title("Causal matrix")
|
| 129 |
+
plt.colorbar()
|
| 130 |
+
# tick_marks = np.arange(len(class_names))
|
| 131 |
+
# plt.xticks(tick_marks, class_names, rotation=45)
|
| 132 |
+
# plt.yticks(tick_marks, class_names)
|
| 133 |
+
|
| 134 |
+
# Use white text if squares are dark; otherwise black.
|
| 135 |
+
threshold = cmtx.max() / 2.0
|
| 136 |
+
for i, j in itertools.product(range(cmtx.shape[0]), range(cmtx.shape[1])):
|
| 137 |
+
color = "white" if cmtx[i, j] < threshold else "black"
|
| 138 |
+
if cmtx.shape[0] < 20 and show_text:
|
| 139 |
+
plt.text(j, i, format(cmtx[i, j], ".2e") if cmtx[i, j] != 0 else ".",
|
| 140 |
+
horizontalalignment="center", color=color,)
|
| 141 |
+
|
| 142 |
+
plt.tight_layout()
|
| 143 |
+
plt.ylabel("True label")
|
| 144 |
+
plt.xlabel("Predicted label")
|
| 145 |
+
|
| 146 |
+
return figure
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def reproduc(seed, benchmark=False, deterministic=True):
|
| 150 |
+
"""Make experiments reproducible
|
| 151 |
+
"""
|
| 152 |
+
random.seed(seed)
|
| 153 |
+
np.random.seed(seed)
|
| 154 |
+
torch.manual_seed(seed)
|
| 155 |
+
torch.cuda.manual_seed_all(seed)
|
| 156 |
+
torch.backends.cudnn.benchmark = benchmark
|
| 157 |
+
torch.backends.cudnn.deterministic = deterministic
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def omegaconf2list(opt, prefix='', sep='.'):
|
| 161 |
+
notation_list = []
|
| 162 |
+
for k, v in opt.items():
|
| 163 |
+
k = str(k)
|
| 164 |
+
if isinstance(v, omegaconf.listconfig.ListConfig):
|
| 165 |
+
notation_list.append("{}{}={}".format(prefix, k, v))
|
| 166 |
+
# if k in ['iter_list','step_list']: # do not sparse list
|
| 167 |
+
# dot_notation_list.append("{}{}={}".format(prefix,k,v))
|
| 168 |
+
# else:
|
| 169 |
+
# templist = []
|
| 170 |
+
# for v_ in v:
|
| 171 |
+
# templist.append('{}{}={}'.format(prefix,k,v_))
|
| 172 |
+
# dot_notation_list.append(templist)
|
| 173 |
+
elif isinstance(v, (float, str, int,)):
|
| 174 |
+
notation_list.append("{}{}={}".format(prefix, k, v))
|
| 175 |
+
elif v is None:
|
| 176 |
+
notation_list.append("{}{}=~".format(prefix, k,))
|
| 177 |
+
elif isinstance(v, omegaconf.dictconfig.DictConfig):
|
| 178 |
+
nested_flat_list = omegaconf2list(v, prefix + k + sep, sep=sep)
|
| 179 |
+
if nested_flat_list:
|
| 180 |
+
notation_list.extend(nested_flat_list)
|
| 181 |
+
else:
|
| 182 |
+
raise NotImplementedError
|
| 183 |
+
return notation_list
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def omegaconf2dotlist(opt, prefix='',):
|
| 187 |
+
return omegaconf2list(opt, prefix, sep='.')
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def omegaconf2dict(opt, sep):
|
| 191 |
+
notation_list = omegaconf2list(opt, sep=sep)
|
| 192 |
+
dict = {notation.split('=', maxsplit=1)[0]: notation.split(
|
| 193 |
+
'=', maxsplit=1)[1] for notation in notation_list}
|
| 194 |
+
return dict
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# def read_video(video_path: str):
|
| 198 |
+
# if ops(video_path)[-1] == ".tif":
|
| 199 |
+
# data = tifffile.imread(video_path)
|
| 200 |
+
# data = (data / np.max(data) * 255).astype(np.uint8)
|
| 201 |
+
# if len(data.shape) == 3:
|
| 202 |
+
# data = data[:, :, :, None]
|
| 203 |
+
# return data
|
| 204 |
+
# else:
|
| 205 |
+
# cap = cv2.VideoCapture(video_path)
|
| 206 |
+
# frames = []
|
| 207 |
+
# while cap.isOpened():
|
| 208 |
+
# # get a frame
|
| 209 |
+
# ret, frame = cap.read()
|
| 210 |
+
# if not ret:
|
| 211 |
+
# break
|
| 212 |
+
# frames.append(np.array(frame)[None])
|
| 213 |
+
|
| 214 |
+
# cap.release()
|
| 215 |
+
# return np.concatenate(frames, axis=0)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# def save_video(video_path: str, data):
|
| 219 |
+
# skvideo.io.vwrite(video_path, data)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
class LabelArray(object):
|
| 224 |
+
# def __init__(self, array, labels):
|
| 225 |
+
# self.arr = array
|
| 226 |
+
# self.labels = labels
|
| 227 |
+
# self.marks = dim_marks
|
| 228 |
+
# assert [len[label_list] for label_list in labels] == self.arr.shape
|
| 229 |
+
|
| 230 |
+
def __init__(self, dim, labels=None):
|
| 231 |
+
if labels is not None:
|
| 232 |
+
if len(dim) != dim:
|
| 233 |
+
raise "The length of labels has to be equal to dim if defined"
|
| 234 |
+
else:
|
| 235 |
+
self.labels = deepcopy(labels)
|
| 236 |
+
else:
|
| 237 |
+
self.labels = [[] for _ in range(dim)]
|
| 238 |
+
self.arr = None
|
| 239 |
+
self.update_arr()
|
| 240 |
+
|
| 241 |
+
def update_arr(self):
|
| 242 |
+
if self.arr is not None:
|
| 243 |
+
oldarr = self.arr
|
| 244 |
+
self.arr = np.zeros([len(dim) for dim in self.labels]) * np.nan
|
| 245 |
+
self.arr[tuple([slice(0,sh_dim,1) for sh_dim in oldarr.shape])] = oldarr
|
| 246 |
+
else:
|
| 247 |
+
self.arr = np.zeros([len(dim) for dim in self.labels]) * np.nan
|
| 248 |
+
self.shape = self.arr.shape
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def __getitem__(self, label_list):
|
| 252 |
+
index_list = []
|
| 253 |
+
for dim,label in enumerate(label_list):
|
| 254 |
+
if isinstance(label, str):
|
| 255 |
+
index_list.append(self.labels[dim].index(label))
|
| 256 |
+
elif isinstance(label, slice):
|
| 257 |
+
index_list.append(label)
|
| 258 |
+
elif isinstance(label, int):
|
| 259 |
+
index_list.append(label)
|
| 260 |
+
else:
|
| 261 |
+
raise NotImplementedError
|
| 262 |
+
|
| 263 |
+
return self.arr[tuple(index_list)]
|
| 264 |
+
|
| 265 |
+
def __setitem__(self, label_list, val):
|
| 266 |
+
index_list = []
|
| 267 |
+
for dim,label in enumerate(label_list):
|
| 268 |
+
if isinstance(label, str):
|
| 269 |
+
if not label in self.labels[dim]:
|
| 270 |
+
self.labels[dim].append(label)
|
| 271 |
+
self.update_arr()
|
| 272 |
+
index_list.append(self.labels[dim].index(label))
|
| 273 |
+
elif isinstance(label, slice):
|
| 274 |
+
index_list.append(label)
|
| 275 |
+
elif isinstance(label, int):
|
| 276 |
+
index_list.append(label)
|
| 277 |
+
else:
|
| 278 |
+
raise NotImplementedError
|
| 279 |
+
|
| 280 |
+
self.arr[tuple(index_list)] = val
|
| 281 |
+
|
| 282 |
+
def __str__(self):
|
| 283 |
+
return str(self.arr) + "\n--------------------------\n" + str(self.labels)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def to_np(self):
|
| 287 |
+
return self.arr
|
| 288 |
+
|
| 289 |
+
def from_np(self, array):
|
| 290 |
+
assert [len[label_list] for label_list in self.labels] == self.arr.shape
|
| 291 |
+
self.arr = array
|
| 292 |
+
|
singular_ticker_causal/algorithms/CUTS_PLUS/utils/opt_type.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
@dataclass
|
| 5 |
+
class ReproducOpt:
|
| 6 |
+
seed: int
|
| 7 |
+
benchmark: bool
|
| 8 |
+
deterministic: bool
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class NetworkOpt:
|
| 12 |
+
name: str
|
| 13 |
+
network_param: Any
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class TrainOpt:
|
| 17 |
+
batch_size: int
|
| 18 |
+
total_epoch: int
|
| 19 |
+
time_window: int
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class TsGAEopt:
|
| 24 |
+
dir_name: str
|
| 25 |
+
task_name: str
|
| 26 |
+
optimizer: Any
|
| 27 |
+
reproduc: ReproducOpt
|
| 28 |
+
network: NetworkOpt
|
| 29 |
+
train: TrainOpt
|
| 30 |
+
log: Any
|
| 31 |
+
causal_thres: str
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class MultiCADopt:
|
| 35 |
+
dir_name: str
|
| 36 |
+
task_name: str
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class MultiCADargs:
|
| 40 |
+
n_nodes: int
|
| 41 |
+
input_step: int
|
| 42 |
+
window_step: int
|
| 43 |
+
stride: int
|
| 44 |
+
batch_size: int
|
| 45 |
+
sample_per_epoch: int
|
| 46 |
+
data_dim: int
|
| 47 |
+
total_epoch: int
|
| 48 |
+
|
| 49 |
+
patience: int
|
| 50 |
+
warmup: Any
|
| 51 |
+
|
| 52 |
+
show_graph_every: int
|
| 53 |
+
val_every: int
|
| 54 |
+
|
| 55 |
+
n_groups: int
|
| 56 |
+
group_policy: Any
|
| 57 |
+
causal_thres: str
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class data_pred:
|
| 61 |
+
model: str
|
| 62 |
+
merge_policy: str
|
| 63 |
+
lr_data_start: float
|
| 64 |
+
lr_data_end: float
|
| 65 |
+
weight_decay: int
|
| 66 |
+
prob: bool
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class graph_discov:
|
| 70 |
+
lr_graph_start: float
|
| 71 |
+
lr_graph_end: float
|
| 72 |
+
lambda_s_start: float
|
| 73 |
+
lambda_s_end: float
|
| 74 |
+
tau_start: float
|
| 75 |
+
tau_end: float
|
| 76 |
+
disable_bwd: bool
|
| 77 |
+
separate_bwd: bool
|
| 78 |
+
disable_ind: bool
|
| 79 |
+
disable_graph: bool
|
| 80 |
+
use_true_graph: bool
|
| 81 |
+
|
| 82 |
+
reproduc: ReproducOpt
|
| 83 |
+
log: Any
|
singular_ticker_causal/algorithms/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CAMEF algorithms subpackage — GPT4MTS and dataloader have been retired.
|
| 2 |
+
# This package is intentionally empty pending removal of the CAMEF directory.
|
singular_ticker_causal/causal_inference/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .causal_model import StructuralCausalModel
|
| 2 |
+
from .identification import IdentificationStrategy, find_adjustment_set, is_identifiable
|
| 3 |
+
from .pywhyllm_assumptions import CausalAssumptionReport, PyWhyLLMConfig, PyWhyLLMAssumptionService
|
| 4 |
+
from .query_engine import CausalQueryEngine
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"CausalAssumptionReport",
|
| 8 |
+
"CausalQueryEngine",
|
| 9 |
+
"IdentificationStrategy",
|
| 10 |
+
"PyWhyLLMConfig",
|
| 11 |
+
"PyWhyLLMAssumptionService",
|
| 12 |
+
"StructuralCausalModel",
|
| 13 |
+
"find_adjustment_set",
|
| 14 |
+
"is_identifiable",
|
| 15 |
+
]
|
singular_ticker_causal/causal_inference/abduction.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Dict
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from .causal_model import StructuralCausalModel
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def abduct_noise(scm: StructuralCausalModel, observed_values: Dict[str, float]) -> Dict[str, float]:
|
| 11 |
+
noise: Dict[str, float] = {}
|
| 12 |
+
for node_idx in scm.topological_indices:
|
| 13 |
+
node = scm.nodes[node_idx]
|
| 14 |
+
if node not in observed_values:
|
| 15 |
+
continue
|
| 16 |
+
obs = float(observed_values[node])
|
| 17 |
+
eq = scm.equations[node]
|
| 18 |
+
if not eq.parents or eq.equation_type == "exogenous":
|
| 19 |
+
noise[node] = obs - eq.intercept
|
| 20 |
+
continue
|
| 21 |
+
parent_vals = []
|
| 22 |
+
for p in eq.parents:
|
| 23 |
+
if p not in observed_values:
|
| 24 |
+
parent_vals = []
|
| 25 |
+
break
|
| 26 |
+
parent_vals.append(float(observed_values[p]))
|
| 27 |
+
if not parent_vals:
|
| 28 |
+
continue
|
| 29 |
+
coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
|
| 30 |
+
pred = eq.intercept + float(np.array(parent_vals, dtype=float) @ coef_vec)
|
| 31 |
+
noise[node] = obs - pred
|
| 32 |
+
return noise
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def counterfactual_predict(
|
| 36 |
+
scm: StructuralCausalModel,
|
| 37 |
+
observed_values: Dict[str, float],
|
| 38 |
+
treatment: str,
|
| 39 |
+
counterfactual_value: float,
|
| 40 |
+
target: str,
|
| 41 |
+
) -> Dict[str, float]:
|
| 42 |
+
if treatment not in scm.node_to_idx:
|
| 43 |
+
raise ValueError(f"Unknown treatment node: {treatment}")
|
| 44 |
+
if target not in scm.node_to_idx:
|
| 45 |
+
raise ValueError(f"Unknown target node: {target}")
|
| 46 |
+
|
| 47 |
+
noises = abduct_noise(scm, observed_values)
|
| 48 |
+
state = scm.data_level[-1].copy()
|
| 49 |
+
for node, value in observed_values.items():
|
| 50 |
+
if node in scm.node_to_idx:
|
| 51 |
+
state[scm.node_to_idx[node]] = float(value)
|
| 52 |
+
|
| 53 |
+
cf = state.copy()
|
| 54 |
+
t_idx = scm.node_to_idx[treatment]
|
| 55 |
+
cf[t_idx] = float(counterfactual_value)
|
| 56 |
+
|
| 57 |
+
for node_idx in scm.topological_indices:
|
| 58 |
+
node = scm.nodes[node_idx]
|
| 59 |
+
if node_idx == t_idx:
|
| 60 |
+
continue
|
| 61 |
+
eq = scm.equations[node]
|
| 62 |
+
if not eq.parents or eq.equation_type == "exogenous":
|
| 63 |
+
cf[node_idx] = eq.intercept + noises.get(node, 0.0)
|
| 64 |
+
continue
|
| 65 |
+
parent_vals = cf[eq.parent_indices]
|
| 66 |
+
coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
|
| 67 |
+
pred = eq.intercept + float(parent_vals @ coef_vec)
|
| 68 |
+
cf[node_idx] = pred + noises.get(node, 0.0)
|
| 69 |
+
|
| 70 |
+
y_idx = scm.node_to_idx[target]
|
| 71 |
+
factual = float(state[y_idx])
|
| 72 |
+
counterfactual = float(cf[y_idx])
|
| 73 |
+
ite = counterfactual - factual
|
| 74 |
+
pct_change = ite / (abs(factual) + 1e-12)
|
| 75 |
+
return {
|
| 76 |
+
"factual_outcome": factual,
|
| 77 |
+
"counterfactual_outcome": counterfactual,
|
| 78 |
+
"ite": float(ite),
|
| 79 |
+
"pct_change": float(pct_change),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def counterfactual_predict_multi(
|
| 84 |
+
scm: StructuralCausalModel,
|
| 85 |
+
observed_values: Dict[str, float],
|
| 86 |
+
counterfactual_values: Dict[str, float],
|
| 87 |
+
target: str,
|
| 88 |
+
) -> Dict[str, float]:
|
| 89 |
+
"""Compute a joint counterfactual for multiple simultaneous interventions."""
|
| 90 |
+
for node in counterfactual_values:
|
| 91 |
+
if node not in scm.node_to_idx:
|
| 92 |
+
raise ValueError(f"Unknown counterfactual node: {node}")
|
| 93 |
+
|
| 94 |
+
noises = abduct_noise(scm, observed_values)
|
| 95 |
+
state = scm.data_level[-1].copy()
|
| 96 |
+
for node, value in observed_values.items():
|
| 97 |
+
if node in scm.node_to_idx:
|
| 98 |
+
state[scm.node_to_idx[node]] = float(value)
|
| 99 |
+
|
| 100 |
+
cf = state.copy()
|
| 101 |
+
for node, value in counterfactual_values.items():
|
| 102 |
+
cf[scm.node_to_idx[node]] = float(value)
|
| 103 |
+
|
| 104 |
+
for node_idx in scm.topological_indices:
|
| 105 |
+
node = scm.nodes[node_idx]
|
| 106 |
+
if node in counterfactual_values:
|
| 107 |
+
continue
|
| 108 |
+
eq = scm.equations[node]
|
| 109 |
+
if not eq.parents or eq.equation_type == "exogenous":
|
| 110 |
+
cf[node_idx] = eq.intercept + noises.get(node, 0.0)
|
| 111 |
+
continue
|
| 112 |
+
parent_vals = cf[eq.parent_indices]
|
| 113 |
+
coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
|
| 114 |
+
pred = eq.intercept + float(parent_vals @ coef_vec)
|
| 115 |
+
cf[node_idx] = pred + noises.get(node, 0.0)
|
| 116 |
+
|
| 117 |
+
y_idx = scm.node_to_idx[target]
|
| 118 |
+
factual = float(state[y_idx])
|
| 119 |
+
counterfactual = float(cf[y_idx])
|
| 120 |
+
ite = counterfactual - factual
|
| 121 |
+
pct_change = ite / (abs(factual) + 1e-12)
|
| 122 |
+
return {
|
| 123 |
+
"factual_outcome": factual,
|
| 124 |
+
"counterfactual_outcome": counterfactual,
|
| 125 |
+
"ite": float(ite),
|
| 126 |
+
"pct_change": float(pct_change),
|
| 127 |
+
}
|
singular_ticker_causal/causal_inference/causal_model.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Dict, List, Optional, Sequence, Tuple
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class StructuralEquation:
|
| 11 |
+
node: str
|
| 12 |
+
parents: List[str]
|
| 13 |
+
parent_indices: List[int]
|
| 14 |
+
intercept: float
|
| 15 |
+
coefficients: Dict[str, float]
|
| 16 |
+
residual_mean: float
|
| 17 |
+
residual_std: float
|
| 18 |
+
r_squared: float
|
| 19 |
+
n_obs: int
|
| 20 |
+
equation_type: str # "linear" | "exogenous"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class StructuralCausalModel:
|
| 25 |
+
nodes: Sequence[str]
|
| 26 |
+
adj: np.ndarray
|
| 27 |
+
adjacency_mask: np.ndarray
|
| 28 |
+
data_tech: np.ndarray
|
| 29 |
+
mask_tech: Optional[np.ndarray] = None
|
| 30 |
+
prohibition_mask: Optional[np.ndarray] = None
|
| 31 |
+
threshold: float = 0.5
|
| 32 |
+
lag: int = 1
|
| 33 |
+
min_obs: int = 12
|
| 34 |
+
ridge_alpha: float = 1e-4
|
| 35 |
+
dag_adj: np.ndarray = field(init=False)
|
| 36 |
+
topological_indices: List[int] = field(init=False, default_factory=list)
|
| 37 |
+
equations: Dict[str, StructuralEquation] = field(init=False, default_factory=dict)
|
| 38 |
+
removed_cycle_edges: List[Dict[str, float]] = field(init=False, default_factory=list)
|
| 39 |
+
|
| 40 |
+
def __post_init__(self) -> None:
|
| 41 |
+
self.nodes = list(self.nodes)
|
| 42 |
+
self.node_to_idx = {node: i for i, node in enumerate(self.nodes)}
|
| 43 |
+
self.data_level = self._extract_level_data(self.data_tech)
|
| 44 |
+
self.mask_level = self._extract_mask_level(self.mask_tech)
|
| 45 |
+
self._validate_shapes()
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def n_nodes(self) -> int:
|
| 49 |
+
return len(self.nodes)
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def t_steps(self) -> int:
|
| 53 |
+
return self.data_level.shape[0]
|
| 54 |
+
|
| 55 |
+
def fit(self) -> "StructuralCausalModel":
|
| 56 |
+
self.dag_adj = self._build_dag()
|
| 57 |
+
self._enforce_acyclic()
|
| 58 |
+
self.topological_indices = self._topological_sort(self.dag_adj)
|
| 59 |
+
self._fit_equations()
|
| 60 |
+
return self
|
| 61 |
+
|
| 62 |
+
def density(self) -> float:
|
| 63 |
+
n = self.n_nodes
|
| 64 |
+
max_edges = n * (n - 1)
|
| 65 |
+
if max_edges == 0:
|
| 66 |
+
return 0.0
|
| 67 |
+
return float(np.sum(self.dag_adj) / max_edges)
|
| 68 |
+
|
| 69 |
+
def parents_of(self, node: str) -> List[str]:
|
| 70 |
+
j = self.node_to_idx[node]
|
| 71 |
+
return [self.nodes[i] for i in np.where(self.dag_adj[:, j])[0]]
|
| 72 |
+
|
| 73 |
+
def has_directed_path(self, source: str, target: str) -> bool:
|
| 74 |
+
s = self.node_to_idx[source]
|
| 75 |
+
t = self.node_to_idx[target]
|
| 76 |
+
stack = [s]
|
| 77 |
+
visited = set()
|
| 78 |
+
while stack:
|
| 79 |
+
u = stack.pop()
|
| 80 |
+
if u == t:
|
| 81 |
+
return True
|
| 82 |
+
if u in visited:
|
| 83 |
+
continue
|
| 84 |
+
visited.add(u)
|
| 85 |
+
children = np.where(self.dag_adj[u])[0].tolist()
|
| 86 |
+
stack.extend(children)
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
def descendants_of(self, node: str) -> List[str]:
|
| 90 |
+
start = self.node_to_idx[node]
|
| 91 |
+
stack = [start]
|
| 92 |
+
visited = set()
|
| 93 |
+
while stack:
|
| 94 |
+
u = stack.pop()
|
| 95 |
+
children = np.where(self.dag_adj[u])[0].tolist()
|
| 96 |
+
for v in children:
|
| 97 |
+
if v not in visited:
|
| 98 |
+
visited.add(v)
|
| 99 |
+
stack.append(v)
|
| 100 |
+
visited.discard(start)
|
| 101 |
+
return [self.nodes[i] for i in sorted(visited)]
|
| 102 |
+
|
| 103 |
+
def _validate_shapes(self) -> None:
|
| 104 |
+
n = len(self.nodes)
|
| 105 |
+
if self.adj.shape != (n, n):
|
| 106 |
+
raise ValueError(f"adj shape mismatch: expected {(n, n)}, got {self.adj.shape}")
|
| 107 |
+
if self.adjacency_mask.shape != (n, n):
|
| 108 |
+
raise ValueError(
|
| 109 |
+
f"adjacency_mask shape mismatch: expected {(n, n)}, got {self.adjacency_mask.shape}"
|
| 110 |
+
)
|
| 111 |
+
if self.prohibition_mask is not None and self.prohibition_mask.shape != (n, n):
|
| 112 |
+
raise ValueError(
|
| 113 |
+
f"prohibition_mask shape mismatch: expected {(n, n)}, got {self.prohibition_mask.shape}"
|
| 114 |
+
)
|
| 115 |
+
if self.data_level.ndim != 2 or self.data_level.shape[1] != n:
|
| 116 |
+
raise ValueError(
|
| 117 |
+
f"data_level shape mismatch: expected (T, {n}), got {self.data_level.shape}"
|
| 118 |
+
)
|
| 119 |
+
if self.mask_level is not None and self.mask_level.shape != self.data_level.shape:
|
| 120 |
+
raise ValueError(
|
| 121 |
+
"mask_tech shape mismatch after extraction: expected shape "
|
| 122 |
+
f"{self.data_level.shape}, got {self.mask_level.shape}"
|
| 123 |
+
)
|
| 124 |
+
if self.lag < 1:
|
| 125 |
+
raise ValueError("lag must be >= 1")
|
| 126 |
+
|
| 127 |
+
def _extract_level_data(self, data_tech: np.ndarray) -> np.ndarray:
|
| 128 |
+
if data_tech.ndim == 3:
|
| 129 |
+
return np.asarray(data_tech[:, :, 0], dtype=float)
|
| 130 |
+
if data_tech.ndim == 2:
|
| 131 |
+
return np.asarray(data_tech, dtype=float)
|
| 132 |
+
raise ValueError(f"Unsupported data_tech ndim={data_tech.ndim}; expected 2 or 3.")
|
| 133 |
+
|
| 134 |
+
def _extract_mask_level(self, mask_tech: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
| 135 |
+
if mask_tech is None:
|
| 136 |
+
return None
|
| 137 |
+
if mask_tech.ndim == 3:
|
| 138 |
+
return np.asarray(mask_tech[:, :, 0], dtype=float)
|
| 139 |
+
if mask_tech.ndim == 2:
|
| 140 |
+
return np.asarray(mask_tech, dtype=float)
|
| 141 |
+
raise ValueError(f"Unsupported mask_tech ndim={mask_tech.ndim}; expected 2 or 3.")
|
| 142 |
+
|
| 143 |
+
def _build_dag(self) -> np.ndarray:
|
| 144 |
+
cuts_edges = self.adj >= self.threshold
|
| 145 |
+
prior_edges = self.adjacency_mask > 0
|
| 146 |
+
dag = np.logical_or(cuts_edges, prior_edges)
|
| 147 |
+
if self.prohibition_mask is not None:
|
| 148 |
+
prohibited = self.prohibition_mask <= 0
|
| 149 |
+
dag = np.where(prohibited, False, dag)
|
| 150 |
+
np.fill_diagonal(dag, False)
|
| 151 |
+
return dag.astype(bool)
|
| 152 |
+
|
| 153 |
+
def _enforce_acyclic(self) -> None:
|
| 154 |
+
while True:
|
| 155 |
+
cycle_edges = self._find_cycle_edges(self.dag_adj)
|
| 156 |
+
if not cycle_edges:
|
| 157 |
+
return
|
| 158 |
+
|
| 159 |
+
removable = []
|
| 160 |
+
for src, dst in cycle_edges:
|
| 161 |
+
mandatory = bool(self.adjacency_mask[src, dst] > 0)
|
| 162 |
+
score = float(self.adj[src, dst])
|
| 163 |
+
removable.append((mandatory, score, src, dst))
|
| 164 |
+
|
| 165 |
+
non_mandatory = [r for r in removable if not r[0]]
|
| 166 |
+
choice = min(non_mandatory or removable, key=lambda x: x[1])
|
| 167 |
+
_, score, src, dst = choice
|
| 168 |
+
|
| 169 |
+
self.dag_adj[src, dst] = False
|
| 170 |
+
self.removed_cycle_edges.append(
|
| 171 |
+
{
|
| 172 |
+
"source": self.nodes[src],
|
| 173 |
+
"target": self.nodes[dst],
|
| 174 |
+
"adj_score": score,
|
| 175 |
+
}
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
def _topological_sort(self, dag_adj: np.ndarray) -> List[int]:
|
| 179 |
+
n = dag_adj.shape[0]
|
| 180 |
+
indegree = np.sum(dag_adj, axis=0).astype(int)
|
| 181 |
+
queue = [i for i in range(n) if indegree[i] == 0]
|
| 182 |
+
order: List[int] = []
|
| 183 |
+
|
| 184 |
+
while queue:
|
| 185 |
+
node = queue.pop(0)
|
| 186 |
+
order.append(node)
|
| 187 |
+
children = np.where(dag_adj[node])[0]
|
| 188 |
+
for child in children:
|
| 189 |
+
indegree[child] -= 1
|
| 190 |
+
if indegree[child] == 0:
|
| 191 |
+
queue.append(int(child))
|
| 192 |
+
|
| 193 |
+
if len(order) != n:
|
| 194 |
+
raise RuntimeError("DAG still contains a cycle after pruning.")
|
| 195 |
+
return order
|
| 196 |
+
|
| 197 |
+
def _find_cycle_edges(self, dag_adj: np.ndarray) -> List[Tuple[int, int]]:
|
| 198 |
+
n = dag_adj.shape[0]
|
| 199 |
+
state = np.zeros(n, dtype=int) # 0=unvisited, 1=visiting, 2=done
|
| 200 |
+
parent = -np.ones(n, dtype=int)
|
| 201 |
+
|
| 202 |
+
def dfs(u: int) -> Optional[List[Tuple[int, int]]]:
|
| 203 |
+
state[u] = 1
|
| 204 |
+
for v in np.where(dag_adj[u])[0]:
|
| 205 |
+
v = int(v)
|
| 206 |
+
if state[v] == 0:
|
| 207 |
+
parent[v] = u
|
| 208 |
+
found = dfs(v)
|
| 209 |
+
if found:
|
| 210 |
+
return found
|
| 211 |
+
elif state[v] == 1:
|
| 212 |
+
nodes = [v]
|
| 213 |
+
cur = u
|
| 214 |
+
while cur != v and cur != -1:
|
| 215 |
+
nodes.append(cur)
|
| 216 |
+
cur = int(parent[cur])
|
| 217 |
+
nodes.append(v)
|
| 218 |
+
nodes.reverse()
|
| 219 |
+
return [(nodes[i], nodes[i + 1]) for i in range(len(nodes) - 1)]
|
| 220 |
+
state[u] = 2
|
| 221 |
+
return None
|
| 222 |
+
|
| 223 |
+
for start in range(n):
|
| 224 |
+
if state[start] == 0:
|
| 225 |
+
result = dfs(start)
|
| 226 |
+
if result:
|
| 227 |
+
return result
|
| 228 |
+
return []
|
| 229 |
+
|
| 230 |
+
def _fit_equations(self) -> None:
|
| 231 |
+
T = self.t_steps
|
| 232 |
+
for idx in self.topological_indices:
|
| 233 |
+
node = self.nodes[idx]
|
| 234 |
+
parent_idx = [int(i) for i in np.where(self.dag_adj[:, idx])[0]]
|
| 235 |
+
parent_names = [self.nodes[i] for i in parent_idx]
|
| 236 |
+
valid_t = self._valid_timesteps(idx, parent_idx)
|
| 237 |
+
|
| 238 |
+
if len(valid_t) == 0:
|
| 239 |
+
self.equations[node] = StructuralEquation(
|
| 240 |
+
node=node,
|
| 241 |
+
parents=parent_names,
|
| 242 |
+
parent_indices=parent_idx,
|
| 243 |
+
intercept=0.0,
|
| 244 |
+
coefficients={},
|
| 245 |
+
residual_mean=0.0,
|
| 246 |
+
residual_std=1.0,
|
| 247 |
+
r_squared=0.0,
|
| 248 |
+
n_obs=0,
|
| 249 |
+
equation_type="exogenous",
|
| 250 |
+
)
|
| 251 |
+
continue
|
| 252 |
+
|
| 253 |
+
y = self.data_level[valid_t, idx]
|
| 254 |
+
|
| 255 |
+
if not parent_idx or len(valid_t) < self.min_obs:
|
| 256 |
+
mu = float(np.mean(y))
|
| 257 |
+
residuals = y - mu
|
| 258 |
+
self.equations[node] = StructuralEquation(
|
| 259 |
+
node=node,
|
| 260 |
+
parents=parent_names,
|
| 261 |
+
parent_indices=parent_idx,
|
| 262 |
+
intercept=mu,
|
| 263 |
+
coefficients={},
|
| 264 |
+
residual_mean=float(np.mean(residuals)) if residuals.size else 0.0,
|
| 265 |
+
residual_std=float(np.std(residuals)) if residuals.size else 1.0,
|
| 266 |
+
r_squared=0.0,
|
| 267 |
+
n_obs=int(len(valid_t)),
|
| 268 |
+
equation_type="exogenous",
|
| 269 |
+
)
|
| 270 |
+
continue
|
| 271 |
+
|
| 272 |
+
X = self.data_level[valid_t - self.lag][:, parent_idx]
|
| 273 |
+
coef, intercept = self._fit_ridge(X, y)
|
| 274 |
+
y_hat = intercept + (X @ coef)
|
| 275 |
+
residuals = y - y_hat
|
| 276 |
+
ss_res = float(np.sum(residuals ** 2))
|
| 277 |
+
ss_tot = float(np.sum((y - np.mean(y)) ** 2))
|
| 278 |
+
r2 = 1.0 - (ss_res / ss_tot) if ss_tot > 1e-12 else 0.0
|
| 279 |
+
|
| 280 |
+
coeffs = {name: float(coef[i]) for i, name in enumerate(parent_names)}
|
| 281 |
+
self.equations[node] = StructuralEquation(
|
| 282 |
+
node=node,
|
| 283 |
+
parents=parent_names,
|
| 284 |
+
parent_indices=parent_idx,
|
| 285 |
+
intercept=float(intercept),
|
| 286 |
+
coefficients=coeffs,
|
| 287 |
+
residual_mean=float(np.mean(residuals)),
|
| 288 |
+
residual_std=float(np.std(residuals)),
|
| 289 |
+
r_squared=r2,
|
| 290 |
+
n_obs=int(len(valid_t)),
|
| 291 |
+
equation_type="linear",
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
def _valid_timesteps(self, node_idx: int, parent_indices: List[int]) -> np.ndarray:
|
| 295 |
+
valid_t = np.arange(self.lag, self.t_steps, dtype=int)
|
| 296 |
+
if self.mask_level is None:
|
| 297 |
+
return valid_t
|
| 298 |
+
|
| 299 |
+
valid = self.mask_level[valid_t, node_idx] > 0
|
| 300 |
+
for p_idx in parent_indices:
|
| 301 |
+
valid &= self.mask_level[valid_t - self.lag, p_idx] > 0
|
| 302 |
+
return valid_t[valid]
|
| 303 |
+
|
| 304 |
+
def _fit_ridge(self, X: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, float]:
|
| 305 |
+
X_mean = np.mean(X, axis=0)
|
| 306 |
+
y_mean = float(np.mean(y))
|
| 307 |
+
Xc = X - X_mean
|
| 308 |
+
yc = y - y_mean
|
| 309 |
+
|
| 310 |
+
p = X.shape[1]
|
| 311 |
+
reg = self.ridge_alpha * np.eye(p)
|
| 312 |
+
beta = np.linalg.solve(Xc.T @ Xc + reg, Xc.T @ yc)
|
| 313 |
+
intercept = y_mean - float(X_mean @ beta)
|
| 314 |
+
return beta, intercept
|
singular_ticker_causal/causal_inference/estimator.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Dict, Optional, Set
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from .causal_model import StructuralCausalModel
|
| 8 |
+
from .mutilator import propagate_intervention
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def estimate_ate(
|
| 12 |
+
scm: StructuralCausalModel,
|
| 13 |
+
treatment: str,
|
| 14 |
+
outcome: str,
|
| 15 |
+
treatment_value: Optional[float] = None,
|
| 16 |
+
adjustment_set: Optional[Set[str]] = None,
|
| 17 |
+
horizon: int = 5,
|
| 18 |
+
) -> Dict[str, object]:
|
| 19 |
+
t_idx = scm.node_to_idx[treatment]
|
| 20 |
+
y_idx = scm.node_to_idx[outcome]
|
| 21 |
+
z_nodes = sorted(adjustment_set or set())
|
| 22 |
+
z_idx = [scm.node_to_idx[z] for z in z_nodes]
|
| 23 |
+
|
| 24 |
+
valid_t = np.arange(scm.lag, scm.t_steps, dtype=int)
|
| 25 |
+
if scm.mask_level is not None:
|
| 26 |
+
valid = (scm.mask_level[valid_t, y_idx] > 0) & (scm.mask_level[valid_t - scm.lag, t_idx] > 0)
|
| 27 |
+
for zi in z_idx:
|
| 28 |
+
valid &= scm.mask_level[valid_t - scm.lag, zi] > 0
|
| 29 |
+
valid_t = valid_t[valid]
|
| 30 |
+
|
| 31 |
+
if len(valid_t) < 5:
|
| 32 |
+
raise ValueError(
|
| 33 |
+
f"Insufficient observations for ATE estimation of {treatment}->{outcome}: {len(valid_t)} rows."
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
y = scm.data_level[valid_t, y_idx]
|
| 37 |
+
x_treat = scm.data_level[valid_t - scm.lag, t_idx]
|
| 38 |
+
X_parts = [np.ones((len(valid_t), 1)), x_treat.reshape(-1, 1)]
|
| 39 |
+
if z_idx:
|
| 40 |
+
X_parts.append(scm.data_level[valid_t - scm.lag][:, z_idx])
|
| 41 |
+
X = np.concatenate(X_parts, axis=1)
|
| 42 |
+
|
| 43 |
+
beta = np.linalg.pinv(X.T @ X) @ (X.T @ y)
|
| 44 |
+
y_hat = X @ beta
|
| 45 |
+
resid = y - y_hat
|
| 46 |
+
dof = max(1, len(y) - X.shape[1])
|
| 47 |
+
sigma2 = float(np.sum(resid ** 2) / dof)
|
| 48 |
+
cov = sigma2 * np.linalg.pinv(X.T @ X)
|
| 49 |
+
se = float(np.sqrt(max(cov[1, 1], 0.0)))
|
| 50 |
+
ate = float(beta[1])
|
| 51 |
+
|
| 52 |
+
baseline = float(np.mean(np.abs(y))) + 1e-12
|
| 53 |
+
ate_normalized = ate / baseline
|
| 54 |
+
ci_95 = (ate - 1.96 * se, ate + 1.96 * se)
|
| 55 |
+
|
| 56 |
+
path_contributions = _path_contributions(scm, treatment, outcome)
|
| 57 |
+
|
| 58 |
+
horizon_effects = []
|
| 59 |
+
if treatment_value is not None:
|
| 60 |
+
baseline_t = scm.t_steps - 1
|
| 61 |
+
shocked = propagate_intervention(scm, treatment, treatment_value, horizon=horizon, baseline_t=baseline_t)
|
| 62 |
+
base_outcome = float(scm.data_level[baseline_t, y_idx])
|
| 63 |
+
for h in range(horizon):
|
| 64 |
+
horizon_effects.append(float(shocked[h, y_idx] - base_outcome))
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"ate": ate,
|
| 68 |
+
"ate_normalized": float(ate_normalized),
|
| 69 |
+
"ci_95": (float(ci_95[0]), float(ci_95[1])),
|
| 70 |
+
"n_obs": int(len(valid_t)),
|
| 71 |
+
"path_contributions": path_contributions,
|
| 72 |
+
"horizon_effects": horizon_effects,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _path_contributions(
|
| 77 |
+
scm: StructuralCausalModel,
|
| 78 |
+
treatment: str,
|
| 79 |
+
outcome: str,
|
| 80 |
+
max_paths: int = 100,
|
| 81 |
+
) -> Dict[str, float]:
|
| 82 |
+
start = scm.node_to_idx[treatment]
|
| 83 |
+
target = scm.node_to_idx[outcome]
|
| 84 |
+
paths = []
|
| 85 |
+
|
| 86 |
+
def dfs(node: int, path: list[int], seen: set[int]) -> None:
|
| 87 |
+
if len(paths) >= max_paths:
|
| 88 |
+
return
|
| 89 |
+
if node == target:
|
| 90 |
+
paths.append(path.copy())
|
| 91 |
+
return
|
| 92 |
+
for child in np.where(scm.dag_adj[node])[0]:
|
| 93 |
+
child = int(child)
|
| 94 |
+
if child in seen:
|
| 95 |
+
continue
|
| 96 |
+
seen.add(child)
|
| 97 |
+
path.append(child)
|
| 98 |
+
dfs(child, path, seen)
|
| 99 |
+
path.pop()
|
| 100 |
+
seen.remove(child)
|
| 101 |
+
|
| 102 |
+
dfs(start, [start], {start})
|
| 103 |
+
|
| 104 |
+
contributions: Dict[str, float] = {}
|
| 105 |
+
for path in paths:
|
| 106 |
+
coeff_product = 1.0
|
| 107 |
+
valid = True
|
| 108 |
+
for i in range(len(path) - 1):
|
| 109 |
+
src = scm.nodes[path[i]]
|
| 110 |
+
dst = scm.nodes[path[i + 1]]
|
| 111 |
+
eq = scm.equations.get(dst)
|
| 112 |
+
if eq is None:
|
| 113 |
+
valid = False
|
| 114 |
+
break
|
| 115 |
+
coef = eq.coefficients.get(src)
|
| 116 |
+
if coef is None:
|
| 117 |
+
valid = False
|
| 118 |
+
break
|
| 119 |
+
coeff_product *= coef
|
| 120 |
+
if not valid:
|
| 121 |
+
continue
|
| 122 |
+
label = " -> ".join(scm.nodes[i] for i in path)
|
| 123 |
+
contributions[label] = float(coeff_product)
|
| 124 |
+
|
| 125 |
+
return contributions
|
singular_ticker_causal/causal_inference/identification.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from typing import Optional, Set, Tuple
|
| 5 |
+
|
| 6 |
+
from .causal_model import StructuralCausalModel
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class IdentificationStrategy(Enum):
|
| 10 |
+
DIRECT = "direct"
|
| 11 |
+
BACKDOOR = "backdoor"
|
| 12 |
+
NOT_IDENTIFIABLE = "not_identifiable"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def find_adjustment_set(
|
| 16 |
+
scm: StructuralCausalModel,
|
| 17 |
+
treatment: str,
|
| 18 |
+
outcome: str,
|
| 19 |
+
) -> Tuple[IdentificationStrategy, Optional[Set[str]]]:
|
| 20 |
+
if treatment not in scm.node_to_idx:
|
| 21 |
+
raise ValueError(f"Unknown treatment node: {treatment}")
|
| 22 |
+
if outcome not in scm.node_to_idx:
|
| 23 |
+
raise ValueError(f"Unknown outcome node: {outcome}")
|
| 24 |
+
if treatment == outcome:
|
| 25 |
+
return IdentificationStrategy.DIRECT, set()
|
| 26 |
+
|
| 27 |
+
if not scm.has_directed_path(treatment, outcome):
|
| 28 |
+
return IdentificationStrategy.NOT_IDENTIFIABLE, None
|
| 29 |
+
|
| 30 |
+
parents = set(scm.parents_of(treatment))
|
| 31 |
+
descendants = set(scm.descendants_of(treatment))
|
| 32 |
+
adjustment = parents - descendants - {outcome}
|
| 33 |
+
|
| 34 |
+
if adjustment:
|
| 35 |
+
return IdentificationStrategy.BACKDOOR, adjustment
|
| 36 |
+
return IdentificationStrategy.DIRECT, set()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def is_identifiable(scm: StructuralCausalModel, treatment: str, outcome: str) -> bool:
|
| 40 |
+
strategy, _ = find_adjustment_set(scm, treatment, outcome)
|
| 41 |
+
return strategy != IdentificationStrategy.NOT_IDENTIFIABLE
|
singular_ticker_causal/causal_inference/mutilator.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from copy import deepcopy
|
| 4 |
+
from typing import Dict, Optional, Sequence
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from .causal_model import StructuralCausalModel
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def mutilate_graph(scm: StructuralCausalModel, treatment: str, value: float) -> StructuralCausalModel:
|
| 12 |
+
mutilated = deepcopy(scm)
|
| 13 |
+
t_idx = mutilated.node_to_idx[treatment]
|
| 14 |
+
mutilated.dag_adj[:, t_idx] = False
|
| 15 |
+
mutilated.pinned_values = {treatment: float(value)}
|
| 16 |
+
return mutilated
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def propagate_intervention(
|
| 20 |
+
scm: StructuralCausalModel,
|
| 21 |
+
treatment: str,
|
| 22 |
+
value: float,
|
| 23 |
+
targets: Optional[Sequence[str]] = None,
|
| 24 |
+
horizon: int = 5,
|
| 25 |
+
baseline_t: int = -1,
|
| 26 |
+
) -> np.ndarray:
|
| 27 |
+
if horizon < 1:
|
| 28 |
+
raise ValueError("horizon must be >= 1")
|
| 29 |
+
|
| 30 |
+
baseline_idx = baseline_t if baseline_t >= 0 else (scm.t_steps + baseline_t)
|
| 31 |
+
if baseline_idx < 0 or baseline_idx >= scm.t_steps:
|
| 32 |
+
raise ValueError(f"baseline_t {baseline_t} resolves out of bounds for T={scm.t_steps}")
|
| 33 |
+
|
| 34 |
+
t_idx = scm.node_to_idx[treatment]
|
| 35 |
+
prev = scm.data_level[baseline_idx].copy()
|
| 36 |
+
forecasts = np.zeros((horizon, scm.n_nodes), dtype=float)
|
| 37 |
+
|
| 38 |
+
for h in range(horizon):
|
| 39 |
+
nxt = prev.copy()
|
| 40 |
+
nxt[t_idx] = float(value)
|
| 41 |
+
for node_idx in scm.topological_indices:
|
| 42 |
+
if node_idx == t_idx:
|
| 43 |
+
continue
|
| 44 |
+
node = scm.nodes[node_idx]
|
| 45 |
+
eq = scm.equations[node]
|
| 46 |
+
if not eq.parent_indices or eq.equation_type == "exogenous":
|
| 47 |
+
continue
|
| 48 |
+
parent_vals = prev[eq.parent_indices]
|
| 49 |
+
coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
|
| 50 |
+
nxt[node_idx] = eq.intercept + float(parent_vals @ coef_vec)
|
| 51 |
+
forecasts[h] = nxt
|
| 52 |
+
prev = nxt
|
| 53 |
+
|
| 54 |
+
if targets:
|
| 55 |
+
missing = [n for n in targets if n not in scm.node_to_idx]
|
| 56 |
+
if missing:
|
| 57 |
+
raise ValueError(f"Unknown targets: {missing}")
|
| 58 |
+
return forecasts
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def target_series(forecasts: np.ndarray, scm: StructuralCausalModel, targets: Sequence[str]) -> Dict[str, list[float]]:
|
| 62 |
+
return {t: [float(v) for v in forecasts[:, scm.node_to_idx[t]]] for t in targets}
|
singular_ticker_causal/causal_inference/pywhyllm_assumptions.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from dataclasses import asdict, dataclass, field
|
| 7 |
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _json_safe(value: Any) -> Any:
|
| 13 |
+
if isinstance(value, np.ndarray):
|
| 14 |
+
return value.tolist()
|
| 15 |
+
if isinstance(value, (np.integer,)):
|
| 16 |
+
return int(value)
|
| 17 |
+
if isinstance(value, (np.floating,)):
|
| 18 |
+
return float(value)
|
| 19 |
+
if isinstance(value, dict):
|
| 20 |
+
return {str(k): _json_safe(v) for k, v in value.items()}
|
| 21 |
+
if isinstance(value, (list, tuple, set)):
|
| 22 |
+
return [_json_safe(v) for v in value]
|
| 23 |
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
| 24 |
+
return value
|
| 25 |
+
return str(value)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class PyWhyLLMConfig:
|
| 30 |
+
enabled: bool = False
|
| 31 |
+
model: str = "gpt-4"
|
| 32 |
+
max_edges: int = 25
|
| 33 |
+
cache_dir: str = os.path.join(
|
| 34 |
+
os.path.dirname(__file__),
|
| 35 |
+
"..",
|
| 36 |
+
"debug_data",
|
| 37 |
+
"pywhyllm_cache",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class CausalAssumptionReport:
|
| 43 |
+
available: bool = True
|
| 44 |
+
reason: Optional[str] = None
|
| 45 |
+
domain_expertises: List[str] = field(default_factory=list)
|
| 46 |
+
suggested_confounders: List[str] = field(default_factory=list)
|
| 47 |
+
suggested_backdoor_sets: List[List[str]] = field(default_factory=list)
|
| 48 |
+
suggested_mediators: List[str] = field(default_factory=list)
|
| 49 |
+
suggested_ivs: List[str] = field(default_factory=list)
|
| 50 |
+
negative_controls: List[str] = field(default_factory=list)
|
| 51 |
+
latent_confounders: List[str] = field(default_factory=list)
|
| 52 |
+
edge_critiques: Any = field(default_factory=list)
|
| 53 |
+
accepted_edges: List[Tuple[str, str]] = field(default_factory=list)
|
| 54 |
+
rejected_edges: List[Tuple[str, str]] = field(default_factory=list)
|
| 55 |
+
warnings: List[str] = field(default_factory=list)
|
| 56 |
+
|
| 57 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 58 |
+
payload = asdict(self)
|
| 59 |
+
payload["accepted_edges"] = [list(edge) for edge in self.accepted_edges]
|
| 60 |
+
payload["rejected_edges"] = [list(edge) for edge in self.rejected_edges]
|
| 61 |
+
return _json_safe(payload)
|
| 62 |
+
|
| 63 |
+
@classmethod
|
| 64 |
+
def from_dict(cls, payload: Dict[str, Any]) -> "CausalAssumptionReport":
|
| 65 |
+
data = dict(payload)
|
| 66 |
+
data["accepted_edges"] = [tuple(edge) for edge in data.get("accepted_edges", [])]
|
| 67 |
+
data["rejected_edges"] = [tuple(edge) for edge in data.get("rejected_edges", [])]
|
| 68 |
+
return cls(**data)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _dedupe_strings(values: Iterable[Any]) -> List[str]:
|
| 72 |
+
seen = set()
|
| 73 |
+
result: List[str] = []
|
| 74 |
+
for value in values or []:
|
| 75 |
+
text = str(value).strip()
|
| 76 |
+
if text and text not in seen:
|
| 77 |
+
seen.add(text)
|
| 78 |
+
result.append(text)
|
| 79 |
+
return result
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _normalise_suggestion(value: Any) -> List[str]:
|
| 83 |
+
if value is None:
|
| 84 |
+
return []
|
| 85 |
+
if isinstance(value, tuple) and len(value) == 2:
|
| 86 |
+
return _normalise_suggestion(value[1])
|
| 87 |
+
if isinstance(value, dict):
|
| 88 |
+
keys = [k for k, v in value.items() if isinstance(k, str) and v]
|
| 89 |
+
if keys:
|
| 90 |
+
return _dedupe_strings(keys)
|
| 91 |
+
flattened: List[str] = []
|
| 92 |
+
for item in value.values():
|
| 93 |
+
flattened.extend(_normalise_suggestion(item))
|
| 94 |
+
return _dedupe_strings(flattened)
|
| 95 |
+
if isinstance(value, (list, set, tuple)):
|
| 96 |
+
flattened = []
|
| 97 |
+
for item in value:
|
| 98 |
+
if isinstance(item, (list, set, tuple, dict)):
|
| 99 |
+
flattened.extend(_normalise_suggestion(item))
|
| 100 |
+
else:
|
| 101 |
+
flattened.append(item)
|
| 102 |
+
return _dedupe_strings(flattened)
|
| 103 |
+
return _dedupe_strings([value])
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class PyWhyLLMAssumptionService:
|
| 107 |
+
def __init__(
|
| 108 |
+
self,
|
| 109 |
+
config: Optional[PyWhyLLMConfig] = None,
|
| 110 |
+
model_suggester: Any = None,
|
| 111 |
+
identification_suggester: Any = None,
|
| 112 |
+
validation_suggester: Any = None,
|
| 113 |
+
relationship_strategy: Any = None,
|
| 114 |
+
):
|
| 115 |
+
self.config = config or PyWhyLLMConfig()
|
| 116 |
+
self._model_suggester = model_suggester
|
| 117 |
+
self._identification_suggester = identification_suggester
|
| 118 |
+
self._validation_suggester = validation_suggester
|
| 119 |
+
self._relationship_strategy = relationship_strategy
|
| 120 |
+
|
| 121 |
+
@property
|
| 122 |
+
def enabled(self) -> bool:
|
| 123 |
+
return bool(self.config.enabled)
|
| 124 |
+
|
| 125 |
+
def analyze(
|
| 126 |
+
self,
|
| 127 |
+
*,
|
| 128 |
+
nodes: Sequence[str],
|
| 129 |
+
dag_adj: np.ndarray,
|
| 130 |
+
treatment: str,
|
| 131 |
+
outcome: str,
|
| 132 |
+
max_edges: Optional[int] = None,
|
| 133 |
+
) -> CausalAssumptionReport:
|
| 134 |
+
if not self.enabled:
|
| 135 |
+
return CausalAssumptionReport(
|
| 136 |
+
available=False,
|
| 137 |
+
reason="PyWhy-LLM is disabled. Set PYWHYLLM_ENABLED=true or pass pywhyllm_enabled=True.",
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
cache_path = self._cache_path(nodes, dag_adj, treatment, outcome)
|
| 141 |
+
cached = self._read_cache(cache_path)
|
| 142 |
+
if cached is not None:
|
| 143 |
+
return cached
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
modeler, identifier, validator, relationship_strategy = self._suggesters()
|
| 147 |
+
except Exception as exc:
|
| 148 |
+
return CausalAssumptionReport(
|
| 149 |
+
available=False,
|
| 150 |
+
reason=f"PyWhy-LLM is not installed or failed to initialize: {exc}",
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
all_factors = list(nodes)
|
| 154 |
+
edges = self._edges(nodes, dag_adj, max_edges or self.config.max_edges)
|
| 155 |
+
warnings: List[str] = []
|
| 156 |
+
|
| 157 |
+
domain_expertises: List[str] = []
|
| 158 |
+
suggested_confounders: List[str] = []
|
| 159 |
+
suggested_backdoor_sets: List[List[str]] = []
|
| 160 |
+
suggested_mediators: List[str] = []
|
| 161 |
+
suggested_ivs: List[str] = []
|
| 162 |
+
negative_controls: List[str] = []
|
| 163 |
+
latent_confounders: List[str] = []
|
| 164 |
+
edge_critiques: Any = []
|
| 165 |
+
suggested_dag: Any = edges
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
domain_expertises = _normalise_suggestion(modeler.suggest_domain_expertises(all_factors))
|
| 169 |
+
except Exception as exc:
|
| 170 |
+
warnings.append(f"domain_expertises failed: {exc}")
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
suggested_confounders = _normalise_suggestion(
|
| 174 |
+
modeler.suggest_confounders(treatment, outcome, all_factors, domain_expertises)
|
| 175 |
+
)
|
| 176 |
+
except Exception as exc:
|
| 177 |
+
warnings.append(f"confounder suggestion failed: {exc}")
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
suggested_dag = modeler.suggest_relationships(
|
| 181 |
+
treatment,
|
| 182 |
+
outcome,
|
| 183 |
+
all_factors,
|
| 184 |
+
domain_expertises,
|
| 185 |
+
relationship_strategy,
|
| 186 |
+
)
|
| 187 |
+
except Exception as exc:
|
| 188 |
+
warnings.append(f"relationship suggestion failed: {exc}")
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
backdoor = identifier.suggest_backdoor(treatment, outcome, all_factors, domain_expertises)
|
| 192 |
+
backdoor_nodes = _normalise_suggestion(backdoor)
|
| 193 |
+
if backdoor_nodes:
|
| 194 |
+
suggested_backdoor_sets = [backdoor_nodes]
|
| 195 |
+
except Exception as exc:
|
| 196 |
+
warnings.append(f"backdoor suggestion failed: {exc}")
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
suggested_mediators = _normalise_suggestion(
|
| 200 |
+
identifier.suggest_mediators(treatment, outcome, all_factors, domain_expertises)
|
| 201 |
+
)
|
| 202 |
+
except Exception as exc:
|
| 203 |
+
warnings.append(f"mediator suggestion failed: {exc}")
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
suggested_ivs = _normalise_suggestion(
|
| 207 |
+
identifier.suggest_ivs(treatment, outcome, all_factors, domain_expertises)
|
| 208 |
+
)
|
| 209 |
+
except Exception as exc:
|
| 210 |
+
warnings.append(f"iv suggestion failed: {exc}")
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
edge_critiques = validator.critique_graph(
|
| 214 |
+
all_factors,
|
| 215 |
+
suggested_dag,
|
| 216 |
+
domain_expertises,
|
| 217 |
+
relationship_strategy,
|
| 218 |
+
)
|
| 219 |
+
except Exception as exc:
|
| 220 |
+
warnings.append(f"edge critique failed: {exc}")
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
latent_confounders = _normalise_suggestion(
|
| 224 |
+
validator.suggest_latent_confounders(treatment, outcome, all_factors, domain_expertises)
|
| 225 |
+
)
|
| 226 |
+
except Exception as exc:
|
| 227 |
+
warnings.append(f"latent confounder suggestion failed: {exc}")
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
negative_controls = _normalise_suggestion(
|
| 231 |
+
validator.suggest_negative_controls(treatment, outcome, all_factors, domain_expertises)
|
| 232 |
+
)
|
| 233 |
+
except Exception as exc:
|
| 234 |
+
warnings.append(f"negative control suggestion failed: {exc}")
|
| 235 |
+
|
| 236 |
+
accepted_edges, rejected_edges = self._classify_edges(edges, edge_critiques)
|
| 237 |
+
report = CausalAssumptionReport(
|
| 238 |
+
available=True,
|
| 239 |
+
domain_expertises=domain_expertises,
|
| 240 |
+
suggested_confounders=suggested_confounders,
|
| 241 |
+
suggested_backdoor_sets=suggested_backdoor_sets,
|
| 242 |
+
suggested_mediators=suggested_mediators,
|
| 243 |
+
suggested_ivs=suggested_ivs,
|
| 244 |
+
negative_controls=negative_controls,
|
| 245 |
+
latent_confounders=latent_confounders,
|
| 246 |
+
edge_critiques=edge_critiques,
|
| 247 |
+
accepted_edges=accepted_edges,
|
| 248 |
+
rejected_edges=rejected_edges,
|
| 249 |
+
warnings=warnings,
|
| 250 |
+
)
|
| 251 |
+
self._write_cache(cache_path, report)
|
| 252 |
+
return report
|
| 253 |
+
|
| 254 |
+
def _suggesters(self) -> Tuple[Any, Any, Any, Any]:
|
| 255 |
+
if self._model_suggester and self._identification_suggester and self._validation_suggester:
|
| 256 |
+
return (
|
| 257 |
+
self._model_suggester,
|
| 258 |
+
self._identification_suggester,
|
| 259 |
+
self._validation_suggester,
|
| 260 |
+
self._relationship_strategy,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
from pywhyllm import RelationshipStrategy
|
| 264 |
+
from pywhyllm.suggesters.identification_suggester import IdentificationSuggester
|
| 265 |
+
from pywhyllm.suggesters.model_suggester import ModelSuggester
|
| 266 |
+
from pywhyllm.suggesters.validation_suggester import ValidationSuggester
|
| 267 |
+
|
| 268 |
+
relationship_strategy = self._relationship_strategy or RelationshipStrategy.Pairwise
|
| 269 |
+
return (
|
| 270 |
+
self._model_suggester or ModelSuggester(self.config.model),
|
| 271 |
+
self._identification_suggester or IdentificationSuggester(self.config.model),
|
| 272 |
+
self._validation_suggester or ValidationSuggester(self.config.model),
|
| 273 |
+
relationship_strategy,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def _cache_path(
|
| 277 |
+
self,
|
| 278 |
+
nodes: Sequence[str],
|
| 279 |
+
dag_adj: np.ndarray,
|
| 280 |
+
treatment: str,
|
| 281 |
+
outcome: str,
|
| 282 |
+
) -> str:
|
| 283 |
+
payload = {
|
| 284 |
+
"nodes": list(nodes),
|
| 285 |
+
"dag_adj": np.asarray(dag_adj, dtype=int).tolist(),
|
| 286 |
+
"treatment": treatment,
|
| 287 |
+
"outcome": outcome,
|
| 288 |
+
"model": self.config.model,
|
| 289 |
+
}
|
| 290 |
+
digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
|
| 291 |
+
return os.path.join(os.path.abspath(self.config.cache_dir), f"{digest}.json")
|
| 292 |
+
|
| 293 |
+
def _read_cache(self, path: str) -> Optional[CausalAssumptionReport]:
|
| 294 |
+
if not os.path.exists(path):
|
| 295 |
+
return None
|
| 296 |
+
try:
|
| 297 |
+
with open(path) as f:
|
| 298 |
+
return CausalAssumptionReport.from_dict(json.load(f))
|
| 299 |
+
except Exception:
|
| 300 |
+
return None
|
| 301 |
+
|
| 302 |
+
def _write_cache(self, path: str, report: CausalAssumptionReport) -> None:
|
| 303 |
+
try:
|
| 304 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 305 |
+
with open(path, "w") as f:
|
| 306 |
+
json.dump(report.to_dict(), f, indent=2)
|
| 307 |
+
except Exception:
|
| 308 |
+
pass
|
| 309 |
+
|
| 310 |
+
def _edges(self, nodes: Sequence[str], dag_adj: np.ndarray, max_edges: int) -> List[Tuple[str, str]]:
|
| 311 |
+
found: List[Tuple[str, str, float]] = []
|
| 312 |
+
for src_idx, src in enumerate(nodes):
|
| 313 |
+
for dst_idx, dst in enumerate(nodes):
|
| 314 |
+
if bool(dag_adj[src_idx, dst_idx]):
|
| 315 |
+
found.append((src, dst, float(dag_adj[src_idx, dst_idx])))
|
| 316 |
+
found.sort(key=lambda edge: abs(edge[2]), reverse=True)
|
| 317 |
+
return [(src, dst) for src, dst, _ in found[:max_edges]]
|
| 318 |
+
|
| 319 |
+
def _classify_edges(
|
| 320 |
+
self,
|
| 321 |
+
edges: List[Tuple[str, str]],
|
| 322 |
+
edge_critiques: Any,
|
| 323 |
+
) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
|
| 324 |
+
critique_text = str(edge_critiques).lower()
|
| 325 |
+
rejected: List[Tuple[str, str]] = []
|
| 326 |
+
for edge in edges:
|
| 327 |
+
src, dst = edge
|
| 328 |
+
edge_tokens = [
|
| 329 |
+
f"{src}->{dst}".lower(),
|
| 330 |
+
f"{src} -> {dst}".lower(),
|
| 331 |
+
f"{src}, {dst}".lower(),
|
| 332 |
+
]
|
| 333 |
+
if any(token in critique_text for token in edge_tokens) and any(
|
| 334 |
+
marker in critique_text for marker in ["reject", "unlikely", "invalid", "implausible"]
|
| 335 |
+
):
|
| 336 |
+
rejected.append(edge)
|
| 337 |
+
accepted = [edge for edge in edges if edge not in rejected]
|
| 338 |
+
return accepted, rejected
|
singular_ticker_causal/causal_inference/query_engine.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
from .abduction import counterfactual_predict, counterfactual_predict_multi
|
| 11 |
+
from .causal_model import StructuralCausalModel
|
| 12 |
+
from .estimator import estimate_ate
|
| 13 |
+
from .identification import find_adjustment_set
|
| 14 |
+
from .mutilator import mutilate_graph, propagate_intervention, target_series
|
| 15 |
+
from .pywhyllm_assumptions import PyWhyLLMConfig, PyWhyLLMAssumptionService
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class CausalQueryEngine:
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
scm: StructuralCausalModel,
|
| 22 |
+
data_tech: Optional[np.ndarray] = None,
|
| 23 |
+
data_text: Optional[np.ndarray] = None,
|
| 24 |
+
pywhyllm_service: Optional[PyWhyLLMAssumptionService] = None,
|
| 25 |
+
pywhyllm_enabled: bool = False,
|
| 26 |
+
):
|
| 27 |
+
self.scm = scm
|
| 28 |
+
self.data_tech = data_tech
|
| 29 |
+
self.data_text = data_text
|
| 30 |
+
self.pywhyllm_enabled = pywhyllm_enabled
|
| 31 |
+
self.pywhyllm_service = pywhyllm_service
|
| 32 |
+
|
| 33 |
+
def assert_edge(self, treatment: str, outcome: str) -> Dict[str, object]:
|
| 34 |
+
strategy, z = find_adjustment_set(self.scm, treatment, outcome)
|
| 35 |
+
if strategy.value == "not_identifiable":
|
| 36 |
+
return {
|
| 37 |
+
"ate": 0.0,
|
| 38 |
+
"ci_95": (0.0, 0.0),
|
| 39 |
+
"strategy": strategy.name,
|
| 40 |
+
"adjustment_set": [],
|
| 41 |
+
"identifiable": False,
|
| 42 |
+
"path_contributions": {},
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
est = estimate_ate(
|
| 46 |
+
self.scm,
|
| 47 |
+
treatment=treatment,
|
| 48 |
+
outcome=outcome,
|
| 49 |
+
adjustment_set=z,
|
| 50 |
+
treatment_value=None,
|
| 51 |
+
)
|
| 52 |
+
return {
|
| 53 |
+
"ate": est["ate"],
|
| 54 |
+
"ci_95": est["ci_95"],
|
| 55 |
+
"strategy": strategy.name,
|
| 56 |
+
"adjustment_set": sorted(z or set()),
|
| 57 |
+
"identifiable": True,
|
| 58 |
+
"path_contributions": est["path_contributions"],
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
def intervene(self, treatment: str, value: float, targets: List[str], horizon: int = 5) -> Dict[str, object]:
|
| 62 |
+
mutilated = mutilate_graph(self.scm, treatment=treatment, value=value)
|
| 63 |
+
forecasts = propagate_intervention(
|
| 64 |
+
mutilated,
|
| 65 |
+
treatment=treatment,
|
| 66 |
+
value=value,
|
| 67 |
+
targets=targets,
|
| 68 |
+
horizon=horizon,
|
| 69 |
+
baseline_t=-1,
|
| 70 |
+
)
|
| 71 |
+
predicted = target_series(forecasts, self.scm, targets)
|
| 72 |
+
ate_per_target = {}
|
| 73 |
+
for target in targets:
|
| 74 |
+
tidx = self.scm.node_to_idx[target]
|
| 75 |
+
base = float(self.scm.data_level[-1, tidx])
|
| 76 |
+
ate_per_target[target] = float(forecasts[0, tidx] - base)
|
| 77 |
+
return {
|
| 78 |
+
"mutilated_adj": mutilated.dag_adj.copy(),
|
| 79 |
+
"predicted_values": predicted,
|
| 80 |
+
"ate_per_target": ate_per_target,
|
| 81 |
+
"horizon": horizon,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
def _counterfactual_outcome(
|
| 85 |
+
self,
|
| 86 |
+
observed: Dict[str, float],
|
| 87 |
+
counterfactual_values: Dict[str, float],
|
| 88 |
+
target: str,
|
| 89 |
+
) -> Dict[str, float]:
|
| 90 |
+
if len(counterfactual_values) == 1:
|
| 91 |
+
treatment, value = next(iter(counterfactual_values.items()))
|
| 92 |
+
return counterfactual_predict(
|
| 93 |
+
self.scm,
|
| 94 |
+
observed_values=observed,
|
| 95 |
+
treatment=treatment,
|
| 96 |
+
counterfactual_value=value,
|
| 97 |
+
target=target,
|
| 98 |
+
)
|
| 99 |
+
return counterfactual_predict_multi(
|
| 100 |
+
self.scm,
|
| 101 |
+
observed_values=observed,
|
| 102 |
+
counterfactual_values=counterfactual_values,
|
| 103 |
+
target=target,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
def _shapley_contributions(
|
| 107 |
+
self,
|
| 108 |
+
observed: Dict[str, float],
|
| 109 |
+
interventions: Dict[str, float],
|
| 110 |
+
target: str,
|
| 111 |
+
mc_samples: int = 1000,
|
| 112 |
+
n_jobs: int = -1,
|
| 113 |
+
) -> Dict[str, float]:
|
| 114 |
+
import random
|
| 115 |
+
from math import factorial
|
| 116 |
+
from joblib import Parallel, delayed
|
| 117 |
+
import os
|
| 118 |
+
|
| 119 |
+
treatments = list(interventions.keys())
|
| 120 |
+
n = len(treatments)
|
| 121 |
+
if n == 1:
|
| 122 |
+
return {treatments[0]: self._counterfactual_outcome(observed, interventions, target)["ite"]}
|
| 123 |
+
|
| 124 |
+
contributions: Dict[str, float] = {t: 0.0 for t in treatments}
|
| 125 |
+
|
| 126 |
+
# Use exact if n <= 10, else Monte Carlo
|
| 127 |
+
use_exact = n <= 10
|
| 128 |
+
if n_jobs < 0:
|
| 129 |
+
n_jobs = os.cpu_count() or 4
|
| 130 |
+
|
| 131 |
+
def marginal_contribution(k: str, subset: list[str]) -> float:
|
| 132 |
+
with_k = {**{t: interventions[t] for t in subset}, k: interventions[k]}
|
| 133 |
+
without_k = {t: interventions[t] for t in subset}
|
| 134 |
+
v_with = self._counterfactual_outcome(observed, with_k, target)["counterfactual_outcome"]
|
| 135 |
+
v_without = self._counterfactual_outcome(observed, without_k, target)["counterfactual_outcome"]
|
| 136 |
+
return v_with - v_without
|
| 137 |
+
|
| 138 |
+
if use_exact:
|
| 139 |
+
all_factorial = float(factorial(n))
|
| 140 |
+
tasks = []
|
| 141 |
+
|
| 142 |
+
for k in treatments:
|
| 143 |
+
others = [t for t in treatments if t != k]
|
| 144 |
+
for r in range(len(others) + 1):
|
| 145 |
+
for subset in __import__("itertools").combinations(others, r):
|
| 146 |
+
subset_list = list(subset)
|
| 147 |
+
weight = float(factorial(len(subset_list)) * factorial(n - len(subset_list) - 1) / all_factorial)
|
| 148 |
+
tasks.append((k, subset_list, weight))
|
| 149 |
+
|
| 150 |
+
results = Parallel(n_jobs=n_jobs, backend="threading")(
|
| 151 |
+
delayed(marginal_contribution)(task[0], task[1]) for task in tasks
|
| 152 |
+
)
|
| 153 |
+
for task, res in zip(tasks, results):
|
| 154 |
+
k, _, weight = task
|
| 155 |
+
contributions[k] += weight * res
|
| 156 |
+
else:
|
| 157 |
+
# Monte Carlo approximation
|
| 158 |
+
tasks = []
|
| 159 |
+
for _ in range(mc_samples):
|
| 160 |
+
perm = treatments.copy()
|
| 161 |
+
random.shuffle(perm)
|
| 162 |
+
for i, k in enumerate(perm):
|
| 163 |
+
subset_list = perm[:i]
|
| 164 |
+
tasks.append((k, subset_list))
|
| 165 |
+
|
| 166 |
+
results = Parallel(n_jobs=n_jobs, backend="threading")(
|
| 167 |
+
delayed(marginal_contribution)(task[0], task[1]) for task in tasks
|
| 168 |
+
)
|
| 169 |
+
for task, res in zip(tasks, results):
|
| 170 |
+
k, _ = task
|
| 171 |
+
contributions[k] += res / mc_samples
|
| 172 |
+
|
| 173 |
+
return contributions
|
| 174 |
+
|
| 175 |
+
def _build_dowhy_graph(self) -> str:
|
| 176 |
+
edges = []
|
| 177 |
+
for src_idx, src in enumerate(self.scm.nodes):
|
| 178 |
+
for dst_idx, dst in enumerate(self.scm.nodes):
|
| 179 |
+
if self.scm.dag_adj[src_idx, dst_idx]:
|
| 180 |
+
edges.append(f"{src} -> {dst}")
|
| 181 |
+
return "digraph{" + "; ".join(edges) + "}"
|
| 182 |
+
|
| 183 |
+
def _default_pywhyllm_service(self) -> PyWhyLLMAssumptionService:
|
| 184 |
+
enabled = self.pywhyllm_enabled or os.environ.get("PYWHYLLM_ENABLED", "").lower() in {
|
| 185 |
+
"1",
|
| 186 |
+
"true",
|
| 187 |
+
"yes",
|
| 188 |
+
"on",
|
| 189 |
+
}
|
| 190 |
+
return PyWhyLLMAssumptionService(
|
| 191 |
+
PyWhyLLMConfig(
|
| 192 |
+
enabled=enabled,
|
| 193 |
+
model=os.environ.get("PYWHYLLM_MODEL", "gpt-4"),
|
| 194 |
+
max_edges=int(os.environ.get("PYWHYLLM_MAX_EDGES", "25")),
|
| 195 |
+
cache_dir=os.environ.get(
|
| 196 |
+
"PYWHYLLM_CACHE_DIR",
|
| 197 |
+
PyWhyLLMConfig().cache_dir,
|
| 198 |
+
),
|
| 199 |
+
)
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
def _get_pywhyllm_service(self) -> PyWhyLLMAssumptionService:
|
| 203 |
+
if self.pywhyllm_service is None:
|
| 204 |
+
self.pywhyllm_service = self._default_pywhyllm_service()
|
| 205 |
+
return self.pywhyllm_service
|
| 206 |
+
|
| 207 |
+
def analyze_assumptions_with_pywhyllm(
|
| 208 |
+
self,
|
| 209 |
+
treatment: str,
|
| 210 |
+
outcome: str,
|
| 211 |
+
max_edges: Optional[int] = None,
|
| 212 |
+
) -> Dict[str, object]:
|
| 213 |
+
report = self._get_pywhyllm_service().analyze(
|
| 214 |
+
nodes=self.scm.nodes,
|
| 215 |
+
dag_adj=self.scm.dag_adj,
|
| 216 |
+
treatment=treatment,
|
| 217 |
+
outcome=outcome,
|
| 218 |
+
max_edges=max_edges,
|
| 219 |
+
)
|
| 220 |
+
return report.to_dict()
|
| 221 |
+
|
| 222 |
+
def _valid_nodes(self, candidates: Sequence[Any]) -> List[str]:
|
| 223 |
+
valid = set(self.scm.nodes)
|
| 224 |
+
result = []
|
| 225 |
+
for candidate in candidates or []:
|
| 226 |
+
node = str(candidate).strip()
|
| 227 |
+
if node in valid and node not in result:
|
| 228 |
+
result.append(node)
|
| 229 |
+
return result
|
| 230 |
+
|
| 231 |
+
def _valid_backdoor_sets(self, report: Dict[str, Any], treatment: str, outcome: str) -> List[List[str]]:
|
| 232 |
+
result = []
|
| 233 |
+
blocked = {treatment, outcome}
|
| 234 |
+
for suggested_set in report.get("suggested_backdoor_sets") or []:
|
| 235 |
+
valid_set = [node for node in self._valid_nodes(suggested_set) if node not in blocked]
|
| 236 |
+
if valid_set and valid_set not in result:
|
| 237 |
+
result.append(valid_set)
|
| 238 |
+
confounders = [node for node in self._valid_nodes(report.get("suggested_confounders") or []) if node not in blocked]
|
| 239 |
+
if confounders and confounders not in result:
|
| 240 |
+
result.append(confounders)
|
| 241 |
+
return result
|
| 242 |
+
|
| 243 |
+
def _coerce_causal_model(self, causal_model_cls: Any, data: pd.DataFrame, treatment: str, outcome: str, graph: str):
|
| 244 |
+
try:
|
| 245 |
+
return causal_model_cls(df=data, treatment=treatment, outcome=outcome, graph=graph)
|
| 246 |
+
except TypeError:
|
| 247 |
+
return causal_model_cls(data=data, treatment=treatment, outcome=outcome, graph=graph)
|
| 248 |
+
|
| 249 |
+
def _parse_p_value(self, value: Any) -> Optional[float]:
|
| 250 |
+
if value is None:
|
| 251 |
+
return None
|
| 252 |
+
if isinstance(value, (int, float, np.floating)):
|
| 253 |
+
return float(value)
|
| 254 |
+
if isinstance(value, (list, tuple)) and value:
|
| 255 |
+
return self._parse_p_value(value[0])
|
| 256 |
+
if isinstance(value, dict):
|
| 257 |
+
for key in ("p_value", "p-value", "p value"):
|
| 258 |
+
if key in value:
|
| 259 |
+
return self._parse_p_value(value[key])
|
| 260 |
+
return None
|
| 261 |
+
match = re.search(r"p[-_ ]?value[^0-9<>=-]*[<>=: ]+\s*([0-9]*\.?[0-9]+)", str(value), re.I)
|
| 262 |
+
if match:
|
| 263 |
+
return float(match.group(1))
|
| 264 |
+
return None
|
| 265 |
+
|
| 266 |
+
def _as_optional_float(self, value: Any) -> Optional[float]:
|
| 267 |
+
if value is None:
|
| 268 |
+
return None
|
| 269 |
+
try:
|
| 270 |
+
arr = np.asarray(value, dtype=float)
|
| 271 |
+
if arr.size == 1:
|
| 272 |
+
return float(arr.reshape(-1)[0])
|
| 273 |
+
except Exception:
|
| 274 |
+
pass
|
| 275 |
+
try:
|
| 276 |
+
return float(value)
|
| 277 |
+
except Exception:
|
| 278 |
+
return None
|
| 279 |
+
|
| 280 |
+
def _parse_refuter_result(self, method: str, refute: Any, alpha: float = 0.05) -> Dict[str, object]:
|
| 281 |
+
text = str(refute)
|
| 282 |
+
result_attr = getattr(refute, "refutation_result", None)
|
| 283 |
+
estimated_effect = getattr(refute, "estimated_effect", None)
|
| 284 |
+
new_effect = getattr(refute, "new_effect", None)
|
| 285 |
+
p_value = self._parse_p_value(result_attr)
|
| 286 |
+
if p_value is None:
|
| 287 |
+
p_value = self._parse_p_value(text)
|
| 288 |
+
lower_text = text.lower()
|
| 289 |
+
|
| 290 |
+
if method == "placebo_treatment":
|
| 291 |
+
if p_value is not None:
|
| 292 |
+
falsified = p_value < alpha
|
| 293 |
+
elif "not statistically significant" in lower_text:
|
| 294 |
+
falsified = False
|
| 295 |
+
elif "statistically significant" in lower_text:
|
| 296 |
+
falsified = True
|
| 297 |
+
else:
|
| 298 |
+
falsified = False
|
| 299 |
+
else:
|
| 300 |
+
if p_value is not None:
|
| 301 |
+
falsified = p_value < alpha
|
| 302 |
+
elif "not statistically significant" in lower_text:
|
| 303 |
+
falsified = False
|
| 304 |
+
elif "statistically significant" in lower_text:
|
| 305 |
+
falsified = True
|
| 306 |
+
else:
|
| 307 |
+
falsified = False
|
| 308 |
+
|
| 309 |
+
return {
|
| 310 |
+
"method": method,
|
| 311 |
+
"refute": text,
|
| 312 |
+
"estimated_effect": self._as_optional_float(estimated_effect),
|
| 313 |
+
"new_effect": self._as_optional_float(new_effect),
|
| 314 |
+
"p_value": p_value,
|
| 315 |
+
"passed": not falsified,
|
| 316 |
+
"falsified": falsified,
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
def _run_dowhy_validation(
|
| 320 |
+
self,
|
| 321 |
+
causal_model_cls: Any,
|
| 322 |
+
treatment: str,
|
| 323 |
+
outcome: str,
|
| 324 |
+
treatment_value: float = 1.0,
|
| 325 |
+
adjustment_candidates: Optional[List[List[str]]] = None,
|
| 326 |
+
negative_controls: Optional[List[str]] = None,
|
| 327 |
+
) -> Dict[str, object]:
|
| 328 |
+
data = pd.DataFrame(self.scm.data_level, columns=self.scm.nodes)
|
| 329 |
+
graph = self._build_dowhy_graph()
|
| 330 |
+
model = self._coerce_causal_model(causal_model_cls, data, treatment, outcome, graph)
|
| 331 |
+
identified_estimand = model.identify_effect()
|
| 332 |
+
estimate = model.estimate_effect(identified_estimand, method_name="backdoor.linear_regression")
|
| 333 |
+
|
| 334 |
+
refutations = []
|
| 335 |
+
for method in ["placebo_treatment", "random_common_cause"]:
|
| 336 |
+
try:
|
| 337 |
+
kwargs = {"method_name": method}
|
| 338 |
+
if method == "placebo_treatment":
|
| 339 |
+
kwargs["placebo_type"] = "permute"
|
| 340 |
+
refute = model.refute_estimate(identified_estimand, estimate, **kwargs)
|
| 341 |
+
refutations.append(self._parse_refuter_result(method, refute))
|
| 342 |
+
except TypeError:
|
| 343 |
+
try:
|
| 344 |
+
kwargs = {"method_name": method}
|
| 345 |
+
if method == "placebo_treatment":
|
| 346 |
+
kwargs["placebo_type"] = "permute"
|
| 347 |
+
refute = model.refute_estimate(estimate, **kwargs)
|
| 348 |
+
refutations.append(self._parse_refuter_result(method, refute))
|
| 349 |
+
except Exception as exc:
|
| 350 |
+
refutations.append({"method": method, "error": str(exc), "passed": False, "falsified": False})
|
| 351 |
+
except Exception as exc:
|
| 352 |
+
refutations.append({"method": method, "error": str(exc), "passed": False, "falsified": False})
|
| 353 |
+
|
| 354 |
+
negative_control_checks = []
|
| 355 |
+
for control in negative_controls or []:
|
| 356 |
+
if control in {treatment, outcome}:
|
| 357 |
+
continue
|
| 358 |
+
try:
|
| 359 |
+
nc_model = self._coerce_causal_model(causal_model_cls, data, treatment, control, graph)
|
| 360 |
+
nc_identified = nc_model.identify_effect()
|
| 361 |
+
nc_estimate = nc_model.estimate_effect(nc_identified, method_name="backdoor.linear_regression")
|
| 362 |
+
negative_control_checks.append(
|
| 363 |
+
{
|
| 364 |
+
"control": control,
|
| 365 |
+
"identified_estimand": str(nc_identified),
|
| 366 |
+
"estimate": str(nc_estimate),
|
| 367 |
+
}
|
| 368 |
+
)
|
| 369 |
+
except Exception as exc:
|
| 370 |
+
negative_control_checks.append({"control": control, "error": str(exc)})
|
| 371 |
+
|
| 372 |
+
falsified = any(bool(item.get("falsified")) for item in refutations)
|
| 373 |
+
return {
|
| 374 |
+
"available": True,
|
| 375 |
+
"falsified": falsified,
|
| 376 |
+
"identified_estimand": str(identified_estimand),
|
| 377 |
+
"estimate": str(estimate),
|
| 378 |
+
"refutations": refutations,
|
| 379 |
+
"adjustment_candidates": adjustment_candidates or [],
|
| 380 |
+
"negative_control_checks": negative_control_checks,
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
def validate_with_dowhy(
|
| 384 |
+
self,
|
| 385 |
+
treatment: str,
|
| 386 |
+
outcome: str,
|
| 387 |
+
treatment_value: float = 1.0,
|
| 388 |
+
num_placebo: int = 5,
|
| 389 |
+
) -> Dict[str, object]:
|
| 390 |
+
try:
|
| 391 |
+
from dowhy import CausalModel
|
| 392 |
+
except Exception as exc:
|
| 393 |
+
return {
|
| 394 |
+
"available": False,
|
| 395 |
+
"reason": str(exc),
|
| 396 |
+
"falsified": False,
|
| 397 |
+
"summary": "DoWhy is not installed or failed to import.",
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
try:
|
| 401 |
+
return self._run_dowhy_validation(
|
| 402 |
+
CausalModel,
|
| 403 |
+
treatment=treatment,
|
| 404 |
+
outcome=outcome,
|
| 405 |
+
treatment_value=treatment_value,
|
| 406 |
+
)
|
| 407 |
+
except Exception as exc:
|
| 408 |
+
return {
|
| 409 |
+
"available": False,
|
| 410 |
+
"reason": str(exc),
|
| 411 |
+
"falsified": False,
|
| 412 |
+
"summary": "DoWhy refutation failed.",
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
def validate_with_pywhyllm_and_dowhy(
|
| 416 |
+
self,
|
| 417 |
+
treatment: str,
|
| 418 |
+
outcome: str,
|
| 419 |
+
treatment_value: float = 1.0,
|
| 420 |
+
max_edges: Optional[int] = None,
|
| 421 |
+
) -> Dict[str, object]:
|
| 422 |
+
warnings: List[str] = []
|
| 423 |
+
pywhyllm_report = self.analyze_assumptions_with_pywhyllm(treatment, outcome, max_edges=max_edges)
|
| 424 |
+
adjustment_candidates = self._valid_backdoor_sets(pywhyllm_report, treatment, outcome)
|
| 425 |
+
negative_controls = [
|
| 426 |
+
node
|
| 427 |
+
for node in self._valid_nodes(pywhyllm_report.get("negative_controls") or [])
|
| 428 |
+
if node not in {treatment, outcome}
|
| 429 |
+
]
|
| 430 |
+
|
| 431 |
+
try:
|
| 432 |
+
from dowhy import CausalModel
|
| 433 |
+
except Exception as exc:
|
| 434 |
+
return {
|
| 435 |
+
"pywhyllm": pywhyllm_report,
|
| 436 |
+
"dowhy": {
|
| 437 |
+
"available": False,
|
| 438 |
+
"reason": str(exc),
|
| 439 |
+
"falsified": False,
|
| 440 |
+
"summary": "DoWhy is not installed or failed to import.",
|
| 441 |
+
"adjustment_candidates": adjustment_candidates,
|
| 442 |
+
"negative_controls": negative_controls,
|
| 443 |
+
},
|
| 444 |
+
"falsified": False,
|
| 445 |
+
"warnings": warnings,
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
try:
|
| 449 |
+
dowhy_report = self._run_dowhy_validation(
|
| 450 |
+
CausalModel,
|
| 451 |
+
treatment=treatment,
|
| 452 |
+
outcome=outcome,
|
| 453 |
+
treatment_value=treatment_value,
|
| 454 |
+
adjustment_candidates=adjustment_candidates,
|
| 455 |
+
negative_controls=negative_controls,
|
| 456 |
+
)
|
| 457 |
+
except Exception as exc:
|
| 458 |
+
dowhy_report = {
|
| 459 |
+
"available": False,
|
| 460 |
+
"reason": str(exc),
|
| 461 |
+
"falsified": False,
|
| 462 |
+
"summary": "DoWhy refutation failed.",
|
| 463 |
+
"adjustment_candidates": adjustment_candidates,
|
| 464 |
+
"negative_controls": negative_controls,
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
warnings.extend(pywhyllm_report.get("warnings") or [])
|
| 468 |
+
return {
|
| 469 |
+
"pywhyllm": pywhyllm_report,
|
| 470 |
+
"dowhy": dowhy_report,
|
| 471 |
+
"falsified": bool(dowhy_report.get("falsified")),
|
| 472 |
+
"warnings": warnings,
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
def counterfactual(
|
| 476 |
+
self,
|
| 477 |
+
observed_t: int,
|
| 478 |
+
treatment: Optional[str] = None,
|
| 479 |
+
cf_value: Optional[float] = None,
|
| 480 |
+
target: str = "",
|
| 481 |
+
treatments: Optional[Dict[str, float]] = None,
|
| 482 |
+
) -> Dict[str, object]:
|
| 483 |
+
t = observed_t if observed_t >= 0 else (self.scm.t_steps + observed_t)
|
| 484 |
+
if t < 0 or t >= self.scm.t_steps:
|
| 485 |
+
raise ValueError(f"observed_t {observed_t} resolves out of bounds for T={self.scm.t_steps}")
|
| 486 |
+
|
| 487 |
+
if treatments is None:
|
| 488 |
+
if treatment is None or cf_value is None:
|
| 489 |
+
raise ValueError("Either treatment/cf_value or treatments must be provided.")
|
| 490 |
+
treatments = {treatment: cf_value}
|
| 491 |
+
elif treatment is not None or cf_value is not None:
|
| 492 |
+
raise ValueError("Provide either treatment/cf_value or treatments, not both.")
|
| 493 |
+
|
| 494 |
+
observed = {node: float(self.scm.data_level[t, i]) for i, node in enumerate(self.scm.nodes)}
|
| 495 |
+
result = self._counterfactual_outcome(observed, treatments, target)
|
| 496 |
+
result["explanation"] = (
|
| 497 |
+
f"Counterfactual computed at t={t}: set {treatments} "
|
| 498 |
+
f"and propagated structural equations with abducted residuals."
|
| 499 |
+
)
|
| 500 |
+
if len(treatments) > 1:
|
| 501 |
+
result["shapley_contributions"] = self._shapley_contributions(observed, treatments, target)
|
| 502 |
+
else:
|
| 503 |
+
result["shapley_contributions"] = {next(iter(treatments)): result["ite"]}
|
| 504 |
+
result["ite_total"] = result["ite"]
|
| 505 |
+
return result
|
singular_ticker_causal/causal_inference/tests/test_causal_queries.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib.util
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
from causal_inference.causal_model import StructuralCausalModel
|
| 5 |
+
from causal_inference.query_engine import CausalQueryEngine
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _synthetic_inputs():
|
| 9 |
+
nodes = ["A", "B", "C"]
|
| 10 |
+
T = 40
|
| 11 |
+
data = np.zeros((T, 3, 1), dtype=float)
|
| 12 |
+
rng = np.random.default_rng(42)
|
| 13 |
+
|
| 14 |
+
a = rng.normal(0, 1, size=T)
|
| 15 |
+
b = 0.6 * np.roll(a, 1) + rng.normal(0, 0.1, size=T)
|
| 16 |
+
c = 0.7 * np.roll(b, 1) + rng.normal(0, 0.1, size=T)
|
| 17 |
+
b[0] = rng.normal()
|
| 18 |
+
c[0] = rng.normal()
|
| 19 |
+
data[:, 0, 0] = a
|
| 20 |
+
data[:, 1, 0] = b
|
| 21 |
+
data[:, 2, 0] = c
|
| 22 |
+
|
| 23 |
+
# A->B, B->C and a weak cycle C->A to exercise pruning.
|
| 24 |
+
adj = np.array(
|
| 25 |
+
[
|
| 26 |
+
[0.0, 0.9, 0.0],
|
| 27 |
+
[0.0, 0.0, 0.8],
|
| 28 |
+
[0.1, 0.0, 0.0],
|
| 29 |
+
],
|
| 30 |
+
dtype=float,
|
| 31 |
+
)
|
| 32 |
+
prior = np.zeros((3, 3), dtype=float)
|
| 33 |
+
mask = np.ones((T, 3), dtype=float)
|
| 34 |
+
return nodes, adj, prior, data, mask
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_scm_fit_and_cycle_prune():
|
| 38 |
+
nodes, adj, prior, data, mask = _synthetic_inputs()
|
| 39 |
+
scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
|
| 40 |
+
assert scm.dag_adj.shape == (3, 3)
|
| 41 |
+
assert len(scm.topological_indices) == 3
|
| 42 |
+
assert np.sum(scm.dag_adj) <= 2
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_intervention_and_counterfactual_shapes():
|
| 46 |
+
nodes, adj, prior, data, mask = _synthetic_inputs()
|
| 47 |
+
scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
|
| 48 |
+
engine = CausalQueryEngine(scm)
|
| 49 |
+
|
| 50 |
+
inter = engine.intervene("A", value=0.5, targets=["B", "C"], horizon=3)
|
| 51 |
+
assert set(inter["predicted_values"].keys()) == {"B", "C"}
|
| 52 |
+
assert len(inter["predicted_values"]["B"]) == 3
|
| 53 |
+
|
| 54 |
+
cf = engine.counterfactual(observed_t=-1, treatment="A", cf_value=0.8, target="C")
|
| 55 |
+
assert "factual_outcome" in cf
|
| 56 |
+
assert "counterfactual_outcome" in cf
|
| 57 |
+
assert "ite" in cf
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_counterfactual_shapley_contributions():
|
| 61 |
+
nodes, adj, prior, data, mask = _synthetic_inputs()
|
| 62 |
+
scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
|
| 63 |
+
engine = CausalQueryEngine(scm)
|
| 64 |
+
|
| 65 |
+
cf = engine.counterfactual(
|
| 66 |
+
observed_t=-1,
|
| 67 |
+
treatments={"A": 0.8, "B": -0.3},
|
| 68 |
+
target="C",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
assert "shapley_contributions" in cf
|
| 72 |
+
assert set(cf["shapley_contributions"].keys()) == {"A", "B"}
|
| 73 |
+
assert abs(sum(cf["shapley_contributions"].values()) - cf["ite"]) < 1e-6
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_dowhy_validation_is_optional():
|
| 77 |
+
nodes, adj, prior, data, mask = _synthetic_inputs()
|
| 78 |
+
scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
|
| 79 |
+
engine = CausalQueryEngine(scm)
|
| 80 |
+
|
| 81 |
+
result = engine.validate_with_dowhy(treatment="A", outcome="C")
|
| 82 |
+
assert "available" in result
|
| 83 |
+
if importlib.util.find_spec("dowhy") is None:
|
| 84 |
+
assert result["available"] is False
|
| 85 |
+
else:
|
| 86 |
+
assert "falsified" in result
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_exogenous_fallback_prevention():
|
| 90 |
+
nodes, adj, prior, data, mask = _synthetic_inputs()
|
| 91 |
+
# Force fallback to exogenous by setting min_obs to a very large number (e.g. 100)
|
| 92 |
+
scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask, min_obs=100).fit()
|
| 93 |
+
engine = CausalQueryEngine(scm)
|
| 94 |
+
|
| 95 |
+
inter = engine.intervene("A", value=0.5, targets=["B", "C"], horizon=3)
|
| 96 |
+
assert set(inter["predicted_values"].keys()) == {"B", "C"}
|
| 97 |
+
assert len(inter["predicted_values"]["B"]) == 3
|
| 98 |
+
|
| 99 |
+
cf = engine.counterfactual(observed_t=-1, treatment="A", cf_value=0.8, target="C")
|
| 100 |
+
assert "factual_outcome" in cf
|
| 101 |
+
assert "counterfactual_outcome" in cf
|
| 102 |
+
assert "ite" in cf
|
singular_ticker_causal/causal_inference/tests/test_pywhyllm_assumptions.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import builtins
|
| 2 |
+
import sys
|
| 3 |
+
import types
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from singular_ticker_causal.causal_inference.causal_model import StructuralCausalModel
|
| 8 |
+
from singular_ticker_causal.causal_inference.pywhyllm_assumptions import (
|
| 9 |
+
PyWhyLLMConfig,
|
| 10 |
+
PyWhyLLMAssumptionService,
|
| 11 |
+
)
|
| 12 |
+
from singular_ticker_causal.causal_inference.query_engine import CausalQueryEngine
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _synthetic_scm():
|
| 16 |
+
nodes = ["A", "B", "C"]
|
| 17 |
+
data = np.zeros((24, 3, 1), dtype=float)
|
| 18 |
+
rng = np.random.default_rng(11)
|
| 19 |
+
data[:, 0, 0] = rng.normal(size=24)
|
| 20 |
+
data[:, 1, 0] = rng.normal(size=24)
|
| 21 |
+
data[:, 2, 0] = rng.normal(size=24)
|
| 22 |
+
adj = np.array(
|
| 23 |
+
[
|
| 24 |
+
[0.0, 0.8, 0.7],
|
| 25 |
+
[0.0, 0.0, 0.9],
|
| 26 |
+
[0.0, 0.0, 0.0],
|
| 27 |
+
],
|
| 28 |
+
dtype=float,
|
| 29 |
+
)
|
| 30 |
+
prior = np.ones((3, 3), dtype=float)
|
| 31 |
+
np.fill_diagonal(prior, 0.0)
|
| 32 |
+
return StructuralCausalModel(
|
| 33 |
+
nodes=nodes,
|
| 34 |
+
adj=adj,
|
| 35 |
+
adjacency_mask=np.zeros((3, 3), dtype=float),
|
| 36 |
+
prohibition_mask=prior,
|
| 37 |
+
data_tech=data,
|
| 38 |
+
).fit()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class FakeModelSuggester:
|
| 42 |
+
def suggest_domain_expertises(self, all_factors):
|
| 43 |
+
return ["financial accounting"]
|
| 44 |
+
|
| 45 |
+
def suggest_confounders(self, treatment, outcome, all_factors, domain_expertises):
|
| 46 |
+
return ({("B", treatment): 1, ("B", outcome): 1}, ["B", "Missing_Node"])
|
| 47 |
+
|
| 48 |
+
def suggest_relationships(self, treatment, outcome, all_factors, domain_expertises, strategy):
|
| 49 |
+
return [(treatment, outcome), ("B", outcome)]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class FakeIdentificationSuggester:
|
| 53 |
+
def suggest_backdoor(self, treatment, outcome, all_factors, domain_expertises):
|
| 54 |
+
return ["B", "Missing_Node"]
|
| 55 |
+
|
| 56 |
+
def suggest_mediators(self, treatment, outcome, all_factors, domain_expertises):
|
| 57 |
+
return ["B"]
|
| 58 |
+
|
| 59 |
+
def suggest_ivs(self, treatment, outcome, all_factors, domain_expertises):
|
| 60 |
+
return ["A"]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class FakeValidationSuggester:
|
| 64 |
+
def critique_graph(self, all_factors, suggested_dag, domain_expertises, strategy):
|
| 65 |
+
return "A -> C accepted; B -> C implausible, reject"
|
| 66 |
+
|
| 67 |
+
def suggest_latent_confounders(self, treatment, outcome, all_factors, domain_expertises):
|
| 68 |
+
return ["market regime"]
|
| 69 |
+
|
| 70 |
+
def suggest_negative_controls(self, treatment, outcome, all_factors, domain_expertises):
|
| 71 |
+
return ["B", "Missing_Node"]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _fake_service(tmp_path):
|
| 75 |
+
return PyWhyLLMAssumptionService(
|
| 76 |
+
PyWhyLLMConfig(enabled=True, cache_dir=str(tmp_path)),
|
| 77 |
+
model_suggester=FakeModelSuggester(),
|
| 78 |
+
identification_suggester=FakeIdentificationSuggester(),
|
| 79 |
+
validation_suggester=FakeValidationSuggester(),
|
| 80 |
+
relationship_strategy="pairwise",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_pywhyllm_assumption_service_uses_fakes_and_cache(tmp_path):
|
| 85 |
+
scm = _synthetic_scm()
|
| 86 |
+
service = _fake_service(tmp_path)
|
| 87 |
+
|
| 88 |
+
report = service.analyze(nodes=scm.nodes, dag_adj=scm.dag_adj, treatment="A", outcome="C")
|
| 89 |
+
|
| 90 |
+
assert report.available is True
|
| 91 |
+
assert report.domain_expertises == ["financial accounting"]
|
| 92 |
+
assert report.suggested_confounders == ["B", "Missing_Node"]
|
| 93 |
+
assert report.suggested_backdoor_sets == [["B", "Missing_Node"]]
|
| 94 |
+
assert report.negative_controls == ["B", "Missing_Node"]
|
| 95 |
+
assert ("B", "C") in report.rejected_edges
|
| 96 |
+
|
| 97 |
+
cached = service.analyze(nodes=scm.nodes, dag_adj=scm.dag_adj, treatment="A", outcome="C")
|
| 98 |
+
assert cached.to_dict() == report.to_dict()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_missing_pywhyllm_returns_unavailable(monkeypatch, tmp_path):
|
| 102 |
+
original_import = builtins.__import__
|
| 103 |
+
|
| 104 |
+
def fake_import(name, *args, **kwargs):
|
| 105 |
+
if name.startswith("pywhyllm"):
|
| 106 |
+
raise ImportError("blocked pywhyllm")
|
| 107 |
+
return original_import(name, *args, **kwargs)
|
| 108 |
+
|
| 109 |
+
monkeypatch.setattr(builtins, "__import__", fake_import)
|
| 110 |
+
scm = _synthetic_scm()
|
| 111 |
+
service = PyWhyLLMAssumptionService(PyWhyLLMConfig(enabled=True, cache_dir=str(tmp_path)))
|
| 112 |
+
|
| 113 |
+
report = service.analyze(nodes=scm.nodes, dag_adj=scm.dag_adj, treatment="A", outcome="C")
|
| 114 |
+
|
| 115 |
+
assert report.available is False
|
| 116 |
+
assert "blocked pywhyllm" in report.reason
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_engine_filters_pywhyllm_adjustments_and_negative_controls(tmp_path):
|
| 120 |
+
scm = _synthetic_scm()
|
| 121 |
+
engine = CausalQueryEngine(scm, pywhyllm_service=_fake_service(tmp_path), pywhyllm_enabled=True)
|
| 122 |
+
|
| 123 |
+
report = engine.analyze_assumptions_with_pywhyllm("A", "C")
|
| 124 |
+
adjustment_sets = engine._valid_backdoor_sets(report, "A", "C")
|
| 125 |
+
negative_controls = engine._valid_nodes(report["negative_controls"])
|
| 126 |
+
|
| 127 |
+
assert adjustment_sets == [["B"]]
|
| 128 |
+
assert negative_controls == ["B"]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_placebo_not_statistically_significant_passes():
|
| 132 |
+
scm = _synthetic_scm()
|
| 133 |
+
engine = CausalQueryEngine(scm)
|
| 134 |
+
|
| 135 |
+
parsed = engine._parse_refuter_result(
|
| 136 |
+
"placebo_treatment",
|
| 137 |
+
"Refute: Use a Placebo Treatment. The result is not statistically significant.",
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
assert parsed["passed"] is True
|
| 141 |
+
assert parsed["falsified"] is False
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_combined_validation_reports_unavailable_dowhy(monkeypatch, tmp_path):
|
| 145 |
+
monkeypatch.setitem(sys.modules, "dowhy", None)
|
| 146 |
+
scm = _synthetic_scm()
|
| 147 |
+
engine = CausalQueryEngine(scm, pywhyllm_service=_fake_service(tmp_path), pywhyllm_enabled=True)
|
| 148 |
+
|
| 149 |
+
result = engine.validate_with_pywhyllm_and_dowhy("A", "C")
|
| 150 |
+
|
| 151 |
+
assert result["pywhyllm"]["available"] is True
|
| 152 |
+
assert result["dowhy"]["available"] is False
|
| 153 |
+
assert result["dowhy"]["adjustment_candidates"] == [["B"]]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_combined_validation_uses_fake_dowhy(monkeypatch, tmp_path):
|
| 157 |
+
class FakeRefute:
|
| 158 |
+
estimated_effect = 1.0
|
| 159 |
+
new_effect = 0.0
|
| 160 |
+
refutation_result = {"p_value": 0.8}
|
| 161 |
+
|
| 162 |
+
def __str__(self):
|
| 163 |
+
return "not statistically significant"
|
| 164 |
+
|
| 165 |
+
class FakeEstimate:
|
| 166 |
+
def __str__(self):
|
| 167 |
+
return "estimate"
|
| 168 |
+
|
| 169 |
+
class FakeCausalModel:
|
| 170 |
+
def __init__(self, **kwargs):
|
| 171 |
+
self.kwargs = kwargs
|
| 172 |
+
|
| 173 |
+
def identify_effect(self):
|
| 174 |
+
return "estimand"
|
| 175 |
+
|
| 176 |
+
def estimate_effect(self, identified_estimand, method_name):
|
| 177 |
+
return FakeEstimate()
|
| 178 |
+
|
| 179 |
+
def refute_estimate(self, identified_estimand, estimate, **kwargs):
|
| 180 |
+
return FakeRefute()
|
| 181 |
+
|
| 182 |
+
fake_dowhy = types.ModuleType("dowhy")
|
| 183 |
+
fake_dowhy.CausalModel = FakeCausalModel
|
| 184 |
+
monkeypatch.setitem(sys.modules, "dowhy", fake_dowhy)
|
| 185 |
+
|
| 186 |
+
scm = _synthetic_scm()
|
| 187 |
+
engine = CausalQueryEngine(scm, pywhyllm_service=_fake_service(tmp_path), pywhyllm_enabled=True)
|
| 188 |
+
|
| 189 |
+
result = engine.validate_with_pywhyllm_and_dowhy("A", "C")
|
| 190 |
+
|
| 191 |
+
assert result["dowhy"]["available"] is True
|
| 192 |
+
assert result["dowhy"]["falsified"] is False
|
| 193 |
+
assert result["dowhy"]["negative_control_checks"][0]["control"] == "B"
|
singular_ticker_causal/data_sources/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .fetcher import Fetcher
|
| 2 |
+
from .gdelt_client import GDELTClient
|
| 3 |
+
from .news_client import NewsClient
|
| 4 |
+
from .sebi_reg30_client import SEBIREG30Client
|
singular_ticker_causal/data_sources/bsedata/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
|
| 3 |
+
MIT License
|
| 4 |
+
|
| 5 |
+
Copyright (c) 2018 - 2024 Shrey Dabhi
|
| 6 |
+
|
| 7 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 8 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 9 |
+
in the Software without restriction, including without limitation the rights
|
| 10 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 11 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 12 |
+
furnished to do so, subject to the following conditions:
|
| 13 |
+
|
| 14 |
+
The above copyright notice and this permission notice shall be included in all
|
| 15 |
+
copies or substantial portions of the Software.
|
| 16 |
+
|
| 17 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 18 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 19 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 20 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 21 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 22 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 23 |
+
SOFTWARE.
|
| 24 |
+
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
name = "bsedata"
|
singular_ticker_causal/data_sources/bsedata/bhavcopy.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import io
|
| 3 |
+
import csv
|
| 4 |
+
import requests
|
| 5 |
+
import tempfile
|
| 6 |
+
import datetime
|
| 7 |
+
from zipfile import ZipFile
|
| 8 |
+
from .exceptions import BhavCopyNotFound
|
| 9 |
+
from .helpers import COMMON_REQUEST_HEADERS
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def loadBhavCopyData(statsDate: datetime.date) -> list:
|
| 13 |
+
tempDir = os.path.join(tempfile.gettempdir(), "bsedata")
|
| 14 |
+
zipfileName = f"EQ{statsDate.strftime('%d%m%y')}_CSV.ZIP"
|
| 15 |
+
r = requests.get(
|
| 16 |
+
f"https://www.bseindia.com/download/BhavCopy/Equity/{zipfileName}",
|
| 17 |
+
headers=COMMON_REQUEST_HEADERS,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
if r.status_code != 200:
|
| 21 |
+
raise BhavCopyNotFound()
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
os.makedirs(tempDir)
|
| 25 |
+
except FileExistsError:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
f_zip = open(os.path.join(tempDir, zipfileName), "wb+")
|
| 29 |
+
f_zip.write(r.content)
|
| 30 |
+
f_zip.close()
|
| 31 |
+
|
| 32 |
+
output = []
|
| 33 |
+
|
| 34 |
+
with ZipFile(os.path.join(tempDir, zipfileName)) as bhavCopyZip:
|
| 35 |
+
with bhavCopyZip.open(f"EQ{statsDate.strftime('%d%m%y')}.CSV") as bhavCopyFile:
|
| 36 |
+
reader = csv.DictReader(io.TextIOWrapper(bhavCopyFile))
|
| 37 |
+
for row in reader:
|
| 38 |
+
output.append(mapBhavCopyRowToDict(row))
|
| 39 |
+
|
| 40 |
+
return output
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def mapBhavCopyRowToDict(row: dict) -> dict:
|
| 44 |
+
SC_TYPE_MAP = {"B": "bond", "Q": "equity", "D": "debenture", "P": "preference"}
|
| 45 |
+
return {
|
| 46 |
+
"scripCode": row["SC_CODE"],
|
| 47 |
+
"open": row["OPEN"],
|
| 48 |
+
"high": row["HIGH"],
|
| 49 |
+
"low": row["LOW"],
|
| 50 |
+
"close": row["CLOSE"],
|
| 51 |
+
"last": row["LAST"],
|
| 52 |
+
"prevClose": row["PREVCLOSE"],
|
| 53 |
+
"totalTrades": row["NO_TRADES"],
|
| 54 |
+
"totalSharesTraded": row["NO_OF_SHRS"],
|
| 55 |
+
"netTurnover": row["NET_TURNOV"],
|
| 56 |
+
"scripType": SC_TYPE_MAP[row["SC_TYPE"]],
|
| 57 |
+
"securityID": row["SC_NAME"].strip(),
|
| 58 |
+
}
|
singular_ticker_causal/data_sources/bsedata/bse.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
|
| 3 |
+
MIT License
|
| 4 |
+
|
| 5 |
+
Copyright (c) 2018 - 2024 Shrey Dabhi
|
| 6 |
+
|
| 7 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 8 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 9 |
+
in the Software without restriction, including without limitation the rights
|
| 10 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 11 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 12 |
+
furnished to do so, subject to the following conditions:
|
| 13 |
+
|
| 14 |
+
The above copyright notice and this permission notice shall be included in all
|
| 15 |
+
copies or substantial portions of the Software.
|
| 16 |
+
|
| 17 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 18 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 19 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 20 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 21 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 22 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 23 |
+
SOFTWARE.
|
| 24 |
+
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from .gainers import getGainers
|
| 28 |
+
from .losers import getLosers
|
| 29 |
+
from .bhavcopy import loadBhavCopyData
|
| 30 |
+
from .quote import quote
|
| 31 |
+
from .indices import indices
|
| 32 |
+
import datetime
|
| 33 |
+
import requests
|
| 34 |
+
import json
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class BSE(object):
|
| 38 |
+
"""
|
| 39 |
+
Class which implements the functionality for
|
| 40 |
+
Bombay Stock Exchange (BSE)
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self, update_codes=False):
|
| 44 |
+
self.__update_codes = update_codes
|
| 45 |
+
if update_codes:
|
| 46 |
+
self.updateScripCodes()
|
| 47 |
+
|
| 48 |
+
def topGainers(self):
|
| 49 |
+
"""
|
| 50 |
+
:returns: A sorted list of codes of top gainers
|
| 51 |
+
"""
|
| 52 |
+
return getGainers()
|
| 53 |
+
|
| 54 |
+
def topLosers(self):
|
| 55 |
+
"""
|
| 56 |
+
:returns: A sorted list of codes of top losers
|
| 57 |
+
"""
|
| 58 |
+
return getLosers()
|
| 59 |
+
|
| 60 |
+
def getQuote(self, scripCode):
|
| 61 |
+
"""
|
| 62 |
+
:param scripCode: A stock code
|
| 63 |
+
:returns: A dictionary which contain details about the stock
|
| 64 |
+
:raises InvalidStockException: Raised for stocks which have been suspended or no longer trading on BSE
|
| 65 |
+
"""
|
| 66 |
+
return quote(scripCode)
|
| 67 |
+
|
| 68 |
+
def getIndices(self, category):
|
| 69 |
+
"""
|
| 70 |
+
:param category: A category of indices
|
| 71 |
+
:returns: A dictionary with details about the indices belonging to the given category
|
| 72 |
+
"""
|
| 73 |
+
return indices(category)
|
| 74 |
+
|
| 75 |
+
def updateScripCodes(self):
|
| 76 |
+
"""
|
| 77 |
+
Download a fresh copy of the scrip code listing
|
| 78 |
+
|
| 79 |
+
:returns: None
|
| 80 |
+
"""
|
| 81 |
+
r = requests.get("https://pub-87b187a07d9c42109c9e6999439a583f.r2.dev/stk.json")
|
| 82 |
+
f_stk = open("stk.json", "w+")
|
| 83 |
+
f_stk.write(json.dumps(r.json()))
|
| 84 |
+
f_stk.close()
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
def getBhavCopyData(self, statsDate: datetime.date):
|
| 88 |
+
"""
|
| 89 |
+
Get historical OHLCV data from Bhav Copy released by BSE everyday after market closing.
|
| 90 |
+
The columns available in the data and their description is as given below.
|
| 91 |
+
|
| 92 |
+
.. list-table::
|
| 93 |
+
:widths: 25 75
|
| 94 |
+
:header-rows: 1
|
| 95 |
+
|
| 96 |
+
* - Dictionary Field
|
| 97 |
+
- Description
|
| 98 |
+
* - scripCode
|
| 99 |
+
- Unique code assigned to a scrip of a company by BSE
|
| 100 |
+
* - open
|
| 101 |
+
- The price at which the security first trades on a given trading day
|
| 102 |
+
* - high
|
| 103 |
+
- The highest intra-day price of a stock
|
| 104 |
+
* - low
|
| 105 |
+
- The lowest intra-day price of a stock
|
| 106 |
+
* - close
|
| 107 |
+
- The final price at which a security is traded on a given trading day
|
| 108 |
+
* - last
|
| 109 |
+
- The last trade price of the stock
|
| 110 |
+
* - prevClose
|
| 111 |
+
- The closing price of the stock for the previous trading day
|
| 112 |
+
* - totalTrades
|
| 113 |
+
- The total number of trades of a scrip
|
| 114 |
+
* - totalSharesTraded
|
| 115 |
+
- The total number of shares transacted of a scrip
|
| 116 |
+
* - netTurnover
|
| 117 |
+
- Total turnover of a scrip
|
| 118 |
+
* - scripType
|
| 119 |
+
- Scrip category: Equity, Preference, Debenture or Bond
|
| 120 |
+
* - securityID
|
| 121 |
+
- Name of the company
|
| 122 |
+
|
| 123 |
+
The Bhav Copy files have been mapped to the above mentioned custom fields. The complete documentation for Bhav Copy can be found here: https://www.bseindia.com/markets/MarketInfo/BhavCopy.aspx.
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
:param statsDate: A `datetime.date` object for the for which you want to fetch the data
|
| 127 |
+
:returns: A list of dictionaries which contains OHLCV data for that day for all scrip codes active on that day
|
| 128 |
+
:raises BhavCopyNotFound: Raised when Bhav Copy file is not found on BSE
|
| 129 |
+
"""
|
| 130 |
+
return loadBhavCopyData(statsDate)
|
| 131 |
+
|
| 132 |
+
def getScripCodes(self):
|
| 133 |
+
"""
|
| 134 |
+
:returns: A dictionary with scrip codes as keys and company names as values
|
| 135 |
+
"""
|
| 136 |
+
f = open("stk.json", "r")
|
| 137 |
+
return json.loads(f.read())
|
| 138 |
+
|
| 139 |
+
def verifyScripCode(self, code):
|
| 140 |
+
"""
|
| 141 |
+
:returns: Company name if it is a valid stock code, else None
|
| 142 |
+
"""
|
| 143 |
+
data = self.getScripCodes()
|
| 144 |
+
return data.get(code)
|
| 145 |
+
|
| 146 |
+
def __str__(self):
|
| 147 |
+
return "Driver Class for Bombay Stock Exchange (BSE)"
|
| 148 |
+
|
| 149 |
+
def __repr__(self):
|
| 150 |
+
return f"<{self.__class__.__name__}: update_codes={self.__update_codes}> Driver Class for Bombay Stock Exchange (BSE)"
|
singular_ticker_causal/data_sources/bsedata/exceptions.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
|
| 3 |
+
MIT License
|
| 4 |
+
|
| 5 |
+
Copyright (c) 2018 - 2024 Shrey Dabhi
|
| 6 |
+
|
| 7 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 8 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 9 |
+
in the Software without restriction, including without limitation the rights
|
| 10 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 11 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 12 |
+
furnished to do so, subject to the following conditions:
|
| 13 |
+
|
| 14 |
+
The above copyright notice and this permission notice shall be included in all
|
| 15 |
+
copies or substantial portions of the Software.
|
| 16 |
+
|
| 17 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 18 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 19 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 20 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 21 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 22 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 23 |
+
SOFTWARE.
|
| 24 |
+
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class InvalidStockException(Exception):
|
| 29 |
+
"""
|
| 30 |
+
Exception raised for stocks which have been suspended or no longer trading on BSE.
|
| 31 |
+
|
| 32 |
+
:param status: the status of the stock as mentioned on BSE website
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, status: str = "Inactive stock"):
|
| 36 |
+
if status == "":
|
| 37 |
+
self.status = "Inactive stock"
|
| 38 |
+
else:
|
| 39 |
+
self.status = status
|
| 40 |
+
super().__init__(self.status)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class BhavCopyNotFound(Exception):
|
| 44 |
+
"""
|
| 45 |
+
Exception raised when the BhavCopy file is not found on BSE website.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
def __init__(self):
|
| 49 |
+
super().__init__(
|
| 50 |
+
"""The BhavCopy file was not found on the BSE website. You are probably trying to get data for a trading holiday."""
|
| 51 |
+
)
|
singular_ticker_causal/data_sources/bsedata/gainers.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
|
| 3 |
+
MIT License
|
| 4 |
+
|
| 5 |
+
Copyright (c) 2018 - 2024 Shrey Dabhi
|
| 6 |
+
|
| 7 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 8 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 9 |
+
in the Software without restriction, including without limitation the rights
|
| 10 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 11 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 12 |
+
furnished to do so, subject to the following conditions:
|
| 13 |
+
|
| 14 |
+
The above copyright notice and this permission notice shall be included in all
|
| 15 |
+
copies or substantial portions of the Software.
|
| 16 |
+
|
| 17 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 18 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 19 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 20 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 21 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 22 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 23 |
+
SOFTWARE.
|
| 24 |
+
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from .helpers import COMMON_REQUEST_HEADERS
|
| 28 |
+
from bs4 import BeautifulSoup as bs
|
| 29 |
+
import requests
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def getGainers() -> dict:
|
| 33 |
+
baseurl = """https://m.bseindia.com"""
|
| 34 |
+
res = requests.get(baseurl, headers=COMMON_REQUEST_HEADERS)
|
| 35 |
+
c = res.content
|
| 36 |
+
soup = bs(c, "lxml")
|
| 37 |
+
for tag in soup("div"):
|
| 38 |
+
try:
|
| 39 |
+
if tag["id"] == "divGainers":
|
| 40 |
+
resSoup = tag
|
| 41 |
+
break
|
| 42 |
+
except KeyError:
|
| 43 |
+
continue
|
| 44 |
+
children = list(resSoup.table.contents)
|
| 45 |
+
children = children[1:]
|
| 46 |
+
gainers = []
|
| 47 |
+
for tr in children:
|
| 48 |
+
td = tr.contents
|
| 49 |
+
gainer = {
|
| 50 |
+
"securityID": str(td[0].a.string),
|
| 51 |
+
"scripCode": str(tr.td.a["href"].split("=")[1]),
|
| 52 |
+
"LTP": str(td[1].string),
|
| 53 |
+
"change": str(td[2].string),
|
| 54 |
+
"pChange": str(td[3].string),
|
| 55 |
+
}
|
| 56 |
+
gainers.append(gainer)
|
| 57 |
+
return gainers
|
singular_ticker_causal/data_sources/bsedata/helpers.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
COMMON_REQUEST_HEADERS = {
|
| 2 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36 Edg/83.0.478.45"
|
| 3 |
+
}
|
singular_ticker_causal/data_sources/bsedata/indices.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
|
| 3 |
+
MIT License
|
| 4 |
+
|
| 5 |
+
Copyright (c) 2018 - 2024 Shrey Dabhi
|
| 6 |
+
|
| 7 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 8 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 9 |
+
in the Software without restriction, including without limitation the rights
|
| 10 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 11 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 12 |
+
furnished to do so, subject to the following conditions:
|
| 13 |
+
|
| 14 |
+
The above copyright notice and this permission notice shall be included in all
|
| 15 |
+
copies or substantial portions of the Software.
|
| 16 |
+
|
| 17 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 18 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 19 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 20 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 21 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 22 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 23 |
+
SOFTWARE.
|
| 24 |
+
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from .helpers import COMMON_REQUEST_HEADERS
|
| 28 |
+
from bs4 import BeautifulSoup as bs
|
| 29 |
+
import requests
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def indices(category: str) -> dict:
|
| 33 |
+
cat = {
|
| 34 |
+
"market_cap/broad": "1,2",
|
| 35 |
+
"sector_and_industry": "2,2",
|
| 36 |
+
"thematics": "3,2",
|
| 37 |
+
"strategy": "4,2",
|
| 38 |
+
"sustainability": "5,2",
|
| 39 |
+
"volatility": "6,1",
|
| 40 |
+
"composite": "7,1",
|
| 41 |
+
"government": "8,1",
|
| 42 |
+
"corporate": "9,1",
|
| 43 |
+
"money_market": "10,1",
|
| 44 |
+
}
|
| 45 |
+
try:
|
| 46 |
+
ddl_category = cat[category]
|
| 47 |
+
except KeyError:
|
| 48 |
+
print(
|
| 49 |
+
"""
|
| 50 |
+
### Invalid category ###
|
| 51 |
+
Use one of the categories mentioned below:
|
| 52 |
+
|
| 53 |
+
market_cap/broad
|
| 54 |
+
sector_and_industry
|
| 55 |
+
thematics
|
| 56 |
+
strategy
|
| 57 |
+
sustainability
|
| 58 |
+
volatility
|
| 59 |
+
composite
|
| 60 |
+
government
|
| 61 |
+
corporate
|
| 62 |
+
money_market
|
| 63 |
+
"""
|
| 64 |
+
)
|
| 65 |
+
return
|
| 66 |
+
baseurl = """https://m.bseindia.com/IndicesView_New.aspx"""
|
| 67 |
+
res = requests.get(baseurl, headers=COMMON_REQUEST_HEADERS)
|
| 68 |
+
c = res.content
|
| 69 |
+
soup = bs(c, "lxml")
|
| 70 |
+
options = {
|
| 71 |
+
"__EVENTTARGET": "ddl_Category",
|
| 72 |
+
"__VIEWSTATEENCRYPTED": "",
|
| 73 |
+
"__EVENTARGUMENT": "",
|
| 74 |
+
"__LASTFOCUS": "",
|
| 75 |
+
"__VIEWSTATEGENERATOR": "162C96CD",
|
| 76 |
+
"UcHeaderMenu1$txtGetQuote": "",
|
| 77 |
+
"__EVENTVALIDATION": "",
|
| 78 |
+
"__VIEWSTATE": "",
|
| 79 |
+
}
|
| 80 |
+
for input in soup("input"):
|
| 81 |
+
try:
|
| 82 |
+
if input["type"] == "hidden":
|
| 83 |
+
if input["id"] == "__VIEWSTATE":
|
| 84 |
+
options["__VIEWSTATE"] = input["value"]
|
| 85 |
+
elif input["id"] == "__EVENTVALIDATION":
|
| 86 |
+
options["__EVENTVALIDATION"] = input["value"]
|
| 87 |
+
except KeyError:
|
| 88 |
+
continue
|
| 89 |
+
options["ddl_Category"] = ddl_category
|
| 90 |
+
res = requests.post(url=baseurl, data=options, headers=COMMON_REQUEST_HEADERS)
|
| 91 |
+
c = res.content
|
| 92 |
+
soup = bs(c, "lxml")
|
| 93 |
+
index_list = []
|
| 94 |
+
for td in soup("td"):
|
| 95 |
+
try:
|
| 96 |
+
if td["class"][0] == "TTRow_left":
|
| 97 |
+
index = {}
|
| 98 |
+
index["currentValue"] = td.next_sibling.string.strip()
|
| 99 |
+
index["change"] = td.next_sibling.next_sibling.string.strip()
|
| 100 |
+
index[
|
| 101 |
+
"pChange"
|
| 102 |
+
] = td.next_sibling.next_sibling.next_sibling.string.strip()
|
| 103 |
+
index["scripFlag"] = td.a["href"].strip().split("=")[1]
|
| 104 |
+
index["name"] = td.a.string.strip().replace(";", "")
|
| 105 |
+
index_list.append(index)
|
| 106 |
+
except KeyError:
|
| 107 |
+
continue
|
| 108 |
+
results = {}
|
| 109 |
+
for span in soup("span", id="inddate"):
|
| 110 |
+
results["updatedOn"] = span.string[6:].split("|")[0].strip()
|
| 111 |
+
results["indices"] = index_list
|
| 112 |
+
return results
|
singular_ticker_causal/data_sources/bsedata/losers.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
|
| 3 |
+
MIT License
|
| 4 |
+
|
| 5 |
+
Copyright (c) 2018 - 2024 Shrey Dabhi
|
| 6 |
+
|
| 7 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 8 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 9 |
+
in the Software without restriction, including without limitation the rights
|
| 10 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 11 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 12 |
+
furnished to do so, subject to the following conditions:
|
| 13 |
+
|
| 14 |
+
The above copyright notice and this permission notice shall be included in all
|
| 15 |
+
copies or substantial portions of the Software.
|
| 16 |
+
|
| 17 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 18 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 19 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 20 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 21 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 22 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 23 |
+
SOFTWARE.
|
| 24 |
+
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from .helpers import COMMON_REQUEST_HEADERS
|
| 28 |
+
from bs4 import BeautifulSoup as bs
|
| 29 |
+
import requests
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def getLosers() -> dict:
|
| 33 |
+
baseurl = """https://m.bseindia.com"""
|
| 34 |
+
res = requests.get(baseurl, headers=COMMON_REQUEST_HEADERS)
|
| 35 |
+
c = res.content
|
| 36 |
+
soup = bs(c, "lxml")
|
| 37 |
+
for tag in soup("div"):
|
| 38 |
+
try:
|
| 39 |
+
if tag["id"] == "divLosers":
|
| 40 |
+
resSoup = tag
|
| 41 |
+
break
|
| 42 |
+
except KeyError:
|
| 43 |
+
continue
|
| 44 |
+
children = list(resSoup.table.contents)
|
| 45 |
+
children = children[1:]
|
| 46 |
+
losers = []
|
| 47 |
+
for tr in children:
|
| 48 |
+
td = tr.contents
|
| 49 |
+
loser = {
|
| 50 |
+
"securityID": str(td[0].a.string),
|
| 51 |
+
"scripCode": str(tr.td.a["href"].split("=")[1]),
|
| 52 |
+
"LTP": str(td[1].string),
|
| 53 |
+
"change": str(td[2].string),
|
| 54 |
+
"pChange": str(td[3].string),
|
| 55 |
+
}
|
| 56 |
+
losers.append(loser)
|
| 57 |
+
return losers
|
singular_ticker_causal/data_sources/bsedata/quote.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
|
| 3 |
+
MIT License
|
| 4 |
+
|
| 5 |
+
Copyright (c) 2018 - 2024 Shrey Dabhi
|
| 6 |
+
|
| 7 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 8 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 9 |
+
in the Software without restriction, including without limitation the rights
|
| 10 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 11 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 12 |
+
furnished to do so, subject to the following conditions:
|
| 13 |
+
|
| 14 |
+
The above copyright notice and this permission notice shall be included in all
|
| 15 |
+
copies or substantial portions of the Software.
|
| 16 |
+
|
| 17 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 18 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 19 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 20 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 21 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 22 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 23 |
+
SOFTWARE.
|
| 24 |
+
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from .exceptions import InvalidStockException
|
| 28 |
+
from .helpers import COMMON_REQUEST_HEADERS
|
| 29 |
+
from datetime import datetime as dt
|
| 30 |
+
from bs4 import BeautifulSoup as bs
|
| 31 |
+
import requests
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def quote(scripCode: str) -> dict:
|
| 35 |
+
baseurl = """https://m.bseindia.com/StockReach.aspx?scripcd="""
|
| 36 |
+
res = requests.get(baseurl + scripCode, headers=COMMON_REQUEST_HEADERS)
|
| 37 |
+
c = res.content
|
| 38 |
+
soup = bs(c, "lxml")
|
| 39 |
+
|
| 40 |
+
res = {}
|
| 41 |
+
|
| 42 |
+
for span in soup("span"):
|
| 43 |
+
updt_date = soup.find("span", id="strongDate").text.split("-")[1].strip()
|
| 44 |
+
updt_diff = dt.strptime(updt_date, "%d %b %y | %I:%M %p") - dt.now()
|
| 45 |
+
if updt_diff.days < -7:
|
| 46 |
+
error_text = ""
|
| 47 |
+
error_text_element = soup.find("td", id="tdDispTxt")
|
| 48 |
+
if error_text_element is not None:
|
| 49 |
+
error_text = error_text_element.text
|
| 50 |
+
raise InvalidStockException(status=error_text)
|
| 51 |
+
try:
|
| 52 |
+
if span["class"][0] == "srcovalue":
|
| 53 |
+
try:
|
| 54 |
+
if span["id"] == "spanchangVal":
|
| 55 |
+
res["change"] = span.string.split("(")[0].strip()
|
| 56 |
+
res["pChange"] = span.string.split("(")[1].strip()[:-2]
|
| 57 |
+
except KeyError:
|
| 58 |
+
res["currentValue"] = span.strong.string
|
| 59 |
+
elif span["class"][0] == "companyname":
|
| 60 |
+
res["companyName"] = span.string
|
| 61 |
+
except KeyError:
|
| 62 |
+
try:
|
| 63 |
+
if span["id"] == "lblPBdate":
|
| 64 |
+
try:
|
| 65 |
+
res["priceBand"] = span.string.split(":")[1].strip()
|
| 66 |
+
except AttributeError:
|
| 67 |
+
res["priceBand"] = ""
|
| 68 |
+
elif span["id"] == "strongDate":
|
| 69 |
+
res["updatedOn"] = span.string.split("-")[1].strip()
|
| 70 |
+
except KeyError:
|
| 71 |
+
continue
|
| 72 |
+
|
| 73 |
+
for td in soup("td"):
|
| 74 |
+
try:
|
| 75 |
+
if td["id"] == "tdCShortName":
|
| 76 |
+
res["securityID"] = td.string.strip()
|
| 77 |
+
elif td["id"] == "tdscripcode":
|
| 78 |
+
res["scripCode"] = td.string.strip()
|
| 79 |
+
elif td["id"] == "tdgroup":
|
| 80 |
+
res["group"] = td.string.strip()
|
| 81 |
+
elif td["id"] == "tdfacevalue":
|
| 82 |
+
res["faceValue"] = td.string.strip()
|
| 83 |
+
elif td["id"] == "tdIndustry":
|
| 84 |
+
res["industry"] = td.string.strip()
|
| 85 |
+
elif td["id"] == "tdpcloseopen":
|
| 86 |
+
res["previousClose"] = td.string.split("/")[0].strip()
|
| 87 |
+
res["previousOpen"] = td.string.split("/")[1].strip()
|
| 88 |
+
elif td["id"] == "tdDHL":
|
| 89 |
+
res["dayHigh"] = td.string.split("/")[0].strip()
|
| 90 |
+
res["dayLow"] = td.string.split("/")[1].strip()
|
| 91 |
+
elif td["id"] == "td52WHL":
|
| 92 |
+
res["52weekHigh"] = td.string.split("/")[0].strip()
|
| 93 |
+
res["52weekLow"] = td.string.split("/")[1].strip()
|
| 94 |
+
elif td["id"] == "tdWAp":
|
| 95 |
+
res["weightedAvgPrice"] = td.string.strip()
|
| 96 |
+
elif td["id"] == "tdTTV":
|
| 97 |
+
res["totalTradedValue"] = td.string.strip() + " Cr."
|
| 98 |
+
elif td["id"] == "tdTTQW":
|
| 99 |
+
res["totalTradedQuantity"] = td.string.split("/")[0].strip() + " Lakh"
|
| 100 |
+
res["2WeekAvgQuantity"] = td.string.split("/")[1].strip() + " Lakh"
|
| 101 |
+
elif td["id"] == "tdMktCapVal":
|
| 102 |
+
res["marketCapFull"] = td.string.split("/")[0].strip() + " Cr."
|
| 103 |
+
res["marketCapFreeFloat"] = td.string.split("/")[1].strip() + " Cr."
|
| 104 |
+
except KeyError:
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
if res.get("priceBand", "") != "":
|
| 108 |
+
for tbody in soup("tbody"):
|
| 109 |
+
try:
|
| 110 |
+
if tbody["id"] == "PBtablebody":
|
| 111 |
+
data = tbody.contents[2]
|
| 112 |
+
res["upperPriceBand"] = data.contents[1].string.strip()
|
| 113 |
+
res["lowerPriceBand"] = data.contents[2].string.strip()
|
| 114 |
+
except KeyError:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
buy = {}
|
| 118 |
+
sell = {}
|
| 119 |
+
for td in soup("td"):
|
| 120 |
+
try:
|
| 121 |
+
if td["id"] == "tdBQ1":
|
| 122 |
+
buy["1"] = {
|
| 123 |
+
"quantity": td.string,
|
| 124 |
+
"price": td.next_sibling.next_sibling.string,
|
| 125 |
+
}
|
| 126 |
+
elif td["id"] == "tdBQ2":
|
| 127 |
+
buy["2"] = {
|
| 128 |
+
"quantity": td.string,
|
| 129 |
+
"price": td.next_sibling.next_sibling.string,
|
| 130 |
+
}
|
| 131 |
+
elif td["id"] == "tdBQ3":
|
| 132 |
+
buy["3"] = {
|
| 133 |
+
"quantity": td.string,
|
| 134 |
+
"price": td.next_sibling.next_sibling.string,
|
| 135 |
+
}
|
| 136 |
+
elif td["id"] == "tdBQ4":
|
| 137 |
+
buy["4"] = {
|
| 138 |
+
"quantity": td.string,
|
| 139 |
+
"price": td.next_sibling.next_sibling.string,
|
| 140 |
+
}
|
| 141 |
+
elif td["id"] == "tdBQ5":
|
| 142 |
+
buy["5"] = {
|
| 143 |
+
"quantity": td.string,
|
| 144 |
+
"price": td.next_sibling.next_sibling.string,
|
| 145 |
+
}
|
| 146 |
+
elif td["id"] == "tdSP1":
|
| 147 |
+
sell["1"] = {
|
| 148 |
+
"price": td.string,
|
| 149 |
+
"quantity": td.next_sibling.next_sibling.string,
|
| 150 |
+
}
|
| 151 |
+
elif td["id"] == "tdSP2":
|
| 152 |
+
sell["2"] = {
|
| 153 |
+
"price": td.string,
|
| 154 |
+
"quantity": td.next_sibling.next_sibling.string,
|
| 155 |
+
}
|
| 156 |
+
elif td["id"] == "tdSP3":
|
| 157 |
+
sell["3"] = {
|
| 158 |
+
"price": td.string,
|
| 159 |
+
"quantity": td.next_sibling.next_sibling.string,
|
| 160 |
+
}
|
| 161 |
+
elif td["id"] == "tdSP4":
|
| 162 |
+
sell["4"] = {
|
| 163 |
+
"price": td.string,
|
| 164 |
+
"quantity": td.next_sibling.next_sibling.string,
|
| 165 |
+
}
|
| 166 |
+
elif td["id"] == "tdSP5":
|
| 167 |
+
sell["5"] = {
|
| 168 |
+
"price": td.string,
|
| 169 |
+
"quantity": td.next_sibling.next_sibling.string,
|
| 170 |
+
}
|
| 171 |
+
except KeyError:
|
| 172 |
+
continue
|
| 173 |
+
res["buy"] = buy
|
| 174 |
+
res["sell"] = sell
|
| 175 |
+
|
| 176 |
+
return res
|
singular_ticker_causal/data_sources/fetcher.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import yfinance as yf
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from singular_ticker_causal.services.schema import INCOME_STATEMENT_NODES, BALANCE_SHEET_NODES, STRATEGIC_NODES
|
| 7 |
+
IND_AS_NODES = INCOME_STATEMENT_NODES + BALANCE_SHEET_NODES + STRATEGIC_NODES
|
| 8 |
+
from .nseconnect.nse import Nse
|
| 9 |
+
from .bsedata.bse import BSE
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Fetcher:
|
| 16 |
+
"""
|
| 17 |
+
Tiered fundamental data fetcher.
|
| 18 |
+
Priority: NSE/BSE → XBRL → IndianAPI → yfinance.
|
| 19 |
+
"""
|
| 20 |
+
def __init__(self, ticker: str, api_key: Optional[str] = None):
|
| 21 |
+
self.ticker = ticker if ticker.endswith(".NS") or ticker.endswith(".BO") else f"{ticker}.NS"
|
| 22 |
+
self.api_key = api_key
|
| 23 |
+
|
| 24 |
+
def fetch(self, start: str, end: str) -> pd.DataFrame:
|
| 25 |
+
logger.info(f"Fetching fundamentals for {self.ticker} from {start} to {end}")
|
| 26 |
+
|
| 27 |
+
df = None
|
| 28 |
+
if self.ticker.endswith(".NS"):
|
| 29 |
+
logger.info("Attempting NSE fetch...")
|
| 30 |
+
df = self._try_nse(start, end)
|
| 31 |
+
elif self.ticker.endswith(".BO"):
|
| 32 |
+
logger.info("Attempting BSE fetch...")
|
| 33 |
+
df = self._try_bse(start, end)
|
| 34 |
+
|
| 35 |
+
if df is None or df.empty:
|
| 36 |
+
logger.info("Attempting Tier 1: XBRL...")
|
| 37 |
+
df = self._try_xbrl(start, end)
|
| 38 |
+
if df is None or df.empty:
|
| 39 |
+
logger.info("Tier 1 failed or returned empty. Attempting Tier 2: IndianAPI...")
|
| 40 |
+
df = self._try_indianapi(start, end)
|
| 41 |
+
if df is None or df.empty:
|
| 42 |
+
logger.info("Tier 2 failed or returned empty. Attempting Tier 3: yfinance...")
|
| 43 |
+
df = self._try_yfinance(start, end)
|
| 44 |
+
|
| 45 |
+
if df is not None and not df.empty:
|
| 46 |
+
df = df.sort_index()
|
| 47 |
+
# Forward fill balance sheet items as they are point-in-time and usually stable
|
| 48 |
+
# We do this AFTER mapping in _try_yfinance, so we use mapped names
|
| 49 |
+
bs_cols = ["Total_Assets", "Shareholders_Equity", "Inventory", "Accounts_Payable", "Total_Debt", "PPE", "CWIP", "Intangible_Assets"]
|
| 50 |
+
available_bs_cols = [c for c in bs_cols if c in df.columns]
|
| 51 |
+
if available_bs_cols:
|
| 52 |
+
df[available_bs_cols] = df[available_bs_cols].ffill()
|
| 53 |
+
|
| 54 |
+
if df is None or df.empty:
|
| 55 |
+
logger.warning(f"No fundamental data found for {self.ticker}")
|
| 56 |
+
return pd.DataFrame(columns=IND_AS_NODES)
|
| 57 |
+
|
| 58 |
+
logger.info("Deriving strategic nodes...")
|
| 59 |
+
df = self._derive_strategic_nodes(df)
|
| 60 |
+
|
| 61 |
+
# Ensure all IND_AS_NODES are present
|
| 62 |
+
for node in IND_AS_NODES:
|
| 63 |
+
if node not in df.columns:
|
| 64 |
+
df[node] = np.nan
|
| 65 |
+
|
| 66 |
+
return df[IND_AS_NODES].sort_index()
|
| 67 |
+
|
| 68 |
+
def _try_nse(self, start: str, end: str) -> Optional[pd.DataFrame]:
|
| 69 |
+
# Using nseconnect for high-fidelity NSE data
|
| 70 |
+
try:
|
| 71 |
+
# Clean ticker (e.g. RELIANCE.NS -> RELIANCE)
|
| 72 |
+
clean_ticker = self.ticker.split('.')[0]
|
| 73 |
+
nse = Nse()
|
| 74 |
+
logger.info(f"Attempting to fetch NSE data for {clean_ticker}...")
|
| 75 |
+
# Note: nseconnect is primarily for quotes;
|
| 76 |
+
# for full fundamentals we still rely on yfinance or XBRL.
|
| 77 |
+
# We return None here to let it fall back, but the plumbing is now real.
|
| 78 |
+
quote = nse.get_quote(clean_ticker)
|
| 79 |
+
if quote:
|
| 80 |
+
logger.info(f"Successfully connected to NSE for {clean_ticker}")
|
| 81 |
+
return None
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger.error(f"Error fetching from NSE: {e}")
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
def _try_bse(self, start: str, end: str) -> Optional[pd.DataFrame]:
|
| 87 |
+
# Using bsedata for high-fidelity BSE data
|
| 88 |
+
try:
|
| 89 |
+
# TODO: Implement mapping from alphabetic ticker to numeric BSE scrip code
|
| 90 |
+
bse = BSE()
|
| 91 |
+
logger.info(f"Attempting to fetch BSE data for {self.ticker}...")
|
| 92 |
+
# Currently limited to quotes; returning None to fall back to yfinance
|
| 93 |
+
return None
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"Error fetching from BSE: {e}")
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
def _try_xbrl(self, start: str, end: str) -> Optional[pd.DataFrame]:
|
| 99 |
+
# Tier 1 extraction via python-xbrl / Arelle is currently in development
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
def _try_indianapi(self, start: str, end: str) -> Optional[pd.DataFrame]:
|
| 103 |
+
# Tier 2 integration for IndianAPI.in / FinEdge API is currently in development
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
def _try_yfinance(self, start: str, end: str) -> Optional[pd.DataFrame]:
|
| 107 |
+
try:
|
| 108 |
+
t = yf.Ticker(self.ticker)
|
| 109 |
+
q_fin = t.quarterly_financials.T
|
| 110 |
+
q_bs = t.quarterly_balance_sheet.T
|
| 111 |
+
q_cf = t.quarterly_cashflow.T
|
| 112 |
+
|
| 113 |
+
if q_fin.empty and q_bs.empty and q_cf.empty:
|
| 114 |
+
logger.warning("All yfinance statements (financials, balance_sheet, cashflow) are empty.")
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
# Merge all three statements
|
| 118 |
+
logger.info(f"Merging yfinance statements: q_fin={q_fin.shape}, q_bs={q_bs.shape}, q_cf={q_cf.shape}")
|
| 119 |
+
df = pd.concat([q_fin, q_bs, q_cf], axis=1)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
df = df.loc[:, ~df.columns.duplicated()] # Remove duplicate columns if any
|
| 123 |
+
logger.info(f"Merged shape after removing duplicates: {df.shape}")
|
| 124 |
+
|
| 125 |
+
# Map yfinance columns to IND_AS_NODES (Simplified mapping for MVP)
|
| 126 |
+
mapping = {
|
| 127 |
+
"Total Revenue": "Revenue",
|
| 128 |
+
"Cost Of Revenue": "COGS",
|
| 129 |
+
"Operating Expense": "Operating_Expenses",
|
| 130 |
+
"Operating Income": "EBIT",
|
| 131 |
+
"EBIT": "EBIT",
|
| 132 |
+
"EBITDA": "EBITDA",
|
| 133 |
+
"Interest Expense": "Interest_Expense",
|
| 134 |
+
"Pretax Income": "EBT",
|
| 135 |
+
"Tax Provision": "Tax_Expense",
|
| 136 |
+
"Net Income": "PAT",
|
| 137 |
+
"Total Assets": "Total_Assets",
|
| 138 |
+
"Stockholders Equity": "Shareholders_Equity",
|
| 139 |
+
"Depreciation And Amortization": "D_A",
|
| 140 |
+
"Inventory": "Inventory",
|
| 141 |
+
"Accounts Payable": "Accounts_Payable",
|
| 142 |
+
"Total Debt": "Total_Debt",
|
| 143 |
+
"Operating Cash Flow": "Operating_Cash_Flow",
|
| 144 |
+
"Capital Expenditure": "Capex",
|
| 145 |
+
"Net PPE": "PPE",
|
| 146 |
+
"Construction In Progress": "CWIP",
|
| 147 |
+
"Goodwill And Other Intangible Assets": "Intangible_Assets",
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
df = df.rename(columns=mapping)
|
| 151 |
+
df = df.loc[:, ~df.columns.duplicated()] # Remove duplicates after rename
|
| 152 |
+
df.index = pd.to_datetime(df.index)
|
| 153 |
+
return df
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.error(f"Error fetching from yfinance: {e}")
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _derive_strategic_nodes(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 160 |
+
"""
|
| 161 |
+
Compute Layer 3 nodes and other derived fields.
|
| 162 |
+
"""
|
| 163 |
+
df = df.copy()
|
| 164 |
+
|
| 165 |
+
# Helper to safely get a series from a column that might be a DataFrame
|
| 166 |
+
def get_series(name):
|
| 167 |
+
if name not in df.columns:
|
| 168 |
+
return None
|
| 169 |
+
col = df[name]
|
| 170 |
+
if isinstance(col, pd.DataFrame):
|
| 171 |
+
logger.warning(f"Column '{name}' is a DataFrame with multiple columns: {col.columns.tolist()}. Taking the first.")
|
| 172 |
+
return col.iloc[:, 0]
|
| 173 |
+
return col
|
| 174 |
+
|
| 175 |
+
# 1. Average Assets & Equity (Rolling 2-period mean)
|
| 176 |
+
assets = get_series("Total_Assets")
|
| 177 |
+
if assets is not None:
|
| 178 |
+
df["Average_Total_Assets"] = assets.rolling(window=2).mean().fillna(assets)
|
| 179 |
+
|
| 180 |
+
equity = get_series("Shareholders_Equity")
|
| 181 |
+
if equity is not None:
|
| 182 |
+
df["Average_Shareholders_Equity"] = equity.rolling(window=2).mean().fillna(equity)
|
| 183 |
+
|
| 184 |
+
# 2. Basic derivations
|
| 185 |
+
rev = get_series("Revenue")
|
| 186 |
+
cogs = get_series("COGS")
|
| 187 |
+
if rev is not None and cogs is not None:
|
| 188 |
+
df["Gross_Profit"] = rev - cogs.fillna(0)
|
| 189 |
+
|
| 190 |
+
ebit = get_series("EBIT")
|
| 191 |
+
da = get_series("D_A")
|
| 192 |
+
if ebit is not None:
|
| 193 |
+
# Proper EBITDA = EBIT + Depreciation & Amortization
|
| 194 |
+
df["EBITDA"] = ebit + da.fillna(0) if da is not None else ebit
|
| 195 |
+
|
| 196 |
+
pat = get_series("PAT")
|
| 197 |
+
if pat is not None and rev is not None:
|
| 198 |
+
df["Net_Profit_Margin"] = pat / rev.replace(0, np.nan)
|
| 199 |
+
|
| 200 |
+
avg_assets = get_series("Average_Total_Assets")
|
| 201 |
+
if rev is not None and avg_assets is not None:
|
| 202 |
+
df["Asset_Turnover"] = rev / avg_assets.replace(0, np.nan)
|
| 203 |
+
|
| 204 |
+
avg_equity = get_series("Average_Shareholders_Equity")
|
| 205 |
+
if avg_assets is not None and avg_equity is not None:
|
| 206 |
+
df["Equity_Multiplier"] = avg_assets / avg_equity.replace(0, np.nan)
|
| 207 |
+
|
| 208 |
+
npm = get_series("Net_Profit_Margin")
|
| 209 |
+
at = get_series("Asset_Turnover")
|
| 210 |
+
em = get_series("Equity_Multiplier")
|
| 211 |
+
if npm is not None and at is not None and em is not None:
|
| 212 |
+
df["ROE"] = npm * at * em
|
| 213 |
+
|
| 214 |
+
ocf = get_series("Operating_Cash_Flow")
|
| 215 |
+
capex = get_series("Capex")
|
| 216 |
+
if ocf is not None and capex is not None:
|
| 217 |
+
df["Free_Cash_Flow"] = ocf - capex.abs().fillna(0)
|
| 218 |
+
|
| 219 |
+
return df
|
singular_ticker_causal/data_sources/gdelt_client.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import random
|
| 3 |
+
import logging
|
| 4 |
+
import requests
|
| 5 |
+
import yfinance as yf
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import List, Dict, Any
|
| 8 |
+
from singular_ticker_causal.utils.llm_client import LLMClient
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ==============================================================
|
| 15 |
+
# GDELT DOC 2.0 FETCHER
|
| 16 |
+
# ==============================================================
|
| 17 |
+
|
| 18 |
+
def _fmt_gdelt_dt(dt: datetime) -> str:
|
| 19 |
+
"""
|
| 20 |
+
Convert datetime -> GDELT YYYYMMDDHHMMSS (UTC).
|
| 21 |
+
"""
|
| 22 |
+
if dt.tzinfo is not None:
|
| 23 |
+
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
| 24 |
+
|
| 25 |
+
return dt.strftime("%Y%m%d%H%M%S")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _dedupe_articles(articles: list[dict]) -> list[dict]:
|
| 29 |
+
"""
|
| 30 |
+
Deduplicate by URL/title combination.
|
| 31 |
+
"""
|
| 32 |
+
seen = set()
|
| 33 |
+
deduped = []
|
| 34 |
+
|
| 35 |
+
for article in articles:
|
| 36 |
+
key = (
|
| 37 |
+
article.get("link", "").strip(),
|
| 38 |
+
article.get("title", "").strip().lower(),
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
if key in seen:
|
| 42 |
+
continue
|
| 43 |
+
|
| 44 |
+
seen.add(key)
|
| 45 |
+
deduped.append(article)
|
| 46 |
+
|
| 47 |
+
return deduped
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class GDELTClient:
|
| 51 |
+
"""
|
| 52 |
+
Thin wrapper around GDELT Doc API for macro context.
|
| 53 |
+
"""
|
| 54 |
+
GDELT_DOC_API: str = "https://api.gdeltproject.org/api/v2/doc/doc"
|
| 55 |
+
GDELT_MAX_RECORDS: int = 250
|
| 56 |
+
GDELT_MAX_WINDOW_DAYS: int = 90
|
| 57 |
+
USER_AGENT: str = (
|
| 58 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
|
| 59 |
+
"Gecko/20100101 Firefox/128.0"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
def __init__(self):
|
| 63 |
+
pass
|
| 64 |
+
|
| 65 |
+
def fetch(
|
| 66 |
+
self,
|
| 67 |
+
keyword: str,
|
| 68 |
+
from_dt: datetime,
|
| 69 |
+
to_dt: datetime,
|
| 70 |
+
ticker: str | None = None,
|
| 71 |
+
**kwargs,
|
| 72 |
+
) -> List[Dict[str, Any]]:
|
| 73 |
+
"""
|
| 74 |
+
Main entry point for GDELT fetching.
|
| 75 |
+
If 'ticker' is provided, performs a broad generic search using yfinance context.
|
| 76 |
+
"""
|
| 77 |
+
if ticker:
|
| 78 |
+
return self.fetch_ticker_context(ticker, from_dt, to_dt, **kwargs)
|
| 79 |
+
|
| 80 |
+
logger.info(
|
| 81 |
+
"Fetching GDELT news for %s from %s to %s",
|
| 82 |
+
keyword,
|
| 83 |
+
from_dt,
|
| 84 |
+
to_dt,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
articles = self.fetch_gdelt_window(
|
| 89 |
+
keyword=keyword,
|
| 90 |
+
from_dt=from_dt,
|
| 91 |
+
to_dt=to_dt,
|
| 92 |
+
**kwargs,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
for article in articles:
|
| 96 |
+
article["credibility_weight"] = 0.5
|
| 97 |
+
article["source_type"] = "gdelt"
|
| 98 |
+
|
| 99 |
+
return articles
|
| 100 |
+
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.exception("Error fetching from GDELT: %s", e)
|
| 103 |
+
return []
|
| 104 |
+
|
| 105 |
+
def _extract_search_terms(self, summary: str, company_name: str) -> Dict[str, List[str]]:
|
| 106 |
+
"""Extract product, partner, and industry search terms from business summary using LLM."""
|
| 107 |
+
if not summary:
|
| 108 |
+
return {"products": [], "partners": [], "industry": []}
|
| 109 |
+
|
| 110 |
+
llm = LLMClient()
|
| 111 |
+
prompt = f"""
|
| 112 |
+
Extract major products/services, strategic partners, and industry-related details from the following summary of {company_name}.
|
| 113 |
+
|
| 114 |
+
Summary: {summary}
|
| 115 |
+
|
| 116 |
+
Important instructions:
|
| 117 |
+
1. For partners, include a few words describing the specific connection or nature of the relationship between {company_name} and the partner (e.g., "{company_name} strategic collaboration with [Partner]").
|
| 118 |
+
2. For industry, extract a few words describing the sector or niche {company_name} is connected to and its specific role/connection (e.g., "{company_name} provides [Services] in the [Industry] sector").
|
| 119 |
+
3. Keep it to the top 5 most relevant terms for products and partners, and top 3 for industry.
|
| 120 |
+
|
| 121 |
+
Return a JSON object with:
|
| 122 |
+
"products": ["product1", "product2", ...],
|
| 123 |
+
"partners": ["{company_name} [connection] [partner1]", ...],
|
| 124 |
+
"industry": ["{company_name} [role/connection] [industry]", ...]
|
| 125 |
+
|
| 126 |
+
Return ONLY valid JSON.
|
| 127 |
+
"""
|
| 128 |
+
try:
|
| 129 |
+
result = llm.chat_json([{"role": "user", "content": prompt}])
|
| 130 |
+
return {
|
| 131 |
+
"products": result.get("products", []),
|
| 132 |
+
"partners": result.get("partners", []),
|
| 133 |
+
"industry": result.get("industry", [])
|
| 134 |
+
}
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logger.error(f"[GDELT] LLM term extraction failed: {e}")
|
| 137 |
+
return {"products": [], "partners": [], "industry": []}
|
| 138 |
+
|
| 139 |
+
def fetch_ticker_context(
|
| 140 |
+
self,
|
| 141 |
+
ticker_symbol: str,
|
| 142 |
+
from_dt: datetime,
|
| 143 |
+
to_dt: datetime,
|
| 144 |
+
**kwargs
|
| 145 |
+
) -> List[Dict[str, Any]]:
|
| 146 |
+
"""
|
| 147 |
+
Generic fetcher that uses yfinance and LLM to build a broad context.
|
| 148 |
+
Sequence: products, partners, company name, company heads.
|
| 149 |
+
"""
|
| 150 |
+
logger.info(f"[GDELT] Fetching generic context for ticker: {ticker_symbol}")
|
| 151 |
+
|
| 152 |
+
try:
|
| 153 |
+
ticker = yf.Ticker(ticker_symbol)
|
| 154 |
+
info = ticker.info
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"[GDELT] yfinance failed for {ticker_symbol}: {e}")
|
| 157 |
+
return []
|
| 158 |
+
|
| 159 |
+
summary = info.get("longBusinessSummary", "")
|
| 160 |
+
company_name = info.get("longName") or info.get("shortName") or ticker_symbol
|
| 161 |
+
officers = info.get("companyOfficers", [])
|
| 162 |
+
|
| 163 |
+
# 1. LLM extraction
|
| 164 |
+
terms = self._extract_search_terms(summary, company_name)
|
| 165 |
+
products = terms.get("products", [])
|
| 166 |
+
partners = terms.get("partners", [])
|
| 167 |
+
industry = terms.get("industry", [])
|
| 168 |
+
|
| 169 |
+
# 2. Officer names
|
| 170 |
+
heads = [o.get("name") for o in officers if o.get("name")]
|
| 171 |
+
|
| 172 |
+
# 3. Execution sequence
|
| 173 |
+
all_articles = []
|
| 174 |
+
search_plan = [
|
| 175 |
+
("products", products),
|
| 176 |
+
("partners", partners),
|
| 177 |
+
("industry", industry),
|
| 178 |
+
("company_name", [company_name]),
|
| 179 |
+
("company_heads", heads)
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
for category, keywords in search_plan:
|
| 183 |
+
for kw in keywords:
|
| 184 |
+
if not kw: continue
|
| 185 |
+
logger.info(f"[GDELT] Scraping category '{category}': {kw}")
|
| 186 |
+
results = self.fetch_gdelt_window(
|
| 187 |
+
keyword=kw,
|
| 188 |
+
from_dt=from_dt,
|
| 189 |
+
to_dt=to_dt,
|
| 190 |
+
**kwargs
|
| 191 |
+
)
|
| 192 |
+
all_articles.extend(results)
|
| 193 |
+
# Polite pause to avoid aggressive rate limiting
|
| 194 |
+
time.sleep(random.uniform(2.0, 5.0))
|
| 195 |
+
|
| 196 |
+
return _dedupe_articles(all_articles)
|
| 197 |
+
|
| 198 |
+
def fetch_gdelt_window(
|
| 199 |
+
self,
|
| 200 |
+
keyword: str,
|
| 201 |
+
from_dt: datetime,
|
| 202 |
+
to_dt: datetime,
|
| 203 |
+
max_records: int = 250,
|
| 204 |
+
exact_phrase: bool = False,
|
| 205 |
+
source_country: str | None = None,
|
| 206 |
+
source_lang: str | None = None,
|
| 207 |
+
theme: str | None = None,
|
| 208 |
+
domain: str | None = None,
|
| 209 |
+
extra_query: str | None = None,
|
| 210 |
+
) -> list:
|
| 211 |
+
"""
|
| 212 |
+
Fetch historical articles from the GDELT DOC 2.0 API.
|
| 213 |
+
Includes robust retry logic with 5-minute cooldown for timeouts/rate-limits.
|
| 214 |
+
"""
|
| 215 |
+
if from_dt >= to_dt:
|
| 216 |
+
raise ValueError("from_dt must be earlier than to_dt")
|
| 217 |
+
|
| 218 |
+
window_days = (to_dt - from_dt).days
|
| 219 |
+
if window_days > self.GDELT_MAX_WINDOW_DAYS:
|
| 220 |
+
raise ValueError(
|
| 221 |
+
f"GDELT DOC API only supports ~{self.GDELT_MAX_WINDOW_DAYS} days history"
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
max_records = min(max_records, self.GDELT_MAX_RECORDS)
|
| 225 |
+
query = self._build_gdelt_query(
|
| 226 |
+
keyword=keyword,
|
| 227 |
+
exact_phrase=exact_phrase,
|
| 228 |
+
source_country=source_country,
|
| 229 |
+
source_lang=source_lang,
|
| 230 |
+
theme=theme,
|
| 231 |
+
domain=domain,
|
| 232 |
+
extra_query=extra_query,
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
params = {
|
| 236 |
+
"query": query,
|
| 237 |
+
"mode": "artlist",
|
| 238 |
+
"format": "json",
|
| 239 |
+
"maxrecords": max_records,
|
| 240 |
+
"sort": "DateDesc",
|
| 241 |
+
"STARTDATETIME": _fmt_gdelt_dt(from_dt),
|
| 242 |
+
"ENDDATETIME": _fmt_gdelt_dt(to_dt),
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
max_retries = 5
|
| 246 |
+
for attempt in range(max_retries):
|
| 247 |
+
try:
|
| 248 |
+
resp = requests.get(
|
| 249 |
+
self.GDELT_DOC_API,
|
| 250 |
+
params=params,
|
| 251 |
+
timeout=45,
|
| 252 |
+
headers={
|
| 253 |
+
"User-Agent": self.USER_AGENT,
|
| 254 |
+
"Accept": "application/json",
|
| 255 |
+
},
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
if resp.status_code == 429:
|
| 259 |
+
logger.warning(
|
| 260 |
+
"[GDELT] Rate limited (429). Cooling down for 5 minutes..."
|
| 261 |
+
)
|
| 262 |
+
time.sleep(305)
|
| 263 |
+
continue
|
| 264 |
+
|
| 265 |
+
resp.raise_for_status()
|
| 266 |
+
|
| 267 |
+
try:
|
| 268 |
+
data = resp.json()
|
| 269 |
+
except Exception as e:
|
| 270 |
+
logger.error("[GDELT] Invalid JSON response (Status %d): %s", resp.status_code, e)
|
| 271 |
+
# Log the body to see what GDELT is actually returning (likely HTML error)
|
| 272 |
+
body_snippet = resp.text[:500] if resp.text else "[Empty Response]"
|
| 273 |
+
logger.error("[GDELT] Response body snippet: %s", body_snippet)
|
| 274 |
+
|
| 275 |
+
# If it's an HTML error page, we might be blocked or throttled in a way that doesn't return 429
|
| 276 |
+
if "<html" in body_snippet.lower():
|
| 277 |
+
logger.warning("[GDELT] Received HTML instead of JSON. Cooling down for 5 minutes...")
|
| 278 |
+
time.sleep(305)
|
| 279 |
+
else:
|
| 280 |
+
time.sleep(10 * (attempt + 1))
|
| 281 |
+
continue
|
| 282 |
+
|
| 283 |
+
raw_articles = data.get("articles", [])
|
| 284 |
+
if not isinstance(raw_articles, list):
|
| 285 |
+
return []
|
| 286 |
+
|
| 287 |
+
results = []
|
| 288 |
+
for article in raw_articles:
|
| 289 |
+
title = article.get("title", "") or ""
|
| 290 |
+
url = article.get("url", "") or ""
|
| 291 |
+
if not title or not url:
|
| 292 |
+
continue
|
| 293 |
+
|
| 294 |
+
normalized = {
|
| 295 |
+
"feed_name": "GDELT",
|
| 296 |
+
"title": title.strip(),
|
| 297 |
+
"link": url.strip(),
|
| 298 |
+
"published": article.get("seendate", ""),
|
| 299 |
+
"summary": title.strip(),
|
| 300 |
+
"language": article.get("language", ""),
|
| 301 |
+
"sourcecountry": article.get("sourcecountry", ""),
|
| 302 |
+
"domain": article.get("domain", ""),
|
| 303 |
+
"socialimage": article.get("socialimage", ""),
|
| 304 |
+
}
|
| 305 |
+
results.append(normalized)
|
| 306 |
+
|
| 307 |
+
results = _dedupe_articles(results)
|
| 308 |
+
logger.info(
|
| 309 |
+
"[GDELT] Retrieved %d articles for '%s'",
|
| 310 |
+
len(results),
|
| 311 |
+
keyword,
|
| 312 |
+
)
|
| 313 |
+
return results
|
| 314 |
+
|
| 315 |
+
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
| 316 |
+
logger.warning(
|
| 317 |
+
"[GDELT] Timeout/Connection error on attempt %d/%d: %s. Cooling down 5 mins...",
|
| 318 |
+
attempt + 1, max_retries, e
|
| 319 |
+
)
|
| 320 |
+
time.sleep(305)
|
| 321 |
+
continue
|
| 322 |
+
|
| 323 |
+
except requests.exceptions.RequestException as e:
|
| 324 |
+
wait_time = min(60, (2 ** attempt) * 5 + random.uniform(0, 3))
|
| 325 |
+
logger.warning(
|
| 326 |
+
"[GDELT] Request error on attempt %d/%d: %s. Retrying in %.2fs",
|
| 327 |
+
attempt + 1, max_retries, e, wait_time
|
| 328 |
+
)
|
| 329 |
+
time.sleep(wait_time)
|
| 330 |
+
|
| 331 |
+
logger.error("[GDELT] Failed after %d retries", max_retries)
|
| 332 |
+
return []
|
| 333 |
+
|
| 334 |
+
def _build_gdelt_query(
|
| 335 |
+
self,
|
| 336 |
+
keyword: str,
|
| 337 |
+
exact_phrase: bool = False,
|
| 338 |
+
source_country: str | None = None,
|
| 339 |
+
source_lang: str | None = None,
|
| 340 |
+
theme: str | None = None,
|
| 341 |
+
domain: str | None = None,
|
| 342 |
+
extra_query: str | None = None,
|
| 343 |
+
) -> str:
|
| 344 |
+
"""Build a valid GDELT DOC API query string."""
|
| 345 |
+
query_parts = []
|
| 346 |
+
if keyword:
|
| 347 |
+
if exact_phrase and " " in keyword:
|
| 348 |
+
query_parts.append(f'"{keyword}"')
|
| 349 |
+
else:
|
| 350 |
+
query_parts.append(keyword)
|
| 351 |
+
|
| 352 |
+
if source_country:
|
| 353 |
+
query_parts.append(f"sourcecountry:{source_country.lower()}")
|
| 354 |
+
if source_lang:
|
| 355 |
+
query_parts.append(f"sourcelang:{source_lang.lower()}")
|
| 356 |
+
if theme:
|
| 357 |
+
query_parts.append(f"theme:{theme}")
|
| 358 |
+
if domain:
|
| 359 |
+
query_parts.append(f"domain:{domain}")
|
| 360 |
+
if extra_query:
|
| 361 |
+
query_parts.append(extra_query)
|
| 362 |
+
|
| 363 |
+
return " ".join(query_parts)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
if __name__ == "__main__":
|
| 367 |
+
import json
|
| 368 |
+
from datetime import timedelta
|
| 369 |
+
|
| 370 |
+
# Configure logging
|
| 371 |
+
logging.basicConfig(
|
| 372 |
+
level=logging.INFO,
|
| 373 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
client = GDELTClient()
|
| 377 |
+
|
| 378 |
+
# Test parameters
|
| 379 |
+
ticker = "RELIANCE.NS"
|
| 380 |
+
end_date = datetime.now(timezone.utc)
|
| 381 |
+
start_date = end_date - timedelta(days=3)
|
| 382 |
+
|
| 383 |
+
print(f"\n--- Testing GDELT Ticker Context Fetch: {ticker} ---")
|
| 384 |
+
try:
|
| 385 |
+
articles = client.fetch(
|
| 386 |
+
keyword="",
|
| 387 |
+
from_dt=start_date,
|
| 388 |
+
to_dt=end_date,
|
| 389 |
+
ticker=ticker
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
output_file = "gdelt.json"
|
| 393 |
+
with open(output_file, "w") as f:
|
| 394 |
+
json.dump(articles, f, indent=4)
|
| 395 |
+
|
| 396 |
+
print(f"Successfully fetched {len(articles)} articles.")
|
| 397 |
+
print(f"Output saved to {output_file}")
|
| 398 |
+
|
| 399 |
+
except Exception as e:
|
| 400 |
+
print(f"Test failed: {e}")
|
singular_ticker_causal/data_sources/news_client.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
singular_ticker_causal/data_sources/news_client.py
|
| 3 |
+
|
| 4 |
+
Fetches quarterly RSS news for a single ticker over a multi-year window.
|
| 5 |
+
- Operates on a single ticker (not a universe)
|
| 6 |
+
- Fetches quarterly windows across the full fundamental date range
|
| 7 |
+
- Returns articles structured for DenoisedNewsEncoder consumption
|
| 8 |
+
|
| 9 |
+
Sources:
|
| 10 |
+
• LiveMint RSS feeds
|
| 11 |
+
• CNBC-TV18 RSS feeds
|
| 12 |
+
• Other RSS feeds (Business Standard, Forbes India, Zee News, Economic Times, etc.)
|
| 13 |
+
• Trading Economics (Selenium — stream, economy, markets, India news)
|
| 14 |
+
• Zerodha Pulse (requests + BeautifulSoup)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import json
|
| 19 |
+
import time
|
| 20 |
+
import logging
|
| 21 |
+
import datetime
|
| 22 |
+
import requests
|
| 23 |
+
import feedparser
|
| 24 |
+
import yfinance as yf
|
| 25 |
+
from typing import List
|
| 26 |
+
from bs4 import BeautifulSoup
|
| 27 |
+
from selenium import webdriver
|
| 28 |
+
from selenium.webdriver.firefox.options import Options as FirefoxOptions
|
| 29 |
+
from selenium.webdriver.firefox.service import Service as FirefoxService
|
| 30 |
+
from selenium.webdriver.common.by import By
|
| 31 |
+
from selenium.webdriver.support.ui import WebDriverWait
|
| 32 |
+
from selenium.webdriver.support import expected_conditions as EC
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ==============================================================
|
| 39 |
+
# SCRAPER SOURCES
|
| 40 |
+
# ==============================================================
|
| 41 |
+
|
| 42 |
+
LIVEMINT_FEEDS = [
|
| 43 |
+
"https://www.livemint.com/rss/companies",
|
| 44 |
+
"https://www.livemint.com/rss/opinion",
|
| 45 |
+
"https://www.livemint.com/rss/money",
|
| 46 |
+
"https://www.livemint.com/rss/politics",
|
| 47 |
+
"https://www.livemint.com/rss/science",
|
| 48 |
+
"https://www.livemint.com/rss/industry",
|
| 49 |
+
"https://www.livemint.com/rss/education",
|
| 50 |
+
"https://www.livemint.com/rss/sports",
|
| 51 |
+
"https://www.livemint.com/rss/technology",
|
| 52 |
+
"https://www.livemint.com/rss/news",
|
| 53 |
+
"https://www.livemint.com/rss/markets",
|
| 54 |
+
"https://www.livemint.com/rss/AI",
|
| 55 |
+
"https://www.livemint.com/rss/insurance",
|
| 56 |
+
"https://www.livemint.com/rss/budget",
|
| 57 |
+
"https://www.livemint.com/rss/elections",
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
CNBC18_FEEDS = [
|
| 61 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/latest.xml",
|
| 62 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/india.xml",
|
| 63 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/economy.xml",
|
| 64 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/market.xml",
|
| 65 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/business.xml",
|
| 66 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/sports.xml",
|
| 67 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/politics.xml",
|
| 68 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/world.xml",
|
| 69 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/education.xml",
|
| 70 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/travel.xml",
|
| 71 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/auto.xml",
|
| 72 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/technology.xml",
|
| 73 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/personal-finance.xml",
|
| 74 |
+
"https://www.cnbctv18.com/commonfeeds/v1/cne/rss/web-stories.xml",
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
OTHER_FEEDS = [
|
| 78 |
+
"http://www.business-standard.com/rss/todays-paper.rss",
|
| 79 |
+
"https://www.forbesindia.com/commonfeeds/v1/frb/rss/blog.xml",
|
| 80 |
+
"http://zeenews.india.com/rss/business.xml",
|
| 81 |
+
"https://economictimes.indiatimes.com/rssfeedsdefault.cms",
|
| 82 |
+
"https://news.google.com/rss?cf=all&hl=en-IN&topic=b&gl=IN&ceid=IN:en",
|
| 83 |
+
"https://cfo.economictimes.indiatimes.com/rss/topstories",
|
| 84 |
+
"https://cfo.economictimes.indiatimes.com/rss/recentstories",
|
| 85 |
+
"https://cfo.economictimes.indiatimes.com/rss/corporate-finance",
|
| 86 |
+
"https://cfo.economictimes.indiatimes.com/rss/esg",
|
| 87 |
+
"https://cfo.economictimes.indiatimes.com/rss/cfo-tech",
|
| 88 |
+
"https://cfo.economictimes.indiatimes.com/rss/governance-risk-compliance",
|
| 89 |
+
"https://cfo.economictimes.indiatimes.com/rss/lateststories",
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
TE_SOURCES = {
|
| 93 |
+
"te_stream": "https://tradingeconomics.com/stream",
|
| 94 |
+
"te_economy": "https://tradingeconomics.com/stream?i=economy",
|
| 95 |
+
"te_markets": "https://tradingeconomics.com/stream?i=markets",
|
| 96 |
+
"te_india": "https://tradingeconomics.com/india/news",
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
ZERODHA_PULSE_URL = "https://pulse.zerodha.com/"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ==============================================================
|
| 103 |
+
# TRADING ECONOMICS Selenium Scraper
|
| 104 |
+
# ==============================================================
|
| 105 |
+
|
| 106 |
+
JS_SCROLL_DOWN = "window.scrollTo(0, document.body.scrollHeight); return document.body.scrollHeight;"
|
| 107 |
+
|
| 108 |
+
JS_EXTRACT_TE_NEWS = """
|
| 109 |
+
var items = [];
|
| 110 |
+
var listItems = document.querySelectorAll('li[id]');
|
| 111 |
+
listItems.forEach(function(li) {
|
| 112 |
+
var titleLink = li.querySelector('a[href]');
|
| 113 |
+
if (!titleLink) return;
|
| 114 |
+
var titleText = '';
|
| 115 |
+
var bTag = titleLink.querySelector('b');
|
| 116 |
+
if (bTag) { titleText = bTag.textContent.trim(); }
|
| 117 |
+
else { titleText = titleLink.textContent.trim(); }
|
| 118 |
+
if (!titleText) return;
|
| 119 |
+
var url = titleLink.getAttribute('href') || '';
|
| 120 |
+
if (url && !url.startsWith('http')) { url = 'https://tradingeconomics.com' + url; }
|
| 121 |
+
var descEl = li.querySelector('.te-stream-item-description, span[style]');
|
| 122 |
+
var description = descEl ? descEl.textContent.trim() : '';
|
| 123 |
+
var dateEl = li.querySelector('small');
|
| 124 |
+
var dateText = dateEl ? dateEl.textContent.trim() : '';
|
| 125 |
+
var countryEl = li.querySelector('.te-stream-country');
|
| 126 |
+
var country = countryEl ? countryEl.textContent.trim() : '';
|
| 127 |
+
var categoryEl = li.querySelector('.te-stream-category');
|
| 128 |
+
var category = categoryEl ? categoryEl.textContent.trim() : '';
|
| 129 |
+
items.push({ title: titleText, description: description, date: dateText, url: url, country: country, category: category });
|
| 130 |
+
});
|
| 131 |
+
return JSON.stringify(items);
|
| 132 |
+
"""
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class NewsClient:
|
| 136 |
+
"""
|
| 137 |
+
Fetches news for a single ticker across a multi-year window by
|
| 138 |
+
chunking into quarterly GDELT calls. Implements the same cache-first
|
| 139 |
+
pattern as causal.test_causal_flow.test_causal_with_text.
|
| 140 |
+
|
| 141 |
+
Usage:
|
| 142 |
+
client = NewsClient("RELIANCE")
|
| 143 |
+
articles = client.fetch("2022-01-01", "2026-04-30")
|
| 144 |
+
# articles: List[dict] with keys: title, content, published, url, credibility_weight, source
|
| 145 |
+
"""
|
| 146 |
+
USER_AGENT: str = (
|
| 147 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
|
| 148 |
+
"Gecko/20100101 Firefox/128.0"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
def __init__(self, ticker: str):
|
| 152 |
+
self.ticker = ticker.upper().replace(".NS", "")
|
| 153 |
+
self.keyword = self._get_ticker_keyword(self.ticker)
|
| 154 |
+
|
| 155 |
+
def _get_ticker_keyword(self, ticker: str) -> str:
|
| 156 |
+
"""Fetch a search-friendly name for the ticker from yfinance."""
|
| 157 |
+
symbol = ticker if "." in ticker else f"{ticker}.NS"
|
| 158 |
+
try:
|
| 159 |
+
t = yf.Ticker(symbol)
|
| 160 |
+
info = t.info
|
| 161 |
+
name = info.get("shortName") or info.get("longName") or ticker
|
| 162 |
+
# Clean up the name for better matching
|
| 163 |
+
for suffix in [" Limited", " Ltd.", " Ltd", " Corp.", " Corp", " Inc.", " Inc"]:
|
| 164 |
+
name = name.replace(suffix, "")
|
| 165 |
+
return name.strip()
|
| 166 |
+
except Exception as e:
|
| 167 |
+
logger.warning(f"Failed to fetch yfinance info for {symbol}: {e}")
|
| 168 |
+
return ticker
|
| 169 |
+
|
| 170 |
+
def _normalise(self, raw: dict) -> dict:
|
| 171 |
+
"""Convert the raw dict format from news.py into a pipeline-ready article dict."""
|
| 172 |
+
title = raw.get("title", "").strip()
|
| 173 |
+
summary = raw.get("summary", "") or raw.get("description", "") or ""
|
| 174 |
+
content = f"{title}. {summary}".strip(". ") if summary and summary != title else title
|
| 175 |
+
pub_raw = raw.get("published", "") or raw.get("seendate", "")
|
| 176 |
+
# Try to parse the published field
|
| 177 |
+
pub_dt = None
|
| 178 |
+
if pub_raw:
|
| 179 |
+
try:
|
| 180 |
+
from dateutil import parser as dp
|
| 181 |
+
pub_dt = dp.parse(pub_raw)
|
| 182 |
+
except Exception:
|
| 183 |
+
pub_dt = None
|
| 184 |
+
if pub_dt is None:
|
| 185 |
+
pub_dt = datetime.datetime.now(datetime.timezone.utc)
|
| 186 |
+
elif pub_dt.tzinfo is None:
|
| 187 |
+
pub_dt = pub_dt.replace(tzinfo=datetime.timezone.utc)
|
| 188 |
+
|
| 189 |
+
return {
|
| 190 |
+
"title": title,
|
| 191 |
+
"content": content,
|
| 192 |
+
"published": pub_dt.isoformat(),
|
| 193 |
+
"url": raw.get("link", "") or raw.get("url", ""),
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
def fetch(
|
| 197 |
+
self,
|
| 198 |
+
start: str,
|
| 199 |
+
end: str,
|
| 200 |
+
include_pulse: bool = False,
|
| 201 |
+
include_te: bool = False,
|
| 202 |
+
) -> List[dict]:
|
| 203 |
+
"""
|
| 204 |
+
Fetch all news for self.ticker between start and end.
|
| 205 |
+
Aggregates RSS, Pulse, and (optionally) Trading Economics.
|
| 206 |
+
"""
|
| 207 |
+
|
| 208 |
+
logger.info(
|
| 209 |
+
f"Fetching news for {self.ticker} ('{self.keyword}') "
|
| 210 |
+
f"from {start} to {end}..."
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
all_raw: List[dict] = []
|
| 214 |
+
|
| 215 |
+
# 1. RSS Feeds
|
| 216 |
+
logger.info("Searching RSS feeds...")
|
| 217 |
+
rss_articles = (
|
| 218 |
+
self.search_rss_feeds(LIVEMINT_FEEDS, self.keyword, "LiveMint")
|
| 219 |
+
+ self.search_rss_feeds(CNBC18_FEEDS, self.keyword, "CNBC18")
|
| 220 |
+
+ self.search_rss_feeds(OTHER_FEEDS, self.keyword, "OtherFeeds")
|
| 221 |
+
)
|
| 222 |
+
all_raw.extend(rss_articles)
|
| 223 |
+
|
| 224 |
+
# 2. Zerodha Pulse
|
| 225 |
+
if include_pulse:
|
| 226 |
+
logger.info("Fetching Zerodha Pulse...")
|
| 227 |
+
pulse_news = self.scrape_pulse()
|
| 228 |
+
all_raw.extend(pulse_news)
|
| 229 |
+
|
| 230 |
+
# 3. Trading Economics (Optional)
|
| 231 |
+
if include_te:
|
| 232 |
+
logger.info("Fetching Trading Economics...")
|
| 233 |
+
te_data = self.scrape_all_te(headless=True, scroll_count=1)
|
| 234 |
+
for items_list in te_data.values():
|
| 235 |
+
all_raw.extend(items_list)
|
| 236 |
+
|
| 237 |
+
# # Filter by date window
|
| 238 |
+
# start_dt = datetime.datetime(
|
| 239 |
+
# *[int(x) for x in start.split("-")], tzinfo=datetime.timezone.utc
|
| 240 |
+
# )
|
| 241 |
+
# end_dt = datetime.datetime(
|
| 242 |
+
# *[int(x) for x in end.split("-")], tzinfo=datetime.timezone.utc
|
| 243 |
+
# )
|
| 244 |
+
# in_window = self._filter_by_window(all_raw, start_dt, end_dt)
|
| 245 |
+
# logger.info(f"Collected {len(in_window)} articles in window.")
|
| 246 |
+
|
| 247 |
+
# # Normalise and deduplicate by title
|
| 248 |
+
# seen_titles: set = set()
|
| 249 |
+
# articles: List[dict] = []
|
| 250 |
+
# for raw in all_raw:
|
| 251 |
+
# title = raw.get("title", "").strip().lower()
|
| 252 |
+
# if not title or title in seen_titles:
|
| 253 |
+
# continue
|
| 254 |
+
# seen_titles.add(title)
|
| 255 |
+
# articles.append(self._normalise(raw))
|
| 256 |
+
|
| 257 |
+
# Sort chronologically
|
| 258 |
+
all_raw.sort(key=lambda a: str(a.get("published") or a.get("date") or ""))
|
| 259 |
+
|
| 260 |
+
logger.info(
|
| 261 |
+
f"Final corpus: {len(all_raw)} unique articles for {self.ticker}."
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
return all_raw
|
| 265 |
+
|
| 266 |
+
def search_rss_feeds(self, feeds: list, search_keyword: str, feed_name: str) -> list:
|
| 267 |
+
"""Search for a keyword across a list of RSS feed URLs."""
|
| 268 |
+
print(f"[DEBUG] search_rss_feeds - Searching {feed_name} for keyword: '{search_keyword}'")
|
| 269 |
+
results = []
|
| 270 |
+
for feed_url in feeds:
|
| 271 |
+
print(f"[DEBUG] search_rss_feeds - Parsing URL: {feed_url}")
|
| 272 |
+
try:
|
| 273 |
+
feed = feedparser.parse(feed_url)
|
| 274 |
+
if not feed.entries:
|
| 275 |
+
print(f"[DEBUG] search_rss_feeds - No entries found for {feed_url}")
|
| 276 |
+
for entry in feed.entries:
|
| 277 |
+
title = entry.get("title", "")
|
| 278 |
+
summary = entry.get("summary", "")
|
| 279 |
+
if not title:
|
| 280 |
+
continue
|
| 281 |
+
if search_keyword.lower() in title.lower() or search_keyword.lower() in summary.lower():
|
| 282 |
+
print(f"[DEBUG] search_rss_feeds - Match found: {title[:60]}...")
|
| 283 |
+
results.append({
|
| 284 |
+
"feed_name": feed_name,
|
| 285 |
+
"title": title,
|
| 286 |
+
"link": entry.get("link", ""),
|
| 287 |
+
"published": entry.get("published", ""),
|
| 288 |
+
"summary": summary,
|
| 289 |
+
})
|
| 290 |
+
except Exception as e:
|
| 291 |
+
print(f"[news.py] RSS error ({feed_url}): {e}")
|
| 292 |
+
print(f"[DEBUG] search_rss_feeds - {feed_name} done. Found {len(results)} items.")
|
| 293 |
+
return results
|
| 294 |
+
|
| 295 |
+
def scrape_all_te(self, headless: bool = True, scroll_count: int = 3) -> dict:
|
| 296 |
+
"""Scrape all Trading Economics news sources. Returns dict of source_key -> list of items."""
|
| 297 |
+
driver = self._create_driver(headless=headless)
|
| 298 |
+
results = {}
|
| 299 |
+
scraped_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
| 300 |
+
try:
|
| 301 |
+
for key, url in TE_SOURCES.items():
|
| 302 |
+
try:
|
| 303 |
+
items = self._scrape_te_page(driver, url, scroll_count=scroll_count)
|
| 304 |
+
for item in items:
|
| 305 |
+
item["source"] = key
|
| 306 |
+
item["scraped_at"] = scraped_at
|
| 307 |
+
results[key] = items
|
| 308 |
+
except Exception as e:
|
| 309 |
+
print(f" [TE] Error scraping {key}: {e}")
|
| 310 |
+
results[key] = []
|
| 311 |
+
try:
|
| 312 |
+
driver.quit()
|
| 313 |
+
except Exception:
|
| 314 |
+
pass
|
| 315 |
+
driver = self._create_driver(headless=headless)
|
| 316 |
+
finally:
|
| 317 |
+
try:
|
| 318 |
+
driver.quit()
|
| 319 |
+
except Exception:
|
| 320 |
+
pass
|
| 321 |
+
return results
|
| 322 |
+
|
| 323 |
+
def scrape_pulse(self) -> list:
|
| 324 |
+
"""Scrape latest news from Zerodha Pulse using requests + BeautifulSoup."""
|
| 325 |
+
print("[news.py] Scraping Zerodha Pulse...")
|
| 326 |
+
headers = {
|
| 327 |
+
"User-Agent": self.USER_AGENT,
|
| 328 |
+
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
| 329 |
+
"Accept-Language": "en-US,en;q=0.5",
|
| 330 |
+
}
|
| 331 |
+
try:
|
| 332 |
+
resp = requests.get(ZERODHA_PULSE_URL, headers=headers, timeout=30)
|
| 333 |
+
resp.raise_for_status()
|
| 334 |
+
except Exception as e:
|
| 335 |
+
print(f"[news.py] Zerodha Pulse error: {e}")
|
| 336 |
+
return []
|
| 337 |
+
|
| 338 |
+
soup = BeautifulSoup(resp.text, "html.parser")
|
| 339 |
+
items = []
|
| 340 |
+
scraped_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
| 341 |
+
|
| 342 |
+
for li in soup.select("li.item"):
|
| 343 |
+
title_el = li.select_one("h2.title a")
|
| 344 |
+
if not title_el:
|
| 345 |
+
continue
|
| 346 |
+
title = title_el.get_text(strip=True)
|
| 347 |
+
url = title_el.get("href", "")
|
| 348 |
+
desc_el = li.select_one("div.desc")
|
| 349 |
+
description = desc_el.get_text(strip=True) if desc_el else ""
|
| 350 |
+
date_el = li.select_one("span.date")
|
| 351 |
+
date_text = date_el.get_text(strip=True) if date_el else ""
|
| 352 |
+
feed_el = li.select_one("span.feed")
|
| 353 |
+
feed = feed_el.get_text(strip=True) if feed_el else ""
|
| 354 |
+
items.append({
|
| 355 |
+
"title": title,
|
| 356 |
+
"description": description,
|
| 357 |
+
"published": date_text,
|
| 358 |
+
"link": url,
|
| 359 |
+
"summary": description,
|
| 360 |
+
"publisher": feed,
|
| 361 |
+
"scraped_at": scraped_at,
|
| 362 |
+
"source": "pulse"
|
| 363 |
+
})
|
| 364 |
+
|
| 365 |
+
print(f"[news.py] Zerodha Pulse: extracted {len(items)} items.")
|
| 366 |
+
return items
|
| 367 |
+
|
| 368 |
+
def _filter_by_window(
|
| 369 |
+
self,
|
| 370 |
+
articles: list,
|
| 371 |
+
from_dt: datetime.datetime,
|
| 372 |
+
to_dt: datetime.datetime,
|
| 373 |
+
) -> list:
|
| 374 |
+
"""
|
| 375 |
+
Drop articles whose parsed `published` timestamp falls outside [from_dt, to_dt].
|
| 376 |
+
Articles with unparseable or missing dates are kept (conservative).
|
| 377 |
+
"""
|
| 378 |
+
from dateutil import parser as dp
|
| 379 |
+
|
| 380 |
+
def _to_utc(dt):
|
| 381 |
+
if dt.tzinfo is None:
|
| 382 |
+
return dt.replace(tzinfo=datetime.timezone.utc)
|
| 383 |
+
return dt.astimezone(datetime.timezone.utc)
|
| 384 |
+
|
| 385 |
+
from_utc = _to_utc(from_dt)
|
| 386 |
+
to_utc = _to_utc(to_dt)
|
| 387 |
+
|
| 388 |
+
filtered = []
|
| 389 |
+
for art in articles:
|
| 390 |
+
pub = art.get("published", "")
|
| 391 |
+
if not pub:
|
| 392 |
+
filtered.append(art)
|
| 393 |
+
continue
|
| 394 |
+
try:
|
| 395 |
+
dt = _to_utc(dp.parse(pub))
|
| 396 |
+
if from_utc <= dt <= to_utc:
|
| 397 |
+
filtered.append(art)
|
| 398 |
+
except Exception:
|
| 399 |
+
filtered.append(art) # keep on parse failure
|
| 400 |
+
return filtered
|
| 401 |
+
|
| 402 |
+
def _create_driver(self, headless: bool = True):
|
| 403 |
+
"""Create a headless Firefox webdriver."""
|
| 404 |
+
options = FirefoxOptions()
|
| 405 |
+
if headless:
|
| 406 |
+
options.add_argument("--headless")
|
| 407 |
+
options.set_preference("general.useragent.override", self.USER_AGENT)
|
| 408 |
+
options.set_preference("dom.webdriver.enabled", False)
|
| 409 |
+
options.set_preference("useAutomationExtension", False)
|
| 410 |
+
service = FirefoxService(log_output=os.devnull)
|
| 411 |
+
driver = webdriver.Firefox(options=options, service=service)
|
| 412 |
+
driver.set_page_load_timeout(60)
|
| 413 |
+
return driver
|
| 414 |
+
|
| 415 |
+
def _scrape_te_page(self, driver, url: str, scroll_count: int = 3, scroll_pause: float = 2.0) -> list:
|
| 416 |
+
"""Scrape a single Trading Economics news page."""
|
| 417 |
+
print(f" [TE] Loading: {url}")
|
| 418 |
+
driver.get(url)
|
| 419 |
+
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.TAG_NAME, "body")))
|
| 420 |
+
time.sleep(3)
|
| 421 |
+
prev_height = 0
|
| 422 |
+
for i in range(scroll_count):
|
| 423 |
+
new_height = driver.execute_script(JS_SCROLL_DOWN)
|
| 424 |
+
if new_height == prev_height:
|
| 425 |
+
break
|
| 426 |
+
prev_height = new_height
|
| 427 |
+
time.sleep(scroll_pause)
|
| 428 |
+
raw = driver.execute_script(JS_EXTRACT_TE_NEWS)
|
| 429 |
+
items = json.loads(raw)
|
| 430 |
+
print(f" [TE] Extracted {len(items)} items from {url}")
|
| 431 |
+
return items
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
if __name__ == "__main__":
|
| 435 |
+
"""Simple test runner for NewsClient."""
|
| 436 |
+
logging.basicConfig(
|
| 437 |
+
level=logging.INFO,
|
| 438 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
ticker = "RELIANCE"
|
| 442 |
+
client = NewsClient(ticker)
|
| 443 |
+
|
| 444 |
+
# Test Fetch (Aggregates RSS and Pulse)
|
| 445 |
+
print(f"\n--- Testing Fetch for {ticker} ---")
|
| 446 |
+
start_date = (datetime.datetime.now() - datetime.timedelta(days=7)).strftime("%Y-%m-%d")
|
| 447 |
+
end_date = datetime.datetime.now().strftime("%Y-%m-%d")
|
| 448 |
+
|
| 449 |
+
# We include_te=False by default as it requires Selenium/Firefox
|
| 450 |
+
articles = client.fetch(start_date, end_date)
|
| 451 |
+
print(f"Fetched {len(articles)} articles.")
|
| 452 |
+
|
| 453 |
+
if articles:
|
| 454 |
+
# Save results to debug_data/
|
| 455 |
+
with open("news.json", "w", encoding="utf-8") as f:
|
| 456 |
+
json.dump(articles, f, indent=4, ensure_ascii=False)
|
| 457 |
+
for a in articles[:3]:
|
| 458 |
+
print(f" - [{a['published']}] {a['title']}")
|
| 459 |
+
else:
|
| 460 |
+
print("No articles found in the specified window.")
|
singular_ticker_causal/data_sources/nseconnect/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
The MIT License (MIT)
|
| 3 |
+
|
| 4 |
+
Copyright (c) 2014 Noufal Nazar
|
| 5 |
+
|
| 6 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 7 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 8 |
+
in the Software without restriction, including without limitation the rights
|
| 9 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 10 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 11 |
+
furnished to do so, subject to the following conditions:
|
| 12 |
+
|
| 13 |
+
The above copyright notice and this permission notice shall be included in all
|
| 14 |
+
copies or substantial portions of the Software.
|
| 15 |
+
|
| 16 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 17 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 18 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 19 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 20 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 21 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 22 |
+
SOFTWARE.
|
| 23 |
+
"""
|
| 24 |
+
__VERSION__='2.0.1'
|
| 25 |
+
from .nse import Nse
|
singular_ticker_causal/data_sources/nseconnect/bases.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
The MIT License (MIT)
|
| 3 |
+
|
| 4 |
+
Copyright (c) 2014 Noufal Nazar
|
| 5 |
+
|
| 6 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 7 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 8 |
+
in the Software without restriction, including without limitation the rights
|
| 9 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 10 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 11 |
+
furnished to do so, subject to the following conditions:
|
| 12 |
+
|
| 13 |
+
The above copyright notice and this permission notice shall be included in all
|
| 14 |
+
copies or substantial portions of the Software.
|
| 15 |
+
|
| 16 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 17 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 18 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 19 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 20 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 21 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 22 |
+
SOFTWARE.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from abc import ABCMeta, abstractmethod
|
| 26 |
+
import six
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class AbstractBaseExchange(six.with_metaclass(ABCMeta, object)):
|
| 30 |
+
|
| 31 |
+
@abstractmethod
|
| 32 |
+
def get_stock_codes(self):
|
| 33 |
+
"""
|
| 34 |
+
:return: list of tuples with stock code and stock name
|
| 35 |
+
"""
|
| 36 |
+
raise NotImplementedError
|
| 37 |
+
|
| 38 |
+
@abstractmethod
|
| 39 |
+
def is_valid_code(self, code):
|
| 40 |
+
"""
|
| 41 |
+
:return: True, if it is a valid stock code, else False
|
| 42 |
+
"""
|
| 43 |
+
raise NotImplementedError
|
| 44 |
+
|
| 45 |
+
@abstractmethod
|
| 46 |
+
def get_quote(self, code):
|
| 47 |
+
"""
|
| 48 |
+
:param code: a stock code
|
| 49 |
+
:return: a dictionary which contain detailed stock code.
|
| 50 |
+
"""
|
| 51 |
+
raise NotImplementedError
|
| 52 |
+
|
| 53 |
+
@abstractmethod
|
| 54 |
+
def get_top_gainers(self):
|
| 55 |
+
"""
|
| 56 |
+
:return: a sorted list of codes of top gainers
|
| 57 |
+
"""
|
| 58 |
+
raise NotImplementedError
|
| 59 |
+
|
| 60 |
+
@abstractmethod
|
| 61 |
+
def get_top_losers(self):
|
| 62 |
+
"""
|
| 63 |
+
:return: a sorted list of codes of top losers
|
| 64 |
+
"""
|
| 65 |
+
raise NotImplementedError
|
| 66 |
+
|
| 67 |
+
@abstractmethod
|
| 68 |
+
def __str__(self):
|
| 69 |
+
"""
|
| 70 |
+
:return: market name
|
| 71 |
+
"""
|
| 72 |
+
raise NotImplementedError
|
singular_ticker_causal/data_sources/nseconnect/cleaners.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module for various data structure cleaning tasks
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
dirty_data = """
|
| 6 |
+
{
|
| 7 |
+
"fname": "Jon",
|
| 8 |
+
"lname": "Doe",
|
| 9 |
+
"age": 20,
|
| 10 |
+
"str_age": "20",
|
| 11 |
+
"pi": 3.1415927,
|
| 12 |
+
"str_pi": "3.1415927",
|
| 13 |
+
"dob": "01-Jan-2023",
|
| 14 |
+
"mobile": [
|
| 15 |
+
{
|
| 16 |
+
"id": "Home",
|
| 17 |
+
"number": "123456789"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"id": "office",
|
| 21 |
+
"number": "987645321"
|
| 22 |
+
}
|
| 23 |
+
]
|
| 24 |
+
}
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def parse_values(obj):
|
| 28 |
+
for key, value in obj.items():
|
| 29 |
+
if isinstance(value, str):
|
| 30 |
+
# Try to parse as datetime if the string matches the format
|
| 31 |
+
date_formats = ["%d-%b-%Y", "%d-%m-%Y", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"]
|
| 32 |
+
for date_format in date_formats:
|
| 33 |
+
try:
|
| 34 |
+
obj[key] = datetime.strptime(value, date_format)
|
| 35 |
+
break
|
| 36 |
+
except ValueError:
|
| 37 |
+
pass
|
| 38 |
+
else:
|
| 39 |
+
# If the string couldn't be parsed as datetime, try numeric conversion
|
| 40 |
+
try:
|
| 41 |
+
obj[key] = int(value)
|
| 42 |
+
except ValueError:
|
| 43 |
+
try:
|
| 44 |
+
obj[key] = float(value)
|
| 45 |
+
except ValueError:
|
| 46 |
+
pass
|
| 47 |
+
elif isinstance(value, dict):
|
| 48 |
+
obj[key] = parse_values(value)
|
| 49 |
+
elif isinstance(value, list):
|
| 50 |
+
obj[key] = [parse_values(item) if isinstance(item, dict) else item for item in value]
|
| 51 |
+
return obj
|
singular_ticker_causal/data_sources/nseconnect/datemgr.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime as dt
|
| 2 |
+
from dateutil.relativedelta import relativedelta
|
| 3 |
+
from dateutil.parser import parse
|
| 4 |
+
from dateutil import rrule
|
| 5 |
+
from .errors import DateFormatError
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_nearest_business_day(d):
|
| 9 |
+
""" takes datetime object"""
|
| 10 |
+
if d.isoweekday() == 7 or d.isoweekday() == 6:
|
| 11 |
+
d = d - relativedelta(days=1)
|
| 12 |
+
return get_nearest_business_day(d)
|
| 13 |
+
|
| 14 |
+
# republic day
|
| 15 |
+
elif d.month == 1 and d.day == 26:
|
| 16 |
+
d = d - relativedelta(days=1)
|
| 17 |
+
return get_nearest_business_day(d)
|
| 18 |
+
# labour day
|
| 19 |
+
elif d.month == 5 and d.day == 1:
|
| 20 |
+
d = d - relativedelta(days=1)
|
| 21 |
+
return get_nearest_business_day(d)
|
| 22 |
+
# independece day
|
| 23 |
+
elif d.month == 8 and d.day == 15:
|
| 24 |
+
d = d - relativedelta(days=1)
|
| 25 |
+
return get_nearest_business_day(d)
|
| 26 |
+
# Gandhi Jayanti
|
| 27 |
+
elif d.month == 10 and d.day == 2:
|
| 28 |
+
d = d - relativedelta(days=1)
|
| 29 |
+
return get_nearest_business_day(d)
|
| 30 |
+
# chirstmas
|
| 31 |
+
elif d.month == 12 and d.day == 25:
|
| 32 |
+
d = d - relativedelta(days=1)
|
| 33 |
+
return get_nearest_business_day(d)
|
| 34 |
+
else:
|
| 35 |
+
return d
|
| 36 |
+
|
| 37 |
+
def is_known_holiday(d):
|
| 38 |
+
"""accepts datetime/date object and returns boolean"""
|
| 39 |
+
if type(d) == dt.datetime:
|
| 40 |
+
d = d.date()
|
| 41 |
+
elif type(d) != dt.date:
|
| 42 |
+
raise DateFormatError("only date objects or datetime objects")
|
| 43 |
+
else:
|
| 44 |
+
# fine do nothing
|
| 45 |
+
pass
|
| 46 |
+
|
| 47 |
+
# declare the list of holidays here.
|
| 48 |
+
# republic day.
|
| 49 |
+
if d.month == 1 and d.day == 26:
|
| 50 |
+
return True
|
| 51 |
+
# labour day
|
| 52 |
+
elif d.month == 5 and d.day == 1:
|
| 53 |
+
d = d - relativedelta(days=1)
|
| 54 |
+
return get_nearest_business_day(d)
|
| 55 |
+
# independence day
|
| 56 |
+
elif d.month == 8 and d.day == 15:
|
| 57 |
+
return True
|
| 58 |
+
# gandhi jayanti
|
| 59 |
+
elif d.month == 10 and d.day == 2:
|
| 60 |
+
return True
|
| 61 |
+
# christmas
|
| 62 |
+
elif d.month == 12 and d.day == 25:
|
| 63 |
+
return True
|
| 64 |
+
else:
|
| 65 |
+
return False
|
| 66 |
+
|
| 67 |
+
def mkdate(d):
|
| 68 |
+
"""tries its best to return a valid date. it can accept pharse like today,
|
| 69 |
+
yesterday, day before yesterday etc.
|
| 70 |
+
"""
|
| 71 |
+
# check if the it == a string
|
| 72 |
+
return_date = ""
|
| 73 |
+
if type(d) is str:
|
| 74 |
+
if d == "today":
|
| 75 |
+
return_date = dt.date.today()
|
| 76 |
+
elif d == "yesterday":
|
| 77 |
+
return_date = dt.date.today() - relativedelta(days=1)
|
| 78 |
+
elif d == "day before yesterday":
|
| 79 |
+
return_date = dt.date.today() - relativedelta(days=2)
|
| 80 |
+
else:
|
| 81 |
+
return_date = parse(d, dayfirst=True).date()
|
| 82 |
+
elif type(d) == dt.datetime:
|
| 83 |
+
return_date = d.date()
|
| 84 |
+
elif type(d) == dt.date:
|
| 85 |
+
return d
|
| 86 |
+
else:
|
| 87 |
+
raise DateFormatError("wrong date format %s" % str(d))
|
| 88 |
+
# check if future date.
|
| 89 |
+
return return_date
|
| 90 |
+
|
| 91 |
+
def usable_date(d):
|
| 92 |
+
"""accepts fuzzy format and returns most sensible date"""
|
| 93 |
+
return get_nearest_business_day(mkdate(d))
|
| 94 |
+
|
| 95 |
+
def get_date_range(frm, to, skip_dates=[]):
|
| 96 |
+
"""accepts fuzzy format date and returns business adjusted date ranges"""
|
| 97 |
+
# for x in rrule.rrule(rrule.DAILY, dtstart=s, until=dt.datetime.now(), byweekday=[0, 1, 2, 3, 4]): print(x)
|
| 98 |
+
frm = usable_date(frm)
|
| 99 |
+
to = usable_date(to)
|
| 100 |
+
datelist = []
|
| 101 |
+
for date in rrule.rrule(rrule.DAILY, dtstart=frm, until=to, byweekday=[0, 1, 2, 3, 4]):
|
| 102 |
+
if not is_known_holiday(date):
|
| 103 |
+
datelist.append(date.date())
|
| 104 |
+
return datelist
|
singular_ticker_causal/data_sources/nseconnect/downloader.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
The MIT License (MIT)
|
| 3 |
+
|
| 4 |
+
Copyright (c) 2014 Noufal Nazar
|
| 5 |
+
|
| 6 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 7 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 8 |
+
in the Software without restriction, including without limitation the rights
|
| 9 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 10 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 11 |
+
furnished to do so, subject to the following conditions:
|
| 12 |
+
|
| 13 |
+
The above copyright notice and this permission notice shall be included in all
|
| 14 |
+
copies or substantial portions of the Software.
|
| 15 |
+
|
| 16 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 17 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 18 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 19 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 20 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 21 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 22 |
+
SOFTWARE.
|
| 23 |
+
|
| 24 |
+
"""
|
| 25 |
+
import io
|
| 26 |
+
import os
|
| 27 |
+
import zipfile
|
| 28 |
+
import datetime as dt
|
| 29 |
+
from urllib.request import Request
|
| 30 |
+
from .datemgr import mkdate, get_date_range
|
| 31 |
+
from .nse import Nse
|
| 32 |
+
from abc import ABCMeta, abstractmethod
|
| 33 |
+
|
| 34 |
+
class BaseBhavcopyDownloader(metaclass=ABCMeta):
|
| 35 |
+
"""Base class for all types of bhavcopy downloader"""
|
| 36 |
+
def __init__(self, from_date, to_date=dt.datetime.now().date(), skip_dates=[]):
|
| 37 |
+
"""accepts date in fuzzy format"""
|
| 38 |
+
self.bhavcopy_base_url = "https://www.nseindia.com/content/historical/EQUITIES/%s/%s/cm%s%s%sbhav.csv.zip"
|
| 39 |
+
self.bhavcopy_base_filename = "cm%s%s%sbhav.csv"
|
| 40 |
+
self.from_date = from_date
|
| 41 |
+
self.to_date = to_date
|
| 42 |
+
self.skip_dates = skip_dates
|
| 43 |
+
self.nse = Nse()
|
| 44 |
+
self.dates = self.generate_dates()
|
| 45 |
+
|
| 46 |
+
def generate_dates(self):
|
| 47 |
+
return get_date_range(self.from_date, self.to_date, skip_dates=self.skip_dates)
|
| 48 |
+
|
| 49 |
+
def get_bhavcopy_url(self, d):
|
| 50 |
+
"""accept date and return bhavcopy url"""
|
| 51 |
+
day_of_month = d.strftime("%d")
|
| 52 |
+
mon = d.strftime("%b").upper()
|
| 53 |
+
year = d.year
|
| 54 |
+
url = self.bhavcopy_base_url % (year, mon, day_of_month, mon, year)
|
| 55 |
+
return url
|
| 56 |
+
|
| 57 |
+
def get_bhavcopy_filename(self, d):
|
| 58 |
+
"""for a given date generate bhavcopy filename"""
|
| 59 |
+
day_of_month = d.strftime("%d")
|
| 60 |
+
mon = d.strftime("%b").upper()
|
| 61 |
+
year = d.year
|
| 62 |
+
filename = self.bhavcopy_base_filename % (day_of_month, mon, year)
|
| 63 |
+
return filename
|
| 64 |
+
|
| 65 |
+
def download_one(self, d):
|
| 66 |
+
"""download bhavcopy for the given date"""
|
| 67 |
+
# this will keep this method usable for any arbitrary date.
|
| 68 |
+
d = mkdate(d)
|
| 69 |
+
# ex_url = "https://www.nseindia.com/content/historical/EQUITIES/2011/NOV/cm08NOV2011bhav.csv.zip"
|
| 70 |
+
url = self.get_bhavcopy_url(d)
|
| 71 |
+
print(url)
|
| 72 |
+
filename = self.get_bhavcopy_filename(d)
|
| 73 |
+
# response = requests.get(url, headers=self.headers)
|
| 74 |
+
response = self.nse.opener.open(Request(url, None, self.nse.headers))
|
| 75 |
+
zip_file_handle = io.BytesIO(response.read())
|
| 76 |
+
zf = zipfile.ZipFile(zip_file_handle)
|
| 77 |
+
return zf.read(filename).decode("utf-8")
|
| 78 |
+
|
| 79 |
+
@abstractmethod
|
| 80 |
+
def download(self):
|
| 81 |
+
pass
|
| 82 |
+
|
| 83 |
+
@abstractmethod
|
| 84 |
+
def update(self):
|
| 85 |
+
pass
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class BhavcopyFileSystemDownloader(BaseBhavcopyDownloader):
|
| 89 |
+
def __init__(self, directory, *args, **kwargs):
|
| 90 |
+
if (os.path.exists(directory) and os.path.isdir(directory) and os.access(directory, os.W_OK)):
|
| 91 |
+
super().__init__(*args, **kwargs)
|
| 92 |
+
self.directory = directory
|
| 93 |
+
else:
|
| 94 |
+
raise Exception("directory path must be valid and writtable, please check manually")
|
| 95 |
+
|
| 96 |
+
def download(self):
|
| 97 |
+
for date in self.dates:
|
| 98 |
+
print("downloading for " + str(date))
|
| 99 |
+
try:
|
| 100 |
+
content = self.download_one(date)
|
| 101 |
+
except Exception as err:
|
| 102 |
+
print("unable to download for the date: %s" % date.strftime("%Y-%m-%d"))
|
| 103 |
+
else:
|
| 104 |
+
fh = open(self.directory + "/" + date.strftime("%Y-%m-%d") + ".csv", "w")
|
| 105 |
+
fh.write(content)
|
| 106 |
+
fh.close()
|
| 107 |
+
|
| 108 |
+
def update(self):
|
| 109 |
+
pass
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == '__main__':
|
| 113 |
+
b = BhavcopyFileSystemDownloader(directory="/tmp/bhavcopy", from_date="01-01-2018")
|
| 114 |
+
b.download()
|
| 115 |
+
|
| 116 |
+
# https://stackoverflow.com/questions/49183801/ssl-certificate-verify-failed-with-urllib
|
singular_ticker_causal/data_sources/nseconnect/errors.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class BhavcopyNotAvailableError(Exception):
|
| 2 |
+
"""this error could occur in case you download bhavcopy for the dates
|
| 3 |
+
when the market was close"""
|
| 4 |
+
pass
|
| 5 |
+
|
| 6 |
+
class DateFormatError(Exception):
|
| 7 |
+
"""in case the date format is errorneous"""
|
| 8 |
+
pass
|