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 ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("bbterm-tui")
5
+ except PackageNotFoundError: # running from a source tree, not installed
6
+ __version__ = "0.0.0+source"
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
+ )
@@ -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
+ )