Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions api_routes_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 32 additions & 1 deletion quant_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion tests/test_accounts_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"]
Expand Down
32 changes: 32 additions & 0 deletions tests/test_quant_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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") == {}
12 changes: 12 additions & 0 deletions utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@
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()
Comment thread
alfwro13 marked this conversation as resolved.
Dismissed


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."""
Expand Down
Loading