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.
- openstockapi/__init__.py +32 -0
- openstockapi/api/financial.py +48 -0
- openstockapi/api/fund.py +32 -0
- openstockapi/api/macro.py +35 -0
- openstockapi/api/news.py +60 -0
- openstockapi/api/orderbook.py +15 -0
- openstockapi/api/stock.py +116 -0
- openstockapi/api/trading.py +79 -0
- openstockapi/config/settings.py +13 -0
- openstockapi/core/base_provider.py +54 -0
- openstockapi/core/exceptions.py +29 -0
- openstockapi/core/http_client.py +62 -0
- openstockapi/core/models.py +67 -0
- openstockapi/core/models_fund.py +22 -0
- openstockapi/core/models_macro.py +12 -0
- openstockapi/core/models_news.py +20 -0
- openstockapi/core/models_trading.py +39 -0
- openstockapi/core/rate_limiter.py +36 -0
- openstockapi/core/security.py +19 -0
- openstockapi/core/types.py +6 -0
- openstockapi/core/utils.py +23 -0
- openstockapi/license/session.py +36 -0
- openstockapi/providers/__init__.py +21 -0
- openstockapi/providers/dnse.py +104 -0
- openstockapi/providers/fmarket.py +58 -0
- openstockapi/providers/kbs.py +69 -0
- openstockapi/providers/mas.py +92 -0
- openstockapi/providers/mbk.py +49 -0
- openstockapi/providers/vci.py +108 -0
- openstockapi/providers/vndirect.py +49 -0
- openstockapi-0.1.0.dist-info/METADATA +46 -0
- openstockapi-0.1.0.dist-info/RECORD +36 -0
- openstockapi-0.1.0.dist-info/WHEEL +5 -0
- openstockapi-0.1.0.dist-info/licenses/LICENSE +176 -0
- openstockapi-0.1.0.dist-info/licenses/NOTICE +5 -0
- openstockapi-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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}")
|