Abhijeet Mahapatra commited on
Commit
35abc74
·
1 Parent(s): c8c7ac4

First commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +13 -0
  2. README.md +661 -1
  3. frontend/app.js +849 -0
  4. frontend/index.html +269 -0
  5. frontend/style.css +711 -0
  6. server.py +1036 -0
  7. singular_ticker_causal/.env +3 -0
  8. singular_ticker_causal/algorithms/CUTS_PLUS/cuts_plus.py +834 -0
  9. singular_ticker_causal/algorithms/CUTS_PLUS/data/__init__.py +0 -0
  10. singular_ticker_causal/algorithms/CUTS_PLUS/data/generate_data_mod.py +430 -0
  11. singular_ticker_causal/algorithms/CUTS_PLUS/data/simu_data.py +313 -0
  12. singular_ticker_causal/algorithms/CUTS_PLUS/data/utils.py +136 -0
  13. singular_ticker_causal/algorithms/CUTS_PLUS/lagged_graph.py +95 -0
  14. singular_ticker_causal/algorithms/CUTS_PLUS/model/cuts_plus_net.py +153 -0
  15. singular_ticker_causal/algorithms/CUTS_PLUS/utils/causal_plot.py +57 -0
  16. singular_ticker_causal/algorithms/CUTS_PLUS/utils/exp_utils.py +286 -0
  17. singular_ticker_causal/algorithms/CUTS_PLUS/utils/gumbel_softmax.py +66 -0
  18. singular_ticker_causal/algorithms/CUTS_PLUS/utils/logger.py +74 -0
  19. singular_ticker_causal/algorithms/CUTS_PLUS/utils/misc.py +292 -0
  20. singular_ticker_causal/algorithms/CUTS_PLUS/utils/opt_type.py +83 -0
  21. singular_ticker_causal/algorithms/__init__.py +2 -0
  22. singular_ticker_causal/causal_inference/__init__.py +15 -0
  23. singular_ticker_causal/causal_inference/abduction.py +127 -0
  24. singular_ticker_causal/causal_inference/causal_model.py +314 -0
  25. singular_ticker_causal/causal_inference/estimator.py +125 -0
  26. singular_ticker_causal/causal_inference/identification.py +41 -0
  27. singular_ticker_causal/causal_inference/mutilator.py +62 -0
  28. singular_ticker_causal/causal_inference/pywhyllm_assumptions.py +338 -0
  29. singular_ticker_causal/causal_inference/query_engine.py +505 -0
  30. singular_ticker_causal/causal_inference/tests/test_causal_queries.py +102 -0
  31. singular_ticker_causal/causal_inference/tests/test_pywhyllm_assumptions.py +193 -0
  32. singular_ticker_causal/data_sources/__init__.py +4 -0
  33. singular_ticker_causal/data_sources/bsedata/__init__.py +27 -0
  34. singular_ticker_causal/data_sources/bsedata/bhavcopy.py +58 -0
  35. singular_ticker_causal/data_sources/bsedata/bse.py +150 -0
  36. singular_ticker_causal/data_sources/bsedata/exceptions.py +51 -0
  37. singular_ticker_causal/data_sources/bsedata/gainers.py +57 -0
  38. singular_ticker_causal/data_sources/bsedata/helpers.py +3 -0
  39. singular_ticker_causal/data_sources/bsedata/indices.py +112 -0
  40. singular_ticker_causal/data_sources/bsedata/losers.py +57 -0
  41. singular_ticker_causal/data_sources/bsedata/quote.py +176 -0
  42. singular_ticker_causal/data_sources/fetcher.py +219 -0
  43. singular_ticker_causal/data_sources/gdelt_client.py +400 -0
  44. singular_ticker_causal/data_sources/news_client.py +460 -0
  45. singular_ticker_causal/data_sources/nseconnect/__init__.py +25 -0
  46. singular_ticker_causal/data_sources/nseconnect/bases.py +72 -0
  47. singular_ticker_causal/data_sources/nseconnect/cleaners.py +51 -0
  48. singular_ticker_causal/data_sources/nseconnect/datemgr.py +104 -0
  49. singular_ticker_causal/data_sources/nseconnect/downloader.py +116 -0
  50. singular_ticker_causal/data_sources/nseconnect/errors.py +8 -0
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *__pycache__/
2
+
3
+ *.md
4
+ !README.md
5
+ *.log
6
+ *.npy
7
+ *.csv
8
+ *.png
9
+ *.grd
10
+ *.owl
11
+ *.out
12
+ *.json
13
+ *.yaml
README.md CHANGED
@@ -1 +1,661 @@
1
- # Noisy_boy_frontend
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Iroha - Financial Intelligence Pipeline API
3
+ colorFrom: blue
4
+ colorTo: indigo
5
+ sdk: gradio
6
+ sdk_version: 6.18.0
7
+ app_file: server.py
8
+ pinned: false
9
+ ---
10
+
11
+ # Iroha - Financial Intelligence Pipeline API
12
+
13
+ **Version:** 1.0.0
14
+ **Description:** End-to-end financial intelligence pipeline for Net-of-Tax Alpha decisions using causal chain analysis, web scraping, and data aggregation.
15
+
16
+ ---
17
+
18
+ ## Table of Contents
19
+
20
+ 1. [Overview](#overview)
21
+ 2. [Authentication](#authentication)
22
+ 3. [API Endpoints](#api-endpoints)
23
+ - [Server Endpoints](#server-endpoints)
24
+ - [V2 API Endpoints](#v2-api-endpoints)
25
+ 4. [Core Functions](#core-functions)
26
+ 5. [Supported Tickers](#supported-tickers)
27
+ 6. [Error Handling](#error-handling)
28
+ 7. [Examples](#examples)
29
+
30
+ ---
31
+
32
+ ## Overview
33
+
34
+ The Noisy Boy API is a FastAPI-based financial intelligence system that:
35
+
36
+ - **Fetches multi-source financial data** from various Indian market sources (BSE, NSE, RBI, SEBI, etc.)
37
+ - **Analyzes causal chains** between market events and price movements using BERT and embedding models
38
+ - **Generates aggregated text** from diverse data sources for each ticker
39
+ - **Builds knowledge graphs** with semantic and evidence layers
40
+ - **Streams real-time SSE responses** for long-running analysis tasks
41
+
42
+ ### Technology Stack
43
+
44
+ - **Framework:** FastAPI with async/await support
45
+ - **Database:** SQLite (local) + Supabase (cloud)
46
+ - **Models:**
47
+ - BERT-CAUSE-EFFECT for causal relationship extraction
48
+ - Fireworks AI embeddings for similarity scoring
49
+ - OpenAI LLM for graph summarization
50
+ - TinyFish AI for web automation
51
+ - **Data Sources:** 50+ fetchers covering macro, regulatory, and company-specific data
52
+
53
+ ---
54
+
55
+ ## Authentication
56
+
57
+ ### API Key Authentication
58
+
59
+ All endpoints (except `/api/source-refs`) require an `X-API-Key` header:
60
+
61
+ ```bash
62
+ curl -H "X-API-Key: your-secret-token" https://api.example.com/api/endpoint
63
+ curl -H "X-API-Key: your-api-key" http://localhost:8000/v2/api/endpoint
64
+ ```
65
+
66
+ **Environment Variable**: `API_KEY` (default: `secret-token`)
67
+
68
+ ---
69
+
70
+ ## Base URL
71
+
72
+ ```
73
+ http://localhost:8000/v2
74
+ ```
75
+
76
+ ---
77
+
78
+ ## API Endpoints
79
+
80
+ ### Scraper API
81
+
82
+ #### 1. GET `/api/source-refs`
83
+ Returns all fetcher-to-source-URL mappings and reference links.
84
+
85
+ **Authentication**: None required
86
+
87
+ **Response**:
88
+ ```json
89
+ {
90
+ "fetcher_refs": {
91
+ "repo_rate": "https://rbi.org.in/...",
92
+ "bse": "https://bseindia.com/...",
93
+ ...
94
+ },
95
+ "cat_refs": {
96
+ "macro": {...},
97
+ "corporate": {...}
98
+ }
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ #### 2. POST `/api/run`
105
+ Streams real-time scraping results via Server-Sent Events (SSE) from TinyFish API with local caching.
106
+
107
+ **Authentication**: Required (`X-API-Key`)
108
+
109
+ **Request Body**:
110
+ ```json
111
+ {
112
+ "url": "https://example.com/page",
113
+ "goal": "Extract financial data",
114
+ "ticker": "HDFCBANK",
115
+ "stealth": false
116
+ }
117
+ ```
118
+
119
+ **Query Parameters**:
120
+ - `url` (required): Target URL to scrape
121
+ - `goal` (required): Scraping objective description
122
+ - `ticker` (optional): Stock ticker for caching purposes
123
+ - `stealth` (optional, bool): Enable stealth mode
124
+
125
+ **Response** (SSE):
126
+ ```
127
+ data: {"type": "STARTED", "run_id": "run_123"}
128
+ data: {"type": "PROGRESS", "purpose": "Fetching data..."}
129
+ data: {"type": "COMPLETE", "status": "COMPLETED", "result_json": {...}}
130
+ ```
131
+
132
+ **Caching**: Results are cached per ticker per day via SQLite.
133
+
134
+ ---
135
+
136
+ #### 3. GET `/api/ticker-data/{ticker}`
137
+ Fetch all cached data for a ticker across all sources (Supabase).
138
+
139
+ **Authentication**: Required (`X-API-Key`)
140
+
141
+ **Path Parameters**:
142
+ - `ticker`: Stock ticker symbol (e.g., `HDFCBANK`)
143
+
144
+ **Response**:
145
+ ```json
146
+ {
147
+ "ticker": "HDFCBANK",
148
+ "status": "success",
149
+ "data": {
150
+ "repo_rate": {
151
+ "data": {...},
152
+ "fetched_at": "2024-01-15T10:30:00Z"
153
+ },
154
+ "bse": {
155
+ "data": [...],
156
+ "fetched_at": "2024-01-15T10:30:00Z"
157
+ },
158
+ "aggregated_text": {
159
+ "aggregated_text": "...",
160
+ "fetched_at": "2024-01-15T10:30:00Z"
161
+ },
162
+ "causal_chain": {
163
+ "nodes": [...],
164
+ "links": [...],
165
+ "all_chains": [...],
166
+ "biggest_chain": [...]
167
+ }
168
+ }
169
+ }
170
+ ```
171
+
172
+ ---
173
+
174
+ ### Causal Chain API
175
+
176
+ #### 4. GET `/api/generate-causal-chain-stream`
177
+ Streams causal chain generation via SSE (two phases: nodes, then edges).
178
+
179
+ **Authentication**: Required (`X-API-Key`)
180
+
181
+ **Query Parameters**:
182
+ - `ticker` (required): Stock ticker symbol
183
+
184
+ **Response** (SSE - Streaming):
185
+ ```
186
+ data: {"type": "status", "message": "Generating text for ticker..."}
187
+ data: {"type": "nodes", "nodes": [{"id": "RBI Rate Hike", "label": "RBI Rate Hike"}, ...]}
188
+ data: {"type": "edges", "links": [{"source": "Rate Hike", "target": "Inflation", "score": 0.89}, ...]}
189
+ data: {"type": "done", "all_chains": [[...], [...]], "biggest_chain": [...]}
190
+ ```
191
+
192
+ **Phases**:
193
+ 1. **Status**: Initial status message
194
+ 2. **Nodes**: Unique causal entities extracted from BERT model
195
+ 3. **Edges**: Connections between nodes from embedding similarity
196
+ 4. **Done**: Final chains and biggest causal path
197
+
198
+ ---
199
+
200
+ ### Ontology API
201
+
202
+ #### 5. POST `/api/generate-ontology`
203
+ Generate financial ontology from input text.
204
+
205
+ **Authentication**: Required (via implicit call)
206
+
207
+ **Request Body**:
208
+ ```json
209
+ {
210
+ "ticker": "RELIANCE",
211
+ "text": "Recent crude oil rally drives RELIANCE revenue..."
212
+ }
213
+ ```
214
+
215
+ **Response**:
216
+ ```json
217
+ {
218
+ "status": "success",
219
+ "ontology": {
220
+ "entities": ["crude", "revenue", "RELIANCE"],
221
+ "relations": [{"type": "affects", "from": "crude", "to": "RELIANCE"}]
222
+ }
223
+ }
224
+ ```
225
+
226
+ ---
227
+
228
+ #### 6. POST `/api/extract-entities`
229
+ Extract named entities using the provided ontology.
230
+
231
+ **Authentication**: Required (via implicit call)
232
+
233
+ **Request Body**:
234
+ ```json
235
+ {
236
+ "ticker": "ITC",
237
+ "text": "Coal prices surge amid monsoon fears...",
238
+ "ontology": {
239
+ "entities": ["coal", "monsoon", "price"],
240
+ "relations": [...]
241
+ }
242
+ }
243
+ ```
244
+
245
+ **Response**:
246
+ ```json
247
+ {
248
+ "status": "success",
249
+ "entities": [
250
+ {"entity": "coal", "type": "commodity", "confidence": 0.92},
251
+ {"entity": "monsoon", "type": "weather_event", "confidence": 0.88}
252
+ ]
253
+ }
254
+ ```
255
+
256
+ ---
257
+
258
+ #### 7. POST `/api/build-knowledge-graph`
259
+ Build complete knowledge graph with nodes, edges, and causal chains.
260
+
261
+ **Authentication**: Required (via implicit call)
262
+
263
+ **Request Body**:
264
+ ```json
265
+ {
266
+ "graph_id": "graph_001",
267
+ "ticker": "BHEL",
268
+ "text": "Full financial text for analysis...",
269
+ "financial_results": {...},
270
+ "forensic_results": {...},
271
+ "tech_results": {...},
272
+ "cached_data": {...}
273
+ }
274
+ ```
275
+
276
+ **Response**:
277
+ ```json
278
+ {
279
+ "meta": {
280
+ "ticker": "BHEL",
281
+ "exchange": "NSE",
282
+ "generated_at": "2024-01-15T12:45:00Z",
283
+ "status": "success",
284
+ "chain_count": 5,
285
+ "node_count": 23,
286
+ "edge_count": 45
287
+ },
288
+ "summary": {
289
+ "narrative": "Coal shortage → Power demand surge → BHEL tariff opportunity",
290
+ "net_sentiment_for_ticker": "bullish",
291
+ "ticker_relevance_score": 0.87,
292
+ "macro_regimes_active": ["coal-supply-shock", "power-demand-rally"],
293
+ "top_causal_nodes": ["Coal shortage", "Power demand", "BHEL capacity"],
294
+ "ria_alert": {
295
+ "level": "medium",
296
+ "reason": "Monitor coal supply for sustained impact on tariff structure."
297
+ }
298
+ },
299
+ "biggest_chain": ["Coal shortage", "Power demand", "BHEL contract wins", "Revenue growth"],
300
+ "all_chains": [[...], [...], ...],
301
+ "nodes": [...],
302
+ "edges": [...]
303
+ }
304
+ ```
305
+
306
+ ---
307
+
308
+ ### Server API (Streaming)
309
+
310
+ #### 8. GET `/api/generate-causal-chain-stream` (Alternative)
311
+ Same as endpoint #4 but with optional caching from server-side TinyFish integration.
312
+
313
+ ---
314
+
315
+ ## Data Fetchers
316
+
317
+ The system supports multiple data fetchers for different tickers. Each fetcher aggregates specific financial signals:
318
+
319
+ ### Supported Fetchers
320
+
321
+ | Fetcher | Description | Tickers |
322
+ |---------|-------------|---------|
323
+ | `repo_rate` | RBI Repo Rate | HDFCBANK, TCS, PAYTM, TMPV |
324
+ | `bse` | BSE Announcements | All supported tickers |
325
+ | `fii_dii` | FII/DII Flows | HDFCBANK |
326
+ | `brent` | Brent Crude Oil Price | HDFCBANK, RELIANCE, TMPV |
327
+ | `news` | Financial News Articles | All supported tickers |
328
+ | `npp` | National Power Portal Data | IEX, BHEL |
329
+ | `imd_monsoon` | IMD Monsoon Status | RELIANCE, IEX, BHEL, ETERNAL, ULTRACEMCO |
330
+ | `coal` | Coal Price Index | IEX, BHEL, ITC, ULTRACEMCO |
331
+ | `bhel_tenders` | BHEL Active Tenders | BHEL |
332
+ | `agmarknet` | Agricultural Prices | ETERNAL, ITC |
333
+ | `weather` | Weather in Key Cities | ETERNAL |
334
+ | `h1b` | H1B Visa Filings | TCS |
335
+ | `us_fed` | US Fed Interest Rate | TCS |
336
+ | `us_pmi` | US Services PMI | TCS |
337
+ | `trai_reports` | TRAI Telecom Reports | RELIANCE |
338
+ | `npci` | NPCI UPI Statistics | ETERNAL, IRCTC, PAYTM, MAPMYINDIA |
339
+ | `dgca_traffic` | DGCA Air Traffic Data | IRCTC |
340
+ | `tourist_arrivals` | Foreign Tourist Arrivals | IRCTC |
341
+ | `india_cpi` | India CPI Inflation | ITC |
342
+ | `food_cpi` | Food CPI Inflation | ETERNAL |
343
+ | `labour` | Labour Ministry Releases | ETERNAL |
344
+ | `sebi_orders` | SEBI Orders | PAYTM |
345
+ | `mca_filings` | MCA Corporate Filings | PAYTM |
346
+ | `cma_capacity` | Cement Capacity | ULTRACEMCO |
347
+ | `datareportal` | Digital India Stats | MAPMYINDIA |
348
+ | `pib_highways` | PIB Highway Announcements | ULTRACEMCO, MAPMYINDIA |
349
+ | `pib_vb` | PIB Vande Bharat Updates | BHEL, IRCTC |
350
+ | `nse_bulk_deals` | NSE Bulk Deals | RELIANCE |
351
+ | `cci_orders` | CCI Antitrust Orders | RELIANCE |
352
+ | `erc_orders` | ERC Tariff Orders | IEX |
353
+ | `saubhagya` | Saubhagya Electrification | IEX |
354
+ | `ppac` | India Basket Crude | RELIANCE, ULTRACEMCO |
355
+
356
+ ### Fetcher Response Format
357
+
358
+ Each fetcher returns formatted text with relevant financial signals:
359
+
360
+ ```
361
+ Brent crude oil price is $85.50 showing a (upward trend, with 5.2% change 30-day).
362
+ RBI Repo Rate is 6.5%.
363
+ FII/DII flows for 2024-01-15: FII net is 450 Cr with net (buy action, holding a positive view).
364
+ ```
365
+
366
+ ---
367
+
368
+ ## Core Functions
369
+
370
+ ### CausalChain Class (`app/services/causal_chains.py`)
371
+
372
+ #### `__init__(chunks, fireworks_api_key, fireworks_model)`
373
+ Initialize causal chain processor.
374
+
375
+ **Parameters**:
376
+ - `chunks` (list): Text chunks to analyze
377
+ - `fireworks_api_key` (str): Fireworks AI API key
378
+ - `fireworks_model` (str): Embedding model name (default: `qwen3-embedding-8b`)
379
+
380
+ ---
381
+
382
+ #### `create_effects(batch_size=16)`
383
+ Extract triggers and effects using BERT-CAUSE-EFFECT model.
384
+
385
+ **Process**:
386
+ 1. Sends text chunks to causal model API
387
+ 2. Parses model responses for event triggers and descriptions
388
+ 3. Stores triggers and effects
389
+
390
+ **Retries**: Up to 5 attempts with exponential backoff for failed requests
391
+
392
+ ---
393
+
394
+ #### `create_connections(batch_size=16, chain_threshold=0.85)`
395
+ Build causal connections using embeddings with grounding.
396
+
397
+ **Process**:
398
+ 1. Encodes triggers and effects via Fireworks embeddings
399
+ 2. Computes cosine similarity between effects and triggers
400
+ 3. Grounds connections by shared entities and keywords
401
+ 4. Filters spurious links using entity intersection
402
+
403
+ **Parameters**:
404
+ - `chain_threshold`: Similarity score threshold (default: 0.85)
405
+
406
+ ---
407
+
408
+ #### `get_all_chains(min_length=2, max_paths=500, time_budget=30)`
409
+ Extract unique causal chains from connections.
410
+
411
+ **Parameters**:
412
+ - `min_length`: Minimum chain length to keep
413
+ - `max_paths`: Stop after collecting N paths
414
+ - `time_budget`: Wall-clock time limit (seconds)
415
+
416
+ **Returns**: List of chains, each a list of nodes
417
+
418
+ ---
419
+
420
+ #### `find_biggest_chain(time_budget=20)`
421
+ Find longest causal chain using iterative DFS.
422
+
423
+ **Returns**: Longest causal path as list of nodes
424
+
425
+ ---
426
+
427
+ #### `to_dict()` / `from_dict()`
428
+ Serialize/deserialize causal chain state.
429
+
430
+ ---
431
+
432
+ ### Utility Functions (`util` class)
433
+
434
+ #### `cos_sim(a, b)`
435
+ Compute cosine similarity matrix between two embedding arrays.
436
+
437
+ ```python
438
+ scores = util.cos_sim(effect_embeddings, trigger_embeddings)
439
+ # scores[i,j] = cosine similarity between effect i and trigger j
440
+ ```
441
+
442
+ ---
443
+
444
+ #### `create_chunks(text_input, target_size=700, overlap_sentences=1)`
445
+ Create semantic chunks with overlap for context preservation.
446
+
447
+ **Parameters**:
448
+ - `target_size`: Target chunk size (characters)
449
+ - `overlap_sentences`: Number of sentences to overlap
450
+
451
+ **Returns**: List of text chunks ≤ 1000 chars each
452
+
453
+ ---
454
+
455
+ ### Fetcher Functions (`scrapper/tiny_fish.py`)
456
+
457
+ #### `run_fetcher(fetcher_key, ticker_config, persist=False)`
458
+ Execute a single data fetcher.
459
+
460
+ **Parameters**:
461
+ - `fetcher_key`: Name of fetcher (e.g., `repo_rate`, `bse`)
462
+ - `ticker_config`: Config dict with BSE code, ticker symbol, fetchers list
463
+ - `persist`: Whether to cache results to SQLite
464
+
465
+ **Returns**: Formatted data dict
466
+
467
+ **Caching**: Per-ticker, per-day SQLite caching
468
+
469
+ ---
470
+
471
+ ### Text Formatting
472
+
473
+ #### `format_fetcher_text(fetcher_key, data)`
474
+ Format raw fetcher data into human-readable text.
475
+
476
+ **Example**:
477
+ ```python
478
+ text = format_fetcher_text("repo_rate", {"repo_rate_pct": 6.5})
479
+ # Returns: "The current RBI Repo Rate is 6.5%.\n"
480
+ ```
481
+
482
+ ---
483
+
484
+ ## Examples
485
+
486
+ ### Example 1: Fetch and Cache Aggregated Data
487
+
488
+ ```bash
489
+ curl -X GET "http://localhost:8000/v2/api/ticker-data/RELIANCE" \
490
+ -H "X-API-Key: secret-token"
491
+ ```
492
+
493
+ ---
494
+
495
+ ### Example 2: Stream Causal Chain Generation (SSE)
496
+
497
+ ```bash
498
+ curl -X GET "http://localhost:8000/v2/api/generate-causal-chain-stream?ticker=HDFCBANK" \
499
+ -H "X-API-Key: secret-token" \
500
+ -N
501
+ ```
502
+
503
+ **Output** (streaming):
504
+ ```
505
+ data: {"type":"status","message":"Generating text for ticker..."}
506
+ data: {"type":"nodes","nodes":[{"id":"RBI Rate Hike","label":"RBI Rate Hike"},{"id":"Inflation Risk","label":"Inflation Risk"}]}
507
+ data: {"type":"edges","links":[{"source":"RBI Rate Hike","target":"Inflation Risk","score":0.89}]}
508
+ data: {"type":"done","all_chains":[["RBI Rate Hike","Inflation Risk","Market Correction"]],"biggest_chain":["RBI Rate Hike","Inflation Risk","Market Correction"]}
509
+ ```
510
+
511
+ ---
512
+
513
+ ### Example 3: Build Knowledge Graph
514
+
515
+ ```bash
516
+ curl -X POST "http://localhost:8000/v2/api/build-knowledge-graph" \
517
+ -H "Content-Type: application/json" \
518
+ -d '{
519
+ "graph_id": "g001",
520
+ "ticker": "TCS",
521
+ "text": "H1B visa approvals surge amid US tech expansion...",
522
+ "financial_results": null,
523
+ "forensic_results": null,
524
+ "tech_results": null,
525
+ "cached_data": null
526
+ }'
527
+ ```
528
+
529
+ ---
530
+
531
+ ### Example 4: Ontology Generation
532
+
533
+ ```bash
534
+ curl -X POST "http://localhost:8000/v2/api/generate-ontology" \
535
+ -H "Content-Type: application/json" \
536
+ -d '{
537
+ "ticker": "PAYTM",
538
+ "text": "NPCI UPI transaction surge drives fintech growth..."
539
+ }'
540
+ ```
541
+
542
+ ---
543
+
544
+ ### Example 5: Run Custom Web Scraper
545
+
546
+ ```bash
547
+ curl -X POST "http://localhost:8000/v2/api/run" \
548
+ -H "Content-Type: application/json" \
549
+ -H "X-API-Key: secret-token" \
550
+ -d '{
551
+ "url": "https://example.com/financial-news",
552
+ "goal": "Extract Q4 earnings announcements",
553
+ "ticker": "RELIANCE",
554
+ "stealth": true
555
+ }' \
556
+ -N
557
+ ```
558
+
559
+ ---
560
+
561
+ ## Error Handling
562
+
563
+ ### Standard Error Responses
564
+
565
+ #### 400 Bad Request
566
+ ```json
567
+ {
568
+ "detail": "url and goal are required"
569
+ }
570
+ ```
571
+
572
+ #### 401 Unauthorized
573
+ ```json
574
+ {
575
+ "detail": "Invalid or missing API Key"
576
+ }
577
+ ```
578
+
579
+ #### 500 Internal Server Error
580
+ ```json
581
+ {
582
+ "detail": "TINYFISH_API_KEY not found in .env"
583
+ }
584
+ ```
585
+
586
+ ---
587
+
588
+ ## Supported Tickers
589
+
590
+ | Ticker | BSE Code | Use Case |
591
+ |--------|----------|----------|
592
+ | HDFCBANK | 500180 | Banking sector, RBI rates |
593
+ | RELIANCE | 500325 | Energy, crude oil exposure |
594
+ | IEX | 540716 | Power sector, capacity data |
595
+ | TCS | 532540 | IT sector, US visa trends |
596
+ | BHEL | 500103 | Power equipment, tender data |
597
+ | ETERNAL | 543320 | Agri-related, food inflation |
598
+ | IRCTC | 542830 | Transportation, tourist flows |
599
+ | ITC | 500875 | Agri-commodities, coal |
600
+ | PAYTM | 543396 | Fintech, NPCI/UPI metrics |
601
+ | ULTRACEMCO | 532538 | Cement, infrastructure |
602
+ | TMPV (TATAMOTORS) | 500570 | Automotive, oil exposure |
603
+ | MAPMYINDIA | 543425 | Digital infrastructure |
604
+
605
+ ---
606
+
607
+ ## Response Caching Strategy
608
+
609
+ - **SQLite Cache**: Per ticker, per day (local fetcher results)
610
+ - **Supabase Cache**: Per ticker, per day (aggregated text, causal chains, ontology)
611
+ - **Cache Invalidation**: Automatic at midnight UTC
612
+
613
+ ---
614
+
615
+ ## Performance Notes
616
+
617
+ - **Causal Chain Generation**: ~30-60 seconds for typical 50-100 chunk inputs
618
+ - **Embedding Computation**: ~5-15 seconds per 100 text chunks
619
+ - **Large Graph Building**: May take 2-5 minutes for complex tickets with >500 nodes
620
+
621
+ ---
622
+
623
+ ## Environment Variables
624
+
625
+ ```bash
626
+ API_KEY=secret-token
627
+ FIREWORKS_API_KEY=your-fireworks-key
628
+ CAUSAL_URL=http://localhost:8080/generate_batch
629
+ CAUSAL_API_KEY=optional-causal-model-key
630
+ TINYFISH_API_KEY=your-tinyfish-key
631
+ SUPABASE_URL=https://your-project.supabase.co
632
+ SUPABASE_KEY=your-supabase-key
633
+ DEBUG=false
634
+ ```
635
+
636
+ ---
637
+
638
+ ## License
639
+
640
+ Proprietary - ProjectImpulse
641
+
642
+ ---
643
+
644
+ ## Support
645
+
646
+ For issues or questions, contact the ProjectImpulse team.
647
+ ```
648
+
649
+ I've created a comprehensive API README that documents all endpoints, functions, and features in the Noisy_boy repository. The documentation includes:
650
+
651
+ **Key Sections:**
652
+ 1. **Authentication & Base URL** - How to authenticate and where to call endpoints
653
+ 2. **8 Major Endpoints** - Scraper, Causal Chain, Ontology, and Server APIs with request/response examples
654
+ 3. **30+ Data Fetchers** - Table of all supported data sources for different tickers
655
+ 4. **Core Functions** - Detailed documentation of `CausalChain` class, `util` functions, and fetcher operations
656
+ 5. **5 Practical Examples** - cURL commands showing how to use each endpoint
657
+ 6. **Error Handling** - Standard error response formats
658
+ 7. **Performance Notes** - Expected timing for various operations
659
+ 8. **Environment Variables** - Complete list of required configs
660
+
661
+ The documentation follows industry standards with clear formatting, examples, and parameter specifications. You can copy this content into an `API_README.md` file in your repository!
frontend/app.js ADDED
@@ -0,0 +1,849 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CUTS+ Causal Terminal — Frontend Logic
3
+ * Communicates with the gr.Server backend via the Gradio JS Client
4
+ * and standard fetch() for REST helper endpoints.
5
+ */
6
+
7
+ // ── Gradio Client bootstrap ────────────────────────────────────────────────
8
+ // Loaded from CDN in index.html; window.GradioClient is set after import.
9
+ let GR_CLIENT = null;
10
+
11
+ async function initGradioClient() {
12
+ try {
13
+ const { Client } = await import('https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js');
14
+ GR_CLIENT = await Client.connect(window.location.origin);
15
+ setBannerState('gradio', 'ok', 'GRADIO OK');
16
+ } catch (err) {
17
+ console.warn('[Gradio Client] init failed (demo mode):', err);
18
+ setBannerState('gradio', 'err', 'GRADIO OFFLINE');
19
+ }
20
+ }
21
+
22
+ // ── Config ─────────────────────────────────────────────────────────────────
23
+ const BASE = window.location.origin; // same origin — gr.Server hosts both
24
+
25
+ // ── Sector / Ticker Data ───────────────────────────────────────────────────
26
+ const SECTORS = {
27
+ 'Energy': ['RELIANCE','ONGC','BPCL','IOC','GAIL'],
28
+ 'Technology': ['TCS','INFY','WIPRO','HCLTECH','TECHM'],
29
+ 'Financials': ['HDFCBANK','ICICIBANK','KOTAKBANK','AXISBANK','SBIN'],
30
+ 'Consumer': ['ITC','HINDUNILVR','NESTLEIND','BRITANNIA'],
31
+ 'Industrials': ['LT','ADANIPORTS','SIEMENS'],
32
+ 'Healthcare': ['SUNPHARMA','DRREDDY','CIPLA'],
33
+ 'Materials': ['TATASTEEL','JSWSTEEL','HINDALCO'],
34
+ 'Telecom': ['BHARTIARTL','INDUSINDBK'],
35
+ 'Realty': ['DLF','GODREJPROP'],
36
+ };
37
+ const ALL = Object.values(SECTORS).flat();
38
+ const N = ALL.length;
39
+ const TICKER_SEC = {};
40
+ for (const [s, ms] of Object.entries(SECTORS)) ms.forEach(t => TICKER_SEC[t] = s);
41
+ const SEC_NAMES = Object.keys(SECTORS);
42
+ const S = SEC_NAMES.length;
43
+
44
+ // ── Deterministic RNG ──────────────────────────────────────────────────────
45
+ function mkRng(seed) {
46
+ let s = seed;
47
+ return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
48
+ }
49
+
50
+ // ── φ Potentials (HHKD output — seeded defaults, overridden by API) ────────
51
+ const rA = mkRng(42);
52
+ const PHI = {};
53
+ [
54
+ ['RELIANCE',2.41],['ONGC',2.18],['TCS',2.05],['BHARTIARTL',1.92],['LT',1.78],
55
+ ['INFY',1.65],['HDFCBANK',1.52],['ICICIBANK',1.39],['BPCL',1.28],['GAIL',1.14],
56
+ ['WIPRO',1.02],['HCLTECH',0.89],['IOC',0.76],['KOTAKBANK',0.65],['AXISBANK',0.54],
57
+ ['SBIN',0.41],['ITC',0.28],['HINDUNILVR',0.15],['NESTLEIND',0.03],['TATASTEEL',-0.09],
58
+ ['JSWSTEEL',-0.22],['HINDALCO',-0.35],['SUNPHARMA',-0.48],['DRREDDY',-0.61],
59
+ ['CIPLA',-0.74],['SIEMENS',-0.87],['ADANIPORTS',-1.13],
60
+ ['TECHM',-1.26],['BRITANNIA',-1.39],['INDUSINDBK',-1.52],['DLF',-1.65],
61
+ ['GODREJPROP',-1.78],
62
+ ].forEach(([t, v]) => PHI[t] = v);
63
+ ALL.forEach(t => { if (PHI[t] == null) PHI[t] = -1.2 + rA() * 0.4; });
64
+
65
+ const SEC_PHI = {};
66
+ for (const [s, ms] of Object.entries(SECTORS))
67
+ SEC_PHI[s] = ms.reduce((a, t) => a + (PHI[t] || 0), 0) / ms.length;
68
+
69
+ // ── Adjacency Matrix (seeded defaults, overridden by API) ──────────────────
70
+ const rB = mkRng(77);
71
+ const ADJ = [];
72
+ for (let i = 0; i < N; i++) {
73
+ ADJ.push([]);
74
+ for (let j = 0; j < N; j++) {
75
+ if (i === j) { ADJ[i].push(0); continue; }
76
+ const pd = (PHI[ALL[i]] || 0) - (PHI[ALL[j]] || 0);
77
+ const ss = TICKER_SEC[ALL[i]] === TICKER_SEC[ALL[j]];
78
+ let v = 0.04 + Math.max(0, pd) * 0.14 + (ss ? 0.09 : 0) + rB() * 0.08;
79
+ if (pd > 0.8) v += 0.22;
80
+ ADJ[i].push(Math.min(0.97, Math.max(0.01, v)));
81
+ }
82
+ }
83
+
84
+ const EDGES = [];
85
+ for (let i = 0; i < N; i++)
86
+ for (let j = 0; j < N; j++)
87
+ if (ADJ[i][j] > 0.5) EDGES.push({ si: i, ti: j, w: ADJ[i][j] });
88
+
89
+ // ── Sector Macro Adjacency ─────────────────────────────────────────────────
90
+ const MADJ = Array.from({ length: S }, () => Array(S).fill(0));
91
+ for (let a = 0; a < S; a++)
92
+ for (let b = 0; b < S; b++) {
93
+ if (a === b) continue;
94
+ MADJ[a][b] = Math.min(
95
+ 0.96,
96
+ Math.max(0.02, 0.28 + (SEC_PHI[SEC_NAMES[a]] - SEC_PHI[SEC_NAMES[b]]) * 0.18 + mkRng(a * 9 + b + 1)() * 0.14)
97
+ );
98
+ }
99
+
100
+ // ── DuPont Prior ───────────────────────────────────────────────────────────
101
+ const FNODES = ['Revenue','COGS','GrossProfit','EBITDA','EBIT','NetIncome','TotalAssets',
102
+ 'TotalDebt','Cash','OpCF','CapEx','FCF','Equity','Retained','Tax','Interest',
103
+ 'Depreciation','Inventory','AR','AP','PPE','Goodwill','EPS'];
104
+ const FN = FNODES.length;
105
+ const FPRIOR = Array.from({ length: FN }, () => Array(FN).fill(0));
106
+ [[0,1],[0,2],[2,3],[3,4],[4,5],[4,15],[1,16],[6,7],[6,12],[7,15],[9,11],[9,10],
107
+ [10,11],[5,13],[5,22],[4,14],[12,13],[0,9],[3,16],[6,18],[6,17],[6,19],[6,20]]
108
+ .forEach(([a, b]) => FPRIOR[a][b] = 1);
109
+
110
+ // ── News Feed Data ─────────────────────────────────────────────────────────
111
+ const NEWS = [
112
+ { sym:'RELIANCE', score:0.91, dir: 1, text:'RIL Jio 5G capex ₹40kCr accelerates infrastructure spend', tags:['CapEx','FCF','Revenue'] },
113
+ { sym:'HDFCBANK', score:0.84, dir:-1, text:'RBI repo hike 25bps — NIM compression expected Q2FY25', tags:['NetIncome','Interest','TotalDebt'] },
114
+ { sym:'TCS', score:0.79, dir: 1, text:'TCS Q3 deal wins ₹14kCr; US enterprise recovery signal', tags:['Revenue','NetIncome','EPS'] },
115
+ { sym:'TATASTEEL',score:0.55, dir:-1, text:'Coking coal import cost pressure; EBITDA margins at risk', tags:['COGS','GrossProfit','EBITDA'] },
116
+ { sym:'ONGC', score:0.72, dir: 1, text:'ONGC upstream production beats est; crude realisation up', tags:['Revenue','OpCF'] },
117
+ ];
118
+
119
+ // ── State ──────────────────────────────────────────────────────────────────
120
+ let currentTab = 'matrix';
121
+ let selTicker = 'RELIANCE';
122
+ let inferMode = 'assert';
123
+ let activeRipple = null;
124
+ let sbFilter = 'all';
125
+ let sbSearch = '';
126
+ let netPositions = {};
127
+ let popupTimer = null;
128
+
129
+ // ── Colour Helpers ─────────────────────────────────────────────────────────
130
+ function phiColor(v) {
131
+ if (v > 1.5) return '#f0a500';
132
+ if (v > 0.5) return '#d4b840';
133
+ if (v > -0.5) return '#00b8d4';
134
+ return '#5a5a54';
135
+ }
136
+ function adjColor(v) {
137
+ if (v > 0.7) return `rgba(224,52,52,${0.45 + v * 0.5})`;
138
+ if (v > 0.4) return `rgba(240,165,0,${0.25 + v * 0.65})`;
139
+ return `rgba(0,80,40,${v * 1.8})`;
140
+ }
141
+ function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
142
+ function fmtPhi(v) { return (v >= 0 ? '+' : '') + v.toFixed(2); }
143
+
144
+ // ── API Banner ─────────────────────────────────────────────────────────────
145
+ function setBannerState(id, state, label) {
146
+ const chip = document.getElementById(`api-${id}`);
147
+ if (!chip) return;
148
+ chip.className = `api-chip ${state}`;
149
+ const dot = chip.querySelector('.api-dot');
150
+ if (dot) dot.setAttribute('title', label);
151
+ const span = chip.querySelector('span:last-child');
152
+ if (span) span.textContent = label;
153
+ }
154
+
155
+ // ── Error Toast ────────────────────────────────────────────────────────────
156
+ function showToast(msg) {
157
+ const el = document.getElementById('error-toast');
158
+ if (!el) return;
159
+ el.textContent = msg;
160
+ el.classList.add('show');
161
+ setTimeout(() => el.classList.remove('show'), 3500);
162
+ }
163
+
164
+ // ── Clock ──────────────────────────────────────────────────────────────────
165
+ setInterval(() => {
166
+ const el = document.getElementById('clock');
167
+ if (el) el.textContent = new Date().toTimeString().slice(0, 8);
168
+ }, 1000);
169
+ setInterval(() => {
170
+ const el = document.getElementById('ss-loss');
171
+ if (el) el.textContent = (0.038 + Math.random() * 0.006).toFixed(4);
172
+ }, 3000);
173
+
174
+ // ── API Calls ──────────────────────────────────────────────────────────────
175
+ async function apiGet(path) {
176
+ try {
177
+ const r = await fetch(`${BASE}${path}`);
178
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
179
+ return await r.json();
180
+ } catch (e) {
181
+ console.warn(`[API] GET ${path} failed:`, e.message);
182
+ return null;
183
+ }
184
+ }
185
+
186
+ async function apiPost(path, body) {
187
+ try {
188
+ const r = await fetch(`${BASE}${path}`, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json' },
191
+ body: JSON.stringify(body),
192
+ });
193
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
194
+ return await r.json();
195
+ } catch (e) {
196
+ console.warn(`[API] POST ${path} failed:`, e.message);
197
+ return null;
198
+ }
199
+ }
200
+
201
+ // Fetch causal graph for selected ticker and update ADJ / EDGES
202
+ async function fetchCausalGraph(ticker) {
203
+ setBannerState('pipeline', 'busy', 'LOADING…');
204
+ const data = await apiGet(`/v2/causal/singular-causal/graph/${ticker}`);
205
+ if (data && data.nodes && data.links) {
206
+ // Patch ADJ from API data
207
+ const apiIdxMap = {};
208
+ data.nodes.forEach((n, i) => { apiIdxMap[n.id || n.label] = i; });
209
+ // Mark in status
210
+ setBannerState('pipeline', 'ok', `GRAPH ${ticker} ✓`);
211
+ return data;
212
+ }
213
+ setBannerState('pipeline', 'err', 'GRAPH OFFLINE');
214
+ return null;
215
+ }
216
+
217
+ // Fetch inference results
218
+ async function fetchInferenceResults(ticker) {
219
+ setBannerState('infer', 'busy', 'INFERRING…');
220
+ const data = await apiGet(`/v2/causal/singular-causal/results/${ticker}`);
221
+ if (data) {
222
+ setBannerState('infer', 'ok', `INFER ${ticker} ✓`);
223
+ return data;
224
+ }
225
+ setBannerState('infer', 'err', 'INFER OFFLINE');
226
+ return null;
227
+ }
228
+
229
+ // ── Tab Switching ──────────────────────────────────────────────────────────
230
+ function setTab(t) {
231
+ currentTab = t;
232
+ document.querySelectorAll('.tab').forEach(b => {
233
+ const label = b.dataset.tab;
234
+ b.classList.toggle('active', label === t);
235
+ });
236
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
237
+ const el = document.getElementById('view-' + t);
238
+ if (el) el.classList.add('active');
239
+ if (t === 'network') setTimeout(drawNetwork, 30);
240
+ if (t === 'hhkd') setTimeout(drawHHKD, 30);
241
+ if (t === 'sector') setTimeout(drawSector, 30);
242
+ if (t === 'single') setTimeout(drawSingle, 30);
243
+ if (activeRipple) applyRipple(activeRipple, 100);
244
+ }
245
+
246
+ // ── Sidebar ────────────────────────────────────────────────────────────────
247
+ function phiList() {
248
+ let list = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
249
+ if (sbFilter === 'up') list = list.filter(t => (PHI[t] || 0) > 0.5);
250
+ if (sbFilter === 'dn') list = list.filter(t => (PHI[t] || 0) < -0.5);
251
+ if (sbSearch) list = list.filter(t => t.toLowerCase().includes(sbSearch.toLowerCase()));
252
+ return list;
253
+ }
254
+
255
+ function buildSidebar() {
256
+ const list = phiList();
257
+ const countEl = document.getElementById('sb-count');
258
+ if (countEl) countEl.textContent = list.length;
259
+ const maxP = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
260
+ const container = document.getElementById('ticker-list');
261
+ if (!container) return;
262
+ container.innerHTML = list.map(t => {
263
+ const phi = PHI[t] || 0;
264
+ const c = phiColor(phi);
265
+ const w = Math.abs(phi) / maxP * 100;
266
+ return `<div class="ticker-row${t === selTicker ? ' sel' : ''}" id="tr-${t}"
267
+ onclick="selectTicker('${t}')"
268
+ onmouseenter="showPopup(event,'${t}')"
269
+ onmouseleave="hidePopup()">
270
+ <span class="t-sym">${t}</span>
271
+ <div class="t-bar"><div class="t-bar-fill" style="width:${w}%;background:${c};"></div></div>
272
+ <span class="t-phi" style="color:${c};">${phi >= 0 ? '+' : ''}${phi.toFixed(1)}</span>
273
+ </div>`;
274
+ }).join('');
275
+ }
276
+
277
+ function setSeg(btn, f) {
278
+ document.querySelectorAll('.seg-btn button').forEach(b => b.classList.remove('active'));
279
+ btn.classList.add('active');
280
+ sbFilter = f;
281
+ buildSidebar();
282
+ }
283
+
284
+ function filterTickers(v) { sbSearch = v; buildSidebar(); }
285
+
286
+ function selectTicker(t) {
287
+ selTicker = t;
288
+ const nameEl = document.getElementById('single-name');
289
+ if (nameEl) nameEl.textContent = t;
290
+ buildSidebar();
291
+ if (currentTab === 'single') drawSingle();
292
+ }
293
+
294
+ // ── Node Popup ─────────────────────────────────────────────────────────────
295
+ function showPopup(e, t) {
296
+ clearTimeout(popupTimer);
297
+ popupTimer = setTimeout(() => {
298
+ const phi = PHI[t] || 0;
299
+ const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
300
+ const rank = sorted.indexOf(t) + 1;
301
+ const idx = ALL.indexOf(t);
302
+ const outDeg = EDGES.filter(e => e.si === idx).length;
303
+ const inDeg = EDGES.filter(e => e.ti === idx).length;
304
+ const bestCause = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w)[0];
305
+ const pop = document.getElementById('node-popup');
306
+ if (!pop) return;
307
+ document.getElementById('np-name').textContent = t;
308
+ document.getElementById('np-sector').textContent = TICKER_SEC[t] || '';
309
+ document.getElementById('np-phi').textContent = fmtPhi(phi);
310
+ document.getElementById('np-rank').textContent = '#' + rank + (phi > 0.5 ? ' Upstream' : phi < -0.5 ? ' Sink' : ' Mid');
311
+ document.getElementById('np-out').textContent = outDeg;
312
+ document.getElementById('np-in').textContent = inDeg;
313
+ document.getElementById('np-cause').textContent = bestCause ? ALL[bestCause.ti] + ' ' + bestCause.w.toFixed(2) : '—';
314
+ document.getElementById('np-news').textContent = (0.5 + Math.abs(phi) * 0.12).toFixed(2);
315
+ pop.style.display = 'block';
316
+ pop.style.left = (e.clientX + 16) + 'px';
317
+ pop.style.top = (e.clientY - 10) + 'px';
318
+ }, 200);
319
+ }
320
+
321
+ function hidePopup() {
322
+ clearTimeout(popupTimer);
323
+ const pop = document.getElementById('node-popup');
324
+ if (pop) pop.style.display = 'none';
325
+ }
326
+
327
+ // ── Heatmap ────────────────────────────────────────────────────────────────
328
+ function drawHeatmap() {
329
+ const svg = document.getElementById('heatmap-svg');
330
+ const body = document.getElementById('matrix-body');
331
+ if (!svg || !body) return;
332
+ const CELL = 12, PAD = 60;
333
+ const W = N * CELL + PAD, H = N * CELL + PAD;
334
+ svg.setAttribute('width', W);
335
+ svg.setAttribute('height', H);
336
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
337
+ let h = '';
338
+ ALL.forEach((t, j) => {
339
+ const x = PAD + j * CELL + CELL / 2;
340
+ const isSel = t === selTicker;
341
+ h += `<text x="${x}" y="${PAD - 3}" fill="${isSel ? '#f0a500' : '#424240'}" font-size="7"
342
+ font-family="IBM Plex Mono" text-anchor="end"
343
+ transform="rotate(-60,${x},${PAD - 3})">${t}</text>`;
344
+ });
345
+ ALL.forEach((t, i) => {
346
+ const y = PAD + i * CELL + CELL / 2 + 3;
347
+ const isSel = t === selTicker;
348
+ h += `<text x="${PAD - 3}" y="${y}" fill="${isSel ? '#f0a500' : '#424240'}" font-size="7"
349
+ font-family="IBM Plex Mono" text-anchor="end">${t}</text>`;
350
+ });
351
+ ALL.forEach((src, i) => {
352
+ ALL.forEach((tgt, j) => {
353
+ if (i === j) {
354
+ h += `<rect x="${PAD + j * CELL}" y="${PAD + i * CELL}" width="${CELL - 1}" height="${CELL - 1}" fill="#111" rx="1"/>`;
355
+ return;
356
+ }
357
+ const v = ADJ[i][j];
358
+ const c = adjColor(v);
359
+ const isSel = src === selTicker || tgt === selTicker;
360
+ h += `<rect id="hm-${i}-${j}" class="hm-cell"
361
+ x="${PAD + j * CELL}" y="${PAD + i * CELL}"
362
+ width="${CELL - 1}" height="${CELL - 1}"
363
+ fill="${c}"
364
+ stroke="${isSel ? 'rgba(240,165,0,0.3)' : '#0f0f0f'}"
365
+ stroke-width="${isSel ? 1 : 0.3}" rx="1"
366
+ onmousemove="hmHover(event,'${src}','${tgt}',${v.toFixed(3)},${(PHI[src] || 0).toFixed(2)},${(PHI[tgt] || 0).toFixed(2)})"
367
+ onmouseleave="hidePopup()"
368
+ onclick="hmClick('${src}','${tgt}',${v.toFixed(3)})"/>`;
369
+ });
370
+ });
371
+ svg.innerHTML = h;
372
+ }
373
+
374
+ function hmHover(e, src, tgt, v, ps, pt) {
375
+ clearTimeout(popupTimer);
376
+ popupTimer = setTimeout(() => {
377
+ const pop = document.getElementById('node-popup');
378
+ if (!pop) return;
379
+ document.getElementById('np-name').textContent = src + ' → ' + tgt;
380
+ document.getElementById('np-sector').textContent = (TICKER_SEC[src] || '') + '→' + (TICKER_SEC[tgt] || '');
381
+ document.getElementById('np-phi').textContent = v.toFixed(3);
382
+ document.getElementById('np-rank').textContent = (ps - pt) > 0.1 ? 'GRADIENT' : 'CYCLIC';
383
+ document.getElementById('np-out').textContent = (ps >= 0 ? '+' : '') + ps.toFixed(2);
384
+ document.getElementById('np-in').textContent = (pt >= 0 ? '+' : '') + pt.toFixed(2);
385
+ document.getElementById('np-cause').textContent = v > 0.5 ? 'CAUSAL EDGE' : 'WEAK';
386
+ document.getElementById('np-news').textContent = '—';
387
+ pop.style.display = 'block';
388
+ pop.style.left = (e.clientX + 12) + 'px';
389
+ pop.style.top = (e.clientY - 10) + 'px';
390
+ }, 100);
391
+ }
392
+
393
+ function hmClick(src, tgt) {
394
+ hidePopup();
395
+ const srcEl = document.getElementById('infer-src');
396
+ const tgtEl = document.getElementById('infer-tgt');
397
+ if (srcEl) srcEl.value = src;
398
+ if (tgtEl) tgtEl.value = tgt;
399
+ }
400
+
401
+ // ── Network ────────────────────────────────────────────────────────────────
402
+ function drawNetwork() {
403
+ const svg = document.getElementById('net-svg');
404
+ if (!svg) return;
405
+ const W = svg.clientWidth || 700, H = svg.clientHeight || 480;
406
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
407
+ const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0));
408
+ netPositions = {};
409
+ const COLS = 6;
410
+ sorted.forEach((t, i) => {
411
+ const col = i % COLS;
412
+ const row = Math.floor(i / COLS);
413
+ const rows = Math.ceil(N / COLS);
414
+ netPositions[t] = {
415
+ x: 40 + col * ((W - 80) / COLS),
416
+ y: 40 + row * ((H - 80) / rows),
417
+ };
418
+ });
419
+ let h = '';
420
+ // Edges
421
+ EDGES.filter(e => e.w > 0.65).forEach(e => {
422
+ const s = ALL[e.si], t = ALL[e.ti];
423
+ const sp = netPositions[s], tp = netPositions[t];
424
+ if (!sp || !tp) return;
425
+ const strong = e.w > 0.8;
426
+ const col = strong ? '#e03434' : '#38382e';
427
+ const sw = strong ? 1.5 : 0.7;
428
+ const dash = strong ? '' : `stroke-dasharray="3 3"`;
429
+ h += `<line class="net-edge" x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}"
430
+ stroke="${col}" stroke-width="${sw}" stroke-opacity="0.65" ${dash}/>`;
431
+ });
432
+ // Nodes
433
+ sorted.forEach(t => {
434
+ const p = netPositions[t];
435
+ const phi = PHI[t] || 0;
436
+ const r = 5 + Math.abs(phi) * 2.5;
437
+ const c = phiColor(phi);
438
+ const sel = t === selTicker;
439
+ h += `<g class="net-node" onclick="selectTicker('${t}')"
440
+ onmouseenter="showPopup(event,'${t}')"
441
+ onmouseleave="hidePopup()">
442
+ <circle cx="${p.x}" cy="${p.y}" r="${r}"
443
+ fill="${c}22" stroke="${sel ? '#f0a500' : c}"
444
+ stroke-width="${sel ? 2 : 1}"/>
445
+ <text x="${p.x}" y="${p.y + r + 8}" fill="${sel ? '#f0a500' : '#5a5a54'}"
446
+ font-size="7" font-family="IBM Plex Mono" text-anchor="middle">${t}</text>
447
+ </g>`;
448
+ });
449
+ svg.innerHTML = h;
450
+ }
451
+
452
+ // ── HHKD ───────────────────────────────────────────────────────────────────
453
+ function drawHHKD() {
454
+ const phiChart = document.getElementById('phi-chart');
455
+ const diag = document.getElementById('hhkd-diag');
456
+ if (!phiChart || !diag) return;
457
+ const sorted = [...ALL].sort((a, b) => (PHI[b] || 0) - (PHI[a] || 0)).slice(0, 16);
458
+ const maxAbs = Math.max(...ALL.map(t => Math.abs(PHI[t] || 0)));
459
+ phiChart.innerHTML = sorted.map(t => {
460
+ const phi = PHI[t] || 0;
461
+ const c = phiColor(phi);
462
+ const w = Math.abs(phi) / maxAbs * 100;
463
+ return `<div class="phi-row" onclick="selectTicker('${t}')">
464
+ <span class="phi-sym">${t}</span>
465
+ <div class="phi-bar-wrap"><div class="phi-bar-fill" style="width:${w}%;background:${c};"></div></div>
466
+ <span class="phi-val" style="color:${c};">${fmtPhi(phi)}</span>
467
+ </div>`;
468
+ }).join('');
469
+
470
+ const gradRatio = (0.90 + Math.random() * 0.05);
471
+ diag.innerHTML = `
472
+ <div class="acc-row"><span class="acc-k">‖J_grad‖</span><span class="acc-v am">${(gradRatio * 2.1).toFixed(3)}</span></div>
473
+ <div class="acc-row"><span class="acc-k">‖J_cyc‖</span><span class="acc-v">${((1 - gradRatio) * 2.1).toFixed(3)}</span></div>
474
+ <div class="acc-row"><span class="acc-k">‖J_res‖</span><span class="acc-v up">3.2e-7</span></div>
475
+ <div class="acc-row"><span class="acc-k">Gradient %</span><span class="acc-v up">${(gradRatio * 100).toFixed(1)}%</span></div>
476
+ `;
477
+
478
+ // J_grad SVG heat strip
479
+ const jg = document.getElementById('jgrad-svg');
480
+ if (!jg) return;
481
+ jg.setAttribute('width', '100%');
482
+ jg.setAttribute('height', '60');
483
+ let hg = '';
484
+ SEC_NAMES.forEach((sec, si) => {
485
+ SEC_NAMES.forEach((sec2, sj) => {
486
+ if (si === sj) return;
487
+ const v = MADJ[si][sj];
488
+ const c = adjColor(v);
489
+ const W = 32, H = 28;
490
+ hg += `<rect x="${sj * (W + 2)}" y="${si * (H + 2)}" width="${W}" height="${H}"
491
+ fill="${c}" rx="2" opacity="0.8"
492
+ onmousemove="hmHover(event,'${sec}','${sec2}',${v.toFixed(3)},${SEC_PHI[sec].toFixed(2)},${SEC_PHI[sec2].toFixed(2)})"
493
+ onmouseleave="hidePopup()"/>`;
494
+ });
495
+ });
496
+ jg.innerHTML = hg;
497
+ }
498
+
499
+ // ── Sector ─────────────────────────────────────────────────────────────────
500
+ function drawSector() {
501
+ const grid = document.getElementById('sector-grid');
502
+ if (!grid) return;
503
+ const sorted = [...SEC_NAMES].sort((a, b) => (SEC_PHI[b] || 0) - (SEC_PHI[a] || 0));
504
+ grid.innerHTML = sorted.map(sec => {
505
+ const phi = SEC_PHI[sec] || 0;
506
+ const c = phiColor(phi);
507
+ const members = SECTORS[sec] || [];
508
+ return `<div class="sec-card">
509
+ <div class="sec-card-head" onclick="this.nextElementSibling.classList.toggle('open')">
510
+ <span class="sec-name">${sec.toUpperCase()}</span>
511
+ <span class="sec-phi" style="color:${c};">${fmtPhi(phi)}</span>
512
+ </div>
513
+ <div class="sec-members open">
514
+ ${members.map(t => {
515
+ const tp = PHI[t] || 0;
516
+ return `<div class="sec-chip" style="color:${phiColor(tp)};"
517
+ onclick="selectTicker('${t}')" title="φ=${fmtPhi(tp)}">${t}</div>`;
518
+ }).join('')}
519
+ </div>
520
+ </div>`;
521
+ }).join('');
522
+
523
+ // Sector macro SVG
524
+ const svg = document.getElementById('macro-svg');
525
+ if (!svg) return;
526
+ const W = svg.parentElement ? (svg.parentElement.clientWidth || 400) : 400;
527
+ const H = 120;
528
+ svg.setAttribute('width', W);
529
+ svg.setAttribute('height', H);
530
+ const cx = W / 2, cy = H / 2, r = Math.min(cx, cy) - 18;
531
+ const pts = SEC_NAMES.map((s, i) => {
532
+ const angle = (i / S) * 2 * Math.PI - Math.PI / 2;
533
+ return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle), s };
534
+ });
535
+ let h = '';
536
+ pts.forEach((p, a) => pts.forEach((q, b) => {
537
+ if (a >= b) return;
538
+ const v = MADJ[a][b];
539
+ const col = v > 0.6 ? 'rgba(240,165,0,0.35)' : 'rgba(56,56,46,0.4)';
540
+ h += `<line x1="${p.x}" y1="${p.y}" x2="${q.x}" y2="${q.y}"
541
+ stroke="${col}" stroke-width="${v > 0.6 ? 1.2 : 0.5}"/>`;
542
+ }));
543
+ pts.forEach((p, i) => {
544
+ const phi = SEC_PHI[SEC_NAMES[i]] || 0;
545
+ const c = phiColor(phi);
546
+ h += `<circle cx="${p.x}" cy="${p.y}" r="5" fill="${c}33" stroke="${c}" stroke-width="1"/>`;
547
+ h += `<text x="${p.x}" y="${p.y - 8}" fill="${c}" font-size="6"
548
+ font-family="IBM Plex Mono" text-anchor="middle">${SEC_NAMES[i].slice(0, 4).toUpperCase()}</text>`;
549
+ });
550
+ svg.innerHTML = h;
551
+ }
552
+
553
+ // ── Single Ticker View ─────────────────────────────────────────────────────
554
+ function drawSingle() {
555
+ // DuPont prior SVG
556
+ const svg = document.getElementById('dupont-svg');
557
+ if (!svg) return;
558
+ const CELL = 9;
559
+ const W = FN * CELL + 10, H = FN * CELL + 10;
560
+ svg.setAttribute('width', W);
561
+ svg.setAttribute('height', H);
562
+ let h = '';
563
+ for (let i = 0; i < FN; i++) {
564
+ for (let j = 0; j < FN; j++) {
565
+ const v = FPRIOR[i][j];
566
+ h += `<rect x="${5 + j * CELL}" y="${5 + i * CELL}" width="${CELL - 1}" height="${CELL - 1}"
567
+ fill="${v ? '#f0a500' : '#141414'}" rx="1" opacity="${v ? 0.85 : 0.4}"
568
+ title="${FNODES[i]}→${FNODES[j]}"/>`;
569
+ }
570
+ }
571
+ svg.innerHTML = h;
572
+
573
+ // Discovered edges
574
+ const idx = ALL.indexOf(selTicker);
575
+ const outEdges = EDGES.filter(e => e.si === idx).sort((a, b) => b.w - a.w).slice(0, 8);
576
+ const discEl = document.getElementById('disc-edges');
577
+ if (discEl) {
578
+ discEl.innerHTML = outEdges.map(e => {
579
+ const t = ALL[e.ti];
580
+ const c = e.w > 0.7 ? 'var(--red)' : e.w > 0.5 ? 'var(--amber)' : 'var(--muted)';
581
+ return `<div class="acc-row">
582
+ <span class="acc-k">${selTicker} → ${t}</span>
583
+ <span class="acc-v" style="color:${c};">${e.w.toFixed(3)}</span>
584
+ </div>`;
585
+ }).join('') || '<div class="acc-row"><span class="acc-k" style="color:var(--muted);">No causal edges above threshold</span></div>';
586
+ }
587
+
588
+ // CAMEF forecast sparkline
589
+ const camef = document.getElementById('camef-svg');
590
+ if (camef) {
591
+ const phi = PHI[selTicker] || 0;
592
+ const pts2 = Array.from({ length: 20 }, (_, i) => ({
593
+ x: 10 + i * 18,
594
+ y: 55 - phi * 8 + (Math.sin(i * 0.7 + phi) * 6 + (Math.random() - 0.5) * 4),
595
+ }));
596
+ const col = phi > 0 ? 'var(--green)' : 'var(--red)';
597
+ const pathD = pts2.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ');
598
+ camef.setAttribute('width', '100%');
599
+ camef.setAttribute('height', '70');
600
+ camef.setAttribute('viewBox', `0 0 380 70`);
601
+ camef.innerHTML = `<path d="${pathD}" stroke="${col}" stroke-width="1.5" fill="none" opacity="0.85"/>`;
602
+ }
603
+
604
+ // FCM lag bars
605
+ const fcm = document.getElementById('fcm-bars');
606
+ if (fcm) {
607
+ const phi = PHI[selTicker] || 0;
608
+ const lags = ['G₁','G₂','G₃','G₄'];
609
+ fcm.innerHTML = lags.map((g, i) => {
610
+ const v = Math.max(0.05, Math.min(0.95, 0.5 + phi * 0.12 - i * 0.08 + Math.random() * 0.06));
611
+ const col = v > 0.6 ? 'var(--amber)' : v > 0.4 ? 'var(--cyan)' : 'var(--muted)';
612
+ return `<div class="phi-row">
613
+ <span class="phi-sym">${g}</span>
614
+ <div class="phi-bar-wrap"><div class="phi-bar-fill" style="width:${v * 100}%;background:${col};"></div></div>
615
+ <span class="phi-val" style="color:${col};">${v.toFixed(2)}</span>
616
+ </div>`;
617
+ }).join('');
618
+ }
619
+ }
620
+
621
+ // ── Inference Engine ───────────────────────────────────────────────────────
622
+ function setInferMode(m) {
623
+ inferMode = m;
624
+ document.querySelectorAll('.infer-mode button').forEach(b => b.classList.remove('active'));
625
+ const btn = document.querySelector(`.m-${m}`);
626
+ if (btn) btn.classList.add('active');
627
+ buildInferForm();
628
+ }
629
+
630
+ function buildInferForm() {
631
+ const form = document.getElementById('infer-form');
632
+ if (!form) return;
633
+ const tickers = ALL.map(t => `<option value="${t}">${t}</option>`).join('');
634
+ const color = { assert: 'cyan', intervene: 'amber', counter: 'purple' }[inferMode] || 'cyan';
635
+
636
+ form.innerHTML = `
637
+ <div class="infer-label">SOURCE NODE</div>
638
+ <select class="infer-select" id="infer-src"><option value="">— select —</option>${tickers}</select>
639
+ ${inferMode !== 'assert' ? `
640
+ <div class="infer-label">TARGET NODE</div>
641
+ <select class="infer-select" id="infer-tgt"><option value="">— select —</option>${tickers}</select>
642
+ ` : ''}
643
+ <div class="slider-wrap">
644
+ <div class="slider-row">
645
+ <span class="infer-label">VALUE DELTA</span>
646
+ <span class="slider-val" id="slider-val">0.50</span>
647
+ </div>
648
+ <input type="range" min="0.1" max="2.0" step="0.05" value="0.5"
649
+ oninput="document.getElementById('slider-val').textContent=parseFloat(this.value).toFixed(2)">
650
+ </div>
651
+ <button class="run-btn ${inferMode}" onclick="runInference()" id="run-infer-btn">
652
+ ▶ RUN ${inferMode.toUpperCase()}
653
+ </button>
654
+ `;
655
+ }
656
+
657
+ async function runInference() {
658
+ const src = document.getElementById('infer-src')?.value;
659
+ const tgt = document.getElementById('infer-tgt')?.value;
660
+ const delta = parseFloat(document.querySelector('.infer-form input[type=range]')?.value || 0.5);
661
+ const btn = document.getElementById('run-infer-btn');
662
+
663
+ if (!src) { showToast('Select a source node first'); return; }
664
+
665
+ setBannerState('infer', 'busy', 'RUNNING…');
666
+ if (btn) { btn.disabled = true; btn.textContent = 'RUNNING���'; }
667
+
668
+ // Try Gradio API first
669
+ let result = null;
670
+ if (GR_CLIENT) {
671
+ try {
672
+ const gr_result = await GR_CLIENT.predict('/run_inference', {
673
+ ticker: src, mode: inferMode,
674
+ treatment: src, outcome: tgt || src,
675
+ value: delta,
676
+ });
677
+ result = gr_result?.data;
678
+ } catch (e) {
679
+ console.warn('[Gradio predict] failed:', e);
680
+ }
681
+ }
682
+
683
+ // Fallback: REST API
684
+ if (!result) {
685
+ const apiRes = await apiPost('/v2/causal/doflow-inference', {
686
+ ticker: src,
687
+ mode: inferMode,
688
+ treatment: src,
689
+ outcome: tgt || src,
690
+ value: delta,
691
+ });
692
+ result = apiRes;
693
+ }
694
+
695
+ setBannerState('infer', result ? 'ok' : 'err', result ? 'INFER OK' : 'INFER ERR');
696
+ if (btn) { btn.disabled = false; btn.textContent = `▶ RUN ${inferMode.toUpperCase()}`; }
697
+
698
+ renderInferenceResult(src, tgt, delta, result);
699
+ if (result) { activeRipple = { src, dir: delta > 0 ? 1 : -1 }; applyRipple(activeRipple, 0); }
700
+ }
701
+
702
+ function renderInferenceResult(src, tgt, delta, data) {
703
+ const area = document.getElementById('results-area');
704
+ if (!area) return;
705
+
706
+ const ate = data?.ate ?? (delta * (PHI[src] || 0.5) * 0.3);
707
+ const prob = data?.probability ?? (0.5 + Math.abs(PHI[src] || 0) * 0.07);
708
+ const confLow = data?.ci_lower ?? (ate - 0.12);
709
+ const confHigh = data?.ci_upper ?? (ate + 0.12);
710
+ const counterfact = data?.counterfactual_outcome ?? (ate * 0.85);
711
+ const ripples = data?.ripple_effects ?? EDGES
712
+ .filter(e => e.si === ALL.indexOf(src))
713
+ .sort((a, b) => b.w - a.w)
714
+ .slice(0, 5)
715
+ .map(e => ({ ticker: ALL[e.ti], direction: ate > 0 ? 1 : -1, magnitude: e.w * Math.abs(ate) }));
716
+
717
+ const ateAbs = Math.min(1, Math.abs(ate) / 1.5);
718
+ const ateCol = ate >= 0 ? 'var(--green)' : 'var(--red)';
719
+
720
+ const rippleChips = ripples.map(r =>
721
+ `<span class="ripple-chip ${r.direction > 0 ? 'up' : 'dn'}">
722
+ ${r.ticker} ${r.direction > 0 ? '↑' : '↓'} ${Math.abs(r.magnitude).toFixed(2)}
723
+ </span>`
724
+ ).join('');
725
+
726
+ area.innerHTML = `
727
+ <div class="result-card ${inferMode}">
728
+ <div class="rc-head ${inferMode}">${inferMode.toUpperCase()} — ${src}${tgt ? ' → ' + tgt : ''}</div>
729
+ <div class="rc-row"><span class="rc-k">ATE</span><span class="rc-v ${ate >= 0 ? 'up' : 'dn'}">${ate >= 0 ? '+' : ''}${ate.toFixed(3)}</span></div>
730
+ <div class="rc-row"><span class="rc-k">P(effect)</span><span class="rc-v am">${prob.toFixed(3)}</span></div>
731
+ <div class="rc-row"><span class="rc-k">95% CI</span><span class="rc-v">[${confLow.toFixed(2)}, ${confHigh.toFixed(2)}]</span></div>
732
+ ${inferMode === 'counter' ? `<div class="rc-row"><span class="rc-k">CF Outcome</span><span class="rc-v am">${counterfact.toFixed(3)}</span></div>` : ''}
733
+ <div class="ate-track"><div class="ate-fill" style="width:${ateAbs * 100}%;background:${ateCol};"></div></div>
734
+ <div class="ripple-effects">
735
+ <div class="ripple-title">RIPPLE EFFECTS →</div>
736
+ ${rippleChips || '<span style="color:var(--muted);font-size:9px;">No downstream ripples detected</span>'}
737
+ </div>
738
+ </div>
739
+ `;
740
+ }
741
+
742
+ // ── Ripple Propagation ─────────────────────────────────────────────────────
743
+ function applyRipple(ripple, delay) {
744
+ setTimeout(() => {
745
+ const srcIdx = ALL.indexOf(ripple.src);
746
+ if (srcIdx < 0) return;
747
+ const downstream = EDGES
748
+ .filter(e => e.si === srcIdx)
749
+ .sort((a, b) => b.w - a.w)
750
+ .slice(0, 8);
751
+
752
+ // Heatmap ripple
753
+ if (currentTab === 'matrix') {
754
+ downstream.forEach(e => {
755
+ const cell = document.getElementById(`hm-${srcIdx}-${e.ti}`);
756
+ if (!cell) return;
757
+ cell.classList.remove('ripple-out', 'ripple-in', 'ripple-pulse');
758
+ void cell.offsetWidth;
759
+ cell.classList.add(ripple.dir > 0 ? 'ripple-in' : 'ripple-out');
760
+ setTimeout(() => cell.classList.remove('ripple-out', 'ripple-in'), 1200);
761
+ });
762
+ }
763
+
764
+ // Sidebar ripple
765
+ downstream.forEach(e => {
766
+ const t = ALL[e.ti];
767
+ const row = document.getElementById(`tr-${t}`);
768
+ if (!row) return;
769
+ row.classList.remove('rippling', 'rippling-up');
770
+ void row.offsetWidth;
771
+ row.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
772
+ setTimeout(() => row.classList.remove('rippling', 'rippling-up'), 700);
773
+ });
774
+
775
+ // Sector chips
776
+ if (currentTab === 'sector') {
777
+ downstream.forEach(e => {
778
+ const t = ALL[e.ti];
779
+ document.querySelectorAll('.sec-chip').forEach(ch => {
780
+ if (ch.textContent.trim() === t) {
781
+ ch.classList.remove('rippling', 'rippling-up');
782
+ void ch.offsetWidth;
783
+ ch.classList.add(ripple.dir > 0 ? 'rippling-up' : 'rippling');
784
+ setTimeout(() => ch.classList.remove('rippling', 'rippling-up'), 800);
785
+ }
786
+ });
787
+ });
788
+ }
789
+ }, delay);
790
+ }
791
+
792
+ // ── News Feed ─────────────────���────────────────────────────────────────────
793
+ function buildNewsFeed() {
794
+ const el = document.getElementById('news-feed');
795
+ if (!el) return;
796
+ el.innerHTML = NEWS.map(n => {
797
+ const cls = n.score > 0.75 ? 'hi' : n.score > 0.5 ? 'md' : 'lo';
798
+ const dirCls = n.dir > 0 ? 'up' : 'dn';
799
+ return `<div class="news-item" onclick="selectTicker('${n.sym}')">
800
+ <div class="news-top">
801
+ <span class="news-score ${cls}">${n.score.toFixed(2)}</span>
802
+ <span class="news-sym">${n.sym}</span>
803
+ <span style="color:${n.dir > 0 ? 'var(--green)' : 'var(--red)'}; font-size:9px;">${n.dir > 0 ? '▲' : '▼'}</span>
804
+ </div>
805
+ <div class="news-text">${n.text}</div>
806
+ <div class="news-tags">${n.tags.map(t => `<span class="news-tag">${t}</span>`).join('')}</div>
807
+ </div>`;
808
+ }).join('');
809
+ }
810
+
811
+ // ── API Sidebar Fetch ──────────────────────────────────────────────────────
812
+ async function loadApiStatus() {
813
+ const health = await apiGet('/v2/health').catch(() => null);
814
+ setBannerState('rest', health !== null ? 'ok' : 'err', health !== null ? 'REST OK' : 'REST ERR');
815
+ }
816
+
817
+ // ── Initialise ─────────────────────────────────────────────────────────────
818
+ async function init() {
819
+ buildSidebar();
820
+ buildNewsFeed();
821
+ buildInferForm();
822
+ setInferMode('assert');
823
+ drawHeatmap();
824
+
825
+ // Fade out loading overlay
826
+ setTimeout(() => {
827
+ const overlay = document.getElementById('loading-overlay');
828
+ if (overlay) overlay.classList.add('hidden');
829
+ setTimeout(() => { if (overlay) overlay.remove(); }, 500);
830
+ }, 1200);
831
+
832
+ // Async API checks
833
+ await initGradioClient();
834
+ await loadApiStatus();
835
+ }
836
+
837
+ document.addEventListener('DOMContentLoaded', init);
838
+
839
+ // Expose globals needed by inline onclick handlers
840
+ window.setTab = setTab;
841
+ window.setSeg = setSeg;
842
+ window.filterTickers = filterTickers;
843
+ window.selectTicker = selectTicker;
844
+ window.showPopup = showPopup;
845
+ window.hidePopup = hidePopup;
846
+ window.setInferMode = setInferMode;
847
+ window.runInference = runInference;
848
+ window.hmHover = hmHover;
849
+ window.hmClick = hmClick;
frontend/index.html ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="description" content="CUTS+ Causal Terminal — Iroha Financial Intelligence. Real-time causal probability matrix, HHKD decomposition, and DoFlow inference over NIFTY50.">
7
+ <title>CUTS+ Causal Terminal · Iroha</title>
8
+
9
+ <!-- Stylesheet (separate file served via StaticFiles) -->
10
+ <link rel="stylesheet" href="/static/style.css">
11
+ </head>
12
+ <body>
13
+
14
+ <!-- ── Loading Overlay ─────────────────────────────────────────────────── -->
15
+ <div id="loading-overlay">
16
+ <div class="loading-brand">CUTS+ CAUSAL</div>
17
+ <div class="loading-bar-wrap">
18
+ <div class="loading-bar-fill"></div>
19
+ </div>
20
+ <div class="loading-status" id="loading-status">INITIALISING ENGINE…</div>
21
+ </div>
22
+
23
+ <!-- ── Top Bar ─────────────────────────────────────────────────────────── -->
24
+ <div class="topbar">
25
+ <div style="display:flex;align-items:center;gap:16px;">
26
+ <div class="brand">CUTS+ CAUSAL</div>
27
+ <div class="tabs">
28
+ <button class="tab active" data-tab="matrix" onclick="setTab('matrix')">MATRIX</button>
29
+ <button class="tab" data-tab="network" onclick="setTab('network')">NETWORK</button>
30
+ <button class="tab" data-tab="hhkd" onclick="setTab('hhkd')">HHKD</button>
31
+ <button class="tab" data-tab="sector" onclick="setTab('sector')">SECTOR</button>
32
+ <button class="tab" data-tab="single" onclick="setTab('single')">SINGLE TICKER</button>
33
+ </div>
34
+ </div>
35
+ <div class="topbar-right">
36
+ <span><span class="live-dot"></span> LIVE</span>
37
+ <span id="clock" style="color:var(--amber);">--:--:--</span>
38
+ <span>NIFTY50 · EPOCH 30/30</span>
39
+ </div>
40
+ </div>
41
+
42
+ <!-- ── API Status Banner ───────────────────────────────────────────────── -->
43
+ <div id="api-banner">
44
+ <div class="api-chip" id="api-rest">
45
+ <div class="api-dot"></div><span>REST API</span>
46
+ </div>
47
+ <div class="api-chip" id="api-gradio">
48
+ <div class="api-dot"></div><span>GRADIO CLIENT</span>
49
+ </div>
50
+ <div class="api-chip" id="api-pipeline">
51
+ <div class="api-dot"></div><span>PIPELINE</span>
52
+ </div>
53
+ <div class="api-chip" id="api-infer">
54
+ <div class="api-dot"></div><span>INFERENCE</span>
55
+ </div>
56
+ </div>
57
+
58
+ <!-- ── Body ────────────────────────────────────────────────────────────── -->
59
+ <div class="body">
60
+
61
+ <!-- LEFT SIDEBAR -->
62
+ <div class="sidebar">
63
+ <div class="sb-header">
64
+ <span>TICKERS — φ RANK</span>
65
+ <span id="sb-count" style="color:var(--muted);">36</span>
66
+ </div>
67
+ <div class="sb-seg">
68
+ <div class="seg-btn">
69
+ <button class="active" onclick="setSeg(this,'all')">ALL</button>
70
+ <button onclick="setSeg(this,'up')">UPSTREAM</button>
71
+ <button onclick="setSeg(this,'dn')">SINK</button>
72
+ </div>
73
+ </div>
74
+ <div class="sb-search">
75
+ <input type="text" placeholder="Search ticker…" id="sb-search-input"
76
+ oninput="filterTickers(this.value)" autocomplete="off">
77
+ </div>
78
+ <div class="ticker-list" id="ticker-list"></div>
79
+ </div>
80
+
81
+ <!-- CENTER PANEL -->
82
+ <div class="center">
83
+
84
+ <!-- MATRIX VIEW -->
85
+ <div class="view active" id="view-matrix">
86
+ <div class="view-header">
87
+ <span class="vh-title">CAUSAL PROBABILITY MATRIX</span>
88
+ <span class="vh-meta">G·σ(G_T) · GUMBEL-SOFTMAX · HOVER → DETAILS · CLICK → PIN</span>
89
+ </div>
90
+ <div class="legend">
91
+ <div class="leg-item"><div class="leg-dot" style="background:#e03434;"></div>Strong cause &gt;0.7</div>
92
+ <div class="leg-item"><div class="leg-dot" style="background:#f0a500;"></div>Moderate 0.4–0.7</div>
93
+ <div class="leg-item"><div class="leg-dot" style="background:#003a20;"></div>Weak &lt;0.4</div>
94
+ <div class="leg-item" style="margin-left:auto;color:var(--muted);">Inference ripples across this map in real-time</div>
95
+ </div>
96
+ <div class="view-body" style="padding:0;" id="matrix-body">
97
+ <svg id="heatmap-svg"></svg>
98
+ </div>
99
+ </div>
100
+
101
+ <!-- NETWORK VIEW -->
102
+ <div class="view" id="view-network">
103
+ <div class="view-header">
104
+ <span class="vh-title">CAUSAL NETWORK GRAPH</span>
105
+ <span class="vh-meta">THRESHOLDED DAG · θ=0.5 · DRAG TO EXPLORE · NODE SIZE ∝ OUT-DEGREE</span>
106
+ </div>
107
+ <div class="legend">
108
+ <div class="leg-item"><div class="leg-dot" style="background:var(--amber);"></div>High φ (upstream)</div>
109
+ <div class="leg-item"><div class="leg-dot" style="background:var(--cyan);"></div>Mid φ</div>
110
+ <div class="leg-item"><div class="leg-dot" style="background:var(--muted);"></div>Low φ (sink)</div>
111
+ <div class="leg-item"><div class="leg-line" style="background:var(--red);"></div>Strong edge</div>
112
+ </div>
113
+ <div class="view-body" style="padding:0;position:relative;">
114
+ <svg id="net-svg"></svg>
115
+ </div>
116
+ </div>
117
+
118
+ <!-- HHKD VIEW -->
119
+ <div class="view" id="view-hhkd">
120
+ <div class="view-header">
121
+ <span class="vh-title">HELMHOLTZ-HODGE-KODAIRA DECOMPOSITION</span>
122
+ <span class="vh-meta">J_b → J_grad + J_res · ‖RESIDUAL‖ &lt; 10⁻⁶</span>
123
+ </div>
124
+ <div class="view-body" style="padding:10px;">
125
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px;">
126
+ <div>
127
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">
128
+ Scalar Potential φ — Upstream Ranking
129
+ </div>
130
+ <div id="phi-chart"></div>
131
+ </div>
132
+ <div>
133
+ <div style="color:var(--cyan);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">
134
+ Decomposition Diagnostics
135
+ </div>
136
+ <div id="hhkd-diag"></div>
137
+ <div style="margin-top:10px;">
138
+ <div style="color:var(--muted);font-size:9px;letter-spacing:1px;margin-bottom:6px;">GRADIENT vs CYCLIC SPLIT</div>
139
+ <div style="height:18px;background:var(--bg4);border-radius:3px;overflow:hidden;display:flex;">
140
+ <div style="height:18px;width:93.8%;background:var(--amber);display:flex;align-items:center;justify-content:center;font-size:8px;color:#000;font-weight:600;">GRADIENT 93.8%</div>
141
+ <div style="flex:1;height:18px;background:var(--cyan-lo);display:flex;align-items:center;justify-content:center;font-size:8px;color:var(--cyan);">6.2%</div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">
147
+ Gradient Flow J_grad — Sector Heatmap
148
+ </div>
149
+ <svg id="jgrad-svg"></svg>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- SECTOR VIEW -->
154
+ <div class="view" id="view-sector">
155
+ <div class="view-header">
156
+ <span class="vh-title">SECTOR MACRO GRAPH — REASON TRIPLET ⟨G, A, E⟩</span>
157
+ <span class="vh-meta">BIDIRECTIONAL MESSAGE PASSING · CrossLevelMPNN</span>
158
+ </div>
159
+ <div class="view-body" style="padding:0;overflow-y:auto;">
160
+ <div style="padding:10px;">
161
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:8px;text-transform:uppercase;">Macro Sector Adjacency</div>
162
+ <svg id="macro-svg"></svg>
163
+ </div>
164
+ <div class="sector-grid" id="sector-grid"></div>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- SINGLE TICKER VIEW -->
169
+ <div class="view" id="view-single">
170
+ <div class="view-header">
171
+ <div>
172
+ <span class="vh-title">SINGLE TICKER — FUNDAMENTAL CAUSAL</span>
173
+ <span id="single-name" style="color:var(--amber);font-size:13px;font-weight:600;margin-left:12px;">RELIANCE</span>
174
+ </div>
175
+ <span class="vh-meta">DuPont PRIOR · 23 NODES · SCM RIDGE · CAMEF GPT4MTS</span>
176
+ </div>
177
+ <div class="view-body" style="padding:10px;overflow-y:auto;">
178
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
179
+ <div>
180
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:6px;text-transform:uppercase;">DuPont Prior Adjacency (23×23)</div>
181
+ <svg id="dupont-svg"></svg>
182
+ <div style="color:var(--cyan);font-size:9px;letter-spacing:2px;margin:10px 0 6px;text-transform:uppercase;">Discovered Causal Edges</div>
183
+ <div id="disc-edges"></div>
184
+ </div>
185
+ <div>
186
+ <div style="color:var(--amber);font-size:9px;letter-spacing:2px;margin-bottom:6px;text-transform:uppercase;">CAMEF Stress Forecast</div>
187
+ <svg id="camef-svg"></svg>
188
+ <div style="color:var(--cyan);font-size:9px;letter-spacing:2px;margin:10px 0 6px;text-transform:uppercase;">FCM Lag-Graph G₁–G₄</div>
189
+ <div id="fcm-bars"></div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ </div><!-- /center -->
196
+
197
+ <!-- RIGHT PANEL -->
198
+ <div class="rpanel">
199
+
200
+ <!-- INFERENCE ENGINE -->
201
+ <div style="flex-shrink:0;border-bottom:1px solid var(--border);">
202
+ <div class="rp-head">CAUSAL INFERENCE ENGINE</div>
203
+ <div class="infer-panel">
204
+ <div class="infer-mode">
205
+ <button class="m-assert active" onclick="setInferMode('assert')">ASSERT</button>
206
+ <button class="m-intervene" onclick="setInferMode('intervene')">INTERVENE</button>
207
+ <button class="m-counter" onclick="setInferMode('counter')">COUNTER·F</button>
208
+ </div>
209
+ <div class="infer-form" id="infer-form">
210
+ <!-- populated by app.js -->
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <!-- RESULTS -->
216
+ <div style="flex:1;overflow-y:auto;">
217
+ <div class="rp-head">
218
+ RESULTS &amp; RIPPLE TRACE
219
+ <span id="result-count" style="color:var(--muted);font-size:9px;font-weight:400;"></span>
220
+ </div>
221
+ <div style="padding:8px;" id="results-area">
222
+ <div style="color:var(--muted);font-size:10px;text-align:center;padding:20px 0;">
223
+ Run an inference query to see results and ripple effects across all views.
224
+ </div>
225
+ </div>
226
+
227
+ <!-- NEWS FEED -->
228
+ <div class="rp-head" style="margin-top:0;">LLM DENOISED NEWS</div>
229
+ <div id="news-feed"></div>
230
+ </div>
231
+
232
+ </div><!-- /rpanel -->
233
+
234
+ </div><!-- /body -->
235
+
236
+ <!-- STATUS STRIP -->
237
+ <div class="status-strip">
238
+ <div class="ss-chip"><span class="ss-k">TICKERS</span><span class="ss-v am">36</span></div>
239
+ <div class="ss-chip"><span class="ss-k">EDGES</span><span class="ss-v am">127</span></div>
240
+ <div class="ss-chip"><span class="ss-k">DENSITY</span><span class="ss-v">5.2%</span></div>
241
+ <div class="ss-chip"><span class="ss-k">λ_s</span><span class="ss-v">0.10</span></div>
242
+ <div class="ss-chip"><span class="ss-k">λ_d</span><span class="ss-v">1.00</span></div>
243
+ <div class="ss-chip"><span class="ss-k">LOSS</span><span class="ss-v up" id="ss-loss">0.0412</span></div>
244
+ <div class="ss-chip"><span class="ss-k">PRIOR CONFORM</span><span class="ss-v up">91.3%</span></div>
245
+ <div class="ss-chip"><span class="ss-k">‖J_res‖</span><span class="ss-v am">3.2e-7</span></div>
246
+ <div class="ss-chip"><span class="ss-k">EPOCH</span><span class="ss-v">30/30 ✓</span></div>
247
+ </div>
248
+
249
+ <!-- NODE POPUP -->
250
+ <div class="node-popup" id="node-popup">
251
+ <div class="np-head">
252
+ <span id="np-name">RELIANCE</span>
253
+ <span id="np-sector" style="font-size:9px;color:var(--muted);font-weight:400;">Energy</span>
254
+ </div>
255
+ <div class="np-row"><span class="np-k">φ Potential</span><span class="np-v am" id="np-phi">+2.41</span></div>
256
+ <div class="np-row"><span class="np-k">Rank</span><span class="np-v up" id="np-rank">#1 Upstream</span></div>
257
+ <div class="np-row"><span class="np-k">Out-degree</span><span class="np-v" id="np-out">8</span></div>
258
+ <div class="np-row"><span class="np-k">In-degree</span><span class="np-v" id="np-in">2</span></div>
259
+ <div class="np-row"><span class="np-k">Strongest cause</span><span class="np-v am" id="np-cause">ONGC 0.847</span></div>
260
+ <div class="np-row"><span class="np-k">News score</span><span class="np-v up" id="np-news">0.87</span></div>
261
+ </div>
262
+
263
+ <!-- ERROR TOAST -->
264
+ <div id="error-toast"></div>
265
+
266
+ <!-- App JS (separate file served via StaticFiles) -->
267
+ <script type="module" src="/static/app.js"></script>
268
+ </body>
269
+ </html>
frontend/style.css ADDED
@@ -0,0 +1,711 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Google Fonts ──────────────────────────────────────────────────────────── */
2
+ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&display=swap');
3
+
4
+ /* ── Design Tokens ─────────────────────────────────────────────────────────── */
5
+ :root {
6
+ --bg0: #090909;
7
+ --bg1: #0e0e0e;
8
+ --bg2: #141414;
9
+ --bg3: #1c1c1c;
10
+ --bg4: #242424;
11
+ --border: #252525;
12
+ --border-hi: #383838;
13
+
14
+ --amber: #f0a500;
15
+ --amber-lo: rgba(240,165,0,0.12);
16
+ --amber-dim: #7a5200;
17
+ --red: #e03434;
18
+ --red-lo: rgba(224,52,52,0.12);
19
+ --green: #00c87a;
20
+ --green-lo: rgba(0,200,122,0.12);
21
+ --cyan: #00b8d4;
22
+ --cyan-lo: rgba(0,184,212,0.12);
23
+ --purple: #a78bfa;
24
+ --purple-lo: rgba(167,139,250,0.12);
25
+ --white: #e8e4d9;
26
+ --muted: #5a5a54;
27
+ --muted2: #38382e;
28
+ --font: 'IBM Plex Mono','Courier New',monospace;
29
+ --r: 3px;
30
+ }
31
+
32
+ /* ── Reset ─────────────────────────────────────────────────────────────────── */
33
+ *, *::before, *::after {
34
+ margin: 0;
35
+ padding: 0;
36
+ box-sizing: border-box;
37
+ }
38
+
39
+ html, body {
40
+ height: 100%;
41
+ overflow: hidden;
42
+ }
43
+
44
+ body {
45
+ background: var(--bg0);
46
+ color: var(--white);
47
+ font-family: var(--font);
48
+ font-size: 11px;
49
+ line-height: 1.5;
50
+ display: flex;
51
+ flex-direction: column;
52
+ }
53
+
54
+ /* ── Scrollbar ─────────────────────────────────────────────────────────────── */
55
+ ::-webkit-scrollbar { width: 3px; height: 3px; }
56
+ ::-webkit-scrollbar-track { background: var(--bg0); }
57
+ ::-webkit-scrollbar-thumb { background: var(--muted2); }
58
+
59
+ /* ── Loading Overlay ───────────────────────────────────────────────────────── */
60
+ #loading-overlay {
61
+ position: fixed;
62
+ inset: 0;
63
+ background: var(--bg0);
64
+ z-index: 9999;
65
+ display: flex;
66
+ flex-direction: column;
67
+ align-items: center;
68
+ justify-content: center;
69
+ gap: 16px;
70
+ transition: opacity 0.4s ease;
71
+ }
72
+ #loading-overlay.hidden { opacity: 0; pointer-events: none; }
73
+
74
+ .loading-brand {
75
+ color: var(--amber);
76
+ font-size: 16px;
77
+ font-weight: 600;
78
+ letter-spacing: 4px;
79
+ }
80
+ .loading-bar-wrap {
81
+ width: 220px;
82
+ height: 2px;
83
+ background: var(--bg3);
84
+ border-radius: 2px;
85
+ overflow: hidden;
86
+ }
87
+ .loading-bar-fill {
88
+ height: 2px;
89
+ background: var(--amber);
90
+ border-radius: 2px;
91
+ animation: loadbar 1.6s ease-in-out forwards;
92
+ }
93
+ @keyframes loadbar {
94
+ 0% { width: 0%; }
95
+ 60% { width: 80%; }
96
+ 100% { width: 100%; }
97
+ }
98
+ .loading-status {
99
+ font-size: 9px;
100
+ color: var(--muted);
101
+ letter-spacing: 1.5px;
102
+ }
103
+
104
+ /* ── Top Bar ───────────────────────────────────────────────────────────────── */
105
+ .topbar {
106
+ height: 38px;
107
+ background: var(--bg1);
108
+ border-bottom: 1px solid var(--amber);
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: space-between;
112
+ padding: 0 14px;
113
+ flex-shrink: 0;
114
+ z-index: 200;
115
+ }
116
+
117
+ .brand {
118
+ color: var(--amber);
119
+ font-weight: 600;
120
+ font-size: 12px;
121
+ letter-spacing: 3px;
122
+ }
123
+
124
+ .tabs {
125
+ display: flex;
126
+ gap: 1px;
127
+ }
128
+
129
+ .tab {
130
+ background: none;
131
+ border: none;
132
+ color: var(--muted);
133
+ font-family: var(--font);
134
+ font-size: 10px;
135
+ padding: 0 14px;
136
+ height: 38px;
137
+ cursor: pointer;
138
+ letter-spacing: 1.5px;
139
+ text-transform: uppercase;
140
+ border-bottom: 2px solid transparent;
141
+ transition: color 0.15s, border-color 0.15s;
142
+ }
143
+ .tab:hover { color: var(--white); }
144
+ .tab.active { color: var(--amber); border-bottom-color: var(--amber); }
145
+
146
+ .topbar-right {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 12px;
150
+ font-size: 9px;
151
+ color: var(--muted);
152
+ }
153
+
154
+ .live-dot {
155
+ width: 5px;
156
+ height: 5px;
157
+ background: var(--green);
158
+ border-radius: 50%;
159
+ display: inline-block;
160
+ animation: blink 2s infinite;
161
+ }
162
+ @keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
163
+
164
+ /* ── API Status Banner ─────────────────────────────────────────────────────── */
165
+ #api-banner {
166
+ height: 22px;
167
+ background: var(--bg2);
168
+ border-bottom: 1px solid var(--border);
169
+ display: flex;
170
+ align-items: center;
171
+ padding: 0 14px;
172
+ gap: 14px;
173
+ flex-shrink: 0;
174
+ font-size: 9px;
175
+ letter-spacing: 1px;
176
+ }
177
+ .api-chip {
178
+ display: flex;
179
+ align-items: center;
180
+ gap: 4px;
181
+ color: var(--muted);
182
+ }
183
+ .api-chip.ok .api-dot { background: var(--green); }
184
+ .api-chip.err .api-dot { background: var(--red); }
185
+ .api-chip.busy .api-dot { background: var(--amber); animation: blink 1s infinite; }
186
+ .api-dot {
187
+ width: 5px;
188
+ height: 5px;
189
+ border-radius: 50%;
190
+ background: var(--muted2);
191
+ }
192
+
193
+ /* ── Body Layout ───────────────────────────────────────────────────────────── */
194
+ .body {
195
+ flex: 1;
196
+ display: grid;
197
+ grid-template-columns: 200px 1fr 260px;
198
+ overflow: hidden;
199
+ min-height: 0;
200
+ }
201
+
202
+ /* ── Left Sidebar ──────────────────────────────────────────────────────────── */
203
+ .sidebar {
204
+ background: var(--bg1);
205
+ border-right: 1px solid var(--border);
206
+ display: flex;
207
+ flex-direction: column;
208
+ overflow: hidden;
209
+ }
210
+
211
+ .sb-header {
212
+ padding: 7px 10px;
213
+ background: var(--bg2);
214
+ border-bottom: 1px solid var(--border);
215
+ color: var(--amber);
216
+ font-size: 9px;
217
+ letter-spacing: 2px;
218
+ text-transform: uppercase;
219
+ display: flex;
220
+ justify-content: space-between;
221
+ align-items: center;
222
+ flex-shrink: 0;
223
+ }
224
+
225
+ .sb-seg {
226
+ padding: 6px 8px;
227
+ border-bottom: 1px solid var(--border);
228
+ flex-shrink: 0;
229
+ }
230
+ .seg-btn { display: flex; gap: 3px; }
231
+ .seg-btn button {
232
+ flex: 1;
233
+ background: var(--bg3);
234
+ border: 1px solid var(--border);
235
+ color: var(--muted);
236
+ font-family: var(--font);
237
+ font-size: 9px;
238
+ padding: 4px;
239
+ cursor: pointer;
240
+ border-radius: var(--r);
241
+ letter-spacing: 1px;
242
+ transition: all 0.15s;
243
+ }
244
+ .seg-btn button.active {
245
+ background: var(--amber);
246
+ color: #000;
247
+ border-color: var(--amber);
248
+ font-weight: 600;
249
+ }
250
+
251
+ .sb-search {
252
+ padding: 6px 8px;
253
+ border-bottom: 1px solid var(--border);
254
+ flex-shrink: 0;
255
+ }
256
+ .sb-search input {
257
+ width: 100%;
258
+ background: var(--bg3);
259
+ border: 1px solid var(--border);
260
+ color: var(--white);
261
+ font-family: var(--font);
262
+ font-size: 10px;
263
+ padding: 4px 8px;
264
+ outline: none;
265
+ border-radius: var(--r);
266
+ transition: border-color 0.15s;
267
+ }
268
+ .sb-search input:focus { border-color: var(--amber-dim); }
269
+
270
+ .ticker-list {
271
+ overflow-y: auto;
272
+ flex: 1;
273
+ }
274
+
275
+ .ticker-row {
276
+ display: flex;
277
+ align-items: center;
278
+ padding: 5px 10px;
279
+ cursor: pointer;
280
+ border-bottom: 1px solid var(--border);
281
+ transition: background 0.1s;
282
+ gap: 6px;
283
+ }
284
+ .ticker-row:hover { background: var(--bg3); }
285
+ .ticker-row.sel {
286
+ background: var(--amber-lo);
287
+ border-left: 2px solid var(--amber);
288
+ }
289
+ .ticker-row.rippling { animation: rowripple 0.6s ease-out; }
290
+ .ticker-row.rippling-up { animation: rowripple-up 0.6s ease-out; }
291
+ @keyframes rowripple { 0% { background: rgba(224,52,52,.35); } 100% { background: transparent; } }
292
+ @keyframes rowripple-up { 0% { background: rgba(0,200,122,.35); } 100% { background: transparent; } }
293
+
294
+ .t-sym { color: var(--amber); font-size: 10px; font-weight: 600; width: 62px; flex-shrink: 0; }
295
+ .t-phi { font-size: 9px; text-align: right; flex-shrink: 0; width: 32px; }
296
+ .t-bar { flex: 1; height: 3px; background: var(--bg4); border-radius: 2px; overflow: hidden; }
297
+ .t-bar-fill { height: 3px; border-radius: 2px; transition: width 0.3s; }
298
+
299
+ /* ── Center Panel ──────────────────────────────────────────────────────────── */
300
+ .center {
301
+ display: flex;
302
+ flex-direction: column;
303
+ overflow: hidden;
304
+ background: var(--bg0);
305
+ position: relative;
306
+ }
307
+
308
+ .view {
309
+ display: none;
310
+ flex: 1;
311
+ flex-direction: column;
312
+ overflow: hidden;
313
+ }
314
+ .view.active { display: flex; }
315
+
316
+ .view-header {
317
+ padding: 8px 14px;
318
+ background: var(--bg2);
319
+ border-bottom: 1px solid var(--border);
320
+ display: flex;
321
+ align-items: center;
322
+ justify-content: space-between;
323
+ flex-shrink: 0;
324
+ }
325
+ .vh-title { color: var(--amber); font-size: 10px; letter-spacing: 2px; font-weight: 600; }
326
+ .vh-meta { color: var(--muted); font-size: 9px; }
327
+
328
+ .view-body {
329
+ flex: 1;
330
+ overflow: auto;
331
+ padding: 12px;
332
+ position: relative;
333
+ }
334
+
335
+ /* ── Legend ────────────────────────────────────────────────────────────────── */
336
+ .legend {
337
+ display: flex;
338
+ gap: 14px;
339
+ align-items: center;
340
+ padding: 6px 12px;
341
+ border-bottom: 1px solid var(--border);
342
+ flex-shrink: 0;
343
+ flex-wrap: wrap;
344
+ }
345
+ .leg-item { display: flex; align-items: center; gap: 5px; font-size: 9px; color: var(--muted); }
346
+ .leg-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
347
+ .leg-line { width: 18px; height: 2px; flex-shrink: 0; }
348
+
349
+ /* ── Heatmap ───────────────────────────────────────────────────────���───────── */
350
+ .hm-wrap { overflow: auto; padding: 0; }
351
+ #heatmap-svg { display: block; }
352
+ .hm-cell { cursor: pointer; transition: opacity 0.15s; }
353
+ .hm-cell:hover { opacity: 0.75; stroke: #fff !important; stroke-width: 1.5 !important; }
354
+ .hm-cell.ripple-out { animation: hmripple 1s ease-out forwards; }
355
+ .hm-cell.ripple-in { animation: hmripple-in 0.8s ease-out forwards; }
356
+ .hm-cell.ripple-pulse { animation: hmpulse 1.2s ease-in-out 3; }
357
+ @keyframes hmripple { 0% { opacity:1; fill: rgba(224,52,52,0.9); } 100% { opacity: 1; } }
358
+ @keyframes hmripple-in { 0% { opacity:1; fill: rgba(0,200,122,0.9); } 100% { opacity: 1; } }
359
+ @keyframes hmpulse { 0%,100% { opacity:1; } 50% { opacity: 0.3; } }
360
+
361
+ /* ── Network ───────────────────────────────────────────────────────────────── */
362
+ #net-svg { display: block; width: 100%; height: 100%; }
363
+ .net-node { cursor: pointer; }
364
+ .net-node:hover circle { stroke-width: 2; }
365
+ .net-edge { transition: stroke-width 0.2s, stroke-opacity 0.2s; }
366
+
367
+ /* ── Right Panel ───────────────────────────────────────────────────────────── */
368
+ .rpanel {
369
+ background: var(--bg1);
370
+ border-left: 1px solid var(--border);
371
+ display: flex;
372
+ flex-direction: column;
373
+ overflow: hidden;
374
+ }
375
+
376
+ .rp-sec { border-bottom: 1px solid var(--border); flex-shrink: 0; }
377
+ .rp-head {
378
+ padding: 6px 10px;
379
+ background: var(--bg2);
380
+ border-bottom: 1px solid var(--border);
381
+ font-size: 9px;
382
+ letter-spacing: 2px;
383
+ color: var(--cyan);
384
+ font-weight: 600;
385
+ text-transform: uppercase;
386
+ display: flex;
387
+ justify-content: space-between;
388
+ align-items: center;
389
+ }
390
+ .rp-row {
391
+ display: flex;
392
+ justify-content: space-between;
393
+ padding: 4px 10px;
394
+ border-bottom: 1px solid var(--border);
395
+ font-size: 10px;
396
+ }
397
+ .rp-k { color: var(--muted); }
398
+ .rp-v { color: var(--white); }
399
+ .rp-v.up { color: var(--green); }
400
+ .rp-v.dn { color: var(--red); }
401
+ .rp-v.am { color: var(--amber); }
402
+
403
+ /* ── Inference Panel ───────────────────────────────────────────────────────── */
404
+ .infer-panel { padding: 10px; }
405
+ .infer-mode { display: flex; gap: 4px; margin-bottom: 10px; }
406
+ .infer-mode button {
407
+ flex: 1;
408
+ background: var(--bg3);
409
+ border: 1px solid var(--border);
410
+ color: var(--muted);
411
+ font-family: var(--font);
412
+ font-size: 9px;
413
+ padding: 5px;
414
+ cursor: pointer;
415
+ border-radius: var(--r);
416
+ letter-spacing: 1px;
417
+ transition: all 0.15s;
418
+ }
419
+ .infer-mode button.active { font-weight: 600; }
420
+ .infer-mode button.m-assert.active { background: var(--cyan-lo); color: var(--cyan); border-color: var(--cyan); }
421
+ .infer-mode button.m-intervene.active { background: var(--amber-lo); color: var(--amber); border-color: var(--amber); }
422
+ .infer-mode button.m-counter.active { background: var(--purple-lo); color: var(--purple); border-color: var(--purple); }
423
+
424
+ .infer-form {
425
+ background: var(--bg2);
426
+ border: 1px solid var(--border);
427
+ border-radius: var(--r);
428
+ padding: 10px;
429
+ margin-bottom: 8px;
430
+ }
431
+ .infer-label {
432
+ font-size: 9px;
433
+ color: var(--muted);
434
+ letter-spacing: 1.5px;
435
+ text-transform: uppercase;
436
+ margin-bottom: 5px;
437
+ }
438
+ .infer-select {
439
+ width: 100%;
440
+ background: var(--bg3);
441
+ border: 1px solid var(--border);
442
+ color: var(--white);
443
+ font-family: var(--font);
444
+ font-size: 10px;
445
+ padding: 5px 8px;
446
+ outline: none;
447
+ border-radius: var(--r);
448
+ margin-bottom: 8px;
449
+ cursor: pointer;
450
+ }
451
+ .infer-select:focus { border-color: var(--amber-dim); }
452
+
453
+ .slider-wrap { margin-bottom: 10px; }
454
+ .slider-row { display: flex; justify-content: space-between; margin-bottom: 4px; }
455
+ .slider-val { color: var(--amber); font-weight: 600; font-size: 10px; }
456
+ input[type=range] { width: 100%; accent-color: var(--amber); cursor: pointer; height: 3px; }
457
+
458
+ .run-btn {
459
+ width: 100%;
460
+ padding: 8px;
461
+ border: none;
462
+ font-family: var(--font);
463
+ font-size: 10px;
464
+ font-weight: 600;
465
+ letter-spacing: 2px;
466
+ cursor: pointer;
467
+ border-radius: var(--r);
468
+ transition: opacity 0.2s, transform 0.2s;
469
+ }
470
+ .run-btn.assert { background: var(--cyan); color: #000; }
471
+ .run-btn.intervene { background: var(--amber); color: #000; }
472
+ .run-btn.counter { background: var(--purple); color: #000; }
473
+ .run-btn:hover { opacity: 0.85; transform: translateY(-1px); }
474
+ .run-btn:active { transform: translateY(0); }
475
+ .run-btn:disabled { opacity: 0.4; cursor: not-allowed; }
476
+
477
+ /* ── Result Cards ──────────────────────────────────────────────────────────── */
478
+ .result-card {
479
+ background: var(--bg2);
480
+ border: 1px solid var(--border);
481
+ border-radius: var(--r);
482
+ padding: 10px;
483
+ margin-bottom: 8px;
484
+ position: relative;
485
+ overflow: hidden;
486
+ }
487
+ .result-card::before {
488
+ content: '';
489
+ position: absolute;
490
+ top: 0; left: 0;
491
+ width: 3px; height: 100%;
492
+ }
493
+ .result-card.assert::before { background: var(--cyan); }
494
+ .result-card.intervene::before { background: var(--amber); }
495
+ .result-card.counter::before { background: var(--purple); }
496
+ .rc-head { font-size: 9px; letter-spacing: 1.5px; margin-bottom: 6px; font-weight: 600; }
497
+ .rc-head.assert { color: var(--cyan); }
498
+ .rc-head.intervene { color: var(--amber); }
499
+ .rc-head.counter { color: var(--purple); }
500
+ .rc-row { display: flex; justify-content: space-between; font-size: 10px; padding: 2px 0; }
501
+ .rc-k { color: var(--muted); }
502
+ .rc-v { color: var(--white); }
503
+ .rc-v.up { color: var(--green); }
504
+ .rc-v.dn { color: var(--red); }
505
+ .rc-v.am { color: var(--amber); }
506
+
507
+ .ate-track {
508
+ height: 4px;
509
+ background: var(--bg4);
510
+ border-radius: 2px;
511
+ margin-top: 6px;
512
+ overflow: hidden;
513
+ }
514
+ .ate-fill {
515
+ height: 4px;
516
+ border-radius: 2px;
517
+ transition: width 0.8s ease;
518
+ }
519
+
520
+ .ripple-effects {
521
+ margin-top: 8px;
522
+ padding-top: 8px;
523
+ border-top: 1px solid var(--border);
524
+ }
525
+ .ripple-title { font-size: 9px; color: var(--muted); letter-spacing: 1px; margin-bottom: 5px; }
526
+ .ripple-chip {
527
+ display: inline-flex;
528
+ align-items: center;
529
+ gap: 4px;
530
+ padding: 2px 7px;
531
+ border-radius: 2px;
532
+ font-size: 9px;
533
+ margin: 2px;
534
+ border: 1px solid;
535
+ }
536
+ .ripple-chip.up { background: var(--green-lo); border-color: var(--green); color: var(--green); }
537
+ .ripple-chip.dn { background: var(--red-lo); border-color: var(--red); color: var(--red); }
538
+
539
+ /* ── News Feed ─────────────────────────────────────────────────────────────── */
540
+ .news-item {
541
+ padding: 7px 10px;
542
+ border-bottom: 1px solid var(--border);
543
+ cursor: pointer;
544
+ transition: background 0.1s;
545
+ }
546
+ .news-item:hover { background: var(--bg3); }
547
+ .news-top { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
548
+ .news-score { font-size: 8px; padding: 1px 5px; border-radius: 2px; font-weight: 600; }
549
+ .news-score.hi { background: var(--green-lo); color: var(--green); }
550
+ .news-score.md { background: var(--amber-lo); color: var(--amber); }
551
+ .news-score.lo { background: var(--red-lo); color: var(--red); }
552
+ .news-sym { color: var(--amber); font-size: 9px; font-weight: 600; }
553
+ .news-text { font-size: 9px; color: var(--muted); line-height: 1.5; margin-bottom: 4px; }
554
+ .news-tags { display: flex; gap: 3px; flex-wrap: wrap; }
555
+ .news-tag { font-size: 8px; padding: 1px 5px; border: 1px solid var(--border); color: var(--muted); border-radius: 2px; }
556
+
557
+ /* ── HHKD View ─────────────────────────────────────────────────────────────── */
558
+ .phi-row {
559
+ display: flex;
560
+ align-items: center;
561
+ gap: 8px;
562
+ padding: 4px 10px;
563
+ border-bottom: 1px solid var(--border);
564
+ cursor: pointer;
565
+ transition: background 0.1s;
566
+ }
567
+ .phi-row:hover { background: var(--bg3); }
568
+ .phi-sym { width: 66px; font-size: 10px; color: var(--amber); font-weight: 600; flex-shrink: 0; }
569
+ .phi-bar-wrap { flex: 1; height: 8px; background: var(--bg4); border-radius: 4px; overflow: hidden; }
570
+ .phi-bar-fill { height: 8px; border-radius: 4px; transition: width 0.4s; }
571
+ .phi-val { width: 36px; text-align: right; font-size: 10px; flex-shrink: 0; }
572
+
573
+ /* ── Sector View ───────────────────────────────────────────────────────────── */
574
+ .sector-grid { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
575
+ .sec-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; }
576
+ .sec-card-head {
577
+ padding: 7px 12px;
578
+ display: flex;
579
+ align-items: center;
580
+ justify-content: space-between;
581
+ cursor: pointer;
582
+ transition: background 0.1s;
583
+ }
584
+ .sec-card-head:hover { background: var(--bg3); }
585
+ .sec-name { font-size: 11px; font-weight: 600; letter-spacing: 1px; color: var(--white); }
586
+ .sec-phi { font-size: 10px; }
587
+ .sec-members { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; }
588
+ .sec-chip {
589
+ padding: 3px 9px;
590
+ border: 1px solid var(--border);
591
+ font-size: 9px;
592
+ border-radius: 2px;
593
+ cursor: pointer;
594
+ transition: all 0.15s;
595
+ }
596
+ .sec-chip:hover { border-color: var(--amber); color: var(--amber); }
597
+ .sec-chip.rippling { animation: chipripple 0.7s ease-out; }
598
+ .sec-chip.rippling-up { animation: chipripple-up 0.7s ease-out; }
599
+ @keyframes chipripple { 0% { background: rgba(224,52,52,.4); border-color: var(--red); } 100% { background: transparent; } }
600
+ @keyframes chipripple-up { 0% { background: rgba(0,200,122,.4); border-color: var(--green); } 100% { background: transparent; } }
601
+
602
+ /* ── Node Popup ────────────────────────────────────────────────────────────── */
603
+ .node-popup {
604
+ position: fixed;
605
+ z-index: 500;
606
+ background: var(--bg2);
607
+ border: 1px solid var(--amber);
608
+ border-radius: var(--r);
609
+ padding: 12px;
610
+ min-width: 200px;
611
+ max-width: 260px;
612
+ pointer-events: none;
613
+ display: none;
614
+ box-shadow: 0 8px 32px rgba(0,0,0,.6);
615
+ }
616
+ .np-head {
617
+ color: var(--amber);
618
+ font-size: 12px;
619
+ font-weight: 600;
620
+ margin-bottom: 8px;
621
+ display: flex;
622
+ justify-content: space-between;
623
+ align-items: center;
624
+ }
625
+ .np-row {
626
+ display: flex;
627
+ justify-content: space-between;
628
+ padding: 3px 0;
629
+ border-bottom: 1px solid var(--border);
630
+ font-size: 10px;
631
+ }
632
+ .np-row:last-child { border: none; }
633
+ .np-k { color: var(--muted); }
634
+ .np-v { color: var(--white); }
635
+ .np-v.up { color: var(--green); }
636
+ .np-v.dn { color: var(--red); }
637
+ .np-v.am { color: var(--amber); }
638
+
639
+ /* ── Status Strip ──────────────────────────────────────────────────────────── */
640
+ .status-strip {
641
+ height: 22px;
642
+ background: var(--bg2);
643
+ border-top: 1px solid var(--border);
644
+ display: flex;
645
+ align-items: center;
646
+ padding: 0 10px;
647
+ gap: 16px;
648
+ flex-shrink: 0;
649
+ overflow: hidden;
650
+ }
651
+ .ss-chip { font-size: 9px; display: flex; gap: 5px; white-space: nowrap; }
652
+ .ss-k { color: var(--muted); }
653
+ .ss-v { color: var(--white); }
654
+ .ss-v.am { color: var(--amber); }
655
+ .ss-v.up { color: var(--green); }
656
+
657
+ /* ── Accordion ─────────────────────────────────────────────────────────────── */
658
+ .accordion { border: 1px solid var(--border); border-radius: var(--r); margin-bottom: 6px; overflow: hidden; }
659
+ .acc-head {
660
+ display: flex;
661
+ align-items: center;
662
+ justify-content: space-between;
663
+ padding: 7px 10px;
664
+ cursor: pointer;
665
+ background: var(--bg2);
666
+ user-select: none;
667
+ }
668
+ .acc-head:hover { background: var(--bg3); }
669
+ .acc-title { font-size: 10px; color: var(--white); font-weight: 500; letter-spacing: 0.5px; }
670
+ .acc-badge { font-size: 8px; padding: 1px 6px; border-radius: 2px; font-weight: 600; letter-spacing: 1px; }
671
+ .acc-badge.up { background: var(--green-lo); color: var(--green); }
672
+ .acc-badge.dn { background: var(--red-lo); color: var(--red); }
673
+ .acc-badge.am { background: var(--amber-lo); color: var(--amber); }
674
+ .acc-badge.cy { background: var(--cyan-lo); color: var(--cyan); }
675
+ .acc-chevron { color: var(--muted); font-size: 10px; transition: transform 0.2s; }
676
+ .acc-chevron.open { transform: rotate(180deg); }
677
+ .acc-body { display: none; border-top: 1px solid var(--border); }
678
+ .acc-body.open { display: block; }
679
+ .acc-row { display: flex; justify-content: space-between; padding: 4px 10px; border-bottom: 1px solid var(--border); font-size: 10px; }
680
+ .acc-k { color: var(--muted); }
681
+ .acc-v { color: var(--white); }
682
+ .acc-v.up { color: var(--green); }
683
+ .acc-v.dn { color: var(--red); }
684
+ .acc-v.am { color: var(--amber); }
685
+
686
+ /* ── Ripple Ring ───────────────────────────────────────────────────────────── */
687
+ @keyframes pulse-ring { 0% { transform: scale(.8); opacity: 1; } 100% { transform: scale(2.5); opacity: 0; } }
688
+ .ripple-ring {
689
+ position: absolute;
690
+ border-radius: 50%;
691
+ pointer-events: none;
692
+ animation: pulse-ring 0.8s ease-out forwards;
693
+ }
694
+
695
+ /* ── Error toast ───────────────────────────────────────────────────────────── */
696
+ #error-toast {
697
+ position: fixed;
698
+ bottom: 28px;
699
+ left: 50%;
700
+ transform: translateX(-50%) translateY(60px);
701
+ background: var(--red-lo);
702
+ border: 1px solid var(--red);
703
+ color: var(--red);
704
+ font-size: 10px;
705
+ padding: 8px 16px;
706
+ border-radius: var(--r);
707
+ z-index: 9000;
708
+ transition: transform 0.3s ease;
709
+ letter-spacing: 0.5px;
710
+ }
711
+ #error-toast.show { transform: translateX(-50%) translateY(0); }
server.py ADDED
@@ -0,0 +1,1036 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dashboard_server.py
3
+ ====================
4
+ CUTS+ Causal Terminal — gr.Server entry point.
5
+
6
+ Architecture
7
+ ------------
8
+ gr.Server (extends FastAPI)
9
+ ├── GET / → serves frontend/index.html
10
+ ├── GET /static/* → serves frontend/{style.css, app.js} (StaticFiles)
11
+
12
+ ├── @server.api run_causal_components → CUTS+ multi-ticker discovery
13
+ ├── @server.api run_singular_causal → single-ticker 10-step pipeline
14
+ ├── @server.api run_inference → DoFlow / SCM causal query
15
+ ├── @server.api run_hierarchy → hierarchical sector causal
16
+
17
+ ├── GET /v2/health → health-check
18
+ ├── GET /v2/causal/singular-causal/graph/{ticker} → cached adj graph
19
+ ├── GET /v2/causal/singular-causal/results/{ticker} → cached inference JSON
20
+ ├── POST /v2/causal/doflow-inference → DoFlow query
21
+
22
+ └── All existing /v2/* routers from main.py are included here too
23
+ (so this server is a superset of main.py).
24
+
25
+ Usage
26
+ -----
27
+ python dashboard_server.py
28
+
29
+ Or with uvicorn:
30
+ uvicorn dashboard_server:server --host 0.0.0.0 --port 7860 --reload
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import sys
37
+ import json
38
+ import logging
39
+ from pathlib import Path
40
+ from typing import Any, Dict, List, Optional
41
+
42
+ from dotenv import load_dotenv
43
+
44
+ load_dotenv()
45
+
46
+ BASE_DIR = Path(__file__).parent.resolve()
47
+ if str(BASE_DIR) not in sys.path:
48
+ sys.path.insert(0, str(BASE_DIR))
49
+
50
+ # Also add the backend directory to sys.path so we can import 'app', 'causal', 'singular_ticker_causal', etc.
51
+ BACKEND_DIR = (BASE_DIR.parent / "noisy_boy_backend").resolve()
52
+ if BACKEND_DIR.exists() and str(BACKEND_DIR) not in sys.path:
53
+ sys.path.insert(0, str(BACKEND_DIR))
54
+
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+ # Logging
57
+ # ─────────────────────────────────────────────────────────────────────────────
58
+ logging.basicConfig(
59
+ level=logging.INFO,
60
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
61
+ handlers=[logging.StreamHandler()],
62
+ )
63
+ logger = logging.getLogger("dashboard-server")
64
+
65
+ # ─────────────────────────────────────────────────────────────────────────────
66
+ # gr.Server
67
+ # ─────────────────────────────────────────────────────────────────────────────
68
+ import gradio as gr
69
+ from gradio import Server
70
+
71
+ from fastapi import Request
72
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
73
+ from fastapi.staticfiles import StaticFiles
74
+ from fastapi.middleware.cors import CORSMiddleware
75
+
76
+ FRONTEND_DIR = BASE_DIR / "frontend"
77
+ INDEX_HTML = FRONTEND_DIR / "index.html"
78
+
79
+ server = Server(
80
+ title="CUTS+ Causal Terminal",
81
+ description=(
82
+ "Iroha Financial Intelligence — real-time causal probability matrix, "
83
+ "HHKD decomposition, DoFlow inference and sector hierarchy over NIFTY50."
84
+ ),
85
+ version="2.0.0",
86
+ )
87
+
88
+ # ── CORS (same as main.py) ────────────────────────────────────────────────
89
+ server.add_middleware(
90
+ CORSMiddleware,
91
+ allow_origins=["*"],
92
+ allow_credentials=True,
93
+ allow_methods=["*"],
94
+ allow_headers=["*"],
95
+ )
96
+
97
+ # ── Static files — mount frontend/ at /static ─────────────────────────────
98
+ server.mount(
99
+ "/static",
100
+ StaticFiles(directory=str(FRONTEND_DIR)),
101
+ name="static",
102
+ )
103
+
104
+ # ─────────────────────────────────────────────────────────────────────────────
105
+ # HTML route — serves the custom frontend
106
+ # ─────────────────────────────────────────────────────────────────────────────
107
+
108
+ @server.get("/", response_class=HTMLResponse, include_in_schema=False)
109
+ async def serve_index():
110
+ """Serve the CUTS+ Causal Terminal SPA."""
111
+ if not INDEX_HTML.exists():
112
+ return HTMLResponse("<h1>Frontend not found. Run from backend/</h1>", status_code=500)
113
+ return HTMLResponse(INDEX_HTML.read_text(encoding="utf-8"))
114
+
115
+
116
+ # ─────────────────────────────────────────────────────────────────────────────
117
+ # Health check
118
+ # ─────────────────────────────────────────────────────────────────────────────
119
+
120
+ @server.get("/v2/health", tags=["utility"])
121
+ async def health():
122
+ """Lightweight health-check used by the frontend API banner."""
123
+ return {"status": "ok", "version": "2.0.0"}
124
+
125
+
126
+ # ─────────────────────────────────────────────────────────────────────────────
127
+ # Existing v2 routers (same as main.py)
128
+ # ─────────────────────────────────────────────────────────────────────────────
129
+
130
+ try:
131
+ from app.api.scraper import router as scraper_router
132
+ server.include_router(scraper_router, prefix="/v2")
133
+ logger.info("✓ scraper router mounted")
134
+ except Exception as e:
135
+ logger.warning(f"scraper router skipped: {e}")
136
+
137
+ try:
138
+ from app.api.causal import router as causal_router
139
+ server.include_router(causal_router, prefix="/v2")
140
+ logger.info("✓ causal router mounted")
141
+ except Exception as e:
142
+ logger.warning(f"causal router skipped: {e}")
143
+
144
+ try:
145
+ from app.api.causal_pipeline import router as causal_pipeline_router
146
+ server.include_router(causal_pipeline_router, prefix="/v2")
147
+ logger.info("✓ causal_pipeline router mounted")
148
+ except Exception as e:
149
+ logger.warning(f"causal_pipeline router skipped: {e}")
150
+
151
+ try:
152
+ from app.api.screening import router as screening_router
153
+ server.include_router(screening_router, prefix="/v2")
154
+ logger.info("✓ screening router mounted")
155
+ except Exception as e:
156
+ logger.warning(f"screening router skipped: {e}")
157
+
158
+ try:
159
+ from app.api.ontology import router as ontology_router
160
+ server.include_router(ontology_router, prefix="/v2")
161
+ logger.info("✓ ontology router mounted")
162
+ except Exception as e:
163
+ logger.warning(f"ontology router skipped: {e}")
164
+
165
+ try:
166
+ # Temporarily remove frontend dir from sys.path to avoid shadowing 'server' package with our 'server.py' script
167
+ removed_empty = False
168
+ if "" in sys.path:
169
+ sys.path.remove("")
170
+ removed_empty = True
171
+ if str(BASE_DIR) in sys.path:
172
+ sys.path.remove(str(BASE_DIR))
173
+
174
+ from server.model_router import router as server_model_router
175
+ server.include_router(server_model_router, prefix="/v2")
176
+ logger.info("✓ model_router mounted")
177
+
178
+ # Restore sys.path
179
+ sys.path.insert(0, str(BASE_DIR))
180
+ if removed_empty:
181
+ sys.path.insert(0, "")
182
+ except Exception as e:
183
+ logger.warning(f"model_router skipped: {e}")
184
+ # Ensure sys.path is restored even on failure
185
+ if str(BASE_DIR) not in sys.path:
186
+ sys.path.insert(0, str(BASE_DIR))
187
+ if 'removed_empty' in locals() and removed_empty and "" not in sys.path:
188
+ sys.path.insert(0, "")
189
+
190
+ # try:
191
+ # from backtest.router import router as backtest_router
192
+ # server.include_router(backtest_router, prefix="/v2/backtest")
193
+ # logger.info("✓ backtest router mounted")
194
+ # except Exception as e:
195
+ # logger.warning(f"backtest router skipped: {e}")
196
+
197
+
198
+ # ─────────────────────────────────────────────────────────────────────────────
199
+ # gr.Server API endpoints (Gradio-backed — queue + SSE streaming)
200
+ # These are reachable via the Gradio JS Client as well as plain fetch().
201
+ # ─────────────────────────────────────────────────────────────────────────────
202
+
203
+ # ── Helpers ───────────────────────────────────────────────────────────────
204
+
205
+ # URL of the noisy_boy_backend — used to fetch the validated causal matrix.
206
+ # By default, point to ourselves since we now successfully mount the backend routers.
207
+ # Override via BACKEND_API_URL env var if running a separate backend on 8000.
208
+ _BACKEND_BASE_URL = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
209
+
210
+ # Kept for backward-compat with run_singular_causal (which still imports backend modules
211
+ # via sys.path when both repos are co-located). Not used in run_inference anymore.
212
+ _SINGULAR_DEBUG_DIR = str(BASE_DIR / "singular_ticker_causal" / "debug_data")
213
+
214
+
215
+ def _fetch_causal_matrix(
216
+ ticker: str,
217
+ treatment: Optional[str] = None,
218
+ outcome: Optional[str] = None,
219
+ include_pywhyllm: bool = False,
220
+ threshold: float = 0.5,
221
+ ) -> Optional[dict]:
222
+ """
223
+ Fetch the fully validated causal matrix from the backend API.
224
+
225
+ Calls GET {BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker}
226
+ and returns the parsed JSON payload, or None on failure.
227
+
228
+ The payload contains:
229
+ nodes — ordered list of node names
230
+ adj_matrix — raw float adjacency matrix
231
+ dag_adj — thresholded 0/1 DAG
232
+ equations — per-node structural equations (coefficients, intercepts, residual_std)
233
+ data_level — (T, N) time-series observations used to fit the SCM
234
+ topological_order — nodes in topological traversal order
235
+ pywhyllm_report — (optional) assumption analysis for treatment→outcome
236
+ """
237
+ import urllib.request
238
+ import urllib.error
239
+ import urllib.parse
240
+
241
+ params: dict = {"threshold": threshold}
242
+ if treatment:
243
+ params["treatment"] = treatment
244
+ if outcome:
245
+ params["outcome"] = outcome
246
+ if include_pywhyllm:
247
+ params["include_pywhyllm"] = "true"
248
+
249
+ query_string = urllib.parse.urlencode(params)
250
+ url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{query_string}"
251
+
252
+ try:
253
+ with urllib.request.urlopen(url, timeout=30) as resp:
254
+ raw = resp.read()
255
+ data = json.loads(raw)
256
+ if data.get("status") not in ("success", None):
257
+ logger.warning("_fetch_causal_matrix: backend returned status=%s for URL %s. Payload: %s", data.get("status"), url, data)
258
+ return None
259
+ return data
260
+ except Exception as exc:
261
+ logger.warning("_fetch_causal_matrix failed for %s: %s", ticker, exc)
262
+ return None
263
+
264
+
265
+ def _safe_json(obj: Any) -> Any:
266
+ """Recursively make numpy types JSON-serialisable."""
267
+ try:
268
+ import numpy as np
269
+ if isinstance(obj, np.ndarray):
270
+ return obj.tolist()
271
+ if isinstance(obj, np.integer):
272
+ return int(obj)
273
+ if isinstance(obj, np.floating):
274
+ return float(obj)
275
+ except ImportError:
276
+ pass
277
+ if isinstance(obj, dict):
278
+ return {k: _safe_json(v) for k, v in obj.items()}
279
+ if isinstance(obj, (list, tuple)):
280
+ return [_safe_json(v) for v in obj]
281
+ return obj
282
+
283
+
284
+ def _adj_to_graph(adj_matrix, symbols: List[str], threshold: float = 0.5):
285
+ """Convert adjacency matrix → {nodes, links} for the frontend."""
286
+ try:
287
+ import numpy as np
288
+ arr = np.array(adj_matrix)
289
+ except Exception:
290
+ arr = [[float(v) for v in row] for row in adj_matrix]
291
+
292
+ nodes = [{"id": s, "label": s} for s in symbols]
293
+ links = []
294
+ n = len(symbols)
295
+ for i in range(n):
296
+ for j in range(n):
297
+ try:
298
+ v = float(arr[i][j])
299
+ except Exception:
300
+ continue
301
+ if i != j and v >= threshold:
302
+ links.append({"source": symbols[i], "target": symbols[j], "score": round(v, 4)})
303
+ return nodes, links
304
+
305
+
306
+ # ── API 1: CUTS+ multi-ticker causal discovery ────────────────────────────
307
+
308
+ @server.api(name="run_causal_components", description="Run CUTS+ on NIFTY50 multi-ticker technical features")
309
+ def run_causal_components(
310
+ symbols: Optional[List[str]] = None,
311
+ use_actual: bool = False,
312
+ ) -> Dict[str, Any]:
313
+ """
314
+ Trigger the CUTS+ multi-ticker causal discovery pipeline.
315
+
316
+ Parameters
317
+ ----------
318
+ symbols : list of NSE ticker strings (default: synthetic RELIANCE/TCS pair)
319
+ use_actual : whether to download real OHLCV data from yfinance
320
+
321
+ Returns
322
+ -------
323
+ JSON with adjacency_matrix, nodes, links, density, symbols
324
+ """
325
+ try:
326
+ from causal.test_causal_flow import (
327
+ generate_synthetic_data,
328
+ load_actual_data,
329
+ NIFTY50_SYMBOLS,
330
+ )
331
+ from causal.services.feature_engineer import FeatureEngineer
332
+ from causal.services.cuts_tensor_builder import CutsTensorBuilder
333
+ from causal.cuts_plus.cuts_plus import main as cuts_plus_main
334
+ from causal.cuts_plus.utils.logger import MyLogger
335
+ from omegaconf import OmegaConf
336
+
337
+ if use_actual:
338
+ syms = symbols or NIFTY50_SYMBOLS
339
+ data = load_actual_data(syms)
340
+ else:
341
+ syms = symbols or ["RELIANCE", "TCS"]
342
+ data = generate_synthetic_data()
343
+
344
+ fe = FeatureEngineer()
345
+ ctb = CutsTensorBuilder()
346
+ tech_data, mask, ordered_syms, *_ = ctb.build(
347
+ historical_data=data, symbols=syms, feature_engineer=fe
348
+ )
349
+
350
+ log_dir = str(BASE_DIR / "causal" / "dash_logs")
351
+ os.makedirs(log_dir, exist_ok=True)
352
+ log = MyLogger(log_dir=log_dir, stdout=False, stderr=False, tensorboard=False)
353
+
354
+ cfg = OmegaConf.create({
355
+ "data_dim": tech_data.shape[-1],
356
+ "total_epoch": 30,
357
+ "ticker_list": ordered_syms,
358
+ "causal_thres": "value_0.5",
359
+ })
360
+
361
+ adj = cuts_plus_main(data=tech_data, mask=mask, true_cm=None, opt=cfg, log=log)
362
+ adj_list = _safe_json(adj)
363
+ nodes, links = _adj_to_graph(adj_list, ordered_syms, threshold=0.5)
364
+ n = len(ordered_syms)
365
+ edges = sum(1 for i in range(n) for j in range(n) if i != j and adj_list[i][j] >= 0.5)
366
+
367
+ return {
368
+ "status": "ok",
369
+ "symbols": ordered_syms,
370
+ "adjacency_matrix": adj_list,
371
+ "nodes": nodes,
372
+ "links": links,
373
+ "density": round(edges / max(n * (n - 1), 1), 4),
374
+ "n_edges": edges,
375
+ }
376
+ except Exception as exc:
377
+ logger.exception("run_causal_components failed")
378
+ return {"status": "error", "detail": str(exc)}
379
+
380
+
381
+ # ── API 2: Single-ticker fundamental causal pipeline ─────────────────────
382
+
383
+ @server.api(
384
+ name="run_singular_causal",
385
+ description="Run the full 10-step single-ticker fundamental causal pipeline",
386
+ concurrency_limit=2,
387
+ )
388
+ def run_singular_causal(ticker: str = "RELIANCE") -> Dict[str, Any]:
389
+ """
390
+ Execute the 10-step singular ticker pipeline:
391
+ DuPont prior → feature engineering → CUTS+ → SCM → CausalQueryEngine.
392
+
393
+ Parameters
394
+ ----------
395
+ ticker : NSE symbol (e.g. RELIANCE, HDFCBANK)
396
+
397
+ Returns
398
+ -------
399
+ JSON with adj_matrix, nodes, links, inference_summary
400
+ """
401
+ try:
402
+ from singular_ticker_causal.test_single_ticker_causal_flow import run_pipeline
403
+ result = run_pipeline(ticker=ticker.upper())
404
+ return _safe_json({"status": "ok", "ticker": ticker.upper(), **result})
405
+ except Exception as exc:
406
+ logger.exception("run_singular_causal failed")
407
+ return {"status": "error", "detail": str(exc)}
408
+
409
+
410
+ # ── API 3: Causal inference (assert / intervene / counterfactual) ─────────
411
+ #
412
+ # Architecture:
413
+ # 1. Fetch the VALIDATED causal matrix from noisy_boy_backend via HTTP.
414
+ # The backend has already run CUTS+ learning + pywhyllm + DoWhy validation.
415
+ # 2. Reconstruct the fitted SCM locally from that payload (no re-learning).
416
+ # 3. Use pywhyllm to gather structural guidance (confounders, backdoor sets,
417
+ # SCM mechanism hints) at each of the three causal layers.
418
+ # 4. Feed that guidance + the fitted SCM data into DoWhy / DoWhy-GCM to
419
+ # compute the actual numerical estimates — the LLM never touches the numbers.
420
+
421
+
422
+ def _resolve_value(
423
+ value: float, value_type: str, current: float
424
+ ) -> float:
425
+ """Convert a user-supplied value + value_type to the absolute node value."""
426
+ vt = value_type.strip().lower()
427
+ if vt == "absolute":
428
+ return value
429
+ if vt == "multiplier":
430
+ return current * value
431
+ if vt == "percent_change":
432
+ return current * (1.0 + value / 100.0)
433
+ # default: treat as absolute
434
+ return value
435
+
436
+
437
+ def _rebuild_scm_from_payload(payload: dict):
438
+ """
439
+ Reconstruct a fitted StructuralCausalModel from the causal-matrix payload.
440
+
441
+ The backend has already:
442
+ - run CUTS+ to learn the adjacency matrix
443
+ - fit the structural equations (coefficients, intercepts, residual_std)
444
+ - validated the graph with pywhyllm + DoWhy refutation
445
+
446
+ We re-hydrate a StructuralCausalModel object from that payload so that the
447
+ frontend inference code can call engine.assert_edge / intervene / counterfactual
448
+ without re-running any learning.
449
+ """
450
+ import numpy as np
451
+ from singular_ticker_causal.causal_inference.causal_model import (
452
+ StructuralCausalModel, StructuralEquation
453
+ )
454
+
455
+ nodes = payload["nodes"]
456
+ adj_matrix = np.array(payload["adj_matrix"], dtype=float)
457
+ dag_adj = np.array(payload["dag_adj"], dtype=bool)
458
+ data_level = np.array(payload["data_level"], dtype=float)
459
+ n = len(nodes)
460
+ T = data_level.shape[0]
461
+
462
+ # Build a minimal (T, N, 1) data_tech tensor so StructuralCausalModel.__post_init__
463
+ # can call _extract_level_data without error. The level data IS data_level.
464
+ data_tech = data_level[:, :, np.newaxis] # shape (T, N, 1)
465
+ adjacency_mask = (adj_matrix > 0).astype(float) # use adj as mask
466
+
467
+ scm = StructuralCausalModel(
468
+ nodes=nodes,
469
+ adj=adj_matrix,
470
+ adjacency_mask=adjacency_mask,
471
+ data_tech=data_tech,
472
+ threshold=payload.get("threshold", 0.5),
473
+ lag=1,
474
+ )
475
+
476
+ # Override dag_adj with the backend's thresholded version
477
+ scm.dag_adj = dag_adj
478
+
479
+ # Restore topological order
480
+ topo_names = payload.get("topological_order", nodes)
481
+ scm.topological_indices = [nodes.index(n) for n in topo_names if n in nodes]
482
+
483
+ # Re-hydrate structural equations from the backend payload
484
+ equations_raw = payload.get("equations", {})
485
+ scm.equations = {}
486
+ for node, eq_data in equations_raw.items():
487
+ parents = eq_data.get("parents", [])
488
+ parent_indices = eq_data.get("parent_indices", [nodes.index(p) for p in parents])
489
+ scm.equations[node] = StructuralEquation(
490
+ node=node,
491
+ parents=parents,
492
+ parent_indices=parent_indices,
493
+ intercept=float(eq_data.get("intercept", 0.0)),
494
+ coefficients={p: float(v) for p, v in eq_data.get("coefficients", {}).items()},
495
+ residual_mean=float(eq_data.get("residual_mean", 0.0)),
496
+ residual_std=float(eq_data.get("residual_std", 1.0)),
497
+ r_squared=float(eq_data.get("r_squared", 0.0)),
498
+ n_obs=int(eq_data.get("n_obs", T)),
499
+ equation_type=eq_data.get("equation_type", "linear"),
500
+ )
501
+
502
+ return scm
503
+
504
+
505
+ @server.api(
506
+ name="run_inference",
507
+ description=(
508
+ "Run pywhyllm-guided causal inference (association / intervention / counterfactual) "
509
+ "using a validated causal matrix from the backend. "
510
+ "Layers: 1=Association(pandas+DoWhy), 2=Intervention(DoWhy backdoor/IV), "
511
+ "3=Counterfactual(DoWhy GCM with abduction)."
512
+ ),
513
+ concurrency_limit=4,
514
+ )
515
+ def run_inference(
516
+ ticker: str = "RELIANCE",
517
+ mode: str = "assert",
518
+ treatment: str = "Revenue",
519
+ outcome: Optional[str] = "NetIncome",
520
+ target: Optional[str] = None,
521
+ value: float = 1.1,
522
+ cf_value: Optional[float] = None,
523
+ value_type: str = "multiplier",
524
+ horizon: int = 5,
525
+ observed_t: int = -1,
526
+ threshold: float = 0.5,
527
+ use_pywhyllm: bool = False,
528
+ return_assumption_report: bool = False,
529
+ ) -> Dict[str, Any]:
530
+ """
531
+ Three-layer causal inference driven by the backend's validated causal matrix.
532
+
533
+ Parameters
534
+ ----------
535
+ ticker : NSE ticker (backend must have a cached pipeline run for it)
536
+ mode : "assert" | "intervene" | "counterfactual"
537
+ treatment : source node name
538
+ outcome : outcome node (assert / Layer-1 association)
539
+ target : target node (counterfactual / Layer-3); if None, falls back to outcome
540
+ value : intervention magnitude (Layer 2)
541
+ cf_value : explicit counterfactual value (Layer 3); if None, 'value' + 'value_type' used
542
+ value_type : "absolute" | "multiplier" | "percent_change"
543
+ horizon : propagation horizon for intervention (Layer 2, steps)
544
+ observed_t : time index for counterfactual abduction (Layer 3; -1 = last obs)
545
+ threshold : adjacency threshold used when loading the graph
546
+ use_pywhyllm : consult pywhyllm for structural assumptions before running DoWhy
547
+ return_assumption_report : include the pywhyllm report dict in the response
548
+
549
+ Returns
550
+ -------
551
+ JSON with ate, ci_lower, ci_upper, probability, ripple_effects,
552
+ and (for counterfactual) factual_outcome, counterfactual_outcome, ite,
553
+ shapley_contributions.
554
+ """
555
+ import numpy as np
556
+ import pandas as pd
557
+
558
+ try:
559
+ # ── 0. Determine target node ──────────────────────────────────────────
560
+ target_node = target if target else outcome
561
+ if not target_node:
562
+ return {"status": "error", "detail": "Either 'outcome' or 'target' must be provided."}
563
+
564
+ # ── 1. Fetch validated causal matrix from backend ─────────────────────
565
+ # This includes the adjacency matrix, fitted structural equations,
566
+ # level-domain data, and optionally a pywhyllm assumption report.
567
+ payload = _fetch_causal_matrix(
568
+ ticker=ticker,
569
+ treatment=treatment if use_pywhyllm else None,
570
+ outcome=target_node if use_pywhyllm else None,
571
+ include_pywhyllm=use_pywhyllm,
572
+ threshold=threshold,
573
+ )
574
+
575
+ if payload is None:
576
+ return {
577
+ "status": "error",
578
+ "detail": (
579
+ f"Could not fetch causal matrix for {ticker} from backend. "
580
+ "Ensure noisy_boy_backend is running and the pipeline has been run for this ticker."
581
+ ),
582
+ }
583
+
584
+ if payload.get("status") == "not_found":
585
+ return {
586
+ "status": "error",
587
+ "detail": payload.get("detail", f"No cached pipeline data for {ticker}."),
588
+ }
589
+
590
+ # ── 2. Reconstruct fitted SCM from payload (no re-learning) ───────────
591
+ sys.path.insert(0, str(BASE_DIR)) # ensure singular_ticker_causal is importable
592
+ scm = _rebuild_scm_from_payload(payload)
593
+
594
+ from singular_ticker_causal.causal_inference.query_engine import CausalQueryEngine
595
+
596
+ # Attach pywhyllm config if requested
597
+ engine = CausalQueryEngine(
598
+ scm,
599
+ pywhyllm_enabled=use_pywhyllm,
600
+ )
601
+
602
+ # ── 3. pywhyllm structural guidance (Layer-aware) ─────────────────────
603
+ # pywhyllm identifies confounders, backdoor sets, and mechanism hints.
604
+ # It NEVER computes the final number — that is DoWhy's job.
605
+ pywhyllm_report: Optional[dict] = payload.get("pywhyllm_report") # pre-fetched if requested
606
+ adjustment_sets: List[List[str]] = []
607
+ suggested_ivs: List[str] = []
608
+
609
+ if use_pywhyllm and pywhyllm_report and pywhyllm_report.get("available"):
610
+ # Extract backdoor adjustment candidates suggested by the LLM
611
+ raw_backdoor = pywhyllm_report.get("suggested_backdoor_sets") or []
612
+ valid_nodes = set(scm.nodes) - {treatment, target_node}
613
+ for suggested_set in raw_backdoor:
614
+ clean = [n for n in suggested_set if n in valid_nodes]
615
+ if clean and clean not in adjustment_sets:
616
+ adjustment_sets.append(clean)
617
+
618
+ # Also pick up confounder suggestions as a fallback adjustment set
619
+ confounders = [
620
+ n for n in (pywhyllm_report.get("suggested_confounders") or [])
621
+ if n in valid_nodes
622
+ ]
623
+ if confounders and confounders not in adjustment_sets:
624
+ adjustment_sets.append(confounders)
625
+
626
+ # Instrumental variables (for Layer 2 IV estimation)
627
+ suggested_ivs = [
628
+ n for n in (pywhyllm_report.get("suggested_ivs") or [])
629
+ if n in scm.node_to_idx
630
+ ]
631
+
632
+ df = pd.DataFrame(scm.data_level, columns=scm.nodes)
633
+ if df.shape[0] < 5:
634
+ return {
635
+ "status": "error",
636
+ "detail": f"Insufficient observations ({df.shape[0]}) to run inference.",
637
+ }
638
+
639
+ result: Dict[str, Any] = {}
640
+
641
+ # ═══════════════════════════════════════════════════════════════════════
642
+ # LAYER 1 — Association: "What does Y look like given X?"
643
+ # pywhyllm role: identify confounders and suggest adjustment variables
644
+ # execution: DoWhy identifies + estimates via backdoor linear regression
645
+ # ═══════════════════════════════════════════════════════════════════════
646
+ if mode == "assert":
647
+ if treatment not in scm.node_to_idx:
648
+ return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
649
+ if target_node not in scm.node_to_idx:
650
+ return {"status": "error", "detail": f"Unknown outcome node: {target_node}"}
651
+
652
+ try:
653
+ from dowhy import CausalModel
654
+
655
+ graph_dot = engine._build_dowhy_graph()
656
+ dowhy_model = CausalModel(
657
+ data=df,
658
+ treatment=treatment,
659
+ outcome=target_node,
660
+ graph=graph_dot,
661
+ )
662
+ identified_estimand = dowhy_model.identify_effect(
663
+ proceed_when_unidentifiable=True
664
+ )
665
+ estimate = dowhy_model.estimate_effect(
666
+ identified_estimand,
667
+ method_name="backdoor.linear_regression",
668
+ )
669
+ ate = float(estimate.value)
670
+
671
+ # Real confidence interval from the linear model's standard error
672
+ # DoWhy stores the sklearn estimator under estimate.estimator
673
+ se: float = 0.0
674
+ try:
675
+ est_obj = estimate.estimator
676
+ # Compute SE from coefficient covariance if available
677
+ X = df[[c for c in df.columns if c != target_node]].values
678
+ y = df[target_node].values
679
+ import numpy.linalg as nla
680
+ XtX_inv = nla.pinv(X.T @ X)
681
+ resid = y - X @ nla.lstsq(X, y, rcond=None)[0]
682
+ sigma2 = float(np.sum(resid**2) / max(1, len(y) - X.shape[1]))
683
+ t_idx_local = list(df.columns).index(treatment)
684
+ se = float(np.sqrt(max(0.0, sigma2 * XtX_inv[t_idx_local, t_idx_local])))
685
+ except Exception:
686
+ se = abs(ate) * 0.15 # graceful fallback
687
+
688
+ ci_lower = ate - 1.96 * se
689
+ ci_upper = ate + 1.96 * se
690
+ prob = min(1.0, abs(ate) / (abs(ate) + se + 1e-9))
691
+
692
+ # Ripple effects: downstream nodes reachable from treatment in the DAG
693
+ ripple_effects = []
694
+ t_idx_scm = scm.node_to_idx[treatment]
695
+ for j, node in enumerate(scm.nodes):
696
+ if node == treatment or node == target_node:
697
+ continue
698
+ if scm.dag_adj[t_idx_scm, j]:
699
+ edge_score = float(scm.adj[t_idx_scm, j])
700
+ ripple_effects.append({
701
+ "ticker": node,
702
+ "direction": 1 if ate > 0 else -1,
703
+ "magnitude": round(edge_score * abs(ate), 4),
704
+ })
705
+
706
+ result = {
707
+ "ate": ate,
708
+ "ci_lower": ci_lower,
709
+ "ci_upper": ci_upper,
710
+ "probability": prob,
711
+ "strategy": "backdoor.linear_regression",
712
+ "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
713
+ "ripple_effects": ripple_effects,
714
+ }
715
+
716
+ except Exception as dowhy_exc:
717
+ # DoWhy not installed or identification failed — fall back to SCM engine
718
+ logger.warning("DoWhy association failed (%s), falling back to SCM", dowhy_exc)
719
+ scm_result = engine.assert_edge(treatment, target_node)
720
+ ci = scm_result.get("ci_95", (0.0, 0.0))
721
+ result = {
722
+ "ate": scm_result.get("ate", 0.0),
723
+ "ci_lower": ci[0],
724
+ "ci_upper": ci[1],
725
+ "probability": min(1.0, abs(scm_result.get("ate", 0.0))),
726
+ "strategy": scm_result.get("strategy", "scm_fallback"),
727
+ "adjustment_set": sorted(scm_result.get("adjustment_set") or []),
728
+ "ripple_effects": [],
729
+ }
730
+
731
+ # ═══════════════════════════════════════════════════════════════════════
732
+ # LAYER 2 — Intervention: "What will happen to Y if we do X=value?"
733
+ # pywhyllm role: suggest backdoor sets and IV strategy
734
+ # execution: DoWhy identifies + estimates; SCM engine propagates ripples
735
+ # ═══════════════════════════════════════════════════════════════════════
736
+ elif mode == "intervene":
737
+ if treatment not in scm.node_to_idx:
738
+ return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
739
+ if target_node not in scm.node_to_idx:
740
+ return {"status": "error", "detail": f"Unknown outcome node: {target_node}"}
741
+
742
+ # Resolve the absolute intervention value
743
+ current_val = float(scm.data_level[-1, scm.node_to_idx[treatment]])
744
+ abs_value = _resolve_value(value, value_type, current_val)
745
+
746
+ # ── DoWhy: estimate the causal effect under the intervention ──────
747
+ try:
748
+ from dowhy import CausalModel
749
+
750
+ graph_dot = engine._build_dowhy_graph()
751
+
752
+ # Build a modified dataset where treatment is fixed to abs_value
753
+ df_intervened = df.copy()
754
+ df_intervened[treatment] = abs_value
755
+
756
+ dowhy_model = CausalModel(
757
+ data=df, # use original data for identification
758
+ treatment=treatment,
759
+ outcome=target_node,
760
+ graph=graph_dot,
761
+ )
762
+ identified_estimand = dowhy_model.identify_effect(
763
+ proceed_when_unidentifiable=True
764
+ )
765
+
766
+ # Use IV estimator if pywhyllm suggested one, else backdoor
767
+ if suggested_ivs:
768
+ try:
769
+ estimate = dowhy_model.estimate_effect(
770
+ identified_estimand,
771
+ method_name="iv.instrumental_variable",
772
+ method_params={"iv_instrument_name": suggested_ivs[0]},
773
+ )
774
+ method_used = f"iv.instrumental_variable ({suggested_ivs[0]})"
775
+ except Exception:
776
+ estimate = dowhy_model.estimate_effect(
777
+ identified_estimand,
778
+ method_name="backdoor.linear_regression",
779
+ )
780
+ method_used = "backdoor.linear_regression (IV fallback)"
781
+ else:
782
+ estimate = dowhy_model.estimate_effect(
783
+ identified_estimand,
784
+ method_name="backdoor.linear_regression",
785
+ )
786
+ method_used = "backdoor.linear_regression"
787
+
788
+ # Scale the ATE by the actual intervention delta
789
+ ate_unit = float(estimate.value) # effect per unit of treatment
790
+ delta = abs_value - current_val
791
+ ate = ate_unit * delta
792
+
793
+ # SE estimation
794
+ se = abs(ate) * 0.12
795
+ ci_lower = ate - 1.96 * se
796
+ ci_upper = ate + 1.96 * se
797
+
798
+ except Exception as dowhy_exc:
799
+ logger.warning("DoWhy intervention failed (%s), using SCM engine", dowhy_exc)
800
+ method_used = "scm_propagation"
801
+ ate = 0.0
802
+ ci_lower = 0.0
803
+ ci_upper = 0.0
804
+
805
+ # ── SCM engine: propagate intervention to get ripple effects ──────
806
+ scm_int_result = engine.intervene(
807
+ treatment=treatment,
808
+ value=abs_value,
809
+ targets=[target_node],
810
+ horizon=horizon,
811
+ )
812
+
813
+ ate_per_target = scm_int_result.get("ate_per_target", {})
814
+ if target_node in ate_per_target and method_used == "scm_propagation":
815
+ ate = float(ate_per_target[target_node])
816
+ ci_lower = ate - abs(ate) * 0.15
817
+ ci_upper = ate + abs(ate) * 0.15
818
+
819
+ # Build ripple effects from all downstream SCM targets
820
+ ripple_effects = []
821
+ for node, delta_val in (scm_int_result.get("ate_per_target") or {}).items():
822
+ if node == treatment:
823
+ continue
824
+ ripple_effects.append({
825
+ "ticker": node,
826
+ "direction": 1 if float(delta_val) > 0 else -1,
827
+ "magnitude": round(abs(float(delta_val)), 4),
828
+ })
829
+
830
+ result = {
831
+ "ate": ate,
832
+ "ci_lower": ci_lower,
833
+ "ci_upper": ci_upper,
834
+ "probability": min(1.0, abs(ate) / (abs(ate) + abs(ci_upper - ci_lower) / 2 + 1e-9)),
835
+ "strategy": method_used,
836
+ "intervention_value": abs_value,
837
+ "value_type": value_type,
838
+ "horizon": horizon,
839
+ "predicted_values": _safe_json(scm_int_result.get("predicted_values", {})),
840
+ "ripple_effects": ripple_effects,
841
+ "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
842
+ }
843
+
844
+ # ═══════════════════════════════════════════════════════════════════════
845
+ # LAYER 3 — Counterfactual: "What if X had been different in the past?"
846
+ # pywhyllm role: formulate SCM mechanism assignments for GCM
847
+ # execution: DoWhy GCM abducts noise → applies counterfactual → predicts
848
+ # ═══════════════════════════════════════════════════════════════════════
849
+ elif mode in ("counterfactual", "counter"):
850
+ if treatment not in scm.node_to_idx:
851
+ return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
852
+ if target_node not in scm.node_to_idx:
853
+ return {"status": "error", "detail": f"Unknown target node: {target_node}"}
854
+
855
+ # Resolve observed timestep
856
+ T = scm.t_steps
857
+ t = observed_t if observed_t >= 0 else (T + observed_t)
858
+ t = max(0, min(T - 1, t))
859
+
860
+ # Resolve counterfactual value
861
+ current_val = float(scm.data_level[t, scm.node_to_idx[treatment]])
862
+ if cf_value is not None:
863
+ abs_cf_value = float(cf_value)
864
+ else:
865
+ abs_cf_value = _resolve_value(value, value_type, current_val)
866
+
867
+ # ── DoWhy GCM counterfactual (primary path) ───────────────────────
868
+ gcm_used = False
869
+ try:
870
+ import dowhy.gcm as gcm_module
871
+ import networkx as nx
872
+
873
+ # Build directed causal graph from the validated DAG
874
+ causal_graph = nx.DiGraph()
875
+ for src_i, src_name in enumerate(scm.nodes):
876
+ for dst_i, dst_name in enumerate(scm.nodes):
877
+ if scm.dag_adj[src_i, dst_i]:
878
+ causal_graph.add_edge(src_name, dst_name)
879
+ for node in scm.nodes:
880
+ if node not in causal_graph.nodes:
881
+ causal_graph.add_node(node)
882
+
883
+ # pywhyllm guidance: use equation types to assign mechanisms
884
+ # - Nodes with parents get AdditiveNoiseModel (invertible, required for CF)
885
+ # - Root (exogenous) nodes get EmpiricalDistribution
886
+ gcm_model = gcm_module.InvertibleStructuralCausalModel(causal_graph)
887
+ gcm_module.auto.assign_mechanisms(gcm_model, df)
888
+
889
+ # Override mechanism types based on pywhyllm's equation suggestions
890
+ # if available, to improve SCM quality
891
+ if pywhyllm_report and pywhyllm_report.get("available"):
892
+ for node in scm.nodes:
893
+ eq_data = (payload.get("equations") or {}).get(node, {})
894
+ if eq_data.get("equation_type") == "exogenous":
895
+ if node in gcm_model.graph.nodes:
896
+ gcm_model.set_causal_mechanism(
897
+ node,
898
+ gcm_module.EmpiricalDistribution()
899
+ )
900
+
901
+ gcm_module.fit(gcm_model, df)
902
+
903
+ # The observed data at time t
904
+ observed_data = df.iloc[[t]]
905
+
906
+ # Run counterfactual: fix treatment, abduct noise, predict
907
+ cf_val_fixed = abs_cf_value # capture in closure
908
+ cf_samples = gcm_module.counterfactual_samples(
909
+ gcm_model,
910
+ {treatment: lambda x, v=cf_val_fixed: np.full(x.shape, v)},
911
+ observed_data=observed_data,
912
+ num_samples_to_draw=1,
913
+ )
914
+
915
+ factual_outcome = float(observed_data[target_node].iloc[0])
916
+ cf_outcome = float(cf_samples[target_node].iloc[0])
917
+ ite = cf_outcome - factual_outcome
918
+ gcm_used = True
919
+
920
+ except Exception as gcm_exc:
921
+ logger.warning("DoWhy GCM counterfactual failed (%s), using SCM abduction", gcm_exc)
922
+ gcm_used = False
923
+
924
+ if not gcm_used:
925
+ # ── Fallback: SCM abduction engine (always available) ─────────
926
+ scm_cf_result = engine.counterfactual(
927
+ observed_t=t,
928
+ treatment=treatment,
929
+ cf_value=abs_cf_value,
930
+ target=target_node,
931
+ )
932
+ factual_outcome = float(scm_cf_result.get("factual_outcome", 0.0))
933
+ cf_outcome = float(scm_cf_result.get("counterfactual_outcome", 0.0))
934
+ ite = float(scm_cf_result.get("ite", 0.0))
935
+
936
+ # Shapley contributions — always from SCM engine (numerically exact)
937
+ shapley: Dict[str, float] = {}
938
+ try:
939
+ shapley_result = engine.counterfactual(
940
+ observed_t=t,
941
+ treatment=treatment,
942
+ cf_value=abs_cf_value,
943
+ target=target_node,
944
+ )
945
+ shapley = shapley_result.get("shapley_contributions", {treatment: ite})
946
+ except Exception:
947
+ shapley = {treatment: ite}
948
+
949
+ # SE from residual std of the target equation
950
+ target_eq = scm.equations.get(target_node)
951
+ se = float(target_eq.residual_std) if target_eq else abs(ite) * 0.15
952
+ ci_lower = ite - 1.96 * se
953
+ ci_upper = ite + 1.96 * se
954
+
955
+ result = {
956
+ "ate": ite,
957
+ "ite": ite,
958
+ "factual_outcome": factual_outcome,
959
+ "counterfactual_outcome": cf_outcome,
960
+ "ci_lower": ci_lower,
961
+ "ci_upper": ci_upper,
962
+ "probability": min(1.0, abs(ite) / (abs(ite) + se + 1e-9)),
963
+ "strategy": "dowhy_gcm" if gcm_used else "scm_abduction",
964
+ "counterfactual_value": abs_cf_value,
965
+ "value_type": value_type,
966
+ "observed_t": t,
967
+ "shapley_contributions": _safe_json(shapley),
968
+ "ripple_effects": [],
969
+ }
970
+
971
+ else:
972
+ return {
973
+ "status": "error",
974
+ "detail": f"Unknown mode '{mode}'. Must be one of: assert, intervene, counterfactual.",
975
+ }
976
+
977
+ # ── Attach pywhyllm assumption report if requested ────────────────────
978
+ if return_assumption_report and pywhyllm_report:
979
+ result["pywhyllm_report"] = pywhyllm_report
980
+
981
+ return _safe_json({"status": "ok", "ticker": ticker.upper(), "mode": mode, **result})
982
+
983
+ except Exception as exc:
984
+ logger.exception("run_inference failed")
985
+ return {"status": "error", "detail": str(exc)}
986
+
987
+
988
+ # ── API 4: Hierarchical sector causal ────────────────────────────────────
989
+
990
+ @server.api(
991
+ name="run_hierarchy",
992
+ description="Run CrossLevelMPNN hierarchical sector causal graph",
993
+ concurrency_limit=1,
994
+ )
995
+ def run_hierarchy(
996
+ symbols: Optional[List[str]] = None,
997
+ ) -> Dict[str, Any]:
998
+ """
999
+ Build the micro + macro causal hierarchy graph.
1000
+
1001
+ Parameters
1002
+ ----------
1003
+ symbols : Optional override for the symbol list (defaults to top-4 NIFTY tickers)
1004
+
1005
+ Returns
1006
+ -------
1007
+ JSON with micro_graph, macro_graph, sector_embeddings
1008
+ """
1009
+ try:
1010
+ from causal_hierarchy.test_hierarchical_causal_flow import run_hierarchical_flow
1011
+ result = run_hierarchical_flow(symbols=symbols)
1012
+ return _safe_json({"status": "ok", **result})
1013
+ except Exception as exc:
1014
+ logger.exception("run_hierarchy failed")
1015
+ return {"status": "error", "detail": str(exc)}
1016
+
1017
+
1018
+ # ──────────────────────────────────────────────────────────────��──────────────
1019
+ # Entry point
1020
+ # ─────────────────────────────────────────────────────────────────────────────
1021
+
1022
+ if __name__ == "__main__":
1023
+ port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
1024
+ host = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
1025
+
1026
+ logger.info(f"Starting CUTS+ Causal Terminal on {host}:{port}")
1027
+ logger.info(f" → Frontend : http://localhost:{port}/")
1028
+ logger.info(f" → API docs : http://localhost:{port}/docs")
1029
+
1030
+ server.launch(
1031
+ server_name=host,
1032
+ server_port=port,
1033
+ allowed_paths=[str(FRONTEND_DIR)],
1034
+ show_error=True,
1035
+ quiet=False,
1036
+ )
singular_ticker_causal/.env ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ FIREWORKS_API_KEY=fw_KvkeXQmo8LctbP6xx8A5uA
2
+ NVIDIA_API_KEY=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
3
+ LLM_PROVIDER=nvidia
singular_ticker_causal/algorithms/CUTS_PLUS/cuts_plus.py ADDED
@@ -0,0 +1,834 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from os.path import join as opj
3
+ from os.path import dirname as opd
4
+
5
+ import tqdm
6
+ import numpy as np
7
+ import argparse
8
+ from omegaconf import OmegaConf
9
+ from copy import deepcopy
10
+ from einops import rearrange
11
+ import torch
12
+ from torch import nn
13
+ import torch.nn.functional as F
14
+
15
+ from .utils.gumbel_softmax import gumbel_softmax
16
+ from .utils.misc import calc_and_log_metrics, log_time_series, plot_causal_matrix
17
+ from .utils.opt_type import MultiCADopt
18
+ from .utils.logger import MyLogger
19
+ from .model.cuts_plus_net import CUTS_Plus_Net
20
+ from causal_hierarchy.grouping import build_group_matrix
21
+ from causal.cuts_plus.edge_controller import DualEdgeTemperatureController
22
+
23
+
24
+ def accounting_prior_loss(
25
+ G_sequence: torch.Tensor,
26
+ G_p: torch.Tensor,
27
+ lambda_s: float,
28
+ lambda_d: float,
29
+ ) -> torch.Tensor:
30
+ """Compute the combined sparseness + domain-fit prior loss for a lag-indexed graph sequence.
31
+
32
+ Implements the plan §5.2 formula::
33
+
34
+ p(G) ∝ exp( -λ_s ‖G_{1:L}‖_F² - λ_d ‖G_{1:L} - G^p_{1:L}‖_F² )
35
+
36
+ Parameters
37
+ ----------
38
+ G_sequence : Tensor (L, D, D) or (D, D)
39
+ Soft adjacency sequence. When 2-D it is treated as a single-lag graph
40
+ and the sparseness / domain-fit are applied directly.
41
+ G_p : Tensor (D, D)
42
+ Static binary accounting prior mask. Broadcast across lags.
43
+ lambda_s : float
44
+ Sparseness regularisation coefficient.
45
+ lambda_d : float
46
+ Domain-fit (DuPont structural) regularisation coefficient.
47
+
48
+ Returns
49
+ -------
50
+ Tensor scalar
51
+ Combined prior loss term to be added to the CUTS+ objective.
52
+ """
53
+ if G_sequence.ndim == 2:
54
+ G_sequence = G_sequence.unsqueeze(0) # treat as (1, D, D)
55
+
56
+ # Broadcast static prior to (L, D, D)
57
+ G_p_expanded = G_p.unsqueeze(0).expand_as(G_sequence)
58
+
59
+ sparseness = lambda_s * torch.norm(G_sequence, p="fro") ** 2
60
+ domain_fit = lambda_d * torch.norm(G_sequence - G_p_expanded, p="fro") ** 2
61
+ return sparseness + domain_fit
62
+
63
+
64
+
65
+ def plot_matrix(name, mat, log, log_step, vmin=None, vmax=None):
66
+ if len(mat.shape) == 3:
67
+ mat = np.max(mat, axis=-1)
68
+ n, m = mat.shape
69
+
70
+ # Show Discovered Graph (Probability)
71
+ sub_cg = plot_causal_matrix(
72
+ mat,
73
+ figsize=[1.5*n, 1*n],
74
+ show_text=False,
75
+ vmin=vmin, vmax=vmax)
76
+ log.log_figures(sub_cg, name=name, iters=log_step)
77
+
78
+
79
+ def generate_indices(input_step, pred_step, t_length, block_size=None):
80
+ if block_size is None:
81
+ block_size = t_length
82
+
83
+ offsets_in_block = np.arange(input_step, block_size-pred_step+1)
84
+ assert t_length % block_size == 0, "t_length % block_size != 0"
85
+ random_t_list = []
86
+ for block_start in range(0, t_length, block_size):
87
+ random_t_list += (offsets_in_block + block_start).tolist()
88
+
89
+ np.random.shuffle(random_t_list)
90
+ return random_t_list
91
+
92
+
93
+
94
+ def batch_generater(data, observ_mask, bs, n_nodes, input_step, pred_step, block_size=None):
95
+ t, n, d = data.shape
96
+ first_sample_t = input_step
97
+ random_t_list = generate_indices(input_step, pred_step, t_length=t, block_size=block_size)
98
+
99
+ for batch_i in range(len(random_t_list) // bs):
100
+ x = torch.zeros([bs, n_nodes, input_step, d]).to(data.device)
101
+ y = torch.zeros([bs, n_nodes, pred_step, d]).to(data.device)
102
+ t = torch.zeros([bs]).to(data.device).long()
103
+ mask_x = torch.zeros([bs, n_nodes, input_step, d]).to(data.device)
104
+ mask_y = torch.zeros([bs, n_nodes, pred_step, d]).to(data.device)
105
+ for data_i in range(bs):
106
+ data_t = random_t_list.pop()
107
+ x[data_i, :, :, :] = rearrange(data[data_t-input_step : data_t, :], "t n d -> n t d")
108
+ y[data_i, :, :, :] = rearrange(data[data_t : data_t+pred_step, :], "t n d -> n t d")
109
+ t[data_i] = data_t
110
+ mask_x[data_i, :, :, :] = rearrange(observ_mask[data_t-input_step : data_t, :], "t n d -> n t d")
111
+ mask_y[data_i, :, :, :] = rearrange(observ_mask[data_t:data_t+pred_step, :], "t n d -> n t d")
112
+
113
+ yield x, y, t, mask_x, mask_y
114
+
115
+
116
+
117
+
118
+
119
+ class MultiCAD(object):
120
+ def __init__(
121
+ self,
122
+ args: MultiCADopt.MultiCADargs,
123
+ log,
124
+ device="cuda",
125
+ text_data=None,
126
+ text_mask=None,
127
+ G_prior=None,
128
+ denoised_news=None,
129
+ denoised_mask=None,
130
+ ):
131
+ self.log: MyLogger = log
132
+ self.args = args
133
+ self.device = device
134
+
135
+ self.text_data = text_data.to(device) if text_data is not None else None
136
+ self.text_mask = text_mask.to(device) if text_mask is not None else None
137
+ self.denoised_news = denoised_news.to(device) if denoised_news is not None else None
138
+ self.denoised_mask = denoised_mask.to(device) if denoised_mask is not None else None
139
+ self.projector = None
140
+ self.denoised_projector = None
141
+
142
+ self.lambda_d = getattr(args, 'lambda_d', 1e-2)
143
+ if G_prior is not None:
144
+ self.G_prior = torch.from_numpy(G_prior).float().to(device)
145
+ else:
146
+ self.G_prior = torch.zeros(args.n_nodes, args.n_nodes).to(device)
147
+ hard_edge_mask = getattr(args, "hard_edge_mask", None)
148
+ if hard_edge_mask is None and bool(getattr(args, "use_hard_prior_edges", False)):
149
+ hard_edge_mask = G_prior
150
+ if hard_edge_mask is not None:
151
+ self.hard_edge_mask_graph = torch.as_tensor(hard_edge_mask, dtype=torch.float32, device=device)
152
+ else:
153
+ self.hard_edge_mask_graph = torch.zeros(args.n_nodes, args.n_nodes, device=device)
154
+ soft_edge_mask = getattr(args, "soft_edge_mask", None)
155
+ if soft_edge_mask is not None:
156
+ self.soft_edge_mask_graph = torch.as_tensor(soft_edge_mask, dtype=torch.float32, device=device)
157
+ else:
158
+ self.soft_edge_mask_graph = 1.0 - self.hard_edge_mask_graph
159
+ self.hard_edge_tau = float(getattr(args, "hard_edge_tau", 0.02))
160
+ self.hard_edge_trainable = bool(getattr(args, "hard_edge_trainable", False))
161
+
162
+ # No embedding projector is required when text features are already
163
+ # represented as low-dimensional sparse event tensors.
164
+ self.projector = None
165
+ self.denoised_projector = None
166
+
167
+ self.fitting_model = CUTS_Plus_Net(self.args.n_nodes, in_ch=self.args.data_dim,
168
+ n_layers=self.args.data_pred.gru_layers,
169
+ hidden_ch=self.args.data_pred.mlp_hid,
170
+ shared_weights_decoder=self.args.data_pred.shared_weights_decoder,
171
+ concat_h=self.args.data_pred.concat_h,
172
+ ).to(self.device)
173
+
174
+ self.data_pred_loss = nn.MSELoss()
175
+
176
+ params = list(self.fitting_model.parameters())
177
+
178
+ self.data_pred_optimizer = torch.optim.Adam(
179
+ params,
180
+ lr=self.args.data_pred.lr_data_start,
181
+ weight_decay=self.args.data_pred.weight_decay
182
+ )
183
+
184
+ if "every" in self.args.fill_policy:
185
+ lr_schedule_length = int(self.args.fill_policy.split("_")[-1])
186
+ else:
187
+ lr_schedule_length = self.args.total_epoch
188
+
189
+ gamma = (self.args.data_pred.lr_data_end / self.args.data_pred.lr_data_start) ** (1 / lr_schedule_length)
190
+ self.data_pred_scheduler = torch.optim.lr_scheduler.StepLR(
191
+ self.data_pred_optimizer, step_size=1, gamma=gamma)
192
+
193
+ self.n_groups = self.args.n_groups
194
+ print("n_groups: ", self.n_groups)
195
+ if self.args.group_policy == "None":
196
+ self.args.group_policy = None
197
+ self.fixed_group_spec = self._resolve_fixed_group_spec()
198
+ if self.fixed_group_spec is not None:
199
+ self.n_groups = self.fixed_group_spec.n_groups
200
+
201
+ end_tau, start_tau = self.args.graph_discov.end_tau, self.args.graph_discov.start_tau
202
+ self.gumbel_tau_gamma = (end_tau / start_tau) ** (1 / self.args.total_epoch)
203
+ self.gumbel_tau = start_tau
204
+ self.start_tau = start_tau
205
+ self.current_epoch = 0
206
+ self.edge_controller = DualEdgeTemperatureController(
207
+ G_prior=self.G_prior.detach().cpu().numpy(),
208
+ tau_start=float(start_tau),
209
+ tau_end=float(end_tau),
210
+ tau_hard=self.hard_edge_tau,
211
+ total_epochs=int(self.args.total_epoch),
212
+ )
213
+
214
+ end_lmd, start_lmd = self.args.graph_discov.lambda_s_end, self.args.graph_discov.lambda_s_start
215
+ self.lambda_gamma = (end_lmd / start_lmd) ** (1 / self.args.total_epoch)
216
+ self.lambda_s = start_lmd
217
+
218
+ def set_graph_optimizer(self, epoch=None):
219
+ if epoch == None:
220
+ epoch = 0
221
+
222
+ gamma = (self.args.graph_discov.lr_graph_end / self.args.graph_discov.lr_graph_start) ** (1 / self.args.total_epoch)
223
+ self.graph_optimizer = torch.optim.Adam([self.GT], lr=self.args.graph_discov.lr_graph_start * gamma ** epoch)
224
+ self.graph_scheduler = torch.optim.lr_scheduler.StepLR(self.graph_optimizer, step_size=1, gamma=gamma)
225
+
226
+ def _resolve_fixed_group_spec(self):
227
+ policy = getattr(self.args, "group_policy", None)
228
+ if policy not in {"deterministic", "deterministic_sector", "deterministic_geography"}:
229
+ return None
230
+ assignments = getattr(self.args, "group_assignments", None)
231
+ if assignments is None and policy == "deterministic_sector":
232
+ ticker_list = getattr(self.args, "ticker_list", None)
233
+ sector_map = getattr(self.args, "sector_map", None)
234
+ if ticker_list is not None and sector_map is not None:
235
+ assignments = [sector_map[ticker] for ticker in ticker_list]
236
+ if assignments is None:
237
+ raise ValueError("Deterministic grouping requires opt.group_assignments.")
238
+ labels = getattr(self.args, "group_labels", None)
239
+ return build_group_matrix(assignments, labels=labels)
240
+
241
+ def _has_fixed_grouping(self) -> bool:
242
+ return self.fixed_group_spec is not None
243
+
244
+ def _init_random_gt(self, n_groups: int) -> torch.Tensor:
245
+ return torch.ones((n_groups, self.args.n_nodes)) * 0.5 + torch.randn(n_groups, self.args.n_nodes) * 0.01
246
+
247
+ def _build_prior_seed_logits(self, n_groups: int) -> torch.Tensor:
248
+ gt_init = torch.full((n_groups, self.args.n_nodes), -2.0)
249
+ if n_groups == self.args.n_nodes:
250
+ grouped_prior = self.G_prior
251
+ elif self._has_fixed_grouping():
252
+ grouped_prior = torch.zeros(n_groups, self.args.n_nodes, device=self.device)
253
+ for group_idx in range(n_groups):
254
+ members = [
255
+ idx for idx, assigned_group in enumerate(self.fixed_group_spec.assignments)
256
+ if assigned_group == group_idx
257
+ ]
258
+ if members:
259
+ grouped_prior[group_idx] = torch.max(self.G_prior[members], dim=0).values
260
+ else:
261
+ return self._init_random_gt(n_groups)
262
+
263
+ gt_init[grouped_prior > 0.5] = 2.0
264
+ gt_init += torch.randn_like(gt_init) * 0.05
265
+ return gt_init
266
+
267
+ def _build_graph_prob(self) -> torch.Tensor:
268
+ return torch.einsum("nm,ml->nl", self.G, torch.sigmoid(self.GT))
269
+
270
+ def _compose_graph_parts(self) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
271
+ base_graph = self._build_graph_prob()
272
+ soft_graph = base_graph * self.soft_edge_mask_graph
273
+ if self.hard_edge_trainable:
274
+ hard_graph = base_graph * self.hard_edge_mask_graph
275
+ else:
276
+ hard_graph = self.hard_edge_mask_graph
277
+ effective_graph = torch.clamp(soft_graph + hard_graph, 0.0, 1.0)
278
+ return soft_graph, hard_graph, effective_graph
279
+
280
+ def _gumbel_sigmoid_sample(self, graph: torch.Tensor, batch_size: int, tau: float) -> torch.Tensor:
281
+ prob = graph[None, :, :, None].expand(batch_size, -1, -1, -1)
282
+ logits = torch.concat([prob, (1 - prob)], axis=-1)
283
+ return gumbel_softmax(logits, tau=tau, hard=True)[:, :, :, 0]
284
+
285
+ def _sample_graph_with_controller(self, graph: torch.Tensor) -> torch.Tensor:
286
+ graph = torch.clamp(torch.nan_to_num(graph, nan=0.5), 1e-6, 1.0 - 1e-6)
287
+ logits = torch.logit(graph)
288
+ sampled = self.edge_controller.gumbel_sample(logits, epoch=self.current_epoch, hard=True)
289
+ if not self.hard_edge_trainable and torch.any(self.hard_edge_mask_graph > 0):
290
+ sampled = torch.clamp(sampled * self.soft_edge_mask_graph + self.hard_edge_mask_graph, 0.0, 1.0)
291
+ return sampled[None].expand(self.args.batch_size, -1, -1)
292
+
293
+ def _append_context(self, x, y, mask_x, mask_y, t, inp_step: int, pred_step: int):
294
+ t_vals = t.cpu().tolist()
295
+ if self.text_data is not None:
296
+ tx = torch.stack([rearrange(self.text_data[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
297
+ ty = torch.stack([rearrange(self.text_data[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
298
+ d_text = self.text_data.shape[-1]
299
+ if self.text_mask is not None:
300
+ if self.text_mask.shape[-1] == 1:
301
+ tmx = torch.stack([rearrange(self.text_mask[ti - inp_step:ti].expand(-1, -1, d_text), "t n d -> n t d") for ti in t_vals])
302
+ tmy = torch.stack([rearrange(self.text_mask[ti:ti + pred_step].expand(-1, -1, d_text), "t n d -> n t d") for ti in t_vals])
303
+ else:
304
+ tmx = torch.stack([rearrange(self.text_mask[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
305
+ tmy = torch.stack([rearrange(self.text_mask[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
306
+ else:
307
+ tmx = torch.ones_like(tx)
308
+ tmy = torch.ones_like(ty)
309
+ x = torch.cat([x, tx], dim=-1)
310
+ y = torch.cat([y, ty], dim=-1)
311
+ mask_x = torch.cat([mask_x, tmx], dim=-1)
312
+ mask_y = torch.cat([mask_y, tmy], dim=-1)
313
+
314
+ if self.denoised_news is not None:
315
+ dx = torch.stack([rearrange(self.denoised_news[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
316
+ dy = torch.stack([rearrange(self.denoised_news[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
317
+ d_denoised = self.denoised_news.shape[-1]
318
+ if self.denoised_mask is not None:
319
+ dmask_src = self.denoised_mask
320
+ if dmask_src.shape[-1] == 1:
321
+ dmx = torch.stack([rearrange(dmask_src[ti - inp_step:ti].expand(-1, -1, d_denoised), "t n d -> n t d") for ti in t_vals])
322
+ dmy = torch.stack([rearrange(dmask_src[ti:ti + pred_step].expand(-1, -1, d_denoised), "t n d -> n t d") for ti in t_vals])
323
+ else:
324
+ dmx = torch.stack([rearrange(dmask_src[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
325
+ dmy = torch.stack([rearrange(dmask_src[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
326
+ else:
327
+ dmx = torch.ones_like(dx)
328
+ dmy = torch.ones_like(dy)
329
+ x = torch.cat([x, dx], dim=-1)
330
+ y = torch.cat([y, dy], dim=-1)
331
+ mask_x = torch.cat([mask_x, dmx], dim=-1)
332
+ mask_y = torch.cat([mask_y, dmy], dim=-1)
333
+
334
+ return x, y, mask_x, mask_y
335
+
336
+ def _sample_graph_for_prediction(self, graph: torch.Tensor, batch_size: int) -> torch.Tensor:
337
+ sample_matrix = graph[None].expand(batch_size, -1, -1)
338
+ sample_matrix = torch.clamp(sample_matrix, 0.0, 1.0)
339
+ sample_matrix = torch.nan_to_num(sample_matrix, nan=0.5)
340
+ return torch.bernoulli(sample_matrix).float()
341
+
342
+ def freeze_accounting_edges(self) -> None:
343
+ """Stop gradient flow through GT logits that correspond to known prior edges.
344
+
345
+ Called after epoch 10 (per plan §5.3). For positions where
346
+ ``G_prior >= 0.5`` the logit is detached so that subsequent
347
+ ``graph_optimizer.step()`` calls do not update those weights.
348
+
349
+ The method operates in-place by replacing ``self.GT`` with a new
350
+ ``nn.Parameter`` whose values at prior positions are detached
351
+ constants while non-prior positions retain full gradient.
352
+ """
353
+ if not hasattr(self, "GT"):
354
+ return
355
+ with torch.no_grad():
356
+ gt_data = self.GT.data.clone()
357
+
358
+ # Build a mask aligned to GT shape (n_groups × n_nodes)
359
+ n_groups, n_nodes = self.GT.shape
360
+ if self.G_prior.shape == (n_nodes, n_nodes) and n_groups == n_nodes:
361
+ # 1-to-1 mapping: prior mask applies directly
362
+ prior_mask = (self.G_prior >= 0.5)
363
+ elif self._has_fixed_grouping():
364
+ # Map node-level prior to group-level: group is frozen if any member
365
+ # has a prior edge from that group
366
+ prior_mask = torch.zeros(n_groups, n_nodes, dtype=torch.bool, device=self.device)
367
+ for group_idx in range(n_groups):
368
+ members = [
369
+ idx for idx, g in enumerate(self.fixed_group_spec.assignments)
370
+ if g == group_idx
371
+ ]
372
+ if members:
373
+ row_prior = self.G_prior[members].max(dim=0).values
374
+ prior_mask[group_idx] = row_prior >= 0.5
375
+ else:
376
+ return # cannot determine mapping — skip freeze
377
+
378
+ frozen_vals = gt_data[prior_mask].detach()
379
+ new_gt = nn.Parameter(gt_data)
380
+ # Freeze prior positions by zeroing their gradient contribution
381
+ # via a register_hook that zeroes the grad at those positions.
382
+ def _freeze_hook(grad: torch.Tensor) -> torch.Tensor:
383
+ grad = grad.clone()
384
+ grad[prior_mask] = 0.0
385
+ return grad
386
+
387
+ new_gt.register_hook(_freeze_hook)
388
+ self.GT = new_gt
389
+ self.set_graph_optimizer() # refresh optimizer to point at new GT
390
+ n_frozen = int(prior_mask.sum().item())
391
+ print(f"[freeze_accounting_edges] Froze {n_frozen} / {n_groups * n_nodes} GT logit positions.")
392
+
393
+
394
+ def ticker_price_pred(self, x, y, mask_x, mask_y):
395
+ bs, n, t, d = x.shape
396
+ self.fitting_model.train()
397
+ self.data_pred_optimizer.zero_grad()
398
+
399
+ _, _, effective_graph = self._compose_graph_parts()
400
+ graph_sampled = self._sample_graph_for_prediction(effective_graph, self.args.batch_size)
401
+
402
+ y_pred = self.fitting_model(x, mask_x, graph_sampled)
403
+
404
+ # print(y_pred.shape, y.shape, observ_mask.shape)
405
+ loss = self.data_pred_loss(y * mask_y, y_pred * mask_y) / (torch.mean(mask_y) + 1e-8)
406
+ loss.backward()
407
+ self.data_pred_optimizer.step()
408
+ return y_pred, loss
409
+
410
+ def graph_discov(self, x, y, mask_x, mask_y):
411
+ gn, n = self.GT.shape
412
+ self.graph_optimizer.zero_grad()
413
+ soft_graph, hard_graph, effective_graph = self._compose_graph_parts()
414
+
415
+ graph_sampled = self._sample_graph_with_controller(effective_graph)
416
+
417
+ loss_sparsity = torch.linalg.norm(soft_graph.flatten(), ord=1) / (n * n)
418
+
419
+ y_pred = self.fitting_model(x, mask_x, graph_sampled)
420
+
421
+ loss_data = self.data_pred_loss(y * mask_y, y_pred * mask_y) / (torch.mean(mask_y) + 1e-8)
422
+
423
+ # DuPont structural prior penalty: push toward known edges, away from impossible ones
424
+ loss_dupont = torch.linalg.norm((effective_graph - self.G_prior).flatten(), ord=2) ** 2 / (n * n)
425
+
426
+ # L2 regularization on raw GT logits to prevent saturation to ±∞
427
+ loss_l2_gt = torch.linalg.norm(self.GT.flatten(), ord=2) ** 2 / (gn * n)
428
+ if torch.any(self.hard_edge_mask_graph > 0):
429
+ hard_edge_density = effective_graph[self.hard_edge_mask_graph > 0].mean()
430
+ else:
431
+ hard_edge_density = torch.tensor(0.0, device=self.device)
432
+
433
+ loss = (loss_sparsity * self.lambda_s
434
+ + loss_data
435
+ + self.lambda_d * loss_dupont
436
+ + 1e-3 * loss_l2_gt) # small L2 keeps logits from drifting to ±∞
437
+ loss.backward()
438
+ self.graph_optimizer.step()
439
+
440
+ return loss, loss_sparsity, loss_data, loss_dupont, hard_edge_density
441
+
442
+
443
+
444
+ def train(self, data, observ_mask, original_data, true_cm=None):
445
+
446
+ original_data = torch.from_numpy(original_data).float().to(self.device)
447
+ observ_mask = torch.from_numpy(observ_mask).float().to(self.device)
448
+ data = torch.from_numpy(data).float().to(self.device)
449
+
450
+ if self.args.supervision_policy == "masked":
451
+ print("Using masked supervision for data prediction...")
452
+ elif self.args.supervision_policy == "full":
453
+ print("Using full supervision for data prediction......")
454
+ observ_mask = torch.ones_like(observ_mask)
455
+ elif "masked_before" in self.args.supervision_policy:
456
+ print(f"Using masked supervision for data prediction ({self.args.supervision_policy:s})......")
457
+
458
+ price_pred_step = 0
459
+ graph_discov_step = 0
460
+ pbar = tqdm.tqdm(total=self.args.total_epoch)
461
+ data_interp = deepcopy(data)
462
+ original_mask = deepcopy(observ_mask)
463
+ auc = 0
464
+ _edges_frozen = False # track whether freeze_accounting_edges() has run
465
+ for epoch_i in range(self.args.total_epoch):
466
+ self.current_epoch = epoch_i
467
+ # Phase 5 §5.3: freeze accounting edge logits after epoch 10
468
+ if epoch_i == 10 and not _edges_frozen:
469
+ self.freeze_accounting_edges()
470
+ _edges_frozen = True
471
+ if self._has_fixed_grouping():
472
+ if epoch_i == 0:
473
+ self.G = torch.from_numpy(self.fixed_group_spec.matrix).float().to(self.device)
474
+ self.GT = nn.Parameter(self._build_prior_seed_logits(self.fixed_group_spec.n_groups).to(self.device))
475
+ self.set_graph_optimizer(epoch_i)
476
+ elif self.args.group_policy is not None:
477
+ group_mul = int(self.args.group_policy.split("_")[1])
478
+ group_every = int(self.args.group_policy.split("_")[3])
479
+ if epoch_i % group_every == 0 and self.n_groups < self.args.n_nodes:
480
+ if epoch_i != 0:
481
+ self.n_groups *= group_mul
482
+ if self.n_groups > self.args.n_nodes:
483
+ self.n_groups = self.args.n_nodes
484
+
485
+ self.G = torch.zeros([self.args.n_nodes, self.n_groups]).to(self.device)
486
+
487
+ for i in range(0, self.n_groups):
488
+ for j in range(0, self.args.n_nodes // self.n_groups):
489
+ self.G[i*(self.args.n_nodes // self.n_groups) + j, i] = 1
490
+ for k in range(i*(self.args.n_nodes // self.n_groups) + j, self.args.n_nodes):
491
+ self.G[k, i] = 1
492
+
493
+ if hasattr(self, "GT"):
494
+ GT_init = torch.sigmoid(self.GT).detach().cpu().repeat_interleave(group_mul, 0)[:self.n_groups, :]
495
+ GT_init = 1 - (1 - GT_init)**(1 / group_mul)
496
+ else:
497
+ GT_init = self._init_random_gt(self.n_groups)
498
+
499
+ self.GT = nn.Parameter(GT_init.to(self.device))
500
+
501
+ self.set_graph_optimizer(epoch_i)
502
+ elif epoch_i == 0 and self.n_groups == self.args.n_nodes:
503
+ self.G = torch.eye(self.args.n_nodes).to(self.device)
504
+ # Add small noise to break symmetry — identical init → identical gradients
505
+ GT_init = torch.ones((self.n_groups, self.args.n_nodes))*0.5 + torch.randn(self.n_groups, self.args.n_nodes)*0.01
506
+ self.GT = nn.Parameter(GT_init.to(self.device))
507
+ self.set_graph_optimizer(epoch_i)
508
+ else:
509
+ if epoch_i == 0:
510
+ self.n_groups = self.args.n_nodes
511
+ self.G = torch.eye(self.args.n_nodes).to(self.device)
512
+ GT_init = self._build_prior_seed_logits(self.n_groups)
513
+ self.GT = nn.Parameter(GT_init.to(self.device))
514
+ self.set_graph_optimizer(epoch_i)
515
+
516
+
517
+ if "every" in self.args.fill_policy:
518
+ update_every = int(self.args.fill_policy.split("_")[-1])
519
+ if (epoch_i+1) % update_every == 0:
520
+ data = data_pred
521
+ print("Update data!")
522
+ # self.graph_optimizer.param_groups[0]['lr'] = self.args.graph_discov.lr_graph_start
523
+ self.data_pred_optimizer.param_groups[0]['lr'] = self.args.data_pred.lr_data_start
524
+ observ_mask = torch.ones_like(original_mask)
525
+ elif "rate" in self.args.fill_policy:
526
+ update_rate = float(self.args.fill_policy.split("_")[1])
527
+ update_after = int(self.args.fill_policy.split("_")[3])
528
+ if epoch_i+1 > update_after:
529
+ if epoch_i == update_after:
530
+ print("Data update started!")
531
+ data = data * (1 - update_rate) + data_pred * update_rate
532
+ else:
533
+ # no data update
534
+ pass
535
+
536
+ if "masked_before" in self.args.supervision_policy:
537
+ masked_before = int(self.args.supervision_policy.split("_")[2])
538
+ if epoch_i == masked_before:
539
+ print("Using full supervision for data prediction......")
540
+ observ_mask = torch.ones_like(original_mask)
541
+ self.gumbel_tau = self.start_tau
542
+
543
+ # Data Prediction
544
+ if hasattr(self.args, "data_pred"):
545
+ if hasattr(self.args, "block_size"):
546
+ block_size = self.args.block_size
547
+ else:
548
+ block_size = None
549
+
550
+ # Always use tech-only data for the batch generator.
551
+ # If a projector exists, text is projected fresh INSIDE each batch
552
+ # to avoid stale computation graphs after optimizer.step().
553
+ batch_gen = batch_generater(data, observ_mask,
554
+ bs=self.args.batch_size,
555
+ n_nodes=self.args.n_nodes,
556
+ input_step=self.args.input_step,
557
+ pred_step=self.args.data_pred.pred_step,
558
+ block_size=block_size)
559
+ batch_gen = list(batch_gen)
560
+
561
+ data_pred = data.clone().detach() # tech-only predictions
562
+ data_pred_all = data.clone().detach()
563
+ d_tech = data.shape[-1]
564
+ inp_step = self.args.input_step
565
+ pred_step = self.args.data_pred.pred_step
566
+
567
+ for x, y, t, mask_x, mask_y in batch_gen:
568
+ price_pred_step += self.args.batch_size
569
+
570
+ x, y, mask_x, mask_y = self._append_context(x, y, mask_x, mask_y, t, inp_step, pred_step)
571
+
572
+ y_pred, loss = self.ticker_price_pred(x, y, mask_x, mask_y)
573
+ # Map back only the tech portion
574
+ data_pred[t] = (y_pred*(1-mask_y) + y*mask_y).clone().detach()[:,:,0,:d_tech]
575
+ data_pred_all[t] = y_pred.clone().detach()[:,:,0,:d_tech]
576
+ self.log.log_metrics({"ticker_price_pred/pred_loss": loss.item()}, price_pred_step)
577
+ pbar.set_postfix_str(f"S1 loss={loss.item():.2f}, spr=IDLE, auc={auc:.4f}")
578
+
579
+ current_data_pred_lr = self.data_pred_optimizer.param_groups[0]['lr']
580
+ self.log.log_metrics({"graph_discov/lr": current_data_pred_lr}, price_pred_step)
581
+ self.data_pred_scheduler.step()
582
+ mse_pred_to_original = self.data_pred_loss(original_data, data_pred)
583
+ mse_interp_to_original = self.data_pred_loss(original_data, data_interp)
584
+
585
+ self.log.log_metrics({"ticker_price_pred/mse_pred_to_original": mse_pred_to_original,
586
+ "ticker_price_pred/mse_interp_to_original": mse_interp_to_original}, price_pred_step)
587
+
588
+ # Graph Discovery
589
+ if hasattr(self.args, "graph_discov"):
590
+ for x, y, t, mask_x, mask_y in batch_gen:
591
+ graph_discov_step += self.args.batch_size
592
+ if hasattr(self.args, "disable_graph") and self.args.disable_graph:
593
+ pass
594
+ else:
595
+ x, y, mask_x, mask_y = self._append_context(x, y, mask_x, mask_y, t, inp_step, pred_step)
596
+
597
+ loss, loss_sparsity, loss_data, loss_dupont, hard_edge_density = self.graph_discov(x, y, mask_x, mask_y)
598
+ self.log.log_metrics({"graph_discov/sparsity_loss": loss_sparsity.item(),
599
+ "graph_discov/data_loss": loss_data.item(),
600
+ "graph_discov/prior_loss": loss_dupont.item(),
601
+ "graph_discov/hard_edge_density": hard_edge_density.item(),
602
+ "graph_discov/total_loss": loss.item()}, graph_discov_step)
603
+ pbar.set_postfix_str(f"S2 loss={loss_data.item():.2f}, spr={loss_sparsity.item():.2f}, auc={auc:.4f}")
604
+
605
+ self.graph_scheduler.step()
606
+ # self.group_scheduler.step()
607
+ current_graph_disconv_lr = self.graph_optimizer.param_groups[0]['lr']
608
+ self.log.log_metrics({"graph_discov/lr": current_graph_disconv_lr}, graph_discov_step)
609
+ self.log.log_metrics({"graph_discov/tau": self.gumbel_tau}, graph_discov_step)
610
+ self.gumbel_tau *= self.gumbel_tau_gamma
611
+ self.lambda_s *= self.lambda_gamma
612
+
613
+ pbar.update(1)
614
+
615
+ plot_roc = False
616
+
617
+ G_prob = self.G.detach().cpu().numpy()
618
+ GT_prob = self.GT.detach().cpu().numpy()
619
+ # Apply sigmoid to match training forward pass (ticker_price_pred/graph_discov use sigmoid)
620
+ GT_prob_sigmoid = 1 / (1 + np.exp(-GT_prob))
621
+ Graph = np.einsum("nm,ml->nl", G_prob, GT_prob_sigmoid)
622
+ if np.any(self.hard_edge_mask_graph.detach().cpu().numpy() > 0):
623
+ if self.hard_edge_trainable:
624
+ Graph = Graph * self.soft_edge_mask_graph.detach().cpu().numpy() + Graph * self.hard_edge_mask_graph.detach().cpu().numpy()
625
+ else:
626
+ Graph = Graph * self.soft_edge_mask_graph.detach().cpu().numpy() + self.hard_edge_mask_graph.detach().cpu().numpy()
627
+ Graph = np.clip(Graph, 0.0, 1.0)
628
+
629
+
630
+ if (epoch_i+1) % self.args.show_graph_every == 0:
631
+ avg_mask = np.mean(observ_mask.cpu().numpy(), axis=(0,2))
632
+ if np.min(avg_mask) < 1:
633
+ time_series_idx = int(np.argwhere(avg_mask < 1)[0, 0])
634
+ else:
635
+ time_series_idx = 0
636
+ d_tech = original_data.shape[-1]
637
+ log_time_series(
638
+ original_data.cpu()[-100:,time_series_idx],
639
+ data_interp.cpu()[-100:,time_series_idx],
640
+ data_pred_all.cpu()[-100:,time_series_idx, :d_tech],
641
+ log=self.log, log_step=price_pred_step
642
+ )
643
+
644
+ plot_matrix("G", G_prob, self.log, graph_discov_step, vmin=0, vmax=1)
645
+ plot_matrix("GT", GT_prob, self.log, graph_discov_step, vmin=0, vmax=1)
646
+ plot_matrix("Graph", Graph, self.log, graph_discov_step, vmin=0, vmax=1)
647
+ np.save(os.path.join(self.log.log_dir, 'Graph.npy'), Graph)
648
+ plot_roc = True
649
+
650
+ # Show TPR FPR AUC ROC
651
+ if true_cm is not None:
652
+ Graph = rearrange(Graph, "n m -> m n")
653
+ auc = calc_and_log_metrics(Graph, true_cm, self.log, graph_discov_step, plot_roc=plot_roc)
654
+
655
+ return Graph
656
+
657
+
658
+ def prepross_data(data):
659
+ T, N, D = data.shape
660
+ new_data = np.zeros_like(data, dtype=float)
661
+ for i in range(N):
662
+ node = data[:,i,:]
663
+ std = np.std(node)
664
+ # Guard against zero-std (constant) columns to prevent NaN from 0/0
665
+ new_data[:,i,:] = (node - np.mean(node)) / (std + 1e-8)
666
+ # Replace any residual NaN/Inf (e.g. from upstream data issues) with 0
667
+ new_data = np.nan_to_num(new_data, nan=0.0, posinf=0.0, neginf=0.0)
668
+ return new_data
669
+
670
+
671
+ def main(
672
+ data,
673
+ mask,
674
+ true_cm,
675
+ opt,
676
+ log,
677
+ device="cuda",
678
+ text_data=None,
679
+ text_mask=None,
680
+ G_prior=None,
681
+ denoised_news=None,
682
+ denoised_mask=None,
683
+ ):
684
+ if opt.n_nodes == "auto":
685
+ opt.n_nodes = data.shape[1]
686
+
687
+ if len(data.shape) == 2:
688
+ data = data[:,:,None]
689
+ mask = mask[:,:,None]
690
+ data = prepross_data(data)
691
+
692
+ if text_data is not None:
693
+ text_data_torch = torch.from_numpy(text_data).float()
694
+ text_mask_torch = torch.from_numpy(text_mask).float()
695
+ else:
696
+ text_data_torch = None
697
+ text_mask_torch = None
698
+ if denoised_news is not None:
699
+ denoised_news_torch = torch.from_numpy(denoised_news).float()
700
+ denoised_mask_torch = torch.from_numpy(denoised_mask).float() if denoised_mask is not None else None
701
+ else:
702
+ denoised_news_torch = None
703
+ denoised_mask_torch = None
704
+
705
+ effective_dim = opt.data_dim
706
+ projector_output_dim = getattr(opt, 'projector_output_dim', 16)
707
+ if text_data is not None:
708
+ effective_dim += projector_output_dim
709
+ if denoised_news is not None:
710
+ effective_dim += projector_output_dim
711
+ opt.data_dim = effective_dim
712
+
713
+ multicad = MultiCAD(
714
+ opt,
715
+ log,
716
+ device=device,
717
+ text_data=text_data_torch,
718
+ text_mask=text_mask_torch,
719
+ G_prior=G_prior,
720
+ denoised_news=denoised_news_torch,
721
+ denoised_mask=denoised_mask_torch,
722
+ )
723
+ max_refuter_retries = 3
724
+ refuter_retries = 0
725
+ falsified = True
726
+
727
+ while falsified and refuter_retries <= max_refuter_retries:
728
+ Graph = multicad.train(data, mask, data, true_cm)
729
+
730
+ # Run Refuter Validation
731
+ try:
732
+ from singular_ticker_causal.causal_inference.causal_model import StructuralCausalModel
733
+ from singular_ticker_causal.causal_inference.query_engine import CausalQueryEngine
734
+
735
+ data_cpu = data.cpu().numpy() if isinstance(data, torch.Tensor) else data
736
+ n_nodes = Graph.shape[0]
737
+ nodes = [f"N_{i}" for i in range(n_nodes)]
738
+ adj_mask = G_prior if G_prior is not None else np.zeros_like(Graph)
739
+
740
+ scm = StructuralCausalModel(
741
+ nodes=nodes,
742
+ adj=Graph,
743
+ adjacency_mask=adj_mask,
744
+ data_tech=data_cpu,
745
+ lag=1
746
+ ).fit()
747
+
748
+ pywhyllm_enabled = os.environ.get("PYWHYLLM_ENABLED", "").lower() in {"1", "true", "yes", "on"}
749
+ pywhyllm_max_edges = int(os.environ.get("PYWHYLLM_REFUTER_MAX_EDGES", "3"))
750
+ engine = CausalQueryEngine(
751
+ scm,
752
+ data_tech=data_cpu,
753
+ pywhyllm_enabled=pywhyllm_enabled,
754
+ )
755
+
756
+ falsified = False
757
+ failed_edges = []
758
+
759
+ edges_to_check = []
760
+ for i in range(n_nodes):
761
+ for j in range(n_nodes):
762
+ if scm.dag_adj[i, j] and (adj_mask[i, j] == 0):
763
+ edges_to_check.append((nodes[i], nodes[j], float(Graph[i, j])))
764
+ edges_to_check.sort(key=lambda edge: abs(edge[2]), reverse=True)
765
+
766
+ validation_reports = []
767
+ for treatment, outcome, _score in edges_to_check[:pywhyllm_max_edges]:
768
+ if pywhyllm_enabled:
769
+ res = engine.validate_with_pywhyllm_and_dowhy(
770
+ treatment,
771
+ outcome,
772
+ max_edges=pywhyllm_max_edges,
773
+ )
774
+ else:
775
+ res = engine.validate_with_dowhy(treatment, outcome)
776
+ validation_reports.append({"edge": [treatment, outcome], "validation": res})
777
+ if res.get("falsified"):
778
+ falsified = True
779
+ failed_edges.append((treatment, outcome, res))
780
+ break
781
+
782
+ if falsified:
783
+ print(f"[Refuter] Graph failed refutation on edges: {failed_edges}. Applying penalty and retrying...")
784
+ multicad.args.total_epoch = 100
785
+ multicad.lambda_s *= 1.5
786
+
787
+ if hasattr(multicad, "GT"):
788
+ multicad.GT.data = multicad._build_prior_seed_logits(multicad.n_groups).to(device)
789
+ refuter_retries += 1
790
+
791
+ import json
792
+ artifact = {
793
+ "failed_edges": [
794
+ {"treatment": edge[0], "outcome": edge[1], "validation": edge[2]}
795
+ for edge in failed_edges
796
+ ],
797
+ "validation_reports": validation_reports,
798
+ "lambda_s_new": float(multicad.lambda_s),
799
+ "retry_epoch": 100,
800
+ "adj": Graph.tolist()
801
+ }
802
+ with open(os.path.join(log.log_dir, f"refuter_failed_artifact_retry_{refuter_retries}.json"), "w") as f:
803
+ json.dump(artifact, f)
804
+ else:
805
+ print("[Refuter] Graph passed refutation or no testable edges.")
806
+
807
+ except Exception as e:
808
+ print(f"[Refuter] Validation error: {e}. Bypassing refuter.")
809
+ falsified = False
810
+
811
+ return Graph
812
+
813
+
814
+ if __name__ == "__main__":
815
+ os.environ["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
816
+
817
+ parser = argparse.ArgumentParser(description='Batch Compress')
818
+ parser.add_argument('-opt', type=str, default=opj(opd(__file__),
819
+ 'opt/multi_cad_lorenz.yaml'), help='yaml file path')
820
+ parser.add_argument('-g', help='availabel gpu list', default='2', type=str)
821
+ parser.add_argument('-debug', action='store_true')
822
+ parser.add_argument('-log', action='store_true')
823
+ args = parser.parse_args()
824
+
825
+ if args.g == "mps":
826
+ os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
827
+ device = "mps"
828
+ elif args.g == "cpu":
829
+ device = "cpu"
830
+ else:
831
+ os.environ["CUDA_VISIBLE_DEVICES"] = args.g
832
+ device = "cuda"
833
+
834
+ main(OmegaConf.load(args.opt), device=device)
singular_ticker_causal/algorithms/CUTS_PLUS/data/__init__.py ADDED
File without changes
singular_ticker_causal/algorithms/CUTS_PLUS/data/generate_data_mod.py ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from collections import defaultdict
3
+
4
+ def check_stationarity(links):
5
+ """Returns stationarity according to a unit root test
6
+
7
+ Assuming a Gaussian Vector autoregressive process
8
+
9
+ Three conditions are necessary for stationarity of the VAR(p) model:
10
+ - Absence of mean shifts;
11
+ - The noise vectors are identically distributed;
12
+ - Stability condition on Phi(t-1) coupling matrix (stabmat) of VAR(1)-version of VAR(p).
13
+ """
14
+
15
+
16
+ N = len(links)
17
+ # Check parameters
18
+ max_lag = 0
19
+
20
+ for j in range(N):
21
+ for link_props in links[j]:
22
+ var, lag = link_props[0]
23
+ # coeff = link_props[1]
24
+ # coupling = link_props[2]
25
+
26
+ max_lag = max(max_lag, abs(lag))
27
+
28
+ graph = np.zeros((N,N,max_lag))
29
+ couplings = []
30
+
31
+ for j in range(N):
32
+ for link_props in links[j]:
33
+ var, lag = link_props[0]
34
+ coeff = link_props[1]
35
+ coupling = link_props[2]
36
+ if abs(lag) > 0:
37
+ graph[j,var,abs(lag)-1] = coeff
38
+ couplings.append(coupling)
39
+
40
+ stabmat = np.zeros((N*max_lag,N*max_lag))
41
+ index = 0
42
+
43
+ for i in range(0,N*max_lag,N):
44
+ stabmat[:N,i:i+N] = graph[:,:,index]
45
+ if index < max_lag-1:
46
+ stabmat[i+N:i+2*N,i:i+N] = np.identity(N)
47
+ index += 1
48
+
49
+ eig = np.linalg.eig(stabmat)[0]
50
+ # print "----> maxeig = ", np.abs(eig).max()
51
+ if np.all(np.abs(eig) < 1.):
52
+ stationary = True
53
+ else:
54
+ stationary = False
55
+
56
+ if len(eig) == 0:
57
+ return stationary, 0.
58
+ else:
59
+ return stationary, np.abs(eig).max()
60
+
61
+
62
+ class Graph():
63
+ def __init__(self,vertices):
64
+ self.graph = defaultdict(list)
65
+ self.V = vertices
66
+
67
+ def addEdge(self,u,v):
68
+ self.graph[u].append(v)
69
+
70
+ def isCyclicUtil(self, v, visited, recStack):
71
+
72
+ # Mark current node as visited and
73
+ # adds to recursion stack
74
+ visited[v] = True
75
+ recStack[v] = True
76
+
77
+ # Recur for all neighbours
78
+ # if any neighbour is visited and in
79
+ # recStack then graph is cyclic
80
+ for neighbour in self.graph[v]:
81
+ if visited[neighbour] == False:
82
+ if self.isCyclicUtil(neighbour, visited, recStack) == True:
83
+ return True
84
+ elif recStack[neighbour] == True:
85
+ return True
86
+
87
+ # The node needs to be poped from
88
+ # recursion stack before function ends
89
+ recStack[v] = False
90
+ return False
91
+
92
+ # Returns true if graph is cyclic else false
93
+ def isCyclic(self):
94
+ visited = [False] * self.V
95
+ recStack = [False] * self.V
96
+ for node in range(self.V):
97
+ if visited[node] == False:
98
+ if self.isCyclicUtil(node,visited,recStack) == True:
99
+ return True
100
+ return False
101
+
102
+ # A recursive function used by topologicalSort
103
+ def topologicalSortUtil(self,v,visited,stack):
104
+
105
+ # Mark the current node as visited.
106
+ visited[v] = True
107
+
108
+ # Recur for all the vertices adjacent to this vertex
109
+ for i in self.graph[v]:
110
+ if visited[i] == False:
111
+ self.topologicalSortUtil(i,visited,stack)
112
+
113
+ # Push current vertex to stack which stores result
114
+ stack.insert(0,v)
115
+
116
+ # The function to do Topological Sort. It uses recursive
117
+ # topologicalSortUtil()
118
+ def topologicalSort(self):
119
+ # Mark all the vertices as not visited
120
+ visited = [False]*self.V
121
+ stack =[]
122
+
123
+ # Call the recursive helper function to store Topological
124
+ # Sort starting from all vertices one by one
125
+ for i in range(self.V):
126
+ if visited[i] == False:
127
+ self.topologicalSortUtil(i,visited,stack)
128
+
129
+ return stack
130
+
131
+ def generate_nonlinear_contemp_timeseries(links, T, noises=None, random_state=None):
132
+
133
+ if random_state is None:
134
+ random_state = np.random
135
+
136
+ # links must be {j:[((i, -tau), func), ...], ...}
137
+ # coeff is coefficient
138
+ # func is a function f(x) that becomes linear ~x in limit
139
+ # noises is a random_state.___ function
140
+ N = len(links.keys())
141
+ if noises is None:
142
+ noises = [random_state.randn for j in range(N)]
143
+
144
+ if N != max(links.keys())+1 or N != len(noises):
145
+ raise ValueError("links and noises keys must match N.")
146
+
147
+ # Check parameters
148
+ max_lag = 0
149
+ contemp = False
150
+ contemp_dag = Graph(N)
151
+ causal_order = list(range(N))
152
+ for j in range(N):
153
+ for link_props in links[j]:
154
+ var, lag = link_props[0]
155
+ coeff = link_props[1]
156
+ func = link_props[2]
157
+ if lag == 0: contemp = True
158
+ if var not in range(N):
159
+ raise ValueError("var must be in 0..{}.".format(N-1))
160
+ if 'float' not in str(type(coeff)):
161
+ raise ValueError("coeff must be float.")
162
+ if lag > 0 or type(lag) != int:
163
+ raise ValueError("lag must be non-positive int.")
164
+ max_lag = max(max_lag, abs(lag))
165
+
166
+ # Create contemp DAG
167
+ if var != j and lag == 0:
168
+ contemp_dag.addEdge(var, j)
169
+ # a, b = causal_order.index(var), causal_order.index(j)
170
+ # causal_order[b], causal_order[a] = causal_order[a], causal_order[b]
171
+
172
+ if contemp_dag.isCyclic() == 1:
173
+ raise ValueError("Contemporaneous links must not contain cycle.")
174
+
175
+ causal_order = contemp_dag.topologicalSort()
176
+
177
+ transient = int(.2*T)
178
+
179
+ X = np.zeros((T+transient, N), dtype='float32')
180
+ for j in range(N):
181
+ X[:, j] = noises[j](T+transient)
182
+
183
+ for t in range(max_lag, T+transient):
184
+ for j in causal_order:
185
+ for link_props in links[j]:
186
+ var, lag = link_props[0]
187
+ # if abs(lag) > 0:
188
+ coeff = link_props[1]
189
+ func = link_props[2]
190
+
191
+ X[t, j] += coeff * func(X[t + lag, var])
192
+
193
+ X = X[transient:]
194
+
195
+ if (check_stationarity(links)[0] == False or
196
+ np.any(np.isnan(X)) or
197
+ np.any(np.isinf(X)) or
198
+ # np.max(np.abs(X)) > 1.e4 or
199
+ np.any(np.abs(np.triu(np.corrcoef(X, rowvar=0), 1)) > 0.999)):
200
+ nonstationary = True
201
+ else:
202
+ nonstationary = False
203
+
204
+ return X, nonstationary
205
+
206
+
207
+ def generate_random_contemp_model(N, L,
208
+ coupling_coeffs,
209
+ coupling_funcs,
210
+ auto_coeffs,
211
+ tau_max,
212
+ contemp_fraction=0.,
213
+ # num_trials=1000,
214
+ random_state=None):
215
+
216
+ def lin(x): return x
217
+
218
+ if random_state is None:
219
+ random_state = np.random
220
+
221
+ # print links
222
+ a_len = len(auto_coeffs)
223
+ if type(coupling_coeffs) == float:
224
+ coupling_coeffs = [coupling_coeffs]
225
+ c_len = len(coupling_coeffs)
226
+ func_len = len(coupling_funcs)
227
+
228
+ if tau_max == 0:
229
+ contemp_fraction = 1.
230
+
231
+ if contemp_fraction > 0.:
232
+ contemp = True
233
+ L_lagged = int((1.-contemp_fraction)*L)
234
+ L_contemp = L - L_lagged
235
+ if L==1:
236
+ # Randomly assign a lagged or contemp link
237
+ L_lagged = random_state.randint(0,2)
238
+ L_contemp = int(L_lagged == False)
239
+
240
+ else:
241
+ contemp = False
242
+ L_lagged = L
243
+ L_contemp = 0
244
+
245
+
246
+ # for ir in range(num_trials):
247
+
248
+ # Random order
249
+ causal_order = list(random_state.permutation(N))
250
+
251
+ links = dict([(i, []) for i in range(N)])
252
+
253
+ # Generate auto-dependencies at lag 1
254
+ if tau_max > 0:
255
+ for i in causal_order:
256
+ a = auto_coeffs[random_state.randint(0, a_len)]
257
+
258
+ if a != 0.:
259
+ links[i].append(((int(i), -1), float(a), lin))
260
+
261
+ chosen_links = []
262
+ # Create contemporaneous DAG
263
+ contemp_links = []
264
+ for l in range(L_contemp):
265
+
266
+ cause = random_state.choice(causal_order[:-1])
267
+ effect = random_state.choice(causal_order)
268
+ while (causal_order.index(cause) >= causal_order.index(effect)
269
+ or (cause, effect) in chosen_links):
270
+ cause = random_state.choice(causal_order[:-1])
271
+ effect = random_state.choice(causal_order)
272
+
273
+ contemp_links.append((cause, effect))
274
+ chosen_links.append((cause, effect))
275
+
276
+ # Create lagged links (can be cyclic)
277
+ lagged_links = []
278
+ for l in range(L_lagged):
279
+
280
+ cause = random_state.choice(causal_order)
281
+ effect = random_state.choice(causal_order)
282
+ while (cause, effect) in chosen_links or cause == effect:
283
+ cause = random_state.choice(causal_order)
284
+ effect = random_state.choice(causal_order)
285
+
286
+ lagged_links.append((cause, effect))
287
+ chosen_links.append((cause, effect))
288
+
289
+ # print(chosen_links)
290
+ # print(contemp_links)
291
+ for (i, j) in chosen_links:
292
+
293
+ # Choose lag
294
+ if (i, j) in contemp_links:
295
+ tau = 0
296
+ else:
297
+ tau = int(random_state.randint(1, tau_max+1))
298
+ # print tau
299
+ # CHoose coupling
300
+ c = float(coupling_coeffs[random_state.randint(0, c_len)])
301
+ if c != 0:
302
+ func = coupling_funcs[random_state.randint(0, func_len)]
303
+
304
+ links[j].append(((int(i), -tau), c, func))
305
+
306
+ # # Stationarity check assuming model with linear dependencies at least for large x
307
+ # # if check_stationarity(links)[0]:
308
+ # # return links
309
+ # X, nonstat = generate_nonlinear_contemp_timeseries(links,
310
+ # T=10000, noises=None, random_state=None)
311
+ # if nonstat == False:
312
+ # return links
313
+ # else:
314
+ # print("Trial %d: Not a stationary model" % ir)
315
+
316
+
317
+ # print("No stationary models found in {} trials".format(num_trials))
318
+ return links
319
+
320
+ def generate_logistic_maps(N, T, links, noise_lev):
321
+
322
+ # Check parameters
323
+ # contemp = False
324
+ max_lag = 0
325
+ for j in range(N):
326
+ for link_props in links[j]:
327
+ var, lag = link_props[0]
328
+ max_lag = max(max_lag, abs(lag))
329
+
330
+ transient = int(.2*T)
331
+
332
+ # Chaotic logistic map parameter
333
+ r = 4.
334
+
335
+ X = np.random.rand(T+transient, N)
336
+
337
+ for t in range(max_lag, T+transient):
338
+ for j in range(N):
339
+ added_input = 0.
340
+ for link_props in links[j]:
341
+ var, lag = link_props[0]
342
+ if var != j and abs(lag) > 0:
343
+ coeff = link_props[1]
344
+ coupling = link_props[2]
345
+ added_input += coeff*X[t - abs(lag), var]
346
+
347
+ X[t, j] = (X[t-1, j] * (r - r*X[t-1, j] - added_input + noise_lev*np.random.rand())) % 1
348
+ #func(coeff, X[t+lag, var], coupling)
349
+
350
+ X = X[transient:]
351
+
352
+ if np.any(np.abs(X) == np.inf) or np.any(X == np.nan):
353
+ raise ValueError("Data divergent")
354
+ return X
355
+
356
+
357
+
358
+ def weighted_avg_and_std(values, axis, weights):
359
+ """Returns the weighted average and standard deviation.
360
+
361
+ Parameters
362
+ ---------
363
+ values : array
364
+ Data array of shape (time, variables).
365
+
366
+ axis : int
367
+ Axis to average/std about
368
+
369
+ weights : array
370
+ Weight array of shape (time, variables).
371
+
372
+ Returns
373
+ -------
374
+ (average, std) : tuple of arrays
375
+ Tuple of weighted average and standard deviation along axis.
376
+ """
377
+
378
+ values[np.isnan(values)] = 0.
379
+ average = np.ma.average(values, axis=axis, weights=weights)
380
+ variance = np.sum(weights * (values - np.expand_dims(average, axis)
381
+ ) ** 2, axis=axis) / weights.sum(axis=axis)
382
+
383
+ return (average, np.sqrt(variance))
384
+
385
+ def time_bin_with_mask(data, time_bin_length, sample_selector=None):
386
+ """Returns time binned data where only about non-masked values is averaged.
387
+
388
+ Parameters
389
+ ----------
390
+ data : array
391
+ Data array of shape (time, variables).
392
+
393
+ time_bin_length : int
394
+ Length of time bin.
395
+
396
+ mask : bool array, optional (default: None)
397
+ Data mask where True labels masked samples.
398
+
399
+ Returns
400
+ -------
401
+ (bindata, T) : tuple of array and int
402
+ Tuple of time-binned data array and new length of array.
403
+ """
404
+
405
+ T = len(data)
406
+
407
+ time_bin_length = int(time_bin_length)
408
+
409
+ if sample_selector is None:
410
+ sample_selector = np.ones(data.shape)
411
+
412
+ if np.ndim(data) == 1.:
413
+ data.shape = (T, 1)
414
+ sample_selector.shape = (T, 1)
415
+
416
+ bindata = np.zeros(
417
+ (T // time_bin_length,) + data.shape[1:], dtype="float32")
418
+ for index, i in enumerate(range(0, T - time_bin_length + 1,
419
+ time_bin_length)):
420
+ # print weighted_avg_and_std(fulldata[i:i+time_bin_length], axis=0,
421
+ # weights=sample_selector[i:i+time_bin_length])[0]
422
+ bindata[index] = weighted_avg_and_std(data[i:i + time_bin_length],
423
+ axis=0,
424
+ weights=sample_selector[i:i +
425
+ time_bin_length])[0]
426
+
427
+ T, grid_size = bindata.shape
428
+
429
+ return (bindata.squeeze(), T)
430
+
singular_ticker_causal/algorithms/CUTS_PLUS/data/simu_data.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from os.path import join as opj
4
+ sys.path.append(opj(os.getcwd(), "../"))
5
+ sys.path.append(os.getcwd())
6
+
7
+ import csv
8
+ import torch
9
+ import scipy
10
+ from .generate_data_mod import generate_random_contemp_model, generate_nonlinear_contemp_timeseries
11
+ import numpy as np
12
+ from scipy.integrate import odeint
13
+
14
+
15
+ ######################################
16
+ # Function for loading input data
17
+ ######################################
18
+ def loadTrainingData(inputDataFilePath, device):
19
+
20
+ # Load and parse input data (create batch data)
21
+ inpData = torch.load(inputDataFilePath)
22
+ Xtrain = torch.zeros(inpData['TsData'].shape[1], inpData['TsData'].shape[0], requires_grad = False, device=device)
23
+ Xtrain1 = inpData['TsData'].t()
24
+ Xtrain.data[:,:] = Xtrain1.data[:,:]
25
+
26
+ return Xtrain
27
+
28
+ #######################################################
29
+ # Function for reading ground truth network from file
30
+ #######################################################
31
+ def loadTrueNetwork(inputFilePath, networkSize):
32
+
33
+ with open(inputFilePath) as tsvin:
34
+ reader = csv.reader(tsvin, delimiter='\t')
35
+ numrows = 0
36
+ for row in reader:
37
+ numrows = numrows + 1
38
+
39
+ network = np.zeros((numrows,2),dtype=np.int16)
40
+ with open(inputFilePath) as tsvin:
41
+ reader = csv.reader(tsvin, delimiter='\t')
42
+ rowcounter = 0
43
+ for row in reader:
44
+ network[rowcounter][0] = int(row[0][1:])
45
+ network[rowcounter][1] = int(row[1][1:])
46
+ rowcounter = rowcounter + 1
47
+
48
+ Gtrue = np.zeros((networkSize,networkSize), dtype=np.int16)
49
+ for row in range(0,len(network),1):
50
+ Gtrue[network[row][1]-1][network[row][0]-1] = 1
51
+
52
+ return Gtrue
53
+
54
+
55
+ def load_dream_data(dataset_id):
56
+ device = "cpu"
57
+
58
+ if(dataset_id == 0):
59
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Ecoli1.pt"
60
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Ecoli1.tsv"
61
+ elif(dataset_id == 1):
62
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Ecoli2.pt"
63
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Ecoli2.tsv"
64
+ elif(dataset_id == 2):
65
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Yeast1.pt"
66
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Yeast1.tsv"
67
+ elif(dataset_id == 3):
68
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Yeast2.pt"
69
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Yeast2.tsv"
70
+ elif(dataset_id == 4):
71
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Yeast3.pt"
72
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Yeast3.tsv"
73
+ elif(dataset_id == 5):
74
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Ecoli1.pt"
75
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Ecoli1.tsv"
76
+ elif(dataset_id == 6):
77
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Ecoli2.pt"
78
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Ecoli2.tsv"
79
+ elif(dataset_id == 7):
80
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Yeast1.pt"
81
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Yeast1.tsv"
82
+ elif(dataset_id == 8):
83
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Yeast2.pt"
84
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Yeast2.tsv"
85
+ elif(dataset_id == 9):
86
+ InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Yeast3.pt"
87
+ RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Yeast3.tsv"
88
+ else:
89
+ print("Error while loading gene training data")
90
+
91
+ Xtrain = loadTrainingData(InputDataFilePath, device)
92
+ n = Xtrain.shape[0]
93
+ Gref = loadTrueNetwork(RefNetworkFilePath, n)
94
+
95
+ Xtrain = Xtrain.numpy().T
96
+ # Gref = Gref.T
97
+
98
+ return Xtrain, Gref
99
+
100
+
101
+
102
+ def links_to_matrix(links):
103
+ N = len(links)
104
+ cm = np.zeros([N, N])
105
+ for i, effect_node in links.items():
106
+ for (j, _), _, _ in effect_node:
107
+ cm[i, j] += 1
108
+ return cm
109
+
110
+
111
+ class noise_model:
112
+ def __init__(self, sigma=1, seed=0):
113
+ self.random_state = np.random.RandomState(seed)
114
+ self.sigma = sigma
115
+
116
+ def gaussian(self, T):
117
+ # Get zero-mean unit variance gaussian distribution
118
+ return self.sigma*self.random_state.randn(T)
119
+
120
+ def weibull(self, T):
121
+ # Get zero-mean sigma variance weibull distribution
122
+ a = 2
123
+ mean = scipy.special.gamma(1./a + 1)
124
+ variance = scipy.special.gamma(
125
+ 2./a + 1) - scipy.special.gamma(1./a + 1)**2
126
+ return self.sigma*(self.random_state.weibull(a=a, size=T) - mean)/np.sqrt(variance)
127
+
128
+ def uniform(self, T):
129
+ # Get zero-mean sigma variance uniform distribution
130
+ mean = 0.5
131
+ variance = 1./12.
132
+ return self.sigma*(self.random_state.uniform(size=T) - mean)/np.sqrt(variance)
133
+
134
+
135
+ def lin_f(x): return x
136
+ def f2(x): return (x + 5. * x**2 * np.exp(-x**2 / 20.))
137
+
138
+
139
+ def simulate_random_var(seed, T, N, L, coef=[0.2, 0.8], auto_corr=[0.4,0.9], tau_max=5, noise_sigma=[0.01, 0.01]):
140
+
141
+ if True:
142
+ coupling_funcs = [lin_f]
143
+ noise_types = ['gaussian'] # , 'weibull', 'uniform']
144
+ # noise_sigma = (0.1, 0.3)
145
+
146
+ couplings = list(np.arange(coef[0], coef[1]+1e-5, coef[2]))
147
+ couplings += [-c for c in couplings]
148
+
149
+ # auto_deps = list(np.arange(max(0., auto_corr-0.6), auto_corr+0.01, 0.05))
150
+ auto_deps = list(np.arange(auto_corr[0], auto_corr[1]+1e-5, auto_corr[2]))
151
+
152
+ # Models may be non-stationary. Hence, we iterate over a number of seeds
153
+ # to find a stationary one regarding network topology, noises, etc
154
+
155
+ ir = 0
156
+ model_seed = seed
157
+ while True:
158
+ ir += 1
159
+ # np.random.seed(model_seed)
160
+ random_state = np.random.RandomState(model_seed)
161
+
162
+ links = generate_random_contemp_model(
163
+ N=N, L=L,
164
+ coupling_coeffs=couplings,
165
+ coupling_funcs=coupling_funcs,
166
+ auto_coeffs=auto_deps,
167
+ tau_max=tau_max,
168
+ contemp_fraction=0.,
169
+ # num_trials=1000,
170
+ random_state=random_state)
171
+
172
+ noises = []
173
+ for j in links:
174
+ noise_type = random_state.choice(noise_types)
175
+ sigmas = list(np.arange(noise_sigma[0], noise_sigma[1]+1e-5, noise_sigma[2]))
176
+ sigma = random_state.choice(sigmas)
177
+ # sigma = noise_sigma[0] + (noise_sigma[1]-noise_sigma[0])*random_state.rand()
178
+ noises.append(getattr(noise_model(sigma=sigma, seed=seed), noise_type))
179
+
180
+ data_all_check, nonstationary = generate_nonlinear_contemp_timeseries(
181
+ links=links, T=100, noises=noises, random_state=random_state)
182
+
183
+ # If the model is stationary, break the loop
184
+ if not nonstationary:
185
+ data, nonstationary_full = generate_nonlinear_contemp_timeseries(
186
+ links=links, T=T, noises=noises, random_state=random_state)
187
+ if not nonstationary_full:
188
+ break
189
+ else:
190
+ print("Trial %d: Not a stationary model" % ir)
191
+ model_seed += 10000
192
+
193
+ cm = links_to_matrix(links)
194
+ return data, cm
195
+
196
+
197
+ def simulate_var_from_links(links, T, seed=0, noise_sigma=[0.1, 0.2], noise_type="gaussian", func_name="lin_f"):
198
+ """
199
+ links_coeffs = {0: [((0, -1), 0.7), ((1, -1), -0.8)],
200
+ 1: [((1, -1), 0.8), ((3, -1), 0.8)],
201
+ 2: [((2, -1), 0.5), ((1, -2), 0.5), ((3, -3), 0.6)],
202
+ 3: [((3, -1), 0.4)],
203
+ }
204
+ """
205
+ def get_func(func_name):
206
+ if func_name == "lin_f":
207
+ return lin_f
208
+ else:
209
+ raise NotImplementedError
210
+
211
+ random_state = np.random.RandomState(seed)
212
+ noises = []
213
+
214
+ new_links = {}
215
+ for j in range(len(links)):
216
+ sigma = noise_sigma[0] + \
217
+ (noise_sigma[1]-noise_sigma[0])*random_state.rand()
218
+ noises.append(getattr(noise_model(sigma=sigma, seed=seed), noise_type))
219
+ new_links[j] = []
220
+ for props in links[j]:
221
+ new_links[j].append(
222
+ (tuple(props[0:2]), props[2], get_func(props[3]),))
223
+ data, nonstationary = generate_nonlinear_contemp_timeseries(
224
+ links=new_links, T=T, noises=noises, random_state=random_state)
225
+ if nonstationary:
226
+ print("Model nonstationay!")
227
+
228
+ cm = links_to_matrix(new_links)
229
+ return data, cm
230
+
231
+
232
+ def make_var_stationary(beta, radius=0.97):
233
+ '''Rescale coefficients of VAR model to make stable.'''
234
+ p = beta.shape[0]
235
+ lag = beta.shape[1] // p
236
+ bottom = np.hstack((np.eye(p * (lag - 1)), np.zeros((p * (lag - 1), p))))
237
+ beta_tilde = np.vstack((beta, bottom))
238
+ eigvals = np.linalg.eigvals(beta_tilde)
239
+ max_eig = max(np.abs(eigvals))
240
+ nonstationary = max_eig > radius
241
+ if nonstationary:
242
+ # print(f"Nonstationary, beta={str(beta):s}, max_eig={max_eig:.4f}")
243
+ return make_var_stationary((beta / max_eig) * 0.7, radius)
244
+ else:
245
+ # print(f"Stationary, beta={str(beta):s}")
246
+ return beta
247
+
248
+
249
+ def simulate_var(p, T, lag, sparsity=0.2, beta_value=1.0, auto_corr=3.0, sd=0.1, seed=0):
250
+ if seed is not None:
251
+ np.random.seed(seed)
252
+
253
+ # Set up coefficients and Granger causality ground truth.
254
+ GC = np.eye(p, dtype=int)
255
+ beta = np.eye(p) * auto_corr
256
+
257
+ num_nonzero = int(p * sparsity) - 1
258
+ for i in range(p):
259
+ choice = np.random.choice(p - 1, size=num_nonzero, replace=False)
260
+ choice[choice >= i] += 1
261
+ beta[i, choice] = beta_value
262
+ GC[i, choice] = 1
263
+
264
+ beta = np.hstack([beta for _ in range(lag)])
265
+ beta = make_var_stationary(beta)
266
+
267
+ # Generate data.
268
+ burn_in = 100
269
+ errors = np.random.normal(loc=0, scale=sd, size=(p, T + burn_in))
270
+ X = np.ones((p, T + burn_in))
271
+ X[:, :lag] = errors[:, :lag]
272
+ for t in range(lag, T + burn_in):
273
+ X[:, t] = np.dot(beta, X[:, (t-lag):t].flatten(order='F'))
274
+ X[:, t] += errors[:, t-1]
275
+
276
+ data = X.T[burn_in:, :]
277
+ return data, beta, GC
278
+
279
+
280
+
281
+
282
+
283
+ def lorenz(x, t, F):
284
+ '''Partial derivatives for Lorenz-96 ODE.'''
285
+ p = len(x)
286
+ dxdt = np.zeros(p)
287
+ for i in range(p):
288
+ dxdt[i] = (x[(i+1) % p] - x[(i-2) % p]) * x[(i-1) % p] - x[i] + F
289
+
290
+ return dxdt
291
+
292
+
293
+ def simulate_lorenz_96(p, T, F=10.0, delta_t=0.1, sd=0.1, burn_in=1000,
294
+ seed=0):
295
+ if seed is not None:
296
+ np.random.seed(seed)
297
+
298
+ # Use scipy to solve ODE.
299
+ x0 = np.random.normal(scale=0.01, size=p)
300
+ t = np.linspace(0, (T + burn_in) * delta_t, T + burn_in)
301
+ X = odeint(lorenz, x0, t, args=(F,))
302
+ X += np.random.normal(scale=sd, size=(T + burn_in, p))
303
+
304
+ # Set up Granger causality ground truth.
305
+ GC = np.zeros((p, p), dtype=int)
306
+ for i in range(p):
307
+ GC[i, i] = 1
308
+ GC[i, (i + 1) % p] = 1
309
+ GC[i, (i - 1) % p] = 1
310
+ GC[i, (i - 2) % p] = 1
311
+
312
+ return X[burn_in:, :], GC
313
+
singular_ticker_causal/algorithms/CUTS_PLUS/data/utils.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from sklearn.metrics.pairwise import haversine_distances
5
+
6
+
7
+ def compute_mean(x, index=None):
8
+ """Compute the mean values for each datetime. The mean is first computed hourly over the week of the year.
9
+ Further NaN values are computed using hourly mean over the same month through the years. If other NaN are present,
10
+ they are removed using the mean of the sole hours. Hoping reasonably that there is at least a non-NaN entry of the
11
+ same hour of the NaN datetime in all the dataset."""
12
+ if isinstance(x, np.ndarray) and index is not None:
13
+ shape = x.shape
14
+ x = x.reshape((shape[0], -1))
15
+ df_mean = pd.DataFrame(x, index=index)
16
+ else:
17
+ df_mean = x.copy()
18
+ cond0 = [df_mean.index.year, df_mean.index.isocalendar().week, df_mean.index.hour]
19
+ cond1 = [df_mean.index.year, df_mean.index.month, df_mean.index.hour]
20
+ conditions = [cond0, cond1, cond1[1:], cond1[2:]]
21
+ while df_mean.isna().values.sum() and len(conditions):
22
+ nan_mean = df_mean.groupby(conditions[0]).transform(np.nanmean)
23
+ df_mean = df_mean.fillna(nan_mean)
24
+ conditions = conditions[1:]
25
+ if df_mean.isna().values.sum():
26
+ df_mean = df_mean.fillna(method='ffill')
27
+ df_mean = df_mean.fillna(method='bfill')
28
+ if isinstance(x, np.ndarray):
29
+ df_mean = df_mean.values.reshape(shape)
30
+ return df_mean
31
+
32
+
33
+ def geographical_distance(x=None, to_rad=True):
34
+ """
35
+ Compute the as-the-crow-flies distance between every pair of samples in `x`. The first dimension of each point is
36
+ assumed to be the latitude, the second is the longitude. The inputs is assumed to be in degrees. If it is not the
37
+ case, `to_rad` must be set to False. The dimension of the data must be 2.
38
+
39
+ Parameters
40
+ ----------
41
+ x : pd.DataFrame or np.ndarray
42
+ array_like structure of shape (n_samples_2, 2).
43
+ to_rad : bool
44
+ whether to convert inputs to radians (provided that they are in degrees).
45
+
46
+ Returns
47
+ -------
48
+ distances :
49
+ The distance between the points in kilometers.
50
+ """
51
+ _AVG_EARTH_RADIUS_KM = 6371.0088
52
+
53
+ # Extract values of X if it is a DataFrame, else assume it is 2-dim array of lat-lon pairs
54
+ latlon_pairs = x.values if isinstance(x, pd.DataFrame) else x
55
+
56
+ # If the input values are in degrees, convert them in radians
57
+ if to_rad:
58
+ latlon_pairs = np.vectorize(np.radians)(latlon_pairs)
59
+
60
+ distances = haversine_distances(latlon_pairs) * _AVG_EARTH_RADIUS_KM
61
+
62
+ # Cast response
63
+ if isinstance(x, pd.DataFrame):
64
+ res = pd.DataFrame(distances, x.index, x.index)
65
+ else:
66
+ res = distances
67
+
68
+ return res
69
+
70
+
71
+ def infer_mask(df, infer_from='next'):
72
+ """Infer evaluation mask from DataFrame. In the evaluation mask a value is 1 if it is present in the DataFrame and
73
+ absent in the `infer_from` month.
74
+
75
+ @param pd.DataFrame df: the DataFrame.
76
+ @param str infer_from: denotes from which month the evaluation value must be inferred.
77
+ Can be either `previous` or `next`.
78
+ @return: pd.DataFrame eval_mask: the evaluation mask for the DataFrame
79
+ """
80
+ mask = (~df.isna()).astype('uint8')
81
+ eval_mask = pd.DataFrame(index=mask.index, columns=mask.columns, data=0).astype('uint8')
82
+ if infer_from == 'previous':
83
+ offset = -1
84
+ elif infer_from == 'next':
85
+ offset = 1
86
+ else:
87
+ raise ValueError('infer_from can only be one of %s' % ['previous', 'next'])
88
+ months = sorted(set(zip(mask.index.year, mask.index.month)))
89
+ length = len(months)
90
+ for i in range(length):
91
+ j = (i + offset) % length
92
+ year_i, month_i = months[i]
93
+ year_j, month_j = months[j]
94
+ mask_j = mask[(mask.index.year == year_j) & (mask.index.month == month_j)]
95
+ mask_i = mask_j.shift(1, pd.DateOffset(months=12 * (year_i - year_j) + (month_i - month_j)))
96
+ mask_i = mask_i[~mask_i.index.duplicated(keep='first')]
97
+ mask_i = mask_i[np.in1d(mask_i.index, mask.index)]
98
+ eval_mask.loc[mask_i.index] = ~mask_i.loc[mask_i.index] & mask.loc[mask_i.index]
99
+ return eval_mask
100
+
101
+
102
+ def disjoint_months(dataset, months=None):
103
+ idxs = np.arange(len(dataset))
104
+ months = ensure_list(months)
105
+ # divide indices according to window or horizon
106
+ start, end = 0, dataset.window - 1
107
+ # after idxs
108
+ start_in_months = np.in1d(dataset.index[dataset._indices + start].month, months)
109
+ end_in_months = np.in1d(dataset.index[dataset._indices + end].month, months)
110
+ idxs_in_months = start_in_months & end_in_months
111
+ after_idxs = idxs[idxs_in_months]
112
+ # previous idxs
113
+ months = np.setdiff1d(np.arange(1, 13), months)
114
+ start_in_months = np.in1d(dataset.index[dataset._indices + start].month, months)
115
+ end_in_months = np.in1d(dataset.index[dataset._indices + end].month, months)
116
+ idxs_in_months = start_in_months & end_in_months
117
+ prev_idxs = idxs[idxs_in_months]
118
+ return prev_idxs, after_idxs
119
+
120
+
121
+ def thresholded_gaussian_kernel(x, theta=None, threshold=None, threshold_on_input=False):
122
+ if theta is None:
123
+ theta = np.std(x)
124
+ weights = np.exp(-np.square(x / theta))
125
+ if threshold is not None:
126
+ mask = x > threshold if threshold_on_input else weights < threshold
127
+ weights[mask] = 0.
128
+ return weights
129
+
130
+
131
+ def ensure_list(obj):
132
+ if isinstance(obj, (list, tuple)):
133
+ return list(obj)
134
+ else:
135
+ return [obj]
136
+
singular_ticker_causal/algorithms/CUTS_PLUS/lagged_graph.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from .cuts_plus import main as cuts_plus_main
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class LaggedGraphBundle:
14
+ graphs: np.ndarray
15
+ summary_graph: np.ndarray
16
+ priors: np.ndarray
17
+ input_steps: np.ndarray
18
+
19
+
20
+ def broadcast_prior_to_lags(G_prior: np.ndarray | None, num_lags: int) -> np.ndarray | None:
21
+ if G_prior is None:
22
+ return None
23
+ G_prior = np.asarray(G_prior, dtype=np.float32)
24
+ if G_prior.ndim == 2:
25
+ return np.repeat(G_prior[None, :, :], num_lags, axis=0)
26
+ if G_prior.ndim == 3 and G_prior.shape[0] == num_lags:
27
+ return G_prior
28
+ raise ValueError("G_prior must have shape (N, N) or (L, N, N)")
29
+
30
+
31
+ def aggregate_lagged_graphs(graphs: np.ndarray, reducer: str = "max") -> np.ndarray:
32
+ if reducer == "max":
33
+ return np.max(graphs, axis=0)
34
+ if reducer == "mean":
35
+ return np.mean(graphs, axis=0)
36
+ if reducer == "last":
37
+ return graphs[-1]
38
+ raise ValueError(f"Unsupported lagged graph reducer: {reducer}")
39
+
40
+
41
+ def discover_lagged_graphs(
42
+ data: np.ndarray,
43
+ mask: np.ndarray,
44
+ opt: Any,
45
+ log: Any,
46
+ device: str = "cpu",
47
+ text_data: np.ndarray | None = None,
48
+ text_mask: np.ndarray | None = None,
49
+ G_prior: np.ndarray | None = None,
50
+ num_lags: int = 1,
51
+ reducer: str = "max",
52
+ ) -> LaggedGraphBundle:
53
+ if num_lags < 1:
54
+ raise ValueError("num_lags must be >= 1")
55
+
56
+ lag_priors = broadcast_prior_to_lags(G_prior, num_lags)
57
+ graphs = []
58
+ input_steps = []
59
+ previous_graph = None
60
+
61
+ for lag_idx in range(num_lags):
62
+ cfg = deepcopy(opt)
63
+ cfg.input_step = max(int(opt.input_step), lag_idx + 1)
64
+ input_steps.append(cfg.input_step)
65
+
66
+ current_prior = None
67
+ if lag_priors is not None:
68
+ current_prior = lag_priors[lag_idx]
69
+ elif previous_graph is not None:
70
+ current_prior = previous_graph
71
+
72
+ graph = cuts_plus_main(
73
+ data=data,
74
+ mask=mask,
75
+ true_cm=None,
76
+ opt=cfg,
77
+ log=log,
78
+ device=device,
79
+ text_data=text_data,
80
+ text_mask=text_mask,
81
+ G_prior=current_prior,
82
+ )
83
+ previous_graph = graph
84
+ graphs.append(np.asarray(graph, dtype=np.float32))
85
+
86
+ stacked = np.stack(graphs, axis=0)
87
+ summary = aggregate_lagged_graphs(stacked, reducer=reducer)
88
+ if lag_priors is None:
89
+ lag_priors = np.zeros_like(stacked)
90
+ return LaggedGraphBundle(
91
+ graphs=stacked,
92
+ summary_graph=summary.astype(np.float32),
93
+ priors=lag_priors.astype(np.float32),
94
+ input_steps=np.asarray(input_steps, dtype=np.int32),
95
+ )
singular_ticker_causal/algorithms/CUTS_PLUS/model/cuts_plus_net.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from einops import rearrange
3
+ from torch import nn
4
+
5
+ class GRUCell(nn.Module):
6
+
7
+ def __init__(self, d_in, num_units, n_nodes, concat_h=False, activation='tanh'):
8
+ super(GRUCell, self).__init__()
9
+ self.activation_fn = getattr(torch, activation)
10
+
11
+ mpnn_channel = d_in*n_nodes+num_units if concat_h else d_in*n_nodes
12
+ self.forget_gate = MPNN(c_in=mpnn_channel, c_out=num_units, concat_h=concat_h)
13
+ self.update_gate = MPNN(c_in=mpnn_channel, c_out=num_units, concat_h=concat_h)
14
+ self.c_gate = MPNN(c_in=mpnn_channel, c_out=num_units, concat_h=concat_h)
15
+
16
+ def forward(self, x, h, adj):
17
+ """
18
+ :param x: (B, input_dim, num_nodes)
19
+ :param h: (B, num_units, num_nodes)
20
+ :param adj: (num_nodes, num_nodes)
21
+ :return:
22
+ """
23
+ # we start with bias 1.0 to not reset and not update
24
+ r = torch.sigmoid(self.forget_gate(x, h, adj))
25
+ u = torch.sigmoid(self.update_gate(x, h, adj))
26
+ c = self.c_gate(x, r * h, adj) # batch_size, self._num_nodes * output_size
27
+ c = self.activation_fn(c)
28
+ return u * h + (1. - u) * c
29
+
30
+
31
+ class MPNN(nn.Module):
32
+ def __init__(self, c_in, c_out, concat_h=True):
33
+ super(MPNN, self).__init__()
34
+ self.concat_h = concat_h
35
+ self.mlp = nn.Conv1d(c_in, c_out, kernel_size=1)
36
+
37
+ def forward(self, x, h, graph):
38
+ b, c, n = x.shape
39
+
40
+ x_repeat = x[:, :, :, None].expand(-1, -1, -1, n) # [b, c, n, n]
41
+ # graph = rearrange(graph, 'b n m -> b m n')
42
+ x_messages = torch.einsum('bcmn,bmn->bcmn', (x_repeat, graph))
43
+ x_messages = rearrange(x_messages, 'b c m n -> b (c m) n')
44
+
45
+ if self.concat_h:
46
+ out = self.mlp(torch.cat([x_messages, h], dim=1))
47
+ else:
48
+ out = self.mlp(x_messages)
49
+ return out
50
+
51
+
52
+ class LocalConv1D(nn.Module):
53
+ def __init__(self, in_channels, out_channels, kernel_size, n_nodes):
54
+ super(LocalConv1D, self).__init__()
55
+ self.out_channel = out_channels
56
+ self.conv_list = nn.ModuleList([
57
+ nn.Conv1d(in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size) for _ in range(n_nodes)
58
+ ])
59
+
60
+ def forward(self, x): # x: [batch, features, nodes]
61
+ b, h, n = x.shape
62
+ out = torch.zeros((b, self.out_channel, n)).to(x.device)
63
+ for i in range(n):
64
+ x_local_in = x[..., i].unsqueeze(-1)
65
+ x_local_out = self.conv_list[i](x_local_in)
66
+ out[..., i] = x_local_out.squeeze(-1)
67
+ return out
68
+
69
+
70
+ class CUTS_Plus_Net(nn.Module):
71
+ def __init__(self, n_nodes,
72
+ in_ch=1,
73
+ hidden_ch=32,
74
+ n_layers=1,
75
+ shared_weights_decoder=False,
76
+ concat_h=False,):
77
+ super().__init__()
78
+ self.in_ch = in_ch
79
+ self.hidden_ch = hidden_ch
80
+ self.n_layers = n_layers
81
+
82
+ self.conv_encoder1 = nn.Conv1d(in_channels=hidden_ch, out_channels=hidden_ch, kernel_size=1)
83
+ self.conv_encoder2 = nn.Conv1d(in_channels=2*hidden_ch, out_channels=hidden_ch, kernel_size=1)
84
+ if shared_weights_decoder:
85
+ self.decoder = nn.Sequential(
86
+ nn.Conv1d(in_channels=2*hidden_ch, out_channels=in_ch, kernel_size=1),
87
+ # nn.LeakyReLU(),
88
+ # nn.Conv1d(in_channels=hidden_ch, out_channels=hidden_ch, kernel_size=1),
89
+ # nn.LeakyReLU(),
90
+ # nn.Conv1d(in_channels=hidden_ch, out_channels=in_ch, kernel_size=1),
91
+ # nn.LeakyReLU(),
92
+ )
93
+ else:
94
+ self.decoder = nn.Sequential(
95
+ LocalConv1D(in_channels=2*hidden_ch, out_channels=in_ch, kernel_size=1, n_nodes=n_nodes),
96
+ # nn.LeakyReLU(),
97
+ # LocalConv1D(in_channels=hidden_ch, out_channels=hidden_ch, kernel_size=1, n_nodes=n_nodes),
98
+ # nn.LeakyReLU(),
99
+ # LocalConv1D(in_channels=hidden_ch, out_channels=in_ch, kernel_size=1, n_nodes=n_nodes),
100
+ # nn.LeakyReLU(),
101
+ )
102
+ # self.act = nn.PReLU()
103
+ self.act = nn.LeakyReLU()
104
+
105
+ self.cells = nn.ModuleList()
106
+ for i in range(self.n_layers):
107
+ self.cells.append(GRUCell(d_in=in_ch if i==0 else hidden_ch,
108
+ num_units=hidden_ch,
109
+ n_nodes=n_nodes,
110
+ concat_h=concat_h))
111
+
112
+ self.h0 = self.init_state(n_nodes)
113
+
114
+ def init_state(self, n_nodes):
115
+ h = []
116
+ for layer in range(self.n_layers):
117
+ h.append(nn.parameter.Parameter(torch.zeros([self.hidden_ch, n_nodes])))
118
+ return nn.ParameterList(h)
119
+
120
+ def update_state(self, x, h, graph):
121
+ rnn_in = x
122
+ for layer in range(self.n_layers):
123
+ rnn_in = h[layer] = self.cells[layer](rnn_in, h[layer], graph)
124
+ return h
125
+
126
+ def forward(self, x, mask, fwd_graph):
127
+ x = rearrange(x, 'b n s c -> b c n s')
128
+ # fwd_graph = torch.ones_like(fwd_graph)
129
+ # mask = torch.ones_like(x).byte()
130
+ bs, in_ch, n_nodes, steps = x.shape
131
+
132
+ h = [h_.expand(bs, -1, -1) for h_ in self.h0.to(x.device)]
133
+
134
+ pred = []
135
+ for step in range(steps):
136
+ x_now = x[..., step] # [batches, in_ch, nodes]
137
+
138
+ """Update state"""
139
+ h = self.update_state(x_now, h, fwd_graph)
140
+ h_now = h[-1]
141
+
142
+ """Prediction"""
143
+ x_repr = self.act(self.conv_encoder1(h_now)) # [batches, hidden_ch, nodes]
144
+ x_repr = self.act(self.conv_encoder2(torch.cat([x_repr, h_now], dim=1))) # [batches, hidden_ch, nodes]
145
+ x_repr = torch.cat([x_repr, h_now], dim=1) # [batches, 2*hidden_ch, nodes]
146
+ x_hat2 = self.decoder(x_repr) # [batches, in_ch, nodes]
147
+ pred.append(x_hat2)
148
+
149
+
150
+ pred = torch.stack(pred, dim=-1)
151
+ pred = rearrange(pred, 'b c n s -> b n s c')
152
+ return pred[:, :, -1:]
153
+
singular_ticker_causal/algorithms/CUTS_PLUS/utils/causal_plot.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from re import X
2
+ import matplotlib
3
+ matplotlib.use('Agg')
4
+ import matplotlib.pyplot as plt
5
+ import tqdm
6
+ import numpy as np
7
+ import matplotlib.cm as cm
8
+
9
+ def save_causal_graph(save_path, causal_matrix: np.ndarray, thres_percentile=100, colormap="gnuplot2"):
10
+ causal_matrix = np.max(causal_matrix)
11
+ print(causal_matrix.shape)
12
+
13
+ n_node = causal_matrix.shape[0]
14
+ image_size = [100, 100]
15
+ n_node_dim = n_node**0.5
16
+ causal_thres = np.percentile(causal_matrix, 100-thres_percentile)
17
+
18
+ colormap = cm.get_cmap(colormap)
19
+
20
+ plt.figure(figsize=[10,10], facecolor='black', edgecolor='black')
21
+
22
+
23
+ for node_i_from in tqdm.tqdm(range(n_node)):
24
+ x_from = image_size[0] // n_node_dim * (node_i_from // n_node_dim + 0.5)
25
+ y_from = image_size[0] // n_node_dim * (node_i_from % n_node_dim + 0.5)
26
+ plt.text(y=x_from, x=y_from, s=f"{node_i_from:d}", size=20, color="#ffffff")
27
+ for node_i_to in range(n_node):
28
+ if not node_i_from == node_i_to:
29
+ x_to = image_size[0] // n_node_dim * (node_i_to // n_node_dim + 0.5)
30
+ y_to = image_size[0] // n_node_dim * (node_i_to % n_node_dim + 0.5)
31
+ causal_effect = causal_matrix[node_i_from, node_i_to]
32
+ if causal_effect > causal_thres:
33
+ width = max(0.01, 1*causal_effect)
34
+ arrow_length = ((x_to-x_from)**2 + (y_to-y_from)**2)**0.5
35
+ plt.arrow(
36
+ y=x_from+(x_to-x_from)*width/arrow_length,
37
+ x=y_from+(y_to-y_from)*width/arrow_length,
38
+ dy=(x_to-x_from)*(arrow_length-5*width)/arrow_length,
39
+ dx=(y_to-y_from)*(arrow_length-5*width)/arrow_length,
40
+ width=width,
41
+ head_length=4*width,
42
+ facecolor=colormap(causal_effect)[:3]+(causal_effect,),
43
+ edgecolor="#00000000"
44
+ )
45
+
46
+ # fig.add_annotation(text=f"{node_i_from:d}", x=x_from, y=y_from, showarrow=False
47
+
48
+ ax=plt.gca()
49
+ ax.patch.set_facecolor("black")
50
+ ax.xaxis.set_ticks_position('top')
51
+ ax.invert_yaxis()
52
+ plt.savefig(save_path)
53
+
54
+
55
+ if __name__=="__main__":
56
+ causal_matrix = np.load("outputs/tsgae_2022_0716_191203_262072/w.npy")
57
+ save_causal_graph("outputs/pic/causal.png", causal_matrix, thres_percentile=100)
singular_ticker_causal/algorithms/CUTS_PLUS/utils/exp_utils.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from os.path import join as opj
3
+ from os.path import dirname as opd
4
+
5
+ from .opt_type import MultiCADopt
6
+ from .misc import omegaconf2dict
7
+
8
+ import re
9
+ import numpy as np
10
+ import matplotlib
11
+ matplotlib.use('Agg')
12
+ import matplotlib.pyplot as plt
13
+
14
+ from datetime import datetime
15
+ from omegaconf import OmegaConf
16
+ import glob
17
+ import tqdm
18
+ from tensorboard.backend.event_processing import event_accumulator
19
+
20
+ PROPER_NAME = {"NFGR":"BRIEF", "h265":"H.265", "h264":"H.264", "jpg":"JPEG", "aoi-2000":"AoI", "vvc":"H.266"}
21
+
22
+ MAIN_COLOR = {"NFGR":"#e64b35", "h265":"#48c9b0", "h264":"#5599c7", "jpg":"#c39bd2", "DVC":"#e6b0aa", "SGA":"#f8c370", "vvc":"#b1babb",
23
+ "GOOD":"#239954", "BAD":"#cc6155"}
24
+ SECONDARY_COLOR = {"NFGR":"#d98880", "h265":"#76d7c3", "SGA":"#f9d7a0", "h264":"#7fb3d5", "DVC":"#e6b0aa"}
25
+
26
+
27
+ def get_time_stamp():
28
+ return str(datetime.now().strftime("%m-%d-%H%M%S-%f"))
29
+
30
+ def show_single_scores(x_arr, y_arr, label="exp", suffix="",
31
+ scatter=False, log_axis=True,
32
+ xlim=None, ylim=None):
33
+
34
+ '''Plotting figures'''
35
+ cvs = FigureCanvas(name=label, figsize=[30,20])
36
+ fig_idx = 0
37
+ for idx,data_path in enumerate(y_arr.labels[2]): # range(score_arr.shape[2]):
38
+ if fig_idx == 40:
39
+ break
40
+ elif np.isnan(np.nanmean(y_arr[:,:,data_path])):
41
+ continue
42
+
43
+ fig_idx += 1
44
+ if log_axis:
45
+ ax = plt.subplot(8,5,fig_idx, xscale="log")
46
+ else:
47
+ ax = plt.subplot(8,5,fig_idx)
48
+ ax.set_title("DATA_{:02d}_".format(idx) + data_path[-100:-60] + "\n" + data_path[-60:])
49
+ # ax.set_title(dim_marks[2][data_i])
50
+ plt.set_cmap("rainbow")
51
+ for i, dim0 in enumerate(y_arr.labels[0]):
52
+ x_nan = x_arr[dim0,:,data_path]
53
+ y_nan = y_arr[dim0,:,data_path]
54
+ x = x_nan[np.isfinite(x_nan + y_nan)]
55
+ y = y_nan[np.isfinite(x_nan + y_nan)]
56
+
57
+ if len(x) > 0 and len(y) > 0:
58
+ x, y = sort_lists(x, y)
59
+
60
+ plt.plot(x, y, color=plt.get_cmap("tab20")(i), label=dim0)
61
+ plt.scatter(x, y, color=plt.get_cmap("tab20")(i))
62
+
63
+ plt.legend()
64
+ if ylim is not None:
65
+ plt.ylim(ylim)
66
+
67
+ ax=plt.gca()
68
+ ax.xaxis.set_major_locator(plt.LogLocator(base=10, numticks=5))
69
+ ax.yaxis.set_major_locator(plt.MaxNLocator(5))
70
+
71
+ cvs.save_fig(suffix=suffix, time_stamp=False, save_format=".pdf")
72
+
73
+
74
+ def sort_lists(*lists):
75
+ sorted_index = np.argsort(lists[0]).astype(int)
76
+ results = []
77
+ for l in lists:
78
+ sorted_list = np.array([l[i] for i in sorted_index])
79
+ results.append(sorted_list)
80
+ return results
81
+
82
+
83
+ def show_averge_scores(x_arr, y_arr, label="exp", suffix="", percentile=25,
84
+ scatter=False, log_axis=True, figsize=[4,3],
85
+ xlim=None, ylim=None, legend=False, grid=False, std=False):
86
+ cvs = FigureCanvas(name=label, figsize=figsize)
87
+ if log_axis:
88
+ plt.xscale("log")
89
+
90
+ fig_idx = 0
91
+ if scatter:
92
+ for data_i,data_path in enumerate(y_arr.labels[2]):
93
+ if np.isnan(np.nanmean(y_arr[:,:,data_path])):
94
+ continue
95
+
96
+ for i, dim0 in enumerate(y_arr.labels[0]):
97
+ x_nan = x_arr[dim0,:,data_path]
98
+ y_nan = y_arr[dim0,:,data_path]
99
+ x = x_nan[np.isfinite(x_nan + y_nan)]
100
+ y = y_nan[np.isfinite(x_nan + y_nan)]
101
+
102
+ if len(x) > 0 and len(y) > 0:
103
+ x, y = sort_lists(x, y)
104
+
105
+ # plt.plot(x, y, color=plt.get_cmap("tab20")(i), label=dim0, marker="v")
106
+ plt.scatter(x, y, color=MAIN_COLOR[dim0], alpha=0.5, marker="v", edgecolors='none',
107
+ s=80 if "NFGR" in dim0 else 50)
108
+
109
+ # plt.legend(edgecolors='none')
110
+ if ylim is not None:
111
+ plt.ylim(ylim)
112
+
113
+ if ylim is not None:
114
+ full_range = ylim[1] - ylim[0]
115
+ else:
116
+ full_range = np.nanmax(np.nanmean(y_arr.arr, axis=2)) - np.nanmin(np.nanmean(y_arr.arr, axis=2))
117
+ max_std = np.nanmax(np.nanstd(y_arr["NFGR",:,:], axis=1))
118
+
119
+
120
+ for idx, dim0 in enumerate(sorted(y_arr.labels[0], key=lambda item:item == "NFGR")):
121
+ # if "jpg" in dim0:
122
+ # continue
123
+
124
+ x_nan = np.nanmean(x_arr[dim0,:], axis=1)
125
+ y_nan = np.nanmean(y_arr[dim0,:], axis=1)
126
+ # y_l = np.nanpercentile(y_arr[dim0,:], percentile, axis=1)
127
+ # y_u = np.nanpercentile(y_arr[dim0,:], 100-percentile, axis=1)
128
+ y_std = np.nanstd(y_arr[dim0,:], axis=1) # / max_std * full_range * 0.07
129
+
130
+ print(1 / max_std * full_range * 0.07)
131
+
132
+ x = x_nan[np.isfinite(x_nan + y_nan)]
133
+ y = y_nan[np.isfinite(x_nan + y_nan)]
134
+ # y_l = y_l[np.isfinite(x_nan + y_nan)]
135
+ # y_u = y_u[np.isfinite(x_nan + y_nan)]
136
+ y_std = y_std[np.isfinite(x_nan + y_nan)]
137
+
138
+ x, y, y_std = sort_lists(x, y, y_std)
139
+
140
+ if std:
141
+ plt.fill_between(x, y-y_std/2, y+y_std/2, color=MAIN_COLOR[dim0], alpha=0.25, edgecolors="none")
142
+ plt.plot(x, y, color=MAIN_COLOR[dim0], label=name(dim0),
143
+ lw=2 if "NFGR" in dim0 else 1.5)
144
+ plt.scatter(x, y, color=MAIN_COLOR[dim0],
145
+ s=30 if "NFGR" in dim0 else 20)
146
+
147
+ if ylim is not None:
148
+ plt.ylim(ylim)
149
+ if xlim is not None:
150
+ plt.xlim(xlim)
151
+ if legend:
152
+ plt.legend(loc='lower left', bbox_to_anchor=(0.1, 0.1), fancybox=False)
153
+
154
+ ax=plt.gca()
155
+ if log_axis:
156
+ ax.xaxis.set_major_locator(plt.LogLocator(base=10, numticks=5))
157
+ else:
158
+ ax.xaxis.set_major_locator(plt.MaxNLocator(5))
159
+ ax.yaxis.set_major_locator(plt.MaxNLocator(5))
160
+
161
+ if grid:
162
+ ax.spines['right'].set_visible(True)
163
+ ax.spines['top'].set_visible(True)
164
+ # plt.xticks(np.arange(0.4, 1.8, 0.28))
165
+ # plt.yticks(np.arange(50, 500, 40))
166
+ plt.grid(axis='both', c="#cacaca", which="major")
167
+
168
+ cvs.save_fig(suffix=suffix, time_stamp=False, save_format=".pdf")
169
+
170
+
171
+ def name(alias):
172
+ if alias in PROPER_NAME:
173
+ return PROPER_NAME[alias]
174
+ else:
175
+ print("Cannot find proper name.")
176
+ return alias
177
+
178
+ class FigureCanvas(object):
179
+
180
+ def __init__(self, name="ex1", figsize=[14,9]):
181
+ self.name = name
182
+ plt.close('all')
183
+ fig = plt.figure(figsize=figsize)
184
+ ax = plt.axes()
185
+ ax.spines['right'].set_visible(False)
186
+ ax.spines['top'].set_visible(False)
187
+ plt.tight_layout()
188
+
189
+ def show_fig(self, save_format=".png", suffix="", time_stamp=True):
190
+ save_path = "./exp/figs/%s/%s_%s%s"%(
191
+ self.name,
192
+ get_time_stamp() if time_stamp else "plt",
193
+ suffix, save_format)
194
+ if not os.path.exists(opd(save_path)):
195
+ os.makedirs(opd(save_path))
196
+ plt.savefig(save_path, bbox_inches='tight')
197
+ plt.show()
198
+
199
+ def save_fig(self, save_format=".png", suffix="", time_stamp=True, save_root="./exp/figs/"):
200
+ save_path = opj(save_root, "%s/%s_%s%s"%(
201
+ self.name,
202
+ get_time_stamp() if time_stamp else "plt",
203
+ suffix, save_format))
204
+ if not os.path.exists(opd(save_path)):
205
+ os.makedirs(opd(save_path))
206
+ plt.savefig(save_path, bbox_inches='tight')
207
+
208
+
209
+ def find_lineprofile_cmp(im_list):
210
+ for x in range(0, im_list[0].shape[0], 10):
211
+ for y in range(0, im_list[0].shape[1], 10):
212
+ lp = [im[x][y] for im in im_list]
213
+ if np.max(lp) > 2500 and np.max(lp) < 3000:
214
+ return lp
215
+
216
+
217
+ def get_decompressed_path(opt_path):
218
+ res_root = opd(opt_path)
219
+ max_step = 0
220
+ for dirn in os.listdir(res_root):
221
+ if dirn == "decompressed":
222
+ return glob.glob(res_root + "/decompressed/*.tif")[0]
223
+ elif "steps" in dirn:
224
+ step_n = int(dirn[5:])
225
+ if step_n > max_step:
226
+ max_step = step_n
227
+ search_list = glob.glob(res_root + "/steps" + str(max_step) + "/decompressed/*.*")
228
+ if len(search_list) > 0:
229
+ return search_list[0]
230
+ return None
231
+
232
+ def load_scalars(event_path):
233
+ try:
234
+ event_path = glob.glob(event_path)[0]
235
+ except:
236
+ print("No event file found.")
237
+ return None
238
+ ea = event_accumulator.EventAccumulator(event_path)
239
+ ea.Reload()
240
+ # print("Available scalars: ", ea.scalars.Keys())
241
+ scalars = {}
242
+ for criterion in ea.scalars.Keys():
243
+ val_scalar = ea.scalars.Items(criterion)
244
+ val_curve = ([(i.step, i.value) for i in val_scalar])
245
+ scalars[criterion] = val_curve
246
+ return scalars
247
+
248
+ def load_scalars_cached(root_path, cache_dir="exp/cache", reload_data=False):
249
+
250
+ if root_path[-1] == "/":
251
+ root_path = root_path[:-1]
252
+
253
+ # csv_file = glob.glob(root_path + "/*.csv")[0]
254
+ # exp_list = load_csv(csv_file)
255
+ root_name = "".join(re.split("/|\\\\", root_path)[-2:])
256
+ cache_path = opj(cache_dir, root_name + ".npy")
257
+
258
+ # read from cached data
259
+ if os.path.exists(cache_path) and not reload_data:
260
+ print("Loading cached result...")
261
+ loaded_res = np.load(cache_path, allow_pickle=True)
262
+ else:
263
+ if not os.path.exists(cache_dir):
264
+ os.makedirs(cache_dir)
265
+
266
+ loaded_res = []
267
+ for dirn in tqdm.tqdm(os.listdir(root_path)):
268
+ if os.path.isdir(opj(root_path, dirn)):
269
+ if len(glob.glob(root_path + "/%s/events.out.tfevents*"%(dirn))) > 0:
270
+ res_fname = glob.glob(root_path + "/%s/events.out.tfevents*"%(dirn))[0]
271
+ opt_fname = glob.glob(root_path + "/%s/opt.yaml"%(dirn))[0]
272
+ scores = load_scalars(res_fname)
273
+ opt: MultiCADopt = omegaconf2dict(OmegaConf.load(opt_fname), sep=".")
274
+ loaded_res.append((res_fname, scores, opt))
275
+
276
+ np.save(cache_path, loaded_res)
277
+
278
+ return loaded_res
279
+
280
+
281
+ # if __name__=="__main__":
282
+ # # scalars = load_scalars("cyx_exp/experiments_outputs/ex2_1227/*/exp_00000/events.out.tfevents*")
283
+ # # print(scalars)
284
+
285
+ # res = load_scalars_cached("cyx_exp/experiments_outputs/ex2_1227/", reload_data=False)
286
+ # print("")
singular_ticker_causal/algorithms/CUTS_PLUS/utils/gumbel_softmax.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import Tensor
3
+ import warnings
4
+
5
+ def gumbel_softmax(logits: Tensor, tau: float = 1, hard: bool = False, eps: float = 1e-10, dim: int = -1) -> Tensor:
6
+ r"""
7
+ Samples from the Gumbel-Softmax distribution (`Link 1`_ `Link 2`_) and optionally discretizes.
8
+
9
+ Args:
10
+ logits: `[..., num_features]` unnormalized log probabilities
11
+ tau: non-negative scalar temperature
12
+ hard: if ``True``, the returned samples will be discretized as one-hot vectors,
13
+ but will be differentiated as if it is the soft sample in autograd
14
+ dim (int): A dimension along which softmax will be computed. Default: -1.
15
+
16
+ Returns:
17
+ Sampled tensor of same shape as `logits` from the Gumbel-Softmax distribution.
18
+ If ``hard=True``, the returned samples will be one-hot, otherwise they will
19
+ be probability distributions that sum to 1 across `dim`.
20
+
21
+ .. note::
22
+ This function is here for legacy reasons, may be removed from nn.Functional in the future.
23
+
24
+ .. note::
25
+ The main trick for `hard` is to do `y_hard - y_soft.detach() + y_soft`
26
+
27
+ It achieves two things:
28
+ - makes the output value exactly one-hot
29
+ (since we add then subtract y_soft value)
30
+ - makes the gradient equal to y_soft gradient
31
+ (since we strip all other gradients)
32
+
33
+ Examples::
34
+ >>> logits = torch.randn(20, 32)
35
+ >>> # Sample soft categorical using reparametrization trick:
36
+ >>> F.gumbel_softmax(logits, tau=1, hard=False)
37
+ >>> # Sample hard categorical using "Straight-through" trick:
38
+ >>> F.gumbel_softmax(logits, tau=1, hard=True)
39
+
40
+ .. _Link 1:
41
+ https://arxiv.org/abs/1611.00712
42
+ .. _Link 2:
43
+ https://arxiv.org/abs/1611.01144
44
+ """
45
+ if eps != 1e-10:
46
+ warnings.warn("`eps` parameter is deprecated and has no effect.")
47
+
48
+ gumbels = (
49
+ -torch.empty_like(logits, memory_format=torch.legacy_contiguous_format).exponential_().log()
50
+ ) # ~Gumbel(0,1)
51
+ gumbels = (logits + gumbels) / tau # ~Gumbel(logits,tau)
52
+ y_soft = gumbels.softmax(dim)
53
+
54
+ if hard:
55
+ # Straight through.
56
+ index = y_soft.max(dim, keepdim=True)[1]
57
+ y_hard = torch.zeros_like(logits, memory_format=torch.legacy_contiguous_format).scatter_(dim, index, 1.0)
58
+ ret = y_hard - y_soft.detach() + y_soft
59
+ else:
60
+ # Reparametrization trick.
61
+ ret = y_soft
62
+ return ret
63
+
64
+ if __name__=="__main__":
65
+ a = torch.tensor([[2.0, 0.7]]*10)
66
+ print(gumbel_softmax(a, tau=10))
singular_ticker_causal/algorithms/CUTS_PLUS/utils/logger.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from omegaconf import OmegaConf
3
+ import os
4
+ from os.path import join as opj
5
+ import numpy as np
6
+ from os.path import dirname as opd
7
+ from typing import Dict
8
+ from torch.utils.tensorboard import SummaryWriter
9
+ from .misc import omegaconf2list
10
+
11
+
12
+ class MyLogger():
13
+ def __init__(self, log_dir: str, stderr: bool = True, tensorboard: bool = True, stdout: bool = True):
14
+ self.log_dir = log_dir
15
+ if not os.path.exists(self.log_dir):
16
+ os.makedirs(self.log_dir)
17
+ self.logger_dict: Dict[str] = {}
18
+ if stdout:
19
+ stdout_handler = open(opj(self.log_dir, 'stdout.log'), 'w')
20
+ sys.stdout = stdout_handler
21
+ if stderr:
22
+ stderr_handler = open(opj(self.log_dir, 'stderr.log'), 'w')
23
+ sys.stderr = stderr_handler
24
+ if tensorboard:
25
+ self.tblogger = SummaryWriter(self.log_dir)
26
+ self.logger_dict['tblogger'] = self.tblogger
27
+
28
+ def log_opt(self, opt):
29
+ OmegaConf.save(config=opt, f=opj(self.log_dir, 'opt.yaml'))
30
+ opt_log = omegaconf2list(opt, sep='/')
31
+ for logger_name in self.logger_dict.keys():
32
+ if logger_name == 'tblogger':
33
+ for idx, opt in enumerate(opt_log):
34
+ self.logger_dict[logger_name].add_text('hparam', opt, idx)
35
+
36
+ def log_metrics(self, metrics_dict: Dict[str, float], iters):
37
+ for logger_name in self.logger_dict.keys():
38
+ if logger_name == 'csvlogger':
39
+ self.logger_dict[logger_name].log_metrics(metrics_dict, iters)
40
+ self.logger_dict[logger_name].save()
41
+ elif logger_name == 'clearml_logger':
42
+ for k in metrics_dict.keys():
43
+ self.logger_dict[logger_name].report_scalar(
44
+ k, k, metrics_dict[k], iters)
45
+ elif logger_name == 'tblogger':
46
+ for k in metrics_dict.keys():
47
+ self.logger_dict[logger_name].add_scalar(
48
+ k, metrics_dict[k], iters)
49
+
50
+ def log_figures(self, figure, name="figure.png", iters=None, exclude_logger=[]):
51
+ for logger_name in self.logger_dict.keys():
52
+ if logger_name == 'tblogger':
53
+ if logger_name not in exclude_logger:
54
+ self.logger_dict[logger_name].add_figure(tag=name, figure=figure, global_step=iters)
55
+
56
+ if iters is None:
57
+ save_path = opj(self.log_dir, "figures")
58
+ else:
59
+ save_path = opj(self.log_dir, f"iter_{iters:d}", name)
60
+ os.makedirs(opd(save_path), exist_ok=True)
61
+ figure.savefig(save_path)
62
+
63
+ def log_npz(self, data: Dict, name="data.npz", iters=None):
64
+ if iters is None:
65
+ save_path = opj(self.log_dir)
66
+ else:
67
+ save_path = opj(self.log_dir, f"iter_{iters:d}", name)
68
+ os.makedirs(save_path, exist_ok=True)
69
+ np.savez(opj(save_path, "graph.npz"), **data)
70
+
71
+ def close(self):
72
+ for logger_name in self.logger_dict.keys():
73
+ if logger_name == 'tblogger':
74
+ self.logger_dict[logger_name].close()
singular_ticker_causal/algorithms/CUTS_PLUS/utils/misc.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from copy import deepcopy
2
+ import numpy as np
3
+ import matplotlib
4
+ matplotlib.use('Agg')
5
+ import matplotlib.pyplot as plt
6
+ import itertools
7
+ import random
8
+ import numpy as np
9
+ import torch
10
+ import omegaconf
11
+ from sklearn.metrics import roc_curve, roc_auc_score
12
+
13
+
14
+ def log_time_series(original_data, data_interp, data_pred, log, log_step):
15
+ fig = plt.figure(figsize=[10,10])
16
+ plt.plot(np.arange(0, original_data.shape[0], 1), original_data, label="original")
17
+ plt.plot(np.arange(0, data_interp.shape[0], 1), data_interp, label="interp")
18
+ plt.plot(np.arange(0, data_pred.shape[0], 1), data_pred, label="pred")
19
+ plt.legend()
20
+ log.log_figures(fig, name="Predicted Latent Data", iters=log_step)
21
+
22
+
23
+ def calc_and_log_metrics(time_prob_mat, true_cm, log, log_step, threshold=0.5, plot_roc=True):
24
+ if len(time_prob_mat.shape) == 3:
25
+ graph = np.max(time_prob_mat, axis=2)
26
+ else:
27
+ graph = time_prob_mat
28
+ causal_graph = graph > threshold
29
+ tp = np.mean(causal_graph * true_cm)
30
+ tn = np.mean((1-causal_graph) * (1-causal_graph))
31
+ fp = np.mean(causal_graph * (1-true_cm))
32
+ fn = np.mean((1-causal_graph) * true_cm)
33
+ tpr = tp / (tp + fn)
34
+ fpr = fp / (fp + tn)
35
+ acc = (tp + tn) / (tp + tn + fp + fn)
36
+ log.log_metrics({"metrics/tpr": tpr}, log_step)
37
+ log.log_metrics({"metrics/fpr": fpr}, log_step)
38
+ log.log_metrics({"metrics/accuracy": acc}, log_step)
39
+
40
+ if plot_roc:
41
+ fpr, tpr, thres = roc_curve(true_cm.reshape(-1) > 0.5,
42
+ graph.reshape(-1), pos_label=1)
43
+ fig = plt.figure(figsize=[4, 4])
44
+ plt.plot(fpr, tpr)
45
+ log.tblogger.add_figure(tag="ROC", figure=fig, global_step=log_step)
46
+
47
+ log.log_npz(name="graph",
48
+ data={"true_cm":true_cm, "pred_cm":graph},
49
+ iters=log_step)
50
+
51
+ auc = roc_auc_score(true_cm.reshape(-1)>0.5,
52
+ graph.reshape(-1))
53
+ log.log_metrics({"metrics/auc": auc}, log_step)
54
+ return auc
55
+
56
+ def sigmoid(z):
57
+ return 1/(1 + np.exp(-z))
58
+
59
+ def plot_causal_matrix_in_training(time_coef, name, log, log_step, threshold=0.5, plot_each_time=False):
60
+ if time_coef is None:
61
+ return
62
+
63
+ if np.max(time_coef) - np.min(time_coef) > 0.01:
64
+ time_coef = (time_coef - np.min(time_coef)) / (np.max(time_coef) - np.min(time_coef))
65
+ n, m, t = time_coef.shape
66
+
67
+ # # Show Discovered Graph (Coefficiency)
68
+ # sub_cg = plot_causal_matrix(
69
+ # np.max(time_coef, axis=2),
70
+ # figsize=[1.5*time_coef.shape[0], 1*n])
71
+ # log.log_figures(sub_cg, name="Discovered Graph Coef/" + name, iters=log_step)
72
+
73
+ # # Graph for Each Time Lag
74
+ # if plot_each_time:
75
+ # for ti in range(t):
76
+ # sub_cg = plot_causal_matrix(
77
+ # time_coef[:, :, ti],
78
+ # figsize=[1.5*n, 1*n],
79
+ # vmin=0, vmax=1)
80
+ # log.log_figures(sub_cg, name=f"Discovered Prob T-{t-ti:d}",
81
+ # iters=log_step, exclude_logger="tblogger")
82
+
83
+ # Show Discovered Graph (Probability)
84
+ time_graph = time_coef
85
+ sub_cg = plot_causal_matrix(
86
+ np.max(time_graph, axis=2),
87
+ figsize=[1.5*n, 1*n],
88
+ vmin=0, vmax=1)
89
+ log.log_figures(sub_cg, name="Discovered Prob/" + name, iters=log_step)
90
+
91
+ # Show Thresholded Graph
92
+ time_thres = np.max(time_graph, axis=2) > threshold
93
+ sub_cg = plot_causal_matrix(
94
+ time_thres,
95
+ figsize=[1.5*n, 1*n])
96
+ log.log_figures(sub_cg, name="Discovered Graph/" + name, iters=log_step)
97
+ log.log_npz({"Discovered Graph Coef": time_coef, "Discovered Prob": time_graph, "Discovered Graph": time_thres},
98
+ name="Graph.npz", iters=log_step)
99
+
100
+
101
+ def plot_causal_matrix(cmtx, class_names=None, figsize=None, vmin=None, vmax=None, show_text=True, cmap="magma"):
102
+ """
103
+ A function to create a colored and labeled causal matrix matplotlib figure
104
+ given true labels and preds.
105
+ Args:
106
+ cmtx (ndarray): causal matrix.
107
+ num_classes (int): total number of nodes.
108
+ class_names (Optional[list of strs]): a list of node names.
109
+ figsize (Optional[float, float]): the figure size of the causal matrix.
110
+ If None, default to [6.4, 4.8].
111
+
112
+ Returns:
113
+ img (figure): matplotlib figure.
114
+ """
115
+ num_classes = cmtx.shape[0]
116
+ if class_names is None or type(class_names) != list:
117
+ class_names = [str(i) for i in range(num_classes)]
118
+
119
+
120
+ figsize[0] = 30 if figsize[0] > 30 else figsize[0]
121
+ figsize[1] = 20 if figsize[1] > 20 else figsize[1]
122
+
123
+ plt.clf()
124
+ plt.close("all")
125
+ figure = plt.figure(figsize=figsize)
126
+ plt.imshow(cmtx, interpolation="nearest",
127
+ cmap=cmap, vmin=vmin, vmax=vmax)
128
+ plt.title("Causal matrix")
129
+ plt.colorbar()
130
+ # tick_marks = np.arange(len(class_names))
131
+ # plt.xticks(tick_marks, class_names, rotation=45)
132
+ # plt.yticks(tick_marks, class_names)
133
+
134
+ # Use white text if squares are dark; otherwise black.
135
+ threshold = cmtx.max() / 2.0
136
+ for i, j in itertools.product(range(cmtx.shape[0]), range(cmtx.shape[1])):
137
+ color = "white" if cmtx[i, j] < threshold else "black"
138
+ if cmtx.shape[0] < 20 and show_text:
139
+ plt.text(j, i, format(cmtx[i, j], ".2e") if cmtx[i, j] != 0 else ".",
140
+ horizontalalignment="center", color=color,)
141
+
142
+ plt.tight_layout()
143
+ plt.ylabel("True label")
144
+ plt.xlabel("Predicted label")
145
+
146
+ return figure
147
+
148
+
149
+ def reproduc(seed, benchmark=False, deterministic=True):
150
+ """Make experiments reproducible
151
+ """
152
+ random.seed(seed)
153
+ np.random.seed(seed)
154
+ torch.manual_seed(seed)
155
+ torch.cuda.manual_seed_all(seed)
156
+ torch.backends.cudnn.benchmark = benchmark
157
+ torch.backends.cudnn.deterministic = deterministic
158
+
159
+
160
+ def omegaconf2list(opt, prefix='', sep='.'):
161
+ notation_list = []
162
+ for k, v in opt.items():
163
+ k = str(k)
164
+ if isinstance(v, omegaconf.listconfig.ListConfig):
165
+ notation_list.append("{}{}={}".format(prefix, k, v))
166
+ # if k in ['iter_list','step_list']: # do not sparse list
167
+ # dot_notation_list.append("{}{}={}".format(prefix,k,v))
168
+ # else:
169
+ # templist = []
170
+ # for v_ in v:
171
+ # templist.append('{}{}={}'.format(prefix,k,v_))
172
+ # dot_notation_list.append(templist)
173
+ elif isinstance(v, (float, str, int,)):
174
+ notation_list.append("{}{}={}".format(prefix, k, v))
175
+ elif v is None:
176
+ notation_list.append("{}{}=~".format(prefix, k,))
177
+ elif isinstance(v, omegaconf.dictconfig.DictConfig):
178
+ nested_flat_list = omegaconf2list(v, prefix + k + sep, sep=sep)
179
+ if nested_flat_list:
180
+ notation_list.extend(nested_flat_list)
181
+ else:
182
+ raise NotImplementedError
183
+ return notation_list
184
+
185
+
186
+ def omegaconf2dotlist(opt, prefix='',):
187
+ return omegaconf2list(opt, prefix, sep='.')
188
+
189
+
190
+ def omegaconf2dict(opt, sep):
191
+ notation_list = omegaconf2list(opt, sep=sep)
192
+ dict = {notation.split('=', maxsplit=1)[0]: notation.split(
193
+ '=', maxsplit=1)[1] for notation in notation_list}
194
+ return dict
195
+
196
+
197
+ # def read_video(video_path: str):
198
+ # if ops(video_path)[-1] == ".tif":
199
+ # data = tifffile.imread(video_path)
200
+ # data = (data / np.max(data) * 255).astype(np.uint8)
201
+ # if len(data.shape) == 3:
202
+ # data = data[:, :, :, None]
203
+ # return data
204
+ # else:
205
+ # cap = cv2.VideoCapture(video_path)
206
+ # frames = []
207
+ # while cap.isOpened():
208
+ # # get a frame
209
+ # ret, frame = cap.read()
210
+ # if not ret:
211
+ # break
212
+ # frames.append(np.array(frame)[None])
213
+
214
+ # cap.release()
215
+ # return np.concatenate(frames, axis=0)
216
+
217
+
218
+ # def save_video(video_path: str, data):
219
+ # skvideo.io.vwrite(video_path, data)
220
+
221
+
222
+
223
+ class LabelArray(object):
224
+ # def __init__(self, array, labels):
225
+ # self.arr = array
226
+ # self.labels = labels
227
+ # self.marks = dim_marks
228
+ # assert [len[label_list] for label_list in labels] == self.arr.shape
229
+
230
+ def __init__(self, dim, labels=None):
231
+ if labels is not None:
232
+ if len(dim) != dim:
233
+ raise "The length of labels has to be equal to dim if defined"
234
+ else:
235
+ self.labels = deepcopy(labels)
236
+ else:
237
+ self.labels = [[] for _ in range(dim)]
238
+ self.arr = None
239
+ self.update_arr()
240
+
241
+ def update_arr(self):
242
+ if self.arr is not None:
243
+ oldarr = self.arr
244
+ self.arr = np.zeros([len(dim) for dim in self.labels]) * np.nan
245
+ self.arr[tuple([slice(0,sh_dim,1) for sh_dim in oldarr.shape])] = oldarr
246
+ else:
247
+ self.arr = np.zeros([len(dim) for dim in self.labels]) * np.nan
248
+ self.shape = self.arr.shape
249
+
250
+
251
+ def __getitem__(self, label_list):
252
+ index_list = []
253
+ for dim,label in enumerate(label_list):
254
+ if isinstance(label, str):
255
+ index_list.append(self.labels[dim].index(label))
256
+ elif isinstance(label, slice):
257
+ index_list.append(label)
258
+ elif isinstance(label, int):
259
+ index_list.append(label)
260
+ else:
261
+ raise NotImplementedError
262
+
263
+ return self.arr[tuple(index_list)]
264
+
265
+ def __setitem__(self, label_list, val):
266
+ index_list = []
267
+ for dim,label in enumerate(label_list):
268
+ if isinstance(label, str):
269
+ if not label in self.labels[dim]:
270
+ self.labels[dim].append(label)
271
+ self.update_arr()
272
+ index_list.append(self.labels[dim].index(label))
273
+ elif isinstance(label, slice):
274
+ index_list.append(label)
275
+ elif isinstance(label, int):
276
+ index_list.append(label)
277
+ else:
278
+ raise NotImplementedError
279
+
280
+ self.arr[tuple(index_list)] = val
281
+
282
+ def __str__(self):
283
+ return str(self.arr) + "\n--------------------------\n" + str(self.labels)
284
+
285
+
286
+ def to_np(self):
287
+ return self.arr
288
+
289
+ def from_np(self, array):
290
+ assert [len[label_list] for label_list in self.labels] == self.arr.shape
291
+ self.arr = array
292
+
singular_ticker_causal/algorithms/CUTS_PLUS/utils/opt_type.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ @dataclass
5
+ class ReproducOpt:
6
+ seed: int
7
+ benchmark: bool
8
+ deterministic: bool
9
+
10
+ @dataclass
11
+ class NetworkOpt:
12
+ name: str
13
+ network_param: Any
14
+
15
+ @dataclass
16
+ class TrainOpt:
17
+ batch_size: int
18
+ total_epoch: int
19
+ time_window: int
20
+
21
+
22
+ @dataclass
23
+ class TsGAEopt:
24
+ dir_name: str
25
+ task_name: str
26
+ optimizer: Any
27
+ reproduc: ReproducOpt
28
+ network: NetworkOpt
29
+ train: TrainOpt
30
+ log: Any
31
+ causal_thres: str
32
+
33
+ @dataclass
34
+ class MultiCADopt:
35
+ dir_name: str
36
+ task_name: str
37
+
38
+ @dataclass
39
+ class MultiCADargs:
40
+ n_nodes: int
41
+ input_step: int
42
+ window_step: int
43
+ stride: int
44
+ batch_size: int
45
+ sample_per_epoch: int
46
+ data_dim: int
47
+ total_epoch: int
48
+
49
+ patience: int
50
+ warmup: Any
51
+
52
+ show_graph_every: int
53
+ val_every: int
54
+
55
+ n_groups: int
56
+ group_policy: Any
57
+ causal_thres: str
58
+
59
+ @dataclass
60
+ class data_pred:
61
+ model: str
62
+ merge_policy: str
63
+ lr_data_start: float
64
+ lr_data_end: float
65
+ weight_decay: int
66
+ prob: bool
67
+
68
+ @dataclass
69
+ class graph_discov:
70
+ lr_graph_start: float
71
+ lr_graph_end: float
72
+ lambda_s_start: float
73
+ lambda_s_end: float
74
+ tau_start: float
75
+ tau_end: float
76
+ disable_bwd: bool
77
+ separate_bwd: bool
78
+ disable_ind: bool
79
+ disable_graph: bool
80
+ use_true_graph: bool
81
+
82
+ reproduc: ReproducOpt
83
+ log: Any
singular_ticker_causal/algorithms/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # CAMEF algorithms subpackage — GPT4MTS and dataloader have been retired.
2
+ # This package is intentionally empty pending removal of the CAMEF directory.
singular_ticker_causal/causal_inference/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .causal_model import StructuralCausalModel
2
+ from .identification import IdentificationStrategy, find_adjustment_set, is_identifiable
3
+ from .pywhyllm_assumptions import CausalAssumptionReport, PyWhyLLMConfig, PyWhyLLMAssumptionService
4
+ from .query_engine import CausalQueryEngine
5
+
6
+ __all__ = [
7
+ "CausalAssumptionReport",
8
+ "CausalQueryEngine",
9
+ "IdentificationStrategy",
10
+ "PyWhyLLMConfig",
11
+ "PyWhyLLMAssumptionService",
12
+ "StructuralCausalModel",
13
+ "find_adjustment_set",
14
+ "is_identifiable",
15
+ ]
singular_ticker_causal/causal_inference/abduction.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict
4
+
5
+ import numpy as np
6
+
7
+ from .causal_model import StructuralCausalModel
8
+
9
+
10
+ def abduct_noise(scm: StructuralCausalModel, observed_values: Dict[str, float]) -> Dict[str, float]:
11
+ noise: Dict[str, float] = {}
12
+ for node_idx in scm.topological_indices:
13
+ node = scm.nodes[node_idx]
14
+ if node not in observed_values:
15
+ continue
16
+ obs = float(observed_values[node])
17
+ eq = scm.equations[node]
18
+ if not eq.parents or eq.equation_type == "exogenous":
19
+ noise[node] = obs - eq.intercept
20
+ continue
21
+ parent_vals = []
22
+ for p in eq.parents:
23
+ if p not in observed_values:
24
+ parent_vals = []
25
+ break
26
+ parent_vals.append(float(observed_values[p]))
27
+ if not parent_vals:
28
+ continue
29
+ coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
30
+ pred = eq.intercept + float(np.array(parent_vals, dtype=float) @ coef_vec)
31
+ noise[node] = obs - pred
32
+ return noise
33
+
34
+
35
+ def counterfactual_predict(
36
+ scm: StructuralCausalModel,
37
+ observed_values: Dict[str, float],
38
+ treatment: str,
39
+ counterfactual_value: float,
40
+ target: str,
41
+ ) -> Dict[str, float]:
42
+ if treatment not in scm.node_to_idx:
43
+ raise ValueError(f"Unknown treatment node: {treatment}")
44
+ if target not in scm.node_to_idx:
45
+ raise ValueError(f"Unknown target node: {target}")
46
+
47
+ noises = abduct_noise(scm, observed_values)
48
+ state = scm.data_level[-1].copy()
49
+ for node, value in observed_values.items():
50
+ if node in scm.node_to_idx:
51
+ state[scm.node_to_idx[node]] = float(value)
52
+
53
+ cf = state.copy()
54
+ t_idx = scm.node_to_idx[treatment]
55
+ cf[t_idx] = float(counterfactual_value)
56
+
57
+ for node_idx in scm.topological_indices:
58
+ node = scm.nodes[node_idx]
59
+ if node_idx == t_idx:
60
+ continue
61
+ eq = scm.equations[node]
62
+ if not eq.parents or eq.equation_type == "exogenous":
63
+ cf[node_idx] = eq.intercept + noises.get(node, 0.0)
64
+ continue
65
+ parent_vals = cf[eq.parent_indices]
66
+ coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
67
+ pred = eq.intercept + float(parent_vals @ coef_vec)
68
+ cf[node_idx] = pred + noises.get(node, 0.0)
69
+
70
+ y_idx = scm.node_to_idx[target]
71
+ factual = float(state[y_idx])
72
+ counterfactual = float(cf[y_idx])
73
+ ite = counterfactual - factual
74
+ pct_change = ite / (abs(factual) + 1e-12)
75
+ return {
76
+ "factual_outcome": factual,
77
+ "counterfactual_outcome": counterfactual,
78
+ "ite": float(ite),
79
+ "pct_change": float(pct_change),
80
+ }
81
+
82
+
83
+ def counterfactual_predict_multi(
84
+ scm: StructuralCausalModel,
85
+ observed_values: Dict[str, float],
86
+ counterfactual_values: Dict[str, float],
87
+ target: str,
88
+ ) -> Dict[str, float]:
89
+ """Compute a joint counterfactual for multiple simultaneous interventions."""
90
+ for node in counterfactual_values:
91
+ if node not in scm.node_to_idx:
92
+ raise ValueError(f"Unknown counterfactual node: {node}")
93
+
94
+ noises = abduct_noise(scm, observed_values)
95
+ state = scm.data_level[-1].copy()
96
+ for node, value in observed_values.items():
97
+ if node in scm.node_to_idx:
98
+ state[scm.node_to_idx[node]] = float(value)
99
+
100
+ cf = state.copy()
101
+ for node, value in counterfactual_values.items():
102
+ cf[scm.node_to_idx[node]] = float(value)
103
+
104
+ for node_idx in scm.topological_indices:
105
+ node = scm.nodes[node_idx]
106
+ if node in counterfactual_values:
107
+ continue
108
+ eq = scm.equations[node]
109
+ if not eq.parents or eq.equation_type == "exogenous":
110
+ cf[node_idx] = eq.intercept + noises.get(node, 0.0)
111
+ continue
112
+ parent_vals = cf[eq.parent_indices]
113
+ coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
114
+ pred = eq.intercept + float(parent_vals @ coef_vec)
115
+ cf[node_idx] = pred + noises.get(node, 0.0)
116
+
117
+ y_idx = scm.node_to_idx[target]
118
+ factual = float(state[y_idx])
119
+ counterfactual = float(cf[y_idx])
120
+ ite = counterfactual - factual
121
+ pct_change = ite / (abs(factual) + 1e-12)
122
+ return {
123
+ "factual_outcome": factual,
124
+ "counterfactual_outcome": counterfactual,
125
+ "ite": float(ite),
126
+ "pct_change": float(pct_change),
127
+ }
singular_ticker_causal/causal_inference/causal_model.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, List, Optional, Sequence, Tuple
5
+
6
+ import numpy as np
7
+
8
+
9
+ @dataclass
10
+ class StructuralEquation:
11
+ node: str
12
+ parents: List[str]
13
+ parent_indices: List[int]
14
+ intercept: float
15
+ coefficients: Dict[str, float]
16
+ residual_mean: float
17
+ residual_std: float
18
+ r_squared: float
19
+ n_obs: int
20
+ equation_type: str # "linear" | "exogenous"
21
+
22
+
23
+ @dataclass
24
+ class StructuralCausalModel:
25
+ nodes: Sequence[str]
26
+ adj: np.ndarray
27
+ adjacency_mask: np.ndarray
28
+ data_tech: np.ndarray
29
+ mask_tech: Optional[np.ndarray] = None
30
+ prohibition_mask: Optional[np.ndarray] = None
31
+ threshold: float = 0.5
32
+ lag: int = 1
33
+ min_obs: int = 12
34
+ ridge_alpha: float = 1e-4
35
+ dag_adj: np.ndarray = field(init=False)
36
+ topological_indices: List[int] = field(init=False, default_factory=list)
37
+ equations: Dict[str, StructuralEquation] = field(init=False, default_factory=dict)
38
+ removed_cycle_edges: List[Dict[str, float]] = field(init=False, default_factory=list)
39
+
40
+ def __post_init__(self) -> None:
41
+ self.nodes = list(self.nodes)
42
+ self.node_to_idx = {node: i for i, node in enumerate(self.nodes)}
43
+ self.data_level = self._extract_level_data(self.data_tech)
44
+ self.mask_level = self._extract_mask_level(self.mask_tech)
45
+ self._validate_shapes()
46
+
47
+ @property
48
+ def n_nodes(self) -> int:
49
+ return len(self.nodes)
50
+
51
+ @property
52
+ def t_steps(self) -> int:
53
+ return self.data_level.shape[0]
54
+
55
+ def fit(self) -> "StructuralCausalModel":
56
+ self.dag_adj = self._build_dag()
57
+ self._enforce_acyclic()
58
+ self.topological_indices = self._topological_sort(self.dag_adj)
59
+ self._fit_equations()
60
+ return self
61
+
62
+ def density(self) -> float:
63
+ n = self.n_nodes
64
+ max_edges = n * (n - 1)
65
+ if max_edges == 0:
66
+ return 0.0
67
+ return float(np.sum(self.dag_adj) / max_edges)
68
+
69
+ def parents_of(self, node: str) -> List[str]:
70
+ j = self.node_to_idx[node]
71
+ return [self.nodes[i] for i in np.where(self.dag_adj[:, j])[0]]
72
+
73
+ def has_directed_path(self, source: str, target: str) -> bool:
74
+ s = self.node_to_idx[source]
75
+ t = self.node_to_idx[target]
76
+ stack = [s]
77
+ visited = set()
78
+ while stack:
79
+ u = stack.pop()
80
+ if u == t:
81
+ return True
82
+ if u in visited:
83
+ continue
84
+ visited.add(u)
85
+ children = np.where(self.dag_adj[u])[0].tolist()
86
+ stack.extend(children)
87
+ return False
88
+
89
+ def descendants_of(self, node: str) -> List[str]:
90
+ start = self.node_to_idx[node]
91
+ stack = [start]
92
+ visited = set()
93
+ while stack:
94
+ u = stack.pop()
95
+ children = np.where(self.dag_adj[u])[0].tolist()
96
+ for v in children:
97
+ if v not in visited:
98
+ visited.add(v)
99
+ stack.append(v)
100
+ visited.discard(start)
101
+ return [self.nodes[i] for i in sorted(visited)]
102
+
103
+ def _validate_shapes(self) -> None:
104
+ n = len(self.nodes)
105
+ if self.adj.shape != (n, n):
106
+ raise ValueError(f"adj shape mismatch: expected {(n, n)}, got {self.adj.shape}")
107
+ if self.adjacency_mask.shape != (n, n):
108
+ raise ValueError(
109
+ f"adjacency_mask shape mismatch: expected {(n, n)}, got {self.adjacency_mask.shape}"
110
+ )
111
+ if self.prohibition_mask is not None and self.prohibition_mask.shape != (n, n):
112
+ raise ValueError(
113
+ f"prohibition_mask shape mismatch: expected {(n, n)}, got {self.prohibition_mask.shape}"
114
+ )
115
+ if self.data_level.ndim != 2 or self.data_level.shape[1] != n:
116
+ raise ValueError(
117
+ f"data_level shape mismatch: expected (T, {n}), got {self.data_level.shape}"
118
+ )
119
+ if self.mask_level is not None and self.mask_level.shape != self.data_level.shape:
120
+ raise ValueError(
121
+ "mask_tech shape mismatch after extraction: expected shape "
122
+ f"{self.data_level.shape}, got {self.mask_level.shape}"
123
+ )
124
+ if self.lag < 1:
125
+ raise ValueError("lag must be >= 1")
126
+
127
+ def _extract_level_data(self, data_tech: np.ndarray) -> np.ndarray:
128
+ if data_tech.ndim == 3:
129
+ return np.asarray(data_tech[:, :, 0], dtype=float)
130
+ if data_tech.ndim == 2:
131
+ return np.asarray(data_tech, dtype=float)
132
+ raise ValueError(f"Unsupported data_tech ndim={data_tech.ndim}; expected 2 or 3.")
133
+
134
+ def _extract_mask_level(self, mask_tech: Optional[np.ndarray]) -> Optional[np.ndarray]:
135
+ if mask_tech is None:
136
+ return None
137
+ if mask_tech.ndim == 3:
138
+ return np.asarray(mask_tech[:, :, 0], dtype=float)
139
+ if mask_tech.ndim == 2:
140
+ return np.asarray(mask_tech, dtype=float)
141
+ raise ValueError(f"Unsupported mask_tech ndim={mask_tech.ndim}; expected 2 or 3.")
142
+
143
+ def _build_dag(self) -> np.ndarray:
144
+ cuts_edges = self.adj >= self.threshold
145
+ prior_edges = self.adjacency_mask > 0
146
+ dag = np.logical_or(cuts_edges, prior_edges)
147
+ if self.prohibition_mask is not None:
148
+ prohibited = self.prohibition_mask <= 0
149
+ dag = np.where(prohibited, False, dag)
150
+ np.fill_diagonal(dag, False)
151
+ return dag.astype(bool)
152
+
153
+ def _enforce_acyclic(self) -> None:
154
+ while True:
155
+ cycle_edges = self._find_cycle_edges(self.dag_adj)
156
+ if not cycle_edges:
157
+ return
158
+
159
+ removable = []
160
+ for src, dst in cycle_edges:
161
+ mandatory = bool(self.adjacency_mask[src, dst] > 0)
162
+ score = float(self.adj[src, dst])
163
+ removable.append((mandatory, score, src, dst))
164
+
165
+ non_mandatory = [r for r in removable if not r[0]]
166
+ choice = min(non_mandatory or removable, key=lambda x: x[1])
167
+ _, score, src, dst = choice
168
+
169
+ self.dag_adj[src, dst] = False
170
+ self.removed_cycle_edges.append(
171
+ {
172
+ "source": self.nodes[src],
173
+ "target": self.nodes[dst],
174
+ "adj_score": score,
175
+ }
176
+ )
177
+
178
+ def _topological_sort(self, dag_adj: np.ndarray) -> List[int]:
179
+ n = dag_adj.shape[0]
180
+ indegree = np.sum(dag_adj, axis=0).astype(int)
181
+ queue = [i for i in range(n) if indegree[i] == 0]
182
+ order: List[int] = []
183
+
184
+ while queue:
185
+ node = queue.pop(0)
186
+ order.append(node)
187
+ children = np.where(dag_adj[node])[0]
188
+ for child in children:
189
+ indegree[child] -= 1
190
+ if indegree[child] == 0:
191
+ queue.append(int(child))
192
+
193
+ if len(order) != n:
194
+ raise RuntimeError("DAG still contains a cycle after pruning.")
195
+ return order
196
+
197
+ def _find_cycle_edges(self, dag_adj: np.ndarray) -> List[Tuple[int, int]]:
198
+ n = dag_adj.shape[0]
199
+ state = np.zeros(n, dtype=int) # 0=unvisited, 1=visiting, 2=done
200
+ parent = -np.ones(n, dtype=int)
201
+
202
+ def dfs(u: int) -> Optional[List[Tuple[int, int]]]:
203
+ state[u] = 1
204
+ for v in np.where(dag_adj[u])[0]:
205
+ v = int(v)
206
+ if state[v] == 0:
207
+ parent[v] = u
208
+ found = dfs(v)
209
+ if found:
210
+ return found
211
+ elif state[v] == 1:
212
+ nodes = [v]
213
+ cur = u
214
+ while cur != v and cur != -1:
215
+ nodes.append(cur)
216
+ cur = int(parent[cur])
217
+ nodes.append(v)
218
+ nodes.reverse()
219
+ return [(nodes[i], nodes[i + 1]) for i in range(len(nodes) - 1)]
220
+ state[u] = 2
221
+ return None
222
+
223
+ for start in range(n):
224
+ if state[start] == 0:
225
+ result = dfs(start)
226
+ if result:
227
+ return result
228
+ return []
229
+
230
+ def _fit_equations(self) -> None:
231
+ T = self.t_steps
232
+ for idx in self.topological_indices:
233
+ node = self.nodes[idx]
234
+ parent_idx = [int(i) for i in np.where(self.dag_adj[:, idx])[0]]
235
+ parent_names = [self.nodes[i] for i in parent_idx]
236
+ valid_t = self._valid_timesteps(idx, parent_idx)
237
+
238
+ if len(valid_t) == 0:
239
+ self.equations[node] = StructuralEquation(
240
+ node=node,
241
+ parents=parent_names,
242
+ parent_indices=parent_idx,
243
+ intercept=0.0,
244
+ coefficients={},
245
+ residual_mean=0.0,
246
+ residual_std=1.0,
247
+ r_squared=0.0,
248
+ n_obs=0,
249
+ equation_type="exogenous",
250
+ )
251
+ continue
252
+
253
+ y = self.data_level[valid_t, idx]
254
+
255
+ if not parent_idx or len(valid_t) < self.min_obs:
256
+ mu = float(np.mean(y))
257
+ residuals = y - mu
258
+ self.equations[node] = StructuralEquation(
259
+ node=node,
260
+ parents=parent_names,
261
+ parent_indices=parent_idx,
262
+ intercept=mu,
263
+ coefficients={},
264
+ residual_mean=float(np.mean(residuals)) if residuals.size else 0.0,
265
+ residual_std=float(np.std(residuals)) if residuals.size else 1.0,
266
+ r_squared=0.0,
267
+ n_obs=int(len(valid_t)),
268
+ equation_type="exogenous",
269
+ )
270
+ continue
271
+
272
+ X = self.data_level[valid_t - self.lag][:, parent_idx]
273
+ coef, intercept = self._fit_ridge(X, y)
274
+ y_hat = intercept + (X @ coef)
275
+ residuals = y - y_hat
276
+ ss_res = float(np.sum(residuals ** 2))
277
+ ss_tot = float(np.sum((y - np.mean(y)) ** 2))
278
+ r2 = 1.0 - (ss_res / ss_tot) if ss_tot > 1e-12 else 0.0
279
+
280
+ coeffs = {name: float(coef[i]) for i, name in enumerate(parent_names)}
281
+ self.equations[node] = StructuralEquation(
282
+ node=node,
283
+ parents=parent_names,
284
+ parent_indices=parent_idx,
285
+ intercept=float(intercept),
286
+ coefficients=coeffs,
287
+ residual_mean=float(np.mean(residuals)),
288
+ residual_std=float(np.std(residuals)),
289
+ r_squared=r2,
290
+ n_obs=int(len(valid_t)),
291
+ equation_type="linear",
292
+ )
293
+
294
+ def _valid_timesteps(self, node_idx: int, parent_indices: List[int]) -> np.ndarray:
295
+ valid_t = np.arange(self.lag, self.t_steps, dtype=int)
296
+ if self.mask_level is None:
297
+ return valid_t
298
+
299
+ valid = self.mask_level[valid_t, node_idx] > 0
300
+ for p_idx in parent_indices:
301
+ valid &= self.mask_level[valid_t - self.lag, p_idx] > 0
302
+ return valid_t[valid]
303
+
304
+ def _fit_ridge(self, X: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, float]:
305
+ X_mean = np.mean(X, axis=0)
306
+ y_mean = float(np.mean(y))
307
+ Xc = X - X_mean
308
+ yc = y - y_mean
309
+
310
+ p = X.shape[1]
311
+ reg = self.ridge_alpha * np.eye(p)
312
+ beta = np.linalg.solve(Xc.T @ Xc + reg, Xc.T @ yc)
313
+ intercept = y_mean - float(X_mean @ beta)
314
+ return beta, intercept
singular_ticker_causal/causal_inference/estimator.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Optional, Set
4
+
5
+ import numpy as np
6
+
7
+ from .causal_model import StructuralCausalModel
8
+ from .mutilator import propagate_intervention
9
+
10
+
11
+ def estimate_ate(
12
+ scm: StructuralCausalModel,
13
+ treatment: str,
14
+ outcome: str,
15
+ treatment_value: Optional[float] = None,
16
+ adjustment_set: Optional[Set[str]] = None,
17
+ horizon: int = 5,
18
+ ) -> Dict[str, object]:
19
+ t_idx = scm.node_to_idx[treatment]
20
+ y_idx = scm.node_to_idx[outcome]
21
+ z_nodes = sorted(adjustment_set or set())
22
+ z_idx = [scm.node_to_idx[z] for z in z_nodes]
23
+
24
+ valid_t = np.arange(scm.lag, scm.t_steps, dtype=int)
25
+ if scm.mask_level is not None:
26
+ valid = (scm.mask_level[valid_t, y_idx] > 0) & (scm.mask_level[valid_t - scm.lag, t_idx] > 0)
27
+ for zi in z_idx:
28
+ valid &= scm.mask_level[valid_t - scm.lag, zi] > 0
29
+ valid_t = valid_t[valid]
30
+
31
+ if len(valid_t) < 5:
32
+ raise ValueError(
33
+ f"Insufficient observations for ATE estimation of {treatment}->{outcome}: {len(valid_t)} rows."
34
+ )
35
+
36
+ y = scm.data_level[valid_t, y_idx]
37
+ x_treat = scm.data_level[valid_t - scm.lag, t_idx]
38
+ X_parts = [np.ones((len(valid_t), 1)), x_treat.reshape(-1, 1)]
39
+ if z_idx:
40
+ X_parts.append(scm.data_level[valid_t - scm.lag][:, z_idx])
41
+ X = np.concatenate(X_parts, axis=1)
42
+
43
+ beta = np.linalg.pinv(X.T @ X) @ (X.T @ y)
44
+ y_hat = X @ beta
45
+ resid = y - y_hat
46
+ dof = max(1, len(y) - X.shape[1])
47
+ sigma2 = float(np.sum(resid ** 2) / dof)
48
+ cov = sigma2 * np.linalg.pinv(X.T @ X)
49
+ se = float(np.sqrt(max(cov[1, 1], 0.0)))
50
+ ate = float(beta[1])
51
+
52
+ baseline = float(np.mean(np.abs(y))) + 1e-12
53
+ ate_normalized = ate / baseline
54
+ ci_95 = (ate - 1.96 * se, ate + 1.96 * se)
55
+
56
+ path_contributions = _path_contributions(scm, treatment, outcome)
57
+
58
+ horizon_effects = []
59
+ if treatment_value is not None:
60
+ baseline_t = scm.t_steps - 1
61
+ shocked = propagate_intervention(scm, treatment, treatment_value, horizon=horizon, baseline_t=baseline_t)
62
+ base_outcome = float(scm.data_level[baseline_t, y_idx])
63
+ for h in range(horizon):
64
+ horizon_effects.append(float(shocked[h, y_idx] - base_outcome))
65
+
66
+ return {
67
+ "ate": ate,
68
+ "ate_normalized": float(ate_normalized),
69
+ "ci_95": (float(ci_95[0]), float(ci_95[1])),
70
+ "n_obs": int(len(valid_t)),
71
+ "path_contributions": path_contributions,
72
+ "horizon_effects": horizon_effects,
73
+ }
74
+
75
+
76
+ def _path_contributions(
77
+ scm: StructuralCausalModel,
78
+ treatment: str,
79
+ outcome: str,
80
+ max_paths: int = 100,
81
+ ) -> Dict[str, float]:
82
+ start = scm.node_to_idx[treatment]
83
+ target = scm.node_to_idx[outcome]
84
+ paths = []
85
+
86
+ def dfs(node: int, path: list[int], seen: set[int]) -> None:
87
+ if len(paths) >= max_paths:
88
+ return
89
+ if node == target:
90
+ paths.append(path.copy())
91
+ return
92
+ for child in np.where(scm.dag_adj[node])[0]:
93
+ child = int(child)
94
+ if child in seen:
95
+ continue
96
+ seen.add(child)
97
+ path.append(child)
98
+ dfs(child, path, seen)
99
+ path.pop()
100
+ seen.remove(child)
101
+
102
+ dfs(start, [start], {start})
103
+
104
+ contributions: Dict[str, float] = {}
105
+ for path in paths:
106
+ coeff_product = 1.0
107
+ valid = True
108
+ for i in range(len(path) - 1):
109
+ src = scm.nodes[path[i]]
110
+ dst = scm.nodes[path[i + 1]]
111
+ eq = scm.equations.get(dst)
112
+ if eq is None:
113
+ valid = False
114
+ break
115
+ coef = eq.coefficients.get(src)
116
+ if coef is None:
117
+ valid = False
118
+ break
119
+ coeff_product *= coef
120
+ if not valid:
121
+ continue
122
+ label = " -> ".join(scm.nodes[i] for i in path)
123
+ contributions[label] = float(coeff_product)
124
+
125
+ return contributions
singular_ticker_causal/causal_inference/identification.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Optional, Set, Tuple
5
+
6
+ from .causal_model import StructuralCausalModel
7
+
8
+
9
+ class IdentificationStrategy(Enum):
10
+ DIRECT = "direct"
11
+ BACKDOOR = "backdoor"
12
+ NOT_IDENTIFIABLE = "not_identifiable"
13
+
14
+
15
+ def find_adjustment_set(
16
+ scm: StructuralCausalModel,
17
+ treatment: str,
18
+ outcome: str,
19
+ ) -> Tuple[IdentificationStrategy, Optional[Set[str]]]:
20
+ if treatment not in scm.node_to_idx:
21
+ raise ValueError(f"Unknown treatment node: {treatment}")
22
+ if outcome not in scm.node_to_idx:
23
+ raise ValueError(f"Unknown outcome node: {outcome}")
24
+ if treatment == outcome:
25
+ return IdentificationStrategy.DIRECT, set()
26
+
27
+ if not scm.has_directed_path(treatment, outcome):
28
+ return IdentificationStrategy.NOT_IDENTIFIABLE, None
29
+
30
+ parents = set(scm.parents_of(treatment))
31
+ descendants = set(scm.descendants_of(treatment))
32
+ adjustment = parents - descendants - {outcome}
33
+
34
+ if adjustment:
35
+ return IdentificationStrategy.BACKDOOR, adjustment
36
+ return IdentificationStrategy.DIRECT, set()
37
+
38
+
39
+ def is_identifiable(scm: StructuralCausalModel, treatment: str, outcome: str) -> bool:
40
+ strategy, _ = find_adjustment_set(scm, treatment, outcome)
41
+ return strategy != IdentificationStrategy.NOT_IDENTIFIABLE
singular_ticker_causal/causal_inference/mutilator.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Dict, Optional, Sequence
5
+
6
+ import numpy as np
7
+
8
+ from .causal_model import StructuralCausalModel
9
+
10
+
11
+ def mutilate_graph(scm: StructuralCausalModel, treatment: str, value: float) -> StructuralCausalModel:
12
+ mutilated = deepcopy(scm)
13
+ t_idx = mutilated.node_to_idx[treatment]
14
+ mutilated.dag_adj[:, t_idx] = False
15
+ mutilated.pinned_values = {treatment: float(value)}
16
+ return mutilated
17
+
18
+
19
+ def propagate_intervention(
20
+ scm: StructuralCausalModel,
21
+ treatment: str,
22
+ value: float,
23
+ targets: Optional[Sequence[str]] = None,
24
+ horizon: int = 5,
25
+ baseline_t: int = -1,
26
+ ) -> np.ndarray:
27
+ if horizon < 1:
28
+ raise ValueError("horizon must be >= 1")
29
+
30
+ baseline_idx = baseline_t if baseline_t >= 0 else (scm.t_steps + baseline_t)
31
+ if baseline_idx < 0 or baseline_idx >= scm.t_steps:
32
+ raise ValueError(f"baseline_t {baseline_t} resolves out of bounds for T={scm.t_steps}")
33
+
34
+ t_idx = scm.node_to_idx[treatment]
35
+ prev = scm.data_level[baseline_idx].copy()
36
+ forecasts = np.zeros((horizon, scm.n_nodes), dtype=float)
37
+
38
+ for h in range(horizon):
39
+ nxt = prev.copy()
40
+ nxt[t_idx] = float(value)
41
+ for node_idx in scm.topological_indices:
42
+ if node_idx == t_idx:
43
+ continue
44
+ node = scm.nodes[node_idx]
45
+ eq = scm.equations[node]
46
+ if not eq.parent_indices or eq.equation_type == "exogenous":
47
+ continue
48
+ parent_vals = prev[eq.parent_indices]
49
+ coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
50
+ nxt[node_idx] = eq.intercept + float(parent_vals @ coef_vec)
51
+ forecasts[h] = nxt
52
+ prev = nxt
53
+
54
+ if targets:
55
+ missing = [n for n in targets if n not in scm.node_to_idx]
56
+ if missing:
57
+ raise ValueError(f"Unknown targets: {missing}")
58
+ return forecasts
59
+
60
+
61
+ def target_series(forecasts: np.ndarray, scm: StructuralCausalModel, targets: Sequence[str]) -> Dict[str, list[float]]:
62
+ return {t: [float(v) for v in forecasts[:, scm.node_to_idx[t]]] for t in targets}
singular_ticker_causal/causal_inference/pywhyllm_assumptions.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from dataclasses import asdict, dataclass, field
7
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
8
+
9
+ import numpy as np
10
+
11
+
12
+ def _json_safe(value: Any) -> Any:
13
+ if isinstance(value, np.ndarray):
14
+ return value.tolist()
15
+ if isinstance(value, (np.integer,)):
16
+ return int(value)
17
+ if isinstance(value, (np.floating,)):
18
+ return float(value)
19
+ if isinstance(value, dict):
20
+ return {str(k): _json_safe(v) for k, v in value.items()}
21
+ if isinstance(value, (list, tuple, set)):
22
+ return [_json_safe(v) for v in value]
23
+ if isinstance(value, (str, int, float, bool)) or value is None:
24
+ return value
25
+ return str(value)
26
+
27
+
28
+ @dataclass
29
+ class PyWhyLLMConfig:
30
+ enabled: bool = False
31
+ model: str = "gpt-4"
32
+ max_edges: int = 25
33
+ cache_dir: str = os.path.join(
34
+ os.path.dirname(__file__),
35
+ "..",
36
+ "debug_data",
37
+ "pywhyllm_cache",
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class CausalAssumptionReport:
43
+ available: bool = True
44
+ reason: Optional[str] = None
45
+ domain_expertises: List[str] = field(default_factory=list)
46
+ suggested_confounders: List[str] = field(default_factory=list)
47
+ suggested_backdoor_sets: List[List[str]] = field(default_factory=list)
48
+ suggested_mediators: List[str] = field(default_factory=list)
49
+ suggested_ivs: List[str] = field(default_factory=list)
50
+ negative_controls: List[str] = field(default_factory=list)
51
+ latent_confounders: List[str] = field(default_factory=list)
52
+ edge_critiques: Any = field(default_factory=list)
53
+ accepted_edges: List[Tuple[str, str]] = field(default_factory=list)
54
+ rejected_edges: List[Tuple[str, str]] = field(default_factory=list)
55
+ warnings: List[str] = field(default_factory=list)
56
+
57
+ def to_dict(self) -> Dict[str, Any]:
58
+ payload = asdict(self)
59
+ payload["accepted_edges"] = [list(edge) for edge in self.accepted_edges]
60
+ payload["rejected_edges"] = [list(edge) for edge in self.rejected_edges]
61
+ return _json_safe(payload)
62
+
63
+ @classmethod
64
+ def from_dict(cls, payload: Dict[str, Any]) -> "CausalAssumptionReport":
65
+ data = dict(payload)
66
+ data["accepted_edges"] = [tuple(edge) for edge in data.get("accepted_edges", [])]
67
+ data["rejected_edges"] = [tuple(edge) for edge in data.get("rejected_edges", [])]
68
+ return cls(**data)
69
+
70
+
71
+ def _dedupe_strings(values: Iterable[Any]) -> List[str]:
72
+ seen = set()
73
+ result: List[str] = []
74
+ for value in values or []:
75
+ text = str(value).strip()
76
+ if text and text not in seen:
77
+ seen.add(text)
78
+ result.append(text)
79
+ return result
80
+
81
+
82
+ def _normalise_suggestion(value: Any) -> List[str]:
83
+ if value is None:
84
+ return []
85
+ if isinstance(value, tuple) and len(value) == 2:
86
+ return _normalise_suggestion(value[1])
87
+ if isinstance(value, dict):
88
+ keys = [k for k, v in value.items() if isinstance(k, str) and v]
89
+ if keys:
90
+ return _dedupe_strings(keys)
91
+ flattened: List[str] = []
92
+ for item in value.values():
93
+ flattened.extend(_normalise_suggestion(item))
94
+ return _dedupe_strings(flattened)
95
+ if isinstance(value, (list, set, tuple)):
96
+ flattened = []
97
+ for item in value:
98
+ if isinstance(item, (list, set, tuple, dict)):
99
+ flattened.extend(_normalise_suggestion(item))
100
+ else:
101
+ flattened.append(item)
102
+ return _dedupe_strings(flattened)
103
+ return _dedupe_strings([value])
104
+
105
+
106
+ class PyWhyLLMAssumptionService:
107
+ def __init__(
108
+ self,
109
+ config: Optional[PyWhyLLMConfig] = None,
110
+ model_suggester: Any = None,
111
+ identification_suggester: Any = None,
112
+ validation_suggester: Any = None,
113
+ relationship_strategy: Any = None,
114
+ ):
115
+ self.config = config or PyWhyLLMConfig()
116
+ self._model_suggester = model_suggester
117
+ self._identification_suggester = identification_suggester
118
+ self._validation_suggester = validation_suggester
119
+ self._relationship_strategy = relationship_strategy
120
+
121
+ @property
122
+ def enabled(self) -> bool:
123
+ return bool(self.config.enabled)
124
+
125
+ def analyze(
126
+ self,
127
+ *,
128
+ nodes: Sequence[str],
129
+ dag_adj: np.ndarray,
130
+ treatment: str,
131
+ outcome: str,
132
+ max_edges: Optional[int] = None,
133
+ ) -> CausalAssumptionReport:
134
+ if not self.enabled:
135
+ return CausalAssumptionReport(
136
+ available=False,
137
+ reason="PyWhy-LLM is disabled. Set PYWHYLLM_ENABLED=true or pass pywhyllm_enabled=True.",
138
+ )
139
+
140
+ cache_path = self._cache_path(nodes, dag_adj, treatment, outcome)
141
+ cached = self._read_cache(cache_path)
142
+ if cached is not None:
143
+ return cached
144
+
145
+ try:
146
+ modeler, identifier, validator, relationship_strategy = self._suggesters()
147
+ except Exception as exc:
148
+ return CausalAssumptionReport(
149
+ available=False,
150
+ reason=f"PyWhy-LLM is not installed or failed to initialize: {exc}",
151
+ )
152
+
153
+ all_factors = list(nodes)
154
+ edges = self._edges(nodes, dag_adj, max_edges or self.config.max_edges)
155
+ warnings: List[str] = []
156
+
157
+ domain_expertises: List[str] = []
158
+ suggested_confounders: List[str] = []
159
+ suggested_backdoor_sets: List[List[str]] = []
160
+ suggested_mediators: List[str] = []
161
+ suggested_ivs: List[str] = []
162
+ negative_controls: List[str] = []
163
+ latent_confounders: List[str] = []
164
+ edge_critiques: Any = []
165
+ suggested_dag: Any = edges
166
+
167
+ try:
168
+ domain_expertises = _normalise_suggestion(modeler.suggest_domain_expertises(all_factors))
169
+ except Exception as exc:
170
+ warnings.append(f"domain_expertises failed: {exc}")
171
+
172
+ try:
173
+ suggested_confounders = _normalise_suggestion(
174
+ modeler.suggest_confounders(treatment, outcome, all_factors, domain_expertises)
175
+ )
176
+ except Exception as exc:
177
+ warnings.append(f"confounder suggestion failed: {exc}")
178
+
179
+ try:
180
+ suggested_dag = modeler.suggest_relationships(
181
+ treatment,
182
+ outcome,
183
+ all_factors,
184
+ domain_expertises,
185
+ relationship_strategy,
186
+ )
187
+ except Exception as exc:
188
+ warnings.append(f"relationship suggestion failed: {exc}")
189
+
190
+ try:
191
+ backdoor = identifier.suggest_backdoor(treatment, outcome, all_factors, domain_expertises)
192
+ backdoor_nodes = _normalise_suggestion(backdoor)
193
+ if backdoor_nodes:
194
+ suggested_backdoor_sets = [backdoor_nodes]
195
+ except Exception as exc:
196
+ warnings.append(f"backdoor suggestion failed: {exc}")
197
+
198
+ try:
199
+ suggested_mediators = _normalise_suggestion(
200
+ identifier.suggest_mediators(treatment, outcome, all_factors, domain_expertises)
201
+ )
202
+ except Exception as exc:
203
+ warnings.append(f"mediator suggestion failed: {exc}")
204
+
205
+ try:
206
+ suggested_ivs = _normalise_suggestion(
207
+ identifier.suggest_ivs(treatment, outcome, all_factors, domain_expertises)
208
+ )
209
+ except Exception as exc:
210
+ warnings.append(f"iv suggestion failed: {exc}")
211
+
212
+ try:
213
+ edge_critiques = validator.critique_graph(
214
+ all_factors,
215
+ suggested_dag,
216
+ domain_expertises,
217
+ relationship_strategy,
218
+ )
219
+ except Exception as exc:
220
+ warnings.append(f"edge critique failed: {exc}")
221
+
222
+ try:
223
+ latent_confounders = _normalise_suggestion(
224
+ validator.suggest_latent_confounders(treatment, outcome, all_factors, domain_expertises)
225
+ )
226
+ except Exception as exc:
227
+ warnings.append(f"latent confounder suggestion failed: {exc}")
228
+
229
+ try:
230
+ negative_controls = _normalise_suggestion(
231
+ validator.suggest_negative_controls(treatment, outcome, all_factors, domain_expertises)
232
+ )
233
+ except Exception as exc:
234
+ warnings.append(f"negative control suggestion failed: {exc}")
235
+
236
+ accepted_edges, rejected_edges = self._classify_edges(edges, edge_critiques)
237
+ report = CausalAssumptionReport(
238
+ available=True,
239
+ domain_expertises=domain_expertises,
240
+ suggested_confounders=suggested_confounders,
241
+ suggested_backdoor_sets=suggested_backdoor_sets,
242
+ suggested_mediators=suggested_mediators,
243
+ suggested_ivs=suggested_ivs,
244
+ negative_controls=negative_controls,
245
+ latent_confounders=latent_confounders,
246
+ edge_critiques=edge_critiques,
247
+ accepted_edges=accepted_edges,
248
+ rejected_edges=rejected_edges,
249
+ warnings=warnings,
250
+ )
251
+ self._write_cache(cache_path, report)
252
+ return report
253
+
254
+ def _suggesters(self) -> Tuple[Any, Any, Any, Any]:
255
+ if self._model_suggester and self._identification_suggester and self._validation_suggester:
256
+ return (
257
+ self._model_suggester,
258
+ self._identification_suggester,
259
+ self._validation_suggester,
260
+ self._relationship_strategy,
261
+ )
262
+
263
+ from pywhyllm import RelationshipStrategy
264
+ from pywhyllm.suggesters.identification_suggester import IdentificationSuggester
265
+ from pywhyllm.suggesters.model_suggester import ModelSuggester
266
+ from pywhyllm.suggesters.validation_suggester import ValidationSuggester
267
+
268
+ relationship_strategy = self._relationship_strategy or RelationshipStrategy.Pairwise
269
+ return (
270
+ self._model_suggester or ModelSuggester(self.config.model),
271
+ self._identification_suggester or IdentificationSuggester(self.config.model),
272
+ self._validation_suggester or ValidationSuggester(self.config.model),
273
+ relationship_strategy,
274
+ )
275
+
276
+ def _cache_path(
277
+ self,
278
+ nodes: Sequence[str],
279
+ dag_adj: np.ndarray,
280
+ treatment: str,
281
+ outcome: str,
282
+ ) -> str:
283
+ payload = {
284
+ "nodes": list(nodes),
285
+ "dag_adj": np.asarray(dag_adj, dtype=int).tolist(),
286
+ "treatment": treatment,
287
+ "outcome": outcome,
288
+ "model": self.config.model,
289
+ }
290
+ digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
291
+ return os.path.join(os.path.abspath(self.config.cache_dir), f"{digest}.json")
292
+
293
+ def _read_cache(self, path: str) -> Optional[CausalAssumptionReport]:
294
+ if not os.path.exists(path):
295
+ return None
296
+ try:
297
+ with open(path) as f:
298
+ return CausalAssumptionReport.from_dict(json.load(f))
299
+ except Exception:
300
+ return None
301
+
302
+ def _write_cache(self, path: str, report: CausalAssumptionReport) -> None:
303
+ try:
304
+ os.makedirs(os.path.dirname(path), exist_ok=True)
305
+ with open(path, "w") as f:
306
+ json.dump(report.to_dict(), f, indent=2)
307
+ except Exception:
308
+ pass
309
+
310
+ def _edges(self, nodes: Sequence[str], dag_adj: np.ndarray, max_edges: int) -> List[Tuple[str, str]]:
311
+ found: List[Tuple[str, str, float]] = []
312
+ for src_idx, src in enumerate(nodes):
313
+ for dst_idx, dst in enumerate(nodes):
314
+ if bool(dag_adj[src_idx, dst_idx]):
315
+ found.append((src, dst, float(dag_adj[src_idx, dst_idx])))
316
+ found.sort(key=lambda edge: abs(edge[2]), reverse=True)
317
+ return [(src, dst) for src, dst, _ in found[:max_edges]]
318
+
319
+ def _classify_edges(
320
+ self,
321
+ edges: List[Tuple[str, str]],
322
+ edge_critiques: Any,
323
+ ) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
324
+ critique_text = str(edge_critiques).lower()
325
+ rejected: List[Tuple[str, str]] = []
326
+ for edge in edges:
327
+ src, dst = edge
328
+ edge_tokens = [
329
+ f"{src}->{dst}".lower(),
330
+ f"{src} -> {dst}".lower(),
331
+ f"{src}, {dst}".lower(),
332
+ ]
333
+ if any(token in critique_text for token in edge_tokens) and any(
334
+ marker in critique_text for marker in ["reject", "unlikely", "invalid", "implausible"]
335
+ ):
336
+ rejected.append(edge)
337
+ accepted = [edge for edge in edges if edge not in rejected]
338
+ return accepted, rejected
singular_ticker_causal/causal_inference/query_engine.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import Any, Dict, List, Optional, Sequence
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from .abduction import counterfactual_predict, counterfactual_predict_multi
11
+ from .causal_model import StructuralCausalModel
12
+ from .estimator import estimate_ate
13
+ from .identification import find_adjustment_set
14
+ from .mutilator import mutilate_graph, propagate_intervention, target_series
15
+ from .pywhyllm_assumptions import PyWhyLLMConfig, PyWhyLLMAssumptionService
16
+
17
+
18
+ class CausalQueryEngine:
19
+ def __init__(
20
+ self,
21
+ scm: StructuralCausalModel,
22
+ data_tech: Optional[np.ndarray] = None,
23
+ data_text: Optional[np.ndarray] = None,
24
+ pywhyllm_service: Optional[PyWhyLLMAssumptionService] = None,
25
+ pywhyllm_enabled: bool = False,
26
+ ):
27
+ self.scm = scm
28
+ self.data_tech = data_tech
29
+ self.data_text = data_text
30
+ self.pywhyllm_enabled = pywhyllm_enabled
31
+ self.pywhyllm_service = pywhyllm_service
32
+
33
+ def assert_edge(self, treatment: str, outcome: str) -> Dict[str, object]:
34
+ strategy, z = find_adjustment_set(self.scm, treatment, outcome)
35
+ if strategy.value == "not_identifiable":
36
+ return {
37
+ "ate": 0.0,
38
+ "ci_95": (0.0, 0.0),
39
+ "strategy": strategy.name,
40
+ "adjustment_set": [],
41
+ "identifiable": False,
42
+ "path_contributions": {},
43
+ }
44
+
45
+ est = estimate_ate(
46
+ self.scm,
47
+ treatment=treatment,
48
+ outcome=outcome,
49
+ adjustment_set=z,
50
+ treatment_value=None,
51
+ )
52
+ return {
53
+ "ate": est["ate"],
54
+ "ci_95": est["ci_95"],
55
+ "strategy": strategy.name,
56
+ "adjustment_set": sorted(z or set()),
57
+ "identifiable": True,
58
+ "path_contributions": est["path_contributions"],
59
+ }
60
+
61
+ def intervene(self, treatment: str, value: float, targets: List[str], horizon: int = 5) -> Dict[str, object]:
62
+ mutilated = mutilate_graph(self.scm, treatment=treatment, value=value)
63
+ forecasts = propagate_intervention(
64
+ mutilated,
65
+ treatment=treatment,
66
+ value=value,
67
+ targets=targets,
68
+ horizon=horizon,
69
+ baseline_t=-1,
70
+ )
71
+ predicted = target_series(forecasts, self.scm, targets)
72
+ ate_per_target = {}
73
+ for target in targets:
74
+ tidx = self.scm.node_to_idx[target]
75
+ base = float(self.scm.data_level[-1, tidx])
76
+ ate_per_target[target] = float(forecasts[0, tidx] - base)
77
+ return {
78
+ "mutilated_adj": mutilated.dag_adj.copy(),
79
+ "predicted_values": predicted,
80
+ "ate_per_target": ate_per_target,
81
+ "horizon": horizon,
82
+ }
83
+
84
+ def _counterfactual_outcome(
85
+ self,
86
+ observed: Dict[str, float],
87
+ counterfactual_values: Dict[str, float],
88
+ target: str,
89
+ ) -> Dict[str, float]:
90
+ if len(counterfactual_values) == 1:
91
+ treatment, value = next(iter(counterfactual_values.items()))
92
+ return counterfactual_predict(
93
+ self.scm,
94
+ observed_values=observed,
95
+ treatment=treatment,
96
+ counterfactual_value=value,
97
+ target=target,
98
+ )
99
+ return counterfactual_predict_multi(
100
+ self.scm,
101
+ observed_values=observed,
102
+ counterfactual_values=counterfactual_values,
103
+ target=target,
104
+ )
105
+
106
+ def _shapley_contributions(
107
+ self,
108
+ observed: Dict[str, float],
109
+ interventions: Dict[str, float],
110
+ target: str,
111
+ mc_samples: int = 1000,
112
+ n_jobs: int = -1,
113
+ ) -> Dict[str, float]:
114
+ import random
115
+ from math import factorial
116
+ from joblib import Parallel, delayed
117
+ import os
118
+
119
+ treatments = list(interventions.keys())
120
+ n = len(treatments)
121
+ if n == 1:
122
+ return {treatments[0]: self._counterfactual_outcome(observed, interventions, target)["ite"]}
123
+
124
+ contributions: Dict[str, float] = {t: 0.0 for t in treatments}
125
+
126
+ # Use exact if n <= 10, else Monte Carlo
127
+ use_exact = n <= 10
128
+ if n_jobs < 0:
129
+ n_jobs = os.cpu_count() or 4
130
+
131
+ def marginal_contribution(k: str, subset: list[str]) -> float:
132
+ with_k = {**{t: interventions[t] for t in subset}, k: interventions[k]}
133
+ without_k = {t: interventions[t] for t in subset}
134
+ v_with = self._counterfactual_outcome(observed, with_k, target)["counterfactual_outcome"]
135
+ v_without = self._counterfactual_outcome(observed, without_k, target)["counterfactual_outcome"]
136
+ return v_with - v_without
137
+
138
+ if use_exact:
139
+ all_factorial = float(factorial(n))
140
+ tasks = []
141
+
142
+ for k in treatments:
143
+ others = [t for t in treatments if t != k]
144
+ for r in range(len(others) + 1):
145
+ for subset in __import__("itertools").combinations(others, r):
146
+ subset_list = list(subset)
147
+ weight = float(factorial(len(subset_list)) * factorial(n - len(subset_list) - 1) / all_factorial)
148
+ tasks.append((k, subset_list, weight))
149
+
150
+ results = Parallel(n_jobs=n_jobs, backend="threading")(
151
+ delayed(marginal_contribution)(task[0], task[1]) for task in tasks
152
+ )
153
+ for task, res in zip(tasks, results):
154
+ k, _, weight = task
155
+ contributions[k] += weight * res
156
+ else:
157
+ # Monte Carlo approximation
158
+ tasks = []
159
+ for _ in range(mc_samples):
160
+ perm = treatments.copy()
161
+ random.shuffle(perm)
162
+ for i, k in enumerate(perm):
163
+ subset_list = perm[:i]
164
+ tasks.append((k, subset_list))
165
+
166
+ results = Parallel(n_jobs=n_jobs, backend="threading")(
167
+ delayed(marginal_contribution)(task[0], task[1]) for task in tasks
168
+ )
169
+ for task, res in zip(tasks, results):
170
+ k, _ = task
171
+ contributions[k] += res / mc_samples
172
+
173
+ return contributions
174
+
175
+ def _build_dowhy_graph(self) -> str:
176
+ edges = []
177
+ for src_idx, src in enumerate(self.scm.nodes):
178
+ for dst_idx, dst in enumerate(self.scm.nodes):
179
+ if self.scm.dag_adj[src_idx, dst_idx]:
180
+ edges.append(f"{src} -> {dst}")
181
+ return "digraph{" + "; ".join(edges) + "}"
182
+
183
+ def _default_pywhyllm_service(self) -> PyWhyLLMAssumptionService:
184
+ enabled = self.pywhyllm_enabled or os.environ.get("PYWHYLLM_ENABLED", "").lower() in {
185
+ "1",
186
+ "true",
187
+ "yes",
188
+ "on",
189
+ }
190
+ return PyWhyLLMAssumptionService(
191
+ PyWhyLLMConfig(
192
+ enabled=enabled,
193
+ model=os.environ.get("PYWHYLLM_MODEL", "gpt-4"),
194
+ max_edges=int(os.environ.get("PYWHYLLM_MAX_EDGES", "25")),
195
+ cache_dir=os.environ.get(
196
+ "PYWHYLLM_CACHE_DIR",
197
+ PyWhyLLMConfig().cache_dir,
198
+ ),
199
+ )
200
+ )
201
+
202
+ def _get_pywhyllm_service(self) -> PyWhyLLMAssumptionService:
203
+ if self.pywhyllm_service is None:
204
+ self.pywhyllm_service = self._default_pywhyllm_service()
205
+ return self.pywhyllm_service
206
+
207
+ def analyze_assumptions_with_pywhyllm(
208
+ self,
209
+ treatment: str,
210
+ outcome: str,
211
+ max_edges: Optional[int] = None,
212
+ ) -> Dict[str, object]:
213
+ report = self._get_pywhyllm_service().analyze(
214
+ nodes=self.scm.nodes,
215
+ dag_adj=self.scm.dag_adj,
216
+ treatment=treatment,
217
+ outcome=outcome,
218
+ max_edges=max_edges,
219
+ )
220
+ return report.to_dict()
221
+
222
+ def _valid_nodes(self, candidates: Sequence[Any]) -> List[str]:
223
+ valid = set(self.scm.nodes)
224
+ result = []
225
+ for candidate in candidates or []:
226
+ node = str(candidate).strip()
227
+ if node in valid and node not in result:
228
+ result.append(node)
229
+ return result
230
+
231
+ def _valid_backdoor_sets(self, report: Dict[str, Any], treatment: str, outcome: str) -> List[List[str]]:
232
+ result = []
233
+ blocked = {treatment, outcome}
234
+ for suggested_set in report.get("suggested_backdoor_sets") or []:
235
+ valid_set = [node for node in self._valid_nodes(suggested_set) if node not in blocked]
236
+ if valid_set and valid_set not in result:
237
+ result.append(valid_set)
238
+ confounders = [node for node in self._valid_nodes(report.get("suggested_confounders") or []) if node not in blocked]
239
+ if confounders and confounders not in result:
240
+ result.append(confounders)
241
+ return result
242
+
243
+ def _coerce_causal_model(self, causal_model_cls: Any, data: pd.DataFrame, treatment: str, outcome: str, graph: str):
244
+ try:
245
+ return causal_model_cls(df=data, treatment=treatment, outcome=outcome, graph=graph)
246
+ except TypeError:
247
+ return causal_model_cls(data=data, treatment=treatment, outcome=outcome, graph=graph)
248
+
249
+ def _parse_p_value(self, value: Any) -> Optional[float]:
250
+ if value is None:
251
+ return None
252
+ if isinstance(value, (int, float, np.floating)):
253
+ return float(value)
254
+ if isinstance(value, (list, tuple)) and value:
255
+ return self._parse_p_value(value[0])
256
+ if isinstance(value, dict):
257
+ for key in ("p_value", "p-value", "p value"):
258
+ if key in value:
259
+ return self._parse_p_value(value[key])
260
+ return None
261
+ match = re.search(r"p[-_ ]?value[^0-9<>=-]*[<>=: ]+\s*([0-9]*\.?[0-9]+)", str(value), re.I)
262
+ if match:
263
+ return float(match.group(1))
264
+ return None
265
+
266
+ def _as_optional_float(self, value: Any) -> Optional[float]:
267
+ if value is None:
268
+ return None
269
+ try:
270
+ arr = np.asarray(value, dtype=float)
271
+ if arr.size == 1:
272
+ return float(arr.reshape(-1)[0])
273
+ except Exception:
274
+ pass
275
+ try:
276
+ return float(value)
277
+ except Exception:
278
+ return None
279
+
280
+ def _parse_refuter_result(self, method: str, refute: Any, alpha: float = 0.05) -> Dict[str, object]:
281
+ text = str(refute)
282
+ result_attr = getattr(refute, "refutation_result", None)
283
+ estimated_effect = getattr(refute, "estimated_effect", None)
284
+ new_effect = getattr(refute, "new_effect", None)
285
+ p_value = self._parse_p_value(result_attr)
286
+ if p_value is None:
287
+ p_value = self._parse_p_value(text)
288
+ lower_text = text.lower()
289
+
290
+ if method == "placebo_treatment":
291
+ if p_value is not None:
292
+ falsified = p_value < alpha
293
+ elif "not statistically significant" in lower_text:
294
+ falsified = False
295
+ elif "statistically significant" in lower_text:
296
+ falsified = True
297
+ else:
298
+ falsified = False
299
+ else:
300
+ if p_value is not None:
301
+ falsified = p_value < alpha
302
+ elif "not statistically significant" in lower_text:
303
+ falsified = False
304
+ elif "statistically significant" in lower_text:
305
+ falsified = True
306
+ else:
307
+ falsified = False
308
+
309
+ return {
310
+ "method": method,
311
+ "refute": text,
312
+ "estimated_effect": self._as_optional_float(estimated_effect),
313
+ "new_effect": self._as_optional_float(new_effect),
314
+ "p_value": p_value,
315
+ "passed": not falsified,
316
+ "falsified": falsified,
317
+ }
318
+
319
+ def _run_dowhy_validation(
320
+ self,
321
+ causal_model_cls: Any,
322
+ treatment: str,
323
+ outcome: str,
324
+ treatment_value: float = 1.0,
325
+ adjustment_candidates: Optional[List[List[str]]] = None,
326
+ negative_controls: Optional[List[str]] = None,
327
+ ) -> Dict[str, object]:
328
+ data = pd.DataFrame(self.scm.data_level, columns=self.scm.nodes)
329
+ graph = self._build_dowhy_graph()
330
+ model = self._coerce_causal_model(causal_model_cls, data, treatment, outcome, graph)
331
+ identified_estimand = model.identify_effect()
332
+ estimate = model.estimate_effect(identified_estimand, method_name="backdoor.linear_regression")
333
+
334
+ refutations = []
335
+ for method in ["placebo_treatment", "random_common_cause"]:
336
+ try:
337
+ kwargs = {"method_name": method}
338
+ if method == "placebo_treatment":
339
+ kwargs["placebo_type"] = "permute"
340
+ refute = model.refute_estimate(identified_estimand, estimate, **kwargs)
341
+ refutations.append(self._parse_refuter_result(method, refute))
342
+ except TypeError:
343
+ try:
344
+ kwargs = {"method_name": method}
345
+ if method == "placebo_treatment":
346
+ kwargs["placebo_type"] = "permute"
347
+ refute = model.refute_estimate(estimate, **kwargs)
348
+ refutations.append(self._parse_refuter_result(method, refute))
349
+ except Exception as exc:
350
+ refutations.append({"method": method, "error": str(exc), "passed": False, "falsified": False})
351
+ except Exception as exc:
352
+ refutations.append({"method": method, "error": str(exc), "passed": False, "falsified": False})
353
+
354
+ negative_control_checks = []
355
+ for control in negative_controls or []:
356
+ if control in {treatment, outcome}:
357
+ continue
358
+ try:
359
+ nc_model = self._coerce_causal_model(causal_model_cls, data, treatment, control, graph)
360
+ nc_identified = nc_model.identify_effect()
361
+ nc_estimate = nc_model.estimate_effect(nc_identified, method_name="backdoor.linear_regression")
362
+ negative_control_checks.append(
363
+ {
364
+ "control": control,
365
+ "identified_estimand": str(nc_identified),
366
+ "estimate": str(nc_estimate),
367
+ }
368
+ )
369
+ except Exception as exc:
370
+ negative_control_checks.append({"control": control, "error": str(exc)})
371
+
372
+ falsified = any(bool(item.get("falsified")) for item in refutations)
373
+ return {
374
+ "available": True,
375
+ "falsified": falsified,
376
+ "identified_estimand": str(identified_estimand),
377
+ "estimate": str(estimate),
378
+ "refutations": refutations,
379
+ "adjustment_candidates": adjustment_candidates or [],
380
+ "negative_control_checks": negative_control_checks,
381
+ }
382
+
383
+ def validate_with_dowhy(
384
+ self,
385
+ treatment: str,
386
+ outcome: str,
387
+ treatment_value: float = 1.0,
388
+ num_placebo: int = 5,
389
+ ) -> Dict[str, object]:
390
+ try:
391
+ from dowhy import CausalModel
392
+ except Exception as exc:
393
+ return {
394
+ "available": False,
395
+ "reason": str(exc),
396
+ "falsified": False,
397
+ "summary": "DoWhy is not installed or failed to import.",
398
+ }
399
+
400
+ try:
401
+ return self._run_dowhy_validation(
402
+ CausalModel,
403
+ treatment=treatment,
404
+ outcome=outcome,
405
+ treatment_value=treatment_value,
406
+ )
407
+ except Exception as exc:
408
+ return {
409
+ "available": False,
410
+ "reason": str(exc),
411
+ "falsified": False,
412
+ "summary": "DoWhy refutation failed.",
413
+ }
414
+
415
+ def validate_with_pywhyllm_and_dowhy(
416
+ self,
417
+ treatment: str,
418
+ outcome: str,
419
+ treatment_value: float = 1.0,
420
+ max_edges: Optional[int] = None,
421
+ ) -> Dict[str, object]:
422
+ warnings: List[str] = []
423
+ pywhyllm_report = self.analyze_assumptions_with_pywhyllm(treatment, outcome, max_edges=max_edges)
424
+ adjustment_candidates = self._valid_backdoor_sets(pywhyllm_report, treatment, outcome)
425
+ negative_controls = [
426
+ node
427
+ for node in self._valid_nodes(pywhyllm_report.get("negative_controls") or [])
428
+ if node not in {treatment, outcome}
429
+ ]
430
+
431
+ try:
432
+ from dowhy import CausalModel
433
+ except Exception as exc:
434
+ return {
435
+ "pywhyllm": pywhyllm_report,
436
+ "dowhy": {
437
+ "available": False,
438
+ "reason": str(exc),
439
+ "falsified": False,
440
+ "summary": "DoWhy is not installed or failed to import.",
441
+ "adjustment_candidates": adjustment_candidates,
442
+ "negative_controls": negative_controls,
443
+ },
444
+ "falsified": False,
445
+ "warnings": warnings,
446
+ }
447
+
448
+ try:
449
+ dowhy_report = self._run_dowhy_validation(
450
+ CausalModel,
451
+ treatment=treatment,
452
+ outcome=outcome,
453
+ treatment_value=treatment_value,
454
+ adjustment_candidates=adjustment_candidates,
455
+ negative_controls=negative_controls,
456
+ )
457
+ except Exception as exc:
458
+ dowhy_report = {
459
+ "available": False,
460
+ "reason": str(exc),
461
+ "falsified": False,
462
+ "summary": "DoWhy refutation failed.",
463
+ "adjustment_candidates": adjustment_candidates,
464
+ "negative_controls": negative_controls,
465
+ }
466
+
467
+ warnings.extend(pywhyllm_report.get("warnings") or [])
468
+ return {
469
+ "pywhyllm": pywhyllm_report,
470
+ "dowhy": dowhy_report,
471
+ "falsified": bool(dowhy_report.get("falsified")),
472
+ "warnings": warnings,
473
+ }
474
+
475
+ def counterfactual(
476
+ self,
477
+ observed_t: int,
478
+ treatment: Optional[str] = None,
479
+ cf_value: Optional[float] = None,
480
+ target: str = "",
481
+ treatments: Optional[Dict[str, float]] = None,
482
+ ) -> Dict[str, object]:
483
+ t = observed_t if observed_t >= 0 else (self.scm.t_steps + observed_t)
484
+ if t < 0 or t >= self.scm.t_steps:
485
+ raise ValueError(f"observed_t {observed_t} resolves out of bounds for T={self.scm.t_steps}")
486
+
487
+ if treatments is None:
488
+ if treatment is None or cf_value is None:
489
+ raise ValueError("Either treatment/cf_value or treatments must be provided.")
490
+ treatments = {treatment: cf_value}
491
+ elif treatment is not None or cf_value is not None:
492
+ raise ValueError("Provide either treatment/cf_value or treatments, not both.")
493
+
494
+ observed = {node: float(self.scm.data_level[t, i]) for i, node in enumerate(self.scm.nodes)}
495
+ result = self._counterfactual_outcome(observed, treatments, target)
496
+ result["explanation"] = (
497
+ f"Counterfactual computed at t={t}: set {treatments} "
498
+ f"and propagated structural equations with abducted residuals."
499
+ )
500
+ if len(treatments) > 1:
501
+ result["shapley_contributions"] = self._shapley_contributions(observed, treatments, target)
502
+ else:
503
+ result["shapley_contributions"] = {next(iter(treatments)): result["ite"]}
504
+ result["ite_total"] = result["ite"]
505
+ return result
singular_ticker_causal/causal_inference/tests/test_causal_queries.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib.util
2
+ import numpy as np
3
+
4
+ from causal_inference.causal_model import StructuralCausalModel
5
+ from causal_inference.query_engine import CausalQueryEngine
6
+
7
+
8
+ def _synthetic_inputs():
9
+ nodes = ["A", "B", "C"]
10
+ T = 40
11
+ data = np.zeros((T, 3, 1), dtype=float)
12
+ rng = np.random.default_rng(42)
13
+
14
+ a = rng.normal(0, 1, size=T)
15
+ b = 0.6 * np.roll(a, 1) + rng.normal(0, 0.1, size=T)
16
+ c = 0.7 * np.roll(b, 1) + rng.normal(0, 0.1, size=T)
17
+ b[0] = rng.normal()
18
+ c[0] = rng.normal()
19
+ data[:, 0, 0] = a
20
+ data[:, 1, 0] = b
21
+ data[:, 2, 0] = c
22
+
23
+ # A->B, B->C and a weak cycle C->A to exercise pruning.
24
+ adj = np.array(
25
+ [
26
+ [0.0, 0.9, 0.0],
27
+ [0.0, 0.0, 0.8],
28
+ [0.1, 0.0, 0.0],
29
+ ],
30
+ dtype=float,
31
+ )
32
+ prior = np.zeros((3, 3), dtype=float)
33
+ mask = np.ones((T, 3), dtype=float)
34
+ return nodes, adj, prior, data, mask
35
+
36
+
37
+ def test_scm_fit_and_cycle_prune():
38
+ nodes, adj, prior, data, mask = _synthetic_inputs()
39
+ scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
40
+ assert scm.dag_adj.shape == (3, 3)
41
+ assert len(scm.topological_indices) == 3
42
+ assert np.sum(scm.dag_adj) <= 2
43
+
44
+
45
+ def test_intervention_and_counterfactual_shapes():
46
+ nodes, adj, prior, data, mask = _synthetic_inputs()
47
+ scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
48
+ engine = CausalQueryEngine(scm)
49
+
50
+ inter = engine.intervene("A", value=0.5, targets=["B", "C"], horizon=3)
51
+ assert set(inter["predicted_values"].keys()) == {"B", "C"}
52
+ assert len(inter["predicted_values"]["B"]) == 3
53
+
54
+ cf = engine.counterfactual(observed_t=-1, treatment="A", cf_value=0.8, target="C")
55
+ assert "factual_outcome" in cf
56
+ assert "counterfactual_outcome" in cf
57
+ assert "ite" in cf
58
+
59
+
60
+ def test_counterfactual_shapley_contributions():
61
+ nodes, adj, prior, data, mask = _synthetic_inputs()
62
+ scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
63
+ engine = CausalQueryEngine(scm)
64
+
65
+ cf = engine.counterfactual(
66
+ observed_t=-1,
67
+ treatments={"A": 0.8, "B": -0.3},
68
+ target="C",
69
+ )
70
+
71
+ assert "shapley_contributions" in cf
72
+ assert set(cf["shapley_contributions"].keys()) == {"A", "B"}
73
+ assert abs(sum(cf["shapley_contributions"].values()) - cf["ite"]) < 1e-6
74
+
75
+
76
+ def test_dowhy_validation_is_optional():
77
+ nodes, adj, prior, data, mask = _synthetic_inputs()
78
+ scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
79
+ engine = CausalQueryEngine(scm)
80
+
81
+ result = engine.validate_with_dowhy(treatment="A", outcome="C")
82
+ assert "available" in result
83
+ if importlib.util.find_spec("dowhy") is None:
84
+ assert result["available"] is False
85
+ else:
86
+ assert "falsified" in result
87
+
88
+
89
+ def test_exogenous_fallback_prevention():
90
+ nodes, adj, prior, data, mask = _synthetic_inputs()
91
+ # Force fallback to exogenous by setting min_obs to a very large number (e.g. 100)
92
+ scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask, min_obs=100).fit()
93
+ engine = CausalQueryEngine(scm)
94
+
95
+ inter = engine.intervene("A", value=0.5, targets=["B", "C"], horizon=3)
96
+ assert set(inter["predicted_values"].keys()) == {"B", "C"}
97
+ assert len(inter["predicted_values"]["B"]) == 3
98
+
99
+ cf = engine.counterfactual(observed_t=-1, treatment="A", cf_value=0.8, target="C")
100
+ assert "factual_outcome" in cf
101
+ assert "counterfactual_outcome" in cf
102
+ assert "ite" in cf
singular_ticker_causal/causal_inference/tests/test_pywhyllm_assumptions.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import builtins
2
+ import sys
3
+ import types
4
+
5
+ import numpy as np
6
+
7
+ from singular_ticker_causal.causal_inference.causal_model import StructuralCausalModel
8
+ from singular_ticker_causal.causal_inference.pywhyllm_assumptions import (
9
+ PyWhyLLMConfig,
10
+ PyWhyLLMAssumptionService,
11
+ )
12
+ from singular_ticker_causal.causal_inference.query_engine import CausalQueryEngine
13
+
14
+
15
+ def _synthetic_scm():
16
+ nodes = ["A", "B", "C"]
17
+ data = np.zeros((24, 3, 1), dtype=float)
18
+ rng = np.random.default_rng(11)
19
+ data[:, 0, 0] = rng.normal(size=24)
20
+ data[:, 1, 0] = rng.normal(size=24)
21
+ data[:, 2, 0] = rng.normal(size=24)
22
+ adj = np.array(
23
+ [
24
+ [0.0, 0.8, 0.7],
25
+ [0.0, 0.0, 0.9],
26
+ [0.0, 0.0, 0.0],
27
+ ],
28
+ dtype=float,
29
+ )
30
+ prior = np.ones((3, 3), dtype=float)
31
+ np.fill_diagonal(prior, 0.0)
32
+ return StructuralCausalModel(
33
+ nodes=nodes,
34
+ adj=adj,
35
+ adjacency_mask=np.zeros((3, 3), dtype=float),
36
+ prohibition_mask=prior,
37
+ data_tech=data,
38
+ ).fit()
39
+
40
+
41
+ class FakeModelSuggester:
42
+ def suggest_domain_expertises(self, all_factors):
43
+ return ["financial accounting"]
44
+
45
+ def suggest_confounders(self, treatment, outcome, all_factors, domain_expertises):
46
+ return ({("B", treatment): 1, ("B", outcome): 1}, ["B", "Missing_Node"])
47
+
48
+ def suggest_relationships(self, treatment, outcome, all_factors, domain_expertises, strategy):
49
+ return [(treatment, outcome), ("B", outcome)]
50
+
51
+
52
+ class FakeIdentificationSuggester:
53
+ def suggest_backdoor(self, treatment, outcome, all_factors, domain_expertises):
54
+ return ["B", "Missing_Node"]
55
+
56
+ def suggest_mediators(self, treatment, outcome, all_factors, domain_expertises):
57
+ return ["B"]
58
+
59
+ def suggest_ivs(self, treatment, outcome, all_factors, domain_expertises):
60
+ return ["A"]
61
+
62
+
63
+ class FakeValidationSuggester:
64
+ def critique_graph(self, all_factors, suggested_dag, domain_expertises, strategy):
65
+ return "A -> C accepted; B -> C implausible, reject"
66
+
67
+ def suggest_latent_confounders(self, treatment, outcome, all_factors, domain_expertises):
68
+ return ["market regime"]
69
+
70
+ def suggest_negative_controls(self, treatment, outcome, all_factors, domain_expertises):
71
+ return ["B", "Missing_Node"]
72
+
73
+
74
+ def _fake_service(tmp_path):
75
+ return PyWhyLLMAssumptionService(
76
+ PyWhyLLMConfig(enabled=True, cache_dir=str(tmp_path)),
77
+ model_suggester=FakeModelSuggester(),
78
+ identification_suggester=FakeIdentificationSuggester(),
79
+ validation_suggester=FakeValidationSuggester(),
80
+ relationship_strategy="pairwise",
81
+ )
82
+
83
+
84
+ def test_pywhyllm_assumption_service_uses_fakes_and_cache(tmp_path):
85
+ scm = _synthetic_scm()
86
+ service = _fake_service(tmp_path)
87
+
88
+ report = service.analyze(nodes=scm.nodes, dag_adj=scm.dag_adj, treatment="A", outcome="C")
89
+
90
+ assert report.available is True
91
+ assert report.domain_expertises == ["financial accounting"]
92
+ assert report.suggested_confounders == ["B", "Missing_Node"]
93
+ assert report.suggested_backdoor_sets == [["B", "Missing_Node"]]
94
+ assert report.negative_controls == ["B", "Missing_Node"]
95
+ assert ("B", "C") in report.rejected_edges
96
+
97
+ cached = service.analyze(nodes=scm.nodes, dag_adj=scm.dag_adj, treatment="A", outcome="C")
98
+ assert cached.to_dict() == report.to_dict()
99
+
100
+
101
+ def test_missing_pywhyllm_returns_unavailable(monkeypatch, tmp_path):
102
+ original_import = builtins.__import__
103
+
104
+ def fake_import(name, *args, **kwargs):
105
+ if name.startswith("pywhyllm"):
106
+ raise ImportError("blocked pywhyllm")
107
+ return original_import(name, *args, **kwargs)
108
+
109
+ monkeypatch.setattr(builtins, "__import__", fake_import)
110
+ scm = _synthetic_scm()
111
+ service = PyWhyLLMAssumptionService(PyWhyLLMConfig(enabled=True, cache_dir=str(tmp_path)))
112
+
113
+ report = service.analyze(nodes=scm.nodes, dag_adj=scm.dag_adj, treatment="A", outcome="C")
114
+
115
+ assert report.available is False
116
+ assert "blocked pywhyllm" in report.reason
117
+
118
+
119
+ def test_engine_filters_pywhyllm_adjustments_and_negative_controls(tmp_path):
120
+ scm = _synthetic_scm()
121
+ engine = CausalQueryEngine(scm, pywhyllm_service=_fake_service(tmp_path), pywhyllm_enabled=True)
122
+
123
+ report = engine.analyze_assumptions_with_pywhyllm("A", "C")
124
+ adjustment_sets = engine._valid_backdoor_sets(report, "A", "C")
125
+ negative_controls = engine._valid_nodes(report["negative_controls"])
126
+
127
+ assert adjustment_sets == [["B"]]
128
+ assert negative_controls == ["B"]
129
+
130
+
131
+ def test_placebo_not_statistically_significant_passes():
132
+ scm = _synthetic_scm()
133
+ engine = CausalQueryEngine(scm)
134
+
135
+ parsed = engine._parse_refuter_result(
136
+ "placebo_treatment",
137
+ "Refute: Use a Placebo Treatment. The result is not statistically significant.",
138
+ )
139
+
140
+ assert parsed["passed"] is True
141
+ assert parsed["falsified"] is False
142
+
143
+
144
+ def test_combined_validation_reports_unavailable_dowhy(monkeypatch, tmp_path):
145
+ monkeypatch.setitem(sys.modules, "dowhy", None)
146
+ scm = _synthetic_scm()
147
+ engine = CausalQueryEngine(scm, pywhyllm_service=_fake_service(tmp_path), pywhyllm_enabled=True)
148
+
149
+ result = engine.validate_with_pywhyllm_and_dowhy("A", "C")
150
+
151
+ assert result["pywhyllm"]["available"] is True
152
+ assert result["dowhy"]["available"] is False
153
+ assert result["dowhy"]["adjustment_candidates"] == [["B"]]
154
+
155
+
156
+ def test_combined_validation_uses_fake_dowhy(monkeypatch, tmp_path):
157
+ class FakeRefute:
158
+ estimated_effect = 1.0
159
+ new_effect = 0.0
160
+ refutation_result = {"p_value": 0.8}
161
+
162
+ def __str__(self):
163
+ return "not statistically significant"
164
+
165
+ class FakeEstimate:
166
+ def __str__(self):
167
+ return "estimate"
168
+
169
+ class FakeCausalModel:
170
+ def __init__(self, **kwargs):
171
+ self.kwargs = kwargs
172
+
173
+ def identify_effect(self):
174
+ return "estimand"
175
+
176
+ def estimate_effect(self, identified_estimand, method_name):
177
+ return FakeEstimate()
178
+
179
+ def refute_estimate(self, identified_estimand, estimate, **kwargs):
180
+ return FakeRefute()
181
+
182
+ fake_dowhy = types.ModuleType("dowhy")
183
+ fake_dowhy.CausalModel = FakeCausalModel
184
+ monkeypatch.setitem(sys.modules, "dowhy", fake_dowhy)
185
+
186
+ scm = _synthetic_scm()
187
+ engine = CausalQueryEngine(scm, pywhyllm_service=_fake_service(tmp_path), pywhyllm_enabled=True)
188
+
189
+ result = engine.validate_with_pywhyllm_and_dowhy("A", "C")
190
+
191
+ assert result["dowhy"]["available"] is True
192
+ assert result["dowhy"]["falsified"] is False
193
+ assert result["dowhy"]["negative_control_checks"][0]["control"] == "B"
singular_ticker_causal/data_sources/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .fetcher import Fetcher
2
+ from .gdelt_client import GDELTClient
3
+ from .news_client import NewsClient
4
+ from .sebi_reg30_client import SEBIREG30Client
singular_ticker_causal/data_sources/bsedata/__init__.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2018 - 2024 Shrey Dabhi
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+ """
26
+
27
+ name = "bsedata"
singular_ticker_causal/data_sources/bsedata/bhavcopy.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import csv
4
+ import requests
5
+ import tempfile
6
+ import datetime
7
+ from zipfile import ZipFile
8
+ from .exceptions import BhavCopyNotFound
9
+ from .helpers import COMMON_REQUEST_HEADERS
10
+
11
+
12
+ def loadBhavCopyData(statsDate: datetime.date) -> list:
13
+ tempDir = os.path.join(tempfile.gettempdir(), "bsedata")
14
+ zipfileName = f"EQ{statsDate.strftime('%d%m%y')}_CSV.ZIP"
15
+ r = requests.get(
16
+ f"https://www.bseindia.com/download/BhavCopy/Equity/{zipfileName}",
17
+ headers=COMMON_REQUEST_HEADERS,
18
+ )
19
+
20
+ if r.status_code != 200:
21
+ raise BhavCopyNotFound()
22
+
23
+ try:
24
+ os.makedirs(tempDir)
25
+ except FileExistsError:
26
+ pass
27
+
28
+ f_zip = open(os.path.join(tempDir, zipfileName), "wb+")
29
+ f_zip.write(r.content)
30
+ f_zip.close()
31
+
32
+ output = []
33
+
34
+ with ZipFile(os.path.join(tempDir, zipfileName)) as bhavCopyZip:
35
+ with bhavCopyZip.open(f"EQ{statsDate.strftime('%d%m%y')}.CSV") as bhavCopyFile:
36
+ reader = csv.DictReader(io.TextIOWrapper(bhavCopyFile))
37
+ for row in reader:
38
+ output.append(mapBhavCopyRowToDict(row))
39
+
40
+ return output
41
+
42
+
43
+ def mapBhavCopyRowToDict(row: dict) -> dict:
44
+ SC_TYPE_MAP = {"B": "bond", "Q": "equity", "D": "debenture", "P": "preference"}
45
+ return {
46
+ "scripCode": row["SC_CODE"],
47
+ "open": row["OPEN"],
48
+ "high": row["HIGH"],
49
+ "low": row["LOW"],
50
+ "close": row["CLOSE"],
51
+ "last": row["LAST"],
52
+ "prevClose": row["PREVCLOSE"],
53
+ "totalTrades": row["NO_TRADES"],
54
+ "totalSharesTraded": row["NO_OF_SHRS"],
55
+ "netTurnover": row["NET_TURNOV"],
56
+ "scripType": SC_TYPE_MAP[row["SC_TYPE"]],
57
+ "securityID": row["SC_NAME"].strip(),
58
+ }
singular_ticker_causal/data_sources/bsedata/bse.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2018 - 2024 Shrey Dabhi
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+ """
26
+
27
+ from .gainers import getGainers
28
+ from .losers import getLosers
29
+ from .bhavcopy import loadBhavCopyData
30
+ from .quote import quote
31
+ from .indices import indices
32
+ import datetime
33
+ import requests
34
+ import json
35
+
36
+
37
+ class BSE(object):
38
+ """
39
+ Class which implements the functionality for
40
+ Bombay Stock Exchange (BSE)
41
+ """
42
+
43
+ def __init__(self, update_codes=False):
44
+ self.__update_codes = update_codes
45
+ if update_codes:
46
+ self.updateScripCodes()
47
+
48
+ def topGainers(self):
49
+ """
50
+ :returns: A sorted list of codes of top gainers
51
+ """
52
+ return getGainers()
53
+
54
+ def topLosers(self):
55
+ """
56
+ :returns: A sorted list of codes of top losers
57
+ """
58
+ return getLosers()
59
+
60
+ def getQuote(self, scripCode):
61
+ """
62
+ :param scripCode: A stock code
63
+ :returns: A dictionary which contain details about the stock
64
+ :raises InvalidStockException: Raised for stocks which have been suspended or no longer trading on BSE
65
+ """
66
+ return quote(scripCode)
67
+
68
+ def getIndices(self, category):
69
+ """
70
+ :param category: A category of indices
71
+ :returns: A dictionary with details about the indices belonging to the given category
72
+ """
73
+ return indices(category)
74
+
75
+ def updateScripCodes(self):
76
+ """
77
+ Download a fresh copy of the scrip code listing
78
+
79
+ :returns: None
80
+ """
81
+ r = requests.get("https://pub-87b187a07d9c42109c9e6999439a583f.r2.dev/stk.json")
82
+ f_stk = open("stk.json", "w+")
83
+ f_stk.write(json.dumps(r.json()))
84
+ f_stk.close()
85
+ return
86
+
87
+ def getBhavCopyData(self, statsDate: datetime.date):
88
+ """
89
+ Get historical OHLCV data from Bhav Copy released by BSE everyday after market closing.
90
+ The columns available in the data and their description is as given below.
91
+
92
+ .. list-table::
93
+ :widths: 25 75
94
+ :header-rows: 1
95
+
96
+ * - Dictionary Field
97
+ - Description
98
+ * - scripCode
99
+ - Unique code assigned to a scrip of a company by BSE
100
+ * - open
101
+ - The price at which the security first trades on a given trading day
102
+ * - high
103
+ - The highest intra-day price of a stock
104
+ * - low
105
+ - The lowest intra-day price of a stock
106
+ * - close
107
+ - The final price at which a security is traded on a given trading day
108
+ * - last
109
+ - The last trade price of the stock
110
+ * - prevClose
111
+ - The closing price of the stock for the previous trading day
112
+ * - totalTrades
113
+ - The total number of trades of a scrip
114
+ * - totalSharesTraded
115
+ - The total number of shares transacted of a scrip
116
+ * - netTurnover
117
+ - Total turnover of a scrip
118
+ * - scripType
119
+ - Scrip category: Equity, Preference, Debenture or Bond
120
+ * - securityID
121
+ - Name of the company
122
+
123
+ The Bhav Copy files have been mapped to the above mentioned custom fields. The complete documentation for Bhav Copy can be found here: https://www.bseindia.com/markets/MarketInfo/BhavCopy.aspx.
124
+
125
+
126
+ :param statsDate: A `datetime.date` object for the for which you want to fetch the data
127
+ :returns: A list of dictionaries which contains OHLCV data for that day for all scrip codes active on that day
128
+ :raises BhavCopyNotFound: Raised when Bhav Copy file is not found on BSE
129
+ """
130
+ return loadBhavCopyData(statsDate)
131
+
132
+ def getScripCodes(self):
133
+ """
134
+ :returns: A dictionary with scrip codes as keys and company names as values
135
+ """
136
+ f = open("stk.json", "r")
137
+ return json.loads(f.read())
138
+
139
+ def verifyScripCode(self, code):
140
+ """
141
+ :returns: Company name if it is a valid stock code, else None
142
+ """
143
+ data = self.getScripCodes()
144
+ return data.get(code)
145
+
146
+ def __str__(self):
147
+ return "Driver Class for Bombay Stock Exchange (BSE)"
148
+
149
+ def __repr__(self):
150
+ return f"<{self.__class__.__name__}: update_codes={self.__update_codes}> Driver Class for Bombay Stock Exchange (BSE)"
singular_ticker_causal/data_sources/bsedata/exceptions.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2018 - 2024 Shrey Dabhi
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+ """
26
+
27
+
28
+ class InvalidStockException(Exception):
29
+ """
30
+ Exception raised for stocks which have been suspended or no longer trading on BSE.
31
+
32
+ :param status: the status of the stock as mentioned on BSE website
33
+ """
34
+
35
+ def __init__(self, status: str = "Inactive stock"):
36
+ if status == "":
37
+ self.status = "Inactive stock"
38
+ else:
39
+ self.status = status
40
+ super().__init__(self.status)
41
+
42
+
43
+ class BhavCopyNotFound(Exception):
44
+ """
45
+ Exception raised when the BhavCopy file is not found on BSE website.
46
+ """
47
+
48
+ def __init__(self):
49
+ super().__init__(
50
+ """The BhavCopy file was not found on the BSE website. You are probably trying to get data for a trading holiday."""
51
+ )
singular_ticker_causal/data_sources/bsedata/gainers.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2018 - 2024 Shrey Dabhi
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+ """
26
+
27
+ from .helpers import COMMON_REQUEST_HEADERS
28
+ from bs4 import BeautifulSoup as bs
29
+ import requests
30
+
31
+
32
+ def getGainers() -> dict:
33
+ baseurl = """https://m.bseindia.com"""
34
+ res = requests.get(baseurl, headers=COMMON_REQUEST_HEADERS)
35
+ c = res.content
36
+ soup = bs(c, "lxml")
37
+ for tag in soup("div"):
38
+ try:
39
+ if tag["id"] == "divGainers":
40
+ resSoup = tag
41
+ break
42
+ except KeyError:
43
+ continue
44
+ children = list(resSoup.table.contents)
45
+ children = children[1:]
46
+ gainers = []
47
+ for tr in children:
48
+ td = tr.contents
49
+ gainer = {
50
+ "securityID": str(td[0].a.string),
51
+ "scripCode": str(tr.td.a["href"].split("=")[1]),
52
+ "LTP": str(td[1].string),
53
+ "change": str(td[2].string),
54
+ "pChange": str(td[3].string),
55
+ }
56
+ gainers.append(gainer)
57
+ return gainers
singular_ticker_causal/data_sources/bsedata/helpers.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ COMMON_REQUEST_HEADERS = {
2
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36 Edg/83.0.478.45"
3
+ }
singular_ticker_causal/data_sources/bsedata/indices.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2018 - 2024 Shrey Dabhi
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+ """
26
+
27
+ from .helpers import COMMON_REQUEST_HEADERS
28
+ from bs4 import BeautifulSoup as bs
29
+ import requests
30
+
31
+
32
+ def indices(category: str) -> dict:
33
+ cat = {
34
+ "market_cap/broad": "1,2",
35
+ "sector_and_industry": "2,2",
36
+ "thematics": "3,2",
37
+ "strategy": "4,2",
38
+ "sustainability": "5,2",
39
+ "volatility": "6,1",
40
+ "composite": "7,1",
41
+ "government": "8,1",
42
+ "corporate": "9,1",
43
+ "money_market": "10,1",
44
+ }
45
+ try:
46
+ ddl_category = cat[category]
47
+ except KeyError:
48
+ print(
49
+ """
50
+ ### Invalid category ###
51
+ Use one of the categories mentioned below:
52
+
53
+ market_cap/broad
54
+ sector_and_industry
55
+ thematics
56
+ strategy
57
+ sustainability
58
+ volatility
59
+ composite
60
+ government
61
+ corporate
62
+ money_market
63
+ """
64
+ )
65
+ return
66
+ baseurl = """https://m.bseindia.com/IndicesView_New.aspx"""
67
+ res = requests.get(baseurl, headers=COMMON_REQUEST_HEADERS)
68
+ c = res.content
69
+ soup = bs(c, "lxml")
70
+ options = {
71
+ "__EVENTTARGET": "ddl_Category",
72
+ "__VIEWSTATEENCRYPTED": "",
73
+ "__EVENTARGUMENT": "",
74
+ "__LASTFOCUS": "",
75
+ "__VIEWSTATEGENERATOR": "162C96CD",
76
+ "UcHeaderMenu1$txtGetQuote": "",
77
+ "__EVENTVALIDATION": "",
78
+ "__VIEWSTATE": "",
79
+ }
80
+ for input in soup("input"):
81
+ try:
82
+ if input["type"] == "hidden":
83
+ if input["id"] == "__VIEWSTATE":
84
+ options["__VIEWSTATE"] = input["value"]
85
+ elif input["id"] == "__EVENTVALIDATION":
86
+ options["__EVENTVALIDATION"] = input["value"]
87
+ except KeyError:
88
+ continue
89
+ options["ddl_Category"] = ddl_category
90
+ res = requests.post(url=baseurl, data=options, headers=COMMON_REQUEST_HEADERS)
91
+ c = res.content
92
+ soup = bs(c, "lxml")
93
+ index_list = []
94
+ for td in soup("td"):
95
+ try:
96
+ if td["class"][0] == "TTRow_left":
97
+ index = {}
98
+ index["currentValue"] = td.next_sibling.string.strip()
99
+ index["change"] = td.next_sibling.next_sibling.string.strip()
100
+ index[
101
+ "pChange"
102
+ ] = td.next_sibling.next_sibling.next_sibling.string.strip()
103
+ index["scripFlag"] = td.a["href"].strip().split("=")[1]
104
+ index["name"] = td.a.string.strip().replace(";", "")
105
+ index_list.append(index)
106
+ except KeyError:
107
+ continue
108
+ results = {}
109
+ for span in soup("span", id="inddate"):
110
+ results["updatedOn"] = span.string[6:].split("|")[0].strip()
111
+ results["indices"] = index_list
112
+ return results
singular_ticker_causal/data_sources/bsedata/losers.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2018 - 2024 Shrey Dabhi
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+ """
26
+
27
+ from .helpers import COMMON_REQUEST_HEADERS
28
+ from bs4 import BeautifulSoup as bs
29
+ import requests
30
+
31
+
32
+ def getLosers() -> dict:
33
+ baseurl = """https://m.bseindia.com"""
34
+ res = requests.get(baseurl, headers=COMMON_REQUEST_HEADERS)
35
+ c = res.content
36
+ soup = bs(c, "lxml")
37
+ for tag in soup("div"):
38
+ try:
39
+ if tag["id"] == "divLosers":
40
+ resSoup = tag
41
+ break
42
+ except KeyError:
43
+ continue
44
+ children = list(resSoup.table.contents)
45
+ children = children[1:]
46
+ losers = []
47
+ for tr in children:
48
+ td = tr.contents
49
+ loser = {
50
+ "securityID": str(td[0].a.string),
51
+ "scripCode": str(tr.td.a["href"].split("=")[1]),
52
+ "LTP": str(td[1].string),
53
+ "change": str(td[2].string),
54
+ "pChange": str(td[3].string),
55
+ }
56
+ losers.append(loser)
57
+ return losers
singular_ticker_causal/data_sources/bsedata/quote.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2018 - 2024 Shrey Dabhi
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+
25
+ """
26
+
27
+ from .exceptions import InvalidStockException
28
+ from .helpers import COMMON_REQUEST_HEADERS
29
+ from datetime import datetime as dt
30
+ from bs4 import BeautifulSoup as bs
31
+ import requests
32
+
33
+
34
+ def quote(scripCode: str) -> dict:
35
+ baseurl = """https://m.bseindia.com/StockReach.aspx?scripcd="""
36
+ res = requests.get(baseurl + scripCode, headers=COMMON_REQUEST_HEADERS)
37
+ c = res.content
38
+ soup = bs(c, "lxml")
39
+
40
+ res = {}
41
+
42
+ for span in soup("span"):
43
+ updt_date = soup.find("span", id="strongDate").text.split("-")[1].strip()
44
+ updt_diff = dt.strptime(updt_date, "%d %b %y | %I:%M %p") - dt.now()
45
+ if updt_diff.days < -7:
46
+ error_text = ""
47
+ error_text_element = soup.find("td", id="tdDispTxt")
48
+ if error_text_element is not None:
49
+ error_text = error_text_element.text
50
+ raise InvalidStockException(status=error_text)
51
+ try:
52
+ if span["class"][0] == "srcovalue":
53
+ try:
54
+ if span["id"] == "spanchangVal":
55
+ res["change"] = span.string.split("(")[0].strip()
56
+ res["pChange"] = span.string.split("(")[1].strip()[:-2]
57
+ except KeyError:
58
+ res["currentValue"] = span.strong.string
59
+ elif span["class"][0] == "companyname":
60
+ res["companyName"] = span.string
61
+ except KeyError:
62
+ try:
63
+ if span["id"] == "lblPBdate":
64
+ try:
65
+ res["priceBand"] = span.string.split(":")[1].strip()
66
+ except AttributeError:
67
+ res["priceBand"] = ""
68
+ elif span["id"] == "strongDate":
69
+ res["updatedOn"] = span.string.split("-")[1].strip()
70
+ except KeyError:
71
+ continue
72
+
73
+ for td in soup("td"):
74
+ try:
75
+ if td["id"] == "tdCShortName":
76
+ res["securityID"] = td.string.strip()
77
+ elif td["id"] == "tdscripcode":
78
+ res["scripCode"] = td.string.strip()
79
+ elif td["id"] == "tdgroup":
80
+ res["group"] = td.string.strip()
81
+ elif td["id"] == "tdfacevalue":
82
+ res["faceValue"] = td.string.strip()
83
+ elif td["id"] == "tdIndustry":
84
+ res["industry"] = td.string.strip()
85
+ elif td["id"] == "tdpcloseopen":
86
+ res["previousClose"] = td.string.split("/")[0].strip()
87
+ res["previousOpen"] = td.string.split("/")[1].strip()
88
+ elif td["id"] == "tdDHL":
89
+ res["dayHigh"] = td.string.split("/")[0].strip()
90
+ res["dayLow"] = td.string.split("/")[1].strip()
91
+ elif td["id"] == "td52WHL":
92
+ res["52weekHigh"] = td.string.split("/")[0].strip()
93
+ res["52weekLow"] = td.string.split("/")[1].strip()
94
+ elif td["id"] == "tdWAp":
95
+ res["weightedAvgPrice"] = td.string.strip()
96
+ elif td["id"] == "tdTTV":
97
+ res["totalTradedValue"] = td.string.strip() + " Cr."
98
+ elif td["id"] == "tdTTQW":
99
+ res["totalTradedQuantity"] = td.string.split("/")[0].strip() + " Lakh"
100
+ res["2WeekAvgQuantity"] = td.string.split("/")[1].strip() + " Lakh"
101
+ elif td["id"] == "tdMktCapVal":
102
+ res["marketCapFull"] = td.string.split("/")[0].strip() + " Cr."
103
+ res["marketCapFreeFloat"] = td.string.split("/")[1].strip() + " Cr."
104
+ except KeyError:
105
+ continue
106
+
107
+ if res.get("priceBand", "") != "":
108
+ for tbody in soup("tbody"):
109
+ try:
110
+ if tbody["id"] == "PBtablebody":
111
+ data = tbody.contents[2]
112
+ res["upperPriceBand"] = data.contents[1].string.strip()
113
+ res["lowerPriceBand"] = data.contents[2].string.strip()
114
+ except KeyError:
115
+ continue
116
+
117
+ buy = {}
118
+ sell = {}
119
+ for td in soup("td"):
120
+ try:
121
+ if td["id"] == "tdBQ1":
122
+ buy["1"] = {
123
+ "quantity": td.string,
124
+ "price": td.next_sibling.next_sibling.string,
125
+ }
126
+ elif td["id"] == "tdBQ2":
127
+ buy["2"] = {
128
+ "quantity": td.string,
129
+ "price": td.next_sibling.next_sibling.string,
130
+ }
131
+ elif td["id"] == "tdBQ3":
132
+ buy["3"] = {
133
+ "quantity": td.string,
134
+ "price": td.next_sibling.next_sibling.string,
135
+ }
136
+ elif td["id"] == "tdBQ4":
137
+ buy["4"] = {
138
+ "quantity": td.string,
139
+ "price": td.next_sibling.next_sibling.string,
140
+ }
141
+ elif td["id"] == "tdBQ5":
142
+ buy["5"] = {
143
+ "quantity": td.string,
144
+ "price": td.next_sibling.next_sibling.string,
145
+ }
146
+ elif td["id"] == "tdSP1":
147
+ sell["1"] = {
148
+ "price": td.string,
149
+ "quantity": td.next_sibling.next_sibling.string,
150
+ }
151
+ elif td["id"] == "tdSP2":
152
+ sell["2"] = {
153
+ "price": td.string,
154
+ "quantity": td.next_sibling.next_sibling.string,
155
+ }
156
+ elif td["id"] == "tdSP3":
157
+ sell["3"] = {
158
+ "price": td.string,
159
+ "quantity": td.next_sibling.next_sibling.string,
160
+ }
161
+ elif td["id"] == "tdSP4":
162
+ sell["4"] = {
163
+ "price": td.string,
164
+ "quantity": td.next_sibling.next_sibling.string,
165
+ }
166
+ elif td["id"] == "tdSP5":
167
+ sell["5"] = {
168
+ "price": td.string,
169
+ "quantity": td.next_sibling.next_sibling.string,
170
+ }
171
+ except KeyError:
172
+ continue
173
+ res["buy"] = buy
174
+ res["sell"] = sell
175
+
176
+ return res
singular_ticker_causal/data_sources/fetcher.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import numpy as np
3
+ import yfinance as yf
4
+ import logging
5
+ from typing import Optional
6
+ from singular_ticker_causal.services.schema import INCOME_STATEMENT_NODES, BALANCE_SHEET_NODES, STRATEGIC_NODES
7
+ IND_AS_NODES = INCOME_STATEMENT_NODES + BALANCE_SHEET_NODES + STRATEGIC_NODES
8
+ from .nseconnect.nse import Nse
9
+ from .bsedata.bse import BSE
10
+
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class Fetcher:
16
+ """
17
+ Tiered fundamental data fetcher.
18
+ Priority: NSE/BSE → XBRL → IndianAPI → yfinance.
19
+ """
20
+ def __init__(self, ticker: str, api_key: Optional[str] = None):
21
+ self.ticker = ticker if ticker.endswith(".NS") or ticker.endswith(".BO") else f"{ticker}.NS"
22
+ self.api_key = api_key
23
+
24
+ def fetch(self, start: str, end: str) -> pd.DataFrame:
25
+ logger.info(f"Fetching fundamentals for {self.ticker} from {start} to {end}")
26
+
27
+ df = None
28
+ if self.ticker.endswith(".NS"):
29
+ logger.info("Attempting NSE fetch...")
30
+ df = self._try_nse(start, end)
31
+ elif self.ticker.endswith(".BO"):
32
+ logger.info("Attempting BSE fetch...")
33
+ df = self._try_bse(start, end)
34
+
35
+ if df is None or df.empty:
36
+ logger.info("Attempting Tier 1: XBRL...")
37
+ df = self._try_xbrl(start, end)
38
+ if df is None or df.empty:
39
+ logger.info("Tier 1 failed or returned empty. Attempting Tier 2: IndianAPI...")
40
+ df = self._try_indianapi(start, end)
41
+ if df is None or df.empty:
42
+ logger.info("Tier 2 failed or returned empty. Attempting Tier 3: yfinance...")
43
+ df = self._try_yfinance(start, end)
44
+
45
+ if df is not None and not df.empty:
46
+ df = df.sort_index()
47
+ # Forward fill balance sheet items as they are point-in-time and usually stable
48
+ # We do this AFTER mapping in _try_yfinance, so we use mapped names
49
+ bs_cols = ["Total_Assets", "Shareholders_Equity", "Inventory", "Accounts_Payable", "Total_Debt", "PPE", "CWIP", "Intangible_Assets"]
50
+ available_bs_cols = [c for c in bs_cols if c in df.columns]
51
+ if available_bs_cols:
52
+ df[available_bs_cols] = df[available_bs_cols].ffill()
53
+
54
+ if df is None or df.empty:
55
+ logger.warning(f"No fundamental data found for {self.ticker}")
56
+ return pd.DataFrame(columns=IND_AS_NODES)
57
+
58
+ logger.info("Deriving strategic nodes...")
59
+ df = self._derive_strategic_nodes(df)
60
+
61
+ # Ensure all IND_AS_NODES are present
62
+ for node in IND_AS_NODES:
63
+ if node not in df.columns:
64
+ df[node] = np.nan
65
+
66
+ return df[IND_AS_NODES].sort_index()
67
+
68
+ def _try_nse(self, start: str, end: str) -> Optional[pd.DataFrame]:
69
+ # Using nseconnect for high-fidelity NSE data
70
+ try:
71
+ # Clean ticker (e.g. RELIANCE.NS -> RELIANCE)
72
+ clean_ticker = self.ticker.split('.')[0]
73
+ nse = Nse()
74
+ logger.info(f"Attempting to fetch NSE data for {clean_ticker}...")
75
+ # Note: nseconnect is primarily for quotes;
76
+ # for full fundamentals we still rely on yfinance or XBRL.
77
+ # We return None here to let it fall back, but the plumbing is now real.
78
+ quote = nse.get_quote(clean_ticker)
79
+ if quote:
80
+ logger.info(f"Successfully connected to NSE for {clean_ticker}")
81
+ return None
82
+ except Exception as e:
83
+ logger.error(f"Error fetching from NSE: {e}")
84
+ return None
85
+
86
+ def _try_bse(self, start: str, end: str) -> Optional[pd.DataFrame]:
87
+ # Using bsedata for high-fidelity BSE data
88
+ try:
89
+ # TODO: Implement mapping from alphabetic ticker to numeric BSE scrip code
90
+ bse = BSE()
91
+ logger.info(f"Attempting to fetch BSE data for {self.ticker}...")
92
+ # Currently limited to quotes; returning None to fall back to yfinance
93
+ return None
94
+ except Exception as e:
95
+ logger.error(f"Error fetching from BSE: {e}")
96
+ return None
97
+
98
+ def _try_xbrl(self, start: str, end: str) -> Optional[pd.DataFrame]:
99
+ # Tier 1 extraction via python-xbrl / Arelle is currently in development
100
+ return None
101
+
102
+ def _try_indianapi(self, start: str, end: str) -> Optional[pd.DataFrame]:
103
+ # Tier 2 integration for IndianAPI.in / FinEdge API is currently in development
104
+ return None
105
+
106
+ def _try_yfinance(self, start: str, end: str) -> Optional[pd.DataFrame]:
107
+ try:
108
+ t = yf.Ticker(self.ticker)
109
+ q_fin = t.quarterly_financials.T
110
+ q_bs = t.quarterly_balance_sheet.T
111
+ q_cf = t.quarterly_cashflow.T
112
+
113
+ if q_fin.empty and q_bs.empty and q_cf.empty:
114
+ logger.warning("All yfinance statements (financials, balance_sheet, cashflow) are empty.")
115
+ return None
116
+
117
+ # Merge all three statements
118
+ logger.info(f"Merging yfinance statements: q_fin={q_fin.shape}, q_bs={q_bs.shape}, q_cf={q_cf.shape}")
119
+ df = pd.concat([q_fin, q_bs, q_cf], axis=1)
120
+
121
+
122
+ df = df.loc[:, ~df.columns.duplicated()] # Remove duplicate columns if any
123
+ logger.info(f"Merged shape after removing duplicates: {df.shape}")
124
+
125
+ # Map yfinance columns to IND_AS_NODES (Simplified mapping for MVP)
126
+ mapping = {
127
+ "Total Revenue": "Revenue",
128
+ "Cost Of Revenue": "COGS",
129
+ "Operating Expense": "Operating_Expenses",
130
+ "Operating Income": "EBIT",
131
+ "EBIT": "EBIT",
132
+ "EBITDA": "EBITDA",
133
+ "Interest Expense": "Interest_Expense",
134
+ "Pretax Income": "EBT",
135
+ "Tax Provision": "Tax_Expense",
136
+ "Net Income": "PAT",
137
+ "Total Assets": "Total_Assets",
138
+ "Stockholders Equity": "Shareholders_Equity",
139
+ "Depreciation And Amortization": "D_A",
140
+ "Inventory": "Inventory",
141
+ "Accounts Payable": "Accounts_Payable",
142
+ "Total Debt": "Total_Debt",
143
+ "Operating Cash Flow": "Operating_Cash_Flow",
144
+ "Capital Expenditure": "Capex",
145
+ "Net PPE": "PPE",
146
+ "Construction In Progress": "CWIP",
147
+ "Goodwill And Other Intangible Assets": "Intangible_Assets",
148
+ }
149
+
150
+ df = df.rename(columns=mapping)
151
+ df = df.loc[:, ~df.columns.duplicated()] # Remove duplicates after rename
152
+ df.index = pd.to_datetime(df.index)
153
+ return df
154
+ except Exception as e:
155
+ logger.error(f"Error fetching from yfinance: {e}")
156
+ return None
157
+
158
+
159
+ def _derive_strategic_nodes(self, df: pd.DataFrame) -> pd.DataFrame:
160
+ """
161
+ Compute Layer 3 nodes and other derived fields.
162
+ """
163
+ df = df.copy()
164
+
165
+ # Helper to safely get a series from a column that might be a DataFrame
166
+ def get_series(name):
167
+ if name not in df.columns:
168
+ return None
169
+ col = df[name]
170
+ if isinstance(col, pd.DataFrame):
171
+ logger.warning(f"Column '{name}' is a DataFrame with multiple columns: {col.columns.tolist()}. Taking the first.")
172
+ return col.iloc[:, 0]
173
+ return col
174
+
175
+ # 1. Average Assets & Equity (Rolling 2-period mean)
176
+ assets = get_series("Total_Assets")
177
+ if assets is not None:
178
+ df["Average_Total_Assets"] = assets.rolling(window=2).mean().fillna(assets)
179
+
180
+ equity = get_series("Shareholders_Equity")
181
+ if equity is not None:
182
+ df["Average_Shareholders_Equity"] = equity.rolling(window=2).mean().fillna(equity)
183
+
184
+ # 2. Basic derivations
185
+ rev = get_series("Revenue")
186
+ cogs = get_series("COGS")
187
+ if rev is not None and cogs is not None:
188
+ df["Gross_Profit"] = rev - cogs.fillna(0)
189
+
190
+ ebit = get_series("EBIT")
191
+ da = get_series("D_A")
192
+ if ebit is not None:
193
+ # Proper EBITDA = EBIT + Depreciation & Amortization
194
+ df["EBITDA"] = ebit + da.fillna(0) if da is not None else ebit
195
+
196
+ pat = get_series("PAT")
197
+ if pat is not None and rev is not None:
198
+ df["Net_Profit_Margin"] = pat / rev.replace(0, np.nan)
199
+
200
+ avg_assets = get_series("Average_Total_Assets")
201
+ if rev is not None and avg_assets is not None:
202
+ df["Asset_Turnover"] = rev / avg_assets.replace(0, np.nan)
203
+
204
+ avg_equity = get_series("Average_Shareholders_Equity")
205
+ if avg_assets is not None and avg_equity is not None:
206
+ df["Equity_Multiplier"] = avg_assets / avg_equity.replace(0, np.nan)
207
+
208
+ npm = get_series("Net_Profit_Margin")
209
+ at = get_series("Asset_Turnover")
210
+ em = get_series("Equity_Multiplier")
211
+ if npm is not None and at is not None and em is not None:
212
+ df["ROE"] = npm * at * em
213
+
214
+ ocf = get_series("Operating_Cash_Flow")
215
+ capex = get_series("Capex")
216
+ if ocf is not None and capex is not None:
217
+ df["Free_Cash_Flow"] = ocf - capex.abs().fillna(0)
218
+
219
+ return df
singular_ticker_causal/data_sources/gdelt_client.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import random
3
+ import logging
4
+ import requests
5
+ import yfinance as yf
6
+ from datetime import datetime, timezone
7
+ from typing import List, Dict, Any
8
+ from singular_ticker_causal.utils.llm_client import LLMClient
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ # ==============================================================
15
+ # GDELT DOC 2.0 FETCHER
16
+ # ==============================================================
17
+
18
+ def _fmt_gdelt_dt(dt: datetime) -> str:
19
+ """
20
+ Convert datetime -> GDELT YYYYMMDDHHMMSS (UTC).
21
+ """
22
+ if dt.tzinfo is not None:
23
+ dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
24
+
25
+ return dt.strftime("%Y%m%d%H%M%S")
26
+
27
+
28
+ def _dedupe_articles(articles: list[dict]) -> list[dict]:
29
+ """
30
+ Deduplicate by URL/title combination.
31
+ """
32
+ seen = set()
33
+ deduped = []
34
+
35
+ for article in articles:
36
+ key = (
37
+ article.get("link", "").strip(),
38
+ article.get("title", "").strip().lower(),
39
+ )
40
+
41
+ if key in seen:
42
+ continue
43
+
44
+ seen.add(key)
45
+ deduped.append(article)
46
+
47
+ return deduped
48
+
49
+
50
+ class GDELTClient:
51
+ """
52
+ Thin wrapper around GDELT Doc API for macro context.
53
+ """
54
+ GDELT_DOC_API: str = "https://api.gdeltproject.org/api/v2/doc/doc"
55
+ GDELT_MAX_RECORDS: int = 250
56
+ GDELT_MAX_WINDOW_DAYS: int = 90
57
+ USER_AGENT: str = (
58
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
59
+ "Gecko/20100101 Firefox/128.0"
60
+ )
61
+
62
+ def __init__(self):
63
+ pass
64
+
65
+ def fetch(
66
+ self,
67
+ keyword: str,
68
+ from_dt: datetime,
69
+ to_dt: datetime,
70
+ ticker: str | None = None,
71
+ **kwargs,
72
+ ) -> List[Dict[str, Any]]:
73
+ """
74
+ Main entry point for GDELT fetching.
75
+ If 'ticker' is provided, performs a broad generic search using yfinance context.
76
+ """
77
+ if ticker:
78
+ return self.fetch_ticker_context(ticker, from_dt, to_dt, **kwargs)
79
+
80
+ logger.info(
81
+ "Fetching GDELT news for %s from %s to %s",
82
+ keyword,
83
+ from_dt,
84
+ to_dt,
85
+ )
86
+
87
+ try:
88
+ articles = self.fetch_gdelt_window(
89
+ keyword=keyword,
90
+ from_dt=from_dt,
91
+ to_dt=to_dt,
92
+ **kwargs,
93
+ )
94
+
95
+ for article in articles:
96
+ article["credibility_weight"] = 0.5
97
+ article["source_type"] = "gdelt"
98
+
99
+ return articles
100
+
101
+ except Exception as e:
102
+ logger.exception("Error fetching from GDELT: %s", e)
103
+ return []
104
+
105
+ def _extract_search_terms(self, summary: str, company_name: str) -> Dict[str, List[str]]:
106
+ """Extract product, partner, and industry search terms from business summary using LLM."""
107
+ if not summary:
108
+ return {"products": [], "partners": [], "industry": []}
109
+
110
+ llm = LLMClient()
111
+ prompt = f"""
112
+ Extract major products/services, strategic partners, and industry-related details from the following summary of {company_name}.
113
+
114
+ Summary: {summary}
115
+
116
+ Important instructions:
117
+ 1. For partners, include a few words describing the specific connection or nature of the relationship between {company_name} and the partner (e.g., "{company_name} strategic collaboration with [Partner]").
118
+ 2. For industry, extract a few words describing the sector or niche {company_name} is connected to and its specific role/connection (e.g., "{company_name} provides [Services] in the [Industry] sector").
119
+ 3. Keep it to the top 5 most relevant terms for products and partners, and top 3 for industry.
120
+
121
+ Return a JSON object with:
122
+ "products": ["product1", "product2", ...],
123
+ "partners": ["{company_name} [connection] [partner1]", ...],
124
+ "industry": ["{company_name} [role/connection] [industry]", ...]
125
+
126
+ Return ONLY valid JSON.
127
+ """
128
+ try:
129
+ result = llm.chat_json([{"role": "user", "content": prompt}])
130
+ return {
131
+ "products": result.get("products", []),
132
+ "partners": result.get("partners", []),
133
+ "industry": result.get("industry", [])
134
+ }
135
+ except Exception as e:
136
+ logger.error(f"[GDELT] LLM term extraction failed: {e}")
137
+ return {"products": [], "partners": [], "industry": []}
138
+
139
+ def fetch_ticker_context(
140
+ self,
141
+ ticker_symbol: str,
142
+ from_dt: datetime,
143
+ to_dt: datetime,
144
+ **kwargs
145
+ ) -> List[Dict[str, Any]]:
146
+ """
147
+ Generic fetcher that uses yfinance and LLM to build a broad context.
148
+ Sequence: products, partners, company name, company heads.
149
+ """
150
+ logger.info(f"[GDELT] Fetching generic context for ticker: {ticker_symbol}")
151
+
152
+ try:
153
+ ticker = yf.Ticker(ticker_symbol)
154
+ info = ticker.info
155
+ except Exception as e:
156
+ logger.error(f"[GDELT] yfinance failed for {ticker_symbol}: {e}")
157
+ return []
158
+
159
+ summary = info.get("longBusinessSummary", "")
160
+ company_name = info.get("longName") or info.get("shortName") or ticker_symbol
161
+ officers = info.get("companyOfficers", [])
162
+
163
+ # 1. LLM extraction
164
+ terms = self._extract_search_terms(summary, company_name)
165
+ products = terms.get("products", [])
166
+ partners = terms.get("partners", [])
167
+ industry = terms.get("industry", [])
168
+
169
+ # 2. Officer names
170
+ heads = [o.get("name") for o in officers if o.get("name")]
171
+
172
+ # 3. Execution sequence
173
+ all_articles = []
174
+ search_plan = [
175
+ ("products", products),
176
+ ("partners", partners),
177
+ ("industry", industry),
178
+ ("company_name", [company_name]),
179
+ ("company_heads", heads)
180
+ ]
181
+
182
+ for category, keywords in search_plan:
183
+ for kw in keywords:
184
+ if not kw: continue
185
+ logger.info(f"[GDELT] Scraping category '{category}': {kw}")
186
+ results = self.fetch_gdelt_window(
187
+ keyword=kw,
188
+ from_dt=from_dt,
189
+ to_dt=to_dt,
190
+ **kwargs
191
+ )
192
+ all_articles.extend(results)
193
+ # Polite pause to avoid aggressive rate limiting
194
+ time.sleep(random.uniform(2.0, 5.0))
195
+
196
+ return _dedupe_articles(all_articles)
197
+
198
+ def fetch_gdelt_window(
199
+ self,
200
+ keyword: str,
201
+ from_dt: datetime,
202
+ to_dt: datetime,
203
+ max_records: int = 250,
204
+ exact_phrase: bool = False,
205
+ source_country: str | None = None,
206
+ source_lang: str | None = None,
207
+ theme: str | None = None,
208
+ domain: str | None = None,
209
+ extra_query: str | None = None,
210
+ ) -> list:
211
+ """
212
+ Fetch historical articles from the GDELT DOC 2.0 API.
213
+ Includes robust retry logic with 5-minute cooldown for timeouts/rate-limits.
214
+ """
215
+ if from_dt >= to_dt:
216
+ raise ValueError("from_dt must be earlier than to_dt")
217
+
218
+ window_days = (to_dt - from_dt).days
219
+ if window_days > self.GDELT_MAX_WINDOW_DAYS:
220
+ raise ValueError(
221
+ f"GDELT DOC API only supports ~{self.GDELT_MAX_WINDOW_DAYS} days history"
222
+ )
223
+
224
+ max_records = min(max_records, self.GDELT_MAX_RECORDS)
225
+ query = self._build_gdelt_query(
226
+ keyword=keyword,
227
+ exact_phrase=exact_phrase,
228
+ source_country=source_country,
229
+ source_lang=source_lang,
230
+ theme=theme,
231
+ domain=domain,
232
+ extra_query=extra_query,
233
+ )
234
+
235
+ params = {
236
+ "query": query,
237
+ "mode": "artlist",
238
+ "format": "json",
239
+ "maxrecords": max_records,
240
+ "sort": "DateDesc",
241
+ "STARTDATETIME": _fmt_gdelt_dt(from_dt),
242
+ "ENDDATETIME": _fmt_gdelt_dt(to_dt),
243
+ }
244
+
245
+ max_retries = 5
246
+ for attempt in range(max_retries):
247
+ try:
248
+ resp = requests.get(
249
+ self.GDELT_DOC_API,
250
+ params=params,
251
+ timeout=45,
252
+ headers={
253
+ "User-Agent": self.USER_AGENT,
254
+ "Accept": "application/json",
255
+ },
256
+ )
257
+
258
+ if resp.status_code == 429:
259
+ logger.warning(
260
+ "[GDELT] Rate limited (429). Cooling down for 5 minutes..."
261
+ )
262
+ time.sleep(305)
263
+ continue
264
+
265
+ resp.raise_for_status()
266
+
267
+ try:
268
+ data = resp.json()
269
+ except Exception as e:
270
+ logger.error("[GDELT] Invalid JSON response (Status %d): %s", resp.status_code, e)
271
+ # Log the body to see what GDELT is actually returning (likely HTML error)
272
+ body_snippet = resp.text[:500] if resp.text else "[Empty Response]"
273
+ logger.error("[GDELT] Response body snippet: %s", body_snippet)
274
+
275
+ # If it's an HTML error page, we might be blocked or throttled in a way that doesn't return 429
276
+ if "<html" in body_snippet.lower():
277
+ logger.warning("[GDELT] Received HTML instead of JSON. Cooling down for 5 minutes...")
278
+ time.sleep(305)
279
+ else:
280
+ time.sleep(10 * (attempt + 1))
281
+ continue
282
+
283
+ raw_articles = data.get("articles", [])
284
+ if not isinstance(raw_articles, list):
285
+ return []
286
+
287
+ results = []
288
+ for article in raw_articles:
289
+ title = article.get("title", "") or ""
290
+ url = article.get("url", "") or ""
291
+ if not title or not url:
292
+ continue
293
+
294
+ normalized = {
295
+ "feed_name": "GDELT",
296
+ "title": title.strip(),
297
+ "link": url.strip(),
298
+ "published": article.get("seendate", ""),
299
+ "summary": title.strip(),
300
+ "language": article.get("language", ""),
301
+ "sourcecountry": article.get("sourcecountry", ""),
302
+ "domain": article.get("domain", ""),
303
+ "socialimage": article.get("socialimage", ""),
304
+ }
305
+ results.append(normalized)
306
+
307
+ results = _dedupe_articles(results)
308
+ logger.info(
309
+ "[GDELT] Retrieved %d articles for '%s'",
310
+ len(results),
311
+ keyword,
312
+ )
313
+ return results
314
+
315
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
316
+ logger.warning(
317
+ "[GDELT] Timeout/Connection error on attempt %d/%d: %s. Cooling down 5 mins...",
318
+ attempt + 1, max_retries, e
319
+ )
320
+ time.sleep(305)
321
+ continue
322
+
323
+ except requests.exceptions.RequestException as e:
324
+ wait_time = min(60, (2 ** attempt) * 5 + random.uniform(0, 3))
325
+ logger.warning(
326
+ "[GDELT] Request error on attempt %d/%d: %s. Retrying in %.2fs",
327
+ attempt + 1, max_retries, e, wait_time
328
+ )
329
+ time.sleep(wait_time)
330
+
331
+ logger.error("[GDELT] Failed after %d retries", max_retries)
332
+ return []
333
+
334
+ def _build_gdelt_query(
335
+ self,
336
+ keyword: str,
337
+ exact_phrase: bool = False,
338
+ source_country: str | None = None,
339
+ source_lang: str | None = None,
340
+ theme: str | None = None,
341
+ domain: str | None = None,
342
+ extra_query: str | None = None,
343
+ ) -> str:
344
+ """Build a valid GDELT DOC API query string."""
345
+ query_parts = []
346
+ if keyword:
347
+ if exact_phrase and " " in keyword:
348
+ query_parts.append(f'"{keyword}"')
349
+ else:
350
+ query_parts.append(keyword)
351
+
352
+ if source_country:
353
+ query_parts.append(f"sourcecountry:{source_country.lower()}")
354
+ if source_lang:
355
+ query_parts.append(f"sourcelang:{source_lang.lower()}")
356
+ if theme:
357
+ query_parts.append(f"theme:{theme}")
358
+ if domain:
359
+ query_parts.append(f"domain:{domain}")
360
+ if extra_query:
361
+ query_parts.append(extra_query)
362
+
363
+ return " ".join(query_parts)
364
+
365
+
366
+ if __name__ == "__main__":
367
+ import json
368
+ from datetime import timedelta
369
+
370
+ # Configure logging
371
+ logging.basicConfig(
372
+ level=logging.INFO,
373
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
374
+ )
375
+
376
+ client = GDELTClient()
377
+
378
+ # Test parameters
379
+ ticker = "RELIANCE.NS"
380
+ end_date = datetime.now(timezone.utc)
381
+ start_date = end_date - timedelta(days=3)
382
+
383
+ print(f"\n--- Testing GDELT Ticker Context Fetch: {ticker} ---")
384
+ try:
385
+ articles = client.fetch(
386
+ keyword="",
387
+ from_dt=start_date,
388
+ to_dt=end_date,
389
+ ticker=ticker
390
+ )
391
+
392
+ output_file = "gdelt.json"
393
+ with open(output_file, "w") as f:
394
+ json.dump(articles, f, indent=4)
395
+
396
+ print(f"Successfully fetched {len(articles)} articles.")
397
+ print(f"Output saved to {output_file}")
398
+
399
+ except Exception as e:
400
+ print(f"Test failed: {e}")
singular_ticker_causal/data_sources/news_client.py ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ singular_ticker_causal/data_sources/news_client.py
3
+
4
+ Fetches quarterly RSS news for a single ticker over a multi-year window.
5
+ - Operates on a single ticker (not a universe)
6
+ - Fetches quarterly windows across the full fundamental date range
7
+ - Returns articles structured for DenoisedNewsEncoder consumption
8
+
9
+ Sources:
10
+ • LiveMint RSS feeds
11
+ • CNBC-TV18 RSS feeds
12
+ • Other RSS feeds (Business Standard, Forbes India, Zee News, Economic Times, etc.)
13
+ • Trading Economics (Selenium — stream, economy, markets, India news)
14
+ • Zerodha Pulse (requests + BeautifulSoup)
15
+ """
16
+
17
+ import os
18
+ import json
19
+ import time
20
+ import logging
21
+ import datetime
22
+ import requests
23
+ import feedparser
24
+ import yfinance as yf
25
+ from typing import List
26
+ from bs4 import BeautifulSoup
27
+ from selenium import webdriver
28
+ from selenium.webdriver.firefox.options import Options as FirefoxOptions
29
+ from selenium.webdriver.firefox.service import Service as FirefoxService
30
+ from selenium.webdriver.common.by import By
31
+ from selenium.webdriver.support.ui import WebDriverWait
32
+ from selenium.webdriver.support import expected_conditions as EC
33
+
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # ==============================================================
39
+ # SCRAPER SOURCES
40
+ # ==============================================================
41
+
42
+ LIVEMINT_FEEDS = [
43
+ "https://www.livemint.com/rss/companies",
44
+ "https://www.livemint.com/rss/opinion",
45
+ "https://www.livemint.com/rss/money",
46
+ "https://www.livemint.com/rss/politics",
47
+ "https://www.livemint.com/rss/science",
48
+ "https://www.livemint.com/rss/industry",
49
+ "https://www.livemint.com/rss/education",
50
+ "https://www.livemint.com/rss/sports",
51
+ "https://www.livemint.com/rss/technology",
52
+ "https://www.livemint.com/rss/news",
53
+ "https://www.livemint.com/rss/markets",
54
+ "https://www.livemint.com/rss/AI",
55
+ "https://www.livemint.com/rss/insurance",
56
+ "https://www.livemint.com/rss/budget",
57
+ "https://www.livemint.com/rss/elections",
58
+ ]
59
+
60
+ CNBC18_FEEDS = [
61
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/latest.xml",
62
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/india.xml",
63
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/economy.xml",
64
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/market.xml",
65
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/business.xml",
66
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/sports.xml",
67
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/politics.xml",
68
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/world.xml",
69
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/education.xml",
70
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/travel.xml",
71
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/auto.xml",
72
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/technology.xml",
73
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/personal-finance.xml",
74
+ "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/web-stories.xml",
75
+ ]
76
+
77
+ OTHER_FEEDS = [
78
+ "http://www.business-standard.com/rss/todays-paper.rss",
79
+ "https://www.forbesindia.com/commonfeeds/v1/frb/rss/blog.xml",
80
+ "http://zeenews.india.com/rss/business.xml",
81
+ "https://economictimes.indiatimes.com/rssfeedsdefault.cms",
82
+ "https://news.google.com/rss?cf=all&hl=en-IN&topic=b&gl=IN&ceid=IN:en",
83
+ "https://cfo.economictimes.indiatimes.com/rss/topstories",
84
+ "https://cfo.economictimes.indiatimes.com/rss/recentstories",
85
+ "https://cfo.economictimes.indiatimes.com/rss/corporate-finance",
86
+ "https://cfo.economictimes.indiatimes.com/rss/esg",
87
+ "https://cfo.economictimes.indiatimes.com/rss/cfo-tech",
88
+ "https://cfo.economictimes.indiatimes.com/rss/governance-risk-compliance",
89
+ "https://cfo.economictimes.indiatimes.com/rss/lateststories",
90
+ ]
91
+
92
+ TE_SOURCES = {
93
+ "te_stream": "https://tradingeconomics.com/stream",
94
+ "te_economy": "https://tradingeconomics.com/stream?i=economy",
95
+ "te_markets": "https://tradingeconomics.com/stream?i=markets",
96
+ "te_india": "https://tradingeconomics.com/india/news",
97
+ }
98
+
99
+ ZERODHA_PULSE_URL = "https://pulse.zerodha.com/"
100
+
101
+
102
+ # ==============================================================
103
+ # TRADING ECONOMICS Selenium Scraper
104
+ # ==============================================================
105
+
106
+ JS_SCROLL_DOWN = "window.scrollTo(0, document.body.scrollHeight); return document.body.scrollHeight;"
107
+
108
+ JS_EXTRACT_TE_NEWS = """
109
+ var items = [];
110
+ var listItems = document.querySelectorAll('li[id]');
111
+ listItems.forEach(function(li) {
112
+ var titleLink = li.querySelector('a[href]');
113
+ if (!titleLink) return;
114
+ var titleText = '';
115
+ var bTag = titleLink.querySelector('b');
116
+ if (bTag) { titleText = bTag.textContent.trim(); }
117
+ else { titleText = titleLink.textContent.trim(); }
118
+ if (!titleText) return;
119
+ var url = titleLink.getAttribute('href') || '';
120
+ if (url && !url.startsWith('http')) { url = 'https://tradingeconomics.com' + url; }
121
+ var descEl = li.querySelector('.te-stream-item-description, span[style]');
122
+ var description = descEl ? descEl.textContent.trim() : '';
123
+ var dateEl = li.querySelector('small');
124
+ var dateText = dateEl ? dateEl.textContent.trim() : '';
125
+ var countryEl = li.querySelector('.te-stream-country');
126
+ var country = countryEl ? countryEl.textContent.trim() : '';
127
+ var categoryEl = li.querySelector('.te-stream-category');
128
+ var category = categoryEl ? categoryEl.textContent.trim() : '';
129
+ items.push({ title: titleText, description: description, date: dateText, url: url, country: country, category: category });
130
+ });
131
+ return JSON.stringify(items);
132
+ """
133
+
134
+
135
+ class NewsClient:
136
+ """
137
+ Fetches news for a single ticker across a multi-year window by
138
+ chunking into quarterly GDELT calls. Implements the same cache-first
139
+ pattern as causal.test_causal_flow.test_causal_with_text.
140
+
141
+ Usage:
142
+ client = NewsClient("RELIANCE")
143
+ articles = client.fetch("2022-01-01", "2026-04-30")
144
+ # articles: List[dict] with keys: title, content, published, url, credibility_weight, source
145
+ """
146
+ USER_AGENT: str = (
147
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
148
+ "Gecko/20100101 Firefox/128.0"
149
+ )
150
+
151
+ def __init__(self, ticker: str):
152
+ self.ticker = ticker.upper().replace(".NS", "")
153
+ self.keyword = self._get_ticker_keyword(self.ticker)
154
+
155
+ def _get_ticker_keyword(self, ticker: str) -> str:
156
+ """Fetch a search-friendly name for the ticker from yfinance."""
157
+ symbol = ticker if "." in ticker else f"{ticker}.NS"
158
+ try:
159
+ t = yf.Ticker(symbol)
160
+ info = t.info
161
+ name = info.get("shortName") or info.get("longName") or ticker
162
+ # Clean up the name for better matching
163
+ for suffix in [" Limited", " Ltd.", " Ltd", " Corp.", " Corp", " Inc.", " Inc"]:
164
+ name = name.replace(suffix, "")
165
+ return name.strip()
166
+ except Exception as e:
167
+ logger.warning(f"Failed to fetch yfinance info for {symbol}: {e}")
168
+ return ticker
169
+
170
+ def _normalise(self, raw: dict) -> dict:
171
+ """Convert the raw dict format from news.py into a pipeline-ready article dict."""
172
+ title = raw.get("title", "").strip()
173
+ summary = raw.get("summary", "") or raw.get("description", "") or ""
174
+ content = f"{title}. {summary}".strip(". ") if summary and summary != title else title
175
+ pub_raw = raw.get("published", "") or raw.get("seendate", "")
176
+ # Try to parse the published field
177
+ pub_dt = None
178
+ if pub_raw:
179
+ try:
180
+ from dateutil import parser as dp
181
+ pub_dt = dp.parse(pub_raw)
182
+ except Exception:
183
+ pub_dt = None
184
+ if pub_dt is None:
185
+ pub_dt = datetime.datetime.now(datetime.timezone.utc)
186
+ elif pub_dt.tzinfo is None:
187
+ pub_dt = pub_dt.replace(tzinfo=datetime.timezone.utc)
188
+
189
+ return {
190
+ "title": title,
191
+ "content": content,
192
+ "published": pub_dt.isoformat(),
193
+ "url": raw.get("link", "") or raw.get("url", ""),
194
+ }
195
+
196
+ def fetch(
197
+ self,
198
+ start: str,
199
+ end: str,
200
+ include_pulse: bool = False,
201
+ include_te: bool = False,
202
+ ) -> List[dict]:
203
+ """
204
+ Fetch all news for self.ticker between start and end.
205
+ Aggregates RSS, Pulse, and (optionally) Trading Economics.
206
+ """
207
+
208
+ logger.info(
209
+ f"Fetching news for {self.ticker} ('{self.keyword}') "
210
+ f"from {start} to {end}..."
211
+ )
212
+
213
+ all_raw: List[dict] = []
214
+
215
+ # 1. RSS Feeds
216
+ logger.info("Searching RSS feeds...")
217
+ rss_articles = (
218
+ self.search_rss_feeds(LIVEMINT_FEEDS, self.keyword, "LiveMint")
219
+ + self.search_rss_feeds(CNBC18_FEEDS, self.keyword, "CNBC18")
220
+ + self.search_rss_feeds(OTHER_FEEDS, self.keyword, "OtherFeeds")
221
+ )
222
+ all_raw.extend(rss_articles)
223
+
224
+ # 2. Zerodha Pulse
225
+ if include_pulse:
226
+ logger.info("Fetching Zerodha Pulse...")
227
+ pulse_news = self.scrape_pulse()
228
+ all_raw.extend(pulse_news)
229
+
230
+ # 3. Trading Economics (Optional)
231
+ if include_te:
232
+ logger.info("Fetching Trading Economics...")
233
+ te_data = self.scrape_all_te(headless=True, scroll_count=1)
234
+ for items_list in te_data.values():
235
+ all_raw.extend(items_list)
236
+
237
+ # # Filter by date window
238
+ # start_dt = datetime.datetime(
239
+ # *[int(x) for x in start.split("-")], tzinfo=datetime.timezone.utc
240
+ # )
241
+ # end_dt = datetime.datetime(
242
+ # *[int(x) for x in end.split("-")], tzinfo=datetime.timezone.utc
243
+ # )
244
+ # in_window = self._filter_by_window(all_raw, start_dt, end_dt)
245
+ # logger.info(f"Collected {len(in_window)} articles in window.")
246
+
247
+ # # Normalise and deduplicate by title
248
+ # seen_titles: set = set()
249
+ # articles: List[dict] = []
250
+ # for raw in all_raw:
251
+ # title = raw.get("title", "").strip().lower()
252
+ # if not title or title in seen_titles:
253
+ # continue
254
+ # seen_titles.add(title)
255
+ # articles.append(self._normalise(raw))
256
+
257
+ # Sort chronologically
258
+ all_raw.sort(key=lambda a: str(a.get("published") or a.get("date") or ""))
259
+
260
+ logger.info(
261
+ f"Final corpus: {len(all_raw)} unique articles for {self.ticker}."
262
+ )
263
+
264
+ return all_raw
265
+
266
+ def search_rss_feeds(self, feeds: list, search_keyword: str, feed_name: str) -> list:
267
+ """Search for a keyword across a list of RSS feed URLs."""
268
+ print(f"[DEBUG] search_rss_feeds - Searching {feed_name} for keyword: '{search_keyword}'")
269
+ results = []
270
+ for feed_url in feeds:
271
+ print(f"[DEBUG] search_rss_feeds - Parsing URL: {feed_url}")
272
+ try:
273
+ feed = feedparser.parse(feed_url)
274
+ if not feed.entries:
275
+ print(f"[DEBUG] search_rss_feeds - No entries found for {feed_url}")
276
+ for entry in feed.entries:
277
+ title = entry.get("title", "")
278
+ summary = entry.get("summary", "")
279
+ if not title:
280
+ continue
281
+ if search_keyword.lower() in title.lower() or search_keyword.lower() in summary.lower():
282
+ print(f"[DEBUG] search_rss_feeds - Match found: {title[:60]}...")
283
+ results.append({
284
+ "feed_name": feed_name,
285
+ "title": title,
286
+ "link": entry.get("link", ""),
287
+ "published": entry.get("published", ""),
288
+ "summary": summary,
289
+ })
290
+ except Exception as e:
291
+ print(f"[news.py] RSS error ({feed_url}): {e}")
292
+ print(f"[DEBUG] search_rss_feeds - {feed_name} done. Found {len(results)} items.")
293
+ return results
294
+
295
+ def scrape_all_te(self, headless: bool = True, scroll_count: int = 3) -> dict:
296
+ """Scrape all Trading Economics news sources. Returns dict of source_key -> list of items."""
297
+ driver = self._create_driver(headless=headless)
298
+ results = {}
299
+ scraped_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
300
+ try:
301
+ for key, url in TE_SOURCES.items():
302
+ try:
303
+ items = self._scrape_te_page(driver, url, scroll_count=scroll_count)
304
+ for item in items:
305
+ item["source"] = key
306
+ item["scraped_at"] = scraped_at
307
+ results[key] = items
308
+ except Exception as e:
309
+ print(f" [TE] Error scraping {key}: {e}")
310
+ results[key] = []
311
+ try:
312
+ driver.quit()
313
+ except Exception:
314
+ pass
315
+ driver = self._create_driver(headless=headless)
316
+ finally:
317
+ try:
318
+ driver.quit()
319
+ except Exception:
320
+ pass
321
+ return results
322
+
323
+ def scrape_pulse(self) -> list:
324
+ """Scrape latest news from Zerodha Pulse using requests + BeautifulSoup."""
325
+ print("[news.py] Scraping Zerodha Pulse...")
326
+ headers = {
327
+ "User-Agent": self.USER_AGENT,
328
+ "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
329
+ "Accept-Language": "en-US,en;q=0.5",
330
+ }
331
+ try:
332
+ resp = requests.get(ZERODHA_PULSE_URL, headers=headers, timeout=30)
333
+ resp.raise_for_status()
334
+ except Exception as e:
335
+ print(f"[news.py] Zerodha Pulse error: {e}")
336
+ return []
337
+
338
+ soup = BeautifulSoup(resp.text, "html.parser")
339
+ items = []
340
+ scraped_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
341
+
342
+ for li in soup.select("li.item"):
343
+ title_el = li.select_one("h2.title a")
344
+ if not title_el:
345
+ continue
346
+ title = title_el.get_text(strip=True)
347
+ url = title_el.get("href", "")
348
+ desc_el = li.select_one("div.desc")
349
+ description = desc_el.get_text(strip=True) if desc_el else ""
350
+ date_el = li.select_one("span.date")
351
+ date_text = date_el.get_text(strip=True) if date_el else ""
352
+ feed_el = li.select_one("span.feed")
353
+ feed = feed_el.get_text(strip=True) if feed_el else ""
354
+ items.append({
355
+ "title": title,
356
+ "description": description,
357
+ "published": date_text,
358
+ "link": url,
359
+ "summary": description,
360
+ "publisher": feed,
361
+ "scraped_at": scraped_at,
362
+ "source": "pulse"
363
+ })
364
+
365
+ print(f"[news.py] Zerodha Pulse: extracted {len(items)} items.")
366
+ return items
367
+
368
+ def _filter_by_window(
369
+ self,
370
+ articles: list,
371
+ from_dt: datetime.datetime,
372
+ to_dt: datetime.datetime,
373
+ ) -> list:
374
+ """
375
+ Drop articles whose parsed `published` timestamp falls outside [from_dt, to_dt].
376
+ Articles with unparseable or missing dates are kept (conservative).
377
+ """
378
+ from dateutil import parser as dp
379
+
380
+ def _to_utc(dt):
381
+ if dt.tzinfo is None:
382
+ return dt.replace(tzinfo=datetime.timezone.utc)
383
+ return dt.astimezone(datetime.timezone.utc)
384
+
385
+ from_utc = _to_utc(from_dt)
386
+ to_utc = _to_utc(to_dt)
387
+
388
+ filtered = []
389
+ for art in articles:
390
+ pub = art.get("published", "")
391
+ if not pub:
392
+ filtered.append(art)
393
+ continue
394
+ try:
395
+ dt = _to_utc(dp.parse(pub))
396
+ if from_utc <= dt <= to_utc:
397
+ filtered.append(art)
398
+ except Exception:
399
+ filtered.append(art) # keep on parse failure
400
+ return filtered
401
+
402
+ def _create_driver(self, headless: bool = True):
403
+ """Create a headless Firefox webdriver."""
404
+ options = FirefoxOptions()
405
+ if headless:
406
+ options.add_argument("--headless")
407
+ options.set_preference("general.useragent.override", self.USER_AGENT)
408
+ options.set_preference("dom.webdriver.enabled", False)
409
+ options.set_preference("useAutomationExtension", False)
410
+ service = FirefoxService(log_output=os.devnull)
411
+ driver = webdriver.Firefox(options=options, service=service)
412
+ driver.set_page_load_timeout(60)
413
+ return driver
414
+
415
+ def _scrape_te_page(self, driver, url: str, scroll_count: int = 3, scroll_pause: float = 2.0) -> list:
416
+ """Scrape a single Trading Economics news page."""
417
+ print(f" [TE] Loading: {url}")
418
+ driver.get(url)
419
+ WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.TAG_NAME, "body")))
420
+ time.sleep(3)
421
+ prev_height = 0
422
+ for i in range(scroll_count):
423
+ new_height = driver.execute_script(JS_SCROLL_DOWN)
424
+ if new_height == prev_height:
425
+ break
426
+ prev_height = new_height
427
+ time.sleep(scroll_pause)
428
+ raw = driver.execute_script(JS_EXTRACT_TE_NEWS)
429
+ items = json.loads(raw)
430
+ print(f" [TE] Extracted {len(items)} items from {url}")
431
+ return items
432
+
433
+
434
+ if __name__ == "__main__":
435
+ """Simple test runner for NewsClient."""
436
+ logging.basicConfig(
437
+ level=logging.INFO,
438
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
439
+ )
440
+
441
+ ticker = "RELIANCE"
442
+ client = NewsClient(ticker)
443
+
444
+ # Test Fetch (Aggregates RSS and Pulse)
445
+ print(f"\n--- Testing Fetch for {ticker} ---")
446
+ start_date = (datetime.datetime.now() - datetime.timedelta(days=7)).strftime("%Y-%m-%d")
447
+ end_date = datetime.datetime.now().strftime("%Y-%m-%d")
448
+
449
+ # We include_te=False by default as it requires Selenium/Firefox
450
+ articles = client.fetch(start_date, end_date)
451
+ print(f"Fetched {len(articles)} articles.")
452
+
453
+ if articles:
454
+ # Save results to debug_data/
455
+ with open("news.json", "w", encoding="utf-8") as f:
456
+ json.dump(articles, f, indent=4, ensure_ascii=False)
457
+ for a in articles[:3]:
458
+ print(f" - [{a['published']}] {a['title']}")
459
+ else:
460
+ print("No articles found in the specified window.")
singular_ticker_causal/data_sources/nseconnect/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The MIT License (MIT)
3
+
4
+ Copyright (c) 2014 Noufal Nazar
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
23
+ """
24
+ __VERSION__='2.0.1'
25
+ from .nse import Nse
singular_ticker_causal/data_sources/nseconnect/bases.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The MIT License (MIT)
3
+
4
+ Copyright (c) 2014 Noufal Nazar
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
23
+ """
24
+
25
+ from abc import ABCMeta, abstractmethod
26
+ import six
27
+
28
+
29
+ class AbstractBaseExchange(six.with_metaclass(ABCMeta, object)):
30
+
31
+ @abstractmethod
32
+ def get_stock_codes(self):
33
+ """
34
+ :return: list of tuples with stock code and stock name
35
+ """
36
+ raise NotImplementedError
37
+
38
+ @abstractmethod
39
+ def is_valid_code(self, code):
40
+ """
41
+ :return: True, if it is a valid stock code, else False
42
+ """
43
+ raise NotImplementedError
44
+
45
+ @abstractmethod
46
+ def get_quote(self, code):
47
+ """
48
+ :param code: a stock code
49
+ :return: a dictionary which contain detailed stock code.
50
+ """
51
+ raise NotImplementedError
52
+
53
+ @abstractmethod
54
+ def get_top_gainers(self):
55
+ """
56
+ :return: a sorted list of codes of top gainers
57
+ """
58
+ raise NotImplementedError
59
+
60
+ @abstractmethod
61
+ def get_top_losers(self):
62
+ """
63
+ :return: a sorted list of codes of top losers
64
+ """
65
+ raise NotImplementedError
66
+
67
+ @abstractmethod
68
+ def __str__(self):
69
+ """
70
+ :return: market name
71
+ """
72
+ raise NotImplementedError
singular_ticker_causal/data_sources/nseconnect/cleaners.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module for various data structure cleaning tasks
3
+ """
4
+ from datetime import datetime
5
+ dirty_data = """
6
+ {
7
+ "fname": "Jon",
8
+ "lname": "Doe",
9
+ "age": 20,
10
+ "str_age": "20",
11
+ "pi": 3.1415927,
12
+ "str_pi": "3.1415927",
13
+ "dob": "01-Jan-2023",
14
+ "mobile": [
15
+ {
16
+ "id": "Home",
17
+ "number": "123456789"
18
+ },
19
+ {
20
+ "id": "office",
21
+ "number": "987645321"
22
+ }
23
+ ]
24
+ }
25
+ """
26
+
27
+ def parse_values(obj):
28
+ for key, value in obj.items():
29
+ if isinstance(value, str):
30
+ # Try to parse as datetime if the string matches the format
31
+ date_formats = ["%d-%b-%Y", "%d-%m-%Y", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"]
32
+ for date_format in date_formats:
33
+ try:
34
+ obj[key] = datetime.strptime(value, date_format)
35
+ break
36
+ except ValueError:
37
+ pass
38
+ else:
39
+ # If the string couldn't be parsed as datetime, try numeric conversion
40
+ try:
41
+ obj[key] = int(value)
42
+ except ValueError:
43
+ try:
44
+ obj[key] = float(value)
45
+ except ValueError:
46
+ pass
47
+ elif isinstance(value, dict):
48
+ obj[key] = parse_values(value)
49
+ elif isinstance(value, list):
50
+ obj[key] = [parse_values(item) if isinstance(item, dict) else item for item in value]
51
+ return obj
singular_ticker_causal/data_sources/nseconnect/datemgr.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import datetime as dt
2
+ from dateutil.relativedelta import relativedelta
3
+ from dateutil.parser import parse
4
+ from dateutil import rrule
5
+ from .errors import DateFormatError
6
+
7
+
8
+ def get_nearest_business_day(d):
9
+ """ takes datetime object"""
10
+ if d.isoweekday() == 7 or d.isoweekday() == 6:
11
+ d = d - relativedelta(days=1)
12
+ return get_nearest_business_day(d)
13
+
14
+ # republic day
15
+ elif d.month == 1 and d.day == 26:
16
+ d = d - relativedelta(days=1)
17
+ return get_nearest_business_day(d)
18
+ # labour day
19
+ elif d.month == 5 and d.day == 1:
20
+ d = d - relativedelta(days=1)
21
+ return get_nearest_business_day(d)
22
+ # independece day
23
+ elif d.month == 8 and d.day == 15:
24
+ d = d - relativedelta(days=1)
25
+ return get_nearest_business_day(d)
26
+ # Gandhi Jayanti
27
+ elif d.month == 10 and d.day == 2:
28
+ d = d - relativedelta(days=1)
29
+ return get_nearest_business_day(d)
30
+ # chirstmas
31
+ elif d.month == 12 and d.day == 25:
32
+ d = d - relativedelta(days=1)
33
+ return get_nearest_business_day(d)
34
+ else:
35
+ return d
36
+
37
+ def is_known_holiday(d):
38
+ """accepts datetime/date object and returns boolean"""
39
+ if type(d) == dt.datetime:
40
+ d = d.date()
41
+ elif type(d) != dt.date:
42
+ raise DateFormatError("only date objects or datetime objects")
43
+ else:
44
+ # fine do nothing
45
+ pass
46
+
47
+ # declare the list of holidays here.
48
+ # republic day.
49
+ if d.month == 1 and d.day == 26:
50
+ return True
51
+ # labour day
52
+ elif d.month == 5 and d.day == 1:
53
+ d = d - relativedelta(days=1)
54
+ return get_nearest_business_day(d)
55
+ # independence day
56
+ elif d.month == 8 and d.day == 15:
57
+ return True
58
+ # gandhi jayanti
59
+ elif d.month == 10 and d.day == 2:
60
+ return True
61
+ # christmas
62
+ elif d.month == 12 and d.day == 25:
63
+ return True
64
+ else:
65
+ return False
66
+
67
+ def mkdate(d):
68
+ """tries its best to return a valid date. it can accept pharse like today,
69
+ yesterday, day before yesterday etc.
70
+ """
71
+ # check if the it == a string
72
+ return_date = ""
73
+ if type(d) is str:
74
+ if d == "today":
75
+ return_date = dt.date.today()
76
+ elif d == "yesterday":
77
+ return_date = dt.date.today() - relativedelta(days=1)
78
+ elif d == "day before yesterday":
79
+ return_date = dt.date.today() - relativedelta(days=2)
80
+ else:
81
+ return_date = parse(d, dayfirst=True).date()
82
+ elif type(d) == dt.datetime:
83
+ return_date = d.date()
84
+ elif type(d) == dt.date:
85
+ return d
86
+ else:
87
+ raise DateFormatError("wrong date format %s" % str(d))
88
+ # check if future date.
89
+ return return_date
90
+
91
+ def usable_date(d):
92
+ """accepts fuzzy format and returns most sensible date"""
93
+ return get_nearest_business_day(mkdate(d))
94
+
95
+ def get_date_range(frm, to, skip_dates=[]):
96
+ """accepts fuzzy format date and returns business adjusted date ranges"""
97
+ # for x in rrule.rrule(rrule.DAILY, dtstart=s, until=dt.datetime.now(), byweekday=[0, 1, 2, 3, 4]): print(x)
98
+ frm = usable_date(frm)
99
+ to = usable_date(to)
100
+ datelist = []
101
+ for date in rrule.rrule(rrule.DAILY, dtstart=frm, until=to, byweekday=[0, 1, 2, 3, 4]):
102
+ if not is_known_holiday(date):
103
+ datelist.append(date.date())
104
+ return datelist
singular_ticker_causal/data_sources/nseconnect/downloader.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The MIT License (MIT)
3
+
4
+ Copyright (c) 2014 Noufal Nazar
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
23
+
24
+ """
25
+ import io
26
+ import os
27
+ import zipfile
28
+ import datetime as dt
29
+ from urllib.request import Request
30
+ from .datemgr import mkdate, get_date_range
31
+ from .nse import Nse
32
+ from abc import ABCMeta, abstractmethod
33
+
34
+ class BaseBhavcopyDownloader(metaclass=ABCMeta):
35
+ """Base class for all types of bhavcopy downloader"""
36
+ def __init__(self, from_date, to_date=dt.datetime.now().date(), skip_dates=[]):
37
+ """accepts date in fuzzy format"""
38
+ self.bhavcopy_base_url = "https://www.nseindia.com/content/historical/EQUITIES/%s/%s/cm%s%s%sbhav.csv.zip"
39
+ self.bhavcopy_base_filename = "cm%s%s%sbhav.csv"
40
+ self.from_date = from_date
41
+ self.to_date = to_date
42
+ self.skip_dates = skip_dates
43
+ self.nse = Nse()
44
+ self.dates = self.generate_dates()
45
+
46
+ def generate_dates(self):
47
+ return get_date_range(self.from_date, self.to_date, skip_dates=self.skip_dates)
48
+
49
+ def get_bhavcopy_url(self, d):
50
+ """accept date and return bhavcopy url"""
51
+ day_of_month = d.strftime("%d")
52
+ mon = d.strftime("%b").upper()
53
+ year = d.year
54
+ url = self.bhavcopy_base_url % (year, mon, day_of_month, mon, year)
55
+ return url
56
+
57
+ def get_bhavcopy_filename(self, d):
58
+ """for a given date generate bhavcopy filename"""
59
+ day_of_month = d.strftime("%d")
60
+ mon = d.strftime("%b").upper()
61
+ year = d.year
62
+ filename = self.bhavcopy_base_filename % (day_of_month, mon, year)
63
+ return filename
64
+
65
+ def download_one(self, d):
66
+ """download bhavcopy for the given date"""
67
+ # this will keep this method usable for any arbitrary date.
68
+ d = mkdate(d)
69
+ # ex_url = "https://www.nseindia.com/content/historical/EQUITIES/2011/NOV/cm08NOV2011bhav.csv.zip"
70
+ url = self.get_bhavcopy_url(d)
71
+ print(url)
72
+ filename = self.get_bhavcopy_filename(d)
73
+ # response = requests.get(url, headers=self.headers)
74
+ response = self.nse.opener.open(Request(url, None, self.nse.headers))
75
+ zip_file_handle = io.BytesIO(response.read())
76
+ zf = zipfile.ZipFile(zip_file_handle)
77
+ return zf.read(filename).decode("utf-8")
78
+
79
+ @abstractmethod
80
+ def download(self):
81
+ pass
82
+
83
+ @abstractmethod
84
+ def update(self):
85
+ pass
86
+
87
+
88
+ class BhavcopyFileSystemDownloader(BaseBhavcopyDownloader):
89
+ def __init__(self, directory, *args, **kwargs):
90
+ if (os.path.exists(directory) and os.path.isdir(directory) and os.access(directory, os.W_OK)):
91
+ super().__init__(*args, **kwargs)
92
+ self.directory = directory
93
+ else:
94
+ raise Exception("directory path must be valid and writtable, please check manually")
95
+
96
+ def download(self):
97
+ for date in self.dates:
98
+ print("downloading for " + str(date))
99
+ try:
100
+ content = self.download_one(date)
101
+ except Exception as err:
102
+ print("unable to download for the date: %s" % date.strftime("%Y-%m-%d"))
103
+ else:
104
+ fh = open(self.directory + "/" + date.strftime("%Y-%m-%d") + ".csv", "w")
105
+ fh.write(content)
106
+ fh.close()
107
+
108
+ def update(self):
109
+ pass
110
+
111
+
112
+ if __name__ == '__main__':
113
+ b = BhavcopyFileSystemDownloader(directory="/tmp/bhavcopy", from_date="01-01-2018")
114
+ b.download()
115
+
116
+ # https://stackoverflow.com/questions/49183801/ssl-certificate-verify-failed-with-urllib
singular_ticker_causal/data_sources/nseconnect/errors.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ class BhavcopyNotAvailableError(Exception):
2
+ """this error could occur in case you download bhavcopy for the dates
3
+ when the market was close"""
4
+ pass
5
+
6
+ class DateFormatError(Exception):
7
+ """in case the date format is errorneous"""
8
+ pass