fix: per-symbol SQLite transactions in research snapshot extender

Avoid inactive-transaction crashes from mixing connection.commit with ORM
Session. Write path is raw SQL, one begin() block per symbol.
This commit is contained in:
2026-07-18 20:34:38 +02:00
parent b6892d13fd
commit 30286111a8
+83 -55
View File
@@ -34,8 +34,7 @@ import time
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from sqlalchemy import create_engine, select, text from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
@@ -93,7 +92,9 @@ def _parse_args() -> argparse.Namespace:
return p.parse_args() return p.parse_args()
def _ensure_rank_only_table(conn) -> None: def _ensure_rank_only_table(engine) -> None:
"""DDL in its own connection/transaction (don't share with ORM Session)."""
with engine.begin() as conn:
conn.execute( conn.execute(
text( text(
""" """
@@ -104,7 +105,6 @@ def _ensure_rank_only_table(conn) -> None:
""" """
) )
) )
conn.commit()
async def _resolve_pool() -> tuple[list[str], dict[str, str]]: async def _resolve_pool() -> tuple[list[str], dict[str, str]]:
@@ -213,8 +213,6 @@ async def _main() -> None:
print(f"Updating existing research snapshot: {output}") print(f"Updating existing research snapshot: {output}")
from app.config import settings from app.config import settings
from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.providers.alpaca import AlpacaOHLCVProvider from app.providers.alpaca import AlpacaOHLCVProvider
if not settings.alpaca_api_key or not settings.alpaca_api_secret: if not settings.alpaca_api_key or not settings.alpaca_api_secret:
@@ -228,30 +226,32 @@ async def _main() -> None:
pool, sources = await _resolve_pool() pool, sources = await _resolve_pool()
print(f"Pool size: {len(pool)} (sources={sources})") print(f"Pool size: {len(pool)} (sources={sources})")
# Sync sqlite via sqlalchemy core (simpler than async for bulk insert) # Sync sqlite via raw SQL — one short transaction per symbol so a failed
engine = create_engine(f"sqlite:///{output.resolve().as_posix()}") # write never leaves the session in "transaction is inactive".
with Session(engine) as session: engine = create_engine(
_ensure_rank_only_table(session.connection()) f"sqlite:///{output.resolve().as_posix()}",
existing = { future=True,
row.symbol: row )
for row in session.execute(select(Ticker)).scalars().all() _ensure_rank_only_table(engine)
}
prod_symbols = set(existing) with engine.connect() as conn:
existing_rows = conn.execute(
text("SELECT id, symbol FROM tickers")
).fetchall()
existing_ids = {str(sym): int(tid) for tid, sym in existing_rows}
prod_symbols = set(existing_ids)
# Bar counts
bar_counts: dict[str, int] = {} bar_counts: dict[str, int] = {}
for sym, ticker in existing.items(): for sym, tid in existing_ids.items():
n = session.execute( n = conn.execute(
text("SELECT COUNT(*) FROM ohlcv_records WHERE ticker_id = :tid"), text("SELECT COUNT(*) FROM ohlcv_records WHERE ticker_id = :tid"),
{"tid": ticker.id}, {"tid": tid},
).scalar_one() ).scalar_one()
bar_counts[sym] = int(n) bar_counts[sym] = int(n)
to_fetch: list[str] = [] to_fetch: list[str] = []
for sym in pool: for sym in pool:
if sym in existing and bar_counts.get(sym, 0) >= args.min_bars: if sym in existing_ids and bar_counts.get(sym, 0) >= args.min_bars:
# Existing production or previously extended — keep rank_only
# only for *new* research names, not original prod universe.
continue continue
to_fetch.append(sym) to_fetch.append(sym)
@@ -262,6 +262,16 @@ async def _main() -> None:
ok = 0 ok = 0
fail = 0 fail = 0
t0 = time.monotonic() t0 = time.monotonic()
insert_ohlcv = text(
"""
INSERT INTO ohlcv_records
(ticker_id, date, open, high, low, close, volume, created_at)
VALUES
(:ticker_id, :date, :open, :high, :low, :close, :volume, :created_at)
"""
)
for index, sym in enumerate(to_fetch, 1): for index, sym in enumerate(to_fetch, 1):
try: try:
bars = await _fetch_symbol_bars( bars = await _fetch_symbol_bars(
@@ -284,55 +294,71 @@ async def _main() -> None:
print(f" [{index}/{len(to_fetch)}] {sym} empty") print(f" [{index}/{len(to_fetch)}] {sym} empty")
continue continue
ticker = existing.get(sym) try:
is_new = ticker is None with engine.begin() as write:
if ticker is None: ticker_id = existing_ids.get(sym)
ticker = Ticker(symbol=sym, name=None, created_at=datetime.now(timezone.utc)) is_new = ticker_id is None
session.add(ticker) if is_new:
session.flush() write.execute(
existing[sym] = ticker text(
"INSERT INTO tickers (symbol, name, created_at) "
"VALUES (:sym, NULL, :created)"
),
{
"sym": sym,
"created": datetime.now(timezone.utc).isoformat(),
},
)
ticker_id = int(
write.execute(
text("SELECT id FROM tickers WHERE symbol = :sym"),
{"sym": sym},
).scalar_one()
)
existing_ids[sym] = ticker_id
# Upsert bars (delete+insert range for simplicity on research path) write.execute(
session.execute(
text( text(
"DELETE FROM ohlcv_records WHERE ticker_id = :tid " "DELETE FROM ohlcv_records WHERE ticker_id = :tid "
"AND date >= :start AND date <= :end" "AND date >= :start AND date <= :end"
), ),
{"tid": ticker.id, "start": start.isoformat(), "end": end.isoformat()}, {
"tid": ticker_id,
"start": start.isoformat(),
"end": end.isoformat(),
},
) )
now = datetime.utcnow() now = datetime.now(timezone.utc).replace(tzinfo=None)
session.bulk_insert_mappings( write.execute(
OHLCVRecord, insert_ohlcv,
[ [
{ {
"ticker_id": ticker.id, "ticker_id": ticker_id,
"date": b.date, "date": b.date.isoformat(),
"open": b.open, "open": float(b.open),
"high": b.high, "high": float(b.high),
"low": b.low, "low": float(b.low),
"close": b.close, "close": float(b.close),
"volume": b.volume, "volume": int(b.volume),
"created_at": now, "created_at": now.isoformat(),
} }
for b in bars for b in bars
], ],
) )
# rank_only only for names that were NOT in the original production
# snapshot at copy time (or are newly introduced to this research DB).
if is_new or sym not in prod_symbols:
# Re-evaluate: if source copy already had the symbol, don't flag.
# Only new inserts get rank_only.
if is_new: if is_new:
session.execute( write.execute(
text( text(
"INSERT OR REPLACE INTO research_rank_only " "INSERT OR REPLACE INTO research_rank_only "
"(ticker_id, symbol) VALUES (:tid, :sym)" "(ticker_id, symbol) VALUES (:tid, :sym)"
), ),
{"tid": ticker.id, "sym": sym}, {"tid": ticker_id, "sym": sym},
) )
except Exception as exc:
fail += 1
if not args.quiet:
print(f" [{index}/{len(to_fetch)}] {sym} WRITE FAIL {exc}")
continue
session.commit()
ok += 1 ok += 1
if not args.quiet and (index % 25 == 0 or index == len(to_fetch)): if not args.quiet and (index % 25 == 0 or index == len(to_fetch)):
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
@@ -341,11 +367,13 @@ async def _main() -> None:
f"elapsed={elapsed/60:.1f}m last={sym} bars={len(bars)}" f"elapsed={elapsed/60:.1f}m last={sym} bars={len(bars)}"
) )
rank_only_n = session.execute( rank_only_n = conn.execute(
text("SELECT COUNT(*) FROM research_rank_only") text("SELECT COUNT(*) FROM research_rank_only")
).scalar_one() ).scalar_one()
ticker_n = session.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one() ticker_n = conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one()
ohlcv_n = session.execute(text("SELECT COUNT(*) FROM ohlcv_records")).scalar_one() ohlcv_n = conn.execute(
text("SELECT COUNT(*) FROM ohlcv_records")
).scalar_one()
print("Done.") print("Done.")
print(f" output: {output}") print(f" output: {output}")