quant-agent 0.3.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.
@@ -0,0 +1,580 @@
1
+ """
2
+ Company fundamentals: stock listings, company profiles, financial statements
3
+ and ratios, corporate events, dividend history.
4
+
5
+ Data sources:
6
+ - Stock listing: Wifeed / Local CSV / SSI.
7
+ - Company overview, profile, shareholders, officers, subsidiaries, events, news: VCI (VietCap Securities).
8
+ - Financial statements and ratios: VCI (VietCap Securities).
9
+ """
10
+
11
+ from bs4 import BeautifulSoup
12
+
13
+ from .config import *
14
+
15
+ # =============================================================================
16
+ # STOCK LISTING
17
+ # =============================================================================
18
+
19
+ def live_stock_list ():
20
+ """
21
+ Return a DataFrame of all available stock symbols. Live data is retrieved from the API.
22
+ """
23
+ data = fetch_json('GET', "https://wifeed.vn/api/thong-tin-co-phieu/danh-sach-ma-chung-khoan", source='Wifeed')
24
+ if data is None:
25
+ return None
26
+ df = pd.DataFrame(data['data'])
27
+ df = df.rename(columns={'fullname_vi': 'organName', 'code': 'ticker', 'loaidn': 'organTypeCode', 'san': 'comGroupCode'})
28
+ return df
29
+
30
+ def organ_listing (lang='vi', headers=ssi_headers):
31
+ """
32
+ Return a DataFrame of all available stock symbols. Live data is retrieved from the SSI API.
33
+ Parameters:
34
+ lang (str): language of the data. Default is 'vi', other options are 'en'
35
+ headers (dict): headers of the request
36
+ """
37
+ url = f"https://fiin-core.ssi.com.vn/Master/GetListOrganization?language={lang}"
38
+ data = fetch_json('GET', url, headers=headers, source='SSI')
39
+ if data is None:
40
+ return None
41
+ print('Total number of companies: ', data['totalCount'])
42
+ return pd.DataFrame(data['items'])
43
+
44
+ def indices_listing (lang='vi', headers=ssi_headers):
45
+ """
46
+ Return a DataFrame of all available indices. Live data is retrieved from the SSI API.
47
+ Parameters:
48
+ lang (str): language of the data. Default is 'vi', other options are 'en'
49
+ headers (dict): headers of the request
50
+ """
51
+ url = f"https://fiin-core.ssi.com.vn/Master/GetAllCompanyGroup?language={lang}"
52
+ data = fetch_json('GET', url, headers=headers, source='SSI')
53
+ if data is None:
54
+ return None
55
+ df = pd.DataFrame(data['items'])
56
+ df = df.sort_values(by='comGroupOrder').reset_index(drop=True)
57
+ return df[['comGroupCode', 'parentComGroupCode', 'comGroupOrder']]
58
+
59
+ def offline_stock_list (path):
60
+ """
61
+ This function returns the list of all available stock symbols from a csv file.
62
+ Parameters:
63
+ path (str): Path or URL of a CSV file containing stock symbols, in the same
64
+ column layout as listing_companies()'s output. No bundled default is
65
+ provided - point this at your own maintained copy.
66
+ Returns: df (DataFrame): A pandas dataframe containing the stock symbols and other information.
67
+ """
68
+ return pd.read_csv(path)
69
+
70
+ def listing_companies (live=False, source='Wifeed', path=None):
71
+ """
72
+ This function returns the list of all available stock symbols from a csv file or a live api request.
73
+ Parameters:
74
+ live (bool): If True, return the list of all available stock symbols from a live api request. If False, read from the CSV given in `path`. Default is False.
75
+ path (str): Required when live=False - path or URL to your own offline CSV of stock symbols. No default is bundled.
76
+ Returns: df (DataFrame): A pandas dataframe containing the stock symbols and other information.
77
+ """
78
+ if not live:
79
+ if path is None:
80
+ print("listing_companies(live=False) requires a 'path' to your own CSV of stock "
81
+ "symbols - no bundled default is provided. Pass path=... or use live=True "
82
+ "to fetch the list from a live API instead.")
83
+ return None
84
+ return offline_stock_list(path)
85
+ if source == 'Wifeed':
86
+ return live_stock_list()
87
+ elif source == 'SSI':
88
+ return organ_listing()
89
+ print(f"Unknown source '{source}'. Use 'Wifeed' or 'SSI'.")
90
+ return None
91
+
92
+
93
+ # =============================================================================
94
+ # COMPANY OVERVIEW (VCI)
95
+ # =============================================================================
96
+
97
+ def company_overview (symbol):
98
+ """
99
+ This function returns the company overview of a target stock symbol.
100
+ Data source: VCI (VietCap Securities).
101
+ stockRating, deltaInWeek/Month/Year, or website).
102
+ Args:
103
+ symbol (:obj:`str`, required): 3 digits name of the desired stock.
104
+ """
105
+ url = f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/company/details?ticker={symbol}'
106
+ payload = fetch_json('GET', url, headers=vci_headers, source='VCI')
107
+ if payload is None:
108
+ return None
109
+ data = payload.get('data')
110
+ if not data:
111
+ print(f'No company overview data returned by VCI for {symbol}.')
112
+ return None
113
+ df = json_normalize(data)
114
+ df = df.rename(columns={
115
+ 'viOrganName': 'organName', 'enOrganName': 'organNameEn',
116
+ 'viOrganShortName': 'shortName', 'enOrganShortName': 'shortNameEn',
117
+ 'sectorVn': 'industry', 'sector': 'industryEn',
118
+ 'enProfile': 'profileEn', 'numberOfSharesMktCap': 'outstandingShare',
119
+ })
120
+ keep_cols = ['ticker', 'organName', 'shortName', 'industry', 'industryEn',
121
+ 'listingDate', 'outstandingShare', 'profile']
122
+ return df[[c for c in keep_cols if c in df.columns]]
123
+
124
+
125
+ # =============================================================================
126
+ # COMPANY PROFILE / OFFICERS / SHAREHOLDERS (VCI)
127
+ # =============================================================================
128
+
129
+ def company_profile (symbol='TCB', headers=None):
130
+ """
131
+ Return a DataFrame of the company's descriptive business profile.
132
+ Data source: VCI (VietCap Securities).
133
+ retired its API; reuses the same `company/details` endpoint as
134
+ `company_overview()`, extracting the long-form profile text instead of the
135
+ structured summary fields.
136
+ Parameters:
137
+ symbol (str): ticker of the company, default is 'TCB', other tickers available can be obtained from the function `listing_companies()`.
138
+ """
139
+ url = f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/company/details?ticker={symbol}'
140
+ payload = fetch_json('GET', url, headers=vci_headers, source='VCI')
141
+ if payload is None:
142
+ return None
143
+ data = payload.get('data')
144
+ if not data:
145
+ print(f'No company profile data returned by VCI for {symbol}.')
146
+ return None
147
+ df = json_normalize(data)
148
+ df['ticker'] = symbol
149
+ # convert HTML-formatted text columns (profile, enProfile) to plain text
150
+ for col in df.columns:
151
+ try:
152
+ df[col] = df[col].apply(lambda x: BeautifulSoup(x, 'html.parser').get_text())
153
+ df[col] = df[col].str.replace('\n', ' ')
154
+ except (TypeError, AttributeError):
155
+ pass
156
+ keep_cols = ['ticker', 'profile', 'enProfile', 'listingDate', 'sectorVn', 'sector']
157
+ return df[[c for c in keep_cols if c in df.columns]]
158
+
159
+ def _fetch_vci_shareholders(symbol):
160
+ """Shared fetcher for VCI's per-owner shareholder list, used by both
161
+ `company_large_shareholders()` and `company_officers()`."""
162
+ url = f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/company/{symbol}/shareholder'
163
+ payload = fetch_json('GET', url, headers=vci_headers, source='VCI')
164
+ if payload is None:
165
+ return None
166
+ records = payload.get('data', [])
167
+ if not records:
168
+ print(f'No shareholder data returned by VCI for {symbol}.')
169
+ return None
170
+ df = json_normalize(records)
171
+ df['ticker'] = symbol
172
+ return df
173
+
174
+ def company_large_shareholders (symbol='TCB', headers=None):
175
+ """
176
+ Return a DataFrame of company large shareholders data.
177
+ Data source: VCI (VietCap Securities).
178
+ Parameters:
179
+ symbol (str): ticker of the company, default is 'TCB'.
180
+ """
181
+ df = _fetch_vci_shareholders(symbol)
182
+ if df is None:
183
+ return None
184
+ df = df.rename(columns={'ownerName': 'shareHolder', 'percentage': 'shareOwnPercent', 'quantity': 'shareQuantity'})
185
+ keep_cols = ['ticker', 'shareHolder', 'ownerType', 'shareOwnPercent', 'shareQuantity', 'publicDate', 'updateDate']
186
+ df = df[[c for c in keep_cols if c in df.columns]]
187
+ return df.sort_values('shareOwnPercent', ascending=False).reset_index(drop=True)
188
+
189
+ def company_fundamental_ratio (symbol='TCB', mode='simplify', missing_pct=0.8, headers=None):
190
+ """
191
+ Return a single-row DataFrame snapshot of the company's most recent
192
+ financial ratios (P/E, P/B, ROE, ROA, market cap, EPS, etc.).
193
+ Data source: VCI (VietCap Securities).
194
+ the same statistics-financial endpoint as `financial_ratio()`, but
195
+ returns only the most recent period as a single row (the closest
196
+ a full history.
197
+ Parameters:
198
+ symbol (str): ticker of the company, default is 'TCB'.
199
+ mode (str): 'simplify' drops any text/name-style columns; kept for backward compatibility.
200
+ missing_pct (float): drop columns with more than this fraction of missing values. Default 0.8.
201
+ """
202
+ request_headers = dict(vci_headers)
203
+ request_headers.update(vci_session_cookies())
204
+ url = f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/company/{symbol}/statistics-financial'
205
+ payload = fetch_json('GET', url, headers=request_headers, source='VCI')
206
+ if payload is None:
207
+ return None
208
+ records = payload.get('data', [])
209
+ if not records:
210
+ print(f'No financial ratio data returned by VCI for {symbol}.')
211
+ return None
212
+ df = json_normalize(records)
213
+ df = df.dropna(axis=1, how='all')
214
+ df = df.sort_values(['yearReport', 'quarter'])
215
+ latest = df.tail(1).copy()
216
+ latest.insert(0, 'ticker', symbol)
217
+ if mode == 'simplify':
218
+ latest = latest.loc[:, ~latest.columns.str.contains('Name')]
219
+ return latest.loc[:, latest.isnull().mean() < missing_pct].reset_index(drop=True)
220
+
221
+ def ticker_price_volatility (symbol='TCB', headers=None):
222
+ """
223
+ Return a DataFrame of ticker price/trading-range data.
224
+ Data source: VCI (VietCap Securities).
225
+ the same company/details endpoint as `company_overview()`. Note the
226
+ 1-year high/low window plus a few point-in-time trading fields, not a
227
+ full week/month/quarter/year breakdown of price changes.
228
+ Parameters:
229
+ symbol (str): ticker of the company, default is 'TCB'.
230
+ """
231
+ url = f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/company/details?ticker={symbol}'
232
+ payload = fetch_json('GET', url, headers=vci_headers, source='VCI')
233
+ if payload is None:
234
+ return None
235
+ data = payload.get('data')
236
+ if not data:
237
+ print(f'No price volatility data returned by VCI for {symbol}.')
238
+ return None
239
+ df = json_normalize(data)
240
+ keep_cols = ['ticker', 'currentPrice', 'marketCap', 'highestPrice1Year', 'lowestPrice1Year',
241
+ 'averageMatchVolume1Month', 'averageMatchValue1Month',
242
+ 'foreignerPercentage', 'maximumForeignPercentage', 'statePercentage']
243
+ df = df[[c for c in keep_cols if c in df.columns]]
244
+ df.columns = [c if c == 'ticker' else 'ticker_' + c for c in df.columns]
245
+ return df
246
+
247
+ def company_insider_deals (symbol='TCB', page_size=20, page=0, headers=None):
248
+ """
249
+ Return a DataFrame of large-shareholder & insider trading transactions.
250
+ Data source: VCI (VietCap Securities) corporate events feed, filtered to
251
+ insider/large-shareholder dealing event codes (DDIND, DDINS, DDRP).
252
+ transaction as a free-text title combining name, action, and quantity
253
+ (e.g. "Nguyen Thu Lan - Subscribe to Buy 800,000 TCB shares") rather than
254
+ Parameters:
255
+ symbol (str): ticker of the company, default is 'TCB'.
256
+ page_size (int): number of items per page, default is 20.
257
+ page (int): page number, default is 0.
258
+ """
259
+ from_date = (datetime.now() - timedelta(days=365 * 10)).strftime('%Y%m%d')
260
+ to_date = datetime.now().strftime('%Y%m%d')
261
+ url = (f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/events'
262
+ f'?ticker={symbol}&fromDate={from_date}&toDate={to_date}&eventCode=DDIND,DDINS,DDRP&page={page}&size={page_size}')
263
+ payload = fetch_json('GET', url, headers=vci_headers, source='VCI')
264
+ if payload is None:
265
+ return None
266
+ content = payload.get('data', {}).get('content', [])
267
+ if not content:
268
+ print(f'No insider dealing data returned by VCI for {symbol}.')
269
+ return None
270
+ df = json_normalize(content)
271
+ df = df.drop(columns=[c for c in ['organCode', 'organNameEn', 'organNameVi', '__typename'] if c in df.columns])
272
+ df = df.rename(columns={'eventTitleVi': 'dealTitle', 'actionTypeVi': 'dealAction', 'publicDate': 'dealAnnounceDate'})
273
+ if 'dealAnnounceDate' in df.columns:
274
+ df['dealAnnounceDate'] = pd.to_datetime(df['dealAnnounceDate'])
275
+ df = df.sort_values('dealAnnounceDate', ascending=False)
276
+ return df.reset_index(drop=True)
277
+
278
+ def company_subsidiaries_listing (symbol='TCB', page_size=100, page=0, headers=None):
279
+ """
280
+ Return a DataFrame of company subsidiaries data.
281
+ Data source: VCI (VietCap Securities). Note
282
+ VCI returns the full subsidiaries list in one call (no pagination); the
283
+ `page_size`/`page` parameters are accepted for backward compatibility
284
+ but have no effect.
285
+ Parameters:
286
+ symbol (str): ticker of the company, default is 'TCB'.
287
+ """
288
+ url = f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/company/{symbol}/relationship'
289
+ payload = fetch_json('GET', url, headers=vci_headers, source='VCI')
290
+ if payload is None:
291
+ return None
292
+ subsidiaries = (payload.get('data') or {}).get('subsidiaries', [])
293
+ if not subsidiaries:
294
+ print(f'No subsidiaries data returned by VCI for {symbol}.')
295
+ return None
296
+ df = json_normalize(subsidiaries)
297
+ df['ticker'] = symbol
298
+ df = df.rename(columns={'rightOrganNameVi': 'subCompanyName', 'rightOrganCode': 'subOrganCode', 'ownedPercentage': 'subOwnPercent'})
299
+ keep_cols = ['ticker', 'subCompanyName', 'subOrganCode', 'subOwnPercent']
300
+ return df[[c for c in keep_cols if c in df.columns]]
301
+
302
+ def company_officers (symbol='TCB', page_size=20, page=0, headers=None):
303
+ """
304
+ Return a DataFrame of company officers data.
305
+ Data source: VCI (VietCap Securities) shareholder list, filtered to
306
+ individuals holding a named position - VCI has no dedicated
307
+ `page` are accepted for backward compatibility but have no effect (VCI
308
+ returns the full list in one call).
309
+ Parameters:
310
+ symbol (str): ticker of the company, default is 'TCB'.
311
+ """
312
+ df = _fetch_vci_shareholders(symbol)
313
+ if df is None:
314
+ return None
315
+ df = df[(df.get('ownerType') == 'INDIVIDUAL') & df.get('positionName').notna()]
316
+ if df.empty:
317
+ print(f'No officer data (individuals with a named position) returned by VCI for {symbol}.')
318
+ return None
319
+ df = df.rename(columns={'ownerName': 'officerName', 'positionName': 'officerPosition', 'percentage': 'officerOwnPercent', 'quantity': 'officerOwnQuantity'})
320
+ keep_cols = ['ticker', 'officerName', 'officerPosition', 'officerOwnPercent', 'officerOwnQuantity']
321
+ df = df[[c for c in keep_cols if c in df.columns]]
322
+ return df.sort_values(['officerOwnPercent', 'officerPosition'], ascending=False).reset_index(drop=True)
323
+
324
+ def company_events (symbol='TPB', page_size=15, page=0, headers=None):
325
+ """
326
+ Return a DataFrame of ticker events data (dividends, share issuance,
327
+ insider dealing, AGM/EGM, M&A, etc.).
328
+ Data source: VCI (VietCap Securities) corporate events feed. Migrated
329
+ Parameters:
330
+ symbol (str): ticker of the company, default is 'TPB'.
331
+ page_size (int): number of records per page, default is 15.
332
+ page (int): page number, default is 0. You can increase the page number to get more events.
333
+ """
334
+ from_date = (datetime.now() - timedelta(days=365 * 10)).strftime('%Y%m%d')
335
+ to_date = datetime.now().strftime('%Y%m%d')
336
+ event_codes = 'DIV,ISS,DDIND,DDINS,DDRP,AGME,AGMR,EGME,AIS,MA,MOVE,NLIS,OTHE,RETU,SUSP'
337
+ url = (f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/events'
338
+ f'?ticker={symbol}&fromDate={from_date}&toDate={to_date}&eventCode={event_codes}&page={page}&size={page_size}')
339
+ payload = fetch_json('GET', url, headers=vci_headers, source='VCI')
340
+ if payload is None:
341
+ return None
342
+ content = payload.get('data', {}).get('content', [])
343
+ if not content:
344
+ print(f'No events returned by VCI for {symbol}.')
345
+ return None
346
+ df = json_normalize(content)
347
+ return df.drop(columns=[c for c in ['organCode', 'organNameEn', 'organNameVi', '__typename'] if c in df.columns])
348
+
349
+ def company_news (symbol='TCB', page_size=15, page=0, headers=None):
350
+ """
351
+ Return a DataFrame of ticker news data.
352
+ Data source: VCI (VietCap Securities).
353
+ Parameters:
354
+ symbol (str): ticker of the company, default is 'TCB'.
355
+ page_size (int): number of records per page, default is 15.
356
+ page (int): page number, default is 0. You can increase the page number to get more news.
357
+ """
358
+ from_date = (datetime.now() - timedelta(days=365 * 10)).strftime('%Y%m%d')
359
+ to_date = datetime.now().strftime('%Y%m%d')
360
+ url = (f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/news'
361
+ f'?ticker={symbol}&fromDate={from_date}&toDate={to_date}&languageId=1&page={page}&size={page_size}')
362
+ payload = fetch_json('GET', url, headers=vci_headers, source='VCI')
363
+ if payload is None:
364
+ return None
365
+ content = payload.get('data', {}).get('content', [])
366
+ if not content:
367
+ print(f'No news returned by VCI for {symbol}.')
368
+ return None
369
+ df = json_normalize(content)
370
+ df['ticker'] = symbol
371
+ keep_cols = ['ticker', 'newsId', 'newsTitle', 'newsShortContent', 'newsSource', 'newsImageUrl', 'publicDate']
372
+ return df[[c for c in keep_cols if c in df.columns]]
373
+
374
+
375
+ # =============================================================================
376
+ # FINANCIAL STATEMENTS & RATIOS (VCI)
377
+ # =============================================================================
378
+
379
+ _VCI_SECTION_MAP = {'balancesheet': 'BALANCE_SHEET', 'incomestatement': 'INCOME_STATEMENT', 'cashflow': 'CASH_FLOW'}
380
+
381
+ def _vci_financial_statement(symbol, report_type, frequency):
382
+ """
383
+ Shared VCI financial-statement fetcher used by both financial_report() and
384
+ financial_flow(). Returns a DataFrame with periods as rows and financial-statement
385
+ line items as columns (labelled in Vietnamese via VCI's metrics endpoint where
386
+ available), or None on error.
387
+ """
388
+ section = _VCI_SECTION_MAP.get(report_type.lower().replace('_', ''), report_type.upper())
389
+ headers = dict(vci_headers)
390
+ headers.update(vci_session_cookies())
391
+ url = f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/company/{symbol}/financial-statement'
392
+ payload = fetch_json('GET', url, headers=headers, params={'section': section}, source='VCI')
393
+ if payload is None:
394
+ return None
395
+ data = payload.get('data', {})
396
+ key = 'years' if frequency.lower().startswith('year') else 'quarters'
397
+ records = data.get(key, [])
398
+ if not records:
399
+ print(f'No {report_type} data returned by VCI for {symbol} ({frequency}).')
400
+ return None
401
+ df = json_normalize(records)
402
+ # Best-effort: resolve cryptic field codes to Vietnamese labels via the metrics endpoint.
403
+ metrics_url = f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/company/{symbol}/financial-statement/metrics'
404
+ metrics_payload = fetch_json('GET', metrics_url, headers=headers, source='VCI')
405
+ if metrics_payload is not None:
406
+ label_map = {
407
+ item['field']: item['titleVi']
408
+ for items in (metrics_payload.get('data') or {}).values()
409
+ for item in items
410
+ if 'field' in item and 'titleVi' in item
411
+ }
412
+ df = df.rename(columns=label_map)
413
+ return df
414
+
415
+ def financial_report (symbol='SSI', report_type='BalanceSheet', frequency='Quarterly', headers=None): # Quarterly, Yearly
416
+ """
417
+ Return financial reports of a stock symbol by type and period.
418
+ Data source: VCI (VietCap Securities). Migrated from SSI in 2026 after SSI's
419
+ download endpoint started blocking automated requests via Cloudflare (403).
420
+ Note the output shape changed: rows are now reporting periods and columns are
421
+ line items (the old SSI Excel export had the opposite orientation).
422
+ Args:
423
+ symbol (:obj:`str`, required): 3 digits name of the desired stock.
424
+ report_type (:obj:`str`, required): BalanceSheet, IncomeStatement, CashFlow
425
+ frequency (:obj:`str`, required): Yearly or Quarterly.
426
+ """
427
+ return _vci_financial_statement(symbol, report_type, frequency)
428
+
429
+ def financial_flow(symbol='TCB', report_type='incomestatement', report_range='quarterly', get_all=True): # incomestatement, balancesheet, cashflow
430
+ """
431
+ This function returns the financial statement line items of a stock symbol over time.
432
+ Data source: VCI (VietCap Securities).
433
+ its API. `financial_flow` and `financial_report` now source from the same VCI
434
+ endpoint; kept as a separate function for backward compatibility with existing code.
435
+ Args:
436
+ symbol (:obj:`str`, required): 3 digits name of the desired stock.
437
+ report_type (:obj:`str`, required): select one of 3 reports: incomestatement, balancesheet, cashflow.
438
+ report_range (:obj:`str`, required): yearly or quarterly.
439
+ """
440
+ df = _vci_financial_statement(symbol, report_type, report_range)
441
+ if df is None:
442
+ return None
443
+ year_col = 'year' if 'year' in df.columns else 'yearReport'
444
+ quarter_col = 'quarter' if 'quarter' in df.columns else 'lengthReport'
445
+ if report_range == 'yearly' and year_col in df.columns:
446
+ df = df.sort_values(year_col).set_index(year_col)
447
+ elif year_col in df.columns and quarter_col in df.columns:
448
+ df = df.sort_values([year_col, quarter_col])
449
+ df['index'] = df[year_col].astype(str).str.cat('-Q' + df[quarter_col].astype(str))
450
+ df = df.set_index('index').drop(columns=[year_col, quarter_col])
451
+ if not get_all:
452
+ df = df.tail(10)
453
+ return df
454
+
455
+ def financial_ratio (symbol, report_range, is_all=False):
456
+ """
457
+ This function retrieves the essential financial ratios of a stock symbol on a quarterly or yearly basis. Some of the expected ratios include: P/E, P/B, ROE, ROA, BVPS, etc
458
+ Data source: VCI (VietCap Securities) statistics-financial endpoint. Migrated from
459
+ (e.g. BVPS) are not present in VCI's response.
460
+ Args:
461
+ symbol (:obj:`str`, required): 3 digits name of the desired stock.
462
+ report_range (:obj:`str`, required): 'yearly' or 'quarterly'.
463
+ is_all (:obj:`bool`, optional): Set to True to keep all available periods, False to keep only the 5 most recent. Default is False.
464
+ """
465
+ headers = dict(vci_headers)
466
+ headers.update(vci_session_cookies())
467
+ url = f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/company/{symbol}/statistics-financial'
468
+ payload = fetch_json('GET', url, headers=headers, source='VCI')
469
+ if payload is None:
470
+ return None
471
+ records = payload.get('data', [])
472
+ if not records:
473
+ print(f'No financial ratio data returned by VCI for {symbol}.')
474
+ return None
475
+ df = json_normalize(records)
476
+ df = df.dropna(axis=1, how='all')
477
+ # VCI marks full-year figures with ratioType='RATIO_YEAR' (quarter=5, a sentinel) and
478
+ # trailing-twelve-month quarterly figures with ratioType='RATIO_TTM' (quarter=1..4).
479
+ if report_range == 'yearly':
480
+ df = df[df['ratioType'] == 'RATIO_YEAR']
481
+ df = df.sort_values('yearReport')
482
+ if not is_all:
483
+ df = df.tail(5)
484
+ df = df.set_index('yearReport')
485
+ elif report_range == 'quarterly':
486
+ df = df[df['ratioType'] == 'RATIO_TTM']
487
+ df = df.sort_values(['yearReport', 'quarter'])
488
+ if not is_all:
489
+ df = df.tail(10)
490
+ df['range'] = 'Q' + df['quarter'].astype(int).astype(str) + '-' + df['yearReport'].astype(int).astype(str)
491
+ df = df.set_index('range')
492
+ df = df.drop(columns=[c for c in ['quarter', 'ratioTTMId', 'ratioType', 'organCode', 'year'] if c in df.columns])
493
+ return df.T
494
+
495
+ def financial_ratio_compare (symbol_ls=["CTG", "TCB", "ACB"], industry_comparison=True, frequency='Yearly', start_year=2010, headers=None):
496
+ """
497
+ This function compares financial ratios (P/E, P/B, ROE, ROA, etc.) of multiple
498
+ stock symbols side by side, for the most recent reporting period.
499
+ Data source: VCI (VietCap Securities), built on top of `financial_ratio()`.
500
+ Migrated from SSI in 2026 after SSI's Excel download endpoint started blocking
501
+ automated requests via Cloudflare (403).
502
+ Note: `industry_comparison` is no longer supported — VCI's statistics-financial
503
+ endpoint does not expose an industry-average aggregate, only per-company figures
504
+ (unlike the old SSI-based version). It is accepted for backward compatibility but
505
+ has no effect. `start_year` is also unused since VCI always returns its own
506
+ available history; use `financial_ratio()` directly if you need a specific range.
507
+ Args:
508
+ symbol_ls (:obj:`list`, required): list of stock symbols to compare, e.g. ["CTG", "TCB", "ACB"].
509
+ industry_comparison (:obj:`bool`, optional): unused, kept for backward compatibility.
510
+ frequency (:obj:`str`, required): 'Yearly' or 'Quarterly'.
511
+ start_year (:obj:`int`, optional): unused, kept for backward compatibility.
512
+ """
513
+ report_range = 'yearly' if frequency.lower().startswith('year') else 'quarterly'
514
+ columns = {}
515
+ for symbol in symbol_ls:
516
+ ratio_df = financial_ratio(symbol, report_range, is_all=False)
517
+ if ratio_df is None or ratio_df.empty:
518
+ print(f'Skipping {symbol}: no financial ratio data available from VCI.')
519
+ continue
520
+ columns[symbol] = ratio_df.iloc[:, -1] # most recent period
521
+ if not columns:
522
+ print('No financial ratio data available for any of the requested symbols.')
523
+ return None
524
+ df = pd.DataFrame(columns)
525
+ df.index.name = 'Chỉ số'
526
+ return df
527
+
528
+ def dividend_history (symbol):
529
+ """
530
+ This function returns the dividend historical data of the seed stock symbol.
531
+ Data source: VCI (VietCap Securities) corporate events feed, filtered to dividend
532
+ Args:
533
+ symbol (:obj:`str`, required): 3 digits name of the desired stock.
534
+ """
535
+ from_date = (datetime.now() - timedelta(days=365 * 15)).strftime('%Y%m%d')
536
+ to_date = datetime.now().strftime('%Y%m%d')
537
+ url = (f'https://iq.vietcap.com.vn/api/iq-insight-service/v1/events'
538
+ f'?ticker={symbol}&fromDate={from_date}&toDate={to_date}&eventCode=DIV&page=0&size=50')
539
+ payload = fetch_json('GET', url, headers=vci_headers, source='VCI')
540
+ if payload is None:
541
+ return None
542
+ content = payload.get('data', {}).get('content', [])
543
+ if not content:
544
+ print(f'No dividend history returned by VCI for {symbol}.')
545
+ return None
546
+ df = json_normalize(content)
547
+ return df.drop(columns=[c for c in ['organCode', '__typename', 'organNameEn', 'organNameVi', 'isEvent'] if c in df.columns])
548
+
549
+
550
+ # =============================================================================
551
+ # STOCK EVALUATION (VCI)
552
+ # =============================================================================
553
+
554
+ def stock_evaluation (symbol='ACB', period=1, time_window='D', headers=None):
555
+ """
556
+ Return a DataFrame of the stock's own historical P/E and P/B ratios by quarter.
557
+ Data source: VCI (VietCap Securities), reusing `financial_ratio()`.
558
+ endpoint also returned industry-average and VNIndex-average P/E and P/B
559
+ for comparison; confirmed VCI has no equivalent industry/index benchmark
560
+ data anywhere, so only the stock's own P/E and P/B series is available
561
+ here. `period`/`time_window` are accepted for backward compatibility but
562
+ no longer change the result - VCI always returns its own available
563
+ quarterly history.
564
+ Parameters:
565
+ symbol (str): ticker of the company, default is 'ACB'.
566
+ period (int): unused, kept for backward compatibility.
567
+ time_window (str): unused, kept for backward compatibility.
568
+ """
569
+ ratio_df = financial_ratio(symbol, 'quarterly', is_all=True)
570
+ if ratio_df is None:
571
+ return None
572
+ keep_rows = [r for r in ['pe', 'pb'] if r in ratio_df.index]
573
+ if not keep_rows:
574
+ print(f'No PE/PB data available from VCI for {symbol}.')
575
+ return None
576
+ df = ratio_df.loc[keep_rows].T
577
+ df = df.rename(columns={'pe': 'PE', 'pb': 'PB'})
578
+ df['ticker'] = symbol
579
+ df.index.name = 'period'
580
+ return df.reset_index()