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 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)