bigdata-smart-batching 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bigdata_smart_batching/__init__.py +28 -0
- bigdata_smart_batching/output_converter.py +96 -0
- bigdata_smart_batching/search_function.py +1071 -0
- bigdata_smart_batching/smart_batching.py +1593 -0
- bigdata_smart_batching/smart_batching_config.py +39 -0
- bigdata_smart_batching-0.1.0.dist-info/METADATA +231 -0
- bigdata_smart_batching-0.1.0.dist-info/RECORD +8 -0
- bigdata_smart_batching-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1593 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smart Batching for Semantic Search
|
|
3
|
+
|
|
4
|
+
This module implements a planning system that uses the comention endpoint to determine
|
|
5
|
+
chunk volumes per company, then creates optimized baskets of companies for semantic
|
|
6
|
+
search queries across multiple time periods with adaptive granularity.
|
|
7
|
+
|
|
8
|
+
The system uses a two-phase approach:
|
|
9
|
+
1. Phase 1: Query the full time period once to get total chunk volumes for all companies
|
|
10
|
+
2. Phase 2: Automatically determine optimal time granularity for each company based on
|
|
11
|
+
volume and create baskets that minimize the total number of semantic search queries.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import csv
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
20
|
+
from datetime import datetime, timedelta
|
|
21
|
+
from typing import TypedDict
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from .smart_batching_config import (
|
|
26
|
+
API_BASE_URL,
|
|
27
|
+
COMENTION_ENDPOINT,
|
|
28
|
+
END_DATE,
|
|
29
|
+
MAX_CHUNKS_PER_BASKET,
|
|
30
|
+
MAX_ENTITIES_IN_ANY_OF,
|
|
31
|
+
MAX_ENTITIES_PER_QUERY,
|
|
32
|
+
START_DATE,
|
|
33
|
+
UNIVERSE_CSV_PATH,
|
|
34
|
+
VOLUME_BUCKETS,
|
|
35
|
+
VOLUME_ENDPOINT,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VolumePoint(TypedDict, total=False):
|
|
40
|
+
"""One point in a volume time series from the volume endpoint."""
|
|
41
|
+
|
|
42
|
+
date: str
|
|
43
|
+
documents: int
|
|
44
|
+
chunks: int
|
|
45
|
+
sentiment: float
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SmartBatchingPlanner:
|
|
49
|
+
"""
|
|
50
|
+
Main orchestrator class for smart batching planning.
|
|
51
|
+
|
|
52
|
+
This class handles the complete workflow of:
|
|
53
|
+
- Loading company universes from CSV
|
|
54
|
+
- Querying comention volumes via the Bigdata API
|
|
55
|
+
- Creating optimized baskets of companies for semantic search
|
|
56
|
+
- Determining optimal time granularity per company
|
|
57
|
+
- Exporting planning results to CSV files
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, api_key: str | None = None, api_base_url: str | None = None):
|
|
61
|
+
"""
|
|
62
|
+
Initialize the planner.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
api_key: BigData API key. If None, will try to get from environment variable BIGDATA_API_KEY.
|
|
66
|
+
api_base_url: API base URL. If None, will use API_BASE_URL from config or environment variable.
|
|
67
|
+
"""
|
|
68
|
+
self.api_key = api_key or os.getenv("BIGDATA_API_KEY")
|
|
69
|
+
if not self.api_key:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"API key must be provided or set in BIGDATA_API_KEY environment variable"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Use provided api_base_url, or fall back to config/environment
|
|
75
|
+
self.api_base_url = api_base_url or os.getenv("BIGDATA_API_BASE_URL") or API_BASE_URL
|
|
76
|
+
self.api_url = f"{self.api_base_url}{COMENTION_ENDPOINT}"
|
|
77
|
+
self.volume_url = f"{self.api_base_url}{VOLUME_ENDPOINT}"
|
|
78
|
+
self.headers = {
|
|
79
|
+
"X-API-KEY": self.api_key,
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def load_universe(self, csv_path: str = UNIVERSE_CSV_PATH, id_column: str = "id") -> list[str]:
|
|
84
|
+
"""
|
|
85
|
+
Read companies from CSV file.
|
|
86
|
+
|
|
87
|
+
Supports two formats:
|
|
88
|
+
1. CSV with header row containing 'id' column (e.g., id,name)
|
|
89
|
+
2. Simple CSV with one entity ID per line (no header)
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
csv_path: Path to CSV file containing company IDs
|
|
93
|
+
id_column: Name of the column containing entity IDs (default: 'id')
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
List of company IDs
|
|
97
|
+
"""
|
|
98
|
+
companies = []
|
|
99
|
+
with open(csv_path, encoding="utf-8") as f:
|
|
100
|
+
reader = csv.reader(f)
|
|
101
|
+
first_row = next(reader, None)
|
|
102
|
+
|
|
103
|
+
if first_row is None:
|
|
104
|
+
return companies
|
|
105
|
+
|
|
106
|
+
# Check if first row is a header containing the id_column
|
|
107
|
+
first_row_lower = [col.strip().lower() for col in first_row]
|
|
108
|
+
|
|
109
|
+
if id_column.lower() in first_row_lower:
|
|
110
|
+
# CSV has header - find the index of the id column
|
|
111
|
+
id_idx = first_row_lower.index(id_column.lower())
|
|
112
|
+
for row in reader:
|
|
113
|
+
if row and len(row) > id_idx and row[id_idx].strip():
|
|
114
|
+
companies.append(row[id_idx].strip())
|
|
115
|
+
else:
|
|
116
|
+
# No header - treat first row as data (first column contains IDs)
|
|
117
|
+
if first_row and first_row[0].strip():
|
|
118
|
+
companies.append(first_row[0].strip())
|
|
119
|
+
for row in reader:
|
|
120
|
+
if row and row[0].strip():
|
|
121
|
+
companies.append(row[0].strip())
|
|
122
|
+
return companies
|
|
123
|
+
|
|
124
|
+
def get_comention_volumes(
|
|
125
|
+
self,
|
|
126
|
+
companies: list[str],
|
|
127
|
+
topic: str,
|
|
128
|
+
start_date: str,
|
|
129
|
+
end_date: str,
|
|
130
|
+
) -> tuple[dict[str, int], int]:
|
|
131
|
+
"""
|
|
132
|
+
Iteratively query comention endpoint for all companies to get chunk volumes.
|
|
133
|
+
|
|
134
|
+
Uses a three-pass approach for maximum accuracy:
|
|
135
|
+
1. First pass: Query all companies in batches
|
|
136
|
+
2. Second pass: Verify companies that didn't appear in first pass results
|
|
137
|
+
(they may have low volume that was pushed out by other high-volume entities)
|
|
138
|
+
3. Third pass: Final verification for companies that still didn't appear in second pass
|
|
139
|
+
(ensures we catch all companies with any volume, even very low volume)
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
companies: List of company IDs
|
|
143
|
+
topic: Topic string for the comention query
|
|
144
|
+
start_date: Start date in ISO format (YYYY-MM-DD)
|
|
145
|
+
end_date: End date in ISO format (YYYY-MM-DD)
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Tuple of (company_volumes_dict, query_count) where:
|
|
149
|
+
- company_volumes_dict: Dict mapping company_id -> total_chunks_count
|
|
150
|
+
Companies with 0 chunks will not appear in the dict (confirmed 0 after 3 passes)
|
|
151
|
+
- query_count: Number of API queries made
|
|
152
|
+
"""
|
|
153
|
+
company_volumes = {}
|
|
154
|
+
remaining_companies = companies.copy()
|
|
155
|
+
query_count = 0
|
|
156
|
+
total_queries_needed = (
|
|
157
|
+
len(companies) + MAX_ENTITIES_IN_ANY_OF - 1
|
|
158
|
+
) // MAX_ENTITIES_IN_ANY_OF
|
|
159
|
+
|
|
160
|
+
# Convert dates to ISO format with timezone
|
|
161
|
+
start_iso = f"{start_date}T00:00:00Z"
|
|
162
|
+
end_iso = f"{end_date}T23:59:59Z"
|
|
163
|
+
|
|
164
|
+
print(
|
|
165
|
+
f" Querying {len(companies)} companies in batches of {MAX_ENTITIES_IN_ANY_OF} (estimated {total_queries_needed} queries)..."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Track companies that were queried but didn't appear in results
|
|
169
|
+
unverified_companies = []
|
|
170
|
+
|
|
171
|
+
# FIRST PASS: Query all companies in batches
|
|
172
|
+
while remaining_companies:
|
|
173
|
+
# Take up to MAX_ENTITIES_IN_ANY_OF companies per query (API complexity limit)
|
|
174
|
+
batch = remaining_companies[:MAX_ENTITIES_IN_ANY_OF]
|
|
175
|
+
|
|
176
|
+
if not batch:
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
payload = {
|
|
180
|
+
"query": {
|
|
181
|
+
"auto_enrich_filters": False,
|
|
182
|
+
"entity_categories": ["companies"],
|
|
183
|
+
"text": topic,
|
|
184
|
+
"filters": {
|
|
185
|
+
"timestamp": {
|
|
186
|
+
"start": start_iso,
|
|
187
|
+
"end": end_iso,
|
|
188
|
+
},
|
|
189
|
+
"entity": {
|
|
190
|
+
"all_of": [],
|
|
191
|
+
"any_of": batch,
|
|
192
|
+
"none_of": [],
|
|
193
|
+
"search_in": "ALL",
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
"limit": MAX_ENTITIES_PER_QUERY,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
response = requests.post(self.api_url, json=payload, headers=self.headers)
|
|
202
|
+
response.raise_for_status()
|
|
203
|
+
data = response.json()
|
|
204
|
+
query_count += 1
|
|
205
|
+
|
|
206
|
+
# Extract company volumes from response
|
|
207
|
+
results = data.get("results", {})
|
|
208
|
+
companies_data = results.get("companies", [])
|
|
209
|
+
|
|
210
|
+
# Track which companies from our batch appeared in the response with chunk data
|
|
211
|
+
found_company_ids = set()
|
|
212
|
+
for company_data in companies_data:
|
|
213
|
+
company_id = company_data.get("id")
|
|
214
|
+
# Only consider companies that have total_chunks_count field
|
|
215
|
+
# Companies with only total_headlines_count are treated as not found
|
|
216
|
+
if "total_chunks_count" not in company_data:
|
|
217
|
+
continue
|
|
218
|
+
chunks_count = company_data["total_chunks_count"]
|
|
219
|
+
if company_id and company_id in batch and chunks_count > 0:
|
|
220
|
+
company_volumes[company_id] = chunks_count
|
|
221
|
+
found_company_ids.add(company_id)
|
|
222
|
+
|
|
223
|
+
# Identify companies from our batch that didn't appear in results
|
|
224
|
+
# These might have low volume that was pushed out by other high-volume entities
|
|
225
|
+
for company_id in batch:
|
|
226
|
+
if company_id not in found_company_ids:
|
|
227
|
+
unverified_companies.append(company_id)
|
|
228
|
+
|
|
229
|
+
# Show progress - count companies found from our universe batch
|
|
230
|
+
found_count = len(found_company_ids)
|
|
231
|
+
found_from_universe = [cid for cid in found_company_ids if cid in batch]
|
|
232
|
+
found_from_universe_count = len(found_from_universe)
|
|
233
|
+
print(
|
|
234
|
+
f" Query {query_count}/{total_queries_needed}: Found {found_from_universe_count} companies from universe batch (out of {len(batch)} input)"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Remove processed companies from remaining list
|
|
238
|
+
remaining_companies = remaining_companies[MAX_ENTITIES_IN_ANY_OF:]
|
|
239
|
+
|
|
240
|
+
except requests.exceptions.HTTPError as e:
|
|
241
|
+
# Try to get error details from response
|
|
242
|
+
error_msg = str(e)
|
|
243
|
+
try:
|
|
244
|
+
error_details = response.json()
|
|
245
|
+
error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
|
|
246
|
+
except:
|
|
247
|
+
try:
|
|
248
|
+
error_text = response.text
|
|
249
|
+
error_msg = f"{error_msg}\nResponse: {error_text[:500]}"
|
|
250
|
+
except:
|
|
251
|
+
pass
|
|
252
|
+
raise RuntimeError(f"Error querying comention endpoint: {error_msg}")
|
|
253
|
+
except requests.exceptions.RequestException as e:
|
|
254
|
+
raise RuntimeError(f"Error querying comention endpoint: {e}")
|
|
255
|
+
|
|
256
|
+
# SECOND PASS: Verify companies that didn't appear in first pass
|
|
257
|
+
still_unverified = []
|
|
258
|
+
if unverified_companies:
|
|
259
|
+
print(
|
|
260
|
+
f"\n Verification pass 1: Re-checking {len(unverified_companies)} companies from universe that didn't appear in first pass..."
|
|
261
|
+
)
|
|
262
|
+
verification_queries_needed = (
|
|
263
|
+
len(unverified_companies) + MAX_ENTITIES_IN_ANY_OF - 1
|
|
264
|
+
) // MAX_ENTITIES_IN_ANY_OF
|
|
265
|
+
|
|
266
|
+
remaining_unverified = unverified_companies.copy()
|
|
267
|
+
verification_count = 0
|
|
268
|
+
|
|
269
|
+
while remaining_unverified:
|
|
270
|
+
# Query smaller batches for verification (can use same size or smaller)
|
|
271
|
+
batch = remaining_unverified[:MAX_ENTITIES_IN_ANY_OF]
|
|
272
|
+
|
|
273
|
+
if not batch:
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
payload = {
|
|
277
|
+
"query": {
|
|
278
|
+
"text": topic,
|
|
279
|
+
"filters": {
|
|
280
|
+
"timestamp": {
|
|
281
|
+
"start": start_iso,
|
|
282
|
+
"end": end_iso,
|
|
283
|
+
},
|
|
284
|
+
"entity": {
|
|
285
|
+
"all_of": [],
|
|
286
|
+
"any_of": batch,
|
|
287
|
+
"none_of": [],
|
|
288
|
+
"search_in": "ALL",
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
"limit": MAX_ENTITIES_PER_QUERY,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
response = requests.post(self.api_url, json=payload, headers=self.headers)
|
|
297
|
+
response.raise_for_status()
|
|
298
|
+
data = response.json()
|
|
299
|
+
query_count += 1
|
|
300
|
+
verification_count += 1
|
|
301
|
+
|
|
302
|
+
# Extract company volumes from response
|
|
303
|
+
results = data.get("results", {})
|
|
304
|
+
companies_data = results.get("companies", [])
|
|
305
|
+
|
|
306
|
+
# Track which companies from this batch appeared with chunk data
|
|
307
|
+
found_in_verification = set()
|
|
308
|
+
verified_count = 0
|
|
309
|
+
for company_data in companies_data:
|
|
310
|
+
company_id = company_data.get("id")
|
|
311
|
+
# Only consider companies that have total_chunks_count field
|
|
312
|
+
if "total_chunks_count" not in company_data:
|
|
313
|
+
continue
|
|
314
|
+
chunks_count = company_data["total_chunks_count"]
|
|
315
|
+
if company_id and company_id in batch and chunks_count > 0:
|
|
316
|
+
# Only update if this company was in our verification batch
|
|
317
|
+
company_volumes[company_id] = chunks_count
|
|
318
|
+
found_in_verification.add(company_id)
|
|
319
|
+
verified_count += 1
|
|
320
|
+
|
|
321
|
+
# Track companies that still didn't appear
|
|
322
|
+
for company_id in batch:
|
|
323
|
+
if company_id not in found_in_verification:
|
|
324
|
+
still_unverified.append(company_id)
|
|
325
|
+
|
|
326
|
+
# Count companies from universe found in this verification pass
|
|
327
|
+
found_in_this_pass = [cid for cid in found_in_verification if cid in batch]
|
|
328
|
+
found_from_universe_count = len(found_in_this_pass)
|
|
329
|
+
print(
|
|
330
|
+
f" Verification query {verification_count}/{verification_queries_needed}: Found {found_from_universe_count} companies from universe (out of {len(batch)} input)"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Remove processed companies from remaining list
|
|
334
|
+
remaining_unverified = remaining_unverified[MAX_ENTITIES_IN_ANY_OF:]
|
|
335
|
+
|
|
336
|
+
except requests.exceptions.HTTPError as e:
|
|
337
|
+
error_msg = str(e)
|
|
338
|
+
try:
|
|
339
|
+
error_details = response.json()
|
|
340
|
+
error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
|
|
341
|
+
except:
|
|
342
|
+
try:
|
|
343
|
+
error_text = response.text
|
|
344
|
+
error_msg = f"{error_msg}\nResponse: {error_text[:500]}"
|
|
345
|
+
except:
|
|
346
|
+
pass
|
|
347
|
+
raise RuntimeError(f"Error in verification query: {error_msg}")
|
|
348
|
+
except requests.exceptions.RequestException as e:
|
|
349
|
+
raise RuntimeError(f"Error in verification query: {e}")
|
|
350
|
+
|
|
351
|
+
# THIRD PASS: Final verification for companies that still didn't appear
|
|
352
|
+
if still_unverified:
|
|
353
|
+
print(
|
|
354
|
+
f"\n Verification pass 2: Final check for {len(still_unverified)} companies from universe that still need verification..."
|
|
355
|
+
)
|
|
356
|
+
final_verification_queries_needed = (
|
|
357
|
+
len(still_unverified) + MAX_ENTITIES_IN_ANY_OF - 1
|
|
358
|
+
) // MAX_ENTITIES_IN_ANY_OF
|
|
359
|
+
|
|
360
|
+
remaining_final_unverified = still_unverified.copy()
|
|
361
|
+
final_verification_count = 0
|
|
362
|
+
|
|
363
|
+
while remaining_final_unverified:
|
|
364
|
+
batch = remaining_final_unverified[:MAX_ENTITIES_IN_ANY_OF]
|
|
365
|
+
|
|
366
|
+
if not batch:
|
|
367
|
+
break
|
|
368
|
+
|
|
369
|
+
payload = {
|
|
370
|
+
"query": {
|
|
371
|
+
"text": topic,
|
|
372
|
+
"filters": {
|
|
373
|
+
"timestamp": {
|
|
374
|
+
"start": start_iso,
|
|
375
|
+
"end": end_iso,
|
|
376
|
+
},
|
|
377
|
+
"entity": {
|
|
378
|
+
"all_of": [],
|
|
379
|
+
"any_of": batch,
|
|
380
|
+
"none_of": [],
|
|
381
|
+
"search_in": "ALL",
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
"limit": MAX_ENTITIES_PER_QUERY,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
response = requests.post(self.api_url, json=payload, headers=self.headers)
|
|
390
|
+
response.raise_for_status()
|
|
391
|
+
data = response.json()
|
|
392
|
+
query_count += 1
|
|
393
|
+
final_verification_count += 1
|
|
394
|
+
|
|
395
|
+
# Extract company volumes from response
|
|
396
|
+
results = data.get("results", {})
|
|
397
|
+
companies_data = results.get("companies", [])
|
|
398
|
+
|
|
399
|
+
final_verified_count = 0
|
|
400
|
+
found_in_final_pass = []
|
|
401
|
+
for company_data in companies_data:
|
|
402
|
+
company_id = company_data.get("id")
|
|
403
|
+
# Only consider companies that have total_chunks_count field
|
|
404
|
+
if "total_chunks_count" not in company_data:
|
|
405
|
+
continue
|
|
406
|
+
chunks_count = company_data["total_chunks_count"]
|
|
407
|
+
if company_id and company_id in batch and chunks_count > 0:
|
|
408
|
+
# Only update if this company was in our final verification batch
|
|
409
|
+
company_volumes[company_id] = chunks_count
|
|
410
|
+
found_in_final_pass.append(company_id)
|
|
411
|
+
final_verified_count += 1
|
|
412
|
+
|
|
413
|
+
# Count companies from universe found in final verification
|
|
414
|
+
found_from_universe_count = len(found_in_final_pass)
|
|
415
|
+
print(
|
|
416
|
+
f" Final verification query {final_verification_count}/{final_verification_queries_needed}: Found {found_from_universe_count} companies from universe (out of {len(batch)} input)"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Remove processed companies from remaining list
|
|
420
|
+
remaining_final_unverified = remaining_final_unverified[MAX_ENTITIES_IN_ANY_OF:]
|
|
421
|
+
|
|
422
|
+
except requests.exceptions.HTTPError as e:
|
|
423
|
+
error_msg = str(e)
|
|
424
|
+
try:
|
|
425
|
+
error_details = response.json()
|
|
426
|
+
error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
|
|
427
|
+
except:
|
|
428
|
+
try:
|
|
429
|
+
error_text = response.text
|
|
430
|
+
error_msg = f"{error_msg}\nResponse: {error_text[:500]}"
|
|
431
|
+
except:
|
|
432
|
+
pass
|
|
433
|
+
raise RuntimeError(f"Error in final verification query: {error_msg}")
|
|
434
|
+
except requests.exceptions.RequestException as e:
|
|
435
|
+
raise RuntimeError(f"Error in final verification query: {e}")
|
|
436
|
+
|
|
437
|
+
# Companies that still don't appear after third pass are confirmed to have 0 volume
|
|
438
|
+
confirmed_zero = len(still_unverified) - len(
|
|
439
|
+
[c for c in still_unverified if c in company_volumes]
|
|
440
|
+
)
|
|
441
|
+
if confirmed_zero > 0:
|
|
442
|
+
print(
|
|
443
|
+
f" Confirmed {confirmed_zero} companies with zero volume after final verification"
|
|
444
|
+
)
|
|
445
|
+
elif unverified_companies:
|
|
446
|
+
# If there were unverified companies but none remain after second pass, all were found
|
|
447
|
+
confirmed_zero = len(unverified_companies) - len(
|
|
448
|
+
[c for c in unverified_companies if c in company_volumes]
|
|
449
|
+
)
|
|
450
|
+
if confirmed_zero > 0:
|
|
451
|
+
print(
|
|
452
|
+
f" Confirmed {confirmed_zero} companies with zero volume after first verification pass"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
print(
|
|
456
|
+
f" Completed {query_count} queries. Found {len(company_volumes)} companies with chunks > 0"
|
|
457
|
+
)
|
|
458
|
+
return company_volumes, query_count
|
|
459
|
+
|
|
460
|
+
def _process_single_batch_comention(
|
|
461
|
+
self,
|
|
462
|
+
batch_idx: int,
|
|
463
|
+
companies: list[str],
|
|
464
|
+
topic: str,
|
|
465
|
+
start_iso: str,
|
|
466
|
+
end_iso: str,
|
|
467
|
+
max_iterations_per_batch: int,
|
|
468
|
+
total_batches: int,
|
|
469
|
+
) -> tuple[dict[str, int], int, list[str]]:
|
|
470
|
+
"""
|
|
471
|
+
Process a single batch for comention iterative query. Used by ThreadPoolExecutor.
|
|
472
|
+
Returns (batch_company_volumes, batch_query_count, batch_very_low_list).
|
|
473
|
+
"""
|
|
474
|
+
batch_start = batch_idx * MAX_ENTITIES_IN_ANY_OF
|
|
475
|
+
batch_end = min(batch_start + MAX_ENTITIES_IN_ANY_OF, len(companies))
|
|
476
|
+
batch_original = companies[batch_start:batch_end]
|
|
477
|
+
batch_original_size = len(batch_original)
|
|
478
|
+
|
|
479
|
+
batch_company_volumes: dict[str, int] = {}
|
|
480
|
+
batch_remaining_set = set(batch_original)
|
|
481
|
+
batch_found_total = 0
|
|
482
|
+
batch_query_count = 0
|
|
483
|
+
iteration = 0
|
|
484
|
+
|
|
485
|
+
while batch_remaining_set and iteration < max_iterations_per_batch:
|
|
486
|
+
iteration += 1
|
|
487
|
+
batch_remaining_list = list(batch_remaining_set)
|
|
488
|
+
|
|
489
|
+
payload = {
|
|
490
|
+
"query": {
|
|
491
|
+
"auto_enrich_filters": False,
|
|
492
|
+
"entity_categories": ["companies"],
|
|
493
|
+
"text": topic,
|
|
494
|
+
"filters": {
|
|
495
|
+
"timestamp": {"start": start_iso, "end": end_iso},
|
|
496
|
+
"entity": {
|
|
497
|
+
"all_of": [],
|
|
498
|
+
"any_of": batch_remaining_list,
|
|
499
|
+
"none_of": [],
|
|
500
|
+
"search_in": "ALL",
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
"limit": MAX_ENTITIES_PER_QUERY,
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
response = requests.post(self.api_url, json=payload, headers=self.headers)
|
|
509
|
+
response.raise_for_status()
|
|
510
|
+
data = response.json()
|
|
511
|
+
batch_query_count += 1
|
|
512
|
+
|
|
513
|
+
results = data.get("results", {})
|
|
514
|
+
companies_data = results.get("companies", [])
|
|
515
|
+
|
|
516
|
+
found_in_iteration: set[str] = set()
|
|
517
|
+
for company_data in companies_data:
|
|
518
|
+
company_id = company_data.get("id")
|
|
519
|
+
if "total_chunks_count" not in company_data:
|
|
520
|
+
continue
|
|
521
|
+
chunks_count = company_data["total_chunks_count"]
|
|
522
|
+
if company_id and company_id in batch_remaining_set and chunks_count > 0:
|
|
523
|
+
batch_company_volumes[company_id] = chunks_count
|
|
524
|
+
found_in_iteration.add(company_id)
|
|
525
|
+
|
|
526
|
+
found_count = len(found_in_iteration)
|
|
527
|
+
batch_found_total += found_count
|
|
528
|
+
batch_remaining_set -= found_in_iteration
|
|
529
|
+
|
|
530
|
+
print(
|
|
531
|
+
f" Batch {batch_idx + 1}/{total_batches}, Iter {iteration}: "
|
|
532
|
+
f"Found {found_count} new companies, {len(batch_remaining_set)} remaining"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
if found_count == 0:
|
|
536
|
+
break
|
|
537
|
+
|
|
538
|
+
except requests.exceptions.HTTPError as e:
|
|
539
|
+
error_msg = str(e)
|
|
540
|
+
try:
|
|
541
|
+
error_details = response.json()
|
|
542
|
+
error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
|
|
543
|
+
except Exception:
|
|
544
|
+
try:
|
|
545
|
+
error_text = response.text
|
|
546
|
+
error_msg = f"{error_msg}\nResponse: {error_text[:500]}"
|
|
547
|
+
except Exception:
|
|
548
|
+
pass
|
|
549
|
+
raise RuntimeError(f"Error querying comention endpoint: {error_msg}") from e
|
|
550
|
+
except requests.exceptions.RequestException as e:
|
|
551
|
+
raise RuntimeError(f"Error querying comention endpoint: {e}") from e
|
|
552
|
+
|
|
553
|
+
zero_count = batch_original_size - batch_found_total
|
|
554
|
+
print(
|
|
555
|
+
f" Batch {batch_idx + 1} complete: {batch_found_total} found, "
|
|
556
|
+
f"{zero_count} very_low, {iteration} iterations"
|
|
557
|
+
)
|
|
558
|
+
return batch_company_volumes, batch_query_count, list(batch_remaining_set)
|
|
559
|
+
|
|
560
|
+
def get_comention_volumes_iterative(
|
|
561
|
+
self,
|
|
562
|
+
companies: list[str],
|
|
563
|
+
topic: str,
|
|
564
|
+
start_date: str,
|
|
565
|
+
end_date: str,
|
|
566
|
+
max_iterations_per_batch: int = 10,
|
|
567
|
+
max_workers: int | None = None,
|
|
568
|
+
) -> tuple[dict[str, int], int, list[str]]:
|
|
569
|
+
"""
|
|
570
|
+
Query comention endpoint using iterative per-batch approach.
|
|
571
|
+
Batches are processed in parallel via ThreadPoolExecutor.
|
|
572
|
+
|
|
573
|
+
For each batch of companies:
|
|
574
|
+
1. Query the batch
|
|
575
|
+
2. Remove found companies from the batch
|
|
576
|
+
3. Re-query remaining companies in the same batch
|
|
577
|
+
4. Stop when an iteration returns 0 new companies
|
|
578
|
+
5. Move to the next batch
|
|
579
|
+
|
|
580
|
+
This is more efficient than the 3-pass approach because it:
|
|
581
|
+
- Handles each batch independently
|
|
582
|
+
- Stops as soon as no new companies are found
|
|
583
|
+
- Never re-queries already found companies
|
|
584
|
+
- Runs batches in parallel (ThreadPoolExecutor) for speed
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
companies: List of company IDs
|
|
588
|
+
topic: Topic string for the comention query
|
|
589
|
+
start_date: Start date in ISO format (YYYY-MM-DD)
|
|
590
|
+
end_date: End date in ISO format (YYYY-MM-DD)
|
|
591
|
+
max_iterations_per_batch: Maximum iterations per batch to prevent infinite loops (default 10)
|
|
592
|
+
max_workers: Max concurrent batch workers. Defaults to min(8, total_batches).
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Tuple of (company_volumes_dict, query_count, very_low_companies) where:
|
|
596
|
+
- company_volumes_dict: Dict mapping company_id -> total_chunks_count (chunks > 0)
|
|
597
|
+
- query_count: Number of API queries made
|
|
598
|
+
- very_low_companies: List of company IDs that have no chunks (0 or not found)
|
|
599
|
+
"""
|
|
600
|
+
company_volumes: dict[str, int] = {}
|
|
601
|
+
very_low_companies: list[str] = []
|
|
602
|
+
query_count = 0
|
|
603
|
+
total_batches = (len(companies) + MAX_ENTITIES_IN_ANY_OF - 1) // MAX_ENTITIES_IN_ANY_OF
|
|
604
|
+
workers = max_workers if max_workers is not None else min(8, total_batches)
|
|
605
|
+
|
|
606
|
+
start_iso = f"{start_date}T00:00:00Z"
|
|
607
|
+
end_iso = f"{end_date}T23:59:59Z"
|
|
608
|
+
|
|
609
|
+
print(
|
|
610
|
+
f" [ITERATIVE MODE] Querying {len(companies)} companies in {total_batches} batches "
|
|
611
|
+
f"of {MAX_ENTITIES_IN_ANY_OF} (max_workers={workers})"
|
|
612
|
+
)
|
|
613
|
+
print(
|
|
614
|
+
f" Each batch iterates until no new companies are found (max {max_iterations_per_batch} iterations)"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
618
|
+
futures = {
|
|
619
|
+
executor.submit(
|
|
620
|
+
self._process_single_batch_comention,
|
|
621
|
+
batch_idx,
|
|
622
|
+
companies,
|
|
623
|
+
topic,
|
|
624
|
+
start_iso,
|
|
625
|
+
end_iso,
|
|
626
|
+
max_iterations_per_batch,
|
|
627
|
+
total_batches,
|
|
628
|
+
): batch_idx
|
|
629
|
+
for batch_idx in range(total_batches)
|
|
630
|
+
}
|
|
631
|
+
for future in as_completed(futures):
|
|
632
|
+
batch_volumes, batch_queries, batch_very_low = future.result()
|
|
633
|
+
company_volumes.update(batch_volumes)
|
|
634
|
+
query_count += batch_queries
|
|
635
|
+
very_low_companies.extend(batch_very_low)
|
|
636
|
+
|
|
637
|
+
print(
|
|
638
|
+
f" Completed {query_count} queries. Found {len(company_volumes)} companies with "
|
|
639
|
+
f"chunks > 0, {len(very_low_companies)} very_low"
|
|
640
|
+
)
|
|
641
|
+
return company_volumes, query_count, very_low_companies
|
|
642
|
+
|
|
643
|
+
def get_volume_timeseries(
|
|
644
|
+
self,
|
|
645
|
+
companies: list[str],
|
|
646
|
+
topic: str,
|
|
647
|
+
start_date: str,
|
|
648
|
+
end_date: str,
|
|
649
|
+
) -> list[VolumePoint]:
|
|
650
|
+
"""
|
|
651
|
+
Query the volume endpoint for a set of companies and return a time series.
|
|
652
|
+
|
|
653
|
+
Batches companies by MAX_ENTITIES_IN_ANY_OF; aggregates results by date
|
|
654
|
+
across all batches: documents and chunks are summed per date; sentiment
|
|
655
|
+
is the arithmetic mean of per-batch sentiment values for that date
|
|
656
|
+
(batches with no sentiment for a date are omitted from the mean).
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
companies: List of company IDs
|
|
660
|
+
topic: Topic string for the query
|
|
661
|
+
start_date: Start date in YYYY-MM-DD format
|
|
662
|
+
end_date: End date in YYYY-MM-DD format
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
List of VolumePoint dicts with keys date, documents, chunks, sentiment,
|
|
666
|
+
sorted by date. Empty list on API error or empty response.
|
|
667
|
+
"""
|
|
668
|
+
start_iso = f"{start_date}T00:00:00Z"
|
|
669
|
+
end_iso = f"{end_date}T23:59:59Z"
|
|
670
|
+
total_batches = (len(companies) + MAX_ENTITIES_IN_ANY_OF - 1) // MAX_ENTITIES_IN_ANY_OF
|
|
671
|
+
|
|
672
|
+
acc_docs: dict[str, int] = defaultdict(int)
|
|
673
|
+
acc_chunks: dict[str, int] = defaultdict(int)
|
|
674
|
+
acc_sentiment_sum: dict[str, float] = defaultdict(float)
|
|
675
|
+
acc_sentiment_count: dict[str, int] = defaultdict(int)
|
|
676
|
+
|
|
677
|
+
for batch_idx in range(total_batches):
|
|
678
|
+
batch_start = batch_idx * MAX_ENTITIES_IN_ANY_OF
|
|
679
|
+
batch_end = min(batch_start + MAX_ENTITIES_IN_ANY_OF, len(companies))
|
|
680
|
+
batch = companies[batch_start:batch_end]
|
|
681
|
+
|
|
682
|
+
payload = {
|
|
683
|
+
"query": {
|
|
684
|
+
"auto_enrich_filters": False,
|
|
685
|
+
"text": topic,
|
|
686
|
+
"filters": {
|
|
687
|
+
"timestamp": {"start": start_iso, "end": end_iso},
|
|
688
|
+
"entity": {
|
|
689
|
+
"all_of": [],
|
|
690
|
+
"any_of": batch,
|
|
691
|
+
"none_of": [],
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
"ranking_params": {
|
|
695
|
+
"source_boost": 1,
|
|
696
|
+
"freshness_boost": 0,
|
|
697
|
+
"reranker": {"enabled": False},
|
|
698
|
+
},
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
response = requests.post(self.volume_url, json=payload, headers=self.headers)
|
|
704
|
+
response.raise_for_status()
|
|
705
|
+
data = response.json()
|
|
706
|
+
except requests.exceptions.HTTPError as e:
|
|
707
|
+
error_msg = str(e)
|
|
708
|
+
try:
|
|
709
|
+
error_details = response.json()
|
|
710
|
+
error_msg = f"{error_msg}\nResponse: {json.dumps(error_details, indent=2)}"
|
|
711
|
+
except Exception:
|
|
712
|
+
try:
|
|
713
|
+
error_msg = f"{error_msg}\nResponse: {response.text[:500]}"
|
|
714
|
+
except Exception:
|
|
715
|
+
pass
|
|
716
|
+
raise RuntimeError(f"Error querying volume endpoint: {error_msg}")
|
|
717
|
+
except requests.exceptions.RequestException as e:
|
|
718
|
+
raise RuntimeError(f"Error querying volume endpoint: {e}")
|
|
719
|
+
|
|
720
|
+
# Parse response: accept list at top level or under "results" / "volume"
|
|
721
|
+
raw_list = data.get("results", {}).get("volume", [])
|
|
722
|
+
for point in raw_list:
|
|
723
|
+
d = point.get("date")
|
|
724
|
+
if d is None:
|
|
725
|
+
continue
|
|
726
|
+
acc_docs[d] += int(point.get("documents") or 0)
|
|
727
|
+
acc_chunks[d] += int(point.get("chunks") or 0)
|
|
728
|
+
s = point.get("sentiment")
|
|
729
|
+
if s is not None:
|
|
730
|
+
acc_sentiment_sum[d] += float(s)
|
|
731
|
+
acc_sentiment_count[d] += 1
|
|
732
|
+
|
|
733
|
+
all_dates = sorted(
|
|
734
|
+
set(acc_docs.keys()) | set(acc_chunks.keys()) | set(acc_sentiment_count.keys())
|
|
735
|
+
)
|
|
736
|
+
out: list[VolumePoint] = []
|
|
737
|
+
for d in all_dates:
|
|
738
|
+
vp: VolumePoint = {
|
|
739
|
+
"date": d,
|
|
740
|
+
"documents": acc_docs[d],
|
|
741
|
+
"chunks": acc_chunks[d],
|
|
742
|
+
}
|
|
743
|
+
if acc_sentiment_count[d] > 0:
|
|
744
|
+
vp["sentiment"] = acc_sentiment_sum[d] / acc_sentiment_count[d]
|
|
745
|
+
out.append(vp)
|
|
746
|
+
return out
|
|
747
|
+
|
|
748
|
+
def filter_zero_volume(self, company_volumes: dict[str, int]) -> dict[str, int]:
|
|
749
|
+
"""
|
|
750
|
+
Filter out companies with 0 chunks (no search needed).
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
company_volumes: Dict mapping company_id -> chunks
|
|
754
|
+
|
|
755
|
+
Returns:
|
|
756
|
+
Dict with only companies that have chunks > 0
|
|
757
|
+
"""
|
|
758
|
+
return {cid: chunks for cid, chunks in company_volumes.items() if chunks > 0}
|
|
759
|
+
|
|
760
|
+
def group_by_volume(self, company_volumes: dict[str, int]) -> dict[str, list[tuple[str, int]]]:
|
|
761
|
+
"""
|
|
762
|
+
Group companies into volume buckets.
|
|
763
|
+
|
|
764
|
+
Args:
|
|
765
|
+
company_volumes: Dict mapping company_id -> chunks
|
|
766
|
+
|
|
767
|
+
Returns:
|
|
768
|
+
Dict mapping bucket name -> list of (company_id, chunks) tuples, sorted by chunks descending
|
|
769
|
+
"""
|
|
770
|
+
buckets = defaultdict(list)
|
|
771
|
+
|
|
772
|
+
for company_id, chunks in company_volumes.items():
|
|
773
|
+
if chunks >= VOLUME_BUCKETS["high"][0]:
|
|
774
|
+
buckets["high"].append((company_id, chunks))
|
|
775
|
+
elif chunks >= VOLUME_BUCKETS["medium"][0]:
|
|
776
|
+
buckets["medium"].append((company_id, chunks))
|
|
777
|
+
elif chunks >= VOLUME_BUCKETS["low"][0]:
|
|
778
|
+
buckets["low"].append((company_id, chunks))
|
|
779
|
+
|
|
780
|
+
# Sort each bucket by chunks (descending)
|
|
781
|
+
for bucket_name in buckets:
|
|
782
|
+
buckets[bucket_name].sort(key=lambda x: x[1], reverse=True)
|
|
783
|
+
|
|
784
|
+
return dict(buckets)
|
|
785
|
+
|
|
786
|
+
def create_baskets(
|
|
787
|
+
self,
|
|
788
|
+
company_volumes: dict[str, int],
|
|
789
|
+
max_chunks: int = MAX_CHUNKS_PER_BASKET,
|
|
790
|
+
very_low_companies: list[str] | None = None,
|
|
791
|
+
min_entities_per_basket: int = 1,
|
|
792
|
+
) -> list[dict]:
|
|
793
|
+
"""
|
|
794
|
+
Create baskets of companies with total chunks < max_chunks.
|
|
795
|
+
|
|
796
|
+
After the initial greedy packing, a post-processing step merges any
|
|
797
|
+
basket that has fewer than min_entities_per_basket entities into an
|
|
798
|
+
adjacent basket (respecting the MAX_ENTITIES_IN_ANY_OF hard limit).
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
company_volumes: Dict mapping company_id -> chunks (already filtered to exclude 0-chunk companies)
|
|
802
|
+
max_chunks: Maximum total chunks per basket
|
|
803
|
+
very_low_companies: Optional list of company IDs with 0 chunks (will be added to very_low baskets)
|
|
804
|
+
min_entities_per_basket: Minimum entities per basket. Baskets below this
|
|
805
|
+
threshold are merged with a neighbor. Default is 1 (no merging).
|
|
806
|
+
|
|
807
|
+
Returns:
|
|
808
|
+
List of basket dictionaries, each containing:
|
|
809
|
+
- basket_id: Unique identifier
|
|
810
|
+
- companies: List of company IDs
|
|
811
|
+
- total_chunks: Sum of chunks for all companies in basket
|
|
812
|
+
- company_count: Number of companies in basket
|
|
813
|
+
- volume_range: Volume range category
|
|
814
|
+
- company_chunks: Dict mapping company_id -> chunks for this basket
|
|
815
|
+
"""
|
|
816
|
+
# Filter out zero-chunk companies
|
|
817
|
+
filtered_volumes = self.filter_zero_volume(company_volumes)
|
|
818
|
+
|
|
819
|
+
baskets: list[dict] = []
|
|
820
|
+
basket_counter = 0
|
|
821
|
+
|
|
822
|
+
if filtered_volumes:
|
|
823
|
+
# Group by volume
|
|
824
|
+
volume_groups = self.group_by_volume(filtered_volumes)
|
|
825
|
+
|
|
826
|
+
# Process each volume group
|
|
827
|
+
for volume_range, companies_list in volume_groups.items():
|
|
828
|
+
current_basket: dict = {
|
|
829
|
+
"companies": [],
|
|
830
|
+
"company_chunks": {},
|
|
831
|
+
"total_chunks": 0,
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
for company_id, chunks in companies_list:
|
|
835
|
+
# Check if adding this company would exceed the chunk limit OR entity limit
|
|
836
|
+
if (
|
|
837
|
+
current_basket["total_chunks"] + chunks > max_chunks
|
|
838
|
+
or len(current_basket["companies"]) >= MAX_ENTITIES_IN_ANY_OF
|
|
839
|
+
):
|
|
840
|
+
# Save current basket and start a new one
|
|
841
|
+
if current_basket["companies"]:
|
|
842
|
+
baskets.append(
|
|
843
|
+
{
|
|
844
|
+
"basket_id": f"{volume_range}_basket_{basket_counter}",
|
|
845
|
+
"companies": current_basket["companies"],
|
|
846
|
+
"company_chunks": current_basket["company_chunks"].copy(),
|
|
847
|
+
"total_chunks": current_basket["total_chunks"],
|
|
848
|
+
"company_count": len(current_basket["companies"]),
|
|
849
|
+
"volume_range": volume_range,
|
|
850
|
+
}
|
|
851
|
+
)
|
|
852
|
+
basket_counter += 1
|
|
853
|
+
|
|
854
|
+
# Start new basket with this company
|
|
855
|
+
current_basket = {
|
|
856
|
+
"companies": [company_id],
|
|
857
|
+
"company_chunks": {company_id: chunks},
|
|
858
|
+
"total_chunks": chunks,
|
|
859
|
+
}
|
|
860
|
+
else:
|
|
861
|
+
# Add company to current basket
|
|
862
|
+
current_basket["companies"].append(company_id)
|
|
863
|
+
current_basket["company_chunks"][company_id] = chunks
|
|
864
|
+
current_basket["total_chunks"] += chunks
|
|
865
|
+
|
|
866
|
+
# Don't forget the last basket in this volume range
|
|
867
|
+
if current_basket["companies"]:
|
|
868
|
+
baskets.append(
|
|
869
|
+
{
|
|
870
|
+
"basket_id": f"{volume_range}_basket_{basket_counter}",
|
|
871
|
+
"companies": current_basket["companies"],
|
|
872
|
+
"company_chunks": current_basket["company_chunks"].copy(),
|
|
873
|
+
"total_chunks": current_basket["total_chunks"],
|
|
874
|
+
"company_count": len(current_basket["companies"]),
|
|
875
|
+
"volume_range": volume_range,
|
|
876
|
+
}
|
|
877
|
+
)
|
|
878
|
+
basket_counter += 1
|
|
879
|
+
|
|
880
|
+
# Process very_low companies (0 chunks) - max 500 companies per basket
|
|
881
|
+
if very_low_companies:
|
|
882
|
+
very_low_basket_counter = 0
|
|
883
|
+
for i in range(0, len(very_low_companies), MAX_ENTITIES_IN_ANY_OF):
|
|
884
|
+
batch = very_low_companies[i : i + MAX_ENTITIES_IN_ANY_OF]
|
|
885
|
+
baskets.append(
|
|
886
|
+
{
|
|
887
|
+
"basket_id": f"very_low_basket_{very_low_basket_counter}",
|
|
888
|
+
"companies": batch,
|
|
889
|
+
"company_chunks": {cid: 0 for cid in batch},
|
|
890
|
+
"total_chunks": 0,
|
|
891
|
+
"company_count": len(batch),
|
|
892
|
+
"volume_range": "very_low",
|
|
893
|
+
}
|
|
894
|
+
)
|
|
895
|
+
very_low_basket_counter += 1
|
|
896
|
+
|
|
897
|
+
if min_entities_per_basket > 1:
|
|
898
|
+
baskets = self._merge_small_baskets(baskets, min_entities_per_basket)
|
|
899
|
+
|
|
900
|
+
return baskets
|
|
901
|
+
|
|
902
|
+
def _merge_small_baskets(
|
|
903
|
+
self,
|
|
904
|
+
baskets: list[dict],
|
|
905
|
+
min_entities: int,
|
|
906
|
+
) -> list[dict]:
|
|
907
|
+
"""
|
|
908
|
+
Merge baskets that have fewer than min_entities companies into an
|
|
909
|
+
adjacent basket, respecting the MAX_ENTITIES_IN_ANY_OF hard limit.
|
|
910
|
+
|
|
911
|
+
Iteratively picks the smallest under-threshold basket and merges it
|
|
912
|
+
with the adjacent basket (previous preferred, then next) whose combined
|
|
913
|
+
entity count stays within the API entity limit.
|
|
914
|
+
|
|
915
|
+
Args:
|
|
916
|
+
baskets: List of basket dicts produced by the greedy packing step
|
|
917
|
+
min_entities: Minimum entities per basket
|
|
918
|
+
|
|
919
|
+
Returns:
|
|
920
|
+
Consolidated list of baskets
|
|
921
|
+
"""
|
|
922
|
+
if len(baskets) <= 1:
|
|
923
|
+
return baskets
|
|
924
|
+
|
|
925
|
+
while True:
|
|
926
|
+
small_indices = [i for i, b in enumerate(baskets) if b["company_count"] < min_entities]
|
|
927
|
+
if not small_indices:
|
|
928
|
+
break
|
|
929
|
+
if len(baskets) <= 1:
|
|
930
|
+
break
|
|
931
|
+
|
|
932
|
+
target_idx = min(small_indices, key=lambda i: baskets[i]["company_count"])
|
|
933
|
+
|
|
934
|
+
# Find the best adjacent basket to merge with (entity-limit safe)
|
|
935
|
+
merge_idx = self._find_merge_neighbor(baskets, target_idx)
|
|
936
|
+
if merge_idx is None:
|
|
937
|
+
break
|
|
938
|
+
|
|
939
|
+
self._absorb_basket(baskets, source_idx=target_idx, dest_idx=merge_idx)
|
|
940
|
+
|
|
941
|
+
return baskets
|
|
942
|
+
|
|
943
|
+
@staticmethod
|
|
944
|
+
def _find_merge_neighbor(baskets: list[dict], target_idx: int) -> int | None:
|
|
945
|
+
"""Return the index of the best adjacent basket to merge *target_idx* into.
|
|
946
|
+
|
|
947
|
+
Prefers the previous basket, then the next, skipping any whose combined
|
|
948
|
+
entity count would exceed MAX_ENTITIES_IN_ANY_OF.
|
|
949
|
+
"""
|
|
950
|
+
target_count = baskets[target_idx]["company_count"]
|
|
951
|
+
candidates = []
|
|
952
|
+
if target_idx > 0:
|
|
953
|
+
candidates.append(target_idx - 1)
|
|
954
|
+
if target_idx < len(baskets) - 1:
|
|
955
|
+
candidates.append(target_idx + 1)
|
|
956
|
+
|
|
957
|
+
for idx in candidates:
|
|
958
|
+
if baskets[idx]["company_count"] + target_count <= MAX_ENTITIES_IN_ANY_OF:
|
|
959
|
+
return idx
|
|
960
|
+
return None
|
|
961
|
+
|
|
962
|
+
@staticmethod
|
|
963
|
+
def _absorb_basket(baskets: list[dict], source_idx: int, dest_idx: int) -> None:
|
|
964
|
+
"""Merge *source_idx* basket into *dest_idx* basket and remove the source."""
|
|
965
|
+
source = baskets[source_idx]
|
|
966
|
+
dest = baskets[dest_idx]
|
|
967
|
+
dest["companies"].extend(source["companies"])
|
|
968
|
+
dest["company_chunks"].update(source["company_chunks"])
|
|
969
|
+
dest["total_chunks"] += source["total_chunks"]
|
|
970
|
+
dest["company_count"] = len(dest["companies"])
|
|
971
|
+
baskets.pop(source_idx)
|
|
972
|
+
|
|
973
|
+
def estimate_subperiod_volumes(
|
|
974
|
+
self,
|
|
975
|
+
company_total_chunks: int,
|
|
976
|
+
sub_period_start: str,
|
|
977
|
+
sub_period_end: str,
|
|
978
|
+
full_period_start: str,
|
|
979
|
+
full_period_end: str,
|
|
980
|
+
) -> int:
|
|
981
|
+
"""
|
|
982
|
+
Estimate chunk volume for a sub-period using uniform distribution.
|
|
983
|
+
|
|
984
|
+
Args:
|
|
985
|
+
company_total_chunks: Total chunks for the company in the full period
|
|
986
|
+
sub_period_start: Start date of sub-period (YYYY-MM-DD)
|
|
987
|
+
sub_period_end: End date of sub-period (YYYY-MM-DD)
|
|
988
|
+
full_period_start: Start date of full period (YYYY-MM-DD)
|
|
989
|
+
full_period_end: End date of full period (YYYY-MM-DD)
|
|
990
|
+
|
|
991
|
+
Returns:
|
|
992
|
+
Estimated chunks for the sub-period (rounded to nearest integer)
|
|
993
|
+
"""
|
|
994
|
+
sub_start = datetime.strptime(sub_period_start, "%Y-%m-%d")
|
|
995
|
+
sub_end = datetime.strptime(sub_period_end, "%Y-%m-%d")
|
|
996
|
+
full_start = datetime.strptime(full_period_start, "%Y-%m-%d")
|
|
997
|
+
full_end = datetime.strptime(full_period_end, "%Y-%m-%d")
|
|
998
|
+
|
|
999
|
+
sub_period_days = (sub_end - sub_start).days + 1 # Inclusive
|
|
1000
|
+
total_period_days = (full_end - full_start).days + 1 # Inclusive
|
|
1001
|
+
|
|
1002
|
+
if total_period_days == 0:
|
|
1003
|
+
return 0
|
|
1004
|
+
|
|
1005
|
+
estimated = (company_total_chunks * sub_period_days) / total_period_days
|
|
1006
|
+
return max(0, int(round(estimated)))
|
|
1007
|
+
|
|
1008
|
+
def calculate_periods_needed(self, total_chunks: int) -> int:
|
|
1009
|
+
"""
|
|
1010
|
+
Calculate how many periods are needed for a company based on its total chunks.
|
|
1011
|
+
|
|
1012
|
+
Args:
|
|
1013
|
+
total_chunks: Total chunks for the company
|
|
1014
|
+
|
|
1015
|
+
Returns:
|
|
1016
|
+
Number of periods needed (ceil(total_chunks / 1000))
|
|
1017
|
+
"""
|
|
1018
|
+
return max(1, math.ceil(total_chunks / MAX_CHUNKS_PER_BASKET))
|
|
1019
|
+
|
|
1020
|
+
def consolidate_period_groups(
|
|
1021
|
+
self,
|
|
1022
|
+
companies_by_periods_needed: dict[int, dict[str, int]],
|
|
1023
|
+
min_entities_per_basket: int,
|
|
1024
|
+
) -> dict[int, dict[str, int]]:
|
|
1025
|
+
"""
|
|
1026
|
+
Merge period groups that have fewer than min_entities_per_basket entities
|
|
1027
|
+
into the nearest group by periods_needed distance.
|
|
1028
|
+
|
|
1029
|
+
Uses a greedy nearest-neighbor strategy: repeatedly pick the smallest
|
|
1030
|
+
under-threshold group and merge it with its closest neighbor (by
|
|
1031
|
+
|periods_needed| distance). The merged group is keyed by the maximum
|
|
1032
|
+
periods_needed of its constituents so that every company in the group
|
|
1033
|
+
gets enough time-window splits.
|
|
1034
|
+
|
|
1035
|
+
Args:
|
|
1036
|
+
companies_by_periods_needed: Dict mapping periods_needed -> {company_id: chunks}
|
|
1037
|
+
min_entities_per_basket: Minimum number of entities required per group
|
|
1038
|
+
|
|
1039
|
+
Returns:
|
|
1040
|
+
Consolidated dict with the same shape; groups below the threshold
|
|
1041
|
+
have been absorbed into their nearest neighbor.
|
|
1042
|
+
"""
|
|
1043
|
+
if min_entities_per_basket <= 1:
|
|
1044
|
+
return companies_by_periods_needed
|
|
1045
|
+
|
|
1046
|
+
result: dict[int, dict[str, int]] = {
|
|
1047
|
+
p: dict(group) for p, group in companies_by_periods_needed.items()
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
while True:
|
|
1051
|
+
small_groups = [p for p in result if len(result[p]) < min_entities_per_basket]
|
|
1052
|
+
if not small_groups:
|
|
1053
|
+
break
|
|
1054
|
+
if len(result) <= 1:
|
|
1055
|
+
break
|
|
1056
|
+
|
|
1057
|
+
target = min(small_groups, key=lambda p: len(result[p]))
|
|
1058
|
+
|
|
1059
|
+
candidates = [p for p in result if p != target]
|
|
1060
|
+
if not candidates:
|
|
1061
|
+
break
|
|
1062
|
+
|
|
1063
|
+
nearest = min(candidates, key=lambda p: abs(p - target))
|
|
1064
|
+
|
|
1065
|
+
new_key = max(target, nearest)
|
|
1066
|
+
old_key = min(target, nearest)
|
|
1067
|
+
|
|
1068
|
+
merged = {}
|
|
1069
|
+
merged.update(result.pop(old_key))
|
|
1070
|
+
merged.update(result.pop(new_key))
|
|
1071
|
+
result[new_key] = merged
|
|
1072
|
+
|
|
1073
|
+
return result
|
|
1074
|
+
|
|
1075
|
+
def determine_split_granularity(
|
|
1076
|
+
self,
|
|
1077
|
+
periods_needed: int,
|
|
1078
|
+
target_period_type: str,
|
|
1079
|
+
start_date: str,
|
|
1080
|
+
end_date: str,
|
|
1081
|
+
) -> tuple[str, list[tuple[str, str]]]:
|
|
1082
|
+
"""
|
|
1083
|
+
Split the date range into exactly periods_needed equal parts.
|
|
1084
|
+
|
|
1085
|
+
Args:
|
|
1086
|
+
periods_needed: Number of periods needed (ceil(total_chunks / 1000))
|
|
1087
|
+
target_period_type: Unused (kept for backward compatibility)
|
|
1088
|
+
start_date: Start date of full period (YYYY-MM-DD)
|
|
1089
|
+
end_date: End date of full period (YYYY-MM-DD)
|
|
1090
|
+
|
|
1091
|
+
Returns:
|
|
1092
|
+
Tuple of (period_type_label, list of (start, end) date tuples for periods)
|
|
1093
|
+
"""
|
|
1094
|
+
if periods_needed <= 1:
|
|
1095
|
+
return ("full_range", [(start_date, end_date)])
|
|
1096
|
+
|
|
1097
|
+
# Split into exactly periods_needed equal parts
|
|
1098
|
+
start = datetime.strptime(start_date, "%Y-%m-%d")
|
|
1099
|
+
end = datetime.strptime(end_date, "%Y-%m-%d")
|
|
1100
|
+
total_days = (end - start).days + 1 # inclusive
|
|
1101
|
+
|
|
1102
|
+
periods = []
|
|
1103
|
+
for i in range(periods_needed):
|
|
1104
|
+
# Calculate start and end day for this period (integer division for even split)
|
|
1105
|
+
period_start_day = i * total_days // periods_needed
|
|
1106
|
+
period_end_day = (i + 1) * total_days // periods_needed - 1
|
|
1107
|
+
|
|
1108
|
+
period_start_dt = start + timedelta(days=period_start_day)
|
|
1109
|
+
period_end_dt = start + timedelta(days=period_end_day)
|
|
1110
|
+
|
|
1111
|
+
periods.append(
|
|
1112
|
+
(period_start_dt.strftime("%Y-%m-%d"), period_end_dt.strftime("%Y-%m-%d"))
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
return (f"split_{periods_needed}", periods)
|
|
1116
|
+
|
|
1117
|
+
def determine_splits_from_volume(
|
|
1118
|
+
self,
|
|
1119
|
+
volume_series: list[VolumePoint],
|
|
1120
|
+
periods_needed: int,
|
|
1121
|
+
start_date: str,
|
|
1122
|
+
end_date: str,
|
|
1123
|
+
min_period_days: int = 30,
|
|
1124
|
+
) -> tuple[str, list[tuple[str, str]]]:
|
|
1125
|
+
"""
|
|
1126
|
+
Split the date range into sub-periods so that chunk volume is balanced
|
|
1127
|
+
across periods using the volume time series. Falls back to equal-length
|
|
1128
|
+
splits if the series is empty or has zero total chunks.
|
|
1129
|
+
|
|
1130
|
+
Args:
|
|
1131
|
+
volume_series: List of VolumePoint with at least "date" and "chunks"
|
|
1132
|
+
periods_needed: Number of periods to produce
|
|
1133
|
+
start_date: Full period start (YYYY-MM-DD)
|
|
1134
|
+
end_date: Full period end (YYYY-MM-DD)
|
|
1135
|
+
min_period_days: Minimum number of days per period
|
|
1136
|
+
Returns:
|
|
1137
|
+
Tuple of (period_type_label, list of (start, end) date tuples)
|
|
1138
|
+
"""
|
|
1139
|
+
if periods_needed <= 1:
|
|
1140
|
+
return ("full_range", [(start_date, end_date)])
|
|
1141
|
+
|
|
1142
|
+
sorted_series = sorted(
|
|
1143
|
+
(p for p in volume_series if p.get("date")),
|
|
1144
|
+
key=lambda p: p.get("date", ""),
|
|
1145
|
+
)
|
|
1146
|
+
total_chunks = sum(p.get("chunks", 0) or 0 for p in sorted_series)
|
|
1147
|
+
|
|
1148
|
+
if not sorted_series or total_chunks <= 0:
|
|
1149
|
+
return self.determine_split_granularity(
|
|
1150
|
+
periods_needed, "biyearly", start_date, end_date
|
|
1151
|
+
)
|
|
1152
|
+
# Cumulative chunks and find boundary dates (one date per boundary to avoid zero-length periods)
|
|
1153
|
+
cumulative = 0
|
|
1154
|
+
boundaries: list[str] = []
|
|
1155
|
+
target_step = total_chunks / periods_needed
|
|
1156
|
+
|
|
1157
|
+
boundaries = self._detect_breakpoints(
|
|
1158
|
+
sorted_series, start_date, target_step, min_period_days
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
periods: list[tuple[str, str]] = []
|
|
1162
|
+
period_starts = [start_date] + [self._next_day(d) for d in boundaries]
|
|
1163
|
+
period_ends = list(boundaries) + [end_date]
|
|
1164
|
+
|
|
1165
|
+
for s, e in zip(period_starts, period_ends):
|
|
1166
|
+
if s <= e:
|
|
1167
|
+
periods.append((s, e))
|
|
1168
|
+
else:
|
|
1169
|
+
periods.append((s, s))
|
|
1170
|
+
|
|
1171
|
+
if not periods:
|
|
1172
|
+
return self.determine_split_granularity(
|
|
1173
|
+
periods_needed, "biyearly", start_date, end_date
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
return (f"split_{periods_needed}_volume", periods)
|
|
1177
|
+
|
|
1178
|
+
def _next_day(self, date_str: str) -> str:
|
|
1179
|
+
"""Return the day after date_str (YYYY-MM-DD) as YYYY-MM-DD."""
|
|
1180
|
+
d = datetime.strptime(date_str, "%Y-%m-%d")
|
|
1181
|
+
next_d = d + timedelta(days=1)
|
|
1182
|
+
return next_d.strftime("%Y-%m-%d")
|
|
1183
|
+
|
|
1184
|
+
def _delta_days(self, date_str1: str, date_str2: str) -> int:
|
|
1185
|
+
"""Return the number of days between date_str1 and date_str2 (YYYY-MM-DD)."""
|
|
1186
|
+
d1 = datetime.strptime(date_str1, "%Y-%m-%d")
|
|
1187
|
+
d2 = datetime.strptime(date_str2, "%Y-%m-%d")
|
|
1188
|
+
return (d2 - d1).days
|
|
1189
|
+
|
|
1190
|
+
def _detect_breakpoints(
|
|
1191
|
+
self, data: list[VolumePoint], start_date: str, max_chunks: int, min_period_days: int
|
|
1192
|
+
) -> list[str]:
|
|
1193
|
+
"""
|
|
1194
|
+
Detect breakpoints in the volume series using the cumulative chunks and delta cumulative delta days.
|
|
1195
|
+
It will return the dates where the breakpoints occur, ensuring that the period is at least min_period_days days long.
|
|
1196
|
+
And the period will not exceed the max_chunks limit.
|
|
1197
|
+
Args:
|
|
1198
|
+
data: List of VolumePoint with at least "date" and "chunks"
|
|
1199
|
+
start_date: Start date of the full period (YYYY-MM-DD). Used to calculate the delta days.
|
|
1200
|
+
max_chunks: Maximum chunks per basket
|
|
1201
|
+
min_period_days: Minimum number of days per period
|
|
1202
|
+
Returns:
|
|
1203
|
+
List of dates (YYYY-MM-DD) where the breakpoints occur
|
|
1204
|
+
"""
|
|
1205
|
+
# Compute cumulative chunks and delta cumulative delta days from the volume series
|
|
1206
|
+
cumulative_chunks = 0
|
|
1207
|
+
cumulative_delta_days = 0
|
|
1208
|
+
for point in data:
|
|
1209
|
+
cumulative_chunks += point.get("chunks", 0) or 0
|
|
1210
|
+
cumulative_delta_days = self._delta_days(start_date, point.get("date", ""))
|
|
1211
|
+
point["cumulative_chunks"] = cumulative_chunks
|
|
1212
|
+
point["cumulative_delta_days"] = cumulative_delta_days
|
|
1213
|
+
|
|
1214
|
+
breakpoints = []
|
|
1215
|
+
current_chunks = 0
|
|
1216
|
+
|
|
1217
|
+
# We track the 'start date' of the current segment to calculate delta
|
|
1218
|
+
segment_start_days = 0
|
|
1219
|
+
|
|
1220
|
+
for i, entry in enumerate(data):
|
|
1221
|
+
# Calculate how many days have passed since the start of THIS segment
|
|
1222
|
+
# cumulative_delta_days is global, so we subtract the offset
|
|
1223
|
+
days_in_segment = entry["cumulative_delta_days"] - segment_start_days
|
|
1224
|
+
|
|
1225
|
+
# Check if adding this entry violates thresholds
|
|
1226
|
+
# Note: We use the entry's individual 'chunks' contribution
|
|
1227
|
+
if (current_chunks + entry["chunks"] > max_chunks) and (
|
|
1228
|
+
days_in_segment >= min_period_days
|
|
1229
|
+
):
|
|
1230
|
+
# Record the index of the breakpoint
|
|
1231
|
+
breakpoints.append(i)
|
|
1232
|
+
|
|
1233
|
+
# Reset trackers for the new segment
|
|
1234
|
+
current_chunks = entry["chunks"]
|
|
1235
|
+
# The new segment's reference point is the previous entry's end
|
|
1236
|
+
segment_start_days = data[i - 1]["cumulative_delta_days"] if i > 0 else 0
|
|
1237
|
+
else:
|
|
1238
|
+
current_chunks += entry["chunks"]
|
|
1239
|
+
|
|
1240
|
+
boundaries = [data[i]["date"] for i in breakpoints]
|
|
1241
|
+
return boundaries
|
|
1242
|
+
|
|
1243
|
+
def sub_period_volumes_from_series(
|
|
1244
|
+
self,
|
|
1245
|
+
volume_series: list[VolumePoint],
|
|
1246
|
+
periods: list[tuple[str, str]],
|
|
1247
|
+
company_group: dict[str, int],
|
|
1248
|
+
) -> list[dict[str, int]]:
|
|
1249
|
+
"""
|
|
1250
|
+
For each sub-period, compute chunk volume per company from the volume series.
|
|
1251
|
+
|
|
1252
|
+
When the series is aggregated for the group, distributes each period's
|
|
1253
|
+
total chunks across companies proportionally to their full-period totals.
|
|
1254
|
+
|
|
1255
|
+
Args:
|
|
1256
|
+
volume_series: Time series with date and chunks
|
|
1257
|
+
periods: List of (start_date, end_date) for each sub-period
|
|
1258
|
+
company_group: Dict company_id -> total_chunks (full period)
|
|
1259
|
+
|
|
1260
|
+
Returns:
|
|
1261
|
+
List of dicts, one per period; each dict maps company_id -> chunks for that period
|
|
1262
|
+
"""
|
|
1263
|
+
result: list[dict[str, int]] = []
|
|
1264
|
+
group_total = sum(company_group.values()) or 1
|
|
1265
|
+
by_date: dict[str, int] = {}
|
|
1266
|
+
for p in volume_series:
|
|
1267
|
+
d = p.get("date")
|
|
1268
|
+
if d:
|
|
1269
|
+
by_date[d] = by_date.get(d, 0) + (p.get("chunks") or 0)
|
|
1270
|
+
|
|
1271
|
+
for period_start, period_end in periods:
|
|
1272
|
+
period_chunks = 0
|
|
1273
|
+
start_dt = datetime.strptime(period_start, "%Y-%m-%d")
|
|
1274
|
+
end_dt = datetime.strptime(period_end, "%Y-%m-%d")
|
|
1275
|
+
current = start_dt
|
|
1276
|
+
while current <= end_dt:
|
|
1277
|
+
key = current.strftime("%Y-%m-%d")
|
|
1278
|
+
period_chunks += by_date.get(key, 0)
|
|
1279
|
+
current += timedelta(days=1)
|
|
1280
|
+
sub_period_volumes: dict[str, int] = {}
|
|
1281
|
+
for company_id, total in company_group.items():
|
|
1282
|
+
if group_total <= 0:
|
|
1283
|
+
sub_period_volumes[company_id] = 0
|
|
1284
|
+
else:
|
|
1285
|
+
proportion = total / group_total
|
|
1286
|
+
sub_period_volumes[company_id] = max(0, int(round(period_chunks * proportion)))
|
|
1287
|
+
result.append(sub_period_volumes)
|
|
1288
|
+
return result
|
|
1289
|
+
|
|
1290
|
+
def plan_all_periods(
|
|
1291
|
+
self,
|
|
1292
|
+
topic: str,
|
|
1293
|
+
start_date: str = START_DATE,
|
|
1294
|
+
end_date: str = END_DATE,
|
|
1295
|
+
volume_query_mode: str = "three_pass",
|
|
1296
|
+
max_iterations_per_batch: int = 10,
|
|
1297
|
+
universe_csv_path: str | None = None,
|
|
1298
|
+
apply_volume_splits: bool = True,
|
|
1299
|
+
min_period_days: int = 30,
|
|
1300
|
+
min_entities_per_basket: int = 1,
|
|
1301
|
+
) -> dict:
|
|
1302
|
+
"""
|
|
1303
|
+
Generate SMART batching plan with optimal granularity per company.
|
|
1304
|
+
|
|
1305
|
+
Phase 1: Query full period once to get total volumes
|
|
1306
|
+
Phase 2: Automatically determine optimal granularity for each company based on volume
|
|
1307
|
+
and create baskets using adaptive splitting (sub-periods so each query <= 1000 chunks)
|
|
1308
|
+
|
|
1309
|
+
Args:
|
|
1310
|
+
topic: Topic string for comention and semantic search queries
|
|
1311
|
+
start_date: Start date in YYYY-MM-DD format
|
|
1312
|
+
end_date: End date in YYYY-MM-DD format
|
|
1313
|
+
volume_query_mode: Method for querying volumes. Options:
|
|
1314
|
+
- "three_pass": Original 3-pass approach (query all, then verify twice)
|
|
1315
|
+
- "iterative": Per-batch iterative approach (query batch, remove found, repeat until empty)
|
|
1316
|
+
max_iterations_per_batch: Max iterations per batch when using "iterative" mode (default 10)
|
|
1317
|
+
universe_csv_path: Optional path to universe CSV; if not set, uses planner default.
|
|
1318
|
+
apply_volume_splits: If True (default), use volume time series to split periods per company.
|
|
1319
|
+
If False, use time-based granularity and estimated sub-period volumes only.
|
|
1320
|
+
min_period_days: Minimum number of days per period. When splitting by volume, we need to ensure
|
|
1321
|
+
that the period is at least min_period_days days long. Default is 30 days.
|
|
1322
|
+
min_entities_per_basket: Minimum number of entities per period group. Groups with fewer
|
|
1323
|
+
entities are merged with the nearest group by periods_needed distance. Default is 1
|
|
1324
|
+
(no consolidation).
|
|
1325
|
+
|
|
1326
|
+
Returns:
|
|
1327
|
+
Planning report with single SMART configuration
|
|
1328
|
+
"""
|
|
1329
|
+
# Load universe
|
|
1330
|
+
companies = (
|
|
1331
|
+
self.load_universe(universe_csv_path) if universe_csv_path else self.load_universe()
|
|
1332
|
+
)
|
|
1333
|
+
total_companies = len(companies)
|
|
1334
|
+
|
|
1335
|
+
report = {
|
|
1336
|
+
"topic": topic,
|
|
1337
|
+
"period_range": {
|
|
1338
|
+
"start": start_date,
|
|
1339
|
+
"end": end_date,
|
|
1340
|
+
},
|
|
1341
|
+
"total_companies": total_companies,
|
|
1342
|
+
"configurations": {},
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
# PHASE 1: Query full period once to get total volumes
|
|
1346
|
+
print("=" * 80)
|
|
1347
|
+
print(f"PHASE 1: Querying full period for all companies ({start_date} to {end_date})")
|
|
1348
|
+
print(f" Mode: {volume_query_mode}")
|
|
1349
|
+
print("=" * 80)
|
|
1350
|
+
|
|
1351
|
+
if volume_query_mode == "iterative":
|
|
1352
|
+
full_period_volumes, total_comention_queries, _ = self.get_comention_volumes_iterative(
|
|
1353
|
+
companies,
|
|
1354
|
+
topic,
|
|
1355
|
+
start_date,
|
|
1356
|
+
end_date,
|
|
1357
|
+
max_iterations_per_batch=max_iterations_per_batch,
|
|
1358
|
+
)
|
|
1359
|
+
else:
|
|
1360
|
+
# Default: three_pass mode
|
|
1361
|
+
full_period_volumes, total_comention_queries = self.get_comention_volumes(
|
|
1362
|
+
companies, topic, start_date, end_date
|
|
1363
|
+
)
|
|
1364
|
+
print(f"\nPhase 1 complete: {total_comention_queries} comention queries")
|
|
1365
|
+
print(f"Found {len(full_period_volumes)} companies with chunks > 0\n")
|
|
1366
|
+
|
|
1367
|
+
# Order the companies by chunks in descending order and company_id in ascending order
|
|
1368
|
+
# This is done to ensure deterministic results on the planning phase.
|
|
1369
|
+
full_period_volumes = dict(
|
|
1370
|
+
sorted(full_period_volumes.items(), key=lambda item: (-item[1], item[0]))
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
# Calculate periods needed for each company
|
|
1374
|
+
company_periods_needed = {}
|
|
1375
|
+
for company_id, chunks in full_period_volumes.items():
|
|
1376
|
+
company_periods_needed[company_id] = self.calculate_periods_needed(chunks)
|
|
1377
|
+
|
|
1378
|
+
# Group companies by periods_needed
|
|
1379
|
+
companies_by_periods_needed = defaultdict(dict)
|
|
1380
|
+
for company_id, chunks in full_period_volumes.items():
|
|
1381
|
+
periods_needed = company_periods_needed[company_id]
|
|
1382
|
+
companies_by_periods_needed[periods_needed][company_id] = chunks
|
|
1383
|
+
|
|
1384
|
+
print("Company categorization by periods needed (before consolidation):")
|
|
1385
|
+
for periods_needed in sorted(companies_by_periods_needed.keys()):
|
|
1386
|
+
count = len(companies_by_periods_needed[periods_needed])
|
|
1387
|
+
print(f" {periods_needed} period(s) needed: {count} companies")
|
|
1388
|
+
print(f" Zero chunks: {total_companies - len(full_period_volumes)} companies\n")
|
|
1389
|
+
|
|
1390
|
+
# Consolidate small groups before fetching volume time series
|
|
1391
|
+
if min_entities_per_basket > 1:
|
|
1392
|
+
companies_by_periods_needed = self.consolidate_period_groups(
|
|
1393
|
+
dict(companies_by_periods_needed), min_entities_per_basket
|
|
1394
|
+
)
|
|
1395
|
+
print(
|
|
1396
|
+
f"Company categorization by periods needed (after consolidation, min_entities={min_entities_per_basket}):"
|
|
1397
|
+
)
|
|
1398
|
+
for periods_needed in sorted(companies_by_periods_needed.keys()):
|
|
1399
|
+
count = len(companies_by_periods_needed[periods_needed])
|
|
1400
|
+
print(f" {periods_needed} period(s) needed: {count} companies")
|
|
1401
|
+
print()
|
|
1402
|
+
|
|
1403
|
+
# PHASE 2: Fetch volume time series per company group (concurrent) for groups needing splits
|
|
1404
|
+
volume_by_group: dict[tuple[int, tuple[str, ...]], list[VolumePoint]] = {}
|
|
1405
|
+
groups_needing_volume = [
|
|
1406
|
+
(p, g)
|
|
1407
|
+
for p, g in sorted(companies_by_periods_needed.items())
|
|
1408
|
+
if p > 1 and apply_volume_splits
|
|
1409
|
+
]
|
|
1410
|
+
if groups_needing_volume:
|
|
1411
|
+
print("Fetching volume time series for company groups (concurrent)...")
|
|
1412
|
+
with ThreadPoolExecutor(max_workers=min(8, len(groups_needing_volume))) as executor:
|
|
1413
|
+
future_to_key = {
|
|
1414
|
+
executor.submit(
|
|
1415
|
+
self.get_volume_timeseries,
|
|
1416
|
+
list(g.keys()),
|
|
1417
|
+
topic,
|
|
1418
|
+
start_date,
|
|
1419
|
+
end_date,
|
|
1420
|
+
): (p, tuple(sorted(g.keys())))
|
|
1421
|
+
for p, g in groups_needing_volume
|
|
1422
|
+
}
|
|
1423
|
+
for future in as_completed(future_to_key):
|
|
1424
|
+
key = future_to_key[future]
|
|
1425
|
+
try:
|
|
1426
|
+
series = future.result()
|
|
1427
|
+
volume_by_group[key] = series
|
|
1428
|
+
except Exception as e:
|
|
1429
|
+
print(f" Volume fetch failed for group: {e}")
|
|
1430
|
+
volume_by_group[key] = []
|
|
1431
|
+
print(f" Fetched volume for {len(volume_by_group)} groups.\n")
|
|
1432
|
+
|
|
1433
|
+
# PHASE 2: Plan baskets using SMART configuration (single optimal plan)
|
|
1434
|
+
print("=" * 80)
|
|
1435
|
+
print("PHASE 2: Planning SMART configuration (optimal granularity per company)")
|
|
1436
|
+
print("=" * 80)
|
|
1437
|
+
|
|
1438
|
+
config_report = {
|
|
1439
|
+
"comention_queries": total_comention_queries,
|
|
1440
|
+
"semantic_queries": 0,
|
|
1441
|
+
"companies_with_chunks": len(full_period_volumes),
|
|
1442
|
+
"companies_with_zero_chunks": total_companies - len(full_period_volumes),
|
|
1443
|
+
"baskets": [],
|
|
1444
|
+
"period_details": [],
|
|
1445
|
+
"uses_estimates": False,
|
|
1446
|
+
"adaptive_splitting": True,
|
|
1447
|
+
"granularity_groups": {},
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
total_semantic_queries = 0
|
|
1451
|
+
all_period_details = []
|
|
1452
|
+
uses_estimates = False
|
|
1453
|
+
granularity_groups = defaultdict(lambda: {"companies": 0, "periods": 0, "baskets": 0})
|
|
1454
|
+
global_basket_counter = 0
|
|
1455
|
+
|
|
1456
|
+
# Process each group of companies by periods_needed
|
|
1457
|
+
for periods_needed, company_group in sorted(companies_by_periods_needed.items()):
|
|
1458
|
+
group_key = (periods_needed, tuple(sorted(company_group.keys())))
|
|
1459
|
+
volume_series = volume_by_group.get(group_key) if periods_needed > 1 else None
|
|
1460
|
+
use_volume_splits = (
|
|
1461
|
+
apply_volume_splits
|
|
1462
|
+
and periods_needed > 1
|
|
1463
|
+
and volume_series is not None
|
|
1464
|
+
and len(volume_series) > 0
|
|
1465
|
+
and sum(p.get("chunks", 0) or 0 for p in volume_series) > 0
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
if use_volume_splits:
|
|
1469
|
+
actual_period_type, actual_periods = self.determine_splits_from_volume(
|
|
1470
|
+
volume_series, periods_needed, start_date, end_date, min_period_days
|
|
1471
|
+
)
|
|
1472
|
+
sub_period_volumes_list = self.sub_period_volumes_from_series(
|
|
1473
|
+
volume_series, actual_periods, company_group
|
|
1474
|
+
)
|
|
1475
|
+
else:
|
|
1476
|
+
actual_period_type, actual_periods = self.determine_split_granularity(
|
|
1477
|
+
periods_needed, "biyearly", start_date, end_date
|
|
1478
|
+
)
|
|
1479
|
+
sub_period_volumes_list = []
|
|
1480
|
+
|
|
1481
|
+
n_per = len(actual_periods)
|
|
1482
|
+
period_word = "period" if n_per == 1 else "periods"
|
|
1483
|
+
print(f" Companies needing {periods_needed} period(s): {len(company_group)} companies")
|
|
1484
|
+
print(f" Using {actual_period_type} granularity ({n_per} {period_word})")
|
|
1485
|
+
|
|
1486
|
+
granularity_groups[actual_period_type]["companies"] += len(company_group)
|
|
1487
|
+
granularity_groups[actual_period_type]["periods"] = len(actual_periods)
|
|
1488
|
+
|
|
1489
|
+
# Process each period for this group
|
|
1490
|
+
for period_idx, (period_start, period_end) in enumerate(actual_periods):
|
|
1491
|
+
if use_volume_splits and period_idx < len(sub_period_volumes_list):
|
|
1492
|
+
sub_period_volumes = sub_period_volumes_list[period_idx]
|
|
1493
|
+
else:
|
|
1494
|
+
sub_period_volumes = {}
|
|
1495
|
+
for company_id, total_chunks in company_group.items():
|
|
1496
|
+
if periods_needed == 1:
|
|
1497
|
+
sub_period_volumes[company_id] = total_chunks
|
|
1498
|
+
else:
|
|
1499
|
+
estimated_chunks = self.estimate_subperiod_volumes(
|
|
1500
|
+
total_chunks,
|
|
1501
|
+
period_start,
|
|
1502
|
+
period_end,
|
|
1503
|
+
start_date,
|
|
1504
|
+
end_date,
|
|
1505
|
+
)
|
|
1506
|
+
if estimated_chunks > 0:
|
|
1507
|
+
sub_period_volumes[company_id] = estimated_chunks
|
|
1508
|
+
uses_estimates = True
|
|
1509
|
+
|
|
1510
|
+
# Create baskets for this sub-period
|
|
1511
|
+
baskets = self.create_baskets(
|
|
1512
|
+
sub_period_volumes,
|
|
1513
|
+
min_entities_per_basket=min_entities_per_basket,
|
|
1514
|
+
)
|
|
1515
|
+
total_semantic_queries += len(baskets)
|
|
1516
|
+
granularity_groups[actual_period_type]["baskets"] += len(baskets)
|
|
1517
|
+
|
|
1518
|
+
# Track companies with chunks in this sub-period
|
|
1519
|
+
companies_with_chunks = len(self.filter_zero_volume(sub_period_volumes))
|
|
1520
|
+
|
|
1521
|
+
# Update basket IDs: global counter, dates only when split, subdivision index (e.g. 1of6)
|
|
1522
|
+
period_start_short = period_start.replace("-", "")
|
|
1523
|
+
period_end_short = period_end.replace("-", "")
|
|
1524
|
+
for basket in baskets:
|
|
1525
|
+
volume_range = basket.get("volume_range", "basket")
|
|
1526
|
+
if n_per == 1:
|
|
1527
|
+
# No split: no date, no subdivision index
|
|
1528
|
+
basket["basket_id"] = f"{volume_range}_basket_{global_basket_counter}"
|
|
1529
|
+
else:
|
|
1530
|
+
# Split: basket number, subdivision index (1of6), then date range
|
|
1531
|
+
basket["basket_id"] = (
|
|
1532
|
+
f"{volume_range}_basket_{global_basket_counter}_"
|
|
1533
|
+
f"{period_idx + 1}of{n_per}_{period_start_short}_{period_end_short}"
|
|
1534
|
+
)
|
|
1535
|
+
global_basket_counter += 1
|
|
1536
|
+
basket["actual_granularity"] = actual_period_type
|
|
1537
|
+
basket["periods_needed"] = periods_needed
|
|
1538
|
+
basket["period_start"] = period_start
|
|
1539
|
+
basket["period_end"] = period_end
|
|
1540
|
+
basket["contains_estimates"] = periods_needed > 1 and not use_volume_splits
|
|
1541
|
+
|
|
1542
|
+
period_detail = {
|
|
1543
|
+
"period_index": period_idx,
|
|
1544
|
+
"start_date": period_start,
|
|
1545
|
+
"end_date": period_end,
|
|
1546
|
+
"actual_granularity": actual_period_type,
|
|
1547
|
+
"periods_needed": periods_needed,
|
|
1548
|
+
"companies_in_group": len(company_group),
|
|
1549
|
+
"comention_queries": 0, # No additional queries needed (using Phase 1 data)
|
|
1550
|
+
"semantic_queries": len(baskets),
|
|
1551
|
+
"companies_with_chunks": companies_with_chunks,
|
|
1552
|
+
"companies_with_zero_chunks": total_companies - companies_with_chunks,
|
|
1553
|
+
"baskets": baskets,
|
|
1554
|
+
"uses_estimates": periods_needed > 1 and not use_volume_splits,
|
|
1555
|
+
}
|
|
1556
|
+
all_period_details.append(period_detail)
|
|
1557
|
+
|
|
1558
|
+
config_report["sub_periods"] = len(all_period_details)
|
|
1559
|
+
config_report["period_details"] = all_period_details
|
|
1560
|
+
config_report["uses_estimates"] = uses_estimates
|
|
1561
|
+
config_report["granularity_groups"] = dict(granularity_groups)
|
|
1562
|
+
|
|
1563
|
+
config_report["semantic_queries"] = total_semantic_queries
|
|
1564
|
+
|
|
1565
|
+
# Calculate efficiency metrics
|
|
1566
|
+
total_chunks = sum(full_period_volumes.values())
|
|
1567
|
+
avg_chunks_per_query = (
|
|
1568
|
+
total_chunks / total_semantic_queries if total_semantic_queries > 0 else 0
|
|
1569
|
+
)
|
|
1570
|
+
utilization = (
|
|
1571
|
+
(avg_chunks_per_query / MAX_CHUNKS_PER_BASKET) * 100
|
|
1572
|
+
if total_semantic_queries > 0
|
|
1573
|
+
else 0
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
config_report["efficiency_metrics"] = {
|
|
1577
|
+
"total_chunks": total_chunks,
|
|
1578
|
+
"semantic_search_queries": total_semantic_queries,
|
|
1579
|
+
"avg_chunks_per_query": round(avg_chunks_per_query, 2),
|
|
1580
|
+
"utilization_percent": round(utilization, 2),
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
# Basket distribution by volume range
|
|
1584
|
+
basket_distribution = defaultdict(int)
|
|
1585
|
+
for period_detail in config_report["period_details"]:
|
|
1586
|
+
for basket in period_detail["baskets"]:
|
|
1587
|
+
basket_distribution[basket["volume_range"]] += 1
|
|
1588
|
+
|
|
1589
|
+
config_report["basket_distribution"] = dict(basket_distribution)
|
|
1590
|
+
|
|
1591
|
+
report["configurations"]["smart"] = config_report
|
|
1592
|
+
|
|
1593
|
+
return report
|