s-ttp commited on
Commit
5e24259
·
verified ·
1 Parent(s): 2a0814c

Pre-split entries ask the user (adjust both, price only, keep) instead of a silent rewrite; data-layer canary so renamed tickers (SQ→XYZ) resolve while real outages read as retry

Browse files
README.md CHANGED
@@ -136,8 +136,11 @@ reasoning matters.
136
  committee screen is skipped and past 85% the plan narrative, with the diagnostics saying so.
137
  - **Plain tables never wait for a model**: a CSV/XLSX with a symbol column and an
138
  average-cost column is read directly; the AI extractor handles transaction exports, PDFs and
139
- odd layouts on the standard tier. A pre-split entry price is auto-corrected with a visible note
140
- (import row and results row) — a 10:1 split can hide behind an ordinary-looking P/L.
 
 
 
141
  - **Pipeline parallelism**: the portfolio strategist and the whole candidate pipeline (pool,
142
  live verification, fit, screens, committee) run alongside the five-lens panel; books of 30+
143
  names use one sample per lens.
 
136
  committee screen is skipped and past 85% the plan narrative, with the diagnostics saying so.
137
  - **Plain tables never wait for a model**: a CSV/XLSX with a symbol column and an
138
  average-cost column is read directly; the AI extractor handles transaction exports, PDFs and
139
+ odd layouts on the standard tier. A pre-split entry price is never rewritten silently: the row
140
+ asks which is right — adjust price and quantity, adjust price only (quantity already
141
+ post-split), or keep as entered — and the analysis refuses to run until the question is
142
+ answered; the results row shows what was chosen. A 10:1 split can hide behind an
143
+ ordinary-looking P/L, and only the investor knows whether their share count is pre- or post-split.
144
  - **Pipeline parallelism**: the portfolio strategist and the whole candidate pipeline (pool,
145
  live verification, fit, screens, committee) run alongside the five-lens panel; books of 30+
146
  names use one sample per lens.
src/market_data.py CHANGED
@@ -237,6 +237,36 @@ _FAST_KEYS = [("price", "lastPrice"), ("currency", "currency"), ("mcap", "market
237
  ("hi52", "yearHigh"), ("lo52", "yearLow")]
238
 
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  def fetch_holding(ticker: str) -> dict:
241
  """Fundamentals + price for one ticker. Layered for resilience:
242
  1) yfinance fast_info (chart API — rarely rate-limited): price, mcap, SMAs, 52w range
@@ -407,6 +437,8 @@ def fetch_holding(ticker: str) -> dict:
407
  d["panel"] = {k: (float(v) if isinstance(v, (int, float, np.floating)) else v)
408
  for k, v in panel.items() if v is not None}
409
  if not d.get("price"):
 
 
410
  raise ValueError(f"No live price found for '{t}' — check the ticker.")
411
  sanitize(d)
412
  _cache_put(f"hold_{t}", d)
 
237
  ("hi52", "yearHigh"), ("lo52", "yearLow")]
238
 
239
 
240
+ class DataUnavailable(ValueError):
241
+ """No price AND the market-data layer itself is not answering (a canary quote fails):
242
+ an outage, not a verdict on the ticker."""
243
+
244
+
245
+ _alive_memo = {"t": 0.0, "ok": True}
246
+
247
+
248
+ def _data_alive() -> bool:
249
+ """Canary: can we quote SPY right now? Memoised for 60s so a burst of misses costs one call."""
250
+ now = time.time()
251
+ if now - _alive_memo["t"] < 60:
252
+ return _alive_memo["ok"]
253
+ ok = False
254
+ try:
255
+ if NDL_KEY:
256
+ from . import sharadar_prices as SP
257
+ ok = bool((SP.snapshot("SPY") or {}).get("price"))
258
+ except Exception:
259
+ ok = False
260
+ if not ok:
261
+ try:
262
+ import yfinance as yf
263
+ ok = bool(_f(yf.Ticker("SPY").fast_info["lastPrice"]))
264
+ except Exception:
265
+ ok = False
266
+ _alive_memo.update(t=now, ok=ok)
267
+ return ok
268
+
269
+
270
  def fetch_holding(ticker: str) -> dict:
271
  """Fundamentals + price for one ticker. Layered for resilience:
272
  1) yfinance fast_info (chart API — rarely rate-limited): price, mcap, SMAs, 52w range
 
437
  d["panel"] = {k: (float(v) if isinstance(v, (int, float, np.floating)) else v)
438
  for k, v in panel.items() if v is not None}
439
  if not d.get("price"):
440
+ if not _data_alive(): # SQ (renamed XYZ) is a miss; GOOG with every source down is an outage
441
+ raise DataUnavailable(f"No live price for '{t}' and the market-data sources are not responding")
442
  raise ValueError(f"No live price found for '{t}' — check the ticker.")
443
  sanitize(d)
444
  _cache_put(f"hold_{t}", d)
src/orchestrator.py CHANGED
@@ -336,6 +336,9 @@ def run_analysis(rows: list[dict], emit, context: dict | None = None, budget_s:
336
  # 1 — resolve + validate
337
  emit(3, "Resolving your holdings…")
338
  holdings, errors = build_holdings(rows)
 
 
 
339
  if not holdings:
340
  raise ValueError("No valid holdings. " + "; ".join(e["error"] for e in errors[:3]))
341
 
 
336
  # 1 — resolve + validate
337
  emit(3, "Resolving your holdings…")
338
  holdings, errors = build_holdings(rows)
339
+ asks = [e for e in errors if e.get("code") == "needs_decision"]
340
+ if asks: # check with the user rather than analyse a holding on a guessed cost basis
341
+ raise ValueError("Before running, answer the split question: " + " | ".join(e["error"] for e in asks[:3]))
342
  if not holdings:
343
  raise ValueError("No valid holdings. " + "; ".join(e["error"] for e in errors[:3]))
344
 
src/portfolio.py CHANGED
@@ -10,7 +10,7 @@ from __future__ import annotations
10
  import re
11
 
12
  from .llm import call_llm, parse_json
13
- from .market_data import av_symbol_search, fetch_holding, fetch_long_range, fetch_splits, fx_to_usd
14
 
15
  _TICKER_RE = re.compile(r"[A-Z0-9\-]{1,10}(\.[A-Z]{1,4})?")
16
 
@@ -40,6 +40,11 @@ def _quote_exists(sym: str) -> dict | None:
40
  row of an import because an upstream blip surfaced as a 500)."""
41
  try:
42
  return fetch_holding(sym)
 
 
 
 
 
43
  except Exception as e:
44
  _tl.errors = getattr(_tl, "errors", []) + [f"{sym}: {type(e).__name__}"]
45
  return None
@@ -405,7 +410,8 @@ def build_holdings(rows: list[dict]) -> tuple[list[dict], list[dict]]:
405
  if sym not in resolved:
406
  resolved[sym] = r
407
  order.append(sym)
408
- lots.setdefault(sym, []).append({"row": i, "entry": entry, "qty": qty, "bought": bought, "side": side})
 
409
  for sym in order:
410
  r = resolved[sym]
411
  agg = aggregate_lots(lots[sym])
@@ -415,17 +421,29 @@ def build_holdings(rows: list[dict]) -> tuple[list[dict], list[dict]]:
415
  entry, qty = agg["entry"], agg["qty"]
416
  price = r["price"]
417
  entry_note = None
418
- try: # a pre-split entry the user did not correct would poison P/L, tax and DCF-vs-entry
419
- for w in validate_row(sym, r["data"], price, entry, sym):
420
- if w.get("code") == "presplit" and (w.get("suggest") or {}).get("field") == "entry":
421
- adj = float(w["suggest"]["value"])
422
- entry_note = f"entry {entry:g} split-adjusted to {adj:g} ({w['message'].split(' — ')[0]})"
423
- if qty:
424
- qty = qty * (entry / adj) # the same lot holds proportionally more shares
425
- entry = adj
426
- break
427
  except Exception:
428
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  fx = fx_to_usd(r["data"].get("currency")) # entry & price share the local currency;
430
  holdings.append({ # market value converts to USD for weights
431
  "symbol": sym, "name": r.get("name") or sym,
 
10
  import re
11
 
12
  from .llm import call_llm, parse_json
13
+ from .market_data import DataUnavailable, av_symbol_search, fetch_holding, fetch_long_range, fetch_splits, fx_to_usd
14
 
15
  _TICKER_RE = re.compile(r"[A-Z0-9\-]{1,10}(\.[A-Z]{1,4})?")
16
 
 
40
  row of an import because an upstream blip surfaced as a 500)."""
41
  try:
42
  return fetch_holding(sym)
43
+ except DataUnavailable as e:
44
+ _tl.errors = getattr(_tl, "errors", []) + [f"{sym}: sources down"]
45
+ return None
46
+ except ValueError:
47
+ return None # a clean miss (dead/renamed ticker): the resolver carries on
48
  except Exception as e:
49
  _tl.errors = getattr(_tl, "errors", []) + [f"{sym}: {type(e).__name__}"]
50
  return None
 
410
  if sym not in resolved:
411
  resolved[sym] = r
412
  order.append(sym)
413
+ lots.setdefault(sym, []).append({"row": i, "entry": entry, "qty": qty, "bought": bought, "side": side,
414
+ "split": str(row.get("split") or "").lower() or None})
415
  for sym in order:
416
  r = resolved[sym]
417
  agg = aggregate_lots(lots[sym])
 
421
  entry, qty = agg["entry"], agg["qty"]
422
  price = r["price"]
423
  entry_note = None
424
+ # a pre-split entry would poison P/L, tax and DCF-vs-entry — but whether the QUANTITY is
425
+ # pre- or post-split only the user knows (7 shares at 171 vs 70 at 17.1), so the row
426
+ # must carry their answer: split = "both" | "price" | "keep"; without one the run stops
427
+ # and asks (never a silent rewrite)
428
+ choice = next((l.get("split") for l in lots[sym] if l.get("split")), None)
429
+ try:
430
+ presplit = next((w for w in validate_row(sym, r["data"], price, entry, sym)
431
+ if w.get("code") == "presplit" and (w.get("suggest") or {}).get("field") == "entry"), None)
 
432
  except Exception:
433
+ presplit = None
434
+ if presplit and choice not in ("keep", "both", "price"):
435
+ errors.append({"row": lots[sym][0]["row"], "query": sym, "code": "needs_decision",
436
+ "error": f"{sym}: {presplit['message']} Choose 'adjust price & quantity', "
437
+ f"'adjust price only' or 'keep as entered' in the row before running."})
438
+ continue
439
+ if presplit and choice in ("both", "price"):
440
+ adj = float(presplit["suggest"]["value"])
441
+ entry_note = f"entry {entry:g} split-adjusted to {adj:g} ({presplit['message'].split(' — ')[0]})"
442
+ if qty and choice == "both":
443
+ qty = qty * (entry / adj) # the lot holds proportionally more shares
444
+ entry = adj
445
+ elif presplit and choice == "keep":
446
+ entry_note = f"entry {entry:g} kept as entered ({presplit['message'].split(' — ')[0]} — you confirmed it)"
447
  fx = fx_to_usd(r["data"].get("currency")) # entry & price share the local currency;
448
  holdings.append({ # market value converts to USD for weights
449
  "symbol": sym, "name": r.get("name") or sym,
static/app.js CHANGED
@@ -30,7 +30,8 @@ function addRow(query = "", entry = "", qty = "", bought = "") {
30
  tr.querySelector(".rm").onclick = () => { tr.remove(); if (!rowsEl.children.length) addRow(); };
31
  const q = tr.querySelector(".q"), e = tr.querySelector(".e");
32
  let timer = null;
33
- const kick = () => { clearTimeout(timer); timer = setTimeout(() => resolveRow(tr), 500); };
 
34
  q.addEventListener("change", kick); e.addEventListener("change", kick);
35
  rowsEl.appendChild(tr);
36
  return tr;
@@ -72,10 +73,28 @@ function applyResolved(tr, r) {
72
  out.innerHTML = html;
73
  markLots();
74
  for (const w of r.warnings || []) {
75
- if (w.code === "presplit" && w.suggest && w.suggest.field === "entry" && tr.dataset.autosplit !== "1") {
76
- tr.dataset.autosplit = "1"; // apply once; the user can still edit
77
- tr.querySelector(".e").value = w.suggest.value;
78
- out.appendChild(el("div", "warn-note", `↕ ${esc(w.message)} Entry set to ${esc(String(w.suggest.value))} — edit it if that is wrong.`));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  continue;
80
  }
81
  const div = el("div", "warn-note", `⚠ ${esc(w.message)} `);
@@ -303,9 +322,16 @@ $("#analyze").onclick = async () => {
303
  entry: parseFloat(tr.querySelector(".e").value) || null,
304
  qty: parseFloat(tr.querySelector(".n").value) || null,
305
  bought: (tr.querySelector(".b") && tr.querySelector(".b").value) || null,
 
306
  })).filter(r => r.query);
307
  const errEl = $("#input-error"); errEl.hidden = true;
308
  if (!rows.length) { errEl.hidden = false; errEl.textContent = "Add at least one holding."; return; }
 
 
 
 
 
 
309
  if (rows.some(r => !r.entry)) { errEl.hidden = false; errEl.textContent = "Every holding needs an entry price."; return; }
310
  const context = {
311
  horizon: $("#ctx-horizon").value || null,
@@ -416,7 +442,7 @@ function renderResults(R) {
416
  tr.innerHTML = `
417
  <td class="tick"><b>${esc(r.ticker)}</b><span>${esc(r.name)}</span></td>
418
  <td>${pct(r.weight, false)}</td>
419
- <td class="${r.pl_pct >= 0 ? "up" : "dn"}">${pct(r.pl_pct)}${r.entry_note ? ` <span class="sig" title="${esc(r.entry_note)}">split-adjusted</span>` : ""}${r.n_lots > 1
420
  ? ` <span class="sig" title="${esc(r.lot_note || "average cost of " + r.n_lots + " lots")}">${r.n_lots} lots · avg ${Number(r.entry).toFixed(2)}</span>` : ""}</td>
421
  <td>${r.upside === null || r.upside === undefined
422
  ? (r.dcf_note ? `<span class="dim" title="DCF not meaningful">${esc(r.dcf_note)}</span>` : "–")
 
30
  tr.querySelector(".rm").onclick = () => { tr.remove(); if (!rowsEl.children.length) addRow(); };
31
  const q = tr.querySelector(".q"), e = tr.querySelector(".e");
32
  let timer = null;
33
+ const kick = () => { clearTimeout(timer); delete tr.dataset.split; delete tr.dataset.presplit;
34
+ timer = setTimeout(() => resolveRow(tr), 500); };
35
  q.addEventListener("change", kick); e.addEventListener("change", kick);
36
  rowsEl.appendChild(tr);
37
  return tr;
 
73
  out.innerHTML = html;
74
  markLots();
75
  for (const w of r.warnings || []) {
76
+ if (w.code === "presplit" && w.suggest && w.suggest.field === "entry") {
77
+ // never rewrite the user's numbers: whether the QUANTITY is pre- or post-split only
78
+ // they know (7 shares at 171 vs 70 at 17.13) — ask, with the maths shown
79
+ if (tr.dataset.split === "keep") {
80
+ out.appendChild(el("div", "warn-note", `↕ ${esc(w.message)} Kept as entered (you confirmed it).`));
81
+ continue;
82
+ }
83
+ tr.dataset.presplit = "1";
84
+ const entryNow = parseFloat(tr.querySelector(".e").value), qtyNow = parseFloat(tr.querySelector(".n").value);
85
+ const ratio = entryNow / Number(w.suggest.value);
86
+ const div = el("div", "warn-note", `↕ ${esc(w.message)} Which is right? `);
87
+ const mk = (label, fn) => { const b = el("button", "warn-apply", label); b.onclick = (ev) => { ev.preventDefault(); fn(); }; div.appendChild(b); div.appendChild(document.createTextNode(" ")); };
88
+ mk(`Adjust price & quantity → ${Number(w.suggest.value).toFixed(2)}${isFinite(qtyNow) ? ` × ${Math.round(qtyNow * ratio)} shares` : ""}`, () => {
89
+ tr.dataset.split = "both"; tr.querySelector(".e").value = w.suggest.value;
90
+ if (isFinite(qtyNow)) tr.querySelector(".n").value = Math.round(qtyNow * ratio * 1000) / 1000;
91
+ delete tr.dataset.presplit; resolveRow(tr);
92
+ });
93
+ mk(`Adjust price only → ${Number(w.suggest.value).toFixed(2)} (quantity is already post-split)`, () => {
94
+ tr.dataset.split = "price"; tr.querySelector(".e").value = w.suggest.value; delete tr.dataset.presplit; resolveRow(tr);
95
+ });
96
+ mk(`Keep ${entryNow} as entered`, () => { tr.dataset.split = "keep"; delete tr.dataset.presplit; resolveRow(tr); });
97
+ out.appendChild(div);
98
  continue;
99
  }
100
  const div = el("div", "warn-note", `⚠ ${esc(w.message)} `);
 
322
  entry: parseFloat(tr.querySelector(".e").value) || null,
323
  qty: parseFloat(tr.querySelector(".n").value) || null,
324
  bought: (tr.querySelector(".b") && tr.querySelector(".b").value) || null,
325
+ split: tr.dataset.split || null,
326
  })).filter(r => r.query);
327
  const errEl = $("#input-error"); errEl.hidden = true;
328
  if (!rows.length) { errEl.hidden = false; errEl.textContent = "Add at least one holding."; return; }
329
+ const pending = [...rowsEl.children].filter(tr => tr.dataset.presplit === "1").map(tr => tr.querySelector(".q").value.trim());
330
+ if (pending.length) {
331
+ errEl.hidden = false;
332
+ errEl.textContent = `Answer the split question on ${pending.join(", ")} first — the entry price looks pre-split and only you know whether the quantity is.`;
333
+ return;
334
+ }
335
  if (rows.some(r => !r.entry)) { errEl.hidden = false; errEl.textContent = "Every holding needs an entry price."; return; }
336
  const context = {
337
  horizon: $("#ctx-horizon").value || null,
 
442
  tr.innerHTML = `
443
  <td class="tick"><b>${esc(r.ticker)}</b><span>${esc(r.name)}</span></td>
444
  <td>${pct(r.weight, false)}</td>
445
+ <td class="${r.pl_pct >= 0 ? "up" : "dn"}">${pct(r.pl_pct)}${r.entry_note ? ` <span class="sig" title="${esc(r.entry_note)}">${r.entry_note.includes("kept") ? "split: kept as entered" : "split-adjusted"}</span>` : ""}${r.n_lots > 1
446
  ? ` <span class="sig" title="${esc(r.lot_note || "average cost of " + r.n_lots + " lots")}">${r.n_lots} lots · avg ${Number(r.entry).toFixed(2)}</span>` : ""}</td>
447
  <td>${r.upside === null || r.upside === undefined
448
  ? (r.dcf_note ? `<span class="dim" title="DCF not meaningful">${esc(r.dcf_note)}</span>` : "–")
tests/test_golden.py CHANGED
@@ -99,9 +99,25 @@ def test_data_outage_reads_as_retry_not_as_unknown_ticker(monkeypatch):
99
  with pytest.raises(PF.LookupUnavailable) as ei:
100
  PF.resolve_symbol("GOOG")
101
  assert "temporarily unavailable" in str(ei.value)
102
- # a clean miss (sources fine, nothing matches) still reads as "not found"
103
  monkeypatch.setattr(PF, "fetch_holding", lambda sym: (_ for _ in ()).throw(ValueError("no quote")))
104
  monkeypatch.setattr(PF, "av_symbol_search", lambda q: [])
105
  monkeypatch.setattr(PF, "call_llm", lambda *a, **k: "{}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  with pytest.raises(PF.LookupUnavailable):
107
- PF.resolve_symbol("GOOG") # fetch raised → still an error, not a verdict on the ticker
 
99
  with pytest.raises(PF.LookupUnavailable) as ei:
100
  PF.resolve_symbol("GOOG")
101
  assert "temporarily unavailable" in str(ei.value)
102
+ # a clean miss (sources fine, nothing matches) still reads as "not found"…
103
  monkeypatch.setattr(PF, "fetch_holding", lambda sym: (_ for _ in ()).throw(ValueError("no quote")))
104
  monkeypatch.setattr(PF, "av_symbol_search", lambda q: [])
105
  monkeypatch.setattr(PF, "call_llm", lambda *a, **k: "{}")
106
+ with pytest.raises(ValueError) as ei:
107
+ PF.resolve_symbol("ZZZZQQ")
108
+ assert "Couldn't find" in str(ei.value) and not isinstance(ei.value, PF.LookupUnavailable)
109
+ # …and a renamed ticker (SQ → XYZ) still falls through to the search, not to "unavailable"
110
+ def fetch(sym):
111
+ if sym == "XYZ":
112
+ return {"price": 60.0, "name": "Block Inc", "currency": "USD"}
113
+ raise ValueError("no quote")
114
+ monkeypatch.setattr(PF, "fetch_holding", fetch)
115
+ monkeypatch.setattr(PF, "av_symbol_search", lambda q: [{"av": "XYZ", "yahoo": "XYZ", "name": "Block Inc", "region": "United States"}] if q.lower() == "block inc" else [])
116
+ monkeypatch.setattr(PF, "call_llm", lambda *a, **k: '{"company_name": "Block Inc", "ticker": "XYZ"}')
117
+ r = PF.resolve_symbol("SQ")
118
+ assert r["symbol"] == "XYZ" and r["price"] == 60.0
119
+ # a genuine outage (canary down) is the retryable error
120
+ from src import market_data as MD
121
+ monkeypatch.setattr(PF, "fetch_holding", lambda sym: (_ for _ in ()).throw(MD.DataUnavailable("sources down")))
122
  with pytest.raises(PF.LookupUnavailable):
123
+ PF.resolve_symbol("GOOG")
tests/test_recommend.py CHANGED
@@ -273,15 +273,20 @@ def test_importer_reads_plain_tables_without_llm():
273
  assert parse_table("just some text\nwithout a table") is None
274
 
275
 
276
- def test_presplit_entry_is_auto_adjusted_in_the_analysis(monkeypatch):
277
  from src import portfolio as PF
278
  monkeypatch.setattr(PF, "resolve_symbol", lambda q: {"symbol": "NFLX", "name": "Netflix", "price": 78.0, "data": {"currency": "USD"}})
279
  monkeypatch.setattr(PF, "fx_to_usd", lambda c: 1.0)
280
  monkeypatch.setattr(PF, "validate_row", lambda *a, **k: [{"code": "presplit", "message": "NFLX had a 10-for-1 split on 2025-11-17 — x",
281
  "suggest": {"field": "entry", "value": 17.1}}])
282
  hs, errs = PF.build_holdings([{"query": "NFLX", "entry": 171, "qty": 5}])
 
 
283
  assert hs[0]["entry"] == 17.1 and hs[0]["qty"] == 50 and "split-adjusted" in hs[0]["entry_note"]
284
- assert abs(hs[0]["pl_pct"] - (78 / 17.1 - 1)) < 1e-6
 
 
 
285
 
286
 
287
  def test_strong_ok_raises_the_bar_for_flagged_names_in_a_cautious_book():
 
273
  assert parse_table("just some text\nwithout a table") is None
274
 
275
 
276
+ def test_presplit_entry_is_a_question_never_a_silent_rewrite(monkeypatch):
277
  from src import portfolio as PF
278
  monkeypatch.setattr(PF, "resolve_symbol", lambda q: {"symbol": "NFLX", "name": "Netflix", "price": 78.0, "data": {"currency": "USD"}})
279
  monkeypatch.setattr(PF, "fx_to_usd", lambda c: 1.0)
280
  monkeypatch.setattr(PF, "validate_row", lambda *a, **k: [{"code": "presplit", "message": "NFLX had a 10-for-1 split on 2025-11-17 — x",
281
  "suggest": {"field": "entry", "value": 17.1}}])
282
  hs, errs = PF.build_holdings([{"query": "NFLX", "entry": 171, "qty": 5}])
283
+ assert not hs and errs[0]["code"] == "needs_decision" and "keep as entered" in errs[0]["error"]
284
+ hs, _ = PF.build_holdings([{"query": "NFLX", "entry": 171, "qty": 5, "split": "both"}])
285
  assert hs[0]["entry"] == 17.1 and hs[0]["qty"] == 50 and "split-adjusted" in hs[0]["entry_note"]
286
+ hs, _ = PF.build_holdings([{"query": "NFLX", "entry": 171, "qty": 5, "split": "price"}])
287
+ assert hs[0]["entry"] == 17.1 and hs[0]["qty"] == 5
288
+ hs, _ = PF.build_holdings([{"query": "NFLX", "entry": 171, "qty": 5, "split": "keep"}])
289
+ assert hs[0]["entry"] == 171 and hs[0]["qty"] == 5 and "kept as entered" in hs[0]["entry_note"]
290
 
291
 
292
  def test_strong_ok_raises_the_bar_for_flagged_names_in_a_cautious_book():