diff --git a/.dockerignore b/.dockerignore index 981ad2e..e12562b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,6 @@ __pycache__/ *.pyc *.pyo .env -*.db *.db-shm *.db-wal .git/ @@ -11,4 +10,3 @@ __pycache__/ *.md test_*.py simulate_trades.py -scripts/ diff --git a/.gitignore b/.gitignore index 4770923..95d59a3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,7 @@ env/ .env *.pem -# 数据库 -*.db +# 数据库 (trumpsignal.db 是例外,用于一次性迁移到 Postgres) *.sqlite *.sqlite3 diff --git a/entrypoint.sh b/entrypoint.sh index aa7aaec..326b463 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,5 +4,35 @@ set -e echo "[entrypoint] Running Alembic migrations..." alembic upgrade head +# One-time migration: if trumpsignal.db exists and posts table is empty, +# migrate SQLite data into Postgres then remove the db file. +if [ -f "trumpsignal.db" ]; then + echo "[entrypoint] Found trumpsignal.db — checking if migration needed..." + POST_COUNT=$(python -c " +import asyncio, os +os.environ.setdefault('DATABASE_URL', '') +import psycopg2 +url = os.environ.get('DATABASE_URL','').replace('+asyncpg','') +try: + conn = psycopg2.connect(url) + cur = conn.cursor() + cur.execute('SELECT COUNT(*) FROM posts') + print(cur.fetchone()[0]) + conn.close() +except Exception as e: + print('0') +" 2>/dev/null || echo "0") + + if [ "$POST_COUNT" = "0" ]; then + echo "[entrypoint] Postgres is empty — running SQLite migration..." + python scripts/migrate_sqlite_to_postgres.py + echo "[entrypoint] Migration done. Removing trumpsignal.db..." + rm -f trumpsignal.db + else + echo "[entrypoint] Postgres already has data (${POST_COUNT} posts) — skipping migration." + rm -f trumpsignal.db + fi +fi + echo "[entrypoint] Starting API server..." exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/requirements.txt b/requirements.txt index 8fd016d..0c027db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ uvicorn[standard]==0.39.0 sqlalchemy[asyncio]==2.0.49 aiosqlite==0.22.1 asyncpg==0.31.0 +psycopg2-binary==2.9.9 alembic==1.16.5 pydantic==2.13.2 pydantic-settings==2.11.0 diff --git a/scripts/migrate_sqlite_to_postgres.py b/scripts/migrate_sqlite_to_postgres.py new file mode 100644 index 0000000..6876c4b --- /dev/null +++ b/scripts/migrate_sqlite_to_postgres.py @@ -0,0 +1,117 @@ +""" +One-time migration: local trumpsignal.db (SQLite) → Postgres. +Called automatically by entrypoint.sh on first deploy. +Uses DATABASE_URL env var (same one uvicorn uses), just strips +asyncpg. +""" +import os +import sqlite3 +import sys +from datetime import datetime, timezone + +import psycopg2 +import psycopg2.extras + +SQLITE_PATH = os.path.join(os.path.dirname(__file__), '..', 'trumpsignal.db') + +raw_url = os.environ.get('DATABASE_URL', '') +if not raw_url: + print("ERROR: DATABASE_URL not set") + sys.exit(1) + +# asyncpg URL → psycopg2 URL +PG_URL = raw_url.replace('postgresql+asyncpg://', 'postgresql://') + + +def parse_dt(v): + if v is None: + return None + if isinstance(v, (int, float)): + return datetime.fromtimestamp(v, tz=timezone.utc).replace(tzinfo=None) + for fmt in ('%Y-%m-%d %H:%M:%S.%f', '%Y-%m-%d %H:%M:%S'): + try: + return datetime.strptime(v, fmt) + except ValueError: + continue + return v + + +def migrate(): + sq = sqlite3.connect(SQLITE_PATH) + sq.row_factory = sqlite3.Row + pg = psycopg2.connect(PG_URL) + cur_sq = sq.cursor() + cur_pg = pg.cursor() + + # ── posts ───────────────────────────────────────────────────────────────── + cur_sq.execute("SELECT * FROM posts") + posts = cur_sq.fetchall() + psycopg2.extras.execute_values(cur_pg, """ + INSERT INTO posts ( + id, external_id, text, source, published_at, + sentiment, ai_confidence, relevant, + price_impact_asset, price_impact_m5, price_impact_m15, price_impact_m1h, + price_at_post, signal, ai_reasoning, prefilter_reason, + analysis_version, created_at + ) VALUES %s ON CONFLICT (id) DO NOTHING + """, [( + r['id'], r['external_id'], r['text'], r['source'], parse_dt(r['published_at']), + r['sentiment'], r['ai_confidence'], bool(r['relevant']), + r['price_impact_asset'], r['price_impact_m5'], r['price_impact_m15'], r['price_impact_m1h'], + r['price_at_post'], r['signal'], r['ai_reasoning'], r['prefilter_reason'], + r['analysis_version'], parse_dt(r['created_at']), + ) for r in posts]) + pg.commit() + print(f" ✓ {len(posts)} posts") + + # ── subscriptions ───────────────────────────────────────────────────────── + cur_sq.execute("SELECT * FROM subscriptions") + subs = cur_sq.fetchall() + psycopg2.extras.execute_values(cur_pg, """ + INSERT INTO subscriptions ( + id, wallet_address, hl_api_key, active, subscribed_at, created_at, + leverage, position_size_usd, take_profit_pct, stop_loss_pct, min_confidence + ) VALUES %s ON CONFLICT (id) DO NOTHING + """, [( + r['id'], r['wallet_address'], r['hl_api_key'], + bool(r['active']), parse_dt(r['subscribed_at']), parse_dt(r['created_at']), + r['leverage'] if r['leverage'] is not None else 3, + r['position_size_usd'] if r['position_size_usd'] is not None else 20.0, + r['take_profit_pct'], r['stop_loss_pct'], + r['min_confidence'] if r['min_confidence'] is not None else 80, + ) for r in subs]) + pg.commit() + print(f" ✓ {len(subs)} subscriptions") + + # ── bot_trades ──────────────────────────────────────────────────────────── + cur_sq.execute("SELECT * FROM bot_trades") + trades = cur_sq.fetchall() + psycopg2.extras.execute_values(cur_pg, """ + INSERT INTO bot_trades ( + id, asset, side, entry_price, exit_price, pnl_usd, + hold_seconds, trigger_post_id, wallet_address, + opened_at, closed_at, hl_order_id + ) VALUES %s ON CONFLICT (id) DO NOTHING + """, [( + r['id'], r['asset'], r['side'], + r['entry_price'], r['exit_price'], r['pnl_usd'], + r['hold_seconds'], r['trigger_post_id'], r['wallet_address'], + parse_dt(r['opened_at']), parse_dt(r['closed_at']), r['hl_order_id'], + ) for r in trades]) + pg.commit() + print(f" ✓ {len(trades)} bot_trades") + + # ── reset sequences ─────────────────────────────────────────────────────── + for table, col in [('posts', 'id'), ('subscriptions', 'id'), ('bot_trades', 'id')]: + cur_pg.execute(f""" + SELECT setval(pg_get_serial_sequence('{table}', '{col}'), + COALESCE((SELECT MAX({col}) FROM {table}), 1)) + """) + pg.commit() + + sq.close() + pg.close() + print("✅ SQLite → Postgres migration complete") + + +if __name__ == '__main__': + migrate() diff --git a/trumpsignal.db b/trumpsignal.db new file mode 100644 index 0000000..55edc36 Binary files /dev/null and b/trumpsignal.db differ