diff --git a/api_routes_accounts.py b/api_routes_accounts.py index fda82f4..9c0c880 100644 --- a/api_routes_accounts.py +++ b/api_routes_accounts.py @@ -59,7 +59,7 @@ unregister_account_topup_job, ) from treasury_bill_engine import buy_treasury_bill, confirm_ytm, delete_treasury_bill, list_treasury_bills -from utils import normalize_ticker +from utils import has_cached_fundamentals, is_excluded_from_yahoo_fetch, normalize_ticker from yahoo_engine import yahoo_engine logger = logging.getLogger(__name__) @@ -198,6 +198,22 @@ class TreasuryBillConfirmYtmBody(BaseModel): face_value: Optional[float] = None +def _ensure_ticker_data(ticker: str, background_tasks: BackgroundTasks) -> None: + """Queues whichever of profile / fundamentals+price / stock_signals row this ticker is still + missing, so it renders correctly on the Portfolio and Watchlist pages straight away. The three + gates are deliberately independent: a universe-scraped ticker has an `asset_profiles` row but + no fundamentals dump, and analyzing it without one silently writes 'USD'/'EQUITY'/'Unknown' + defaults over the profile's real values.""" + if is_excluded_from_yahoo_fetch(ticker): + return + if not _ticker_known(ticker): + background_tasks.add_task(update_single_profile, ticker) + if not has_cached_fundamentals(ticker): + background_tasks.add_task(fetch_and_save_single_ticker, ticker) + if not _has_stock_signals_row(ticker): + background_tasks.add_task(QuantEngine().analyze_ticker, ticker) + + def _resolve_exchange_rate(currency: Optional[str], exchange_rate: Optional[float], txn_date: str) -> float: if exchange_rate is not None: return exchange_rate @@ -403,9 +419,8 @@ async def api_create_transaction( content={"status": "error", "message": "Use POST /accounts/{id}/transfer to record a transfer."}, ) ticker = normalize_ticker(body.ticker) if body.ticker else None - if ticker and not _ticker_known(ticker): - background_tasks.add_task(update_single_profile, ticker) - background_tasks.add_task(fetch_and_save_single_ticker, ticker) + if ticker: + _ensure_ticker_data(ticker, background_tasks) currency = body.currency or acc["currency"] exchange_rate = _resolve_exchange_rate(currency, body.exchange_rate, body.txn_date) fee_currency, fee_exchange_rate = _resolve_fee_currency_and_rate( @@ -646,11 +661,7 @@ async def api_add_watchlist_item(request: Request, account_id: int, body: Watchl ) if item_id is None: return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to add ticker to watchlist."}) - if not _ticker_known(ticker): - background_tasks.add_task(update_single_profile, ticker) - background_tasks.add_task(fetch_and_save_single_ticker, ticker) - if not _has_stock_signals_row(ticker): - background_tasks.add_task(QuantEngine().analyze_ticker, ticker) + _ensure_ticker_data(ticker, background_tasks) return JSONResponse(content={"status": "success", "id": item_id}) except Exception as e: logger.error("api_add_watchlist_item failed for account %s: %s", account_id, e) @@ -695,9 +706,7 @@ async def api_import_csv(request: Request, account_id: int, background_tasks: Ba return JSONResponse(status_code=422, content={"status": "error", "message": result["error"]}) tickers = {txn["ticker"] for txn in get_transactions(account_id) if txn["ticker"]} for ticker in tickers: - if not _ticker_known(ticker): - background_tasks.add_task(update_single_profile, ticker) - background_tasks.add_task(fetch_and_save_single_ticker, ticker) + _ensure_ticker_data(ticker, background_tasks) background_tasks.add_task(resnapshot_account, account_id) skipped_rows = result["skipped_rows"] if skipped_rows: diff --git a/quant_signals.py b/quant_signals.py index 00e3aee..de86423 100644 --- a/quant_signals.py +++ b/quant_signals.py @@ -196,10 +196,41 @@ def load_fundamentals(self, ticker: str) -> dict: return {} filepath = FUNDAMENTALS_DIR / f"{safe_ticker}.json" if not filepath.exists(): - return {} + return self._fundamentals_from_profile(ticker) with open(filepath, 'r') as f: return json.load(f) + @staticmethod + def _fundamentals_from_profile(ticker: str) -> dict: + """The `asset_profiles` row reshaped into the Yahoo `.info` keys analyze_ticker reads, for a + ticker whose fundamentals dump was never fetched. Without it an empty dict falls through to + the 'USD'/'EQUITY'/'Unknown' defaults, which then overwrite the profile's correct values in + `stock_signals` — mistagging a GBP LSE ETF as USD and applying a phantom FX conversion to + every price derived from it.""" + conn = None + try: + conn = get_connection() + row = conn.execute( + "SELECT company_name, sector, country, currency, quote_type FROM asset_profiles WHERE ticker = ?", + (ticker,), + ).fetchone() + if row is None: + return {} + info = { + 'shortName': row['company_name'], + 'sector': row['sector'], + 'country': row['country'], + 'currency': row['currency'], + 'quoteType': row['quote_type'], + } + return {k: v for k, v in info.items() if v} + except Exception as e: + logger.error("Profile fundamentals fallback failed for %s: %s", ticker, e) + return {} + finally: + if conn: + conn.close() + def calculate_vcp_breakout(self, df: pd.DataFrame) -> Tuple[bool, bool, bool]: """Returns (is_vcp_base, is_confirmed_breakout, has_prior_uptrend) for all 5 Minervini VCP criteria.""" # Require minimum 1 year of data to compute a valid 52-week window diff --git a/tests/test_accounts_api.py b/tests/test_accounts_api.py index d271753..1a696ee 100644 --- a/tests/test_accounts_api.py +++ b/tests/test_accounts_api.py @@ -450,6 +450,71 @@ def test_create_transaction_with_unknown_ticker_triggers_price_fetch(client): _db.soft_delete_account(account_id) +@pytest.mark.api +def test_create_transaction_known_ticker_without_signals_row_triggers_analyze(client): + """A ticker already in asset_profiles (from the universe scrape) but with no stock_signals row + must still get a fundamentals fetch and an analyze_ticker() call queued on Buy — otherwise it + never appears on the Portfolio page, which renders FROM stock_signals.""" + import database as _db + account_id = _create_account(client) + + conn = _db.get_connection() + try: + conn.execute( + "INSERT OR IGNORE INTO asset_profiles (ticker, company_name, currency) VALUES (?, ?, ?)", + ("ZZBUYANALYZE", "Buy Analyze Ltd.", "GBP"), + ) + conn.execute("DELETE FROM stock_signals WHERE ticker = 'ZZBUYANALYZE'") + conn.commit() + finally: + conn.close() + + mock_engine = MagicMock() + with ( + patch("api_routes_accounts.update_single_profile") as mock_profile, + patch("api_routes_accounts.fetch_and_save_single_ticker") as mock_fetch, + patch("api_routes_accounts.QuantEngine", return_value=mock_engine), + ): + resp = client.post(f"/api/accounts/{account_id}/transactions", json={ + "txn_type": "Buy", "txn_date": "2026-01-15", "ticker": "ZZBUYANALYZE", + "currency": "GBP", "quantity": 1, "unit_price": 1.0, "exchange_rate": 1.0, + }) + assert resp.status_code == 200 + mock_profile.assert_not_called() + mock_fetch.assert_called_once_with("ZZBUYANALYZE") + mock_engine.analyze_ticker.assert_called_once_with("ZZBUYANALYZE") + + _db.soft_delete_account(account_id) + conn = _db.get_connection() + try: + conn.execute("DELETE FROM asset_profiles WHERE ticker = 'ZZBUYANALYZE'") + conn.commit() + finally: + conn.close() + + +@pytest.mark.api +def test_create_transaction_synthetic_ticker_never_reaches_yahoo(client): + account_id = _create_account(client) + mock_engine = MagicMock() + with ( + patch("api_routes_accounts.update_single_profile") as mock_profile, + patch("api_routes_accounts.fetch_and_save_single_ticker") as mock_fetch, + patch("api_routes_accounts.QuantEngine", return_value=mock_engine), + ): + resp = client.post(f"/api/accounts/{account_id}/transactions", json={ + "txn_type": "Buy", "txn_date": "2026-01-15", "ticker": "TBILL-999", + "currency": "GBP", "quantity": 1, "unit_price": 1.0, "exchange_rate": 1.0, + }) + assert resp.status_code == 200 + mock_profile.assert_not_called() + mock_fetch.assert_not_called() + mock_engine.analyze_ticker.assert_not_called() + + import database as _db + _db.soft_delete_account(account_id) + + @pytest.mark.api def test_create_transaction_blank_exchange_rate_is_auto_filled(client): account_id = _create_account(client) @@ -1060,7 +1125,7 @@ def test_add_watchlist_item_missing_stock_signals_row_triggers_analyze(client): resp = client.post(f"/api/accounts/{wl_id}/watchlist-items", json={"ticker": "ZZANALYZEME2"}) assert resp.status_code == 200 mock_profile.assert_not_called() - mock_fetch.assert_not_called() + mock_fetch.assert_called_once_with("ZZANALYZEME2") mock_engine.analyze_ticker.assert_called_once_with("ZZANALYZEME2") item_id = _json(resp)["id"] diff --git a/tests/test_quant_signals.py b/tests/test_quant_signals.py index bcd533a..3c0be6b 100644 --- a/tests/test_quant_signals.py +++ b/tests/test_quant_signals.py @@ -152,3 +152,35 @@ def test_none_stop_loss_does_not_write_a_row(self): ).fetchall() conn.close() assert rows == [] + + +class TestLoadFundamentalsProfileFallback: + TICKER = "ZZPROFFALL.L" + + def setup_method(self): + conn = _db.get_connection() + conn.execute( + """INSERT OR REPLACE INTO asset_profiles + (ticker, company_name, sector, country, currency, quote_type) + VALUES (?, ?, ?, ?, ?, ?)""", + (self.TICKER, "Profile Fallback ETF", "Unclassified", "Unknown", "GBP", "ETF"), + ) + conn.commit() + conn.close() + + def teardown_method(self): + conn = _db.get_connection() + conn.execute("DELETE FROM asset_profiles WHERE ticker=?", (self.TICKER,)) + conn.commit() + conn.close() + + def test_missing_fundamentals_json_falls_back_to_asset_profiles(self, tmp_path): + with patch("quant_signals.FUNDAMENTALS_DIR", tmp_path): + info = QuantEngine().load_fundamentals(self.TICKER) + assert info["currency"] == "GBP" + assert info["quoteType"] == "ETF" + assert info["shortName"] == "Profile Fallback ETF" + + def test_missing_fundamentals_and_no_profile_returns_empty(self, tmp_path): + with patch("quant_signals.FUNDAMENTALS_DIR", tmp_path): + assert QuantEngine().load_fundamentals("ZZNOPROFILE.L") == {} diff --git a/utils.py b/utils.py index 12ab2d5..5af63de 100644 --- a/utils.py +++ b/utils.py @@ -109,6 +109,18 @@ def safe_ticker_filename(ticker: Optional[str]) -> Optional[str]: return ticker +def has_cached_fundamentals(ticker: Optional[str]) -> bool: + """Whether this ticker's raw Yahoo `.info` dump exists under FUNDAMENTALS_DIR. Presence in + `asset_profiles` does not imply it — the universe scrape writes profiles without ever fetching + the dump — so anything that would otherwise analyze the ticker with an empty `info` (and fall + back on 'USD'/'EQUITY'/'Unknown' defaults) must gate on this, not on the profile row.""" + from config import FUNDAMENTALS_DIR + safe_ticker = safe_ticker_filename(ticker) + if not safe_ticker: + return False + return (FUNDAMENTALS_DIR / f"{safe_ticker}.json").exists() + + def ignored_tickers_set(config: Optional[dict] = None) -> set: """Normalized Settings-page IGNORED_TICKERS — the single source every Yahoo-touching ticker-list builder must filter against, so an ignored ticker is actually ignored everywhere."""