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 +5 -2
- src/market_data.py +32 -0
- src/orchestrator.py +3 -0
- src/portfolio.py +30 -12
- static/app.js +32 -6
- tests/test_golden.py +18 -2
- tests/test_recommend.py +7 -2
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
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
break
|
| 427 |
except Exception:
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
|
|
|
| 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"
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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():
|