bbterm-tui 0.1.1__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.
- bbterm/__init__.py +6 -0
- bbterm/commands.py +82 -0
- bbterm/config.py +37 -0
- bbterm/data/__init__.py +33 -0
- bbterm/data/fundamentals.py +111 -0
- bbterm/data/models.py +63 -0
- bbterm/data/providers/__init__.py +0 -0
- bbterm/data/providers/base.py +41 -0
- bbterm/data/providers/databento_.py +54 -0
- bbterm/data/providers/edgar.py +57 -0
- bbterm/data/providers/yfinance_.py +46 -0
- bbterm/data/service.py +102 -0
- bbterm/data/stats.py +57 -0
- bbterm/data/store.py +131 -0
- bbterm/sync.py +37 -0
- bbterm/tui/__init__.py +0 -0
- bbterm/tui/app.py +271 -0
- bbterm/tui/widgets/__init__.py +0 -0
- bbterm/tui/widgets/chart.py +112 -0
- bbterm/tui/widgets/command_bar.py +30 -0
- bbterm/tui/widgets/filings.py +36 -0
- bbterm/tui/widgets/fundamentals.py +70 -0
- bbterm/tui/widgets/stats.py +66 -0
- bbterm/tui/widgets/strip.py +30 -0
- bbterm/tui/widgets/watchlist.py +76 -0
- bbterm_tui-0.1.1.dist-info/METADATA +169 -0
- bbterm_tui-0.1.1.dist-info/RECORD +31 -0
- bbterm_tui-0.1.1.dist-info/WHEEL +5 -0
- bbterm_tui-0.1.1.dist-info/entry_points.txt +3 -0
- bbterm_tui-0.1.1.dist-info/licenses/LICENSE +661 -0
- bbterm_tui-0.1.1.dist-info/top_level.txt +1 -0
bbterm/__init__.py
ADDED
bbterm/commands.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
_SYMBOL_RE = re.compile(r"^[A-Z0-9]{1,6}([.\-][A-Z0-9]{1,4})?$")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class LoadSymbol:
|
|
11
|
+
symbol: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AddSymbol:
|
|
16
|
+
symbol: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RemoveSymbol:
|
|
21
|
+
symbol: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ShowChart:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ShowStats:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ShowFundamentals:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ShowFilings:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Help:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Unknown:
|
|
51
|
+
text: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_symbol(token: str) -> bool:
|
|
55
|
+
return bool(_SYMBOL_RE.match(token))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_command(text: str):
|
|
59
|
+
cleaned = " ".join(text.strip().split())
|
|
60
|
+
if not cleaned:
|
|
61
|
+
return Unknown(text)
|
|
62
|
+
parts = cleaned.split(" ")
|
|
63
|
+
verb = parts[0].upper()
|
|
64
|
+
arg = parts[1].upper() if len(parts) > 1 else None
|
|
65
|
+
|
|
66
|
+
if verb in ("ADD",):
|
|
67
|
+
return AddSymbol(arg) if arg and _is_symbol(arg) else Unknown(text)
|
|
68
|
+
if verb in ("DEL", "REMOVE"):
|
|
69
|
+
return RemoveSymbol(arg) if arg and _is_symbol(arg) else Unknown(text)
|
|
70
|
+
if verb == "GP":
|
|
71
|
+
return ShowChart()
|
|
72
|
+
if verb == "DES":
|
|
73
|
+
return ShowStats()
|
|
74
|
+
if verb == "FA":
|
|
75
|
+
return ShowFundamentals()
|
|
76
|
+
if verb == "FIL":
|
|
77
|
+
return ShowFilings()
|
|
78
|
+
if verb in ("?", "HELP"):
|
|
79
|
+
return Help()
|
|
80
|
+
if arg is None and _is_symbol(verb):
|
|
81
|
+
return LoadSymbol(verb)
|
|
82
|
+
return Unknown(text)
|
bbterm/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Config:
|
|
10
|
+
databento_api_key: str | None
|
|
11
|
+
db_path: Path
|
|
12
|
+
cost_cap_usd: float
|
|
13
|
+
databento_dataset: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_dotenv(path: Path) -> dict[str, str]:
|
|
17
|
+
if not path.exists():
|
|
18
|
+
return {}
|
|
19
|
+
values: dict[str, str] = {}
|
|
20
|
+
for line in path.read_text().splitlines():
|
|
21
|
+
line = line.strip()
|
|
22
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
23
|
+
continue
|
|
24
|
+
key, _, value = line.partition("=")
|
|
25
|
+
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
26
|
+
return values
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config(root: Path | None = None) -> Config:
|
|
30
|
+
root = root or Path.cwd()
|
|
31
|
+
env = {**_load_dotenv(root / ".env"), **os.environ}
|
|
32
|
+
return Config(
|
|
33
|
+
databento_api_key=env.get("DATABENTO_API_KEY"),
|
|
34
|
+
db_path=Path(env.get("BBTERM_DB_PATH", str(root / "data" / "market.duckdb"))),
|
|
35
|
+
cost_cap_usd=float(env.get("BBTERM_COST_CAP_USD", "1.0")),
|
|
36
|
+
databento_dataset=env.get("BBTERM_DATASET", "EQUS.MINI"),
|
|
37
|
+
)
|
bbterm/data/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from bbterm.config import Config
|
|
6
|
+
from bbterm.data.providers.edgar import EdgarProvider
|
|
7
|
+
from bbterm.data.providers.yfinance_ import YFinanceProvider
|
|
8
|
+
from bbterm.data.service import DataService
|
|
9
|
+
from bbterm.data.store import Store
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_service(config: Config) -> DataService:
|
|
13
|
+
store = Store(config.db_path)
|
|
14
|
+
yf_provider = YFinanceProvider()
|
|
15
|
+
edgar = EdgarProvider()
|
|
16
|
+
if config.databento_api_key:
|
|
17
|
+
try:
|
|
18
|
+
from bbterm.data.providers.databento_ import DatabentoProvider
|
|
19
|
+
except ImportError:
|
|
20
|
+
print(
|
|
21
|
+
"DATABENTO_API_KEY is set but the 'databento' package is not "
|
|
22
|
+
"installed. Run: pip install 'bbterm[databento]'. "
|
|
23
|
+
"Falling back to yfinance.",
|
|
24
|
+
file=sys.stderr,
|
|
25
|
+
)
|
|
26
|
+
else:
|
|
27
|
+
bars = DatabentoProvider(
|
|
28
|
+
api_key=config.databento_api_key,
|
|
29
|
+
dataset=config.databento_dataset,
|
|
30
|
+
cost_cap_usd=config.cost_cap_usd,
|
|
31
|
+
)
|
|
32
|
+
return DataService(store, bars, yf_provider, edgar_provider=edgar)
|
|
33
|
+
return DataService(store, yf_provider, yf_provider, edgar_provider=edgar)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
from bbterm.data.models import Filing, FundamentalMetric
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class MetricSpec:
|
|
11
|
+
label: str
|
|
12
|
+
concepts: list[str] # candidate XBRL concept names, first match wins
|
|
13
|
+
unit: str # units key: "USD" | "USD/shares" | "shares"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
METRIC_SPECS: list[MetricSpec] = [
|
|
17
|
+
MetricSpec("Revenue",
|
|
18
|
+
["RevenueFromContractWithCustomerExcludingAssessedTax",
|
|
19
|
+
"Revenues", "SalesRevenueNet"], "USD"),
|
|
20
|
+
MetricSpec("Net Income", ["NetIncomeLoss"], "USD"),
|
|
21
|
+
MetricSpec("EPS (diluted)", ["EarningsPerShareDiluted"], "USD/shares"),
|
|
22
|
+
MetricSpec("Gross Profit", ["GrossProfit"], "USD"),
|
|
23
|
+
MetricSpec("Total Assets", ["Assets"], "USD"),
|
|
24
|
+
MetricSpec("Total Liabilities", ["Liabilities"], "USD"),
|
|
25
|
+
MetricSpec("Stockholders' Equity",
|
|
26
|
+
["StockholdersEquity",
|
|
27
|
+
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"],
|
|
28
|
+
"USD"),
|
|
29
|
+
MetricSpec("Operating Cash Flow",
|
|
30
|
+
["NetCashProvidedByUsedInOperatingActivities"], "USD"),
|
|
31
|
+
MetricSpec("Shares Outstanding",
|
|
32
|
+
["CommonStockSharesOutstanding",
|
|
33
|
+
"EntityCommonStockSharesOutstanding"], "shares"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _find_unit_series(facts_json: dict, concept: str, unit: str) -> list[dict] | None:
|
|
38
|
+
"""Return the datapoint list for concept+unit, searching us-gaap then dei."""
|
|
39
|
+
facts = facts_json.get("facts", {})
|
|
40
|
+
for taxonomy in ("us-gaap", "dei"):
|
|
41
|
+
node = facts.get(taxonomy, {}).get(concept)
|
|
42
|
+
if node:
|
|
43
|
+
series = node.get("units", {}).get(unit)
|
|
44
|
+
if series:
|
|
45
|
+
return series
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _annual(series: list[dict]) -> list[dict]:
|
|
50
|
+
return [d for d in series if d.get("fp") == "FY" and "end" in d and "val" in d]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_one(facts_json: dict, spec: MetricSpec) -> FundamentalMetric | None:
|
|
54
|
+
for concept in spec.concepts:
|
|
55
|
+
series = _find_unit_series(facts_json, concept, spec.unit)
|
|
56
|
+
if not series:
|
|
57
|
+
continue
|
|
58
|
+
annual = _annual(series)
|
|
59
|
+
if not annual:
|
|
60
|
+
continue
|
|
61
|
+
latest = max(annual, key=lambda d: (d["end"], d.get("fy", 0)))
|
|
62
|
+
prior = [d for d in annual if d.get("fy") == latest.get("fy", 0) - 1]
|
|
63
|
+
yoy = None
|
|
64
|
+
if prior:
|
|
65
|
+
prior_val = max(prior, key=lambda d: d["end"])["val"]
|
|
66
|
+
if prior_val:
|
|
67
|
+
yoy = (latest["val"] - prior_val) / abs(prior_val) * 100
|
|
68
|
+
return FundamentalMetric(
|
|
69
|
+
label=spec.label,
|
|
70
|
+
value=float(latest["val"]),
|
|
71
|
+
unit=spec.unit,
|
|
72
|
+
period_end=date.fromisoformat(latest["end"]),
|
|
73
|
+
fy=int(latest.get("fy", 0)),
|
|
74
|
+
fp="FY",
|
|
75
|
+
yoy_pct=yoy,
|
|
76
|
+
)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extract_fundamentals(facts_json: dict) -> list[FundamentalMetric]:
|
|
81
|
+
out = []
|
|
82
|
+
for spec in METRIC_SPECS:
|
|
83
|
+
metric = _extract_one(facts_json, spec)
|
|
84
|
+
if metric is not None:
|
|
85
|
+
out.append(metric)
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_filings(submissions_json: dict, limit: int = 20) -> list[Filing]:
|
|
90
|
+
cik = int(submissions_json.get("cik", 0))
|
|
91
|
+
recent = submissions_json.get("filings", {}).get("recent", {})
|
|
92
|
+
forms = recent.get("form", [])
|
|
93
|
+
dates = recent.get("filingDate", [])
|
|
94
|
+
periods = recent.get("reportDate", [])
|
|
95
|
+
accns = recent.get("accessionNumber", [])
|
|
96
|
+
out: list[Filing] = []
|
|
97
|
+
for i in range(min(limit, len(forms))):
|
|
98
|
+
acc = accns[i]
|
|
99
|
+
acc_nodash = acc.replace("-", "")
|
|
100
|
+
url = (
|
|
101
|
+
f"https://www.sec.gov/Archives/edgar/data/{cik}/"
|
|
102
|
+
f"{acc_nodash}/{acc}-index.htm"
|
|
103
|
+
)
|
|
104
|
+
out.append(Filing(
|
|
105
|
+
form=forms[i],
|
|
106
|
+
filed_date=date.fromisoformat(dates[i]),
|
|
107
|
+
period=periods[i] if i < len(periods) else "",
|
|
108
|
+
accession=acc,
|
|
109
|
+
url=url,
|
|
110
|
+
))
|
|
111
|
+
return out
|
bbterm/data/models.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Bar:
|
|
9
|
+
symbol: str
|
|
10
|
+
interval: str # "1d" or "1m"
|
|
11
|
+
ts: datetime
|
|
12
|
+
open: float
|
|
13
|
+
high: float
|
|
14
|
+
low: float
|
|
15
|
+
close: float
|
|
16
|
+
volume: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Quote:
|
|
21
|
+
symbol: str
|
|
22
|
+
price: float
|
|
23
|
+
prev_close: float
|
|
24
|
+
name: str = ""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def change(self) -> float:
|
|
28
|
+
return self.price - self.prev_close
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def pct_change(self) -> float:
|
|
32
|
+
if not self.prev_close:
|
|
33
|
+
return 0.0
|
|
34
|
+
return self.change / self.prev_close * 100
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_up(self) -> bool:
|
|
38
|
+
return self.change >= 0
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def change_str(self) -> str:
|
|
42
|
+
sign = "+" if self.change >= 0 else ""
|
|
43
|
+
return f"{sign}{self.change:.2f} ({sign}{self.pct_change:.2f}%)"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class FundamentalMetric:
|
|
48
|
+
label: str
|
|
49
|
+
value: float
|
|
50
|
+
unit: str
|
|
51
|
+
period_end: date
|
|
52
|
+
fy: int
|
|
53
|
+
fp: str
|
|
54
|
+
yoy_pct: float | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Filing:
|
|
59
|
+
form: str
|
|
60
|
+
filed_date: date
|
|
61
|
+
period: str
|
|
62
|
+
accession: str
|
|
63
|
+
url: str
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from bbterm.data.models import Bar, Quote
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CostCapExceeded(Exception):
|
|
10
|
+
def __init__(self, estimated_usd: float, cap_usd: float) -> None:
|
|
11
|
+
self.estimated_usd = estimated_usd
|
|
12
|
+
self.cap_usd = cap_usd
|
|
13
|
+
super().__init__(
|
|
14
|
+
f"estimated cost ${estimated_usd:.4f} exceeds cap ${cap_usd:.2f}"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BarProvider(Protocol):
|
|
19
|
+
name: str
|
|
20
|
+
|
|
21
|
+
def get_bars(
|
|
22
|
+
self, symbol: str, interval: str, start: datetime, end: datetime
|
|
23
|
+
) -> list[Bar]: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QuoteProvider(Protocol):
|
|
27
|
+
name: str
|
|
28
|
+
|
|
29
|
+
def get_quote(self, symbol: str) -> Quote | None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FundamentalsProvider(Protocol):
|
|
33
|
+
name: str
|
|
34
|
+
|
|
35
|
+
def get_facts(self, symbol: str) -> dict: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FilingsProvider(Protocol):
|
|
39
|
+
name: str
|
|
40
|
+
|
|
41
|
+
def get_submissions(self, symbol: str) -> dict: ...
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import databento as db
|
|
6
|
+
|
|
7
|
+
from bbterm.data.models import Bar
|
|
8
|
+
from bbterm.data.providers.base import CostCapExceeded
|
|
9
|
+
|
|
10
|
+
_SCHEMA = {"1d": "ohlcv-1d", "1m": "ohlcv-1m"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatabentoProvider:
|
|
14
|
+
name = "databento"
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: str,
|
|
19
|
+
dataset: str,
|
|
20
|
+
cost_cap_usd: float,
|
|
21
|
+
client: db.Historical | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._client = client or db.Historical(api_key)
|
|
24
|
+
self._dataset = dataset
|
|
25
|
+
self._cap = cost_cap_usd
|
|
26
|
+
|
|
27
|
+
def get_bars(
|
|
28
|
+
self, symbol: str, interval: str, start: datetime, end: datetime
|
|
29
|
+
) -> list[Bar]:
|
|
30
|
+
schema = _SCHEMA[interval]
|
|
31
|
+
cost = float(
|
|
32
|
+
self._client.metadata.get_cost(
|
|
33
|
+
dataset=self._dataset, symbols=[symbol], schema=schema,
|
|
34
|
+
start=start, end=end,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
if cost > self._cap:
|
|
38
|
+
raise CostCapExceeded(cost, self._cap)
|
|
39
|
+
result = self._client.timeseries.get_range(
|
|
40
|
+
dataset=self._dataset, symbols=[symbol], schema=schema,
|
|
41
|
+
start=start, end=end,
|
|
42
|
+
)
|
|
43
|
+
df = result.to_df()
|
|
44
|
+
bars: list[Bar] = []
|
|
45
|
+
for ts, row in df.iterrows():
|
|
46
|
+
naive = ts.to_pydatetime().replace(tzinfo=None)
|
|
47
|
+
bars.append(
|
|
48
|
+
Bar(
|
|
49
|
+
symbol, interval, naive,
|
|
50
|
+
float(row["open"]), float(row["high"]),
|
|
51
|
+
float(row["low"]), float(row["close"]), int(row["volume"]),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return bars
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
_USER_AGENT = "bbterm/0.1 (yagurootajum@gmail.com)"
|
|
8
|
+
_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"
|
|
9
|
+
_FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
|
|
10
|
+
_SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik}.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _http_get(url: str, user_agent: str) -> bytes:
|
|
14
|
+
req = urllib.request.Request(url, headers={"User-Agent": user_agent})
|
|
15
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
16
|
+
return resp.read()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EdgarProvider:
|
|
20
|
+
name = "edgar"
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
user_agent: str = _USER_AGENT,
|
|
25
|
+
*,
|
|
26
|
+
opener=None,
|
|
27
|
+
rate_limit_sleep: float = 0.0,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._ua = user_agent
|
|
30
|
+
self._open = opener or _http_get
|
|
31
|
+
self._sleep = rate_limit_sleep
|
|
32
|
+
self._cik_map: dict[str, str] | None = None
|
|
33
|
+
|
|
34
|
+
def _fetch(self, url: str) -> bytes:
|
|
35
|
+
if self._sleep:
|
|
36
|
+
time.sleep(self._sleep)
|
|
37
|
+
return self._open(url, self._ua)
|
|
38
|
+
|
|
39
|
+
def _load_cik_map(self) -> dict[str, str]:
|
|
40
|
+
raw = json.loads(self._fetch(_TICKERS_URL))
|
|
41
|
+
out: dict[str, str] = {}
|
|
42
|
+
for row in raw.values():
|
|
43
|
+
out[str(row["ticker"]).upper()] = f"{int(row['cik_str']):010d}"
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
def _cik(self, symbol: str) -> str:
|
|
47
|
+
if self._cik_map is None:
|
|
48
|
+
self._cik_map = self._load_cik_map()
|
|
49
|
+
return self._cik_map[symbol.upper()]
|
|
50
|
+
|
|
51
|
+
def get_facts(self, symbol: str) -> dict:
|
|
52
|
+
url = _FACTS_URL.format(cik=self._cik(symbol))
|
|
53
|
+
return json.loads(self._fetch(url))
|
|
54
|
+
|
|
55
|
+
def get_submissions(self, symbol: str) -> dict:
|
|
56
|
+
url = _SUBMISSIONS_URL.format(cik=self._cik(symbol))
|
|
57
|
+
return json.loads(self._fetch(url))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import yfinance as yf
|
|
6
|
+
|
|
7
|
+
from bbterm.data.models import Bar, Quote
|
|
8
|
+
|
|
9
|
+
_YF_INTERVAL = {"1d": "1d", "1m": "1m"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class YFinanceProvider:
|
|
13
|
+
"""Dev/fallback provider. Unofficial Yahoo data — not for commercial use."""
|
|
14
|
+
|
|
15
|
+
name = "yfinance"
|
|
16
|
+
|
|
17
|
+
def get_bars(
|
|
18
|
+
self, symbol: str, interval: str, start: datetime, end: datetime
|
|
19
|
+
) -> list[Bar]:
|
|
20
|
+
try:
|
|
21
|
+
df = yf.Ticker(symbol).history(
|
|
22
|
+
start=start, end=end, interval=_YF_INTERVAL[interval]
|
|
23
|
+
)
|
|
24
|
+
except Exception:
|
|
25
|
+
return []
|
|
26
|
+
bars: list[Bar] = []
|
|
27
|
+
for ts, row in df.iterrows():
|
|
28
|
+
naive = ts.to_pydatetime().replace(tzinfo=None)
|
|
29
|
+
bars.append(
|
|
30
|
+
Bar(
|
|
31
|
+
symbol, interval, naive,
|
|
32
|
+
float(row["Open"]), float(row["High"]),
|
|
33
|
+
float(row["Low"]), float(row["Close"]), int(row["Volume"]),
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return bars
|
|
37
|
+
|
|
38
|
+
def get_quote(self, symbol: str) -> Quote | None:
|
|
39
|
+
try:
|
|
40
|
+
fast = yf.Ticker(symbol).fast_info
|
|
41
|
+
price, prev = fast.last_price, fast.previous_close
|
|
42
|
+
if price is None or prev is None:
|
|
43
|
+
return None
|
|
44
|
+
return Quote(symbol=symbol, price=float(price), prev_close=float(prev))
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
bbterm/data/service.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from bbterm.data.fundamentals import extract_fundamentals, parse_filings
|
|
9
|
+
from bbterm.data.models import Bar, Filing, FundamentalMetric, Quote
|
|
10
|
+
from bbterm.data.providers.base import BarProvider, QuoteProvider
|
|
11
|
+
from bbterm.data.store import Store
|
|
12
|
+
|
|
13
|
+
FETCH_TTL_SECONDS = 300.0
|
|
14
|
+
EDGAR_TTL_SECONDS = 86400.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _step(interval: str) -> timedelta:
|
|
18
|
+
return timedelta(days=1) if interval == "1d" else timedelta(minutes=1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DataService:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
store: Store,
|
|
25
|
+
bar_provider: BarProvider,
|
|
26
|
+
quote_provider: QuoteProvider,
|
|
27
|
+
fetch_ttl: float = FETCH_TTL_SECONDS,
|
|
28
|
+
edgar_provider=None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.store = store
|
|
31
|
+
self._bars = bar_provider
|
|
32
|
+
self._quotes = quote_provider
|
|
33
|
+
self._ttl = fetch_ttl
|
|
34
|
+
self._edgar = edgar_provider
|
|
35
|
+
self._last_fetch: dict[tuple[str, str], float] = {}
|
|
36
|
+
|
|
37
|
+
async def get_bars(
|
|
38
|
+
self, symbol: str, interval: str, start: datetime, end: datetime
|
|
39
|
+
) -> list[Bar]:
|
|
40
|
+
coverage = self.store.coverage(symbol, interval)
|
|
41
|
+
gaps: list[tuple[datetime, datetime]] = []
|
|
42
|
+
if coverage is None:
|
|
43
|
+
gaps.append((start, end))
|
|
44
|
+
elif not self._recently_fetched(symbol, interval):
|
|
45
|
+
lo, hi = coverage
|
|
46
|
+
if start < lo:
|
|
47
|
+
gaps.append((start, lo - _step(interval)))
|
|
48
|
+
if end > hi:
|
|
49
|
+
gaps.append((hi + _step(interval), end))
|
|
50
|
+
# Daily bars are timestamped at midnight while query bounds carry a
|
|
51
|
+
# time-of-day, so a gap can collapse to an inverted/sub-step range the
|
|
52
|
+
# provider rejects. Drop any gap that isn't a forward span.
|
|
53
|
+
gaps = [(s, e) for s, e in gaps if s < e]
|
|
54
|
+
for gap_start, gap_end in gaps:
|
|
55
|
+
fetched = await asyncio.to_thread(
|
|
56
|
+
self._bars.get_bars, symbol, interval, gap_start, gap_end
|
|
57
|
+
)
|
|
58
|
+
self.store.upsert_bars(fetched)
|
|
59
|
+
self._last_fetch[(symbol, interval)] = time.monotonic()
|
|
60
|
+
return self.store.get_bars(symbol, interval, start, end)
|
|
61
|
+
|
|
62
|
+
async def get_quote(self, symbol: str) -> Quote | None:
|
|
63
|
+
return await asyncio.to_thread(self._quotes.get_quote, symbol)
|
|
64
|
+
|
|
65
|
+
def _recently_fetched(self, symbol: str, interval: str) -> bool:
|
|
66
|
+
if self._ttl <= 0:
|
|
67
|
+
return False
|
|
68
|
+
last = self._last_fetch.get((symbol, interval))
|
|
69
|
+
return last is not None and (time.monotonic() - last) < self._ttl
|
|
70
|
+
|
|
71
|
+
# ---- EDGAR fundamentals / filings -------------------------------------
|
|
72
|
+
def _edgar_fresh(self, cached) -> bool:
|
|
73
|
+
if cached is None:
|
|
74
|
+
return False
|
|
75
|
+
fetched_at, _ = cached
|
|
76
|
+
return (datetime.now() - fetched_at).total_seconds() < EDGAR_TTL_SECONDS
|
|
77
|
+
|
|
78
|
+
async def get_fundamentals(self, symbol: str) -> list[FundamentalMetric]:
|
|
79
|
+
cached = self.store.get_edgar_facts(symbol)
|
|
80
|
+
if not self._edgar_fresh(cached):
|
|
81
|
+
try:
|
|
82
|
+
facts = await asyncio.to_thread(self._edgar.get_facts, symbol)
|
|
83
|
+
self.store.set_edgar_facts(symbol, json.dumps(facts))
|
|
84
|
+
cached = self.store.get_edgar_facts(symbol)
|
|
85
|
+
except Exception:
|
|
86
|
+
if cached is None:
|
|
87
|
+
raise
|
|
88
|
+
_, payload = cached
|
|
89
|
+
return extract_fundamentals(json.loads(payload))
|
|
90
|
+
|
|
91
|
+
async def get_filings(self, symbol: str) -> list[Filing]:
|
|
92
|
+
cached = self.store.get_edgar_filings(symbol)
|
|
93
|
+
if not self._edgar_fresh(cached):
|
|
94
|
+
try:
|
|
95
|
+
subs = await asyncio.to_thread(self._edgar.get_submissions, symbol)
|
|
96
|
+
self.store.set_edgar_filings(symbol, json.dumps(subs))
|
|
97
|
+
cached = self.store.get_edgar_filings(symbol)
|
|
98
|
+
except Exception:
|
|
99
|
+
if cached is None:
|
|
100
|
+
raise
|
|
101
|
+
_, payload = cached
|
|
102
|
+
return parse_filings(json.loads(payload))
|
bbterm/data/stats.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from bbterm.data.models import Bar, Quote
|
|
6
|
+
|
|
7
|
+
_ONE_MONTH_SESSIONS = 21
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Stats:
|
|
12
|
+
symbol: str
|
|
13
|
+
last: float
|
|
14
|
+
change: float | None
|
|
15
|
+
pct_change: float | None
|
|
16
|
+
high_52w: float
|
|
17
|
+
low_52w: float
|
|
18
|
+
ret_1m: float | None
|
|
19
|
+
ret_ytd: float | None
|
|
20
|
+
avg_volume: float
|
|
21
|
+
day_low: float
|
|
22
|
+
day_high: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compute_stats(bars: list[Bar], quote: Quote | None) -> Stats | None:
|
|
26
|
+
if not bars:
|
|
27
|
+
return None
|
|
28
|
+
last_bar = bars[-1]
|
|
29
|
+
last = quote.price if quote else last_bar.close
|
|
30
|
+
change = quote.change if quote else None
|
|
31
|
+
pct_change = quote.pct_change if quote else None
|
|
32
|
+
|
|
33
|
+
ret_1m = None
|
|
34
|
+
if len(bars) >= _ONE_MONTH_SESSIONS + 1:
|
|
35
|
+
ref = bars[-(_ONE_MONTH_SESSIONS + 1)].close
|
|
36
|
+
if ref:
|
|
37
|
+
ret_1m = (last_bar.close / ref - 1) * 100
|
|
38
|
+
|
|
39
|
+
year = last_bar.ts.year
|
|
40
|
+
ytd_ref = next((b.close for b in bars if b.ts.year == year), None)
|
|
41
|
+
ret_ytd = None
|
|
42
|
+
if ytd_ref:
|
|
43
|
+
ret_ytd = (last_bar.close / ytd_ref - 1) * 100
|
|
44
|
+
|
|
45
|
+
return Stats(
|
|
46
|
+
symbol=last_bar.symbol,
|
|
47
|
+
last=last,
|
|
48
|
+
change=change,
|
|
49
|
+
pct_change=pct_change,
|
|
50
|
+
high_52w=max(b.high for b in bars),
|
|
51
|
+
low_52w=min(b.low for b in bars),
|
|
52
|
+
ret_1m=ret_1m,
|
|
53
|
+
ret_ytd=ret_ytd,
|
|
54
|
+
avg_volume=sum(b.volume for b in bars) / len(bars),
|
|
55
|
+
day_low=last_bar.low,
|
|
56
|
+
day_high=last_bar.high,
|
|
57
|
+
)
|