Spaces:
Sleeping
Sleeping
Abhijeet Mahapatra commited on
Commit ·
8b19c22
1
Parent(s): 35abc74
Removed causal learning and validation
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +0 -645
- server.py +396 -515
- singular_ticker_causal/.env +0 -3
- singular_ticker_causal/algorithms/CUTS_PLUS/cuts_plus.py +0 -834
- singular_ticker_causal/algorithms/CUTS_PLUS/data/__init__.py +0 -0
- singular_ticker_causal/algorithms/CUTS_PLUS/data/generate_data_mod.py +0 -430
- singular_ticker_causal/algorithms/CUTS_PLUS/data/simu_data.py +0 -313
- singular_ticker_causal/algorithms/CUTS_PLUS/data/utils.py +0 -136
- singular_ticker_causal/algorithms/CUTS_PLUS/lagged_graph.py +0 -95
- singular_ticker_causal/algorithms/CUTS_PLUS/model/cuts_plus_net.py +0 -153
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/causal_plot.py +0 -57
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/exp_utils.py +0 -286
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/gumbel_softmax.py +0 -66
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/logger.py +0 -74
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/misc.py +0 -292
- singular_ticker_causal/algorithms/CUTS_PLUS/utils/opt_type.py +0 -83
- singular_ticker_causal/algorithms/__init__.py +0 -2
- singular_ticker_causal/causal_inference/__init__.py +0 -15
- singular_ticker_causal/causal_inference/abduction.py +0 -127
- singular_ticker_causal/causal_inference/causal_model.py +0 -314
- singular_ticker_causal/causal_inference/estimator.py +0 -125
- singular_ticker_causal/causal_inference/identification.py +0 -41
- singular_ticker_causal/causal_inference/mutilator.py +0 -62
- singular_ticker_causal/causal_inference/pywhyllm_assumptions.py +0 -338
- singular_ticker_causal/causal_inference/query_engine.py +0 -505
- singular_ticker_causal/causal_inference/tests/test_causal_queries.py +0 -102
- singular_ticker_causal/causal_inference/tests/test_pywhyllm_assumptions.py +0 -193
- singular_ticker_causal/data_sources/__init__.py +0 -4
- singular_ticker_causal/data_sources/bsedata/__init__.py +0 -27
- singular_ticker_causal/data_sources/bsedata/bhavcopy.py +0 -58
- singular_ticker_causal/data_sources/bsedata/bse.py +0 -150
- singular_ticker_causal/data_sources/bsedata/exceptions.py +0 -51
- singular_ticker_causal/data_sources/bsedata/gainers.py +0 -57
- singular_ticker_causal/data_sources/bsedata/helpers.py +0 -3
- singular_ticker_causal/data_sources/bsedata/indices.py +0 -112
- singular_ticker_causal/data_sources/bsedata/losers.py +0 -57
- singular_ticker_causal/data_sources/bsedata/quote.py +0 -176
- singular_ticker_causal/data_sources/fetcher.py +0 -219
- singular_ticker_causal/data_sources/gdelt_client.py +0 -400
- singular_ticker_causal/data_sources/news_client.py +0 -460
- singular_ticker_causal/data_sources/nseconnect/__init__.py +0 -25
- singular_ticker_causal/data_sources/nseconnect/bases.py +0 -72
- singular_ticker_causal/data_sources/nseconnect/cleaners.py +0 -51
- singular_ticker_causal/data_sources/nseconnect/datemgr.py +0 -104
- singular_ticker_causal/data_sources/nseconnect/downloader.py +0 -116
- singular_ticker_causal/data_sources/nseconnect/errors.py +0 -8
- singular_ticker_causal/data_sources/nseconnect/nse.py +0 -624
- singular_ticker_causal/data_sources/nseconnect/ua.py +0 -137
- singular_ticker_causal/data_sources/nseconnect/urls.py +0 -35
- singular_ticker_causal/data_sources/nseconnect/utils.py +0 -373
README.md
CHANGED
|
@@ -14,648 +14,3 @@ pinned: false
|
|
| 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!
|
|
|
|
| 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 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
dashboard_server.py
|
| 3 |
====================
|
| 4 |
-
|
| 5 |
|
| 6 |
Architecture
|
| 7 |
------------
|
|
@@ -9,15 +9,9 @@ gr.Server (extends FastAPI)
|
|
| 9 |
├── GET / → serves frontend/index.html
|
| 10 |
├── GET /static/* → serves frontend/{style.css, app.js} (StaticFiles)
|
| 11 |
│
|
| 12 |
-
├── @server.api
|
| 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
|
| 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).
|
|
@@ -36,9 +30,14 @@ 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()
|
|
@@ -47,7 +46,7 @@ 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',
|
| 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))
|
|
@@ -65,19 +64,109 @@ logger = logging.getLogger("dashboard-server")
|
|
| 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="
|
| 81 |
description=(
|
| 82 |
"Iroha Financial Intelligence — real-time causal probability matrix, "
|
| 83 |
"HHKD decomposition, DoFlow inference and sector hierarchy over NIFTY50."
|
|
@@ -107,7 +196,7 @@ server.mount(
|
|
| 107 |
|
| 108 |
@server.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 109 |
async def serve_index():
|
| 110 |
-
"""Serve the
|
| 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"))
|
|
@@ -123,78 +212,6 @@ async def health():
|
|
| 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().
|
|
@@ -207,10 +224,6 @@ except Exception as e:
|
|
| 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,
|
|
@@ -254,7 +267,10 @@ def _fetch_causal_matrix(
|
|
| 254 |
raw = resp.read()
|
| 255 |
data = json.loads(raw)
|
| 256 |
if data.get("status") not in ("success", None):
|
| 257 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 258 |
return None
|
| 259 |
return data
|
| 260 |
except Exception as exc:
|
|
@@ -281,147 +297,7 @@ def _safe_json(obj: Any) -> Any:
|
|
| 281 |
return obj
|
| 282 |
|
| 283 |
|
| 284 |
-
def
|
| 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":
|
|
@@ -434,81 +310,168 @@ def _resolve_value(
|
|
| 434 |
return value
|
| 435 |
|
| 436 |
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 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 |
-
|
| 447 |
-
|
| 448 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
n = len(nodes)
|
| 460 |
T = data_level.shape[0]
|
| 461 |
|
| 462 |
-
#
|
| 463 |
-
|
| 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 |
-
#
|
| 477 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
|
| 504 |
|
| 505 |
@server.api(
|
| 506 |
name="run_inference",
|
| 507 |
description=(
|
| 508 |
-
"Run
|
| 509 |
-
"using a validated causal matrix from the backend. "
|
| 510 |
-
"Layers: 1=Association(
|
| 511 |
-
"3=Counterfactual(
|
| 512 |
),
|
| 513 |
concurrency_limit=4,
|
| 514 |
)
|
|
@@ -587,35 +550,38 @@ def run_inference(
|
|
| 587 |
"detail": payload.get("detail", f"No cached pipeline data for {ticker}."),
|
| 588 |
}
|
| 589 |
|
| 590 |
-
# ── 2.
|
| 591 |
-
|
| 592 |
-
|
|
|
|
|
|
|
|
|
|
| 593 |
|
| 594 |
-
|
|
|
|
| 595 |
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
|
| 602 |
-
# ── 3. pywhyllm structural guidance (
|
| 603 |
-
|
| 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(
|
| 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
|
|
@@ -623,36 +589,24 @@ def run_inference(
|
|
| 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 |
-
#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
dowhy_model = CausalModel(
|
| 657 |
data=df,
|
| 658 |
treatment=treatment,
|
|
@@ -668,18 +622,15 @@ def run_inference(
|
|
| 668 |
)
|
| 669 |
ate = float(estimate.value)
|
| 670 |
|
| 671 |
-
#
|
| 672 |
-
# DoWhy stores the sklearn estimator under estimate.estimator
|
| 673 |
se: float = 0.0
|
| 674 |
try:
|
| 675 |
-
|
| 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:
|
|
@@ -689,14 +640,14 @@ def run_inference(
|
|
| 689 |
ci_upper = ate + 1.96 * se
|
| 690 |
prob = min(1.0, abs(ate) / (abs(ate) + se + 1e-9))
|
| 691 |
|
| 692 |
-
# Ripple effects: downstream
|
| 693 |
ripple_effects = []
|
| 694 |
-
|
| 695 |
-
for j, node in enumerate(
|
| 696 |
if node == treatment or node == target_node:
|
| 697 |
continue
|
| 698 |
-
if
|
| 699 |
-
edge_score = float(
|
| 700 |
ripple_effects.append({
|
| 701 |
"ticker": node,
|
| 702 |
"direction": 1 if ate > 0 else -1,
|
|
@@ -714,47 +665,65 @@ def run_inference(
|
|
| 714 |
}
|
| 715 |
|
| 716 |
except Exception as dowhy_exc:
|
| 717 |
-
# DoWhy not installed or identification failed — fall back to
|
| 718 |
-
logger.warning("DoWhy association failed (%s), falling back to
|
| 719 |
-
|
| 720 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
result = {
|
| 722 |
-
"ate":
|
| 723 |
-
"ci_lower":
|
| 724 |
-
"ci_upper":
|
| 725 |
-
"probability": min(1.0, abs(
|
| 726 |
-
"strategy":
|
| 727 |
-
"adjustment_set":
|
| 728 |
-
"ripple_effects":
|
| 729 |
}
|
| 730 |
|
| 731 |
# ═══════════════════════════════════════════════════════════════════════
|
| 732 |
# LAYER 2 — Intervention: "What will happen to Y if we do X=value?"
|
| 733 |
-
#
|
| 734 |
-
# execution: DoWhy identifies + estimates; SCM engine propagates ripples
|
| 735 |
# ═══════════════════════════════════════════════════════════════════════
|
| 736 |
elif mode == "intervene":
|
| 737 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
| 747 |
try:
|
| 748 |
from dowhy import CausalModel
|
| 749 |
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
|
|
|
| 755 |
|
| 756 |
dowhy_model = CausalModel(
|
| 757 |
-
data=df,
|
| 758 |
treatment=treatment,
|
| 759 |
outcome=target_node,
|
| 760 |
graph=graph_dot,
|
|
@@ -762,63 +731,39 @@ def run_inference(
|
|
| 762 |
identified_estimand = dowhy_model.identify_effect(
|
| 763 |
proceed_when_unidentifiable=True
|
| 764 |
)
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 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
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
|
|
|
| 807 |
treatment=treatment,
|
| 808 |
-
|
| 809 |
-
targets=[target_node],
|
| 810 |
horizon=horizon,
|
| 811 |
)
|
| 812 |
|
| 813 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 820 |
ripple_effects = []
|
| 821 |
-
for node, delta_val in
|
| 822 |
if node == treatment:
|
| 823 |
continue
|
| 824 |
ripple_effects.append({
|
|
@@ -836,75 +781,51 @@ def run_inference(
|
|
| 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 |
-
#
|
| 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(
|
| 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 |
-
#
|
| 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
|
| 876 |
-
for
|
| 877 |
-
if
|
| 878 |
-
causal_graph.add_edge(
|
| 879 |
-
for node in
|
| 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)},
|
|
@@ -913,42 +834,32 @@ def run_inference(
|
|
| 913 |
)
|
| 914 |
|
| 915 |
factual_outcome = float(observed_data[target_node].iloc[0])
|
| 916 |
-
|
| 917 |
-
ite =
|
| 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 |
-
|
| 926 |
-
|
| 927 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 950 |
-
|
| 951 |
-
|
|
|
|
|
|
|
|
|
|
| 952 |
ci_lower = ite - 1.96 * se
|
| 953 |
ci_upper = ite + 1.96 * se
|
| 954 |
|
|
@@ -956,7 +867,7 @@ def run_inference(
|
|
| 956 |
"ate": ite,
|
| 957 |
"ite": ite,
|
| 958 |
"factual_outcome": factual_outcome,
|
| 959 |
-
"counterfactual_outcome":
|
| 960 |
"ci_lower": ci_lower,
|
| 961 |
"ci_upper": ci_upper,
|
| 962 |
"probability": min(1.0, abs(ite) / (abs(ite) + se + 1e-9)),
|
|
@@ -964,7 +875,7 @@ def run_inference(
|
|
| 964 |
"counterfactual_value": abs_cf_value,
|
| 965 |
"value_type": value_type,
|
| 966 |
"observed_t": t,
|
| 967 |
-
"shapley_contributions":
|
| 968 |
"ripple_effects": [],
|
| 969 |
}
|
| 970 |
|
|
@@ -985,36 +896,6 @@ def run_inference(
|
|
| 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 |
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1023,7 +904,7 @@ 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
|
| 1027 |
logger.info(f" → Frontend : http://localhost:{port}/")
|
| 1028 |
logger.info(f" → API docs : http://localhost:{port}/docs")
|
| 1029 |
|
|
|
|
| 1 |
"""
|
| 2 |
dashboard_server.py
|
| 3 |
====================
|
| 4 |
+
Iroha Financial Intelligence — gr.Server entry point.
|
| 5 |
|
| 6 |
Architecture
|
| 7 |
------------
|
|
|
|
| 9 |
├── GET / → serves frontend/index.html
|
| 10 |
├── GET /static/* → serves frontend/{style.css, app.js} (StaticFiles)
|
| 11 |
│
|
| 12 |
+
├── @server.api run_inference → DoFlow / SCM causal query (via BACKEND_API)
|
|
|
|
|
|
|
|
|
|
| 13 |
│
|
| 14 |
+
├── GET /v2/health → health-check
|
|
|
|
|
|
|
|
|
|
| 15 |
│
|
| 16 |
└── All existing /v2/* routers from main.py are included here too
|
| 17 |
(so this server is a superset of main.py).
|
|
|
|
| 30 |
import sys
|
| 31 |
import json
|
| 32 |
import logging
|
| 33 |
+
import urllib.error
|
| 34 |
+
import urllib.parse
|
| 35 |
+
import urllib.request
|
| 36 |
from pathlib import Path
|
| 37 |
+
from gradio import Server
|
| 38 |
from typing import Any, Dict, List, Optional
|
| 39 |
+
from fastapi.staticfiles import StaticFiles
|
| 40 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 41 |
from dotenv import load_dotenv
|
| 42 |
|
| 43 |
load_dotenv()
|
|
|
|
| 46 |
if str(BASE_DIR) not in sys.path:
|
| 47 |
sys.path.insert(0, str(BASE_DIR))
|
| 48 |
|
| 49 |
+
# Also add the backend directory to sys.path so we can import 'app', 'causal', etc.
|
| 50 |
BACKEND_DIR = (BASE_DIR.parent / "noisy_boy_backend").resolve()
|
| 51 |
if BACKEND_DIR.exists() and str(BACKEND_DIR) not in sys.path:
|
| 52 |
sys.path.insert(0, str(BACKEND_DIR))
|
|
|
|
| 64 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 65 |
# gr.Server
|
| 66 |
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
FRONTEND_DIR = BASE_DIR / "frontend"
|
| 69 |
INDEX_HTML = FRONTEND_DIR / "index.html"
|
| 70 |
|
| 71 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 72 |
+
# Backend URL
|
| 73 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 74 |
+
|
| 75 |
+
_BACKEND_BASE_URL: str = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 79 |
+
# Public API
|
| 80 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 81 |
+
|
| 82 |
+
def run_pipeline(
|
| 83 |
+
ticker: str = "RELIANCE",
|
| 84 |
+
threshold: float = 0.5,
|
| 85 |
+
treatment: Optional[str] = None,
|
| 86 |
+
outcome: Optional[str] = None,
|
| 87 |
+
include_pywhyllm: bool = False,
|
| 88 |
+
) -> Dict[str, Any]:
|
| 89 |
+
"""
|
| 90 |
+
Fetch the validated causal matrix for *ticker* from the backend API.
|
| 91 |
+
|
| 92 |
+
Parameters
|
| 93 |
+
----------
|
| 94 |
+
ticker : NSE symbol (e.g. RELIANCE, HDFCBANK)
|
| 95 |
+
threshold : adjacency threshold for DAG construction
|
| 96 |
+
treatment : optional treatment node for pywhyllm assumptions
|
| 97 |
+
outcome : optional outcome node for pywhyllm assumptions
|
| 98 |
+
include_pywhyllm: request pywhyllm assumption report from backend
|
| 99 |
+
|
| 100 |
+
Returns
|
| 101 |
+
-------
|
| 102 |
+
dict with keys:
|
| 103 |
+
nodes, adj_matrix, dag_adj, equations, data_level,
|
| 104 |
+
topological_order, nodes_graph, links_graph
|
| 105 |
+
Raises RuntimeError if the backend cannot be reached or returns an error.
|
| 106 |
+
"""
|
| 107 |
+
params: dict = {"threshold": threshold}
|
| 108 |
+
if treatment:
|
| 109 |
+
params["treatment"] = treatment
|
| 110 |
+
if outcome:
|
| 111 |
+
params["outcome"] = outcome
|
| 112 |
+
if include_pywhyllm:
|
| 113 |
+
params["include_pywhyllm"] = "true"
|
| 114 |
+
|
| 115 |
+
qs = urllib.parse.urlencode(params)
|
| 116 |
+
url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{qs}"
|
| 117 |
+
logger.info("run_pipeline: fetching %s", url)
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
with urllib.request.urlopen(url, timeout=60) as resp:
|
| 121 |
+
raw = resp.read()
|
| 122 |
+
except urllib.error.URLError as exc:
|
| 123 |
+
raise RuntimeError(
|
| 124 |
+
f"Could not reach backend at {_BACKEND_BASE_URL}. "
|
| 125 |
+
f"Ensure noisy_boy_backend is running. Original error: {exc}"
|
| 126 |
+
) from exc
|
| 127 |
+
|
| 128 |
+
payload = json.loads(raw)
|
| 129 |
+
|
| 130 |
+
status = payload.get("status")
|
| 131 |
+
if status == "not_found":
|
| 132 |
+
raise RuntimeError(
|
| 133 |
+
payload.get(
|
| 134 |
+
"detail",
|
| 135 |
+
f"No cached pipeline data for {ticker} on backend. "
|
| 136 |
+
"Run the singular-causal pipeline on the backend first.",
|
| 137 |
+
)
|
| 138 |
+
)
|
| 139 |
+
if status not in ("success", None, "ok"):
|
| 140 |
+
raise RuntimeError(
|
| 141 |
+
f"Backend returned unexpected status '{status}' for {ticker}. "
|
| 142 |
+
f"Payload: {payload}"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Build frontend-friendly graph representation
|
| 146 |
+
nodes: List[str] = payload.get("nodes", [])
|
| 147 |
+
adj_matrix = payload.get("adj_matrix", [])
|
| 148 |
+
dag_adj = payload.get("dag_adj", [])
|
| 149 |
+
|
| 150 |
+
nodes_graph = [{"id": n, "label": n} for n in nodes]
|
| 151 |
+
links_graph = []
|
| 152 |
+
for i, src in enumerate(nodes):
|
| 153 |
+
for j, dst in enumerate(nodes):
|
| 154 |
+
if i != j:
|
| 155 |
+
try:
|
| 156 |
+
score = float(adj_matrix[i][j])
|
| 157 |
+
except (IndexError, TypeError, ValueError):
|
| 158 |
+
score = 0.0
|
| 159 |
+
if score >= threshold:
|
| 160 |
+
links_graph.append({"source": src, "target": dst, "score": round(score, 4)})
|
| 161 |
+
|
| 162 |
+
return {
|
| 163 |
+
**payload,
|
| 164 |
+
"nodes_graph": nodes_graph,
|
| 165 |
+
"links_graph": links_graph,
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
server = Server(
|
| 169 |
+
title="Iroha Causal Terminal",
|
| 170 |
description=(
|
| 171 |
"Iroha Financial Intelligence — real-time causal probability matrix, "
|
| 172 |
"HHKD decomposition, DoFlow inference and sector hierarchy over NIFTY50."
|
|
|
|
| 196 |
|
| 197 |
@server.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 198 |
async def serve_index():
|
| 199 |
+
"""Serve the Iroha Causal Terminal SPA."""
|
| 200 |
if not INDEX_HTML.exists():
|
| 201 |
return HTMLResponse("<h1>Frontend not found. Run from backend/</h1>", status_code=500)
|
| 202 |
return HTMLResponse(INDEX_HTML.read_text(encoding="utf-8"))
|
|
|
|
| 212 |
return {"status": "ok", "version": "2.0.0"}
|
| 213 |
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 216 |
# gr.Server API endpoints (Gradio-backed — queue + SSE streaming)
|
| 217 |
# These are reachable via the Gradio JS Client as well as plain fetch().
|
|
|
|
| 224 |
# Override via BACKEND_API_URL env var if running a separate backend on 8000.
|
| 225 |
_BACKEND_BASE_URL = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
|
| 228 |
def _fetch_causal_matrix(
|
| 229 |
ticker: str,
|
|
|
|
| 267 |
raw = resp.read()
|
| 268 |
data = json.loads(raw)
|
| 269 |
if data.get("status") not in ("success", None):
|
| 270 |
+
logger.warning(
|
| 271 |
+
"_fetch_causal_matrix: backend returned status=%s for URL %s. Payload: %s",
|
| 272 |
+
data.get("status"), url, data,
|
| 273 |
+
)
|
| 274 |
return None
|
| 275 |
return data
|
| 276 |
except Exception as exc:
|
|
|
|
| 297 |
return obj
|
| 298 |
|
| 299 |
|
| 300 |
+
def _resolve_value(value: float, value_type: str, current: float) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
"""Convert a user-supplied value + value_type to the absolute node value."""
|
| 302 |
vt = value_type.strip().lower()
|
| 303 |
if vt == "absolute":
|
|
|
|
| 310 |
return value
|
| 311 |
|
| 312 |
|
| 313 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 314 |
+
# Pure-numpy inference helpers (no local causal training imports)
|
| 315 |
+
# These functions work entirely from the payload returned by the backend API.
|
| 316 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
+
def _build_dag_from_payload(payload: dict):
|
| 319 |
+
"""
|
| 320 |
+
Return a numpy bool DAG adjacency matrix and list of node names
|
| 321 |
+
from the backend causal-matrix payload.
|
| 322 |
"""
|
| 323 |
import numpy as np
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
nodes = payload["nodes"]
|
|
|
|
| 325 |
dag_adj = np.array(payload["dag_adj"], dtype=bool)
|
| 326 |
+
adj_matrix = np.array(payload["adj_matrix"], dtype=float)
|
| 327 |
+
return nodes, dag_adj, adj_matrix
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _propagate_intervention(
|
| 331 |
+
nodes: list,
|
| 332 |
+
dag_adj,
|
| 333 |
+
equations: dict,
|
| 334 |
+
data_level,
|
| 335 |
+
topological_order: list,
|
| 336 |
+
treatment: str,
|
| 337 |
+
abs_value: float,
|
| 338 |
+
targets: list,
|
| 339 |
+
horizon: int = 5,
|
| 340 |
+
):
|
| 341 |
+
"""
|
| 342 |
+
Propagate a hard intervention (do(treatment=abs_value)) through the
|
| 343 |
+
structural equations for `horizon` steps, returning ATE per target node.
|
| 344 |
+
Uses only numpy — no local causal model imports.
|
| 345 |
+
"""
|
| 346 |
+
import numpy as np
|
| 347 |
+
|
| 348 |
+
node_to_idx = {n: i for i, n in enumerate(nodes)}
|
| 349 |
n = len(nodes)
|
| 350 |
T = data_level.shape[0]
|
| 351 |
|
| 352 |
+
# Start from the last observed time step
|
| 353 |
+
state = data_level[-1].copy().astype(float)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
+
# Fix the treatment node
|
| 356 |
+
t_idx = node_to_idx[treatment]
|
| 357 |
+
state[t_idx] = abs_value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
|
| 359 |
+
ate_per_target: Dict[str, float] = {}
|
| 360 |
+
baseline = data_level[-1].copy().astype(float)
|
| 361 |
+
|
| 362 |
+
for _ in range(horizon):
|
| 363 |
+
new_state = state.copy()
|
| 364 |
+
for node_name in topological_order:
|
| 365 |
+
if node_name == treatment:
|
| 366 |
+
continue
|
| 367 |
+
eq = equations.get(node_name)
|
| 368 |
+
if eq is None:
|
| 369 |
+
continue
|
| 370 |
+
parents = eq.get("parents", [])
|
| 371 |
+
coefficients = eq.get("coefficients", {})
|
| 372 |
+
intercept = float(eq.get("intercept", 0.0))
|
| 373 |
+
if not parents:
|
| 374 |
+
continue
|
| 375 |
+
val = intercept
|
| 376 |
+
for p in parents:
|
| 377 |
+
p_idx = node_to_idx.get(p)
|
| 378 |
+
if p_idx is not None:
|
| 379 |
+
val += float(coefficients.get(p, 0.0)) * float(state[p_idx])
|
| 380 |
+
n_idx = node_to_idx[node_name]
|
| 381 |
+
new_state[n_idx] = val
|
| 382 |
+
state = new_state
|
| 383 |
+
|
| 384 |
+
for target in targets:
|
| 385 |
+
t_i = node_to_idx.get(target)
|
| 386 |
+
if t_i is not None:
|
| 387 |
+
ate_per_target[target] = float(state[t_i] - baseline[t_i])
|
| 388 |
+
|
| 389 |
+
return ate_per_target, state
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def _abduct_and_predict(
|
| 393 |
+
nodes: list,
|
| 394 |
+
dag_adj,
|
| 395 |
+
equations: dict,
|
| 396 |
+
data_level,
|
| 397 |
+
topological_order: list,
|
| 398 |
+
treatment: str,
|
| 399 |
+
cf_value: float,
|
| 400 |
+
target: str,
|
| 401 |
+
observed_t: int,
|
| 402 |
+
):
|
| 403 |
+
"""
|
| 404 |
+
Simple SCM abduction for counterfactual:
|
| 405 |
+
1. Abduct residuals from the observed time step.
|
| 406 |
+
2. Re-run structural equations with treatment fixed to cf_value.
|
| 407 |
+
3. Return factual_outcome, cf_outcome, ITE.
|
| 408 |
+
"""
|
| 409 |
+
import numpy as np
|
| 410 |
+
|
| 411 |
+
node_to_idx = {n: i for i, n in enumerate(nodes)}
|
| 412 |
+
obs = data_level[observed_t].copy().astype(float)
|
| 413 |
+
|
| 414 |
+
# Abduct residuals
|
| 415 |
+
residuals: Dict[str, float] = {}
|
| 416 |
+
for node_name in topological_order:
|
| 417 |
+
eq = equations.get(node_name)
|
| 418 |
+
if eq is None or not eq.get("parents"):
|
| 419 |
+
residuals[node_name] = 0.0
|
| 420 |
+
continue
|
| 421 |
+
parents = eq.get("parents", [])
|
| 422 |
+
coefficients = eq.get("coefficients", {})
|
| 423 |
+
intercept = float(eq.get("intercept", 0.0))
|
| 424 |
+
predicted = intercept
|
| 425 |
+
for p in parents:
|
| 426 |
+
p_idx = node_to_idx.get(p)
|
| 427 |
+
if p_idx is not None:
|
| 428 |
+
predicted += float(coefficients.get(p, 0.0)) * float(obs[node_to_idx[p]])
|
| 429 |
+
residuals[node_name] = float(obs[node_to_idx[node_name]]) - predicted
|
| 430 |
+
|
| 431 |
+
# Counterfactual: fix treatment, replay equations with abducted noise
|
| 432 |
+
cf_state = obs.copy()
|
| 433 |
+
cf_state[node_to_idx[treatment]] = cf_value
|
| 434 |
+
|
| 435 |
+
for node_name in topological_order:
|
| 436 |
+
if node_name == treatment:
|
| 437 |
+
continue
|
| 438 |
+
eq = equations.get(node_name)
|
| 439 |
+
if eq is None or not eq.get("parents"):
|
| 440 |
+
continue
|
| 441 |
+
parents = eq.get("parents", [])
|
| 442 |
+
coefficients = eq.get("coefficients", {})
|
| 443 |
+
intercept = float(eq.get("intercept", 0.0))
|
| 444 |
+
predicted = intercept
|
| 445 |
+
for p in parents:
|
| 446 |
+
p_idx = node_to_idx.get(p)
|
| 447 |
+
if p_idx is not None:
|
| 448 |
+
predicted += float(coefficients.get(p, 0.0)) * float(cf_state[p_idx])
|
| 449 |
+
n_idx = node_to_idx[node_name]
|
| 450 |
+
cf_state[n_idx] = predicted + residuals.get(node_name, 0.0)
|
| 451 |
+
|
| 452 |
+
factual_outcome = float(obs[node_to_idx[target]])
|
| 453 |
+
cf_outcome = float(cf_state[node_to_idx[target]])
|
| 454 |
+
ite = cf_outcome - factual_outcome
|
| 455 |
+
return factual_outcome, cf_outcome, ite
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
# ── API: Causal inference (assert / intervene / counterfactual) ───────────
|
| 459 |
+
#
|
| 460 |
+
# Architecture:
|
| 461 |
+
# 1. Fetch the VALIDATED causal matrix from noisy_boy_backend via HTTP.
|
| 462 |
+
# The backend has already run CUTS+ learning + pywhyllm + DoWhy validation.
|
| 463 |
+
# 2. Use the payload data (equations, adj, data_level) for inference
|
| 464 |
+
# using pure numpy/pandas — no local causal training imports required.
|
| 465 |
+
# 3. Optionally consult pywhyllm guidance from the backend payload.
|
| 466 |
|
| 467 |
|
| 468 |
@server.api(
|
| 469 |
name="run_inference",
|
| 470 |
description=(
|
| 471 |
+
"Run causal inference (association / intervention / counterfactual) "
|
| 472 |
+
"using a validated causal matrix fetched from the backend API. "
|
| 473 |
+
"Layers: 1=Association(DoWhy backdoor), 2=Intervention(SCM propagation), "
|
| 474 |
+
"3=Counterfactual(SCM abduction)."
|
| 475 |
),
|
| 476 |
concurrency_limit=4,
|
| 477 |
)
|
|
|
|
| 550 |
"detail": payload.get("detail", f"No cached pipeline data for {ticker}."),
|
| 551 |
}
|
| 552 |
|
| 553 |
+
# ── 2. Unpack payload (no local causal training imports) ──────────────
|
| 554 |
+
nodes, dag_adj, adj_matrix = _build_dag_from_payload(payload)
|
| 555 |
+
node_to_idx = {n: i for i, n in enumerate(nodes)}
|
| 556 |
+
data_level = np.array(payload["data_level"], dtype=float)
|
| 557 |
+
equations_raw = payload.get("equations", {})
|
| 558 |
+
topo_order = payload.get("topological_order", nodes)
|
| 559 |
|
| 560 |
+
T = data_level.shape[0]
|
| 561 |
+
df = pd.DataFrame(data_level, columns=nodes)
|
| 562 |
|
| 563 |
+
if treatment not in node_to_idx:
|
| 564 |
+
return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
|
| 565 |
+
if target_node not in node_to_idx:
|
| 566 |
+
return {"status": "error", "detail": f"Unknown outcome/target node: {target_node}"}
|
| 567 |
+
if df.shape[0] < 5:
|
| 568 |
+
return {
|
| 569 |
+
"status": "error",
|
| 570 |
+
"detail": f"Insufficient observations ({df.shape[0]}) to run inference.",
|
| 571 |
+
}
|
| 572 |
|
| 573 |
+
# ── 3. pywhyllm structural guidance (from backend payload) ────────────
|
| 574 |
+
pywhyllm_report: Optional[dict] = payload.get("pywhyllm_report")
|
|
|
|
|
|
|
| 575 |
adjustment_sets: List[List[str]] = []
|
|
|
|
| 576 |
|
| 577 |
if use_pywhyllm and pywhyllm_report and pywhyllm_report.get("available"):
|
|
|
|
| 578 |
raw_backdoor = pywhyllm_report.get("suggested_backdoor_sets") or []
|
| 579 |
+
valid_nodes = set(nodes) - {treatment, target_node}
|
| 580 |
for suggested_set in raw_backdoor:
|
| 581 |
clean = [n for n in suggested_set if n in valid_nodes]
|
| 582 |
if clean and clean not in adjustment_sets:
|
| 583 |
adjustment_sets.append(clean)
|
| 584 |
|
|
|
|
| 585 |
confounders = [
|
| 586 |
n for n in (pywhyllm_report.get("suggested_confounders") or [])
|
| 587 |
if n in valid_nodes
|
|
|
|
| 589 |
if confounders and confounders not in adjustment_sets:
|
| 590 |
adjustment_sets.append(confounders)
|
| 591 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
result: Dict[str, Any] = {}
|
| 593 |
|
| 594 |
# ═══════════════════════════════════════════════════════════════════════
|
| 595 |
# LAYER 1 — Association: "What does Y look like given X?"
|
| 596 |
+
# Uses DoWhy with the backend-provided DAG, falling back to OLS.
|
|
|
|
| 597 |
# ═══════════════════════════════════════════════════════════════════════
|
| 598 |
if mode == "assert":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
try:
|
| 600 |
from dowhy import CausalModel
|
| 601 |
|
| 602 |
+
# Build DOT graph string from dag_adj
|
| 603 |
+
edges = []
|
| 604 |
+
for si, src in enumerate(nodes):
|
| 605 |
+
for di, dst in enumerate(nodes):
|
| 606 |
+
if dag_adj[si, di]:
|
| 607 |
+
edges.append(f"{src} -> {dst}")
|
| 608 |
+
graph_dot = "digraph{" + "; ".join(edges) + "}"
|
| 609 |
+
|
| 610 |
dowhy_model = CausalModel(
|
| 611 |
data=df,
|
| 612 |
treatment=treatment,
|
|
|
|
| 622 |
)
|
| 623 |
ate = float(estimate.value)
|
| 624 |
|
| 625 |
+
# Confidence interval from OLS residuals
|
|
|
|
| 626 |
se: float = 0.0
|
| 627 |
try:
|
| 628 |
+
import numpy.linalg as nla
|
|
|
|
| 629 |
X = df[[c for c in df.columns if c != target_node]].values
|
| 630 |
y = df[target_node].values
|
|
|
|
| 631 |
XtX_inv = nla.pinv(X.T @ X)
|
| 632 |
resid = y - X @ nla.lstsq(X, y, rcond=None)[0]
|
| 633 |
+
sigma2 = float(np.sum(resid ** 2) / max(1, len(y) - X.shape[1]))
|
| 634 |
t_idx_local = list(df.columns).index(treatment)
|
| 635 |
se = float(np.sqrt(max(0.0, sigma2 * XtX_inv[t_idx_local, t_idx_local])))
|
| 636 |
except Exception:
|
|
|
|
| 640 |
ci_upper = ate + 1.96 * se
|
| 641 |
prob = min(1.0, abs(ate) / (abs(ate) + se + 1e-9))
|
| 642 |
|
| 643 |
+
# Ripple effects: direct downstream neighbours of treatment
|
| 644 |
ripple_effects = []
|
| 645 |
+
t_idx_g = node_to_idx[treatment]
|
| 646 |
+
for j, node in enumerate(nodes):
|
| 647 |
if node == treatment or node == target_node:
|
| 648 |
continue
|
| 649 |
+
if dag_adj[t_idx_g, j]:
|
| 650 |
+
edge_score = float(adj_matrix[t_idx_g, j])
|
| 651 |
ripple_effects.append({
|
| 652 |
"ticker": node,
|
| 653 |
"direction": 1 if ate > 0 else -1,
|
|
|
|
| 665 |
}
|
| 666 |
|
| 667 |
except Exception as dowhy_exc:
|
| 668 |
+
# DoWhy not installed or identification failed — fall back to OLS
|
| 669 |
+
logger.warning("DoWhy association failed (%s), falling back to OLS", dowhy_exc)
|
| 670 |
+
t_idx_g = node_to_idx[treatment]
|
| 671 |
+
out_idx = node_to_idx[target_node]
|
| 672 |
+
|
| 673 |
+
# Simple OLS: regress target on treatment
|
| 674 |
+
X = df[[treatment]].values
|
| 675 |
+
y = df[target_node].values
|
| 676 |
+
import numpy.linalg as nla
|
| 677 |
+
coef = nla.lstsq(np.c_[np.ones(len(X)), X], y, rcond=None)[0]
|
| 678 |
+
ate = float(coef[1])
|
| 679 |
+
se = abs(ate) * 0.15
|
| 680 |
+
ci_lower = ate - 1.96 * se
|
| 681 |
+
ci_upper = ate + 1.96 * se
|
| 682 |
+
|
| 683 |
+
ripple_effects = []
|
| 684 |
+
for j, node in enumerate(nodes):
|
| 685 |
+
if node == treatment or node == target_node:
|
| 686 |
+
continue
|
| 687 |
+
if dag_adj[t_idx_g, j]:
|
| 688 |
+
ripple_effects.append({
|
| 689 |
+
"ticker": node,
|
| 690 |
+
"direction": 1 if ate > 0 else -1,
|
| 691 |
+
"magnitude": round(float(adj_matrix[t_idx_g, j]) * abs(ate), 4),
|
| 692 |
+
})
|
| 693 |
+
|
| 694 |
result = {
|
| 695 |
+
"ate": ate,
|
| 696 |
+
"ci_lower": ci_lower,
|
| 697 |
+
"ci_upper": ci_upper,
|
| 698 |
+
"probability": min(1.0, abs(ate) / (abs(ate) + se + 1e-9)),
|
| 699 |
+
"strategy": "ols_fallback",
|
| 700 |
+
"adjustment_set": adjustment_sets[0] if adjustment_sets else [],
|
| 701 |
+
"ripple_effects": ripple_effects,
|
| 702 |
}
|
| 703 |
|
| 704 |
# ═══════════════════════════════════════════════════════════════════════
|
| 705 |
# LAYER 2 — Intervention: "What will happen to Y if we do X=value?"
|
| 706 |
+
# Propagates through structural equations from the backend payload.
|
|
|
|
| 707 |
# ═══════════════════════════════════════════════════════════════════════
|
| 708 |
elif mode == "intervene":
|
| 709 |
+
current_val = float(data_level[-1, node_to_idx[treatment]])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 710 |
abs_value = _resolve_value(value, value_type, current_val)
|
| 711 |
|
| 712 |
+
# Try DoWhy for ATE estimation first
|
| 713 |
+
ate = 0.0
|
| 714 |
+
method_used = "scm_propagation"
|
| 715 |
try:
|
| 716 |
from dowhy import CausalModel
|
| 717 |
|
| 718 |
+
edges = []
|
| 719 |
+
for si, src in enumerate(nodes):
|
| 720 |
+
for di, dst in enumerate(nodes):
|
| 721 |
+
if dag_adj[si, di]:
|
| 722 |
+
edges.append(f"{src} -> {dst}")
|
| 723 |
+
graph_dot = "digraph{" + "; ".join(edges) + "}"
|
| 724 |
|
| 725 |
dowhy_model = CausalModel(
|
| 726 |
+
data=df,
|
| 727 |
treatment=treatment,
|
| 728 |
outcome=target_node,
|
| 729 |
graph=graph_dot,
|
|
|
|
| 731 |
identified_estimand = dowhy_model.identify_effect(
|
| 732 |
proceed_when_unidentifiable=True
|
| 733 |
)
|
| 734 |
+
estimate = dowhy_model.estimate_effect(
|
| 735 |
+
identified_estimand,
|
| 736 |
+
method_name="backdoor.linear_regression",
|
| 737 |
+
)
|
| 738 |
+
ate_unit = float(estimate.value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
delta = abs_value - current_val
|
| 740 |
ate = ate_unit * delta
|
| 741 |
+
method_used = "backdoor.linear_regression"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 742 |
except Exception as dowhy_exc:
|
| 743 |
+
logger.warning("DoWhy intervention failed (%s), using SCM propagation", dowhy_exc)
|
| 744 |
+
|
| 745 |
+
# SCM propagation for ripple effects (pure numpy, no training imports)
|
| 746 |
+
ate_per_target, final_state = _propagate_intervention(
|
| 747 |
+
nodes=nodes,
|
| 748 |
+
dag_adj=dag_adj,
|
| 749 |
+
equations=equations_raw,
|
| 750 |
+
data_level=data_level,
|
| 751 |
+
topological_order=topo_order,
|
| 752 |
treatment=treatment,
|
| 753 |
+
abs_value=abs_value,
|
| 754 |
+
targets=[target_node] + [n for n in nodes if n != treatment],
|
| 755 |
horizon=horizon,
|
| 756 |
)
|
| 757 |
|
| 758 |
+
if method_used == "scm_propagation" and target_node in ate_per_target:
|
|
|
|
| 759 |
ate = float(ate_per_target[target_node])
|
|
|
|
|
|
|
| 760 |
|
| 761 |
+
se = abs(ate) * 0.12
|
| 762 |
+
ci_lower = ate - 1.96 * se
|
| 763 |
+
ci_upper = ate + 1.96 * se
|
| 764 |
+
|
| 765 |
ripple_effects = []
|
| 766 |
+
for node, delta_val in ate_per_target.items():
|
| 767 |
if node == treatment:
|
| 768 |
continue
|
| 769 |
ripple_effects.append({
|
|
|
|
| 781 |
"intervention_value": abs_value,
|
| 782 |
"value_type": value_type,
|
| 783 |
"horizon": horizon,
|
|
|
|
| 784 |
"ripple_effects": ripple_effects,
|
| 785 |
"adjustment_set": adjustment_sets[0] if adjustment_sets else [],
|
| 786 |
}
|
| 787 |
|
| 788 |
# ═══════════════════════════════════════════════════════════════════════
|
| 789 |
# LAYER 3 — Counterfactual: "What if X had been different in the past?"
|
| 790 |
+
# Uses SCM abduction via pure numpy structural equations.
|
|
|
|
| 791 |
# ═══════════════════════════════════════════════════════════════════════
|
| 792 |
elif mode in ("counterfactual", "counter"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
# Resolve observed timestep
|
|
|
|
| 794 |
t = observed_t if observed_t >= 0 else (T + observed_t)
|
| 795 |
t = max(0, min(T - 1, t))
|
| 796 |
|
| 797 |
# Resolve counterfactual value
|
| 798 |
+
current_val = float(data_level[t, node_to_idx[treatment]])
|
| 799 |
if cf_value is not None:
|
| 800 |
abs_cf_value = float(cf_value)
|
| 801 |
else:
|
| 802 |
abs_cf_value = _resolve_value(value, value_type, current_val)
|
| 803 |
|
| 804 |
+
# Try DoWhy GCM first
|
| 805 |
gcm_used = False
|
| 806 |
+
factual_outcome = 0.0
|
| 807 |
+
cf_outcome_val = 0.0
|
| 808 |
+
ite = 0.0
|
| 809 |
+
|
| 810 |
try:
|
| 811 |
import dowhy.gcm as gcm_module
|
| 812 |
import networkx as nx
|
| 813 |
|
|
|
|
| 814 |
causal_graph = nx.DiGraph()
|
| 815 |
+
for si, src in enumerate(nodes):
|
| 816 |
+
for di, dst in enumerate(nodes):
|
| 817 |
+
if dag_adj[si, di]:
|
| 818 |
+
causal_graph.add_edge(src, dst)
|
| 819 |
+
for node in nodes:
|
| 820 |
if node not in causal_graph.nodes:
|
| 821 |
causal_graph.add_node(node)
|
| 822 |
|
|
|
|
|
|
|
|
|
|
| 823 |
gcm_model = gcm_module.InvertibleStructuralCausalModel(causal_graph)
|
| 824 |
gcm_module.auto.assign_mechanisms(gcm_model, df)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
gcm_module.fit(gcm_model, df)
|
| 826 |
|
|
|
|
| 827 |
observed_data = df.iloc[[t]]
|
| 828 |
+
cf_val_fixed = abs_cf_value
|
|
|
|
|
|
|
| 829 |
cf_samples = gcm_module.counterfactual_samples(
|
| 830 |
gcm_model,
|
| 831 |
{treatment: lambda x, v=cf_val_fixed: np.full(x.shape, v)},
|
|
|
|
| 834 |
)
|
| 835 |
|
| 836 |
factual_outcome = float(observed_data[target_node].iloc[0])
|
| 837 |
+
cf_outcome_val = float(cf_samples[target_node].iloc[0])
|
| 838 |
+
ite = cf_outcome_val - factual_outcome
|
| 839 |
gcm_used = True
|
| 840 |
|
| 841 |
except Exception as gcm_exc:
|
| 842 |
logger.warning("DoWhy GCM counterfactual failed (%s), using SCM abduction", gcm_exc)
|
|
|
|
| 843 |
|
| 844 |
if not gcm_used:
|
| 845 |
+
factual_outcome, cf_outcome_val, ite = _abduct_and_predict(
|
| 846 |
+
nodes=nodes,
|
| 847 |
+
dag_adj=dag_adj,
|
| 848 |
+
equations=equations_raw,
|
| 849 |
+
data_level=data_level,
|
| 850 |
+
topological_order=topo_order,
|
| 851 |
treatment=treatment,
|
| 852 |
cf_value=abs_cf_value,
|
| 853 |
target=target_node,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
observed_t=t,
|
|
|
|
|
|
|
|
|
|
| 855 |
)
|
|
|
|
|
|
|
|
|
|
| 856 |
|
| 857 |
+
# Shapley: single-treatment — just use the ITE directly
|
| 858 |
+
shapley = {treatment: ite}
|
| 859 |
+
|
| 860 |
+
# SE from residual_std of the target equation (from backend payload)
|
| 861 |
+
target_eq_data = equations_raw.get(target_node, {})
|
| 862 |
+
se = float(target_eq_data.get("residual_std", abs(ite) * 0.15))
|
| 863 |
ci_lower = ite - 1.96 * se
|
| 864 |
ci_upper = ite + 1.96 * se
|
| 865 |
|
|
|
|
| 867 |
"ate": ite,
|
| 868 |
"ite": ite,
|
| 869 |
"factual_outcome": factual_outcome,
|
| 870 |
+
"counterfactual_outcome": cf_outcome_val,
|
| 871 |
"ci_lower": ci_lower,
|
| 872 |
"ci_upper": ci_upper,
|
| 873 |
"probability": min(1.0, abs(ite) / (abs(ite) + se + 1e-9)),
|
|
|
|
| 875 |
"counterfactual_value": abs_cf_value,
|
| 876 |
"value_type": value_type,
|
| 877 |
"observed_t": t,
|
| 878 |
+
"shapley_contributions": shapley,
|
| 879 |
"ripple_effects": [],
|
| 880 |
}
|
| 881 |
|
|
|
|
| 896 |
return {"status": "error", "detail": str(exc)}
|
| 897 |
|
| 898 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 900 |
# Entry point
|
| 901 |
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
| 904 |
port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
|
| 905 |
host = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
|
| 906 |
|
| 907 |
+
logger.info(f"Starting Iroha Causal Terminal on {host}:{port}")
|
| 908 |
logger.info(f" → Frontend : http://localhost:{port}/")
|
| 909 |
logger.info(f" → API docs : http://localhost:{port}/docs")
|
| 910 |
|
singular_ticker_causal/.env
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,834 +0,0 @@
|
|
| 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
DELETED
|
File without changes
|
singular_ticker_causal/algorithms/CUTS_PLUS/data/generate_data_mod.py
DELETED
|
@@ -1,430 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,313 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,136 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,95 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,153 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,57 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,286 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,66 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,74 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,292 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,83 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,15 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,127 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,314 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,125 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,41 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,62 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,338 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,505 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,102 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,193 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,4 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,27 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,150 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,51 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,57 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,112 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,57 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,176 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,219 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,400 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,460 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,72 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,51 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,104 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,116 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
singular_ticker_causal/data_sources/nseconnect/nse.py
DELETED
|
@@ -1,624 +0,0 @@
|
|
| 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 |
-
|
| 26 |
-
import csv
|
| 27 |
-
from .bases import AbstractBaseExchange
|
| 28 |
-
from .urls import (
|
| 29 |
-
STOCKS_CSV_URL, STOCKS_IN_INDEX_URL, QUOTE_API_URL, QUOTE_DRIVATIVE_URL,
|
| 30 |
-
TOP_GAINERS_URL, TOP_LOSERS_URL, ALL_INDICES_URL,
|
| 31 |
-
FIFTYTWO_WEEK_HIGH_URL, FIFTYTWO_WEEK_LOW_URL
|
| 32 |
-
)
|
| 33 |
-
from .ua import Session
|
| 34 |
-
from .utils import cast_intfloat_string_values_to_intfloat
|
| 35 |
-
|
| 36 |
-
class Nse(AbstractBaseExchange):
|
| 37 |
-
"""
|
| 38 |
-
class which implements all the functionality for
|
| 39 |
-
National Stock Exchange
|
| 40 |
-
"""
|
| 41 |
-
__CODECACHE__ = None
|
| 42 |
-
|
| 43 |
-
def __init__(self, session_refresh_interval=120):
|
| 44 |
-
"""Initialize a new NSE object.
|
| 45 |
-
Initializes a session management for making API calls to NSE (National Stock Exchange).
|
| 46 |
-
Args:
|
| 47 |
-
session_refresh_interval (int, optional): Time interval in seconds after which the session
|
| 48 |
-
should be refreshed. Defaults to 120 seconds.
|
| 49 |
-
Note:
|
| 50 |
-
The session refresh interval helps maintain an active connection with NSE servers by
|
| 51 |
-
periodically creating a new session to prevent timeouts.
|
| 52 |
-
"""
|
| 53 |
-
|
| 54 |
-
self.session_refresh_interval = session_refresh_interval
|
| 55 |
-
self.session = Session(session_refresh_interval)
|
| 56 |
-
|
| 57 |
-
#############################
|
| 58 |
-
### STOCKS APIS ###
|
| 59 |
-
#############################
|
| 60 |
-
|
| 61 |
-
def get_stock_codes(self):
|
| 62 |
-
"""Gets a list of stock codes traded in NSE.
|
| 63 |
-
|
| 64 |
-
This function fetches stock data from NSE's CSV endpoint and extracts the stock symbols.
|
| 65 |
-
|
| 66 |
-
Returns:
|
| 67 |
-
list: A list of strings containing stock symbols traded on NSE.
|
| 68 |
-
|
| 69 |
-
Example:
|
| 70 |
-
>>> nse = Nse()
|
| 71 |
-
>>> codes = nse.get_stock_codes()
|
| 72 |
-
>>> print(codes[:5])
|
| 73 |
-
['20MICRONS', '3IINFOTECH', '3MINDIA', '3PLAND', '63MOONS']
|
| 74 |
-
"""
|
| 75 |
-
res = self.session.fetch(STOCKS_CSV_URL)
|
| 76 |
-
csv_content = res.text.splitlines()
|
| 77 |
-
symbols = []
|
| 78 |
-
csv_reader = csv.DictReader(csv_content)
|
| 79 |
-
for row in csv_reader:
|
| 80 |
-
symbols.append(row['SYMBOL'])
|
| 81 |
-
return symbols
|
| 82 |
-
|
| 83 |
-
def is_valid_code(self, code):
|
| 84 |
-
"""Checks if a given stock code is valid.
|
| 85 |
-
|
| 86 |
-
This method validates whether the provided stock code exists in the list of valid
|
| 87 |
-
stock codes from NSE (National Stock Exchange).
|
| 88 |
-
|
| 89 |
-
Args:
|
| 90 |
-
code (str): Stock code/symbol to validate.
|
| 91 |
-
|
| 92 |
-
Returns:
|
| 93 |
-
bool: True if the code is valid, False otherwise.
|
| 94 |
-
|
| 95 |
-
Example:
|
| 96 |
-
>>> nse = NSE()
|
| 97 |
-
>>> nse.is_valid_code("INFY")
|
| 98 |
-
True
|
| 99 |
-
>>> nse.is_valid_code("INVALID")
|
| 100 |
-
False
|
| 101 |
-
"""
|
| 102 |
-
stock_codes = self.get_stock_codes()
|
| 103 |
-
return code.upper() in stock_codes
|
| 104 |
-
|
| 105 |
-
def get_quote(self, code, all_data=False):
|
| 106 |
-
"""Gets the stock quote for a given NSE stock symbol.
|
| 107 |
-
|
| 108 |
-
This function fetches real-time or delayed quote data from NSE for the specified stock code.
|
| 109 |
-
|
| 110 |
-
Args:
|
| 111 |
-
code (str): NSE stock symbol/code for which quote is to be fetched
|
| 112 |
-
all_data (bool, optional): If True returns complete quote data, if False returns only price info.
|
| 113 |
-
Defaults to False.
|
| 114 |
-
|
| 115 |
-
Returns:
|
| 116 |
-
dict: A dictionary containing quote data.
|
| 117 |
-
|
| 118 |
-
Raises:
|
| 119 |
-
requests.exceptions.RequestException: If there is an error in HTTP request
|
| 120 |
-
ValueError: If the response JSON is invalid
|
| 121 |
-
|
| 122 |
-
Example:
|
| 123 |
-
>>> nse = Nse()
|
| 124 |
-
>>> nse.get_quote('abb')
|
| 125 |
-
{
|
| 126 |
-
'lastPrice': 5189.1,
|
| 127 |
-
'change': 70.55,
|
| 128 |
-
'pChange': 1.38,
|
| 129 |
-
'previousClose': 5118.55,
|
| 130 |
-
'open': 5160,
|
| 131 |
-
'close': 5187.65,
|
| 132 |
-
'vwap': 5162.91,
|
| 133 |
-
'stockIndClosePrice': 0,
|
| 134 |
-
'lowerCP': 4606.7,
|
| 135 |
-
'upperCP': 5630.4,
|
| 136 |
-
'pPriceBand': 'No Band',
|
| 137 |
-
'basePrice': 5118.55,
|
| 138 |
-
'intraDayHighLow': {'min': 5101, 'max': 5218.45, 'value': 5189.1},
|
| 139 |
-
'weekHighLow': {'min': 4890}
|
| 140 |
-
}
|
| 141 |
-
"""
|
| 142 |
-
code = code.upper()
|
| 143 |
-
# TODO: implement if the code is valid
|
| 144 |
-
res = self.session.fetch(QUOTE_API_URL % code)
|
| 145 |
-
res = res.json()['priceInfo'] if all_data is False else res.json()
|
| 146 |
-
return cast_intfloat_string_values_to_intfloat(res)
|
| 147 |
-
|
| 148 |
-
def get_52_week_high(self):
|
| 149 |
-
"""Retrieves a list of stocks that have hit their 52-week high.
|
| 150 |
-
|
| 151 |
-
This method fetches data for stocks that have reached new 52-week high prices on the NSE.
|
| 152 |
-
|
| 153 |
-
Returns:
|
| 154 |
-
list[dict]: A list of dictionaries containing 52-week high data.
|
| 155 |
-
|
| 156 |
-
Example:
|
| 157 |
-
>>> nse.get_52_week_high()
|
| 158 |
-
[{'symbol': 'AVANTIFEED',
|
| 159 |
-
'series': 'EQ',
|
| 160 |
-
'comapnyName': 'Avanti Feeds Limited',
|
| 161 |
-
'new52WHL': 899,
|
| 162 |
-
'prev52WHL': 849.9,
|
| 163 |
-
'prevHLDate': '13-Mar-2025',
|
| 164 |
-
'ltp': 887,
|
| 165 |
-
'prevClose': 842.55,
|
| 166 |
-
'change': 44.45,
|
| 167 |
-
'pChange': 5.28},
|
| 168 |
-
{...}
|
| 169 |
-
]
|
| 170 |
-
"""
|
| 171 |
-
res = self.session.fetch(FIFTYTWO_WEEK_HIGH_URL)
|
| 172 |
-
json_response = res.json()
|
| 173 |
-
# Handle the new API response structure which has dataLtpGreater20 and dataLtpLess20 fields
|
| 174 |
-
data = cast_intfloat_string_values_to_intfloat(json_response)
|
| 175 |
-
|
| 176 |
-
# Check if the old structure with 'data' key exists
|
| 177 |
-
if 'data' in data:
|
| 178 |
-
return data['data']
|
| 179 |
-
|
| 180 |
-
# Otherwise, extract and combine the lists from the new structure
|
| 181 |
-
result = []
|
| 182 |
-
if 'dataLtpGreater20' in data:
|
| 183 |
-
result.extend(data['dataLtpGreater20'])
|
| 184 |
-
if 'dataLtpLess20' in data:
|
| 185 |
-
result.extend(data['dataLtpLess20'])
|
| 186 |
-
return result
|
| 187 |
-
|
| 188 |
-
def get_52_week_low(self):
|
| 189 |
-
"""Retrieves a list of stocks that have hit their 52-week low.
|
| 190 |
-
|
| 191 |
-
This method fetches data for stocks that have reached new 52-week low prices on the NSE.
|
| 192 |
-
|
| 193 |
-
Returns:
|
| 194 |
-
list[dict]: A list of dictionaries containing 52-week low data.
|
| 195 |
-
|
| 196 |
-
Example:
|
| 197 |
-
>>> nse.get_52_week_low()
|
| 198 |
-
[{'symbol': 'AVANTIFEED',
|
| 199 |
-
'series': 'EQ',
|
| 200 |
-
'comapnyName': 'Avanti Feeds Limited',
|
| 201 |
-
'new52WHL': 899,
|
| 202 |
-
'prev52WHL': 849.9,
|
| 203 |
-
'prevHLDate': '13-Mar-2025',
|
| 204 |
-
'ltp': 887,
|
| 205 |
-
'prevClose': 842.55,
|
| 206 |
-
'change': 44.45,
|
| 207 |
-
'pChange': 5.28},
|
| 208 |
-
{...}
|
| 209 |
-
]
|
| 210 |
-
"""
|
| 211 |
-
res = self.session.fetch(FIFTYTWO_WEEK_LOW_URL)
|
| 212 |
-
json_response = res.json()
|
| 213 |
-
# Handle the new API response structure which has dataLtpGreater20 and dataLtpLess20 fields
|
| 214 |
-
data = cast_intfloat_string_values_to_intfloat(json_response)
|
| 215 |
-
|
| 216 |
-
# Check if the old structure with 'data' key exists
|
| 217 |
-
if 'data' in data:
|
| 218 |
-
return data['data']
|
| 219 |
-
|
| 220 |
-
# Otherwise, extract and combine the lists from the new structure
|
| 221 |
-
result = []
|
| 222 |
-
if 'dataLtpGreater20' in data:
|
| 223 |
-
result.extend(data['dataLtpGreater20'])
|
| 224 |
-
if 'dataLtpLess20' in data:
|
| 225 |
-
result.extend(data['dataLtpLess20'])
|
| 226 |
-
return result
|
| 227 |
-
|
| 228 |
-
#############################
|
| 229 |
-
### INDEX APIS ###
|
| 230 |
-
#############################
|
| 231 |
-
|
| 232 |
-
def get_index_quote(self, index="NIFTY 50"):
|
| 233 |
-
"""Gets the quote for a specific index from NSE.
|
| 234 |
-
|
| 235 |
-
This function retrieves detailed quote information for a given index code from the
|
| 236 |
-
National Stock Exchange (NSE) of India.
|
| 237 |
-
|
| 238 |
-
Args:
|
| 239 |
-
index (str): The index code/symbol (e.g. "NIFTY 50", "BANKNIFTY", etc.)
|
| 240 |
-
|
| 241 |
-
Returns:
|
| 242 |
-
dict: A dictionary containing index quote details
|
| 243 |
-
|
| 244 |
-
Raises:
|
| 245 |
-
Exception: If the provided index code is invalid or not found
|
| 246 |
-
|
| 247 |
-
Example:
|
| 248 |
-
>>> nse = NSE()
|
| 249 |
-
>>> nse.get_index_quote("NIFTY 50")
|
| 250 |
-
{
|
| 251 |
-
'key': 'BROAD MARKET INDICES',
|
| 252 |
-
'index': 'NIFTY 50',
|
| 253 |
-
'last': 22508.75,
|
| 254 |
-
'variation': 111.55,
|
| 255 |
-
'percentChange': 0.5,
|
| 256 |
-
'open': 22353.15,
|
| 257 |
-
'high': 22577.0,
|
| 258 |
-
'low': 22353.15,
|
| 259 |
-
'previousClose': 22397.2,
|
| 260 |
-
'yearHigh': 26277.35,
|
| 261 |
-
'yearLow': 21281.45,
|
| 262 |
-
# ... additional fields omitted for brevity
|
| 263 |
-
}
|
| 264 |
-
"""
|
| 265 |
-
|
| 266 |
-
url = ALL_INDICES_URL
|
| 267 |
-
all_index_quote = self.get_all_index_quote()
|
| 268 |
-
index_list = [ i['indexSymbol'] for i in all_index_quote]
|
| 269 |
-
index = index.upper()
|
| 270 |
-
index = ' '.join(index.split())
|
| 271 |
-
if index in index_list:
|
| 272 |
-
response = list(filter(lambda idx: idx['indexSymbol'] == index, all_index_quote))[0]
|
| 273 |
-
return cast_intfloat_string_values_to_intfloat(response)
|
| 274 |
-
else:
|
| 275 |
-
raise Exception('Wrong index code')
|
| 276 |
-
|
| 277 |
-
def get_index_list(self):
|
| 278 |
-
"""Gets a list of all NSE index symbols.
|
| 279 |
-
|
| 280 |
-
This method fetches all available NSE (National Stock Exchange) index symbols by
|
| 281 |
-
extracting the 'indexSymbol' from the complete index quote data.
|
| 282 |
-
|
| 283 |
-
Returns:
|
| 284 |
-
list: A list of strings containing index symbols (e.g., ['NIFTY 50', 'NIFTY BANK', ...])
|
| 285 |
-
|
| 286 |
-
Examples:
|
| 287 |
-
>>> nse = Nse()
|
| 288 |
-
>>> indices = nse.get_index_list()
|
| 289 |
-
>>> print(indices)
|
| 290 |
-
['NIFTY 50', 'NIFTY BANK', 'NIFTY IT', ...]
|
| 291 |
-
"""
|
| 292 |
-
return [ i['indexSymbol'] for i in self.get_all_index_quote()]
|
| 293 |
-
|
| 294 |
-
def get_all_index_quote(self):
|
| 295 |
-
"""Gets information for all NSE indices in one request.
|
| 296 |
-
|
| 297 |
-
This method fetches quotes and information for all available indices on the
|
| 298 |
-
National Stock Exchange (NSE) through a single API call.
|
| 299 |
-
|
| 300 |
-
Returns:
|
| 301 |
-
list[dict]: A list of dictionaries where each dictionary contains quote
|
| 302 |
-
information for an index. The quote information includes details like
|
| 303 |
-
index name, current value, change, percentage change etc.
|
| 304 |
-
|
| 305 |
-
Example:
|
| 306 |
-
>>> nse = Nse()
|
| 307 |
-
>>> quotes = nse.get_all_index_quote()
|
| 308 |
-
>>> quotes # Sample output
|
| 309 |
-
[
|
| 310 |
-
{
|
| 311 |
-
'key': 'BROAD MARKET INDICES',
|
| 312 |
-
'index': 'NIFTY 50',
|
| 313 |
-
'indexSymbol': 'NIFTY 50',
|
| 314 |
-
'last': 22508.75,
|
| 315 |
-
'variation': 111.55,
|
| 316 |
-
'percentChange': 0.5,
|
| 317 |
-
'open': 22353.15,
|
| 318 |
-
...
|
| 319 |
-
},
|
| 320 |
-
# ... additional indices follow
|
| 321 |
-
]
|
| 322 |
-
|
| 323 |
-
Raises:
|
| 324 |
-
URLError: If there is an error accessing the NSE API endpoint
|
| 325 |
-
ValueError: If the response JSON cannot be parsed properly
|
| 326 |
-
"""
|
| 327 |
-
url = ALL_INDICES_URL
|
| 328 |
-
res = self.session.fetch(url)
|
| 329 |
-
return res.json()['data']
|
| 330 |
-
|
| 331 |
-
def get_top_gainers(self, index="NIFTY"):
|
| 332 |
-
"""Gets the list of top gaining stocks for the specified index.
|
| 333 |
-
|
| 334 |
-
This function retrieves real-time data for stocks that have gained the most value
|
| 335 |
-
during the current trading day. It can filter results by different indices.
|
| 336 |
-
|
| 337 |
-
Args:
|
| 338 |
-
index (str, optional): The index to get top gainers for. Defaults to "NIFTY".
|
| 339 |
-
Valid values are:
|
| 340 |
-
- NIFTY: Nifty 50 index
|
| 341 |
-
- BANKNIFTY: Bank Nifty index
|
| 342 |
-
- NIFTYNEXT50: Nifty Next 50 index
|
| 343 |
-
- SecGtr20: Securities greater than 20
|
| 344 |
-
- SecLwr20: Securities lower than 20
|
| 345 |
-
- FNO: Futures & Options
|
| 346 |
-
- ALL: All stocks
|
| 347 |
-
|
| 348 |
-
Returns:
|
| 349 |
-
list[dict]: List of dictionaries containing top gainer details.
|
| 350 |
-
|
| 351 |
-
Raises:
|
| 352 |
-
ConnectionError: If unable to fetch data from NSE
|
| 353 |
-
|
| 354 |
-
Example:
|
| 355 |
-
>>> nse = Nse()
|
| 356 |
-
>>> gainers = nse.get_top_gainers()
|
| 357 |
-
>>> gainers[0] # Sample output
|
| 358 |
-
{
|
| 359 |
-
'symbol': 'DRREDDY',
|
| 360 |
-
'series': 'EQ',
|
| 361 |
-
'open_price': 1107.9,
|
| 362 |
-
'high_price': 1154.1,
|
| 363 |
-
'low_price': 1101.5,
|
| 364 |
-
'ltp': 1151.5,
|
| 365 |
-
'prev_price': 1107.95,
|
| 366 |
-
'net_price': 3.93,
|
| 367 |
-
'trade_quantity': 2714559,
|
| 368 |
-
'turnover': 31016.01,
|
| 369 |
-
'market_type': 'N',
|
| 370 |
-
'ca_ex_dt': '28-Oct-2024',
|
| 371 |
-
'ca_purpose': 'Face Value Split (Sub-Division) - From Rs 5/- Per Share To Re 1/- Per Share',
|
| 372 |
-
'perChange': 3.93
|
| 373 |
-
}
|
| 374 |
-
"""
|
| 375 |
-
return self._get_top_gainers_losers('gainers', index)
|
| 376 |
-
|
| 377 |
-
def get_top_losers(self, index="NIFTY"): # Changed from None to "NIFTY"
|
| 378 |
-
"""Gets the top losers from specified index from NSE.
|
| 379 |
-
|
| 380 |
-
The function fetches real-time data for stocks that have declined the most in terms
|
| 381 |
-
of percentage change compared to their previous closing price.
|
| 382 |
-
|
| 383 |
-
Args:
|
| 384 |
-
index (str, optional): Index name for which top losers are to be fetched.
|
| 385 |
-
Available options:
|
| 386 |
-
- NIFTY (Default)
|
| 387 |
-
- BANKNIFTY
|
| 388 |
-
- NIFTYNEXT50
|
| 389 |
-
- SecGtr20
|
| 390 |
-
- SecLwr20
|
| 391 |
-
- FNO
|
| 392 |
-
- ALL
|
| 393 |
-
|
| 394 |
-
Returns:
|
| 395 |
-
list: List of dictionaries containing stock information with following keys:
|
| 396 |
-
|
| 397 |
-
Raises:
|
| 398 |
-
URLError: When unable to connect to NSE
|
| 399 |
-
ValueError: When invalid index is provided
|
| 400 |
-
|
| 401 |
-
Examples:
|
| 402 |
-
>>> from nseconnect import Nse
|
| 403 |
-
>>> nse = Nse()
|
| 404 |
-
>>> losers = nse.get_top_losers()
|
| 405 |
-
>>> losers[0]
|
| 406 |
-
{'symbol': 'TATAMOTORS', 'series': 'EQ', 'openPrice': 375.0, ...}
|
| 407 |
-
"""
|
| 408 |
-
return self._get_top_gainers_losers('losers', index) # Changed from 'gainers' to 'losers'
|
| 409 |
-
|
| 410 |
-
def get_advances_declines(self, index='nifty 50'):
|
| 411 |
-
"""Gets the advances/declines data for given index.
|
| 412 |
-
This method provides the number of stocks advancing and declining in a given index
|
| 413 |
-
on NSE at any given point of time.
|
| 414 |
-
Args:
|
| 415 |
-
index (str, optional): Name of the index. Defaults to 'nifty 50'.
|
| 416 |
-
Valid values include 'NIFTY 50', 'NIFTY BANK', etc.
|
| 417 |
-
Returns:
|
| 418 |
-
dict: A dictionary with two keys:
|
| 419 |
-
- 'advances': Number of advancing stocks in the index
|
| 420 |
-
- 'declines': Number of declining stocks in the index
|
| 421 |
-
Examples:
|
| 422 |
-
>>> nse = Nse()
|
| 423 |
-
>>> nse.get_advances_declines(index="NIFTY BANK")
|
| 424 |
-
{'advances': 7, 'declines': 4}
|
| 425 |
-
Note:
|
| 426 |
-
The method is case-insensitive for the index parameter.
|
| 427 |
-
"""
|
| 428 |
-
|
| 429 |
-
# fixing this
|
| 430 |
-
index = index.upper()
|
| 431 |
-
index_quote = self.get_index_quote(index)
|
| 432 |
-
return {'advances': index_quote['advances'], 'declines': index_quote['declines']}
|
| 433 |
-
|
| 434 |
-
def get_stocks_in_index(self, index="NIFTY 50"):
|
| 435 |
-
"""Gets the list of symbols of stocks included in the specified NSE index.
|
| 436 |
-
The function retrieves the current constituents of a given NSE index like NIFTY 50,
|
| 437 |
-
NIFTY BANK etc. and returns their stock symbols.
|
| 438 |
-
Args:
|
| 439 |
-
index (str, optional): Name of the NSE index. Defaults to "NIFTY 50".
|
| 440 |
-
Possible values: "NIFTY 50", "NIFTY BANK", "NIFTY IT" etc.
|
| 441 |
-
Returns:
|
| 442 |
-
list: List of stock symbols (str) that are part of the specified index.
|
| 443 |
-
Raises:
|
| 444 |
-
URLError: If unable to connect to NSE server
|
| 445 |
-
ValueError: If invalid index name is provided
|
| 446 |
-
Examples:
|
| 447 |
-
>>> nse = Nse()
|
| 448 |
-
>>> nse.get_stocks_in_index("NIFTY 50")
|
| 449 |
-
['ADANIPORTS', 'ASIANPAINT', 'AXISBANK', 'BAJAJ-AUTO', 'BAJFINANCE', ...]
|
| 450 |
-
>>> nse.get_stocks_in_index("NIFTY BANK")
|
| 451 |
-
['AUBANK', 'AXISBANK', 'BANDHANBNK', 'FEDERALBNK', 'HDFCBANK', ...]
|
| 452 |
-
"""
|
| 453 |
-
|
| 454 |
-
index = index.upper()
|
| 455 |
-
url = STOCKS_IN_INDEX_URL % index
|
| 456 |
-
res = self.session.fetch(url)
|
| 457 |
-
res_dict = res.json()
|
| 458 |
-
return [stock['symbol'] for stock in res_dict['data']][1:]
|
| 459 |
-
|
| 460 |
-
def get_stock_quote_in_index(self, index="NIFTY 50", include_index=False):
|
| 461 |
-
"""Gets stock quotes for all stocks in a given index.
|
| 462 |
-
This function fetches real-time quotes for all stocks that are part of the specified index
|
| 463 |
-
from NSE (National Stock Exchange).
|
| 464 |
-
Args:
|
| 465 |
-
index (str, optional): The name of the index. Defaults to "NIFTY 50".
|
| 466 |
-
include_index (bool, optional): Whether to include the index itself in results.
|
| 467 |
-
If True, includes both stocks and index. If False, returns only stocks.
|
| 468 |
-
Defaults to False.
|
| 469 |
-
Returns:
|
| 470 |
-
list: A list of dictionaries containing stock quote data.
|
| 471 |
-
Each dictionary contains various fields including:
|
| 472 |
-
- symbol: Stock symbol
|
| 473 |
-
- open: Opening price
|
| 474 |
-
- high: High price
|
| 475 |
-
- low: Low price
|
| 476 |
-
- lastPrice: Last traded price
|
| 477 |
-
- change: Change in price
|
| 478 |
-
- pChange: Percentage change
|
| 479 |
-
And other relevant trading information.
|
| 480 |
-
Raises:
|
| 481 |
-
URLError: If unable to connect to NSE servers
|
| 482 |
-
ValueError: If invalid index name is provided
|
| 483 |
-
Example:
|
| 484 |
-
>>> nse = Nse()
|
| 485 |
-
>>> nifty_quotes = nse.get_stock_quote_in_index("NIFTY 50")
|
| 486 |
-
>>> nifty_quotes_with_index = nse.get_stock_quote_in_index("NIFTY 50", include_index=True)
|
| 487 |
-
"""
|
| 488 |
-
|
| 489 |
-
index = index.upper()
|
| 490 |
-
url = STOCKS_IN_INDEX_URL % index
|
| 491 |
-
res = self.session.fetch(url)
|
| 492 |
-
res_dict = res.json()
|
| 493 |
-
res_dict = cast_intfloat_string_values_to_intfloat(res_dict)
|
| 494 |
-
if include_index is False:
|
| 495 |
-
return [record for record in res_dict['data'] if record['priority'] == 0]
|
| 496 |
-
else:
|
| 497 |
-
return res_dict['data']
|
| 498 |
-
|
| 499 |
-
def _get_top_gainers_losers(self, direction, index):
|
| 500 |
-
"""Internal method to fetch top gainers or losers for a given index.
|
| 501 |
-
|
| 502 |
-
Args:
|
| 503 |
-
direction (str): Either 'gainers' or 'losers'
|
| 504 |
-
index (str): Index name - one of NIFTY, BANKNIFTY, NIFTYNEXT50, SecGtr20, SecLwr20, FNO, ALL
|
| 505 |
-
|
| 506 |
-
Returns:
|
| 507 |
-
list: List of dictionaries containing top gainers/losers data for the specified index
|
| 508 |
-
|
| 509 |
-
Raises:
|
| 510 |
-
ValueError: If invalid index name is provided
|
| 511 |
-
"""
|
| 512 |
-
index = index or 'NIFTY' # Default to NIFTY if None
|
| 513 |
-
index = index.upper()
|
| 514 |
-
index = {
|
| 515 |
-
"NIFTY": "NIFTY",
|
| 516 |
-
"NIFTY 50": "NIFTY",
|
| 517 |
-
"NIFTY BANK": "BANKNIFTY",
|
| 518 |
-
"BANKNIFTY": "BANKNIFTY",
|
| 519 |
-
"NIFTYNEXT50": "NIFTYNEXT50",
|
| 520 |
-
"NIFTY NEXT 50": "NIFTYNEXT50",
|
| 521 |
-
"SECGTR20": "SecGtr20",
|
| 522 |
-
"SECLWR20": "SecLwr20",
|
| 523 |
-
"FNO": "FOSec",
|
| 524 |
-
"ALL": "allSec"
|
| 525 |
-
}.get(index)
|
| 526 |
-
if index is None:
|
| 527 |
-
raise ValueError("Index must be one of NIFTY 50, NIFTY BANK, NIFTY NEXT 50, SecGtr20, SecLwr20, FNO, ALL")
|
| 528 |
-
url = TOP_GAINERS_URL if direction == 'gainers' else TOP_LOSERS_URL
|
| 529 |
-
res = self.session.fetch(url)
|
| 530 |
-
return cast_intfloat_string_values_to_intfloat(res.json())[index]['data']
|
| 531 |
-
|
| 532 |
-
#############################
|
| 533 |
-
### DERIVATIVE APIS ###
|
| 534 |
-
#############################
|
| 535 |
-
|
| 536 |
-
def get_future_quote(self, code, expiry_date=None):
|
| 537 |
-
"""Get future quote for given stock code.
|
| 538 |
-
|
| 539 |
-
This function fetches futures trading data for a given stock code from NSE's derivatives segment.
|
| 540 |
-
If expiry date is provided, returns data for that specific expiry, else returns data for all
|
| 541 |
-
available expiry dates.
|
| 542 |
-
|
| 543 |
-
Args:
|
| 544 |
-
code (str): Stock code for which futures data needs to be fetched
|
| 545 |
-
expiry_date (str, optional): Expiry date in format DD-MMM-YYYY (e.g. "27-Mar-2025").
|
| 546 |
-
Defaults to None.
|
| 547 |
-
|
| 548 |
-
Returns:
|
| 549 |
-
Union[dict, list]: If expiry_date provided returns dict with futures data for that expiry,
|
| 550 |
-
else returns list of dicts with data for all expiries.
|
| 551 |
-
|
| 552 |
-
Example:
|
| 553 |
-
>>> nse = Nse()
|
| 554 |
-
>>> nse.get_future_quote('RELIANCE')
|
| 555 |
-
[{'expiryDate': '27-Mar-2025',
|
| 556 |
-
'lastPrice': 1246,
|
| 557 |
-
'premium': 4.45,
|
| 558 |
-
'openPrice': 1245.25,
|
| 559 |
-
'highPrice': 1260.85,
|
| 560 |
-
'lowPrice': 1236.2,
|
| 561 |
-
'openInterest': 257812,
|
| 562 |
-
'changeInOpenInterest': 7144,
|
| 563 |
-
...},
|
| 564 |
-
{...}]
|
| 565 |
-
"""
|
| 566 |
-
|
| 567 |
-
url = QUOTE_DRIVATIVE_URL % code.upper()
|
| 568 |
-
res = self.session.fetch(url)
|
| 569 |
-
res_dict = res.json()
|
| 570 |
-
# list containing all options and futures data
|
| 571 |
-
data = res_dict['stocks']
|
| 572 |
-
# filter out only future data
|
| 573 |
-
future_data = [s for s in data if s['metadata']['instrumentType'] == "Stock Futures"]
|
| 574 |
-
# future data is very convoluted, so flatten-out the desired data
|
| 575 |
-
# !! there is bug in spelling of the key 'dailyvolatility', it is not camel cased
|
| 576 |
-
# fixing that in my code for uniformity
|
| 577 |
-
filtered_data = [
|
| 578 |
-
{
|
| 579 |
-
'expiryDate': record['metadata']['expiryDate'],
|
| 580 |
-
'lastPrice': record['metadata']['lastPrice'],
|
| 581 |
-
'premium': record['metadata']['lastPrice'] - record['underlyingValue'],
|
| 582 |
-
'openPrice': record['metadata']['openPrice'],
|
| 583 |
-
'highPrice': record['metadata']['highPrice'],
|
| 584 |
-
'lowPrice': record['metadata']['lowPrice'],
|
| 585 |
-
'closePrice': record['metadata']['closePrice'],
|
| 586 |
-
'prevClose': record['metadata']['prevClose'],
|
| 587 |
-
'change': record['metadata']['change'],
|
| 588 |
-
'pChange': record['metadata']['pChange'],
|
| 589 |
-
'numberOfContractsTraded': record['metadata']['numberOfContractsTraded'],
|
| 590 |
-
'totalTurnover': record['metadata']['totalTurnover'],
|
| 591 |
-
'underlyingValue': record['underlyingValue'],
|
| 592 |
-
'tradedVolume': record['marketDeptOrderBook']['tradeInfo']['tradedVolume'],
|
| 593 |
-
'openInterest': record['marketDeptOrderBook']['tradeInfo']['openInterest'],
|
| 594 |
-
'changeInOpenInterest': record['marketDeptOrderBook']['tradeInfo']['changeinOpenInterest'],
|
| 595 |
-
'pchangeinOpenInterest': record['marketDeptOrderBook']['tradeInfo']['pchangeinOpenInterest'],
|
| 596 |
-
'marketLot': record['marketDeptOrderBook']['tradeInfo']['marketLot'],
|
| 597 |
-
'dailyVolatility': record['marketDeptOrderBook']['otherInfo']['dailyvolatility'],
|
| 598 |
-
'annualisedVolatility': record['marketDeptOrderBook']['otherInfo']['annualisedVolatility']
|
| 599 |
-
}
|
| 600 |
-
for record in future_data
|
| 601 |
-
]
|
| 602 |
-
# if expiry_date is provided, filter out data for that expiry date
|
| 603 |
-
if expiry_date:
|
| 604 |
-
matching_records = [record for record in filtered_data if record['expiryDate'] == expiry_date]
|
| 605 |
-
if matching_records:
|
| 606 |
-
return matching_records[0] # Return the first matching record
|
| 607 |
-
else:
|
| 608 |
-
# Return an empty dictionary if no records found for the given expiry date
|
| 609 |
-
return {}
|
| 610 |
-
return filtered_data
|
| 611 |
-
|
| 612 |
-
def __str__(self):
|
| 613 |
-
"""Returns a string representation of the NSE driver class.
|
| 614 |
-
Returns:
|
| 615 |
-
str: A descriptive string identifying this as the NSE driver class.
|
| 616 |
-
"""
|
| 617 |
-
|
| 618 |
-
return 'Driver Class for National Stock Exchange (NSE)'
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
if __name__ == "__main__":
|
| 622 |
-
n = Nse()
|
| 623 |
-
# data = n.download_bhavcopy("14th Dec")
|
| 624 |
-
n.get_quote('reliance')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
singular_ticker_causal/data_sources/nseconnect/ua.py
DELETED
|
@@ -1,137 +0,0 @@
|
|
| 1 |
-
import requests
|
| 2 |
-
import random
|
| 3 |
-
from datetime import datetime as dt
|
| 4 |
-
from .urls import NSE_MAIN
|
| 5 |
-
from time import sleep
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class Session():
|
| 9 |
-
__CACHE__ = {}
|
| 10 |
-
|
| 11 |
-
def __init__(self, session_refresh_interval=60, cache_timeout=60):
|
| 12 |
-
"""Initialize the class instance with session and cache parameters.
|
| 13 |
-
Args:
|
| 14 |
-
session_refresh_interval (int, optional): Time interval in seconds to refresh session. Defaults to 60.
|
| 15 |
-
cache_timeout (int, optional): Cache timeout duration in seconds. Defaults to 20.
|
| 16 |
-
Attributes:
|
| 17 |
-
session_refresh_interval (int): Time interval for session refresh.
|
| 18 |
-
cache_timeout (int): Duration for cache timeout.
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
self.session_refresh_interval = session_refresh_interval
|
| 22 |
-
self.cache_timeout = cache_timeout # cache timeout in seconds
|
| 23 |
-
self._session = None # Initialize _session attribute to None
|
| 24 |
-
self.create_session()
|
| 25 |
-
self.flush()
|
| 26 |
-
|
| 27 |
-
def nse_headers(self):
|
| 28 |
-
"""Returns a dictionary of headers required for making requests to NSE (National Stock Exchange).
|
| 29 |
-
These headers are designed to mimic a web browser request to prevent request blocking.
|
| 30 |
-
Returns:
|
| 31 |
-
dict: A dictionary containing HTTP headers with the following keys:
|
| 32 |
-
- Accept: Acceptable content types
|
| 33 |
-
- Accept-Language: Preferred language for response
|
| 34 |
-
- user-agent: Browser identification string
|
| 35 |
-
- X-Requested-With: Identifies AJAX requests
|
| 36 |
-
"""
|
| 37 |
-
|
| 38 |
-
return {
|
| 39 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
| 40 |
-
"Accept-Language": "en-US,en;q=0.9",
|
| 41 |
-
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
| 42 |
-
"X-Requested-With": "XMLHttpRequest",
|
| 43 |
-
"Referer": "https://www.nseindia.com/",
|
| 44 |
-
"Origin": "https://www.nseindia.com",
|
| 45 |
-
"Connection": "keep-alive",
|
| 46 |
-
"Sec-Fetch-Dest": "empty",
|
| 47 |
-
"Sec-Fetch-Mode": "cors",
|
| 48 |
-
"Sec-Fetch-Site": "same-origin"
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
def create_session(self):
|
| 52 |
-
"""Creates and initializes a new HTTP session for NSE (National Stock Exchange) API requests.
|
| 53 |
-
This method sets up a requests.Session object with appropriate headers for NSE and initializes
|
| 54 |
-
it by making a GET request to the NSE home page. The session is used for subsequent API calls.
|
| 55 |
-
Returns:
|
| 56 |
-
None
|
| 57 |
-
Side Effects:
|
| 58 |
-
- Sets self._session with configured requests.Session object
|
| 59 |
-
- Sets self._session_init_time with current timestamp
|
| 60 |
-
"""
|
| 61 |
-
|
| 62 |
-
# Clean up old session if it exists
|
| 63 |
-
if hasattr(self, '_session') and self._session is not None:
|
| 64 |
-
old_session = self._session
|
| 65 |
-
self._session = None
|
| 66 |
-
# Explicitly delete old session to ensure garbage collection
|
| 67 |
-
del old_session
|
| 68 |
-
|
| 69 |
-
# Create a completely new session object
|
| 70 |
-
self._session = requests.Session()
|
| 71 |
-
self._session.headers.update(self.nse_headers())
|
| 72 |
-
|
| 73 |
-
# First visit NSE home page to get cookies
|
| 74 |
-
self._session.get(NSE_MAIN)
|
| 75 |
-
# Small delay to mimic human behavior
|
| 76 |
-
sleep(1)
|
| 77 |
-
# Visit the market page to get additional cookies
|
| 78 |
-
self._session.get(f"{NSE_MAIN}/market-data/live-equity-market")
|
| 79 |
-
|
| 80 |
-
self._session_init_time = dt.now()
|
| 81 |
-
|
| 82 |
-
def flush(self):
|
| 83 |
-
"""Flushes the cached user agent data.
|
| 84 |
-
This method clears the internal cache dictionary storing user agent information
|
| 85 |
-
by resetting the class's __CACHE__ attribute to an empty dictionary.
|
| 86 |
-
Returns:
|
| 87 |
-
None
|
| 88 |
-
"""
|
| 89 |
-
|
| 90 |
-
self.__class__.__CACHE__ = {}
|
| 91 |
-
|
| 92 |
-
def fetch(self, url):
|
| 93 |
-
"""Fetches data from a given URL with caching and session management.
|
| 94 |
-
This method implements a caching mechanism and session refresh logic to optimize
|
| 95 |
-
network requests. It also includes random delays to prevent rate limiting.
|
| 96 |
-
Args:
|
| 97 |
-
url (str): The URL to fetch data from.
|
| 98 |
-
Returns:
|
| 99 |
-
requests.Response: The response object from the request.
|
| 100 |
-
Note:
|
| 101 |
-
- Uses class-level cache to store responses
|
| 102 |
-
- Implements random delays between 0-300ms before making requests
|
| 103 |
-
- Auto-refreshes session if expired based on session_refresh_interval
|
| 104 |
-
"""
|
| 105 |
-
|
| 106 |
-
# Check cache first
|
| 107 |
-
if url in self.__class__.__CACHE__:
|
| 108 |
-
cache_time, response = self.__class__.__CACHE__[url]
|
| 109 |
-
if (dt.now() - cache_time).seconds < self.cache_timeout:
|
| 110 |
-
# print("serving from cache")
|
| 111 |
-
return response
|
| 112 |
-
|
| 113 |
-
# Only check session expiry if we need to make a network request
|
| 114 |
-
time_diff = dt.now() - self._session_init_time
|
| 115 |
-
if time_diff.seconds >= self.session_refresh_interval:
|
| 116 |
-
# print("re-initing the session because of expiry")
|
| 117 |
-
self.create_session()
|
| 118 |
-
|
| 119 |
-
# Add random delay before making request
|
| 120 |
-
sleep_time = random.uniform(0, 0.3) # Random delay between 0-300ms
|
| 121 |
-
# print(f"Adding random delay of {sleep_time:.3f} seconds")
|
| 122 |
-
sleep(sleep_time)
|
| 123 |
-
|
| 124 |
-
# Make actual request if not in cache or cache expired
|
| 125 |
-
try:
|
| 126 |
-
response = self._session.get(url)
|
| 127 |
-
# Force a 401 response to retry with a fresh session
|
| 128 |
-
if response.status_code == 401:
|
| 129 |
-
self.create_session()
|
| 130 |
-
response = self._session.get(url)
|
| 131 |
-
except requests.RequestException:
|
| 132 |
-
# Try again with a fresh session on any request exception
|
| 133 |
-
self.create_session()
|
| 134 |
-
response = self._session.get(url)
|
| 135 |
-
|
| 136 |
-
self.__class__.__CACHE__[url] = (dt.now(), response)
|
| 137 |
-
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
singular_ticker_causal/data_sources/nseconnect/urls.py
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
URL constants for NSE related operations
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
-
# Base URLs
|
| 6 |
-
NSE_HOME = "https://www.nseindia.com"
|
| 7 |
-
NSE_MAIN = "https://www.nseindia.com"
|
| 8 |
-
NSE_LEGACY = "https://www.nseindia.com"
|
| 9 |
-
|
| 10 |
-
# Quote URLs
|
| 11 |
-
QUOTE_EQUITY_URL = f"{NSE_MAIN}/get-quotes/equity?symbol=%s"
|
| 12 |
-
QUOTE_API_URL = f"{NSE_MAIN}/api/quote-equity?symbol=%s"
|
| 13 |
-
|
| 14 |
-
# Stock list URLs
|
| 15 |
-
STOCKS_CSV_URL = f"https://archives.nseindia.com/content/equities/EQUITY_L.csv"
|
| 16 |
-
|
| 17 |
-
# Market movers URLs
|
| 18 |
-
TOP_GAINERS_URL = f"{NSE_MAIN}/api/live-analysis-variations?index=gainers"
|
| 19 |
-
TOP_LOSERS_URL = f"{NSE_MAIN}/api/live-analysis-variations?index=loosers"
|
| 20 |
-
TOP_FNO_GAINER_URL = f"{NSE_MAIN}/api/market-data-pre-open?key=FO"
|
| 21 |
-
TOP_FNO_LOSER_URL = f"{NSE_MAIN}/api/market-data-pre-open?key=FO"
|
| 22 |
-
FIFTYTWO_WEEK_HIGH_URL = f"{NSE_MAIN}/api/live-analysis-52Week?index=high"
|
| 23 |
-
FIFTYTWO_WEEK_LOW_URL = f"{NSE_MAIN}/api/live-analysis-52Week?index=low"
|
| 24 |
-
|
| 25 |
-
# Index URLs
|
| 26 |
-
ALL_INDICES_URL = f"{NSE_MAIN}/api/allIndices"
|
| 27 |
-
STOCKS_IN_INDEX_URL = f"{NSE_MAIN}/api/equity-stockIndices?index=%s"
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
# Historical data URLs
|
| 31 |
-
BHAVCOPY_BASE_URL = f"{NSE_MAIN}/archives/equities-bhavcopy/%s"
|
| 32 |
-
BHAVCOPY_BASE_FILENAME = "cm%s%s%sbhav.csv"
|
| 33 |
-
|
| 34 |
-
# Drivative URLs
|
| 35 |
-
QUOTE_DRIVATIVE_URL = f"{NSE_MAIN}/api/quote-derivative?symbol=%s"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
singular_ticker_causal/data_sources/nseconnect/utils.py
DELETED
|
@@ -1,373 +0,0 @@
|
|
| 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 six
|
| 26 |
-
import re
|
| 27 |
-
import operator
|
| 28 |
-
|
| 29 |
-
def byte_adaptor(fbuffer):
|
| 30 |
-
""" provides py3 compatibility by converting byte based
|
| 31 |
-
file stream to string based file stream
|
| 32 |
-
|
| 33 |
-
Arguments:
|
| 34 |
-
fbuffer: file like objects containing bytes
|
| 35 |
-
|
| 36 |
-
Returns:
|
| 37 |
-
string buffer
|
| 38 |
-
"""
|
| 39 |
-
if six.PY3:
|
| 40 |
-
strings = fbuffer.read().decode('latin-1')
|
| 41 |
-
fbuffer = six.StringIO(strings)
|
| 42 |
-
return fbuffer
|
| 43 |
-
else:
|
| 44 |
-
return fbuffer
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def js_adaptor(buffer):
|
| 48 |
-
""" convert javascript objects like true, none, NaN etc. to
|
| 49 |
-
quoted word.
|
| 50 |
-
|
| 51 |
-
Arguments:
|
| 52 |
-
buffer: string to be converted
|
| 53 |
-
|
| 54 |
-
Returns:
|
| 55 |
-
string after conversion
|
| 56 |
-
"""
|
| 57 |
-
buffer = re.sub('true', 'True', buffer)
|
| 58 |
-
buffer = re.sub('false', 'False', buffer)
|
| 59 |
-
buffer = re.sub('none', 'None', buffer)
|
| 60 |
-
buffer = re.sub('NaN', '"NaN"', buffer)
|
| 61 |
-
return buffer
|
| 62 |
-
|
| 63 |
-
def cast_intfloat_string_values_to_intfloat(data, round_digits=2):
|
| 64 |
-
"""Recursively converts string representations of numbers to integers or floats in nested data structures.
|
| 65 |
-
This function traverses through dictionaries and lists, converting string values that represent
|
| 66 |
-
numbers into their corresponding numeric types (int or float). For float values, it rounds to
|
| 67 |
-
the specified number of decimal places.
|
| 68 |
-
Args:
|
| 69 |
-
data (Union[dict, list]): The input data structure containing values to be converted.
|
| 70 |
-
Can be either a dictionary or a list, potentially nested.
|
| 71 |
-
round_digits (int, optional): Number of decimal places to round float values to.
|
| 72 |
-
Defaults to 2.
|
| 73 |
-
Returns:
|
| 74 |
-
Union[dict, list]: A new data structure of the same type as input, with string
|
| 75 |
-
representations of numbers converted to their numeric types.
|
| 76 |
-
Example:
|
| 77 |
-
>>> data = {'a': '1', 'b': '2.5', 'c': 'text', 'd': {'e': '3.14'}}
|
| 78 |
-
>>> cast_intfloat_string_values_to_intfloat(data)
|
| 79 |
-
{'a': 1, 'b': 2.5, 'c': 'text', 'd': {'e': 3.14}}
|
| 80 |
-
"""
|
| 81 |
-
|
| 82 |
-
if isinstance(data, dict):
|
| 83 |
-
data = data.copy()
|
| 84 |
-
for key, value in data.items():
|
| 85 |
-
if isinstance(value, str):
|
| 86 |
-
try:
|
| 87 |
-
data[key] = int(value)
|
| 88 |
-
except ValueError:
|
| 89 |
-
try:
|
| 90 |
-
data[key] = round(float(value), round_digits)
|
| 91 |
-
except ValueError:
|
| 92 |
-
pass
|
| 93 |
-
elif isinstance(value, (dict, list)):
|
| 94 |
-
data[key] = cast_intfloat_string_values_to_intfloat(value, round_digits)
|
| 95 |
-
elif isinstance(value, float):
|
| 96 |
-
data[key] = round(value, round_digits)
|
| 97 |
-
elif isinstance(data, list):
|
| 98 |
-
data = data[:]
|
| 99 |
-
for i, value in enumerate(data):
|
| 100 |
-
if isinstance(value, str):
|
| 101 |
-
try:
|
| 102 |
-
data[i] = int(value)
|
| 103 |
-
except ValueError:
|
| 104 |
-
try:
|
| 105 |
-
data[i] = round(float(value), round_digits)
|
| 106 |
-
except ValueError:
|
| 107 |
-
pass
|
| 108 |
-
elif isinstance(value, (dict, list)):
|
| 109 |
-
data[i] = cast_intfloat_string_values_to_intfloat(value, round_digits)
|
| 110 |
-
elif isinstance(value, float):
|
| 111 |
-
data[i] = round(value, round_digits)
|
| 112 |
-
return data
|
| 113 |
-
|
| 114 |
-
def camel_to_title(camel_str):
|
| 115 |
-
"""Converts a camel case string to title case.
|
| 116 |
-
This function takes a camel case string and converts it to title case by adding
|
| 117 |
-
spaces before capital letters and capitalizing the first letter of each word.
|
| 118 |
-
Args:
|
| 119 |
-
camel_str (str): The camel case string to be converted.
|
| 120 |
-
Returns:
|
| 121 |
-
str: The converted string in title case format.
|
| 122 |
-
Examples:
|
| 123 |
-
>>> camel_to_title("camelCaseString")
|
| 124 |
-
'Camel Case String'
|
| 125 |
-
>>> camel_to_title("thisIsATest")
|
| 126 |
-
'This Is A Test'
|
| 127 |
-
"""
|
| 128 |
-
|
| 129 |
-
return re.sub(r'(?<!^)(?=[A-Z])', ' ', camel_str).title()
|
| 130 |
-
|
| 131 |
-
def _resolve_path(data, path, case_insensitive=True):
|
| 132 |
-
"""Helper function to resolve dot notation paths in dictionaries."""
|
| 133 |
-
if not path:
|
| 134 |
-
return data
|
| 135 |
-
|
| 136 |
-
parts = path.split('.')
|
| 137 |
-
current = data
|
| 138 |
-
|
| 139 |
-
for part in parts:
|
| 140 |
-
if isinstance(current, dict):
|
| 141 |
-
if case_insensitive:
|
| 142 |
-
key_map = {k.lower(): k for k in current.keys()}
|
| 143 |
-
part_lower = part.lower()
|
| 144 |
-
if part_lower in key_map:
|
| 145 |
-
current = current[key_map[part_lower]]
|
| 146 |
-
else:
|
| 147 |
-
return None
|
| 148 |
-
else:
|
| 149 |
-
current = current.get(part)
|
| 150 |
-
else:
|
| 151 |
-
return None
|
| 152 |
-
return current
|
| 153 |
-
|
| 154 |
-
def _parse_query(query_str):
|
| 155 |
-
"""Parse query string into (path, operator, value) tuple."""
|
| 156 |
-
operators = {
|
| 157 |
-
'==': operator.eq,
|
| 158 |
-
'!=': operator.ne,
|
| 159 |
-
'>=': operator.ge,
|
| 160 |
-
'<=': operator.le,
|
| 161 |
-
'>': operator.gt,
|
| 162 |
-
'<': operator.lt
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
for op_str, op_func in operators.items():
|
| 166 |
-
if op_str in query_str:
|
| 167 |
-
path, value = query_str.split(op_str)
|
| 168 |
-
path = path.strip()
|
| 169 |
-
value = value.strip()
|
| 170 |
-
|
| 171 |
-
# Try to convert value to number if possible
|
| 172 |
-
try:
|
| 173 |
-
value = int(value)
|
| 174 |
-
except ValueError:
|
| 175 |
-
try:
|
| 176 |
-
value = float(value)
|
| 177 |
-
except ValueError:
|
| 178 |
-
# Keep as string if not numeric
|
| 179 |
-
pass
|
| 180 |
-
|
| 181 |
-
return path, op_func, value
|
| 182 |
-
|
| 183 |
-
return None, None, None
|
| 184 |
-
|
| 185 |
-
def dict_to_table(data, title="Data Table", filter=None, ignore=None, sort=None, direction="desc", query=None):
|
| 186 |
-
"""Converts dictionary or list of dictionaries to a formatted table using Rich library.
|
| 187 |
-
This function takes either a dictionary or a list of dictionaries and displays it as a
|
| 188 |
-
formatted table in the console. It supports filtering specific keys, ignoring keys, and
|
| 189 |
-
applies special formatting for negative numbers.
|
| 190 |
-
Args:
|
| 191 |
-
data (Union[dict, List[dict]]): The data to be displayed. Can be either a dictionary
|
| 192 |
-
or a list of dictionaries.
|
| 193 |
-
title (str, optional): The title to display above the table. Defaults to "Data Table".
|
| 194 |
-
filter (List[str], optional): List of keys to include in the output. If provided,
|
| 195 |
-
only these keys will be displayed. Keys are matched case-insensitively.
|
| 196 |
-
Defaults to None.
|
| 197 |
-
ignore (List[str], optional): List of keys to exclude from the output. Keys are
|
| 198 |
-
matched case-insensitively. Defaults to None.
|
| 199 |
-
sort (str, optional): Key to sort by. Case-insensitive. Will sort numerically
|
| 200 |
-
for numeric values and alphabetically for string values. Defaults to None.
|
| 201 |
-
direction (str, optional): Sort direction - "asc" for ascending or "desc" for
|
| 202 |
-
descending. Defaults to "desc".
|
| 203 |
-
query (str, optional): Filter rows using dot notation path and comparison.
|
| 204 |
-
Supports operators: ==, !=, >, <, >=, <=
|
| 205 |
-
Example: "market.price>100" or "status.active==True"
|
| 206 |
-
Keys are matched case-insensitively. Defaults to None.
|
| 207 |
-
"""
|
| 208 |
-
from rich.console import Console
|
| 209 |
-
from rich.table import Table
|
| 210 |
-
|
| 211 |
-
console = Console()
|
| 212 |
-
table = Table(title=title)
|
| 213 |
-
|
| 214 |
-
if not data:
|
| 215 |
-
console.print("[red]No data to display![/red]")
|
| 216 |
-
return
|
| 217 |
-
|
| 218 |
-
# Parse query if provided
|
| 219 |
-
query_path = None
|
| 220 |
-
query_op = None
|
| 221 |
-
query_value = None
|
| 222 |
-
if query:
|
| 223 |
-
query_path, query_op, query_value = _parse_query(query)
|
| 224 |
-
if not all([query_path, query_op, query_value]):
|
| 225 |
-
console.print("[red]Invalid query format![/red]")
|
| 226 |
-
return
|
| 227 |
-
|
| 228 |
-
# Validate direction
|
| 229 |
-
if direction not in ["asc", "desc"]:
|
| 230 |
-
console.print("[red]Direction must be 'asc' or 'desc'![/red]")
|
| 231 |
-
return
|
| 232 |
-
|
| 233 |
-
# Normalize filter, ignore and sort keys
|
| 234 |
-
if filter:
|
| 235 |
-
if not isinstance(filter, list):
|
| 236 |
-
console.print("[red]Filter should be a list of keys![/red]")
|
| 237 |
-
return
|
| 238 |
-
filter = [str(key).lower() for key in filter]
|
| 239 |
-
|
| 240 |
-
if ignore:
|
| 241 |
-
if not isinstance(ignore, list):
|
| 242 |
-
console.print("[red]Ignore should be a list of keys![/red]")
|
| 243 |
-
return
|
| 244 |
-
ignore = [str(key).lower() for key in ignore]
|
| 245 |
-
else:
|
| 246 |
-
ignore = []
|
| 247 |
-
|
| 248 |
-
if sort:
|
| 249 |
-
sort = str(sort).lower()
|
| 250 |
-
|
| 251 |
-
# Check if data is a list of dicts
|
| 252 |
-
if isinstance(data, list) and all(isinstance(i, dict) for i in data):
|
| 253 |
-
# Get all unique keys and create key mapping
|
| 254 |
-
keys = set()
|
| 255 |
-
for item in data:
|
| 256 |
-
keys.update(item.keys())
|
| 257 |
-
key_map = {k.lower(): k for k in keys}
|
| 258 |
-
|
| 259 |
-
# Validate sort key if provided
|
| 260 |
-
if sort and sort not in key_map:
|
| 261 |
-
console.print(f"[red]Sort key '{sort}' not found in data![/red]")
|
| 262 |
-
return
|
| 263 |
-
|
| 264 |
-
# Create ordered keys list
|
| 265 |
-
if filter:
|
| 266 |
-
ordered_keys = [key_map[f] for f in filter if f in key_map and f not in ignore]
|
| 267 |
-
else:
|
| 268 |
-
ordered_keys = [key_map[k.lower()] for k in keys if k.lower() not in ignore]
|
| 269 |
-
|
| 270 |
-
if not ordered_keys:
|
| 271 |
-
console.print("[red]No matching keys found![/red]")
|
| 272 |
-
return
|
| 273 |
-
|
| 274 |
-
# Apply query filter before sorting
|
| 275 |
-
if query:
|
| 276 |
-
filtered_data = []
|
| 277 |
-
for item in data:
|
| 278 |
-
item_value = _resolve_path(item, query_path)
|
| 279 |
-
if item_value is not None:
|
| 280 |
-
try:
|
| 281 |
-
if query_op(item_value, query_value):
|
| 282 |
-
filtered_data.append(item)
|
| 283 |
-
except TypeError:
|
| 284 |
-
# Handle type mismatch gracefully
|
| 285 |
-
continue
|
| 286 |
-
data = filtered_data
|
| 287 |
-
|
| 288 |
-
if not data:
|
| 289 |
-
console.print("[red]No data matches the query![/red]")
|
| 290 |
-
return
|
| 291 |
-
|
| 292 |
-
# Sort data if sort key is provided
|
| 293 |
-
if sort and sort in key_map:
|
| 294 |
-
original_key = key_map[sort]
|
| 295 |
-
# Try numeric sort first
|
| 296 |
-
try:
|
| 297 |
-
sorted_data = sorted(
|
| 298 |
-
data,
|
| 299 |
-
key=lambda x: float(x.get(original_key, 0)),
|
| 300 |
-
reverse=(direction == "desc")
|
| 301 |
-
)
|
| 302 |
-
except (ValueError, TypeError):
|
| 303 |
-
# Fall back to string sort
|
| 304 |
-
sorted_data = sorted(
|
| 305 |
-
data,
|
| 306 |
-
key=lambda x: str(x.get(original_key, "")),
|
| 307 |
-
reverse=(direction == "desc")
|
| 308 |
-
)
|
| 309 |
-
else:
|
| 310 |
-
sorted_data = data
|
| 311 |
-
|
| 312 |
-
# Add columns and display table
|
| 313 |
-
for key in ordered_keys:
|
| 314 |
-
table.add_column(camel_to_title(key), style="bright_white")
|
| 315 |
-
|
| 316 |
-
for item in sorted_data:
|
| 317 |
-
row = []
|
| 318 |
-
for key in ordered_keys:
|
| 319 |
-
value = item.get(key, "")
|
| 320 |
-
if isinstance(value, (int, float)) and value < 0:
|
| 321 |
-
row.append(f"[red]{value}[/red]")
|
| 322 |
-
else:
|
| 323 |
-
row.append(f"[bright_white]{value}[/bright_white]")
|
| 324 |
-
table.add_row(*row)
|
| 325 |
-
|
| 326 |
-
elif isinstance(data, dict):
|
| 327 |
-
# Single dict can't be queried for rows
|
| 328 |
-
if query:
|
| 329 |
-
console.print("[red]Query is only supported for list of dictionaries![/red]")
|
| 330 |
-
return
|
| 331 |
-
|
| 332 |
-
# Filter and ignore the dictionary data
|
| 333 |
-
filtered_data = {}
|
| 334 |
-
key_map = {k.lower(): k for k in data.keys()}
|
| 335 |
-
|
| 336 |
-
if filter:
|
| 337 |
-
# Add keys in filter order if they exist and not in ignore
|
| 338 |
-
for f in filter:
|
| 339 |
-
if f in key_map and f not in ignore:
|
| 340 |
-
original_key = key_map[f]
|
| 341 |
-
value = data[original_key]
|
| 342 |
-
if not isinstance(value, (dict, list, tuple, set)):
|
| 343 |
-
filtered_data[original_key] = value
|
| 344 |
-
else:
|
| 345 |
-
# If no filter, exclude ignored and nested items
|
| 346 |
-
filtered_data = {k: v for k, v in data.items()
|
| 347 |
-
if not isinstance(v, (dict, list, tuple, set))
|
| 348 |
-
and k.lower() not in ignore}
|
| 349 |
-
|
| 350 |
-
if not filtered_data:
|
| 351 |
-
console.print("[red]No matching key-value pairs to display![/red]")
|
| 352 |
-
return
|
| 353 |
-
|
| 354 |
-
# Add columns
|
| 355 |
-
table.add_column("Key", style="cyan", no_wrap=True)
|
| 356 |
-
table.add_column("Value", style="bright_white")
|
| 357 |
-
|
| 358 |
-
# Add rows
|
| 359 |
-
for key, value in filtered_data.items():
|
| 360 |
-
if isinstance(value, (int, float)) and value < 0:
|
| 361 |
-
value_str = f"[red]{value}[/red]"
|
| 362 |
-
else:
|
| 363 |
-
value_str = f"[bright_white]{value}[/bright_white]"
|
| 364 |
-
table.add_row(camel_to_title(key), value_str)
|
| 365 |
-
|
| 366 |
-
else:
|
| 367 |
-
console.print("[red]Unsupported data format![/red]")
|
| 368 |
-
return
|
| 369 |
-
|
| 370 |
-
console.print(table)
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|