openstockapi 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.
@@ -0,0 +1,12 @@
1
+ from datetime import datetime
2
+ from pydantic import BaseModel
3
+ from typing import List, Optional
4
+
5
+ class MacroIndicatorEntry(BaseModel):
6
+ name: str
7
+ year: int
8
+ period: str # e.g., "Annual", "Tháng 5/2026"
9
+ value: Optional[float] = None
10
+ unit: str
11
+ source: Optional[str] = None
12
+ provider: str
@@ -0,0 +1,20 @@
1
+ from datetime import datetime
2
+ from pydantic import BaseModel
3
+ from typing import Optional
4
+
5
+ class CompanyNewsEntry(BaseModel):
6
+ symbol: str
7
+ news_id: int
8
+ title: str
9
+ publish_date: datetime
10
+ url: Optional[str] = None
11
+ summary: Optional[str] = None
12
+ provider: str
13
+
14
+ class CompanyEventEntry(BaseModel):
15
+ symbol: str
16
+ event_id: Optional[str] = None
17
+ title: str
18
+ event_date: Optional[datetime] = None
19
+ details: Optional[str] = None
20
+ provider: str
@@ -0,0 +1,39 @@
1
+ from pydantic import BaseModel
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+ class ForeignTradingEntry(BaseModel):
6
+ symbol: str
7
+ date: datetime
8
+ buy_volume: float
9
+ buy_value: float
10
+ sell_volume: float
11
+ sell_value: float
12
+ net_volume: float
13
+ net_value: float
14
+ provider: str
15
+
16
+ class PropTradingEntry(BaseModel):
17
+ symbol: str
18
+ date: datetime
19
+ buy_volume: float
20
+ buy_value: float
21
+ sell_volume: float
22
+ sell_value: float
23
+ net_volume: float
24
+ net_value: float
25
+ provider: str
26
+
27
+ class InsiderTradingEntry(BaseModel):
28
+ symbol: str
29
+ trader_name: str
30
+ position: Optional[str] = None
31
+ relationship: Optional[str] = None
32
+ action_type: str # "Buy" or "Sell"
33
+ registered_volume: float
34
+ actual_volume: Optional[float] = None
35
+ trade_status: str
36
+ start_date: Optional[datetime] = None
37
+ end_date: Optional[datetime] = None
38
+ post_volume: float
39
+ provider: str
@@ -0,0 +1,36 @@
1
+ import time
2
+ from openstockapi.core.types import DataTier
3
+ from openstockapi.core.exceptions import RateLimitError
4
+
5
+ # Max requests per minute
6
+ TIER_LIMITS = {
7
+ DataTier.FREE: 10,
8
+ DataTier.PRO: 200,
9
+ DataTier.PREMIUM: 500,
10
+ }
11
+
12
+ class TokenBucketRateLimiter:
13
+ def __init__(self) -> None:
14
+ # Simplistic in-memory client state map: client_id -> (tokens, last_update_time)
15
+ self.buckets = {}
16
+
17
+ def check_limit(self, client_key: str, tier: DataTier) -> None:
18
+ limit = TIER_LIMITS.get(tier, 10)
19
+ now = time.time()
20
+
21
+ if client_key not in self.buckets:
22
+ self.buckets[client_key] = (limit - 1.0, now)
23
+ return
24
+
25
+ tokens, last_time = self.buckets[client_key]
26
+ # Calculate refill
27
+ elapsed = now - last_time
28
+ refill = elapsed * (limit / 60.0)
29
+ new_tokens = min(limit, tokens + refill)
30
+
31
+ if new_tokens < 1.0:
32
+ raise RateLimitError(f"Rate limit exceeded for tier '{tier.value}'. Limit: {limit} req/min.")
33
+
34
+ self.buckets[client_key] = (new_tokens - 1.0, now)
35
+
36
+ rate_limiter = TokenBucketRateLimiter()
@@ -0,0 +1,19 @@
1
+ from openstockapi.core.exceptions import TierUpgradeRequiredError
2
+ from openstockapi.license.session import get_current_session
3
+ from openstockapi.core.types import DataTier
4
+ from openstockapi.core.rate_limiter import rate_limiter
5
+
6
+ def enforce_tier_and_rate_limit(required_tier: DataTier, endpoint_name: str) -> None:
7
+ session = get_current_session()
8
+ current_tier = session.tier
9
+
10
+ # 1. Enforce data classification tier
11
+ # Hierarchical checks: FREE = 0, PRO = 1, PREMIUM = 2
12
+ tier_levels = {DataTier.FREE: 0, DataTier.PRO: 1, DataTier.PREMIUM: 2}
13
+
14
+ if tier_levels[current_tier] < tier_levels[required_tier]:
15
+ raise TierUpgradeRequiredError(required_tier.value, endpoint_name)
16
+
17
+ # 2. Local client-side rate limit enforcement
18
+ client_key = session.api_key or "anonymous"
19
+ rate_limiter.check_limit(client_key, current_tier)
@@ -0,0 +1,6 @@
1
+ from enum import Enum
2
+
3
+ class DataTier(str, Enum):
4
+ FREE = "free"
5
+ PRO = "pro"
6
+ PREMIUM = "premium"
@@ -0,0 +1,23 @@
1
+ import re
2
+ from datetime import datetime
3
+ from typing import Any
4
+
5
+ def clean_symbol(symbol: str) -> str:
6
+ """Standardize ticker symbols to uppercase alphanumeric."""
7
+ return re.sub(r"[^A-Za-z0-9]", "", symbol).upper()
8
+
9
+ def parse_date(date_val: Any) -> datetime:
10
+ """Parse common date inputs into datetime objects."""
11
+ if isinstance(date_val, datetime):
12
+ return date_val
13
+ if isinstance(date_val, (int, float)):
14
+ return datetime.fromtimestamp(date_val)
15
+ if isinstance(date_val, str):
16
+ # Remove timezone offsets or fractional seconds if they complicate standard patterns
17
+ cleaned = date_val.split('.')[0]
18
+ for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y%m%d"):
19
+ try:
20
+ return datetime.strptime(cleaned, fmt)
21
+ except ValueError:
22
+ continue
23
+ raise ValueError(f"Could not parse date: {date_val}")
@@ -0,0 +1,36 @@
1
+ import os
2
+ from typing import Optional
3
+ from openstockapi.core.types import DataTier
4
+
5
+ class Session:
6
+ """Manages API key authentication and resolves the current tier."""
7
+ def __init__(self, api_key: Optional[str] = None) -> None:
8
+ self.api_key = api_key or os.getenv("OPENSTOCKAPI_KEY")
9
+ self.tier = self._resolve_tier()
10
+
11
+ def _resolve_tier(self) -> DataTier:
12
+ if not self.api_key:
13
+ return DataTier.FREE
14
+
15
+ # Simple decode rule for Phase 1: check prefixes of key
16
+ # pro_xxx -> PRO
17
+ # prem_xxx -> PREMIUM
18
+ key = self.api_key.strip().lower()
19
+ if key.startswith("pro_"):
20
+ return DataTier.PRO
21
+ elif key.startswith("prem_") or key.startswith("premium_"):
22
+ return DataTier.PREMIUM
23
+ return DataTier.FREE
24
+
25
+ # Default global session singleton
26
+ _current_session = Session()
27
+
28
+ def get_current_session() -> Session:
29
+ return _current_session
30
+
31
+ def set_current_session(session: Session) -> None:
32
+ global _current_session
33
+ _current_session = session
34
+
35
+ def init(api_key: str) -> None:
36
+ set_current_session(Session(api_key))
@@ -0,0 +1,21 @@
1
+ from openstockapi.providers.mas import MASProvider
2
+ from openstockapi.providers.dnse import DNSEProvider
3
+ from openstockapi.providers.vndirect import VNDIRECTProvider
4
+ from openstockapi.providers.vci import VCIProvider
5
+ from openstockapi.providers.mbk import MBKProvider
6
+ from openstockapi.providers.fmarket import FmarketProvider
7
+ from openstockapi.providers.kbs import KBSProvider
8
+
9
+ # In Phase 1, we also instantiate the VCI provider as stub or simple registry
10
+ PROVIDERS = {
11
+ "mas": MASProvider(),
12
+ "dnse": DNSEProvider(),
13
+ "vndirect": VNDIRECTProvider(),
14
+ "vci": VCIProvider(),
15
+ "mbk": MBKProvider(),
16
+ "fmarket": FmarketProvider(),
17
+ "kbs": KBSProvider(),
18
+ }
19
+
20
+ def get_provider(name: str):
21
+ return PROVIDERS.get(name.lower())
@@ -0,0 +1,104 @@
1
+ from datetime import datetime
2
+ from typing import List
3
+ from openstockapi.core.base_provider import BaseProvider
4
+ from openstockapi.core.types import DataTier
5
+ from openstockapi.core.models import OHLCVBar, FinancialItem, OrderBook, OrderBookEntry
6
+ from openstockapi.core.http_client import http_client
7
+ from openstockapi.core.exceptions import DataParseError
8
+ from openstockapi.core.utils import parse_date
9
+
10
+ class DNSEProvider(BaseProvider):
11
+ name = "dnse"
12
+ required_tier = DataTier.FREE
13
+
14
+ def get_ohlcv(self, symbol: str, resolution: str, from_date: str, to_date: str) -> List[OHLCVBar]:
15
+ try:
16
+ from_dt = parse_date(from_date)
17
+ to_dt = parse_date(to_date)
18
+ from_ts = int(from_dt.timestamp())
19
+ to_ts = int(to_dt.timestamp())
20
+ except Exception as e:
21
+ raise ValueError(f"Invalid date format passed to DNSE get_ohlcv: {e}")
22
+
23
+ # Map resolution to DNSE resolution values
24
+ # e.g., "1D" -> "1D", "1m" -> "1", etc.
25
+ res_map = {"1d": "1D", "1m": "1", "5m": "5", "15m": "15", "1h": "60", "1w": "1W"}
26
+ dnse_res = res_map.get(resolution.lower(), resolution)
27
+
28
+ url = f"https://services.entrade.com.vn/chart-api/v2/ohlc/history?resolution={dnse_res}&symbol={symbol}&from={from_ts}&to={to_ts}"
29
+
30
+ try:
31
+ res = http_client.request("GET", url)
32
+ data = res.json()
33
+
34
+ # Format: lists of t (timestamp), o, h, l, c, v
35
+ t_list = data.get("t", [])
36
+ o_list = data.get("o", [])
37
+ h_list = data.get("h", [])
38
+ l_list = data.get("l", [])
39
+ c_list = data.get("c", [])
40
+ v_list = data.get("v", [])
41
+
42
+ results = []
43
+ for i in range(len(t_list)):
44
+ results.append(OHLCVBar(
45
+ symbol=symbol,
46
+ timestamp=datetime.fromtimestamp(t_list[i]),
47
+ open=float(o_list[i]),
48
+ high=float(h_list[i]),
49
+ low=float(l_list[i]),
50
+ close=float(c_list[i]),
51
+ volume=int(v_list[i]),
52
+ provider=self.name
53
+ ))
54
+ return results
55
+ except Exception as e:
56
+ raise DataParseError(f"Failed to fetch/parse OHLCV history from DNSE: {str(e)}")
57
+
58
+ def get_financial_statements(self, symbol: str, stmt_type: str, period: str) -> List[FinancialItem]:
59
+ raise NotImplementedError("DNSE provider does not support financial statements. Use 'mas' instead.")
60
+
61
+ async def async_get_ohlcv(self, symbol: str, resolution: str, from_date: str, to_date: str) -> List[OHLCVBar]:
62
+ try:
63
+ from_dt = parse_date(from_date)
64
+ to_dt = parse_date(to_date)
65
+ from_ts = int(from_dt.timestamp())
66
+ to_ts = int(to_dt.timestamp())
67
+ except Exception as e:
68
+ raise ValueError(f"Invalid date format passed to DNSE get_ohlcv: {e}")
69
+
70
+ res_map = {"1d": "1D", "1m": "1", "5m": "5", "15m": "15", "1h": "60", "1w": "1W"}
71
+ dnse_res = res_map.get(resolution.lower(), resolution)
72
+
73
+ url = f"https://services.entrade.com.vn/chart-api/v2/ohlc/history?resolution={dnse_res}&symbol={symbol}&from={from_ts}&to={to_ts}"
74
+
75
+ try:
76
+ res = await http_client.async_request("GET", url)
77
+ data = res.json()
78
+
79
+ t_list = data.get("t", [])
80
+ o_list = data.get("o", [])
81
+ h_list = data.get("h", [])
82
+ l_list = data.get("l", [])
83
+ c_list = data.get("c", [])
84
+ v_list = data.get("v", [])
85
+
86
+ results = []
87
+ for i in range(len(t_list)):
88
+ results.append(OHLCVBar(
89
+ symbol=symbol,
90
+ timestamp=datetime.fromtimestamp(t_list[i]),
91
+ open=float(o_list[i]),
92
+ high=float(h_list[i]),
93
+ low=float(l_list[i]),
94
+ close=float(c_list[i]),
95
+ volume=int(v_list[i]),
96
+ provider=self.name
97
+ ))
98
+ return results
99
+ except Exception as e:
100
+ raise DataParseError(f"Failed to fetch/parse OHLCV history from DNSE: {str(e)}")
101
+
102
+ def get_order_book(self, symbol: str) -> OrderBook:
103
+ # Requires paid JWT token (stubbed here, in real implementation we fetch using token from session)
104
+ raise NotImplementedError("Depth API is a premium feature. Authenticable tokens are handled in Control Plane integration.")
@@ -0,0 +1,58 @@
1
+ from typing import List
2
+ from openstockapi.core.base_provider import BaseProvider
3
+ from openstockapi.core.types import DataTier
4
+ from openstockapi.core.models_fund import FundDetails, FundHolding
5
+ from openstockapi.core.http_client import http_client
6
+ from openstockapi.core.exceptions import DataParseError
7
+
8
+ class FmarketProvider(BaseProvider):
9
+ name = "fmarket"
10
+ required_tier = DataTier.FREE
11
+
12
+ def get_ohlcv(self, symbol: str, resolution: str, from_date: str, to_date: str) -> List[any]:
13
+ raise NotImplementedError()
14
+
15
+ def get_financial_statements(self, symbol: str, stmt_type: str, period: str) -> List[any]:
16
+ raise NotImplementedError()
17
+
18
+ def get_fund_details(self, fund_id: int) -> FundDetails:
19
+ url = f"https://api.fmarket.vn/res/products/{fund_id}"
20
+ headers = {
21
+ "Accept": "application/json, text/plain, */*",
22
+ "Content-Type": "application/json",
23
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
24
+ }
25
+ try:
26
+ res = http_client.request("GET", url, headers=headers)
27
+ data = res.json()
28
+
29
+ p_data = data.get("data", {})
30
+ if not p_data:
31
+ raise DataParseError(f"No fund details data returned by Fmarket for fund_id: {fund_id}")
32
+
33
+ raw_holdings = p_data.get("productTopHoldingList", [])
34
+ holdings = []
35
+ for h in raw_holdings:
36
+ holdings.append(FundHolding(
37
+ ticker=h.get("stockCode") or h.get("name", ""),
38
+ name=h.get("name"),
39
+ net_asset_percent=float(h.get("netAssetPercent", 0)),
40
+ asset_value=float(h.get("assetValue")) if h.get("assetValue") is not None else None,
41
+ volume=float(h.get("volume")) if h.get("volume") is not None else None
42
+ ))
43
+
44
+ return FundDetails(
45
+ fund_id=int(p_data.get("id")),
46
+ name=p_data.get("name", ""),
47
+ short_name=p_data.get("shortName", ""),
48
+ code=p_data.get("code", ""),
49
+ price=float(p_data.get("price", 0)),
50
+ nav=float(p_data.get("nav", 0)),
51
+ expected_return=p_data.get("expectedReturn"),
52
+ management_fee=p_data.get("managementFee"),
53
+ description=p_data.get("description"),
54
+ holdings=holdings,
55
+ provider=self.name
56
+ )
57
+ except Exception as e:
58
+ raise DataParseError(f"Failed to fetch/parse fund details from Fmarket: {e}")
@@ -0,0 +1,69 @@
1
+ from typing import List
2
+ from openstockapi.core.base_provider import BaseProvider
3
+ from openstockapi.core.types import DataTier
4
+ from openstockapi.core.models_news import CompanyNewsEntry, CompanyEventEntry
5
+ from openstockapi.core.http_client import http_client
6
+ from openstockapi.core.exceptions import DataParseError
7
+ from openstockapi.core.utils import parse_date
8
+
9
+ class KBSProvider(BaseProvider):
10
+ name = "kbs"
11
+ required_tier = DataTier.FREE
12
+
13
+ def get_ohlcv(self, symbol: str, resolution: str, from_date: str, to_date: str) -> List[any]:
14
+ raise NotImplementedError()
15
+
16
+ def get_financial_statements(self, symbol: str, stmt_type: str, period: str) -> List[any]:
17
+ raise NotImplementedError()
18
+
19
+ def _get_headers(self) -> dict:
20
+ return {
21
+ "Accept": "application/json, text/plain, */*",
22
+ "Accept-Language": "en-US,en;q=0.9,vi-VN;q=0.8,vi;q=0.7",
23
+ "Connection": "keep-alive",
24
+ "Content-Type": "application/json",
25
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"
26
+ }
27
+
28
+ def get_company_news(self, symbol: str, limit: int = 10) -> List[CompanyNewsEntry]:
29
+ url = f"https://kbbuddywts.kbsec.com.vn/iis-server/investment/stockinfo/news/{symbol}?l=1&p=1&s={limit}"
30
+ try:
31
+ res = http_client.request("GET", url, headers=self._get_headers())
32
+ data = res.json()
33
+
34
+ results = []
35
+ for item in data:
36
+ results.append(CompanyNewsEntry(
37
+ symbol=symbol,
38
+ news_id=int(item.get("ArticleID", 0)),
39
+ title=item.get("Title", ""),
40
+ publish_date=parse_date(item.get("PublishTime")),
41
+ url=item.get("URL"),
42
+ summary=item.get("Head"),
43
+ provider=self.name
44
+ ))
45
+ return results
46
+ except Exception as e:
47
+ raise DataParseError(f"Failed to fetch/parse news from KBS: {e}")
48
+
49
+ def get_company_events(self, symbol: str, limit: int = 10) -> List[CompanyEventEntry]:
50
+ url = f"https://kbbuddywts.kbsec.com.vn/iis-server/investment/stockinfo/event/{symbol}?l=1&p=1&s={limit}"
51
+ try:
52
+ res = http_client.request("GET", url, headers=self._get_headers())
53
+ data = res.json()
54
+
55
+ results = []
56
+ for item in data:
57
+ # Structure may contain EventTitle, EventDate etc depending on response.
58
+ # KBS event schema varies, but we parse gracefully.
59
+ results.append(CompanyEventEntry(
60
+ symbol=symbol,
61
+ event_id=str(item.get("EventID", "")),
62
+ title=item.get("EventTitle", item.get("Title", "Event")),
63
+ event_date=parse_date(item.get("EventDate")) if item.get("EventDate") else None,
64
+ details=item.get("EventContent", item.get("Content")),
65
+ provider=self.name
66
+ ))
67
+ return results
68
+ except Exception as e:
69
+ raise DataParseError(f"Failed to fetch/parse events from KBS: {e}")
@@ -0,0 +1,92 @@
1
+ from datetime import datetime
2
+ import json
3
+ import urllib.parse
4
+ from typing import List
5
+ from openstockapi.core.base_provider import BaseProvider
6
+ from openstockapi.core.types import DataTier
7
+ from openstockapi.core.models import FinancialItem, OHLCVBar
8
+ from openstockapi.core.http_client import http_client
9
+ from openstockapi.core.exceptions import DataParseError
10
+
11
+ class MASProvider(BaseProvider):
12
+ name = "mas"
13
+ required_tier = DataTier.FREE
14
+
15
+ def get_ohlcv(self, symbol: str, resolution: str, from_date: str, to_date: str) -> List[OHLCVBar]:
16
+ # MAS does not officially support clean OHLC history in free apis doc, DNSE is preferred.
17
+ # But we implement a basic placeholder or pass.
18
+ raise NotImplementedError("MAS provider does not support historical OHLCV. Use 'dnse' instead.")
19
+
20
+ def get_financial_statements(self, symbol: str, stmt_type: str, period: str) -> List[FinancialItem]:
21
+ # Normalizes statement types (CDKT -> CDKT, KQKD -> KQKD, etc.)
22
+ type_mapping = {
23
+ "income": "KQKD",
24
+ "balance": "CDKT",
25
+ "cashflow": "LCTT",
26
+ "ratios": "CSTC"
27
+ }
28
+ mas_type = type_mapping.get(stmt_type.lower(), "CDKT")
29
+ term_type = "Q" if period.upper() == "Q" else "Y"
30
+
31
+ gql_query = f'query{{vsFinancialReportList(StockCode:"{symbol}",Type:"{mas_type}",TermType:"{term_type}"){{YearPeriod,TermCode,Content{{Values{{Name,Value}}}}}}}}'
32
+ encoded_query = urllib.parse.quote(gql_query)
33
+ url = f"https://masboard.masvn.com/api/v2/vs/financialReport?query={encoded_query}"
34
+
35
+ try:
36
+ res = http_client.request("GET", url)
37
+ data = res.json()
38
+
39
+ # MAS returns a list directly or wraps it
40
+ report_list = []
41
+ if isinstance(data, list):
42
+ report_list = data
43
+ elif isinstance(data, dict):
44
+ report_list = data.get("data", {}).get("vsFinancialReportList", [])
45
+
46
+ results = []
47
+ for report in report_list:
48
+ year_period = report.get("YearPeriod", 0)
49
+ term_code = report.get("TermCode") # e.g. "Q1", "Year"
50
+
51
+ # Deduce quarter
52
+ quarter = None
53
+ if term_code and term_code.startswith("Q"):
54
+ try:
55
+ quarter = int(term_code[1])
56
+ except ValueError:
57
+ pass
58
+
59
+ items = {}
60
+ content = report.get("Content", [])
61
+ if isinstance(content, list) and len(content) > 0:
62
+ values = content[0].get("Values", [])
63
+ for val in values:
64
+ name = val.get("Name")
65
+ value = val.get("Value")
66
+ if name:
67
+ try:
68
+ items[name] = float(value) if value is not None else None
69
+ except ValueError:
70
+ items[name] = None
71
+ elif isinstance(content, dict):
72
+ values = content.get("Values", [])
73
+ for val in values:
74
+ name = val.get("Name")
75
+ value = val.get("Value")
76
+ if name:
77
+ try:
78
+ items[name] = float(value) if value is not None else None
79
+ except ValueError:
80
+ items[name] = None
81
+
82
+ results.append(FinancialItem(
83
+ symbol=symbol,
84
+ year=int(year_period),
85
+ quarter=quarter,
86
+ statement_type=stmt_type,
87
+ items=items,
88
+ provider=self.name
89
+ ))
90
+ return results
91
+ except Exception as e:
92
+ raise DataParseError(f"Failed to fetch/parse financial statements from MAS: {str(e)}")
@@ -0,0 +1,49 @@
1
+ from typing import List
2
+ from openstockapi.core.base_provider import BaseProvider
3
+ from openstockapi.core.types import DataTier
4
+ from openstockapi.core.models_macro import MacroIndicatorEntry
5
+ from openstockapi.core.http_client import http_client
6
+ from openstockapi.core.exceptions import DataParseError
7
+
8
+ class MBKProvider(BaseProvider):
9
+ name = "mbk"
10
+ required_tier = DataTier.FREE
11
+
12
+ def get_ohlcv(self, symbol: str, resolution: str, from_date: str, to_date: str) -> List[any]:
13
+ raise NotImplementedError()
14
+
15
+ def get_financial_statements(self, symbol: str, stmt_type: str, period: str) -> List[any]:
16
+ raise NotImplementedError()
17
+
18
+ def get_macro_indicators(self) -> List[MacroIndicatorEntry]:
19
+ url = "https://data.maybanktrade.com.vn/data/reportdatatopbynormtype"
20
+ headers = {
21
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
22
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
23
+ "Accept": "application/json, text/javascript, */*; q=0.01",
24
+ "X-Requested-With": "XMLHttpRequest",
25
+ "Referer": "https://data.maybanktrade.com.vn/"
26
+ }
27
+
28
+ # Fetch Money Supply (M2) & Credit data (type=2, normTypeID=51)
29
+ payload = "type=2&fromYear=2024&toYear=2026&from=1&to=12&normTypeID=51"
30
+
31
+ try:
32
+ res = http_client.request("POST", url, headers=headers, content=payload)
33
+ data = res.json()
34
+
35
+ results = []
36
+ for item in data:
37
+ val = item.get("NormValue")
38
+ results.append(MacroIndicatorEntry(
39
+ name=item.get("NormName", "M2"),
40
+ year=int(item.get("TermYear", 2026)),
41
+ period=item.get("ReportTime", ""),
42
+ value=float(val) if val is not None else None,
43
+ unit=item.get("UnitCode", "Tỷ VNĐ"),
44
+ source=item.get("FromSource"),
45
+ provider=self.name
46
+ ))
47
+ return results
48
+ except Exception as e:
49
+ raise DataParseError(f"Failed to fetch/parse Maybank Macro M2 indicators: {e}")