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

Removed causal learning and validation

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +0 -645
  2. server.py +396 -515
  3. singular_ticker_causal/.env +0 -3
  4. singular_ticker_causal/algorithms/CUTS_PLUS/cuts_plus.py +0 -834
  5. singular_ticker_causal/algorithms/CUTS_PLUS/data/__init__.py +0 -0
  6. singular_ticker_causal/algorithms/CUTS_PLUS/data/generate_data_mod.py +0 -430
  7. singular_ticker_causal/algorithms/CUTS_PLUS/data/simu_data.py +0 -313
  8. singular_ticker_causal/algorithms/CUTS_PLUS/data/utils.py +0 -136
  9. singular_ticker_causal/algorithms/CUTS_PLUS/lagged_graph.py +0 -95
  10. singular_ticker_causal/algorithms/CUTS_PLUS/model/cuts_plus_net.py +0 -153
  11. singular_ticker_causal/algorithms/CUTS_PLUS/utils/causal_plot.py +0 -57
  12. singular_ticker_causal/algorithms/CUTS_PLUS/utils/exp_utils.py +0 -286
  13. singular_ticker_causal/algorithms/CUTS_PLUS/utils/gumbel_softmax.py +0 -66
  14. singular_ticker_causal/algorithms/CUTS_PLUS/utils/logger.py +0 -74
  15. singular_ticker_causal/algorithms/CUTS_PLUS/utils/misc.py +0 -292
  16. singular_ticker_causal/algorithms/CUTS_PLUS/utils/opt_type.py +0 -83
  17. singular_ticker_causal/algorithms/__init__.py +0 -2
  18. singular_ticker_causal/causal_inference/__init__.py +0 -15
  19. singular_ticker_causal/causal_inference/abduction.py +0 -127
  20. singular_ticker_causal/causal_inference/causal_model.py +0 -314
  21. singular_ticker_causal/causal_inference/estimator.py +0 -125
  22. singular_ticker_causal/causal_inference/identification.py +0 -41
  23. singular_ticker_causal/causal_inference/mutilator.py +0 -62
  24. singular_ticker_causal/causal_inference/pywhyllm_assumptions.py +0 -338
  25. singular_ticker_causal/causal_inference/query_engine.py +0 -505
  26. singular_ticker_causal/causal_inference/tests/test_causal_queries.py +0 -102
  27. singular_ticker_causal/causal_inference/tests/test_pywhyllm_assumptions.py +0 -193
  28. singular_ticker_causal/data_sources/__init__.py +0 -4
  29. singular_ticker_causal/data_sources/bsedata/__init__.py +0 -27
  30. singular_ticker_causal/data_sources/bsedata/bhavcopy.py +0 -58
  31. singular_ticker_causal/data_sources/bsedata/bse.py +0 -150
  32. singular_ticker_causal/data_sources/bsedata/exceptions.py +0 -51
  33. singular_ticker_causal/data_sources/bsedata/gainers.py +0 -57
  34. singular_ticker_causal/data_sources/bsedata/helpers.py +0 -3
  35. singular_ticker_causal/data_sources/bsedata/indices.py +0 -112
  36. singular_ticker_causal/data_sources/bsedata/losers.py +0 -57
  37. singular_ticker_causal/data_sources/bsedata/quote.py +0 -176
  38. singular_ticker_causal/data_sources/fetcher.py +0 -219
  39. singular_ticker_causal/data_sources/gdelt_client.py +0 -400
  40. singular_ticker_causal/data_sources/news_client.py +0 -460
  41. singular_ticker_causal/data_sources/nseconnect/__init__.py +0 -25
  42. singular_ticker_causal/data_sources/nseconnect/bases.py +0 -72
  43. singular_ticker_causal/data_sources/nseconnect/cleaners.py +0 -51
  44. singular_ticker_causal/data_sources/nseconnect/datemgr.py +0 -104
  45. singular_ticker_causal/data_sources/nseconnect/downloader.py +0 -116
  46. singular_ticker_causal/data_sources/nseconnect/errors.py +0 -8
  47. singular_ticker_causal/data_sources/nseconnect/nse.py +0 -624
  48. singular_ticker_causal/data_sources/nseconnect/ua.py +0 -137
  49. singular_ticker_causal/data_sources/nseconnect/urls.py +0 -35
  50. singular_ticker_causal/data_sources/nseconnect/utils.py +0 -373
README.md CHANGED
@@ -14,648 +14,3 @@ pinned: false
14
  **Description:** End-to-end financial intelligence pipeline for Net-of-Tax Alpha decisions using causal chain analysis, web scraping, and data aggregation.
15
 
16
  ---
17
-
18
- ## Table of Contents
19
-
20
- 1. [Overview](#overview)
21
- 2. [Authentication](#authentication)
22
- 3. [API Endpoints](#api-endpoints)
23
- - [Server Endpoints](#server-endpoints)
24
- - [V2 API Endpoints](#v2-api-endpoints)
25
- 4. [Core Functions](#core-functions)
26
- 5. [Supported Tickers](#supported-tickers)
27
- 6. [Error Handling](#error-handling)
28
- 7. [Examples](#examples)
29
-
30
- ---
31
-
32
- ## Overview
33
-
34
- The Noisy Boy API is a FastAPI-based financial intelligence system that:
35
-
36
- - **Fetches multi-source financial data** from various Indian market sources (BSE, NSE, RBI, SEBI, etc.)
37
- - **Analyzes causal chains** between market events and price movements using BERT and embedding models
38
- - **Generates aggregated text** from diverse data sources for each ticker
39
- - **Builds knowledge graphs** with semantic and evidence layers
40
- - **Streams real-time SSE responses** for long-running analysis tasks
41
-
42
- ### Technology Stack
43
-
44
- - **Framework:** FastAPI with async/await support
45
- - **Database:** SQLite (local) + Supabase (cloud)
46
- - **Models:**
47
- - BERT-CAUSE-EFFECT for causal relationship extraction
48
- - Fireworks AI embeddings for similarity scoring
49
- - OpenAI LLM for graph summarization
50
- - TinyFish AI for web automation
51
- - **Data Sources:** 50+ fetchers covering macro, regulatory, and company-specific data
52
-
53
- ---
54
-
55
- ## Authentication
56
-
57
- ### API Key Authentication
58
-
59
- All endpoints (except `/api/source-refs`) require an `X-API-Key` header:
60
-
61
- ```bash
62
- curl -H "X-API-Key: your-secret-token" https://api.example.com/api/endpoint
63
- curl -H "X-API-Key: your-api-key" http://localhost:8000/v2/api/endpoint
64
- ```
65
-
66
- **Environment Variable**: `API_KEY` (default: `secret-token`)
67
-
68
- ---
69
-
70
- ## Base URL
71
-
72
- ```
73
- http://localhost:8000/v2
74
- ```
75
-
76
- ---
77
-
78
- ## API Endpoints
79
-
80
- ### Scraper API
81
-
82
- #### 1. GET `/api/source-refs`
83
- Returns all fetcher-to-source-URL mappings and reference links.
84
-
85
- **Authentication**: None required
86
-
87
- **Response**:
88
- ```json
89
- {
90
- "fetcher_refs": {
91
- "repo_rate": "https://rbi.org.in/...",
92
- "bse": "https://bseindia.com/...",
93
- ...
94
- },
95
- "cat_refs": {
96
- "macro": {...},
97
- "corporate": {...}
98
- }
99
- }
100
- ```
101
-
102
- ---
103
-
104
- #### 2. POST `/api/run`
105
- Streams real-time scraping results via Server-Sent Events (SSE) from TinyFish API with local caching.
106
-
107
- **Authentication**: Required (`X-API-Key`)
108
-
109
- **Request Body**:
110
- ```json
111
- {
112
- "url": "https://example.com/page",
113
- "goal": "Extract financial data",
114
- "ticker": "HDFCBANK",
115
- "stealth": false
116
- }
117
- ```
118
-
119
- **Query Parameters**:
120
- - `url` (required): Target URL to scrape
121
- - `goal` (required): Scraping objective description
122
- - `ticker` (optional): Stock ticker for caching purposes
123
- - `stealth` (optional, bool): Enable stealth mode
124
-
125
- **Response** (SSE):
126
- ```
127
- data: {"type": "STARTED", "run_id": "run_123"}
128
- data: {"type": "PROGRESS", "purpose": "Fetching data..."}
129
- data: {"type": "COMPLETE", "status": "COMPLETED", "result_json": {...}}
130
- ```
131
-
132
- **Caching**: Results are cached per ticker per day via SQLite.
133
-
134
- ---
135
-
136
- #### 3. GET `/api/ticker-data/{ticker}`
137
- Fetch all cached data for a ticker across all sources (Supabase).
138
-
139
- **Authentication**: Required (`X-API-Key`)
140
-
141
- **Path Parameters**:
142
- - `ticker`: Stock ticker symbol (e.g., `HDFCBANK`)
143
-
144
- **Response**:
145
- ```json
146
- {
147
- "ticker": "HDFCBANK",
148
- "status": "success",
149
- "data": {
150
- "repo_rate": {
151
- "data": {...},
152
- "fetched_at": "2024-01-15T10:30:00Z"
153
- },
154
- "bse": {
155
- "data": [...],
156
- "fetched_at": "2024-01-15T10:30:00Z"
157
- },
158
- "aggregated_text": {
159
- "aggregated_text": "...",
160
- "fetched_at": "2024-01-15T10:30:00Z"
161
- },
162
- "causal_chain": {
163
- "nodes": [...],
164
- "links": [...],
165
- "all_chains": [...],
166
- "biggest_chain": [...]
167
- }
168
- }
169
- }
170
- ```
171
-
172
- ---
173
-
174
- ### Causal Chain API
175
-
176
- #### 4. GET `/api/generate-causal-chain-stream`
177
- Streams causal chain generation via SSE (two phases: nodes, then edges).
178
-
179
- **Authentication**: Required (`X-API-Key`)
180
-
181
- **Query Parameters**:
182
- - `ticker` (required): Stock ticker symbol
183
-
184
- **Response** (SSE - Streaming):
185
- ```
186
- data: {"type": "status", "message": "Generating text for ticker..."}
187
- data: {"type": "nodes", "nodes": [{"id": "RBI Rate Hike", "label": "RBI Rate Hike"}, ...]}
188
- data: {"type": "edges", "links": [{"source": "Rate Hike", "target": "Inflation", "score": 0.89}, ...]}
189
- data: {"type": "done", "all_chains": [[...], [...]], "biggest_chain": [...]}
190
- ```
191
-
192
- **Phases**:
193
- 1. **Status**: Initial status message
194
- 2. **Nodes**: Unique causal entities extracted from BERT model
195
- 3. **Edges**: Connections between nodes from embedding similarity
196
- 4. **Done**: Final chains and biggest causal path
197
-
198
- ---
199
-
200
- ### Ontology API
201
-
202
- #### 5. POST `/api/generate-ontology`
203
- Generate financial ontology from input text.
204
-
205
- **Authentication**: Required (via implicit call)
206
-
207
- **Request Body**:
208
- ```json
209
- {
210
- "ticker": "RELIANCE",
211
- "text": "Recent crude oil rally drives RELIANCE revenue..."
212
- }
213
- ```
214
-
215
- **Response**:
216
- ```json
217
- {
218
- "status": "success",
219
- "ontology": {
220
- "entities": ["crude", "revenue", "RELIANCE"],
221
- "relations": [{"type": "affects", "from": "crude", "to": "RELIANCE"}]
222
- }
223
- }
224
- ```
225
-
226
- ---
227
-
228
- #### 6. POST `/api/extract-entities`
229
- Extract named entities using the provided ontology.
230
-
231
- **Authentication**: Required (via implicit call)
232
-
233
- **Request Body**:
234
- ```json
235
- {
236
- "ticker": "ITC",
237
- "text": "Coal prices surge amid monsoon fears...",
238
- "ontology": {
239
- "entities": ["coal", "monsoon", "price"],
240
- "relations": [...]
241
- }
242
- }
243
- ```
244
-
245
- **Response**:
246
- ```json
247
- {
248
- "status": "success",
249
- "entities": [
250
- {"entity": "coal", "type": "commodity", "confidence": 0.92},
251
- {"entity": "monsoon", "type": "weather_event", "confidence": 0.88}
252
- ]
253
- }
254
- ```
255
-
256
- ---
257
-
258
- #### 7. POST `/api/build-knowledge-graph`
259
- Build complete knowledge graph with nodes, edges, and causal chains.
260
-
261
- **Authentication**: Required (via implicit call)
262
-
263
- **Request Body**:
264
- ```json
265
- {
266
- "graph_id": "graph_001",
267
- "ticker": "BHEL",
268
- "text": "Full financial text for analysis...",
269
- "financial_results": {...},
270
- "forensic_results": {...},
271
- "tech_results": {...},
272
- "cached_data": {...}
273
- }
274
- ```
275
-
276
- **Response**:
277
- ```json
278
- {
279
- "meta": {
280
- "ticker": "BHEL",
281
- "exchange": "NSE",
282
- "generated_at": "2024-01-15T12:45:00Z",
283
- "status": "success",
284
- "chain_count": 5,
285
- "node_count": 23,
286
- "edge_count": 45
287
- },
288
- "summary": {
289
- "narrative": "Coal shortage → Power demand surge → BHEL tariff opportunity",
290
- "net_sentiment_for_ticker": "bullish",
291
- "ticker_relevance_score": 0.87,
292
- "macro_regimes_active": ["coal-supply-shock", "power-demand-rally"],
293
- "top_causal_nodes": ["Coal shortage", "Power demand", "BHEL capacity"],
294
- "ria_alert": {
295
- "level": "medium",
296
- "reason": "Monitor coal supply for sustained impact on tariff structure."
297
- }
298
- },
299
- "biggest_chain": ["Coal shortage", "Power demand", "BHEL contract wins", "Revenue growth"],
300
- "all_chains": [[...], [...], ...],
301
- "nodes": [...],
302
- "edges": [...]
303
- }
304
- ```
305
-
306
- ---
307
-
308
- ### Server API (Streaming)
309
-
310
- #### 8. GET `/api/generate-causal-chain-stream` (Alternative)
311
- Same as endpoint #4 but with optional caching from server-side TinyFish integration.
312
-
313
- ---
314
-
315
- ## Data Fetchers
316
-
317
- The system supports multiple data fetchers for different tickers. Each fetcher aggregates specific financial signals:
318
-
319
- ### Supported Fetchers
320
-
321
- | Fetcher | Description | Tickers |
322
- |---------|-------------|---------|
323
- | `repo_rate` | RBI Repo Rate | HDFCBANK, TCS, PAYTM, TMPV |
324
- | `bse` | BSE Announcements | All supported tickers |
325
- | `fii_dii` | FII/DII Flows | HDFCBANK |
326
- | `brent` | Brent Crude Oil Price | HDFCBANK, RELIANCE, TMPV |
327
- | `news` | Financial News Articles | All supported tickers |
328
- | `npp` | National Power Portal Data | IEX, BHEL |
329
- | `imd_monsoon` | IMD Monsoon Status | RELIANCE, IEX, BHEL, ETERNAL, ULTRACEMCO |
330
- | `coal` | Coal Price Index | IEX, BHEL, ITC, ULTRACEMCO |
331
- | `bhel_tenders` | BHEL Active Tenders | BHEL |
332
- | `agmarknet` | Agricultural Prices | ETERNAL, ITC |
333
- | `weather` | Weather in Key Cities | ETERNAL |
334
- | `h1b` | H1B Visa Filings | TCS |
335
- | `us_fed` | US Fed Interest Rate | TCS |
336
- | `us_pmi` | US Services PMI | TCS |
337
- | `trai_reports` | TRAI Telecom Reports | RELIANCE |
338
- | `npci` | NPCI UPI Statistics | ETERNAL, IRCTC, PAYTM, MAPMYINDIA |
339
- | `dgca_traffic` | DGCA Air Traffic Data | IRCTC |
340
- | `tourist_arrivals` | Foreign Tourist Arrivals | IRCTC |
341
- | `india_cpi` | India CPI Inflation | ITC |
342
- | `food_cpi` | Food CPI Inflation | ETERNAL |
343
- | `labour` | Labour Ministry Releases | ETERNAL |
344
- | `sebi_orders` | SEBI Orders | PAYTM |
345
- | `mca_filings` | MCA Corporate Filings | PAYTM |
346
- | `cma_capacity` | Cement Capacity | ULTRACEMCO |
347
- | `datareportal` | Digital India Stats | MAPMYINDIA |
348
- | `pib_highways` | PIB Highway Announcements | ULTRACEMCO, MAPMYINDIA |
349
- | `pib_vb` | PIB Vande Bharat Updates | BHEL, IRCTC |
350
- | `nse_bulk_deals` | NSE Bulk Deals | RELIANCE |
351
- | `cci_orders` | CCI Antitrust Orders | RELIANCE |
352
- | `erc_orders` | ERC Tariff Orders | IEX |
353
- | `saubhagya` | Saubhagya Electrification | IEX |
354
- | `ppac` | India Basket Crude | RELIANCE, ULTRACEMCO |
355
-
356
- ### Fetcher Response Format
357
-
358
- Each fetcher returns formatted text with relevant financial signals:
359
-
360
- ```
361
- Brent crude oil price is $85.50 showing a (upward trend, with 5.2% change 30-day).
362
- RBI Repo Rate is 6.5%.
363
- FII/DII flows for 2024-01-15: FII net is 450 Cr with net (buy action, holding a positive view).
364
- ```
365
-
366
- ---
367
-
368
- ## Core Functions
369
-
370
- ### CausalChain Class (`app/services/causal_chains.py`)
371
-
372
- #### `__init__(chunks, fireworks_api_key, fireworks_model)`
373
- Initialize causal chain processor.
374
-
375
- **Parameters**:
376
- - `chunks` (list): Text chunks to analyze
377
- - `fireworks_api_key` (str): Fireworks AI API key
378
- - `fireworks_model` (str): Embedding model name (default: `qwen3-embedding-8b`)
379
-
380
- ---
381
-
382
- #### `create_effects(batch_size=16)`
383
- Extract triggers and effects using BERT-CAUSE-EFFECT model.
384
-
385
- **Process**:
386
- 1. Sends text chunks to causal model API
387
- 2. Parses model responses for event triggers and descriptions
388
- 3. Stores triggers and effects
389
-
390
- **Retries**: Up to 5 attempts with exponential backoff for failed requests
391
-
392
- ---
393
-
394
- #### `create_connections(batch_size=16, chain_threshold=0.85)`
395
- Build causal connections using embeddings with grounding.
396
-
397
- **Process**:
398
- 1. Encodes triggers and effects via Fireworks embeddings
399
- 2. Computes cosine similarity between effects and triggers
400
- 3. Grounds connections by shared entities and keywords
401
- 4. Filters spurious links using entity intersection
402
-
403
- **Parameters**:
404
- - `chain_threshold`: Similarity score threshold (default: 0.85)
405
-
406
- ---
407
-
408
- #### `get_all_chains(min_length=2, max_paths=500, time_budget=30)`
409
- Extract unique causal chains from connections.
410
-
411
- **Parameters**:
412
- - `min_length`: Minimum chain length to keep
413
- - `max_paths`: Stop after collecting N paths
414
- - `time_budget`: Wall-clock time limit (seconds)
415
-
416
- **Returns**: List of chains, each a list of nodes
417
-
418
- ---
419
-
420
- #### `find_biggest_chain(time_budget=20)`
421
- Find longest causal chain using iterative DFS.
422
-
423
- **Returns**: Longest causal path as list of nodes
424
-
425
- ---
426
-
427
- #### `to_dict()` / `from_dict()`
428
- Serialize/deserialize causal chain state.
429
-
430
- ---
431
-
432
- ### Utility Functions (`util` class)
433
-
434
- #### `cos_sim(a, b)`
435
- Compute cosine similarity matrix between two embedding arrays.
436
-
437
- ```python
438
- scores = util.cos_sim(effect_embeddings, trigger_embeddings)
439
- # scores[i,j] = cosine similarity between effect i and trigger j
440
- ```
441
-
442
- ---
443
-
444
- #### `create_chunks(text_input, target_size=700, overlap_sentences=1)`
445
- Create semantic chunks with overlap for context preservation.
446
-
447
- **Parameters**:
448
- - `target_size`: Target chunk size (characters)
449
- - `overlap_sentences`: Number of sentences to overlap
450
-
451
- **Returns**: List of text chunks ≤ 1000 chars each
452
-
453
- ---
454
-
455
- ### Fetcher Functions (`scrapper/tiny_fish.py`)
456
-
457
- #### `run_fetcher(fetcher_key, ticker_config, persist=False)`
458
- Execute a single data fetcher.
459
-
460
- **Parameters**:
461
- - `fetcher_key`: Name of fetcher (e.g., `repo_rate`, `bse`)
462
- - `ticker_config`: Config dict with BSE code, ticker symbol, fetchers list
463
- - `persist`: Whether to cache results to SQLite
464
-
465
- **Returns**: Formatted data dict
466
-
467
- **Caching**: Per-ticker, per-day SQLite caching
468
-
469
- ---
470
-
471
- ### Text Formatting
472
-
473
- #### `format_fetcher_text(fetcher_key, data)`
474
- Format raw fetcher data into human-readable text.
475
-
476
- **Example**:
477
- ```python
478
- text = format_fetcher_text("repo_rate", {"repo_rate_pct": 6.5})
479
- # Returns: "The current RBI Repo Rate is 6.5%.\n"
480
- ```
481
-
482
- ---
483
-
484
- ## Examples
485
-
486
- ### Example 1: Fetch and Cache Aggregated Data
487
-
488
- ```bash
489
- curl -X GET "http://localhost:8000/v2/api/ticker-data/RELIANCE" \
490
- -H "X-API-Key: secret-token"
491
- ```
492
-
493
- ---
494
-
495
- ### Example 2: Stream Causal Chain Generation (SSE)
496
-
497
- ```bash
498
- curl -X GET "http://localhost:8000/v2/api/generate-causal-chain-stream?ticker=HDFCBANK" \
499
- -H "X-API-Key: secret-token" \
500
- -N
501
- ```
502
-
503
- **Output** (streaming):
504
- ```
505
- data: {"type":"status","message":"Generating text for ticker..."}
506
- data: {"type":"nodes","nodes":[{"id":"RBI Rate Hike","label":"RBI Rate Hike"},{"id":"Inflation Risk","label":"Inflation Risk"}]}
507
- data: {"type":"edges","links":[{"source":"RBI Rate Hike","target":"Inflation Risk","score":0.89}]}
508
- data: {"type":"done","all_chains":[["RBI Rate Hike","Inflation Risk","Market Correction"]],"biggest_chain":["RBI Rate Hike","Inflation Risk","Market Correction"]}
509
- ```
510
-
511
- ---
512
-
513
- ### Example 3: Build Knowledge Graph
514
-
515
- ```bash
516
- curl -X POST "http://localhost:8000/v2/api/build-knowledge-graph" \
517
- -H "Content-Type: application/json" \
518
- -d '{
519
- "graph_id": "g001",
520
- "ticker": "TCS",
521
- "text": "H1B visa approvals surge amid US tech expansion...",
522
- "financial_results": null,
523
- "forensic_results": null,
524
- "tech_results": null,
525
- "cached_data": null
526
- }'
527
- ```
528
-
529
- ---
530
-
531
- ### Example 4: Ontology Generation
532
-
533
- ```bash
534
- curl -X POST "http://localhost:8000/v2/api/generate-ontology" \
535
- -H "Content-Type: application/json" \
536
- -d '{
537
- "ticker": "PAYTM",
538
- "text": "NPCI UPI transaction surge drives fintech growth..."
539
- }'
540
- ```
541
-
542
- ---
543
-
544
- ### Example 5: Run Custom Web Scraper
545
-
546
- ```bash
547
- curl -X POST "http://localhost:8000/v2/api/run" \
548
- -H "Content-Type: application/json" \
549
- -H "X-API-Key: secret-token" \
550
- -d '{
551
- "url": "https://example.com/financial-news",
552
- "goal": "Extract Q4 earnings announcements",
553
- "ticker": "RELIANCE",
554
- "stealth": true
555
- }' \
556
- -N
557
- ```
558
-
559
- ---
560
-
561
- ## Error Handling
562
-
563
- ### Standard Error Responses
564
-
565
- #### 400 Bad Request
566
- ```json
567
- {
568
- "detail": "url and goal are required"
569
- }
570
- ```
571
-
572
- #### 401 Unauthorized
573
- ```json
574
- {
575
- "detail": "Invalid or missing API Key"
576
- }
577
- ```
578
-
579
- #### 500 Internal Server Error
580
- ```json
581
- {
582
- "detail": "TINYFISH_API_KEY not found in .env"
583
- }
584
- ```
585
-
586
- ---
587
-
588
- ## Supported Tickers
589
-
590
- | Ticker | BSE Code | Use Case |
591
- |--------|----------|----------|
592
- | HDFCBANK | 500180 | Banking sector, RBI rates |
593
- | RELIANCE | 500325 | Energy, crude oil exposure |
594
- | IEX | 540716 | Power sector, capacity data |
595
- | TCS | 532540 | IT sector, US visa trends |
596
- | BHEL | 500103 | Power equipment, tender data |
597
- | ETERNAL | 543320 | Agri-related, food inflation |
598
- | IRCTC | 542830 | Transportation, tourist flows |
599
- | ITC | 500875 | Agri-commodities, coal |
600
- | PAYTM | 543396 | Fintech, NPCI/UPI metrics |
601
- | ULTRACEMCO | 532538 | Cement, infrastructure |
602
- | TMPV (TATAMOTORS) | 500570 | Automotive, oil exposure |
603
- | MAPMYINDIA | 543425 | Digital infrastructure |
604
-
605
- ---
606
-
607
- ## Response Caching Strategy
608
-
609
- - **SQLite Cache**: Per ticker, per day (local fetcher results)
610
- - **Supabase Cache**: Per ticker, per day (aggregated text, causal chains, ontology)
611
- - **Cache Invalidation**: Automatic at midnight UTC
612
-
613
- ---
614
-
615
- ## Performance Notes
616
-
617
- - **Causal Chain Generation**: ~30-60 seconds for typical 50-100 chunk inputs
618
- - **Embedding Computation**: ~5-15 seconds per 100 text chunks
619
- - **Large Graph Building**: May take 2-5 minutes for complex tickets with >500 nodes
620
-
621
- ---
622
-
623
- ## Environment Variables
624
-
625
- ```bash
626
- API_KEY=secret-token
627
- FIREWORKS_API_KEY=your-fireworks-key
628
- CAUSAL_URL=http://localhost:8080/generate_batch
629
- CAUSAL_API_KEY=optional-causal-model-key
630
- TINYFISH_API_KEY=your-tinyfish-key
631
- SUPABASE_URL=https://your-project.supabase.co
632
- SUPABASE_KEY=your-supabase-key
633
- DEBUG=false
634
- ```
635
-
636
- ---
637
-
638
- ## License
639
-
640
- Proprietary - ProjectImpulse
641
-
642
- ---
643
-
644
- ## Support
645
-
646
- For issues or questions, contact the ProjectImpulse team.
647
- ```
648
-
649
- I've created a comprehensive API README that documents all endpoints, functions, and features in the Noisy_boy repository. The documentation includes:
650
-
651
- **Key Sections:**
652
- 1. **Authentication & Base URL** - How to authenticate and where to call endpoints
653
- 2. **8 Major Endpoints** - Scraper, Causal Chain, Ontology, and Server APIs with request/response examples
654
- 3. **30+ Data Fetchers** - Table of all supported data sources for different tickers
655
- 4. **Core Functions** - Detailed documentation of `CausalChain` class, `util` functions, and fetcher operations
656
- 5. **5 Practical Examples** - cURL commands showing how to use each endpoint
657
- 6. **Error Handling** - Standard error response formats
658
- 7. **Performance Notes** - Expected timing for various operations
659
- 8. **Environment Variables** - Complete list of required configs
660
-
661
- The documentation follows industry standards with clear formatting, examples, and parameter specifications. You can copy this content into an `API_README.md` file in your repository!
 
14
  **Description:** End-to-end financial intelligence pipeline for Net-of-Tax Alpha decisions using causal chain analysis, web scraping, and data aggregation.
15
 
16
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
  dashboard_server.py
3
  ====================
4
- CUTS+ Causal Terminal — gr.Server entry point.
5
 
6
  Architecture
7
  ------------
@@ -9,15 +9,9 @@ gr.Server (extends FastAPI)
9
  ├── GET / → serves frontend/index.html
10
  ├── GET /static/* → serves frontend/{style.css, app.js} (StaticFiles)
11
 
12
- ├── @server.api 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).
@@ -36,9 +30,14 @@ import os
36
  import sys
37
  import json
38
  import logging
 
 
 
39
  from pathlib import Path
 
40
  from typing import Any, Dict, List, Optional
41
-
 
42
  from dotenv import load_dotenv
43
 
44
  load_dotenv()
@@ -47,7 +46,7 @@ BASE_DIR = Path(__file__).parent.resolve()
47
  if str(BASE_DIR) not in sys.path:
48
  sys.path.insert(0, str(BASE_DIR))
49
 
50
- # Also add the backend directory to sys.path so we can import 'app', 'causal', '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))
@@ -65,19 +64,109 @@ logger = logging.getLogger("dashboard-server")
65
  # ─────────────────────────────────────────────────────────────────────────────
66
  # gr.Server
67
  # ─────────────────────────────────────────────────────────────────────────────
68
- import gradio as gr
69
- from gradio import Server
70
-
71
- from fastapi import Request
72
- from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
73
- from fastapi.staticfiles import StaticFiles
74
- from fastapi.middleware.cors import CORSMiddleware
75
 
76
  FRONTEND_DIR = BASE_DIR / "frontend"
77
  INDEX_HTML = FRONTEND_DIR / "index.html"
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  server = Server(
80
- title="CUTS+ Causal Terminal",
81
  description=(
82
  "Iroha Financial Intelligence — real-time causal probability matrix, "
83
  "HHKD decomposition, DoFlow inference and sector hierarchy over NIFTY50."
@@ -107,7 +196,7 @@ server.mount(
107
 
108
  @server.get("/", response_class=HTMLResponse, include_in_schema=False)
109
  async def serve_index():
110
- """Serve the 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"))
@@ -123,78 +212,6 @@ async def health():
123
  return {"status": "ok", "version": "2.0.0"}
124
 
125
 
126
- # ─────────────────────────────────────────────────────────────────────────────
127
- # Existing v2 routers (same as main.py)
128
- # ─────────────────────────────────────────────────────────────────────────────
129
-
130
- try:
131
- from app.api.scraper import router as scraper_router
132
- server.include_router(scraper_router, prefix="/v2")
133
- logger.info("✓ scraper router mounted")
134
- except Exception as e:
135
- logger.warning(f"scraper router skipped: {e}")
136
-
137
- try:
138
- from app.api.causal import router as causal_router
139
- server.include_router(causal_router, prefix="/v2")
140
- logger.info("✓ causal router mounted")
141
- except Exception as e:
142
- logger.warning(f"causal router skipped: {e}")
143
-
144
- try:
145
- from app.api.causal_pipeline import router as causal_pipeline_router
146
- server.include_router(causal_pipeline_router, prefix="/v2")
147
- logger.info("✓ causal_pipeline router mounted")
148
- except Exception as e:
149
- logger.warning(f"causal_pipeline router skipped: {e}")
150
-
151
- try:
152
- from app.api.screening import router as screening_router
153
- server.include_router(screening_router, prefix="/v2")
154
- logger.info("✓ screening router mounted")
155
- except Exception as e:
156
- logger.warning(f"screening router skipped: {e}")
157
-
158
- try:
159
- from app.api.ontology import router as ontology_router
160
- server.include_router(ontology_router, prefix="/v2")
161
- logger.info("✓ ontology router mounted")
162
- except Exception as e:
163
- logger.warning(f"ontology router skipped: {e}")
164
-
165
- try:
166
- # Temporarily remove frontend dir from sys.path to avoid shadowing 'server' package with our 'server.py' script
167
- removed_empty = False
168
- if "" in sys.path:
169
- sys.path.remove("")
170
- removed_empty = True
171
- if str(BASE_DIR) in sys.path:
172
- sys.path.remove(str(BASE_DIR))
173
-
174
- from server.model_router import router as server_model_router
175
- server.include_router(server_model_router, prefix="/v2")
176
- logger.info("✓ model_router mounted")
177
-
178
- # Restore sys.path
179
- sys.path.insert(0, str(BASE_DIR))
180
- if removed_empty:
181
- sys.path.insert(0, "")
182
- except Exception as e:
183
- logger.warning(f"model_router skipped: {e}")
184
- # Ensure sys.path is restored even on failure
185
- if str(BASE_DIR) not in sys.path:
186
- sys.path.insert(0, str(BASE_DIR))
187
- if 'removed_empty' in locals() and removed_empty and "" not in sys.path:
188
- sys.path.insert(0, "")
189
-
190
- # try:
191
- # from backtest.router import router as backtest_router
192
- # server.include_router(backtest_router, prefix="/v2/backtest")
193
- # logger.info("✓ backtest router mounted")
194
- # except Exception as e:
195
- # logger.warning(f"backtest router skipped: {e}")
196
-
197
-
198
  # ─────────────────────────────────────────────────────────────────────────────
199
  # gr.Server API endpoints (Gradio-backed — queue + SSE streaming)
200
  # These are reachable via the Gradio JS Client as well as plain fetch().
@@ -207,10 +224,6 @@ except Exception as e:
207
  # Override via BACKEND_API_URL env var if running a separate backend on 8000.
208
  _BACKEND_BASE_URL = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
209
 
210
- # Kept for backward-compat with run_singular_causal (which still imports backend modules
211
- # via sys.path when both repos are co-located). Not used in run_inference anymore.
212
- _SINGULAR_DEBUG_DIR = str(BASE_DIR / "singular_ticker_causal" / "debug_data")
213
-
214
 
215
  def _fetch_causal_matrix(
216
  ticker: str,
@@ -254,7 +267,10 @@ def _fetch_causal_matrix(
254
  raw = resp.read()
255
  data = json.loads(raw)
256
  if data.get("status") not in ("success", None):
257
- logger.warning("_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:
@@ -281,147 +297,7 @@ def _safe_json(obj: Any) -> Any:
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":
@@ -434,81 +310,168 @@ def _resolve_value(
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
  )
@@ -587,35 +550,38 @@ def run_inference(
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
@@ -623,36 +589,24 @@ def run_inference(
623
  if confounders and confounders not in adjustment_sets:
624
  adjustment_sets.append(confounders)
625
 
626
- # Instrumental variables (for Layer 2 IV estimation)
627
- suggested_ivs = [
628
- n for n in (pywhyllm_report.get("suggested_ivs") or [])
629
- if n in scm.node_to_idx
630
- ]
631
-
632
- df = pd.DataFrame(scm.data_level, columns=scm.nodes)
633
- if df.shape[0] < 5:
634
- return {
635
- "status": "error",
636
- "detail": f"Insufficient observations ({df.shape[0]}) to run inference.",
637
- }
638
-
639
  result: Dict[str, Any] = {}
640
 
641
  # ═══════════════════════════════════════════════════════════════════════
642
  # LAYER 1 — Association: "What does Y look like given X?"
643
- # 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,
@@ -668,18 +622,15 @@ def run_inference(
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:
@@ -689,14 +640,14 @@ def run_inference(
689
  ci_upper = ate + 1.96 * se
690
  prob = min(1.0, abs(ate) / (abs(ate) + se + 1e-9))
691
 
692
- # Ripple effects: downstream 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,
@@ -714,47 +665,65 @@ def run_inference(
714
  }
715
 
716
  except Exception as dowhy_exc:
717
- # DoWhy not installed or identification failed — fall back to 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,
@@ -762,63 +731,39 @@ def run_inference(
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({
@@ -836,75 +781,51 @@ def run_inference(
836
  "intervention_value": abs_value,
837
  "value_type": value_type,
838
  "horizon": horizon,
839
- "predicted_values": _safe_json(scm_int_result.get("predicted_values", {})),
840
  "ripple_effects": ripple_effects,
841
  "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
842
  }
843
 
844
  # ═══════════════════════════════════════════════════════════════════════
845
  # LAYER 3 — Counterfactual: "What if X had been different in the past?"
846
- # 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)},
@@ -913,42 +834,32 @@ def run_inference(
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
 
@@ -956,7 +867,7 @@ def run_inference(
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)),
@@ -964,7 +875,7 @@ def run_inference(
964
  "counterfactual_value": abs_cf_value,
965
  "value_type": value_type,
966
  "observed_t": t,
967
- "shapley_contributions": _safe_json(shapley),
968
  "ripple_effects": [],
969
  }
970
 
@@ -985,36 +896,6 @@ def run_inference(
985
  return {"status": "error", "detail": str(exc)}
986
 
987
 
988
- # ── API 4: Hierarchical sector causal ────────────────────────────────────
989
-
990
- @server.api(
991
- name="run_hierarchy",
992
- description="Run CrossLevelMPNN hierarchical sector causal graph",
993
- concurrency_limit=1,
994
- )
995
- def run_hierarchy(
996
- symbols: Optional[List[str]] = None,
997
- ) -> Dict[str, Any]:
998
- """
999
- Build the micro + macro causal hierarchy graph.
1000
-
1001
- Parameters
1002
- ----------
1003
- symbols : Optional override for the symbol list (defaults to top-4 NIFTY tickers)
1004
-
1005
- Returns
1006
- -------
1007
- JSON with micro_graph, macro_graph, sector_embeddings
1008
- """
1009
- try:
1010
- from causal_hierarchy.test_hierarchical_causal_flow import run_hierarchical_flow
1011
- result = run_hierarchical_flow(symbols=symbols)
1012
- return _safe_json({"status": "ok", **result})
1013
- except Exception as exc:
1014
- logger.exception("run_hierarchy failed")
1015
- return {"status": "error", "detail": str(exc)}
1016
-
1017
-
1018
  # ─────────────────────────────────────────────────────────────────────────────
1019
  # Entry point
1020
  # ─────────────────────────────────────────────────────────────────────────────
@@ -1023,7 +904,7 @@ if __name__ == "__main__":
1023
  port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
1024
  host = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
1025
 
1026
- logger.info(f"Starting 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
 
 
1
  """
2
  dashboard_server.py
3
  ====================
4
+ Iroha Financial Intelligence — gr.Server entry point.
5
 
6
  Architecture
7
  ------------
 
9
  ├── GET / → serves frontend/index.html
10
  ├── GET /static/* → serves frontend/{style.css, app.js} (StaticFiles)
11
 
12
+ ├── @server.api run_inference DoFlow / SCM causal query (via BACKEND_API)
 
 
 
13
 
14
+ ├── GET /v2/health → health-check
 
 
 
15
 
16
  └── All existing /v2/* routers from main.py are included here too
17
  (so this server is a superset of main.py).
 
30
  import sys
31
  import json
32
  import logging
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
  from pathlib import Path
37
+ from gradio import Server
38
  from typing import Any, Dict, List, Optional
39
+ from fastapi.staticfiles import StaticFiles
40
+ from fastapi.middleware.cors import CORSMiddleware
41
  from dotenv import load_dotenv
42
 
43
  load_dotenv()
 
46
  if str(BASE_DIR) not in sys.path:
47
  sys.path.insert(0, str(BASE_DIR))
48
 
49
+ # Also add the backend directory to sys.path so we can import 'app', 'causal', etc.
50
  BACKEND_DIR = (BASE_DIR.parent / "noisy_boy_backend").resolve()
51
  if BACKEND_DIR.exists() and str(BACKEND_DIR) not in sys.path:
52
  sys.path.insert(0, str(BACKEND_DIR))
 
64
  # ─────────────────────────────────────────────────────────────────────────────
65
  # gr.Server
66
  # ─────────────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
67
 
68
  FRONTEND_DIR = BASE_DIR / "frontend"
69
  INDEX_HTML = FRONTEND_DIR / "index.html"
70
 
71
+ # ─────────────────────────────────────────────────────────────────────────────
72
+ # Backend URL
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ _BACKEND_BASE_URL: str = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
76
+
77
+
78
+ # ─────────────────────────────────────────────────────────────────────────────
79
+ # Public API
80
+ # ─────────────────────────────────────────────────────────────────────────────
81
+
82
+ def run_pipeline(
83
+ ticker: str = "RELIANCE",
84
+ threshold: float = 0.5,
85
+ treatment: Optional[str] = None,
86
+ outcome: Optional[str] = None,
87
+ include_pywhyllm: bool = False,
88
+ ) -> Dict[str, Any]:
89
+ """
90
+ Fetch the validated causal matrix for *ticker* from the backend API.
91
+
92
+ Parameters
93
+ ----------
94
+ ticker : NSE symbol (e.g. RELIANCE, HDFCBANK)
95
+ threshold : adjacency threshold for DAG construction
96
+ treatment : optional treatment node for pywhyllm assumptions
97
+ outcome : optional outcome node for pywhyllm assumptions
98
+ include_pywhyllm: request pywhyllm assumption report from backend
99
+
100
+ Returns
101
+ -------
102
+ dict with keys:
103
+ nodes, adj_matrix, dag_adj, equations, data_level,
104
+ topological_order, nodes_graph, links_graph
105
+ Raises RuntimeError if the backend cannot be reached or returns an error.
106
+ """
107
+ params: dict = {"threshold": threshold}
108
+ if treatment:
109
+ params["treatment"] = treatment
110
+ if outcome:
111
+ params["outcome"] = outcome
112
+ if include_pywhyllm:
113
+ params["include_pywhyllm"] = "true"
114
+
115
+ qs = urllib.parse.urlencode(params)
116
+ url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{qs}"
117
+ logger.info("run_pipeline: fetching %s", url)
118
+
119
+ try:
120
+ with urllib.request.urlopen(url, timeout=60) as resp:
121
+ raw = resp.read()
122
+ except urllib.error.URLError as exc:
123
+ raise RuntimeError(
124
+ f"Could not reach backend at {_BACKEND_BASE_URL}. "
125
+ f"Ensure noisy_boy_backend is running. Original error: {exc}"
126
+ ) from exc
127
+
128
+ payload = json.loads(raw)
129
+
130
+ status = payload.get("status")
131
+ if status == "not_found":
132
+ raise RuntimeError(
133
+ payload.get(
134
+ "detail",
135
+ f"No cached pipeline data for {ticker} on backend. "
136
+ "Run the singular-causal pipeline on the backend first.",
137
+ )
138
+ )
139
+ if status not in ("success", None, "ok"):
140
+ raise RuntimeError(
141
+ f"Backend returned unexpected status '{status}' for {ticker}. "
142
+ f"Payload: {payload}"
143
+ )
144
+
145
+ # Build frontend-friendly graph representation
146
+ nodes: List[str] = payload.get("nodes", [])
147
+ adj_matrix = payload.get("adj_matrix", [])
148
+ dag_adj = payload.get("dag_adj", [])
149
+
150
+ nodes_graph = [{"id": n, "label": n} for n in nodes]
151
+ links_graph = []
152
+ for i, src in enumerate(nodes):
153
+ for j, dst in enumerate(nodes):
154
+ if i != j:
155
+ try:
156
+ score = float(adj_matrix[i][j])
157
+ except (IndexError, TypeError, ValueError):
158
+ score = 0.0
159
+ if score >= threshold:
160
+ links_graph.append({"source": src, "target": dst, "score": round(score, 4)})
161
+
162
+ return {
163
+ **payload,
164
+ "nodes_graph": nodes_graph,
165
+ "links_graph": links_graph,
166
+ }
167
+
168
  server = Server(
169
+ title="Iroha Causal Terminal",
170
  description=(
171
  "Iroha Financial Intelligence — real-time causal probability matrix, "
172
  "HHKD decomposition, DoFlow inference and sector hierarchy over NIFTY50."
 
196
 
197
  @server.get("/", response_class=HTMLResponse, include_in_schema=False)
198
  async def serve_index():
199
+ """Serve the Iroha Causal Terminal SPA."""
200
  if not INDEX_HTML.exists():
201
  return HTMLResponse("<h1>Frontend not found. Run from backend/</h1>", status_code=500)
202
  return HTMLResponse(INDEX_HTML.read_text(encoding="utf-8"))
 
212
  return {"status": "ok", "version": "2.0.0"}
213
 
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  # ─────────────────────────────────────────────────────────────────────────────
216
  # gr.Server API endpoints (Gradio-backed — queue + SSE streaming)
217
  # These are reachable via the Gradio JS Client as well as plain fetch().
 
224
  # Override via BACKEND_API_URL env var if running a separate backend on 8000.
225
  _BACKEND_BASE_URL = os.environ.get("BACKEND_API_URL", "http://localhost:7860")
226
 
 
 
 
 
227
 
228
  def _fetch_causal_matrix(
229
  ticker: str,
 
267
  raw = resp.read()
268
  data = json.loads(raw)
269
  if data.get("status") not in ("success", None):
270
+ logger.warning(
271
+ "_fetch_causal_matrix: backend returned status=%s for URL %s. Payload: %s",
272
+ data.get("status"), url, data,
273
+ )
274
  return None
275
  return data
276
  except Exception as exc:
 
297
  return obj
298
 
299
 
300
+ def _resolve_value(value: float, value_type: str, current: float) -> float:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  """Convert a user-supplied value + value_type to the absolute node value."""
302
  vt = value_type.strip().lower()
303
  if vt == "absolute":
 
310
  return value
311
 
312
 
313
+ # ─────────────────────────────────────────────────────────────────────────────
314
+ # Pure-numpy inference helpers (no local causal training imports)
315
+ # These functions work entirely from the payload returned by the backend API.
316
+ # ─────────────────────────────────────────────────────────────────────────────
 
 
 
 
317
 
318
+ def _build_dag_from_payload(payload: dict):
319
+ """
320
+ Return a numpy bool DAG adjacency matrix and list of node names
321
+ from the backend causal-matrix payload.
322
  """
323
  import numpy as np
 
 
 
 
324
  nodes = payload["nodes"]
 
325
  dag_adj = np.array(payload["dag_adj"], dtype=bool)
326
+ adj_matrix = np.array(payload["adj_matrix"], dtype=float)
327
+ return nodes, dag_adj, adj_matrix
328
+
329
+
330
+ def _propagate_intervention(
331
+ nodes: list,
332
+ dag_adj,
333
+ equations: dict,
334
+ data_level,
335
+ topological_order: list,
336
+ treatment: str,
337
+ abs_value: float,
338
+ targets: list,
339
+ horizon: int = 5,
340
+ ):
341
+ """
342
+ Propagate a hard intervention (do(treatment=abs_value)) through the
343
+ structural equations for `horizon` steps, returning ATE per target node.
344
+ Uses only numpy — no local causal model imports.
345
+ """
346
+ import numpy as np
347
+
348
+ node_to_idx = {n: i for i, n in enumerate(nodes)}
349
  n = len(nodes)
350
  T = data_level.shape[0]
351
 
352
+ # Start from the last observed time step
353
+ state = data_level[-1].copy().astype(float)
 
 
 
 
 
 
 
 
 
 
 
354
 
355
+ # Fix the treatment node
356
+ t_idx = node_to_idx[treatment]
357
+ state[t_idx] = abs_value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
 
359
+ ate_per_target: Dict[str, float] = {}
360
+ baseline = data_level[-1].copy().astype(float)
361
+
362
+ for _ in range(horizon):
363
+ new_state = state.copy()
364
+ for node_name in topological_order:
365
+ if node_name == treatment:
366
+ continue
367
+ eq = equations.get(node_name)
368
+ if eq is None:
369
+ continue
370
+ parents = eq.get("parents", [])
371
+ coefficients = eq.get("coefficients", {})
372
+ intercept = float(eq.get("intercept", 0.0))
373
+ if not parents:
374
+ continue
375
+ val = intercept
376
+ for p in parents:
377
+ p_idx = node_to_idx.get(p)
378
+ if p_idx is not None:
379
+ val += float(coefficients.get(p, 0.0)) * float(state[p_idx])
380
+ n_idx = node_to_idx[node_name]
381
+ new_state[n_idx] = val
382
+ state = new_state
383
+
384
+ for target in targets:
385
+ t_i = node_to_idx.get(target)
386
+ if t_i is not None:
387
+ ate_per_target[target] = float(state[t_i] - baseline[t_i])
388
+
389
+ return ate_per_target, state
390
+
391
+
392
+ def _abduct_and_predict(
393
+ nodes: list,
394
+ dag_adj,
395
+ equations: dict,
396
+ data_level,
397
+ topological_order: list,
398
+ treatment: str,
399
+ cf_value: float,
400
+ target: str,
401
+ observed_t: int,
402
+ ):
403
+ """
404
+ Simple SCM abduction for counterfactual:
405
+ 1. Abduct residuals from the observed time step.
406
+ 2. Re-run structural equations with treatment fixed to cf_value.
407
+ 3. Return factual_outcome, cf_outcome, ITE.
408
+ """
409
+ import numpy as np
410
+
411
+ node_to_idx = {n: i for i, n in enumerate(nodes)}
412
+ obs = data_level[observed_t].copy().astype(float)
413
+
414
+ # Abduct residuals
415
+ residuals: Dict[str, float] = {}
416
+ for node_name in topological_order:
417
+ eq = equations.get(node_name)
418
+ if eq is None or not eq.get("parents"):
419
+ residuals[node_name] = 0.0
420
+ continue
421
+ parents = eq.get("parents", [])
422
+ coefficients = eq.get("coefficients", {})
423
+ intercept = float(eq.get("intercept", 0.0))
424
+ predicted = intercept
425
+ for p in parents:
426
+ p_idx = node_to_idx.get(p)
427
+ if p_idx is not None:
428
+ predicted += float(coefficients.get(p, 0.0)) * float(obs[node_to_idx[p]])
429
+ residuals[node_name] = float(obs[node_to_idx[node_name]]) - predicted
430
+
431
+ # Counterfactual: fix treatment, replay equations with abducted noise
432
+ cf_state = obs.copy()
433
+ cf_state[node_to_idx[treatment]] = cf_value
434
+
435
+ for node_name in topological_order:
436
+ if node_name == treatment:
437
+ continue
438
+ eq = equations.get(node_name)
439
+ if eq is None or not eq.get("parents"):
440
+ continue
441
+ parents = eq.get("parents", [])
442
+ coefficients = eq.get("coefficients", {})
443
+ intercept = float(eq.get("intercept", 0.0))
444
+ predicted = intercept
445
+ for p in parents:
446
+ p_idx = node_to_idx.get(p)
447
+ if p_idx is not None:
448
+ predicted += float(coefficients.get(p, 0.0)) * float(cf_state[p_idx])
449
+ n_idx = node_to_idx[node_name]
450
+ cf_state[n_idx] = predicted + residuals.get(node_name, 0.0)
451
+
452
+ factual_outcome = float(obs[node_to_idx[target]])
453
+ cf_outcome = float(cf_state[node_to_idx[target]])
454
+ ite = cf_outcome - factual_outcome
455
+ return factual_outcome, cf_outcome, ite
456
+
457
+
458
+ # ── API: Causal inference (assert / intervene / counterfactual) ───────────
459
+ #
460
+ # Architecture:
461
+ # 1. Fetch the VALIDATED causal matrix from noisy_boy_backend via HTTP.
462
+ # The backend has already run CUTS+ learning + pywhyllm + DoWhy validation.
463
+ # 2. Use the payload data (equations, adj, data_level) for inference
464
+ # using pure numpy/pandas — no local causal training imports required.
465
+ # 3. Optionally consult pywhyllm guidance from the backend payload.
466
 
467
 
468
  @server.api(
469
  name="run_inference",
470
  description=(
471
+ "Run causal inference (association / intervention / counterfactual) "
472
+ "using a validated causal matrix fetched from the backend API. "
473
+ "Layers: 1=Association(DoWhy backdoor), 2=Intervention(SCM propagation), "
474
+ "3=Counterfactual(SCM abduction)."
475
  ),
476
  concurrency_limit=4,
477
  )
 
550
  "detail": payload.get("detail", f"No cached pipeline data for {ticker}."),
551
  }
552
 
553
+ # ── 2. Unpack payload (no local causal training imports) ──────────────
554
+ nodes, dag_adj, adj_matrix = _build_dag_from_payload(payload)
555
+ node_to_idx = {n: i for i, n in enumerate(nodes)}
556
+ data_level = np.array(payload["data_level"], dtype=float)
557
+ equations_raw = payload.get("equations", {})
558
+ topo_order = payload.get("topological_order", nodes)
559
 
560
+ T = data_level.shape[0]
561
+ df = pd.DataFrame(data_level, columns=nodes)
562
 
563
+ if treatment not in node_to_idx:
564
+ return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
565
+ if target_node not in node_to_idx:
566
+ return {"status": "error", "detail": f"Unknown outcome/target node: {target_node}"}
567
+ if df.shape[0] < 5:
568
+ return {
569
+ "status": "error",
570
+ "detail": f"Insufficient observations ({df.shape[0]}) to run inference.",
571
+ }
572
 
573
+ # ── 3. pywhyllm structural guidance (from backend payload) ────────────
574
+ pywhyllm_report: Optional[dict] = payload.get("pywhyllm_report")
 
 
575
  adjustment_sets: List[List[str]] = []
 
576
 
577
  if use_pywhyllm and pywhyllm_report and pywhyllm_report.get("available"):
 
578
  raw_backdoor = pywhyllm_report.get("suggested_backdoor_sets") or []
579
+ valid_nodes = set(nodes) - {treatment, target_node}
580
  for suggested_set in raw_backdoor:
581
  clean = [n for n in suggested_set if n in valid_nodes]
582
  if clean and clean not in adjustment_sets:
583
  adjustment_sets.append(clean)
584
 
 
585
  confounders = [
586
  n for n in (pywhyllm_report.get("suggested_confounders") or [])
587
  if n in valid_nodes
 
589
  if confounders and confounders not in adjustment_sets:
590
  adjustment_sets.append(confounders)
591
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  result: Dict[str, Any] = {}
593
 
594
  # ═══════════════════════════════════════════════════════════════════════
595
  # LAYER 1 — Association: "What does Y look like given X?"
596
+ # Uses DoWhy with the backend-provided DAG, falling back to OLS.
 
597
  # ═══════════════════════════════════════════════════════════════════════
598
  if mode == "assert":
 
 
 
 
 
599
  try:
600
  from dowhy import CausalModel
601
 
602
+ # Build DOT graph string from dag_adj
603
+ edges = []
604
+ for si, src in enumerate(nodes):
605
+ for di, dst in enumerate(nodes):
606
+ if dag_adj[si, di]:
607
+ edges.append(f"{src} -> {dst}")
608
+ graph_dot = "digraph{" + "; ".join(edges) + "}"
609
+
610
  dowhy_model = CausalModel(
611
  data=df,
612
  treatment=treatment,
 
622
  )
623
  ate = float(estimate.value)
624
 
625
+ # Confidence interval from OLS residuals
 
626
  se: float = 0.0
627
  try:
628
+ import numpy.linalg as nla
 
629
  X = df[[c for c in df.columns if c != target_node]].values
630
  y = df[target_node].values
 
631
  XtX_inv = nla.pinv(X.T @ X)
632
  resid = y - X @ nla.lstsq(X, y, rcond=None)[0]
633
+ sigma2 = float(np.sum(resid ** 2) / max(1, len(y) - X.shape[1]))
634
  t_idx_local = list(df.columns).index(treatment)
635
  se = float(np.sqrt(max(0.0, sigma2 * XtX_inv[t_idx_local, t_idx_local])))
636
  except Exception:
 
640
  ci_upper = ate + 1.96 * se
641
  prob = min(1.0, abs(ate) / (abs(ate) + se + 1e-9))
642
 
643
+ # Ripple effects: direct downstream neighbours of treatment
644
  ripple_effects = []
645
+ t_idx_g = node_to_idx[treatment]
646
+ for j, node in enumerate(nodes):
647
  if node == treatment or node == target_node:
648
  continue
649
+ if dag_adj[t_idx_g, j]:
650
+ edge_score = float(adj_matrix[t_idx_g, j])
651
  ripple_effects.append({
652
  "ticker": node,
653
  "direction": 1 if ate > 0 else -1,
 
665
  }
666
 
667
  except Exception as dowhy_exc:
668
+ # DoWhy not installed or identification failed — fall back to OLS
669
+ logger.warning("DoWhy association failed (%s), falling back to OLS", dowhy_exc)
670
+ t_idx_g = node_to_idx[treatment]
671
+ out_idx = node_to_idx[target_node]
672
+
673
+ # Simple OLS: regress target on treatment
674
+ X = df[[treatment]].values
675
+ y = df[target_node].values
676
+ import numpy.linalg as nla
677
+ coef = nla.lstsq(np.c_[np.ones(len(X)), X], y, rcond=None)[0]
678
+ ate = float(coef[1])
679
+ se = abs(ate) * 0.15
680
+ ci_lower = ate - 1.96 * se
681
+ ci_upper = ate + 1.96 * se
682
+
683
+ ripple_effects = []
684
+ for j, node in enumerate(nodes):
685
+ if node == treatment or node == target_node:
686
+ continue
687
+ if dag_adj[t_idx_g, j]:
688
+ ripple_effects.append({
689
+ "ticker": node,
690
+ "direction": 1 if ate > 0 else -1,
691
+ "magnitude": round(float(adj_matrix[t_idx_g, j]) * abs(ate), 4),
692
+ })
693
+
694
  result = {
695
+ "ate": ate,
696
+ "ci_lower": ci_lower,
697
+ "ci_upper": ci_upper,
698
+ "probability": min(1.0, abs(ate) / (abs(ate) + se + 1e-9)),
699
+ "strategy": "ols_fallback",
700
+ "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
701
+ "ripple_effects": ripple_effects,
702
  }
703
 
704
  # ═══════════════════════════════════════════════════════════════════════
705
  # LAYER 2 — Intervention: "What will happen to Y if we do X=value?"
706
+ # Propagates through structural equations from the backend payload.
 
707
  # ═══════════════════════════════════════════════════════════════════════
708
  elif mode == "intervene":
709
+ current_val = float(data_level[-1, node_to_idx[treatment]])
 
 
 
 
 
 
710
  abs_value = _resolve_value(value, value_type, current_val)
711
 
712
+ # Try DoWhy for ATE estimation first
713
+ ate = 0.0
714
+ method_used = "scm_propagation"
715
  try:
716
  from dowhy import CausalModel
717
 
718
+ edges = []
719
+ for si, src in enumerate(nodes):
720
+ for di, dst in enumerate(nodes):
721
+ if dag_adj[si, di]:
722
+ edges.append(f"{src} -> {dst}")
723
+ graph_dot = "digraph{" + "; ".join(edges) + "}"
724
 
725
  dowhy_model = CausalModel(
726
+ data=df,
727
  treatment=treatment,
728
  outcome=target_node,
729
  graph=graph_dot,
 
731
  identified_estimand = dowhy_model.identify_effect(
732
  proceed_when_unidentifiable=True
733
  )
734
+ estimate = dowhy_model.estimate_effect(
735
+ identified_estimand,
736
+ method_name="backdoor.linear_regression",
737
+ )
738
+ ate_unit = float(estimate.value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  delta = abs_value - current_val
740
  ate = ate_unit * delta
741
+ method_used = "backdoor.linear_regression"
 
 
 
 
 
742
  except Exception as dowhy_exc:
743
+ logger.warning("DoWhy intervention failed (%s), using SCM propagation", dowhy_exc)
744
+
745
+ # SCM propagation for ripple effects (pure numpy, no training imports)
746
+ ate_per_target, final_state = _propagate_intervention(
747
+ nodes=nodes,
748
+ dag_adj=dag_adj,
749
+ equations=equations_raw,
750
+ data_level=data_level,
751
+ topological_order=topo_order,
752
  treatment=treatment,
753
+ abs_value=abs_value,
754
+ targets=[target_node] + [n for n in nodes if n != treatment],
755
  horizon=horizon,
756
  )
757
 
758
+ if method_used == "scm_propagation" and target_node in ate_per_target:
 
759
  ate = float(ate_per_target[target_node])
 
 
760
 
761
+ se = abs(ate) * 0.12
762
+ ci_lower = ate - 1.96 * se
763
+ ci_upper = ate + 1.96 * se
764
+
765
  ripple_effects = []
766
+ for node, delta_val in ate_per_target.items():
767
  if node == treatment:
768
  continue
769
  ripple_effects.append({
 
781
  "intervention_value": abs_value,
782
  "value_type": value_type,
783
  "horizon": horizon,
 
784
  "ripple_effects": ripple_effects,
785
  "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
786
  }
787
 
788
  # ═══════════════════════════════════════════════════════════════════════
789
  # LAYER 3 — Counterfactual: "What if X had been different in the past?"
790
+ # Uses SCM abduction via pure numpy structural equations.
 
791
  # ═══════════════════════════════════════════════════════════════════════
792
  elif mode in ("counterfactual", "counter"):
 
 
 
 
 
793
  # Resolve observed timestep
 
794
  t = observed_t if observed_t >= 0 else (T + observed_t)
795
  t = max(0, min(T - 1, t))
796
 
797
  # Resolve counterfactual value
798
+ current_val = float(data_level[t, node_to_idx[treatment]])
799
  if cf_value is not None:
800
  abs_cf_value = float(cf_value)
801
  else:
802
  abs_cf_value = _resolve_value(value, value_type, current_val)
803
 
804
+ # Try DoWhy GCM first
805
  gcm_used = False
806
+ factual_outcome = 0.0
807
+ cf_outcome_val = 0.0
808
+ ite = 0.0
809
+
810
  try:
811
  import dowhy.gcm as gcm_module
812
  import networkx as nx
813
 
 
814
  causal_graph = nx.DiGraph()
815
+ for si, src in enumerate(nodes):
816
+ for di, dst in enumerate(nodes):
817
+ if dag_adj[si, di]:
818
+ causal_graph.add_edge(src, dst)
819
+ for node in nodes:
820
  if node not in causal_graph.nodes:
821
  causal_graph.add_node(node)
822
 
 
 
 
823
  gcm_model = gcm_module.InvertibleStructuralCausalModel(causal_graph)
824
  gcm_module.auto.assign_mechanisms(gcm_model, df)
 
 
 
 
 
 
 
 
 
 
 
 
 
825
  gcm_module.fit(gcm_model, df)
826
 
 
827
  observed_data = df.iloc[[t]]
828
+ cf_val_fixed = abs_cf_value
 
 
829
  cf_samples = gcm_module.counterfactual_samples(
830
  gcm_model,
831
  {treatment: lambda x, v=cf_val_fixed: np.full(x.shape, v)},
 
834
  )
835
 
836
  factual_outcome = float(observed_data[target_node].iloc[0])
837
+ cf_outcome_val = float(cf_samples[target_node].iloc[0])
838
+ ite = cf_outcome_val - factual_outcome
839
  gcm_used = True
840
 
841
  except Exception as gcm_exc:
842
  logger.warning("DoWhy GCM counterfactual failed (%s), using SCM abduction", gcm_exc)
 
843
 
844
  if not gcm_used:
845
+ factual_outcome, cf_outcome_val, ite = _abduct_and_predict(
846
+ nodes=nodes,
847
+ dag_adj=dag_adj,
848
+ equations=equations_raw,
849
+ data_level=data_level,
850
+ topological_order=topo_order,
851
  treatment=treatment,
852
  cf_value=abs_cf_value,
853
  target=target_node,
 
 
 
 
 
 
 
 
 
854
  observed_t=t,
 
 
 
855
  )
 
 
 
856
 
857
+ # Shapley: single-treatment just use the ITE directly
858
+ shapley = {treatment: ite}
859
+
860
+ # SE from residual_std of the target equation (from backend payload)
861
+ target_eq_data = equations_raw.get(target_node, {})
862
+ se = float(target_eq_data.get("residual_std", abs(ite) * 0.15))
863
  ci_lower = ite - 1.96 * se
864
  ci_upper = ite + 1.96 * se
865
 
 
867
  "ate": ite,
868
  "ite": ite,
869
  "factual_outcome": factual_outcome,
870
+ "counterfactual_outcome": cf_outcome_val,
871
  "ci_lower": ci_lower,
872
  "ci_upper": ci_upper,
873
  "probability": min(1.0, abs(ite) / (abs(ite) + se + 1e-9)),
 
875
  "counterfactual_value": abs_cf_value,
876
  "value_type": value_type,
877
  "observed_t": t,
878
+ "shapley_contributions": shapley,
879
  "ripple_effects": [],
880
  }
881
 
 
896
  return {"status": "error", "detail": str(exc)}
897
 
898
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
899
  # ─────────────────────────────────────────────────────────────────────────────
900
  # Entry point
901
  # ─────────────────────────────────────────────────────────────────────────────
 
904
  port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
905
  host = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
906
 
907
+ logger.info(f"Starting Iroha Causal Terminal on {host}:{port}")
908
  logger.info(f" → Frontend : http://localhost:{port}/")
909
  logger.info(f" → API docs : http://localhost:{port}/docs")
910
 
singular_ticker_causal/.env DELETED
@@ -1,3 +0,0 @@
1
- FIREWORKS_API_KEY=fw_KvkeXQmo8LctbP6xx8A5uA
2
- NVIDIA_API_KEY=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
3
- LLM_PROVIDER=nvidia
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/cuts_plus.py DELETED
@@ -1,834 +0,0 @@
1
- import os
2
- from os.path import join as opj
3
- from os.path import dirname as opd
4
-
5
- import tqdm
6
- import numpy as np
7
- import argparse
8
- from omegaconf import OmegaConf
9
- from copy import deepcopy
10
- from einops import rearrange
11
- import torch
12
- from torch import nn
13
- import torch.nn.functional as F
14
-
15
- from .utils.gumbel_softmax import gumbel_softmax
16
- from .utils.misc import calc_and_log_metrics, log_time_series, plot_causal_matrix
17
- from .utils.opt_type import MultiCADopt
18
- from .utils.logger import MyLogger
19
- from .model.cuts_plus_net import CUTS_Plus_Net
20
- from causal_hierarchy.grouping import build_group_matrix
21
- from causal.cuts_plus.edge_controller import DualEdgeTemperatureController
22
-
23
-
24
- def accounting_prior_loss(
25
- G_sequence: torch.Tensor,
26
- G_p: torch.Tensor,
27
- lambda_s: float,
28
- lambda_d: float,
29
- ) -> torch.Tensor:
30
- """Compute the combined sparseness + domain-fit prior loss for a lag-indexed graph sequence.
31
-
32
- Implements the plan §5.2 formula::
33
-
34
- p(G) ∝ exp( -λ_s ‖G_{1:L}‖_F² - λ_d ‖G_{1:L} - G^p_{1:L}‖_F² )
35
-
36
- Parameters
37
- ----------
38
- G_sequence : Tensor (L, D, D) or (D, D)
39
- Soft adjacency sequence. When 2-D it is treated as a single-lag graph
40
- and the sparseness / domain-fit are applied directly.
41
- G_p : Tensor (D, D)
42
- Static binary accounting prior mask. Broadcast across lags.
43
- lambda_s : float
44
- Sparseness regularisation coefficient.
45
- lambda_d : float
46
- Domain-fit (DuPont structural) regularisation coefficient.
47
-
48
- Returns
49
- -------
50
- Tensor scalar
51
- Combined prior loss term to be added to the CUTS+ objective.
52
- """
53
- if G_sequence.ndim == 2:
54
- G_sequence = G_sequence.unsqueeze(0) # treat as (1, D, D)
55
-
56
- # Broadcast static prior to (L, D, D)
57
- G_p_expanded = G_p.unsqueeze(0).expand_as(G_sequence)
58
-
59
- sparseness = lambda_s * torch.norm(G_sequence, p="fro") ** 2
60
- domain_fit = lambda_d * torch.norm(G_sequence - G_p_expanded, p="fro") ** 2
61
- return sparseness + domain_fit
62
-
63
-
64
-
65
- def plot_matrix(name, mat, log, log_step, vmin=None, vmax=None):
66
- if len(mat.shape) == 3:
67
- mat = np.max(mat, axis=-1)
68
- n, m = mat.shape
69
-
70
- # Show Discovered Graph (Probability)
71
- sub_cg = plot_causal_matrix(
72
- mat,
73
- figsize=[1.5*n, 1*n],
74
- show_text=False,
75
- vmin=vmin, vmax=vmax)
76
- log.log_figures(sub_cg, name=name, iters=log_step)
77
-
78
-
79
- def generate_indices(input_step, pred_step, t_length, block_size=None):
80
- if block_size is None:
81
- block_size = t_length
82
-
83
- offsets_in_block = np.arange(input_step, block_size-pred_step+1)
84
- assert t_length % block_size == 0, "t_length % block_size != 0"
85
- random_t_list = []
86
- for block_start in range(0, t_length, block_size):
87
- random_t_list += (offsets_in_block + block_start).tolist()
88
-
89
- np.random.shuffle(random_t_list)
90
- return random_t_list
91
-
92
-
93
-
94
- def batch_generater(data, observ_mask, bs, n_nodes, input_step, pred_step, block_size=None):
95
- t, n, d = data.shape
96
- first_sample_t = input_step
97
- random_t_list = generate_indices(input_step, pred_step, t_length=t, block_size=block_size)
98
-
99
- for batch_i in range(len(random_t_list) // bs):
100
- x = torch.zeros([bs, n_nodes, input_step, d]).to(data.device)
101
- y = torch.zeros([bs, n_nodes, pred_step, d]).to(data.device)
102
- t = torch.zeros([bs]).to(data.device).long()
103
- mask_x = torch.zeros([bs, n_nodes, input_step, d]).to(data.device)
104
- mask_y = torch.zeros([bs, n_nodes, pred_step, d]).to(data.device)
105
- for data_i in range(bs):
106
- data_t = random_t_list.pop()
107
- x[data_i, :, :, :] = rearrange(data[data_t-input_step : data_t, :], "t n d -> n t d")
108
- y[data_i, :, :, :] = rearrange(data[data_t : data_t+pred_step, :], "t n d -> n t d")
109
- t[data_i] = data_t
110
- mask_x[data_i, :, :, :] = rearrange(observ_mask[data_t-input_step : data_t, :], "t n d -> n t d")
111
- mask_y[data_i, :, :, :] = rearrange(observ_mask[data_t:data_t+pred_step, :], "t n d -> n t d")
112
-
113
- yield x, y, t, mask_x, mask_y
114
-
115
-
116
-
117
-
118
-
119
- class MultiCAD(object):
120
- def __init__(
121
- self,
122
- args: MultiCADopt.MultiCADargs,
123
- log,
124
- device="cuda",
125
- text_data=None,
126
- text_mask=None,
127
- G_prior=None,
128
- denoised_news=None,
129
- denoised_mask=None,
130
- ):
131
- self.log: MyLogger = log
132
- self.args = args
133
- self.device = device
134
-
135
- self.text_data = text_data.to(device) if text_data is not None else None
136
- self.text_mask = text_mask.to(device) if text_mask is not None else None
137
- self.denoised_news = denoised_news.to(device) if denoised_news is not None else None
138
- self.denoised_mask = denoised_mask.to(device) if denoised_mask is not None else None
139
- self.projector = None
140
- self.denoised_projector = None
141
-
142
- self.lambda_d = getattr(args, 'lambda_d', 1e-2)
143
- if G_prior is not None:
144
- self.G_prior = torch.from_numpy(G_prior).float().to(device)
145
- else:
146
- self.G_prior = torch.zeros(args.n_nodes, args.n_nodes).to(device)
147
- hard_edge_mask = getattr(args, "hard_edge_mask", None)
148
- if hard_edge_mask is None and bool(getattr(args, "use_hard_prior_edges", False)):
149
- hard_edge_mask = G_prior
150
- if hard_edge_mask is not None:
151
- self.hard_edge_mask_graph = torch.as_tensor(hard_edge_mask, dtype=torch.float32, device=device)
152
- else:
153
- self.hard_edge_mask_graph = torch.zeros(args.n_nodes, args.n_nodes, device=device)
154
- soft_edge_mask = getattr(args, "soft_edge_mask", None)
155
- if soft_edge_mask is not None:
156
- self.soft_edge_mask_graph = torch.as_tensor(soft_edge_mask, dtype=torch.float32, device=device)
157
- else:
158
- self.soft_edge_mask_graph = 1.0 - self.hard_edge_mask_graph
159
- self.hard_edge_tau = float(getattr(args, "hard_edge_tau", 0.02))
160
- self.hard_edge_trainable = bool(getattr(args, "hard_edge_trainable", False))
161
-
162
- # No embedding projector is required when text features are already
163
- # represented as low-dimensional sparse event tensors.
164
- self.projector = None
165
- self.denoised_projector = None
166
-
167
- self.fitting_model = CUTS_Plus_Net(self.args.n_nodes, in_ch=self.args.data_dim,
168
- n_layers=self.args.data_pred.gru_layers,
169
- hidden_ch=self.args.data_pred.mlp_hid,
170
- shared_weights_decoder=self.args.data_pred.shared_weights_decoder,
171
- concat_h=self.args.data_pred.concat_h,
172
- ).to(self.device)
173
-
174
- self.data_pred_loss = nn.MSELoss()
175
-
176
- params = list(self.fitting_model.parameters())
177
-
178
- self.data_pred_optimizer = torch.optim.Adam(
179
- params,
180
- lr=self.args.data_pred.lr_data_start,
181
- weight_decay=self.args.data_pred.weight_decay
182
- )
183
-
184
- if "every" in self.args.fill_policy:
185
- lr_schedule_length = int(self.args.fill_policy.split("_")[-1])
186
- else:
187
- lr_schedule_length = self.args.total_epoch
188
-
189
- gamma = (self.args.data_pred.lr_data_end / self.args.data_pred.lr_data_start) ** (1 / lr_schedule_length)
190
- self.data_pred_scheduler = torch.optim.lr_scheduler.StepLR(
191
- self.data_pred_optimizer, step_size=1, gamma=gamma)
192
-
193
- self.n_groups = self.args.n_groups
194
- print("n_groups: ", self.n_groups)
195
- if self.args.group_policy == "None":
196
- self.args.group_policy = None
197
- self.fixed_group_spec = self._resolve_fixed_group_spec()
198
- if self.fixed_group_spec is not None:
199
- self.n_groups = self.fixed_group_spec.n_groups
200
-
201
- end_tau, start_tau = self.args.graph_discov.end_tau, self.args.graph_discov.start_tau
202
- self.gumbel_tau_gamma = (end_tau / start_tau) ** (1 / self.args.total_epoch)
203
- self.gumbel_tau = start_tau
204
- self.start_tau = start_tau
205
- self.current_epoch = 0
206
- self.edge_controller = DualEdgeTemperatureController(
207
- G_prior=self.G_prior.detach().cpu().numpy(),
208
- tau_start=float(start_tau),
209
- tau_end=float(end_tau),
210
- tau_hard=self.hard_edge_tau,
211
- total_epochs=int(self.args.total_epoch),
212
- )
213
-
214
- end_lmd, start_lmd = self.args.graph_discov.lambda_s_end, self.args.graph_discov.lambda_s_start
215
- self.lambda_gamma = (end_lmd / start_lmd) ** (1 / self.args.total_epoch)
216
- self.lambda_s = start_lmd
217
-
218
- def set_graph_optimizer(self, epoch=None):
219
- if epoch == None:
220
- epoch = 0
221
-
222
- gamma = (self.args.graph_discov.lr_graph_end / self.args.graph_discov.lr_graph_start) ** (1 / self.args.total_epoch)
223
- self.graph_optimizer = torch.optim.Adam([self.GT], lr=self.args.graph_discov.lr_graph_start * gamma ** epoch)
224
- self.graph_scheduler = torch.optim.lr_scheduler.StepLR(self.graph_optimizer, step_size=1, gamma=gamma)
225
-
226
- def _resolve_fixed_group_spec(self):
227
- policy = getattr(self.args, "group_policy", None)
228
- if policy not in {"deterministic", "deterministic_sector", "deterministic_geography"}:
229
- return None
230
- assignments = getattr(self.args, "group_assignments", None)
231
- if assignments is None and policy == "deterministic_sector":
232
- ticker_list = getattr(self.args, "ticker_list", None)
233
- sector_map = getattr(self.args, "sector_map", None)
234
- if ticker_list is not None and sector_map is not None:
235
- assignments = [sector_map[ticker] for ticker in ticker_list]
236
- if assignments is None:
237
- raise ValueError("Deterministic grouping requires opt.group_assignments.")
238
- labels = getattr(self.args, "group_labels", None)
239
- return build_group_matrix(assignments, labels=labels)
240
-
241
- def _has_fixed_grouping(self) -> bool:
242
- return self.fixed_group_spec is not None
243
-
244
- def _init_random_gt(self, n_groups: int) -> torch.Tensor:
245
- return torch.ones((n_groups, self.args.n_nodes)) * 0.5 + torch.randn(n_groups, self.args.n_nodes) * 0.01
246
-
247
- def _build_prior_seed_logits(self, n_groups: int) -> torch.Tensor:
248
- gt_init = torch.full((n_groups, self.args.n_nodes), -2.0)
249
- if n_groups == self.args.n_nodes:
250
- grouped_prior = self.G_prior
251
- elif self._has_fixed_grouping():
252
- grouped_prior = torch.zeros(n_groups, self.args.n_nodes, device=self.device)
253
- for group_idx in range(n_groups):
254
- members = [
255
- idx for idx, assigned_group in enumerate(self.fixed_group_spec.assignments)
256
- if assigned_group == group_idx
257
- ]
258
- if members:
259
- grouped_prior[group_idx] = torch.max(self.G_prior[members], dim=0).values
260
- else:
261
- return self._init_random_gt(n_groups)
262
-
263
- gt_init[grouped_prior > 0.5] = 2.0
264
- gt_init += torch.randn_like(gt_init) * 0.05
265
- return gt_init
266
-
267
- def _build_graph_prob(self) -> torch.Tensor:
268
- return torch.einsum("nm,ml->nl", self.G, torch.sigmoid(self.GT))
269
-
270
- def _compose_graph_parts(self) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
271
- base_graph = self._build_graph_prob()
272
- soft_graph = base_graph * self.soft_edge_mask_graph
273
- if self.hard_edge_trainable:
274
- hard_graph = base_graph * self.hard_edge_mask_graph
275
- else:
276
- hard_graph = self.hard_edge_mask_graph
277
- effective_graph = torch.clamp(soft_graph + hard_graph, 0.0, 1.0)
278
- return soft_graph, hard_graph, effective_graph
279
-
280
- def _gumbel_sigmoid_sample(self, graph: torch.Tensor, batch_size: int, tau: float) -> torch.Tensor:
281
- prob = graph[None, :, :, None].expand(batch_size, -1, -1, -1)
282
- logits = torch.concat([prob, (1 - prob)], axis=-1)
283
- return gumbel_softmax(logits, tau=tau, hard=True)[:, :, :, 0]
284
-
285
- def _sample_graph_with_controller(self, graph: torch.Tensor) -> torch.Tensor:
286
- graph = torch.clamp(torch.nan_to_num(graph, nan=0.5), 1e-6, 1.0 - 1e-6)
287
- logits = torch.logit(graph)
288
- sampled = self.edge_controller.gumbel_sample(logits, epoch=self.current_epoch, hard=True)
289
- if not self.hard_edge_trainable and torch.any(self.hard_edge_mask_graph > 0):
290
- sampled = torch.clamp(sampled * self.soft_edge_mask_graph + self.hard_edge_mask_graph, 0.0, 1.0)
291
- return sampled[None].expand(self.args.batch_size, -1, -1)
292
-
293
- def _append_context(self, x, y, mask_x, mask_y, t, inp_step: int, pred_step: int):
294
- t_vals = t.cpu().tolist()
295
- if self.text_data is not None:
296
- tx = torch.stack([rearrange(self.text_data[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
297
- ty = torch.stack([rearrange(self.text_data[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
298
- d_text = self.text_data.shape[-1]
299
- if self.text_mask is not None:
300
- if self.text_mask.shape[-1] == 1:
301
- tmx = torch.stack([rearrange(self.text_mask[ti - inp_step:ti].expand(-1, -1, d_text), "t n d -> n t d") for ti in t_vals])
302
- tmy = torch.stack([rearrange(self.text_mask[ti:ti + pred_step].expand(-1, -1, d_text), "t n d -> n t d") for ti in t_vals])
303
- else:
304
- tmx = torch.stack([rearrange(self.text_mask[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
305
- tmy = torch.stack([rearrange(self.text_mask[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
306
- else:
307
- tmx = torch.ones_like(tx)
308
- tmy = torch.ones_like(ty)
309
- x = torch.cat([x, tx], dim=-1)
310
- y = torch.cat([y, ty], dim=-1)
311
- mask_x = torch.cat([mask_x, tmx], dim=-1)
312
- mask_y = torch.cat([mask_y, tmy], dim=-1)
313
-
314
- if self.denoised_news is not None:
315
- dx = torch.stack([rearrange(self.denoised_news[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
316
- dy = torch.stack([rearrange(self.denoised_news[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
317
- d_denoised = self.denoised_news.shape[-1]
318
- if self.denoised_mask is not None:
319
- dmask_src = self.denoised_mask
320
- if dmask_src.shape[-1] == 1:
321
- dmx = torch.stack([rearrange(dmask_src[ti - inp_step:ti].expand(-1, -1, d_denoised), "t n d -> n t d") for ti in t_vals])
322
- dmy = torch.stack([rearrange(dmask_src[ti:ti + pred_step].expand(-1, -1, d_denoised), "t n d -> n t d") for ti in t_vals])
323
- else:
324
- dmx = torch.stack([rearrange(dmask_src[ti - inp_step:ti], "t n d -> n t d") for ti in t_vals])
325
- dmy = torch.stack([rearrange(dmask_src[ti:ti + pred_step], "t n d -> n t d") for ti in t_vals])
326
- else:
327
- dmx = torch.ones_like(dx)
328
- dmy = torch.ones_like(dy)
329
- x = torch.cat([x, dx], dim=-1)
330
- y = torch.cat([y, dy], dim=-1)
331
- mask_x = torch.cat([mask_x, dmx], dim=-1)
332
- mask_y = torch.cat([mask_y, dmy], dim=-1)
333
-
334
- return x, y, mask_x, mask_y
335
-
336
- def _sample_graph_for_prediction(self, graph: torch.Tensor, batch_size: int) -> torch.Tensor:
337
- sample_matrix = graph[None].expand(batch_size, -1, -1)
338
- sample_matrix = torch.clamp(sample_matrix, 0.0, 1.0)
339
- sample_matrix = torch.nan_to_num(sample_matrix, nan=0.5)
340
- return torch.bernoulli(sample_matrix).float()
341
-
342
- def freeze_accounting_edges(self) -> None:
343
- """Stop gradient flow through GT logits that correspond to known prior edges.
344
-
345
- Called after epoch 10 (per plan §5.3). For positions where
346
- ``G_prior >= 0.5`` the logit is detached so that subsequent
347
- ``graph_optimizer.step()`` calls do not update those weights.
348
-
349
- The method operates in-place by replacing ``self.GT`` with a new
350
- ``nn.Parameter`` whose values at prior positions are detached
351
- constants while non-prior positions retain full gradient.
352
- """
353
- if not hasattr(self, "GT"):
354
- return
355
- with torch.no_grad():
356
- gt_data = self.GT.data.clone()
357
-
358
- # Build a mask aligned to GT shape (n_groups × n_nodes)
359
- n_groups, n_nodes = self.GT.shape
360
- if self.G_prior.shape == (n_nodes, n_nodes) and n_groups == n_nodes:
361
- # 1-to-1 mapping: prior mask applies directly
362
- prior_mask = (self.G_prior >= 0.5)
363
- elif self._has_fixed_grouping():
364
- # Map node-level prior to group-level: group is frozen if any member
365
- # has a prior edge from that group
366
- prior_mask = torch.zeros(n_groups, n_nodes, dtype=torch.bool, device=self.device)
367
- for group_idx in range(n_groups):
368
- members = [
369
- idx for idx, g in enumerate(self.fixed_group_spec.assignments)
370
- if g == group_idx
371
- ]
372
- if members:
373
- row_prior = self.G_prior[members].max(dim=0).values
374
- prior_mask[group_idx] = row_prior >= 0.5
375
- else:
376
- return # cannot determine mapping — skip freeze
377
-
378
- frozen_vals = gt_data[prior_mask].detach()
379
- new_gt = nn.Parameter(gt_data)
380
- # Freeze prior positions by zeroing their gradient contribution
381
- # via a register_hook that zeroes the grad at those positions.
382
- def _freeze_hook(grad: torch.Tensor) -> torch.Tensor:
383
- grad = grad.clone()
384
- grad[prior_mask] = 0.0
385
- return grad
386
-
387
- new_gt.register_hook(_freeze_hook)
388
- self.GT = new_gt
389
- self.set_graph_optimizer() # refresh optimizer to point at new GT
390
- n_frozen = int(prior_mask.sum().item())
391
- print(f"[freeze_accounting_edges] Froze {n_frozen} / {n_groups * n_nodes} GT logit positions.")
392
-
393
-
394
- def ticker_price_pred(self, x, y, mask_x, mask_y):
395
- bs, n, t, d = x.shape
396
- self.fitting_model.train()
397
- self.data_pred_optimizer.zero_grad()
398
-
399
- _, _, effective_graph = self._compose_graph_parts()
400
- graph_sampled = self._sample_graph_for_prediction(effective_graph, self.args.batch_size)
401
-
402
- y_pred = self.fitting_model(x, mask_x, graph_sampled)
403
-
404
- # print(y_pred.shape, y.shape, observ_mask.shape)
405
- loss = self.data_pred_loss(y * mask_y, y_pred * mask_y) / (torch.mean(mask_y) + 1e-8)
406
- loss.backward()
407
- self.data_pred_optimizer.step()
408
- return y_pred, loss
409
-
410
- def graph_discov(self, x, y, mask_x, mask_y):
411
- gn, n = self.GT.shape
412
- self.graph_optimizer.zero_grad()
413
- soft_graph, hard_graph, effective_graph = self._compose_graph_parts()
414
-
415
- graph_sampled = self._sample_graph_with_controller(effective_graph)
416
-
417
- loss_sparsity = torch.linalg.norm(soft_graph.flatten(), ord=1) / (n * n)
418
-
419
- y_pred = self.fitting_model(x, mask_x, graph_sampled)
420
-
421
- loss_data = self.data_pred_loss(y * mask_y, y_pred * mask_y) / (torch.mean(mask_y) + 1e-8)
422
-
423
- # DuPont structural prior penalty: push toward known edges, away from impossible ones
424
- loss_dupont = torch.linalg.norm((effective_graph - self.G_prior).flatten(), ord=2) ** 2 / (n * n)
425
-
426
- # L2 regularization on raw GT logits to prevent saturation to ±∞
427
- loss_l2_gt = torch.linalg.norm(self.GT.flatten(), ord=2) ** 2 / (gn * n)
428
- if torch.any(self.hard_edge_mask_graph > 0):
429
- hard_edge_density = effective_graph[self.hard_edge_mask_graph > 0].mean()
430
- else:
431
- hard_edge_density = torch.tensor(0.0, device=self.device)
432
-
433
- loss = (loss_sparsity * self.lambda_s
434
- + loss_data
435
- + self.lambda_d * loss_dupont
436
- + 1e-3 * loss_l2_gt) # small L2 keeps logits from drifting to ±∞
437
- loss.backward()
438
- self.graph_optimizer.step()
439
-
440
- return loss, loss_sparsity, loss_data, loss_dupont, hard_edge_density
441
-
442
-
443
-
444
- def train(self, data, observ_mask, original_data, true_cm=None):
445
-
446
- original_data = torch.from_numpy(original_data).float().to(self.device)
447
- observ_mask = torch.from_numpy(observ_mask).float().to(self.device)
448
- data = torch.from_numpy(data).float().to(self.device)
449
-
450
- if self.args.supervision_policy == "masked":
451
- print("Using masked supervision for data prediction...")
452
- elif self.args.supervision_policy == "full":
453
- print("Using full supervision for data prediction......")
454
- observ_mask = torch.ones_like(observ_mask)
455
- elif "masked_before" in self.args.supervision_policy:
456
- print(f"Using masked supervision for data prediction ({self.args.supervision_policy:s})......")
457
-
458
- price_pred_step = 0
459
- graph_discov_step = 0
460
- pbar = tqdm.tqdm(total=self.args.total_epoch)
461
- data_interp = deepcopy(data)
462
- original_mask = deepcopy(observ_mask)
463
- auc = 0
464
- _edges_frozen = False # track whether freeze_accounting_edges() has run
465
- for epoch_i in range(self.args.total_epoch):
466
- self.current_epoch = epoch_i
467
- # Phase 5 §5.3: freeze accounting edge logits after epoch 10
468
- if epoch_i == 10 and not _edges_frozen:
469
- self.freeze_accounting_edges()
470
- _edges_frozen = True
471
- if self._has_fixed_grouping():
472
- if epoch_i == 0:
473
- self.G = torch.from_numpy(self.fixed_group_spec.matrix).float().to(self.device)
474
- self.GT = nn.Parameter(self._build_prior_seed_logits(self.fixed_group_spec.n_groups).to(self.device))
475
- self.set_graph_optimizer(epoch_i)
476
- elif self.args.group_policy is not None:
477
- group_mul = int(self.args.group_policy.split("_")[1])
478
- group_every = int(self.args.group_policy.split("_")[3])
479
- if epoch_i % group_every == 0 and self.n_groups < self.args.n_nodes:
480
- if epoch_i != 0:
481
- self.n_groups *= group_mul
482
- if self.n_groups > self.args.n_nodes:
483
- self.n_groups = self.args.n_nodes
484
-
485
- self.G = torch.zeros([self.args.n_nodes, self.n_groups]).to(self.device)
486
-
487
- for i in range(0, self.n_groups):
488
- for j in range(0, self.args.n_nodes // self.n_groups):
489
- self.G[i*(self.args.n_nodes // self.n_groups) + j, i] = 1
490
- for k in range(i*(self.args.n_nodes // self.n_groups) + j, self.args.n_nodes):
491
- self.G[k, i] = 1
492
-
493
- if hasattr(self, "GT"):
494
- GT_init = torch.sigmoid(self.GT).detach().cpu().repeat_interleave(group_mul, 0)[:self.n_groups, :]
495
- GT_init = 1 - (1 - GT_init)**(1 / group_mul)
496
- else:
497
- GT_init = self._init_random_gt(self.n_groups)
498
-
499
- self.GT = nn.Parameter(GT_init.to(self.device))
500
-
501
- self.set_graph_optimizer(epoch_i)
502
- elif epoch_i == 0 and self.n_groups == self.args.n_nodes:
503
- self.G = torch.eye(self.args.n_nodes).to(self.device)
504
- # Add small noise to break symmetry — identical init → identical gradients
505
- GT_init = torch.ones((self.n_groups, self.args.n_nodes))*0.5 + torch.randn(self.n_groups, self.args.n_nodes)*0.01
506
- self.GT = nn.Parameter(GT_init.to(self.device))
507
- self.set_graph_optimizer(epoch_i)
508
- else:
509
- if epoch_i == 0:
510
- self.n_groups = self.args.n_nodes
511
- self.G = torch.eye(self.args.n_nodes).to(self.device)
512
- GT_init = self._build_prior_seed_logits(self.n_groups)
513
- self.GT = nn.Parameter(GT_init.to(self.device))
514
- self.set_graph_optimizer(epoch_i)
515
-
516
-
517
- if "every" in self.args.fill_policy:
518
- update_every = int(self.args.fill_policy.split("_")[-1])
519
- if (epoch_i+1) % update_every == 0:
520
- data = data_pred
521
- print("Update data!")
522
- # self.graph_optimizer.param_groups[0]['lr'] = self.args.graph_discov.lr_graph_start
523
- self.data_pred_optimizer.param_groups[0]['lr'] = self.args.data_pred.lr_data_start
524
- observ_mask = torch.ones_like(original_mask)
525
- elif "rate" in self.args.fill_policy:
526
- update_rate = float(self.args.fill_policy.split("_")[1])
527
- update_after = int(self.args.fill_policy.split("_")[3])
528
- if epoch_i+1 > update_after:
529
- if epoch_i == update_after:
530
- print("Data update started!")
531
- data = data * (1 - update_rate) + data_pred * update_rate
532
- else:
533
- # no data update
534
- pass
535
-
536
- if "masked_before" in self.args.supervision_policy:
537
- masked_before = int(self.args.supervision_policy.split("_")[2])
538
- if epoch_i == masked_before:
539
- print("Using full supervision for data prediction......")
540
- observ_mask = torch.ones_like(original_mask)
541
- self.gumbel_tau = self.start_tau
542
-
543
- # Data Prediction
544
- if hasattr(self.args, "data_pred"):
545
- if hasattr(self.args, "block_size"):
546
- block_size = self.args.block_size
547
- else:
548
- block_size = None
549
-
550
- # Always use tech-only data for the batch generator.
551
- # If a projector exists, text is projected fresh INSIDE each batch
552
- # to avoid stale computation graphs after optimizer.step().
553
- batch_gen = batch_generater(data, observ_mask,
554
- bs=self.args.batch_size,
555
- n_nodes=self.args.n_nodes,
556
- input_step=self.args.input_step,
557
- pred_step=self.args.data_pred.pred_step,
558
- block_size=block_size)
559
- batch_gen = list(batch_gen)
560
-
561
- data_pred = data.clone().detach() # tech-only predictions
562
- data_pred_all = data.clone().detach()
563
- d_tech = data.shape[-1]
564
- inp_step = self.args.input_step
565
- pred_step = self.args.data_pred.pred_step
566
-
567
- for x, y, t, mask_x, mask_y in batch_gen:
568
- price_pred_step += self.args.batch_size
569
-
570
- x, y, mask_x, mask_y = self._append_context(x, y, mask_x, mask_y, t, inp_step, pred_step)
571
-
572
- y_pred, loss = self.ticker_price_pred(x, y, mask_x, mask_y)
573
- # Map back only the tech portion
574
- data_pred[t] = (y_pred*(1-mask_y) + y*mask_y).clone().detach()[:,:,0,:d_tech]
575
- data_pred_all[t] = y_pred.clone().detach()[:,:,0,:d_tech]
576
- self.log.log_metrics({"ticker_price_pred/pred_loss": loss.item()}, price_pred_step)
577
- pbar.set_postfix_str(f"S1 loss={loss.item():.2f}, spr=IDLE, auc={auc:.4f}")
578
-
579
- current_data_pred_lr = self.data_pred_optimizer.param_groups[0]['lr']
580
- self.log.log_metrics({"graph_discov/lr": current_data_pred_lr}, price_pred_step)
581
- self.data_pred_scheduler.step()
582
- mse_pred_to_original = self.data_pred_loss(original_data, data_pred)
583
- mse_interp_to_original = self.data_pred_loss(original_data, data_interp)
584
-
585
- self.log.log_metrics({"ticker_price_pred/mse_pred_to_original": mse_pred_to_original,
586
- "ticker_price_pred/mse_interp_to_original": mse_interp_to_original}, price_pred_step)
587
-
588
- # Graph Discovery
589
- if hasattr(self.args, "graph_discov"):
590
- for x, y, t, mask_x, mask_y in batch_gen:
591
- graph_discov_step += self.args.batch_size
592
- if hasattr(self.args, "disable_graph") and self.args.disable_graph:
593
- pass
594
- else:
595
- x, y, mask_x, mask_y = self._append_context(x, y, mask_x, mask_y, t, inp_step, pred_step)
596
-
597
- loss, loss_sparsity, loss_data, loss_dupont, hard_edge_density = self.graph_discov(x, y, mask_x, mask_y)
598
- self.log.log_metrics({"graph_discov/sparsity_loss": loss_sparsity.item(),
599
- "graph_discov/data_loss": loss_data.item(),
600
- "graph_discov/prior_loss": loss_dupont.item(),
601
- "graph_discov/hard_edge_density": hard_edge_density.item(),
602
- "graph_discov/total_loss": loss.item()}, graph_discov_step)
603
- pbar.set_postfix_str(f"S2 loss={loss_data.item():.2f}, spr={loss_sparsity.item():.2f}, auc={auc:.4f}")
604
-
605
- self.graph_scheduler.step()
606
- # self.group_scheduler.step()
607
- current_graph_disconv_lr = self.graph_optimizer.param_groups[0]['lr']
608
- self.log.log_metrics({"graph_discov/lr": current_graph_disconv_lr}, graph_discov_step)
609
- self.log.log_metrics({"graph_discov/tau": self.gumbel_tau}, graph_discov_step)
610
- self.gumbel_tau *= self.gumbel_tau_gamma
611
- self.lambda_s *= self.lambda_gamma
612
-
613
- pbar.update(1)
614
-
615
- plot_roc = False
616
-
617
- G_prob = self.G.detach().cpu().numpy()
618
- GT_prob = self.GT.detach().cpu().numpy()
619
- # Apply sigmoid to match training forward pass (ticker_price_pred/graph_discov use sigmoid)
620
- GT_prob_sigmoid = 1 / (1 + np.exp(-GT_prob))
621
- Graph = np.einsum("nm,ml->nl", G_prob, GT_prob_sigmoid)
622
- if np.any(self.hard_edge_mask_graph.detach().cpu().numpy() > 0):
623
- if self.hard_edge_trainable:
624
- Graph = Graph * self.soft_edge_mask_graph.detach().cpu().numpy() + Graph * self.hard_edge_mask_graph.detach().cpu().numpy()
625
- else:
626
- Graph = Graph * self.soft_edge_mask_graph.detach().cpu().numpy() + self.hard_edge_mask_graph.detach().cpu().numpy()
627
- Graph = np.clip(Graph, 0.0, 1.0)
628
-
629
-
630
- if (epoch_i+1) % self.args.show_graph_every == 0:
631
- avg_mask = np.mean(observ_mask.cpu().numpy(), axis=(0,2))
632
- if np.min(avg_mask) < 1:
633
- time_series_idx = int(np.argwhere(avg_mask < 1)[0, 0])
634
- else:
635
- time_series_idx = 0
636
- d_tech = original_data.shape[-1]
637
- log_time_series(
638
- original_data.cpu()[-100:,time_series_idx],
639
- data_interp.cpu()[-100:,time_series_idx],
640
- data_pred_all.cpu()[-100:,time_series_idx, :d_tech],
641
- log=self.log, log_step=price_pred_step
642
- )
643
-
644
- plot_matrix("G", G_prob, self.log, graph_discov_step, vmin=0, vmax=1)
645
- plot_matrix("GT", GT_prob, self.log, graph_discov_step, vmin=0, vmax=1)
646
- plot_matrix("Graph", Graph, self.log, graph_discov_step, vmin=0, vmax=1)
647
- np.save(os.path.join(self.log.log_dir, 'Graph.npy'), Graph)
648
- plot_roc = True
649
-
650
- # Show TPR FPR AUC ROC
651
- if true_cm is not None:
652
- Graph = rearrange(Graph, "n m -> m n")
653
- auc = calc_and_log_metrics(Graph, true_cm, self.log, graph_discov_step, plot_roc=plot_roc)
654
-
655
- return Graph
656
-
657
-
658
- def prepross_data(data):
659
- T, N, D = data.shape
660
- new_data = np.zeros_like(data, dtype=float)
661
- for i in range(N):
662
- node = data[:,i,:]
663
- std = np.std(node)
664
- # Guard against zero-std (constant) columns to prevent NaN from 0/0
665
- new_data[:,i,:] = (node - np.mean(node)) / (std + 1e-8)
666
- # Replace any residual NaN/Inf (e.g. from upstream data issues) with 0
667
- new_data = np.nan_to_num(new_data, nan=0.0, posinf=0.0, neginf=0.0)
668
- return new_data
669
-
670
-
671
- def main(
672
- data,
673
- mask,
674
- true_cm,
675
- opt,
676
- log,
677
- device="cuda",
678
- text_data=None,
679
- text_mask=None,
680
- G_prior=None,
681
- denoised_news=None,
682
- denoised_mask=None,
683
- ):
684
- if opt.n_nodes == "auto":
685
- opt.n_nodes = data.shape[1]
686
-
687
- if len(data.shape) == 2:
688
- data = data[:,:,None]
689
- mask = mask[:,:,None]
690
- data = prepross_data(data)
691
-
692
- if text_data is not None:
693
- text_data_torch = torch.from_numpy(text_data).float()
694
- text_mask_torch = torch.from_numpy(text_mask).float()
695
- else:
696
- text_data_torch = None
697
- text_mask_torch = None
698
- if denoised_news is not None:
699
- denoised_news_torch = torch.from_numpy(denoised_news).float()
700
- denoised_mask_torch = torch.from_numpy(denoised_mask).float() if denoised_mask is not None else None
701
- else:
702
- denoised_news_torch = None
703
- denoised_mask_torch = None
704
-
705
- effective_dim = opt.data_dim
706
- projector_output_dim = getattr(opt, 'projector_output_dim', 16)
707
- if text_data is not None:
708
- effective_dim += projector_output_dim
709
- if denoised_news is not None:
710
- effective_dim += projector_output_dim
711
- opt.data_dim = effective_dim
712
-
713
- multicad = MultiCAD(
714
- opt,
715
- log,
716
- device=device,
717
- text_data=text_data_torch,
718
- text_mask=text_mask_torch,
719
- G_prior=G_prior,
720
- denoised_news=denoised_news_torch,
721
- denoised_mask=denoised_mask_torch,
722
- )
723
- max_refuter_retries = 3
724
- refuter_retries = 0
725
- falsified = True
726
-
727
- while falsified and refuter_retries <= max_refuter_retries:
728
- Graph = multicad.train(data, mask, data, true_cm)
729
-
730
- # Run Refuter Validation
731
- try:
732
- from singular_ticker_causal.causal_inference.causal_model import StructuralCausalModel
733
- from singular_ticker_causal.causal_inference.query_engine import CausalQueryEngine
734
-
735
- data_cpu = data.cpu().numpy() if isinstance(data, torch.Tensor) else data
736
- n_nodes = Graph.shape[0]
737
- nodes = [f"N_{i}" for i in range(n_nodes)]
738
- adj_mask = G_prior if G_prior is not None else np.zeros_like(Graph)
739
-
740
- scm = StructuralCausalModel(
741
- nodes=nodes,
742
- adj=Graph,
743
- adjacency_mask=adj_mask,
744
- data_tech=data_cpu,
745
- lag=1
746
- ).fit()
747
-
748
- pywhyllm_enabled = os.environ.get("PYWHYLLM_ENABLED", "").lower() in {"1", "true", "yes", "on"}
749
- pywhyllm_max_edges = int(os.environ.get("PYWHYLLM_REFUTER_MAX_EDGES", "3"))
750
- engine = CausalQueryEngine(
751
- scm,
752
- data_tech=data_cpu,
753
- pywhyllm_enabled=pywhyllm_enabled,
754
- )
755
-
756
- falsified = False
757
- failed_edges = []
758
-
759
- edges_to_check = []
760
- for i in range(n_nodes):
761
- for j in range(n_nodes):
762
- if scm.dag_adj[i, j] and (adj_mask[i, j] == 0):
763
- edges_to_check.append((nodes[i], nodes[j], float(Graph[i, j])))
764
- edges_to_check.sort(key=lambda edge: abs(edge[2]), reverse=True)
765
-
766
- validation_reports = []
767
- for treatment, outcome, _score in edges_to_check[:pywhyllm_max_edges]:
768
- if pywhyllm_enabled:
769
- res = engine.validate_with_pywhyllm_and_dowhy(
770
- treatment,
771
- outcome,
772
- max_edges=pywhyllm_max_edges,
773
- )
774
- else:
775
- res = engine.validate_with_dowhy(treatment, outcome)
776
- validation_reports.append({"edge": [treatment, outcome], "validation": res})
777
- if res.get("falsified"):
778
- falsified = True
779
- failed_edges.append((treatment, outcome, res))
780
- break
781
-
782
- if falsified:
783
- print(f"[Refuter] Graph failed refutation on edges: {failed_edges}. Applying penalty and retrying...")
784
- multicad.args.total_epoch = 100
785
- multicad.lambda_s *= 1.5
786
-
787
- if hasattr(multicad, "GT"):
788
- multicad.GT.data = multicad._build_prior_seed_logits(multicad.n_groups).to(device)
789
- refuter_retries += 1
790
-
791
- import json
792
- artifact = {
793
- "failed_edges": [
794
- {"treatment": edge[0], "outcome": edge[1], "validation": edge[2]}
795
- for edge in failed_edges
796
- ],
797
- "validation_reports": validation_reports,
798
- "lambda_s_new": float(multicad.lambda_s),
799
- "retry_epoch": 100,
800
- "adj": Graph.tolist()
801
- }
802
- with open(os.path.join(log.log_dir, f"refuter_failed_artifact_retry_{refuter_retries}.json"), "w") as f:
803
- json.dump(artifact, f)
804
- else:
805
- print("[Refuter] Graph passed refutation or no testable edges.")
806
-
807
- except Exception as e:
808
- print(f"[Refuter] Validation error: {e}. Bypassing refuter.")
809
- falsified = False
810
-
811
- return Graph
812
-
813
-
814
- if __name__ == "__main__":
815
- os.environ["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
816
-
817
- parser = argparse.ArgumentParser(description='Batch Compress')
818
- parser.add_argument('-opt', type=str, default=opj(opd(__file__),
819
- 'opt/multi_cad_lorenz.yaml'), help='yaml file path')
820
- parser.add_argument('-g', help='availabel gpu list', default='2', type=str)
821
- parser.add_argument('-debug', action='store_true')
822
- parser.add_argument('-log', action='store_true')
823
- args = parser.parse_args()
824
-
825
- if args.g == "mps":
826
- os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
827
- device = "mps"
828
- elif args.g == "cpu":
829
- device = "cpu"
830
- else:
831
- os.environ["CUDA_VISIBLE_DEVICES"] = args.g
832
- device = "cuda"
833
-
834
- main(OmegaConf.load(args.opt), device=device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/data/__init__.py DELETED
File without changes
singular_ticker_causal/algorithms/CUTS_PLUS/data/generate_data_mod.py DELETED
@@ -1,430 +0,0 @@
1
- import numpy as np
2
- from collections import defaultdict
3
-
4
- def check_stationarity(links):
5
- """Returns stationarity according to a unit root test
6
-
7
- Assuming a Gaussian Vector autoregressive process
8
-
9
- Three conditions are necessary for stationarity of the VAR(p) model:
10
- - Absence of mean shifts;
11
- - The noise vectors are identically distributed;
12
- - Stability condition on Phi(t-1) coupling matrix (stabmat) of VAR(1)-version of VAR(p).
13
- """
14
-
15
-
16
- N = len(links)
17
- # Check parameters
18
- max_lag = 0
19
-
20
- for j in range(N):
21
- for link_props in links[j]:
22
- var, lag = link_props[0]
23
- # coeff = link_props[1]
24
- # coupling = link_props[2]
25
-
26
- max_lag = max(max_lag, abs(lag))
27
-
28
- graph = np.zeros((N,N,max_lag))
29
- couplings = []
30
-
31
- for j in range(N):
32
- for link_props in links[j]:
33
- var, lag = link_props[0]
34
- coeff = link_props[1]
35
- coupling = link_props[2]
36
- if abs(lag) > 0:
37
- graph[j,var,abs(lag)-1] = coeff
38
- couplings.append(coupling)
39
-
40
- stabmat = np.zeros((N*max_lag,N*max_lag))
41
- index = 0
42
-
43
- for i in range(0,N*max_lag,N):
44
- stabmat[:N,i:i+N] = graph[:,:,index]
45
- if index < max_lag-1:
46
- stabmat[i+N:i+2*N,i:i+N] = np.identity(N)
47
- index += 1
48
-
49
- eig = np.linalg.eig(stabmat)[0]
50
- # print "----> maxeig = ", np.abs(eig).max()
51
- if np.all(np.abs(eig) < 1.):
52
- stationary = True
53
- else:
54
- stationary = False
55
-
56
- if len(eig) == 0:
57
- return stationary, 0.
58
- else:
59
- return stationary, np.abs(eig).max()
60
-
61
-
62
- class Graph():
63
- def __init__(self,vertices):
64
- self.graph = defaultdict(list)
65
- self.V = vertices
66
-
67
- def addEdge(self,u,v):
68
- self.graph[u].append(v)
69
-
70
- def isCyclicUtil(self, v, visited, recStack):
71
-
72
- # Mark current node as visited and
73
- # adds to recursion stack
74
- visited[v] = True
75
- recStack[v] = True
76
-
77
- # Recur for all neighbours
78
- # if any neighbour is visited and in
79
- # recStack then graph is cyclic
80
- for neighbour in self.graph[v]:
81
- if visited[neighbour] == False:
82
- if self.isCyclicUtil(neighbour, visited, recStack) == True:
83
- return True
84
- elif recStack[neighbour] == True:
85
- return True
86
-
87
- # The node needs to be poped from
88
- # recursion stack before function ends
89
- recStack[v] = False
90
- return False
91
-
92
- # Returns true if graph is cyclic else false
93
- def isCyclic(self):
94
- visited = [False] * self.V
95
- recStack = [False] * self.V
96
- for node in range(self.V):
97
- if visited[node] == False:
98
- if self.isCyclicUtil(node,visited,recStack) == True:
99
- return True
100
- return False
101
-
102
- # A recursive function used by topologicalSort
103
- def topologicalSortUtil(self,v,visited,stack):
104
-
105
- # Mark the current node as visited.
106
- visited[v] = True
107
-
108
- # Recur for all the vertices adjacent to this vertex
109
- for i in self.graph[v]:
110
- if visited[i] == False:
111
- self.topologicalSortUtil(i,visited,stack)
112
-
113
- # Push current vertex to stack which stores result
114
- stack.insert(0,v)
115
-
116
- # The function to do Topological Sort. It uses recursive
117
- # topologicalSortUtil()
118
- def topologicalSort(self):
119
- # Mark all the vertices as not visited
120
- visited = [False]*self.V
121
- stack =[]
122
-
123
- # Call the recursive helper function to store Topological
124
- # Sort starting from all vertices one by one
125
- for i in range(self.V):
126
- if visited[i] == False:
127
- self.topologicalSortUtil(i,visited,stack)
128
-
129
- return stack
130
-
131
- def generate_nonlinear_contemp_timeseries(links, T, noises=None, random_state=None):
132
-
133
- if random_state is None:
134
- random_state = np.random
135
-
136
- # links must be {j:[((i, -tau), func), ...], ...}
137
- # coeff is coefficient
138
- # func is a function f(x) that becomes linear ~x in limit
139
- # noises is a random_state.___ function
140
- N = len(links.keys())
141
- if noises is None:
142
- noises = [random_state.randn for j in range(N)]
143
-
144
- if N != max(links.keys())+1 or N != len(noises):
145
- raise ValueError("links and noises keys must match N.")
146
-
147
- # Check parameters
148
- max_lag = 0
149
- contemp = False
150
- contemp_dag = Graph(N)
151
- causal_order = list(range(N))
152
- for j in range(N):
153
- for link_props in links[j]:
154
- var, lag = link_props[0]
155
- coeff = link_props[1]
156
- func = link_props[2]
157
- if lag == 0: contemp = True
158
- if var not in range(N):
159
- raise ValueError("var must be in 0..{}.".format(N-1))
160
- if 'float' not in str(type(coeff)):
161
- raise ValueError("coeff must be float.")
162
- if lag > 0 or type(lag) != int:
163
- raise ValueError("lag must be non-positive int.")
164
- max_lag = max(max_lag, abs(lag))
165
-
166
- # Create contemp DAG
167
- if var != j and lag == 0:
168
- contemp_dag.addEdge(var, j)
169
- # a, b = causal_order.index(var), causal_order.index(j)
170
- # causal_order[b], causal_order[a] = causal_order[a], causal_order[b]
171
-
172
- if contemp_dag.isCyclic() == 1:
173
- raise ValueError("Contemporaneous links must not contain cycle.")
174
-
175
- causal_order = contemp_dag.topologicalSort()
176
-
177
- transient = int(.2*T)
178
-
179
- X = np.zeros((T+transient, N), dtype='float32')
180
- for j in range(N):
181
- X[:, j] = noises[j](T+transient)
182
-
183
- for t in range(max_lag, T+transient):
184
- for j in causal_order:
185
- for link_props in links[j]:
186
- var, lag = link_props[0]
187
- # if abs(lag) > 0:
188
- coeff = link_props[1]
189
- func = link_props[2]
190
-
191
- X[t, j] += coeff * func(X[t + lag, var])
192
-
193
- X = X[transient:]
194
-
195
- if (check_stationarity(links)[0] == False or
196
- np.any(np.isnan(X)) or
197
- np.any(np.isinf(X)) or
198
- # np.max(np.abs(X)) > 1.e4 or
199
- np.any(np.abs(np.triu(np.corrcoef(X, rowvar=0), 1)) > 0.999)):
200
- nonstationary = True
201
- else:
202
- nonstationary = False
203
-
204
- return X, nonstationary
205
-
206
-
207
- def generate_random_contemp_model(N, L,
208
- coupling_coeffs,
209
- coupling_funcs,
210
- auto_coeffs,
211
- tau_max,
212
- contemp_fraction=0.,
213
- # num_trials=1000,
214
- random_state=None):
215
-
216
- def lin(x): return x
217
-
218
- if random_state is None:
219
- random_state = np.random
220
-
221
- # print links
222
- a_len = len(auto_coeffs)
223
- if type(coupling_coeffs) == float:
224
- coupling_coeffs = [coupling_coeffs]
225
- c_len = len(coupling_coeffs)
226
- func_len = len(coupling_funcs)
227
-
228
- if tau_max == 0:
229
- contemp_fraction = 1.
230
-
231
- if contemp_fraction > 0.:
232
- contemp = True
233
- L_lagged = int((1.-contemp_fraction)*L)
234
- L_contemp = L - L_lagged
235
- if L==1:
236
- # Randomly assign a lagged or contemp link
237
- L_lagged = random_state.randint(0,2)
238
- L_contemp = int(L_lagged == False)
239
-
240
- else:
241
- contemp = False
242
- L_lagged = L
243
- L_contemp = 0
244
-
245
-
246
- # for ir in range(num_trials):
247
-
248
- # Random order
249
- causal_order = list(random_state.permutation(N))
250
-
251
- links = dict([(i, []) for i in range(N)])
252
-
253
- # Generate auto-dependencies at lag 1
254
- if tau_max > 0:
255
- for i in causal_order:
256
- a = auto_coeffs[random_state.randint(0, a_len)]
257
-
258
- if a != 0.:
259
- links[i].append(((int(i), -1), float(a), lin))
260
-
261
- chosen_links = []
262
- # Create contemporaneous DAG
263
- contemp_links = []
264
- for l in range(L_contemp):
265
-
266
- cause = random_state.choice(causal_order[:-1])
267
- effect = random_state.choice(causal_order)
268
- while (causal_order.index(cause) >= causal_order.index(effect)
269
- or (cause, effect) in chosen_links):
270
- cause = random_state.choice(causal_order[:-1])
271
- effect = random_state.choice(causal_order)
272
-
273
- contemp_links.append((cause, effect))
274
- chosen_links.append((cause, effect))
275
-
276
- # Create lagged links (can be cyclic)
277
- lagged_links = []
278
- for l in range(L_lagged):
279
-
280
- cause = random_state.choice(causal_order)
281
- effect = random_state.choice(causal_order)
282
- while (cause, effect) in chosen_links or cause == effect:
283
- cause = random_state.choice(causal_order)
284
- effect = random_state.choice(causal_order)
285
-
286
- lagged_links.append((cause, effect))
287
- chosen_links.append((cause, effect))
288
-
289
- # print(chosen_links)
290
- # print(contemp_links)
291
- for (i, j) in chosen_links:
292
-
293
- # Choose lag
294
- if (i, j) in contemp_links:
295
- tau = 0
296
- else:
297
- tau = int(random_state.randint(1, tau_max+1))
298
- # print tau
299
- # CHoose coupling
300
- c = float(coupling_coeffs[random_state.randint(0, c_len)])
301
- if c != 0:
302
- func = coupling_funcs[random_state.randint(0, func_len)]
303
-
304
- links[j].append(((int(i), -tau), c, func))
305
-
306
- # # Stationarity check assuming model with linear dependencies at least for large x
307
- # # if check_stationarity(links)[0]:
308
- # # return links
309
- # X, nonstat = generate_nonlinear_contemp_timeseries(links,
310
- # T=10000, noises=None, random_state=None)
311
- # if nonstat == False:
312
- # return links
313
- # else:
314
- # print("Trial %d: Not a stationary model" % ir)
315
-
316
-
317
- # print("No stationary models found in {} trials".format(num_trials))
318
- return links
319
-
320
- def generate_logistic_maps(N, T, links, noise_lev):
321
-
322
- # Check parameters
323
- # contemp = False
324
- max_lag = 0
325
- for j in range(N):
326
- for link_props in links[j]:
327
- var, lag = link_props[0]
328
- max_lag = max(max_lag, abs(lag))
329
-
330
- transient = int(.2*T)
331
-
332
- # Chaotic logistic map parameter
333
- r = 4.
334
-
335
- X = np.random.rand(T+transient, N)
336
-
337
- for t in range(max_lag, T+transient):
338
- for j in range(N):
339
- added_input = 0.
340
- for link_props in links[j]:
341
- var, lag = link_props[0]
342
- if var != j and abs(lag) > 0:
343
- coeff = link_props[1]
344
- coupling = link_props[2]
345
- added_input += coeff*X[t - abs(lag), var]
346
-
347
- X[t, j] = (X[t-1, j] * (r - r*X[t-1, j] - added_input + noise_lev*np.random.rand())) % 1
348
- #func(coeff, X[t+lag, var], coupling)
349
-
350
- X = X[transient:]
351
-
352
- if np.any(np.abs(X) == np.inf) or np.any(X == np.nan):
353
- raise ValueError("Data divergent")
354
- return X
355
-
356
-
357
-
358
- def weighted_avg_and_std(values, axis, weights):
359
- """Returns the weighted average and standard deviation.
360
-
361
- Parameters
362
- ---------
363
- values : array
364
- Data array of shape (time, variables).
365
-
366
- axis : int
367
- Axis to average/std about
368
-
369
- weights : array
370
- Weight array of shape (time, variables).
371
-
372
- Returns
373
- -------
374
- (average, std) : tuple of arrays
375
- Tuple of weighted average and standard deviation along axis.
376
- """
377
-
378
- values[np.isnan(values)] = 0.
379
- average = np.ma.average(values, axis=axis, weights=weights)
380
- variance = np.sum(weights * (values - np.expand_dims(average, axis)
381
- ) ** 2, axis=axis) / weights.sum(axis=axis)
382
-
383
- return (average, np.sqrt(variance))
384
-
385
- def time_bin_with_mask(data, time_bin_length, sample_selector=None):
386
- """Returns time binned data where only about non-masked values is averaged.
387
-
388
- Parameters
389
- ----------
390
- data : array
391
- Data array of shape (time, variables).
392
-
393
- time_bin_length : int
394
- Length of time bin.
395
-
396
- mask : bool array, optional (default: None)
397
- Data mask where True labels masked samples.
398
-
399
- Returns
400
- -------
401
- (bindata, T) : tuple of array and int
402
- Tuple of time-binned data array and new length of array.
403
- """
404
-
405
- T = len(data)
406
-
407
- time_bin_length = int(time_bin_length)
408
-
409
- if sample_selector is None:
410
- sample_selector = np.ones(data.shape)
411
-
412
- if np.ndim(data) == 1.:
413
- data.shape = (T, 1)
414
- sample_selector.shape = (T, 1)
415
-
416
- bindata = np.zeros(
417
- (T // time_bin_length,) + data.shape[1:], dtype="float32")
418
- for index, i in enumerate(range(0, T - time_bin_length + 1,
419
- time_bin_length)):
420
- # print weighted_avg_and_std(fulldata[i:i+time_bin_length], axis=0,
421
- # weights=sample_selector[i:i+time_bin_length])[0]
422
- bindata[index] = weighted_avg_and_std(data[i:i + time_bin_length],
423
- axis=0,
424
- weights=sample_selector[i:i +
425
- time_bin_length])[0]
426
-
427
- T, grid_size = bindata.shape
428
-
429
- return (bindata.squeeze(), T)
430
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/data/simu_data.py DELETED
@@ -1,313 +0,0 @@
1
- import os
2
- import sys
3
- from os.path import join as opj
4
- sys.path.append(opj(os.getcwd(), "../"))
5
- sys.path.append(os.getcwd())
6
-
7
- import csv
8
- import torch
9
- import scipy
10
- from .generate_data_mod import generate_random_contemp_model, generate_nonlinear_contemp_timeseries
11
- import numpy as np
12
- from scipy.integrate import odeint
13
-
14
-
15
- ######################################
16
- # Function for loading input data
17
- ######################################
18
- def loadTrainingData(inputDataFilePath, device):
19
-
20
- # Load and parse input data (create batch data)
21
- inpData = torch.load(inputDataFilePath)
22
- Xtrain = torch.zeros(inpData['TsData'].shape[1], inpData['TsData'].shape[0], requires_grad = False, device=device)
23
- Xtrain1 = inpData['TsData'].t()
24
- Xtrain.data[:,:] = Xtrain1.data[:,:]
25
-
26
- return Xtrain
27
-
28
- #######################################################
29
- # Function for reading ground truth network from file
30
- #######################################################
31
- def loadTrueNetwork(inputFilePath, networkSize):
32
-
33
- with open(inputFilePath) as tsvin:
34
- reader = csv.reader(tsvin, delimiter='\t')
35
- numrows = 0
36
- for row in reader:
37
- numrows = numrows + 1
38
-
39
- network = np.zeros((numrows,2),dtype=np.int16)
40
- with open(inputFilePath) as tsvin:
41
- reader = csv.reader(tsvin, delimiter='\t')
42
- rowcounter = 0
43
- for row in reader:
44
- network[rowcounter][0] = int(row[0][1:])
45
- network[rowcounter][1] = int(row[1][1:])
46
- rowcounter = rowcounter + 1
47
-
48
- Gtrue = np.zeros((networkSize,networkSize), dtype=np.int16)
49
- for row in range(0,len(network),1):
50
- Gtrue[network[row][1]-1][network[row][0]-1] = 1
51
-
52
- return Gtrue
53
-
54
-
55
- def load_dream_data(dataset_id):
56
- device = "cpu"
57
-
58
- if(dataset_id == 0):
59
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Ecoli1.pt"
60
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Ecoli1.tsv"
61
- elif(dataset_id == 1):
62
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Ecoli2.pt"
63
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Ecoli2.tsv"
64
- elif(dataset_id == 2):
65
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Yeast1.pt"
66
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Yeast1.tsv"
67
- elif(dataset_id == 3):
68
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Yeast2.pt"
69
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Yeast2.tsv"
70
- elif(dataset_id == 4):
71
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size100Yeast3.pt"
72
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize100-Yeast3.tsv"
73
- elif(dataset_id == 5):
74
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Ecoli1.pt"
75
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Ecoli1.tsv"
76
- elif(dataset_id == 6):
77
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Ecoli2.pt"
78
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Ecoli2.tsv"
79
- elif(dataset_id == 7):
80
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Yeast1.pt"
81
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Yeast1.tsv"
82
- elif(dataset_id == 8):
83
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Yeast2.pt"
84
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Yeast2.tsv"
85
- elif(dataset_id == 9):
86
- InputDataFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/Dream3TensorData/Size10Yeast3.pt"
87
- RefNetworkFilePath = "causal_discov_sota/SRU_for_GCI/data/dream3/TrueGeneNetworks/InSilicoSize10-Yeast3.tsv"
88
- else:
89
- print("Error while loading gene training data")
90
-
91
- Xtrain = loadTrainingData(InputDataFilePath, device)
92
- n = Xtrain.shape[0]
93
- Gref = loadTrueNetwork(RefNetworkFilePath, n)
94
-
95
- Xtrain = Xtrain.numpy().T
96
- # Gref = Gref.T
97
-
98
- return Xtrain, Gref
99
-
100
-
101
-
102
- def links_to_matrix(links):
103
- N = len(links)
104
- cm = np.zeros([N, N])
105
- for i, effect_node in links.items():
106
- for (j, _), _, _ in effect_node:
107
- cm[i, j] += 1
108
- return cm
109
-
110
-
111
- class noise_model:
112
- def __init__(self, sigma=1, seed=0):
113
- self.random_state = np.random.RandomState(seed)
114
- self.sigma = sigma
115
-
116
- def gaussian(self, T):
117
- # Get zero-mean unit variance gaussian distribution
118
- return self.sigma*self.random_state.randn(T)
119
-
120
- def weibull(self, T):
121
- # Get zero-mean sigma variance weibull distribution
122
- a = 2
123
- mean = scipy.special.gamma(1./a + 1)
124
- variance = scipy.special.gamma(
125
- 2./a + 1) - scipy.special.gamma(1./a + 1)**2
126
- return self.sigma*(self.random_state.weibull(a=a, size=T) - mean)/np.sqrt(variance)
127
-
128
- def uniform(self, T):
129
- # Get zero-mean sigma variance uniform distribution
130
- mean = 0.5
131
- variance = 1./12.
132
- return self.sigma*(self.random_state.uniform(size=T) - mean)/np.sqrt(variance)
133
-
134
-
135
- def lin_f(x): return x
136
- def f2(x): return (x + 5. * x**2 * np.exp(-x**2 / 20.))
137
-
138
-
139
- def simulate_random_var(seed, T, N, L, coef=[0.2, 0.8], auto_corr=[0.4,0.9], tau_max=5, noise_sigma=[0.01, 0.01]):
140
-
141
- if True:
142
- coupling_funcs = [lin_f]
143
- noise_types = ['gaussian'] # , 'weibull', 'uniform']
144
- # noise_sigma = (0.1, 0.3)
145
-
146
- couplings = list(np.arange(coef[0], coef[1]+1e-5, coef[2]))
147
- couplings += [-c for c in couplings]
148
-
149
- # auto_deps = list(np.arange(max(0., auto_corr-0.6), auto_corr+0.01, 0.05))
150
- auto_deps = list(np.arange(auto_corr[0], auto_corr[1]+1e-5, auto_corr[2]))
151
-
152
- # Models may be non-stationary. Hence, we iterate over a number of seeds
153
- # to find a stationary one regarding network topology, noises, etc
154
-
155
- ir = 0
156
- model_seed = seed
157
- while True:
158
- ir += 1
159
- # np.random.seed(model_seed)
160
- random_state = np.random.RandomState(model_seed)
161
-
162
- links = generate_random_contemp_model(
163
- N=N, L=L,
164
- coupling_coeffs=couplings,
165
- coupling_funcs=coupling_funcs,
166
- auto_coeffs=auto_deps,
167
- tau_max=tau_max,
168
- contemp_fraction=0.,
169
- # num_trials=1000,
170
- random_state=random_state)
171
-
172
- noises = []
173
- for j in links:
174
- noise_type = random_state.choice(noise_types)
175
- sigmas = list(np.arange(noise_sigma[0], noise_sigma[1]+1e-5, noise_sigma[2]))
176
- sigma = random_state.choice(sigmas)
177
- # sigma = noise_sigma[0] + (noise_sigma[1]-noise_sigma[0])*random_state.rand()
178
- noises.append(getattr(noise_model(sigma=sigma, seed=seed), noise_type))
179
-
180
- data_all_check, nonstationary = generate_nonlinear_contemp_timeseries(
181
- links=links, T=100, noises=noises, random_state=random_state)
182
-
183
- # If the model is stationary, break the loop
184
- if not nonstationary:
185
- data, nonstationary_full = generate_nonlinear_contemp_timeseries(
186
- links=links, T=T, noises=noises, random_state=random_state)
187
- if not nonstationary_full:
188
- break
189
- else:
190
- print("Trial %d: Not a stationary model" % ir)
191
- model_seed += 10000
192
-
193
- cm = links_to_matrix(links)
194
- return data, cm
195
-
196
-
197
- def simulate_var_from_links(links, T, seed=0, noise_sigma=[0.1, 0.2], noise_type="gaussian", func_name="lin_f"):
198
- """
199
- links_coeffs = {0: [((0, -1), 0.7), ((1, -1), -0.8)],
200
- 1: [((1, -1), 0.8), ((3, -1), 0.8)],
201
- 2: [((2, -1), 0.5), ((1, -2), 0.5), ((3, -3), 0.6)],
202
- 3: [((3, -1), 0.4)],
203
- }
204
- """
205
- def get_func(func_name):
206
- if func_name == "lin_f":
207
- return lin_f
208
- else:
209
- raise NotImplementedError
210
-
211
- random_state = np.random.RandomState(seed)
212
- noises = []
213
-
214
- new_links = {}
215
- for j in range(len(links)):
216
- sigma = noise_sigma[0] + \
217
- (noise_sigma[1]-noise_sigma[0])*random_state.rand()
218
- noises.append(getattr(noise_model(sigma=sigma, seed=seed), noise_type))
219
- new_links[j] = []
220
- for props in links[j]:
221
- new_links[j].append(
222
- (tuple(props[0:2]), props[2], get_func(props[3]),))
223
- data, nonstationary = generate_nonlinear_contemp_timeseries(
224
- links=new_links, T=T, noises=noises, random_state=random_state)
225
- if nonstationary:
226
- print("Model nonstationay!")
227
-
228
- cm = links_to_matrix(new_links)
229
- return data, cm
230
-
231
-
232
- def make_var_stationary(beta, radius=0.97):
233
- '''Rescale coefficients of VAR model to make stable.'''
234
- p = beta.shape[0]
235
- lag = beta.shape[1] // p
236
- bottom = np.hstack((np.eye(p * (lag - 1)), np.zeros((p * (lag - 1), p))))
237
- beta_tilde = np.vstack((beta, bottom))
238
- eigvals = np.linalg.eigvals(beta_tilde)
239
- max_eig = max(np.abs(eigvals))
240
- nonstationary = max_eig > radius
241
- if nonstationary:
242
- # print(f"Nonstationary, beta={str(beta):s}, max_eig={max_eig:.4f}")
243
- return make_var_stationary((beta / max_eig) * 0.7, radius)
244
- else:
245
- # print(f"Stationary, beta={str(beta):s}")
246
- return beta
247
-
248
-
249
- def simulate_var(p, T, lag, sparsity=0.2, beta_value=1.0, auto_corr=3.0, sd=0.1, seed=0):
250
- if seed is not None:
251
- np.random.seed(seed)
252
-
253
- # Set up coefficients and Granger causality ground truth.
254
- GC = np.eye(p, dtype=int)
255
- beta = np.eye(p) * auto_corr
256
-
257
- num_nonzero = int(p * sparsity) - 1
258
- for i in range(p):
259
- choice = np.random.choice(p - 1, size=num_nonzero, replace=False)
260
- choice[choice >= i] += 1
261
- beta[i, choice] = beta_value
262
- GC[i, choice] = 1
263
-
264
- beta = np.hstack([beta for _ in range(lag)])
265
- beta = make_var_stationary(beta)
266
-
267
- # Generate data.
268
- burn_in = 100
269
- errors = np.random.normal(loc=0, scale=sd, size=(p, T + burn_in))
270
- X = np.ones((p, T + burn_in))
271
- X[:, :lag] = errors[:, :lag]
272
- for t in range(lag, T + burn_in):
273
- X[:, t] = np.dot(beta, X[:, (t-lag):t].flatten(order='F'))
274
- X[:, t] += errors[:, t-1]
275
-
276
- data = X.T[burn_in:, :]
277
- return data, beta, GC
278
-
279
-
280
-
281
-
282
-
283
- def lorenz(x, t, F):
284
- '''Partial derivatives for Lorenz-96 ODE.'''
285
- p = len(x)
286
- dxdt = np.zeros(p)
287
- for i in range(p):
288
- dxdt[i] = (x[(i+1) % p] - x[(i-2) % p]) * x[(i-1) % p] - x[i] + F
289
-
290
- return dxdt
291
-
292
-
293
- def simulate_lorenz_96(p, T, F=10.0, delta_t=0.1, sd=0.1, burn_in=1000,
294
- seed=0):
295
- if seed is not None:
296
- np.random.seed(seed)
297
-
298
- # Use scipy to solve ODE.
299
- x0 = np.random.normal(scale=0.01, size=p)
300
- t = np.linspace(0, (T + burn_in) * delta_t, T + burn_in)
301
- X = odeint(lorenz, x0, t, args=(F,))
302
- X += np.random.normal(scale=sd, size=(T + burn_in, p))
303
-
304
- # Set up Granger causality ground truth.
305
- GC = np.zeros((p, p), dtype=int)
306
- for i in range(p):
307
- GC[i, i] = 1
308
- GC[i, (i + 1) % p] = 1
309
- GC[i, (i - 1) % p] = 1
310
- GC[i, (i - 2) % p] = 1
311
-
312
- return X[burn_in:, :], GC
313
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/data/utils.py DELETED
@@ -1,136 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
-
4
- from sklearn.metrics.pairwise import haversine_distances
5
-
6
-
7
- def compute_mean(x, index=None):
8
- """Compute the mean values for each datetime. The mean is first computed hourly over the week of the year.
9
- Further NaN values are computed using hourly mean over the same month through the years. If other NaN are present,
10
- they are removed using the mean of the sole hours. Hoping reasonably that there is at least a non-NaN entry of the
11
- same hour of the NaN datetime in all the dataset."""
12
- if isinstance(x, np.ndarray) and index is not None:
13
- shape = x.shape
14
- x = x.reshape((shape[0], -1))
15
- df_mean = pd.DataFrame(x, index=index)
16
- else:
17
- df_mean = x.copy()
18
- cond0 = [df_mean.index.year, df_mean.index.isocalendar().week, df_mean.index.hour]
19
- cond1 = [df_mean.index.year, df_mean.index.month, df_mean.index.hour]
20
- conditions = [cond0, cond1, cond1[1:], cond1[2:]]
21
- while df_mean.isna().values.sum() and len(conditions):
22
- nan_mean = df_mean.groupby(conditions[0]).transform(np.nanmean)
23
- df_mean = df_mean.fillna(nan_mean)
24
- conditions = conditions[1:]
25
- if df_mean.isna().values.sum():
26
- df_mean = df_mean.fillna(method='ffill')
27
- df_mean = df_mean.fillna(method='bfill')
28
- if isinstance(x, np.ndarray):
29
- df_mean = df_mean.values.reshape(shape)
30
- return df_mean
31
-
32
-
33
- def geographical_distance(x=None, to_rad=True):
34
- """
35
- Compute the as-the-crow-flies distance between every pair of samples in `x`. The first dimension of each point is
36
- assumed to be the latitude, the second is the longitude. The inputs is assumed to be in degrees. If it is not the
37
- case, `to_rad` must be set to False. The dimension of the data must be 2.
38
-
39
- Parameters
40
- ----------
41
- x : pd.DataFrame or np.ndarray
42
- array_like structure of shape (n_samples_2, 2).
43
- to_rad : bool
44
- whether to convert inputs to radians (provided that they are in degrees).
45
-
46
- Returns
47
- -------
48
- distances :
49
- The distance between the points in kilometers.
50
- """
51
- _AVG_EARTH_RADIUS_KM = 6371.0088
52
-
53
- # Extract values of X if it is a DataFrame, else assume it is 2-dim array of lat-lon pairs
54
- latlon_pairs = x.values if isinstance(x, pd.DataFrame) else x
55
-
56
- # If the input values are in degrees, convert them in radians
57
- if to_rad:
58
- latlon_pairs = np.vectorize(np.radians)(latlon_pairs)
59
-
60
- distances = haversine_distances(latlon_pairs) * _AVG_EARTH_RADIUS_KM
61
-
62
- # Cast response
63
- if isinstance(x, pd.DataFrame):
64
- res = pd.DataFrame(distances, x.index, x.index)
65
- else:
66
- res = distances
67
-
68
- return res
69
-
70
-
71
- def infer_mask(df, infer_from='next'):
72
- """Infer evaluation mask from DataFrame. In the evaluation mask a value is 1 if it is present in the DataFrame and
73
- absent in the `infer_from` month.
74
-
75
- @param pd.DataFrame df: the DataFrame.
76
- @param str infer_from: denotes from which month the evaluation value must be inferred.
77
- Can be either `previous` or `next`.
78
- @return: pd.DataFrame eval_mask: the evaluation mask for the DataFrame
79
- """
80
- mask = (~df.isna()).astype('uint8')
81
- eval_mask = pd.DataFrame(index=mask.index, columns=mask.columns, data=0).astype('uint8')
82
- if infer_from == 'previous':
83
- offset = -1
84
- elif infer_from == 'next':
85
- offset = 1
86
- else:
87
- raise ValueError('infer_from can only be one of %s' % ['previous', 'next'])
88
- months = sorted(set(zip(mask.index.year, mask.index.month)))
89
- length = len(months)
90
- for i in range(length):
91
- j = (i + offset) % length
92
- year_i, month_i = months[i]
93
- year_j, month_j = months[j]
94
- mask_j = mask[(mask.index.year == year_j) & (mask.index.month == month_j)]
95
- mask_i = mask_j.shift(1, pd.DateOffset(months=12 * (year_i - year_j) + (month_i - month_j)))
96
- mask_i = mask_i[~mask_i.index.duplicated(keep='first')]
97
- mask_i = mask_i[np.in1d(mask_i.index, mask.index)]
98
- eval_mask.loc[mask_i.index] = ~mask_i.loc[mask_i.index] & mask.loc[mask_i.index]
99
- return eval_mask
100
-
101
-
102
- def disjoint_months(dataset, months=None):
103
- idxs = np.arange(len(dataset))
104
- months = ensure_list(months)
105
- # divide indices according to window or horizon
106
- start, end = 0, dataset.window - 1
107
- # after idxs
108
- start_in_months = np.in1d(dataset.index[dataset._indices + start].month, months)
109
- end_in_months = np.in1d(dataset.index[dataset._indices + end].month, months)
110
- idxs_in_months = start_in_months & end_in_months
111
- after_idxs = idxs[idxs_in_months]
112
- # previous idxs
113
- months = np.setdiff1d(np.arange(1, 13), months)
114
- start_in_months = np.in1d(dataset.index[dataset._indices + start].month, months)
115
- end_in_months = np.in1d(dataset.index[dataset._indices + end].month, months)
116
- idxs_in_months = start_in_months & end_in_months
117
- prev_idxs = idxs[idxs_in_months]
118
- return prev_idxs, after_idxs
119
-
120
-
121
- def thresholded_gaussian_kernel(x, theta=None, threshold=None, threshold_on_input=False):
122
- if theta is None:
123
- theta = np.std(x)
124
- weights = np.exp(-np.square(x / theta))
125
- if threshold is not None:
126
- mask = x > threshold if threshold_on_input else weights < threshold
127
- weights[mask] = 0.
128
- return weights
129
-
130
-
131
- def ensure_list(obj):
132
- if isinstance(obj, (list, tuple)):
133
- return list(obj)
134
- else:
135
- return [obj]
136
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/lagged_graph.py DELETED
@@ -1,95 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from copy import deepcopy
4
- from dataclasses import dataclass
5
- from typing import Any
6
-
7
- import numpy as np
8
-
9
- from .cuts_plus import main as cuts_plus_main
10
-
11
-
12
- @dataclass(frozen=True)
13
- class LaggedGraphBundle:
14
- graphs: np.ndarray
15
- summary_graph: np.ndarray
16
- priors: np.ndarray
17
- input_steps: np.ndarray
18
-
19
-
20
- def broadcast_prior_to_lags(G_prior: np.ndarray | None, num_lags: int) -> np.ndarray | None:
21
- if G_prior is None:
22
- return None
23
- G_prior = np.asarray(G_prior, dtype=np.float32)
24
- if G_prior.ndim == 2:
25
- return np.repeat(G_prior[None, :, :], num_lags, axis=0)
26
- if G_prior.ndim == 3 and G_prior.shape[0] == num_lags:
27
- return G_prior
28
- raise ValueError("G_prior must have shape (N, N) or (L, N, N)")
29
-
30
-
31
- def aggregate_lagged_graphs(graphs: np.ndarray, reducer: str = "max") -> np.ndarray:
32
- if reducer == "max":
33
- return np.max(graphs, axis=0)
34
- if reducer == "mean":
35
- return np.mean(graphs, axis=0)
36
- if reducer == "last":
37
- return graphs[-1]
38
- raise ValueError(f"Unsupported lagged graph reducer: {reducer}")
39
-
40
-
41
- def discover_lagged_graphs(
42
- data: np.ndarray,
43
- mask: np.ndarray,
44
- opt: Any,
45
- log: Any,
46
- device: str = "cpu",
47
- text_data: np.ndarray | None = None,
48
- text_mask: np.ndarray | None = None,
49
- G_prior: np.ndarray | None = None,
50
- num_lags: int = 1,
51
- reducer: str = "max",
52
- ) -> LaggedGraphBundle:
53
- if num_lags < 1:
54
- raise ValueError("num_lags must be >= 1")
55
-
56
- lag_priors = broadcast_prior_to_lags(G_prior, num_lags)
57
- graphs = []
58
- input_steps = []
59
- previous_graph = None
60
-
61
- for lag_idx in range(num_lags):
62
- cfg = deepcopy(opt)
63
- cfg.input_step = max(int(opt.input_step), lag_idx + 1)
64
- input_steps.append(cfg.input_step)
65
-
66
- current_prior = None
67
- if lag_priors is not None:
68
- current_prior = lag_priors[lag_idx]
69
- elif previous_graph is not None:
70
- current_prior = previous_graph
71
-
72
- graph = cuts_plus_main(
73
- data=data,
74
- mask=mask,
75
- true_cm=None,
76
- opt=cfg,
77
- log=log,
78
- device=device,
79
- text_data=text_data,
80
- text_mask=text_mask,
81
- G_prior=current_prior,
82
- )
83
- previous_graph = graph
84
- graphs.append(np.asarray(graph, dtype=np.float32))
85
-
86
- stacked = np.stack(graphs, axis=0)
87
- summary = aggregate_lagged_graphs(stacked, reducer=reducer)
88
- if lag_priors is None:
89
- lag_priors = np.zeros_like(stacked)
90
- return LaggedGraphBundle(
91
- graphs=stacked,
92
- summary_graph=summary.astype(np.float32),
93
- priors=lag_priors.astype(np.float32),
94
- input_steps=np.asarray(input_steps, dtype=np.int32),
95
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/model/cuts_plus_net.py DELETED
@@ -1,153 +0,0 @@
1
- import torch
2
- from einops import rearrange
3
- from torch import nn
4
-
5
- class GRUCell(nn.Module):
6
-
7
- def __init__(self, d_in, num_units, n_nodes, concat_h=False, activation='tanh'):
8
- super(GRUCell, self).__init__()
9
- self.activation_fn = getattr(torch, activation)
10
-
11
- mpnn_channel = d_in*n_nodes+num_units if concat_h else d_in*n_nodes
12
- self.forget_gate = MPNN(c_in=mpnn_channel, c_out=num_units, concat_h=concat_h)
13
- self.update_gate = MPNN(c_in=mpnn_channel, c_out=num_units, concat_h=concat_h)
14
- self.c_gate = MPNN(c_in=mpnn_channel, c_out=num_units, concat_h=concat_h)
15
-
16
- def forward(self, x, h, adj):
17
- """
18
- :param x: (B, input_dim, num_nodes)
19
- :param h: (B, num_units, num_nodes)
20
- :param adj: (num_nodes, num_nodes)
21
- :return:
22
- """
23
- # we start with bias 1.0 to not reset and not update
24
- r = torch.sigmoid(self.forget_gate(x, h, adj))
25
- u = torch.sigmoid(self.update_gate(x, h, adj))
26
- c = self.c_gate(x, r * h, adj) # batch_size, self._num_nodes * output_size
27
- c = self.activation_fn(c)
28
- return u * h + (1. - u) * c
29
-
30
-
31
- class MPNN(nn.Module):
32
- def __init__(self, c_in, c_out, concat_h=True):
33
- super(MPNN, self).__init__()
34
- self.concat_h = concat_h
35
- self.mlp = nn.Conv1d(c_in, c_out, kernel_size=1)
36
-
37
- def forward(self, x, h, graph):
38
- b, c, n = x.shape
39
-
40
- x_repeat = x[:, :, :, None].expand(-1, -1, -1, n) # [b, c, n, n]
41
- # graph = rearrange(graph, 'b n m -> b m n')
42
- x_messages = torch.einsum('bcmn,bmn->bcmn', (x_repeat, graph))
43
- x_messages = rearrange(x_messages, 'b c m n -> b (c m) n')
44
-
45
- if self.concat_h:
46
- out = self.mlp(torch.cat([x_messages, h], dim=1))
47
- else:
48
- out = self.mlp(x_messages)
49
- return out
50
-
51
-
52
- class LocalConv1D(nn.Module):
53
- def __init__(self, in_channels, out_channels, kernel_size, n_nodes):
54
- super(LocalConv1D, self).__init__()
55
- self.out_channel = out_channels
56
- self.conv_list = nn.ModuleList([
57
- nn.Conv1d(in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size) for _ in range(n_nodes)
58
- ])
59
-
60
- def forward(self, x): # x: [batch, features, nodes]
61
- b, h, n = x.shape
62
- out = torch.zeros((b, self.out_channel, n)).to(x.device)
63
- for i in range(n):
64
- x_local_in = x[..., i].unsqueeze(-1)
65
- x_local_out = self.conv_list[i](x_local_in)
66
- out[..., i] = x_local_out.squeeze(-1)
67
- return out
68
-
69
-
70
- class CUTS_Plus_Net(nn.Module):
71
- def __init__(self, n_nodes,
72
- in_ch=1,
73
- hidden_ch=32,
74
- n_layers=1,
75
- shared_weights_decoder=False,
76
- concat_h=False,):
77
- super().__init__()
78
- self.in_ch = in_ch
79
- self.hidden_ch = hidden_ch
80
- self.n_layers = n_layers
81
-
82
- self.conv_encoder1 = nn.Conv1d(in_channels=hidden_ch, out_channels=hidden_ch, kernel_size=1)
83
- self.conv_encoder2 = nn.Conv1d(in_channels=2*hidden_ch, out_channels=hidden_ch, kernel_size=1)
84
- if shared_weights_decoder:
85
- self.decoder = nn.Sequential(
86
- nn.Conv1d(in_channels=2*hidden_ch, out_channels=in_ch, kernel_size=1),
87
- # nn.LeakyReLU(),
88
- # nn.Conv1d(in_channels=hidden_ch, out_channels=hidden_ch, kernel_size=1),
89
- # nn.LeakyReLU(),
90
- # nn.Conv1d(in_channels=hidden_ch, out_channels=in_ch, kernel_size=1),
91
- # nn.LeakyReLU(),
92
- )
93
- else:
94
- self.decoder = nn.Sequential(
95
- LocalConv1D(in_channels=2*hidden_ch, out_channels=in_ch, kernel_size=1, n_nodes=n_nodes),
96
- # nn.LeakyReLU(),
97
- # LocalConv1D(in_channels=hidden_ch, out_channels=hidden_ch, kernel_size=1, n_nodes=n_nodes),
98
- # nn.LeakyReLU(),
99
- # LocalConv1D(in_channels=hidden_ch, out_channels=in_ch, kernel_size=1, n_nodes=n_nodes),
100
- # nn.LeakyReLU(),
101
- )
102
- # self.act = nn.PReLU()
103
- self.act = nn.LeakyReLU()
104
-
105
- self.cells = nn.ModuleList()
106
- for i in range(self.n_layers):
107
- self.cells.append(GRUCell(d_in=in_ch if i==0 else hidden_ch,
108
- num_units=hidden_ch,
109
- n_nodes=n_nodes,
110
- concat_h=concat_h))
111
-
112
- self.h0 = self.init_state(n_nodes)
113
-
114
- def init_state(self, n_nodes):
115
- h = []
116
- for layer in range(self.n_layers):
117
- h.append(nn.parameter.Parameter(torch.zeros([self.hidden_ch, n_nodes])))
118
- return nn.ParameterList(h)
119
-
120
- def update_state(self, x, h, graph):
121
- rnn_in = x
122
- for layer in range(self.n_layers):
123
- rnn_in = h[layer] = self.cells[layer](rnn_in, h[layer], graph)
124
- return h
125
-
126
- def forward(self, x, mask, fwd_graph):
127
- x = rearrange(x, 'b n s c -> b c n s')
128
- # fwd_graph = torch.ones_like(fwd_graph)
129
- # mask = torch.ones_like(x).byte()
130
- bs, in_ch, n_nodes, steps = x.shape
131
-
132
- h = [h_.expand(bs, -1, -1) for h_ in self.h0.to(x.device)]
133
-
134
- pred = []
135
- for step in range(steps):
136
- x_now = x[..., step] # [batches, in_ch, nodes]
137
-
138
- """Update state"""
139
- h = self.update_state(x_now, h, fwd_graph)
140
- h_now = h[-1]
141
-
142
- """Prediction"""
143
- x_repr = self.act(self.conv_encoder1(h_now)) # [batches, hidden_ch, nodes]
144
- x_repr = self.act(self.conv_encoder2(torch.cat([x_repr, h_now], dim=1))) # [batches, hidden_ch, nodes]
145
- x_repr = torch.cat([x_repr, h_now], dim=1) # [batches, 2*hidden_ch, nodes]
146
- x_hat2 = self.decoder(x_repr) # [batches, in_ch, nodes]
147
- pred.append(x_hat2)
148
-
149
-
150
- pred = torch.stack(pred, dim=-1)
151
- pred = rearrange(pred, 'b c n s -> b n s c')
152
- return pred[:, :, -1:]
153
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/utils/causal_plot.py DELETED
@@ -1,57 +0,0 @@
1
- from re import X
2
- import matplotlib
3
- matplotlib.use('Agg')
4
- import matplotlib.pyplot as plt
5
- import tqdm
6
- import numpy as np
7
- import matplotlib.cm as cm
8
-
9
- def save_causal_graph(save_path, causal_matrix: np.ndarray, thres_percentile=100, colormap="gnuplot2"):
10
- causal_matrix = np.max(causal_matrix)
11
- print(causal_matrix.shape)
12
-
13
- n_node = causal_matrix.shape[0]
14
- image_size = [100, 100]
15
- n_node_dim = n_node**0.5
16
- causal_thres = np.percentile(causal_matrix, 100-thres_percentile)
17
-
18
- colormap = cm.get_cmap(colormap)
19
-
20
- plt.figure(figsize=[10,10], facecolor='black', edgecolor='black')
21
-
22
-
23
- for node_i_from in tqdm.tqdm(range(n_node)):
24
- x_from = image_size[0] // n_node_dim * (node_i_from // n_node_dim + 0.5)
25
- y_from = image_size[0] // n_node_dim * (node_i_from % n_node_dim + 0.5)
26
- plt.text(y=x_from, x=y_from, s=f"{node_i_from:d}", size=20, color="#ffffff")
27
- for node_i_to in range(n_node):
28
- if not node_i_from == node_i_to:
29
- x_to = image_size[0] // n_node_dim * (node_i_to // n_node_dim + 0.5)
30
- y_to = image_size[0] // n_node_dim * (node_i_to % n_node_dim + 0.5)
31
- causal_effect = causal_matrix[node_i_from, node_i_to]
32
- if causal_effect > causal_thres:
33
- width = max(0.01, 1*causal_effect)
34
- arrow_length = ((x_to-x_from)**2 + (y_to-y_from)**2)**0.5
35
- plt.arrow(
36
- y=x_from+(x_to-x_from)*width/arrow_length,
37
- x=y_from+(y_to-y_from)*width/arrow_length,
38
- dy=(x_to-x_from)*(arrow_length-5*width)/arrow_length,
39
- dx=(y_to-y_from)*(arrow_length-5*width)/arrow_length,
40
- width=width,
41
- head_length=4*width,
42
- facecolor=colormap(causal_effect)[:3]+(causal_effect,),
43
- edgecolor="#00000000"
44
- )
45
-
46
- # fig.add_annotation(text=f"{node_i_from:d}", x=x_from, y=y_from, showarrow=False
47
-
48
- ax=plt.gca()
49
- ax.patch.set_facecolor("black")
50
- ax.xaxis.set_ticks_position('top')
51
- ax.invert_yaxis()
52
- plt.savefig(save_path)
53
-
54
-
55
- if __name__=="__main__":
56
- causal_matrix = np.load("outputs/tsgae_2022_0716_191203_262072/w.npy")
57
- save_causal_graph("outputs/pic/causal.png", causal_matrix, thres_percentile=100)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/utils/exp_utils.py DELETED
@@ -1,286 +0,0 @@
1
- import os
2
- from os.path import join as opj
3
- from os.path import dirname as opd
4
-
5
- from .opt_type import MultiCADopt
6
- from .misc import omegaconf2dict
7
-
8
- import re
9
- import numpy as np
10
- import matplotlib
11
- matplotlib.use('Agg')
12
- import matplotlib.pyplot as plt
13
-
14
- from datetime import datetime
15
- from omegaconf import OmegaConf
16
- import glob
17
- import tqdm
18
- from tensorboard.backend.event_processing import event_accumulator
19
-
20
- PROPER_NAME = {"NFGR":"BRIEF", "h265":"H.265", "h264":"H.264", "jpg":"JPEG", "aoi-2000":"AoI", "vvc":"H.266"}
21
-
22
- MAIN_COLOR = {"NFGR":"#e64b35", "h265":"#48c9b0", "h264":"#5599c7", "jpg":"#c39bd2", "DVC":"#e6b0aa", "SGA":"#f8c370", "vvc":"#b1babb",
23
- "GOOD":"#239954", "BAD":"#cc6155"}
24
- SECONDARY_COLOR = {"NFGR":"#d98880", "h265":"#76d7c3", "SGA":"#f9d7a0", "h264":"#7fb3d5", "DVC":"#e6b0aa"}
25
-
26
-
27
- def get_time_stamp():
28
- return str(datetime.now().strftime("%m-%d-%H%M%S-%f"))
29
-
30
- def show_single_scores(x_arr, y_arr, label="exp", suffix="",
31
- scatter=False, log_axis=True,
32
- xlim=None, ylim=None):
33
-
34
- '''Plotting figures'''
35
- cvs = FigureCanvas(name=label, figsize=[30,20])
36
- fig_idx = 0
37
- for idx,data_path in enumerate(y_arr.labels[2]): # range(score_arr.shape[2]):
38
- if fig_idx == 40:
39
- break
40
- elif np.isnan(np.nanmean(y_arr[:,:,data_path])):
41
- continue
42
-
43
- fig_idx += 1
44
- if log_axis:
45
- ax = plt.subplot(8,5,fig_idx, xscale="log")
46
- else:
47
- ax = plt.subplot(8,5,fig_idx)
48
- ax.set_title("DATA_{:02d}_".format(idx) + data_path[-100:-60] + "\n" + data_path[-60:])
49
- # ax.set_title(dim_marks[2][data_i])
50
- plt.set_cmap("rainbow")
51
- for i, dim0 in enumerate(y_arr.labels[0]):
52
- x_nan = x_arr[dim0,:,data_path]
53
- y_nan = y_arr[dim0,:,data_path]
54
- x = x_nan[np.isfinite(x_nan + y_nan)]
55
- y = y_nan[np.isfinite(x_nan + y_nan)]
56
-
57
- if len(x) > 0 and len(y) > 0:
58
- x, y = sort_lists(x, y)
59
-
60
- plt.plot(x, y, color=plt.get_cmap("tab20")(i), label=dim0)
61
- plt.scatter(x, y, color=plt.get_cmap("tab20")(i))
62
-
63
- plt.legend()
64
- if ylim is not None:
65
- plt.ylim(ylim)
66
-
67
- ax=plt.gca()
68
- ax.xaxis.set_major_locator(plt.LogLocator(base=10, numticks=5))
69
- ax.yaxis.set_major_locator(plt.MaxNLocator(5))
70
-
71
- cvs.save_fig(suffix=suffix, time_stamp=False, save_format=".pdf")
72
-
73
-
74
- def sort_lists(*lists):
75
- sorted_index = np.argsort(lists[0]).astype(int)
76
- results = []
77
- for l in lists:
78
- sorted_list = np.array([l[i] for i in sorted_index])
79
- results.append(sorted_list)
80
- return results
81
-
82
-
83
- def show_averge_scores(x_arr, y_arr, label="exp", suffix="", percentile=25,
84
- scatter=False, log_axis=True, figsize=[4,3],
85
- xlim=None, ylim=None, legend=False, grid=False, std=False):
86
- cvs = FigureCanvas(name=label, figsize=figsize)
87
- if log_axis:
88
- plt.xscale("log")
89
-
90
- fig_idx = 0
91
- if scatter:
92
- for data_i,data_path in enumerate(y_arr.labels[2]):
93
- if np.isnan(np.nanmean(y_arr[:,:,data_path])):
94
- continue
95
-
96
- for i, dim0 in enumerate(y_arr.labels[0]):
97
- x_nan = x_arr[dim0,:,data_path]
98
- y_nan = y_arr[dim0,:,data_path]
99
- x = x_nan[np.isfinite(x_nan + y_nan)]
100
- y = y_nan[np.isfinite(x_nan + y_nan)]
101
-
102
- if len(x) > 0 and len(y) > 0:
103
- x, y = sort_lists(x, y)
104
-
105
- # plt.plot(x, y, color=plt.get_cmap("tab20")(i), label=dim0, marker="v")
106
- plt.scatter(x, y, color=MAIN_COLOR[dim0], alpha=0.5, marker="v", edgecolors='none',
107
- s=80 if "NFGR" in dim0 else 50)
108
-
109
- # plt.legend(edgecolors='none')
110
- if ylim is not None:
111
- plt.ylim(ylim)
112
-
113
- if ylim is not None:
114
- full_range = ylim[1] - ylim[0]
115
- else:
116
- full_range = np.nanmax(np.nanmean(y_arr.arr, axis=2)) - np.nanmin(np.nanmean(y_arr.arr, axis=2))
117
- max_std = np.nanmax(np.nanstd(y_arr["NFGR",:,:], axis=1))
118
-
119
-
120
- for idx, dim0 in enumerate(sorted(y_arr.labels[0], key=lambda item:item == "NFGR")):
121
- # if "jpg" in dim0:
122
- # continue
123
-
124
- x_nan = np.nanmean(x_arr[dim0,:], axis=1)
125
- y_nan = np.nanmean(y_arr[dim0,:], axis=1)
126
- # y_l = np.nanpercentile(y_arr[dim0,:], percentile, axis=1)
127
- # y_u = np.nanpercentile(y_arr[dim0,:], 100-percentile, axis=1)
128
- y_std = np.nanstd(y_arr[dim0,:], axis=1) # / max_std * full_range * 0.07
129
-
130
- print(1 / max_std * full_range * 0.07)
131
-
132
- x = x_nan[np.isfinite(x_nan + y_nan)]
133
- y = y_nan[np.isfinite(x_nan + y_nan)]
134
- # y_l = y_l[np.isfinite(x_nan + y_nan)]
135
- # y_u = y_u[np.isfinite(x_nan + y_nan)]
136
- y_std = y_std[np.isfinite(x_nan + y_nan)]
137
-
138
- x, y, y_std = sort_lists(x, y, y_std)
139
-
140
- if std:
141
- plt.fill_between(x, y-y_std/2, y+y_std/2, color=MAIN_COLOR[dim0], alpha=0.25, edgecolors="none")
142
- plt.plot(x, y, color=MAIN_COLOR[dim0], label=name(dim0),
143
- lw=2 if "NFGR" in dim0 else 1.5)
144
- plt.scatter(x, y, color=MAIN_COLOR[dim0],
145
- s=30 if "NFGR" in dim0 else 20)
146
-
147
- if ylim is not None:
148
- plt.ylim(ylim)
149
- if xlim is not None:
150
- plt.xlim(xlim)
151
- if legend:
152
- plt.legend(loc='lower left', bbox_to_anchor=(0.1, 0.1), fancybox=False)
153
-
154
- ax=plt.gca()
155
- if log_axis:
156
- ax.xaxis.set_major_locator(plt.LogLocator(base=10, numticks=5))
157
- else:
158
- ax.xaxis.set_major_locator(plt.MaxNLocator(5))
159
- ax.yaxis.set_major_locator(plt.MaxNLocator(5))
160
-
161
- if grid:
162
- ax.spines['right'].set_visible(True)
163
- ax.spines['top'].set_visible(True)
164
- # plt.xticks(np.arange(0.4, 1.8, 0.28))
165
- # plt.yticks(np.arange(50, 500, 40))
166
- plt.grid(axis='both', c="#cacaca", which="major")
167
-
168
- cvs.save_fig(suffix=suffix, time_stamp=False, save_format=".pdf")
169
-
170
-
171
- def name(alias):
172
- if alias in PROPER_NAME:
173
- return PROPER_NAME[alias]
174
- else:
175
- print("Cannot find proper name.")
176
- return alias
177
-
178
- class FigureCanvas(object):
179
-
180
- def __init__(self, name="ex1", figsize=[14,9]):
181
- self.name = name
182
- plt.close('all')
183
- fig = plt.figure(figsize=figsize)
184
- ax = plt.axes()
185
- ax.spines['right'].set_visible(False)
186
- ax.spines['top'].set_visible(False)
187
- plt.tight_layout()
188
-
189
- def show_fig(self, save_format=".png", suffix="", time_stamp=True):
190
- save_path = "./exp/figs/%s/%s_%s%s"%(
191
- self.name,
192
- get_time_stamp() if time_stamp else "plt",
193
- suffix, save_format)
194
- if not os.path.exists(opd(save_path)):
195
- os.makedirs(opd(save_path))
196
- plt.savefig(save_path, bbox_inches='tight')
197
- plt.show()
198
-
199
- def save_fig(self, save_format=".png", suffix="", time_stamp=True, save_root="./exp/figs/"):
200
- save_path = opj(save_root, "%s/%s_%s%s"%(
201
- self.name,
202
- get_time_stamp() if time_stamp else "plt",
203
- suffix, save_format))
204
- if not os.path.exists(opd(save_path)):
205
- os.makedirs(opd(save_path))
206
- plt.savefig(save_path, bbox_inches='tight')
207
-
208
-
209
- def find_lineprofile_cmp(im_list):
210
- for x in range(0, im_list[0].shape[0], 10):
211
- for y in range(0, im_list[0].shape[1], 10):
212
- lp = [im[x][y] for im in im_list]
213
- if np.max(lp) > 2500 and np.max(lp) < 3000:
214
- return lp
215
-
216
-
217
- def get_decompressed_path(opt_path):
218
- res_root = opd(opt_path)
219
- max_step = 0
220
- for dirn in os.listdir(res_root):
221
- if dirn == "decompressed":
222
- return glob.glob(res_root + "/decompressed/*.tif")[0]
223
- elif "steps" in dirn:
224
- step_n = int(dirn[5:])
225
- if step_n > max_step:
226
- max_step = step_n
227
- search_list = glob.glob(res_root + "/steps" + str(max_step) + "/decompressed/*.*")
228
- if len(search_list) > 0:
229
- return search_list[0]
230
- return None
231
-
232
- def load_scalars(event_path):
233
- try:
234
- event_path = glob.glob(event_path)[0]
235
- except:
236
- print("No event file found.")
237
- return None
238
- ea = event_accumulator.EventAccumulator(event_path)
239
- ea.Reload()
240
- # print("Available scalars: ", ea.scalars.Keys())
241
- scalars = {}
242
- for criterion in ea.scalars.Keys():
243
- val_scalar = ea.scalars.Items(criterion)
244
- val_curve = ([(i.step, i.value) for i in val_scalar])
245
- scalars[criterion] = val_curve
246
- return scalars
247
-
248
- def load_scalars_cached(root_path, cache_dir="exp/cache", reload_data=False):
249
-
250
- if root_path[-1] == "/":
251
- root_path = root_path[:-1]
252
-
253
- # csv_file = glob.glob(root_path + "/*.csv")[0]
254
- # exp_list = load_csv(csv_file)
255
- root_name = "".join(re.split("/|\\\\", root_path)[-2:])
256
- cache_path = opj(cache_dir, root_name + ".npy")
257
-
258
- # read from cached data
259
- if os.path.exists(cache_path) and not reload_data:
260
- print("Loading cached result...")
261
- loaded_res = np.load(cache_path, allow_pickle=True)
262
- else:
263
- if not os.path.exists(cache_dir):
264
- os.makedirs(cache_dir)
265
-
266
- loaded_res = []
267
- for dirn in tqdm.tqdm(os.listdir(root_path)):
268
- if os.path.isdir(opj(root_path, dirn)):
269
- if len(glob.glob(root_path + "/%s/events.out.tfevents*"%(dirn))) > 0:
270
- res_fname = glob.glob(root_path + "/%s/events.out.tfevents*"%(dirn))[0]
271
- opt_fname = glob.glob(root_path + "/%s/opt.yaml"%(dirn))[0]
272
- scores = load_scalars(res_fname)
273
- opt: MultiCADopt = omegaconf2dict(OmegaConf.load(opt_fname), sep=".")
274
- loaded_res.append((res_fname, scores, opt))
275
-
276
- np.save(cache_path, loaded_res)
277
-
278
- return loaded_res
279
-
280
-
281
- # if __name__=="__main__":
282
- # # scalars = load_scalars("cyx_exp/experiments_outputs/ex2_1227/*/exp_00000/events.out.tfevents*")
283
- # # print(scalars)
284
-
285
- # res = load_scalars_cached("cyx_exp/experiments_outputs/ex2_1227/", reload_data=False)
286
- # print("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/utils/gumbel_softmax.py DELETED
@@ -1,66 +0,0 @@
1
- import torch
2
- from torch import Tensor
3
- import warnings
4
-
5
- def gumbel_softmax(logits: Tensor, tau: float = 1, hard: bool = False, eps: float = 1e-10, dim: int = -1) -> Tensor:
6
- r"""
7
- Samples from the Gumbel-Softmax distribution (`Link 1`_ `Link 2`_) and optionally discretizes.
8
-
9
- Args:
10
- logits: `[..., num_features]` unnormalized log probabilities
11
- tau: non-negative scalar temperature
12
- hard: if ``True``, the returned samples will be discretized as one-hot vectors,
13
- but will be differentiated as if it is the soft sample in autograd
14
- dim (int): A dimension along which softmax will be computed. Default: -1.
15
-
16
- Returns:
17
- Sampled tensor of same shape as `logits` from the Gumbel-Softmax distribution.
18
- If ``hard=True``, the returned samples will be one-hot, otherwise they will
19
- be probability distributions that sum to 1 across `dim`.
20
-
21
- .. note::
22
- This function is here for legacy reasons, may be removed from nn.Functional in the future.
23
-
24
- .. note::
25
- The main trick for `hard` is to do `y_hard - y_soft.detach() + y_soft`
26
-
27
- It achieves two things:
28
- - makes the output value exactly one-hot
29
- (since we add then subtract y_soft value)
30
- - makes the gradient equal to y_soft gradient
31
- (since we strip all other gradients)
32
-
33
- Examples::
34
- >>> logits = torch.randn(20, 32)
35
- >>> # Sample soft categorical using reparametrization trick:
36
- >>> F.gumbel_softmax(logits, tau=1, hard=False)
37
- >>> # Sample hard categorical using "Straight-through" trick:
38
- >>> F.gumbel_softmax(logits, tau=1, hard=True)
39
-
40
- .. _Link 1:
41
- https://arxiv.org/abs/1611.00712
42
- .. _Link 2:
43
- https://arxiv.org/abs/1611.01144
44
- """
45
- if eps != 1e-10:
46
- warnings.warn("`eps` parameter is deprecated and has no effect.")
47
-
48
- gumbels = (
49
- -torch.empty_like(logits, memory_format=torch.legacy_contiguous_format).exponential_().log()
50
- ) # ~Gumbel(0,1)
51
- gumbels = (logits + gumbels) / tau # ~Gumbel(logits,tau)
52
- y_soft = gumbels.softmax(dim)
53
-
54
- if hard:
55
- # Straight through.
56
- index = y_soft.max(dim, keepdim=True)[1]
57
- y_hard = torch.zeros_like(logits, memory_format=torch.legacy_contiguous_format).scatter_(dim, index, 1.0)
58
- ret = y_hard - y_soft.detach() + y_soft
59
- else:
60
- # Reparametrization trick.
61
- ret = y_soft
62
- return ret
63
-
64
- if __name__=="__main__":
65
- a = torch.tensor([[2.0, 0.7]]*10)
66
- print(gumbel_softmax(a, tau=10))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/utils/logger.py DELETED
@@ -1,74 +0,0 @@
1
- import sys
2
- from omegaconf import OmegaConf
3
- import os
4
- from os.path import join as opj
5
- import numpy as np
6
- from os.path import dirname as opd
7
- from typing import Dict
8
- from torch.utils.tensorboard import SummaryWriter
9
- from .misc import omegaconf2list
10
-
11
-
12
- class MyLogger():
13
- def __init__(self, log_dir: str, stderr: bool = True, tensorboard: bool = True, stdout: bool = True):
14
- self.log_dir = log_dir
15
- if not os.path.exists(self.log_dir):
16
- os.makedirs(self.log_dir)
17
- self.logger_dict: Dict[str] = {}
18
- if stdout:
19
- stdout_handler = open(opj(self.log_dir, 'stdout.log'), 'w')
20
- sys.stdout = stdout_handler
21
- if stderr:
22
- stderr_handler = open(opj(self.log_dir, 'stderr.log'), 'w')
23
- sys.stderr = stderr_handler
24
- if tensorboard:
25
- self.tblogger = SummaryWriter(self.log_dir)
26
- self.logger_dict['tblogger'] = self.tblogger
27
-
28
- def log_opt(self, opt):
29
- OmegaConf.save(config=opt, f=opj(self.log_dir, 'opt.yaml'))
30
- opt_log = omegaconf2list(opt, sep='/')
31
- for logger_name in self.logger_dict.keys():
32
- if logger_name == 'tblogger':
33
- for idx, opt in enumerate(opt_log):
34
- self.logger_dict[logger_name].add_text('hparam', opt, idx)
35
-
36
- def log_metrics(self, metrics_dict: Dict[str, float], iters):
37
- for logger_name in self.logger_dict.keys():
38
- if logger_name == 'csvlogger':
39
- self.logger_dict[logger_name].log_metrics(metrics_dict, iters)
40
- self.logger_dict[logger_name].save()
41
- elif logger_name == 'clearml_logger':
42
- for k in metrics_dict.keys():
43
- self.logger_dict[logger_name].report_scalar(
44
- k, k, metrics_dict[k], iters)
45
- elif logger_name == 'tblogger':
46
- for k in metrics_dict.keys():
47
- self.logger_dict[logger_name].add_scalar(
48
- k, metrics_dict[k], iters)
49
-
50
- def log_figures(self, figure, name="figure.png", iters=None, exclude_logger=[]):
51
- for logger_name in self.logger_dict.keys():
52
- if logger_name == 'tblogger':
53
- if logger_name not in exclude_logger:
54
- self.logger_dict[logger_name].add_figure(tag=name, figure=figure, global_step=iters)
55
-
56
- if iters is None:
57
- save_path = opj(self.log_dir, "figures")
58
- else:
59
- save_path = opj(self.log_dir, f"iter_{iters:d}", name)
60
- os.makedirs(opd(save_path), exist_ok=True)
61
- figure.savefig(save_path)
62
-
63
- def log_npz(self, data: Dict, name="data.npz", iters=None):
64
- if iters is None:
65
- save_path = opj(self.log_dir)
66
- else:
67
- save_path = opj(self.log_dir, f"iter_{iters:d}", name)
68
- os.makedirs(save_path, exist_ok=True)
69
- np.savez(opj(save_path, "graph.npz"), **data)
70
-
71
- def close(self):
72
- for logger_name in self.logger_dict.keys():
73
- if logger_name == 'tblogger':
74
- self.logger_dict[logger_name].close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/utils/misc.py DELETED
@@ -1,292 +0,0 @@
1
- from copy import deepcopy
2
- import numpy as np
3
- import matplotlib
4
- matplotlib.use('Agg')
5
- import matplotlib.pyplot as plt
6
- import itertools
7
- import random
8
- import numpy as np
9
- import torch
10
- import omegaconf
11
- from sklearn.metrics import roc_curve, roc_auc_score
12
-
13
-
14
- def log_time_series(original_data, data_interp, data_pred, log, log_step):
15
- fig = plt.figure(figsize=[10,10])
16
- plt.plot(np.arange(0, original_data.shape[0], 1), original_data, label="original")
17
- plt.plot(np.arange(0, data_interp.shape[0], 1), data_interp, label="interp")
18
- plt.plot(np.arange(0, data_pred.shape[0], 1), data_pred, label="pred")
19
- plt.legend()
20
- log.log_figures(fig, name="Predicted Latent Data", iters=log_step)
21
-
22
-
23
- def calc_and_log_metrics(time_prob_mat, true_cm, log, log_step, threshold=0.5, plot_roc=True):
24
- if len(time_prob_mat.shape) == 3:
25
- graph = np.max(time_prob_mat, axis=2)
26
- else:
27
- graph = time_prob_mat
28
- causal_graph = graph > threshold
29
- tp = np.mean(causal_graph * true_cm)
30
- tn = np.mean((1-causal_graph) * (1-causal_graph))
31
- fp = np.mean(causal_graph * (1-true_cm))
32
- fn = np.mean((1-causal_graph) * true_cm)
33
- tpr = tp / (tp + fn)
34
- fpr = fp / (fp + tn)
35
- acc = (tp + tn) / (tp + tn + fp + fn)
36
- log.log_metrics({"metrics/tpr": tpr}, log_step)
37
- log.log_metrics({"metrics/fpr": fpr}, log_step)
38
- log.log_metrics({"metrics/accuracy": acc}, log_step)
39
-
40
- if plot_roc:
41
- fpr, tpr, thres = roc_curve(true_cm.reshape(-1) > 0.5,
42
- graph.reshape(-1), pos_label=1)
43
- fig = plt.figure(figsize=[4, 4])
44
- plt.plot(fpr, tpr)
45
- log.tblogger.add_figure(tag="ROC", figure=fig, global_step=log_step)
46
-
47
- log.log_npz(name="graph",
48
- data={"true_cm":true_cm, "pred_cm":graph},
49
- iters=log_step)
50
-
51
- auc = roc_auc_score(true_cm.reshape(-1)>0.5,
52
- graph.reshape(-1))
53
- log.log_metrics({"metrics/auc": auc}, log_step)
54
- return auc
55
-
56
- def sigmoid(z):
57
- return 1/(1 + np.exp(-z))
58
-
59
- def plot_causal_matrix_in_training(time_coef, name, log, log_step, threshold=0.5, plot_each_time=False):
60
- if time_coef is None:
61
- return
62
-
63
- if np.max(time_coef) - np.min(time_coef) > 0.01:
64
- time_coef = (time_coef - np.min(time_coef)) / (np.max(time_coef) - np.min(time_coef))
65
- n, m, t = time_coef.shape
66
-
67
- # # Show Discovered Graph (Coefficiency)
68
- # sub_cg = plot_causal_matrix(
69
- # np.max(time_coef, axis=2),
70
- # figsize=[1.5*time_coef.shape[0], 1*n])
71
- # log.log_figures(sub_cg, name="Discovered Graph Coef/" + name, iters=log_step)
72
-
73
- # # Graph for Each Time Lag
74
- # if plot_each_time:
75
- # for ti in range(t):
76
- # sub_cg = plot_causal_matrix(
77
- # time_coef[:, :, ti],
78
- # figsize=[1.5*n, 1*n],
79
- # vmin=0, vmax=1)
80
- # log.log_figures(sub_cg, name=f"Discovered Prob T-{t-ti:d}",
81
- # iters=log_step, exclude_logger="tblogger")
82
-
83
- # Show Discovered Graph (Probability)
84
- time_graph = time_coef
85
- sub_cg = plot_causal_matrix(
86
- np.max(time_graph, axis=2),
87
- figsize=[1.5*n, 1*n],
88
- vmin=0, vmax=1)
89
- log.log_figures(sub_cg, name="Discovered Prob/" + name, iters=log_step)
90
-
91
- # Show Thresholded Graph
92
- time_thres = np.max(time_graph, axis=2) > threshold
93
- sub_cg = plot_causal_matrix(
94
- time_thres,
95
- figsize=[1.5*n, 1*n])
96
- log.log_figures(sub_cg, name="Discovered Graph/" + name, iters=log_step)
97
- log.log_npz({"Discovered Graph Coef": time_coef, "Discovered Prob": time_graph, "Discovered Graph": time_thres},
98
- name="Graph.npz", iters=log_step)
99
-
100
-
101
- def plot_causal_matrix(cmtx, class_names=None, figsize=None, vmin=None, vmax=None, show_text=True, cmap="magma"):
102
- """
103
- A function to create a colored and labeled causal matrix matplotlib figure
104
- given true labels and preds.
105
- Args:
106
- cmtx (ndarray): causal matrix.
107
- num_classes (int): total number of nodes.
108
- class_names (Optional[list of strs]): a list of node names.
109
- figsize (Optional[float, float]): the figure size of the causal matrix.
110
- If None, default to [6.4, 4.8].
111
-
112
- Returns:
113
- img (figure): matplotlib figure.
114
- """
115
- num_classes = cmtx.shape[0]
116
- if class_names is None or type(class_names) != list:
117
- class_names = [str(i) for i in range(num_classes)]
118
-
119
-
120
- figsize[0] = 30 if figsize[0] > 30 else figsize[0]
121
- figsize[1] = 20 if figsize[1] > 20 else figsize[1]
122
-
123
- plt.clf()
124
- plt.close("all")
125
- figure = plt.figure(figsize=figsize)
126
- plt.imshow(cmtx, interpolation="nearest",
127
- cmap=cmap, vmin=vmin, vmax=vmax)
128
- plt.title("Causal matrix")
129
- plt.colorbar()
130
- # tick_marks = np.arange(len(class_names))
131
- # plt.xticks(tick_marks, class_names, rotation=45)
132
- # plt.yticks(tick_marks, class_names)
133
-
134
- # Use white text if squares are dark; otherwise black.
135
- threshold = cmtx.max() / 2.0
136
- for i, j in itertools.product(range(cmtx.shape[0]), range(cmtx.shape[1])):
137
- color = "white" if cmtx[i, j] < threshold else "black"
138
- if cmtx.shape[0] < 20 and show_text:
139
- plt.text(j, i, format(cmtx[i, j], ".2e") if cmtx[i, j] != 0 else ".",
140
- horizontalalignment="center", color=color,)
141
-
142
- plt.tight_layout()
143
- plt.ylabel("True label")
144
- plt.xlabel("Predicted label")
145
-
146
- return figure
147
-
148
-
149
- def reproduc(seed, benchmark=False, deterministic=True):
150
- """Make experiments reproducible
151
- """
152
- random.seed(seed)
153
- np.random.seed(seed)
154
- torch.manual_seed(seed)
155
- torch.cuda.manual_seed_all(seed)
156
- torch.backends.cudnn.benchmark = benchmark
157
- torch.backends.cudnn.deterministic = deterministic
158
-
159
-
160
- def omegaconf2list(opt, prefix='', sep='.'):
161
- notation_list = []
162
- for k, v in opt.items():
163
- k = str(k)
164
- if isinstance(v, omegaconf.listconfig.ListConfig):
165
- notation_list.append("{}{}={}".format(prefix, k, v))
166
- # if k in ['iter_list','step_list']: # do not sparse list
167
- # dot_notation_list.append("{}{}={}".format(prefix,k,v))
168
- # else:
169
- # templist = []
170
- # for v_ in v:
171
- # templist.append('{}{}={}'.format(prefix,k,v_))
172
- # dot_notation_list.append(templist)
173
- elif isinstance(v, (float, str, int,)):
174
- notation_list.append("{}{}={}".format(prefix, k, v))
175
- elif v is None:
176
- notation_list.append("{}{}=~".format(prefix, k,))
177
- elif isinstance(v, omegaconf.dictconfig.DictConfig):
178
- nested_flat_list = omegaconf2list(v, prefix + k + sep, sep=sep)
179
- if nested_flat_list:
180
- notation_list.extend(nested_flat_list)
181
- else:
182
- raise NotImplementedError
183
- return notation_list
184
-
185
-
186
- def omegaconf2dotlist(opt, prefix='',):
187
- return omegaconf2list(opt, prefix, sep='.')
188
-
189
-
190
- def omegaconf2dict(opt, sep):
191
- notation_list = omegaconf2list(opt, sep=sep)
192
- dict = {notation.split('=', maxsplit=1)[0]: notation.split(
193
- '=', maxsplit=1)[1] for notation in notation_list}
194
- return dict
195
-
196
-
197
- # def read_video(video_path: str):
198
- # if ops(video_path)[-1] == ".tif":
199
- # data = tifffile.imread(video_path)
200
- # data = (data / np.max(data) * 255).astype(np.uint8)
201
- # if len(data.shape) == 3:
202
- # data = data[:, :, :, None]
203
- # return data
204
- # else:
205
- # cap = cv2.VideoCapture(video_path)
206
- # frames = []
207
- # while cap.isOpened():
208
- # # get a frame
209
- # ret, frame = cap.read()
210
- # if not ret:
211
- # break
212
- # frames.append(np.array(frame)[None])
213
-
214
- # cap.release()
215
- # return np.concatenate(frames, axis=0)
216
-
217
-
218
- # def save_video(video_path: str, data):
219
- # skvideo.io.vwrite(video_path, data)
220
-
221
-
222
-
223
- class LabelArray(object):
224
- # def __init__(self, array, labels):
225
- # self.arr = array
226
- # self.labels = labels
227
- # self.marks = dim_marks
228
- # assert [len[label_list] for label_list in labels] == self.arr.shape
229
-
230
- def __init__(self, dim, labels=None):
231
- if labels is not None:
232
- if len(dim) != dim:
233
- raise "The length of labels has to be equal to dim if defined"
234
- else:
235
- self.labels = deepcopy(labels)
236
- else:
237
- self.labels = [[] for _ in range(dim)]
238
- self.arr = None
239
- self.update_arr()
240
-
241
- def update_arr(self):
242
- if self.arr is not None:
243
- oldarr = self.arr
244
- self.arr = np.zeros([len(dim) for dim in self.labels]) * np.nan
245
- self.arr[tuple([slice(0,sh_dim,1) for sh_dim in oldarr.shape])] = oldarr
246
- else:
247
- self.arr = np.zeros([len(dim) for dim in self.labels]) * np.nan
248
- self.shape = self.arr.shape
249
-
250
-
251
- def __getitem__(self, label_list):
252
- index_list = []
253
- for dim,label in enumerate(label_list):
254
- if isinstance(label, str):
255
- index_list.append(self.labels[dim].index(label))
256
- elif isinstance(label, slice):
257
- index_list.append(label)
258
- elif isinstance(label, int):
259
- index_list.append(label)
260
- else:
261
- raise NotImplementedError
262
-
263
- return self.arr[tuple(index_list)]
264
-
265
- def __setitem__(self, label_list, val):
266
- index_list = []
267
- for dim,label in enumerate(label_list):
268
- if isinstance(label, str):
269
- if not label in self.labels[dim]:
270
- self.labels[dim].append(label)
271
- self.update_arr()
272
- index_list.append(self.labels[dim].index(label))
273
- elif isinstance(label, slice):
274
- index_list.append(label)
275
- elif isinstance(label, int):
276
- index_list.append(label)
277
- else:
278
- raise NotImplementedError
279
-
280
- self.arr[tuple(index_list)] = val
281
-
282
- def __str__(self):
283
- return str(self.arr) + "\n--------------------------\n" + str(self.labels)
284
-
285
-
286
- def to_np(self):
287
- return self.arr
288
-
289
- def from_np(self, array):
290
- assert [len[label_list] for label_list in self.labels] == self.arr.shape
291
- self.arr = array
292
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/CUTS_PLUS/utils/opt_type.py DELETED
@@ -1,83 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import Any
3
-
4
- @dataclass
5
- class ReproducOpt:
6
- seed: int
7
- benchmark: bool
8
- deterministic: bool
9
-
10
- @dataclass
11
- class NetworkOpt:
12
- name: str
13
- network_param: Any
14
-
15
- @dataclass
16
- class TrainOpt:
17
- batch_size: int
18
- total_epoch: int
19
- time_window: int
20
-
21
-
22
- @dataclass
23
- class TsGAEopt:
24
- dir_name: str
25
- task_name: str
26
- optimizer: Any
27
- reproduc: ReproducOpt
28
- network: NetworkOpt
29
- train: TrainOpt
30
- log: Any
31
- causal_thres: str
32
-
33
- @dataclass
34
- class MultiCADopt:
35
- dir_name: str
36
- task_name: str
37
-
38
- @dataclass
39
- class MultiCADargs:
40
- n_nodes: int
41
- input_step: int
42
- window_step: int
43
- stride: int
44
- batch_size: int
45
- sample_per_epoch: int
46
- data_dim: int
47
- total_epoch: int
48
-
49
- patience: int
50
- warmup: Any
51
-
52
- show_graph_every: int
53
- val_every: int
54
-
55
- n_groups: int
56
- group_policy: Any
57
- causal_thres: str
58
-
59
- @dataclass
60
- class data_pred:
61
- model: str
62
- merge_policy: str
63
- lr_data_start: float
64
- lr_data_end: float
65
- weight_decay: int
66
- prob: bool
67
-
68
- @dataclass
69
- class graph_discov:
70
- lr_graph_start: float
71
- lr_graph_end: float
72
- lambda_s_start: float
73
- lambda_s_end: float
74
- tau_start: float
75
- tau_end: float
76
- disable_bwd: bool
77
- separate_bwd: bool
78
- disable_ind: bool
79
- disable_graph: bool
80
- use_true_graph: bool
81
-
82
- reproduc: ReproducOpt
83
- log: Any
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/algorithms/__init__.py DELETED
@@ -1,2 +0,0 @@
1
- # CAMEF algorithms subpackage — GPT4MTS and dataloader have been retired.
2
- # This package is intentionally empty pending removal of the CAMEF directory.
 
 
 
singular_ticker_causal/causal_inference/__init__.py DELETED
@@ -1,15 +0,0 @@
1
- from .causal_model import StructuralCausalModel
2
- from .identification import IdentificationStrategy, find_adjustment_set, is_identifiable
3
- from .pywhyllm_assumptions import CausalAssumptionReport, PyWhyLLMConfig, PyWhyLLMAssumptionService
4
- from .query_engine import CausalQueryEngine
5
-
6
- __all__ = [
7
- "CausalAssumptionReport",
8
- "CausalQueryEngine",
9
- "IdentificationStrategy",
10
- "PyWhyLLMConfig",
11
- "PyWhyLLMAssumptionService",
12
- "StructuralCausalModel",
13
- "find_adjustment_set",
14
- "is_identifiable",
15
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/causal_inference/abduction.py DELETED
@@ -1,127 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Dict
4
-
5
- import numpy as np
6
-
7
- from .causal_model import StructuralCausalModel
8
-
9
-
10
- def abduct_noise(scm: StructuralCausalModel, observed_values: Dict[str, float]) -> Dict[str, float]:
11
- noise: Dict[str, float] = {}
12
- for node_idx in scm.topological_indices:
13
- node = scm.nodes[node_idx]
14
- if node not in observed_values:
15
- continue
16
- obs = float(observed_values[node])
17
- eq = scm.equations[node]
18
- if not eq.parents or eq.equation_type == "exogenous":
19
- noise[node] = obs - eq.intercept
20
- continue
21
- parent_vals = []
22
- for p in eq.parents:
23
- if p not in observed_values:
24
- parent_vals = []
25
- break
26
- parent_vals.append(float(observed_values[p]))
27
- if not parent_vals:
28
- continue
29
- coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
30
- pred = eq.intercept + float(np.array(parent_vals, dtype=float) @ coef_vec)
31
- noise[node] = obs - pred
32
- return noise
33
-
34
-
35
- def counterfactual_predict(
36
- scm: StructuralCausalModel,
37
- observed_values: Dict[str, float],
38
- treatment: str,
39
- counterfactual_value: float,
40
- target: str,
41
- ) -> Dict[str, float]:
42
- if treatment not in scm.node_to_idx:
43
- raise ValueError(f"Unknown treatment node: {treatment}")
44
- if target not in scm.node_to_idx:
45
- raise ValueError(f"Unknown target node: {target}")
46
-
47
- noises = abduct_noise(scm, observed_values)
48
- state = scm.data_level[-1].copy()
49
- for node, value in observed_values.items():
50
- if node in scm.node_to_idx:
51
- state[scm.node_to_idx[node]] = float(value)
52
-
53
- cf = state.copy()
54
- t_idx = scm.node_to_idx[treatment]
55
- cf[t_idx] = float(counterfactual_value)
56
-
57
- for node_idx in scm.topological_indices:
58
- node = scm.nodes[node_idx]
59
- if node_idx == t_idx:
60
- continue
61
- eq = scm.equations[node]
62
- if not eq.parents or eq.equation_type == "exogenous":
63
- cf[node_idx] = eq.intercept + noises.get(node, 0.0)
64
- continue
65
- parent_vals = cf[eq.parent_indices]
66
- coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
67
- pred = eq.intercept + float(parent_vals @ coef_vec)
68
- cf[node_idx] = pred + noises.get(node, 0.0)
69
-
70
- y_idx = scm.node_to_idx[target]
71
- factual = float(state[y_idx])
72
- counterfactual = float(cf[y_idx])
73
- ite = counterfactual - factual
74
- pct_change = ite / (abs(factual) + 1e-12)
75
- return {
76
- "factual_outcome": factual,
77
- "counterfactual_outcome": counterfactual,
78
- "ite": float(ite),
79
- "pct_change": float(pct_change),
80
- }
81
-
82
-
83
- def counterfactual_predict_multi(
84
- scm: StructuralCausalModel,
85
- observed_values: Dict[str, float],
86
- counterfactual_values: Dict[str, float],
87
- target: str,
88
- ) -> Dict[str, float]:
89
- """Compute a joint counterfactual for multiple simultaneous interventions."""
90
- for node in counterfactual_values:
91
- if node not in scm.node_to_idx:
92
- raise ValueError(f"Unknown counterfactual node: {node}")
93
-
94
- noises = abduct_noise(scm, observed_values)
95
- state = scm.data_level[-1].copy()
96
- for node, value in observed_values.items():
97
- if node in scm.node_to_idx:
98
- state[scm.node_to_idx[node]] = float(value)
99
-
100
- cf = state.copy()
101
- for node, value in counterfactual_values.items():
102
- cf[scm.node_to_idx[node]] = float(value)
103
-
104
- for node_idx in scm.topological_indices:
105
- node = scm.nodes[node_idx]
106
- if node in counterfactual_values:
107
- continue
108
- eq = scm.equations[node]
109
- if not eq.parents or eq.equation_type == "exogenous":
110
- cf[node_idx] = eq.intercept + noises.get(node, 0.0)
111
- continue
112
- parent_vals = cf[eq.parent_indices]
113
- coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
114
- pred = eq.intercept + float(parent_vals @ coef_vec)
115
- cf[node_idx] = pred + noises.get(node, 0.0)
116
-
117
- y_idx = scm.node_to_idx[target]
118
- factual = float(state[y_idx])
119
- counterfactual = float(cf[y_idx])
120
- ite = counterfactual - factual
121
- pct_change = ite / (abs(factual) + 1e-12)
122
- return {
123
- "factual_outcome": factual,
124
- "counterfactual_outcome": counterfactual,
125
- "ite": float(ite),
126
- "pct_change": float(pct_change),
127
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/causal_inference/causal_model.py DELETED
@@ -1,314 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass, field
4
- from typing import Dict, List, Optional, Sequence, Tuple
5
-
6
- import numpy as np
7
-
8
-
9
- @dataclass
10
- class StructuralEquation:
11
- node: str
12
- parents: List[str]
13
- parent_indices: List[int]
14
- intercept: float
15
- coefficients: Dict[str, float]
16
- residual_mean: float
17
- residual_std: float
18
- r_squared: float
19
- n_obs: int
20
- equation_type: str # "linear" | "exogenous"
21
-
22
-
23
- @dataclass
24
- class StructuralCausalModel:
25
- nodes: Sequence[str]
26
- adj: np.ndarray
27
- adjacency_mask: np.ndarray
28
- data_tech: np.ndarray
29
- mask_tech: Optional[np.ndarray] = None
30
- prohibition_mask: Optional[np.ndarray] = None
31
- threshold: float = 0.5
32
- lag: int = 1
33
- min_obs: int = 12
34
- ridge_alpha: float = 1e-4
35
- dag_adj: np.ndarray = field(init=False)
36
- topological_indices: List[int] = field(init=False, default_factory=list)
37
- equations: Dict[str, StructuralEquation] = field(init=False, default_factory=dict)
38
- removed_cycle_edges: List[Dict[str, float]] = field(init=False, default_factory=list)
39
-
40
- def __post_init__(self) -> None:
41
- self.nodes = list(self.nodes)
42
- self.node_to_idx = {node: i for i, node in enumerate(self.nodes)}
43
- self.data_level = self._extract_level_data(self.data_tech)
44
- self.mask_level = self._extract_mask_level(self.mask_tech)
45
- self._validate_shapes()
46
-
47
- @property
48
- def n_nodes(self) -> int:
49
- return len(self.nodes)
50
-
51
- @property
52
- def t_steps(self) -> int:
53
- return self.data_level.shape[0]
54
-
55
- def fit(self) -> "StructuralCausalModel":
56
- self.dag_adj = self._build_dag()
57
- self._enforce_acyclic()
58
- self.topological_indices = self._topological_sort(self.dag_adj)
59
- self._fit_equations()
60
- return self
61
-
62
- def density(self) -> float:
63
- n = self.n_nodes
64
- max_edges = n * (n - 1)
65
- if max_edges == 0:
66
- return 0.0
67
- return float(np.sum(self.dag_adj) / max_edges)
68
-
69
- def parents_of(self, node: str) -> List[str]:
70
- j = self.node_to_idx[node]
71
- return [self.nodes[i] for i in np.where(self.dag_adj[:, j])[0]]
72
-
73
- def has_directed_path(self, source: str, target: str) -> bool:
74
- s = self.node_to_idx[source]
75
- t = self.node_to_idx[target]
76
- stack = [s]
77
- visited = set()
78
- while stack:
79
- u = stack.pop()
80
- if u == t:
81
- return True
82
- if u in visited:
83
- continue
84
- visited.add(u)
85
- children = np.where(self.dag_adj[u])[0].tolist()
86
- stack.extend(children)
87
- return False
88
-
89
- def descendants_of(self, node: str) -> List[str]:
90
- start = self.node_to_idx[node]
91
- stack = [start]
92
- visited = set()
93
- while stack:
94
- u = stack.pop()
95
- children = np.where(self.dag_adj[u])[0].tolist()
96
- for v in children:
97
- if v not in visited:
98
- visited.add(v)
99
- stack.append(v)
100
- visited.discard(start)
101
- return [self.nodes[i] for i in sorted(visited)]
102
-
103
- def _validate_shapes(self) -> None:
104
- n = len(self.nodes)
105
- if self.adj.shape != (n, n):
106
- raise ValueError(f"adj shape mismatch: expected {(n, n)}, got {self.adj.shape}")
107
- if self.adjacency_mask.shape != (n, n):
108
- raise ValueError(
109
- f"adjacency_mask shape mismatch: expected {(n, n)}, got {self.adjacency_mask.shape}"
110
- )
111
- if self.prohibition_mask is not None and self.prohibition_mask.shape != (n, n):
112
- raise ValueError(
113
- f"prohibition_mask shape mismatch: expected {(n, n)}, got {self.prohibition_mask.shape}"
114
- )
115
- if self.data_level.ndim != 2 or self.data_level.shape[1] != n:
116
- raise ValueError(
117
- f"data_level shape mismatch: expected (T, {n}), got {self.data_level.shape}"
118
- )
119
- if self.mask_level is not None and self.mask_level.shape != self.data_level.shape:
120
- raise ValueError(
121
- "mask_tech shape mismatch after extraction: expected shape "
122
- f"{self.data_level.shape}, got {self.mask_level.shape}"
123
- )
124
- if self.lag < 1:
125
- raise ValueError("lag must be >= 1")
126
-
127
- def _extract_level_data(self, data_tech: np.ndarray) -> np.ndarray:
128
- if data_tech.ndim == 3:
129
- return np.asarray(data_tech[:, :, 0], dtype=float)
130
- if data_tech.ndim == 2:
131
- return np.asarray(data_tech, dtype=float)
132
- raise ValueError(f"Unsupported data_tech ndim={data_tech.ndim}; expected 2 or 3.")
133
-
134
- def _extract_mask_level(self, mask_tech: Optional[np.ndarray]) -> Optional[np.ndarray]:
135
- if mask_tech is None:
136
- return None
137
- if mask_tech.ndim == 3:
138
- return np.asarray(mask_tech[:, :, 0], dtype=float)
139
- if mask_tech.ndim == 2:
140
- return np.asarray(mask_tech, dtype=float)
141
- raise ValueError(f"Unsupported mask_tech ndim={mask_tech.ndim}; expected 2 or 3.")
142
-
143
- def _build_dag(self) -> np.ndarray:
144
- cuts_edges = self.adj >= self.threshold
145
- prior_edges = self.adjacency_mask > 0
146
- dag = np.logical_or(cuts_edges, prior_edges)
147
- if self.prohibition_mask is not None:
148
- prohibited = self.prohibition_mask <= 0
149
- dag = np.where(prohibited, False, dag)
150
- np.fill_diagonal(dag, False)
151
- return dag.astype(bool)
152
-
153
- def _enforce_acyclic(self) -> None:
154
- while True:
155
- cycle_edges = self._find_cycle_edges(self.dag_adj)
156
- if not cycle_edges:
157
- return
158
-
159
- removable = []
160
- for src, dst in cycle_edges:
161
- mandatory = bool(self.adjacency_mask[src, dst] > 0)
162
- score = float(self.adj[src, dst])
163
- removable.append((mandatory, score, src, dst))
164
-
165
- non_mandatory = [r for r in removable if not r[0]]
166
- choice = min(non_mandatory or removable, key=lambda x: x[1])
167
- _, score, src, dst = choice
168
-
169
- self.dag_adj[src, dst] = False
170
- self.removed_cycle_edges.append(
171
- {
172
- "source": self.nodes[src],
173
- "target": self.nodes[dst],
174
- "adj_score": score,
175
- }
176
- )
177
-
178
- def _topological_sort(self, dag_adj: np.ndarray) -> List[int]:
179
- n = dag_adj.shape[0]
180
- indegree = np.sum(dag_adj, axis=0).astype(int)
181
- queue = [i for i in range(n) if indegree[i] == 0]
182
- order: List[int] = []
183
-
184
- while queue:
185
- node = queue.pop(0)
186
- order.append(node)
187
- children = np.where(dag_adj[node])[0]
188
- for child in children:
189
- indegree[child] -= 1
190
- if indegree[child] == 0:
191
- queue.append(int(child))
192
-
193
- if len(order) != n:
194
- raise RuntimeError("DAG still contains a cycle after pruning.")
195
- return order
196
-
197
- def _find_cycle_edges(self, dag_adj: np.ndarray) -> List[Tuple[int, int]]:
198
- n = dag_adj.shape[0]
199
- state = np.zeros(n, dtype=int) # 0=unvisited, 1=visiting, 2=done
200
- parent = -np.ones(n, dtype=int)
201
-
202
- def dfs(u: int) -> Optional[List[Tuple[int, int]]]:
203
- state[u] = 1
204
- for v in np.where(dag_adj[u])[0]:
205
- v = int(v)
206
- if state[v] == 0:
207
- parent[v] = u
208
- found = dfs(v)
209
- if found:
210
- return found
211
- elif state[v] == 1:
212
- nodes = [v]
213
- cur = u
214
- while cur != v and cur != -1:
215
- nodes.append(cur)
216
- cur = int(parent[cur])
217
- nodes.append(v)
218
- nodes.reverse()
219
- return [(nodes[i], nodes[i + 1]) for i in range(len(nodes) - 1)]
220
- state[u] = 2
221
- return None
222
-
223
- for start in range(n):
224
- if state[start] == 0:
225
- result = dfs(start)
226
- if result:
227
- return result
228
- return []
229
-
230
- def _fit_equations(self) -> None:
231
- T = self.t_steps
232
- for idx in self.topological_indices:
233
- node = self.nodes[idx]
234
- parent_idx = [int(i) for i in np.where(self.dag_adj[:, idx])[0]]
235
- parent_names = [self.nodes[i] for i in parent_idx]
236
- valid_t = self._valid_timesteps(idx, parent_idx)
237
-
238
- if len(valid_t) == 0:
239
- self.equations[node] = StructuralEquation(
240
- node=node,
241
- parents=parent_names,
242
- parent_indices=parent_idx,
243
- intercept=0.0,
244
- coefficients={},
245
- residual_mean=0.0,
246
- residual_std=1.0,
247
- r_squared=0.0,
248
- n_obs=0,
249
- equation_type="exogenous",
250
- )
251
- continue
252
-
253
- y = self.data_level[valid_t, idx]
254
-
255
- if not parent_idx or len(valid_t) < self.min_obs:
256
- mu = float(np.mean(y))
257
- residuals = y - mu
258
- self.equations[node] = StructuralEquation(
259
- node=node,
260
- parents=parent_names,
261
- parent_indices=parent_idx,
262
- intercept=mu,
263
- coefficients={},
264
- residual_mean=float(np.mean(residuals)) if residuals.size else 0.0,
265
- residual_std=float(np.std(residuals)) if residuals.size else 1.0,
266
- r_squared=0.0,
267
- n_obs=int(len(valid_t)),
268
- equation_type="exogenous",
269
- )
270
- continue
271
-
272
- X = self.data_level[valid_t - self.lag][:, parent_idx]
273
- coef, intercept = self._fit_ridge(X, y)
274
- y_hat = intercept + (X @ coef)
275
- residuals = y - y_hat
276
- ss_res = float(np.sum(residuals ** 2))
277
- ss_tot = float(np.sum((y - np.mean(y)) ** 2))
278
- r2 = 1.0 - (ss_res / ss_tot) if ss_tot > 1e-12 else 0.0
279
-
280
- coeffs = {name: float(coef[i]) for i, name in enumerate(parent_names)}
281
- self.equations[node] = StructuralEquation(
282
- node=node,
283
- parents=parent_names,
284
- parent_indices=parent_idx,
285
- intercept=float(intercept),
286
- coefficients=coeffs,
287
- residual_mean=float(np.mean(residuals)),
288
- residual_std=float(np.std(residuals)),
289
- r_squared=r2,
290
- n_obs=int(len(valid_t)),
291
- equation_type="linear",
292
- )
293
-
294
- def _valid_timesteps(self, node_idx: int, parent_indices: List[int]) -> np.ndarray:
295
- valid_t = np.arange(self.lag, self.t_steps, dtype=int)
296
- if self.mask_level is None:
297
- return valid_t
298
-
299
- valid = self.mask_level[valid_t, node_idx] > 0
300
- for p_idx in parent_indices:
301
- valid &= self.mask_level[valid_t - self.lag, p_idx] > 0
302
- return valid_t[valid]
303
-
304
- def _fit_ridge(self, X: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, float]:
305
- X_mean = np.mean(X, axis=0)
306
- y_mean = float(np.mean(y))
307
- Xc = X - X_mean
308
- yc = y - y_mean
309
-
310
- p = X.shape[1]
311
- reg = self.ridge_alpha * np.eye(p)
312
- beta = np.linalg.solve(Xc.T @ Xc + reg, Xc.T @ yc)
313
- intercept = y_mean - float(X_mean @ beta)
314
- return beta, intercept
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/causal_inference/estimator.py DELETED
@@ -1,125 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Dict, Optional, Set
4
-
5
- import numpy as np
6
-
7
- from .causal_model import StructuralCausalModel
8
- from .mutilator import propagate_intervention
9
-
10
-
11
- def estimate_ate(
12
- scm: StructuralCausalModel,
13
- treatment: str,
14
- outcome: str,
15
- treatment_value: Optional[float] = None,
16
- adjustment_set: Optional[Set[str]] = None,
17
- horizon: int = 5,
18
- ) -> Dict[str, object]:
19
- t_idx = scm.node_to_idx[treatment]
20
- y_idx = scm.node_to_idx[outcome]
21
- z_nodes = sorted(adjustment_set or set())
22
- z_idx = [scm.node_to_idx[z] for z in z_nodes]
23
-
24
- valid_t = np.arange(scm.lag, scm.t_steps, dtype=int)
25
- if scm.mask_level is not None:
26
- valid = (scm.mask_level[valid_t, y_idx] > 0) & (scm.mask_level[valid_t - scm.lag, t_idx] > 0)
27
- for zi in z_idx:
28
- valid &= scm.mask_level[valid_t - scm.lag, zi] > 0
29
- valid_t = valid_t[valid]
30
-
31
- if len(valid_t) < 5:
32
- raise ValueError(
33
- f"Insufficient observations for ATE estimation of {treatment}->{outcome}: {len(valid_t)} rows."
34
- )
35
-
36
- y = scm.data_level[valid_t, y_idx]
37
- x_treat = scm.data_level[valid_t - scm.lag, t_idx]
38
- X_parts = [np.ones((len(valid_t), 1)), x_treat.reshape(-1, 1)]
39
- if z_idx:
40
- X_parts.append(scm.data_level[valid_t - scm.lag][:, z_idx])
41
- X = np.concatenate(X_parts, axis=1)
42
-
43
- beta = np.linalg.pinv(X.T @ X) @ (X.T @ y)
44
- y_hat = X @ beta
45
- resid = y - y_hat
46
- dof = max(1, len(y) - X.shape[1])
47
- sigma2 = float(np.sum(resid ** 2) / dof)
48
- cov = sigma2 * np.linalg.pinv(X.T @ X)
49
- se = float(np.sqrt(max(cov[1, 1], 0.0)))
50
- ate = float(beta[1])
51
-
52
- baseline = float(np.mean(np.abs(y))) + 1e-12
53
- ate_normalized = ate / baseline
54
- ci_95 = (ate - 1.96 * se, ate + 1.96 * se)
55
-
56
- path_contributions = _path_contributions(scm, treatment, outcome)
57
-
58
- horizon_effects = []
59
- if treatment_value is not None:
60
- baseline_t = scm.t_steps - 1
61
- shocked = propagate_intervention(scm, treatment, treatment_value, horizon=horizon, baseline_t=baseline_t)
62
- base_outcome = float(scm.data_level[baseline_t, y_idx])
63
- for h in range(horizon):
64
- horizon_effects.append(float(shocked[h, y_idx] - base_outcome))
65
-
66
- return {
67
- "ate": ate,
68
- "ate_normalized": float(ate_normalized),
69
- "ci_95": (float(ci_95[0]), float(ci_95[1])),
70
- "n_obs": int(len(valid_t)),
71
- "path_contributions": path_contributions,
72
- "horizon_effects": horizon_effects,
73
- }
74
-
75
-
76
- def _path_contributions(
77
- scm: StructuralCausalModel,
78
- treatment: str,
79
- outcome: str,
80
- max_paths: int = 100,
81
- ) -> Dict[str, float]:
82
- start = scm.node_to_idx[treatment]
83
- target = scm.node_to_idx[outcome]
84
- paths = []
85
-
86
- def dfs(node: int, path: list[int], seen: set[int]) -> None:
87
- if len(paths) >= max_paths:
88
- return
89
- if node == target:
90
- paths.append(path.copy())
91
- return
92
- for child in np.where(scm.dag_adj[node])[0]:
93
- child = int(child)
94
- if child in seen:
95
- continue
96
- seen.add(child)
97
- path.append(child)
98
- dfs(child, path, seen)
99
- path.pop()
100
- seen.remove(child)
101
-
102
- dfs(start, [start], {start})
103
-
104
- contributions: Dict[str, float] = {}
105
- for path in paths:
106
- coeff_product = 1.0
107
- valid = True
108
- for i in range(len(path) - 1):
109
- src = scm.nodes[path[i]]
110
- dst = scm.nodes[path[i + 1]]
111
- eq = scm.equations.get(dst)
112
- if eq is None:
113
- valid = False
114
- break
115
- coef = eq.coefficients.get(src)
116
- if coef is None:
117
- valid = False
118
- break
119
- coeff_product *= coef
120
- if not valid:
121
- continue
122
- label = " -> ".join(scm.nodes[i] for i in path)
123
- contributions[label] = float(coeff_product)
124
-
125
- return contributions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/causal_inference/identification.py DELETED
@@ -1,41 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from enum import Enum
4
- from typing import Optional, Set, Tuple
5
-
6
- from .causal_model import StructuralCausalModel
7
-
8
-
9
- class IdentificationStrategy(Enum):
10
- DIRECT = "direct"
11
- BACKDOOR = "backdoor"
12
- NOT_IDENTIFIABLE = "not_identifiable"
13
-
14
-
15
- def find_adjustment_set(
16
- scm: StructuralCausalModel,
17
- treatment: str,
18
- outcome: str,
19
- ) -> Tuple[IdentificationStrategy, Optional[Set[str]]]:
20
- if treatment not in scm.node_to_idx:
21
- raise ValueError(f"Unknown treatment node: {treatment}")
22
- if outcome not in scm.node_to_idx:
23
- raise ValueError(f"Unknown outcome node: {outcome}")
24
- if treatment == outcome:
25
- return IdentificationStrategy.DIRECT, set()
26
-
27
- if not scm.has_directed_path(treatment, outcome):
28
- return IdentificationStrategy.NOT_IDENTIFIABLE, None
29
-
30
- parents = set(scm.parents_of(treatment))
31
- descendants = set(scm.descendants_of(treatment))
32
- adjustment = parents - descendants - {outcome}
33
-
34
- if adjustment:
35
- return IdentificationStrategy.BACKDOOR, adjustment
36
- return IdentificationStrategy.DIRECT, set()
37
-
38
-
39
- def is_identifiable(scm: StructuralCausalModel, treatment: str, outcome: str) -> bool:
40
- strategy, _ = find_adjustment_set(scm, treatment, outcome)
41
- return strategy != IdentificationStrategy.NOT_IDENTIFIABLE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/causal_inference/mutilator.py DELETED
@@ -1,62 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from copy import deepcopy
4
- from typing import Dict, Optional, Sequence
5
-
6
- import numpy as np
7
-
8
- from .causal_model import StructuralCausalModel
9
-
10
-
11
- def mutilate_graph(scm: StructuralCausalModel, treatment: str, value: float) -> StructuralCausalModel:
12
- mutilated = deepcopy(scm)
13
- t_idx = mutilated.node_to_idx[treatment]
14
- mutilated.dag_adj[:, t_idx] = False
15
- mutilated.pinned_values = {treatment: float(value)}
16
- return mutilated
17
-
18
-
19
- def propagate_intervention(
20
- scm: StructuralCausalModel,
21
- treatment: str,
22
- value: float,
23
- targets: Optional[Sequence[str]] = None,
24
- horizon: int = 5,
25
- baseline_t: int = -1,
26
- ) -> np.ndarray:
27
- if horizon < 1:
28
- raise ValueError("horizon must be >= 1")
29
-
30
- baseline_idx = baseline_t if baseline_t >= 0 else (scm.t_steps + baseline_t)
31
- if baseline_idx < 0 or baseline_idx >= scm.t_steps:
32
- raise ValueError(f"baseline_t {baseline_t} resolves out of bounds for T={scm.t_steps}")
33
-
34
- t_idx = scm.node_to_idx[treatment]
35
- prev = scm.data_level[baseline_idx].copy()
36
- forecasts = np.zeros((horizon, scm.n_nodes), dtype=float)
37
-
38
- for h in range(horizon):
39
- nxt = prev.copy()
40
- nxt[t_idx] = float(value)
41
- for node_idx in scm.topological_indices:
42
- if node_idx == t_idx:
43
- continue
44
- node = scm.nodes[node_idx]
45
- eq = scm.equations[node]
46
- if not eq.parent_indices or eq.equation_type == "exogenous":
47
- continue
48
- parent_vals = prev[eq.parent_indices]
49
- coef_vec = np.array([eq.coefficients[p] for p in eq.parents], dtype=float)
50
- nxt[node_idx] = eq.intercept + float(parent_vals @ coef_vec)
51
- forecasts[h] = nxt
52
- prev = nxt
53
-
54
- if targets:
55
- missing = [n for n in targets if n not in scm.node_to_idx]
56
- if missing:
57
- raise ValueError(f"Unknown targets: {missing}")
58
- return forecasts
59
-
60
-
61
- def target_series(forecasts: np.ndarray, scm: StructuralCausalModel, targets: Sequence[str]) -> Dict[str, list[float]]:
62
- return {t: [float(v) for v in forecasts[:, scm.node_to_idx[t]]] for t in targets}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/causal_inference/pywhyllm_assumptions.py DELETED
@@ -1,338 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- import json
5
- import os
6
- from dataclasses import asdict, dataclass, field
7
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
8
-
9
- import numpy as np
10
-
11
-
12
- def _json_safe(value: Any) -> Any:
13
- if isinstance(value, np.ndarray):
14
- return value.tolist()
15
- if isinstance(value, (np.integer,)):
16
- return int(value)
17
- if isinstance(value, (np.floating,)):
18
- return float(value)
19
- if isinstance(value, dict):
20
- return {str(k): _json_safe(v) for k, v in value.items()}
21
- if isinstance(value, (list, tuple, set)):
22
- return [_json_safe(v) for v in value]
23
- if isinstance(value, (str, int, float, bool)) or value is None:
24
- return value
25
- return str(value)
26
-
27
-
28
- @dataclass
29
- class PyWhyLLMConfig:
30
- enabled: bool = False
31
- model: str = "gpt-4"
32
- max_edges: int = 25
33
- cache_dir: str = os.path.join(
34
- os.path.dirname(__file__),
35
- "..",
36
- "debug_data",
37
- "pywhyllm_cache",
38
- )
39
-
40
-
41
- @dataclass
42
- class CausalAssumptionReport:
43
- available: bool = True
44
- reason: Optional[str] = None
45
- domain_expertises: List[str] = field(default_factory=list)
46
- suggested_confounders: List[str] = field(default_factory=list)
47
- suggested_backdoor_sets: List[List[str]] = field(default_factory=list)
48
- suggested_mediators: List[str] = field(default_factory=list)
49
- suggested_ivs: List[str] = field(default_factory=list)
50
- negative_controls: List[str] = field(default_factory=list)
51
- latent_confounders: List[str] = field(default_factory=list)
52
- edge_critiques: Any = field(default_factory=list)
53
- accepted_edges: List[Tuple[str, str]] = field(default_factory=list)
54
- rejected_edges: List[Tuple[str, str]] = field(default_factory=list)
55
- warnings: List[str] = field(default_factory=list)
56
-
57
- def to_dict(self) -> Dict[str, Any]:
58
- payload = asdict(self)
59
- payload["accepted_edges"] = [list(edge) for edge in self.accepted_edges]
60
- payload["rejected_edges"] = [list(edge) for edge in self.rejected_edges]
61
- return _json_safe(payload)
62
-
63
- @classmethod
64
- def from_dict(cls, payload: Dict[str, Any]) -> "CausalAssumptionReport":
65
- data = dict(payload)
66
- data["accepted_edges"] = [tuple(edge) for edge in data.get("accepted_edges", [])]
67
- data["rejected_edges"] = [tuple(edge) for edge in data.get("rejected_edges", [])]
68
- return cls(**data)
69
-
70
-
71
- def _dedupe_strings(values: Iterable[Any]) -> List[str]:
72
- seen = set()
73
- result: List[str] = []
74
- for value in values or []:
75
- text = str(value).strip()
76
- if text and text not in seen:
77
- seen.add(text)
78
- result.append(text)
79
- return result
80
-
81
-
82
- def _normalise_suggestion(value: Any) -> List[str]:
83
- if value is None:
84
- return []
85
- if isinstance(value, tuple) and len(value) == 2:
86
- return _normalise_suggestion(value[1])
87
- if isinstance(value, dict):
88
- keys = [k for k, v in value.items() if isinstance(k, str) and v]
89
- if keys:
90
- return _dedupe_strings(keys)
91
- flattened: List[str] = []
92
- for item in value.values():
93
- flattened.extend(_normalise_suggestion(item))
94
- return _dedupe_strings(flattened)
95
- if isinstance(value, (list, set, tuple)):
96
- flattened = []
97
- for item in value:
98
- if isinstance(item, (list, set, tuple, dict)):
99
- flattened.extend(_normalise_suggestion(item))
100
- else:
101
- flattened.append(item)
102
- return _dedupe_strings(flattened)
103
- return _dedupe_strings([value])
104
-
105
-
106
- class PyWhyLLMAssumptionService:
107
- def __init__(
108
- self,
109
- config: Optional[PyWhyLLMConfig] = None,
110
- model_suggester: Any = None,
111
- identification_suggester: Any = None,
112
- validation_suggester: Any = None,
113
- relationship_strategy: Any = None,
114
- ):
115
- self.config = config or PyWhyLLMConfig()
116
- self._model_suggester = model_suggester
117
- self._identification_suggester = identification_suggester
118
- self._validation_suggester = validation_suggester
119
- self._relationship_strategy = relationship_strategy
120
-
121
- @property
122
- def enabled(self) -> bool:
123
- return bool(self.config.enabled)
124
-
125
- def analyze(
126
- self,
127
- *,
128
- nodes: Sequence[str],
129
- dag_adj: np.ndarray,
130
- treatment: str,
131
- outcome: str,
132
- max_edges: Optional[int] = None,
133
- ) -> CausalAssumptionReport:
134
- if not self.enabled:
135
- return CausalAssumptionReport(
136
- available=False,
137
- reason="PyWhy-LLM is disabled. Set PYWHYLLM_ENABLED=true or pass pywhyllm_enabled=True.",
138
- )
139
-
140
- cache_path = self._cache_path(nodes, dag_adj, treatment, outcome)
141
- cached = self._read_cache(cache_path)
142
- if cached is not None:
143
- return cached
144
-
145
- try:
146
- modeler, identifier, validator, relationship_strategy = self._suggesters()
147
- except Exception as exc:
148
- return CausalAssumptionReport(
149
- available=False,
150
- reason=f"PyWhy-LLM is not installed or failed to initialize: {exc}",
151
- )
152
-
153
- all_factors = list(nodes)
154
- edges = self._edges(nodes, dag_adj, max_edges or self.config.max_edges)
155
- warnings: List[str] = []
156
-
157
- domain_expertises: List[str] = []
158
- suggested_confounders: List[str] = []
159
- suggested_backdoor_sets: List[List[str]] = []
160
- suggested_mediators: List[str] = []
161
- suggested_ivs: List[str] = []
162
- negative_controls: List[str] = []
163
- latent_confounders: List[str] = []
164
- edge_critiques: Any = []
165
- suggested_dag: Any = edges
166
-
167
- try:
168
- domain_expertises = _normalise_suggestion(modeler.suggest_domain_expertises(all_factors))
169
- except Exception as exc:
170
- warnings.append(f"domain_expertises failed: {exc}")
171
-
172
- try:
173
- suggested_confounders = _normalise_suggestion(
174
- modeler.suggest_confounders(treatment, outcome, all_factors, domain_expertises)
175
- )
176
- except Exception as exc:
177
- warnings.append(f"confounder suggestion failed: {exc}")
178
-
179
- try:
180
- suggested_dag = modeler.suggest_relationships(
181
- treatment,
182
- outcome,
183
- all_factors,
184
- domain_expertises,
185
- relationship_strategy,
186
- )
187
- except Exception as exc:
188
- warnings.append(f"relationship suggestion failed: {exc}")
189
-
190
- try:
191
- backdoor = identifier.suggest_backdoor(treatment, outcome, all_factors, domain_expertises)
192
- backdoor_nodes = _normalise_suggestion(backdoor)
193
- if backdoor_nodes:
194
- suggested_backdoor_sets = [backdoor_nodes]
195
- except Exception as exc:
196
- warnings.append(f"backdoor suggestion failed: {exc}")
197
-
198
- try:
199
- suggested_mediators = _normalise_suggestion(
200
- identifier.suggest_mediators(treatment, outcome, all_factors, domain_expertises)
201
- )
202
- except Exception as exc:
203
- warnings.append(f"mediator suggestion failed: {exc}")
204
-
205
- try:
206
- suggested_ivs = _normalise_suggestion(
207
- identifier.suggest_ivs(treatment, outcome, all_factors, domain_expertises)
208
- )
209
- except Exception as exc:
210
- warnings.append(f"iv suggestion failed: {exc}")
211
-
212
- try:
213
- edge_critiques = validator.critique_graph(
214
- all_factors,
215
- suggested_dag,
216
- domain_expertises,
217
- relationship_strategy,
218
- )
219
- except Exception as exc:
220
- warnings.append(f"edge critique failed: {exc}")
221
-
222
- try:
223
- latent_confounders = _normalise_suggestion(
224
- validator.suggest_latent_confounders(treatment, outcome, all_factors, domain_expertises)
225
- )
226
- except Exception as exc:
227
- warnings.append(f"latent confounder suggestion failed: {exc}")
228
-
229
- try:
230
- negative_controls = _normalise_suggestion(
231
- validator.suggest_negative_controls(treatment, outcome, all_factors, domain_expertises)
232
- )
233
- except Exception as exc:
234
- warnings.append(f"negative control suggestion failed: {exc}")
235
-
236
- accepted_edges, rejected_edges = self._classify_edges(edges, edge_critiques)
237
- report = CausalAssumptionReport(
238
- available=True,
239
- domain_expertises=domain_expertises,
240
- suggested_confounders=suggested_confounders,
241
- suggested_backdoor_sets=suggested_backdoor_sets,
242
- suggested_mediators=suggested_mediators,
243
- suggested_ivs=suggested_ivs,
244
- negative_controls=negative_controls,
245
- latent_confounders=latent_confounders,
246
- edge_critiques=edge_critiques,
247
- accepted_edges=accepted_edges,
248
- rejected_edges=rejected_edges,
249
- warnings=warnings,
250
- )
251
- self._write_cache(cache_path, report)
252
- return report
253
-
254
- def _suggesters(self) -> Tuple[Any, Any, Any, Any]:
255
- if self._model_suggester and self._identification_suggester and self._validation_suggester:
256
- return (
257
- self._model_suggester,
258
- self._identification_suggester,
259
- self._validation_suggester,
260
- self._relationship_strategy,
261
- )
262
-
263
- from pywhyllm import RelationshipStrategy
264
- from pywhyllm.suggesters.identification_suggester import IdentificationSuggester
265
- from pywhyllm.suggesters.model_suggester import ModelSuggester
266
- from pywhyllm.suggesters.validation_suggester import ValidationSuggester
267
-
268
- relationship_strategy = self._relationship_strategy or RelationshipStrategy.Pairwise
269
- return (
270
- self._model_suggester or ModelSuggester(self.config.model),
271
- self._identification_suggester or IdentificationSuggester(self.config.model),
272
- self._validation_suggester or ValidationSuggester(self.config.model),
273
- relationship_strategy,
274
- )
275
-
276
- def _cache_path(
277
- self,
278
- nodes: Sequence[str],
279
- dag_adj: np.ndarray,
280
- treatment: str,
281
- outcome: str,
282
- ) -> str:
283
- payload = {
284
- "nodes": list(nodes),
285
- "dag_adj": np.asarray(dag_adj, dtype=int).tolist(),
286
- "treatment": treatment,
287
- "outcome": outcome,
288
- "model": self.config.model,
289
- }
290
- digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
291
- return os.path.join(os.path.abspath(self.config.cache_dir), f"{digest}.json")
292
-
293
- def _read_cache(self, path: str) -> Optional[CausalAssumptionReport]:
294
- if not os.path.exists(path):
295
- return None
296
- try:
297
- with open(path) as f:
298
- return CausalAssumptionReport.from_dict(json.load(f))
299
- except Exception:
300
- return None
301
-
302
- def _write_cache(self, path: str, report: CausalAssumptionReport) -> None:
303
- try:
304
- os.makedirs(os.path.dirname(path), exist_ok=True)
305
- with open(path, "w") as f:
306
- json.dump(report.to_dict(), f, indent=2)
307
- except Exception:
308
- pass
309
-
310
- def _edges(self, nodes: Sequence[str], dag_adj: np.ndarray, max_edges: int) -> List[Tuple[str, str]]:
311
- found: List[Tuple[str, str, float]] = []
312
- for src_idx, src in enumerate(nodes):
313
- for dst_idx, dst in enumerate(nodes):
314
- if bool(dag_adj[src_idx, dst_idx]):
315
- found.append((src, dst, float(dag_adj[src_idx, dst_idx])))
316
- found.sort(key=lambda edge: abs(edge[2]), reverse=True)
317
- return [(src, dst) for src, dst, _ in found[:max_edges]]
318
-
319
- def _classify_edges(
320
- self,
321
- edges: List[Tuple[str, str]],
322
- edge_critiques: Any,
323
- ) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
324
- critique_text = str(edge_critiques).lower()
325
- rejected: List[Tuple[str, str]] = []
326
- for edge in edges:
327
- src, dst = edge
328
- edge_tokens = [
329
- f"{src}->{dst}".lower(),
330
- f"{src} -> {dst}".lower(),
331
- f"{src}, {dst}".lower(),
332
- ]
333
- if any(token in critique_text for token in edge_tokens) and any(
334
- marker in critique_text for marker in ["reject", "unlikely", "invalid", "implausible"]
335
- ):
336
- rejected.append(edge)
337
- accepted = [edge for edge in edges if edge not in rejected]
338
- return accepted, rejected
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/causal_inference/query_engine.py DELETED
@@ -1,505 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import re
5
- from typing import Any, Dict, List, Optional, Sequence
6
-
7
- import numpy as np
8
- import pandas as pd
9
-
10
- from .abduction import counterfactual_predict, counterfactual_predict_multi
11
- from .causal_model import StructuralCausalModel
12
- from .estimator import estimate_ate
13
- from .identification import find_adjustment_set
14
- from .mutilator import mutilate_graph, propagate_intervention, target_series
15
- from .pywhyllm_assumptions import PyWhyLLMConfig, PyWhyLLMAssumptionService
16
-
17
-
18
- class CausalQueryEngine:
19
- def __init__(
20
- self,
21
- scm: StructuralCausalModel,
22
- data_tech: Optional[np.ndarray] = None,
23
- data_text: Optional[np.ndarray] = None,
24
- pywhyllm_service: Optional[PyWhyLLMAssumptionService] = None,
25
- pywhyllm_enabled: bool = False,
26
- ):
27
- self.scm = scm
28
- self.data_tech = data_tech
29
- self.data_text = data_text
30
- self.pywhyllm_enabled = pywhyllm_enabled
31
- self.pywhyllm_service = pywhyllm_service
32
-
33
- def assert_edge(self, treatment: str, outcome: str) -> Dict[str, object]:
34
- strategy, z = find_adjustment_set(self.scm, treatment, outcome)
35
- if strategy.value == "not_identifiable":
36
- return {
37
- "ate": 0.0,
38
- "ci_95": (0.0, 0.0),
39
- "strategy": strategy.name,
40
- "adjustment_set": [],
41
- "identifiable": False,
42
- "path_contributions": {},
43
- }
44
-
45
- est = estimate_ate(
46
- self.scm,
47
- treatment=treatment,
48
- outcome=outcome,
49
- adjustment_set=z,
50
- treatment_value=None,
51
- )
52
- return {
53
- "ate": est["ate"],
54
- "ci_95": est["ci_95"],
55
- "strategy": strategy.name,
56
- "adjustment_set": sorted(z or set()),
57
- "identifiable": True,
58
- "path_contributions": est["path_contributions"],
59
- }
60
-
61
- def intervene(self, treatment: str, value: float, targets: List[str], horizon: int = 5) -> Dict[str, object]:
62
- mutilated = mutilate_graph(self.scm, treatment=treatment, value=value)
63
- forecasts = propagate_intervention(
64
- mutilated,
65
- treatment=treatment,
66
- value=value,
67
- targets=targets,
68
- horizon=horizon,
69
- baseline_t=-1,
70
- )
71
- predicted = target_series(forecasts, self.scm, targets)
72
- ate_per_target = {}
73
- for target in targets:
74
- tidx = self.scm.node_to_idx[target]
75
- base = float(self.scm.data_level[-1, tidx])
76
- ate_per_target[target] = float(forecasts[0, tidx] - base)
77
- return {
78
- "mutilated_adj": mutilated.dag_adj.copy(),
79
- "predicted_values": predicted,
80
- "ate_per_target": ate_per_target,
81
- "horizon": horizon,
82
- }
83
-
84
- def _counterfactual_outcome(
85
- self,
86
- observed: Dict[str, float],
87
- counterfactual_values: Dict[str, float],
88
- target: str,
89
- ) -> Dict[str, float]:
90
- if len(counterfactual_values) == 1:
91
- treatment, value = next(iter(counterfactual_values.items()))
92
- return counterfactual_predict(
93
- self.scm,
94
- observed_values=observed,
95
- treatment=treatment,
96
- counterfactual_value=value,
97
- target=target,
98
- )
99
- return counterfactual_predict_multi(
100
- self.scm,
101
- observed_values=observed,
102
- counterfactual_values=counterfactual_values,
103
- target=target,
104
- )
105
-
106
- def _shapley_contributions(
107
- self,
108
- observed: Dict[str, float],
109
- interventions: Dict[str, float],
110
- target: str,
111
- mc_samples: int = 1000,
112
- n_jobs: int = -1,
113
- ) -> Dict[str, float]:
114
- import random
115
- from math import factorial
116
- from joblib import Parallel, delayed
117
- import os
118
-
119
- treatments = list(interventions.keys())
120
- n = len(treatments)
121
- if n == 1:
122
- return {treatments[0]: self._counterfactual_outcome(observed, interventions, target)["ite"]}
123
-
124
- contributions: Dict[str, float] = {t: 0.0 for t in treatments}
125
-
126
- # Use exact if n <= 10, else Monte Carlo
127
- use_exact = n <= 10
128
- if n_jobs < 0:
129
- n_jobs = os.cpu_count() or 4
130
-
131
- def marginal_contribution(k: str, subset: list[str]) -> float:
132
- with_k = {**{t: interventions[t] for t in subset}, k: interventions[k]}
133
- without_k = {t: interventions[t] for t in subset}
134
- v_with = self._counterfactual_outcome(observed, with_k, target)["counterfactual_outcome"]
135
- v_without = self._counterfactual_outcome(observed, without_k, target)["counterfactual_outcome"]
136
- return v_with - v_without
137
-
138
- if use_exact:
139
- all_factorial = float(factorial(n))
140
- tasks = []
141
-
142
- for k in treatments:
143
- others = [t for t in treatments if t != k]
144
- for r in range(len(others) + 1):
145
- for subset in __import__("itertools").combinations(others, r):
146
- subset_list = list(subset)
147
- weight = float(factorial(len(subset_list)) * factorial(n - len(subset_list) - 1) / all_factorial)
148
- tasks.append((k, subset_list, weight))
149
-
150
- results = Parallel(n_jobs=n_jobs, backend="threading")(
151
- delayed(marginal_contribution)(task[0], task[1]) for task in tasks
152
- )
153
- for task, res in zip(tasks, results):
154
- k, _, weight = task
155
- contributions[k] += weight * res
156
- else:
157
- # Monte Carlo approximation
158
- tasks = []
159
- for _ in range(mc_samples):
160
- perm = treatments.copy()
161
- random.shuffle(perm)
162
- for i, k in enumerate(perm):
163
- subset_list = perm[:i]
164
- tasks.append((k, subset_list))
165
-
166
- results = Parallel(n_jobs=n_jobs, backend="threading")(
167
- delayed(marginal_contribution)(task[0], task[1]) for task in tasks
168
- )
169
- for task, res in zip(tasks, results):
170
- k, _ = task
171
- contributions[k] += res / mc_samples
172
-
173
- return contributions
174
-
175
- def _build_dowhy_graph(self) -> str:
176
- edges = []
177
- for src_idx, src in enumerate(self.scm.nodes):
178
- for dst_idx, dst in enumerate(self.scm.nodes):
179
- if self.scm.dag_adj[src_idx, dst_idx]:
180
- edges.append(f"{src} -> {dst}")
181
- return "digraph{" + "; ".join(edges) + "}"
182
-
183
- def _default_pywhyllm_service(self) -> PyWhyLLMAssumptionService:
184
- enabled = self.pywhyllm_enabled or os.environ.get("PYWHYLLM_ENABLED", "").lower() in {
185
- "1",
186
- "true",
187
- "yes",
188
- "on",
189
- }
190
- return PyWhyLLMAssumptionService(
191
- PyWhyLLMConfig(
192
- enabled=enabled,
193
- model=os.environ.get("PYWHYLLM_MODEL", "gpt-4"),
194
- max_edges=int(os.environ.get("PYWHYLLM_MAX_EDGES", "25")),
195
- cache_dir=os.environ.get(
196
- "PYWHYLLM_CACHE_DIR",
197
- PyWhyLLMConfig().cache_dir,
198
- ),
199
- )
200
- )
201
-
202
- def _get_pywhyllm_service(self) -> PyWhyLLMAssumptionService:
203
- if self.pywhyllm_service is None:
204
- self.pywhyllm_service = self._default_pywhyllm_service()
205
- return self.pywhyllm_service
206
-
207
- def analyze_assumptions_with_pywhyllm(
208
- self,
209
- treatment: str,
210
- outcome: str,
211
- max_edges: Optional[int] = None,
212
- ) -> Dict[str, object]:
213
- report = self._get_pywhyllm_service().analyze(
214
- nodes=self.scm.nodes,
215
- dag_adj=self.scm.dag_adj,
216
- treatment=treatment,
217
- outcome=outcome,
218
- max_edges=max_edges,
219
- )
220
- return report.to_dict()
221
-
222
- def _valid_nodes(self, candidates: Sequence[Any]) -> List[str]:
223
- valid = set(self.scm.nodes)
224
- result = []
225
- for candidate in candidates or []:
226
- node = str(candidate).strip()
227
- if node in valid and node not in result:
228
- result.append(node)
229
- return result
230
-
231
- def _valid_backdoor_sets(self, report: Dict[str, Any], treatment: str, outcome: str) -> List[List[str]]:
232
- result = []
233
- blocked = {treatment, outcome}
234
- for suggested_set in report.get("suggested_backdoor_sets") or []:
235
- valid_set = [node for node in self._valid_nodes(suggested_set) if node not in blocked]
236
- if valid_set and valid_set not in result:
237
- result.append(valid_set)
238
- confounders = [node for node in self._valid_nodes(report.get("suggested_confounders") or []) if node not in blocked]
239
- if confounders and confounders not in result:
240
- result.append(confounders)
241
- return result
242
-
243
- def _coerce_causal_model(self, causal_model_cls: Any, data: pd.DataFrame, treatment: str, outcome: str, graph: str):
244
- try:
245
- return causal_model_cls(df=data, treatment=treatment, outcome=outcome, graph=graph)
246
- except TypeError:
247
- return causal_model_cls(data=data, treatment=treatment, outcome=outcome, graph=graph)
248
-
249
- def _parse_p_value(self, value: Any) -> Optional[float]:
250
- if value is None:
251
- return None
252
- if isinstance(value, (int, float, np.floating)):
253
- return float(value)
254
- if isinstance(value, (list, tuple)) and value:
255
- return self._parse_p_value(value[0])
256
- if isinstance(value, dict):
257
- for key in ("p_value", "p-value", "p value"):
258
- if key in value:
259
- return self._parse_p_value(value[key])
260
- return None
261
- match = re.search(r"p[-_ ]?value[^0-9<>=-]*[<>=: ]+\s*([0-9]*\.?[0-9]+)", str(value), re.I)
262
- if match:
263
- return float(match.group(1))
264
- return None
265
-
266
- def _as_optional_float(self, value: Any) -> Optional[float]:
267
- if value is None:
268
- return None
269
- try:
270
- arr = np.asarray(value, dtype=float)
271
- if arr.size == 1:
272
- return float(arr.reshape(-1)[0])
273
- except Exception:
274
- pass
275
- try:
276
- return float(value)
277
- except Exception:
278
- return None
279
-
280
- def _parse_refuter_result(self, method: str, refute: Any, alpha: float = 0.05) -> Dict[str, object]:
281
- text = str(refute)
282
- result_attr = getattr(refute, "refutation_result", None)
283
- estimated_effect = getattr(refute, "estimated_effect", None)
284
- new_effect = getattr(refute, "new_effect", None)
285
- p_value = self._parse_p_value(result_attr)
286
- if p_value is None:
287
- p_value = self._parse_p_value(text)
288
- lower_text = text.lower()
289
-
290
- if method == "placebo_treatment":
291
- if p_value is not None:
292
- falsified = p_value < alpha
293
- elif "not statistically significant" in lower_text:
294
- falsified = False
295
- elif "statistically significant" in lower_text:
296
- falsified = True
297
- else:
298
- falsified = False
299
- else:
300
- if p_value is not None:
301
- falsified = p_value < alpha
302
- elif "not statistically significant" in lower_text:
303
- falsified = False
304
- elif "statistically significant" in lower_text:
305
- falsified = True
306
- else:
307
- falsified = False
308
-
309
- return {
310
- "method": method,
311
- "refute": text,
312
- "estimated_effect": self._as_optional_float(estimated_effect),
313
- "new_effect": self._as_optional_float(new_effect),
314
- "p_value": p_value,
315
- "passed": not falsified,
316
- "falsified": falsified,
317
- }
318
-
319
- def _run_dowhy_validation(
320
- self,
321
- causal_model_cls: Any,
322
- treatment: str,
323
- outcome: str,
324
- treatment_value: float = 1.0,
325
- adjustment_candidates: Optional[List[List[str]]] = None,
326
- negative_controls: Optional[List[str]] = None,
327
- ) -> Dict[str, object]:
328
- data = pd.DataFrame(self.scm.data_level, columns=self.scm.nodes)
329
- graph = self._build_dowhy_graph()
330
- model = self._coerce_causal_model(causal_model_cls, data, treatment, outcome, graph)
331
- identified_estimand = model.identify_effect()
332
- estimate = model.estimate_effect(identified_estimand, method_name="backdoor.linear_regression")
333
-
334
- refutations = []
335
- for method in ["placebo_treatment", "random_common_cause"]:
336
- try:
337
- kwargs = {"method_name": method}
338
- if method == "placebo_treatment":
339
- kwargs["placebo_type"] = "permute"
340
- refute = model.refute_estimate(identified_estimand, estimate, **kwargs)
341
- refutations.append(self._parse_refuter_result(method, refute))
342
- except TypeError:
343
- try:
344
- kwargs = {"method_name": method}
345
- if method == "placebo_treatment":
346
- kwargs["placebo_type"] = "permute"
347
- refute = model.refute_estimate(estimate, **kwargs)
348
- refutations.append(self._parse_refuter_result(method, refute))
349
- except Exception as exc:
350
- refutations.append({"method": method, "error": str(exc), "passed": False, "falsified": False})
351
- except Exception as exc:
352
- refutations.append({"method": method, "error": str(exc), "passed": False, "falsified": False})
353
-
354
- negative_control_checks = []
355
- for control in negative_controls or []:
356
- if control in {treatment, outcome}:
357
- continue
358
- try:
359
- nc_model = self._coerce_causal_model(causal_model_cls, data, treatment, control, graph)
360
- nc_identified = nc_model.identify_effect()
361
- nc_estimate = nc_model.estimate_effect(nc_identified, method_name="backdoor.linear_regression")
362
- negative_control_checks.append(
363
- {
364
- "control": control,
365
- "identified_estimand": str(nc_identified),
366
- "estimate": str(nc_estimate),
367
- }
368
- )
369
- except Exception as exc:
370
- negative_control_checks.append({"control": control, "error": str(exc)})
371
-
372
- falsified = any(bool(item.get("falsified")) for item in refutations)
373
- return {
374
- "available": True,
375
- "falsified": falsified,
376
- "identified_estimand": str(identified_estimand),
377
- "estimate": str(estimate),
378
- "refutations": refutations,
379
- "adjustment_candidates": adjustment_candidates or [],
380
- "negative_control_checks": negative_control_checks,
381
- }
382
-
383
- def validate_with_dowhy(
384
- self,
385
- treatment: str,
386
- outcome: str,
387
- treatment_value: float = 1.0,
388
- num_placebo: int = 5,
389
- ) -> Dict[str, object]:
390
- try:
391
- from dowhy import CausalModel
392
- except Exception as exc:
393
- return {
394
- "available": False,
395
- "reason": str(exc),
396
- "falsified": False,
397
- "summary": "DoWhy is not installed or failed to import.",
398
- }
399
-
400
- try:
401
- return self._run_dowhy_validation(
402
- CausalModel,
403
- treatment=treatment,
404
- outcome=outcome,
405
- treatment_value=treatment_value,
406
- )
407
- except Exception as exc:
408
- return {
409
- "available": False,
410
- "reason": str(exc),
411
- "falsified": False,
412
- "summary": "DoWhy refutation failed.",
413
- }
414
-
415
- def validate_with_pywhyllm_and_dowhy(
416
- self,
417
- treatment: str,
418
- outcome: str,
419
- treatment_value: float = 1.0,
420
- max_edges: Optional[int] = None,
421
- ) -> Dict[str, object]:
422
- warnings: List[str] = []
423
- pywhyllm_report = self.analyze_assumptions_with_pywhyllm(treatment, outcome, max_edges=max_edges)
424
- adjustment_candidates = self._valid_backdoor_sets(pywhyllm_report, treatment, outcome)
425
- negative_controls = [
426
- node
427
- for node in self._valid_nodes(pywhyllm_report.get("negative_controls") or [])
428
- if node not in {treatment, outcome}
429
- ]
430
-
431
- try:
432
- from dowhy import CausalModel
433
- except Exception as exc:
434
- return {
435
- "pywhyllm": pywhyllm_report,
436
- "dowhy": {
437
- "available": False,
438
- "reason": str(exc),
439
- "falsified": False,
440
- "summary": "DoWhy is not installed or failed to import.",
441
- "adjustment_candidates": adjustment_candidates,
442
- "negative_controls": negative_controls,
443
- },
444
- "falsified": False,
445
- "warnings": warnings,
446
- }
447
-
448
- try:
449
- dowhy_report = self._run_dowhy_validation(
450
- CausalModel,
451
- treatment=treatment,
452
- outcome=outcome,
453
- treatment_value=treatment_value,
454
- adjustment_candidates=adjustment_candidates,
455
- negative_controls=negative_controls,
456
- )
457
- except Exception as exc:
458
- dowhy_report = {
459
- "available": False,
460
- "reason": str(exc),
461
- "falsified": False,
462
- "summary": "DoWhy refutation failed.",
463
- "adjustment_candidates": adjustment_candidates,
464
- "negative_controls": negative_controls,
465
- }
466
-
467
- warnings.extend(pywhyllm_report.get("warnings") or [])
468
- return {
469
- "pywhyllm": pywhyllm_report,
470
- "dowhy": dowhy_report,
471
- "falsified": bool(dowhy_report.get("falsified")),
472
- "warnings": warnings,
473
- }
474
-
475
- def counterfactual(
476
- self,
477
- observed_t: int,
478
- treatment: Optional[str] = None,
479
- cf_value: Optional[float] = None,
480
- target: str = "",
481
- treatments: Optional[Dict[str, float]] = None,
482
- ) -> Dict[str, object]:
483
- t = observed_t if observed_t >= 0 else (self.scm.t_steps + observed_t)
484
- if t < 0 or t >= self.scm.t_steps:
485
- raise ValueError(f"observed_t {observed_t} resolves out of bounds for T={self.scm.t_steps}")
486
-
487
- if treatments is None:
488
- if treatment is None or cf_value is None:
489
- raise ValueError("Either treatment/cf_value or treatments must be provided.")
490
- treatments = {treatment: cf_value}
491
- elif treatment is not None or cf_value is not None:
492
- raise ValueError("Provide either treatment/cf_value or treatments, not both.")
493
-
494
- observed = {node: float(self.scm.data_level[t, i]) for i, node in enumerate(self.scm.nodes)}
495
- result = self._counterfactual_outcome(observed, treatments, target)
496
- result["explanation"] = (
497
- f"Counterfactual computed at t={t}: set {treatments} "
498
- f"and propagated structural equations with abducted residuals."
499
- )
500
- if len(treatments) > 1:
501
- result["shapley_contributions"] = self._shapley_contributions(observed, treatments, target)
502
- else:
503
- result["shapley_contributions"] = {next(iter(treatments)): result["ite"]}
504
- result["ite_total"] = result["ite"]
505
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/causal_inference/tests/test_causal_queries.py DELETED
@@ -1,102 +0,0 @@
1
- import importlib.util
2
- import numpy as np
3
-
4
- from causal_inference.causal_model import StructuralCausalModel
5
- from causal_inference.query_engine import CausalQueryEngine
6
-
7
-
8
- def _synthetic_inputs():
9
- nodes = ["A", "B", "C"]
10
- T = 40
11
- data = np.zeros((T, 3, 1), dtype=float)
12
- rng = np.random.default_rng(42)
13
-
14
- a = rng.normal(0, 1, size=T)
15
- b = 0.6 * np.roll(a, 1) + rng.normal(0, 0.1, size=T)
16
- c = 0.7 * np.roll(b, 1) + rng.normal(0, 0.1, size=T)
17
- b[0] = rng.normal()
18
- c[0] = rng.normal()
19
- data[:, 0, 0] = a
20
- data[:, 1, 0] = b
21
- data[:, 2, 0] = c
22
-
23
- # A->B, B->C and a weak cycle C->A to exercise pruning.
24
- adj = np.array(
25
- [
26
- [0.0, 0.9, 0.0],
27
- [0.0, 0.0, 0.8],
28
- [0.1, 0.0, 0.0],
29
- ],
30
- dtype=float,
31
- )
32
- prior = np.zeros((3, 3), dtype=float)
33
- mask = np.ones((T, 3), dtype=float)
34
- return nodes, adj, prior, data, mask
35
-
36
-
37
- def test_scm_fit_and_cycle_prune():
38
- nodes, adj, prior, data, mask = _synthetic_inputs()
39
- scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
40
- assert scm.dag_adj.shape == (3, 3)
41
- assert len(scm.topological_indices) == 3
42
- assert np.sum(scm.dag_adj) <= 2
43
-
44
-
45
- def test_intervention_and_counterfactual_shapes():
46
- nodes, adj, prior, data, mask = _synthetic_inputs()
47
- scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
48
- engine = CausalQueryEngine(scm)
49
-
50
- inter = engine.intervene("A", value=0.5, targets=["B", "C"], horizon=3)
51
- assert set(inter["predicted_values"].keys()) == {"B", "C"}
52
- assert len(inter["predicted_values"]["B"]) == 3
53
-
54
- cf = engine.counterfactual(observed_t=-1, treatment="A", cf_value=0.8, target="C")
55
- assert "factual_outcome" in cf
56
- assert "counterfactual_outcome" in cf
57
- assert "ite" in cf
58
-
59
-
60
- def test_counterfactual_shapley_contributions():
61
- nodes, adj, prior, data, mask = _synthetic_inputs()
62
- scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
63
- engine = CausalQueryEngine(scm)
64
-
65
- cf = engine.counterfactual(
66
- observed_t=-1,
67
- treatments={"A": 0.8, "B": -0.3},
68
- target="C",
69
- )
70
-
71
- assert "shapley_contributions" in cf
72
- assert set(cf["shapley_contributions"].keys()) == {"A", "B"}
73
- assert abs(sum(cf["shapley_contributions"].values()) - cf["ite"]) < 1e-6
74
-
75
-
76
- def test_dowhy_validation_is_optional():
77
- nodes, adj, prior, data, mask = _synthetic_inputs()
78
- scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask).fit()
79
- engine = CausalQueryEngine(scm)
80
-
81
- result = engine.validate_with_dowhy(treatment="A", outcome="C")
82
- assert "available" in result
83
- if importlib.util.find_spec("dowhy") is None:
84
- assert result["available"] is False
85
- else:
86
- assert "falsified" in result
87
-
88
-
89
- def test_exogenous_fallback_prevention():
90
- nodes, adj, prior, data, mask = _synthetic_inputs()
91
- # Force fallback to exogenous by setting min_obs to a very large number (e.g. 100)
92
- scm = StructuralCausalModel(nodes=nodes, adj=adj, adjacency_mask=prior, prohibition_mask=prior, data_tech=data, mask_tech=mask, min_obs=100).fit()
93
- engine = CausalQueryEngine(scm)
94
-
95
- inter = engine.intervene("A", value=0.5, targets=["B", "C"], horizon=3)
96
- assert set(inter["predicted_values"].keys()) == {"B", "C"}
97
- assert len(inter["predicted_values"]["B"]) == 3
98
-
99
- cf = engine.counterfactual(observed_t=-1, treatment="A", cf_value=0.8, target="C")
100
- assert "factual_outcome" in cf
101
- assert "counterfactual_outcome" in cf
102
- assert "ite" in cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/causal_inference/tests/test_pywhyllm_assumptions.py DELETED
@@ -1,193 +0,0 @@
1
- import builtins
2
- import sys
3
- import types
4
-
5
- import numpy as np
6
-
7
- from singular_ticker_causal.causal_inference.causal_model import StructuralCausalModel
8
- from singular_ticker_causal.causal_inference.pywhyllm_assumptions import (
9
- PyWhyLLMConfig,
10
- PyWhyLLMAssumptionService,
11
- )
12
- from singular_ticker_causal.causal_inference.query_engine import CausalQueryEngine
13
-
14
-
15
- def _synthetic_scm():
16
- nodes = ["A", "B", "C"]
17
- data = np.zeros((24, 3, 1), dtype=float)
18
- rng = np.random.default_rng(11)
19
- data[:, 0, 0] = rng.normal(size=24)
20
- data[:, 1, 0] = rng.normal(size=24)
21
- data[:, 2, 0] = rng.normal(size=24)
22
- adj = np.array(
23
- [
24
- [0.0, 0.8, 0.7],
25
- [0.0, 0.0, 0.9],
26
- [0.0, 0.0, 0.0],
27
- ],
28
- dtype=float,
29
- )
30
- prior = np.ones((3, 3), dtype=float)
31
- np.fill_diagonal(prior, 0.0)
32
- return StructuralCausalModel(
33
- nodes=nodes,
34
- adj=adj,
35
- adjacency_mask=np.zeros((3, 3), dtype=float),
36
- prohibition_mask=prior,
37
- data_tech=data,
38
- ).fit()
39
-
40
-
41
- class FakeModelSuggester:
42
- def suggest_domain_expertises(self, all_factors):
43
- return ["financial accounting"]
44
-
45
- def suggest_confounders(self, treatment, outcome, all_factors, domain_expertises):
46
- return ({("B", treatment): 1, ("B", outcome): 1}, ["B", "Missing_Node"])
47
-
48
- def suggest_relationships(self, treatment, outcome, all_factors, domain_expertises, strategy):
49
- return [(treatment, outcome), ("B", outcome)]
50
-
51
-
52
- class FakeIdentificationSuggester:
53
- def suggest_backdoor(self, treatment, outcome, all_factors, domain_expertises):
54
- return ["B", "Missing_Node"]
55
-
56
- def suggest_mediators(self, treatment, outcome, all_factors, domain_expertises):
57
- return ["B"]
58
-
59
- def suggest_ivs(self, treatment, outcome, all_factors, domain_expertises):
60
- return ["A"]
61
-
62
-
63
- class FakeValidationSuggester:
64
- def critique_graph(self, all_factors, suggested_dag, domain_expertises, strategy):
65
- return "A -> C accepted; B -> C implausible, reject"
66
-
67
- def suggest_latent_confounders(self, treatment, outcome, all_factors, domain_expertises):
68
- return ["market regime"]
69
-
70
- def suggest_negative_controls(self, treatment, outcome, all_factors, domain_expertises):
71
- return ["B", "Missing_Node"]
72
-
73
-
74
- def _fake_service(tmp_path):
75
- return PyWhyLLMAssumptionService(
76
- PyWhyLLMConfig(enabled=True, cache_dir=str(tmp_path)),
77
- model_suggester=FakeModelSuggester(),
78
- identification_suggester=FakeIdentificationSuggester(),
79
- validation_suggester=FakeValidationSuggester(),
80
- relationship_strategy="pairwise",
81
- )
82
-
83
-
84
- def test_pywhyllm_assumption_service_uses_fakes_and_cache(tmp_path):
85
- scm = _synthetic_scm()
86
- service = _fake_service(tmp_path)
87
-
88
- report = service.analyze(nodes=scm.nodes, dag_adj=scm.dag_adj, treatment="A", outcome="C")
89
-
90
- assert report.available is True
91
- assert report.domain_expertises == ["financial accounting"]
92
- assert report.suggested_confounders == ["B", "Missing_Node"]
93
- assert report.suggested_backdoor_sets == [["B", "Missing_Node"]]
94
- assert report.negative_controls == ["B", "Missing_Node"]
95
- assert ("B", "C") in report.rejected_edges
96
-
97
- cached = service.analyze(nodes=scm.nodes, dag_adj=scm.dag_adj, treatment="A", outcome="C")
98
- assert cached.to_dict() == report.to_dict()
99
-
100
-
101
- def test_missing_pywhyllm_returns_unavailable(monkeypatch, tmp_path):
102
- original_import = builtins.__import__
103
-
104
- def fake_import(name, *args, **kwargs):
105
- if name.startswith("pywhyllm"):
106
- raise ImportError("blocked pywhyllm")
107
- return original_import(name, *args, **kwargs)
108
-
109
- monkeypatch.setattr(builtins, "__import__", fake_import)
110
- scm = _synthetic_scm()
111
- service = PyWhyLLMAssumptionService(PyWhyLLMConfig(enabled=True, cache_dir=str(tmp_path)))
112
-
113
- report = service.analyze(nodes=scm.nodes, dag_adj=scm.dag_adj, treatment="A", outcome="C")
114
-
115
- assert report.available is False
116
- assert "blocked pywhyllm" in report.reason
117
-
118
-
119
- def test_engine_filters_pywhyllm_adjustments_and_negative_controls(tmp_path):
120
- scm = _synthetic_scm()
121
- engine = CausalQueryEngine(scm, pywhyllm_service=_fake_service(tmp_path), pywhyllm_enabled=True)
122
-
123
- report = engine.analyze_assumptions_with_pywhyllm("A", "C")
124
- adjustment_sets = engine._valid_backdoor_sets(report, "A", "C")
125
- negative_controls = engine._valid_nodes(report["negative_controls"])
126
-
127
- assert adjustment_sets == [["B"]]
128
- assert negative_controls == ["B"]
129
-
130
-
131
- def test_placebo_not_statistically_significant_passes():
132
- scm = _synthetic_scm()
133
- engine = CausalQueryEngine(scm)
134
-
135
- parsed = engine._parse_refuter_result(
136
- "placebo_treatment",
137
- "Refute: Use a Placebo Treatment. The result is not statistically significant.",
138
- )
139
-
140
- assert parsed["passed"] is True
141
- assert parsed["falsified"] is False
142
-
143
-
144
- def test_combined_validation_reports_unavailable_dowhy(monkeypatch, tmp_path):
145
- monkeypatch.setitem(sys.modules, "dowhy", None)
146
- scm = _synthetic_scm()
147
- engine = CausalQueryEngine(scm, pywhyllm_service=_fake_service(tmp_path), pywhyllm_enabled=True)
148
-
149
- result = engine.validate_with_pywhyllm_and_dowhy("A", "C")
150
-
151
- assert result["pywhyllm"]["available"] is True
152
- assert result["dowhy"]["available"] is False
153
- assert result["dowhy"]["adjustment_candidates"] == [["B"]]
154
-
155
-
156
- def test_combined_validation_uses_fake_dowhy(monkeypatch, tmp_path):
157
- class FakeRefute:
158
- estimated_effect = 1.0
159
- new_effect = 0.0
160
- refutation_result = {"p_value": 0.8}
161
-
162
- def __str__(self):
163
- return "not statistically significant"
164
-
165
- class FakeEstimate:
166
- def __str__(self):
167
- return "estimate"
168
-
169
- class FakeCausalModel:
170
- def __init__(self, **kwargs):
171
- self.kwargs = kwargs
172
-
173
- def identify_effect(self):
174
- return "estimand"
175
-
176
- def estimate_effect(self, identified_estimand, method_name):
177
- return FakeEstimate()
178
-
179
- def refute_estimate(self, identified_estimand, estimate, **kwargs):
180
- return FakeRefute()
181
-
182
- fake_dowhy = types.ModuleType("dowhy")
183
- fake_dowhy.CausalModel = FakeCausalModel
184
- monkeypatch.setitem(sys.modules, "dowhy", fake_dowhy)
185
-
186
- scm = _synthetic_scm()
187
- engine = CausalQueryEngine(scm, pywhyllm_service=_fake_service(tmp_path), pywhyllm_enabled=True)
188
-
189
- result = engine.validate_with_pywhyllm_and_dowhy("A", "C")
190
-
191
- assert result["dowhy"]["available"] is True
192
- assert result["dowhy"]["falsified"] is False
193
- assert result["dowhy"]["negative_control_checks"][0]["control"] == "B"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/__init__.py DELETED
@@ -1,4 +0,0 @@
1
- from .fetcher import Fetcher
2
- from .gdelt_client import GDELTClient
3
- from .news_client import NewsClient
4
- from .sebi_reg30_client import SEBIREG30Client
 
 
 
 
 
singular_ticker_causal/data_sources/bsedata/__init__.py DELETED
@@ -1,27 +0,0 @@
1
- """
2
-
3
- MIT License
4
-
5
- Copyright (c) 2018 - 2024 Shrey Dabhi
6
-
7
- Permission is hereby granted, free of charge, to any person obtaining a copy
8
- of this software and associated documentation files (the "Software"), to deal
9
- in the Software without restriction, including without limitation the rights
10
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
- copies of the Software, and to permit persons to whom the Software is
12
- furnished to do so, subject to the following conditions:
13
-
14
- The above copyright notice and this permission notice shall be included in all
15
- copies or substantial portions of the Software.
16
-
17
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
- SOFTWARE.
24
-
25
- """
26
-
27
- name = "bsedata"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/bsedata/bhavcopy.py DELETED
@@ -1,58 +0,0 @@
1
- import os
2
- import io
3
- import csv
4
- import requests
5
- import tempfile
6
- import datetime
7
- from zipfile import ZipFile
8
- from .exceptions import BhavCopyNotFound
9
- from .helpers import COMMON_REQUEST_HEADERS
10
-
11
-
12
- def loadBhavCopyData(statsDate: datetime.date) -> list:
13
- tempDir = os.path.join(tempfile.gettempdir(), "bsedata")
14
- zipfileName = f"EQ{statsDate.strftime('%d%m%y')}_CSV.ZIP"
15
- r = requests.get(
16
- f"https://www.bseindia.com/download/BhavCopy/Equity/{zipfileName}",
17
- headers=COMMON_REQUEST_HEADERS,
18
- )
19
-
20
- if r.status_code != 200:
21
- raise BhavCopyNotFound()
22
-
23
- try:
24
- os.makedirs(tempDir)
25
- except FileExistsError:
26
- pass
27
-
28
- f_zip = open(os.path.join(tempDir, zipfileName), "wb+")
29
- f_zip.write(r.content)
30
- f_zip.close()
31
-
32
- output = []
33
-
34
- with ZipFile(os.path.join(tempDir, zipfileName)) as bhavCopyZip:
35
- with bhavCopyZip.open(f"EQ{statsDate.strftime('%d%m%y')}.CSV") as bhavCopyFile:
36
- reader = csv.DictReader(io.TextIOWrapper(bhavCopyFile))
37
- for row in reader:
38
- output.append(mapBhavCopyRowToDict(row))
39
-
40
- return output
41
-
42
-
43
- def mapBhavCopyRowToDict(row: dict) -> dict:
44
- SC_TYPE_MAP = {"B": "bond", "Q": "equity", "D": "debenture", "P": "preference"}
45
- return {
46
- "scripCode": row["SC_CODE"],
47
- "open": row["OPEN"],
48
- "high": row["HIGH"],
49
- "low": row["LOW"],
50
- "close": row["CLOSE"],
51
- "last": row["LAST"],
52
- "prevClose": row["PREVCLOSE"],
53
- "totalTrades": row["NO_TRADES"],
54
- "totalSharesTraded": row["NO_OF_SHRS"],
55
- "netTurnover": row["NET_TURNOV"],
56
- "scripType": SC_TYPE_MAP[row["SC_TYPE"]],
57
- "securityID": row["SC_NAME"].strip(),
58
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/bsedata/bse.py DELETED
@@ -1,150 +0,0 @@
1
- """
2
-
3
- MIT License
4
-
5
- Copyright (c) 2018 - 2024 Shrey Dabhi
6
-
7
- Permission is hereby granted, free of charge, to any person obtaining a copy
8
- of this software and associated documentation files (the "Software"), to deal
9
- in the Software without restriction, including without limitation the rights
10
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
- copies of the Software, and to permit persons to whom the Software is
12
- furnished to do so, subject to the following conditions:
13
-
14
- The above copyright notice and this permission notice shall be included in all
15
- copies or substantial portions of the Software.
16
-
17
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
- SOFTWARE.
24
-
25
- """
26
-
27
- from .gainers import getGainers
28
- from .losers import getLosers
29
- from .bhavcopy import loadBhavCopyData
30
- from .quote import quote
31
- from .indices import indices
32
- import datetime
33
- import requests
34
- import json
35
-
36
-
37
- class BSE(object):
38
- """
39
- Class which implements the functionality for
40
- Bombay Stock Exchange (BSE)
41
- """
42
-
43
- def __init__(self, update_codes=False):
44
- self.__update_codes = update_codes
45
- if update_codes:
46
- self.updateScripCodes()
47
-
48
- def topGainers(self):
49
- """
50
- :returns: A sorted list of codes of top gainers
51
- """
52
- return getGainers()
53
-
54
- def topLosers(self):
55
- """
56
- :returns: A sorted list of codes of top losers
57
- """
58
- return getLosers()
59
-
60
- def getQuote(self, scripCode):
61
- """
62
- :param scripCode: A stock code
63
- :returns: A dictionary which contain details about the stock
64
- :raises InvalidStockException: Raised for stocks which have been suspended or no longer trading on BSE
65
- """
66
- return quote(scripCode)
67
-
68
- def getIndices(self, category):
69
- """
70
- :param category: A category of indices
71
- :returns: A dictionary with details about the indices belonging to the given category
72
- """
73
- return indices(category)
74
-
75
- def updateScripCodes(self):
76
- """
77
- Download a fresh copy of the scrip code listing
78
-
79
- :returns: None
80
- """
81
- r = requests.get("https://pub-87b187a07d9c42109c9e6999439a583f.r2.dev/stk.json")
82
- f_stk = open("stk.json", "w+")
83
- f_stk.write(json.dumps(r.json()))
84
- f_stk.close()
85
- return
86
-
87
- def getBhavCopyData(self, statsDate: datetime.date):
88
- """
89
- Get historical OHLCV data from Bhav Copy released by BSE everyday after market closing.
90
- The columns available in the data and their description is as given below.
91
-
92
- .. list-table::
93
- :widths: 25 75
94
- :header-rows: 1
95
-
96
- * - Dictionary Field
97
- - Description
98
- * - scripCode
99
- - Unique code assigned to a scrip of a company by BSE
100
- * - open
101
- - The price at which the security first trades on a given trading day
102
- * - high
103
- - The highest intra-day price of a stock
104
- * - low
105
- - The lowest intra-day price of a stock
106
- * - close
107
- - The final price at which a security is traded on a given trading day
108
- * - last
109
- - The last trade price of the stock
110
- * - prevClose
111
- - The closing price of the stock for the previous trading day
112
- * - totalTrades
113
- - The total number of trades of a scrip
114
- * - totalSharesTraded
115
- - The total number of shares transacted of a scrip
116
- * - netTurnover
117
- - Total turnover of a scrip
118
- * - scripType
119
- - Scrip category: Equity, Preference, Debenture or Bond
120
- * - securityID
121
- - Name of the company
122
-
123
- The Bhav Copy files have been mapped to the above mentioned custom fields. The complete documentation for Bhav Copy can be found here: https://www.bseindia.com/markets/MarketInfo/BhavCopy.aspx.
124
-
125
-
126
- :param statsDate: A `datetime.date` object for the for which you want to fetch the data
127
- :returns: A list of dictionaries which contains OHLCV data for that day for all scrip codes active on that day
128
- :raises BhavCopyNotFound: Raised when Bhav Copy file is not found on BSE
129
- """
130
- return loadBhavCopyData(statsDate)
131
-
132
- def getScripCodes(self):
133
- """
134
- :returns: A dictionary with scrip codes as keys and company names as values
135
- """
136
- f = open("stk.json", "r")
137
- return json.loads(f.read())
138
-
139
- def verifyScripCode(self, code):
140
- """
141
- :returns: Company name if it is a valid stock code, else None
142
- """
143
- data = self.getScripCodes()
144
- return data.get(code)
145
-
146
- def __str__(self):
147
- return "Driver Class for Bombay Stock Exchange (BSE)"
148
-
149
- def __repr__(self):
150
- return f"<{self.__class__.__name__}: update_codes={self.__update_codes}> Driver Class for Bombay Stock Exchange (BSE)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/bsedata/exceptions.py DELETED
@@ -1,51 +0,0 @@
1
- """
2
-
3
- MIT License
4
-
5
- Copyright (c) 2018 - 2024 Shrey Dabhi
6
-
7
- Permission is hereby granted, free of charge, to any person obtaining a copy
8
- of this software and associated documentation files (the "Software"), to deal
9
- in the Software without restriction, including without limitation the rights
10
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
- copies of the Software, and to permit persons to whom the Software is
12
- furnished to do so, subject to the following conditions:
13
-
14
- The above copyright notice and this permission notice shall be included in all
15
- copies or substantial portions of the Software.
16
-
17
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
- SOFTWARE.
24
-
25
- """
26
-
27
-
28
- class InvalidStockException(Exception):
29
- """
30
- Exception raised for stocks which have been suspended or no longer trading on BSE.
31
-
32
- :param status: the status of the stock as mentioned on BSE website
33
- """
34
-
35
- def __init__(self, status: str = "Inactive stock"):
36
- if status == "":
37
- self.status = "Inactive stock"
38
- else:
39
- self.status = status
40
- super().__init__(self.status)
41
-
42
-
43
- class BhavCopyNotFound(Exception):
44
- """
45
- Exception raised when the BhavCopy file is not found on BSE website.
46
- """
47
-
48
- def __init__(self):
49
- super().__init__(
50
- """The BhavCopy file was not found on the BSE website. You are probably trying to get data for a trading holiday."""
51
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/bsedata/gainers.py DELETED
@@ -1,57 +0,0 @@
1
- """
2
-
3
- MIT License
4
-
5
- Copyright (c) 2018 - 2024 Shrey Dabhi
6
-
7
- Permission is hereby granted, free of charge, to any person obtaining a copy
8
- of this software and associated documentation files (the "Software"), to deal
9
- in the Software without restriction, including without limitation the rights
10
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
- copies of the Software, and to permit persons to whom the Software is
12
- furnished to do so, subject to the following conditions:
13
-
14
- The above copyright notice and this permission notice shall be included in all
15
- copies or substantial portions of the Software.
16
-
17
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
- SOFTWARE.
24
-
25
- """
26
-
27
- from .helpers import COMMON_REQUEST_HEADERS
28
- from bs4 import BeautifulSoup as bs
29
- import requests
30
-
31
-
32
- def getGainers() -> dict:
33
- baseurl = """https://m.bseindia.com"""
34
- res = requests.get(baseurl, headers=COMMON_REQUEST_HEADERS)
35
- c = res.content
36
- soup = bs(c, "lxml")
37
- for tag in soup("div"):
38
- try:
39
- if tag["id"] == "divGainers":
40
- resSoup = tag
41
- break
42
- except KeyError:
43
- continue
44
- children = list(resSoup.table.contents)
45
- children = children[1:]
46
- gainers = []
47
- for tr in children:
48
- td = tr.contents
49
- gainer = {
50
- "securityID": str(td[0].a.string),
51
- "scripCode": str(tr.td.a["href"].split("=")[1]),
52
- "LTP": str(td[1].string),
53
- "change": str(td[2].string),
54
- "pChange": str(td[3].string),
55
- }
56
- gainers.append(gainer)
57
- return gainers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/bsedata/helpers.py DELETED
@@ -1,3 +0,0 @@
1
- COMMON_REQUEST_HEADERS = {
2
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36 Edg/83.0.478.45"
3
- }
 
 
 
 
singular_ticker_causal/data_sources/bsedata/indices.py DELETED
@@ -1,112 +0,0 @@
1
- """
2
-
3
- MIT License
4
-
5
- Copyright (c) 2018 - 2024 Shrey Dabhi
6
-
7
- Permission is hereby granted, free of charge, to any person obtaining a copy
8
- of this software and associated documentation files (the "Software"), to deal
9
- in the Software without restriction, including without limitation the rights
10
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
- copies of the Software, and to permit persons to whom the Software is
12
- furnished to do so, subject to the following conditions:
13
-
14
- The above copyright notice and this permission notice shall be included in all
15
- copies or substantial portions of the Software.
16
-
17
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
- SOFTWARE.
24
-
25
- """
26
-
27
- from .helpers import COMMON_REQUEST_HEADERS
28
- from bs4 import BeautifulSoup as bs
29
- import requests
30
-
31
-
32
- def indices(category: str) -> dict:
33
- cat = {
34
- "market_cap/broad": "1,2",
35
- "sector_and_industry": "2,2",
36
- "thematics": "3,2",
37
- "strategy": "4,2",
38
- "sustainability": "5,2",
39
- "volatility": "6,1",
40
- "composite": "7,1",
41
- "government": "8,1",
42
- "corporate": "9,1",
43
- "money_market": "10,1",
44
- }
45
- try:
46
- ddl_category = cat[category]
47
- except KeyError:
48
- print(
49
- """
50
- ### Invalid category ###
51
- Use one of the categories mentioned below:
52
-
53
- market_cap/broad
54
- sector_and_industry
55
- thematics
56
- strategy
57
- sustainability
58
- volatility
59
- composite
60
- government
61
- corporate
62
- money_market
63
- """
64
- )
65
- return
66
- baseurl = """https://m.bseindia.com/IndicesView_New.aspx"""
67
- res = requests.get(baseurl, headers=COMMON_REQUEST_HEADERS)
68
- c = res.content
69
- soup = bs(c, "lxml")
70
- options = {
71
- "__EVENTTARGET": "ddl_Category",
72
- "__VIEWSTATEENCRYPTED": "",
73
- "__EVENTARGUMENT": "",
74
- "__LASTFOCUS": "",
75
- "__VIEWSTATEGENERATOR": "162C96CD",
76
- "UcHeaderMenu1$txtGetQuote": "",
77
- "__EVENTVALIDATION": "",
78
- "__VIEWSTATE": "",
79
- }
80
- for input in soup("input"):
81
- try:
82
- if input["type"] == "hidden":
83
- if input["id"] == "__VIEWSTATE":
84
- options["__VIEWSTATE"] = input["value"]
85
- elif input["id"] == "__EVENTVALIDATION":
86
- options["__EVENTVALIDATION"] = input["value"]
87
- except KeyError:
88
- continue
89
- options["ddl_Category"] = ddl_category
90
- res = requests.post(url=baseurl, data=options, headers=COMMON_REQUEST_HEADERS)
91
- c = res.content
92
- soup = bs(c, "lxml")
93
- index_list = []
94
- for td in soup("td"):
95
- try:
96
- if td["class"][0] == "TTRow_left":
97
- index = {}
98
- index["currentValue"] = td.next_sibling.string.strip()
99
- index["change"] = td.next_sibling.next_sibling.string.strip()
100
- index[
101
- "pChange"
102
- ] = td.next_sibling.next_sibling.next_sibling.string.strip()
103
- index["scripFlag"] = td.a["href"].strip().split("=")[1]
104
- index["name"] = td.a.string.strip().replace(";", "")
105
- index_list.append(index)
106
- except KeyError:
107
- continue
108
- results = {}
109
- for span in soup("span", id="inddate"):
110
- results["updatedOn"] = span.string[6:].split("|")[0].strip()
111
- results["indices"] = index_list
112
- return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/bsedata/losers.py DELETED
@@ -1,57 +0,0 @@
1
- """
2
-
3
- MIT License
4
-
5
- Copyright (c) 2018 - 2024 Shrey Dabhi
6
-
7
- Permission is hereby granted, free of charge, to any person obtaining a copy
8
- of this software and associated documentation files (the "Software"), to deal
9
- in the Software without restriction, including without limitation the rights
10
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
- copies of the Software, and to permit persons to whom the Software is
12
- furnished to do so, subject to the following conditions:
13
-
14
- The above copyright notice and this permission notice shall be included in all
15
- copies or substantial portions of the Software.
16
-
17
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
- SOFTWARE.
24
-
25
- """
26
-
27
- from .helpers import COMMON_REQUEST_HEADERS
28
- from bs4 import BeautifulSoup as bs
29
- import requests
30
-
31
-
32
- def getLosers() -> dict:
33
- baseurl = """https://m.bseindia.com"""
34
- res = requests.get(baseurl, headers=COMMON_REQUEST_HEADERS)
35
- c = res.content
36
- soup = bs(c, "lxml")
37
- for tag in soup("div"):
38
- try:
39
- if tag["id"] == "divLosers":
40
- resSoup = tag
41
- break
42
- except KeyError:
43
- continue
44
- children = list(resSoup.table.contents)
45
- children = children[1:]
46
- losers = []
47
- for tr in children:
48
- td = tr.contents
49
- loser = {
50
- "securityID": str(td[0].a.string),
51
- "scripCode": str(tr.td.a["href"].split("=")[1]),
52
- "LTP": str(td[1].string),
53
- "change": str(td[2].string),
54
- "pChange": str(td[3].string),
55
- }
56
- losers.append(loser)
57
- return losers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/bsedata/quote.py DELETED
@@ -1,176 +0,0 @@
1
- """
2
-
3
- MIT License
4
-
5
- Copyright (c) 2018 - 2024 Shrey Dabhi
6
-
7
- Permission is hereby granted, free of charge, to any person obtaining a copy
8
- of this software and associated documentation files (the "Software"), to deal
9
- in the Software without restriction, including without limitation the rights
10
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
- copies of the Software, and to permit persons to whom the Software is
12
- furnished to do so, subject to the following conditions:
13
-
14
- The above copyright notice and this permission notice shall be included in all
15
- copies or substantial portions of the Software.
16
-
17
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
- SOFTWARE.
24
-
25
- """
26
-
27
- from .exceptions import InvalidStockException
28
- from .helpers import COMMON_REQUEST_HEADERS
29
- from datetime import datetime as dt
30
- from bs4 import BeautifulSoup as bs
31
- import requests
32
-
33
-
34
- def quote(scripCode: str) -> dict:
35
- baseurl = """https://m.bseindia.com/StockReach.aspx?scripcd="""
36
- res = requests.get(baseurl + scripCode, headers=COMMON_REQUEST_HEADERS)
37
- c = res.content
38
- soup = bs(c, "lxml")
39
-
40
- res = {}
41
-
42
- for span in soup("span"):
43
- updt_date = soup.find("span", id="strongDate").text.split("-")[1].strip()
44
- updt_diff = dt.strptime(updt_date, "%d %b %y | %I:%M %p") - dt.now()
45
- if updt_diff.days < -7:
46
- error_text = ""
47
- error_text_element = soup.find("td", id="tdDispTxt")
48
- if error_text_element is not None:
49
- error_text = error_text_element.text
50
- raise InvalidStockException(status=error_text)
51
- try:
52
- if span["class"][0] == "srcovalue":
53
- try:
54
- if span["id"] == "spanchangVal":
55
- res["change"] = span.string.split("(")[0].strip()
56
- res["pChange"] = span.string.split("(")[1].strip()[:-2]
57
- except KeyError:
58
- res["currentValue"] = span.strong.string
59
- elif span["class"][0] == "companyname":
60
- res["companyName"] = span.string
61
- except KeyError:
62
- try:
63
- if span["id"] == "lblPBdate":
64
- try:
65
- res["priceBand"] = span.string.split(":")[1].strip()
66
- except AttributeError:
67
- res["priceBand"] = ""
68
- elif span["id"] == "strongDate":
69
- res["updatedOn"] = span.string.split("-")[1].strip()
70
- except KeyError:
71
- continue
72
-
73
- for td in soup("td"):
74
- try:
75
- if td["id"] == "tdCShortName":
76
- res["securityID"] = td.string.strip()
77
- elif td["id"] == "tdscripcode":
78
- res["scripCode"] = td.string.strip()
79
- elif td["id"] == "tdgroup":
80
- res["group"] = td.string.strip()
81
- elif td["id"] == "tdfacevalue":
82
- res["faceValue"] = td.string.strip()
83
- elif td["id"] == "tdIndustry":
84
- res["industry"] = td.string.strip()
85
- elif td["id"] == "tdpcloseopen":
86
- res["previousClose"] = td.string.split("/")[0].strip()
87
- res["previousOpen"] = td.string.split("/")[1].strip()
88
- elif td["id"] == "tdDHL":
89
- res["dayHigh"] = td.string.split("/")[0].strip()
90
- res["dayLow"] = td.string.split("/")[1].strip()
91
- elif td["id"] == "td52WHL":
92
- res["52weekHigh"] = td.string.split("/")[0].strip()
93
- res["52weekLow"] = td.string.split("/")[1].strip()
94
- elif td["id"] == "tdWAp":
95
- res["weightedAvgPrice"] = td.string.strip()
96
- elif td["id"] == "tdTTV":
97
- res["totalTradedValue"] = td.string.strip() + " Cr."
98
- elif td["id"] == "tdTTQW":
99
- res["totalTradedQuantity"] = td.string.split("/")[0].strip() + " Lakh"
100
- res["2WeekAvgQuantity"] = td.string.split("/")[1].strip() + " Lakh"
101
- elif td["id"] == "tdMktCapVal":
102
- res["marketCapFull"] = td.string.split("/")[0].strip() + " Cr."
103
- res["marketCapFreeFloat"] = td.string.split("/")[1].strip() + " Cr."
104
- except KeyError:
105
- continue
106
-
107
- if res.get("priceBand", "") != "":
108
- for tbody in soup("tbody"):
109
- try:
110
- if tbody["id"] == "PBtablebody":
111
- data = tbody.contents[2]
112
- res["upperPriceBand"] = data.contents[1].string.strip()
113
- res["lowerPriceBand"] = data.contents[2].string.strip()
114
- except KeyError:
115
- continue
116
-
117
- buy = {}
118
- sell = {}
119
- for td in soup("td"):
120
- try:
121
- if td["id"] == "tdBQ1":
122
- buy["1"] = {
123
- "quantity": td.string,
124
- "price": td.next_sibling.next_sibling.string,
125
- }
126
- elif td["id"] == "tdBQ2":
127
- buy["2"] = {
128
- "quantity": td.string,
129
- "price": td.next_sibling.next_sibling.string,
130
- }
131
- elif td["id"] == "tdBQ3":
132
- buy["3"] = {
133
- "quantity": td.string,
134
- "price": td.next_sibling.next_sibling.string,
135
- }
136
- elif td["id"] == "tdBQ4":
137
- buy["4"] = {
138
- "quantity": td.string,
139
- "price": td.next_sibling.next_sibling.string,
140
- }
141
- elif td["id"] == "tdBQ5":
142
- buy["5"] = {
143
- "quantity": td.string,
144
- "price": td.next_sibling.next_sibling.string,
145
- }
146
- elif td["id"] == "tdSP1":
147
- sell["1"] = {
148
- "price": td.string,
149
- "quantity": td.next_sibling.next_sibling.string,
150
- }
151
- elif td["id"] == "tdSP2":
152
- sell["2"] = {
153
- "price": td.string,
154
- "quantity": td.next_sibling.next_sibling.string,
155
- }
156
- elif td["id"] == "tdSP3":
157
- sell["3"] = {
158
- "price": td.string,
159
- "quantity": td.next_sibling.next_sibling.string,
160
- }
161
- elif td["id"] == "tdSP4":
162
- sell["4"] = {
163
- "price": td.string,
164
- "quantity": td.next_sibling.next_sibling.string,
165
- }
166
- elif td["id"] == "tdSP5":
167
- sell["5"] = {
168
- "price": td.string,
169
- "quantity": td.next_sibling.next_sibling.string,
170
- }
171
- except KeyError:
172
- continue
173
- res["buy"] = buy
174
- res["sell"] = sell
175
-
176
- return res
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/fetcher.py DELETED
@@ -1,219 +0,0 @@
1
- import pandas as pd
2
- import numpy as np
3
- import yfinance as yf
4
- import logging
5
- from typing import Optional
6
- from singular_ticker_causal.services.schema import INCOME_STATEMENT_NODES, BALANCE_SHEET_NODES, STRATEGIC_NODES
7
- IND_AS_NODES = INCOME_STATEMENT_NODES + BALANCE_SHEET_NODES + STRATEGIC_NODES
8
- from .nseconnect.nse import Nse
9
- from .bsedata.bse import BSE
10
-
11
-
12
- logger = logging.getLogger(__name__)
13
-
14
-
15
- class Fetcher:
16
- """
17
- Tiered fundamental data fetcher.
18
- Priority: NSE/BSE → XBRL → IndianAPI → yfinance.
19
- """
20
- def __init__(self, ticker: str, api_key: Optional[str] = None):
21
- self.ticker = ticker if ticker.endswith(".NS") or ticker.endswith(".BO") else f"{ticker}.NS"
22
- self.api_key = api_key
23
-
24
- def fetch(self, start: str, end: str) -> pd.DataFrame:
25
- logger.info(f"Fetching fundamentals for {self.ticker} from {start} to {end}")
26
-
27
- df = None
28
- if self.ticker.endswith(".NS"):
29
- logger.info("Attempting NSE fetch...")
30
- df = self._try_nse(start, end)
31
- elif self.ticker.endswith(".BO"):
32
- logger.info("Attempting BSE fetch...")
33
- df = self._try_bse(start, end)
34
-
35
- if df is None or df.empty:
36
- logger.info("Attempting Tier 1: XBRL...")
37
- df = self._try_xbrl(start, end)
38
- if df is None or df.empty:
39
- logger.info("Tier 1 failed or returned empty. Attempting Tier 2: IndianAPI...")
40
- df = self._try_indianapi(start, end)
41
- if df is None or df.empty:
42
- logger.info("Tier 2 failed or returned empty. Attempting Tier 3: yfinance...")
43
- df = self._try_yfinance(start, end)
44
-
45
- if df is not None and not df.empty:
46
- df = df.sort_index()
47
- # Forward fill balance sheet items as they are point-in-time and usually stable
48
- # We do this AFTER mapping in _try_yfinance, so we use mapped names
49
- bs_cols = ["Total_Assets", "Shareholders_Equity", "Inventory", "Accounts_Payable", "Total_Debt", "PPE", "CWIP", "Intangible_Assets"]
50
- available_bs_cols = [c for c in bs_cols if c in df.columns]
51
- if available_bs_cols:
52
- df[available_bs_cols] = df[available_bs_cols].ffill()
53
-
54
- if df is None or df.empty:
55
- logger.warning(f"No fundamental data found for {self.ticker}")
56
- return pd.DataFrame(columns=IND_AS_NODES)
57
-
58
- logger.info("Deriving strategic nodes...")
59
- df = self._derive_strategic_nodes(df)
60
-
61
- # Ensure all IND_AS_NODES are present
62
- for node in IND_AS_NODES:
63
- if node not in df.columns:
64
- df[node] = np.nan
65
-
66
- return df[IND_AS_NODES].sort_index()
67
-
68
- def _try_nse(self, start: str, end: str) -> Optional[pd.DataFrame]:
69
- # Using nseconnect for high-fidelity NSE data
70
- try:
71
- # Clean ticker (e.g. RELIANCE.NS -> RELIANCE)
72
- clean_ticker = self.ticker.split('.')[0]
73
- nse = Nse()
74
- logger.info(f"Attempting to fetch NSE data for {clean_ticker}...")
75
- # Note: nseconnect is primarily for quotes;
76
- # for full fundamentals we still rely on yfinance or XBRL.
77
- # We return None here to let it fall back, but the plumbing is now real.
78
- quote = nse.get_quote(clean_ticker)
79
- if quote:
80
- logger.info(f"Successfully connected to NSE for {clean_ticker}")
81
- return None
82
- except Exception as e:
83
- logger.error(f"Error fetching from NSE: {e}")
84
- return None
85
-
86
- def _try_bse(self, start: str, end: str) -> Optional[pd.DataFrame]:
87
- # Using bsedata for high-fidelity BSE data
88
- try:
89
- # TODO: Implement mapping from alphabetic ticker to numeric BSE scrip code
90
- bse = BSE()
91
- logger.info(f"Attempting to fetch BSE data for {self.ticker}...")
92
- # Currently limited to quotes; returning None to fall back to yfinance
93
- return None
94
- except Exception as e:
95
- logger.error(f"Error fetching from BSE: {e}")
96
- return None
97
-
98
- def _try_xbrl(self, start: str, end: str) -> Optional[pd.DataFrame]:
99
- # Tier 1 extraction via python-xbrl / Arelle is currently in development
100
- return None
101
-
102
- def _try_indianapi(self, start: str, end: str) -> Optional[pd.DataFrame]:
103
- # Tier 2 integration for IndianAPI.in / FinEdge API is currently in development
104
- return None
105
-
106
- def _try_yfinance(self, start: str, end: str) -> Optional[pd.DataFrame]:
107
- try:
108
- t = yf.Ticker(self.ticker)
109
- q_fin = t.quarterly_financials.T
110
- q_bs = t.quarterly_balance_sheet.T
111
- q_cf = t.quarterly_cashflow.T
112
-
113
- if q_fin.empty and q_bs.empty and q_cf.empty:
114
- logger.warning("All yfinance statements (financials, balance_sheet, cashflow) are empty.")
115
- return None
116
-
117
- # Merge all three statements
118
- logger.info(f"Merging yfinance statements: q_fin={q_fin.shape}, q_bs={q_bs.shape}, q_cf={q_cf.shape}")
119
- df = pd.concat([q_fin, q_bs, q_cf], axis=1)
120
-
121
-
122
- df = df.loc[:, ~df.columns.duplicated()] # Remove duplicate columns if any
123
- logger.info(f"Merged shape after removing duplicates: {df.shape}")
124
-
125
- # Map yfinance columns to IND_AS_NODES (Simplified mapping for MVP)
126
- mapping = {
127
- "Total Revenue": "Revenue",
128
- "Cost Of Revenue": "COGS",
129
- "Operating Expense": "Operating_Expenses",
130
- "Operating Income": "EBIT",
131
- "EBIT": "EBIT",
132
- "EBITDA": "EBITDA",
133
- "Interest Expense": "Interest_Expense",
134
- "Pretax Income": "EBT",
135
- "Tax Provision": "Tax_Expense",
136
- "Net Income": "PAT",
137
- "Total Assets": "Total_Assets",
138
- "Stockholders Equity": "Shareholders_Equity",
139
- "Depreciation And Amortization": "D_A",
140
- "Inventory": "Inventory",
141
- "Accounts Payable": "Accounts_Payable",
142
- "Total Debt": "Total_Debt",
143
- "Operating Cash Flow": "Operating_Cash_Flow",
144
- "Capital Expenditure": "Capex",
145
- "Net PPE": "PPE",
146
- "Construction In Progress": "CWIP",
147
- "Goodwill And Other Intangible Assets": "Intangible_Assets",
148
- }
149
-
150
- df = df.rename(columns=mapping)
151
- df = df.loc[:, ~df.columns.duplicated()] # Remove duplicates after rename
152
- df.index = pd.to_datetime(df.index)
153
- return df
154
- except Exception as e:
155
- logger.error(f"Error fetching from yfinance: {e}")
156
- return None
157
-
158
-
159
- def _derive_strategic_nodes(self, df: pd.DataFrame) -> pd.DataFrame:
160
- """
161
- Compute Layer 3 nodes and other derived fields.
162
- """
163
- df = df.copy()
164
-
165
- # Helper to safely get a series from a column that might be a DataFrame
166
- def get_series(name):
167
- if name not in df.columns:
168
- return None
169
- col = df[name]
170
- if isinstance(col, pd.DataFrame):
171
- logger.warning(f"Column '{name}' is a DataFrame with multiple columns: {col.columns.tolist()}. Taking the first.")
172
- return col.iloc[:, 0]
173
- return col
174
-
175
- # 1. Average Assets & Equity (Rolling 2-period mean)
176
- assets = get_series("Total_Assets")
177
- if assets is not None:
178
- df["Average_Total_Assets"] = assets.rolling(window=2).mean().fillna(assets)
179
-
180
- equity = get_series("Shareholders_Equity")
181
- if equity is not None:
182
- df["Average_Shareholders_Equity"] = equity.rolling(window=2).mean().fillna(equity)
183
-
184
- # 2. Basic derivations
185
- rev = get_series("Revenue")
186
- cogs = get_series("COGS")
187
- if rev is not None and cogs is not None:
188
- df["Gross_Profit"] = rev - cogs.fillna(0)
189
-
190
- ebit = get_series("EBIT")
191
- da = get_series("D_A")
192
- if ebit is not None:
193
- # Proper EBITDA = EBIT + Depreciation & Amortization
194
- df["EBITDA"] = ebit + da.fillna(0) if da is not None else ebit
195
-
196
- pat = get_series("PAT")
197
- if pat is not None and rev is not None:
198
- df["Net_Profit_Margin"] = pat / rev.replace(0, np.nan)
199
-
200
- avg_assets = get_series("Average_Total_Assets")
201
- if rev is not None and avg_assets is not None:
202
- df["Asset_Turnover"] = rev / avg_assets.replace(0, np.nan)
203
-
204
- avg_equity = get_series("Average_Shareholders_Equity")
205
- if avg_assets is not None and avg_equity is not None:
206
- df["Equity_Multiplier"] = avg_assets / avg_equity.replace(0, np.nan)
207
-
208
- npm = get_series("Net_Profit_Margin")
209
- at = get_series("Asset_Turnover")
210
- em = get_series("Equity_Multiplier")
211
- if npm is not None and at is not None and em is not None:
212
- df["ROE"] = npm * at * em
213
-
214
- ocf = get_series("Operating_Cash_Flow")
215
- capex = get_series("Capex")
216
- if ocf is not None and capex is not None:
217
- df["Free_Cash_Flow"] = ocf - capex.abs().fillna(0)
218
-
219
- return df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/gdelt_client.py DELETED
@@ -1,400 +0,0 @@
1
- import time
2
- import random
3
- import logging
4
- import requests
5
- import yfinance as yf
6
- from datetime import datetime, timezone
7
- from typing import List, Dict, Any
8
- from singular_ticker_causal.utils.llm_client import LLMClient
9
-
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- # ==============================================================
15
- # GDELT DOC 2.0 FETCHER
16
- # ==============================================================
17
-
18
- def _fmt_gdelt_dt(dt: datetime) -> str:
19
- """
20
- Convert datetime -> GDELT YYYYMMDDHHMMSS (UTC).
21
- """
22
- if dt.tzinfo is not None:
23
- dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
24
-
25
- return dt.strftime("%Y%m%d%H%M%S")
26
-
27
-
28
- def _dedupe_articles(articles: list[dict]) -> list[dict]:
29
- """
30
- Deduplicate by URL/title combination.
31
- """
32
- seen = set()
33
- deduped = []
34
-
35
- for article in articles:
36
- key = (
37
- article.get("link", "").strip(),
38
- article.get("title", "").strip().lower(),
39
- )
40
-
41
- if key in seen:
42
- continue
43
-
44
- seen.add(key)
45
- deduped.append(article)
46
-
47
- return deduped
48
-
49
-
50
- class GDELTClient:
51
- """
52
- Thin wrapper around GDELT Doc API for macro context.
53
- """
54
- GDELT_DOC_API: str = "https://api.gdeltproject.org/api/v2/doc/doc"
55
- GDELT_MAX_RECORDS: int = 250
56
- GDELT_MAX_WINDOW_DAYS: int = 90
57
- USER_AGENT: str = (
58
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
59
- "Gecko/20100101 Firefox/128.0"
60
- )
61
-
62
- def __init__(self):
63
- pass
64
-
65
- def fetch(
66
- self,
67
- keyword: str,
68
- from_dt: datetime,
69
- to_dt: datetime,
70
- ticker: str | None = None,
71
- **kwargs,
72
- ) -> List[Dict[str, Any]]:
73
- """
74
- Main entry point for GDELT fetching.
75
- If 'ticker' is provided, performs a broad generic search using yfinance context.
76
- """
77
- if ticker:
78
- return self.fetch_ticker_context(ticker, from_dt, to_dt, **kwargs)
79
-
80
- logger.info(
81
- "Fetching GDELT news for %s from %s to %s",
82
- keyword,
83
- from_dt,
84
- to_dt,
85
- )
86
-
87
- try:
88
- articles = self.fetch_gdelt_window(
89
- keyword=keyword,
90
- from_dt=from_dt,
91
- to_dt=to_dt,
92
- **kwargs,
93
- )
94
-
95
- for article in articles:
96
- article["credibility_weight"] = 0.5
97
- article["source_type"] = "gdelt"
98
-
99
- return articles
100
-
101
- except Exception as e:
102
- logger.exception("Error fetching from GDELT: %s", e)
103
- return []
104
-
105
- def _extract_search_terms(self, summary: str, company_name: str) -> Dict[str, List[str]]:
106
- """Extract product, partner, and industry search terms from business summary using LLM."""
107
- if not summary:
108
- return {"products": [], "partners": [], "industry": []}
109
-
110
- llm = LLMClient()
111
- prompt = f"""
112
- Extract major products/services, strategic partners, and industry-related details from the following summary of {company_name}.
113
-
114
- Summary: {summary}
115
-
116
- Important instructions:
117
- 1. For partners, include a few words describing the specific connection or nature of the relationship between {company_name} and the partner (e.g., "{company_name} strategic collaboration with [Partner]").
118
- 2. For industry, extract a few words describing the sector or niche {company_name} is connected to and its specific role/connection (e.g., "{company_name} provides [Services] in the [Industry] sector").
119
- 3. Keep it to the top 5 most relevant terms for products and partners, and top 3 for industry.
120
-
121
- Return a JSON object with:
122
- "products": ["product1", "product2", ...],
123
- "partners": ["{company_name} [connection] [partner1]", ...],
124
- "industry": ["{company_name} [role/connection] [industry]", ...]
125
-
126
- Return ONLY valid JSON.
127
- """
128
- try:
129
- result = llm.chat_json([{"role": "user", "content": prompt}])
130
- return {
131
- "products": result.get("products", []),
132
- "partners": result.get("partners", []),
133
- "industry": result.get("industry", [])
134
- }
135
- except Exception as e:
136
- logger.error(f"[GDELT] LLM term extraction failed: {e}")
137
- return {"products": [], "partners": [], "industry": []}
138
-
139
- def fetch_ticker_context(
140
- self,
141
- ticker_symbol: str,
142
- from_dt: datetime,
143
- to_dt: datetime,
144
- **kwargs
145
- ) -> List[Dict[str, Any]]:
146
- """
147
- Generic fetcher that uses yfinance and LLM to build a broad context.
148
- Sequence: products, partners, company name, company heads.
149
- """
150
- logger.info(f"[GDELT] Fetching generic context for ticker: {ticker_symbol}")
151
-
152
- try:
153
- ticker = yf.Ticker(ticker_symbol)
154
- info = ticker.info
155
- except Exception as e:
156
- logger.error(f"[GDELT] yfinance failed for {ticker_symbol}: {e}")
157
- return []
158
-
159
- summary = info.get("longBusinessSummary", "")
160
- company_name = info.get("longName") or info.get("shortName") or ticker_symbol
161
- officers = info.get("companyOfficers", [])
162
-
163
- # 1. LLM extraction
164
- terms = self._extract_search_terms(summary, company_name)
165
- products = terms.get("products", [])
166
- partners = terms.get("partners", [])
167
- industry = terms.get("industry", [])
168
-
169
- # 2. Officer names
170
- heads = [o.get("name") for o in officers if o.get("name")]
171
-
172
- # 3. Execution sequence
173
- all_articles = []
174
- search_plan = [
175
- ("products", products),
176
- ("partners", partners),
177
- ("industry", industry),
178
- ("company_name", [company_name]),
179
- ("company_heads", heads)
180
- ]
181
-
182
- for category, keywords in search_plan:
183
- for kw in keywords:
184
- if not kw: continue
185
- logger.info(f"[GDELT] Scraping category '{category}': {kw}")
186
- results = self.fetch_gdelt_window(
187
- keyword=kw,
188
- from_dt=from_dt,
189
- to_dt=to_dt,
190
- **kwargs
191
- )
192
- all_articles.extend(results)
193
- # Polite pause to avoid aggressive rate limiting
194
- time.sleep(random.uniform(2.0, 5.0))
195
-
196
- return _dedupe_articles(all_articles)
197
-
198
- def fetch_gdelt_window(
199
- self,
200
- keyword: str,
201
- from_dt: datetime,
202
- to_dt: datetime,
203
- max_records: int = 250,
204
- exact_phrase: bool = False,
205
- source_country: str | None = None,
206
- source_lang: str | None = None,
207
- theme: str | None = None,
208
- domain: str | None = None,
209
- extra_query: str | None = None,
210
- ) -> list:
211
- """
212
- Fetch historical articles from the GDELT DOC 2.0 API.
213
- Includes robust retry logic with 5-minute cooldown for timeouts/rate-limits.
214
- """
215
- if from_dt >= to_dt:
216
- raise ValueError("from_dt must be earlier than to_dt")
217
-
218
- window_days = (to_dt - from_dt).days
219
- if window_days > self.GDELT_MAX_WINDOW_DAYS:
220
- raise ValueError(
221
- f"GDELT DOC API only supports ~{self.GDELT_MAX_WINDOW_DAYS} days history"
222
- )
223
-
224
- max_records = min(max_records, self.GDELT_MAX_RECORDS)
225
- query = self._build_gdelt_query(
226
- keyword=keyword,
227
- exact_phrase=exact_phrase,
228
- source_country=source_country,
229
- source_lang=source_lang,
230
- theme=theme,
231
- domain=domain,
232
- extra_query=extra_query,
233
- )
234
-
235
- params = {
236
- "query": query,
237
- "mode": "artlist",
238
- "format": "json",
239
- "maxrecords": max_records,
240
- "sort": "DateDesc",
241
- "STARTDATETIME": _fmt_gdelt_dt(from_dt),
242
- "ENDDATETIME": _fmt_gdelt_dt(to_dt),
243
- }
244
-
245
- max_retries = 5
246
- for attempt in range(max_retries):
247
- try:
248
- resp = requests.get(
249
- self.GDELT_DOC_API,
250
- params=params,
251
- timeout=45,
252
- headers={
253
- "User-Agent": self.USER_AGENT,
254
- "Accept": "application/json",
255
- },
256
- )
257
-
258
- if resp.status_code == 429:
259
- logger.warning(
260
- "[GDELT] Rate limited (429). Cooling down for 5 minutes..."
261
- )
262
- time.sleep(305)
263
- continue
264
-
265
- resp.raise_for_status()
266
-
267
- try:
268
- data = resp.json()
269
- except Exception as e:
270
- logger.error("[GDELT] Invalid JSON response (Status %d): %s", resp.status_code, e)
271
- # Log the body to see what GDELT is actually returning (likely HTML error)
272
- body_snippet = resp.text[:500] if resp.text else "[Empty Response]"
273
- logger.error("[GDELT] Response body snippet: %s", body_snippet)
274
-
275
- # If it's an HTML error page, we might be blocked or throttled in a way that doesn't return 429
276
- if "<html" in body_snippet.lower():
277
- logger.warning("[GDELT] Received HTML instead of JSON. Cooling down for 5 minutes...")
278
- time.sleep(305)
279
- else:
280
- time.sleep(10 * (attempt + 1))
281
- continue
282
-
283
- raw_articles = data.get("articles", [])
284
- if not isinstance(raw_articles, list):
285
- return []
286
-
287
- results = []
288
- for article in raw_articles:
289
- title = article.get("title", "") or ""
290
- url = article.get("url", "") or ""
291
- if not title or not url:
292
- continue
293
-
294
- normalized = {
295
- "feed_name": "GDELT",
296
- "title": title.strip(),
297
- "link": url.strip(),
298
- "published": article.get("seendate", ""),
299
- "summary": title.strip(),
300
- "language": article.get("language", ""),
301
- "sourcecountry": article.get("sourcecountry", ""),
302
- "domain": article.get("domain", ""),
303
- "socialimage": article.get("socialimage", ""),
304
- }
305
- results.append(normalized)
306
-
307
- results = _dedupe_articles(results)
308
- logger.info(
309
- "[GDELT] Retrieved %d articles for '%s'",
310
- len(results),
311
- keyword,
312
- )
313
- return results
314
-
315
- except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
316
- logger.warning(
317
- "[GDELT] Timeout/Connection error on attempt %d/%d: %s. Cooling down 5 mins...",
318
- attempt + 1, max_retries, e
319
- )
320
- time.sleep(305)
321
- continue
322
-
323
- except requests.exceptions.RequestException as e:
324
- wait_time = min(60, (2 ** attempt) * 5 + random.uniform(0, 3))
325
- logger.warning(
326
- "[GDELT] Request error on attempt %d/%d: %s. Retrying in %.2fs",
327
- attempt + 1, max_retries, e, wait_time
328
- )
329
- time.sleep(wait_time)
330
-
331
- logger.error("[GDELT] Failed after %d retries", max_retries)
332
- return []
333
-
334
- def _build_gdelt_query(
335
- self,
336
- keyword: str,
337
- exact_phrase: bool = False,
338
- source_country: str | None = None,
339
- source_lang: str | None = None,
340
- theme: str | None = None,
341
- domain: str | None = None,
342
- extra_query: str | None = None,
343
- ) -> str:
344
- """Build a valid GDELT DOC API query string."""
345
- query_parts = []
346
- if keyword:
347
- if exact_phrase and " " in keyword:
348
- query_parts.append(f'"{keyword}"')
349
- else:
350
- query_parts.append(keyword)
351
-
352
- if source_country:
353
- query_parts.append(f"sourcecountry:{source_country.lower()}")
354
- if source_lang:
355
- query_parts.append(f"sourcelang:{source_lang.lower()}")
356
- if theme:
357
- query_parts.append(f"theme:{theme}")
358
- if domain:
359
- query_parts.append(f"domain:{domain}")
360
- if extra_query:
361
- query_parts.append(extra_query)
362
-
363
- return " ".join(query_parts)
364
-
365
-
366
- if __name__ == "__main__":
367
- import json
368
- from datetime import timedelta
369
-
370
- # Configure logging
371
- logging.basicConfig(
372
- level=logging.INFO,
373
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
374
- )
375
-
376
- client = GDELTClient()
377
-
378
- # Test parameters
379
- ticker = "RELIANCE.NS"
380
- end_date = datetime.now(timezone.utc)
381
- start_date = end_date - timedelta(days=3)
382
-
383
- print(f"\n--- Testing GDELT Ticker Context Fetch: {ticker} ---")
384
- try:
385
- articles = client.fetch(
386
- keyword="",
387
- from_dt=start_date,
388
- to_dt=end_date,
389
- ticker=ticker
390
- )
391
-
392
- output_file = "gdelt.json"
393
- with open(output_file, "w") as f:
394
- json.dump(articles, f, indent=4)
395
-
396
- print(f"Successfully fetched {len(articles)} articles.")
397
- print(f"Output saved to {output_file}")
398
-
399
- except Exception as e:
400
- print(f"Test failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/news_client.py DELETED
@@ -1,460 +0,0 @@
1
- """
2
- singular_ticker_causal/data_sources/news_client.py
3
-
4
- Fetches quarterly RSS news for a single ticker over a multi-year window.
5
- - Operates on a single ticker (not a universe)
6
- - Fetches quarterly windows across the full fundamental date range
7
- - Returns articles structured for DenoisedNewsEncoder consumption
8
-
9
- Sources:
10
- • LiveMint RSS feeds
11
- • CNBC-TV18 RSS feeds
12
- • Other RSS feeds (Business Standard, Forbes India, Zee News, Economic Times, etc.)
13
- • Trading Economics (Selenium — stream, economy, markets, India news)
14
- • Zerodha Pulse (requests + BeautifulSoup)
15
- """
16
-
17
- import os
18
- import json
19
- import time
20
- import logging
21
- import datetime
22
- import requests
23
- import feedparser
24
- import yfinance as yf
25
- from typing import List
26
- from bs4 import BeautifulSoup
27
- from selenium import webdriver
28
- from selenium.webdriver.firefox.options import Options as FirefoxOptions
29
- from selenium.webdriver.firefox.service import Service as FirefoxService
30
- from selenium.webdriver.common.by import By
31
- from selenium.webdriver.support.ui import WebDriverWait
32
- from selenium.webdriver.support import expected_conditions as EC
33
-
34
-
35
- logger = logging.getLogger(__name__)
36
-
37
-
38
- # ==============================================================
39
- # SCRAPER SOURCES
40
- # ==============================================================
41
-
42
- LIVEMINT_FEEDS = [
43
- "https://www.livemint.com/rss/companies",
44
- "https://www.livemint.com/rss/opinion",
45
- "https://www.livemint.com/rss/money",
46
- "https://www.livemint.com/rss/politics",
47
- "https://www.livemint.com/rss/science",
48
- "https://www.livemint.com/rss/industry",
49
- "https://www.livemint.com/rss/education",
50
- "https://www.livemint.com/rss/sports",
51
- "https://www.livemint.com/rss/technology",
52
- "https://www.livemint.com/rss/news",
53
- "https://www.livemint.com/rss/markets",
54
- "https://www.livemint.com/rss/AI",
55
- "https://www.livemint.com/rss/insurance",
56
- "https://www.livemint.com/rss/budget",
57
- "https://www.livemint.com/rss/elections",
58
- ]
59
-
60
- CNBC18_FEEDS = [
61
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/latest.xml",
62
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/india.xml",
63
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/economy.xml",
64
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/market.xml",
65
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/business.xml",
66
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/sports.xml",
67
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/politics.xml",
68
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/world.xml",
69
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/education.xml",
70
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/travel.xml",
71
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/auto.xml",
72
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/technology.xml",
73
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/personal-finance.xml",
74
- "https://www.cnbctv18.com/commonfeeds/v1/cne/rss/web-stories.xml",
75
- ]
76
-
77
- OTHER_FEEDS = [
78
- "http://www.business-standard.com/rss/todays-paper.rss",
79
- "https://www.forbesindia.com/commonfeeds/v1/frb/rss/blog.xml",
80
- "http://zeenews.india.com/rss/business.xml",
81
- "https://economictimes.indiatimes.com/rssfeedsdefault.cms",
82
- "https://news.google.com/rss?cf=all&hl=en-IN&topic=b&gl=IN&ceid=IN:en",
83
- "https://cfo.economictimes.indiatimes.com/rss/topstories",
84
- "https://cfo.economictimes.indiatimes.com/rss/recentstories",
85
- "https://cfo.economictimes.indiatimes.com/rss/corporate-finance",
86
- "https://cfo.economictimes.indiatimes.com/rss/esg",
87
- "https://cfo.economictimes.indiatimes.com/rss/cfo-tech",
88
- "https://cfo.economictimes.indiatimes.com/rss/governance-risk-compliance",
89
- "https://cfo.economictimes.indiatimes.com/rss/lateststories",
90
- ]
91
-
92
- TE_SOURCES = {
93
- "te_stream": "https://tradingeconomics.com/stream",
94
- "te_economy": "https://tradingeconomics.com/stream?i=economy",
95
- "te_markets": "https://tradingeconomics.com/stream?i=markets",
96
- "te_india": "https://tradingeconomics.com/india/news",
97
- }
98
-
99
- ZERODHA_PULSE_URL = "https://pulse.zerodha.com/"
100
-
101
-
102
- # ==============================================================
103
- # TRADING ECONOMICS Selenium Scraper
104
- # ==============================================================
105
-
106
- JS_SCROLL_DOWN = "window.scrollTo(0, document.body.scrollHeight); return document.body.scrollHeight;"
107
-
108
- JS_EXTRACT_TE_NEWS = """
109
- var items = [];
110
- var listItems = document.querySelectorAll('li[id]');
111
- listItems.forEach(function(li) {
112
- var titleLink = li.querySelector('a[href]');
113
- if (!titleLink) return;
114
- var titleText = '';
115
- var bTag = titleLink.querySelector('b');
116
- if (bTag) { titleText = bTag.textContent.trim(); }
117
- else { titleText = titleLink.textContent.trim(); }
118
- if (!titleText) return;
119
- var url = titleLink.getAttribute('href') || '';
120
- if (url && !url.startsWith('http')) { url = 'https://tradingeconomics.com' + url; }
121
- var descEl = li.querySelector('.te-stream-item-description, span[style]');
122
- var description = descEl ? descEl.textContent.trim() : '';
123
- var dateEl = li.querySelector('small');
124
- var dateText = dateEl ? dateEl.textContent.trim() : '';
125
- var countryEl = li.querySelector('.te-stream-country');
126
- var country = countryEl ? countryEl.textContent.trim() : '';
127
- var categoryEl = li.querySelector('.te-stream-category');
128
- var category = categoryEl ? categoryEl.textContent.trim() : '';
129
- items.push({ title: titleText, description: description, date: dateText, url: url, country: country, category: category });
130
- });
131
- return JSON.stringify(items);
132
- """
133
-
134
-
135
- class NewsClient:
136
- """
137
- Fetches news for a single ticker across a multi-year window by
138
- chunking into quarterly GDELT calls. Implements the same cache-first
139
- pattern as causal.test_causal_flow.test_causal_with_text.
140
-
141
- Usage:
142
- client = NewsClient("RELIANCE")
143
- articles = client.fetch("2022-01-01", "2026-04-30")
144
- # articles: List[dict] with keys: title, content, published, url, credibility_weight, source
145
- """
146
- USER_AGENT: str = (
147
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
148
- "Gecko/20100101 Firefox/128.0"
149
- )
150
-
151
- def __init__(self, ticker: str):
152
- self.ticker = ticker.upper().replace(".NS", "")
153
- self.keyword = self._get_ticker_keyword(self.ticker)
154
-
155
- def _get_ticker_keyword(self, ticker: str) -> str:
156
- """Fetch a search-friendly name for the ticker from yfinance."""
157
- symbol = ticker if "." in ticker else f"{ticker}.NS"
158
- try:
159
- t = yf.Ticker(symbol)
160
- info = t.info
161
- name = info.get("shortName") or info.get("longName") or ticker
162
- # Clean up the name for better matching
163
- for suffix in [" Limited", " Ltd.", " Ltd", " Corp.", " Corp", " Inc.", " Inc"]:
164
- name = name.replace(suffix, "")
165
- return name.strip()
166
- except Exception as e:
167
- logger.warning(f"Failed to fetch yfinance info for {symbol}: {e}")
168
- return ticker
169
-
170
- def _normalise(self, raw: dict) -> dict:
171
- """Convert the raw dict format from news.py into a pipeline-ready article dict."""
172
- title = raw.get("title", "").strip()
173
- summary = raw.get("summary", "") or raw.get("description", "") or ""
174
- content = f"{title}. {summary}".strip(". ") if summary and summary != title else title
175
- pub_raw = raw.get("published", "") or raw.get("seendate", "")
176
- # Try to parse the published field
177
- pub_dt = None
178
- if pub_raw:
179
- try:
180
- from dateutil import parser as dp
181
- pub_dt = dp.parse(pub_raw)
182
- except Exception:
183
- pub_dt = None
184
- if pub_dt is None:
185
- pub_dt = datetime.datetime.now(datetime.timezone.utc)
186
- elif pub_dt.tzinfo is None:
187
- pub_dt = pub_dt.replace(tzinfo=datetime.timezone.utc)
188
-
189
- return {
190
- "title": title,
191
- "content": content,
192
- "published": pub_dt.isoformat(),
193
- "url": raw.get("link", "") or raw.get("url", ""),
194
- }
195
-
196
- def fetch(
197
- self,
198
- start: str,
199
- end: str,
200
- include_pulse: bool = False,
201
- include_te: bool = False,
202
- ) -> List[dict]:
203
- """
204
- Fetch all news for self.ticker between start and end.
205
- Aggregates RSS, Pulse, and (optionally) Trading Economics.
206
- """
207
-
208
- logger.info(
209
- f"Fetching news for {self.ticker} ('{self.keyword}') "
210
- f"from {start} to {end}..."
211
- )
212
-
213
- all_raw: List[dict] = []
214
-
215
- # 1. RSS Feeds
216
- logger.info("Searching RSS feeds...")
217
- rss_articles = (
218
- self.search_rss_feeds(LIVEMINT_FEEDS, self.keyword, "LiveMint")
219
- + self.search_rss_feeds(CNBC18_FEEDS, self.keyword, "CNBC18")
220
- + self.search_rss_feeds(OTHER_FEEDS, self.keyword, "OtherFeeds")
221
- )
222
- all_raw.extend(rss_articles)
223
-
224
- # 2. Zerodha Pulse
225
- if include_pulse:
226
- logger.info("Fetching Zerodha Pulse...")
227
- pulse_news = self.scrape_pulse()
228
- all_raw.extend(pulse_news)
229
-
230
- # 3. Trading Economics (Optional)
231
- if include_te:
232
- logger.info("Fetching Trading Economics...")
233
- te_data = self.scrape_all_te(headless=True, scroll_count=1)
234
- for items_list in te_data.values():
235
- all_raw.extend(items_list)
236
-
237
- # # Filter by date window
238
- # start_dt = datetime.datetime(
239
- # *[int(x) for x in start.split("-")], tzinfo=datetime.timezone.utc
240
- # )
241
- # end_dt = datetime.datetime(
242
- # *[int(x) for x in end.split("-")], tzinfo=datetime.timezone.utc
243
- # )
244
- # in_window = self._filter_by_window(all_raw, start_dt, end_dt)
245
- # logger.info(f"Collected {len(in_window)} articles in window.")
246
-
247
- # # Normalise and deduplicate by title
248
- # seen_titles: set = set()
249
- # articles: List[dict] = []
250
- # for raw in all_raw:
251
- # title = raw.get("title", "").strip().lower()
252
- # if not title or title in seen_titles:
253
- # continue
254
- # seen_titles.add(title)
255
- # articles.append(self._normalise(raw))
256
-
257
- # Sort chronologically
258
- all_raw.sort(key=lambda a: str(a.get("published") or a.get("date") or ""))
259
-
260
- logger.info(
261
- f"Final corpus: {len(all_raw)} unique articles for {self.ticker}."
262
- )
263
-
264
- return all_raw
265
-
266
- def search_rss_feeds(self, feeds: list, search_keyword: str, feed_name: str) -> list:
267
- """Search for a keyword across a list of RSS feed URLs."""
268
- print(f"[DEBUG] search_rss_feeds - Searching {feed_name} for keyword: '{search_keyword}'")
269
- results = []
270
- for feed_url in feeds:
271
- print(f"[DEBUG] search_rss_feeds - Parsing URL: {feed_url}")
272
- try:
273
- feed = feedparser.parse(feed_url)
274
- if not feed.entries:
275
- print(f"[DEBUG] search_rss_feeds - No entries found for {feed_url}")
276
- for entry in feed.entries:
277
- title = entry.get("title", "")
278
- summary = entry.get("summary", "")
279
- if not title:
280
- continue
281
- if search_keyword.lower() in title.lower() or search_keyword.lower() in summary.lower():
282
- print(f"[DEBUG] search_rss_feeds - Match found: {title[:60]}...")
283
- results.append({
284
- "feed_name": feed_name,
285
- "title": title,
286
- "link": entry.get("link", ""),
287
- "published": entry.get("published", ""),
288
- "summary": summary,
289
- })
290
- except Exception as e:
291
- print(f"[news.py] RSS error ({feed_url}): {e}")
292
- print(f"[DEBUG] search_rss_feeds - {feed_name} done. Found {len(results)} items.")
293
- return results
294
-
295
- def scrape_all_te(self, headless: bool = True, scroll_count: int = 3) -> dict:
296
- """Scrape all Trading Economics news sources. Returns dict of source_key -> list of items."""
297
- driver = self._create_driver(headless=headless)
298
- results = {}
299
- scraped_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
300
- try:
301
- for key, url in TE_SOURCES.items():
302
- try:
303
- items = self._scrape_te_page(driver, url, scroll_count=scroll_count)
304
- for item in items:
305
- item["source"] = key
306
- item["scraped_at"] = scraped_at
307
- results[key] = items
308
- except Exception as e:
309
- print(f" [TE] Error scraping {key}: {e}")
310
- results[key] = []
311
- try:
312
- driver.quit()
313
- except Exception:
314
- pass
315
- driver = self._create_driver(headless=headless)
316
- finally:
317
- try:
318
- driver.quit()
319
- except Exception:
320
- pass
321
- return results
322
-
323
- def scrape_pulse(self) -> list:
324
- """Scrape latest news from Zerodha Pulse using requests + BeautifulSoup."""
325
- print("[news.py] Scraping Zerodha Pulse...")
326
- headers = {
327
- "User-Agent": self.USER_AGENT,
328
- "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
329
- "Accept-Language": "en-US,en;q=0.5",
330
- }
331
- try:
332
- resp = requests.get(ZERODHA_PULSE_URL, headers=headers, timeout=30)
333
- resp.raise_for_status()
334
- except Exception as e:
335
- print(f"[news.py] Zerodha Pulse error: {e}")
336
- return []
337
-
338
- soup = BeautifulSoup(resp.text, "html.parser")
339
- items = []
340
- scraped_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
341
-
342
- for li in soup.select("li.item"):
343
- title_el = li.select_one("h2.title a")
344
- if not title_el:
345
- continue
346
- title = title_el.get_text(strip=True)
347
- url = title_el.get("href", "")
348
- desc_el = li.select_one("div.desc")
349
- description = desc_el.get_text(strip=True) if desc_el else ""
350
- date_el = li.select_one("span.date")
351
- date_text = date_el.get_text(strip=True) if date_el else ""
352
- feed_el = li.select_one("span.feed")
353
- feed = feed_el.get_text(strip=True) if feed_el else ""
354
- items.append({
355
- "title": title,
356
- "description": description,
357
- "published": date_text,
358
- "link": url,
359
- "summary": description,
360
- "publisher": feed,
361
- "scraped_at": scraped_at,
362
- "source": "pulse"
363
- })
364
-
365
- print(f"[news.py] Zerodha Pulse: extracted {len(items)} items.")
366
- return items
367
-
368
- def _filter_by_window(
369
- self,
370
- articles: list,
371
- from_dt: datetime.datetime,
372
- to_dt: datetime.datetime,
373
- ) -> list:
374
- """
375
- Drop articles whose parsed `published` timestamp falls outside [from_dt, to_dt].
376
- Articles with unparseable or missing dates are kept (conservative).
377
- """
378
- from dateutil import parser as dp
379
-
380
- def _to_utc(dt):
381
- if dt.tzinfo is None:
382
- return dt.replace(tzinfo=datetime.timezone.utc)
383
- return dt.astimezone(datetime.timezone.utc)
384
-
385
- from_utc = _to_utc(from_dt)
386
- to_utc = _to_utc(to_dt)
387
-
388
- filtered = []
389
- for art in articles:
390
- pub = art.get("published", "")
391
- if not pub:
392
- filtered.append(art)
393
- continue
394
- try:
395
- dt = _to_utc(dp.parse(pub))
396
- if from_utc <= dt <= to_utc:
397
- filtered.append(art)
398
- except Exception:
399
- filtered.append(art) # keep on parse failure
400
- return filtered
401
-
402
- def _create_driver(self, headless: bool = True):
403
- """Create a headless Firefox webdriver."""
404
- options = FirefoxOptions()
405
- if headless:
406
- options.add_argument("--headless")
407
- options.set_preference("general.useragent.override", self.USER_AGENT)
408
- options.set_preference("dom.webdriver.enabled", False)
409
- options.set_preference("useAutomationExtension", False)
410
- service = FirefoxService(log_output=os.devnull)
411
- driver = webdriver.Firefox(options=options, service=service)
412
- driver.set_page_load_timeout(60)
413
- return driver
414
-
415
- def _scrape_te_page(self, driver, url: str, scroll_count: int = 3, scroll_pause: float = 2.0) -> list:
416
- """Scrape a single Trading Economics news page."""
417
- print(f" [TE] Loading: {url}")
418
- driver.get(url)
419
- WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.TAG_NAME, "body")))
420
- time.sleep(3)
421
- prev_height = 0
422
- for i in range(scroll_count):
423
- new_height = driver.execute_script(JS_SCROLL_DOWN)
424
- if new_height == prev_height:
425
- break
426
- prev_height = new_height
427
- time.sleep(scroll_pause)
428
- raw = driver.execute_script(JS_EXTRACT_TE_NEWS)
429
- items = json.loads(raw)
430
- print(f" [TE] Extracted {len(items)} items from {url}")
431
- return items
432
-
433
-
434
- if __name__ == "__main__":
435
- """Simple test runner for NewsClient."""
436
- logging.basicConfig(
437
- level=logging.INFO,
438
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
439
- )
440
-
441
- ticker = "RELIANCE"
442
- client = NewsClient(ticker)
443
-
444
- # Test Fetch (Aggregates RSS and Pulse)
445
- print(f"\n--- Testing Fetch for {ticker} ---")
446
- start_date = (datetime.datetime.now() - datetime.timedelta(days=7)).strftime("%Y-%m-%d")
447
- end_date = datetime.datetime.now().strftime("%Y-%m-%d")
448
-
449
- # We include_te=False by default as it requires Selenium/Firefox
450
- articles = client.fetch(start_date, end_date)
451
- print(f"Fetched {len(articles)} articles.")
452
-
453
- if articles:
454
- # Save results to debug_data/
455
- with open("news.json", "w", encoding="utf-8") as f:
456
- json.dump(articles, f, indent=4, ensure_ascii=False)
457
- for a in articles[:3]:
458
- print(f" - [{a['published']}] {a['title']}")
459
- else:
460
- print("No articles found in the specified window.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/__init__.py DELETED
@@ -1,25 +0,0 @@
1
- """
2
- The MIT License (MIT)
3
-
4
- Copyright (c) 2014 Noufal Nazar
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in all
14
- copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- SOFTWARE.
23
- """
24
- __VERSION__='2.0.1'
25
- from .nse import Nse
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/bases.py DELETED
@@ -1,72 +0,0 @@
1
- """
2
- The MIT License (MIT)
3
-
4
- Copyright (c) 2014 Noufal Nazar
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in all
14
- copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- SOFTWARE.
23
- """
24
-
25
- from abc import ABCMeta, abstractmethod
26
- import six
27
-
28
-
29
- class AbstractBaseExchange(six.with_metaclass(ABCMeta, object)):
30
-
31
- @abstractmethod
32
- def get_stock_codes(self):
33
- """
34
- :return: list of tuples with stock code and stock name
35
- """
36
- raise NotImplementedError
37
-
38
- @abstractmethod
39
- def is_valid_code(self, code):
40
- """
41
- :return: True, if it is a valid stock code, else False
42
- """
43
- raise NotImplementedError
44
-
45
- @abstractmethod
46
- def get_quote(self, code):
47
- """
48
- :param code: a stock code
49
- :return: a dictionary which contain detailed stock code.
50
- """
51
- raise NotImplementedError
52
-
53
- @abstractmethod
54
- def get_top_gainers(self):
55
- """
56
- :return: a sorted list of codes of top gainers
57
- """
58
- raise NotImplementedError
59
-
60
- @abstractmethod
61
- def get_top_losers(self):
62
- """
63
- :return: a sorted list of codes of top losers
64
- """
65
- raise NotImplementedError
66
-
67
- @abstractmethod
68
- def __str__(self):
69
- """
70
- :return: market name
71
- """
72
- raise NotImplementedError
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/cleaners.py DELETED
@@ -1,51 +0,0 @@
1
- """
2
- Module for various data structure cleaning tasks
3
- """
4
- from datetime import datetime
5
- dirty_data = """
6
- {
7
- "fname": "Jon",
8
- "lname": "Doe",
9
- "age": 20,
10
- "str_age": "20",
11
- "pi": 3.1415927,
12
- "str_pi": "3.1415927",
13
- "dob": "01-Jan-2023",
14
- "mobile": [
15
- {
16
- "id": "Home",
17
- "number": "123456789"
18
- },
19
- {
20
- "id": "office",
21
- "number": "987645321"
22
- }
23
- ]
24
- }
25
- """
26
-
27
- def parse_values(obj):
28
- for key, value in obj.items():
29
- if isinstance(value, str):
30
- # Try to parse as datetime if the string matches the format
31
- date_formats = ["%d-%b-%Y", "%d-%m-%Y", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"]
32
- for date_format in date_formats:
33
- try:
34
- obj[key] = datetime.strptime(value, date_format)
35
- break
36
- except ValueError:
37
- pass
38
- else:
39
- # If the string couldn't be parsed as datetime, try numeric conversion
40
- try:
41
- obj[key] = int(value)
42
- except ValueError:
43
- try:
44
- obj[key] = float(value)
45
- except ValueError:
46
- pass
47
- elif isinstance(value, dict):
48
- obj[key] = parse_values(value)
49
- elif isinstance(value, list):
50
- obj[key] = [parse_values(item) if isinstance(item, dict) else item for item in value]
51
- return obj
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/datemgr.py DELETED
@@ -1,104 +0,0 @@
1
- import datetime as dt
2
- from dateutil.relativedelta import relativedelta
3
- from dateutil.parser import parse
4
- from dateutil import rrule
5
- from .errors import DateFormatError
6
-
7
-
8
- def get_nearest_business_day(d):
9
- """ takes datetime object"""
10
- if d.isoweekday() == 7 or d.isoweekday() == 6:
11
- d = d - relativedelta(days=1)
12
- return get_nearest_business_day(d)
13
-
14
- # republic day
15
- elif d.month == 1 and d.day == 26:
16
- d = d - relativedelta(days=1)
17
- return get_nearest_business_day(d)
18
- # labour day
19
- elif d.month == 5 and d.day == 1:
20
- d = d - relativedelta(days=1)
21
- return get_nearest_business_day(d)
22
- # independece day
23
- elif d.month == 8 and d.day == 15:
24
- d = d - relativedelta(days=1)
25
- return get_nearest_business_day(d)
26
- # Gandhi Jayanti
27
- elif d.month == 10 and d.day == 2:
28
- d = d - relativedelta(days=1)
29
- return get_nearest_business_day(d)
30
- # chirstmas
31
- elif d.month == 12 and d.day == 25:
32
- d = d - relativedelta(days=1)
33
- return get_nearest_business_day(d)
34
- else:
35
- return d
36
-
37
- def is_known_holiday(d):
38
- """accepts datetime/date object and returns boolean"""
39
- if type(d) == dt.datetime:
40
- d = d.date()
41
- elif type(d) != dt.date:
42
- raise DateFormatError("only date objects or datetime objects")
43
- else:
44
- # fine do nothing
45
- pass
46
-
47
- # declare the list of holidays here.
48
- # republic day.
49
- if d.month == 1 and d.day == 26:
50
- return True
51
- # labour day
52
- elif d.month == 5 and d.day == 1:
53
- d = d - relativedelta(days=1)
54
- return get_nearest_business_day(d)
55
- # independence day
56
- elif d.month == 8 and d.day == 15:
57
- return True
58
- # gandhi jayanti
59
- elif d.month == 10 and d.day == 2:
60
- return True
61
- # christmas
62
- elif d.month == 12 and d.day == 25:
63
- return True
64
- else:
65
- return False
66
-
67
- def mkdate(d):
68
- """tries its best to return a valid date. it can accept pharse like today,
69
- yesterday, day before yesterday etc.
70
- """
71
- # check if the it == a string
72
- return_date = ""
73
- if type(d) is str:
74
- if d == "today":
75
- return_date = dt.date.today()
76
- elif d == "yesterday":
77
- return_date = dt.date.today() - relativedelta(days=1)
78
- elif d == "day before yesterday":
79
- return_date = dt.date.today() - relativedelta(days=2)
80
- else:
81
- return_date = parse(d, dayfirst=True).date()
82
- elif type(d) == dt.datetime:
83
- return_date = d.date()
84
- elif type(d) == dt.date:
85
- return d
86
- else:
87
- raise DateFormatError("wrong date format %s" % str(d))
88
- # check if future date.
89
- return return_date
90
-
91
- def usable_date(d):
92
- """accepts fuzzy format and returns most sensible date"""
93
- return get_nearest_business_day(mkdate(d))
94
-
95
- def get_date_range(frm, to, skip_dates=[]):
96
- """accepts fuzzy format date and returns business adjusted date ranges"""
97
- # for x in rrule.rrule(rrule.DAILY, dtstart=s, until=dt.datetime.now(), byweekday=[0, 1, 2, 3, 4]): print(x)
98
- frm = usable_date(frm)
99
- to = usable_date(to)
100
- datelist = []
101
- for date in rrule.rrule(rrule.DAILY, dtstart=frm, until=to, byweekday=[0, 1, 2, 3, 4]):
102
- if not is_known_holiday(date):
103
- datelist.append(date.date())
104
- return datelist
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/downloader.py DELETED
@@ -1,116 +0,0 @@
1
- """
2
- The MIT License (MIT)
3
-
4
- Copyright (c) 2014 Noufal Nazar
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in all
14
- copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- SOFTWARE.
23
-
24
- """
25
- import io
26
- import os
27
- import zipfile
28
- import datetime as dt
29
- from urllib.request import Request
30
- from .datemgr import mkdate, get_date_range
31
- from .nse import Nse
32
- from abc import ABCMeta, abstractmethod
33
-
34
- class BaseBhavcopyDownloader(metaclass=ABCMeta):
35
- """Base class for all types of bhavcopy downloader"""
36
- def __init__(self, from_date, to_date=dt.datetime.now().date(), skip_dates=[]):
37
- """accepts date in fuzzy format"""
38
- self.bhavcopy_base_url = "https://www.nseindia.com/content/historical/EQUITIES/%s/%s/cm%s%s%sbhav.csv.zip"
39
- self.bhavcopy_base_filename = "cm%s%s%sbhav.csv"
40
- self.from_date = from_date
41
- self.to_date = to_date
42
- self.skip_dates = skip_dates
43
- self.nse = Nse()
44
- self.dates = self.generate_dates()
45
-
46
- def generate_dates(self):
47
- return get_date_range(self.from_date, self.to_date, skip_dates=self.skip_dates)
48
-
49
- def get_bhavcopy_url(self, d):
50
- """accept date and return bhavcopy url"""
51
- day_of_month = d.strftime("%d")
52
- mon = d.strftime("%b").upper()
53
- year = d.year
54
- url = self.bhavcopy_base_url % (year, mon, day_of_month, mon, year)
55
- return url
56
-
57
- def get_bhavcopy_filename(self, d):
58
- """for a given date generate bhavcopy filename"""
59
- day_of_month = d.strftime("%d")
60
- mon = d.strftime("%b").upper()
61
- year = d.year
62
- filename = self.bhavcopy_base_filename % (day_of_month, mon, year)
63
- return filename
64
-
65
- def download_one(self, d):
66
- """download bhavcopy for the given date"""
67
- # this will keep this method usable for any arbitrary date.
68
- d = mkdate(d)
69
- # ex_url = "https://www.nseindia.com/content/historical/EQUITIES/2011/NOV/cm08NOV2011bhav.csv.zip"
70
- url = self.get_bhavcopy_url(d)
71
- print(url)
72
- filename = self.get_bhavcopy_filename(d)
73
- # response = requests.get(url, headers=self.headers)
74
- response = self.nse.opener.open(Request(url, None, self.nse.headers))
75
- zip_file_handle = io.BytesIO(response.read())
76
- zf = zipfile.ZipFile(zip_file_handle)
77
- return zf.read(filename).decode("utf-8")
78
-
79
- @abstractmethod
80
- def download(self):
81
- pass
82
-
83
- @abstractmethod
84
- def update(self):
85
- pass
86
-
87
-
88
- class BhavcopyFileSystemDownloader(BaseBhavcopyDownloader):
89
- def __init__(self, directory, *args, **kwargs):
90
- if (os.path.exists(directory) and os.path.isdir(directory) and os.access(directory, os.W_OK)):
91
- super().__init__(*args, **kwargs)
92
- self.directory = directory
93
- else:
94
- raise Exception("directory path must be valid and writtable, please check manually")
95
-
96
- def download(self):
97
- for date in self.dates:
98
- print("downloading for " + str(date))
99
- try:
100
- content = self.download_one(date)
101
- except Exception as err:
102
- print("unable to download for the date: %s" % date.strftime("%Y-%m-%d"))
103
- else:
104
- fh = open(self.directory + "/" + date.strftime("%Y-%m-%d") + ".csv", "w")
105
- fh.write(content)
106
- fh.close()
107
-
108
- def update(self):
109
- pass
110
-
111
-
112
- if __name__ == '__main__':
113
- b = BhavcopyFileSystemDownloader(directory="/tmp/bhavcopy", from_date="01-01-2018")
114
- b.download()
115
-
116
- # https://stackoverflow.com/questions/49183801/ssl-certificate-verify-failed-with-urllib
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/errors.py DELETED
@@ -1,8 +0,0 @@
1
- class BhavcopyNotAvailableError(Exception):
2
- """this error could occur in case you download bhavcopy for the dates
3
- when the market was close"""
4
- pass
5
-
6
- class DateFormatError(Exception):
7
- """in case the date format is errorneous"""
8
- pass
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/nse.py DELETED
@@ -1,624 +0,0 @@
1
- """
2
- The MIT License (MIT)
3
-
4
- Copyright (c) 2014 Noufal Nazar
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in all
14
- copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- SOFTWARE.
23
-
24
- """
25
-
26
- import csv
27
- from .bases import AbstractBaseExchange
28
- from .urls import (
29
- STOCKS_CSV_URL, STOCKS_IN_INDEX_URL, QUOTE_API_URL, QUOTE_DRIVATIVE_URL,
30
- TOP_GAINERS_URL, TOP_LOSERS_URL, ALL_INDICES_URL,
31
- FIFTYTWO_WEEK_HIGH_URL, FIFTYTWO_WEEK_LOW_URL
32
- )
33
- from .ua import Session
34
- from .utils import cast_intfloat_string_values_to_intfloat
35
-
36
- class Nse(AbstractBaseExchange):
37
- """
38
- class which implements all the functionality for
39
- National Stock Exchange
40
- """
41
- __CODECACHE__ = None
42
-
43
- def __init__(self, session_refresh_interval=120):
44
- """Initialize a new NSE object.
45
- Initializes a session management for making API calls to NSE (National Stock Exchange).
46
- Args:
47
- session_refresh_interval (int, optional): Time interval in seconds after which the session
48
- should be refreshed. Defaults to 120 seconds.
49
- Note:
50
- The session refresh interval helps maintain an active connection with NSE servers by
51
- periodically creating a new session to prevent timeouts.
52
- """
53
-
54
- self.session_refresh_interval = session_refresh_interval
55
- self.session = Session(session_refresh_interval)
56
-
57
- #############################
58
- ### STOCKS APIS ###
59
- #############################
60
-
61
- def get_stock_codes(self):
62
- """Gets a list of stock codes traded in NSE.
63
-
64
- This function fetches stock data from NSE's CSV endpoint and extracts the stock symbols.
65
-
66
- Returns:
67
- list: A list of strings containing stock symbols traded on NSE.
68
-
69
- Example:
70
- >>> nse = Nse()
71
- >>> codes = nse.get_stock_codes()
72
- >>> print(codes[:5])
73
- ['20MICRONS', '3IINFOTECH', '3MINDIA', '3PLAND', '63MOONS']
74
- """
75
- res = self.session.fetch(STOCKS_CSV_URL)
76
- csv_content = res.text.splitlines()
77
- symbols = []
78
- csv_reader = csv.DictReader(csv_content)
79
- for row in csv_reader:
80
- symbols.append(row['SYMBOL'])
81
- return symbols
82
-
83
- def is_valid_code(self, code):
84
- """Checks if a given stock code is valid.
85
-
86
- This method validates whether the provided stock code exists in the list of valid
87
- stock codes from NSE (National Stock Exchange).
88
-
89
- Args:
90
- code (str): Stock code/symbol to validate.
91
-
92
- Returns:
93
- bool: True if the code is valid, False otherwise.
94
-
95
- Example:
96
- >>> nse = NSE()
97
- >>> nse.is_valid_code("INFY")
98
- True
99
- >>> nse.is_valid_code("INVALID")
100
- False
101
- """
102
- stock_codes = self.get_stock_codes()
103
- return code.upper() in stock_codes
104
-
105
- def get_quote(self, code, all_data=False):
106
- """Gets the stock quote for a given NSE stock symbol.
107
-
108
- This function fetches real-time or delayed quote data from NSE for the specified stock code.
109
-
110
- Args:
111
- code (str): NSE stock symbol/code for which quote is to be fetched
112
- all_data (bool, optional): If True returns complete quote data, if False returns only price info.
113
- Defaults to False.
114
-
115
- Returns:
116
- dict: A dictionary containing quote data.
117
-
118
- Raises:
119
- requests.exceptions.RequestException: If there is an error in HTTP request
120
- ValueError: If the response JSON is invalid
121
-
122
- Example:
123
- >>> nse = Nse()
124
- >>> nse.get_quote('abb')
125
- {
126
- 'lastPrice': 5189.1,
127
- 'change': 70.55,
128
- 'pChange': 1.38,
129
- 'previousClose': 5118.55,
130
- 'open': 5160,
131
- 'close': 5187.65,
132
- 'vwap': 5162.91,
133
- 'stockIndClosePrice': 0,
134
- 'lowerCP': 4606.7,
135
- 'upperCP': 5630.4,
136
- 'pPriceBand': 'No Band',
137
- 'basePrice': 5118.55,
138
- 'intraDayHighLow': {'min': 5101, 'max': 5218.45, 'value': 5189.1},
139
- 'weekHighLow': {'min': 4890}
140
- }
141
- """
142
- code = code.upper()
143
- # TODO: implement if the code is valid
144
- res = self.session.fetch(QUOTE_API_URL % code)
145
- res = res.json()['priceInfo'] if all_data is False else res.json()
146
- return cast_intfloat_string_values_to_intfloat(res)
147
-
148
- def get_52_week_high(self):
149
- """Retrieves a list of stocks that have hit their 52-week high.
150
-
151
- This method fetches data for stocks that have reached new 52-week high prices on the NSE.
152
-
153
- Returns:
154
- list[dict]: A list of dictionaries containing 52-week high data.
155
-
156
- Example:
157
- >>> nse.get_52_week_high()
158
- [{'symbol': 'AVANTIFEED',
159
- 'series': 'EQ',
160
- 'comapnyName': 'Avanti Feeds Limited',
161
- 'new52WHL': 899,
162
- 'prev52WHL': 849.9,
163
- 'prevHLDate': '13-Mar-2025',
164
- 'ltp': 887,
165
- 'prevClose': 842.55,
166
- 'change': 44.45,
167
- 'pChange': 5.28},
168
- {...}
169
- ]
170
- """
171
- res = self.session.fetch(FIFTYTWO_WEEK_HIGH_URL)
172
- json_response = res.json()
173
- # Handle the new API response structure which has dataLtpGreater20 and dataLtpLess20 fields
174
- data = cast_intfloat_string_values_to_intfloat(json_response)
175
-
176
- # Check if the old structure with 'data' key exists
177
- if 'data' in data:
178
- return data['data']
179
-
180
- # Otherwise, extract and combine the lists from the new structure
181
- result = []
182
- if 'dataLtpGreater20' in data:
183
- result.extend(data['dataLtpGreater20'])
184
- if 'dataLtpLess20' in data:
185
- result.extend(data['dataLtpLess20'])
186
- return result
187
-
188
- def get_52_week_low(self):
189
- """Retrieves a list of stocks that have hit their 52-week low.
190
-
191
- This method fetches data for stocks that have reached new 52-week low prices on the NSE.
192
-
193
- Returns:
194
- list[dict]: A list of dictionaries containing 52-week low data.
195
-
196
- Example:
197
- >>> nse.get_52_week_low()
198
- [{'symbol': 'AVANTIFEED',
199
- 'series': 'EQ',
200
- 'comapnyName': 'Avanti Feeds Limited',
201
- 'new52WHL': 899,
202
- 'prev52WHL': 849.9,
203
- 'prevHLDate': '13-Mar-2025',
204
- 'ltp': 887,
205
- 'prevClose': 842.55,
206
- 'change': 44.45,
207
- 'pChange': 5.28},
208
- {...}
209
- ]
210
- """
211
- res = self.session.fetch(FIFTYTWO_WEEK_LOW_URL)
212
- json_response = res.json()
213
- # Handle the new API response structure which has dataLtpGreater20 and dataLtpLess20 fields
214
- data = cast_intfloat_string_values_to_intfloat(json_response)
215
-
216
- # Check if the old structure with 'data' key exists
217
- if 'data' in data:
218
- return data['data']
219
-
220
- # Otherwise, extract and combine the lists from the new structure
221
- result = []
222
- if 'dataLtpGreater20' in data:
223
- result.extend(data['dataLtpGreater20'])
224
- if 'dataLtpLess20' in data:
225
- result.extend(data['dataLtpLess20'])
226
- return result
227
-
228
- #############################
229
- ### INDEX APIS ###
230
- #############################
231
-
232
- def get_index_quote(self, index="NIFTY 50"):
233
- """Gets the quote for a specific index from NSE.
234
-
235
- This function retrieves detailed quote information for a given index code from the
236
- National Stock Exchange (NSE) of India.
237
-
238
- Args:
239
- index (str): The index code/symbol (e.g. "NIFTY 50", "BANKNIFTY", etc.)
240
-
241
- Returns:
242
- dict: A dictionary containing index quote details
243
-
244
- Raises:
245
- Exception: If the provided index code is invalid or not found
246
-
247
- Example:
248
- >>> nse = NSE()
249
- >>> nse.get_index_quote("NIFTY 50")
250
- {
251
- 'key': 'BROAD MARKET INDICES',
252
- 'index': 'NIFTY 50',
253
- 'last': 22508.75,
254
- 'variation': 111.55,
255
- 'percentChange': 0.5,
256
- 'open': 22353.15,
257
- 'high': 22577.0,
258
- 'low': 22353.15,
259
- 'previousClose': 22397.2,
260
- 'yearHigh': 26277.35,
261
- 'yearLow': 21281.45,
262
- # ... additional fields omitted for brevity
263
- }
264
- """
265
-
266
- url = ALL_INDICES_URL
267
- all_index_quote = self.get_all_index_quote()
268
- index_list = [ i['indexSymbol'] for i in all_index_quote]
269
- index = index.upper()
270
- index = ' '.join(index.split())
271
- if index in index_list:
272
- response = list(filter(lambda idx: idx['indexSymbol'] == index, all_index_quote))[0]
273
- return cast_intfloat_string_values_to_intfloat(response)
274
- else:
275
- raise Exception('Wrong index code')
276
-
277
- def get_index_list(self):
278
- """Gets a list of all NSE index symbols.
279
-
280
- This method fetches all available NSE (National Stock Exchange) index symbols by
281
- extracting the 'indexSymbol' from the complete index quote data.
282
-
283
- Returns:
284
- list: A list of strings containing index symbols (e.g., ['NIFTY 50', 'NIFTY BANK', ...])
285
-
286
- Examples:
287
- >>> nse = Nse()
288
- >>> indices = nse.get_index_list()
289
- >>> print(indices)
290
- ['NIFTY 50', 'NIFTY BANK', 'NIFTY IT', ...]
291
- """
292
- return [ i['indexSymbol'] for i in self.get_all_index_quote()]
293
-
294
- def get_all_index_quote(self):
295
- """Gets information for all NSE indices in one request.
296
-
297
- This method fetches quotes and information for all available indices on the
298
- National Stock Exchange (NSE) through a single API call.
299
-
300
- Returns:
301
- list[dict]: A list of dictionaries where each dictionary contains quote
302
- information for an index. The quote information includes details like
303
- index name, current value, change, percentage change etc.
304
-
305
- Example:
306
- >>> nse = Nse()
307
- >>> quotes = nse.get_all_index_quote()
308
- >>> quotes # Sample output
309
- [
310
- {
311
- 'key': 'BROAD MARKET INDICES',
312
- 'index': 'NIFTY 50',
313
- 'indexSymbol': 'NIFTY 50',
314
- 'last': 22508.75,
315
- 'variation': 111.55,
316
- 'percentChange': 0.5,
317
- 'open': 22353.15,
318
- ...
319
- },
320
- # ... additional indices follow
321
- ]
322
-
323
- Raises:
324
- URLError: If there is an error accessing the NSE API endpoint
325
- ValueError: If the response JSON cannot be parsed properly
326
- """
327
- url = ALL_INDICES_URL
328
- res = self.session.fetch(url)
329
- return res.json()['data']
330
-
331
- def get_top_gainers(self, index="NIFTY"):
332
- """Gets the list of top gaining stocks for the specified index.
333
-
334
- This function retrieves real-time data for stocks that have gained the most value
335
- during the current trading day. It can filter results by different indices.
336
-
337
- Args:
338
- index (str, optional): The index to get top gainers for. Defaults to "NIFTY".
339
- Valid values are:
340
- - NIFTY: Nifty 50 index
341
- - BANKNIFTY: Bank Nifty index
342
- - NIFTYNEXT50: Nifty Next 50 index
343
- - SecGtr20: Securities greater than 20
344
- - SecLwr20: Securities lower than 20
345
- - FNO: Futures & Options
346
- - ALL: All stocks
347
-
348
- Returns:
349
- list[dict]: List of dictionaries containing top gainer details.
350
-
351
- Raises:
352
- ConnectionError: If unable to fetch data from NSE
353
-
354
- Example:
355
- >>> nse = Nse()
356
- >>> gainers = nse.get_top_gainers()
357
- >>> gainers[0] # Sample output
358
- {
359
- 'symbol': 'DRREDDY',
360
- 'series': 'EQ',
361
- 'open_price': 1107.9,
362
- 'high_price': 1154.1,
363
- 'low_price': 1101.5,
364
- 'ltp': 1151.5,
365
- 'prev_price': 1107.95,
366
- 'net_price': 3.93,
367
- 'trade_quantity': 2714559,
368
- 'turnover': 31016.01,
369
- 'market_type': 'N',
370
- 'ca_ex_dt': '28-Oct-2024',
371
- 'ca_purpose': 'Face Value Split (Sub-Division) - From Rs 5/- Per Share To Re 1/- Per Share',
372
- 'perChange': 3.93
373
- }
374
- """
375
- return self._get_top_gainers_losers('gainers', index)
376
-
377
- def get_top_losers(self, index="NIFTY"): # Changed from None to "NIFTY"
378
- """Gets the top losers from specified index from NSE.
379
-
380
- The function fetches real-time data for stocks that have declined the most in terms
381
- of percentage change compared to their previous closing price.
382
-
383
- Args:
384
- index (str, optional): Index name for which top losers are to be fetched.
385
- Available options:
386
- - NIFTY (Default)
387
- - BANKNIFTY
388
- - NIFTYNEXT50
389
- - SecGtr20
390
- - SecLwr20
391
- - FNO
392
- - ALL
393
-
394
- Returns:
395
- list: List of dictionaries containing stock information with following keys:
396
-
397
- Raises:
398
- URLError: When unable to connect to NSE
399
- ValueError: When invalid index is provided
400
-
401
- Examples:
402
- >>> from nseconnect import Nse
403
- >>> nse = Nse()
404
- >>> losers = nse.get_top_losers()
405
- >>> losers[0]
406
- {'symbol': 'TATAMOTORS', 'series': 'EQ', 'openPrice': 375.0, ...}
407
- """
408
- return self._get_top_gainers_losers('losers', index) # Changed from 'gainers' to 'losers'
409
-
410
- def get_advances_declines(self, index='nifty 50'):
411
- """Gets the advances/declines data for given index.
412
- This method provides the number of stocks advancing and declining in a given index
413
- on NSE at any given point of time.
414
- Args:
415
- index (str, optional): Name of the index. Defaults to 'nifty 50'.
416
- Valid values include 'NIFTY 50', 'NIFTY BANK', etc.
417
- Returns:
418
- dict: A dictionary with two keys:
419
- - 'advances': Number of advancing stocks in the index
420
- - 'declines': Number of declining stocks in the index
421
- Examples:
422
- >>> nse = Nse()
423
- >>> nse.get_advances_declines(index="NIFTY BANK")
424
- {'advances': 7, 'declines': 4}
425
- Note:
426
- The method is case-insensitive for the index parameter.
427
- """
428
-
429
- # fixing this
430
- index = index.upper()
431
- index_quote = self.get_index_quote(index)
432
- return {'advances': index_quote['advances'], 'declines': index_quote['declines']}
433
-
434
- def get_stocks_in_index(self, index="NIFTY 50"):
435
- """Gets the list of symbols of stocks included in the specified NSE index.
436
- The function retrieves the current constituents of a given NSE index like NIFTY 50,
437
- NIFTY BANK etc. and returns their stock symbols.
438
- Args:
439
- index (str, optional): Name of the NSE index. Defaults to "NIFTY 50".
440
- Possible values: "NIFTY 50", "NIFTY BANK", "NIFTY IT" etc.
441
- Returns:
442
- list: List of stock symbols (str) that are part of the specified index.
443
- Raises:
444
- URLError: If unable to connect to NSE server
445
- ValueError: If invalid index name is provided
446
- Examples:
447
- >>> nse = Nse()
448
- >>> nse.get_stocks_in_index("NIFTY 50")
449
- ['ADANIPORTS', 'ASIANPAINT', 'AXISBANK', 'BAJAJ-AUTO', 'BAJFINANCE', ...]
450
- >>> nse.get_stocks_in_index("NIFTY BANK")
451
- ['AUBANK', 'AXISBANK', 'BANDHANBNK', 'FEDERALBNK', 'HDFCBANK', ...]
452
- """
453
-
454
- index = index.upper()
455
- url = STOCKS_IN_INDEX_URL % index
456
- res = self.session.fetch(url)
457
- res_dict = res.json()
458
- return [stock['symbol'] for stock in res_dict['data']][1:]
459
-
460
- def get_stock_quote_in_index(self, index="NIFTY 50", include_index=False):
461
- """Gets stock quotes for all stocks in a given index.
462
- This function fetches real-time quotes for all stocks that are part of the specified index
463
- from NSE (National Stock Exchange).
464
- Args:
465
- index (str, optional): The name of the index. Defaults to "NIFTY 50".
466
- include_index (bool, optional): Whether to include the index itself in results.
467
- If True, includes both stocks and index. If False, returns only stocks.
468
- Defaults to False.
469
- Returns:
470
- list: A list of dictionaries containing stock quote data.
471
- Each dictionary contains various fields including:
472
- - symbol: Stock symbol
473
- - open: Opening price
474
- - high: High price
475
- - low: Low price
476
- - lastPrice: Last traded price
477
- - change: Change in price
478
- - pChange: Percentage change
479
- And other relevant trading information.
480
- Raises:
481
- URLError: If unable to connect to NSE servers
482
- ValueError: If invalid index name is provided
483
- Example:
484
- >>> nse = Nse()
485
- >>> nifty_quotes = nse.get_stock_quote_in_index("NIFTY 50")
486
- >>> nifty_quotes_with_index = nse.get_stock_quote_in_index("NIFTY 50", include_index=True)
487
- """
488
-
489
- index = index.upper()
490
- url = STOCKS_IN_INDEX_URL % index
491
- res = self.session.fetch(url)
492
- res_dict = res.json()
493
- res_dict = cast_intfloat_string_values_to_intfloat(res_dict)
494
- if include_index is False:
495
- return [record for record in res_dict['data'] if record['priority'] == 0]
496
- else:
497
- return res_dict['data']
498
-
499
- def _get_top_gainers_losers(self, direction, index):
500
- """Internal method to fetch top gainers or losers for a given index.
501
-
502
- Args:
503
- direction (str): Either 'gainers' or 'losers'
504
- index (str): Index name - one of NIFTY, BANKNIFTY, NIFTYNEXT50, SecGtr20, SecLwr20, FNO, ALL
505
-
506
- Returns:
507
- list: List of dictionaries containing top gainers/losers data for the specified index
508
-
509
- Raises:
510
- ValueError: If invalid index name is provided
511
- """
512
- index = index or 'NIFTY' # Default to NIFTY if None
513
- index = index.upper()
514
- index = {
515
- "NIFTY": "NIFTY",
516
- "NIFTY 50": "NIFTY",
517
- "NIFTY BANK": "BANKNIFTY",
518
- "BANKNIFTY": "BANKNIFTY",
519
- "NIFTYNEXT50": "NIFTYNEXT50",
520
- "NIFTY NEXT 50": "NIFTYNEXT50",
521
- "SECGTR20": "SecGtr20",
522
- "SECLWR20": "SecLwr20",
523
- "FNO": "FOSec",
524
- "ALL": "allSec"
525
- }.get(index)
526
- if index is None:
527
- raise ValueError("Index must be one of NIFTY 50, NIFTY BANK, NIFTY NEXT 50, SecGtr20, SecLwr20, FNO, ALL")
528
- url = TOP_GAINERS_URL if direction == 'gainers' else TOP_LOSERS_URL
529
- res = self.session.fetch(url)
530
- return cast_intfloat_string_values_to_intfloat(res.json())[index]['data']
531
-
532
- #############################
533
- ### DERIVATIVE APIS ###
534
- #############################
535
-
536
- def get_future_quote(self, code, expiry_date=None):
537
- """Get future quote for given stock code.
538
-
539
- This function fetches futures trading data for a given stock code from NSE's derivatives segment.
540
- If expiry date is provided, returns data for that specific expiry, else returns data for all
541
- available expiry dates.
542
-
543
- Args:
544
- code (str): Stock code for which futures data needs to be fetched
545
- expiry_date (str, optional): Expiry date in format DD-MMM-YYYY (e.g. "27-Mar-2025").
546
- Defaults to None.
547
-
548
- Returns:
549
- Union[dict, list]: If expiry_date provided returns dict with futures data for that expiry,
550
- else returns list of dicts with data for all expiries.
551
-
552
- Example:
553
- >>> nse = Nse()
554
- >>> nse.get_future_quote('RELIANCE')
555
- [{'expiryDate': '27-Mar-2025',
556
- 'lastPrice': 1246,
557
- 'premium': 4.45,
558
- 'openPrice': 1245.25,
559
- 'highPrice': 1260.85,
560
- 'lowPrice': 1236.2,
561
- 'openInterest': 257812,
562
- 'changeInOpenInterest': 7144,
563
- ...},
564
- {...}]
565
- """
566
-
567
- url = QUOTE_DRIVATIVE_URL % code.upper()
568
- res = self.session.fetch(url)
569
- res_dict = res.json()
570
- # list containing all options and futures data
571
- data = res_dict['stocks']
572
- # filter out only future data
573
- future_data = [s for s in data if s['metadata']['instrumentType'] == "Stock Futures"]
574
- # future data is very convoluted, so flatten-out the desired data
575
- # !! there is bug in spelling of the key 'dailyvolatility', it is not camel cased
576
- # fixing that in my code for uniformity
577
- filtered_data = [
578
- {
579
- 'expiryDate': record['metadata']['expiryDate'],
580
- 'lastPrice': record['metadata']['lastPrice'],
581
- 'premium': record['metadata']['lastPrice'] - record['underlyingValue'],
582
- 'openPrice': record['metadata']['openPrice'],
583
- 'highPrice': record['metadata']['highPrice'],
584
- 'lowPrice': record['metadata']['lowPrice'],
585
- 'closePrice': record['metadata']['closePrice'],
586
- 'prevClose': record['metadata']['prevClose'],
587
- 'change': record['metadata']['change'],
588
- 'pChange': record['metadata']['pChange'],
589
- 'numberOfContractsTraded': record['metadata']['numberOfContractsTraded'],
590
- 'totalTurnover': record['metadata']['totalTurnover'],
591
- 'underlyingValue': record['underlyingValue'],
592
- 'tradedVolume': record['marketDeptOrderBook']['tradeInfo']['tradedVolume'],
593
- 'openInterest': record['marketDeptOrderBook']['tradeInfo']['openInterest'],
594
- 'changeInOpenInterest': record['marketDeptOrderBook']['tradeInfo']['changeinOpenInterest'],
595
- 'pchangeinOpenInterest': record['marketDeptOrderBook']['tradeInfo']['pchangeinOpenInterest'],
596
- 'marketLot': record['marketDeptOrderBook']['tradeInfo']['marketLot'],
597
- 'dailyVolatility': record['marketDeptOrderBook']['otherInfo']['dailyvolatility'],
598
- 'annualisedVolatility': record['marketDeptOrderBook']['otherInfo']['annualisedVolatility']
599
- }
600
- for record in future_data
601
- ]
602
- # if expiry_date is provided, filter out data for that expiry date
603
- if expiry_date:
604
- matching_records = [record for record in filtered_data if record['expiryDate'] == expiry_date]
605
- if matching_records:
606
- return matching_records[0] # Return the first matching record
607
- else:
608
- # Return an empty dictionary if no records found for the given expiry date
609
- return {}
610
- return filtered_data
611
-
612
- def __str__(self):
613
- """Returns a string representation of the NSE driver class.
614
- Returns:
615
- str: A descriptive string identifying this as the NSE driver class.
616
- """
617
-
618
- return 'Driver Class for National Stock Exchange (NSE)'
619
-
620
-
621
- if __name__ == "__main__":
622
- n = Nse()
623
- # data = n.download_bhavcopy("14th Dec")
624
- n.get_quote('reliance')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/ua.py DELETED
@@ -1,137 +0,0 @@
1
- import requests
2
- import random
3
- from datetime import datetime as dt
4
- from .urls import NSE_MAIN
5
- from time import sleep
6
-
7
-
8
- class Session():
9
- __CACHE__ = {}
10
-
11
- def __init__(self, session_refresh_interval=60, cache_timeout=60):
12
- """Initialize the class instance with session and cache parameters.
13
- Args:
14
- session_refresh_interval (int, optional): Time interval in seconds to refresh session. Defaults to 60.
15
- cache_timeout (int, optional): Cache timeout duration in seconds. Defaults to 20.
16
- Attributes:
17
- session_refresh_interval (int): Time interval for session refresh.
18
- cache_timeout (int): Duration for cache timeout.
19
- """
20
-
21
- self.session_refresh_interval = session_refresh_interval
22
- self.cache_timeout = cache_timeout # cache timeout in seconds
23
- self._session = None # Initialize _session attribute to None
24
- self.create_session()
25
- self.flush()
26
-
27
- def nse_headers(self):
28
- """Returns a dictionary of headers required for making requests to NSE (National Stock Exchange).
29
- These headers are designed to mimic a web browser request to prevent request blocking.
30
- Returns:
31
- dict: A dictionary containing HTTP headers with the following keys:
32
- - Accept: Acceptable content types
33
- - Accept-Language: Preferred language for response
34
- - user-agent: Browser identification string
35
- - X-Requested-With: Identifies AJAX requests
36
- """
37
-
38
- return {
39
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
40
- "Accept-Language": "en-US,en;q=0.9",
41
- "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
42
- "X-Requested-With": "XMLHttpRequest",
43
- "Referer": "https://www.nseindia.com/",
44
- "Origin": "https://www.nseindia.com",
45
- "Connection": "keep-alive",
46
- "Sec-Fetch-Dest": "empty",
47
- "Sec-Fetch-Mode": "cors",
48
- "Sec-Fetch-Site": "same-origin"
49
- }
50
-
51
- def create_session(self):
52
- """Creates and initializes a new HTTP session for NSE (National Stock Exchange) API requests.
53
- This method sets up a requests.Session object with appropriate headers for NSE and initializes
54
- it by making a GET request to the NSE home page. The session is used for subsequent API calls.
55
- Returns:
56
- None
57
- Side Effects:
58
- - Sets self._session with configured requests.Session object
59
- - Sets self._session_init_time with current timestamp
60
- """
61
-
62
- # Clean up old session if it exists
63
- if hasattr(self, '_session') and self._session is not None:
64
- old_session = self._session
65
- self._session = None
66
- # Explicitly delete old session to ensure garbage collection
67
- del old_session
68
-
69
- # Create a completely new session object
70
- self._session = requests.Session()
71
- self._session.headers.update(self.nse_headers())
72
-
73
- # First visit NSE home page to get cookies
74
- self._session.get(NSE_MAIN)
75
- # Small delay to mimic human behavior
76
- sleep(1)
77
- # Visit the market page to get additional cookies
78
- self._session.get(f"{NSE_MAIN}/market-data/live-equity-market")
79
-
80
- self._session_init_time = dt.now()
81
-
82
- def flush(self):
83
- """Flushes the cached user agent data.
84
- This method clears the internal cache dictionary storing user agent information
85
- by resetting the class's __CACHE__ attribute to an empty dictionary.
86
- Returns:
87
- None
88
- """
89
-
90
- self.__class__.__CACHE__ = {}
91
-
92
- def fetch(self, url):
93
- """Fetches data from a given URL with caching and session management.
94
- This method implements a caching mechanism and session refresh logic to optimize
95
- network requests. It also includes random delays to prevent rate limiting.
96
- Args:
97
- url (str): The URL to fetch data from.
98
- Returns:
99
- requests.Response: The response object from the request.
100
- Note:
101
- - Uses class-level cache to store responses
102
- - Implements random delays between 0-300ms before making requests
103
- - Auto-refreshes session if expired based on session_refresh_interval
104
- """
105
-
106
- # Check cache first
107
- if url in self.__class__.__CACHE__:
108
- cache_time, response = self.__class__.__CACHE__[url]
109
- if (dt.now() - cache_time).seconds < self.cache_timeout:
110
- # print("serving from cache")
111
- return response
112
-
113
- # Only check session expiry if we need to make a network request
114
- time_diff = dt.now() - self._session_init_time
115
- if time_diff.seconds >= self.session_refresh_interval:
116
- # print("re-initing the session because of expiry")
117
- self.create_session()
118
-
119
- # Add random delay before making request
120
- sleep_time = random.uniform(0, 0.3) # Random delay between 0-300ms
121
- # print(f"Adding random delay of {sleep_time:.3f} seconds")
122
- sleep(sleep_time)
123
-
124
- # Make actual request if not in cache or cache expired
125
- try:
126
- response = self._session.get(url)
127
- # Force a 401 response to retry with a fresh session
128
- if response.status_code == 401:
129
- self.create_session()
130
- response = self._session.get(url)
131
- except requests.RequestException:
132
- # Try again with a fresh session on any request exception
133
- self.create_session()
134
- response = self._session.get(url)
135
-
136
- self.__class__.__CACHE__[url] = (dt.now(), response)
137
- return response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/urls.py DELETED
@@ -1,35 +0,0 @@
1
- """
2
- URL constants for NSE related operations
3
- """
4
-
5
- # Base URLs
6
- NSE_HOME = "https://www.nseindia.com"
7
- NSE_MAIN = "https://www.nseindia.com"
8
- NSE_LEGACY = "https://www.nseindia.com"
9
-
10
- # Quote URLs
11
- QUOTE_EQUITY_URL = f"{NSE_MAIN}/get-quotes/equity?symbol=%s"
12
- QUOTE_API_URL = f"{NSE_MAIN}/api/quote-equity?symbol=%s"
13
-
14
- # Stock list URLs
15
- STOCKS_CSV_URL = f"https://archives.nseindia.com/content/equities/EQUITY_L.csv"
16
-
17
- # Market movers URLs
18
- TOP_GAINERS_URL = f"{NSE_MAIN}/api/live-analysis-variations?index=gainers"
19
- TOP_LOSERS_URL = f"{NSE_MAIN}/api/live-analysis-variations?index=loosers"
20
- TOP_FNO_GAINER_URL = f"{NSE_MAIN}/api/market-data-pre-open?key=FO"
21
- TOP_FNO_LOSER_URL = f"{NSE_MAIN}/api/market-data-pre-open?key=FO"
22
- FIFTYTWO_WEEK_HIGH_URL = f"{NSE_MAIN}/api/live-analysis-52Week?index=high"
23
- FIFTYTWO_WEEK_LOW_URL = f"{NSE_MAIN}/api/live-analysis-52Week?index=low"
24
-
25
- # Index URLs
26
- ALL_INDICES_URL = f"{NSE_MAIN}/api/allIndices"
27
- STOCKS_IN_INDEX_URL = f"{NSE_MAIN}/api/equity-stockIndices?index=%s"
28
-
29
-
30
- # Historical data URLs
31
- BHAVCOPY_BASE_URL = f"{NSE_MAIN}/archives/equities-bhavcopy/%s"
32
- BHAVCOPY_BASE_FILENAME = "cm%s%s%sbhav.csv"
33
-
34
- # Drivative URLs
35
- QUOTE_DRIVATIVE_URL = f"{NSE_MAIN}/api/quote-derivative?symbol=%s"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
singular_ticker_causal/data_sources/nseconnect/utils.py DELETED
@@ -1,373 +0,0 @@
1
- """
2
- The MIT License (MIT)
3
-
4
- Copyright (c) 2014 Noufal Nazar
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in all
14
- copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- SOFTWARE.
23
-
24
- """
25
- import six
26
- import re
27
- import operator
28
-
29
- def byte_adaptor(fbuffer):
30
- """ provides py3 compatibility by converting byte based
31
- file stream to string based file stream
32
-
33
- Arguments:
34
- fbuffer: file like objects containing bytes
35
-
36
- Returns:
37
- string buffer
38
- """
39
- if six.PY3:
40
- strings = fbuffer.read().decode('latin-1')
41
- fbuffer = six.StringIO(strings)
42
- return fbuffer
43
- else:
44
- return fbuffer
45
-
46
-
47
- def js_adaptor(buffer):
48
- """ convert javascript objects like true, none, NaN etc. to
49
- quoted word.
50
-
51
- Arguments:
52
- buffer: string to be converted
53
-
54
- Returns:
55
- string after conversion
56
- """
57
- buffer = re.sub('true', 'True', buffer)
58
- buffer = re.sub('false', 'False', buffer)
59
- buffer = re.sub('none', 'None', buffer)
60
- buffer = re.sub('NaN', '"NaN"', buffer)
61
- return buffer
62
-
63
- def cast_intfloat_string_values_to_intfloat(data, round_digits=2):
64
- """Recursively converts string representations of numbers to integers or floats in nested data structures.
65
- This function traverses through dictionaries and lists, converting string values that represent
66
- numbers into their corresponding numeric types (int or float). For float values, it rounds to
67
- the specified number of decimal places.
68
- Args:
69
- data (Union[dict, list]): The input data structure containing values to be converted.
70
- Can be either a dictionary or a list, potentially nested.
71
- round_digits (int, optional): Number of decimal places to round float values to.
72
- Defaults to 2.
73
- Returns:
74
- Union[dict, list]: A new data structure of the same type as input, with string
75
- representations of numbers converted to their numeric types.
76
- Example:
77
- >>> data = {'a': '1', 'b': '2.5', 'c': 'text', 'd': {'e': '3.14'}}
78
- >>> cast_intfloat_string_values_to_intfloat(data)
79
- {'a': 1, 'b': 2.5, 'c': 'text', 'd': {'e': 3.14}}
80
- """
81
-
82
- if isinstance(data, dict):
83
- data = data.copy()
84
- for key, value in data.items():
85
- if isinstance(value, str):
86
- try:
87
- data[key] = int(value)
88
- except ValueError:
89
- try:
90
- data[key] = round(float(value), round_digits)
91
- except ValueError:
92
- pass
93
- elif isinstance(value, (dict, list)):
94
- data[key] = cast_intfloat_string_values_to_intfloat(value, round_digits)
95
- elif isinstance(value, float):
96
- data[key] = round(value, round_digits)
97
- elif isinstance(data, list):
98
- data = data[:]
99
- for i, value in enumerate(data):
100
- if isinstance(value, str):
101
- try:
102
- data[i] = int(value)
103
- except ValueError:
104
- try:
105
- data[i] = round(float(value), round_digits)
106
- except ValueError:
107
- pass
108
- elif isinstance(value, (dict, list)):
109
- data[i] = cast_intfloat_string_values_to_intfloat(value, round_digits)
110
- elif isinstance(value, float):
111
- data[i] = round(value, round_digits)
112
- return data
113
-
114
- def camel_to_title(camel_str):
115
- """Converts a camel case string to title case.
116
- This function takes a camel case string and converts it to title case by adding
117
- spaces before capital letters and capitalizing the first letter of each word.
118
- Args:
119
- camel_str (str): The camel case string to be converted.
120
- Returns:
121
- str: The converted string in title case format.
122
- Examples:
123
- >>> camel_to_title("camelCaseString")
124
- 'Camel Case String'
125
- >>> camel_to_title("thisIsATest")
126
- 'This Is A Test'
127
- """
128
-
129
- return re.sub(r'(?<!^)(?=[A-Z])', ' ', camel_str).title()
130
-
131
- def _resolve_path(data, path, case_insensitive=True):
132
- """Helper function to resolve dot notation paths in dictionaries."""
133
- if not path:
134
- return data
135
-
136
- parts = path.split('.')
137
- current = data
138
-
139
- for part in parts:
140
- if isinstance(current, dict):
141
- if case_insensitive:
142
- key_map = {k.lower(): k for k in current.keys()}
143
- part_lower = part.lower()
144
- if part_lower in key_map:
145
- current = current[key_map[part_lower]]
146
- else:
147
- return None
148
- else:
149
- current = current.get(part)
150
- else:
151
- return None
152
- return current
153
-
154
- def _parse_query(query_str):
155
- """Parse query string into (path, operator, value) tuple."""
156
- operators = {
157
- '==': operator.eq,
158
- '!=': operator.ne,
159
- '>=': operator.ge,
160
- '<=': operator.le,
161
- '>': operator.gt,
162
- '<': operator.lt
163
- }
164
-
165
- for op_str, op_func in operators.items():
166
- if op_str in query_str:
167
- path, value = query_str.split(op_str)
168
- path = path.strip()
169
- value = value.strip()
170
-
171
- # Try to convert value to number if possible
172
- try:
173
- value = int(value)
174
- except ValueError:
175
- try:
176
- value = float(value)
177
- except ValueError:
178
- # Keep as string if not numeric
179
- pass
180
-
181
- return path, op_func, value
182
-
183
- return None, None, None
184
-
185
- def dict_to_table(data, title="Data Table", filter=None, ignore=None, sort=None, direction="desc", query=None):
186
- """Converts dictionary or list of dictionaries to a formatted table using Rich library.
187
- This function takes either a dictionary or a list of dictionaries and displays it as a
188
- formatted table in the console. It supports filtering specific keys, ignoring keys, and
189
- applies special formatting for negative numbers.
190
- Args:
191
- data (Union[dict, List[dict]]): The data to be displayed. Can be either a dictionary
192
- or a list of dictionaries.
193
- title (str, optional): The title to display above the table. Defaults to "Data Table".
194
- filter (List[str], optional): List of keys to include in the output. If provided,
195
- only these keys will be displayed. Keys are matched case-insensitively.
196
- Defaults to None.
197
- ignore (List[str], optional): List of keys to exclude from the output. Keys are
198
- matched case-insensitively. Defaults to None.
199
- sort (str, optional): Key to sort by. Case-insensitive. Will sort numerically
200
- for numeric values and alphabetically for string values. Defaults to None.
201
- direction (str, optional): Sort direction - "asc" for ascending or "desc" for
202
- descending. Defaults to "desc".
203
- query (str, optional): Filter rows using dot notation path and comparison.
204
- Supports operators: ==, !=, >, <, >=, <=
205
- Example: "market.price>100" or "status.active==True"
206
- Keys are matched case-insensitively. Defaults to None.
207
- """
208
- from rich.console import Console
209
- from rich.table import Table
210
-
211
- console = Console()
212
- table = Table(title=title)
213
-
214
- if not data:
215
- console.print("[red]No data to display![/red]")
216
- return
217
-
218
- # Parse query if provided
219
- query_path = None
220
- query_op = None
221
- query_value = None
222
- if query:
223
- query_path, query_op, query_value = _parse_query(query)
224
- if not all([query_path, query_op, query_value]):
225
- console.print("[red]Invalid query format![/red]")
226
- return
227
-
228
- # Validate direction
229
- if direction not in ["asc", "desc"]:
230
- console.print("[red]Direction must be 'asc' or 'desc'![/red]")
231
- return
232
-
233
- # Normalize filter, ignore and sort keys
234
- if filter:
235
- if not isinstance(filter, list):
236
- console.print("[red]Filter should be a list of keys![/red]")
237
- return
238
- filter = [str(key).lower() for key in filter]
239
-
240
- if ignore:
241
- if not isinstance(ignore, list):
242
- console.print("[red]Ignore should be a list of keys![/red]")
243
- return
244
- ignore = [str(key).lower() for key in ignore]
245
- else:
246
- ignore = []
247
-
248
- if sort:
249
- sort = str(sort).lower()
250
-
251
- # Check if data is a list of dicts
252
- if isinstance(data, list) and all(isinstance(i, dict) for i in data):
253
- # Get all unique keys and create key mapping
254
- keys = set()
255
- for item in data:
256
- keys.update(item.keys())
257
- key_map = {k.lower(): k for k in keys}
258
-
259
- # Validate sort key if provided
260
- if sort and sort not in key_map:
261
- console.print(f"[red]Sort key '{sort}' not found in data![/red]")
262
- return
263
-
264
- # Create ordered keys list
265
- if filter:
266
- ordered_keys = [key_map[f] for f in filter if f in key_map and f not in ignore]
267
- else:
268
- ordered_keys = [key_map[k.lower()] for k in keys if k.lower() not in ignore]
269
-
270
- if not ordered_keys:
271
- console.print("[red]No matching keys found![/red]")
272
- return
273
-
274
- # Apply query filter before sorting
275
- if query:
276
- filtered_data = []
277
- for item in data:
278
- item_value = _resolve_path(item, query_path)
279
- if item_value is not None:
280
- try:
281
- if query_op(item_value, query_value):
282
- filtered_data.append(item)
283
- except TypeError:
284
- # Handle type mismatch gracefully
285
- continue
286
- data = filtered_data
287
-
288
- if not data:
289
- console.print("[red]No data matches the query![/red]")
290
- return
291
-
292
- # Sort data if sort key is provided
293
- if sort and sort in key_map:
294
- original_key = key_map[sort]
295
- # Try numeric sort first
296
- try:
297
- sorted_data = sorted(
298
- data,
299
- key=lambda x: float(x.get(original_key, 0)),
300
- reverse=(direction == "desc")
301
- )
302
- except (ValueError, TypeError):
303
- # Fall back to string sort
304
- sorted_data = sorted(
305
- data,
306
- key=lambda x: str(x.get(original_key, "")),
307
- reverse=(direction == "desc")
308
- )
309
- else:
310
- sorted_data = data
311
-
312
- # Add columns and display table
313
- for key in ordered_keys:
314
- table.add_column(camel_to_title(key), style="bright_white")
315
-
316
- for item in sorted_data:
317
- row = []
318
- for key in ordered_keys:
319
- value = item.get(key, "")
320
- if isinstance(value, (int, float)) and value < 0:
321
- row.append(f"[red]{value}[/red]")
322
- else:
323
- row.append(f"[bright_white]{value}[/bright_white]")
324
- table.add_row(*row)
325
-
326
- elif isinstance(data, dict):
327
- # Single dict can't be queried for rows
328
- if query:
329
- console.print("[red]Query is only supported for list of dictionaries![/red]")
330
- return
331
-
332
- # Filter and ignore the dictionary data
333
- filtered_data = {}
334
- key_map = {k.lower(): k for k in data.keys()}
335
-
336
- if filter:
337
- # Add keys in filter order if they exist and not in ignore
338
- for f in filter:
339
- if f in key_map and f not in ignore:
340
- original_key = key_map[f]
341
- value = data[original_key]
342
- if not isinstance(value, (dict, list, tuple, set)):
343
- filtered_data[original_key] = value
344
- else:
345
- # If no filter, exclude ignored and nested items
346
- filtered_data = {k: v for k, v in data.items()
347
- if not isinstance(v, (dict, list, tuple, set))
348
- and k.lower() not in ignore}
349
-
350
- if not filtered_data:
351
- console.print("[red]No matching key-value pairs to display![/red]")
352
- return
353
-
354
- # Add columns
355
- table.add_column("Key", style="cyan", no_wrap=True)
356
- table.add_column("Value", style="bright_white")
357
-
358
- # Add rows
359
- for key, value in filtered_data.items():
360
- if isinstance(value, (int, float)) and value < 0:
361
- value_str = f"[red]{value}[/red]"
362
- else:
363
- value_str = f"[bright_white]{value}[/bright_white]"
364
- table.add_row(camel_to_title(key), value_str)
365
-
366
- else:
367
- console.print("[red]Unsupported data format![/red]")
368
- return
369
-
370
- console.print(table)
371
-
372
-
373
-