hodlvn 0.1.0__tar.gz
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.
- hodlvn-0.1.0/PKG-INFO +33 -0
- hodlvn-0.1.0/README.md +21 -0
- hodlvn-0.1.0/hodl/__init__.py +33 -0
- hodlvn-0.1.0/hodl/data.py +152 -0
- hodlvn-0.1.0/hodl/engine_portfolio.py +88 -0
- hodlvn-0.1.0/hodl/engine_single.py +81 -0
- hodlvn-0.1.0/hodl/metrics.py +61 -0
- hodlvn-0.1.0/hodl/portfolio_ctx.py +117 -0
- hodlvn-0.1.0/hodl/rules_vn.py +60 -0
- hodlvn-0.1.0/hodl/samples.py +51 -0
- hodlvn-0.1.0/hodl/script_env.py +140 -0
- hodlvn-0.1.0/hodl/script_portfolio.py +86 -0
- hodlvn-0.1.0/hodl/ta.py +79 -0
- hodlvn-0.1.0/hodlvn.egg-info/PKG-INFO +33 -0
- hodlvn-0.1.0/hodlvn.egg-info/SOURCES.txt +21 -0
- hodlvn-0.1.0/hodlvn.egg-info/dependency_links.txt +1 -0
- hodlvn-0.1.0/hodlvn.egg-info/requires.txt +2 -0
- hodlvn-0.1.0/hodlvn.egg-info/top_level.txt +1 -0
- hodlvn-0.1.0/pyproject.toml +19 -0
- hodlvn-0.1.0/setup.cfg +4 -0
- hodlvn-0.1.0/tests/test_core.py +154 -0
- hodlvn-0.1.0/tests/test_docs_examples.py +65 -0
- hodlvn-0.1.0/tests/test_sdk.py +47 -0
hodlvn-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hodlvn
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HODL Lab — chỉ báo, chiến lược, backtest danh mục cổ phiếu Việt Nam trên dữ liệu HODL (cần API key hạng Loyalty)
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Project-URL: Homepage, https://hodl.vn/lab
|
|
7
|
+
Project-URL: Documentation, https://hodl.vn/lab/docs
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: pandas>=2.0
|
|
11
|
+
Requires-Dist: numpy>=1.24
|
|
12
|
+
|
|
13
|
+
# hodlvn — HODL Lab SDK
|
|
14
|
+
|
|
15
|
+
Viết chỉ báo, chiến lược và backtest danh mục cổ phiếu Việt Nam trên dữ liệu của HODL. Lõi này cũng là thứ chạy trong
|
|
16
|
+
trình duyệt ở [hodl.vn/lab](https://hodl.vn/lab), nên kết quả ở SDK và trên web giống nhau tới đồng.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install hodlvn
|
|
20
|
+
export HODL_API_KEY=hodl_... # tạo ở hodl.vn/tai-khoan/ket-noi-ai (hạng Loyalty)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import hodl
|
|
25
|
+
lab = hodl.Client()
|
|
26
|
+
res = lab.run('universe("HX-BANK")\nrebalance("M")\npick(top(momentum(63), 3))', kind="portfolio", start="2021-01-04")
|
|
27
|
+
print(res["summary"])
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Backtest đã áp sẵn luật thị trường Việt Nam: lô 100, biên độ trần / sàn theo sàn, T+2,5, phí 0,15%, thuế bán 0,1%,
|
|
31
|
+
cổ tức tiền (thuế 5%) và cổ phiếu thưởng theo đúng ngày GDKHQ, khớp lệnh ở giá mở phiên sau tín hiệu, giá thật.
|
|
32
|
+
|
|
33
|
+
Hướng dẫn đầy đủ: https://hodl.vn/lab/docs
|
hodlvn-0.1.0/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# hodlvn — HODL Lab SDK
|
|
2
|
+
|
|
3
|
+
Viết chỉ báo, chiến lược và backtest danh mục cổ phiếu Việt Nam trên dữ liệu của HODL. Lõi này cũng là thứ chạy trong
|
|
4
|
+
trình duyệt ở [hodl.vn/lab](https://hodl.vn/lab), nên kết quả ở SDK và trên web giống nhau tới đồng.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install hodlvn
|
|
8
|
+
export HODL_API_KEY=hodl_... # tạo ở hodl.vn/tai-khoan/ket-noi-ai (hạng Loyalty)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import hodl
|
|
13
|
+
lab = hodl.Client()
|
|
14
|
+
res = lab.run('universe("HX-BANK")\nrebalance("M")\npick(top(momentum(63), 3))', kind="portfolio", start="2021-01-04")
|
|
15
|
+
print(res["summary"])
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Backtest đã áp sẵn luật thị trường Việt Nam: lô 100, biên độ trần / sàn theo sàn, T+2,5, phí 0,15%, thuế bán 0,1%,
|
|
19
|
+
cổ tức tiền (thuế 5%) và cổ phiếu thưởng theo đúng ngày GDKHQ, khớp lệnh ở giá mở phiên sau tín hiệu, giá thật.
|
|
20
|
+
|
|
21
|
+
Hướng dẫn đầy đủ: https://hodl.vn/lab/docs
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""hodl — lõi HODL Lab: chạy script chỉ báo / chiến lược / danh mục trên dữ liệu cổ phiếu Việt Nam của HODL.
|
|
2
|
+
|
|
3
|
+
Trong Lab (trình duyệt) script KHÔNG cần import gì. Dùng như SDK (máy riêng, hạng Loyalty):
|
|
4
|
+
|
|
5
|
+
import hodl
|
|
6
|
+
lab = hodl.Client(api_key="hodl_…") # hoặc biến môi trường HODL_API_KEY
|
|
7
|
+
res = lab.run('universe("HX-BANK"); rebalance("M"); pick(top(momentum(63), 3))', kind="portfolio", start="2021-01-01")
|
|
8
|
+
res["summary"]
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from .data import Data, DataError, FnSource, HttpSource, MemorySource
|
|
15
|
+
from .script_env import KINDS, ScriptError, run
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
__all__ = ["Client", "Data", "DataError", "FnSource", "HttpSource", "MemorySource", "ScriptError", "KINDS", "run"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Client:
|
|
22
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None):
|
|
23
|
+
key = api_key or os.environ.get("HODL_API_KEY")
|
|
24
|
+
if not key:
|
|
25
|
+
raise DataError("lab_invalid_key", "Thiếu API key (tham số api_key hoặc biến môi trường HODL_API_KEY)")
|
|
26
|
+
self.data = Data(HttpSource(key, base_url) if base_url else HttpSource(key))
|
|
27
|
+
|
|
28
|
+
def run(self, code: str, kind: str, symbol: str | None = None, start: str | None = None, params: dict | None = None,
|
|
29
|
+
capital: float = 100_000_000, fee_pct: float = 0.15) -> dict:
|
|
30
|
+
return run(code, kind, self.data, symbol=symbol, start=start, params=params, capital=capital, fee_pct=fee_pct)
|
|
31
|
+
|
|
32
|
+
def bars(self, symbols, start=None, end=None, adjusted=True):
|
|
33
|
+
return self.data.bars(symbols, start, end, adjusted)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Lấy dữ liệu từ Data API của HODL (`/lab/v1/*`) — một giao diện cho cả hai nơi chạy:
|
|
2
|
+
|
|
3
|
+
- Trình duyệt (Pyodide): `FnSource(fn)` — `fn(path_query) -> str JSON` là cầu JS ĐỒNG BỘ do Worker cấp, chỉ gọi được
|
|
4
|
+
`/api/lab/*` cùng nguồn (Worker chặn mọi URL khác).
|
|
5
|
+
- SDK (CPython): `HttpSource(api_key)` — gọi `https://api.totrieu.com/lab/v1` bằng API key hạng Loyalty.
|
|
6
|
+
- Test: `MemorySource({path: json})`.
|
|
7
|
+
|
|
8
|
+
Lỗi của API (hết lượt, quá hạn mức mã…) ném `DataError` có `code` để giao diện mời nâng hạng / báo rõ.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from datetime import date
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE = "https://api.totrieu.com/lab/v1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DataError(RuntimeError):
|
|
27
|
+
def __init__(self, code: str, message: str):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.code = code
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _query(path: str, params: dict) -> str:
|
|
33
|
+
q = {k: v for k, v in params.items() if v is not None}
|
|
34
|
+
return f"{path}?{urllib.parse.urlencode(q)}" if q else path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Source:
|
|
38
|
+
def get(self, path: str, **params) -> dict: # pragma: no cover — giao diện
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FnSource(Source):
|
|
43
|
+
def __init__(self, fn):
|
|
44
|
+
self.fn = fn
|
|
45
|
+
|
|
46
|
+
def get(self, path: str, **params) -> dict:
|
|
47
|
+
data = json.loads(str(self.fn(_query(path, params))))
|
|
48
|
+
if isinstance(data, dict) and "detail" in data:
|
|
49
|
+
d = data["detail"]
|
|
50
|
+
if isinstance(d, dict):
|
|
51
|
+
raise DataError(d.get("code", "lab_error"), d.get("message", ""))
|
|
52
|
+
raise DataError("lab_bad_request", f"Tham số không hợp lệ: {d}")
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
CACHED_PATHS = ("/bars", "/events", "/universe/history", "/fundamentals")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HttpSource(Source):
|
|
60
|
+
"""SDK: gọi Data API bằng API key. Nến / sự kiện / rổ / chỉ số cơ bản đệm trên đĩa theo NGÀY (`~/.cache/hodl`, đổi bằng
|
|
61
|
+
`HODL_CACHE_DIR`; `HODL_NO_CACHE=1` để tắt) — chạy lại script trong ngày không tốn lượt gọi."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE, timeout: float = 60, cache_dir: str | None = None):
|
|
64
|
+
self.api_key, self.base_url, self.timeout = api_key, base_url.rstrip("/"), timeout
|
|
65
|
+
off = os.environ.get("HODL_NO_CACHE") == "1"
|
|
66
|
+
self.cache_dir = None if off else Path(cache_dir or os.environ.get("HODL_CACHE_DIR") or Path.home() / ".cache" / "hodl")
|
|
67
|
+
|
|
68
|
+
def _cache_file(self, url: str) -> Path | None:
|
|
69
|
+
if self.cache_dir is None:
|
|
70
|
+
return None
|
|
71
|
+
key = hashlib.sha256(f"{date.today().isoformat()}|{self.api_key[-8:]}|{url}".encode()).hexdigest()[:32]
|
|
72
|
+
return self.cache_dir / f"{key}.json"
|
|
73
|
+
|
|
74
|
+
def get(self, path: str, **params) -> dict:
|
|
75
|
+
url = self.base_url + _query(path, params)
|
|
76
|
+
cached = self._cache_file(url) if path in CACHED_PATHS else None
|
|
77
|
+
if cached and cached.is_file():
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(cached.read_text(encoding="utf-8"))
|
|
80
|
+
except ValueError:
|
|
81
|
+
pass
|
|
82
|
+
data = self._fetch(url)
|
|
83
|
+
if cached:
|
|
84
|
+
try:
|
|
85
|
+
cached.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
cached.write_text(json.dumps(data), encoding="utf-8")
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
def _fetch(self, url: str) -> dict:
|
|
92
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {self.api_key}", "User-Agent": "hodlvn-sdk"})
|
|
93
|
+
try:
|
|
94
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
95
|
+
return json.loads(r.read().decode())
|
|
96
|
+
except urllib.error.HTTPError as e:
|
|
97
|
+
try:
|
|
98
|
+
d = json.loads(e.read().decode()).get("detail") or {}
|
|
99
|
+
except ValueError:
|
|
100
|
+
d = {}
|
|
101
|
+
raise DataError(d.get("code", f"http_{e.code}") if isinstance(d, dict) else f"http_{e.code}",
|
|
102
|
+
d.get("message", str(e)) if isinstance(d, dict) else str(d)) from None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class MemorySource(Source):
|
|
106
|
+
def __init__(self, routes: dict):
|
|
107
|
+
self.routes = routes
|
|
108
|
+
|
|
109
|
+
def get(self, path: str, **params) -> dict:
|
|
110
|
+
return self.routes[path](**params) if callable(self.routes[path]) else self.routes[path]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Data:
|
|
114
|
+
"""Bộ đệm theo lượt chạy: một mã chỉ tải một lần dù script gọi nhiều lần."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, source: Source):
|
|
117
|
+
self.source = source
|
|
118
|
+
self._bars: dict[tuple, pd.DataFrame] = {}
|
|
119
|
+
self._events: dict[str, list] = {}
|
|
120
|
+
self.exchanges: dict[str, str] = {}
|
|
121
|
+
self.truncated = False
|
|
122
|
+
|
|
123
|
+
def bars(self, symbols, start=None, end=None, adjusted=True) -> dict[str, pd.DataFrame]:
|
|
124
|
+
syms = [symbols] if isinstance(symbols, str) else list(symbols)
|
|
125
|
+
need = [s.upper() for s in syms if (s.upper(), start, end, adjusted) not in self._bars]
|
|
126
|
+
for i in range(0, len(need), 30):
|
|
127
|
+
chunk = need[i:i + 30]
|
|
128
|
+
res = self.source.get("/bars", symbols=",".join(chunk), start=start, end=end, adjusted=str(adjusted).lower())
|
|
129
|
+
self.truncated = self.truncated or bool(res.get("truncated"))
|
|
130
|
+
self.exchanges.update(res.get("exchanges") or {})
|
|
131
|
+
for s in chunk:
|
|
132
|
+
rows = res["bars"].get(s, [])
|
|
133
|
+
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
|
|
134
|
+
df["date"] = pd.to_datetime(df["date"])
|
|
135
|
+
self._bars[(s, start, end, adjusted)] = df.set_index("date").astype(float)
|
|
136
|
+
return {s.upper(): self._bars[(s.upper(), start, end, adjusted)] for s in syms}
|
|
137
|
+
|
|
138
|
+
def events(self, symbols) -> dict[str, list]:
|
|
139
|
+
syms = [s.upper() for s in ([symbols] if isinstance(symbols, str) else symbols)]
|
|
140
|
+
need = [s for s in syms if s not in self._events]
|
|
141
|
+
for i in range(0, len(need), 30):
|
|
142
|
+
chunk = need[i:i + 30]
|
|
143
|
+
res = self.source.get("/events", symbols=",".join(chunk))["events"]
|
|
144
|
+
for s in chunk:
|
|
145
|
+
self._events[s] = res.get(s, [])
|
|
146
|
+
return {s: self._events[s] for s in syms}
|
|
147
|
+
|
|
148
|
+
def universe_history(self, code: str, start) -> dict:
|
|
149
|
+
return self.source.get("/universe/history", code=code, start=str(start)[:10])
|
|
150
|
+
|
|
151
|
+
def fundamentals(self, symbols, fields) -> dict:
|
|
152
|
+
return self.source.get("/fundamentals", symbols=",".join(symbols), fields=",".join(fields))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Backtest CẢ RỔ (tab Danh mục): mỗi kỳ cân bằng, script chọn mã (+ tỷ trọng) từ dữ liệu tới GIÁ ĐÓNG phiên trước, lệnh
|
|
2
|
+
khớp ở giá MỞ ngày cân bằng, giá thật, luật VN (lô 100, biên độ, T+2,5, phí, thuế, cổ tức).
|
|
3
|
+
|
|
4
|
+
`picker(asof_index, members) -> dict[mã, tỷ trọng]` do `script_env` cấp (chạy lại script ở từng kỳ).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from . import rules_vn as R
|
|
11
|
+
from .engine_single import _events_by_date, apply_events
|
|
12
|
+
|
|
13
|
+
FREQ = {"W": "W", "M": "M", "Q": "Q", "Y": "Y"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def rebalance_days(dates: list[pd.Timestamp], freq: str) -> list[int]:
|
|
17
|
+
"""Chỉ số phiên ĐẦU của mỗi tuần / tháng / quý / năm."""
|
|
18
|
+
key = {"W": lambda d: (d.isocalendar()[0], d.isocalendar()[1]), "M": lambda d: (d.year, d.month),
|
|
19
|
+
"Q": lambda d: (d.year, (d.month - 1) // 3), "Y": lambda d: d.year}[freq]
|
|
20
|
+
out, last = [], None
|
|
21
|
+
for i, d in enumerate(dates):
|
|
22
|
+
k = key(d)
|
|
23
|
+
if k != last:
|
|
24
|
+
out.append(i)
|
|
25
|
+
last = k
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def members_at(periods: list[dict], day: str) -> list[str]:
|
|
30
|
+
cur = periods[0]["symbols"] if periods else []
|
|
31
|
+
for p in periods:
|
|
32
|
+
if p["effective_date"] <= day:
|
|
33
|
+
cur = p["symbols"]
|
|
34
|
+
return cur
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_portfolio(real: dict[str, pd.DataFrame], periods: list[dict], picker, freq: str = "M",
|
|
38
|
+
events: dict[str, list] | None = None, capital: float = 100_000_000, fee_pct: float = R.DEFAULT_FEE_PCT,
|
|
39
|
+
exchanges: dict[str, str] | None = None) -> dict:
|
|
40
|
+
cal = sorted(set().union(*[set(df.index) for df in real.values()])) if real else []
|
|
41
|
+
days = [d.strftime("%Y-%m-%d") for d in cal]
|
|
42
|
+
opens = {s: df["open"].reindex(cal).ffill() for s, df in real.items()}
|
|
43
|
+
closes = {s: df["close"].reindex(cal).ffill() for s, df in real.items()}
|
|
44
|
+
ev = {s: _events_by_date(e) for s, e in (events or {}).items()}
|
|
45
|
+
exchanges = exchanges or {}
|
|
46
|
+
reb = set(rebalance_days(cal, FREQ.get(freq, "M")))
|
|
47
|
+
cash = float(capital)
|
|
48
|
+
pos: dict[str, int] = {}
|
|
49
|
+
bought_at: dict[str, int] = {}
|
|
50
|
+
trades: list[dict] = []
|
|
51
|
+
holdings: list[dict] = []
|
|
52
|
+
equity: list[list] = []
|
|
53
|
+
for i, day in enumerate(days):
|
|
54
|
+
for s in list(pos):
|
|
55
|
+
bonus, div = apply_events(day, pos[s], ev.get(s, {}))
|
|
56
|
+
pos[s] += bonus
|
|
57
|
+
cash += div
|
|
58
|
+
if i in reb and i > 0:
|
|
59
|
+
members = [m for m in members_at(periods, day) if m in real]
|
|
60
|
+
target = picker(i - 1, members) or {}
|
|
61
|
+
px = {s: float(opens[s].iloc[i]) for s in set(pos) | set(target) if s in opens and opens[s].iloc[i] == opens[s].iloc[i]}
|
|
62
|
+
ref = {s: float(closes[s].iloc[i - 1]) for s in px}
|
|
63
|
+
# Bán trước (mã rời danh sách hoặc giảm tỷ trọng), rồi mua — không vay tiền.
|
|
64
|
+
value = cash + sum(q * px.get(s, 0) for s, q in pos.items())
|
|
65
|
+
want = {s: int(value * w // (px[s] * (1 + fee_pct / 100)) // R.LOT * R.LOT) for s, w in target.items() if px.get(s)}
|
|
66
|
+
for s in sorted(pos):
|
|
67
|
+
if s not in px:
|
|
68
|
+
continue # không có giá mở hôm nay (tạm ngừng GD) → giữ nguyên tới kỳ sau
|
|
69
|
+
diff = pos[s] - want.get(s, 0)
|
|
70
|
+
if diff > 0 and i - bought_at.get(s, -99) >= R.SETTLEMENT_BARS and not R.at_floor(px[s], ref[s], exchanges.get(s, "HOSE")):
|
|
71
|
+
cash += R.sell_proceeds(diff, px[s], fee_pct)
|
|
72
|
+
pos[s] -= diff
|
|
73
|
+
trades.append({"date": day, "symbol": s, "side": "sell", "qty": diff, "price": px[s]})
|
|
74
|
+
if not pos[s]:
|
|
75
|
+
del pos[s]
|
|
76
|
+
for s in sorted(want):
|
|
77
|
+
diff = want[s] - pos.get(s, 0)
|
|
78
|
+
diff = min(diff, R.lots(cash, px[s], fee_pct))
|
|
79
|
+
if diff > 0 and not R.at_ceiling(px[s], ref[s], exchanges.get(s, "HOSE")):
|
|
80
|
+
cash -= R.buy_cost(diff, px[s], fee_pct)
|
|
81
|
+
pos[s] = pos.get(s, 0) + diff
|
|
82
|
+
bought_at[s] = i
|
|
83
|
+
trades.append({"date": day, "symbol": s, "side": "buy", "qty": diff, "price": px[s]})
|
|
84
|
+
holdings.append({"date": day, "weights": {s: round(w, 4) for s, w in target.items()},
|
|
85
|
+
"held": {s: q for s, q in sorted(pos.items())}})
|
|
86
|
+
mark = cash + sum(q * float(closes[s].iloc[i]) for s, q in pos.items() if closes[s].iloc[i] == closes[s].iloc[i])
|
|
87
|
+
equity.append([day, round(mark)])
|
|
88
|
+
return {"equity": equity, "trades": trades, "holdings": holdings, "capital": capital}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Backtest MỘT mã (tab Chiến lược): tín hiệu tính ở giá đóng phiên t → khớp ở giá MỞ phiên t+1, giá THẬT, luật VN.
|
|
2
|
+
Lệnh mua chỉ hiệu lực phiên kế tiếp (không khớp được — giá trần — thì huỷ); lệnh bán giữ tới khi bán được (T+2,5, sàn).
|
|
3
|
+
|
|
4
|
+
Tín hiệu do script tính trên nến hồi tố (khớp biểu đồ); khớp lệnh + định giá theo giá thật, cổ tức tiền / cổ phiếu cộng đúng
|
|
5
|
+
ngày GDKHQ (`rules_vn`). Mỗi lần mua dùng toàn bộ tiền mặt (bội số lô), bán hết vị thế.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from . import rules_vn as R
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _events_by_date(events: list[dict]) -> dict[str, list[dict]]:
|
|
15
|
+
out: dict[str, list[dict]] = {}
|
|
16
|
+
for e in events:
|
|
17
|
+
out.setdefault(e["date"], []).append(e)
|
|
18
|
+
return out
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def apply_events(day: str, shares: int, events_by_date: dict, tax_pct: float = R.DIVIDEND_TAX_PCT) -> tuple[int, float]:
|
|
22
|
+
"""(cổ phiếu thưởng, cổ tức tiền ròng) cho `shares` đang giữ trước ngày GDKHQ `day`. Quyền mua: không thực hiện."""
|
|
23
|
+
bonus, cash = 0, 0.0
|
|
24
|
+
for e in events_by_date.get(day, []):
|
|
25
|
+
kind = e.get("event_type")
|
|
26
|
+
if kind == "cash_dividend":
|
|
27
|
+
per = e.get("amount") or (e.get("ratio_percent") or 0) * 100
|
|
28
|
+
cash += shares * float(per) * (1 - tax_pct / 100)
|
|
29
|
+
elif kind in ("stock_dividend", "split"):
|
|
30
|
+
rf, rt = int(e.get("ratio_from") or 0), int(e.get("ratio_to") or 0)
|
|
31
|
+
if rf > 0 and rt > 0:
|
|
32
|
+
bonus += shares * rt // rf
|
|
33
|
+
return bonus, cash
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_single(real: pd.DataFrame, buy: pd.Series, sell: pd.Series, events: list[dict], capital: float = 100_000_000,
|
|
37
|
+
fee_pct: float = R.DEFAULT_FEE_PCT, exchange: str = "HOSE") -> dict:
|
|
38
|
+
"""`real`: nến giá thật (index ngày). `buy` / `sell`: tín hiệu bool cùng index (đã căn ngày). Trả equity + lệnh."""
|
|
39
|
+
dates = [d.strftime("%Y-%m-%d") for d in real.index]
|
|
40
|
+
o, c = real["open"].tolist(), real["close"].tolist()
|
|
41
|
+
b = buy.reindex(real.index).fillna(False).astype(bool).tolist()
|
|
42
|
+
s = sell.reindex(real.index).fillna(False).astype(bool).tolist()
|
|
43
|
+
ev = _events_by_date(events)
|
|
44
|
+
cash, shares, cost, entry_i, entry_px = float(capital), 0, 0.0, -1, 0.0
|
|
45
|
+
pending: str | None = None
|
|
46
|
+
trades: list[dict] = []
|
|
47
|
+
equity: list[list] = []
|
|
48
|
+
dividends = 0.0
|
|
49
|
+
for i, day in enumerate(dates):
|
|
50
|
+
if shares:
|
|
51
|
+
bonus, div = apply_events(day, shares, ev)
|
|
52
|
+
shares += bonus
|
|
53
|
+
cash += div
|
|
54
|
+
dividends += div
|
|
55
|
+
ref = c[i - 1] if i else o[i]
|
|
56
|
+
if pending == "buy":
|
|
57
|
+
# Lệnh mua chỉ hiệu lực phiên ngay sau tín hiệu (như ATO): trần / thiếu tiền → huỷ, chờ tín hiệu mới.
|
|
58
|
+
pending = None
|
|
59
|
+
qty = R.lots(cash, o[i], fee_pct) if not shares and not R.at_ceiling(o[i], ref, exchange) else 0
|
|
60
|
+
if qty:
|
|
61
|
+
spent = R.buy_cost(qty, o[i], fee_pct)
|
|
62
|
+
cash -= spent
|
|
63
|
+
shares, cost, entry_i, entry_px = qty, spent, i, o[i]
|
|
64
|
+
elif pending == "sell" and shares and i - entry_i >= R.SETTLEMENT_BARS and not R.at_floor(o[i], ref, exchange):
|
|
65
|
+
got = R.sell_proceeds(shares, o[i], fee_pct)
|
|
66
|
+
cash += got
|
|
67
|
+
trades.append({"entry_date": dates[entry_i], "entry_price": entry_px, "exit_date": day, "exit_price": o[i],
|
|
68
|
+
"qty": shares, "pnl": round(got - cost), "pnl_pct": round((got / cost - 1) * 100, 2),
|
|
69
|
+
"bars": i - entry_i, "open": False})
|
|
70
|
+
shares, cost, pending = 0, 0.0, None
|
|
71
|
+
equity.append([day, round(cash + shares * c[i])])
|
|
72
|
+
if s[i] and shares:
|
|
73
|
+
pending = "sell"
|
|
74
|
+
elif b[i] and not shares:
|
|
75
|
+
pending = "buy"
|
|
76
|
+
if shares:
|
|
77
|
+
mark = R.sell_proceeds(shares, c[-1], fee_pct)
|
|
78
|
+
trades.append({"entry_date": dates[entry_i], "entry_price": entry_px, "exit_date": None, "exit_price": c[-1],
|
|
79
|
+
"qty": shares, "pnl": round(mark - cost), "pnl_pct": round((mark / cost - 1) * 100, 2),
|
|
80
|
+
"bars": len(dates) - 1 - entry_i, "open": True})
|
|
81
|
+
return {"equity": equity, "trades": trades, "dividends_net": round(dividends), "capital": capital}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Chỉ số kết quả chung cho mọi backtest: lãi, lợi suất năm, Sharpe, lỗ sâu nhất, tỷ lệ thắng, ma trận lãi theo tháng."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _series(equity: list[list]) -> pd.Series:
|
|
10
|
+
return pd.Series([v for _, v in equity], index=pd.to_datetime([d for d, _ in equity]), dtype=float)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def summarize(equity: list[list], trades: list[dict], capital: float, benchmark: list[list] | None = None) -> dict:
|
|
14
|
+
if not equity:
|
|
15
|
+
return {}
|
|
16
|
+
eq = _series(equity)
|
|
17
|
+
ret = eq.iloc[-1] / capital - 1
|
|
18
|
+
years = max((eq.index[-1] - eq.index[0]).days / 365.25, 1 / 365.25)
|
|
19
|
+
daily = eq.pct_change(fill_method=None).dropna()
|
|
20
|
+
sd = daily.std()
|
|
21
|
+
peak = eq.cummax()
|
|
22
|
+
dd = (eq / peak - 1).min()
|
|
23
|
+
closed = [t for t in trades if not t.get("open")]
|
|
24
|
+
wins = [t for t in closed if t["pnl"] > 0]
|
|
25
|
+
ret = float(ret)
|
|
26
|
+
out = {
|
|
27
|
+
"return_pct": round(ret * 100, 2),
|
|
28
|
+
"cagr_pct": round(((1 + ret) ** (1 / years) - 1) * 100, 2) if ret > -1 else -100.0,
|
|
29
|
+
"sharpe": round(float(daily.mean() / sd) * math.sqrt(250), 2) if sd and not math.isnan(sd) else None,
|
|
30
|
+
"max_drawdown_pct": round(float(dd) * 100, 2),
|
|
31
|
+
"trades": len(trades),
|
|
32
|
+
"win_rate_pct": round(len(wins) / len(closed) * 100, 1) if closed else None,
|
|
33
|
+
"avg_trade_pct": round(sum(t["pnl_pct"] for t in closed) / len(closed), 2) if closed else None,
|
|
34
|
+
"final_value": round(float(eq.iloc[-1])),
|
|
35
|
+
"start": equity[0][0],
|
|
36
|
+
"end": equity[-1][0],
|
|
37
|
+
}
|
|
38
|
+
if benchmark:
|
|
39
|
+
bm = _series(benchmark)
|
|
40
|
+
out["benchmark_return_pct"] = round(float(bm.iloc[-1] / bm.iloc[0] - 1) * 100, 2)
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def monthly(equity: list[list]) -> dict[str, dict[str, float]]:
|
|
45
|
+
"""{năm: {tháng: % lãi}} — lưới 'Theo tháng'."""
|
|
46
|
+
eq = _series(equity)
|
|
47
|
+
if eq.empty:
|
|
48
|
+
return {}
|
|
49
|
+
last = eq.groupby([eq.index.year, eq.index.month]).last()
|
|
50
|
+
prev = last.shift().fillna(eq.iloc[0])
|
|
51
|
+
pct = (last / prev - 1) * 100
|
|
52
|
+
out: dict[str, dict[str, float]] = {}
|
|
53
|
+
for (y, m), v in pct.items():
|
|
54
|
+
out.setdefault(str(y), {})[str(m)] = round(float(v), 2)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def drawdown(equity: list[list]) -> list[list]:
|
|
59
|
+
eq = _series(equity)
|
|
60
|
+
dd = (eq / eq.cummax() - 1) * 100
|
|
61
|
+
return [[d.strftime("%Y-%m-%d"), round(float(v), 2)] for d, v in dd.items()]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Hàm dùng trong script Danh mục, tính tại MỘT kỳ cân bằng (dữ liệu tới giá đóng phiên trước — không nhìn trước tương lai).
|
|
2
|
+
|
|
3
|
+
`momentum(63)`, `pe()`, `fundamental("roe")`… trả `pandas.Series` theo mã của rổ tại kỳ đó; `top / bottom` chọn mã;
|
|
4
|
+
`pick(...)` ghi danh sách (chia đều) hoặc dict mã → tỷ trọng.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PortfolioCtx:
|
|
14
|
+
def __init__(self, adj_close: pd.DataFrame, real_close: pd.DataFrame, fundamentals: dict[str, list[dict]]):
|
|
15
|
+
self.adj_close, self.real_close, self.fundamentals = adj_close, real_close, fundamentals
|
|
16
|
+
self.asof: pd.Timestamp | None = None
|
|
17
|
+
self.members: list[str] = []
|
|
18
|
+
self.picked: dict[str, float] | None = None
|
|
19
|
+
self.scores: dict[str, float] = {}
|
|
20
|
+
|
|
21
|
+
def at(self, asof: pd.Timestamp | None, members: list[str]) -> "PortfolioCtx":
|
|
22
|
+
self.asof, self.members, self.picked, self.scores = asof, members, None, {}
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def _hist(self, frame: pd.DataFrame) -> pd.DataFrame:
|
|
26
|
+
if self.asof is None or frame.empty:
|
|
27
|
+
return pd.DataFrame(columns=self.members)
|
|
28
|
+
cols = [m for m in self.members if m in frame.columns]
|
|
29
|
+
return frame.loc[:self.asof, cols]
|
|
30
|
+
|
|
31
|
+
# ── dữ liệu theo rổ ──
|
|
32
|
+
def close(self) -> pd.DataFrame:
|
|
33
|
+
"""Bảng giá đóng cửa (hồi tố) của cả rổ tới phiên trước kỳ cân bằng: cột = mã, hàng = phiên."""
|
|
34
|
+
return self._hist(self.adj_close)
|
|
35
|
+
|
|
36
|
+
def basket(self) -> list[str]:
|
|
37
|
+
"""Danh sách mã của rổ TẠI kỳ cân bằng đang xét (rổ theo thời điểm)."""
|
|
38
|
+
return list(self.members)
|
|
39
|
+
|
|
40
|
+
def momentum(self, n: int = 63) -> pd.Series:
|
|
41
|
+
"""Lãi / lỗ n phiên gần nhất của từng mã (0,12 = +12%). 21 ≈ 1 tháng, 63 ≈ 3 tháng, 252 ≈ 1 năm."""
|
|
42
|
+
h = self._hist(self.adj_close)
|
|
43
|
+
if len(h) <= n:
|
|
44
|
+
return pd.Series(dtype=float)
|
|
45
|
+
return (h.iloc[-1] / h.iloc[-1 - int(n)] - 1).dropna()
|
|
46
|
+
|
|
47
|
+
def volatility(self, n: int = 63) -> pd.Series:
|
|
48
|
+
"""Độ biến động năm hoá (độ lệch chuẩn lãi ngày × √250) của n phiên gần nhất, từng mã."""
|
|
49
|
+
h = self._hist(self.adj_close)
|
|
50
|
+
return h.pct_change(fill_method=None).iloc[-int(n):].std().dropna() * math.sqrt(250)
|
|
51
|
+
|
|
52
|
+
def fundamental(self, field: str) -> pd.Series:
|
|
53
|
+
"""Chỉ số cơ bản kỳ gần nhất ĐÃ CÔNG BỐ tới ngày xét: "roe", "roa", "eps", "bvps", "net_profit", "revenue", "debt_to_equity"."""
|
|
54
|
+
day = self.asof.strftime("%Y-%m-%d") if self.asof is not None else ""
|
|
55
|
+
out = {}
|
|
56
|
+
for m in self.members:
|
|
57
|
+
rows = [r for r in self.fundamentals.get(m, []) if r["available_from"] <= day and r.get(field) is not None]
|
|
58
|
+
if rows:
|
|
59
|
+
out[m] = rows[-1][field]
|
|
60
|
+
return pd.Series(out, dtype=float)
|
|
61
|
+
|
|
62
|
+
def _eps_ttm(self) -> pd.Series:
|
|
63
|
+
day = self.asof.strftime("%Y-%m-%d") if self.asof is not None else ""
|
|
64
|
+
out = {}
|
|
65
|
+
for m in self.members:
|
|
66
|
+
q = [r for r in self.fundamentals.get(m, []) if "Q" in r["period"] and r["available_from"] <= day and r.get("eps") is not None]
|
|
67
|
+
if len(q) >= 4:
|
|
68
|
+
out[m] = sum(r["eps"] for r in q[-4:])
|
|
69
|
+
return pd.Series(out, dtype=float)
|
|
70
|
+
|
|
71
|
+
def _price(self) -> pd.Series:
|
|
72
|
+
h = self._hist(self.real_close)
|
|
73
|
+
return h.iloc[-1].dropna() if len(h) else pd.Series(dtype=float)
|
|
74
|
+
|
|
75
|
+
def pe(self) -> pd.Series:
|
|
76
|
+
"""P/E: giá thật chia EPS 4 quý gần nhất đã công bố. Bỏ mã lỗ (EPS ≤ 0)."""
|
|
77
|
+
eps = self._eps_ttm()
|
|
78
|
+
eps = eps[eps > 0]
|
|
79
|
+
return (self._price() / eps).dropna()
|
|
80
|
+
|
|
81
|
+
def pb(self) -> pd.Series:
|
|
82
|
+
"""P/B: giá thật chia giá trị sổ sách / cp kỳ gần nhất đã công bố."""
|
|
83
|
+
bv = self.fundamental("bvps")
|
|
84
|
+
bv = bv[bv > 0]
|
|
85
|
+
return (self._price() / bv).dropna()
|
|
86
|
+
|
|
87
|
+
# ── chọn ── (điểm xếp hạng của mã được chọn ghi lại để bảng "Nắm giữ" hiện cạnh tỷ trọng)
|
|
88
|
+
def _ranked(self, series: pd.Series, n: int, ascending: bool) -> list[str]:
|
|
89
|
+
if not len(series):
|
|
90
|
+
return []
|
|
91
|
+
chosen = series.sort_values(ascending=ascending).head(int(n))
|
|
92
|
+
self.scores = {str(k): round(float(v), 4) for k, v in chosen.items()}
|
|
93
|
+
return list(chosen.index)
|
|
94
|
+
|
|
95
|
+
def top(self, series: pd.Series, n: int = 3) -> list[str]:
|
|
96
|
+
"""n mã có giá trị CAO nhất của chuỗi xếp hạng (động lượng, ROE…)."""
|
|
97
|
+
return self._ranked(series, n, False)
|
|
98
|
+
|
|
99
|
+
def bottom(self, series: pd.Series, n: int = 3) -> list[str]:
|
|
100
|
+
"""n mã có giá trị THẤP nhất (P/E, P/B, biến động…)."""
|
|
101
|
+
return self._ranked(series, n, True)
|
|
102
|
+
|
|
103
|
+
def pick(self, chosen) -> None:
|
|
104
|
+
"""Chốt danh mục kỳ này: danh sách mã (chia đều) hoặc dict mã → tỷ trọng (tự chuẩn hoá về 100%)."""
|
|
105
|
+
if isinstance(chosen, dict):
|
|
106
|
+
w = {k: float(v) for k, v in chosen.items() if float(v) > 0}
|
|
107
|
+
elif isinstance(chosen, pd.Series):
|
|
108
|
+
w = {k: 1.0 for k in chosen.index}
|
|
109
|
+
else:
|
|
110
|
+
w = {k: 1.0 for k in (chosen or [])}
|
|
111
|
+
total = sum(w.values())
|
|
112
|
+
self.picked = {k: v / total for k, v in w.items()} if total else {}
|
|
113
|
+
|
|
114
|
+
def exports(self) -> dict:
|
|
115
|
+
return {"momentum": self.momentum, "volatility": self.volatility, "fundamental": self.fundamental, "pe": self.pe,
|
|
116
|
+
"pb": self.pb, "top": self.top, "bottom": self.bottom, "pick": self.pick, "members": self.basket,
|
|
117
|
+
"prices": self.close}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Luật thị trường cổ phiếu Việt Nam áp trong mọi backtest của Lab — người dùng KHÔNG phải tự viết.
|
|
2
|
+
|
|
3
|
+
- Lô 100; KL làm tròn xuống theo lô, tiền lẻ giữ lại.
|
|
4
|
+
- Biên độ trần / sàn theo sàn (HOSE 7%, HNX 10%, UPCoM 15%): giá mở chạm trần thì không mua được, chạm sàn thì không bán
|
|
5
|
+
được (không ai bán / mua đối ứng — giả định bảo thủ).
|
|
6
|
+
- T+2,5: mua phiên T, khớp ở giá MỞ phiên sau tín hiệu → bán sớm nhất ở giá mở phiên T+3.
|
|
7
|
+
- Phí mua / bán theo % giá trị (mặc định 0,15%), thuế bán 0,1%, thuế cổ tức tiền 5%.
|
|
8
|
+
- Bước giá HOSE: <10.000 → 10đ, <50.000 → 50đ, còn lại 100đ (HNX/UPCoM 100đ).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
LOT = 100
|
|
13
|
+
PRICE_LIMIT = {"HOSE": 0.07, "HNX": 0.10, "UPCOM": 0.15}
|
|
14
|
+
SETTLEMENT_BARS = 3
|
|
15
|
+
DEFAULT_FEE_PCT = 0.15
|
|
16
|
+
SELL_TAX_PCT = 0.1
|
|
17
|
+
DIVIDEND_TAX_PCT = 5.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def tick(price: float, exchange: str = "HOSE") -> int:
|
|
21
|
+
if exchange != "HOSE":
|
|
22
|
+
return 100
|
|
23
|
+
return 10 if price < 10_000 else 50 if price < 50_000 else 100
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def round_tick(price: float, exchange: str = "HOSE") -> int:
|
|
27
|
+
t = tick(price, exchange)
|
|
28
|
+
return int(round(price / t)) * t
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def limit_band(ref: float, exchange: str = "HOSE") -> tuple[int, int]:
|
|
32
|
+
"""(sàn, trần) từ giá tham chiếu (giá đóng phiên trước); làm tròn vào trong theo bước giá như sở."""
|
|
33
|
+
pct = PRICE_LIMIT.get(exchange.upper(), 0.07)
|
|
34
|
+
hi, lo = ref * (1 + pct), ref * (1 - pct)
|
|
35
|
+
t_hi, t_lo = tick(hi, exchange), tick(lo, exchange)
|
|
36
|
+
return int(-(-lo // t_lo) * t_lo), int(hi // t_hi * t_hi)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def at_ceiling(price: float, ref: float, exchange: str = "HOSE") -> bool:
|
|
40
|
+
return ref > 0 and price >= limit_band(ref, exchange)[1]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def at_floor(price: float, ref: float, exchange: str = "HOSE") -> bool:
|
|
44
|
+
return ref > 0 and price <= limit_band(ref, exchange)[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def lots(cash: float, price: float, fee_pct: float) -> int:
|
|
48
|
+
"""Số cổ phiếu mua được (bội số lô) với `cash`, đã tính phí mua."""
|
|
49
|
+
if price <= 0:
|
|
50
|
+
return 0
|
|
51
|
+
n = int(cash // (price * (1 + fee_pct / 100)))
|
|
52
|
+
return n // LOT * LOT
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def buy_cost(qty: int, price: float, fee_pct: float) -> float:
|
|
56
|
+
return qty * price * (1 + fee_pct / 100)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sell_proceeds(qty: int, price: float, fee_pct: float) -> float:
|
|
60
|
+
return qty * price * (1 - fee_pct / 100 - SELL_TAX_PCT / 100)
|